diff --git a/Cargo.lock b/Cargo.lock index e20e679dbdee903dedff8325f5612f9c093525ea..3a62b4bc78dca8915ff60716e6ac1f108275aa8b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -563,6 +563,12 @@ dependencies = [ "syn 3.0.5", ] +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "byteorder-lite" version = "0.1.0" @@ -575,6 +581,18 @@ version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +[[package]] +name = "cab" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "171228650e6721d5acc0868a462cd864f49ac5f64e4a42cde270406e64e404d2" +dependencies = [ + "byteorder", + "flate2", + "lzxd", + "time", +] + [[package]] name = "calloop" version = "0.13.0" @@ -900,6 +918,12 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + [[package]] name = "digest" version = "0.11.3" @@ -987,6 +1011,7 @@ dependencies = [ "subsetter", "swash", "wgpu", + "windows", ] [[package]] @@ -1849,6 +1874,12 @@ dependencies = [ "twox-hash", ] +[[package]] +name = "lzxd" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c17f346186eccb574ba5581acefc514f0c70a642db4f96e245034a0a158a7168" + [[package]] name = "md-5" version = "0.11.0" @@ -1946,6 +1977,7 @@ dependencies = [ "serde_json", "tempfile", "wgpu", + "zeroize", ] [[package]] @@ -2042,6 +2074,11 @@ dependencies = [ name = "notebook" version = "0.1.0" dependencies = [ + "aes-gcm", + "base64 0.22.1", + "cab", + "getrandom 0.4.3", + "hmac", "libc", "nix", "onestore", @@ -2067,6 +2104,12 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + [[package]] name = "num-traits" version = "0.2.19" @@ -2747,6 +2790,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "presser" version = "0.3.1" @@ -3392,6 +3441,7 @@ dependencies = [ "windows-sys 0.61.2", "winit", "zbus", + "zeroize", ] [[package]] @@ -3591,6 +3641,25 @@ dependencies = [ "zune-jpeg", ] +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + [[package]] name = "tiny-skia" version = "0.11.4" diff --git a/apps/ios/Snowbound.xcodeproj/project.pbxproj b/apps/ios/Snowbound.xcodeproj/project.pbxproj index 494656c41f48fbe70e14f43441d296196416928c..6da247470093f069e0f90697945b417c770146c0 100644 --- a/apps/ios/Snowbound.xcodeproj/project.pbxproj +++ b/apps/ios/Snowbound.xcodeproj/project.pbxproj @@ -7,14 +7,12 @@ objects = { /* Begin PBXBuildFile section */ - 5B0000000000000000000101 /* Sample in Resources */ = {isa = PBXBuildFile; fileRef = 5B0000000000000000000201 /* Sample */; }; 5B0000000000000000000126 /* Snowbound Guide in Resources */ = {isa = PBXBuildFile; fileRef = 5B0000000000000000000226 /* Snowbound Guide */; }; 5B0000000000000000000102 /* Snowbound-Tahoe.icon in Resources */ = {isa = PBXBuildFile; fileRef = 5B0000000000000000000205 /* Snowbound-Tahoe.icon */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ 5B0000000000000000000200 /* Snowbound.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Snowbound.app; sourceTree = BUILT_PRODUCTS_DIR; }; - 5B0000000000000000000201 /* Sample */ = {isa = PBXFileReference; lastKnownFileType = folder; name = Sample; path = "../../corpus/m6/native-features-01/notebook"; sourceTree = SOURCE_ROOT; }; 5B0000000000000000000226 /* Snowbound Guide */ = {isa = PBXFileReference; lastKnownFileType = folder; name = "Snowbound Guide"; path = "../../docs/guide-notebook/Snowbound Guide"; sourceTree = SOURCE_ROOT; }; 5B0000000000000000000202 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 5B0000000000000000000203 /* Snowbound-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Snowbound-Bridging-Header.h"; sourceTree = ""; }; @@ -45,7 +43,6 @@ isa = PBXGroup; children = ( 5B0000000000000000000300 /* Snowbound */, - 5B0000000000000000000201 /* Sample */, 5B0000000000000000000226 /* Snowbound Guide */, 5B0000000000000000000205 /* Snowbound-Tahoe.icon */, 5B0000000000000000000202 /* Info.plist */, @@ -126,7 +123,6 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - 5B0000000000000000000101 /* Sample in Resources */, 5B0000000000000000000126 /* Snowbound Guide in Resources */, 5B0000000000000000000102 /* Snowbound-Tahoe.icon in Resources */, ); diff --git a/apps/ios/Snowbound/App.swift b/apps/ios/Snowbound/App.swift index f9b5ac77699c548fc50618072c125d7093e55f84..5a3f12581274e1c45c1c4c80923398f4f73d22cf 100644 --- a/apps/ios/Snowbound/App.swift +++ b/apps/ios/Snowbound/App.swift @@ -150,6 +150,8 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate, UISplitViewContro private let notebooks = NotebooksViewController() private let pages = PagesViewController() private var showingPage = false + /// A first launch opened a page by itself. + private var landed = false private var background = UIBackgroundTaskIdentifier.invalid func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options: UIScene.ConnectionOptions) { @@ -203,8 +205,15 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate, UISplitViewContro } return } - guard let place = Place.saved, place.notebook == notebook.id, - let tab = notebook.tabs.first(where: { $0.path == place.section }) + guard let place = Place.saved else { + // The first notebook found opens at its first page, as Notes opens on a note. + guard Prototype.welcome, !landed, let tab = notebook.tabs.first(where: \.readable) else { return } + landed = true + return open(tab, of: notebook) { [weak self] section in + if let page = section.rows.first { self?.show(section: section, page: page.id) } + } + } + guard place.notebook == notebook.id, let tab = notebook.tabs.first(where: { $0.path == place.section }) else { return } open(tab, of: notebook, page: place.page) } @@ -213,7 +222,8 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate, UISplitViewContro _ tab: Tab, of notebook: Notebook, page: String? = nil, reveal: Reveal? = nil, then: ((Section) -> Void)? = nil ) { - guard Author.name != nil else { + // The welcome prototype asks at the first edit instead. + guard Author.name != nil || Prototype.welcome else { return Author.ask(from: split) { [weak self] in self?.open(tab, of: notebook, page: page, reveal: reveal, then: then) } @@ -223,7 +233,7 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate, UISplitViewContro Section.open(tab, of: notebook) { [weak self] section, problem in guard let self else { return } guard let section else { - pages.failed(tab, problem) + pages.failed(problem) return } pages.load(section) @@ -300,7 +310,8 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate, UISplitViewContro /// Composition ends and every edit is stored and published while the system allows. func sceneDidEnterBackground(_ scene: UIScene) { NotificationCenter.default.post(name: PageViewController.leaving, object: nil) - for notebook in Notebooks.all { notebook.pause() } + // Protected sections open again only once unlocked. + for notebook in Notebooks.all { notebook.pause(); notebook.lockAll() } let sections = Section.all guard !sections.isEmpty, background == .invalid else { return } background = UIApplication.shared.beginBackgroundTask(withName: "Saving") { [weak self] in diff --git a/apps/ios/Snowbound/CanvasView.swift b/apps/ios/Snowbound/CanvasView.swift index b2ed72e138d477a6cd0af10a4eba8e5cfde9c44a..fa18181abb96ee5c25c55b192731f2100eeb97b4 100644 --- a/apps/ios/Snowbound/CanvasView.swift +++ b/apps/ios/Snowbound/CanvasView.swift @@ -81,6 +81,11 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI private var origin = CGPoint.zero private var syncing = false private var scrolled = false + private var drag: Drag? + /// The zoom and finger spread a pinch started from. + private var pinchStart: (zoom: CGFloat, spread: CGFloat)? + private var inDetent = false + private let detentTick = UISelectionFeedbackGenerator() /// How far right of the content's corner a page opens. private var left: CGFloat = 0 /// The zoom a double tap returns to. @@ -230,7 +235,7 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI paper() sync() let skip = margin - resting = openingZoom() + resting = openingZoom(skipping: skip) zoomScale = resting left = skip * resting home() @@ -301,12 +306,14 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI dirty = true } - /// The zoom the reader last pinched to, within reason; before that, 100%, where OneNote's - /// 11 pt body text shows at 17 points. - private func openingZoom() -> CGFloat { - guard let zoom = UserDefaults.standard.object(forKey: Self.zoomKey) as? Double else { return 1 } - // A page opens readable however far the last one was pinched. - return min(2, max(0.75, zoom)) + /// The zoom the reader last pinched to, within reason, or before that 100%, where OneNote's + /// 11 pt body text shows at 17 points; less where that fits the page right of `skip` + /// across the screen. + private func openingZoom(skipping skip: CGFloat) -> CGFloat { + let pinched = UserDefaults.standard.object(forKey: Self.zoomKey) as? CGFloat ?? 1 + let fit = bounds.inset(by: safeAreaInsets).width / max(1, content.bounds.width - skip) + // A page opens readable however far the last one was pinched or however wide it is. + return max(0.75, min(2, pinched, fit)) } /// 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 if !scrolled { contentOffset = CGPoint(x: left - adjustedContentInset.left, y: -adjustedContentInset.top) } } - func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { scrolled = true } - func scrollViewWillBeginZooming(_ scrollView: UIScrollView, with view: UIView?) { scrolled = true } + /// A drag that starts within 30° of an axis sticks to it; a 24-point stretch of the + /// finger more than 40° off it lets go, and the page catches up over the next 48 points. + private struct Drag { + static let snap = tan(CGFloat.pi / 6) + static let release = tan(CGFloat.pi * 2 / 9) + static let stretch: CGFloat = 24 + static let catchUp: CGFloat = 48 + + /// The axis the lock holds still. + let cross: WritableKeyPath + var locked = true + /// The offset last shown, from which the scroll view's next step is measured. + var offset: CGPoint + /// The translation where the stretch of the gesture being judged began. + var stretch: CGPoint + /// How far the held axis trails the finger. + var lag: CGFloat = 0 + } + + func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { + scrolled = true + let moved = panGestureRecognizer.translation(in: self) + let cross: WritableKeyPath? = + abs(moved.x) <= abs(moved.y) * Drag.snap ? \.x : abs(moved.y) <= abs(moved.x) * Drag.snap ? \.y : nil + drag = cross.map { Drag(cross: $0, offset: contentOffset, stretch: moved) } + } + + func scrollViewWillBeginZooming(_ scrollView: UIScrollView, with view: UIView?) { + scrolled = true + drag = nil + pinchStart = spread.map { (zoomScale, $0) } + inDetent = abs(log(zoomScale)) < Self.detent + detentTick.prepare() + } + func viewForZooming(in scrollView: UIScrollView) -> UIView? { content } - func scrollViewDidScroll(_ scrollView: UIScrollView) { transform() } + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + if var drag, isDragging || isDecelerating, !isZooming { + let cross = drag.cross + let along = cross == \CGPoint.x ? \CGPoint.y : \CGPoint.x + var offset = contentOffset + let step = offset[keyPath: cross] - drag.offset[keyPath: cross] + if drag.locked { + drag.lag += step + offset[keyPath: cross] = drag.offset[keyPath: cross] + let moved = panGestureRecognizer.translation(in: self) + let stretch = CGPoint(x: moved.x - drag.stretch.x, y: moved.y - drag.stretch.y) + if isTracking, hypot(stretch.x, stretch.y) >= Drag.stretch { + drag.locked = abs(stretch[keyPath: cross]) <= abs(stretch[keyPath: along]) * Drag.release + drag.stretch = moved + } + } else { + let travel = hypot(step, offset[keyPath: along] - drag.offset[keyPath: along]) + let paid = drag.lag * (1 - exp(-travel / Drag.catchUp)) + offset[keyPath: cross] += paid + drag.lag -= paid + } + drag.offset = offset + self.drag = drag.locked || abs(drag.lag) >= 0.5 ? drag : nil + if offset != contentOffset { contentOffset = offset } + } + transform() + } + + /// How far apart a pinch's two fingers are. + private var spread: CGFloat? { + guard let pinch = pinchGestureRecognizer, pinch.numberOfTouches == 2 else { return nil } + let a = pinch.location(ofTouch: 0, in: self), b = pinch.location(ofTouch: 1, in: self) + return hypot(a.x - b.x, a.y - b.y) + } + + /// How far either side of 100% a pinch sticks, as a log of the zoom. + private static let detent = log(CGFloat(1.1)) + + /// Pinching near 100% sticks there: within `detent` of it the zoom follows the fingers + /// on a curve flat at 100% that meets them again at the band's edges. func scrollViewDidZoom(_ scrollView: UIScrollView) { + if let pinch = pinchGestureRecognizer, pinch.state == .changed, let pinchStart, let spread { + // The scroll view's pinch reports the zoom it shows, so the fingers give the raw one. + let raw = log(pinchStart.zoom * spread / pinchStart.spread) + let x = abs(raw) / Self.detent + if x < 1 { + if !inDetent { detentTick.selectionChanged() } + let shown = exp(Self.detent * x * x * (2 - x) * (raw < 0 ? -1 : 1)) + if abs(shown - zoomScale) > 0.0001 { + let focus = pinch.location(in: content) + let screen = CGPoint(x: pinch.location(in: self).x - contentOffset.x, y: pinch.location(in: self).y - contentOffset.y) + zoomScale = shown + contentOffset = CGPoint(x: focus.x * shown - screen.x, y: focus.y * shown - screen.y) + } + } + inDetent = x < 1 + } transform() // Scrolling carries the caret and handles along; zooming moves them within the page. display.setNeedsSelectionUpdate() } func scrollViewDidEndZooming(_ scrollView: UIScrollView, with view: UIView?, atScale scale: CGFloat) { + pinchStart = nil if tapZooming { tapZooming = false } else { - resting = scale - UserDefaults.standard.set(Double(scale), forKey: Self.zoomKey) + // A pinch let go within the detent settles on 100%. + resting = abs(log(scale)) < Self.detent ? 1 : scale + if resting != scale { setZoomScale(resting, animated: true) } + UserDefaults.standard.set(Double(resting), forKey: Self.zoomKey) } } @@ -694,8 +793,10 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI edit(external: true) { sb_delete_backward(handle) } } - /// Runs a change to the page, telling the system when it did not ask for it. - private func edit(external: Bool = false, _ change: () -> Bool) { + /// Runs a change to the page, telling the system when it did not ask for it; returns + /// whether it changed. + @discardableResult + private func edit(external: Bool = false, _ change: () -> Bool) -> Bool { if external { inputDelegate?.selectionWillChange(self) inputDelegate?.textWillChange(self) @@ -712,6 +813,7 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI } display.setNeedsSelectionUpdate() changedSelection() + return changed } /// Tells the bar buttons and the format bar what the selection now has. @@ -736,6 +838,10 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI override var inputAccessoryView: UIView? { formatBar } override func becomeFirstResponder() -> Bool { + if Prototype.welcome, Author.name == nil, let controller = window?.rootViewController { + Prototype.askName(from: controller) { [weak self] in _ = self?.becomeFirstResponder() } + return false + } guard super.becomeFirstResponder() else { return false } if let handle { sb_view_focus(handle, true) } display.isActivated = true @@ -876,6 +982,37 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI /// A conflict page, which shows what is stored and takes no edits. var readOnly: Bool { handle.map(sb_view_read_only) ?? false } + /// The column a reading view fills: the screen's width less the room tags hang in. + private var readingWidth: Float { Float(bounds.inset(by: safeAreaInsets).width - 34 - 12) } + + /// Whether the page has a reading view here, and how it reads or why not. + var reading: (offered: Bool, verdict: String)? { + struct Reading: Decodable { + let offered: Bool + let verdict: String + } + guard let handle, let read = decode(Reading.self, sb_view_reading(handle, readingWidth)) else { return nil } + return (read.offered, read.verdict) + } + + /// Whether the page shows reflowed, as `showReading` left it. + private(set) var readingShown = false + + /// Shows the page reflowed into the screen's width at 100%, read-only, or as laid out. + func showReading(_ on: Bool) { + guard let handle else { return } + _ = resignFirstResponder() + readingShown = sb_view_set_reading(handle, on ? readingWidth : 0) + zoomScale = 1 + resting = 1 + scrolled = false + sync() + left = margin + home() + dirty = true + onChange?() + } + /// The title as typed; nil on a page without an editable title. var pageTitle: String? { handle.flatMap { take(sb_view_title($0)) } } @@ -933,11 +1070,7 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI /// Selects paragraph `id` and brings it into view. func selectParagraph(_ id: String) -> Bool { guard let handle else { return false } - var found = false - edit(external: true) { - found = sb_view_select_paragraph(handle, id) - return found - } + let found = edit(external: true) { sb_view_select_paragraph(handle, id) } if found { reveal(selectedTextRange.map { firstRect(for: $0) } ?? .zero) } return found } @@ -966,11 +1099,7 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI /// Selects the first place `query` occurs and brings it into view. func find(_ query: String) -> Bool { guard let handle else { return false } - var found = false - edit(external: true) { - found = sb_view_find(handle, query) - return found - } + let found = edit(external: true) { sb_view_find(handle, query) } if found { reveal(selectedTextRange.map { firstRect(for: $0) } ?? .zero) } return found } @@ -978,12 +1107,7 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI /// Puts the caret at the end of the page title. func focusTitle() -> Bool { guard let handle else { return false } - var focused = false - edit(external: true) { - focused = sb_view_focus_title(handle) - return focused - } - return focused + return edit(external: true) { sb_view_focus_title(handle) } } /// A photo as OneNote 2010 can read it: JPEG, at most 2048 pixels across, shown at most diff --git a/apps/ios/Snowbound/ICloud.swift b/apps/ios/Snowbound/ICloud.swift index 62fdc27a8fb43e443df4ebefd25ca87cc3e2f802..fbe77e22b30191a421013df06a1fd931acc22bf8 100644 --- a/apps/ios/Snowbound/ICloud.swift +++ b/apps/ios/Snowbound/ICloud.swift @@ -78,6 +78,12 @@ enum ICloud { /// Looks the folder up off the main thread, as asking iCloud can take a while. static func lookUp() { background({ () -> URL? in + #if DEBUG + // A simulator signs into no account; `SNOWBOUND_ICLOUD` names a folder standing in. + if let folder = ProcessInfo.processInfo.environment["SNOWBOUND_ICLOUD"] { + return URL(fileURLWithPath: folder, isDirectory: true) + } + #endif guard let container = FileManager.default.url(forUbiquityContainerIdentifier: identifier)? .appendingPathComponent("Documents", isDirectory: true) diff --git a/apps/ios/Snowbound/Lists.swift b/apps/ios/Snowbound/Lists.swift index 136f9b12ccc0bf434edc0a7652c94372f5dbd8c5..725d16780fff5ca43cefc58209e6722178606fea 100644 --- a/apps/ios/Snowbound/Lists.swift +++ b/apps/ios/Snowbound/Lists.swift @@ -133,6 +133,13 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke func reload() { guard isViewLoaded else { return } + if Prototype.welcome { + let welcome = Prototype.welcome( + new: { [weak self] in self?.newNotebook(inCloud: ICloud.documents != nil) }, + openFolder: { [weak self] in self?.openFolder() }, connect: { [weak self] in self?.connect() }) + contentUnavailableConfiguration = welcome + if welcome != nil { return dataSource.apply(.init(), animatingDifferences: false) } + } let copied = Notebooks.onDevice.contains { $0.source == .documents(path: Notebooks.guide) } let guide: [Item] = Notebooks.guideOffered && !copied ? [.guide] : [] let cloud: [(Location, [Notebook], [Item])] = @@ -186,6 +193,14 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke reload() } + /// Lists the notebooks again and opens the first section of the one from `source`. + private func rescan(opening source: Source) { + rescan { [weak self] notebook in + guard notebook.source == source, let tab = notebook.tabs.first(where: \.readable) else { return } + self?.onOpen?(tab, notebook) + } + } + private func showOnDevice(_ shown: Bool) { Notebooks.showsOnDevice = shown rescan() @@ -239,7 +254,9 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke content.image = UIImage( systemName: tab.readable ? "rectangle.portrait.fill" : tab.downloading ? "icloud.and.arrow.down" : "lock.fill") content.imageProperties.tintColor = tab.uiColor - if !tab.readable { + if tab.locked { + content.secondaryText = "Password protected" + } else if !tab.readable { content.secondaryText = tab.downloading ? "Downloading…" : tab.problem ?? "Can’t be opened here" content.textProperties.color = .secondaryLabel } @@ -370,7 +387,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke return closed ? nil : problem ?? "" }) { [weak self] problem in if let problem { - self?.refuse(title, problem) + self?.alert(title, problem) return } notebook.pause() @@ -387,7 +404,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke isFile ? name + "." + folder.pathExtension : name) guard target != folder else { return } guard !FileManager.default.fileExists(atPath: target.path) else { - self?.refuse("“\(name)” Already Exists", "Choose a different name.") + self?.alert("“\(name)” Already Exists", "Choose a different name.") return } self?.close(notebook, refusing: "Can’t Rename") { @@ -395,7 +412,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke try FileManager.default.moveItem(at: folder, to: target) _ = sb_notebook_moved(cacheDirectory.path, folder.path, target.path, nil) } catch { - self?.refuse("Can’t Rename", error.localizedDescription) + self?.alert("Can’t Rename", error.localizedDescription) } self?.rescan() } @@ -406,10 +423,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke field.clearButtonMode = .whileEditing NotificationCenter.default.addObserver( forName: UITextField.textDidChangeNotification, object: field, queue: .main - ) { _ in - let name = (field.text ?? "").trimmingCharacters(in: .whitespaces) - rename.isEnabled = !name.isEmpty && !name.hasPrefix(".") && !name.contains("/") - } + ) { _ in rename.isEnabled = Self.namesFile(field) } } alert.addAction(UIAlertAction(title: "Cancel", style: .cancel)) alert.addAction(rename) @@ -417,11 +431,17 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke present(alert, animated: true) } + /// Whether `field` holds a name a notebook's folder can take. + private static func namesFile(_ field: UITextField) -> Bool { + let name = (field.text ?? "").trimmingCharacters(in: .whitespaces) + return !name.isEmpty && !name.hasPrefix(".") && !name.contains("/") + } + /// Copies the notebook beside itself, named as Files names a duplicate. private func duplicate(_ folder: URL, of notebook: Notebook) { // The copy takes what the open sections have stored. for section in Section.all where section.notebook === notebook && !section.flush(10) { - return refuse("Can’t Duplicate", "Some changes haven’t been saved to the notebook yet.") + return alert("Can’t Duplicate", "Some changes haven’t been saved to the notebook yet.") } let base = folder.deletingPathExtension().lastPathComponent let ext = folder.pathExtension @@ -436,7 +456,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke do { try FileManager.default.copyItem(at: folder, to: target) } catch { return error.localizedDescription } return nil }) { [weak self] problem in - if let problem { self?.refuse("Can’t Duplicate", problem) } + if let problem { self?.alert("Can’t Duplicate", problem) } self?.rescan() } } @@ -475,7 +495,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke }) alert.addAction(UIAlertAction(title: "Delete", style: .destructive) { [weak self] _ in do { try FileManager.default.removeItem(at: folder) } catch { - self?.refuse("Can’t Delete", error.localizedDescription) + self?.alert("Can’t Delete", error.localizedDescription) } self?.rescan() }) @@ -495,7 +515,8 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke guard let item = dataSource.itemIdentifier(for: indexPath) else { return false } switch item { case .section(let id, let path): - return notebook(id)?.tabs.first(where: { $0.path == path })?.readable == true + let tab = notebook(id)?.tabs.first(where: { $0.path == path }) + return tab?.readable == true || tab?.locked == true case .status(let id): guard let notebook = notebook(id), notebook.problem != nil else { return false } let spinner = UIActivityIndicatorView(style: .medium) @@ -532,9 +553,36 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke guard case .section(let id, let path) = dataSource.itemIdentifier(for: indexPath), let notebook = notebook(id), let tab = notebook.tabs.first(where: { $0.path == path }) else { return } + if tab.locked { return unlock(tab, of: notebook, wrong: false) } onOpen?(tab, notebook) } + /// Asks for a protected section's password, as OneNote's Protected Section dialog does, + /// and opens it once unlocked. + private func unlock(_ tab: Tab, of notebook: Notebook, wrong: Bool) { + let alert = UIAlertController( + title: "Protected Section", + message: wrong ? "Password is incorrect." : "Section “\(tab.name)” is password protected.", + preferredStyle: .alert) + alert.addTextField { field in + field.isSecureTextEntry = true + field.placeholder = "Password" + } + alert.addAction(UIAlertAction(title: "Cancel", style: .cancel)) + alert.addAction(UIAlertAction(title: "OK", style: .default) { [weak self, weak alert] _ in + let password = alert?.textFields?.first?.text ?? "" + notebook.unlock(tab.path, password: password) { unlocked in + self?.reload() + if unlocked, let opened = notebook.tabs.first(where: { $0.path == tab.path }) { + self?.onOpen?(opened, notebook) + } else { + self?.unlock(tab, of: notebook, wrong: true) + } + } + }) + present(alert, animated: true) + } + private func add(_ notebook: Notebook) { Notebooks.add(notebook) openFirstSection(of: notebook) @@ -572,10 +620,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke field.autocapitalizationType = .words NotificationCenter.default.addObserver( forName: UITextField.textDidChangeNotification, object: field, queue: .main - ) { _ in - let name = (field.text ?? "").trimmingCharacters(in: .whitespaces) - create.isEnabled = !name.isEmpty && !name.hasPrefix(".") && !name.contains("/") - } + ) { _ in create.isEnabled = Self.namesFile(field) } } alert.addAction(UIAlertAction(title: "Cancel", style: .cancel)) alert.addAction(create) @@ -587,7 +632,7 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke let place = inCloud ? ICloud.documents : documentsDirectory guard let folder = place?.appendingPathComponent(name) else { return } guard !FileManager.default.fileExists(atPath: folder.path) else { - return refuse("“\(name)” Already Exists", "Choose a different name.") + return alert("“\(name)” Already Exists", "Choose a different name.") } let (date, time) = titleDate() background({ () -> String? in @@ -597,14 +642,9 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke return made ? nil : problem ?? "" }) { [weak self] problem in guard let self else { return } - if let problem { return refuse("Can’t Create Notebook", problem) } + if let problem { return alert("Can’t Create Notebook", problem) } if !inCloud && !Notebooks.showsOnDevice { Notebooks.showsOnDevice = true } - rescan { [weak self] notebook in - guard notebook.source == (inCloud ? .icloud(path: name) : .documents(path: name)), - let tab = notebook.tabs.first(where: \.readable) - else { return } - self?.onOpen?(tab, notebook) - } + rescan(opening: inCloud ? .icloud(path: name) : .documents(path: name)) } } @@ -621,23 +661,12 @@ final class NotebooksViewController: UICollectionViewController, UIDocumentPicke return nil }) { [weak self] problem in guard let self else { return } - if let problem { return refuse("Can’t Open the Guide", problem) } + if let problem { return alert("Can’t Open the Guide", problem) } if !Notebooks.showsOnDevice { Notebooks.showsOnDevice = true } - rescan { [weak self] notebook in - guard notebook.source == .documents(path: Notebooks.guide), - let tab = notebook.tabs.first(where: \.readable) - else { return } - self?.onOpen?(tab, notebook) - } + rescan(opening: .documents(path: Notebooks.guide)) } } - private func refuse(_ title: String, _ message: String) { - let alert = UIAlertController(title: title, message: message, preferredStyle: .alert) - alert.addAction(UIAlertAction(title: "OK", style: .default)) - present(alert, animated: true) - } - private func openFolder() { let picker = UIDocumentPickerViewController( forOpeningContentTypes: [.folder, UTType(filenameExtension: "one") ?? .data]) @@ -721,6 +750,11 @@ final class PagesViewController: UITableViewController { /// Opens a page of the section. var onOpen: ((Section, String) -> Void)? + /// The section strip prototype, over the pages. + private lazy var strip = SectionStrip { [weak self] tab, notebook in + (self?.view.window?.windowScene?.delegate as? SceneDelegate)?.open(tab, of: notebook) + } + init() { super.init(style: .plain) } @@ -740,6 +774,25 @@ final class PagesViewController: UITableViewController { ] NotificationCenter.default.addObserver( self, selector: #selector(changed), name: Section.changed, object: nil) + if Prototype.sectionStrip { + for direction in [UISwipeGestureRecognizer.Direction.left, .right] { + let swipe = UISwipeGestureRecognizer(target: self, action: #selector(swiped)) + swipe.direction = direction + tableView.addGestureRecognizer(swipe) + } + } + } + + @objc private func swiped(_ swipe: UISwipeGestureRecognizer) { + if let tab = strip.neighbour(swipe.direction == .left ? 1 : -1) { strip.pick(tab) } + } + + override func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { + Prototype.sectionStrip && strip.notebook != nil ? strip : nil + } + + override func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { + Prototype.sectionStrip && strip.notebook != nil ? 50 : 0 } override func viewWillAppear(_ animated: Bool) { @@ -751,6 +804,10 @@ final class PagesViewController: UITableViewController { section = nil items = [] title = tab.name + if Prototype.sectionStrip, let notebook = strip.notebook { + title = notebook.name + strip.select(tab.path) + } tableView.reloadData() var loading = UIContentUnavailableConfiguration.loading() loading.text = "Opening…" @@ -768,7 +825,7 @@ final class PagesViewController: UITableViewController { navigationController?.setToolbarHidden(true, animated: false) } - func failed(_ tab: Tab, _ problem: String?) { + func failed(_ problem: String?) { var empty = UIContentUnavailableConfiguration.empty() empty.text = "Can’t Open Section" empty.secondaryText = problem @@ -778,6 +835,10 @@ final class PagesViewController: UITableViewController { func load(_ section: Section) { self.section = section title = section.tab.name + if Prototype.sectionStrip { + title = section.notebook.name + strip.show(section.notebook, selected: section.tab.path) + } navigationController?.setToolbarHidden(false, animated: false) reload() } @@ -816,7 +877,7 @@ final class PagesViewController: UITableViewController { } private func delete(_ item: Item) { - section?.delete(item.id) { _ in } + section?.delete(item.id) } override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { items.count } @@ -859,6 +920,8 @@ final class PagesViewController: UITableViewController { override func tableView( _ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath ) -> UISwipeActionsConfiguration? { + // A swipe across the list changes section instead; the context menu deletes. + if Prototype.sectionStrip { return nil } let item = items[indexPath.row] let delete = UIContextualAction(style: .destructive, title: "Delete") { [weak self] _, _, done in self?.delete(item) diff --git a/apps/ios/Snowbound/Notebooks.swift b/apps/ios/Snowbound/Notebooks.swift index 17562e7fdfb14a1e7ff3021420d38e255db6cb2a..bf45c3e19f0c55ed00057bc7efc5304fd50630bc 100644 --- a/apps/ios/Snowbound/Notebooks.swift +++ b/apps/ios/Snowbound/Notebooks.swift @@ -30,7 +30,11 @@ enum Author { static var name: String? { get { UserDefaults.standard.string(forKey: key) } - set { UserDefaults.standard.set(newValue, forKey: key) } + set { + UserDefaults.standard.set(newValue, forKey: key) + // Open sections name it in their edits from now on. + for section in Section.all { sb_section_set_author(section.handle, newValue ?? "") } + } } /// Asks for the user name OneNote shows beside changes, then runs `then`. @@ -126,13 +130,22 @@ struct Tab: Decodable, Equatable { let group: String let color: [UInt8] let readable: Bool + /// Password protected and not unlocked: `Notebook.unlock` opens it. + let locked: Bool /// Not on this device yet; `Notebook` asks iCloud Drive for it. let downloading: Bool /// Why the file could not be read, where retrying or repair may help. let problem: String? - var uiColor: UIColor { - UIColor(red: CGFloat(color[0]) / 255, green: CGFloat(color[1]) / 255, blue: CGFloat(color[2]) / 255, alpha: 1) + var uiColor: UIColor { UIColor(rgb: color) } +} + +extension UIViewController { + /// Tells the reader `title` and `message` in an alert they dismiss with OK. + func alert(_ title: String, _ message: String) { + let alert = UIAlertController(title: title, message: message, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "OK", style: .default)) + present(alert, animated: true) } } @@ -205,13 +218,8 @@ final class Notebook { } let accessed = url.startAccessingSecurityScopedResource() ? url : nil let local = onThisDevice(url) - var library: OpaquePointer? - // A coordinated read brings a cloud folder's files down before they are read. - var coordination: NSError? - NSFileCoordinator().coordinate(readingItemAt: url, options: [], error: &coordination) { url in - library = sb_library_open(url.path, cacheDirectory.path, local, &error) - } - return (library, accessed, take(error) ?? coordination?.localizedDescription, local ? url : nil) + let (library, problem) = coordinatedOpen(url, local: local) + return (library, accessed, problem, local ? url : nil) case .documents(let path): let url = documentsDirectory.appendingPathComponent(path) let library = sb_library_open(url.path, cacheDirectory.path, true, &error) @@ -220,12 +228,8 @@ final class Notebook { guard let url = ICloud.documents?.appendingPathComponent(path) else { return (nil, nil, "Turn on iCloud Drive in Settings to open this notebook.", nil) } - var library: OpaquePointer? - var coordination: NSError? - NSFileCoordinator().coordinate(readingItemAt: url, options: [], error: &coordination) { url in - library = sb_library_open(url.path, cacheDirectory.path, false, &error) - } - return (library, nil, take(error) ?? coordination?.localizedDescription, url) + let (library, problem) = coordinatedOpen(url, local: false) + return (library, nil, problem, url) case .path(let path): let url = URL(fileURLWithPath: path) let local = onThisDevice(url) @@ -262,6 +266,21 @@ final class Notebook { } } + /// Unlocks the protected section at `path` with `password` off the main thread, then lists + /// the sections again; `done` learns whether the password matched. + func unlock(_ path: String, password: String, done: @escaping (Bool) -> Void) { + guard let handle else { return done(false) } + let pointer = Int(bitPattern: handle) + background({ sb_library_unlock(OpaquePointer(bitPattern: pointer), path, password) }) { [self] unlocked in + reload { done(unlocked) } + } + } + + /// Locks every protected section, as OneNote's Lock All does. + func lockAll() { + if let handle { sb_library_lock_all(handle) } + } + /// Lists the sections again, off the main thread. func reload(done: @escaping () -> Void) { guard let handle else { @@ -323,6 +342,18 @@ final class Notebook { } } +/// Opens the library at `url` within a coordinated read, which brings a cloud folder's files +/// down before they are read; nil with why where it cannot. +private func coordinatedOpen(_ url: URL, local: Bool) -> (OpaquePointer?, String?) { + var library: OpaquePointer? + var error: UnsafeMutablePointer? + var coordination: NSError? + NSFileCoordinator().coordinate(readingItemAt: url, options: [], error: &coordination) { url in + library = sb_library_open(url.path, cacheDirectory.path, local, &error) + } + return (library, take(error) ?? coordination?.localizedDescription) +} + /// Whether `url` is kept on this device, rather than by a file provider that keeps it elsewhere /// too, as iCloud Drive does. private func onThisDevice(_ url: URL) -> Bool { @@ -414,7 +445,7 @@ enum Notebooks { set { UserDefaults.standard.set(!newValue, forKey: hidden) } } - /// The kept notebooks and those in Documents, where the bundled sample goes on first launch. + /// The kept notebooks and those in Documents. static func load() { if let scripted { elsewhere = [ @@ -435,12 +466,6 @@ enum Notebooks { return Notebook(id: entry.id, name: entry.name, source: entry.source) } } else { - let sample = documentsDirectory.appendingPathComponent("Sample") - if !FileManager.default.fileExists(atPath: sample.path), - let bundled = Bundle.main.url(forResource: "notebook", withExtension: nil) - { - try? FileManager.default.copyItem(at: bundled, to: sample) - } // The notebook the first version remembered from Files. if let bookmark = UserDefaults.standard.data(forKey: "notebook") { var stale = false @@ -456,34 +481,31 @@ enum Notebooks { /// Lists Documents again, keeping the notebooks still there; returns those new to the list. @discardableResult static func scan() -> [Notebook] { - guard showsOnDevice else { - onDevice = [] - return [] - } - var added: [Notebook] = [] - onDevice = notebooks(in: documentsDirectory).map { name in - if let kept = onDevice.first(where: { $0.id == "Documents/" + name }) { return kept } - let notebook = Notebook( - id: "Documents/" + name, name: (name as NSString).deletingPathExtension, source: .documents(path: name)) - added.append(notebook) - return notebook - } - return added + let folder = showsOnDevice ? documentsDirectory : nil + return rescan(folder, "Documents/", &onDevice) { .documents(path: $0) } } /// Lists Snowbound's folder in iCloud Drive again, as `scan` lists Documents; none while /// iCloud Drive is off. @discardableResult static func scanICloud() -> [Notebook] { - guard let folder = ICloud.documents, scripted == nil else { - inCloud = [] + rescan(scripted == nil ? ICloud.documents : nil, "iCloud/", &inCloud) { .icloud(path: $0) } + } + + /// Lists `folder`'s notebooks into `list`, keeping those still there and naming new ones by + /// `prefix` and their `source`; returns the new ones. + private static func rescan( + _ folder: URL?, _ prefix: String, _ list: inout [Notebook], source: (String) -> Source + ) -> [Notebook] { + guard let folder else { + list = [] return [] } var added: [Notebook] = [] - inCloud = notebooks(in: folder).map { name in - if let kept = inCloud.first(where: { $0.id == "iCloud/" + name }) { return kept } + list = notebooks(in: folder).map { [list] name in + if let kept = list.first(where: { $0.id == prefix + name }) { return kept } let notebook = Notebook( - id: "iCloud/" + name, name: (name as NSString).deletingPathExtension, source: .icloud(path: name)) + id: prefix + name, name: (name as NSString).deletingPathExtension, source: source(name)) added.append(notebook) return notebook } @@ -650,13 +672,13 @@ final class Section { return id } - func delete(_ id: String, done: @escaping (Bool) -> Void) { + func delete(_ id: String, done: @escaping () -> Void = {}) { let (date, time) = titleDate() let pointer = Int(bitPattern: handle) - background({ sb_section_delete_page(OpaquePointer(bitPattern: pointer), id, date, time) }) { [self] deleted in + background({ sb_section_delete_page(OpaquePointer(bitPattern: pointer), id, date, time) }) { [self] _ in reloadRows() NotificationCenter.default.post(name: Self.changed, object: self, userInfo: ["flags": Self.listed]) - done(deleted) + done() } } diff --git a/apps/ios/Snowbound/PageViewController.swift b/apps/ios/Snowbound/PageViewController.swift index ccaa9a8eb997ef3b8ac0d4b3aab941754e4d56d1..04379b01e3b1d20aa072d6efc34eec5ac4cd1a11 100644 --- a/apps/ios/Snowbound/PageViewController.swift +++ b/apps/ios/Snowbound/PageViewController.swift @@ -21,6 +21,8 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate private let titleFocus: Bool private lazy var canvas = CanvasView(section: section, page: page) private let bar = ConflictBar() + /// Reading View, beside the page menu where the page has one. + private var reader: UIBarButtonItem? /// Opens another page or conflict page of the section. var onOpen: ((String) -> Void)? private lazy var done = UIBarButtonItem( @@ -96,6 +98,12 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate if revealed || titleFocus && canvas.focusTitle() { _ = canvas.becomeFirstResponder() } + if Prototype.reading, canvas.reading?.offered == true { + reader = UIBarButtonItem( + title: "Reading View", image: UIImage(systemName: "text.justify.left"), + primaryAction: UIAction { [weak self] _ in self?.toggleReading() }) + editingChanged() + } } private func showTitle(_ title: String) { @@ -121,8 +129,10 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate undo.isEnabled = canvas.canUndo(redo: false) redo.isEnabled = canvas.canUndo(redo: true) // Drawing keeps undo at hand, as typing does. + reader?.isSelected = canvas.readingShown guard canvas.isFirstResponder || canvas.pickerShown else { - if navigationItem.rightBarButtonItems?.first !== more { navigationItem.rightBarButtonItems = [more, draw] } + let resting = [more, draw] + (reader.map { [$0] } ?? []) + if navigationItem.rightBarButtonItems != resting { navigationItem.rightBarButtonItems = resting } return } let items = canvas.isFirstResponder ? [done, more, draw, redo, undo] : [more, draw, redo, undo] @@ -164,7 +174,7 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate title: "Space", image: UIImage(systemName: "arrow.up.and.down.text.horizontal"), attributes: editable ) { [weak self] _ in self?.canvas.insertSpace() }, ]) - return [ + return (Prototype.reading ? [readingAction()] : []) + [ insert, UIAction(title: "Page Background…", image: UIImage(systemName: "paintpalette"), attributes: editable) { [weak self] _ in self?.showPaper() @@ -189,12 +199,27 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate UIAction(title: "Delete Page", image: UIImage(systemName: "trash"), attributes: .destructive) { [weak self] _ in guard let self else { return } - section.delete(page) { _ in } + section.delete(page) }, ]), ] } + /// Reading View: the page reflowed into the screen's width, read-only, where it reflows. + private func readingAction() -> UIAction { + let reading = canvas.reading + return UIAction( + title: "Reading View", subtitle: reading.flatMap { Prototype.readingNote($0.verdict) }, + image: UIImage(systemName: "text.justify.left"), + attributes: reading?.offered == true || canvas.readingShown ? [] : .disabled, + state: canvas.readingShown ? .on : .off + ) { [weak self] _ in self?.toggleReading() } + } + + private func toggleReading() { + canvas.showReading(!canvas.readingShown) + } + /// The page's theme, its section's and its notebook's, each pickable; the page wears a /// new one when it opens again. private func themeMenu() -> UIMenu { @@ -228,12 +253,8 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate let paper = letter ? CGSize(width: 612, height: 792) : CGSize(width: 595.276, height: 841.89) let title = canvas.pageTitle ?? section.row(of: page)?.row.title ?? "" guard let pdf = canvas.pdf(paper: paper, section: section.tab.name) else { - let alert = UIAlertController( - title: export ? "Couldn't Export the PDF" : "Couldn't Print", - message: "The page could not be laid out on paper.", preferredStyle: .alert) - alert.addAction(UIAlertAction(title: "OK", style: .default)) - present(alert, animated: true) - return + return alert( + export ? "Couldn't Export the PDF" : "Couldn't Print", "The page could not be laid out on paper.") } if export { let name = title.components(separatedBy: CharacterSet(charactersIn: "/\\:")).joined(separator: "-") @@ -274,12 +295,7 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate // Nothing typed is lost unannounced: the page's text goes on the clipboard. UIPasteboard.general.string = canvas.pageText canvas.reload(discard: true) - let alert = UIAlertController( - title: "Change Not Saved", - message: "The page shows what was last saved. Your text is on the clipboard to paste back.", - preferredStyle: .alert) - alert.addAction(UIAlertAction(title: "OK", style: .default)) - present(alert, animated: true) + alert("Change Not Saved", "The page shows what was last saved. Your text is on the clipboard to paste back.") } else if flags & Section.remote != 0 { canvas.reload(discard: false) } @@ -332,7 +348,7 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate private func conflictAction() { guard let (row, version) = section.row(of: page) else { return } if version != nil { - section.delete(page) { [weak self] _ in self?.onOpen?(row.id) } + section.delete(page) { [weak self] in self?.onOpen?(row.id) } } else if let newest = row.versions.max(by: { ($0.created ?? 0) < ($1.created ?? 0) }) { onOpen?(newest.id) } diff --git a/apps/ios/Snowbound/Prototypes.swift b/apps/ios/Snowbound/Prototypes.swift new file mode 100644 index 0000000000000000000000000000000000000000..02fe052512703113e441a14f1c9951a102cdf7c9 --- /dev/null +++ b/apps/ios/Snowbound/Prototypes.swift @@ -0,0 +1,241 @@ +import UIKit + +/// Design prototypes, off unless a launch argument turns one on, as +/// `-SnowboundReading YES`, `-SnowboundWelcome YES` or `-SnowboundSectionStrip YES` (Xcode's +/// scheme, `simctl launch`, or `devicectl … --arguments`). +enum Prototype { + /// Page menu: Reading View, the page reflowed into the screen's width, read-only. + static var reading: Bool { UserDefaults.standard.bool(forKey: "SnowboundReading") } +} + +extension Prototype { + /// What the page menu says under Reading View for a verdict from `sb_view_reading`. + static func readingNote(_ verdict: String) -> String? { + let notes = [ + ("Fits", "Page already fits"), + ("RightToLeft", "Right-to-left pages not yet"), + ("Drawn", "Mostly drawings"), + ("Arranged", "Laid out as a canvas"), + ("Asides", "Side notes follow their lines"), + ("SideBySide", "Columns read left to right"), + ("Group", "Overlapping parts stay together"), + ("Wide", "Some parts stay wide"), + ] + let found = notes.filter { verdict.contains($0.0) }.map(\.1) + return found.isEmpty ? nil : found.joined(separator: " · ") + } +} + +extension Prototype { + /// First launch: a welcome where the list would be empty, the first notebook found opened + /// at its first page, and the name asked at the first edit rather than the first open. + static var welcome: Bool { UserDefaults.standard.bool(forKey: "SnowboundWelcome") } + + /// The notebook list's welcome while there are no notebooks, nil once there are or while + /// iCloud Drive is still being looked up. + static func welcome( + new: @escaping () -> Void, openFolder: @escaping () -> Void, connect: @escaping () -> Void + ) -> UIContentUnavailableConfiguration? { + guard Notebooks.all.isEmpty, !ICloud.signedIn || ICloud.documents != nil else { return nil } + var welcome = UIContentUnavailableConfiguration.empty() + welcome.image = UIImage(systemName: "book.closed") + welcome.text = "No Notebooks Yet" + welcome.secondaryText = + ICloud.documents != nil + ? "New notebooks go in iCloud Drive, where OneNote and your other devices can open them." + : "New notebooks stay on this iPhone. Turn on iCloud Drive to use them on all your devices." + var button = UIButton.Configuration.borderedProminent() + button.title = "New Notebook" + welcome.button = button + welcome.buttonProperties.primaryAction = UIAction { _ in new() } + var open = UIButton.Configuration.plain() + open.title = "Open Existing Notebook" + welcome.secondaryButton = open + var elsewhere = [ + UIAction(title: "Open Folder…", image: UIImage(systemName: "folder")) { _ in openFolder() }, + UIAction(title: "Connect to Server…", image: UIImage(systemName: "server.rack")) { _ in connect() }, + ] + if !ICloud.signedIn { + elsewhere.append( + UIAction(title: "Turn On iCloud Drive", image: UIImage(systemName: "icloud")) { _ in + if let settings = URL(string: UIApplication.openSettingsURLString) { + UIApplication.shared.open(settings) + } + }) + } + welcome.secondaryButtonProperties.menu = UIMenu(children: elsewhere) + return welcome + } + + /// Asks for the name edits are stored under, then runs `then`. + static func askName(from controller: UIViewController, then: @escaping () -> Void) { + let alert = UIAlertController( + title: "Your Name", message: "OneNote shows it beside the pages and changes you make.", + preferredStyle: .alert) + let save = UIAlertAction(title: "Continue", style: .default) { [weak alert] _ in + Author.name = alert?.textFields?.first?.text?.trimmingCharacters(in: .whitespaces) + then() + } + save.isEnabled = false + alert.addTextField { field in + field.placeholder = "Name" + field.textContentType = .name + field.autocapitalizationType = .words + NotificationCenter.default.addObserver( + forName: UITextField.textDidChangeNotification, object: field, queue: .main + ) { _ in save.isEnabled = !(field.text ?? "").trimmingCharacters(in: .whitespaces).isEmpty } + } + alert.addAction(UIAlertAction(title: "Not Now", style: .cancel)) + alert.addAction(save) + alert.preferredAction = save + (controller.presentedViewController ?? controller).present(alert, animated: true) + } +} + +extension Prototype { + /// Pages list: the notebook's sections as OneNote's coloured tabs over the pages; a tab or + /// a swipe across the list changes section. + static var sectionStrip: Bool { UserDefaults.standard.bool(forKey: "SnowboundSectionStrip") } +} + +/// A notebook's sections as a row of coloured tabs, the open one raised; a section group is a +/// tab whose menu holds its sections. +final class SectionStrip: UIView { + private let scroll = UIScrollView() + private let row = UIStackView() + private let rule = UIView() + private(set) var notebook: Notebook? + private var selected = "" + private let onPick: (Tab, Notebook) -> Void + + init(onPick: @escaping (Tab, Notebook) -> Void) { + self.onPick = onPick + super.init(frame: CGRect(x: 0, y: 0, width: 320, height: 50)) + backgroundColor = .systemBackground + scroll.showsHorizontalScrollIndicator = false + row.axis = .horizontal + row.spacing = 2 + row.alignment = .bottom + for view in [scroll, rule] as [UIView] { + view.translatesAutoresizingMaskIntoConstraints = false + addSubview(view) + } + row.translatesAutoresizingMaskIntoConstraints = false + scroll.addSubview(row) + NSLayoutConstraint.activate([ + scroll.leadingAnchor.constraint(equalTo: leadingAnchor), + scroll.trailingAnchor.constraint(equalTo: trailingAnchor), + scroll.topAnchor.constraint(equalTo: topAnchor, constant: 6), + scroll.bottomAnchor.constraint(equalTo: rule.topAnchor), + rule.leadingAnchor.constraint(equalTo: leadingAnchor), + rule.trailingAnchor.constraint(equalTo: trailingAnchor), + rule.bottomAnchor.constraint(equalTo: bottomAnchor), + rule.heightAnchor.constraint(equalToConstant: 3), + row.leadingAnchor.constraint(equalTo: scroll.contentLayoutGuide.leadingAnchor, constant: 12), + row.trailingAnchor.constraint(equalTo: scroll.contentLayoutGuide.trailingAnchor, constant: -12), + row.topAnchor.constraint(equalTo: scroll.contentLayoutGuide.topAnchor), + row.bottomAnchor.constraint(equalTo: scroll.contentLayoutGuide.bottomAnchor), + row.heightAnchor.constraint(equalTo: scroll.frameLayoutGuide.heightAnchor), + ]) + } + + required init?(coder: NSCoder) { fatalError() } + + /// The readable sections in the notebook's order, groups' sections where the group is. + private var readable: [Tab] { notebook?.tabs.filter(\.readable) ?? [] } + + func show(_ notebook: Notebook, selected path: String) { + let first = self.notebook == nil + self.notebook = notebook + select(path) + #if DEBUG + // `SNOWBOUND_STRIP_TOUR=N` steps N sections on, one each 1.5 s, for recordings. + if first, let steps = ProcessInfo.processInfo.environment["SNOWBOUND_STRIP_TOUR"].flatMap(Int.init) { + for step in 1...max(1, steps) { + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5 * Double(step)) { [weak self] in + if let tab = self?.neighbour(1) { self?.pick(tab) } + } + } + } + #endif + } + + func select(_ path: String) { + selected = path + guard let notebook else { return } + for view in row.arrangedSubviews { view.removeFromSuperview() } + var groups: Set = [] + for tab in notebook.tabs where tab.readable { + let group = tab.group.split(separator: "/").first.map(String.init) + if let group { + guard groups.insert(group).inserted else { continue } + let inside = notebook.tabs.filter { $0.readable && $0.group.hasPrefix(group) } + let current = inside.first { $0.path == path } + row.addArrangedSubview( + button(current?.name ?? group, color: current?.uiColor ?? .systemGray, raised: current != nil, + folder: true, menu: UIMenu(title: group, children: inside.map { tab in + UIAction(title: tab.name, image: swatch(tab.uiColor), + state: tab.path == path ? .on : .off) { [weak self] _ in self?.pick(tab) } + }))) + } else { + let tab = tab + let button = button(tab.name, color: tab.uiColor, raised: tab.path == path, folder: false, menu: nil) + button.addAction(UIAction { [weak self] _ in self?.pick(tab) }, for: .primaryActionTriggered) + row.addArrangedSubview(button) + } + } + rule.backgroundColor = notebook.tabs.first { $0.path == path }?.uiColor ?? .separator + layoutIfNeeded() + if let raised = row.arrangedSubviews.first(where: { $0.tag == 1 }) { + scroll.scrollRectToVisible(raised.frame.insetBy(dx: -40, dy: 0), animated: false) + } + } + + /// The readable section `step` places after the open one, if any. + func neighbour(_ step: Int) -> Tab? { + let tabs = readable + guard let at = tabs.firstIndex(where: { $0.path == selected }), tabs.indices.contains(at + step) else { + return nil + } + return tabs[at + step] + } + + func pick(_ tab: Tab) { + guard let notebook else { return } + select(tab.path) + onPick(tab, notebook) + } + + private func button(_ title: String, color: UIColor, raised: Bool, folder: Bool, menu: UIMenu?) -> UIButton { + var configuration = UIButton.Configuration.filled() + configuration.title = title + configuration.image = folder ? UIImage(systemName: "folder") : nil + configuration.imagePadding = 4 + configuration.preferredSymbolConfigurationForImage = UIImage.SymbolConfiguration(textStyle: .caption1) + configuration.baseBackgroundColor = raised ? color : color.withAlphaComponent(0.28) + configuration.baseForegroundColor = raised ? .white : .label + configuration.background.cornerRadius = 9 + configuration.contentInsets = NSDirectionalEdgeInsets( + top: raised ? 11 : 8, leading: 14, bottom: raised ? 9 : 7, trailing: 14) + configuration.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { + var attributes = $0 + attributes.font = .preferredFont(forTextStyle: .subheadline).withTraits(raised ? .traitBold : []) + return attributes + } + let button = UIButton(configuration: configuration) + // Tabs: only the top corners round, the open one meeting the rule beneath. + button.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] + button.layer.cornerRadius = 9 + button.clipsToBounds = true + button.tag = raised ? 1 : 0 + if let menu { + button.menu = menu + button.showsMenuAsPrimaryAction = true + } + return button + } + + private func swatch(_ color: UIColor) -> UIImage? { + UIImage(systemName: "rectangle.portrait.fill")?.withTintColor(color, renderingMode: .alwaysOriginal) + } +} diff --git a/apps/ios/Snowbound/Script.swift b/apps/ios/Snowbound/Script.swift index e48f4e36679deea2b141ec39aed7a3daceab0ed8..42926d5eeba70461f40701f6d2a4d77e64ea9671 100644 --- a/apps/ios/Snowbound/Script.swift +++ b/apps/ios/Snowbound/Script.swift @@ -14,7 +14,7 @@ extension CanvasView { /// `tool:pen:N`, `tool:shape:N`, `tool:eraser` or `tool:lasso` gives it a tool, `picker` shows or hides the /// drawing tools, `pencildoubletap` is the Pencil's double tap, and `deleteink` deletes /// what the lasso picked; `rotate:landscape` or `rotate:portrait` turns the device; `done` - /// ends editing, `tree` saves the view hierarchy to + /// ends editing, `reading` turns the reading view on or off, `tree` saves the view hierarchy to /// Documents/tree.txt, and `shot:a` the window to Documents/a.png, as a device has no /// screenshot command. func runScript() { @@ -73,8 +73,9 @@ extension CanvasView { from: position(from: beginningOfDocument, offset: values[0])!, to: position(from: beginningOfDocument, offset: values[1])!) case "scroll": - let values = argument.split(separator: ",").compactMap { Double($0) } - setContentOffset(CGPoint(x: values[0], y: values[1]), animated: true) + // `scroll:,Y` keeps the horizontal offset. + let values = argument.split(separator: ",", omittingEmptySubsequences: false).map { Double($0) } + setContentOffset(CGPoint(x: values[0] ?? contentOffset.x, y: values[1] ?? contentOffset.y), animated: true) case "zoom": setZoomScale(Double(argument) ?? 1, animated: false) case "doubletap": let values = argument.split(separator: ",").compactMap { Double($0) } @@ -90,6 +91,7 @@ extension CanvasView { menu.presentEditMenu(with: UIEditMenuConfiguration(identifier: nil, sourcePoint: CGPoint(x: caret.midX, y: caret.minY))) case "done": _ = resignFirstResponder() case "selectall": selectAll(nil) + case "reading": showReading(!readingShown) case "format": apply(UInt8(argument) ?? 0) case "find": _ = find(argument) case "title": _ = focusTitle() diff --git a/apps/ios/Snowbound/Server.swift b/apps/ios/Snowbound/Server.swift index b7de342be353709c8e4ab954d35d3a9a160057f3..98b4a5c4c7246ac589f86cedea4a29ce15ecea40 100644 --- a/apps/ios/Snowbound/Server.swift +++ b/apps/ios/Snowbound/Server.swift @@ -110,12 +110,6 @@ final class ServerViewController: UITableViewController, UITextFieldDelegate { } } - private func alert(_ title: String, _ message: String) { - let alert = UIAlertController(title: title, message: message, preferredStyle: .alert) - alert.addAction(UIAlertAction(title: "OK", style: .default)) - present(alert, animated: true) - } - override func numberOfSections(in tableView: UITableView) -> Int { registered ? 2 : 1 } override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 2 } diff --git a/arc/canvas.md b/arc/canvas.md index e098a9305527713283a691dd63ea2d4c5ef2e99b..9e35300d4d9d3a458ae5af171f2059302be4a350 100644 --- a/arc/canvas.md +++ b/arc/canvas.md @@ -84,6 +84,11 @@ same line heights, same places. That took measuring, not guessing. Snap To Grid (the shape gallery's last item, kept between launches) is off. Tags sit in a column to the left of the text with OneNote's spacing, and a tag's colour paints the whole paragraph as it does there. +- **A right-to-left page is the same frame, seen from its right.** OneNote 2010 + keeps its positions left to right like any page's, from a margin origin some + 10,800 pt out, and opens it scrolled to the right end of its content, keeping + that edge as the window resizes. The canvas does the same and stores clicks, + strokes and drags as on any page (`corpus/rtl-page`). These rules were established against OneNote's own output: its XML export gives outline sizes, its PDF export gives exact line breaks, and screenshots diff --git a/arc/file-format.md b/arc/file-format.md index 2b85e489fb14427f56db24b84f9fc0dba0be1098..f500abeed01a32a0fefe110186fc20dccd69d1d6 100644 --- a/arc/file-format.md +++ b/arc/file-format.md @@ -182,11 +182,19 @@ tools), while the writer stays strict. ## Protected sections -Password-protected sections keep their encrypted structure. With the -`protected` feature, `onestore` opens OneNote 2010's AES wrapper for a supplied -password. Edits are sealed back under the section's key, with a fresh IV for -each object. Plaintext never reaches the replica's cache. A protected section is -edited directly in its file and never queued. +A password-protected section keeps its structure in the clear and encrypts every +object and payload under one AES-128 key, which Office's Agile password encryption +wraps (SHA-1, 100,000 rounds). `protected::Key` opens it with the password, and +`Section::unlock` keeps the section open under it: objects decode into the arena as it +opens, and each seal encrypts what it appends with a fresh IV and names the key in every +revision. Edits are ops like any other's; only the bytes differ. + +Setting, changing or removing a password is one of the few whole-image writes, as it is +in OneNote 2010, which writes the section anew under a new file identity, new object +space identities and new payload identities, and leaves its TOC to follow. Snowbound does +the same (`protected::rekey`): keeping any identity would let a cache, OneNote's or a +replica, mistake the old file's revisions or payloads for the new file's. The evidence, +with the crypto, is in `corpus/protected-sections`. ## Looking inside diff --git a/arc/platforms.md b/arc/platforms.md index 36d86630cd1ae56f92d6cb38625ef93e626caf86..97c307868b48b21cc6a22bba9c5e4b911b47b26e 100644 --- a/arc/platforms.md +++ b/arc/platforms.md @@ -10,7 +10,7 @@ drawn by Snowbound versus the operating system. shell snowbound + ui snowbound + ui snowbound + ui UIKit (apps/ios) glue snowbound/src/macos snowbound/src/linux snowbound/src/windows crates/mobile (C ABI) page canvas ─────────────────────────────────────────────────────────────────────► -paint draw (wgpu: Metal) draw (Vulkan or GL) draw (D3D12, or GL on 7) draw (Metal) +paint draw (wgpu: Metal) draw (Vulkan or GL) draw (D3D12; D3D11 on 7) draw (Metal) data notebook::session + embedded SMB client ────────────────────────────────────► format onestore ───────────────────────────────────────────────────────────────────► ``` @@ -103,9 +103,15 @@ keyboard, the toolbar and the macOS menu bar all run commands from it. imports, and aarch64 for Windows 11 on Arm. The few imports of Windows 8 and later that dependencies still name are answered by `platform/windows/rt/shims.c`; everything newer is looked up at run time. -- `draw` builds both backends on Windows and the surface picks one: +- `draw` builds three backends on Windows and the surface picks one: Direct3D 12 through wgpu where Windows has it (10 and 11), otherwise - OpenGL 2.1 on a WGL context, as on Windows 7 (`surface_windows.rs`). + Direct3D 11, as on Windows 7, on WARP where no device reaches feature + level 10_0, and OpenGL 2.1 on a WGL context only where neither starts + (`surface_windows.rs`). Direct3D 11 presents through a blit-model DXGI + swap chain, as 7 has no flip model, and through DirectComposition on 10, + whose window has no redirection surface; OpenGL drivers such as Intel's + place their frames below the caption the system would draw even where the + row takes its place. - The toolbar's row is the title bar wherever the desktop composes windows. On Windows 7 with Aero and on 11, the system's frame keeps its sides, 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. draws them as 11 does, lit where the system reports the pointer. On 8 and 10, whose frames are opaque, the window has no system frame and the row draws and runs caption buttons as 10 does, over acrylic on 10. Over - 7's glass the sidebar stays opaque, and fields and tool buttons take white - faces, as Internet Explorer's do there. The theme and material follow the - system's colour mode as it changes. With Windows 7's basic or classic - theme the system draws the title bar and the row lies beneath it, as on - KDE. + 7's glass, which takes any colour and whatever lies behind the window, + the toolbar keeps opaque faces under its icons: by default the strip is + opaque, as Explorer's command bar is under its glass, stopping short of the + caption buttons 7 draws beneath the window's pixels, and + `SNOWBOUND_W7_CHROME` tries the alternatives, `pills` for a face per group + of tools over the glass, `frost` for a strip the glass tints through, and + `tint` and `tint-tiles` for one panel or a tile per group in the glass's + colour at a lightness text keeps its contrast on, as Mica tints. + The theme and material follow the system's colour mode as it changes. + With Windows 7's basic or classic theme the system draws the title bar + and the row lies beneath it, as on KDE. - A notebook on a share opens by its UNC path through Windows' own SMB client, which takes OneNote's opens and locks natively: `onestore` opens a section as OneNote does (a reader shares it with everyone, a writer denies diff --git a/arc/sync.md b/arc/sync.md index 4fe269ae6f0309f53038f622a1f910c803be271b..2c2e936ada2948daa32c0fd3568b6425ec6d2936 100644 --- a/arc/sync.md +++ b/arc/sync.md @@ -55,7 +55,15 @@ reopens the same queue after a relaunch. It holds: - **payloads**: picture and attachment bytes, stored once each, keyed by hash. The working state is never stored. It is the base with each sealed batch -replayed and the open batch applied, rebuilt on open. The database runs in WAL +replayed and the open batch applied, rebuilt on open. + +A password-protected section's replica holds nothing in the clear. Its base and sealed +transactions are the file's own ciphertext, and its edits and payloads are sealed with +AES-256-GCM under a key derived from the section's, so the queue opens, offline or after a +relaunch, only once the section is unlocked. A locked section is never opened in the +background; its edits wait until it is unlocked again. A replica of a section another device +then protects is deleted the next time the notebook is read, unless edits wait in it; those +wait for the key, then come back as copies of the pages they changed. The database runs in WAL mode with full synchronous commits, and every setting is read back to check it took. diff --git a/corpus/m6/native-page-direction-03/ORACLE.md b/corpus/m6/native-page-direction-03/ORACLE.md index 8ddcc5c70408bd38368566e59908f1d262db06b5..dbc5ae55faa46dd823f7d659b6d2223478dddc7d 100644 --- a/corpus/m6/native-page-direction-03/ORACLE.md +++ b/corpus/m6/native-page-direction-03/ORACLE.md @@ -5,9 +5,10 @@ table columns to 96 and 144 points through OneNote COM. `before.xml`, `after.xml and `read/page-000.xml` retain the native observations. The saved store uses visual left-to-right column order [Right cell, Left cell], with widths [144, 96]. COM exports cells in page direction [Left cell, Right cell], with widths [96, 144]. -The native PDF independently confirms the physical cell order. Default outline alignment anchors the right edge: -store x + LayoutMaxWidth - PageMarginOriginX - 36 equals native x + native width. -The native table outline measures wider than its stored suggested maximum width. +The native PDF independently confirms the physical cell order. Native x is store x - +PageMarginOriginX - 36; the native table outline measures wider than its stored suggested +maximum width and keeps its right edge, store x + LayoutMaxWidth - PageMarginOriginX - 36 +equalling native x + native width (`corpus/rtl-page` has narrower outlines keep their left). Vertical coordinates retain the 14.4-point canonical origin. The public fixture includes images, a file and two opaque ink objects. All diff --git a/corpus/notebook-package/README.md b/corpus/notebook-package/README.md new file mode 100644 index 0000000000000000000000000000000000000000..4368a79a478a51e3d31f12ccc2e0fa04cf616136 --- /dev/null +++ b/corpus/notebook-package/README.md @@ -0,0 +1,27 @@ +# Notebook packages + +OneNote 2010's Save As offers a page and a section as a OneNote 2010 section (`.one`), 2007 +section, PDF, XPS or single-file web page, and a notebook as a OneNote Package (`.onepkg`), PDF +or XPS. Observed in a lab clone on 2026-10-01: + +- `native/Lab.onepkg` is OneNote's package of a notebook of two sections and a recycle bin: an + LZX (2^18-byte window) cabinet of the TOC and sections at their paths, the bin left out though + the TOC keeps its entry; each file written anew, with no `guidAncestor` or `crcName`. +- A section or page saved as a section is a file of its own identity, outside any notebook, + which OneNote then opens among Open Sections. +- Unpack Notebook (opening a package) asks for the name (the package's), colour (the package's) + and folder, writes each file with a new identity and version, places it under its folder's + TOC, and lists it anew beside its stale entry. +- Show Unread Changes, Mark as Read and what is unread live in OneNote's local cache, not in the + notebook's files or the registry. + +`candidate/Packed.onepkg` and `candidate/Unpacked` are what +`a_notebook_packs_and_unpacks_as_onenote_packs_one` in `crates/notebook/tests/package.rs` +exports with `NOTEBOOK_PACKAGE_EXPORT`: Snowbound's MSZIP package of a notebook with a section +group, and the notebook Snowbound unpacked from it. `onenote-unpacked` is OneNote 2010 unpacking +Snowbound's package through its Unpack Notebook dialog; `cold` is a fresh OneNote 2010's read of +Snowbound's unpacked notebook, every file left as written. `save-as/candidate` holds Save As's +copy of a section and section of a page as `save_as_writes_copies_outside_the_notebook` exports +them with `NOTEBOOK_SAVE_AS_EXPORT`; `save-as/cold` is OneNote 2010 opening their folder, which +adopts them (new identities, placed) and reads each page. `tools/test_notebook_package.py` +checks them all without a VM. diff --git a/corpus/notebook-package/candidate/Packed.onepkg b/corpus/notebook-package/candidate/Packed.onepkg new file mode 100644 index 0000000000000000000000000000000000000000..517d53e70ca683c1a95639d73236bf1ed5aacb9f Binary files /dev/null and b/corpus/notebook-package/candidate/Packed.onepkg differ diff --git a/corpus/notebook-package/candidate/Unpacked/Group/Inner.one b/corpus/notebook-package/candidate/Unpacked/Group/Inner.one new file mode 100644 index 0000000000000000000000000000000000000000..59a7508c6e1ffc125acacba98e133fad0ba6085f Binary files /dev/null and b/corpus/notebook-package/candidate/Unpacked/Group/Inner.one differ diff --git a/corpus/notebook-package/candidate/Unpacked/Group/Open Notebook.onetoc2 b/corpus/notebook-package/candidate/Unpacked/Group/Open Notebook.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..d273afe517d6429be34549ca0ddc1e5a84432124 Binary files /dev/null and b/corpus/notebook-package/candidate/Unpacked/Group/Open Notebook.onetoc2 differ diff --git a/corpus/notebook-package/candidate/Unpacked/New Section 1.one b/corpus/notebook-package/candidate/Unpacked/New Section 1.one new file mode 100644 index 0000000000000000000000000000000000000000..8f24418f706c772ef0879974ea18e4861ab991c6 Binary files /dev/null and b/corpus/notebook-package/candidate/Unpacked/New Section 1.one differ diff --git a/corpus/notebook-package/candidate/Unpacked/Open Notebook.onetoc2 b/corpus/notebook-package/candidate/Unpacked/Open Notebook.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..5ca684cbbd70123431f9fd5dfd49f54e8ee6fe2f Binary files /dev/null and b/corpus/notebook-package/candidate/Unpacked/Open Notebook.onetoc2 differ diff --git a/corpus/notebook-package/cold/commands.jsonl b/corpus/notebook-package/cold/commands.jsonl new file mode 100644 index 0000000000000000000000000000000000000000..4d103152d56310735ba46cd1654dc5bf6698fd8c --- /dev/null +++ b/corpus/notebook-package/cold/commands.jsonl @@ -0,0 +1,3 @@ +{"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} +{"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} +{"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} diff --git a/corpus/notebook-package/cold/machine.json b/corpus/notebook-package/cold/machine.json new file mode 100644 index 0000000000000000000000000000000000000000..770178a688f15ff2e1d41ed8b7decdeb3f25d693 --- /dev/null +++ b/corpus/notebook-package/cold/machine.json @@ -0,0 +1 @@ +{"name": "m6-7f93e47c", "hostname": "ONE-M6-7F93E47C"} diff --git a/corpus/notebook-package/cold/notebook/Group/Inner.one b/corpus/notebook-package/cold/notebook/Group/Inner.one new file mode 100644 index 0000000000000000000000000000000000000000..59a7508c6e1ffc125acacba98e133fad0ba6085f Binary files /dev/null and b/corpus/notebook-package/cold/notebook/Group/Inner.one differ diff --git a/corpus/notebook-package/cold/notebook/Group/Open Notebook.onetoc2 b/corpus/notebook-package/cold/notebook/Group/Open Notebook.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..d273afe517d6429be34549ca0ddc1e5a84432124 Binary files /dev/null and b/corpus/notebook-package/cold/notebook/Group/Open Notebook.onetoc2 differ diff --git a/corpus/notebook-package/cold/notebook/New Section 1.one b/corpus/notebook-package/cold/notebook/New Section 1.one new file mode 100644 index 0000000000000000000000000000000000000000..8f24418f706c772ef0879974ea18e4861ab991c6 Binary files /dev/null and b/corpus/notebook-package/cold/notebook/New Section 1.one differ diff --git a/corpus/notebook-package/cold/notebook/Open Notebook.onetoc2 b/corpus/notebook-package/cold/notebook/Open Notebook.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..5ca684cbbd70123431f9fd5dfd49f54e8ee6fe2f Binary files /dev/null and b/corpus/notebook-package/cold/notebook/Open Notebook.onetoc2 differ diff --git a/corpus/notebook-package/cold/read/environment.json b/corpus/notebook-package/cold/read/environment.json new file mode 100644 index 0000000000000000000000000000000000000000..e1727b4e42e54ad043cd6cc9a19393f0193598c4 --- /dev/null +++ b/corpus/notebook-package/cold/read/environment.json @@ -0,0 +1,7 @@ +{ + "powershell": "5.1.14409.1005", + "schema": "xs2010", + "hostname": "ONE-M6-7F93E47C", + "cold": true, + "onenote": "14.0.4763.1000" +} diff --git a/corpus/notebook-package/cold/read/hierarchy.xml b/corpus/notebook-package/cold/read/hierarchy.xml new file mode 100644 index 0000000000000000000000000000000000000000..9a3b717a6cd14c1c88521c433ffd7a7b4afc83b4 --- /dev/null +++ b/corpus/notebook-package/cold/read/hierarchy.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/notebook-package/cold/read/page-000.navigation.json b/corpus/notebook-package/cold/read/page-000.navigation.json new file mode 100644 index 0000000000000000000000000000000000000000..163d0e2ebf70e671e15b2c4234850d0c72fe963e --- /dev/null +++ b/corpus/notebook-package/cold/read/page-000.navigation.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "Inside - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/notebook-package/cold/read/page-000.png b/corpus/notebook-package/cold/read/page-000.png new file mode 100644 index 0000000000000000000000000000000000000000..03c501cc8e58fea42f18bfd09675d77f2849e93d Binary files /dev/null and b/corpus/notebook-package/cold/read/page-000.png differ diff --git a/corpus/notebook-package/cold/read/page-000.xml b/corpus/notebook-package/cold/read/page-000.xml new file mode 100644 index 0000000000000000000000000000000000000000..235c0685a202d04e63c5d4016dfab2b486ced92e --- /dev/null +++ b/corpus/notebook-package/cold/read/page-000.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/notebook-package/cold/read/page-001.navigation.json b/corpus/notebook-package/cold/read/page-001.navigation.json new file mode 100644 index 0000000000000000000000000000000000000000..418643b70158be86cb7e65ea2ffe9eb552c0e2f7 --- /dev/null +++ b/corpus/notebook-package/cold/read/page-001.navigation.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "First - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/notebook-package/cold/read/page-001.png b/corpus/notebook-package/cold/read/page-001.png new file mode 100644 index 0000000000000000000000000000000000000000..e499cee932ef4dde5f6988365495cde9bad05c96 Binary files /dev/null and b/corpus/notebook-package/cold/read/page-001.png differ diff --git a/corpus/notebook-package/cold/read/page-001.xml b/corpus/notebook-package/cold/read/page-001.xml new file mode 100644 index 0000000000000000000000000000000000000000..b7250fae78ed575b6ff282ad9a8295a4b455da99 --- /dev/null +++ b/corpus/notebook-package/cold/read/page-001.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/notebook-package/cold/read/payloads.json b/corpus/notebook-package/cold/read/payloads.json new file mode 100644 index 0000000000000000000000000000000000000000..1ce91a413d6fff4d58765187b76756f0e97ae843 --- /dev/null +++ b/corpus/notebook-package/cold/read/payloads.json @@ -0,0 +1,3 @@ +[ + +] \ No newline at end of file diff --git a/corpus/notebook-package/cold/run.json b/corpus/notebook-package/cold/run.json new file mode 100644 index 0000000000000000000000000000000000000000..c55c40654ee7e020fc7f24922adef7cba3f80185 --- /dev/null +++ b/corpus/notebook-package/cold/run.json @@ -0,0 +1,18 @@ +{ + "notebook": "/private/tmp/snowbound-notebook-features/package-export/Unpacked", + "expected_pages": -1, + "author": null, + "author_timeout_seconds": 600, + "inspect": false, + "collect_notebook": true, + "base": { + "file": "win7-office-base.qcow2", + "format": "qcow2", + "sha256": "a1a4f8fab782ee14885ff801ca2f6347c208fdcfc3513637096f314315c89346", + "virtual_size": 68719476736 + }, + "scripts": { + "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331", + "read.ps1": "22bc760f955ef59bb5f0ccfd55271f8d868674084127fbe219e1048bd3777f35" + } +} diff --git a/corpus/notebook-package/cold/scripts/cold.ps1 b/corpus/notebook-package/cold/scripts/cold.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..a0b99e7aeb86f4dc10032537800501a52ad65b4f --- /dev/null +++ b/corpus/notebook-package/cold/scripts/cold.ps1 @@ -0,0 +1,27 @@ +param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '') +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$root = [IO.Path]::GetFullPath($Root).TrimEnd('\') +if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { + throw 'Choose a run directly below C:\one-tests\runs.' +} +if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' } +$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote' +if ($CloneHost) { + if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) { + throw 'The disposable clone hostname does not match this machine.' + } + New-Item "$key\Options\Paths" -Force | Out-Null + New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null +} elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or + -not (Test-Path 'C:\one-tests\profile-original-cache')) { + throw 'Park the personal OneNote profile before resetting the test cache.' +} +$cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0' +$parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root)) +if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' } +New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null +if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked } +if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse } +New-Item "$key\OpenNotebooks" | Out-Null +New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null diff --git a/corpus/notebook-package/cold/scripts/read.ps1 b/corpus/notebook-package/cold/scripts/read.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..a42366b32e8baaf13dae92e9472cae7a071220cc --- /dev/null +++ b/corpus/notebook-package/cold/scripts/read.ps1 @@ -0,0 +1,144 @@ +param( + [Parameter(Mandatory=$true)][string]$Root, + [int]$ExpectedPages = -1, + [switch]$UseCurrentCache, + [switch]$Pdf, + [switch]$KeepOpen, + [string]$CloneHost = '' +) +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$root = [IO.Path]::GetFullPath($Root).TrimEnd('\') +if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { + throw 'Choose a run directly below C:\one-tests\runs.' +} +$notebook = Join-Path $root 'notebook' +$output = Join-Path $root 'read' +if (Test-Path $output) { throw 'Choose a new read destination.' } +if ($UseCurrentCache) { + if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') { + throw 'Park the personal OneNote profile before reading test notebooks.' + } +} else { + & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost +} +New-Item -ItemType Directory -Path $output | Out-Null +$app = New-Object -ComObject OneNote.Application +$notebookId = '' +$failure = $null +try { + $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0) + $process = Get-Process ONENOTE + @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion; + powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } | + ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8 + $sections = @() + foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) { + $id = '' + $app.OpenHierarchy($file.FullName, '', [ref]$id, 0) + $sections += $id + } + $deadline = [DateTime]::UtcNow.AddSeconds(300) + $previous = '' + $lastChange = '' + $stableSince = [DateTime]::UtcNow + $settled = $false + do { + $pages = @{} + foreach ($section in $sections) { + $hierarchy = '' + $app.GetHierarchy($section, 4, [ref]$hierarchy, 1) + [xml]$xml = $hierarchy + foreach ($node in $xml.SelectNodes('//*[@path]')) { + if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) { + throw 'OneNote opened a section outside the copied notebook.' + } + } + foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) { + $id = $node.GetAttribute('ID') + $content = '' + $app.GetPageContent($id, [ref]$content, 1, 1) + $pages[$id] = $content + } + } + $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] })) + if ($signature -ne $previous) { + $lastChange = $previous + $previous = $signature + $stableSince = [DateTime]::UtcNow + } + if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or + ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and + ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break } + Start-Sleep -Milliseconds 250 + } while ([DateTime]::UtcNow -lt $deadline) + if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) { + [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8) + $index = 0 + foreach ($id in @($pages.Keys | Sort-Object)) { + [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) + $index++ + } + $hierarchy = '' + $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1) + [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8) + throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled." + } + $index = 0 + $payloads = @() + foreach ($id in @($pages.Keys | Sort-Object)) { + [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) + if ($Pdf) { + $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index) + $app.NavigateTo($id, '', $false) + $app.Publish($id, $pdfPath, 3, '') + if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' } + } + [xml]$page = $pages[$id] + foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) { + $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache')) + $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant() + [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes) + # A paragraph's file is named by its paragraph, one on the page by itself. + $owner = if ($file.ParentNode.LocalName -eq 'Page') { $file } else { $file.ParentNode } + $payloads += @{ page = $id; object = $owner.GetAttribute('objectID'); + kind = $file.LocalName; name = $file.GetAttribute('preferredName'); + sha256 = $hash; bytes = $bytes.Length } + } + $index++ + } + [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8) + $all = '' + $app.GetHierarchy($notebookId, 4, [ref]$all, 1) + [xml]$finalTree = $all + $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique) + if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) { + throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.' + } + [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8) + Write-Output "Read $($sections.Count) sections and $($pages.Count) pages." +} catch { + $failure = $_ + [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8) + throw +} finally { + try { + try { + if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) } + if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) { + $app.CloseNotebook($notebookId, $false) + } + } catch { + if ($null -eq $failure) { throw } + [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8) + } + } finally { + [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app) + } + $app = $null + [GC]::Collect() + [GC]::WaitForPendingFinalizers() + if (-not $UseCurrentCache -and -not $CloneHost) { + Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10 + } +} diff --git a/corpus/notebook-package/cold/source.json b/corpus/notebook-package/cold/source.json new file mode 100644 index 0000000000000000000000000000000000000000..f80f8f65376a287228b6a46976df760c7f438386 --- /dev/null +++ b/corpus/notebook-package/cold/source.json @@ -0,0 +1,26 @@ +[ + { + "path": "Group/Inner.one", + "bytes": 6032, + "sha256": "a5c70b17a6885e7c8852c69520c6be71961546e0fedf6ad0ff380e3db130e1ce", + "mtime_ns": 1790878684326770910 + }, + { + "path": "Group/Open Notebook.onetoc2", + "bytes": 4120, + "sha256": "204a6f736bf18e2304bd15d458e41d2f411b7f25a3be71337c4ed45071d300da", + "mtime_ns": 1790878684326669077 + }, + { + "path": "New Section 1.one", + "bytes": 6024, + "sha256": "842735103d262398916182756bcb46e3eafd602801cb37d695e4dd96afbd9f05", + "mtime_ns": 1790878684326501410 + }, + { + "path": "Open Notebook.onetoc2", + "bytes": 6120, + "sha256": "0f79ac377df5e59f49435239ad250b284a461da433eea502a8ec68f02e9d5479", + "mtime_ns": 1790878684326399618 + } +] diff --git a/corpus/notebook-package/cold/teardown.json b/corpus/notebook-package/cold/teardown.json new file mode 100644 index 0000000000000000000000000000000000000000..05a47793de40f322e745c4c4183727e3590ecf70 --- /dev/null +++ b/corpus/notebook-package/cold/teardown.json @@ -0,0 +1 @@ +{"absent": true} diff --git a/corpus/notebook-package/native/Lab.onepkg b/corpus/notebook-package/native/Lab.onepkg new file mode 100644 index 0000000000000000000000000000000000000000..6c73d8d38c8fa8ec7def95e805ec56affab4050c Binary files /dev/null and b/corpus/notebook-package/native/Lab.onepkg differ diff --git a/corpus/notebook-package/onenote-unpacked/hierarchy.xml b/corpus/notebook-package/onenote-unpacked/hierarchy.xml new file mode 100644 index 0000000000000000000000000000000000000000..077e10b9dee03cbaed7059d57226bbdd3d4a245a --- /dev/null +++ b/corpus/notebook-package/onenote-unpacked/hierarchy.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/notebook-package/onenote-unpacked/unpack-dialog.png b/corpus/notebook-package/onenote-unpacked/unpack-dialog.png new file mode 100644 index 0000000000000000000000000000000000000000..fea56b966e49f443f277a4818a46a0ee5b349762 Binary files /dev/null and b/corpus/notebook-package/onenote-unpacked/unpack-dialog.png differ diff --git a/corpus/notebook-package/onenote-unpacked/unpacked.png b/corpus/notebook-package/onenote-unpacked/unpacked.png new file mode 100644 index 0000000000000000000000000000000000000000..77d40b759efa4a42a1fd372acf36095cc00a0de2 Binary files /dev/null and b/corpus/notebook-package/onenote-unpacked/unpacked.png differ diff --git a/corpus/notebook-package/save-as/candidate/Inner copy.one b/corpus/notebook-package/save-as/candidate/Inner copy.one new file mode 100644 index 0000000000000000000000000000000000000000..84e9e2d27f9adfbbb819319e0710a21f1f8dcde1 Binary files /dev/null and b/corpus/notebook-package/save-as/candidate/Inner copy.one differ diff --git a/corpus/notebook-package/save-as/candidate/Inside.one b/corpus/notebook-package/save-as/candidate/Inside.one new file mode 100644 index 0000000000000000000000000000000000000000..672ecb6d144edeacbde5394da3a6c41634293095 Binary files /dev/null and b/corpus/notebook-package/save-as/candidate/Inside.one differ diff --git a/corpus/notebook-package/save-as/cold/commands.jsonl b/corpus/notebook-package/save-as/cold/commands.jsonl new file mode 100644 index 0000000000000000000000000000000000000000..c33b60ab22518d143d6a2bfda0e055cfed7afdc4 --- /dev/null +++ b/corpus/notebook-package/save-as/cold/commands.jsonl @@ -0,0 +1,3 @@ +{"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} +{"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} +{"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} diff --git a/corpus/notebook-package/save-as/cold/machine.json b/corpus/notebook-package/save-as/cold/machine.json new file mode 100644 index 0000000000000000000000000000000000000000..39cef3cdcba9c4320f42c2a3100c1a64956c7126 --- /dev/null +++ b/corpus/notebook-package/save-as/cold/machine.json @@ -0,0 +1 @@ +{"name": "m6-113f9c31", "hostname": "ONE-M6-113F9C31"} diff --git a/corpus/notebook-package/save-as/cold/notebook/Inner copy.one b/corpus/notebook-package/save-as/cold/notebook/Inner copy.one new file mode 100644 index 0000000000000000000000000000000000000000..97d6e242b0ed0ed8e44825cc3fc41d9c57251c54 Binary files /dev/null and b/corpus/notebook-package/save-as/cold/notebook/Inner copy.one differ diff --git a/corpus/notebook-package/save-as/cold/notebook/Inside.one b/corpus/notebook-package/save-as/cold/notebook/Inside.one new file mode 100644 index 0000000000000000000000000000000000000000..0531a88d3b64e20373bbc6a9f2d0ac4eca0b7357 Binary files /dev/null and b/corpus/notebook-package/save-as/cold/notebook/Inside.one differ diff --git a/corpus/notebook-package/save-as/cold/notebook/Open Notebook.onetoc2 b/corpus/notebook-package/save-as/cold/notebook/Open Notebook.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..184243e0b28c8072683b9cc70efaa93b7afd037a Binary files /dev/null and b/corpus/notebook-package/save-as/cold/notebook/Open Notebook.onetoc2 differ diff --git a/corpus/notebook-package/save-as/cold/read/environment.json b/corpus/notebook-package/save-as/cold/read/environment.json new file mode 100644 index 0000000000000000000000000000000000000000..39a66e2c1c2daa64d190403cc12ba693a77c332b --- /dev/null +++ b/corpus/notebook-package/save-as/cold/read/environment.json @@ -0,0 +1,7 @@ +{ + "powershell": "5.1.14409.1005", + "schema": "xs2010", + "hostname": "ONE-M6-113F9C31", + "cold": true, + "onenote": "14.0.4763.1000" +} diff --git a/corpus/notebook-package/save-as/cold/read/hierarchy.xml b/corpus/notebook-package/save-as/cold/read/hierarchy.xml new file mode 100644 index 0000000000000000000000000000000000000000..d28802a4a2c70daed5e709e6f9e9d2635bd6f0a8 --- /dev/null +++ b/corpus/notebook-package/save-as/cold/read/hierarchy.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/notebook-package/save-as/cold/read/page-000.navigation.json b/corpus/notebook-package/save-as/cold/read/page-000.navigation.json new file mode 100644 index 0000000000000000000000000000000000000000..163d0e2ebf70e671e15b2c4234850d0c72fe963e --- /dev/null +++ b/corpus/notebook-package/save-as/cold/read/page-000.navigation.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "Inside - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/notebook-package/save-as/cold/read/page-000.png b/corpus/notebook-package/save-as/cold/read/page-000.png new file mode 100644 index 0000000000000000000000000000000000000000..873fa5fc7c6a9b52dba420585867ca8c9cc21587 Binary files /dev/null and b/corpus/notebook-package/save-as/cold/read/page-000.png differ diff --git a/corpus/notebook-package/save-as/cold/read/page-000.xml b/corpus/notebook-package/save-as/cold/read/page-000.xml new file mode 100644 index 0000000000000000000000000000000000000000..a157b38a7c8aacf515b384851d42f0635dd3958a --- /dev/null +++ b/corpus/notebook-package/save-as/cold/read/page-000.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/notebook-package/save-as/cold/read/page-001.navigation.json b/corpus/notebook-package/save-as/cold/read/page-001.navigation.json new file mode 100644 index 0000000000000000000000000000000000000000..163d0e2ebf70e671e15b2c4234850d0c72fe963e --- /dev/null +++ b/corpus/notebook-package/save-as/cold/read/page-001.navigation.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "Inside - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/notebook-package/save-as/cold/read/page-001.png b/corpus/notebook-package/save-as/cold/read/page-001.png new file mode 100644 index 0000000000000000000000000000000000000000..d8602ed46099a17c74e2c7c05c54df98ce8c6a9e Binary files /dev/null and b/corpus/notebook-package/save-as/cold/read/page-001.png differ diff --git a/corpus/notebook-package/save-as/cold/read/page-001.xml b/corpus/notebook-package/save-as/cold/read/page-001.xml new file mode 100644 index 0000000000000000000000000000000000000000..8afea2511daa9e2348e3c2aa83c65f287559dcef --- /dev/null +++ b/corpus/notebook-package/save-as/cold/read/page-001.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/notebook-package/save-as/cold/read/payloads.json b/corpus/notebook-package/save-as/cold/read/payloads.json new file mode 100644 index 0000000000000000000000000000000000000000..1ce91a413d6fff4d58765187b76756f0e97ae843 --- /dev/null +++ b/corpus/notebook-package/save-as/cold/read/payloads.json @@ -0,0 +1,3 @@ +[ + +] \ No newline at end of file diff --git a/corpus/notebook-package/save-as/cold/run.json b/corpus/notebook-package/save-as/cold/run.json new file mode 100644 index 0000000000000000000000000000000000000000..d59cc4279979747ab86fc64b2afcf1421617b703 --- /dev/null +++ b/corpus/notebook-package/save-as/cold/run.json @@ -0,0 +1,18 @@ +{ + "notebook": "/private/tmp/snowbound-notebook-features/saveas-export", + "expected_pages": -1, + "author": null, + "author_timeout_seconds": 600, + "inspect": false, + "collect_notebook": true, + "base": { + "file": "win7-office-base.qcow2", + "format": "qcow2", + "sha256": "a1a4f8fab782ee14885ff801ca2f6347c208fdcfc3513637096f314315c89346", + "virtual_size": 68719476736 + }, + "scripts": { + "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331", + "read.ps1": "22bc760f955ef59bb5f0ccfd55271f8d868674084127fbe219e1048bd3777f35" + } +} diff --git a/corpus/notebook-package/save-as/cold/scripts/cold.ps1 b/corpus/notebook-package/save-as/cold/scripts/cold.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..a0b99e7aeb86f4dc10032537800501a52ad65b4f --- /dev/null +++ b/corpus/notebook-package/save-as/cold/scripts/cold.ps1 @@ -0,0 +1,27 @@ +param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '') +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$root = [IO.Path]::GetFullPath($Root).TrimEnd('\') +if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { + throw 'Choose a run directly below C:\one-tests\runs.' +} +if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' } +$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote' +if ($CloneHost) { + if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) { + throw 'The disposable clone hostname does not match this machine.' + } + New-Item "$key\Options\Paths" -Force | Out-Null + New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null +} elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or + -not (Test-Path 'C:\one-tests\profile-original-cache')) { + throw 'Park the personal OneNote profile before resetting the test cache.' +} +$cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0' +$parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root)) +if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' } +New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null +if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked } +if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse } +New-Item "$key\OpenNotebooks" | Out-Null +New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null diff --git a/corpus/notebook-package/save-as/cold/scripts/read.ps1 b/corpus/notebook-package/save-as/cold/scripts/read.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..a42366b32e8baaf13dae92e9472cae7a071220cc --- /dev/null +++ b/corpus/notebook-package/save-as/cold/scripts/read.ps1 @@ -0,0 +1,144 @@ +param( + [Parameter(Mandatory=$true)][string]$Root, + [int]$ExpectedPages = -1, + [switch]$UseCurrentCache, + [switch]$Pdf, + [switch]$KeepOpen, + [string]$CloneHost = '' +) +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$root = [IO.Path]::GetFullPath($Root).TrimEnd('\') +if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { + throw 'Choose a run directly below C:\one-tests\runs.' +} +$notebook = Join-Path $root 'notebook' +$output = Join-Path $root 'read' +if (Test-Path $output) { throw 'Choose a new read destination.' } +if ($UseCurrentCache) { + if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') { + throw 'Park the personal OneNote profile before reading test notebooks.' + } +} else { + & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost +} +New-Item -ItemType Directory -Path $output | Out-Null +$app = New-Object -ComObject OneNote.Application +$notebookId = '' +$failure = $null +try { + $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0) + $process = Get-Process ONENOTE + @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion; + powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } | + ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8 + $sections = @() + foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) { + $id = '' + $app.OpenHierarchy($file.FullName, '', [ref]$id, 0) + $sections += $id + } + $deadline = [DateTime]::UtcNow.AddSeconds(300) + $previous = '' + $lastChange = '' + $stableSince = [DateTime]::UtcNow + $settled = $false + do { + $pages = @{} + foreach ($section in $sections) { + $hierarchy = '' + $app.GetHierarchy($section, 4, [ref]$hierarchy, 1) + [xml]$xml = $hierarchy + foreach ($node in $xml.SelectNodes('//*[@path]')) { + if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) { + throw 'OneNote opened a section outside the copied notebook.' + } + } + foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) { + $id = $node.GetAttribute('ID') + $content = '' + $app.GetPageContent($id, [ref]$content, 1, 1) + $pages[$id] = $content + } + } + $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] })) + if ($signature -ne $previous) { + $lastChange = $previous + $previous = $signature + $stableSince = [DateTime]::UtcNow + } + if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or + ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and + ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break } + Start-Sleep -Milliseconds 250 + } while ([DateTime]::UtcNow -lt $deadline) + if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) { + [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8) + $index = 0 + foreach ($id in @($pages.Keys | Sort-Object)) { + [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) + $index++ + } + $hierarchy = '' + $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1) + [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8) + throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled." + } + $index = 0 + $payloads = @() + foreach ($id in @($pages.Keys | Sort-Object)) { + [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) + if ($Pdf) { + $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index) + $app.NavigateTo($id, '', $false) + $app.Publish($id, $pdfPath, 3, '') + if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' } + } + [xml]$page = $pages[$id] + foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) { + $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache')) + $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant() + [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes) + # A paragraph's file is named by its paragraph, one on the page by itself. + $owner = if ($file.ParentNode.LocalName -eq 'Page') { $file } else { $file.ParentNode } + $payloads += @{ page = $id; object = $owner.GetAttribute('objectID'); + kind = $file.LocalName; name = $file.GetAttribute('preferredName'); + sha256 = $hash; bytes = $bytes.Length } + } + $index++ + } + [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8) + $all = '' + $app.GetHierarchy($notebookId, 4, [ref]$all, 1) + [xml]$finalTree = $all + $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique) + if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) { + throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.' + } + [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8) + Write-Output "Read $($sections.Count) sections and $($pages.Count) pages." +} catch { + $failure = $_ + [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8) + throw +} finally { + try { + try { + if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) } + if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) { + $app.CloseNotebook($notebookId, $false) + } + } catch { + if ($null -eq $failure) { throw } + [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8) + } + } finally { + [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app) + } + $app = $null + [GC]::Collect() + [GC]::WaitForPendingFinalizers() + if (-not $UseCurrentCache -and -not $CloneHost) { + Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10 + } +} diff --git a/corpus/notebook-package/save-as/cold/source.json b/corpus/notebook-package/save-as/cold/source.json new file mode 100644 index 0000000000000000000000000000000000000000..9ae5036eb0219ce8d6a1e2988cb9135b4851bd85 --- /dev/null +++ b/corpus/notebook-package/save-as/cold/source.json @@ -0,0 +1,14 @@ +[ + { + "path": "Inner copy.one", + "bytes": 6032, + "sha256": "48f848eb6d0c1ec03a137763b8595b7b4cb7ee6e0f5f8bdf96e6cb5f5c23be1e", + "mtime_ns": 1790880013898068009 + }, + { + "path": "Inside.one", + "bytes": 6032, + "sha256": "0a311b978c401a5571be844dfd54cddb551d6820eac0b4d72ad590afb563bcb2", + "mtime_ns": 1790880013898269467 + } +] diff --git a/corpus/notebook-package/save-as/cold/teardown.json b/corpus/notebook-package/save-as/cold/teardown.json new file mode 100644 index 0000000000000000000000000000000000000000..05a47793de40f322e745c4c4183727e3590ecf70 --- /dev/null +++ b/corpus/notebook-package/save-as/cold/teardown.json @@ -0,0 +1 @@ +{"absent": true} diff --git a/corpus/protected-edit/README.md b/corpus/protected-edit/README.md index bc7dc43bc32e6c0817ecf781b64e2f6c3445e44a..c8654e8bfb8d0b26819ad27003f92f3813fca0ee 100644 --- a/corpus/protected-edit/README.md +++ b/corpus/protected-edit/README.md @@ -2,8 +2,8 @@ `candidate/notebook` is `native-encrypted/encrypted-01` after a Rust edit of the unlocked page appended text to the first paragraph and added a -paragraph, exported by `an_unlocked_page_is_saved_under_the_section_key` -(`ONESTORE_PROTECTED_EXPORT`). `candidate/read` is OneNote 2010's COM read from a +paragraph, exported by the protected writer that `Section::unlock` has since replaced +(`corpus/protected-sections` gates the current one). `candidate/read` is OneNote 2010's COM read from a fresh clone with an empty cache after unlocking the section in its UI with the fixture password (`../native-encrypted/manifest.json`). diff --git a/corpus/protected-sections/README.md b/corpus/protected-sections/README.md new file mode 100644 index 0000000000000000000000000000000000000000..fa07a838e25e7f73ee01460083fd58f9414a2200 --- /dev/null +++ b/corpus/protected-sections/README.md @@ -0,0 +1,58 @@ +# Password-protected sections + +OneNote 2010 (14.0.4763.1000, Windows 7 lab) setting, changing and removing a section's +password, and Snowbound doing the same to a notebook that a fresh OneNote then cold-opens. +Passwords are fictitious and public ([manifest.json](manifest.json), +`candidate/passwords.json`). + +## What OneNote does + +- **Set, change, remove.** Each writes the section anew: OneNote deletes the file and + creates one at the same path (a new NTFS file index, its creation time tunnelled), which + grows through some 30 transactions. Every identity is new: the header's file GUID, the root + object space, each page space (one fresh GUID for all of them, each its own `n`), and each payload's + file-data GUID. Each space keeps its labelled revisions, each a checkpoint: the current one + under roles 1 and 4, the version history under its context. The TOC gains an entry for + the new identity and keeps the old one (`native/*.one` from `source/synthetic.one`). +- **The crypto.** Office Agile encryption (MS-OFFCRYPTO 2.3.4.10) inside the + `ObjectDataEncryptionKeyV2FNDX` container (MS-ONESTORE 2.5.19): the words + `3, length, 16, length − 16`, version `4.4` with flags `0x40`, then UTF-8 XML with a CRLF + after the declaration and no data-integrity element. Key data and key encryptor are both + AES-128, CBC, SHA-1, 16-byte salts; `spinCount` 100000. H0 = SHA-1(salt ‖ UTF-16LE + password), Hn = SHA-1(LE32(n) ‖ Hn−1); each block key is SHA-1(H ‖ label)[..16] with the + password salt as IV. The verifier hash is padded with zeros to 32 bytes. A change of + password draws a new 16-byte content key. Property objects are their reference streams, a + length, a random IV and CBC of `u16 padding ‖ bytes ‖ random padding`; payloads are CBC, + IV = SHA-1(key-data salt ‖ LE32(0))[..16], of `u64 length ‖ bytes ‖ random padding`. + Read-only declarations hash the clear bytes zero-padded to 8. Every revision OneNote + writes anew names the key; its later dependent revisions do not. +- **Locking.** Locked, the section shows "This section is password protected. Click here or + press ENTER to unlock this section." with no page list; the Protected Section dialog says + "Password is incorrect." Options › Advanced › Passwords: lock after not being worked in for + 1, 5, 10 (default), 15 or 30 minutes, 1, 2, 4, 8 or 12 hours or 1 day (registry + `Options\Save\PasswordTimeout`, minutes; it takes effect after a restart); lock on + navigating away (off); let add-ins reach unlocked sections (on). With a minute set, a + section idle on screen locked between 72 and 132 seconds. Ctrl+Alt+L and Lock All lock + every section. Locking and unlocking write nothing. +- **Search and Tags Summary** leave locked sections out without saying so, and take them in + once unlocked. +- **Other writers.** With the section locked, OneNote wrote nothing while another writer + appended a revision, and showed it once unlocked; unlocked, it showed the next one live. + When the other writer gave the section a new password (a new file, as above), OneNote + showed it locked again, refused the old password and took the new one + (`native/observed/`). Observed on a local folder with one OneNote; two OneNotes on a share + were not tried. + +`native/snowbound-changed-then-onenote-edit.one` is a section Snowbound protected, edited and +gave another password, after OneNote unlocked it and typed into it. + +## Snowbound + +`candidate/notebook` comes from `a_notebook_protected_through_its_sessions` +(`crates/notebook/tests/protected.rs`, `ONESTORE_PROTECTED_EXPORT`): OneNote's pages imported +into five sections, each given a password through `Notebook::set_password`; `Edited` edited +through its session three times, `Changed` given another password and edited, `Removed` +unprotected, `Conflicted` edited offline by two replicas into conflict pages. +`tools/native_protected.py` cold-opened it in a fresh clone, unlocked each section in OneNote's +dialog, typed into `Edited` through COM and read every page (`cold/`, with the notebook +OneNote left). `tools/test_protected_sections.py` checks both directions without a VM. diff --git a/corpus/protected-sections/candidate/notebook/Changed.one b/corpus/protected-sections/candidate/notebook/Changed.one new file mode 100644 index 0000000000000000000000000000000000000000..6d931caba83f4f03b321ba14beb8c1c2909aacf4 Binary files /dev/null and b/corpus/protected-sections/candidate/notebook/Changed.one differ diff --git a/corpus/protected-sections/candidate/notebook/Conflicted.one b/corpus/protected-sections/candidate/notebook/Conflicted.one new file mode 100644 index 0000000000000000000000000000000000000000..186a518ad8d2e243e56a072554c2498c6beaf7d1 Binary files /dev/null and b/corpus/protected-sections/candidate/notebook/Conflicted.one differ diff --git a/corpus/protected-sections/candidate/notebook/Edited.one b/corpus/protected-sections/candidate/notebook/Edited.one new file mode 100644 index 0000000000000000000000000000000000000000..a93439a30b56d08fb291a3d87fe45125025a81da Binary files /dev/null and b/corpus/protected-sections/candidate/notebook/Edited.one differ diff --git a/corpus/protected-sections/candidate/notebook/New Section 1.one b/corpus/protected-sections/candidate/notebook/New Section 1.one new file mode 100644 index 0000000000000000000000000000000000000000..05204c03b5b30e42f3c05b9f40c69a5dcb816c16 Binary files /dev/null and b/corpus/protected-sections/candidate/notebook/New Section 1.one differ diff --git a/corpus/protected-sections/candidate/notebook/Open Notebook.onetoc2 b/corpus/protected-sections/candidate/notebook/Open Notebook.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..366a9a8c9fba591e258e7105b45834faee3fea3c Binary files /dev/null and b/corpus/protected-sections/candidate/notebook/Open Notebook.onetoc2 differ diff --git a/corpus/protected-sections/candidate/notebook/Protected.one b/corpus/protected-sections/candidate/notebook/Protected.one new file mode 100644 index 0000000000000000000000000000000000000000..ca9c73b53ff410c073f916c23c5006df8e3cf8f3 Binary files /dev/null and b/corpus/protected-sections/candidate/notebook/Protected.one differ diff --git a/corpus/protected-sections/candidate/notebook/Removed.one b/corpus/protected-sections/candidate/notebook/Removed.one new file mode 100644 index 0000000000000000000000000000000000000000..c242008e6c7b2acbb5353e7759bf70f5bc485aef Binary files /dev/null and b/corpus/protected-sections/candidate/notebook/Removed.one differ diff --git a/corpus/protected-sections/candidate/passwords.json b/corpus/protected-sections/candidate/passwords.json new file mode 100644 index 0000000000000000000000000000000000000000..d7b4248f39fb8e41d90fc6ee1b369a2f4921f020 --- /dev/null +++ b/corpus/protected-sections/candidate/passwords.json @@ -0,0 +1,7 @@ +{ + "Changed.one": "Changed again", + "Conflicted.one": "Conflicted gate password", + "Edited.one": "Edited gate password", + "Protected.one": "Protected gate password", + "Removed.one": null +} \ No newline at end of file diff --git a/corpus/protected-sections/cold/before-read/af1af4b8987109ce1e335e538910fb3860b7c57eb3b6110a06aa2114e06d69a7.attachment b/corpus/protected-sections/cold/before-read/af1af4b8987109ce1e335e538910fb3860b7c57eb3b6110a06aa2114e06d69a7.attachment new file mode 100644 index 0000000000000000000000000000000000000000..66b6151e91f90df6a61a19ef2f4447da278e07b2 --- /dev/null +++ b/corpus/protected-sections/cold/before-read/af1af4b8987109ce1e335e538910fb3860b7c57eb3b6110a06aa2114e06d69a7.attachment @@ -0,0 +1 @@ +Fictitious attachment for native corpus. \ No newline at end of file diff --git a/corpus/protected-sections/cold/before-read/environment.json b/corpus/protected-sections/cold/before-read/environment.json new file mode 100644 index 0000000000000000000000000000000000000000..551f12798f53d63c8dee8b77bba7346bd4fd285a --- /dev/null +++ b/corpus/protected-sections/cold/before-read/environment.json @@ -0,0 +1,7 @@ +{ + "powershell": "5.1.14409.1005", + "schema": "xs2010", + "hostname": "ONE-M6-7DD6464F", + "cold": true, + "onenote": "14.0.4763.1000" +} diff --git a/corpus/protected-sections/cold/before-read/hierarchy.xml b/corpus/protected-sections/cold/before-read/hierarchy.xml new file mode 100644 index 0000000000000000000000000000000000000000..c33dfb411d4c11635d78f15c70e8b25b00401b99 --- /dev/null +++ b/corpus/protected-sections/cold/before-read/hierarchy.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/protected-sections/cold/before-read/page-000.xml b/corpus/protected-sections/cold/before-read/page-000.xml new file mode 100644 index 0000000000000000000000000000000000000000..4edb2a1e82296acf21afc114a8a06b7f3c1785a6 --- /dev/null +++ b/corpus/protected-sections/cold/before-read/page-000.xml @@ -0,0 +1,10 @@ + +Fictitious: café, 東京, مرحبا]]>Fictitious: café, 東京, مرحبا]]>iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9J1uoA +AAAASUVORK5CYII= diff --git a/corpus/protected-sections/cold/before-read/page-001.xml b/corpus/protected-sections/cold/before-read/page-001.xml new file mode 100644 index 0000000000000000000000000000000000000000..b48c0ea70cc6d5f931a93ebe60e7104cd2fa7028 --- /dev/null +++ b/corpus/protected-sections/cold/before-read/page-001.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/protected-sections/cold/before-read/page-002.xml b/corpus/protected-sections/cold/before-read/page-002.xml new file mode 100644 index 0000000000000000000000000000000000000000..6470dfc5de69c0385d67bb816651d72b0b3d16d6 --- /dev/null +++ b/corpus/protected-sections/cold/before-read/page-002.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/protected-sections/cold/before-read/page-003.xml b/corpus/protected-sections/cold/before-read/page-003.xml new file mode 100644 index 0000000000000000000000000000000000000000..0d5b215f47b0c8bdb032b7406b1d5f178470ca0f --- /dev/null +++ b/corpus/protected-sections/cold/before-read/page-003.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/protected-sections/cold/before-read/payloads.json b/corpus/protected-sections/cold/before-read/payloads.json new file mode 100644 index 0000000000000000000000000000000000000000..70b778b0185ab794547555041fcd026dfd67a078 --- /dev/null +++ b/corpus/protected-sections/cold/before-read/payloads.json @@ -0,0 +1,10 @@ +[ + { + "sha256": "af1af4b8987109ce1e335e538910fb3860b7c57eb3b6110a06aa2114e06d69a7", + "name": "fictitious-attachment.txt", + "object": "{5E6C34E5-427A-0A35-01B4-729CAEED1AE3}{130}{B0}", + "kind": "InsertedFile", + "page": "{49198084-72D5-0478-36CF-554C2E384795}{1}{B0}", + "bytes": 43 + } +] \ No newline at end of file diff --git a/corpus/protected-sections/cold/commands.jsonl b/corpus/protected-sections/cold/commands.jsonl new file mode 100644 index 0000000000000000000000000000000000000000..de898046ca903ea553b8d5b10796470e19875d1c --- /dev/null +++ b/corpus/protected-sections/cold/commands.jsonl @@ -0,0 +1,11 @@ +{"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} +{"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} +{"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} +{"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} +{"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} +{"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} +{"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} +{"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} +{"command": "powershell -NoProfile -Command \"Rename-Item C:\\one-tests\\runs\\capture\\read before-read\"", "exit": 0, "stdout": "", "stderr": "", "error": null} +{"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} +{"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} diff --git a/corpus/protected-sections/cold/machine.json b/corpus/protected-sections/cold/machine.json new file mode 100644 index 0000000000000000000000000000000000000000..b8ec87fc4343bda03890e63230b5a8272dfb8be3 --- /dev/null +++ b/corpus/protected-sections/cold/machine.json @@ -0,0 +1 @@ +{"name": "m6-7dd6464f", "hostname": "ONE-M6-7DD6464F"} diff --git a/corpus/protected-sections/cold/notebook/Changed.one b/corpus/protected-sections/cold/notebook/Changed.one new file mode 100644 index 0000000000000000000000000000000000000000..f0916f2240991e0372537c4895f3f2b3e6e5bf14 Binary files /dev/null and b/corpus/protected-sections/cold/notebook/Changed.one differ diff --git a/corpus/protected-sections/cold/notebook/Conflicted.one b/corpus/protected-sections/cold/notebook/Conflicted.one new file mode 100644 index 0000000000000000000000000000000000000000..fdf142a1fe8c0214ad6f52c241c146908cb686dd Binary files /dev/null and b/corpus/protected-sections/cold/notebook/Conflicted.one differ diff --git a/corpus/protected-sections/cold/notebook/Edited.one b/corpus/protected-sections/cold/notebook/Edited.one new file mode 100644 index 0000000000000000000000000000000000000000..b784b2c0116015c0b27c51b592fed0841c31c4c1 Binary files /dev/null and b/corpus/protected-sections/cold/notebook/Edited.one differ diff --git a/corpus/protected-sections/cold/notebook/New Section 1.one b/corpus/protected-sections/cold/notebook/New Section 1.one new file mode 100644 index 0000000000000000000000000000000000000000..655f64b568d3bcb0f559b73879db232f0fcc558a Binary files /dev/null and b/corpus/protected-sections/cold/notebook/New Section 1.one differ diff --git a/corpus/protected-sections/cold/notebook/Open Notebook.onetoc2 b/corpus/protected-sections/cold/notebook/Open Notebook.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..366a9a8c9fba591e258e7105b45834faee3fea3c Binary files /dev/null and b/corpus/protected-sections/cold/notebook/Open Notebook.onetoc2 differ diff --git a/corpus/protected-sections/cold/notebook/Protected.one b/corpus/protected-sections/cold/notebook/Protected.one new file mode 100644 index 0000000000000000000000000000000000000000..4a21d2ae99c8ea1efd03d14d0e56b4f39d061a67 Binary files /dev/null and b/corpus/protected-sections/cold/notebook/Protected.one differ diff --git a/corpus/protected-sections/cold/notebook/Removed.one b/corpus/protected-sections/cold/notebook/Removed.one new file mode 100644 index 0000000000000000000000000000000000000000..d1398ca440a4bafb301aea279e3da51801712468 Binary files /dev/null and b/corpus/protected-sections/cold/notebook/Removed.one differ diff --git a/corpus/protected-sections/cold/read/af1af4b8987109ce1e335e538910fb3860b7c57eb3b6110a06aa2114e06d69a7.attachment b/corpus/protected-sections/cold/read/af1af4b8987109ce1e335e538910fb3860b7c57eb3b6110a06aa2114e06d69a7.attachment new file mode 100644 index 0000000000000000000000000000000000000000..66b6151e91f90df6a61a19ef2f4447da278e07b2 --- /dev/null +++ b/corpus/protected-sections/cold/read/af1af4b8987109ce1e335e538910fb3860b7c57eb3b6110a06aa2114e06d69a7.attachment @@ -0,0 +1 @@ +Fictitious attachment for native corpus. \ No newline at end of file diff --git a/corpus/protected-sections/cold/read/environment.json b/corpus/protected-sections/cold/read/environment.json new file mode 100644 index 0000000000000000000000000000000000000000..e814f07183d381a6ce561424c1b3c5ea53cf903c --- /dev/null +++ b/corpus/protected-sections/cold/read/environment.json @@ -0,0 +1,7 @@ +{ + "powershell": "5.1.14409.1005", + "schema": "xs2010", + "hostname": "ONE-M6-7DD6464F", + "cold": false, + "onenote": "14.0.4763.1000" +} diff --git a/corpus/protected-sections/cold/read/hierarchy.xml b/corpus/protected-sections/cold/read/hierarchy.xml new file mode 100644 index 0000000000000000000000000000000000000000..ff4efd05454ff136f37e37b5e7dd7aef80b85aeb --- /dev/null +++ b/corpus/protected-sections/cold/read/hierarchy.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/protected-sections/cold/read/page-000.xml b/corpus/protected-sections/cold/read/page-000.xml new file mode 100644 index 0000000000000000000000000000000000000000..33865a2109f8270aa86a1e9bb233753f1c5e3860 --- /dev/null +++ b/corpus/protected-sections/cold/read/page-000.xml @@ -0,0 +1,13 @@ + +Fictitious: café, 東京, مرحبا]]>Fictitious: café, 東京, مرحبا Edited first under the key. Edited second under the key. Edited third under the key.]]>iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9J1uoA +AAAASUVORK5CYII= diff --git a/corpus/protected-sections/cold/read/page-001.xml b/corpus/protected-sections/cold/read/page-001.xml new file mode 100644 index 0000000000000000000000000000000000000000..377e978c02254e6814bcdc4567b5caacb49fcaad --- /dev/null +++ b/corpus/protected-sections/cold/read/page-001.xml @@ -0,0 +1,11 @@ + +Fictitious: café, 東京, مرحبا]]>Fictitious: café, 東京, مرحبا Ours offline.]]>iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9J1uoA +AAAASUVORK5CYII= diff --git a/corpus/protected-sections/cold/read/page-002.xml b/corpus/protected-sections/cold/read/page-002.xml new file mode 100644 index 0000000000000000000000000000000000000000..0df0abd1a73f2383e458fc6e3f01e5e43cb48a58 --- /dev/null +++ b/corpus/protected-sections/cold/read/page-002.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/protected-sections/cold/read/page-003.xml b/corpus/protected-sections/cold/read/page-003.xml new file mode 100644 index 0000000000000000000000000000000000000000..de17b9702cf9c2d434cded22f90cca6f73aae385 --- /dev/null +++ b/corpus/protected-sections/cold/read/page-003.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/protected-sections/cold/read/page-004.xml b/corpus/protected-sections/cold/read/page-004.xml new file mode 100644 index 0000000000000000000000000000000000000000..4aafdd9692a2124452dc46ecc1a28d6da0b536dc --- /dev/null +++ b/corpus/protected-sections/cold/read/page-004.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/protected-sections/cold/read/page-005.xml b/corpus/protected-sections/cold/read/page-005.xml new file mode 100644 index 0000000000000000000000000000000000000000..4edb2a1e82296acf21afc114a8a06b7f3c1785a6 --- /dev/null +++ b/corpus/protected-sections/cold/read/page-005.xml @@ -0,0 +1,10 @@ + +Fictitious: café, 東京, مرحبا]]>Fictitious: café, 東京, مرحبا]]>iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9J1uoA +AAAASUVORK5CYII= diff --git a/corpus/protected-sections/cold/read/page-006.xml b/corpus/protected-sections/cold/read/page-006.xml new file mode 100644 index 0000000000000000000000000000000000000000..773a662c916bdc25f9a7b10f930ed585b99019a0 --- /dev/null +++ b/corpus/protected-sections/cold/read/page-006.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/protected-sections/cold/read/page-007.xml b/corpus/protected-sections/cold/read/page-007.xml new file mode 100644 index 0000000000000000000000000000000000000000..c6798d1e5508b5142ace75414442af5c6404aa84 --- /dev/null +++ b/corpus/protected-sections/cold/read/page-007.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/protected-sections/cold/read/page-008.xml b/corpus/protected-sections/cold/read/page-008.xml new file mode 100644 index 0000000000000000000000000000000000000000..6470dfc5de69c0385d67bb816651d72b0b3d16d6 --- /dev/null +++ b/corpus/protected-sections/cold/read/page-008.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/protected-sections/cold/read/page-009.xml b/corpus/protected-sections/cold/read/page-009.xml new file mode 100644 index 0000000000000000000000000000000000000000..34aabc0fa1da6d33eaa0f5661644acd4be4d32ae --- /dev/null +++ b/corpus/protected-sections/cold/read/page-009.xml @@ -0,0 +1,10 @@ + +Fictitious: café, 東京, مرحبا]]>Fictitious: café, 東京, مرحبا]]>iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9J1uoA +AAAASUVORK5CYII= diff --git a/corpus/protected-sections/cold/read/page-010.xml b/corpus/protected-sections/cold/read/page-010.xml new file mode 100644 index 0000000000000000000000000000000000000000..50f08e24db102bc667da0d6b0f15fc933c67fff3 --- /dev/null +++ b/corpus/protected-sections/cold/read/page-010.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/protected-sections/cold/read/page-011.xml b/corpus/protected-sections/cold/read/page-011.xml new file mode 100644 index 0000000000000000000000000000000000000000..7ddd7db7dee4b3cf9b721a08d1b25865da7ce0dd --- /dev/null +++ b/corpus/protected-sections/cold/read/page-011.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/protected-sections/cold/read/page-012.xml b/corpus/protected-sections/cold/read/page-012.xml new file mode 100644 index 0000000000000000000000000000000000000000..7302a631344e10b0ea24914c986d981f080d1532 --- /dev/null +++ b/corpus/protected-sections/cold/read/page-012.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/protected-sections/cold/read/page-013.xml b/corpus/protected-sections/cold/read/page-013.xml new file mode 100644 index 0000000000000000000000000000000000000000..c1f7ab7de96d187d9dfbbe40c770aed00eece3eb --- /dev/null +++ b/corpus/protected-sections/cold/read/page-013.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/protected-sections/cold/read/page-014.xml b/corpus/protected-sections/cold/read/page-014.xml new file mode 100644 index 0000000000000000000000000000000000000000..0d5b215f47b0c8bdb032b7406b1d5f178470ca0f --- /dev/null +++ b/corpus/protected-sections/cold/read/page-014.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/protected-sections/cold/read/page-015.xml b/corpus/protected-sections/cold/read/page-015.xml new file mode 100644 index 0000000000000000000000000000000000000000..8eae34fc39bd93d5623ec87687aeec2d048e1040 --- /dev/null +++ b/corpus/protected-sections/cold/read/page-015.xml @@ -0,0 +1,11 @@ + +Fictitious: café, 東京, مرحبا]]>Fictitious: café, 東京, مرحبا Written under the changed password.]]>iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9J1uoA +AAAASUVORK5CYII= diff --git a/corpus/protected-sections/cold/read/payloads.json b/corpus/protected-sections/cold/read/payloads.json new file mode 100644 index 0000000000000000000000000000000000000000..fb5e20eb9784d078910f438033c99a5e9be5b2e6 --- /dev/null +++ b/corpus/protected-sections/cold/read/payloads.json @@ -0,0 +1,42 @@ +[ + { + "sha256": "af1af4b8987109ce1e335e538910fb3860b7c57eb3b6110a06aa2114e06d69a7", + "name": "fictitious-attachment.txt", + "object": "{494711AC-8C21-0B35-201D-C281CCD3535F}{52}{B0}", + "kind": "InsertedFile", + "page": "{09B82703-E16E-0E2C-0350-7B8F0E453BD8}{1}{B0}", + "bytes": 43 + }, + { + "sha256": "af1af4b8987109ce1e335e538910fb3860b7c57eb3b6110a06aa2114e06d69a7", + "name": "fictitious-attachment.txt", + "object": "{9295EB43-6BB7-01D8-3BCB-B972DFF40F79}{169}{B0}", + "kind": "InsertedFile", + "page": "{0A6963A0-3B14-022B-3322-0716C2D41936}{1}{B0}", + "bytes": 43 + }, + { + "sha256": "af1af4b8987109ce1e335e538910fb3860b7c57eb3b6110a06aa2114e06d69a7", + "name": "fictitious-attachment.txt", + "object": "{5E6C34E5-427A-0A35-01B4-729CAEED1AE3}{130}{B0}", + "kind": "InsertedFile", + "page": "{49198084-72D5-0478-36CF-554C2E384795}{1}{B0}", + "bytes": 43 + }, + { + "sha256": "af1af4b8987109ce1e335e538910fb3860b7c57eb3b6110a06aa2114e06d69a7", + "name": "fictitious-attachment.txt", + "object": "{2258725C-FAAF-0704-1006-EE69B8759811}{13}{B0}", + "kind": "InsertedFile", + "page": "{866584A2-177A-0F98-3D57-EE8DE1960AF4}{1}{B0}", + "bytes": 43 + }, + { + "sha256": "af1af4b8987109ce1e335e538910fb3860b7c57eb3b6110a06aa2114e06d69a7", + "name": "fictitious-attachment.txt", + "object": "{9E1DBD46-BE03-07FB-16F8-D5BB1289206E}{91}{B0}", + "kind": "InsertedFile", + "page": "{FD77E918-CDA0-0CA5-1038-F63ED915C073}{1}{B0}", + "bytes": 43 + } +] \ No newline at end of file diff --git a/corpus/protected-sections/cold/run.json b/corpus/protected-sections/cold/run.json new file mode 100644 index 0000000000000000000000000000000000000000..78306f525e67a294a9894525779b08b840d2d0c5 --- /dev/null +++ b/corpus/protected-sections/cold/run.json @@ -0,0 +1,18 @@ +{ + "notebook": "/private/tmp/snowbound-locked/gate/notebook", + "expected_pages": -1, + "author": null, + "author_timeout_seconds": 600, + "inspect": false, + "collect_notebook": true, + "base": { + "file": "win7-office-base.qcow2", + "format": "qcow2", + "sha256": "a1a4f8fab782ee14885ff801ca2f6347c208fdcfc3513637096f314315c89346", + "virtual_size": 68719476736 + }, + "scripts": { + "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331", + "read.ps1": "22bc760f955ef59bb5f0ccfd55271f8d868674084127fbe219e1048bd3777f35" + } +} diff --git a/corpus/protected-sections/cold/scripts/cold.ps1 b/corpus/protected-sections/cold/scripts/cold.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..a0b99e7aeb86f4dc10032537800501a52ad65b4f --- /dev/null +++ b/corpus/protected-sections/cold/scripts/cold.ps1 @@ -0,0 +1,27 @@ +param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '') +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$root = [IO.Path]::GetFullPath($Root).TrimEnd('\') +if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { + throw 'Choose a run directly below C:\one-tests\runs.' +} +if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' } +$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote' +if ($CloneHost) { + if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) { + throw 'The disposable clone hostname does not match this machine.' + } + New-Item "$key\Options\Paths" -Force | Out-Null + New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null +} elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or + -not (Test-Path 'C:\one-tests\profile-original-cache')) { + throw 'Park the personal OneNote profile before resetting the test cache.' +} +$cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0' +$parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root)) +if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' } +New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null +if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked } +if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse } +New-Item "$key\OpenNotebooks" | Out-Null +New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null diff --git a/corpus/protected-sections/cold/scripts/read.ps1 b/corpus/protected-sections/cold/scripts/read.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..a42366b32e8baaf13dae92e9472cae7a071220cc --- /dev/null +++ b/corpus/protected-sections/cold/scripts/read.ps1 @@ -0,0 +1,144 @@ +param( + [Parameter(Mandatory=$true)][string]$Root, + [int]$ExpectedPages = -1, + [switch]$UseCurrentCache, + [switch]$Pdf, + [switch]$KeepOpen, + [string]$CloneHost = '' +) +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$root = [IO.Path]::GetFullPath($Root).TrimEnd('\') +if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { + throw 'Choose a run directly below C:\one-tests\runs.' +} +$notebook = Join-Path $root 'notebook' +$output = Join-Path $root 'read' +if (Test-Path $output) { throw 'Choose a new read destination.' } +if ($UseCurrentCache) { + if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') { + throw 'Park the personal OneNote profile before reading test notebooks.' + } +} else { + & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost +} +New-Item -ItemType Directory -Path $output | Out-Null +$app = New-Object -ComObject OneNote.Application +$notebookId = '' +$failure = $null +try { + $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0) + $process = Get-Process ONENOTE + @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion; + powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } | + ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8 + $sections = @() + foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) { + $id = '' + $app.OpenHierarchy($file.FullName, '', [ref]$id, 0) + $sections += $id + } + $deadline = [DateTime]::UtcNow.AddSeconds(300) + $previous = '' + $lastChange = '' + $stableSince = [DateTime]::UtcNow + $settled = $false + do { + $pages = @{} + foreach ($section in $sections) { + $hierarchy = '' + $app.GetHierarchy($section, 4, [ref]$hierarchy, 1) + [xml]$xml = $hierarchy + foreach ($node in $xml.SelectNodes('//*[@path]')) { + if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) { + throw 'OneNote opened a section outside the copied notebook.' + } + } + foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) { + $id = $node.GetAttribute('ID') + $content = '' + $app.GetPageContent($id, [ref]$content, 1, 1) + $pages[$id] = $content + } + } + $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] })) + if ($signature -ne $previous) { + $lastChange = $previous + $previous = $signature + $stableSince = [DateTime]::UtcNow + } + if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or + ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and + ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break } + Start-Sleep -Milliseconds 250 + } while ([DateTime]::UtcNow -lt $deadline) + if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) { + [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8) + $index = 0 + foreach ($id in @($pages.Keys | Sort-Object)) { + [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) + $index++ + } + $hierarchy = '' + $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1) + [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8) + throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled." + } + $index = 0 + $payloads = @() + foreach ($id in @($pages.Keys | Sort-Object)) { + [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) + if ($Pdf) { + $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index) + $app.NavigateTo($id, '', $false) + $app.Publish($id, $pdfPath, 3, '') + if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' } + } + [xml]$page = $pages[$id] + foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) { + $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache')) + $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant() + [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes) + # A paragraph's file is named by its paragraph, one on the page by itself. + $owner = if ($file.ParentNode.LocalName -eq 'Page') { $file } else { $file.ParentNode } + $payloads += @{ page = $id; object = $owner.GetAttribute('objectID'); + kind = $file.LocalName; name = $file.GetAttribute('preferredName'); + sha256 = $hash; bytes = $bytes.Length } + } + $index++ + } + [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8) + $all = '' + $app.GetHierarchy($notebookId, 4, [ref]$all, 1) + [xml]$finalTree = $all + $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique) + if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) { + throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.' + } + [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8) + Write-Output "Read $($sections.Count) sections and $($pages.Count) pages." +} catch { + $failure = $_ + [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8) + throw +} finally { + try { + try { + if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) } + if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) { + $app.CloseNotebook($notebookId, $false) + } + } catch { + if ($null -eq $failure) { throw } + [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8) + } + } finally { + [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app) + } + $app = $null + [GC]::Collect() + [GC]::WaitForPendingFinalizers() + if (-not $UseCurrentCache -and -not $CloneHost) { + Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10 + } +} diff --git a/corpus/protected-sections/cold/source.json b/corpus/protected-sections/cold/source.json new file mode 100644 index 0000000000000000000000000000000000000000..e84cdea9f0e652f3a059cf57a036ec8d12f441c3 --- /dev/null +++ b/corpus/protected-sections/cold/source.json @@ -0,0 +1,44 @@ +[ + { + "path": "Changed.one", + "bytes": 26248, + "sha256": "c6dfb825da16bd399d9c08e59b9fce17ce6d31000ac9dcb35fb88348abcc25f6", + "mtime_ns": 1790841675948190811 + }, + { + "path": "Conflicted.one", + "bytes": 44384, + "sha256": "53bf55828c7d7b3e27812bb8a37a58826019d363982b460eb20b99038f077e2e", + "mtime_ns": 1790841676132162946 + }, + { + "path": "Edited.one", + "bytes": 28952, + "sha256": "36fa95d9944b91cc702b2834a8938fdf4db03cb4cc4eea59ad4118cc0045fe1a", + "mtime_ns": 1790841675847211065 + }, + { + "path": "New Section 1.one", + "bytes": 6064, + "sha256": "7c59a07e40a6162d9392b069dfec88822b0ba540e671ed1199582e89c958013b", + "mtime_ns": 1790841674768801766 + }, + { + "path": "Open Notebook.onetoc2", + "bytes": 8520, + "sha256": "4308d8a6273eb58cd0089cbc056d4cdb5f5767e8f61a1173bbb17a8fb853e6ed", + "mtime_ns": 1790841675974244740 + }, + { + "path": "Protected.one", + "bytes": 22976, + "sha256": "d1ec539f23bd1b7355c3e9891735fc23a3f27b65d304c6cfd19eed18f0fae857", + "mtime_ns": 1790841675582959551 + }, + { + "path": "Removed.one", + "bytes": 19656, + "sha256": "4db34bfd031431a02909183e75c0f836eb10e43aef0842434b44ec801735a232", + "mtime_ns": 1790841675961507402 + } +] diff --git a/corpus/protected-sections/cold/teardown.json b/corpus/protected-sections/cold/teardown.json new file mode 100644 index 0000000000000000000000000000000000000000..05a47793de40f322e745c4c4183727e3590ecf70 --- /dev/null +++ b/corpus/protected-sections/cold/teardown.json @@ -0,0 +1 @@ +{"absent": true} diff --git a/corpus/protected-sections/cold/unlock-Changed.json b/corpus/protected-sections/cold/unlock-Changed.json new file mode 100644 index 0000000000000000000000000000000000000000..6930ab11f870f1a0ba74d202bcebf21a2e8c4544 --- /dev/null +++ b/corpus/protected-sections/cold/unlock-Changed.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "Gate - Microsoft OneNote", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "Gate - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/protected-sections/cold/unlock-Changed.png b/corpus/protected-sections/cold/unlock-Changed.png new file mode 100644 index 0000000000000000000000000000000000000000..97ca2c6bee8c0006551b0c7671bc4f74b5cebf4a Binary files /dev/null and b/corpus/protected-sections/cold/unlock-Changed.png differ diff --git a/corpus/protected-sections/cold/unlock-Conflicted.json b/corpus/protected-sections/cold/unlock-Conflicted.json new file mode 100644 index 0000000000000000000000000000000000000000..6930ab11f870f1a0ba74d202bcebf21a2e8c4544 --- /dev/null +++ b/corpus/protected-sections/cold/unlock-Conflicted.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "Gate - Microsoft OneNote", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "Gate - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/protected-sections/cold/unlock-Conflicted.png b/corpus/protected-sections/cold/unlock-Conflicted.png new file mode 100644 index 0000000000000000000000000000000000000000..e9a43036a0619eaa99fdf939bd03596f4b58550a Binary files /dev/null and b/corpus/protected-sections/cold/unlock-Conflicted.png differ diff --git a/corpus/protected-sections/cold/unlock-Edited.json b/corpus/protected-sections/cold/unlock-Edited.json new file mode 100644 index 0000000000000000000000000000000000000000..6930ab11f870f1a0ba74d202bcebf21a2e8c4544 --- /dev/null +++ b/corpus/protected-sections/cold/unlock-Edited.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "Gate - Microsoft OneNote", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "Gate - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/protected-sections/cold/unlock-Edited.png b/corpus/protected-sections/cold/unlock-Edited.png new file mode 100644 index 0000000000000000000000000000000000000000..72f01c161fef41bc0975a96d60635fba1cee7502 Binary files /dev/null and b/corpus/protected-sections/cold/unlock-Edited.png differ diff --git a/corpus/protected-sections/cold/unlock-Protected.json b/corpus/protected-sections/cold/unlock-Protected.json new file mode 100644 index 0000000000000000000000000000000000000000..6930ab11f870f1a0ba74d202bcebf21a2e8c4544 --- /dev/null +++ b/corpus/protected-sections/cold/unlock-Protected.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "Gate - Microsoft OneNote", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "Gate - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/protected-sections/cold/unlock-Protected.png b/corpus/protected-sections/cold/unlock-Protected.png new file mode 100644 index 0000000000000000000000000000000000000000..9ee9de93475855341d4d219bfa92d6287da10b25 Binary files /dev/null and b/corpus/protected-sections/cold/unlock-Protected.png differ diff --git a/corpus/protected-sections/manifest.json b/corpus/protected-sections/manifest.json new file mode 100644 index 0000000000000000000000000000000000000000..ceeb44812541948e21384b6edcd23f6608ef3802 --- /dev/null +++ b/corpus/protected-sections/manifest.json @@ -0,0 +1,10 @@ +{ + "native_build": "14.0.4763.1000", + "source": "source/synthetic.one", + "native": { + "protected.one": "lab-pass-1", + "changed.one": "lab-pass-2", + "removed.one": null, + "snowbound-changed-then-onenote-edit.one": "snow-pass-2" + } +} diff --git a/corpus/protected-sections/native/changed.one b/corpus/protected-sections/native/changed.one new file mode 100644 index 0000000000000000000000000000000000000000..587c5ac9b64b855511f9d24cbe1c8092dd78fdc6 Binary files /dev/null and b/corpus/protected-sections/native/changed.one differ diff --git a/corpus/protected-sections/native/observed/onenote-change-password.png b/corpus/protected-sections/native/observed/onenote-change-password.png new file mode 100644 index 0000000000000000000000000000000000000000..e67e0ecce606f795b3172d35631a3d3ab0120fd6 Binary files /dev/null and b/corpus/protected-sections/native/observed/onenote-change-password.png differ diff --git a/corpus/protected-sections/native/observed/onenote-locked-page.png b/corpus/protected-sections/native/observed/onenote-locked-page.png new file mode 100644 index 0000000000000000000000000000000000000000..ce9ee07d3b7dc5e9e03c9bfdd1b309e3d7dd30f0 Binary files /dev/null and b/corpus/protected-sections/native/observed/onenote-locked-page.png differ diff --git a/corpus/protected-sections/native/observed/onenote-options-lock-after.png b/corpus/protected-sections/native/observed/onenote-options-lock-after.png new file mode 100644 index 0000000000000000000000000000000000000000..ec2b31b3844e116d65ba10430f824079c2a60ea2 Binary files /dev/null and b/corpus/protected-sections/native/observed/onenote-options-lock-after.png differ diff --git a/corpus/protected-sections/native/observed/onenote-options-passwords.png b/corpus/protected-sections/native/observed/onenote-options-passwords.png new file mode 100644 index 0000000000000000000000000000000000000000..5ea870fa68e12bb33b754b3812f1d2f50e6aa5fa Binary files /dev/null and b/corpus/protected-sections/native/observed/onenote-options-passwords.png differ diff --git a/corpus/protected-sections/native/observed/onenote-pane-protected.png b/corpus/protected-sections/native/observed/onenote-pane-protected.png new file mode 100644 index 0000000000000000000000000000000000000000..c2d13af7b2640f33dc1b1a3601a13470afb88646 Binary files /dev/null and b/corpus/protected-sections/native/observed/onenote-pane-protected.png differ diff --git a/corpus/protected-sections/native/observed/onenote-relocks-after-remote-password-change.png b/corpus/protected-sections/native/observed/onenote-relocks-after-remote-password-change.png new file mode 100644 index 0000000000000000000000000000000000000000..6229bf91f19891ea23ddff860cde98c04a6dec72 Binary files /dev/null and b/corpus/protected-sections/native/observed/onenote-relocks-after-remote-password-change.png differ diff --git a/corpus/protected-sections/native/observed/onenote-remove-password.png b/corpus/protected-sections/native/observed/onenote-remove-password.png new file mode 100644 index 0000000000000000000000000000000000000000..72568e349434b52548b7b8ec710544195255d5f7 Binary files /dev/null and b/corpus/protected-sections/native/observed/onenote-remove-password.png differ diff --git a/corpus/protected-sections/native/observed/onenote-search-locked.png b/corpus/protected-sections/native/observed/onenote-search-locked.png new file mode 100644 index 0000000000000000000000000000000000000000..a419bcc59ee5b88a18f19805110a95f63ed45304 Binary files /dev/null and b/corpus/protected-sections/native/observed/onenote-search-locked.png differ diff --git a/corpus/protected-sections/native/observed/onenote-set-password.png b/corpus/protected-sections/native/observed/onenote-set-password.png new file mode 100644 index 0000000000000000000000000000000000000000..28477a4642a6a7240092b715b764cc206e8958b1 Binary files /dev/null and b/corpus/protected-sections/native/observed/onenote-set-password.png differ diff --git a/corpus/protected-sections/native/observed/onenote-tags-locked.png b/corpus/protected-sections/native/observed/onenote-tags-locked.png new file mode 100644 index 0000000000000000000000000000000000000000..0e3b03e6d5c78b7d569aee4c24defa29fd273813 Binary files /dev/null and b/corpus/protected-sections/native/observed/onenote-tags-locked.png differ diff --git a/corpus/protected-sections/native/observed/onenote-tags-unlocked.png b/corpus/protected-sections/native/observed/onenote-tags-unlocked.png new file mode 100644 index 0000000000000000000000000000000000000000..832f7b774ee9bc7b8e167ce5eea4879201dd9331 Binary files /dev/null and b/corpus/protected-sections/native/observed/onenote-tags-unlocked.png differ diff --git a/corpus/protected-sections/native/observed/onenote-unlock-dialog.png b/corpus/protected-sections/native/observed/onenote-unlock-dialog.png new file mode 100644 index 0000000000000000000000000000000000000000..491f9dedee4c0cf8e1f9468d84718f0202fdfd76 Binary files /dev/null and b/corpus/protected-sections/native/observed/onenote-unlock-dialog.png differ diff --git a/corpus/protected-sections/native/observed/onenote-unlock-wrong.png b/corpus/protected-sections/native/observed/onenote-unlock-wrong.png new file mode 100644 index 0000000000000000000000000000000000000000..7becd2e40ffa257161d4ad4e940fa96958a2eb3f Binary files /dev/null and b/corpus/protected-sections/native/observed/onenote-unlock-wrong.png differ diff --git a/corpus/protected-sections/native/observed/onenote-unlocked-shows-locked-append.png b/corpus/protected-sections/native/observed/onenote-unlocked-shows-locked-append.png new file mode 100644 index 0000000000000000000000000000000000000000..05abf6355d52ccea882ce0dafa91b8cca1a13d61 Binary files /dev/null and b/corpus/protected-sections/native/observed/onenote-unlocked-shows-locked-append.png differ diff --git a/corpus/protected-sections/native/protected.one b/corpus/protected-sections/native/protected.one new file mode 100644 index 0000000000000000000000000000000000000000..b91ec65c89a34abc3a6a95ca493a219d4a388b83 Binary files /dev/null and b/corpus/protected-sections/native/protected.one differ diff --git a/corpus/protected-sections/native/removed.one b/corpus/protected-sections/native/removed.one new file mode 100644 index 0000000000000000000000000000000000000000..523ac428f79dae71d0ec4f2753f675e277caa4fa Binary files /dev/null and b/corpus/protected-sections/native/removed.one differ diff --git a/corpus/protected-sections/native/snowbound-changed-then-onenote-edit.one b/corpus/protected-sections/native/snowbound-changed-then-onenote-edit.one new file mode 100644 index 0000000000000000000000000000000000000000..0bcc091d584526924d31c1cc5210e7a1e9bd1ae2 Binary files /dev/null and b/corpus/protected-sections/native/snowbound-changed-then-onenote-edit.one differ diff --git a/corpus/protected-sections/source/synthetic.one b/corpus/protected-sections/source/synthetic.one new file mode 100644 index 0000000000000000000000000000000000000000..078f35f2c97a208e9ea6f29d5505e89d82883953 Binary files /dev/null and b/corpus/protected-sections/source/synthetic.one differ diff --git a/corpus/recycle-bin-view/README.md b/corpus/recycle-bin-view/README.md new file mode 100644 index 0000000000000000000000000000000000000000..1c755855b92e0c33ae381dd66fb30e0c54bb2109 --- /dev/null +++ b/corpus/recycle-bin-view/README.md @@ -0,0 +1,19 @@ +# Recycle bin view + +OneNote 2010's Notebook Recycle Bin (Share, observed in a lab clone on 2026-10-01): the bin +opens as its own row of tabs, Deleted Pages then each deleted section, headed `Recycle Bin for +"Notebook":`, read-only under a bar ("To restore a page or section, right-click and move it out +of this Recycle Bin. Content here is deleted after 60 days."). A page's Move or Copy takes it to +any section; a section's moves it into the notebook or a group, its file moving there. Empty +Recycle Bin asks "Are you sure you want to empty the Recycle Bin for this notebook?", deletes +Deleted Pages' pages in one revision and each binned section's file, and leaves the bin's TOC +byte for byte, its entries naming files no longer there. + +`candidate/before` is the notebook +`pages_and_sections_leave_the_recycle_bin_as_onenote_restores_them` in +`crates/snowbound/src/recycle.rs` exports with `SNOWBOUND_RECYCLE_EXPORT`: three pages and five +sections deleted into the bin. `candidate/after` is it once Snowbound restored a page (its +identity kept), copied one out (a new identity), deleted one for good, restored a section to the +notebook and emptied the bin. `cold/before` and `cold/after` are fresh OneNote 2010 reads of +each: everything where it was meant to be, every file left as written. +`tools/test_recycle_bin_view.py` checks them without a VM. diff --git a/corpus/recycle-bin-view/candidate/after/New Section 1.one b/corpus/recycle-bin-view/candidate/after/New Section 1.one new file mode 100644 index 0000000000000000000000000000000000000000..a4ff145d5caed0d157de43c5a45ebeb36e19f738 Binary files /dev/null and b/corpus/recycle-bin-view/candidate/after/New Section 1.one differ diff --git a/corpus/recycle-bin-view/candidate/after/OneNote_RecycleBin/OneNote_DeletedPages.one b/corpus/recycle-bin-view/candidate/after/OneNote_RecycleBin/OneNote_DeletedPages.one new file mode 100644 index 0000000000000000000000000000000000000000..c9ad8fe1c0ff740dca1c8c948d03f277a54a118b Binary files /dev/null and b/corpus/recycle-bin-view/candidate/after/OneNote_RecycleBin/OneNote_DeletedPages.one differ diff --git a/corpus/recycle-bin-view/candidate/after/OneNote_RecycleBin/Open Notebook.onetoc2 b/corpus/recycle-bin-view/candidate/after/OneNote_RecycleBin/Open Notebook.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..cc7642d044a9d41989e523aa612020a3fe187d04 Binary files /dev/null and b/corpus/recycle-bin-view/candidate/after/OneNote_RecycleBin/Open Notebook.onetoc2 differ diff --git a/corpus/recycle-bin-view/candidate/after/Open Notebook.onetoc2 b/corpus/recycle-bin-view/candidate/after/Open Notebook.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..d5196250b23738e38ce031814e733d582a8a7bf4 Binary files /dev/null and b/corpus/recycle-bin-view/candidate/after/Open Notebook.onetoc2 differ diff --git a/corpus/recycle-bin-view/candidate/after/Restored.one b/corpus/recycle-bin-view/candidate/after/Restored.one new file mode 100644 index 0000000000000000000000000000000000000000..779033e424f67d104bde8746df94c2c532f0ee2a Binary files /dev/null and b/corpus/recycle-bin-view/candidate/after/Restored.one differ diff --git a/corpus/recycle-bin-view/candidate/before/New Section 1.one b/corpus/recycle-bin-view/candidate/before/New Section 1.one new file mode 100644 index 0000000000000000000000000000000000000000..02954af59d39f74de14db49ce9e73142aa4c5eb8 Binary files /dev/null and b/corpus/recycle-bin-view/candidate/before/New Section 1.one differ diff --git a/corpus/recycle-bin-view/candidate/before/OneNote_RecycleBin/Back.one b/corpus/recycle-bin-view/candidate/before/OneNote_RecycleBin/Back.one new file mode 100644 index 0000000000000000000000000000000000000000..066266e6721bf0ebf952711b454d7134bf4d016a Binary files /dev/null and b/corpus/recycle-bin-view/candidate/before/OneNote_RecycleBin/Back.one differ diff --git a/corpus/recycle-bin-view/candidate/before/OneNote_RecycleBin/Copied.one b/corpus/recycle-bin-view/candidate/before/OneNote_RecycleBin/Copied.one new file mode 100644 index 0000000000000000000000000000000000000000..a0c1c012576fbe4aa7152abf55f2044e1d75d38b Binary files /dev/null and b/corpus/recycle-bin-view/candidate/before/OneNote_RecycleBin/Copied.one differ diff --git a/corpus/recycle-bin-view/candidate/before/OneNote_RecycleBin/Emptied.one b/corpus/recycle-bin-view/candidate/before/OneNote_RecycleBin/Emptied.one new file mode 100644 index 0000000000000000000000000000000000000000..97e399a976b788ad824d9841dc68835643f9231e Binary files /dev/null and b/corpus/recycle-bin-view/candidate/before/OneNote_RecycleBin/Emptied.one differ diff --git a/corpus/recycle-bin-view/candidate/before/OneNote_RecycleBin/OneNote_DeletedPages.one b/corpus/recycle-bin-view/candidate/before/OneNote_RecycleBin/OneNote_DeletedPages.one new file mode 100644 index 0000000000000000000000000000000000000000..bca10cd5a9e5e1e78de646f78d52287342612d51 Binary files /dev/null and b/corpus/recycle-bin-view/candidate/before/OneNote_RecycleBin/OneNote_DeletedPages.one differ diff --git a/corpus/recycle-bin-view/candidate/before/OneNote_RecycleBin/Open Notebook.onetoc2 b/corpus/recycle-bin-view/candidate/before/OneNote_RecycleBin/Open Notebook.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..7198e1f48e755c343f39aa3981c2422a09561883 Binary files /dev/null and b/corpus/recycle-bin-view/candidate/before/OneNote_RecycleBin/Open Notebook.onetoc2 differ diff --git a/corpus/recycle-bin-view/candidate/before/OneNote_RecycleBin/Purged.one b/corpus/recycle-bin-view/candidate/before/OneNote_RecycleBin/Purged.one new file mode 100644 index 0000000000000000000000000000000000000000..24d0385f08b82f9cb4cc7fb88e5af6fd3a80b7b1 Binary files /dev/null and b/corpus/recycle-bin-view/candidate/before/OneNote_RecycleBin/Purged.one differ diff --git a/corpus/recycle-bin-view/candidate/before/OneNote_RecycleBin/Restored.one b/corpus/recycle-bin-view/candidate/before/OneNote_RecycleBin/Restored.one new file mode 100644 index 0000000000000000000000000000000000000000..a37451b4003a799f0c36f76110f99b704544e387 Binary files /dev/null and b/corpus/recycle-bin-view/candidate/before/OneNote_RecycleBin/Restored.one differ diff --git a/corpus/recycle-bin-view/candidate/before/Open Notebook.onetoc2 b/corpus/recycle-bin-view/candidate/before/Open Notebook.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..b51f56283169257cf8d6382cccc4dc89cee63639 Binary files /dev/null and b/corpus/recycle-bin-view/candidate/before/Open Notebook.onetoc2 differ diff --git a/corpus/recycle-bin-view/cold/after/commands.jsonl b/corpus/recycle-bin-view/cold/after/commands.jsonl new file mode 100644 index 0000000000000000000000000000000000000000..2325cb5caf423279821525bba6bdddae5aeb4b6a --- /dev/null +++ b/corpus/recycle-bin-view/cold/after/commands.jsonl @@ -0,0 +1,3 @@ +{"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} +{"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} +{"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} diff --git a/corpus/recycle-bin-view/cold/after/machine.json b/corpus/recycle-bin-view/cold/after/machine.json new file mode 100644 index 0000000000000000000000000000000000000000..d808ed4061aade9647a35efc998bc8a5e96dc799 --- /dev/null +++ b/corpus/recycle-bin-view/cold/after/machine.json @@ -0,0 +1 @@ +{"name": "m6-4293bb43", "hostname": "ONE-M6-4293BB43"} diff --git a/corpus/recycle-bin-view/cold/after/notebook/New Section 1.one b/corpus/recycle-bin-view/cold/after/notebook/New Section 1.one new file mode 100644 index 0000000000000000000000000000000000000000..a4ff145d5caed0d157de43c5a45ebeb36e19f738 Binary files /dev/null and b/corpus/recycle-bin-view/cold/after/notebook/New Section 1.one differ diff --git a/corpus/recycle-bin-view/cold/after/notebook/OneNote_RecycleBin/OneNote_DeletedPages.one b/corpus/recycle-bin-view/cold/after/notebook/OneNote_RecycleBin/OneNote_DeletedPages.one new file mode 100644 index 0000000000000000000000000000000000000000..c9ad8fe1c0ff740dca1c8c948d03f277a54a118b Binary files /dev/null and b/corpus/recycle-bin-view/cold/after/notebook/OneNote_RecycleBin/OneNote_DeletedPages.one differ diff --git a/corpus/recycle-bin-view/cold/after/notebook/OneNote_RecycleBin/Open Notebook.onetoc2 b/corpus/recycle-bin-view/cold/after/notebook/OneNote_RecycleBin/Open Notebook.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..cc7642d044a9d41989e523aa612020a3fe187d04 Binary files /dev/null and b/corpus/recycle-bin-view/cold/after/notebook/OneNote_RecycleBin/Open Notebook.onetoc2 differ diff --git a/corpus/recycle-bin-view/cold/after/notebook/Open Notebook.onetoc2 b/corpus/recycle-bin-view/cold/after/notebook/Open Notebook.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..d5196250b23738e38ce031814e733d582a8a7bf4 Binary files /dev/null and b/corpus/recycle-bin-view/cold/after/notebook/Open Notebook.onetoc2 differ diff --git a/corpus/recycle-bin-view/cold/after/notebook/Restored.one b/corpus/recycle-bin-view/cold/after/notebook/Restored.one new file mode 100644 index 0000000000000000000000000000000000000000..779033e424f67d104bde8746df94c2c532f0ee2a Binary files /dev/null and b/corpus/recycle-bin-view/cold/after/notebook/Restored.one differ diff --git a/corpus/recycle-bin-view/cold/after/read/environment.json b/corpus/recycle-bin-view/cold/after/read/environment.json new file mode 100644 index 0000000000000000000000000000000000000000..e078b880815b2202dd8b6c9d633e3c7b1c9aba7e --- /dev/null +++ b/corpus/recycle-bin-view/cold/after/read/environment.json @@ -0,0 +1,7 @@ +{ + "powershell": "5.1.14409.1005", + "schema": "xs2010", + "hostname": "ONE-M6-4293BB43", + "cold": true, + "onenote": "14.0.4763.1000" +} diff --git a/corpus/recycle-bin-view/cold/after/read/hierarchy.xml b/corpus/recycle-bin-view/cold/after/read/hierarchy.xml new file mode 100644 index 0000000000000000000000000000000000000000..68812b6c6416f47644587e12993bf4a1f78326ff --- /dev/null +++ b/corpus/recycle-bin-view/cold/after/read/hierarchy.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/recycle-bin-view/cold/after/read/page-000.navigation.json b/corpus/recycle-bin-view/cold/after/read/page-000.navigation.json new file mode 100644 index 0000000000000000000000000000000000000000..786f5d77169a6a0cc6a55ecc374de91d8d836905 --- /dev/null +++ b/corpus/recycle-bin-view/cold/after/read/page-000.navigation.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "Kept - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/recycle-bin-view/cold/after/read/page-000.png b/corpus/recycle-bin-view/cold/after/read/page-000.png new file mode 100644 index 0000000000000000000000000000000000000000..b2aad44f8952cbd2288425e61aef24797aa4a2c3 Binary files /dev/null and b/corpus/recycle-bin-view/cold/after/read/page-000.png differ diff --git a/corpus/recycle-bin-view/cold/after/read/page-000.xml b/corpus/recycle-bin-view/cold/after/read/page-000.xml new file mode 100644 index 0000000000000000000000000000000000000000..5a29e8fd82db69890b3112eb6cf8e5e2c769c93e --- /dev/null +++ b/corpus/recycle-bin-view/cold/after/read/page-000.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/recycle-bin-view/cold/after/read/page-001.navigation.json b/corpus/recycle-bin-view/cold/after/read/page-001.navigation.json new file mode 100644 index 0000000000000000000000000000000000000000..983148b40e4c8de82cfaaec2324625cbeec56dc3 --- /dev/null +++ b/corpus/recycle-bin-view/cold/after/read/page-001.navigation.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "Restored page - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/recycle-bin-view/cold/after/read/page-001.png b/corpus/recycle-bin-view/cold/after/read/page-001.png new file mode 100644 index 0000000000000000000000000000000000000000..6f761efb808af77e986933b154ca59738d740b10 Binary files /dev/null and b/corpus/recycle-bin-view/cold/after/read/page-001.png differ diff --git a/corpus/recycle-bin-view/cold/after/read/page-001.xml b/corpus/recycle-bin-view/cold/after/read/page-001.xml new file mode 100644 index 0000000000000000000000000000000000000000..cedb54ea9679a4703ca175e898f9f92130ef04b4 --- /dev/null +++ b/corpus/recycle-bin-view/cold/after/read/page-001.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/recycle-bin-view/cold/after/read/page-002.navigation.json b/corpus/recycle-bin-view/cold/after/read/page-002.navigation.json new file mode 100644 index 0000000000000000000000000000000000000000..a0820975652a5b88c4d3fd40d0c41e8291629b36 --- /dev/null +++ b/corpus/recycle-bin-view/cold/after/read/page-002.navigation.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "In a restored section - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/recycle-bin-view/cold/after/read/page-002.png b/corpus/recycle-bin-view/cold/after/read/page-002.png new file mode 100644 index 0000000000000000000000000000000000000000..289544633ca34d34a851d607bd0f698f2e29ac40 Binary files /dev/null and b/corpus/recycle-bin-view/cold/after/read/page-002.png differ diff --git a/corpus/recycle-bin-view/cold/after/read/page-002.xml b/corpus/recycle-bin-view/cold/after/read/page-002.xml new file mode 100644 index 0000000000000000000000000000000000000000..b3924ba4ba08db8976be71b095c29291cfc9530c --- /dev/null +++ b/corpus/recycle-bin-view/cold/after/read/page-002.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/recycle-bin-view/cold/after/read/page-003.navigation.json b/corpus/recycle-bin-view/cold/after/read/page-003.navigation.json new file mode 100644 index 0000000000000000000000000000000000000000..e12934cfc3629d8de3638369df4969f449ea435c --- /dev/null +++ b/corpus/recycle-bin-view/cold/after/read/page-003.navigation.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "Copied page - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/recycle-bin-view/cold/after/read/page-003.png b/corpus/recycle-bin-view/cold/after/read/page-003.png new file mode 100644 index 0000000000000000000000000000000000000000..c20d3f2794da478720ec8f5edd80ba7b58cb39ee Binary files /dev/null and b/corpus/recycle-bin-view/cold/after/read/page-003.png differ diff --git a/corpus/recycle-bin-view/cold/after/read/page-003.xml b/corpus/recycle-bin-view/cold/after/read/page-003.xml new file mode 100644 index 0000000000000000000000000000000000000000..e93af30c967add5e463d69242b1ce439e28a3ea8 --- /dev/null +++ b/corpus/recycle-bin-view/cold/after/read/page-003.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/recycle-bin-view/cold/after/read/payloads.json b/corpus/recycle-bin-view/cold/after/read/payloads.json new file mode 100644 index 0000000000000000000000000000000000000000..1ce91a413d6fff4d58765187b76756f0e97ae843 --- /dev/null +++ b/corpus/recycle-bin-view/cold/after/read/payloads.json @@ -0,0 +1,3 @@ +[ + +] \ No newline at end of file diff --git a/corpus/recycle-bin-view/cold/after/run.json b/corpus/recycle-bin-view/cold/after/run.json new file mode 100644 index 0000000000000000000000000000000000000000..346b3f3cb3db119ef193e9d1b2d50561bfc5281e --- /dev/null +++ b/corpus/recycle-bin-view/cold/after/run.json @@ -0,0 +1,18 @@ +{ + "notebook": "/private/tmp/snowbound-notebook-features/recycle-export/after", + "expected_pages": -1, + "author": null, + "author_timeout_seconds": 600, + "inspect": false, + "collect_notebook": true, + "base": { + "file": "win7-office-base.qcow2", + "format": "qcow2", + "sha256": "a1a4f8fab782ee14885ff801ca2f6347c208fdcfc3513637096f314315c89346", + "virtual_size": 68719476736 + }, + "scripts": { + "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331", + "read.ps1": "22bc760f955ef59bb5f0ccfd55271f8d868674084127fbe219e1048bd3777f35" + } +} diff --git a/corpus/recycle-bin-view/cold/after/scripts/cold.ps1 b/corpus/recycle-bin-view/cold/after/scripts/cold.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..a0b99e7aeb86f4dc10032537800501a52ad65b4f --- /dev/null +++ b/corpus/recycle-bin-view/cold/after/scripts/cold.ps1 @@ -0,0 +1,27 @@ +param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '') +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$root = [IO.Path]::GetFullPath($Root).TrimEnd('\') +if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { + throw 'Choose a run directly below C:\one-tests\runs.' +} +if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' } +$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote' +if ($CloneHost) { + if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) { + throw 'The disposable clone hostname does not match this machine.' + } + New-Item "$key\Options\Paths" -Force | Out-Null + New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null +} elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or + -not (Test-Path 'C:\one-tests\profile-original-cache')) { + throw 'Park the personal OneNote profile before resetting the test cache.' +} +$cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0' +$parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root)) +if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' } +New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null +if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked } +if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse } +New-Item "$key\OpenNotebooks" | Out-Null +New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null diff --git a/corpus/recycle-bin-view/cold/after/scripts/read.ps1 b/corpus/recycle-bin-view/cold/after/scripts/read.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..a42366b32e8baaf13dae92e9472cae7a071220cc --- /dev/null +++ b/corpus/recycle-bin-view/cold/after/scripts/read.ps1 @@ -0,0 +1,144 @@ +param( + [Parameter(Mandatory=$true)][string]$Root, + [int]$ExpectedPages = -1, + [switch]$UseCurrentCache, + [switch]$Pdf, + [switch]$KeepOpen, + [string]$CloneHost = '' +) +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$root = [IO.Path]::GetFullPath($Root).TrimEnd('\') +if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { + throw 'Choose a run directly below C:\one-tests\runs.' +} +$notebook = Join-Path $root 'notebook' +$output = Join-Path $root 'read' +if (Test-Path $output) { throw 'Choose a new read destination.' } +if ($UseCurrentCache) { + if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') { + throw 'Park the personal OneNote profile before reading test notebooks.' + } +} else { + & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost +} +New-Item -ItemType Directory -Path $output | Out-Null +$app = New-Object -ComObject OneNote.Application +$notebookId = '' +$failure = $null +try { + $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0) + $process = Get-Process ONENOTE + @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion; + powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } | + ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8 + $sections = @() + foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) { + $id = '' + $app.OpenHierarchy($file.FullName, '', [ref]$id, 0) + $sections += $id + } + $deadline = [DateTime]::UtcNow.AddSeconds(300) + $previous = '' + $lastChange = '' + $stableSince = [DateTime]::UtcNow + $settled = $false + do { + $pages = @{} + foreach ($section in $sections) { + $hierarchy = '' + $app.GetHierarchy($section, 4, [ref]$hierarchy, 1) + [xml]$xml = $hierarchy + foreach ($node in $xml.SelectNodes('//*[@path]')) { + if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) { + throw 'OneNote opened a section outside the copied notebook.' + } + } + foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) { + $id = $node.GetAttribute('ID') + $content = '' + $app.GetPageContent($id, [ref]$content, 1, 1) + $pages[$id] = $content + } + } + $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] })) + if ($signature -ne $previous) { + $lastChange = $previous + $previous = $signature + $stableSince = [DateTime]::UtcNow + } + if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or + ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and + ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break } + Start-Sleep -Milliseconds 250 + } while ([DateTime]::UtcNow -lt $deadline) + if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) { + [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8) + $index = 0 + foreach ($id in @($pages.Keys | Sort-Object)) { + [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) + $index++ + } + $hierarchy = '' + $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1) + [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8) + throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled." + } + $index = 0 + $payloads = @() + foreach ($id in @($pages.Keys | Sort-Object)) { + [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) + if ($Pdf) { + $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index) + $app.NavigateTo($id, '', $false) + $app.Publish($id, $pdfPath, 3, '') + if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' } + } + [xml]$page = $pages[$id] + foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) { + $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache')) + $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant() + [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes) + # A paragraph's file is named by its paragraph, one on the page by itself. + $owner = if ($file.ParentNode.LocalName -eq 'Page') { $file } else { $file.ParentNode } + $payloads += @{ page = $id; object = $owner.GetAttribute('objectID'); + kind = $file.LocalName; name = $file.GetAttribute('preferredName'); + sha256 = $hash; bytes = $bytes.Length } + } + $index++ + } + [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8) + $all = '' + $app.GetHierarchy($notebookId, 4, [ref]$all, 1) + [xml]$finalTree = $all + $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique) + if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) { + throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.' + } + [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8) + Write-Output "Read $($sections.Count) sections and $($pages.Count) pages." +} catch { + $failure = $_ + [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8) + throw +} finally { + try { + try { + if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) } + if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) { + $app.CloseNotebook($notebookId, $false) + } + } catch { + if ($null -eq $failure) { throw } + [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8) + } + } finally { + [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app) + } + $app = $null + [GC]::Collect() + [GC]::WaitForPendingFinalizers() + if (-not $UseCurrentCache -and -not $CloneHost) { + Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10 + } +} diff --git a/corpus/recycle-bin-view/cold/after/source.json b/corpus/recycle-bin-view/cold/after/source.json new file mode 100644 index 0000000000000000000000000000000000000000..2adc9a038753050520e42855ecbd62e994a7add5 --- /dev/null +++ b/corpus/recycle-bin-view/cold/after/source.json @@ -0,0 +1,32 @@ +[ + { + "path": "New Section 1.one", + "bytes": 10768, + "sha256": "dba689859ca9223cc7f7280b8126df9dcfa8bad9146237a2609d69cc1fc014d9", + "mtime_ns": 1790878542932303216 + }, + { + "path": "OneNote_RecycleBin/OneNote_DeletedPages.one", + "bytes": 14816, + "sha256": "bb80046a0205e1aac6ac1d6b04b50285921860880a3da23b26832a0582391979", + "mtime_ns": 1790878543094303021 + }, + { + "path": "OneNote_RecycleBin/Open Notebook.onetoc2", + "bytes": 6440, + "sha256": "b932bfa563549a224ab8f3f950d4b23c0e74fbdd56d2ba31b1296ee33495c2c3", + "mtime_ns": 1790878543072446775 + }, + { + "path": "Open Notebook.onetoc2", + "bytes": 11160, + "sha256": "00509e99899c98ce844e32441563d64714964d0d1f4b976a1e1e55e6558a5405", + "mtime_ns": 1790878543056292320 + }, + { + "path": "Restored.one", + "bytes": 6120, + "sha256": "79fbc722219346d2b4359b4bdb3efd60fed68c7953bdf76354501a0f8e614a31", + "mtime_ns": 1790878543039977656 + } +] diff --git a/corpus/recycle-bin-view/cold/after/teardown.json b/corpus/recycle-bin-view/cold/after/teardown.json new file mode 100644 index 0000000000000000000000000000000000000000..05a47793de40f322e745c4c4183727e3590ecf70 --- /dev/null +++ b/corpus/recycle-bin-view/cold/after/teardown.json @@ -0,0 +1 @@ +{"absent": true} diff --git a/corpus/recycle-bin-view/cold/before/commands.jsonl b/corpus/recycle-bin-view/cold/before/commands.jsonl new file mode 100644 index 0000000000000000000000000000000000000000..b7236c4a49d9abae710f78f3288e8a84ae8021b8 --- /dev/null +++ b/corpus/recycle-bin-view/cold/before/commands.jsonl @@ -0,0 +1,3 @@ +{"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} +{"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} +{"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} diff --git a/corpus/recycle-bin-view/cold/before/machine.json b/corpus/recycle-bin-view/cold/before/machine.json new file mode 100644 index 0000000000000000000000000000000000000000..07913368ab8d8468f8e1ea5b80cf301bdaa316cd --- /dev/null +++ b/corpus/recycle-bin-view/cold/before/machine.json @@ -0,0 +1 @@ +{"name": "m6-9027d2d6", "hostname": "ONE-M6-9027D2D6"} diff --git a/corpus/recycle-bin-view/cold/before/notebook/New Section 1.one b/corpus/recycle-bin-view/cold/before/notebook/New Section 1.one new file mode 100644 index 0000000000000000000000000000000000000000..02954af59d39f74de14db49ce9e73142aa4c5eb8 Binary files /dev/null and b/corpus/recycle-bin-view/cold/before/notebook/New Section 1.one differ diff --git a/corpus/recycle-bin-view/cold/before/notebook/OneNote_RecycleBin/Back.one b/corpus/recycle-bin-view/cold/before/notebook/OneNote_RecycleBin/Back.one new file mode 100644 index 0000000000000000000000000000000000000000..066266e6721bf0ebf952711b454d7134bf4d016a Binary files /dev/null and b/corpus/recycle-bin-view/cold/before/notebook/OneNote_RecycleBin/Back.one differ diff --git a/corpus/recycle-bin-view/cold/before/notebook/OneNote_RecycleBin/Copied.one b/corpus/recycle-bin-view/cold/before/notebook/OneNote_RecycleBin/Copied.one new file mode 100644 index 0000000000000000000000000000000000000000..a0c1c012576fbe4aa7152abf55f2044e1d75d38b Binary files /dev/null and b/corpus/recycle-bin-view/cold/before/notebook/OneNote_RecycleBin/Copied.one differ diff --git a/corpus/recycle-bin-view/cold/before/notebook/OneNote_RecycleBin/Emptied.one b/corpus/recycle-bin-view/cold/before/notebook/OneNote_RecycleBin/Emptied.one new file mode 100644 index 0000000000000000000000000000000000000000..97e399a976b788ad824d9841dc68835643f9231e Binary files /dev/null and b/corpus/recycle-bin-view/cold/before/notebook/OneNote_RecycleBin/Emptied.one differ diff --git a/corpus/recycle-bin-view/cold/before/notebook/OneNote_RecycleBin/OneNote_DeletedPages.one b/corpus/recycle-bin-view/cold/before/notebook/OneNote_RecycleBin/OneNote_DeletedPages.one new file mode 100644 index 0000000000000000000000000000000000000000..bca10cd5a9e5e1e78de646f78d52287342612d51 Binary files /dev/null and b/corpus/recycle-bin-view/cold/before/notebook/OneNote_RecycleBin/OneNote_DeletedPages.one differ diff --git a/corpus/recycle-bin-view/cold/before/notebook/OneNote_RecycleBin/Open Notebook.onetoc2 b/corpus/recycle-bin-view/cold/before/notebook/OneNote_RecycleBin/Open Notebook.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..7198e1f48e755c343f39aa3981c2422a09561883 Binary files /dev/null and b/corpus/recycle-bin-view/cold/before/notebook/OneNote_RecycleBin/Open Notebook.onetoc2 differ diff --git a/corpus/recycle-bin-view/cold/before/notebook/OneNote_RecycleBin/Purged.one b/corpus/recycle-bin-view/cold/before/notebook/OneNote_RecycleBin/Purged.one new file mode 100644 index 0000000000000000000000000000000000000000..24d0385f08b82f9cb4cc7fb88e5af6fd3a80b7b1 Binary files /dev/null and b/corpus/recycle-bin-view/cold/before/notebook/OneNote_RecycleBin/Purged.one differ diff --git a/corpus/recycle-bin-view/cold/before/notebook/OneNote_RecycleBin/Restored.one b/corpus/recycle-bin-view/cold/before/notebook/OneNote_RecycleBin/Restored.one new file mode 100644 index 0000000000000000000000000000000000000000..a37451b4003a799f0c36f76110f99b704544e387 Binary files /dev/null and b/corpus/recycle-bin-view/cold/before/notebook/OneNote_RecycleBin/Restored.one differ diff --git a/corpus/recycle-bin-view/cold/before/notebook/Open Notebook.onetoc2 b/corpus/recycle-bin-view/cold/before/notebook/Open Notebook.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..b51f56283169257cf8d6382cccc4dc89cee63639 Binary files /dev/null and b/corpus/recycle-bin-view/cold/before/notebook/Open Notebook.onetoc2 differ diff --git a/corpus/recycle-bin-view/cold/before/read/environment.json b/corpus/recycle-bin-view/cold/before/read/environment.json new file mode 100644 index 0000000000000000000000000000000000000000..27ec55ab778157ed9e43ec54cb063f399f20392d --- /dev/null +++ b/corpus/recycle-bin-view/cold/before/read/environment.json @@ -0,0 +1,7 @@ +{ + "powershell": "5.1.14409.1005", + "schema": "xs2010", + "hostname": "ONE-M6-9027D2D6", + "cold": true, + "onenote": "14.0.4763.1000" +} diff --git a/corpus/recycle-bin-view/cold/before/read/hierarchy.xml b/corpus/recycle-bin-view/cold/before/read/hierarchy.xml new file mode 100644 index 0000000000000000000000000000000000000000..1ebd48a146a4f03e7604d7fe975382c44539486b --- /dev/null +++ b/corpus/recycle-bin-view/cold/before/read/hierarchy.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/recycle-bin-view/cold/before/read/page-000.navigation.json b/corpus/recycle-bin-view/cold/before/read/page-000.navigation.json new file mode 100644 index 0000000000000000000000000000000000000000..d103fe551771c44e2a0e837c579330a7417b707b --- /dev/null +++ b/corpus/recycle-bin-view/cold/before/read/page-000.navigation.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "In a restored section (Read-Only - Recycle Bin) - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/recycle-bin-view/cold/before/read/page-000.png b/corpus/recycle-bin-view/cold/before/read/page-000.png new file mode 100644 index 0000000000000000000000000000000000000000..2de1343748cfe9d127a9c071d4028bfea4f14989 Binary files /dev/null and b/corpus/recycle-bin-view/cold/before/read/page-000.png differ diff --git a/corpus/recycle-bin-view/cold/before/read/page-000.xml b/corpus/recycle-bin-view/cold/before/read/page-000.xml new file mode 100644 index 0000000000000000000000000000000000000000..19e60e3c0659c29cf6e1e4b23a9db4afcfabf60f --- /dev/null +++ b/corpus/recycle-bin-view/cold/before/read/page-000.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/recycle-bin-view/cold/before/read/page-001.navigation.json b/corpus/recycle-bin-view/cold/before/read/page-001.navigation.json new file mode 100644 index 0000000000000000000000000000000000000000..9c5e423d31fc982b584542431afce6de58822c8b --- /dev/null +++ b/corpus/recycle-bin-view/cold/before/read/page-001.navigation.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "Copied page (Read-Only - Recycle Bin) - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/recycle-bin-view/cold/before/read/page-001.png b/corpus/recycle-bin-view/cold/before/read/page-001.png new file mode 100644 index 0000000000000000000000000000000000000000..653eaecf4b5564df4a8bf44fc9e89886a44a7538 Binary files /dev/null and b/corpus/recycle-bin-view/cold/before/read/page-001.png differ diff --git a/corpus/recycle-bin-view/cold/before/read/page-001.xml b/corpus/recycle-bin-view/cold/before/read/page-001.xml new file mode 100644 index 0000000000000000000000000000000000000000..28403dcc0ff92b198e97a4db32c26fc07c5aa02f --- /dev/null +++ b/corpus/recycle-bin-view/cold/before/read/page-001.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/recycle-bin-view/cold/before/read/page-002.navigation.json b/corpus/recycle-bin-view/cold/before/read/page-002.navigation.json new file mode 100644 index 0000000000000000000000000000000000000000..9c5e423d31fc982b584542431afce6de58822c8b --- /dev/null +++ b/corpus/recycle-bin-view/cold/before/read/page-002.navigation.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "Copied page (Read-Only - Recycle Bin) - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/recycle-bin-view/cold/before/read/page-002.png b/corpus/recycle-bin-view/cold/before/read/page-002.png new file mode 100644 index 0000000000000000000000000000000000000000..482ffcfbc4e94f4ee75bb5930352bc89a90b253c Binary files /dev/null and b/corpus/recycle-bin-view/cold/before/read/page-002.png differ diff --git a/corpus/recycle-bin-view/cold/before/read/page-002.xml b/corpus/recycle-bin-view/cold/before/read/page-002.xml new file mode 100644 index 0000000000000000000000000000000000000000..9d10e94c54930aa073162aa9b17d3069a667859c --- /dev/null +++ b/corpus/recycle-bin-view/cold/before/read/page-002.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/recycle-bin-view/cold/before/read/page-003.navigation.json b/corpus/recycle-bin-view/cold/before/read/page-003.navigation.json new file mode 100644 index 0000000000000000000000000000000000000000..786f5d77169a6a0cc6a55ecc374de91d8d836905 --- /dev/null +++ b/corpus/recycle-bin-view/cold/before/read/page-003.navigation.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "Kept - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/recycle-bin-view/cold/before/read/page-003.png b/corpus/recycle-bin-view/cold/before/read/page-003.png new file mode 100644 index 0000000000000000000000000000000000000000..f37593a9beff28fa147631db49a564c964f8bed7 Binary files /dev/null and b/corpus/recycle-bin-view/cold/before/read/page-003.png differ diff --git a/corpus/recycle-bin-view/cold/before/read/page-003.xml b/corpus/recycle-bin-view/cold/before/read/page-003.xml new file mode 100644 index 0000000000000000000000000000000000000000..3f2ef60b922f4efe228f09ea83889121531b23ec --- /dev/null +++ b/corpus/recycle-bin-view/cold/before/read/page-003.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/recycle-bin-view/cold/before/read/page-004.navigation.json b/corpus/recycle-bin-view/cold/before/read/page-004.navigation.json new file mode 100644 index 0000000000000000000000000000000000000000..c6ad93f4ef700dadd2fc10d99cc3f805d8ba6212 --- /dev/null +++ b/corpus/recycle-bin-view/cold/before/read/page-004.navigation.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "In an emptied section (Read-Only - Recycle Bin) - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/recycle-bin-view/cold/before/read/page-004.png b/corpus/recycle-bin-view/cold/before/read/page-004.png new file mode 100644 index 0000000000000000000000000000000000000000..903616747f1cd7b7a710ec1596ababb139c78c71 Binary files /dev/null and b/corpus/recycle-bin-view/cold/before/read/page-004.png differ diff --git a/corpus/recycle-bin-view/cold/before/read/page-004.xml b/corpus/recycle-bin-view/cold/before/read/page-004.xml new file mode 100644 index 0000000000000000000000000000000000000000..af8f6c870899d3b0993d4fd47ede7574ed1d8a3a --- /dev/null +++ b/corpus/recycle-bin-view/cold/before/read/page-004.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/recycle-bin-view/cold/before/read/page-005.navigation.json b/corpus/recycle-bin-view/cold/before/read/page-005.navigation.json new file mode 100644 index 0000000000000000000000000000000000000000..d16fb646f9f559bdfab19eaad159d21f37ea6b16 --- /dev/null +++ b/corpus/recycle-bin-view/cold/before/read/page-005.navigation.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "Restored page (Read-Only - Recycle Bin) - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/recycle-bin-view/cold/before/read/page-005.png b/corpus/recycle-bin-view/cold/before/read/page-005.png new file mode 100644 index 0000000000000000000000000000000000000000..3120d57cf23fe8866ef5d59edffbf7b7631f8664 Binary files /dev/null and b/corpus/recycle-bin-view/cold/before/read/page-005.png differ diff --git a/corpus/recycle-bin-view/cold/before/read/page-005.xml b/corpus/recycle-bin-view/cold/before/read/page-005.xml new file mode 100644 index 0000000000000000000000000000000000000000..8a5e0604e4d2627d511f58b8f00a68dd1152f38f --- /dev/null +++ b/corpus/recycle-bin-view/cold/before/read/page-005.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/recycle-bin-view/cold/before/read/page-006.navigation.json b/corpus/recycle-bin-view/cold/before/read/page-006.navigation.json new file mode 100644 index 0000000000000000000000000000000000000000..9c340888da0a18e3b6146b1e6f79b5fa86de18bf --- /dev/null +++ b/corpus/recycle-bin-view/cold/before/read/page-006.navigation.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "Purged page (Read-Only - Recycle Bin) - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/recycle-bin-view/cold/before/read/page-006.png b/corpus/recycle-bin-view/cold/before/read/page-006.png new file mode 100644 index 0000000000000000000000000000000000000000..17672d0daddc886da439f128385e9fa8b1742fed Binary files /dev/null and b/corpus/recycle-bin-view/cold/before/read/page-006.png differ diff --git a/corpus/recycle-bin-view/cold/before/read/page-006.xml b/corpus/recycle-bin-view/cold/before/read/page-006.xml new file mode 100644 index 0000000000000000000000000000000000000000..9fe81f66a4bd2edf6b8d6988d35e194f0707e56f --- /dev/null +++ b/corpus/recycle-bin-view/cold/before/read/page-006.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/recycle-bin-view/cold/before/read/page-007.navigation.json b/corpus/recycle-bin-view/cold/before/read/page-007.navigation.json new file mode 100644 index 0000000000000000000000000000000000000000..d16fb646f9f559bdfab19eaad159d21f37ea6b16 --- /dev/null +++ b/corpus/recycle-bin-view/cold/before/read/page-007.navigation.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "Restored page (Read-Only - Recycle Bin) - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/recycle-bin-view/cold/before/read/page-007.png b/corpus/recycle-bin-view/cold/before/read/page-007.png new file mode 100644 index 0000000000000000000000000000000000000000..1de2e727af15f857e1c486c57b97d1b2ddf923fe Binary files /dev/null and b/corpus/recycle-bin-view/cold/before/read/page-007.png differ diff --git a/corpus/recycle-bin-view/cold/before/read/page-007.xml b/corpus/recycle-bin-view/cold/before/read/page-007.xml new file mode 100644 index 0000000000000000000000000000000000000000..5b030e900018d0c5aca5a21df4ea2a8c56d9acb4 --- /dev/null +++ b/corpus/recycle-bin-view/cold/before/read/page-007.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/recycle-bin-view/cold/before/read/page-008.navigation.json b/corpus/recycle-bin-view/cold/before/read/page-008.navigation.json new file mode 100644 index 0000000000000000000000000000000000000000..9c340888da0a18e3b6146b1e6f79b5fa86de18bf --- /dev/null +++ b/corpus/recycle-bin-view/cold/before/read/page-008.navigation.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "Purged page (Read-Only - Recycle Bin) - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/recycle-bin-view/cold/before/read/page-008.png b/corpus/recycle-bin-view/cold/before/read/page-008.png new file mode 100644 index 0000000000000000000000000000000000000000..cf298cf40f14b2bf1984279f2e6b75948e12022a Binary files /dev/null and b/corpus/recycle-bin-view/cold/before/read/page-008.png differ diff --git a/corpus/recycle-bin-view/cold/before/read/page-008.xml b/corpus/recycle-bin-view/cold/before/read/page-008.xml new file mode 100644 index 0000000000000000000000000000000000000000..b4b88881bde71a256989378767de550cf8bf3910 --- /dev/null +++ b/corpus/recycle-bin-view/cold/before/read/page-008.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/recycle-bin-view/cold/before/read/payloads.json b/corpus/recycle-bin-view/cold/before/read/payloads.json new file mode 100644 index 0000000000000000000000000000000000000000..1ce91a413d6fff4d58765187b76756f0e97ae843 --- /dev/null +++ b/corpus/recycle-bin-view/cold/before/read/payloads.json @@ -0,0 +1,3 @@ +[ + +] \ No newline at end of file diff --git a/corpus/recycle-bin-view/cold/before/run.json b/corpus/recycle-bin-view/cold/before/run.json new file mode 100644 index 0000000000000000000000000000000000000000..df7b142efb06b6bb7cd56c7d733ccd255d1eb38f --- /dev/null +++ b/corpus/recycle-bin-view/cold/before/run.json @@ -0,0 +1,18 @@ +{ + "notebook": "/private/tmp/snowbound-notebook-features/recycle-export/before", + "expected_pages": -1, + "author": null, + "author_timeout_seconds": 600, + "inspect": false, + "collect_notebook": true, + "base": { + "file": "win7-office-base.qcow2", + "format": "qcow2", + "sha256": "a1a4f8fab782ee14885ff801ca2f6347c208fdcfc3513637096f314315c89346", + "virtual_size": 68719476736 + }, + "scripts": { + "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331", + "read.ps1": "22bc760f955ef59bb5f0ccfd55271f8d868674084127fbe219e1048bd3777f35" + } +} diff --git a/corpus/recycle-bin-view/cold/before/scripts/cold.ps1 b/corpus/recycle-bin-view/cold/before/scripts/cold.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..a0b99e7aeb86f4dc10032537800501a52ad65b4f --- /dev/null +++ b/corpus/recycle-bin-view/cold/before/scripts/cold.ps1 @@ -0,0 +1,27 @@ +param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '') +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$root = [IO.Path]::GetFullPath($Root).TrimEnd('\') +if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { + throw 'Choose a run directly below C:\one-tests\runs.' +} +if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' } +$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote' +if ($CloneHost) { + if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) { + throw 'The disposable clone hostname does not match this machine.' + } + New-Item "$key\Options\Paths" -Force | Out-Null + New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null +} elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or + -not (Test-Path 'C:\one-tests\profile-original-cache')) { + throw 'Park the personal OneNote profile before resetting the test cache.' +} +$cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0' +$parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root)) +if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' } +New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null +if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked } +if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse } +New-Item "$key\OpenNotebooks" | Out-Null +New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null diff --git a/corpus/recycle-bin-view/cold/before/scripts/read.ps1 b/corpus/recycle-bin-view/cold/before/scripts/read.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..a42366b32e8baaf13dae92e9472cae7a071220cc --- /dev/null +++ b/corpus/recycle-bin-view/cold/before/scripts/read.ps1 @@ -0,0 +1,144 @@ +param( + [Parameter(Mandatory=$true)][string]$Root, + [int]$ExpectedPages = -1, + [switch]$UseCurrentCache, + [switch]$Pdf, + [switch]$KeepOpen, + [string]$CloneHost = '' +) +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$root = [IO.Path]::GetFullPath($Root).TrimEnd('\') +if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { + throw 'Choose a run directly below C:\one-tests\runs.' +} +$notebook = Join-Path $root 'notebook' +$output = Join-Path $root 'read' +if (Test-Path $output) { throw 'Choose a new read destination.' } +if ($UseCurrentCache) { + if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') { + throw 'Park the personal OneNote profile before reading test notebooks.' + } +} else { + & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost +} +New-Item -ItemType Directory -Path $output | Out-Null +$app = New-Object -ComObject OneNote.Application +$notebookId = '' +$failure = $null +try { + $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0) + $process = Get-Process ONENOTE + @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion; + powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } | + ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8 + $sections = @() + foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) { + $id = '' + $app.OpenHierarchy($file.FullName, '', [ref]$id, 0) + $sections += $id + } + $deadline = [DateTime]::UtcNow.AddSeconds(300) + $previous = '' + $lastChange = '' + $stableSince = [DateTime]::UtcNow + $settled = $false + do { + $pages = @{} + foreach ($section in $sections) { + $hierarchy = '' + $app.GetHierarchy($section, 4, [ref]$hierarchy, 1) + [xml]$xml = $hierarchy + foreach ($node in $xml.SelectNodes('//*[@path]')) { + if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) { + throw 'OneNote opened a section outside the copied notebook.' + } + } + foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) { + $id = $node.GetAttribute('ID') + $content = '' + $app.GetPageContent($id, [ref]$content, 1, 1) + $pages[$id] = $content + } + } + $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] })) + if ($signature -ne $previous) { + $lastChange = $previous + $previous = $signature + $stableSince = [DateTime]::UtcNow + } + if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or + ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and + ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break } + Start-Sleep -Milliseconds 250 + } while ([DateTime]::UtcNow -lt $deadline) + if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) { + [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8) + $index = 0 + foreach ($id in @($pages.Keys | Sort-Object)) { + [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) + $index++ + } + $hierarchy = '' + $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1) + [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8) + throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled." + } + $index = 0 + $payloads = @() + foreach ($id in @($pages.Keys | Sort-Object)) { + [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) + if ($Pdf) { + $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index) + $app.NavigateTo($id, '', $false) + $app.Publish($id, $pdfPath, 3, '') + if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' } + } + [xml]$page = $pages[$id] + foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) { + $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache')) + $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant() + [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes) + # A paragraph's file is named by its paragraph, one on the page by itself. + $owner = if ($file.ParentNode.LocalName -eq 'Page') { $file } else { $file.ParentNode } + $payloads += @{ page = $id; object = $owner.GetAttribute('objectID'); + kind = $file.LocalName; name = $file.GetAttribute('preferredName'); + sha256 = $hash; bytes = $bytes.Length } + } + $index++ + } + [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8) + $all = '' + $app.GetHierarchy($notebookId, 4, [ref]$all, 1) + [xml]$finalTree = $all + $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique) + if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) { + throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.' + } + [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8) + Write-Output "Read $($sections.Count) sections and $($pages.Count) pages." +} catch { + $failure = $_ + [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8) + throw +} finally { + try { + try { + if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) } + if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) { + $app.CloseNotebook($notebookId, $false) + } + } catch { + if ($null -eq $failure) { throw } + [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8) + } + } finally { + [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app) + } + $app = $null + [GC]::Collect() + [GC]::WaitForPendingFinalizers() + if (-not $UseCurrentCache -and -not $CloneHost) { + Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10 + } +} diff --git a/corpus/recycle-bin-view/cold/before/source.json b/corpus/recycle-bin-view/cold/before/source.json new file mode 100644 index 0000000000000000000000000000000000000000..725feee3fcc9f2e9b9a236f07d6731899f794f53 --- /dev/null +++ b/corpus/recycle-bin-view/cold/before/source.json @@ -0,0 +1,56 @@ +[ + { + "path": "New Section 1.one", + "bytes": 6024, + "sha256": "6510443f5eaac454107799ce05166f1481397a2ade00105a810a4922f1a1d1a5", + "mtime_ns": 1790878536381575386 + }, + { + "path": "OneNote_RecycleBin/Back.one", + "bytes": 6072, + "sha256": "7456ebd650898521e31b0c575e95d9247c7d3513dac736c0fd260996840089f4", + "mtime_ns": 1790878536887022715 + }, + { + "path": "OneNote_RecycleBin/Copied.one", + "bytes": 6072, + "sha256": "32d49b0a546b63f785927beab2c2322e7a099114a7a14df20387afe08de7202d", + "mtime_ns": 1790878536938612540 + }, + { + "path": "OneNote_RecycleBin/Emptied.one", + "bytes": 6120, + "sha256": "f9023245ad7907bfe5146a8af40e8a2898b7ced31eb68d8da931986c3bd61b62", + "mtime_ns": 1790878537062700352 + }, + { + "path": "OneNote_RecycleBin/OneNote_DeletedPages.one", + "bytes": 10816, + "sha256": "1290e82b1827fd302cd19fe007eff5aef8d7d9ed577d830bdb664a495249b3fb", + "mtime_ns": 1790878536875973134 + }, + { + "path": "OneNote_RecycleBin/Open Notebook.onetoc2", + "bytes": 5880, + "sha256": "95df8154a57d6d202e75fa2c9514ab89ee7bb232ffc2865e24c216c6ebb65b07", + "mtime_ns": 1790878537079049557 + }, + { + "path": "OneNote_RecycleBin/Purged.one", + "bytes": 6072, + "sha256": "0f62253a5ef9b41cfbb8246dc97e8d1968789d5a1e035b9f48b803d8b7a8b44e", + "mtime_ns": 1790878536980735991 + }, + { + "path": "OneNote_RecycleBin/Restored.one", + "bytes": 6120, + "sha256": "7d1487ed8c558adc48ffc878c1d9f73bc1ff00fa03e71671d478cc45bfe9a6e9", + "mtime_ns": 1790878537021413776 + }, + { + "path": "Open Notebook.onetoc2", + "bytes": 10656, + "sha256": "812827df8f4cb6d2425db9453ee2b93e4b600b41c858c38d28bc9b5e879c00f2", + "mtime_ns": 1790878537095054096 + } +] diff --git a/corpus/recycle-bin-view/cold/before/teardown.json b/corpus/recycle-bin-view/cold/before/teardown.json new file mode 100644 index 0000000000000000000000000000000000000000..05a47793de40f322e745c4c4183727e3590ecf70 --- /dev/null +++ b/corpus/recycle-bin-view/cold/before/teardown.json @@ -0,0 +1 @@ +{"absent": true} diff --git a/corpus/rtl-page/README.md b/corpus/rtl-page/README.md new file mode 100644 index 0000000000000000000000000000000000000000..b330bcca7df443f8b46e76987c8f0f34cfcb784d --- /dev/null +++ b/corpus/rtl-page/README.md @@ -0,0 +1,19 @@ +# Editing a right-to-left page + +OneNote 2010 keeps a right-to-left page's positions in the same left-to-right frame +as any page, from a margin origin that `corpus/m6/native-page-direction-03` stores at +x 10763.25: native XML x is stored x minus the origin minus 36. It opens the page +scrolled to the right end of its content and keeps the view's right edge when the +window resizes. A click on blank page puts an outline's left edge on the grid +anchored at the margin origin, as on any page; only an outline laid out wider than +its stored width keeps its right edge. A new drawing stores page-frame strokes with +a zero offset (Win7 lab, 2026-10-01). + +`candidate/` is the output of `crates/canvas/tests/rtl_page.rs` on that page through +the page view: it opens at the drawings' right edge, a click and typing add an outline, +a pen stroke adds a drawing, and a header drag moves the positioned outline two grid +columns right, each published as a revision. `cold/` is a fresh OneNote 2010 read of +it: the new outline at x 126, the moved one at 180, the stroke where the pen went. +`tools/test_rtl_page.py` checks this without a VM. Regenerate with +`SNOWBOUND_RTL_EXPORT` set to a new absolute directory while running the test, then +cold-open it with `tools/native_runner.py OUTPUT COLD --expected-pages 1 --collect-notebook --screenshots`. diff --git a/corpus/rtl-page/candidate/Open Notebook.onetoc2 b/corpus/rtl-page/candidate/Open Notebook.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..1671a68b860a5a00e6f6eba976a3e677c5c37834 Binary files /dev/null and b/corpus/rtl-page/candidate/Open Notebook.onetoc2 differ diff --git a/corpus/rtl-page/candidate/synthetic.one b/corpus/rtl-page/candidate/synthetic.one new file mode 100644 index 0000000000000000000000000000000000000000..4ddcb20081e3a68ca55d8cb2662590fa83c3830a Binary files /dev/null and b/corpus/rtl-page/candidate/synthetic.one differ diff --git a/corpus/rtl-page/cold/commands.jsonl b/corpus/rtl-page/cold/commands.jsonl new file mode 100644 index 0000000000000000000000000000000000000000..afdfd1bb8d034dc5f0ac3a5f63232a6ad3405926 --- /dev/null +++ b/corpus/rtl-page/cold/commands.jsonl @@ -0,0 +1,3 @@ +{"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} +{"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} +{"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} diff --git a/corpus/rtl-page/cold/machine.json b/corpus/rtl-page/cold/machine.json new file mode 100644 index 0000000000000000000000000000000000000000..e8dcb7cdf9954aef9eefd6d431bbeb503694f500 --- /dev/null +++ b/corpus/rtl-page/cold/machine.json @@ -0,0 +1 @@ +{"name": "m6-be5b6549", "hostname": "ONE-M6-BE5B6549"} diff --git a/corpus/rtl-page/cold/notebook/Open Notebook.onetoc2 b/corpus/rtl-page/cold/notebook/Open Notebook.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..f9d1f722b71acd7a4e3149b56d62fd6a98b3d5b2 Binary files /dev/null and b/corpus/rtl-page/cold/notebook/Open Notebook.onetoc2 differ diff --git a/corpus/rtl-page/cold/notebook/synthetic.one b/corpus/rtl-page/cold/notebook/synthetic.one new file mode 100644 index 0000000000000000000000000000000000000000..3e0ef8c302c746577e9a2d5d27fbff8a2395dcbc Binary files /dev/null and b/corpus/rtl-page/cold/notebook/synthetic.one differ diff --git a/corpus/rtl-page/cold/read/af1af4b8987109ce1e335e538910fb3860b7c57eb3b6110a06aa2114e06d69a7.attachment b/corpus/rtl-page/cold/read/af1af4b8987109ce1e335e538910fb3860b7c57eb3b6110a06aa2114e06d69a7.attachment new file mode 100644 index 0000000000000000000000000000000000000000..66b6151e91f90df6a61a19ef2f4447da278e07b2 --- /dev/null +++ b/corpus/rtl-page/cold/read/af1af4b8987109ce1e335e538910fb3860b7c57eb3b6110a06aa2114e06d69a7.attachment @@ -0,0 +1 @@ +Fictitious attachment for native corpus. \ No newline at end of file diff --git a/corpus/rtl-page/cold/read/environment.json b/corpus/rtl-page/cold/read/environment.json new file mode 100644 index 0000000000000000000000000000000000000000..a69d14102ae21a47278df6da17ac0d2010efcc46 --- /dev/null +++ b/corpus/rtl-page/cold/read/environment.json @@ -0,0 +1,7 @@ +{ + "powershell": "5.1.14409.1005", + "schema": "xs2010", + "hostname": "ONE-M6-BE5B6549", + "cold": true, + "onenote": "14.0.4763.1000" +} diff --git a/corpus/rtl-page/cold/read/hierarchy.xml b/corpus/rtl-page/cold/read/hierarchy.xml new file mode 100644 index 0000000000000000000000000000000000000000..e601bcb6d42cb752c7b58396d16193b7cec25bf6 --- /dev/null +++ b/corpus/rtl-page/cold/read/hierarchy.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/rtl-page/cold/read/page-000.navigation.json b/corpus/rtl-page/cold/read/page-000.navigation.json new file mode 100644 index 0000000000000000000000000000000000000000..2f394e7eaf1defcfe3e06e321b5af76be4f84054 --- /dev/null +++ b/corpus/rtl-page/cold/read/page-000.navigation.json @@ -0,0 +1,13 @@ +{ + "exit": 0, + "stdout": "", + "stderr": "", + "w": 800, + "h": 600, + "error": null, + "win": { + "title": "Fictitious: caf\u00e9, \u6771\u4eac, \u0645\u0631\u062d\u0628\u0627 - Microsoft OneNote", + "class": "Framework::CFrame", + "dialog": false + } +} \ No newline at end of file diff --git a/corpus/rtl-page/cold/read/page-000.png b/corpus/rtl-page/cold/read/page-000.png new file mode 100644 index 0000000000000000000000000000000000000000..974f402667fc6bdbab743137c45686a0b08cd9b3 Binary files /dev/null and b/corpus/rtl-page/cold/read/page-000.png differ diff --git a/corpus/rtl-page/cold/read/page-000.xml b/corpus/rtl-page/cold/read/page-000.xml new file mode 100644 index 0000000000000000000000000000000000000000..f33d928544f484c179bc4e80abe15119babd27a3 --- /dev/null +++ b/corpus/rtl-page/cold/read/page-000.xml @@ -0,0 +1,31 @@ + +Fictitious: café, 東京, مرحبا]]>AK4FHQSiAaIBAWAqABofi0JwRYR1gL7QRdkE+oeVMvLsF0G4wBa/8yv0et67E4vlXrNInJuhJVq3 +URK/7HHMzCKxQYkOuxMs7nqhWM9UiuaXxU+PBvi60uGbIsRmbacNslhJmh1OQDQGbBADBkgQRSNG +IwUIC2VPZmdoZGkZFDIIAIAeAiHi4kEzCADwEAKLxeJBZAMVRgBACpUE/QGH81d3mq7mkzgM0mcB +mk1gEBgEBgEBgE1mUAgMzmUAmsygE1mUzmkBmc0gMzmk1mUAmsyTOaTWZQCazKZgALSmiazKATWZ +QCAwCAwBAZpM4DAJlNYBMprAJlNZpM4DNJmmU1mkzgM0mcBmkzmU1gEyAAFpTSAzOaQGZzRAYBAY +BAYBMprAIDNJnAZpM0yms0mcBmkzgM0mcymsAmU1gEyms0maZAACZTSawCZTWATKawCAoBAYBAZn +NIDAJrMoBNZlAJrMpnMoBNZlAJrMpnNIDM5pAZnNJrMkzmk1AACH8C7XgWnmkzgM0msAmU1gEBgE +BgEBTOaQGATWZQCazKATWZTOaTWZTNNJrMpmmUzTSaplM00mqZTOaTVMpmmk1TKATKazSawCAzSZ +wGAQFAIDAJrMoBAZnNIDM5pAZnMpnNJrMpmmk1mUzTSaplM00mqZTNNJrMpmmk1TKZplhuAzKATW +ZQCazKAIDAIDAIDNJnAYBMprAJlM4DNJnMprNJnMk1mkzmSazRM5kms0TOZJnMprNEzmSazQtKaQ +GZzRNZlAIDAIDAIDAJlNYAmU1gEymsAmU1mkzmU1miZzKazRM5kmcyTWaJnMk1mkzmSazRM5kAUU +RgBAAAAJBAUURgAAAAASABEgXhT+65lz/EiPi+8SFvjqCAk+gEcDAa/89Qd0AxVGAEAKABEg2R87 +QQrSFUA=AOECHQJSegFgKgAaH4tCcEWEdYC+0EXZBPqHlTLy7BdBuMAWv/Mr9HreuxOL5V6zSJyboSVat1ES +v+xxzMwisUGJDrsTLO56oVjPVIrml8VPjwb4utLhmyLEZm2nDbJYSZodTkA0BmwQAwZIEEUjRiMF +CAtlT2ZnaGRpGRQyCACAHgIh4uJBMwgA8BACi8XiQWQDFUYAQArKAUuH88s3nkzmkzgM0mcBmkzg +KAQGAQGATWZQCAzOaQGZzKATWZTOaQGZzSAzOaTWZQCazJM5pNZlAJrMpmAAAACH8D9XgeBJlM0z +mRNZpNZpM4DAJlNYBAUAgMAgMzmkBgE1mUAmsyTOaTWZTOaTVMpnNJqmUzTSaplM00mqZTOZTNNJ +qmUzTSagBRRGAIAAAAkEBRRGAAAAABIAESDPTQ4xwIZyTqQ8FVDbHxeKCj9AI5rARvPPUDugAxVG +AEAKABEgLMlfQdkfI0A=iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9J1uoA +AAAASUVORK5CYII=APMBHQPIAVABYCoAGh+LQnBFhHWAvtBF2QT6h5Uy8uwXQbjAFr/zK/R63rsTi+Ves0icm6ElWrdR +Er/scczMIrFBiQ67EyzueqFYz1SK5pfFT48G+LrS4ZsixGZtpw2yWEmaHU5ANAZsEAMGSBRE+Zx9 +BQgLZU9mZ2hkaRkUMggAgB4CIeLiQTMIAPAQAovF4kFkAxVGAEAKXBWC/xca9f4uMc5SVKSpSIL/ +AFTB/gCn3AFgAAUURgDAAAAJBAUURgAAAAASABEgzCikRHwBF0ik6a3tvqHwOgo/QCO0AoyWs9A7 +gAMVRgBACj9AI0bCHMUqWCWg + diff --git a/corpus/rtl-page/cold/read/payloads.json b/corpus/rtl-page/cold/read/payloads.json new file mode 100644 index 0000000000000000000000000000000000000000..920a376287fcf451b11c15a27a162ecd21d9a32c --- /dev/null +++ b/corpus/rtl-page/cold/read/payloads.json @@ -0,0 +1,10 @@ +[ + { + "sha256": "af1af4b8987109ce1e335e538910fb3860b7c57eb3b6110a06aa2114e06d69a7", + "name": "fictitious-attachment.txt", + "object": "{B2FC7E18-A51C-4707-923A-CC9918928E18}{11}{B0}", + "kind": "InsertedFile", + "page": "{3BB37A5E-7DDE-4484-94A3-0EC09BBE088D}{1}{B0}", + "bytes": 43 + } +] \ No newline at end of file diff --git a/corpus/rtl-page/cold/run.json b/corpus/rtl-page/cold/run.json new file mode 100644 index 0000000000000000000000000000000000000000..4fe1789972092ae0fe28df07c087eb412d74aeca --- /dev/null +++ b/corpus/rtl-page/cold/run.json @@ -0,0 +1,18 @@ +{ + "notebook": "/private/tmp/snowbound-rtl/candidate", + "expected_pages": 1, + "author": null, + "author_timeout_seconds": 600, + "inspect": false, + "collect_notebook": true, + "base": { + "file": "win7-office-base.qcow2", + "format": "qcow2", + "sha256": "a1a4f8fab782ee14885ff801ca2f6347c208fdcfc3513637096f314315c89346", + "virtual_size": 68719476736 + }, + "scripts": { + "cold.ps1": "c177fc72ae6c2634d186f5671a880de20b09f9716532c37c69cb13aa15c7e331", + "read.ps1": "22bc760f955ef59bb5f0ccfd55271f8d868674084127fbe219e1048bd3777f35" + } +} diff --git a/corpus/rtl-page/cold/scripts/cold.ps1 b/corpus/rtl-page/cold/scripts/cold.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..a0b99e7aeb86f4dc10032537800501a52ad65b4f --- /dev/null +++ b/corpus/rtl-page/cold/scripts/cold.ps1 @@ -0,0 +1,27 @@ +param([Parameter(Mandatory=$true)][string]$Root, [string]$CloneHost = '') +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$root = [IO.Path]::GetFullPath($Root).TrimEnd('\') +if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { + throw 'Choose a run directly below C:\one-tests\runs.' +} +if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before resetting its test cache.' } +$key = 'HKCU:\Software\Microsoft\Office\14.0\OneNote' +if ($CloneHost) { + if ($CloneHost -notmatch '^ONE-[A-Z0-9-]+$' -or [Environment]::MachineName -ne $CloneHost) { + throw 'The disposable clone hostname does not match this machine.' + } + New-Item "$key\Options\Paths" -Force | Out-Null + New-ItemProperty "$key\Options\Paths" -Name UnfiledNotesSection -PropertyType ExpandString -Value 'C:\one-tests\Loose.one' -Force | Out-Null +} elseif ((Get-ItemProperty "$key\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or + -not (Test-Path 'C:\one-tests\profile-original-cache')) { + throw 'Park the personal OneNote profile before resetting the test cache.' +} +$cache = Join-Path $env:LOCALAPPDATA 'Microsoft\OneNote\14.0' +$parked = Join-Path 'C:\one-tests\caches' ([IO.Path]::GetFileName($root)) +if (Test-Path $parked) { throw 'Choose a new run; its parked cache already exists.' } +New-Item -ItemType Directory -Path 'C:\one-tests\caches' -Force | Out-Null +if (Test-Path $cache) { Move-Item -LiteralPath $cache -Destination $parked } +if (Test-Path "$key\OpenNotebooks") { Remove-Item "$key\OpenNotebooks" -Recurse } +New-Item "$key\OpenNotebooks" | Out-Null +New-ItemProperty "$key\OpenNotebooks" -Name '1' -PropertyType String -Value "$root\notebook" | Out-Null diff --git a/corpus/rtl-page/cold/scripts/read.ps1 b/corpus/rtl-page/cold/scripts/read.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..a42366b32e8baaf13dae92e9472cae7a071220cc --- /dev/null +++ b/corpus/rtl-page/cold/scripts/read.ps1 @@ -0,0 +1,144 @@ +param( + [Parameter(Mandatory=$true)][string]$Root, + [int]$ExpectedPages = -1, + [switch]$UseCurrentCache, + [switch]$Pdf, + [switch]$KeepOpen, + [string]$CloneHost = '' +) +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$root = [IO.Path]::GetFullPath($Root).TrimEnd('\') +if ([IO.Path]::GetDirectoryName($root) -ne 'C:\one-tests\runs') { + throw 'Choose a run directly below C:\one-tests\runs.' +} +$notebook = Join-Path $root 'notebook' +$output = Join-Path $root 'read' +if (Test-Path $output) { throw 'Choose a new read destination.' } +if ($UseCurrentCache) { + if ((Get-ItemProperty 'HKCU:\Software\Microsoft\Office\14.0\OneNote\Options\Paths').UnfiledNotesSection -ne 'C:\one-tests\Loose.one') { + throw 'Park the personal OneNote profile before reading test notebooks.' + } +} else { + & "$PSScriptRoot\cold-current.ps1" -Root $root -CloneHost $CloneHost +} +New-Item -ItemType Directory -Path $output | Out-Null +$app = New-Object -ComObject OneNote.Application +$notebookId = '' +$failure = $null +try { + $app.OpenHierarchy($notebook, '', [ref]$notebookId, 0) + $process = Get-Process ONENOTE + @{ hostname = [Environment]::MachineName; onenote = $process.MainModule.FileVersionInfo.FileVersion; + powershell = $PSVersionTable.PSVersion.ToString(); schema = 'xs2010'; cold = (-not $UseCurrentCache.IsPresent) } | + ConvertTo-Json | Set-Content (Join-Path $output 'environment.json') -Encoding UTF8 + $sections = @() + foreach ($file in @(Get-ChildItem $notebook -Recurse | Where-Object { $_.Extension -eq '.one' })) { + $id = '' + $app.OpenHierarchy($file.FullName, '', [ref]$id, 0) + $sections += $id + } + $deadline = [DateTime]::UtcNow.AddSeconds(300) + $previous = '' + $lastChange = '' + $stableSince = [DateTime]::UtcNow + $settled = $false + do { + $pages = @{} + foreach ($section in $sections) { + $hierarchy = '' + $app.GetHierarchy($section, 4, [ref]$hierarchy, 1) + [xml]$xml = $hierarchy + foreach ($node in $xml.SelectNodes('//*[@path]')) { + if (-not $node.GetAttribute('path').StartsWith("$notebook\", [StringComparison]::OrdinalIgnoreCase)) { + throw 'OneNote opened a section outside the copied notebook.' + } + } + foreach ($node in $xml.SelectNodes('//*[local-name()="Page"]')) { + $id = $node.GetAttribute('ID') + $content = '' + $app.GetPageContent($id, [ref]$content, 1, 1) + $pages[$id] = $content + } + } + $signature = [String]::Join('|', @($pages.Keys | Sort-Object | ForEach-Object { $_ + $pages[$_] })) + if ($signature -ne $previous) { + $lastChange = $previous + $previous = $signature + $stableSince = [DateTime]::UtcNow + } + if ((($ExpectedPages -ge 0 -and $pages.Count -eq $ExpectedPages) -or + ($ExpectedPages -lt 0 -and $pages.Count -gt 0)) -and + ([DateTime]::UtcNow - $stableSince).TotalSeconds -ge 2) { $settled = $true; break } + Start-Sleep -Milliseconds 250 + } while ([DateTime]::UtcNow -lt $deadline) + if (-not $settled -or ($ExpectedPages -ge 0 -and $pages.Count -ne $ExpectedPages) -or ($ExpectedPages -lt 0 -and $pages.Count -eq 0)) { + [IO.File]::WriteAllText((Join-Path $output 'previous-signature.txt'), $lastChange, [Text.Encoding]::UTF8) + $index = 0 + foreach ($id in @($pages.Keys | Sort-Object)) { + [IO.File]::WriteAllText((Join-Path $output ('unsettled-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) + $index++ + } + $hierarchy = '' + $app.GetHierarchy($notebookId, 4, [ref]$hierarchy, 1) + [IO.File]::WriteAllText((Join-Path $output 'unsettled-hierarchy.xml'), $hierarchy, [Text.Encoding]::UTF8) + throw "Expected $ExpectedPages stable pages; OneNote returned $($pages.Count), settled=$settled." + } + $index = 0 + $payloads = @() + foreach ($id in @($pages.Keys | Sort-Object)) { + [IO.File]::WriteAllText((Join-Path $output ('page-{0:d3}.xml' -f $index)), $pages[$id], [Text.Encoding]::UTF8) + if ($Pdf) { + $pdfPath = Join-Path $output ('page-{0:d3}.pdf' -f $index) + $app.NavigateTo($id, '', $false) + $app.Publish($id, $pdfPath, 3, '') + if (-not (Test-Path $pdfPath) -or (Get-Item $pdfPath).Length -eq 0) { throw 'OneNote did not publish the page PDF.' } + } + [xml]$page = $pages[$id] + foreach ($file in $page.SelectNodes('//*[local-name()="InsertedFile" or local-name()="MediaFile"]')) { + $bytes = [IO.File]::ReadAllBytes($file.GetAttribute('pathCache')) + $hash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace('-', '').ToLowerInvariant() + [IO.File]::WriteAllBytes((Join-Path $output ($hash + '.attachment')), $bytes) + # A paragraph's file is named by its paragraph, one on the page by itself. + $owner = if ($file.ParentNode.LocalName -eq 'Page') { $file } else { $file.ParentNode } + $payloads += @{ page = $id; object = $owner.GetAttribute('objectID'); + kind = $file.LocalName; name = $file.GetAttribute('preferredName'); + sha256 = $hash; bytes = $bytes.Length } + } + $index++ + } + [IO.File]::WriteAllText((Join-Path $output 'payloads.json'), (ConvertTo-Json -InputObject $payloads -Depth 4), [Text.Encoding]::UTF8) + $all = '' + $app.GetHierarchy($notebookId, 4, [ref]$all, 1) + [xml]$finalTree = $all + $finalIds = @($finalTree.SelectNodes('//*[local-name()="Page"]') | ForEach-Object { $_.GetAttribute('ID') } | Sort-Object -Unique) + if ($finalIds.Count -ne $pages.Count -or @($finalIds | Where-Object { -not $pages.ContainsKey($_) }).Count -ne 0) { + throw 'The notebook hierarchy changed while collecting page evidence; repeat the cold read.' + } + [IO.File]::WriteAllText((Join-Path $output 'hierarchy.xml'), $all, [Text.Encoding]::UTF8) + Write-Output "Read $($sections.Count) sections and $($pages.Count) pages." +} catch { + $failure = $_ + [IO.File]::WriteAllText((Join-Path $output 'failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8) + throw +} finally { + try { + try { + if ($notebookId -and $CloneHost) { $app.SyncHierarchy($notebookId) } + if ($notebookId -and -not $KeepOpen -and (-not $UseCurrentCache -or $CloneHost)) { + $app.CloseNotebook($notebookId, $false) + } + } catch { + if ($null -eq $failure) { throw } + [IO.File]::WriteAllText((Join-Path $output 'cleanup-failure.txt'), ($_ | Out-String), [Text.Encoding]::UTF8) + } + } finally { + [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($app) + } + $app = $null + [GC]::Collect() + [GC]::WaitForPendingFinalizers() + if (-not $UseCurrentCache -and -not $CloneHost) { + Get-Process ONENOTE -ErrorAction SilentlyContinue | Wait-Process -Timeout 10 + } +} diff --git a/corpus/rtl-page/cold/source.json b/corpus/rtl-page/cold/source.json new file mode 100644 index 0000000000000000000000000000000000000000..143ea1781c94c5827a7d68e3b8efa49102dfb8d8 --- /dev/null +++ b/corpus/rtl-page/cold/source.json @@ -0,0 +1,14 @@ +[ + { + "path": "Open Notebook.onetoc2", + "bytes": 1612, + "sha256": "3064cce91ed16ee1b2ff088d096fbd19492a6802a2f45a94447ccad490d8c452", + "mtime_ns": 1790841446325464192 + }, + { + "path": "synthetic.one", + "bytes": 45760, + "sha256": "7ee4b534028071dde4ced7e24109ed381c96bb3198728eb27b5d7b59de2c7a5d", + "mtime_ns": 1790841446324673647 + } +] diff --git a/corpus/rtl-page/cold/teardown.json b/corpus/rtl-page/cold/teardown.json new file mode 100644 index 0000000000000000000000000000000000000000..05a47793de40f322e745c4c4183727e3590ecf70 --- /dev/null +++ b/corpus/rtl-page/cold/teardown.json @@ -0,0 +1 @@ +{"absent": true} diff --git a/crates/canvas/Cargo.toml b/crates/canvas/Cargo.toml index d96a8c6f405146d8a63bf188521aeca85e63664b..50e4d9b00a870c70e2cfdd293b53375236accf0d 100644 --- a/crates/canvas/Cargo.toml +++ b/crates/canvas/Cargo.toml @@ -46,6 +46,10 @@ wgpu.workspace = true name = "render_page" required-features = ["gpu"] +[[example]] +name = "reading_inventory" +required-features = ["gpu"] + [[example]] name = "print_pdf" required-features = ["pdf"] diff --git a/crates/canvas/examples/reading_inventory.rs b/crates/canvas/examples/reading_inventory.rs new file mode 100644 index 0000000000000000000000000000000000000000..af47b8321106ec710cdb2c4f4da0478ef37b2556 --- /dev/null +++ b/crates/canvas/examples/reading_inventory.rs @@ -0,0 +1,382 @@ +//! How many pages a reading view reflows into a phone's column, across the active pages of +//! every distinct section under the given directories, as Markdown; with `--json FILE`, one +//! line per page with its blocks in reading order. `--render SECTION INDEX PREFIX` draws page +//! INDEX of SECTION whole (`PREFIX-page.png`) and reflowed (`PREFIX-column.png`). +//! +//! `cargo run --release -p canvas --features gpu --example reading_inventory -- [--column 230] corpus` + +use canvas::{ + gpu::{Paper, Viewport, page::PageScene}, + layout::TextEngine, + reading::{self, Kept, Kind, Refusal, Verdict}, +}; +use draw::Renderer; +use onestore::{Arena, Section, page::Page}; +use std::{ + collections::{BTreeMap, BTreeSet, hash_map::DefaultHasher}, + fmt::Write as _, + hash::{Hash, Hasher}, + io::Write as _, + path::{Path, PathBuf}, + time::Duration, +}; + +/// Host points per page point at the iOS app's 100%: OneNote's 96 dpi enlarged 15%. +const POINT: f32 = 96.0 / 72.0 * 1.15; + +fn sections(directory: &Path, out: &mut Vec) { + let Ok(entries) = std::fs::read_dir(directory) else { + return; + }; + let mut entries: Vec = entries.flatten().map(|entry| entry.path()).collect(); + entries.sort(); + for path in entries { + if path.is_dir() { + sections(&path, out); + } else if path.extension().is_some_and(|e| e == "one") { + out.push(path); + } + } +} + +fn label(verdict: &Verdict) -> String { + match verdict { + Verdict::Fits => "fits".into(), + Verdict::Reflows => "reflows".into(), + Verdict::Partial(kept) => format!( + "partial: {}", + kept.iter() + .map(|k| kept_name(*k)) + .collect::>() + .join(", ") + ), + Verdict::Refused(refusal) => format!( + "refused: {}", + match refusal { + Refusal::RightToLeft => "right to left", + Refusal::Drawn => "mostly ink", + Refusal::Arranged => "mostly overlapping", + } + ), + } +} + +fn kept_name(kept: Kept) -> &'static str { + match kept { + Kept::Asides => "asides", + Kept::SideBySide => "side by side", + Kept::Group => "overlap kept whole", + Kept::Wide => "wider than the column", + } +} + +fn kind_name(kind: Kind) -> &'static str { + match kind { + Kind::Text => "text", + Kind::Picture => "picture", + Kind::File => "file", + Kind::Drawing => "drawing", + Kind::Group => "group", + } +} + +fn open(path: &Path) -> Option> { + let image = std::fs::read(path).ok()?; + let arena = Arena::default(); + let mut section = Section::open(&arena, image).ok()?; + let pages = section.pages().ok()?; + Some( + pages + .into_iter() + .filter_map(|(space, title, _)| section.page(space).ok().map(|page| (title, page))) + .collect(), + ) +} + +fn main() -> Result<(), Box> { + let mut args: Vec = std::env::args().skip(1).collect(); + let mut column = 230.0; + let mut json = None; + if let Some(at) = args.iter().position(|a| a == "--column") { + column = args[at + 1].parse()?; + args.drain(at..at + 2); + } + if let Some(at) = args.iter().position(|a| a == "--json") { + json = Some(std::fs::File::create(&args[at + 1])?); + args.drain(at..at + 2); + } + let mut engine = TextEngine::default(); + if args.first().is_some_and(|a| a == "--render") { + let [_, section, index, prefix] = &args[..] else { + return Err("--render SECTION INDEX PREFIX".into()); + }; + let pages = open(Path::new(section)).ok_or("section does not open")?; + let (_, page) = pages + .into_iter() + .nth(index.parse()?) + .ok_or("no such page")?; + let read = reading::read(&page, column, &mut engine)?; + let mut gpu = Gpu::new()?; + gpu.whole(&page, &mut engine, &format!("{prefix}-page.png"))?; + if let Some(reflowed) = &read.page { + gpu.column( + reflowed, + column, + &mut engine, + &format!("{prefix}-column.png"), + )?; + } + println!("{}", label(&read.verdict)); + return Ok(()); + } + let mut seen = BTreeSet::new(); + let mut distinct = BTreeSet::new(); + let mut verdicts: BTreeMap> = BTreeMap::new(); + let (mut pages, mut failed) = (0, 0); + for root in &args { + let mut paths = Vec::new(); + sections(Path::new(root), &mut paths); + for path in paths { + let Ok(bytes) = std::fs::read(&path) else { + continue; + }; + let mut hasher = DefaultHasher::new(); + bytes.hash(&mut hasher); + if !seen.insert(hasher.finish()) { + continue; + } + let Some(section) = open(&path) else { + continue; + }; + for (index, (title, page)) in section.into_iter().enumerate() { + // Fixtures repeat pages across their runs; each page counts once. + let mut hasher = DefaultHasher::new(); + serde_json::to_string(&page)?.hash(&mut hasher); + if !distinct.insert(hasher.finish()) { + continue; + } + pages += 1; + let place = format!("{} #{index} — {title}", path.display()); + let read = match reading::read(&page, column, &mut engine) { + Ok(read) => read, + Err(_) => { + failed += 1; + continue; + } + }; + let verdict = label(&read.verdict); + if let Some(json) = &mut json { + let blocks: Vec<_> = read + .blocks + .iter() + .map(|b| serde_json::json!({"kind": kind_name(b.kind), "members": b.members.len(), "bounds": b.bounds})) + .collect(); + let line = serde_json::json!({ + "section": path.display().to_string(), "index": index, "title": title, + "verdict": verdict, "offered": read.verdict.offered(), "blocks": blocks, + }); + writeln!(json, "{line}")?; + } + verdicts.entry(verdict).or_default().push(place); + } + } + } + let mut out = String::new(); + writeln!(out, "# Reading view eligibility\n")?; + writeln!( + out, + "{} distinct sections, {pages} distinct pages ({failed} failed to lay out), column {column} pt ({:.0} pt on screen at 100%).\n", + seen.len(), + column * POINT + )?; + writeln!( + out, + "| Verdict | Pages | Share | Examples |\n| --- | ---: | ---: | --- |" + )?; + let mut rows: Vec<_> = verdicts.iter().collect(); + rows.sort_by_key(|row| std::cmp::Reverse(row.1.len())); + for (verdict, places) in rows { + let examples: Vec = places.iter().take(3).map(|p| format!("`{p}`")).collect(); + writeln!( + out, + "| {verdict} | {} | {:.1}% | {} |", + places.len(), + 100.0 * places.len() as f64 / pages.max(1) as f64, + examples.join("
").replace('|', "\\|") + )?; + } + print!("{out}"); + Ok(()) +} + +struct Gpu { + renderer: Renderer, +} + +impl Gpu { + fn new() -> Result> { + let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle()); + let adapter = pollster::block_on(instance.request_adapter(&Default::default()))?; + let (device, queue) = pollster::block_on(adapter.request_device(&Default::default()))?; + Ok(Self { + renderer: Renderer::new(device, queue, wgpu::TextureFormat::Rgba8UnormSrgb), + }) + } + + /// The whole page, 804 pixels across: a phone's width at 2x. + fn whole( + &mut self, + page: &Page, + engine: &mut TextEngine, + path: &str, + ) -> Result<(), Box> { + let (scene, bounds) = scene(page, engine)?; + let [x0, y0, x1, y1] = bounds; + let scale = 780.0 / (x1 - x0); + let height = (((y1 - y0) * scale) as u32 + 24).min(4000); + self.draw( + scene, + Viewport { + size: [804, height], + scale, + origin: [12.0 - x0 * scale, 12.0 - y0 * scale], + }, + path, + ) + } + + /// The reflowed page as the phone shows it at 100%, 34 points of tag room on its left. + fn column( + &mut self, + page: &Page, + column: f32, + engine: &mut TextEngine, + path: &str, + ) -> Result<(), Box> { + let (scene, [x0, y0, _, y1]) = scene(page, engine)?; + let scale = 2.0 * POINT; + let _ = column; + let height = (((y1 - y0) * scale) as u32 + 48).min(6000); + self.draw( + scene, + Viewport { + size: [804, height], + scale, + origin: [68.0 - x0 * scale, 24.0 - y0 * scale], + }, + path, + ) + } + + fn draw( + &mut self, + mut scene: PageScene, + viewport: Viewport, + path: &str, + ) -> Result<(), Box> { + scene.settle(None, viewport.scale, Paper::WHITE); + let mut primitives = Vec::new(); + scene.append_primitives(&mut primitives, [0.0; 2], Paper::WHITE)?; + let size = viewport.size; + let device = self.renderer.device().clone(); + let target = device.create_texture(&wgpu::TextureDescriptor { + label: None, + size: wgpu::Extent3d { + width: size[0], + height: size[1], + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba8UnormSrgb, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, + view_formats: &[], + }); + let row_bytes = (size[0] * 4).next_multiple_of(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT); + let buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: None, + size: u64::from(row_bytes) * u64::from(size[1]), + usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, + mapped_at_creation: false, + }); + self.renderer + .draw( + &target.create_view(&Default::default()), + size, + [1.0; 4], + &[viewport.layer(&primitives)], + ) + .map_err(|e| format!("{e:?}"))?; + let mut encoder = device.create_command_encoder(&Default::default()); + encoder.copy_texture_to_buffer( + target.as_image_copy(), + wgpu::TexelCopyBufferInfo { + buffer: &buffer, + layout: wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(row_bytes), + rows_per_image: Some(size[1]), + }, + }, + wgpu::Extent3d { + width: size[0], + height: size[1], + depth_or_array_layers: 1, + }, + ); + self.renderer.queue().submit([encoder.finish()]); + let (sender, receiver) = std::sync::mpsc::channel(); + buffer.map_async(wgpu::MapMode::Read, .., move |result| { + let _ = sender.send(result); + }); + device.poll(wgpu::PollType::Wait { + submission_index: None, + timeout: Some(Duration::from_secs(10)), + })?; + receiver.recv_timeout(Duration::from_secs(10))??; + let mapped = buffer.get_mapped_range(..)?; + let pixels: Vec = mapped + .chunks_exact(row_bytes as usize) + .flat_map(|row| row[..size[0] as usize * 4].iter().copied()) + .collect(); + let mut encoder = png::Encoder::new(std::fs::File::create(path)?, size[0], size[1]); + encoder.set_color(png::ColorType::Rgba); + encoder.set_depth(png::BitDepth::Eight); + encoder.write_header()?.write_image_data(&pixels)?; + Ok(()) + } +} + +/// The page's scene, pictures decoded, and its content's bounds with the title. +fn scene( + page: &Page, + engine: &mut TextEngine, +) -> Result<(PageScene, [f32; 4]), Box> { + let editor = canvas::editor::CanvasEditor::from_page(page.clone(), engine)?; + let mut bounds = [ + f32::INFINITY, + f32::INFINITY, + f32::NEG_INFINITY, + f32::NEG_INFINITY, + ]; + for outline in editor.outlines() { + let b = outline.bounds(); + bounds = [ + bounds[0].min(b.x0 as f32), + bounds[1].min(b.y0 as f32), + bounds[2].max(b.x1 as f32), + bounds[3].max(b.y1 as f32), + ]; + } + for block in reading::read(page, f32::INFINITY, engine)?.blocks { + let b = block.bounds; + bounds = [ + bounds[0].min(b[0]), + bounds[1].min(b[1]), + bounds[2].max(b[2]), + bounds[3].max(b[3]), + ]; + } + Ok((PageScene::new(page.clone(), engine)?, bounds)) +} diff --git a/crates/canvas/examples/unsupported_inventory.rs b/crates/canvas/examples/unsupported_inventory.rs index 1eddfb8e8978911e343a06355216be265ac1522e..3f2694a6de4f8757a6420f970d29e7a04810800d 100644 --- a/crates/canvas/examples/unsupported_inventory.rs +++ b/crates/canvas/examples/unsupported_inventory.rs @@ -544,7 +544,38 @@ fn page(engine: &mut TextEngine, page: Page, place: &str, inventory: &mut Invent probe(&mut editor, engine, inventory, place); } -fn section(engine: &mut TextEngine, path: &Path, root: &Path, inventory: &mut Inventory) { +/// The passwords the roots' `manifest.json` files record for their protected sections. +fn passwords(directory: &Path, out: &mut Vec) { + let Ok(entries) = std::fs::read_dir(directory) else { + return; + }; + for path in entries.flatten().map(|entry| entry.path()) { + if path.is_dir() { + passwords(&path, out); + } else if path.file_name().is_some_and(|name| name == "manifest.json") + && let Ok(bytes) = std::fs::read(&path) + && let Ok(manifest) = serde_json::from_slice::(&bytes) + { + let named = manifest["native"] + .as_object() + .into_iter() + .flat_map(|map| map.values()); + out.extend( + std::iter::once(&manifest["password"]) + .chain(named) + .filter_map(|password| password.as_str().map(str::to_owned)), + ); + } + } +} + +fn section( + engine: &mut TextEngine, + path: &Path, + root: &Path, + passwords: &[String], + inventory: &mut Inventory, +) { let Ok(image) = std::fs::read(path) else { return; }; @@ -554,7 +585,15 @@ fn section(engine: &mut TextEngine, path: &Path, root: &Path, inventory: &mut In .display() .to_string(); let arena = Arena::default(); - let mut section = match Section::open(&arena, image) { + // A protected section opens under the password its fixture records. + let key = passwords + .iter() + .find_map(|password| onestore::protected::Key::open(&image, password).ok()); + let opened = match &key { + Some(key) => Section::unlock(&arena, image, key).map_err(onestore::Error::from), + None => Section::open(&arena, image), + }; + let mut section = match opened { Ok(section) => section, Err(error) => { inventory.add( @@ -612,6 +651,10 @@ fn main() { let mut inventory = Inventory::default(); let mut seen = BTreeSet::new(); let mut duplicates = 0; + let mut known = Vec::new(); + for root in &roots { + passwords(root, &mut known); + } for root in &roots { let mut paths = Vec::new(); sections(root, &mut paths); @@ -625,7 +668,7 @@ fn main() { duplicates += 1; continue; } - section(&mut engine, &path, root, &mut inventory); + section(&mut engine, &path, root, &known, &mut inventory); } } println!("# Unsupported content inventory\n"); diff --git a/crates/canvas/src/conflict.rs b/crates/canvas/src/conflict.rs index d3186859dc696e4018a036a4c179674a394b631d..0a7c00eb0f1f6050d168854561613f613c188080 100644 --- a/crates/canvas/src/conflict.rs +++ b/crates/canvas/src/conflict.rs @@ -1,5 +1,6 @@ //! Conflict pages and page versions as OneNote shows them. +use crate::document::descendants; use onestore::{ ExGuid, page::{Page, PageObject, PageParagraph, ParagraphContent}, @@ -21,9 +22,19 @@ pub fn highlighted(page: Page, objects: &[ExGuid]) -> Page { /// banded as OneNote shows it; all of it without one. pub fn changes(version: Page, older: Option<&Page>) -> Page { let texts = |page: &Page| { - let mut texts = Vec::new(); - each_text(page, &mut |id, text| texts.push((id, text.to_owned()))); - texts + page.objects + .iter() + .flat_map(|object| match object { + PageObject::Outline(outline) => std::slice::from_ref(outline), + PageObject::Title(title) => title.outlines.as_slice(), + _ => &[], + }) + .flat_map(|outline| descendants(&outline.paragraphs, None)) + .filter_map(|(_, _, node)| match &node.content { + ParagraphContent::Text(text) => Some((text.id, text.text.text().to_owned())), + _ => None, + }) + .collect::>() }; let before = older.map(texts).unwrap_or_default(); let changed: Vec = texts(&version) @@ -34,33 +45,6 @@ pub fn changes(version: Page, older: Option<&Page>) -> Page { banded(version, &changed, CHANGED) } -fn each_text(page: &Page, f: &mut impl FnMut(ExGuid, &str)) { - fn walk(paragraphs: &[PageParagraph], f: &mut impl FnMut(ExGuid, &str)) { - for paragraph in paragraphs { - match ¶graph.content { - ParagraphContent::Table(table) => { - for cell in table.rows.iter().flat_map(|row| &row.cells) { - walk(&cell.paragraphs, f); - } - } - ParagraphContent::Text(text) => f(text.id, text.text.text()), - _ => {} - } - } - } - for object in &page.objects { - match object { - PageObject::Outline(outline) => walk(&outline.paragraphs, f), - PageObject::Title(title) => { - for outline in &title.outlines { - walk(&outline.paragraphs, f); - } - } - _ => {} - } - } -} - fn banded(mut page: Page, objects: &[ExGuid], color: u32) -> Page { fn mark(paragraphs: &mut [PageParagraph], objects: &[ExGuid], color: u32) { for paragraph in paragraphs { diff --git a/crates/canvas/src/date.rs b/crates/canvas/src/date.rs index 0ef98c2460670404ca1450e061750bd06efe0dd9..477f03402c9a22f5e0f1f8378c0a057e4d279865 100644 --- a/crates/canvas/src/date.rs +++ b/crates/canvas/src/date.rs @@ -32,7 +32,7 @@ impl PageDate { &self.layout } - pub fn fields(&self) -> impl Iterator { + pub(crate) fn fields(&self) -> impl Iterator { let swapped = self .source .paragraphs diff --git a/crates/canvas/src/document.rs b/crates/canvas/src/document.rs index cb9899ddfe5856f2f80e0590618fc548843499f2..e29df4feb16ae6879a145678e4f199942d074d2e 100644 --- a/crates/canvas/src/document.rs +++ b/crates/canvas/src/document.rs @@ -26,11 +26,11 @@ pub struct TextDocument { #[derive(Clone, Debug, PartialEq)] pub(crate) struct DocumentEdit { /// Cell identity, or None for the document root. - pub container: Option, - pub range: Range, - pub replacement: Vec, + pub(crate) container: Option, + pub(crate) range: Range, + pub(crate) replacement: Vec, /// Width patches address surviving tables outside the replacement subtree. - pub columns: BTreeMap>, + pub(crate) columns: BTreeMap>, } pub(crate) fn node(text: Paragraph, format: Format) -> Result { @@ -757,7 +757,7 @@ impl TextDocument { Err(EditError::InvalidRange) } - pub fn slice(&self, range: Range) -> Result, EditError> { + pub(crate) fn slice(&self, range: Range) -> Result, EditError> { if range.start > range.end { return Err(EditError::InvalidRange); } diff --git a/crates/canvas/src/editor.rs b/crates/canvas/src/editor.rs index 41d0f75a8a86709d7f77d612090af2febf4b8222..84f35790368b72e7be2f625f330dc1c340b697e8 100644 --- a/crates/canvas/src/editor.rs +++ b/crates/canvas/src/editor.rs @@ -100,10 +100,7 @@ pub struct Awaited { } /// A new picture of encoded `bytes`, `size` points large. -pub(crate) fn picture( - bytes: Vec, - size: [f32; 2], -) -> Result { +pub fn picture(bytes: Vec, size: [f32; 2]) -> Result { Ok(onestore::page::Image { id: onestore::page::text::new_id()?, layout: onestore::document::Layout { @@ -187,6 +184,7 @@ struct PageHeader { identity: Option<[u8; 16]>, created: Option, margin_origin: [f32; 2], + rtl: bool, color: Option, rule_lines: Option, areas: Vec, @@ -378,7 +376,7 @@ impl TextOutline { } pub fn origin(&self) -> [f32; 2] { - [self.layout.x.unwrap_or(0.0), self.layout.y.unwrap_or(0.0)] + crate::origin(&self.layout) } pub fn wrap_width(&self) -> f32 { @@ -509,7 +507,7 @@ impl TextOutline { } /// All the outline's shown text. - pub fn whole(&self) -> Selection { + pub(crate) fn whole(&self) -> Selection { let paragraph = self.source_index(self.shaped.paragraphs.len() - 1); let last = self.document.paragraph(paragraph).unwrap(); [ @@ -587,7 +585,11 @@ impl TextOutline { /// Where a mark under UTF-16 `range` of paragraph `index` runs, outline-local: each /// line's left, right and baseline. - pub fn underlines(&self, index: usize, range: Range) -> Result, EditError> { + pub(crate) fn underlines( + &self, + index: usize, + range: Range, + ) -> Result, EditError> { let paragraph = self.paragraph_layout(index)?; let selection = ParagraphSelection::new( paragraph.cursor(range.start, Affinity::Downstream)?, @@ -935,39 +937,15 @@ impl CanvasEditor { width: f32, ) -> Result { let outline = TextOutline::new(engine, document, width, [0.0; 2])?; - let (outlines, active) = if outline.is_empty() { - ( - Vec::new(), - Focus::Caret { - outline: Box::new(outline), - index: 0, - }, - ) - } else { - (vec![outline], Focus::Outline(0)) - }; - Ok(Self { - outlines, - definitions: BTreeMap::new(), - objects: Vec::new(), - date: None, - header: PageHeader::default(), - active, - undo: Vec::new(), - redo: Vec::new(), - composition: None, - preferred_x: None, - pending: None, - ops: Ok(Vec::new()), - stored: None, - whole: None, - typing: None, - recording: None, - ink_drag: None, - provisional: BTreeMap::new(), - default_font: DefaultFont::default(), - styles: BTreeMap::new(), - }) + let empty = outline.is_empty(); + let mut editor = Self::from_text_outlines(vec![outline], BTreeMap::new(), None)?; + if empty { + editor.active = Focus::Caret { + outline: Box::new(editor.outlines.pop().unwrap()), + index: 0, + }; + } + Ok(editor) } pub fn from_page(mut page: Page, engine: &mut TextEngine) -> Result { @@ -1014,6 +992,7 @@ impl CanvasEditor { identity: page.identity, created: page.created, margin_origin: page.margin_origin, + rtl: page.rtl, color: page.color, rule_lines: page.rule_lines, areas, @@ -1317,6 +1296,7 @@ impl CanvasEditor { .map(PageDate::timestamp) .or(self.header.created), margin_origin: self.header.margin_origin, + rtl: self.header.rtl, color: self.header.color, rule_lines: self.header.rule_lines, objects, @@ -1516,7 +1496,8 @@ impl CanvasEditor { .collect() } - pub fn from_outlines( + #[cfg(test)] + pub(crate) fn from_outlines( engine: &mut TextEngine, outlines: Vec, definitions: BTreeMap, @@ -1631,14 +1612,15 @@ impl CanvasEditor { return false; } self.finish_composition(); - let mut entries = Vec::new(); + let depth = self.undo.len(); if paper { - entries.push(self.paper(color, rule_lines)); + let entry = self.paper(color, rule_lines); + self.undo.push(entry); } if let Some(art) = art { for index in backgrounds.into_iter().rev() { let image = self.remove_picture(index); - entries.push(History::Picture { + self.undo.push(History::Picture { index, image: Some(Box::new(image)), }); @@ -1646,19 +1628,13 @@ impl CanvasEditor { // Art lies under everything else on the page. for image in art.into_iter().rev() { self.add_picture(0, image); - entries.push(History::Picture { + self.undo.push(History::Picture { index: 0, image: None, }); } } - self.undo.push(match entries.len() { - 1 => entries.pop().unwrap(), - _ => History::Group { - entries, - page: false, - }, - }); + self.group(depth, false); self.redo.clear(); true } @@ -1748,7 +1724,6 @@ impl CanvasEditor { } } - /// Provisional input geometry outside the document's outline collection. /// The caret a run of typing left while typing there still joins the run: the word /// before it is still being written. pub fn typing(&self) -> Option<(ExGuid, TextPosition)> { @@ -1759,6 +1734,7 @@ impl CanvasEditor { .then_some((outline, at)) } + /// Provisional input geometry outside the document's outline collection. pub fn caret_outline(&self) -> Option<&TextOutline> { match &self.active { Focus::Caret { outline, .. } => Some(outline), @@ -2020,9 +1996,8 @@ impl CanvasEditor { return Some(([x0, y0], [x1 - x0, y1 - y0])); } if let Some(image) = self.image(id) { - let layout = &image.layout; return Some(( - [layout.x.unwrap_or(0.0), layout.y.unwrap_or(0.0)], + crate::origin(&image.layout), crate::outline::image_size(image)?, )); } @@ -2034,7 +2009,7 @@ impl CanvasEditor { } /// Whether the picture sits in an outline's flow, where only its size can change. - pub fn image_in_outline(&self, id: ExGuid) -> bool { + pub(crate) fn image_in_outline(&self, id: ExGuid) -> bool { self.outline_picture(id).is_some() } @@ -2052,7 +2027,7 @@ impl CanvasEditor { } /// The address picture `id` links to, in an outline or on the page. - pub fn picture_link(&self, id: ExGuid) -> Option<&str> { + pub(crate) fn picture_link(&self, id: ExGuid) -> Option<&str> { let image = match self.image(id) { Some(image) => image, None => match &self.outline_picture(id)?.3.content { @@ -2064,7 +2039,7 @@ impl CanvasEditor { } /// The file a paragraph holds or the page shows, which the host opens and saves. - pub fn attachment(&self, id: ExGuid) -> Option<&onestore::page::Attachment> { + pub(crate) fn attachment(&self, id: ExGuid) -> Option<&onestore::page::Attachment> { if let Some((file, _)) = self.page_file(id) { return Some(file); } @@ -2083,15 +2058,14 @@ impl CanvasEditor { } /// Where a file draws with its name, in page coordinates. - pub fn attachment_rect(&self, id: ExGuid) -> Option<[f32; 4]> { + pub(crate) fn attachment_rect(&self, id: ExGuid) -> Option<[f32; 4]> { if let Some((_, rect)) = self.page_file(id) { return Some(rect); } let (outline, ..) = self.outline_picture(id)?; let outline = self.outlines.iter().find(|item| item.id == outline)?; - let [x0, y0, x1, y1] = outline.shaped.objects.iter().find(|o| o.id == id)?.bounds(); - let [x, y] = outline.origin(); - Some([x + x0, y + y0, x + x1, y + y1]) + let bounds = outline.shaped.objects.iter().find(|o| o.id == id)?.bounds(); + Some(crate::translated(bounds, outline.origin())) } /// Replaces a picture paragraph through the outline's text history. @@ -2231,7 +2205,7 @@ impl CanvasEditor { /// Leaves a picture as OneNote's Left and Right arrows do: the caret goes to the end of the /// text outline before it in page order, or to the start of the one after. False when there /// is none. - pub fn step_from_image( + pub(crate) fn step_from_image( &mut self, engine: &mut TextEngine, id: ExGuid, @@ -2394,15 +2368,7 @@ impl CanvasEditor { break; } } - let entries = self.undo.split_off(depth); - if entries.len() > 1 { - self.undo.push(History::Group { - entries, - page: false, - }); - } else { - self.undo.extend(entries); - } + self.group(depth, false); result.map(|()| awaited) } @@ -2414,7 +2380,11 @@ impl CanvasEditor { at: Awaited, image: onestore::page::Image, ) -> Result { - let Some(outline) = self.outlines.iter().find(|outline| outline.id == at.outline) else { + let Some(outline) = self + .outlines + .iter() + .find(|outline| outline.id == at.outline) + else { return Ok(false); }; let Some((container, index, node)) = descendants(outline.document.nodes(), None) @@ -2605,6 +2575,12 @@ impl CanvasEditor { self.header.margin_origin } + /// Whether the page runs right to left, which OneNote 2010 opens scrolled to its right + /// end. + pub fn rtl(&self) -> bool { + self.header.rtl + } + pub fn resize(&mut self, engine: &mut TextEngine, width: f32) -> Result<(), EditorError> { let outline = self.active_outline(); if outline.layout.reserved_width.or(outline.layout.max_width) == Some(width) @@ -2760,7 +2736,7 @@ impl CanvasEditor { } /// Selects outline `id` as an object, as a click on its handle does. - pub fn select_outline(&mut self, id: ExGuid) -> Result<(), EditError> { + pub(crate) fn select_outline(&mut self, id: ExGuid) -> Result<(), EditError> { self.focus_outline(id)?; let whole = self.active_outline().whole(); self.select(whole)?; @@ -2793,20 +2769,38 @@ impl CanvasEditor { Ok(()) } - /// Runs `edit` as one undo step. + /// Makes the history entries past `depth` one undo step, `page` when they edited OneNote's + /// page selection; false when there are none. Groups among them are flattened into it. + fn group(&mut self, depth: usize, mut page: bool) -> bool { + let mut entries = Vec::new(); + for entry in self.undo.split_off(depth) { + match entry { + History::Group { + entries: inner, + page: selected, + } => { + page |= selected; + entries.extend(inner); + } + entry => entries.push(entry), + } + } + match entries.len() { + 0 => return false, + 1 if !page => self.undo.extend(entries), + _ => self.undo.push(History::Group { entries, page }), + } + true + } + + /// Runs `edit`, an edit of OneNote's page selection, as one undo step. fn grouped( &mut self, edit: impl FnOnce(&mut Self) -> Result, ) -> Result { let depth = self.undo.len(); let result = edit(self); - let entries = self.undo.split_off(depth); - if !entries.is_empty() { - self.undo.push(History::Group { - entries, - page: true, - }); - } + self.group(depth, true); result } @@ -2828,13 +2822,7 @@ impl CanvasEditor { } let depth = self.undo.len(); let result = self.delete(engine, false).and_then(|_| edit(self, engine)); - let entries = self.undo.split_off(depth); - if !entries.is_empty() { - self.undo.push(History::Group { - entries, - page: false, - }); - } + self.group(depth, false); result.map(Some) } @@ -2858,27 +2846,8 @@ impl CanvasEditor { }); let depth = self.undo.len() - usize::from(joins); let result = edit(self); - let (mut run, mut page) = (Vec::new(), false); - for entry in self.undo.split_off(depth) { - match entry { - History::Group { - entries, - page: selected, - } => { - page |= selected; - if run.is_empty() { - run = entries; - } else { - run.extend(entries); - } - } - entry => run.push(entry), - } - } - match run.len() { - 0 => return result, - 1 if !page => self.undo.extend(run), - _ => self.undo.push(History::Group { entries: run, page }), + if !self.group(depth, false) { + return result; } if result.is_ok() { let caret = self.selection().positions[1]; @@ -2957,7 +2926,7 @@ impl CanvasEditor { }) } - pub fn select_at(&mut self, x: f32, y: f32, extend: bool) -> Result<(), EditError> { + pub(crate) fn select_at(&mut self, x: f32, y: f32, extend: bool) -> Result<(), EditError> { let mut selection = self.selection_at(x, y, SelectionUnit::Grapheme)?; if extend { selection.positions[0] = self.selection().positions[0]; @@ -3448,12 +3417,12 @@ impl CanvasEditor { Ok(()) } - pub fn selection_rects(&self) -> Result, EditError> { + pub(crate) fn selection_rects(&self) -> Result, EditError> { self.active_outline() .range_rects(self.active_outline().selection) } - pub fn marked_rects(&self) -> Result, EditError> { + pub(crate) fn marked_rects(&self) -> Result, EditError> { match &self.composition { Some(composition) => self.active_outline().range_rects(Selection { positions: [composition.range.start, composition.range.end], @@ -3506,7 +3475,11 @@ impl CanvasEditor { /// Replaces the selection with `text` in the formatting of the text it replaces, as a /// spelling correction does. - pub fn correct(&mut self, engine: &mut TextEngine, text: &str) -> Result<(), EditorError> { + pub(crate) fn correct( + &mut self, + engine: &mut TextEngine, + text: &str, + ) -> Result<(), EditorError> { let [anchor, focus] = self.selection().positions; let end = anchor.max(focus); let format = self @@ -3752,7 +3725,7 @@ impl CanvasEditor { /// Alt+Shift+Up or Down: swaps the selected paragraphs, with their children, and the /// sibling above or below with its children. Does nothing past the first or last sibling. - pub fn move_paragraphs( + pub(crate) fn move_paragraphs( &mut self, engine: &mut TextEngine, up: bool, @@ -3994,7 +3967,7 @@ impl CanvasEditor { /// Takes up `parked`, the editor this page had when it was last left, history and all, /// in place of this one: what storage changed since reaches it as `refresh` would. - pub fn resume( + pub(crate) fn resume( &mut self, mut parked: CanvasEditor, engine: &mut TextEngine, @@ -4928,9 +4901,6 @@ impl CanvasEditor { } } -/// Where `position` in `old` lies in `new`, the same outline changed elsewhere: in the same -/// paragraph, past what changed in its text when it lies after it; at the start of the -/// paragraph now at its place when that one is gone. /// Whether `content` is picture or file `id`. fn holds(content: &ParagraphContent, id: ExGuid) -> bool { match content { @@ -4940,6 +4910,9 @@ fn holds(content: &ParagraphContent, id: ExGuid) -> bool { } } +/// Where `position` in `old` lies in `new`, the same outline changed elsewhere: in the same +/// paragraph, past what changed in its text when it lies after it; at the start of the +/// paragraph now at its place when that one is gone. fn follow(old: &TextDocument, new: &TextDocument, position: TextPosition) -> TextPosition { let found = old.leaf(position.paragraph).and_then(|(_, _, node)| { let paragraph = new @@ -6447,6 +6420,7 @@ mod tests { identity: None, created: None, margin_origin: [36.0, 14.4], + rtl: false, color: None, rule_lines: None, definitions: BTreeMap::new(), @@ -6558,6 +6532,7 @@ mod tests { identity: None, created: None, margin_origin: [36.0, 14.4], + rtl: false, color: None, rule_lines: None, definitions: BTreeMap::new(), @@ -6656,6 +6631,7 @@ mod tests { identity: None, created: None, margin_origin: [36.0, 14.4], + rtl: false, color: None, rule_lines: None, definitions: BTreeMap::new(), @@ -6720,6 +6696,7 @@ mod tests { identity: None, created: None, margin_origin: [36.0, 14.4], + rtl: false, color: None, rule_lines: None, definitions: BTreeMap::new(), @@ -6840,6 +6817,7 @@ mod tests { identity: None, created: None, margin_origin: [36.0, 14.4], + rtl: false, color: None, rule_lines: None, definitions: BTreeMap::new(), @@ -6959,6 +6937,7 @@ mod tests { identity: None, created: None, margin_origin: [36.0, 14.4], + rtl: false, color: None, rule_lines: None, definitions: BTreeMap::new(), @@ -7060,6 +7039,7 @@ mod tests { identity: None, created: None, margin_origin: [36.0, 14.4], + rtl: false, color: None, rule_lines: None, definitions: BTreeMap::new(), @@ -10623,4 +10603,34 @@ mod tests { [[(0, 0), (0, 5)]; 6] ); } + + #[test] + fn pasting_over_the_page_selection_is_one_step_that_undo_selects_again() { + let mut engine = TextEngine::default(); + let lines = ["One", "Two"].map(|line| Paragraph::new(line.into(), Format::default())); + let mut editor = CanvasEditor::new( + &mut engine, + TextDocument::new(lines.to_vec()).unwrap(), + 400.0, + ) + .unwrap(); + editor.select_page().unwrap(); + editor + .paste_pieces( + &mut engine, + vec![ + Piece::Text("Pasted".into()), + Piece::Picture(vec![0; 8], [10.0; 2]), + ], + 0x409, + ) + .unwrap(); + assert_eq!(editor.history_depth(), [1, 0]); + let _ = editor.take_ops(); + let mut remote = editor.page().unwrap(); + remote.color = Some(0x00ff_ffff); + editor.refresh(remote, &mut engine).unwrap(); + assert!(editor.undo(&mut engine).unwrap()); + assert_eq!(editor.whole(), Some(Whole::Page)); + } } diff --git a/crates/canvas/src/editor/equation.rs b/crates/canvas/src/editor/equation.rs index d76775c96e8565819d09180990165e5eeb9cffe2..af683cfe03fbdd17e1cbc84f5f7c161c6cd24a35 100644 --- a/crates/canvas/src/editor/equation.rs +++ b/crates/canvas/src/editor/equation.rs @@ -186,7 +186,7 @@ impl CanvasEditor { } /// Whether the caret is in an equation, which Linear and Professional act on. - pub fn in_equation(&self) -> bool { + pub(crate) fn in_equation(&self) -> bool { self.caret_zone().is_some() } @@ -270,15 +270,7 @@ impl CanvasEditor { } Ok(()) })(); - let entries = self.undo.split_off(depth); - match entries.len() { - 0 => {} - 1 => self.undo.extend(entries), - _ => self.undo.push(History::Group { - entries, - page: false, - }), - } + self.group(depth, false); result } diff --git a/crates/canvas/src/editor/format.rs b/crates/canvas/src/editor/format.rs index af9e92437a5a14f3ae0878c601b2dbba243150cc..507d67a4ca6198effee9ba5d182af137ae604b05 100644 --- a/crates/canvas/src/editor/format.rs +++ b/crates/canvas/src/editor/format.rs @@ -208,8 +208,8 @@ impl DefaultFont { subscript: Some(false), font: Some(self.face.clone()), font_size: Some(self.size), - color: Some(self.color.unwrap_or(0xff00_0000)), - highlight: Some(0xff00_0000), + color: Some(self.color.unwrap_or(AUTOMATIC)), + highlight: Some(AUTOMATIC), space_before: Some(0.0), space_after: Some(0.0), line_spacing: Some(0.0), @@ -293,7 +293,7 @@ impl NoteTag { } /// The definition OneNote stores for the tag at place `action_type` in the list. - pub fn definition(&self, action_type: u16) -> Definition { + pub(crate) fn definition(&self, action_type: u16) -> Definition { Definition { kind: Kind::TagDefinition { label: Some(self.label.clone()), @@ -453,18 +453,18 @@ fn covered( }) } -pub(super) fn leaves_mut(nodes: &mut [PageParagraph], change: &mut impl FnMut(&mut PageParagraph)) { +pub(super) fn leaves_mut(nodes: &mut [PageParagraph]) -> Vec<&mut PageParagraph> { + let mut leaves = Vec::new(); for node in nodes { - match &mut node.content { - ParagraphContent::Text(_) => change(node), - ParagraphContent::Table(table) => { - for cell in table.rows.iter_mut().flat_map(|row| &mut row.cells) { - leaves_mut(&mut cell.paragraphs, change); - } + if node.text().is_some() { + leaves.push(node); + } else if let ParagraphContent::Table(table) = &mut node.content { + for cell in table.rows.iter_mut().flat_map(|row| &mut row.cells) { + leaves.extend(leaves_mut(&mut cell.paragraphs)); } - _ => {} } } + leaves } /// `text` with `change` applied to the formats of bytes `range`, or to its only format when empty. @@ -753,6 +753,7 @@ impl CanvasEditor { | Formatting::Tag(..) | Formatting::RemoveTags | Formatting::Check + | Formatting::Style(_) if title => { return Ok(()); @@ -763,7 +764,7 @@ impl CanvasEditor { } Formatting::Align(alignment) => { let bases = bases(&replacement)?; - leaves_mut(&mut replacement, &mut |node| { + for node in leaves_mut(&mut replacement) { if ranges.remove(&node.id).is_some() { let value = match alignment { Alignment::Left => bases[&node.id].alignment.map(|_| 0), @@ -775,7 +776,7 @@ impl CanvasEditor { format.alignment = value; }); } - }); + } } Formatting::Bullets | Formatting::Numbering | Formatting::List(_) => { let style = match command { @@ -821,7 +822,7 @@ impl CanvasEditor { self.definitions.insert(id, definition); lists.insert(node, id); } - leaves_mut(&mut replacement, &mut |node| { + for node in leaves_mut(&mut replacement) { if ranges.remove(&node.id).is_some() { if remove { node.lists.clear(); @@ -829,50 +830,40 @@ impl CanvasEditor { node.lists = vec![*id]; } } - }); + } } Formatting::Tag(tag, action_type) => { let definition = tag.definition(*action_type); - let existing = self - .definitions - .iter() - .find(|(_, other)| other.kind == definition.kind); - let id = match existing { - Some((id, _)) => *id, - None => { - let id = new_id()?; - self.definitions.insert(id, definition.clone()); - id - } - }; + let id = self.define_tag(&definition)?; let has = |node: &PageParagraph| { tags(node).any(|tag| self.tag_kind(tag) == Some(&definition.kind)) }; let remove = covered(&replacement, ends).all(|(node, _)| has(node)); let created = time32(); - leaves_mut(&mut replacement, &mut |node| { + for node in leaves_mut(&mut replacement) { if ranges.remove(&node.id).is_none() || !remove && has(node) { - return; + continue; } let ParagraphContent::Text(text) = &mut node.content else { unreachable!() }; let added = (!remove).then_some((id, tag.shape, created)); self.retag([&mut node.tags, &mut text.tags], &definition.kind, added); - }); + } } - Formatting::RemoveTags => leaves_mut(&mut replacement, &mut |node| { - if ranges.remove(&node.id).is_some() { - node.tags.clear(); - node.text_mut().unwrap().tags.clear(); + Formatting::RemoveTags => { + for node in leaves_mut(&mut replacement) { + if ranges.remove(&node.id).is_some() { + node.tags.clear(); + node.text_mut().unwrap().tags.clear(); + } } - }), - Formatting::Check => self.check(&mut replacement, ranges, ends), - Formatting::Style(_) if title => return Ok(()), + } + Formatting::Check => self.check(&mut replacement, &ranges), Formatting::Style(definition) => { let olds = bases(&replacement)?; let style = self.define_style(definition)?; - leaves_mut(&mut replacement, &mut |node| { + for node in leaves_mut(&mut replacement) { if ranges.remove(&node.id).is_some() { node.style = Some(style); let old = &olds[&node.id]; @@ -899,7 +890,7 @@ impl CanvasEditor { }; }); } - }); + } } Formatting::Toggle(_) | Formatting::Font(_) @@ -971,7 +962,7 @@ impl CanvasEditor { self.pending = Some((id, caret, format)); return Ok(()); } - leaves_mut(&mut replacement, &mut |node| { + for node in leaves_mut(&mut replacement) { if let Some(range) = ranges.remove(&node.id) { let base = &bases[&node.id]; let text = &mut node.text_mut().unwrap().text; @@ -984,7 +975,7 @@ impl CanvasEditor { }); } } - }); + } } } if self.active_outline().document.container(container)?[range.clone()] == replacement[..] { @@ -1018,6 +1009,20 @@ impl CanvasEditor { Ok(id) } + /// The page's definition of the tag `definition` describes, made where it has none. + fn define_tag(&mut self, definition: &Definition) -> Result { + if let Some((id, _)) = self + .definitions + .iter() + .find(|(_, other)| other.kind == definition.kind) + { + return Ok(*id); + } + let id = new_id()?; + self.definitions.insert(id, definition.clone()); + Ok(id) + } + /// A paragraph style named `name`: the page's in its theme's formatting, else the page's /// first of that name, else the theme's made on the page; Normal falls back to the /// Default font's. None where nothing names it. @@ -1100,7 +1105,7 @@ impl CanvasEditor { let ranges = covered(&replacement, ends) .map(|(node, range)| (node.id, range)) .collect(); - self.check(&mut replacement, ranges, ends); + self.check(&mut replacement, &ranges); let selection = outline.selection; self.commit( engine, @@ -1176,19 +1181,7 @@ impl CanvasEditor { match command { Formatting::Tag(tag, action_type) => { let definition = tag.definition(*action_type); - let existing = self - .definitions - .iter() - .find(|(_, other)| other.kind == definition.kind) - .map(|(id, _)| *id); - let defined = match existing { - Some(id) => id, - None => { - let id = new_id()?; - self.definitions.insert(id, definition.clone()); - id - } - }; + let defined = self.define_tag(&definition)?; let has = tags .iter() .any(|tag| self.tag_kind(tag) == Some(&definition.kind)); @@ -1284,7 +1277,8 @@ impl CanvasEditor { ); } - /// Checks the check boxes among `tags`, or clears them once all are checked. + /// Checks the check boxes among `tags`, or clears them once all are checked; OneNote keeps + /// a cleared box's completion time as zero. fn toggle_checks(&self, mut tags: Vec<&mut Tag>) { let checkable = |tag: &Tag| { matches!( @@ -1307,39 +1301,19 @@ impl CanvasEditor { } /// Checks the check boxes of the `covered` paragraphs, or clears them once all are - /// checked; OneNote keeps a cleared box's completion time as zero. - fn check( - &self, - replacement: &mut [PageParagraph], - mut covered: BTreeMap>, - ends: Ends, - ) { - let checkable = |tag: &Tag| { - matches!( - self.tag_kind(tag), - Some(Kind::TagDefinition { shape: Some(shape), .. }) - if crate::outline::checkable(*shape) - ) - }; - let checked = self::covered(replacement, ends) - .flat_map(|(node, _)| tags(node)) - .filter(|tag| checkable(tag)) - .all(|tag| tag.status & 1 != 0); - let completed = if checked { Some(0) } else { time32() }; - leaves_mut(replacement, &mut |node| { - if covered.remove(&node.id).is_none() { - return; - } - let ParagraphContent::Text(text) = &mut node.content else { - unreachable!() - }; - for tag in node.tags.iter_mut().chain(&mut text.tags) { - if checkable(tag) && (tag.status & 1 != 0) == checked { - tag.status ^= 1; - tag.completed = completed; - } - } - }); + /// checked. + fn check(&self, replacement: &mut [PageParagraph], covered: &BTreeMap>) { + let tags = leaves_mut(replacement) + .into_iter() + .filter(|node| covered.contains_key(&node.id)) + .flat_map(|node| { + let ParagraphContent::Text(text) = &mut node.content else { + unreachable!() + }; + node.tags.iter_mut().chain(&mut text.tags) + }) + .collect(); + self.toggle_checks(tags); } } diff --git a/crates/canvas/src/editor/ink.rs b/crates/canvas/src/editor/ink.rs index 2045df5a565d715061bb4f3add8d7e32a9de78c1..5d0b753f07679806706820575086b535eb6454e8 100644 --- a/crates/canvas/src/editor/ink.rs +++ b/crates/canvas/src/editor/ink.rs @@ -35,7 +35,7 @@ impl Pen { } } - pub const fn highlighter(color: u32) -> Self { + pub(crate) const fn highlighter(color: u32) -> Self { Self { width: 70.0, color: Some(color), @@ -130,25 +130,8 @@ impl CanvasEditor { let page::Content::Ink(ink) = &self.objects[index] else { continue; }; - let offset = page::ink_offset(ink); - let touched = |stroke: &InkStroke| { - let reach = reach + stroke.width.max(stroke.height) / 2.0; - let mut points = stroke - .points - .iter() - .map(|p| [p[0] + offset[0], p[1] + offset[1]]); - let Some(mut previous) = points.next() else { - return false; - }; - if segments_distance([previous, previous], [from, to]) <= reach { - return true; - } - points.any(|point| { - let near = segments_distance([previous, point], [from, to]) <= reach; - previous = point; - near - }) - }; + let offset = crate::origin(&ink.layout); + let touched = |stroke: &InkStroke| stroke_near(stroke, offset, [from, to], reach); let (kept, erased): (Vec, Vec) = ink .strokes .iter() @@ -216,13 +199,13 @@ impl CanvasEditor { /// The page's drawings, on top first, with most of their points inside `lasso`, a /// closed path in page points. - pub fn ink_within(&self, lasso: &[[f32; 2]]) -> Vec { + pub(crate) fn ink_within(&self, lasso: &[[f32; 2]]) -> Vec { self.objects .iter() .rev() .filter_map(|object| match object { page::Content::Ink(ink) => { - let offset = page::ink_offset(ink); + let offset = crate::origin(&ink.layout); let points = points(ink); let inside = points .iter() @@ -236,28 +219,11 @@ impl CanvasEditor { } /// The drawing on top whose strokes pass within `reach` of `point`. - pub fn ink_at(&self, point: [f32; 2], reach: f32) -> Option { + pub(crate) fn ink_at(&self, point: [f32; 2], reach: f32) -> Option { self.objects.iter().rev().find_map(|object| match object { page::Content::Ink(ink) => { - let offset = page::ink_offset(ink); - let near = |stroke: &InkStroke| { - let reach = reach + stroke.width.max(stroke.height) / 2.0; - let mut points = stroke - .points - .iter() - .map(|p| [p[0] + offset[0], p[1] + offset[1]]); - let first = points.next(); - first.is_some_and(|first| { - let mut previous = first; - segments_distance([first, first], [point, point]) <= reach - || points.any(|p| { - let near = - segments_distance([previous, p], [point, point]) <= reach; - previous = p; - near - }) - }) - }; + let offset = crate::origin(&ink.layout); + let near = |stroke: &InkStroke| stroke_near(stroke, offset, [point, point], reach); (ink.strokes.iter().any(near) || ink.groups.iter().any(|g| group_touched(g, &near))) .then_some(ink.id) } @@ -271,11 +237,8 @@ impl CanvasEditor { self.objects .iter() .filter_map(|object| match object { - page::Content::Ink(ink) if ids.contains(&ink.id) => { - let [dx, dy] = self.ink_drag_offset(ink.id); - page::ink_bounds(ink) - .map(|[x0, y0, x1, y1]| [x0 + dx, y0 + dy, x1 + dx, y1 + dy]) - } + page::Content::Ink(ink) if ids.contains(&ink.id) => page::ink_bounds(ink) + .map(|bounds| crate::translated(bounds, self.ink_drag_offset(ink.id))), _ => None, }) .reduce(|a, b| { @@ -290,7 +253,7 @@ impl CanvasEditor { /// Shows drawings dragged `delta` from where they lie, without storing it, until the /// drag ends with `move_ink` or `None`. - pub fn drag_ink(&mut self, drag: Option<(Vec, [f32; 2])>) { + pub(crate) fn drag_ink(&mut self, drag: Option<(Vec, [f32; 2])>) { self.ink_drag = drag; } @@ -427,6 +390,25 @@ impl CanvasEditor { } } +/// Whether `stroke`, of a drawing at `offset`, passes within `reach` of segment `path`, its pen's +/// width included. +fn stroke_near(stroke: &InkStroke, offset: [f32; 2], path: [[f32; 2]; 2], reach: f32) -> bool { + let reach = reach + stroke.width.max(stroke.height) / 2.0; + let mut points = stroke + .points + .iter() + .map(|p| [p[0] + offset[0], p[1] + offset[1]]); + let Some(mut previous) = points.next() else { + return false; + }; + segments_distance([previous, previous], path) <= reach + || points.any(|point| { + let near = segments_distance([previous, point], path) <= reach; + previous = point; + near + }) +} + fn group_touched(ink: &Ink, touched: &impl Fn(&InkStroke) -> bool) -> bool { ink.strokes.iter().any(touched) || ink.groups.iter().any(|group| group_touched(group, touched)) } diff --git a/crates/canvas/src/editor/link.rs b/crates/canvas/src/editor/link.rs index add6d2a2c7d587803ae4476380fcb888d4b34be8..d765e7328306bf5ba6b6542ea2c0da2bf375d349 100644 --- a/crates/canvas/src/editor/link.rs +++ b/crates/canvas/src/editor/link.rs @@ -3,18 +3,18 @@ //! typed and ended by a space or Enter becomes a link of its own text, typing after a label //! is plain text, and Remove Link leaves the label as plain text. -use super::format::{leaves_mut, restyle, selected}; +use super::format::restyle; use super::*; /// A link in a paragraph, in UTF-16 offsets of its stored text. #[derive(Clone, Debug, PartialEq, Eq)] pub struct Link { - pub paragraph: usize, + pub(crate) paragraph: usize, /// The hidden field code naming the address, before the label; none where the label is /// its own address. - pub code: Option>, + pub(crate) code: Option>, pub label: Range, - pub target: String, + pub(crate) target: String, } impl Link { @@ -193,7 +193,7 @@ impl CanvasEditor { /// The address of the link drawn under document point `point` of outline `id`, which a /// click opens as OneNote's does. - pub fn link_under(&self, id: ExGuid, [x, y]: [f32; 2]) -> Option { + pub(crate) fn link_under(&self, id: ExGuid, [x, y]: [f32; 2]) -> Option { let outline = self.visible_outlines().find(|outline| outline.id == id)?; let index = outline.paragraph_at(x, y).ok()?; let paragraph = &outline.shaped.paragraphs[index]; @@ -414,7 +414,7 @@ impl CanvasEditor { } /// Select Link: the label of the link at the caret. - pub fn select_link(&mut self) -> Result { + pub(crate) fn select_link(&mut self) -> Result { let [anchor, focus] = self.active_outline().selection.positions; let Some(link) = self.link_at(anchor.min(focus)) else { return Ok(false); @@ -491,25 +491,20 @@ impl CanvasEditor { text: Paragraph, selection: Selection, ) -> Result<(), EditorError> { - let document = &self.active_outline().document; - let at = TextPosition { - paragraph, - offset: 0, - }; - let (container, range, [(id, _), _]) = selected(document, [at; 2].into())?; - let mut replacement = document.container(container)?[range.clone()].to_vec(); - leaves_mut(&mut replacement, &mut |node| { - if node.id == id { - node.text_mut().unwrap().text = text.clone(); - } - }); + let (container, index, node) = self + .active_outline() + .document + .leaf(paragraph) + .ok_or(EditError::InvalidRange)?; + let mut node = node.clone(); + node.text_mut().unwrap().text = text; self.commit( engine, DocumentEdit { columns: BTreeMap::new(), container, - range, - replacement, + range: index..index + 1, + replacement: vec![node], }, selection, ) diff --git a/crates/canvas/src/editor/ops.rs b/crates/canvas/src/editor/ops.rs index 0860c4102ec7da943753d0d5ef1a71cce28515a6..803123ce38e89b0508838b86492241e45de88ee0 100644 --- a/crates/canvas/src/editor/ops.rs +++ b/crates/canvas/src/editor/ops.rs @@ -285,7 +285,6 @@ pub(super) fn layout_ops(id: ExGuid, old: &Layout, new: &Layout) -> Lowered { Ok(ops) } -/// A page picture's stored position, size and description. /// The op giving `target` `tags`, with the definitions they name. pub(super) fn tags( target: ExGuid, @@ -306,6 +305,7 @@ pub(super) fn tags( }] } +/// A page picture's stored position, size and description. pub(super) fn picture_layout(image: &onestore::page::Image) -> Vec { vec![PageOp::Picture { picture: image.id, @@ -404,25 +404,17 @@ impl CanvasEditor { return layout_ops(outline.id, &old, &moved(&outline.layout)); } for object in &self.objects { - match object { - page::Content::Image(image) if image.id == placement.id => return picture(image), - page::Content::Ink(ink) if ink.id == placement.id => { - return layout_ops(ink.id, &old, &moved(&ink.layout)); + if let Some(image) = object.picture().filter(|image| image.id == placement.id) { + return picture(image); + } + match (object, object.layout()) { + (page::Content::ReadOnly(object), Some((id, _))) + if id == placement.id && !matches!(object.source, PageObject::Outline(_)) => + { + return Err(refused("Unsupported objects cannot be moved")); } - page::Content::File { source, .. } if source.id == placement.id => { - return layout_ops(source.id, &old, &moved(&source.layout)); - } - page::Content::Outline { source, .. } if source.id == placement.id => { - return layout_ops(source.id, &old, &moved(&source.layout)); - } - page::Content::ReadOnly(object) if object.source.id() == placement.id => { - return match &object.source { - PageObject::Outline(source) => { - layout_ops(source.id, &old, &moved(&source.layout)) - } - PageObject::Image(image) => picture(image), - _ => Err(refused("Unsupported objects cannot be moved")), - }; + (_, Some((id, layout))) if id == placement.id => { + return layout_ops(id, &old, &moved(layout)); } _ => {} } @@ -467,6 +459,7 @@ impl CanvasEditor { identity: None, created: None, margin_origin: [0.0; 2], + rtl: false, color: None, rule_lines: None, objects, diff --git a/crates/canvas/src/editor/page.rs b/crates/canvas/src/editor/page.rs index dd0b87d96f241f0c04141c58ac7da1a204974647..407cffec76e14c78b84c9b41399f9eda87ae26af 100644 --- a/crates/canvas/src/editor/page.rs +++ b/crates/canvas/src/editor/page.rs @@ -10,19 +10,19 @@ use std::collections::BTreeMap; /// A title object's own state, plus the child origins `build` replaces with page coordinates. pub(crate) struct TitleArea { - pub id: onestore::ExGuid, - pub date: Option, - pub layout: onestore::document::Layout, - pub origins: BTreeMap; 2]>, + pub(crate) id: onestore::ExGuid, + pub(crate) date: Option, + pub(crate) layout: onestore::document::Layout, + pub(crate) origins: BTreeMap; 2]>, } pub(crate) struct Import { - pub objects: Vec, - pub outlines: Vec, - pub date: Option, - pub areas: Vec, + pub(crate) objects: Vec, + pub(crate) outlines: Vec, + pub(crate) date: Option, + pub(crate) areas: Vec, /// The paragraph each outline holding no text shows after its objects, not stored. - pub provisional: BTreeMap, + pub(crate) provisional: BTreeMap, } pub(crate) enum Content { @@ -95,9 +95,8 @@ impl Content { let Self::File { source, layout } = self else { return None; }; - let [x, y] = [source.layout.x, source.layout.y].map(|v| v.unwrap_or(0.0)); - let [x0, y0, x1, y1] = layout.bounds(); - Some((source, [x + x0, y + y0, x + x1, y + y1])) + let origin = crate::origin(&source.layout); + Some((source, crate::translated(layout.bounds(), origin))) } /// A tagged picture or file: its identity, note tags and bounds in page points. @@ -106,7 +105,7 @@ impl Content { ) -> Option<(onestore::ExGuid, &[onestore::document::Tag], [f32; 4])> { let (id, tags, bounds) = match self { Self::Image(image) => { - let [x, y] = [image.layout.x, image.layout.y].map(|v| v.unwrap_or(0.0)); + let [x, y] = crate::origin(&image.layout); let [width, height] = crate::outline::image_size(image)?; (image.id, &image.tags, [x, y, x + width, y + height]) } @@ -143,14 +142,11 @@ impl ReadOnlyObject { [0.0; 2] }; let layout = source.layout(); - let x = layout.x.unwrap_or(0.0) + offset[0]; - let y = layout.y.unwrap_or(0.0) + offset[1]; let width = layout.max_width.unwrap_or(160.0); let height = layout.max_height.unwrap_or(42.0); - if [x, y, width, height].iter().any(|v| !v.is_finite()) || width <= 0.0 || height <= 0.0 { + if ![width, height].iter().all(|v| v.is_finite() && *v > 0.0) { return Err(EditorError::InvalidGeometry); } - let width = width.max(160.0); let label = engine.layout( &Paragraph::new( message.into(), @@ -160,21 +156,21 @@ impl ReadOnlyObject { ..Default::default() }, ), - width - 16.0, + width.max(160.0) - 16.0, )?; - let height = height.max(label.height() + 16.0); - let rect = [x, y, x + width, y + height]; - if rect.iter().any(|v| !v.is_finite()) { - return Err(EditorError::InvalidGeometry); - } - Ok(Box::new(Self { + let object = Box::new(Self { source, message, label, offset, - })) + }); + if object.rect().iter().any(|v| !v.is_finite()) { + return Err(EditorError::InvalidGeometry); + } + Ok(object) } - pub fn rect(&self) -> [f32; 4] { + + pub(crate) fn rect(&self) -> [f32; 4] { let layout = self.source.layout(); let x = layout.x.unwrap_or(0.0) + self.offset[0]; let y = layout.y.unwrap_or(0.0) + self.offset[1]; @@ -206,10 +202,7 @@ pub(crate) fn build( for object in std::mem::take(&mut page.objects) { match &object { PageObject::Outline(outline) => { - let origin = [ - outline.layout.x.unwrap_or(0.0), - outline.layout.y.unwrap_or(0.0), - ]; + let origin = crate::origin(&outline.layout); if origin.iter().any(|v| !v.is_finite()) { return Err(EditorError::InvalidGeometry); } @@ -377,10 +370,7 @@ pub(crate) fn build( objects.push(Content::unavailable(object, engine)?); continue; }; - let origin = [ - source.layout.x.unwrap_or(0.0), - source.layout.y.unwrap_or(0.0), - ]; + let origin = crate::origin(&source.layout); if origin.iter().any(|v| !v.is_finite()) || size.iter().any(|v| !v.is_finite() || *v <= 0.0) || !(origin[0] + size[0]).is_finite() @@ -435,12 +425,7 @@ pub(crate) fn build( /// The painted extent of a page's ink drawing, pen included, as `[x0, y0, x1, y1]` page /// points. pub(crate) fn ink_bounds(ink: &Ink) -> Option<[f32; 4]> { - let [x, y] = ink_offset(ink); - stroke_bounds(ink).map(|[x0, y0, x1, y1]| [x0 + x, y0 + y, x1 + x, y1 + y]) -} - -pub(crate) fn ink_offset(ink: &Ink) -> [f32; 2] { - [ink.layout.x.unwrap_or(0.0), ink.layout.y.unwrap_or(0.0)] + stroke_bounds(ink).map(|bounds| crate::translated(bounds, crate::origin(&ink.layout))) } /// The painted extent of every stroke before the drawing's offset. diff --git a/crates/canvas/src/editor/recording.rs b/crates/canvas/src/editor/recording.rs index 7093635c1c82c3f5c86b8d44fcc662dd1e307f1a..4149634ba7bdc414d6d70af2184e857b9ca2559d 100644 --- a/crates/canvas/src/editor/recording.rs +++ b/crates/canvas/src/editor/recording.rs @@ -259,7 +259,7 @@ const BUTTON: [f32; 2] = [10.5, 24.0]; impl CanvasEditor { /// The recording `id` names, in an outline or on the page. - pub fn recording_file(&self, id: [u8; 16]) -> Option<&Attachment> { + pub(crate) fn recording_file(&self, id: [u8; 16]) -> Option<&Attachment> { let named = |file: &&Attachment| file.recording.is_some_and(|r| r.id == id); self.objects .iter() @@ -293,7 +293,7 @@ impl CanvasEditor { /// The play button OneNote shows for document point `point`: beside the linked note or /// the recording level with it, over its outline or in the margin before it. Gives the /// button's square, the recording and the moment it plays from. - pub fn play_button(&self, point: [f32; 2]) -> Option<([f32; 4], [u8; 16], u32)> { + pub(crate) fn play_button(&self, point: [f32; 2]) -> Option<([f32; 4], [u8; 16], u32)> { let [x, y] = point; let outline = self.visible_outlines().find(|outline| { let bounds = outline.bounds(); diff --git a/crates/canvas/src/editor/space.rs b/crates/canvas/src/editor/space.rs index 0b5de50edeb65abfc58b5e5bf49e462728b9203a..cda163046025f08c1187d7827efd16a488ec6d93 100644 --- a/crates/canvas/src/editor/space.rs +++ b/crates/canvas/src/editor/space.rs @@ -68,7 +68,7 @@ impl CanvasEditor { layout, below_title: None, } => { - let [x, y] = [source.layout.x, source.layout.y].map(|v| v.unwrap_or(0.0)); + let [x, y] = crate::origin(&source.layout); (source.id, [x, y, x + layout.size[0], y + layout.size[1]]) } page::Content::Image(image) => { @@ -109,13 +109,13 @@ impl CanvasEditor { /// How far back from `line` Insert Space can take the page along `axis`: never over what /// stays before the line, so at most zero. - pub fn space_limit(&self, axis: usize, line: f32) -> f32 { + pub(crate) fn space_limit(&self, axis: usize, line: f32) -> f32 { (self.spaced(axis, line).1 - line).min(0.0) } /// The extents, as `[x0, y0, x1, y1]` page points, of what Insert Space at `line` along /// `axis` moves. - pub fn space_preview(&self, axis: usize, line: f32) -> Vec<[f32; 4]> { + pub(crate) fn space_preview(&self, axis: usize, line: f32) -> Vec<[f32; 4]> { self.spaced(axis, line) .0 .into_iter() @@ -156,7 +156,7 @@ impl CanvasEditor { let layout = self.object_layout_mut(id).unwrap(); // Undo restores an unset coordinate as the zero it stands for, as ops // cannot unset one. - let origin = [layout.x, layout.y].map(|v| v.unwrap_or(0.0)); + let origin = crate::origin(layout); let previous = origin.map(Some); [layout.x, layout.y] = shift(origin).map(Some); self.undo.push(History::Position { @@ -182,27 +182,16 @@ impl CanvasEditor { } }; if let Err(error) = result { - self.group(depth); + self.group(depth, false); return Err(error); } } - self.group(depth); + self.group(depth, false); self.redo.clear(); self.preferred_x = None; Ok(true) } - /// Makes the history entries past `depth` one undo step that leaves the selection alone. - fn group(&mut self, depth: usize) { - let entries = self.undo.split_off(depth); - if !entries.is_empty() { - self.undo.push(History::Group { - entries, - page: false, - }); - } - } - /// Parts outline `id`'s root nodes from `at` into new outline `part` at `position`, which /// takes the rest of the outline's layout; returns each root `part` then holds with the /// parent it had. diff --git a/crates/canvas/src/editor/table.rs b/crates/canvas/src/editor/table.rs index 243170bf8c5dd8b62dace0d22d9f22abbf25e9a7..ec41efe676b9f9de3945bc54a46c0317827c05c1 100644 --- a/crates/canvas/src/editor/table.rs +++ b/crates/canvas/src/editor/table.rs @@ -65,16 +65,59 @@ fn empty_cell(source: &TableCell) -> Result { Default::default(), )?, }; + new_cell(source.indents.clone(), source.shading, vec![node]) +} + +/// A new cell of `paragraphs`. +fn new_cell( + indents: Vec, + shading: Option, + paragraphs: Vec, +) -> Result { Ok(TableCell { id: new_id()?, layout: Default::default(), - indents: source.indents.clone(), - shading: source.shading, - paragraphs: vec![node], + indents, + shading, + paragraphs, unsupported: Vec::new(), }) } +/// A new bordered table of `rows` and `columns` new columns, in `source`'s place in the +/// outline's tree. +fn table_paragraph( + source: &PageParagraph, + columns: usize, + rows: Vec, +) -> Result { + Ok(PageParagraph { + id: new_id()?, + parent: source.parent, + level: source.level, + style: None, + format: source.format.clone(), + lists: Vec::new(), + tags: Vec::new(), + media: Default::default(), + collapsed: false, + content: ParagraphContent::Table(Table { + id: new_id()?, + columns: vec![ + TableColumn { + width: COLUMN_WIDTH, + locked: false + }; + columns + ], + rows, + borders: Some(true), + layout: Default::default(), + tags: Vec::new(), + }), + }) +} + fn cell_range(document: &TextDocument, cell: &TableCell) -> Result, EditError> { let mut content = leaves(&cell.paragraphs, None); let (_, _, first) = content.next().ok_or(EditError::InvalidStructure)?; @@ -250,7 +293,11 @@ impl CanvasEditor { impl TextOutline { /// The table column whose right border lies within `reach` of outline-local `point`, as /// `(table, column, width)`. - pub fn column_border(&self, point: [f32; 2], reach: f32) -> Option<(ExGuid, usize, f32)> { + pub(crate) fn column_border( + &self, + point: [f32; 2], + reach: f32, + ) -> Option<(ExGuid, usize, f32)> { let columns = descendants(self.document.nodes(), None) .filter_map(|(_, _, node)| match &node.content { ParagraphContent::Table(table) => Some((table.id, table.columns.len())), @@ -276,7 +323,7 @@ impl TextOutline { impl CanvasEditor { /// The active outline with column `column` of `table` `width` wide, as a border drag /// shows it before release. - pub fn preview_column( + pub(crate) fn preview_column( &self, engine: &mut TextEngine, table: ExGuid, @@ -383,53 +430,23 @@ impl CanvasEditor { let text = &node.text().unwrap().text; let end = text.utf16_offset(text.text().len())?; let format = text.format_at(caret.offset)?; - let cell = || -> Result { + let empty = || -> Result { let mut paragraph = crate::document::node( Paragraph::new(String::new(), format.clone()), node.format.clone(), )?; paragraph.style = node.style; - Ok(TableCell { - id: new_id()?, - layout: Default::default(), - indents: outline.indents.clone(), - shading: None, - paragraphs: vec![paragraph], - unsupported: Vec::new(), + new_cell(outline.indents.clone(), None, vec![paragraph]) + }; + let rows = (0..rows) + .map(|_| { + Ok(TableRow { + id: new_id()?, + cells: (0..columns).map(|_| empty()).collect::>()?, + }) }) - }; - let table = PageParagraph { - id: new_id()?, - parent: node.parent, - level: node.level, - style: None, - format: node.format.clone(), - lists: Vec::new(), - tags: Vec::new(), - media: Default::default(), - collapsed: false, - content: ParagraphContent::Table(onestore::page::Table { - id: new_id()?, - columns: vec![ - TableColumn { - width: COLUMN_WIDTH, - locked: false - }; - columns - ], - rows: (0..rows) - .map(|_| { - Ok(TableRow { - id: new_id()?, - cells: (0..columns).map(|_| cell()).collect::>()?, - }) - }) - .collect::>()?, - borders: Some(true), - layout: Default::default(), - tags: Vec::new(), - }), - }; + .collect::>()?; + let table = table_paragraph(node, columns, rows)?; let parent = |candidate: &PageParagraph| candidate.parent == Some(node.id); let childless = !outline.document.container(container)?.iter().any(parent); let (edit, first) = if end == 0 && childless { @@ -489,12 +506,8 @@ impl CanvasEditor { let format = source.text().unwrap().text.format_at(focus.offset)?; let mut split = outline.document.replace( focus..focus, - vec![ - Paragraph::new(String::new(), format.clone()), - Paragraph::new(String::new(), format.clone()), - ], + vec![Paragraph::new(String::new(), format.clone()); 2], )?; - let id = new_id()?; // The table's paragraph holds the list, tags and children (`evidence/structural-edits/ // xml/c6-*-midtab.xml`, `c10-tab-parent-1.xml`). let children = split.replacement.split_off(2); @@ -511,44 +524,20 @@ impl CanvasEditor { paragraph.level = 1; paragraph.parent = None; paragraph.lists.clear(); - Ok(TableCell { - id: new_id()?, - layout: Default::default(), - indents: outline.indents.clone(), - shading: None, - paragraphs: vec![paragraph], - unsupported: Vec::new(), - }) + new_cell(outline.indents.clone(), None, vec![paragraph]) }) .collect::, EditError>>()?; + let row = TableRow { + id: new_id()?, + cells, + }; let wrapper = PageParagraph { - id, - parent: source.parent, - level: source.level, - style: None, - format: source.format.clone(), lists, tags, - media: Default::default(), collapsed, - content: ParagraphContent::Table(onestore::page::Table { - id: new_id()?, - columns: vec![ - TableColumn { - width: COLUMN_WIDTH, - locked: false - }; - 2 - ], - rows: vec![TableRow { - id: new_id()?, - cells, - }], - borders: Some(true), - layout: Default::default(), - tags: Vec::new(), - }), + ..table_paragraph(source, 2, vec![row])? }; + let id = wrapper.id; split.replacement.push(wrapper); split .replacement @@ -598,22 +587,12 @@ impl CanvasEditor { let format = source.text().unwrap().text.format_at(focus.offset)?; let split = outline.document.replace( focus..focus, - vec![ - Paragraph::new(String::new(), format.clone()), - Paragraph::new(String::new(), format.clone()), - ], + vec![Paragraph::new(String::new(), format.clone()); 2], )?; - let cell = &mut table.rows[0].cells[location.column]; - cell.paragraphs.splice(split.range, split.replacement); - let paragraphs = cell.paragraphs.split_off(local + 1); - let target = TableCell { - id: new_id()?, - layout: Default::default(), - indents: cell.indents.clone(), - shading: cell.shading, - paragraphs, - unsupported: Vec::new(), - }; + let source = &mut table.rows[0].cells[location.column]; + source.paragraphs.splice(split.range, split.replacement); + let paragraphs = source.paragraphs.split_off(local + 1); + let target = new_cell(source.indents.clone(), source.shading, paragraphs)?; table.rows[0].cells.push(target); table.columns.push(TableColumn { width: COLUMN_WIDTH, @@ -722,7 +701,7 @@ impl CanvasEditor { let ParagraphContent::Table(table) = &mut wrapper.content else { unreachable!() }; - let (replacement, next) = if exit { + let replacement = if exit { table.rows.pop(); let blank = || { Ok::<_, EditError>(PageParagraph { @@ -732,14 +711,7 @@ impl CanvasEditor { ..self.blank_paragraph(source)? }) }; - let (first, second) = (blank()?, blank()?); - ( - vec![wrapper, first, second], - TextPosition { - paragraph: focus.paragraph + 1, - offset: 0, - }, - ) + vec![wrapper, blank()?, blank()?] } else { let cells = row .cells @@ -750,13 +722,11 @@ impl CanvasEditor { id: new_id()?, cells, }); - ( - vec![wrapper], - TextPosition { - paragraph: focus.paragraph + 1, - offset: 0, - }, - ) + vec![wrapper] + }; + let next = TextPosition { + paragraph: focus.paragraph + 1, + offset: 0, }; self.commit( engine, diff --git a/crates/canvas/src/gpu/page.rs b/crates/canvas/src/gpu/page.rs index e08881737341bb0d2206f1a8e3c37563688c9bb3..9142cd115e8945e2db9350aea161d90f7909b76f 100644 --- a/crates/canvas/src/gpu/page.rs +++ b/crates/canvas/src/gpu/page.rs @@ -301,7 +301,7 @@ impl PageScene { /// Follows `editor` after an edit or undo brought page-level pictures the scene has not /// seen, as `refresh` does. - pub fn follow( + pub(crate) fn follow( &mut self, editor: &mut CanvasEditor, engine: &mut TextEngine, @@ -415,7 +415,7 @@ impl PageScene { /// this thread; `waker` is woken when one lands and the page should be drawn again. /// Call before collecting each frame's primitives. True once everything in view shows /// its raster for this paper and scale. - pub fn update_pictures( + pub(crate) fn update_pictures( &mut self, editor: Option<&CanvasEditor>, view: [f32; 4], @@ -468,37 +468,32 @@ impl PageScene { [x, y]: [f32; 2], rects: &mut Vec<(onestore::ExGuid, [f32; 4])>, ) { - rects.extend(layout.objects.iter().map(|object| { - let [x0, y0, x1, y1] = object.rect; - (object.id, [x0 + x, y0 + y, x1 + x, y1 + y]) - })); + rects.extend( + layout + .objects + .iter() + .map(|object| (object.id, crate::translated(object.rect, [x, y]))), + ); } let mut rects = Vec::new(); for content in self.objects(editor).into_iter().flatten() { match content { Content::Image(source) => { - let [x, y] = [ - source.layout.x.unwrap_or(0.0), - source.layout.y.unwrap_or(0.0), - ]; + let [x, y] = crate::origin(&source.layout); if let Some([width, height]) = crate::outline::image_size(source) { rects.push((source.id, [x, y, x + width, y + height])); } } Content::File { source, layout } => { - let [x, y] = [source.layout.x, source.layout.y].map(|v| v.unwrap_or(0.0)); - let [x0, y0, x1, y1] = layout.rect; - rects.push((source.id, [x + x0, y + y0, x + x1, y + y1])); + let origin = crate::origin(&source.layout); + rects.push((source.id, crate::translated(layout.rect, origin))); } Content::Outline { source, layout, below_title, } => { - let origin = [ - source.layout.x.unwrap_or(0.0), - source.layout.y.unwrap_or(0.0), - ]; + let origin = crate::origin(&source.layout); if let Ok(origin) = outline_origin(origin, *below_title, editor) { outline(layout, origin, &mut rects); } @@ -518,7 +513,7 @@ impl PageScene { /// Pictures, files and handwriting inside an outline whose origin is `origin`. /// `moving` draws one picture at a previewed rectangle, in the same coordinates as `origin`. - pub fn append_outline_objects<'a>( + pub(crate) fn append_outline_objects<'a>( &'a self, outline: &'a crate::outline::OutlineLayout, origin: [f32; 2], @@ -541,15 +536,9 @@ impl PageScene { paper: Paper, primitives: &mut Vec>, ) { - let [x0, y0, x1, y1] = object.rect; let rect = match moving { Some((id, rect)) if id == object.id => rect, - _ => [ - x0 + origin[0], - y0 + origin[1], - x1 + origin[0], - y1 + origin[1], - ], + _ => crate::translated(object.rect, origin), }; let picture = self.pictures.get(&object.id); if let Some(image) = picture.and_then(Picture::image) { @@ -644,7 +633,7 @@ impl PageScene { /// Bounds of noneditable content in page coordinates, each with whether OneNote's view /// leaves room beyond it: a picture's corner is as far as the view reaches. - pub fn content_bounds<'a>( + pub(crate) fn content_bounds<'a>( &'a self, editor: &'a CanvasEditor, ) -> impl Iterator + 'a { @@ -657,15 +646,9 @@ impl PageScene { layout, below_title, } => { - let origin = outline_origin( - [ - source.layout.x.unwrap_or(0.0), - source.layout.y.unwrap_or(0.0), - ], - *below_title, - Some(editor), - ) - .ok()?; + let origin = + outline_origin(crate::origin(&source.layout), *below_title, Some(editor)) + .ok()?; Some(( [ origin[0], @@ -679,10 +662,7 @@ impl PageScene { Content::Date { below_title } => { let date = editor.date()?; let origin = outline_origin( - [ - date.source().layout.x.unwrap_or(0.0), - date.source().layout.y.unwrap_or(0.0), - ], + crate::origin(&date.source().layout), *below_title, Some(editor), ) @@ -699,7 +679,7 @@ impl PageScene { )) } Content::Image(source) => { - let [x, y] = [source.layout.x, source.layout.y].map(|v| v.unwrap_or(0.0)); + let [x, y] = crate::origin(&source.layout); let [width, height] = crate::outline::image_size(source)?; Some(([x, y, x + width, y + height], false)) } @@ -710,7 +690,7 @@ impl PageScene { }) } - pub fn date_fields( + pub(crate) fn date_fields( &self, editor: &CanvasEditor, ) -> impl Iterator { @@ -721,13 +701,9 @@ impl PageScene { .into_iter() .flatten() .find_map(|object| match object { - Content::Date { below_title } => Some(( - [ - date.source().layout.x.unwrap_or(0.0), - date.source().layout.y.unwrap_or(0.0), - ], - *below_title, - )), + Content::Date { below_title } => { + Some((crate::origin(&date.source().layout), *below_title)) + } _ => None, }) && let Ok(origin) = outline_origin(origin, title, Some(editor)) @@ -794,10 +770,7 @@ impl PageScene { } } Content::Image(source) if !source.background => { - let [x, y] = [ - source.layout.x.unwrap_or(0.0), - source.layout.y.unwrap_or(0.0), - ]; + let [x, y] = crate::origin(&source.layout); if let Some([width, height]) = crate::outline::image_size(source) && (x..=x + width).contains(&point[0]) && (y..=y + height).contains(&point[1]) @@ -846,14 +819,7 @@ impl PageScene { layout, below_title, } => { - let [x, y] = outline_origin( - [ - source.layout.x.unwrap_or(0.0), - source.layout.y.unwrap_or(0.0), - ], - *below_title, - None, - )?; + let [x, y] = outline_origin(crate::origin(&source.layout), *below_title, None)?; cover([x, y, x + layout.size[0], y + layout.size[1]], false); for paragraph in &layout.paragraphs { let [px, py] = [x + paragraph.origin[0], y + paragraph.origin[1]]; @@ -869,10 +835,7 @@ impl PageScene { } } Content::Image(source) => { - let [x, y] = [ - source.layout.x.unwrap_or(0.0), - source.layout.y.unwrap_or(0.0), - ]; + let [x, y] = crate::origin(&source.layout); let size = match self.backgrounds.get(&source.id) { Some(art) => Some(art.size), None => crate::outline::image_size(source), @@ -912,7 +875,7 @@ impl PageScene { /// `moving` draws one picture at a previewed rectangle instead of its stored layout. /// Outlines the editor does not hold draw their tags' symbols. - pub fn append_primitives_with<'a, E: From>( + pub(crate) fn append_primitives_with<'a, E: From>( &'a self, primitives: &mut Vec>, offset: [f32; 2], @@ -927,39 +890,16 @@ impl PageScene { source, below_title, .. - } => outline_origin( - [ - source.layout.x.unwrap_or(0.0), - source.layout.y.unwrap_or(0.0), - ], - *below_title, - editor, - )?, + } => outline_origin(crate::origin(&source.layout), *below_title, editor)?, Content::Date { below_title } => { let date = editor .and_then(CanvasEditor::date) .ok_or(SceneError::MissingOutline)?; - outline_origin( - [ - date.source().layout.x.unwrap_or(0.0), - date.source().layout.y.unwrap_or(0.0), - ], - *below_title, - editor, - )? + outline_origin(crate::origin(&date.source().layout), *below_title, editor)? } - Content::Image(source) => [ - source.layout.x.unwrap_or(0.0), - source.layout.y.unwrap_or(0.0), - ], + Content::Image(source) => crate::origin(&source.layout), Content::ReadOnly(object) => { - let [x0, y0, x1, y1] = object.rect(); - let rect = [ - x0 + offset[0], - y0 + offset[1], - x1 + offset[0], - y1 + offset[1], - ]; + let rect = crate::translated(object.rect(), offset); append_placeholder(rect, paper, primitives); primitives.push(Primitive::Text { clip: None, @@ -974,7 +914,7 @@ impl PageScene { continue; } Content::Ink(ink) => { - let [x, y] = crate::editor::page::ink_offset(ink); + let [x, y] = crate::origin(&ink.layout); let [dx, dy] = editor.map_or([0.0; 2], |editor| editor.ink_drag_offset(ink.id)); let offset = [offset[0] + x + dx, offset[1] + y + dy]; append_ink(ink, offset, paper.ink, primitives); @@ -984,7 +924,7 @@ impl PageScene { // A dragged file draws where its column would land. let [x, y] = match moving { Some((id, [x0, y0, ..])) if id == source.id => [x0, y0], - _ => [source.layout.x, source.layout.y].map(|v| v.unwrap_or(0.0)), + _ => crate::origin(&source.layout), }; self.append_object( layout, @@ -1043,12 +983,7 @@ impl PageScene { continue; }; let rect = match moving { - Some((id, [x0, y0, x1, y1])) if id == source.id => [ - x0 + offset[0], - y0 + offset[1], - x1 + offset[0], - y1 + offset[1], - ], + Some((id, rect)) if id == source.id => crate::translated(rect, offset), _ => [ object_origin[0], object_origin[1], @@ -1120,7 +1055,7 @@ impl PageScene { impl crate::outline::OutlineLayout { /// Paragraphs that may paint between outline-local `rows`, with their indices. - pub fn visible( + pub(crate) fn visible( &self, rows: [f32; 2], ) -> impl Iterator { @@ -1141,7 +1076,7 @@ impl crate::outline::OutlineLayout { /// Highlights behind the paragraphs `visible` finds between outline-local `rows`. A black /// highlight paints in the paper's ink, censoring the automatic text on it in any theme. /// A paragraph's band spans its outline, or its cell, tinted for the paper. - pub fn append_background_primitives( + pub(crate) fn append_background_primitives( &self, primitives: &mut Vec>, origin: [f32; 2], @@ -1200,7 +1135,7 @@ impl crate::outline::OutlineLayout { /// One paragraph of this outline, whose origin is `origin`: its text or equation, list /// markers and tags, those `art` maps drawn with their art. - pub fn append_paragraph_primitives<'a>( + pub(crate) fn append_paragraph_primitives<'a>( &'a self, index: usize, paragraph: &'a crate::outline::ParagraphLayout, @@ -1291,7 +1226,7 @@ impl crate::outline::OutlineLayout { /// The note tags of this outline's tables, pictures and files, whose origin is `origin`, /// those `art` maps drawn with their art. - pub fn append_block_tag_primitives( + pub(crate) fn append_block_tag_primitives( &self, primitives: &mut Vec>, origin: [f32; 2], @@ -1316,7 +1251,7 @@ impl crate::outline::OutlineLayout { } } - pub fn append_table_primitives( + pub(crate) fn append_table_primitives( &self, primitives: &mut Vec>, origin: [f32; 2], @@ -1409,6 +1344,7 @@ mod tests { created: Some(1), title: "Header".into(), margin_origin: [36.0, 14.4], + rtl: false, color: None, rule_lines: None, definitions: BTreeMap::new(), @@ -1858,6 +1794,7 @@ mod tests { created: None, title: "Header".into(), margin_origin: [36.0, 14.4], + rtl: false, color: None, rule_lines: None, definitions: BTreeMap::new(), @@ -1934,6 +1871,7 @@ mod tests { created: None, title: String::new(), margin_origin: [0.0; 2], + rtl: false, color: None, rule_lines: None, definitions: BTreeMap::new(), @@ -2020,6 +1958,7 @@ mod tests { created: None, title: String::new(), margin_origin: [36.0, 14.0], + rtl: false, color: None, rule_lines: None, objects: vec![unsupported, PageObject::Outline(editable)], @@ -2108,6 +2047,7 @@ mod tests { created: None, title: String::new(), margin_origin: [0.0; 2], + rtl: false, color: None, rule_lines: None, definitions: BTreeMap::new(), @@ -2294,6 +2234,7 @@ mod tests { created: None, title: String::new(), margin_origin: [0.0; 2], + rtl: false, color: None, rule_lines: None, definitions: BTreeMap::new(), @@ -2478,6 +2419,7 @@ mod tests { created: None, title: String::new(), margin_origin: [0.0; 2], + rtl: false, color: None, rule_lines: None, definitions: BTreeMap::new(), @@ -2592,6 +2534,7 @@ mod tests { created: None, title: String::new(), margin_origin: [0.0; 2], + rtl: false, color: None, rule_lines: None, definitions: BTreeMap::new(), @@ -2689,6 +2632,7 @@ mod tests { created: None, title: String::new(), margin_origin: [0.0; 2], + rtl: false, color: None, rule_lines: None, definitions: BTreeMap::new(), @@ -2763,6 +2707,7 @@ mod tests { created: None, title: String::new(), margin_origin: [0.0; 2], + rtl: false, color: None, rule_lines: None, definitions: BTreeMap::new(), diff --git a/crates/canvas/src/gpu/page/background.rs b/crates/canvas/src/gpu/page/background.rs index cc3c0147e062c69d82c3636e4955d5f6295a11ec..adb2ffe47df38eb4303d2c9a70969c370ef6195b 100644 --- a/crates/canvas/src/gpu/page/background.rs +++ b/crates/canvas/src/gpu/page/background.rs @@ -410,11 +410,11 @@ const INLINE_PIXELS: f32 = 1024.0 * 1024.0; pub(super) struct Background { svg: &'static str, /// Points; OneNote draws a picture without a stored size at its natural size. - pub size: [f32; 2], + pub(crate) size: [f32; 2], /// For light and for dark paper. variants: [Variant; 2], /// Whether the first raster is made on the frame's thread rather than awaited. - pub inline: bool, + pub(crate) inline: bool, } /// A raster's device pixels per point, and the dark paper it was recoloured for. @@ -430,19 +430,19 @@ struct Variant { impl Background { /// The rasters made so far, for light and for dark paper. - pub fn images(&self) -> impl Iterator { + pub(crate) fn images(&self) -> impl Iterator { self.variants .iter() .filter_map(|variant| variant.shown.as_ref().map(|(image, _)| image)) } /// Whether both recreate the same template picture at the same size. - pub fn same(&self, other: &Self) -> bool { + pub(crate) fn same(&self, other: &Self) -> bool { self.svg == other.svg && self.size == other.size } /// OneNote's template pictures by their hash, and those Snowbound wrote by their marker. - pub fn recognise(picture: &onestore::page::Image) -> Option { + pub(crate) fn recognise(picture: &onestore::page::Image) -> Option { let bytes = picture.bytes.as_deref()?; let digest: String = Sha256::digest(bytes) .iter() @@ -472,7 +472,7 @@ impl Background { } /// The latest raster for this paper, if one has landed. - pub fn image(&self, paper: Paper) -> Option<&RasterImage> { + pub(crate) fn image(&self, paper: Paper) -> Option<&RasterImage> { let (image, _) = self.variants[usize::from(dark(paper))].shown.as_ref()?; Some(image) } @@ -480,7 +480,7 @@ impl Background { /// Takes a finished raster and asks the worker for one at `scale` device pixels per /// point when the shown one differs; `waker` hears when it lands. True once the raster /// shown matches. - pub fn update(&mut self, paper: Paper, scale: f32, waker: &Waker) -> bool { + pub(crate) fn update(&mut self, paper: Paper, scale: f32, waker: &Waker) -> bool { let dark = dark(paper); let [width, height] = self.size.map(|side| side.max(1.0)); let density = density(scale) @@ -958,6 +958,7 @@ mod tests { created: None, title: String::new(), margin_origin: [0.0; 2], + rtl: false, color: None, rule_lines: None, definitions: BTreeMap::new(), diff --git a/crates/canvas/src/gpu/page/picture.rs b/crates/canvas/src/gpu/page/picture.rs index bd44b77d4d43ec0c89ff94ed6ecce472864b688b..8d79bece61f304c13de7274b539e2eec15ee891b 100644 --- a/crates/canvas/src/gpu/page/picture.rs +++ b/crates/canvas/src/gpu/page/picture.rs @@ -30,12 +30,12 @@ pub(super) struct Picture { } impl Picture { - /// Reads the size from the picture's header; None when the renderer cannot decode it. /// Whether both show the same stored bytes. - pub fn same(&self, other: &Self) -> bool { + pub(crate) fn same(&self, other: &Self) -> bool { self.bytes == other.bytes } + /// Reads the size from the picture's header; None when the renderer cannot decode it. pub fn new(bytes: &Arc<[u8]>) -> Option { Some(Self { native: RasterImage::measure(bytes).ok()?, @@ -48,11 +48,11 @@ impl Picture { } /// The latest raster, once one has landed. - pub fn image(&self) -> Option<&RasterImage> { + pub(crate) fn image(&self) -> Option<&RasterImage> { self.shown.as_ref() } - pub fn failed(&self) -> bool { + pub(crate) fn failed(&self) -> bool { self.failed } } diff --git a/crates/canvas/src/interaction/accessibility.rs b/crates/canvas/src/interaction/accessibility.rs index 3857899ec81a54fcfb5719874f3bdd3ae2eb5a42..74243d1aaddb8bef62999192323c17ca7fb2e692 100644 --- a/crates/canvas/src/interaction/accessibility.rs +++ b/crates/canvas/src/interaction/accessibility.rs @@ -722,6 +722,7 @@ pub(super) mod tests { title: "Header".into(), created: None, margin_origin: [36.0, 14.4], + rtl: false, color: None, rule_lines: None, definitions: Default::default(), @@ -816,6 +817,7 @@ pub(super) mod tests { created: None, title: String::new(), margin_origin: [0.0; 2], + rtl: false, color: None, rule_lines: None, definitions: Default::default(), diff --git a/crates/canvas/src/interaction/mod.rs b/crates/canvas/src/interaction/mod.rs index b3434707f3bd76166c7717b0c4511d6cb95af81f..7437e110637d1fbe2999ee7a00238e7c1fb0b30b 100644 --- a/crates/canvas/src/interaction/mod.rs +++ b/crates/canvas/src/interaction/mod.rs @@ -35,7 +35,7 @@ const HANDLE_HEIGHT: f32 = 6.75; /// Accessible names of the page date's fields. pub const DATE_LABELS: [&str; 2] = ["Page date", "Page time"]; -pub type Result = std::result::Result>; +pub(crate) type Result = std::result::Result>; #[derive(Clone, Copy, Debug, PartialEq)] pub enum Cursor { @@ -112,7 +112,7 @@ pub struct Correction { pub struct Response { pub changed: bool, pub moved: bool, - pub redraw: bool, + pub(crate) redraw: bool, pub request: Option, } @@ -359,7 +359,7 @@ impl PageView { } /// Shows a page reloaded from storage in place of the edited one, whose editor it returns. - pub fn replace( + pub(crate) fn replace( &mut self, mut editor: CanvasEditor, scene: Option<(PageScene, [f32; 2])>, @@ -435,10 +435,16 @@ impl PageView { } } - /// OneNote 2010 opens a page scrolled fully left and up, however far down its content - /// lies. + /// OneNote 2010 opens a page scrolled fully up, however far down its content lies, and + /// fully left, or right on a right-to-left page. fn place_opened(&mut self) { - self.viewport.origin = self.scroll().min.map(|offset| -offset); + let scroll = self.scroll(); + let x = if self.editor.rtl() { + scroll.max[0] + } else { + scroll.min[0] + }; + self.viewport.origin = [-x, -scroll.min[1]]; } pub fn modifiers(&self) -> Modifiers { @@ -480,6 +486,12 @@ impl PageView { self.object_focus.is_none() } + /// Edits from the toolbar and menus wait while an input method composes or an object holds + /// focus. + fn edits_wait(&self) -> bool { + !self.accepts_text() || self.editor.marked_range().is_some() + } + /// The outline being dragged and where it would land. pub fn outline_preview(&self) -> Option<(onestore::ExGuid, [f32; 2])> { let Drag::Outline { @@ -624,7 +636,7 @@ impl PageView { /// Page coordinates of a focused object. fn object_rect(&self, focus: ObjectFocus) -> [f32; 4] { let (scene, offset) = self.scene.as_ref().unwrap(); - let [x0, y0, x1, y1] = match focus { + let rect = match focus { ObjectFocus::ReadOnly(index) => scene .read_only(Some(&self.editor)) .nth(index) @@ -636,12 +648,7 @@ impl PageView { } ObjectFocus::File(id) => self.editor.attachment_rect(id).unwrap(), }; - [ - x0 + offset[0], - y0 + offset[1], - x1 + offset[0], - y1 + offset[1], - ] + crate::translated(rect, *offset) } /// Keeps the view in bounds and restarts the caret blink after a change. @@ -778,10 +785,7 @@ impl PageView { return self.moved(); } } - Ok(Response { - redraw: true, - ..Response::default() - }) + Ok(Response::redraw()) } /// Moves the caret by lines until it has gone a view's height, then scrolls as far so @@ -881,18 +885,16 @@ impl PageView { .content_bounds(&self.editor) .map(move |(rect, padded)| { ( - [ - rect[0] + offset[0], - rect[1] + offset[1], - rect[2] + offset[0], - rect[3] + offset[1], - ], + crate::translated(rect, *offset), if padded { pad } else { 0.0 }, ) }) }); - let mut scroll = - scroll::Scroll::new(self.viewport, editable.map(|rect| (rect, pad)).chain(fixed)); + let mut scroll = scroll::Scroll::new( + self.viewport, + editable.map(|rect| (rect, pad)).chain(fixed), + self.editor.rtl(), + ); if let Some(reach) = self.reach { for axis in 0..2 { scroll.min[axis] = scroll.min[axis].min(reach[axis] * self.viewport.scale); @@ -989,17 +991,7 @@ impl PageView { preview, self.object_focus, Paint { - caret: if self.focused - && !matches!( - self.drag, - Some( - Drag::Outline { .. } - | Drag::Resize { .. } - | Drag::Column { .. } - | Drag::Image { .. } - | Drag::Space { .. } - ) - ) { + caret: if self.focused && matches!(self.drag, None | Some(Drag::Text { .. })) { self.caret } else { 0.0 @@ -1069,7 +1061,6 @@ impl PageView { if self.drawing() { return Cursor::Crosshair; } - let point = self.viewport.document_point(self.pointer); if self.drag.is_none() && self.play_button(point).is_some() { return Cursor::Pointer; } @@ -1111,8 +1102,12 @@ impl PageView { (repaint, hold.map(|hold| now + hold)) } - /// `size` in device pixels. + /// `size` in device pixels. A right-to-left page keeps its right edge in place, as + /// OneNote 2010's does. pub fn resized(&mut self, size: [u32; 2]) -> Result { + if self.editor.rtl() { + self.viewport.origin[0] += size[0] as f32 - self.viewport.size[0] as f32; + } self.viewport.size = size; if size.contains(&0) { return Ok(Response::default()); @@ -1534,7 +1529,7 @@ impl PageView { { return self.edited(); } - if !self.accepts_text() || self.editor.marked_range().is_some() { + if self.edits_wait() { return Ok(Response::default()); } self.editor.format(&mut self.engine, command)?; @@ -1783,7 +1778,7 @@ impl PageView { /// Alt+= and the toolbar's Equation: see [`CanvasEditor::insert_equation`]. pub fn insert_equation(&mut self) -> Result { - if !self.accepts_text() || self.editor.marked_range().is_some() { + if self.edits_wait() { return Ok(Response::default()); } self.editor.insert_equation(&mut self.engine)?; @@ -1792,7 +1787,7 @@ impl PageView { /// Insert, Table: see [`CanvasEditor::insert_table`]. pub fn insert_table(&mut self, rows: usize, columns: usize) -> Result { - if !self.accepts_text() || self.editor.marked_range().is_some() { + if self.edits_wait() { return Ok(Response::default()); } self.editor.insert_table(&mut self.engine, rows, columns)?; @@ -1802,7 +1797,7 @@ impl PageView { /// Insert, Picture, or a pasted one: a PNG, JPEG or GIF `size` points large; see /// [`CanvasEditor::insert_picture`]. pub fn insert_picture(&mut self, bytes: Vec, size: [f32; 2]) -> Result { - if !self.accepts_text() || self.editor.marked_range().is_some() { + if self.edits_wait() { return Ok(Response::default()); } let image = crate::editor::picture(bytes, size)?; @@ -1818,7 +1813,7 @@ impl PageView { pieces: Vec, language: u32, ) -> Result<(Vec, Response)> { - if !self.accepts_text() || self.editor.marked_range().is_some() { + if self.edits_wait() { return Ok((Vec::new(), Response::default())); } let awaited = self @@ -1845,7 +1840,7 @@ impl PageView { /// Record Audio: see [`CanvasEditor::start_recording`]; none where text cannot go. pub fn start_recording(&mut self, label: &str) -> Result<(Option<[u8; 16]>, Response)> { - if !self.accepts_text() || self.editor.marked_range().is_some() { + if self.edits_wait() { return Ok((None, Response::default())); } let id = self.editor.start_recording(&mut self.engine, label)?; @@ -1879,7 +1874,7 @@ impl PageView { /// Insert, Attach File: see [`CanvasEditor::insert_attachment`]. A file without an icon /// takes the page's blank one. pub fn insert_attachment(&mut self, mut file: onestore::page::Attachment) -> Result { - if !self.accepts_text() || self.editor.marked_range().is_some() { + if self.edits_wait() { return Ok(Response::default()); } file.preview @@ -2051,17 +2046,7 @@ impl PageView { return self.edited(); } } - if matches!( - self.drag, - Some( - Drag::Outline { .. } - | Drag::Resize { .. } - | Drag::Column { .. } - | Drag::Image { .. } - | Drag::Space { .. } - ) - ) || self.space - { + if !matches!(self.drag, None | Some(Drag::Text { .. })) || self.space { self.drag = None; self.space = false; if key == &Key::Named(NamedKey::Escape) { @@ -2270,7 +2255,7 @@ pub enum Hit { } /// What a document point lands on; `pixel` is document points per device pixel. -pub fn page_hit_test( +pub(crate) fn page_hit_test( editor: &CanvasEditor, scene: Option<&(PageScene, [f32; 2])>, point: [f32; 2], @@ -2574,16 +2559,7 @@ fn page_primitives<'a>( if let Some((scene, _)) = scene { let moving = match preview { Some(PointerFeedback::Image(id, origin, size)) => { - let [x0, y0, x1, y1] = image_rect(origin, size); - Some(( - id, - [ - x0 + offset[0], - y0 + offset[1], - x1 + offset[0], - y1 + offset[1], - ], - )) + Some((id, crate::translated(image_rect(origin, size), offset))) } _ => None, }; @@ -2663,13 +2639,10 @@ fn page_primitives<'a>( } if let Some(ObjectFocus::ReadOnly(index)) = object_focus { let (scene, offset) = scene.unwrap(); - let [x0, y0, x1, y1] = scene.read_only(Some(editor)).nth(index).unwrap().rect(); - let [x0, y0, x1, y1] = [ - x0 + offset[0], - y0 + offset[1], - x1 + offset[0], - y1 + offset[1], - ]; + let [x0, y0, x1, y1] = crate::translated( + scene.read_only(Some(editor)).nth(index).unwrap().rect(), + *offset, + ); let border = 2.0 / paint.scale; for rect in [ [x0, y0, x1, y0 + border], @@ -2899,12 +2872,7 @@ fn append_outline_chrome( let [x, y] = origin; let (bounds, body_top) = outline_chrome(outline, pixel); let [dx, dy] = [x - outline.origin()[0], y - outline.origin()[1]]; - let [left, top, right, bottom] = [ - bounds[0] + dx, - bounds[1] + dy, - bounds[2] + dx, - bounds[3] + dy, - ]; + let [left, top, right, bottom] = crate::translated(bounds, [dx, dy]); let body_top = body_top + dy; if outline.title { primitives.push(Primitive::RoundedRect { diff --git a/crates/canvas/src/interaction/scroll.rs b/crates/canvas/src/interaction/scroll.rs index fd2068e16505a04e89ef15c347468eec802c4421..90ed1265b4276f7836f662f40abef478389b2709 100644 --- a/crates/canvas/src/interaction/scroll.rs +++ b/crates/canvas/src/interaction/scroll.rs @@ -10,25 +10,36 @@ pub struct Scroll { impl Scroll { /// Each content rectangle comes with the room, in device pixels, OneNote leaves beyond - /// it left and up. - pub fn new(viewport: Viewport, bounds: impl Iterator) -> Self { + /// it up and toward the page's start: left, or right on a right-to-left page. + pub fn new( + viewport: Viewport, + bounds: impl Iterator, + rtl: bool, + ) -> Self { let mut min = [0.0_f32; 2]; let mut max = [0.0_f32; 2]; + let mut end = f32::NEG_INFINITY; for (rect, pad) in bounds { for axis in 0..2 { min[axis] = min[axis].min(rect[axis] * viewport.scale - pad); max[axis] = max[axis].max(rect[axis + 2]); } + end = end.max(rect[2] * viewport.scale + pad); } - // OneNote stops at the page origin unless content reaches past it. + // OneNote stops at the page origin unless content reaches past it; a right-to-left + // page ends where its content does. for axis in 0..2 { - max[axis] = - ((max[axis] + 36.0) * viewport.scale - viewport.size[axis] as f32).max(min[axis]); + max[axis] = if rtl && axis == 0 && end.is_finite() { + end - viewport.size[0] as f32 + } else { + (max[axis] + 36.0) * viewport.scale - viewport.size[axis] as f32 + } + .max(min[axis]); } Self { min, max } } - pub fn clamp(&self, viewport: &mut Viewport) { + pub(crate) fn clamp(&self, viewport: &mut Viewport) { for axis in 0..2 { viewport.origin[axis] = -(-viewport.origin[axis]).clamp(self.min[axis], self.max[axis]); } @@ -49,6 +60,7 @@ mod tests { let scroll = Scroll::new( viewport, [([-80.0, -20.0, 1000.0, 1200.0], 11.0)].into_iter(), + false, ); scroll.clamp(&mut viewport); assert_eq!(viewport.origin, [171.0, 51.0]); @@ -68,7 +80,23 @@ mod tests { ([-0.5, -22.35, 613.0, 141.3], 0.0), ([36.0, 86.4, 400.0, 180.0], 11.0), ]; - let scroll = Scroll::new(viewport, bounds.into_iter()); + let scroll = Scroll::new(viewport, bounds.into_iter(), false); assert_eq!(scroll.min, [-1.0, -44.7]); } + + #[test] + fn right_to_left_pages_end_at_their_content() { + let viewport = Viewport { + size: [800, 600], + scale: 2.0, + origin: [0.0; 2], + }; + let bounds = [ + ([10835.0, 14.4, 10962.0, 28.0], 11.0), + ([11220.0, 84.0, 11334.0, 145.0], 0.0), + ]; + let scroll = Scroll::new(viewport, bounds.into_iter(), true); + assert_eq!(scroll.min, [0.0, 0.0]); + assert_eq!(scroll.max[0], 11334.0 * 2.0 - 800.0); + } } diff --git a/crates/canvas/src/interaction/tests.rs b/crates/canvas/src/interaction/tests.rs index ca5a9f9543564cc27665bf8e57728b8e143ec9e2..7e34db0aa5c32674d84144838800898b3bc41ac9 100644 --- a/crates/canvas/src/interaction/tests.rs +++ b/crates/canvas/src/interaction/tests.rs @@ -57,6 +57,7 @@ fn date_buttons_keep_accessibility_identity_and_match_mouse_hits_after_reflow() title: "Header".into(), created: Some(1), margin_origin: [36.0, 14.4], + rtl: false, color: None, rule_lines: None, definitions: Default::default(), @@ -590,6 +591,7 @@ fn extension_hits_yield_to_objects_and_keep_their_source_coordinate_frame() { title: String::new(), created: None, margin_origin: [0.0; 2], + rtl: false, color: None, rule_lines: None, definitions: Default::default(), @@ -719,6 +721,7 @@ fn a_picture_in_an_outline_takes_the_click_over_its_text() { identity: None, created: None, margin_origin: [36.0, 14.4], + rtl: false, color: None, rule_lines: None, definitions: Default::default(), @@ -1247,6 +1250,7 @@ fn overlapping_objects_follow_paint_order_through_creation_movement_and_undo() { created: None, title: String::new(), margin_origin: [0.0; 2], + rtl: false, color: None, rule_lines: None, definitions: Default::default(), @@ -1445,6 +1449,7 @@ fn read_only_focus_retires_text_overlays_and_draws_a_scaled_focus_border() { created: None, title: String::new(), margin_origin: [0.0; 2], + rtl: false, color: None, rule_lines: None, definitions: Default::default(), @@ -1566,6 +1571,7 @@ fn picture_view() -> (PageView, onestore::ExGuid) { identity: None, created: None, margin_origin: [36.0, 14.4], + rtl: false, color: None, rule_lines: None, definitions: Default::default(), @@ -1604,6 +1610,7 @@ fn a_page_outline_emptied_by_backspace_still_draws() { identity: None, created: None, margin_origin: [36.0, 14.4], + rtl: false, color: None, rule_lines: None, definitions: Default::default(), @@ -2079,6 +2086,7 @@ fn page_keys_scroll_or_carry_the_caret_as_the_platform_does() { identity: None, created: None, margin_origin: [36.0, 14.4], + rtl: false, color: None, rule_lines: None, definitions: Default::default(), @@ -2171,6 +2179,7 @@ fn page_selection_view() -> (PageView, [onestore::ExGuid; 4]) { identity: None, created: None, margin_origin: [36.0, 14.4], + rtl: false, color: None, rule_lines: None, definitions: Default::default(), @@ -2712,6 +2721,7 @@ fn art_draws_at_once_over_the_rule_lines_and_follows_undo() { title: String::new(), created: None, margin_origin: [36.0, 14.4], + rtl: false, color: None, rule_lines: Some(crate::template::RULE_LINES[2].1), definitions: Default::default(), @@ -2832,6 +2842,7 @@ fn a_file_dropped_on_blank_page_lies_on_the_page_and_drags() { identity: None, created: None, margin_origin: [36.0, 14.4], + rtl: false, color: None, rule_lines: None, definitions: Default::default(), @@ -2959,6 +2970,7 @@ fn misspelled_words_are_marked_and_corrected_in_one_edit() { identity: None, created: None, margin_origin: [36.0, 14.4], + rtl: false, color: None, rule_lines: None, definitions: Default::default(), @@ -3072,6 +3084,7 @@ fn the_note_playing_scrolls_into_view() { identity: None, created: None, margin_origin: [36.0, 14.4], + rtl: false, color: None, rule_lines: None, definitions: Default::default(), @@ -3129,6 +3142,7 @@ fn the_spelling_pane_walks_marked_words_from_the_caret() { identity: None, created: None, margin_origin: [36.0, 14.4], + rtl: false, color: None, rule_lines: None, definitions: Default::default(), diff --git a/crates/canvas/src/language.rs b/crates/canvas/src/language.rs index ec1bceb3e4063f562ae667d1780c1915d6517e23..78b7347f3f9ea008c0922423c4494ce3d7898758 100644 --- a/crates/canvas/src/language.rs +++ b/crates/canvas/src/language.rs @@ -135,7 +135,7 @@ pub fn lcid(tag: &str) -> u32 { /// The BCP-47 tag of a Windows LCID; a bare language stands for the locale Windows picks /// for it. None for LCIDs outside the table, such as math's. -pub fn tag(lcid: u32) -> Option<&'static str> { +pub(crate) fn tag(lcid: u32) -> Option<&'static str> { LCIDS .iter() .find(|(_, known)| *known == lcid) diff --git a/crates/canvas/src/layout.rs b/crates/canvas/src/layout.rs index 8fb8ea431dd89f7472a4d5f3687d486117e5ddbd..b2d39360abf5066e85d97447ebbe1df64a927420 100644 --- a/crates/canvas/src/layout.rs +++ b/crates/canvas/src/layout.rs @@ -42,11 +42,11 @@ const SUBSCRIPT_DROP: f32 = 0.08; #[derive(Clone, Copy, Debug, Default, PartialEq)] pub struct TextBrush { /// COLORREF, or `None` for OneNote's automatic colour, which follows the paper. - pub color: Option, - pub highlight: Option, + pub(crate) color: Option, + pub(crate) highlight: Option, /// Points the run draws above the line's baseline: positive for a superscript, /// negative for a subscript. Line metrics and hit-testing keep the baseline. - pub rise: f32, + pub(crate) rise: f32, } #[derive(Clone, Debug)] @@ -72,11 +72,11 @@ pub struct TextLayout { /// Room kept in a line for something drawn inline, such as an equation, before the text at /// byte `index`; the line grows to its ascent and descent. #[derive(Clone, Copy, Debug, PartialEq)] -pub struct InlineSpace { - pub index: usize, - pub width: f32, - pub ascent: f32, - pub descent: f32, +pub(crate) struct InlineSpace { + pub(crate) index: usize, + pub(crate) width: f32, + pub(crate) ascent: f32, + pub(crate) descent: f32, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -106,8 +106,8 @@ impl fmt::Display for LayoutError { impl std::error::Error for LayoutError {} /// What a run without a font or size of its own is laid out in. -pub const DEFAULT_FONT: &str = "Arial"; -pub const DEFAULT_FONT_SIZE: f32 = 11.0; +pub(crate) const DEFAULT_FONT: &str = "Arial"; +pub(crate) const DEFAULT_FONT_SIZE: f32 = 11.0; /// Metric-compatible faces for the fonts OneNote pages use most, under the SIL Open Font /// Licence files beside them, by the family each stands in for. @@ -425,7 +425,7 @@ impl TextEngine { } /// Lays out `paragraph` keeping `spaces` inline, for what draws in them. - pub fn layout_with( + pub(crate) fn layout_with( &mut self, paragraph: &Paragraph, width: f32, @@ -531,7 +531,7 @@ impl TextEngine { impl TextLayout { /// Process-unique identity for caches of an externally immutable layout. - pub fn id(&self) -> u64 { + pub(crate) fn id(&self) -> u64 { self.id } @@ -592,11 +592,11 @@ impl TextLayout { last.top + last.height } - pub fn cursor(&self, byte: usize, affinity: Affinity) -> Cursor { + pub(crate) fn cursor(&self, byte: usize, affinity: Affinity) -> Cursor { Cursor::from_byte_index(&self.shaped, byte, affinity) } - pub fn hit_test(&self, x: f32, y: f32) -> Cursor { + pub(crate) fn hit_test(&self, x: f32, y: f32) -> Cursor { let index = self .lines .partition_point(|line| line.top + line.height <= y) @@ -645,7 +645,7 @@ impl TextLayout { /// Each line's part of `selection` as a mark under the text runs: its left, its right /// and the line's baseline. - pub fn underlines(&self, selection: Selection) -> Vec<[f32; 3]> { + pub(crate) fn underlines(&self, selection: Selection) -> Vec<[f32; 3]> { selection .geometry(&self.shaped) .into_iter() diff --git a/crates/canvas/src/lib.rs b/crates/canvas/src/lib.rs index de0389776bb173090782f64dc8f19dd22381a50c..840d8486a761dcb1e2c2e0358360e860ec7d939f 100644 --- a/crates/canvas/src/lib.rs +++ b/crates/canvas/src/lib.rs @@ -5,6 +5,7 @@ pub mod language; pub mod layout; pub mod math; pub mod outline; +pub mod reading; pub mod search; pub mod spelling; pub mod template; @@ -17,6 +18,16 @@ pub mod interaction; #[cfg(feature = "pdf")] pub mod print; +/// Where an object stored with `layout` lies; an unset coordinate is zero. +pub(crate) fn origin(layout: &onestore::document::Layout) -> [f32; 2] { + [layout.x.unwrap_or(0.0), layout.y.unwrap_or(0.0)] +} + +/// `[x0, y0, x1, y1]` moved by `[x, y]`. +pub(crate) fn translated([x0, y0, x1, y1]: [f32; 4], [x, y]: [f32; 2]) -> [f32; 4] { + [x0 + x, y0 + y, x1 + x, y1 + y] +} + /// Parley's caret affinity mapped onto the page model's hidden-field affinity. pub fn affinity(affinity: parley::Affinity) -> onestore::page::text::Affinity { match affinity { diff --git a/crates/canvas/src/math.rs b/crates/canvas/src/math.rs index 2991775e5aa3e1a87f79f303220fcbecfa27a8cf..d1f3ae6bca997142d09f54995943595e12fe1612 100644 --- a/crates/canvas/src/math.rs +++ b/crates/canvas/src/math.rs @@ -11,14 +11,14 @@ use onestore::page::text::Paragraph; pub struct MathLayout { pub size: [f32; 2], /// COLORREF for rules and strokes, or `None` for the paper's ink. - pub color: Option, + pub(crate) color: Option, /// Distance from the top to the baseline of the equation's main row. pub baseline: f32, - pub items: Vec, + pub(crate) items: Vec, } #[derive(Clone)] -pub enum MathItem { +pub(crate) enum MathItem { Text { layout: TextLayout, origin: [f32; 2], @@ -79,7 +79,7 @@ fn offset(item: MathItem, [dx, dy]: [f32; 2]) -> MathItem { layout, origin: [origin[0] + dx, origin[1] + dy], }, - MathItem::Rule([x0, y0, x1, y1]) => MathItem::Rule([x0 + dx, y0 + dy, x1 + dx, y1 + dy]), + MathItem::Rule(rect) => MathItem::Rule(crate::translated(rect, [dx, dy])), MathItem::Stroke { from, to, width } => MathItem::Stroke { from: [from[0] + dx, from[1] + dy], to: [to[0] + dx, to[1] + dy], diff --git a/crates/canvas/src/outline.rs b/crates/canvas/src/outline.rs index 91b1be1b502b01ae2e88e9c1f1bb20128d89084c..264c522ba12e23d9ba36937af6bb4bb528ce995a 100644 --- a/crates/canvas/src/outline.rs +++ b/crates/canvas/src/outline.rs @@ -21,7 +21,7 @@ pub struct OutlineLayout { pub paragraphs: Vec, pub tables: Vec, /// Pictures, files and handwriting that occupy a paragraph of their own. - pub objects: Vec, + pub(crate) objects: Vec, pub size: [f32; 2], /// One per root node, so an edit places anew only what it changes and moves what follows. blocks: Vec, @@ -49,37 +49,37 @@ struct Block { /// A layout of root nodes `range` once an edit applies, and how the nodes after them move. pub(crate) struct Relayout { - pub range: Range, - pub segment: OutlineLayout, + pub(crate) range: Range, + pub(crate) segment: OutlineLayout, /// Each following node's new top and flow state, up to the first that stays. moved: Vec<(f32, (f64, Option))>, - pub size: [f32; 2], + pub(crate) size: [f32; 2], widths: [f32; 2], } #[derive(Clone)] -pub struct ObjectLayout { +pub(crate) struct ObjectLayout { /// The object's identity, which keys a picture's or file icon's decoded image. - pub id: ExGuid, + pub(crate) id: ExGuid, /// Where the picture, file icon, drawing or placeholder draws, outline-local. - pub rect: [f32; 4], - pub kind: ObjectKind, + pub(crate) rect: [f32; 4], + pub(crate) kind: ObjectKind, /// Outline-local bottom of the whole object, label included. - pub bottom: f32, - pub tags: Option, + pub(crate) bottom: f32, + pub(crate) tags: Option, } /// The note tags of a table, picture or file, which OneNote centres on it in the tag column. #[derive(Clone)] -pub struct BlockTags { +pub(crate) struct BlockTags { /// The paragraph holding the block. - pub paragraph: ExGuid, + pub(crate) paragraph: ExGuid, /// Its siblings' group, whose list markers the tags clear. parent: Option, /// The block's left edge, outline-local. x: f32, /// Origins are outline-local. - pub tags: Vec, + pub(crate) tags: Vec, } impl BlockTags { @@ -130,7 +130,7 @@ impl BlockTags { } #[derive(Clone)] -pub enum ObjectKind { +pub(crate) enum ObjectKind { Picture, /// A file's icon with its name centered below. File(ParagraphLayout), @@ -141,7 +141,7 @@ pub enum ObjectKind { } impl ObjectLayout { - pub fn label(&self) -> Option<&ParagraphLayout> { + pub(crate) fn label(&self) -> Option<&ParagraphLayout> { match &self.kind { ObjectKind::File(label) | ObjectKind::Unsupported(label) => Some(label), ObjectKind::Picture | ObjectKind::Ink(_) => None, @@ -243,7 +243,7 @@ pub struct TableLayout { /// Cells are in paragraph order with disjoint visible ranges. pub cells: Vec, pub borders: bool, - pub tags: Option, + pub(crate) tags: Option, } #[derive(Clone)] @@ -251,12 +251,12 @@ pub struct CellLayout { pub id: ExGuid, pub rect: [f32; 4], /// Visible paragraph indices, including paragraphs in nested tables. - pub paragraphs: std::ops::Range, + pub(crate) paragraphs: std::ops::Range, } impl CellLayout { /// Native ink gutters extend beyond the cell borders. - pub fn text_bounds(&self) -> [f32; 4] { + pub(crate) fn text_bounds(&self) -> [f32; 4] { [ self.rect[0] - 2.7, self.rect[1], @@ -265,7 +265,7 @@ impl CellLayout { ] } - pub fn clip(&self, rect: parley::BoundingBox) -> Option { + pub(crate) fn clip(&self, rect: parley::BoundingBox) -> Option { let [left, top, right, bottom] = self.text_bounds().map(f64::from); let rect = parley::BoundingBox::new( rect.x0.max(left), @@ -296,7 +296,7 @@ pub struct ParagraphLayout { pub math: Vec, /// A highlight of the whole paragraph (its own `highlight`), COLORREF: a band across its /// outline behind its lines, as OneNote marks a conflict page's conflicting changes. - pub band: Option, + pub(crate) band: Option, } /// How the page draws a note tag. @@ -318,13 +318,13 @@ impl TagIcon { }) } - pub fn checkable(self) -> bool { + pub(crate) fn checkable(self) -> bool { matches!(self, Self::Symbol { shape, .. } if checkable(shape)) } } /// Whether tag symbol `shape` is a check box, as MS-ONE's NoteTagShape marks them. -pub fn checkable(shape: u16) -> bool { +pub(crate) fn checkable(shape: u16) -> bool { matches!( shape, 1..=12 | 28 | 30 | 32 | 48 | 50 | 52 | 69 | 71 | 73 | 89..=99 @@ -498,9 +498,9 @@ pub struct ParagraphTag { impl ParagraphTag { /// The icon's side beside 10 to 17.5 pt text. - pub const SIZE: f32 = 12.0; + pub(crate) const SIZE: f32 = 12.0; /// How far a 12 pt icon starts left of its text when no sibling has a list marker. - pub const INSET: f32 = 20.25; + pub(crate) const INSET: f32 = 20.25; /// Space between a tag and the leftmost list marker among its paragraph's siblings. const MARKER_GAP: f32 = 0.9; @@ -1258,7 +1258,7 @@ pub(crate) fn visible_paragraphs<'a>( impl OutlineLayout { /// OneNote gives an outline one tag column, as wide as its most-tagged paragraph needs; each /// paragraph's tags start at the column's left edge. Tag origins assume a one-tag column. - pub fn tag_column_offset(&self) -> f32 { + pub(crate) fn tag_column_offset(&self) -> f32 { -self .paragraphs .iter() @@ -1296,7 +1296,7 @@ impl OutlineLayout { } /// Innermost table cell containing a visible paragraph index. - pub fn paragraph_cell(&self, index: usize) -> Option<&CellLayout> { + pub(crate) fn paragraph_cell(&self, index: usize) -> Option<&CellLayout> { self.tables.iter().rev().find_map(|table| { let cell = table .cells diff --git a/crates/canvas/src/reading.rs b/crates/canvas/src/reading.rs new file mode 100644 index 0000000000000000000000000000000000000000..64d0647af3416d9d010e216b89d8158e66b0571f --- /dev/null +++ b/crates/canvas/src/reading.rs @@ -0,0 +1,638 @@ +//! Reading view: a page's content in reading order, reflowed into one column as wide as a +//! phone's screen. The reflowed page is only shown; nothing of it is stored. +//! +//! Blocks are the page's outlines, pictures, files and drawings, as laid out. Blocks whose +//! lines or pictures overlap, ink over text, and strokes within reach of each other join +//! into one piece moved whole. Reading order is a recursive XY cut: rows split by horizontal +//! gaps, then columns by vertical gaps, left column first. Notes beside one long outline are +//! asides: the outline splits after the paragraph each note sits beside, and the note follows +//! it. Text rewraps to the column, pictures shrink to it, and everything else keeps its size. + +use crate::{ + editor::{CanvasEditor, EditorError, TextOutline, page::Content}, + layout::TextEngine, +}; +use onestore::{ + ExGuid, + page::{Outline, Page, PageObject, ParagraphContent}, +}; +use std::{collections::BTreeMap, ops::Range}; + +/// Points between blocks in the column: OneNote's grid row. +const GAP: f32 = 18.0; +/// Strokes this near one another are one drawing, as handwriting is many strokes. +const INK_REACH: f32 = 9.0; +/// The share of the content's area drawn, or kept as laid out, past which the page is a +/// canvas rather than a document. +const CANVAS_SHARE: f64 = 0.5; +/// How far below a paragraph's top a note beside it may start and still follow it. +const ASIDE_SLACK: f32 = 6.0; +/// How far an aside sits in from the column's edge, and the room above and below it. +const ASIDE_INDENT: f32 = 18.0; +const ASIDE_GAP: f32 = 6.0; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Kind { + Text, + Picture, + File, + Drawing, + /// Overlapping blocks or ink over text, moved as one piece. + Group, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Block { + pub kind: Kind, + /// The page objects it moves. + pub members: Vec, + /// `[x0, y0, x1, y1]` in page points, as laid out on the page. + pub bounds: [f32; 4], + /// The outline's top-level paragraphs it shows, where asides split the outline. + pub part: Option>, + /// A note beside an outline, read after the paragraph it sits by. + pub aside: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Verdict { + /// No wider than the column already: shown as laid out. + Fits, + /// Every block flows into the column. + Reflows, + /// Flows, with what each `Kept` names read in an order or shape the page decides. + Partial(Vec), + /// Not offered. + Refused(Refusal), +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Kept { + /// Notes beside an outline, each placed after the paragraph it sits beside. + Asides, + /// Blocks side by side, or interlocked, read left column first. + SideBySide, + /// Overlapping blocks or ink over text, moved whole at their size. + Group, + /// Something still wider than the column: a table with set column widths, a drawing, a + /// group. + Wide, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Refusal { + /// Its positions read from the right; not yet reflowed. + RightToLeft, + /// Mostly ink. + Drawn, + /// Mostly overlapping blocks or ink over text. + Arranged, +} + +pub struct Reading { + pub verdict: Verdict, + /// In reading order where the page reflows, else as found. + pub blocks: Vec, + /// The page reflowed, where it reflows. + pub page: Option, +} + +impl Verdict { + /// Whether the page has a reading view. + pub fn offered(&self) -> bool { + matches!(self, Self::Reflows | Self::Partial(_)) + } +} + +/// `page` read into a column `column` points wide. +pub fn read(page: &Page, column: f32, engine: &mut TextEngine) -> Result { + let editor = CanvasEditor::from_page(page.clone(), engine)?; + let blocks = blocks(&editor); + let refused = |refusal, blocks| Reading { + verdict: Verdict::Refused(refusal), + blocks, + page: None, + }; + if page.rtl { + return Ok(refused(Refusal::RightToLeft, blocks)); + } + let left = blocks + .iter() + .map(|b| b.bounds[0]) + .fold(f32::INFINITY, f32::min); + let right = blocks + .iter() + .map(|b| b.bounds[2]) + .fold(f32::NEG_INFINITY, f32::max); + if blocks.is_empty() || right - left <= column + 1.0 { + return Ok(Reading { + verdict: Verdict::Fits, + blocks, + page: None, + }); + } + let area = |kind: Option| -> f64 { + blocks + .iter() + .filter(|b| kind.is_none_or(|kind| b.kind == kind)) + .map(|b| f64::from(b.bounds[2] - b.bounds[0]) * f64::from(b.bounds[3] - b.bounds[1])) + .sum() + }; + let total = area(None).max(1.0); + if area(Some(Kind::Drawing)) / total >= CANVAS_SHARE { + return Ok(refused(Refusal::Drawn, blocks)); + } + if area(Some(Kind::Group)) / total >= CANVAS_SHARE { + return Ok(refused(Refusal::Arranged, blocks)); + } + let mut kept = Vec::new(); + if blocks.iter().any(|b| b.kind == Kind::Group) { + kept.push(Kept::Group); + } + let tops: BTreeMap> = editor + .outlines() + .iter() + .map(|outline| (outline.id, root_tops(outline))) + .collect(); + let blocks = order(blocks, &tops, &mut kept); + let start = editor.body_start().unwrap_or([left, blocks[0].bounds[1]]); + let mut reflowed = page.clone(); + let blocks = split(&mut reflowed, blocks); + reflow( + &mut reflowed, + &blocks, + [start[0].min(left), start[1]], + column, + engine, + &mut kept, + )?; + kept.sort(); + kept.dedup(); + Ok(Reading { + verdict: if kept.is_empty() { + Verdict::Reflows + } else { + Verdict::Partial(kept) + }, + blocks, + page: Some(reflowed), + }) +} + +/// Where `outline`'s lines, table cells and pictures lie, in page points. +fn marks(outline: &TextOutline) -> Vec<[f32; 4]> { + let [x, y] = outline.origin(); + let shaped = outline.shaped(); + let lines = outline.layouts().flat_map(|(_, paragraph)| { + let [px, py] = paragraph.origin; + paragraph.text.lines().map(move |(line, bounds)| { + let metrics = line.metrics(); + let left = x + px + metrics.offset; + let top = y + py + bounds.top; + [ + left, + top, + left + metrics.advance - metrics.trailing_whitespace, + top + bounds.height, + ] + }) + }); + let cells = shaped + .tables + .iter() + .flat_map(|table| &table.cells) + .map(|cell| cell.rect); + let objects = shaped.objects.iter().map(|object| object.rect); + lines + .chain( + cells + .chain(objects) + .map(|rect| crate::translated(rect, [x, y])), + ) + .filter(|[x0, y0, x1, y1]| x1 > x0 && y1 > y0) + .collect() +} + +/// The page top of each of `outline`'s top-level paragraphs: its own text's, or the first +/// shown below it; the one before's where nothing below it shows. +fn root_tops(outline: &TextOutline) -> Vec { + let source = outline.snapshot(); + let mut root_of = BTreeMap::new(); + let mut roots = 0; + for paragraph in &source.paragraphs { + if paragraph.parent.is_none() { + roots += 1; + } + root_of.insert(paragraph.id, roots - 1); + } + let y = outline.origin()[1]; + let mut tops = vec![f32::INFINITY; roots]; + for (_, paragraph) in outline.layouts() { + if let Some(&root) = root_of.get(¶graph.id) { + tops[root] = tops[root].min(y + paragraph.origin[1]); + } + } + let mut last = y; + for top in &mut tops { + if top.is_finite() { + last = *top; + } else { + *top = last; + } + } + tops +} + +/// A page object as laid out: its identity, kind, bounds, and where its lines and pictures +/// lie within them. +type Item = (ExGuid, Kind, [f32; 4], Vec<[f32; 4]>); + +/// The page's content as laid out, overlapping pieces and nearby strokes joined. +fn blocks(editor: &CanvasEditor) -> Vec { + let mut items: Vec = editor + .outlines() + .iter() + .filter(|outline| !outline.title && !outline.is_empty()) + .map(|outline| { + let b = outline.bounds(); + let bounds = [b.x0, b.y0, b.x1, b.y1].map(|v| v as f32); + (outline.id, Kind::Text, bounds, marks(outline)) + }) + .collect(); + for object in &editor.objects { + let item = match object { + Content::Outline { + source, + layout, + below_title: None, + } => { + let [x, y] = crate::origin(&source.layout); + Some(( + source.id, + Kind::Text, + [x, y, x + layout.size[0], y + layout.size[1]], + )) + } + Content::Image(image) if !image.background => { + crate::outline::image_size(image).map(|[width, height]| { + let [x, y] = crate::origin(&image.layout); + (image.id, Kind::Picture, [x, y, x + width, y + height]) + }) + } + Content::File { source, .. } => object.file().map(|(_, b)| (source.id, Kind::File, b)), + Content::Ink(ink) => { + crate::editor::page::ink_bounds(ink).map(|b| (ink.id, Kind::Drawing, b)) + } + Content::ReadOnly(object) if !matches!(object.source, PageObject::Title(_)) => { + Some((object.source.id(), Kind::Picture, object.rect())) + } + _ => None, + }; + let item = item.filter(|(_, _, b)| b.iter().all(|v| v.is_finite())); + items.extend(item.map(|(id, kind, bounds)| (id, kind, bounds, vec![bounds]))); + } + let mut parent: Vec = (0..items.len()).collect(); + fn root(parent: &mut [usize], mut i: usize) -> usize { + while parent[i] != i { + parent[i] = parent[parent[i]]; + i = parent[i]; + } + i + } + let meets = |a: &[f32; 4], b: &[f32; 4], reach: f32| { + a[0] < b[2] + reach && b[0] < a[2] + reach && a[1] < b[3] + reach && b[1] < a[3] + reach + }; + for i in 0..items.len() { + for j in i + 1..items.len() { + let (a, b) = (&items[i], &items[j]); + // Touching edges are not overlap. + let reach = if a.1 == Kind::Drawing && b.1 == Kind::Drawing { + INK_REACH + } else { + -1.0 + }; + if meets(&a.2, &b.2, reach) + && a.3.iter().any(|m| b.3.iter().any(|n| meets(m, n, reach))) + { + let (ra, rb) = (root(&mut parent, i), root(&mut parent, j)); + parent[ra] = rb; + } + } + } + let mut joined: BTreeMap = BTreeMap::new(); + for (i, (id, kind, bounds, _)) in items.into_iter().enumerate() { + let r = root(&mut parent, i); + joined + .entry(r) + .and_modify(|block| { + block.members.push(id); + if !(block.kind == Kind::Drawing && kind == Kind::Drawing) { + block.kind = Kind::Group; + } + block.bounds = union(block.bounds, bounds); + }) + .or_insert(Block { + kind, + members: vec![id], + bounds, + part: None, + aside: false, + }); + } + joined.into_values().collect() +} + +fn union(a: [f32; 4], b: [f32; 4]) -> [f32; 4] { + [ + a[0].min(b[0]), + a[1].min(b[1]), + a[2].max(b[2]), + a[3].max(b[3]), + ] +} + +/// `blocks` in reading order by recursive XY cut: rows first, then columns left to right, +/// notes beside a single outline as its asides. +fn order( + blocks: Vec, + tops: &BTreeMap>, + kept: &mut Vec, +) -> Vec { + if blocks.len() <= 1 { + return blocks; + } + let mut parts = cut(blocks, 1); + if let [row] = &mut parts[..] { + let row = std::mem::take(row); + if let Some(asides) = asides(&row, tops) { + kept.push(Kept::Asides); + return asides; + } + parts = cut(row, 0); + let texts = parts + .iter() + .filter(|part| part.iter().any(|b| b.kind == Kind::Text)); + if texts.count() > 1 { + kept.push(Kept::SideBySide); + } + } + if let [interlocked] = &mut parts[..] { + // No gap runs across: top to bottom, then left to right. + kept.push(Kept::SideBySide); + let mut blocks = std::mem::take(interlocked); + blocks.sort_by(|a, b| { + a.bounds[1] + .total_cmp(&b.bounds[1]) + .then(a.bounds[0].total_cmp(&b.bounds[0])) + }); + return blocks; + } + parts + .into_iter() + .flat_map(|part| order(part, tops, kept)) + .collect() +} + +/// `row` as one outline split after each paragraph a note beside it sits by, where every +/// other block is text no taller than the outline and clear of it across. +fn asides(row: &[Block], tops: &BTreeMap>) -> Option> { + let height = |b: &Block| b.bounds[3] - b.bounds[1]; + let main = row + .iter() + .filter(|b| b.kind == Kind::Text) + .max_by(|a, b| height(a).total_cmp(&height(b)))?; + let tops = tops.get(&main.members[0])?; + let mut notes: Vec<&Block> = row.iter().filter(|b| *b != main).collect(); + let beside = |b: &Block| { + b.kind == Kind::Text + && height(b) < height(main) + && (b.bounds[0] >= main.bounds[0] + 72.0 || b.bounds[2] <= main.bounds[0]) + }; + if notes.is_empty() || tops.len() < 2 || !notes.iter().all(|b| beside(b)) { + return None; + } + notes.sort_by(|a, b| a.bounds[1].total_cmp(&b.bounds[1])); + let mut out = Vec::new(); + let mut from = 0; + let slice = |range: Range| { + let mut bounds = main.bounds; + bounds[1] = tops[range.start]; + bounds[3] = tops.get(range.end).copied().unwrap_or(main.bounds[3]); + Block { + part: Some(range), + bounds, + ..main.clone() + } + }; + for note in notes { + let after = tops + .partition_point(|top| *top <= note.bounds[1] + ASIDE_SLACK) + .max(1); + if after > from { + out.push(slice(from..after)); + from = after; + } + out.push(Block { + aside: true, + ..note.clone() + }); + } + if from < tops.len() { + out.push(slice(from..tops.len())); + } + Some(out) +} + +/// `blocks` split at each gap along `axis` (0 for x, 1 for y) that no block spans. +fn cut(mut blocks: Vec, axis: usize) -> Vec> { + blocks.sort_by(|a, b| a.bounds[axis].total_cmp(&b.bounds[axis])); + let mut parts: Vec> = Vec::new(); + let mut end = f32::NEG_INFINITY; + for block in blocks { + if parts.is_empty() || block.bounds[axis] >= end { + parts.push(Vec::new()); + } + end = end.max(block.bounds[axis + 2]); + parts.last_mut().unwrap().push(block); + } + parts +} + +/// Splits each outline asides cut into one outline per part, shown only: the first part +/// keeps the outline's identity, the others take ones derived from it. +fn split(page: &mut Page, mut blocks: Vec) -> Vec { + let mut whole: BTreeMap = BTreeMap::new(); + for block in &mut blocks { + let Some(range) = block.part.clone() else { + continue; + }; + let id = block.members[0]; + let source = match whole.get(&id) { + Some(source) => source.clone(), + None => { + let Some(PageObject::Outline(source)) = object_mut(page, id).cloned() else { + continue; + }; + whole.insert(id, source.clone()); + source + } + }; + let mut root = 0; + let paragraphs = source + .paragraphs + .iter() + .filter(|paragraph| { + if paragraph.parent.is_none() { + root += 1; + } + range.contains(&(root - 1)) + }) + .cloned() + .collect(); + let part = Outline { + id: if range.start == 0 { + id + } else { + ExGuid { + guid: id.guid, + n: id.n ^ (0x8000_0000 | range.start as u32), + } + }, + paragraphs, + ..source + }; + block.members[0] = part.id; + match object_mut(page, part.id) { + Some(object) => *object = PageObject::Outline(part), + None => page.objects.push(PageObject::Outline(part)), + } + } + blocks +} + +/// Stacks `blocks` in order from `start` down a column `column` wide. +fn reflow( + page: &mut Page, + blocks: &[Block], + start: [f32; 2], + column: f32, + engine: &mut TextEngine, + kept: &mut Vec, +) -> Result<(), EditorError> { + let [left, mut y] = start; + // Widths first: text rewraps, pictures shrink, the rest moves across whole. + let mut heights = Vec::with_capacity(blocks.len()); + for block in blocks { + let [x0, y0, x1, y1] = block.bounds; + let mut height = y1 - y0; + let indent = if block.aside { ASIDE_INDENT } else { 0.0 }; + match (block.kind, object_mut(page, block.members[0])) { + (Kind::Text, Some(PageObject::Outline(outline))) => { + outline.layout.x = Some(left + indent); + outline.layout.max_width = Some((x1 - x0).min(column - indent)); + outline.layout.reserved_width = None; + outline.min_width = None; + collapse_blanks(outline); + for paragraph in &mut outline.paragraphs { + if let ParagraphContent::Image(image) = &mut paragraph.content { + shrink(image, column - 36.0); + } + } + } + (Kind::Picture, Some(PageObject::Image(image))) => { + image.layout.x = Some(left); + height = shrink(image, column).unwrap_or(height); + } + _ => { + for &id in &block.members { + if let Some(object) = object_mut(page, id) { + let layout = object.layout_mut(); + layout.x = Some(layout.x.unwrap_or(0.0) + left - x0); + } + } + if x1 - x0 > column + 1.0 { + kept.push(Kept::Wide); + } + } + } + heights.push(height); + } + // Text heights at the new widths. + let laid = CanvasEditor::from_page(page.clone(), engine)?; + for (block, height) in blocks.iter().zip(&mut heights) { + if block.kind != Kind::Text { + continue; + } + if let Some(outline) = laid.outlines().iter().find(|o| o.id == block.members[0]) { + let b = outline.bounds(); + *height = (b.y1 - b.y0) as f32; + if (b.x1 - b.x0) as f32 > column + 1.0 { + kept.push(Kept::Wide); + } + } + } + for (index, (block, height)) in blocks.iter().zip(&heights).enumerate() { + // Asides and the parts of the outline they split sit close. + let close = |block: &Block| block.aside || block.part.as_ref().is_some_and(|p| p.start > 0); + if index > 0 { + let previous = &blocks[index - 1]; + y += if close(block) || previous.aside { + ASIDE_GAP + } else { + GAP + }; + } + let dy = y - block.bounds[1]; + for (index, &id) in block.members.iter().enumerate() { + if let Some(object) = object_mut(page, id) { + let layout = object.layout_mut(); + layout.y = Some( + if index == 0 && matches!(block.kind, Kind::Text | Kind::Picture) { + y + } else { + layout.y.unwrap_or(0.0) + dy + }, + ); + } + } + y += height; + } + Ok(()) +} + +/// Drops each blank top-level paragraph that follows another, as spacing kept a side +/// column level with the text beside it. +fn collapse_blanks(outline: &mut Outline) { + let blank = |p: &onestore::page::PageParagraph| { + p.lists.is_empty() + && p.tags.is_empty() + && p.text() + .is_some_and(|t| t.tags.is_empty() && t.text.text().trim().is_empty()) + }; + let parents: std::collections::BTreeSet = + outline.paragraphs.iter().filter_map(|p| p.parent).collect(); + let mut previous_blank = false; + let mut root_kept = true; + outline.paragraphs.retain(|p| { + if p.parent.is_some() { + return root_kept; + } + let is_blank = blank(p) && !parents.contains(&p.id); + root_kept = !(is_blank && previous_blank); + previous_blank = is_blank; + root_kept + }); +} + +fn object_mut(page: &mut Page, id: ExGuid) -> Option<&mut PageObject> { + page.objects.iter_mut().find(|object| object.id() == id) +} + +/// Scales `image` down to `width` points across, keeping its shape; its new height. +fn shrink(image: &mut onestore::page::Image, width: f32) -> Option { + let [w, h] = crate::outline::image_size(image)?; + let scale = (width / w).min(1.0); + image.layout.max_width = Some(w * scale); + image.layout.max_height = Some(h * scale); + Some(h * scale) +} diff --git a/crates/canvas/src/search.rs b/crates/canvas/src/search.rs index e63476cca4c383097b8b94ab79402f85d90b5de9..b64e1b0a01e01bc644db6ad3b77decad8462595f 100644 --- a/crates/canvas/src/search.rs +++ b/crates/canvas/src/search.rs @@ -2,7 +2,7 @@ //! case and diacritics; a page matches when all its words appear in its title or text, and //! pages whose titles hold every word come first, then the most recently modified. -use crate::document::TextPosition; +use crate::document::{TextPosition, descendants}; use crate::editor::{CanvasEditor, Selection}; use icu_normalizer::DecomposingNormalizerBorrowed; use onestore::ExGuid; @@ -67,7 +67,7 @@ impl Query { } /// Where the query's words start words of `text`, as byte ranges of it. - pub fn find(&self, text: &str) -> Vec> { + pub(crate) fn find(&self, text: &str) -> Vec> { let (folded, source) = fold_mapped(text); self.hits(&folded) .into_iter() @@ -180,25 +180,13 @@ pub fn shown(paragraph: &Paragraph) -> String { /// Calls `visit` with each text paragraph of the page's outlines, in page order, tables /// cell by cell. fn text_paragraphs<'a>(page: &'a Page, mut visit: impl FnMut(&'a PageParagraph, &'a Paragraph)) { - fn walk<'a>( - paragraphs: &'a [PageParagraph], - visit: &mut impl FnMut(&'a PageParagraph, &'a Paragraph), - ) { - for paragraph in paragraphs { - match ¶graph.content { - ParagraphContent::Text(text) => visit(paragraph, &text.text), - ParagraphContent::Table(table) => { - for cell in table.rows.iter().flat_map(|row| &row.cells) { - walk(&cell.paragraphs, visit); - } - } - _ => {} - } - } - } for object in &page.objects { if let PageObject::Outline(outline) = object { - walk(&outline.paragraphs, &mut visit); + for (_, _, node) in descendants(&outline.paragraphs, None) { + if let ParagraphContent::Text(text) = &node.content { + visit(node, &text.text); + } + } } } } @@ -206,32 +194,19 @@ fn text_paragraphs<'a>(page: &'a Page, mut visit: impl FnMut(&'a PageParagraph, /// The text OneNote recognised in each of the page's pictures, or printed on a printout's /// pages, in page order. fn picture_text(page: &Page) -> Vec<&str> { - fn walk<'a>(paragraphs: &'a [PageParagraph], out: &mut Vec<&'a str>) { - for paragraph in paragraphs { - match ¶graph.content { - ParagraphContent::Image(image) => { - out.extend(image.text.as_ref().map(|text| text.text.as_str())) - } - ParagraphContent::Table(table) => { - for cell in table.rows.iter().flat_map(|row| &row.cells) { - walk(&cell.paragraphs, out); - } - } - _ => {} - } - } - } - let mut out = Vec::new(); - for object in &page.objects { - match object { - PageObject::Outline(outline) => walk(&outline.paragraphs, &mut out), - PageObject::Image(image) => { - out.extend(image.text.as_ref().map(|text| text.text.as_str())) - } - _ => {} - } - } - out + let pictures = page.objects.iter().flat_map(|object| match object { + PageObject::Outline(outline) => descendants(&outline.paragraphs, None) + .filter_map(|(_, _, node)| match &node.content { + ParagraphContent::Image(image) => Some(image), + _ => None, + }) + .collect(), + PageObject::Image(image) => vec![image], + _ => Vec::new(), + }); + pictures + .filter_map(|image| Some(image.text.as_ref()?.text.as_str())) + .collect() } /// A page's text outside its title, a paragraph to a line, then the text in its pictures, diff --git a/crates/canvas/src/spelling.rs b/crates/canvas/src/spelling.rs index 24d499dd84820ea6d6e4c07baf4ea08413d10f9c..e0ba73c5aba8b3f650cf7962166e150e595f81ad 100644 --- a/crates/canvas/src/spelling.rs +++ b/crates/canvas/src/spelling.rs @@ -60,10 +60,10 @@ pub fn pick(language: u32, available: &[String]) -> Option<&str> { /// A marked word: misspelled, or repeating the word before it. #[derive(Clone, Debug, PartialEq, Eq)] -pub struct Mark { +pub(crate) struct Mark { /// Bytes of the paragraph's text. - pub range: Range, - pub repeated: bool, + pub(crate) range: Range, + pub(crate) repeated: bool, } /// A word of a paragraph: its bytes, its language, and whether its spelling is checked. @@ -352,7 +352,7 @@ impl Spelling { /// The marks on `paragraph` once it is checked, less accepted words; until then none, /// and it is checked. - pub fn marks(&self, paragraph: &Paragraph) -> Vec { + pub(crate) fn marks(&self, paragraph: &Paragraph) -> Vec { let key = key(paragraph); let mut state = self.shared.state.lock().unwrap(); if let Some(marks) = state.marks(key, paragraph) { @@ -366,7 +366,7 @@ impl Spelling { /// The marks on `paragraph`, checked on this thread where not yet checked, as the /// Spelling pane walks the page. - pub fn marks_now(&self, paragraph: &Paragraph) -> Vec { + pub(crate) fn marks_now(&self, paragraph: &Paragraph) -> Vec { let key = key(paragraph); if let Some(marks) = self.shared.state.lock().unwrap().marks(key, paragraph) { return marks; @@ -378,7 +378,7 @@ impl Spelling { } /// Corrections for `word` in `language`, best first. - pub fn suggest(&self, word: &str, language: Option) -> Vec { + pub(crate) fn suggest(&self, word: &str, language: Option) -> Vec { self.shared .dictionary .suggest(word, language.unwrap_or(crate::language::EN_US)) diff --git a/crates/canvas/tests/reading.rs b/crates/canvas/tests/reading.rs new file mode 100644 index 0000000000000000000000000000000000000000..898f1f7009b23cd62f7260feda0c5faaaf2ba055 --- /dev/null +++ b/crates/canvas/tests/reading.rs @@ -0,0 +1,90 @@ +//! The reading view over OneNote-written pages: what it offers, the order it reads, and that +//! the reflowed page keeps every paragraph and object while only the shown copy moves. + +use canvas::{ + layout::TextEngine, + reading::{self, Kept, Kind, Refusal, Verdict}, +}; +use onestore::{ + Arena, ExGuid, Section, + page::{Page, PageObject}, +}; +use std::collections::BTreeSet; + +const COLUMN: f32 = 230.0; + +fn first_page(bytes: &[u8]) -> Page { + let arena = Arena::default(); + let mut section = Section::open(&arena, bytes.to_vec()).unwrap(); + let space = section.pages().unwrap()[0].0; + section.page(space).unwrap() +} + +fn paragraphs(page: &Page) -> BTreeSet { + page.objects + .iter() + .filter_map(|object| match object { + PageObject::Outline(outline) => Some(outline.paragraphs.iter().map(|p| p.id)), + _ => None, + }) + .flatten() + .collect() +} + +#[test] +fn a_planning_page_reads_down_one_column() { + let page = first_page(include_bytes!( + "../../../corpus/notebook-management/native/page-color/Garden.one" + )); + let mut engine = TextEngine::default(); + let read = reading::read(&page, COLUMN, &mut engine).unwrap(); + assert_eq!( + read.verdict, + Verdict::Partial(vec![Kept::Asides, Kept::SideBySide, Kept::Wide]) + ); + let reflowed = read.page.unwrap(); + // Splitting around asides drops no paragraph; only spacing blank runs could go. + assert_eq!(paragraphs(&reflowed), paragraphs(&page)); + // Every block starts at the column's left edge or an aside's indent, top to bottom. + let mut tops = Vec::new(); + for block in &read.blocks { + let object = reflowed + .objects + .iter() + .find(|object| object.id() == block.members[0]) + .unwrap(); + let layout = object.layout(); + if block.kind == Kind::Text { + assert!(layout.max_width.unwrap() <= COLUMN); + } + tops.push(layout.y.unwrap()); + } + assert!(tops.windows(2).all(|pair| pair[0] < pair[1]), "{tops:?}"); + // The page itself is untouched. + let again = first_page(include_bytes!( + "../../../corpus/notebook-management/native/page-color/Garden.one" + )); + assert_eq!( + serde_json::to_string(&page).unwrap(), + serde_json::to_string(&again).unwrap() + ); +} + +#[test] +fn a_drawing_is_not_reflowed() { + let page = first_page(include_bytes!( + "../../../corpus/ink-edit/drawing/candidate/ink.one" + )); + let read = reading::read(&page, COLUMN, &mut TextEngine::default()).unwrap(); + assert_eq!(read.verdict, Verdict::Refused(Refusal::Drawn)); + assert!(read.page.is_none()); +} + +#[test] +fn a_page_as_narrow_as_the_column_shows_as_laid_out() { + let page = first_page(include_bytes!( + "../../../corpus/append/round-01/complex/notebook/synthetic.one" + )); + let read = reading::read(&page, 2000.0, &mut TextEngine::default()).unwrap(); + assert_eq!(read.verdict, Verdict::Fits); +} diff --git a/crates/canvas/tests/rtl_page.rs b/crates/canvas/tests/rtl_page.rs new file mode 100644 index 0000000000000000000000000000000000000000..90b0d008e499a590e35a55ca26e7e4de1b9ede69 --- /dev/null +++ b/crates/canvas/tests/rtl_page.rs @@ -0,0 +1,188 @@ +//! A right-to-left page through the page view, as a host drives it. OneNote 2010 keeps such a +//! page's positions in the same left-to-right frame, its margin origin some 10,800 pt right of +//! the page origin, and opens it scrolled to the right end of its content; the view does the +//! same, and a click, a stroke and a drag store positions OneNote reads back where they were +//! put. `SNOWBOUND_RTL_EXPORT` names a new directory receiving the edited section as a notebook +//! for a cold reopen in OneNote 2010 (`corpus/rtl-page`). +#![cfg(feature = "interaction")] + +use canvas::{ + editor::Pen, + gpu::page::PageScene, + interaction::{PageView, ink::Tool}, + layout::TextEngine, +}; +use onestore::{ + Arena, ExGuid, Section, Store, + op::{Edit, Op}, + page::{Page, PageObject}, +}; +use std::time::{Duration, Instant}; + +const SOURCE: &[u8] = + include_bytes!("../../../corpus/m6/native-page-direction-03/notebook/synthetic.one"); + +fn drag(view: &mut PageView, path: &[[f32; 2]]) { + let _ = view.pointer_moved(path[0]).unwrap(); + let _ = view.pointer_pressed(Instant::now()).unwrap(); + for point in &path[1..] { + let _ = view.pointer_moved(*point).unwrap(); + } + let _ = view.pointer_released().unwrap(); +} + +fn outline<'a>(page: &'a Page, text: &str) -> &'a onestore::page::Outline { + page.objects + .iter() + .find_map(|object| match object { + PageObject::Outline(outline) + if outline.paragraphs.iter().any(|paragraph| { + matches!(¶graph.content, + onestore::page::ParagraphContent::Text(run) if run.text.text() == text) + }) => + { + Some(outline) + } + _ => None, + }) + .unwrap() +} + +#[test] +fn a_right_to_left_page_opens_at_its_right_end_and_stores_edits_in_onenotes_frame() { + let arena = Arena::default(); + let mut section = Section::open(&arena, SOURCE.to_vec()).unwrap(); + let space: ExGuid = section.pages().unwrap()[0].0; + let page = section.page(space).unwrap(); + assert!(page.rtl); + let margin = page.margin_origin; + let ink_right = page + .objects + .iter() + .filter_map(|object| match object { + PageObject::Ink(ink) => ink.bounds().map(|b| b[0] + b[2] + ink.layout.x.unwrap()), + _ => None, + }) + .fold(f32::NEG_INFINITY, f32::max); + let mut engine = TextEngine::default(); + let (scene, editor) = PageScene::from_page(page, &mut engine).unwrap(); + let mut view = PageView::new( + editor, + engine, + Some((scene, [0.0; 2])), + [800, 600], + 1.0, + Duration::from_millis(500), + ); + + // The view opens at the right end of the content, the drawings at its right edge. + let scroll = view.scroll(); + assert_eq!(view.viewport.origin[0], -scroll.max[0]); + let right = view.viewport.document_point([800.0, 0.0])[0]; + assert!( + ink_right <= right && right < ink_right + 12.0, + "{ink_right} {right}" + ); + assert_eq!(scroll.min[0], 0.0); + // Resizing keeps the right edge, as OneNote's does. + let _ = view.resized([600, 600]).unwrap(); + assert_eq!(view.viewport.document_point([600.0, 0.0])[0], right); + let _ = view.resized([800, 600]).unwrap(); + assert_eq!(view.viewport.document_point([800.0, 0.0])[0], right); + + let mut at = 134_100_000_000_000_000; + let mut store = |view: &mut PageView, section: &mut Section| { + let ops = view.editor.take_ops().unwrap(); + assert!(!ops.is_empty()); + at += 10_000_000; + let ops = ops.into_iter().map(|op| Op::Page { space, op }).collect(); + section.apply("Snowbound Test", &Edit { at, ops }).unwrap(); + section.page(space).unwrap() + }; + + // A click on blank page puts an outline there, on the grid anchored at the margin origin. + let click = [240.0, 480.0]; + let point = view.viewport.document_point(click); + let _ = view.pointer_moved(click).unwrap(); + let _ = view.pointer_pressed(Instant::now()).unwrap(); + let _ = view.pointer_released().unwrap(); + let _ = view + .insert_text("Typed on a right-to-left page".into()) + .unwrap(); + let page = store(&mut view, &mut section); + let typed = outline(&page, "Typed on a right-to-left page") + .layout + .clone(); + let [x, y] = [typed.x.unwrap(), typed.y.unwrap()]; + for (stored, clicked, origin) in [(x, point[0], margin[0]), (y, point[1], margin[1])] { + assert!( + ((stored - origin) / 18.0).fract().abs() < 1e-3, + "{stored} {origin}" + ); + assert!((stored - clicked).abs() <= 18.0, "{stored} {clicked}"); + } + assert!(x > margin[0]); + + // A stroke lands in page points where the pen went. + let _ = view.set_tool(Tool::Pen(Pen::new(1.0, Some(0x1f4e79)))); + let path: Vec<[f32; 2]> = (0..=20) + .map(|step| [300.0 + step as f32 * 5.0, 520.0 + step as f32 * 2.0]) + .collect(); + drag(&mut view, &path); + let known: Vec = page.objects.iter().map(PageObject::id).collect(); + let page = store(&mut view, &mut section); + let [PageObject::Ink(ink)] = page + .objects + .iter() + .filter(|object| !known.contains(&object.id())) + .collect::>()[..] + else { + panic!("the stroke is not one new drawing") + }; + let offset = [ink.layout.x.unwrap_or(0.0), ink.layout.y.unwrap_or(0.0)]; + let first = ink.strokes[0].points[0]; + let expected = view.viewport.document_point(path[0]); + for axis in 0..2 { + assert!((first[axis] + offset[axis] - expected[axis]).abs() < 0.5); + } + let _ = view.set_tool(Tool::Select); + + // The header drags an outline two grid columns right, onto the grid anchored at the + // margin origin. + let before = outline(&page, "Fictitious positioned outline.") + .layout + .clone(); + let header = [ + before.x.unwrap() * view.viewport.scale + view.viewport.origin[0] + 40.0, + before.y.unwrap() * view.viewport.scale + view.viewport.origin[1] - 8.0, + ]; + let steps: Vec<[f32; 2]> = (0..=12) + .map(|step| [header[0] + step as f32 * 4.0, header[1] + step as f32 * 2.0]) + .collect(); + drag(&mut view, &steps); + let page = store(&mut view, &mut section); + let after = outline(&page, "Fictitious positioned outline.") + .layout + .clone(); + assert_eq!(after.x.unwrap() - before.x.unwrap(), 36.0); + assert!(after.y.unwrap() > before.y.unwrap()); + assert_eq!(((after.y.unwrap() - margin[1]) / 18.0).fract(), 0.0); + + section.seal().unwrap(); + let written = section.image(); + if let Some(directory) = std::env::var_os("SNOWBOUND_RTL_EXPORT") { + let directory = std::path::PathBuf::from(directory); + std::fs::create_dir(&directory).unwrap(); + std::fs::write(directory.join("synthetic.one"), &written).unwrap(); + let file_id = Store::parse(&written).unwrap().header.file_id; + std::fs::write( + directory.join("Open Notebook.onetoc2"), + onestore::create_table_of_contents( + "Open Notebook.onetoc2", + &[("synthetic.one", file_id)], + ) + .unwrap(), + ) + .unwrap(); + } +} diff --git a/crates/draw/Cargo.toml b/crates/draw/Cargo.toml index 6e0ac70b2dbea62d7086e2e43cd55dc04c356a6b..be32f891644047e9eb95e4b2a1d0fddeae43e4e2 100644 --- a/crates/draw/Cargo.toml +++ b/crates/draw/Cargo.toml @@ -7,10 +7,10 @@ publish = false [features] default = ["wgpu"] # The renderer; `edit` needs only parley. It submits through wgpu, or without it through -# OpenGL 2.1, for Mac OS X 10.6. On Windows it has both, and its maker picks one, as -# Windows 7 has no Direct3D 12. +# OpenGL 2.1, for Mac OS X 10.6. On Windows it has Direct3D 11 and OpenGL besides, and its +# maker picks one, as Windows 7 has no Direct3D 12. render = ["dep:base64", "dep:bytemuck", "dep:image", "dep:linebender_resource_handle", "dep:roxmltree", "dep:swash"] -wgpu = ["render", "dep:wgpu"] +wgpu = ["render", "dep:wgpu", "dep:windows"] # Writes layers as PDF pages, text as real text in subset fonts. pdf = ["render", "dep:miniz_oxide", "dep:pdf-writer", "dep:subsetter"] @@ -28,6 +28,16 @@ subsetter = { version = "0.2.6", default-features = false, optional = true } swash = { version = "0.2.10", optional = true } wgpu = { workspace = true, optional = true } +[target.'cfg(windows)'.dependencies] +windows = { version = "0.62", default-features = false, optional = true, features = [ + "Win32_Graphics_Direct3D", + "Win32_Graphics_Direct3D11", + "Win32_Graphics_DirectComposition", + "Win32_Graphics_Dxgi_Common", + "Win32_System_LibraryLoader", + "Win32_UI_WindowsAndMessaging", +] } + [dev-dependencies] pollster = "0.4" png = "0.18" diff --git a/crates/draw/src/draw.hlsl b/crates/draw/src/draw.hlsl new file mode 100644 index 0000000000000000000000000000000000000000..fe69295170419c26f2a487292a2587ed19670f08 --- /dev/null +++ b/crates/draw/src/draw.hlsl @@ -0,0 +1,153 @@ +// Shader model 4.0 port of draw.wgsl, for Direct3D 11 from feature level 10_0. The render +// module embeds its bytecode, compiled by render/dxbc/compile.ps1. +struct Vertex { + float4 position : SV_Position; + float2 uv : TEXCOORD0; + float4 color : TEXCOORD1; + float2 local : TEXCOORD2; + nointerpolation float4 shape : TEXCOORD3; + nointerpolation float stroke : TEXCOORD4; + float2 clip_local : TEXCOORD5; + nointerpolation float3 clip : TEXCOORD6; + nointerpolation float blur : TEXCOORD7; +}; + +Texture2D atlas : register(t0); +SamplerState atlas_sampler : register(s0); + +Vertex vertex(float2 position : TEXCOORD0, float2 uv : TEXCOORD1, float4 color : TEXCOORD2, + float2 local : TEXCOORD3, float4 shape : TEXCOORD4, float stroke : TEXCOORD5, + float2 clip_local : TEXCOORD6, float3 clip : TEXCOORD7, float blur : TEXCOORD8) { + Vertex output; + output.position = float4(position, 0.0, 1.0); + output.uv = uv; + output.color = color; + output.local = local; + output.shape = shape; + output.stroke = stroke; + output.clip_local = clip_local; + output.clip = clip; + output.blur = blur; + return output; +} + +float ellipse_arc(float angle, float2 radius) { + if (radius.x == radius.y) { + return angle * radius.x; + } + // Four-point Gauss-Legendre quadrature. + const float4 nodes = float4(-0.86113631, -0.33998104, 0.33998104, 0.86113631); + const float4 weights = float4(0.34785485, 0.65214515, 0.65214515, 0.34785485); + float half_angle = angle * 0.5; + float scale = max(radius.x, radius.y); + float sum = 0.0; + [unroll] for (int i = 0; i < 4; i++) { + float t = half_angle * (nodes[i] + 1.0); + sum += weights[i] * length((radius / scale) * float2(sin(t), cos(t))); + } + return half_angle * sum * scale; +} + +float2 erf(float2 x) { + float2 s = sign(x); + float2 a = abs(x); + float2 y = 1.0 + (0.278393 + (0.230389 + 0.078108 * (a * a)) * a) * a; + y *= y; + return s - s / (y * y); +} + +float shadow(float2 p, float2 half_size, float corner, float sigma) { + float start = clamp(-3.0 * sigma, p.y - half_size.y, p.y + half_size.y); + float end = clamp(3.0 * sigma, p.y - half_size.y, p.y + half_size.y); + float stride = (end - start) / 4.0; + float y = start + stride * 0.5; + float value = 0.0; + [unroll] for (int i = 0; i < 4; i++) { + float delta = min(half_size.y - corner - abs(p.y - y), 0.0); + float curved = half_size.x - corner + sqrt(max(0.0, corner * corner - delta * delta)); + float2 integral = 0.5 + 0.5 * erf((p.x + float2(-curved, curved)) * (0.70710678 / sigma)); + float weight = exp(-y * y / (2.0 * sigma * sigma)) / (2.50662827 * sigma); + value += (integral.y - integral.x) * weight * stride; + y += stride; + } + return value; +} + +float taper(float2 p, float h, float r0, float r1) { + float2 q = float2(abs(p.y), p.x + h); + float span = 2.0 * h; + float b = (r0 - r1) / max(span, 1e-6); + float far = length(q - float2(0.0, span)) - r1; + if (abs(b) >= 1.0) { + return min(length(q) - r0, far); + } + float a = sqrt(1.0 - b * b); + float k = dot(q, float2(-b, a)); + if (k < 0.0) { + return length(q) - r0; + } + if (k > a * span) { + return far; + } + return dot(q, float2(a, b)) - r0; +} + +float4 fragment(Vertex input) : SV_Target { + float4 color = atlas.Sample(atlas_sampler, input.uv) * input.color; + if (input.blur > 0.0) { + color *= shadow(input.local, input.shape.xy, input.shape.z, input.blur); + } else if (input.blur < 0.0) { + color *= saturate(0.5 - taper(input.local, input.shape.x, input.shape.y, input.shape.z)); + } else if (input.shape.x > 0.0 && input.shape.y > 0.0) { + float2 q = abs(input.local) - input.shape.xy + input.shape.zw; + float distance; + if (input.shape.z == input.shape.w) { + distance = length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - input.shape.z; + } else if (all(q > 0.0)) { + float2 radius = max(input.shape.zw, 0.0001); + float2 normalized = q / radius; + float k0 = length(normalized); + distance = k0 * (k0 - 1.0) / max(length(normalized / radius), 1e-20); + } else { + float2 edge = abs(input.local) - input.shape.xy; + distance = max(edge.x, edge.y); + } + float coverage = saturate(0.5 - distance); + float width = abs(input.stroke); + if (width > 0.0) { + coverage *= saturate(distance + width + 0.5); + } + if (input.stroke < 0.0) { + float2 radius = max(input.shape.zw - width * 0.5, 0.0); + if (any(radius == 0.0)) { + radius = 0.0; + } + float2 straight = input.shape.xy - width * 0.5 - radius; + float2 p = abs(input.local); + float quarter = straight.x + straight.y + ellipse_arc(1.57079632679, radius); + float along; + if (p.y <= straight.y) { + along = p.y; + } else if (p.x <= straight.x || radius.x == 0.0) { + along = quarter - p.x; + } else { + float angle = atan2((p.y - straight.y) / radius.y, (p.x - straight.x) / radius.x); + along = straight.y + ellipse_arc(angle, radius); + } + if (input.local.x < 0.0) { + along = input.local.y < 0.0 ? 2.0 * quarter + along : 2.0 * quarter - along; + } else if (input.local.y < 0.0) { + along = 4.0 * quarter - along; + } + float phase = frac(along / (4.0 * width)) * (4.0 * width); + coverage *= saturate(width + 0.5 - abs(phase - 2.0 * width)); + } + color *= coverage; + } + if (input.clip.x > 0.0) { + float2 q = abs(input.clip_local) - input.clip.xy + input.clip.z; + float distance = length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - input.clip.z; + color *= saturate(0.5 - distance); + } + return color; +} diff --git a/crates/draw/src/edit.rs b/crates/draw/src/edit.rs index 80e24eaff251b44f1a1c4501f12451642c3320f8..f01ccbd8356c5c38915f646dd5c134aa715af2ac 100644 --- a/crates/draw/src/edit.rs +++ b/crates/draw/src/edit.rs @@ -432,25 +432,11 @@ pub fn caret_blink(since: Duration) -> (f32, Option) { /// `caret`, linear RGBA, with the alpha that shows it at `opacity` over `backdrop` as bright /// as AppKit's layers do, which blend in sRGB rather than linear light. pub fn caret_color(caret: [f32; 4], backdrop: [f32; 4], opacity: f32) -> [f32; 4] { - let encode = |value: f32| { - if value <= 0.003_130_8 { - value * 12.92 - } else { - 1.055 * value.powf(1.0 / 2.4) - 0.055 - } - }; - let decode = |value: f32| { - if value <= 0.040_45 { - value / 12.92 - } else { - ((value + 0.055) / 1.055).powf(2.4) - } - }; let luminance = |[red, green, blue]: [f32; 3]| 0.2126 * red + 0.7152 * green + 0.0722 * blue; let [under, over] = [backdrop, caret].map(|color| [color[0], color[1], color[2]]); let shown: [f32; 3] = std::array::from_fn(|channel| { - let [under, over] = [under[channel], over[channel]].map(encode); - decode(under + opacity * (over - under)) + let [under, over] = [under[channel], over[channel]].map(crate::encode); + crate::linear(under + opacity * (over - under)) }); let span = luminance(over) - luminance(under); let alpha = if span.abs() < 1e-4 { diff --git a/crates/draw/src/lib.rs b/crates/draw/src/lib.rs index 1db5447092de2b19c8b2d59d54b85d400640a29a..2cc498a09b6799d0ce85faff1185301d778a3dba 100644 --- a/crates/draw/src/lib.rs +++ b/crates/draw/src/lib.rs @@ -1,6 +1,6 @@ //! What the page and the interface share: painting layers of glyphs, icons, paths, -//! images, rounded rectangles and pen strokes with wgpu or OpenGL, each layer with its -//! own transform and clip, and editing text within a laid-out line. +//! images, rounded rectangles and pen strokes with wgpu, Direct3D 11 or OpenGL, each layer +//! with its own transform and clip, and editing text within a laid-out line. pub mod edit; #[cfg(feature = "render")] @@ -8,3 +8,21 @@ mod render; #[cfg(feature = "render")] pub use render::*; + +/// sRGB's encoding of a linear channel. +fn encode(linear: f32) -> f32 { + if linear <= 0.003_130_8 { + linear * 12.92 + } else { + 1.055 * linear.powf(1.0 / 2.4) - 0.055 + } +} + +/// The linear light of an sRGB-encoded channel. +fn linear(encoded: f32) -> f32 { + if encoded <= 0.040_45 { + encoded / 12.92 + } else { + ((encoded + 0.055) / 1.055).powf(2.4) + } +} diff --git a/crates/draw/src/render.rs b/crates/draw/src/render.rs index f8ead5876512d611a120a5a37417dae3db41b524..c16123b864c771ba9bfa2871ac62b020848c5ffe 100644 --- a/crates/draw/src/render.rs +++ b/crates/draw/src/render.rs @@ -1,4 +1,6 @@ #[cfg(all(feature = "wgpu", windows))] +mod d3d11; +#[cfg(all(feature = "wgpu", windows))] mod dual; #[cfg(any(windows, not(feature = "wgpu")))] mod gl; @@ -19,8 +21,6 @@ use gl as backend; use webgpu as backend; pub use backend::Target; -#[cfg(all(feature = "wgpu", windows))] -pub use gl::Target as GlTarget; pub use icon::{Palette, picture_icon}; #[cfg(feature = "pdf")] pub use pdf::{Sheet, pdf}; @@ -34,6 +34,8 @@ use linebender_resource_handle::WeakBlob; use parley::fontique::Blob; use std::{ collections::{HashMap, HashSet}, + ffi::CStr, + mem::offset_of, ops::Range, }; use swash::{ @@ -171,14 +173,25 @@ impl RasterImage { } } -/// What a backend submits: the prepared vertices and batches, and the images they paint. +/// What a backend submits: each group's batches, painted into an offscreen picture of its +/// own, then the batches painting the target, and the images they show. struct Frame<'a> { vertices: &'a [Vertex], + groups: &'a [Vec], batches: &'a [Batch], - groups: &'a [Group], images: &'a HashMap, } +impl Frame<'_> { + /// The texture image `id` uploaded as, as the backend drawing it holds it. + fn image(&self, id: u64) -> &T + where + backend::Image: AsRef, + { + self.images[&id].texture.as_ref() + } +} + struct CachedImage { texture: backend::Image, bytes: u64, @@ -192,24 +205,58 @@ struct Batch { scissor: [u32; 4], } -/// Batches that paint offscreen as one, then onto the target by `composite`'s vertices. -struct Group { - batches: Range, - composite: Range, -} - -/// How a batch meets what lies beneath it. +/// How a batch meets what lies beneath it, and what it samples: the atlas unless it paints +/// a picture. #[derive(Clone, Copy, PartialEq)] enum Blend { Over, /// A premultiplied picture over it. Image(u64), + /// That group's offscreen picture over it. + Group(usize), /// Clearing it by the batch's coverage. Erase, /// Multiplying it by the batch's colour where covered. Multiply, } +#[derive(Clone, Copy)] +enum Factor { + Zero, + One, + SourceAlpha, + OneMinusSourceAlpha, + Destination, +} + +impl Blend { + /// Each blend state a backend makes: colour's source and destination factors, then + /// alpha's, every one adding. + const STATES: [[Factor; 4]; 4] = { + use Factor::*; + [ + // Coverage accumulates in alpha, leaving premultiplied colour over a transparent + // clear. + [SourceAlpha, OneMinusSourceAlpha, One, OneMinusSourceAlpha], + [One, OneMinusSourceAlpha, One, OneMinusSourceAlpha], + [Zero, OneMinusSourceAlpha, Zero, OneMinusSourceAlpha], + // The fragment's colour is scaled by its coverage, so this leaves the target times + // the colour where covered and the target elsewhere; alpha stays. + [Destination, OneMinusSourceAlpha, Zero, One], + ] + }; + + /// This blend's index in `STATES`. + fn state(self) -> usize { + match self { + Blend::Over => 0, + Blend::Image(_) | Blend::Group(_) => 1, + Blend::Erase => 2, + Blend::Multiply => 3, + } + } +} + #[repr(C)] #[derive(Clone, Copy, Pod, Zeroable)] struct Vertex { @@ -228,6 +275,19 @@ struct Vertex { blur: f32, } +/// Each `Vertex` field's name in the shaders, float count and offset, in shader input order. +const ATTRIBUTES: [(&CStr, usize, usize); 9] = [ + (c"position", 2, offset_of!(Vertex, position)), + (c"uv", 2, offset_of!(Vertex, uv)), + (c"color", 4, offset_of!(Vertex, color)), + (c"local", 2, offset_of!(Vertex, local)), + (c"shape", 4, offset_of!(Vertex, shape)), + (c"stroke", 1, offset_of!(Vertex, stroke)), + (c"clip_local", 2, offset_of!(Vertex, clip_local)), + (c"clip", 3, offset_of!(Vertex, clip)), + (c"blur", 1, offset_of!(Vertex, blur)), +]; + #[derive(Hash, PartialEq, Eq)] enum AtlasKey { Text { @@ -313,6 +373,18 @@ impl Motion { } } +impl Layer<'_> { + fn space(&self, size: [u32; 2]) -> Space { + Space { + size, + scale: self.scale, + origin: self.origin, + backdrop: self.backdrop, + round: self.round, + } + } +} + /// A layer's transform onto the target. #[derive(Clone, Copy)] struct Space { @@ -324,13 +396,18 @@ struct Space { } impl Space { + /// The device point at layer point `point`. + fn point(&self, point: [f32; 2]) -> [f32; 2] { + [0, 1].map(|axis| point[axis] * self.scale + self.origin[axis]) + } + + /// The device bounds of layer bounds `rect`. + fn rect(&self, rect: [f32; 4]) -> [f32; 4] { + [0, 1, 2, 3].map(|edge| rect[edge] * self.scale + self.origin[edge % 2]) + } + fn visible_rect(&self, rect: [f32; 4]) -> Result, RenderError> { - let rect = [ - rect[0] * self.scale + self.origin[0], - rect[1] * self.scale + self.origin[1], - rect[2] * self.scale + self.origin[0], - rect[3] * self.scale + self.origin[1], - ]; + let rect = self.rect(rect); if rect.iter().any(|v| !v.is_finite()) || rect[2] < rect[0] || rect[3] < rect[1] { return Err(RenderError::InvalidPrimitive); } @@ -508,7 +585,7 @@ pub struct Renderer { glyphs: HashMap>, images: HashMap, batches: Vec, - groups: Vec, + groups: Vec>, /// Batches before this one take no more primitives. barrier: usize, scaler: ScaleContext, @@ -516,10 +593,36 @@ pub struct Renderer { row_height: u32, } +#[cfg(any(windows, not(feature = "wgpu")))] impl Renderer { - fn with_gpu(gpu: backend::Gpu) -> Self { - let renderer = Self { - gpu, + /// Draws with the OpenGL context current on this thread, which must stay current + /// whenever the renderer or a `Target` is used. + pub fn opengl() -> Result { + gl::Gpu::new().map(Self::with_gpu) + } + + /// An offscreen frame `size` pixels large, for Direct3D 11 or OpenGL. + pub fn target(&self, size: [u32; 2]) -> Result { + self.gpu.target(size) + } + + /// Shows `target` in the window, premultiplying each pixel again in sRGB if + /// `translucent`, as the desktop compositing the window over its backdrop needs. + /// OpenGL's frame waits for its context's buffers to swap. + pub fn present(&self, target: &Target, translucent: bool) { + self.gpu.present(target, translucent); + } + + /// The sRGB RGBA rows of `target`, from `Renderer::target`, top first. + pub fn read_pixels(&self, target: &Target) -> Result, String> { + self.gpu.read_pixels(target) + } +} + +impl Renderer { + fn with_gpu(gpu: impl Into) -> Self { + let mut renderer = Self { + gpu: gpu.into(), vertices: Vec::new(), glyphs: HashMap::new(), images: HashMap::new(), @@ -527,10 +630,10 @@ impl Renderer { groups: Vec::new(), barrier: 0, scaler: ScaleContext::with_max_entries(32), - pen: [1, 0], - row_height: 1, + pen: [0; 2], + row_height: 0, }; - renderer.gpu.write_atlas([0, 0], [1, 1], &[255; 4]); + renderer.clear_glyph_cache(); renderer } @@ -539,23 +642,12 @@ impl Renderer { self.gpu.max_texture_dimension() } - fn atlas_side(&self) -> u32 { - self.gpu.atlas_side() - } - - /// Replaces the atlas with an empty one twice as wide and tall. - fn grow_atlas(&mut self) { - self.gpu.new_atlas(self.atlas_side() * 2); - self.clear_glyph_cache(); - self.gpu.write_atlas([0, 0], [1, 1], &[255; 4]); - } - fn glyph_limit(&self) -> usize { - MAX_GLYPHS * (self.atlas_side() / ATLAS_SIZE).pow(2) as usize + MAX_GLYPHS * (self.gpu.atlas_side() / ATLAS_SIZE).pow(2) as usize } fn uv(&self, glyph: AtlasGlyph) -> [f32; 4] { - let side = self.atlas_side() as f32; + let side = self.gpu.atlas_side() as f32; [ glyph.x as f32 / side, glyph.y as f32 / side, @@ -564,13 +656,15 @@ impl Renderer { ] } - /// The atlas's first texel, the white that solid fills sample. + /// The atlas's white texel. fn white(&self) -> [f32; 4] { - [0.5 / self.atlas_side() as f32; 4] + [0.5 / self.gpu.atlas_side() as f32; 4] } + /// Empties the atlas but for its first texel, the white that solid fills sample. pub fn clear_glyph_cache(&mut self) { self.glyphs.clear(); + self.gpu.write_atlas([0, 0], [1, 1], &[255; 4]); self.pen = [1, 0]; self.row_height = 1; } @@ -593,12 +687,12 @@ impl Renderer { images: self.images.len(), image_bytes, vertices: self.vertices.len(), - batches: self.batches.len(), + batches: self.batches.len() + self.groups.iter().map(Vec::len).sum::(), vertex_capacity_bytes: self.vertices.capacity() * size_of::(), batch_capacity_bytes: self.batches.capacity() * size_of::(), glyph_capacity: self.glyphs.capacity(), image_capacity: self.images.capacity(), - atlas_bytes: u64::from(self.atlas_side()).pow(2) * 4, + atlas_bytes: u64::from(self.gpu.atlas_side()).pow(2) * 4, vertex_buffer_bytes: VERTEX_BUFFER_BYTES, within_budget: self.glyphs.len() <= self.glyph_limit() && self.images.len() <= MAX_IMAGES @@ -633,16 +727,9 @@ impl Renderer { let mut active_images = HashSet::new(); let mut image_bytes = 0; for layer in layers { - let space = Space { - size, - scale: layer.scale, - origin: layer.origin, - backdrop: layer.backdrop, - round: layer.round, - }; for primitive in layer.primitives { if let Primitive::Image { image, rect } = primitive - && space.visible_rect(*rect)?.is_some() + && layer.space(size).visible_rect(*rect)?.is_some() && active_images.insert(image.id()) { image_bytes += image.pixels().len() as u64; @@ -668,15 +755,16 @@ impl Renderer { }; // Once cleared, the atlas holds only this frame's entries; past half full, the // next frames would clear it again and again. - let crowded = full || (cleared && 2 * self.pen[1] > self.atlas_side()); + let crowded = full || (cleared && 2 * self.pen[1] > self.gpu.atlas_side()); if !crowded { break; } if !cleared { self.clear_glyph_cache(); cleared = true; - } else if self.atlas_side() * 2 <= limit { - self.grow_atlas(); + } else if self.gpu.atlas_side() * 2 <= limit { + self.gpu.new_atlas(self.gpu.atlas_side() * 2); + self.clear_glyph_cache(); } else if full { return Err(RenderError::AtlasFull); } else { @@ -685,8 +773,8 @@ impl Renderer { } let frame = Frame { vertices: &self.vertices, - batches: &self.batches, groups: &self.groups, + batches: &self.batches, images: &self.images, }; self.gpu.submit(&frame, target, size, clear); @@ -713,13 +801,7 @@ impl Renderer { self.barrier = self.batches.len(); group = motion.map(|motion| (motion, self.batches.len())); } - let space = Space { - size, - scale: layer.scale, - origin: layer.origin, - backdrop: layer.backdrop, - round: layer.round, - }; + let space = layer.space(size); let bounds = match layer.clip { Some(clip) => space.scissor(clip), None => [0, 0, size[0], size[1]], @@ -737,12 +819,11 @@ impl Renderer { Ok(()) } - /// Makes the batches from `start` a group appearing by `motion`: the vertices that - /// paint its offscreen picture over the target, in strips so the turn stays in - /// perspective across the picture. + /// Makes the batches from `start` a group appearing by `motion`, leaving in their place + /// the batch that paints its offscreen picture over the target, in strips so the turn + /// stays in perspective across the picture. fn group(&mut self, motion: Motion, start: usize, size: [u32; 2]) -> Result<(), RenderError> { - let batches = start..self.batches.len(); - if batches.is_empty() { + if start == self.batches.len() { return Ok(()); } let [width, height] = size.map(|side| side as f32); @@ -771,12 +852,7 @@ impl Renderer { position: [shown_x * 2.0 / width - 1.0, 1.0 - shown_y * 2.0 / height], uv: [x / width, if flipped { 1.0 - v } else { v }], color: [motion.opacity; 4], - local: [0.0; 2], - shape: [0.0; 4], - stroke: 0.0, - clip_local: [0.0; 2], - clip: [0.0; 3], - blur: 0.0, + ..Vertex::zeroed() } }; for strip in 0..strips { @@ -790,10 +866,13 @@ impl Renderer { ]; self.vertices.extend([a, b, c, a, c, d]); } - self.groups.push(Group { - batches, - composite: from..self.vertices.len() as u32, + let batches = self.batches.split_off(start); + self.batches.push(Batch { + vertices: from..self.vertices.len() as u32, + blend: Blend::Group(self.groups.len()), + scissor: [0, 0, size[0], size[1]], }); + self.groups.push(batches); Ok(()) } @@ -815,10 +894,7 @@ impl Renderer { tint, palette, } => { - let origin = [ - space.origin[0] + origin[0] * space.scale, - space.origin[1] + origin[1] * space.scale, - ]; + let origin = space.point(*origin); self.icon(Space { origin, ..space }, sources, *size, *tint, palette)?; } Primitive::Path { @@ -838,10 +914,7 @@ impl Renderer { clip, ink, } => { - let origin = [ - space.origin[0] + origin[0] * space.scale, - space.origin[1] + origin[1] * space.scale, - ]; + let origin = space.point(*origin); if origin.iter().any(|v| !v.is_finite()) { return Err(RenderError::InvalidPrimitive); } @@ -858,17 +931,7 @@ impl Renderer { text.runs(&mut |run| self.glyph_run(space, run, *ink))?; } Primitive::Rect { rect, color } => { - self.quad( - space, - [ - rect[0] * space.scale + space.origin[0], - rect[1] * space.scale + space.origin[1], - rect[2] * space.scale + space.origin[0], - rect[3] * space.scale + space.origin[1], - ], - self.white(), - *color, - )?; + self.quad(space, space.rect(*rect), self.white(), *color)?; } Primitive::RoundedRect { rect, @@ -993,7 +1056,7 @@ impl Renderer { return Ok(()); } let size = run.size * scale; - if !size.is_finite() || size > self.atlas_side() as f32 { + if !size.is_finite() || size > self.gpu.atlas_side() as f32 { return Err(RenderError::AtlasFull); } let color = run @@ -1119,7 +1182,7 @@ impl Renderer { fn upload(&mut self, image: swash::scale::image::Image) -> Result { let p = image.placement; - let side = self.atlas_side(); + let side = self.gpu.atlas_side(); if p.width + 2 > side || p.height + 2 > side { return Err(RenderError::AtlasFull); } @@ -1173,7 +1236,7 @@ impl Renderer { { return Ok(()); } - if size + 2.0 > self.atlas_side() as f32 { + if size + 2.0 > self.gpu.atlas_side() as f32 { return Err(RenderError::AtlasFull); } let size = size as u32; @@ -1226,12 +1289,7 @@ impl Renderer { width } }; - let rect = [ - rect[0] * space.scale + space.origin[0], - rect[1] * space.scale + space.origin[1], - rect[2] * space.scale + space.origin[0], - rect[3] * space.scale + space.origin[1], - ]; + let rect = space.rect(rect); let start = self.vertices.len(); // A pixel of margin holds the antialiased fringe outside the edge. let fringe = [rect[0] - 1.0, rect[1] - 1.0, rect[2] + 1.0, rect[3] + 1.0]; @@ -1273,7 +1331,7 @@ impl Renderer { if !radius.is_finite() || radius < 0.0 || !blur.is_finite() || blur <= 0.0 { return Err(RenderError::InvalidPrimitive); } - let rect = [0, 1, 2, 3].map(|i| rect[i] * space.scale + space.origin[i % 2]); + let rect = space.rect(rect); let sigma = blur * space.scale / 2.0; let reach = 3.0 * sigma; let start = self.vertices.len(); @@ -1305,8 +1363,9 @@ impl Renderer { style: PathStyle, colors: [[f32; 4]; 2], ) -> Result<(), RenderError> { - let [x, y] = [0, 1] - .map(|axis| ((space.origin[axis] + origin[axis] * space.scale) * 4.0).round() * 0.25); + let [x, y] = space + .point(origin) + .map(|value| (value * 4.0).round() * 0.25); let (kind, width) = match style { PathStyle::Fill | PathStyle::Erase => (0, 0.0), PathStyle::Stroke(width) => (1, width), @@ -1400,13 +1459,7 @@ impl Renderer { round: bool, color: [f32; 4], ) -> Result<(), RenderError> { - let pixel = |p: [f32; 2]| { - [ - p[0] * space.scale + space.origin[0], - p[1] * space.scale + space.origin[1], - ] - }; - let [from, to] = [pixel(from), pixel(to)]; + let [from, to] = [space.point(from), space.point(to)]; // Hairlines stay one device pixel wide. let radii = widths.map(|width| (width * space.scale).max(1.0) * 0.5); let radius = radii[0].max(radii[1]); @@ -1463,14 +1516,12 @@ impl Renderer { x * 2.0 / space.size[0] as f32 - 1.0, 1.0 - y * 2.0 / space.size[1] as f32, ], - uv: [0.5 / self.atlas_side() as f32; 2], + uv: [self.white()[0]; 2], color, local, shape, - stroke: 0.0, - clip_local: [0.0; 2], - clip: [0.0; 3], blur, + ..Vertex::zeroed() }); } Ok(()) @@ -1520,11 +1571,7 @@ impl Renderer { uv, color, local, - shape: [0.0; 4], - stroke: 0.0, - clip_local: [0.0; 2], - clip: [0.0; 3], - blur: 0.0, + ..Vertex::zeroed() }); } Ok(()) @@ -1585,12 +1632,7 @@ fn soften( } fn srgb_byte(value: f32) -> u8 { - let srgb = if value <= 0.0031308 { - value * 12.92 - } else { - 1.055 * value.powf(1.0 / 2.4) - 0.055 - }; - (srgb * 255.0).round() as u8 + (crate::encode(value) * 255.0).round() as u8 } /// Coverage for text of sRGB luminance `tone` sixteenths that, blended in linear light, @@ -1599,11 +1641,11 @@ fn srgb_byte(value: f32) -> u8 { /// nearly untouched. fn text_coverage(tone: u8) -> [u8; 256] { let encoded = f32::from(tone) / 16.0; - let ink = linear(encoded); + let ink = crate::linear(encoded); std::array::from_fn(|coverage| { let coverage = coverage as f32 / 255.0; let weighted = if ink < 0.999 { - (1.0 - linear(1.0 - coverage * (1.0 - encoded))) / (1.0 - ink) + (1.0 - crate::linear(1.0 - coverage * (1.0 - encoded))) / (1.0 - ink) } else { coverage }; @@ -1611,14 +1653,6 @@ fn text_coverage(tone: u8) -> [u8; 256] { }) } -fn linear(value: f32) -> f32 { - if value <= 0.04045 { - value / 12.92 - } else { - ((value + 0.055) / 1.055).powf(2.4) - } -} - /// Least OKLab lightness difference between text and the backdrop it stays legible on. const LEGIBLE: f32 = 0.4; @@ -1670,7 +1704,7 @@ pub fn from_oklab([lightness, a, b]: [f32; 3]) -> [f32; 3] { /// Linear RGBA of an opaque sRGB colour. pub fn srgb(red: u8, green: u8, blue: u8) -> [f32; 4] { - let [red, green, blue] = [red, green, blue].map(|byte| linear(f32::from(byte) / 255.0)); + let [red, green, blue] = [red, green, blue].map(|byte| crate::linear(f32::from(byte) / 255.0)); [red, green, blue, 1.0] } @@ -1681,18 +1715,18 @@ pub fn srgb_bytes([red, green, blue, _]: [f32; 4]) -> [u8; 3] { /// The hue in degrees of a linear colour, as it looks in sRGB. pub fn hue(color: [f32; 4]) -> f32 { - let [r, g, b] = [color[0], color[1], color[2]].map(|value| { - if value <= 0.003_130_8 { - value * 12.92 - } else { - 1.055 * value.powf(1.0 / 2.4) - 0.055 - } - }); - let max = r.max(g).max(b); - let range = max - r.min(g).min(b); + to_hsl([color[0], color[1], color[2]].map(crate::encode))[0] +} + +/// Hue in degrees, saturation and lightness of an encoded sRGB colour. +fn to_hsl([r, g, b]: [f32; 3]) -> [f32; 3] { + let [max, min] = [r.max(g).max(b), r.min(g).min(b)]; + let lightness = (max + min) / 2.0; + let range = max - min; if range == 0.0 { - return 0.0; + return [0.0, 0.0, lightness]; } + let saturation = range / (1.0 - (2.0 * lightness - 1.0).abs()); let sector = if max == r { (g - b) / range } else if max == g { @@ -1700,7 +1734,16 @@ pub fn hue(color: [f32; 4]) -> f32 { } else { (r - g) / range + 4.0 }; - (sector * 60.0).rem_euclid(360.0) + [(sector * 60.0).rem_euclid(360.0), saturation, lightness] +} + +/// The encoded sRGB colour of a hue in degrees, saturation and lightness. +fn from_hsl([hue, saturation, lightness]: [f32; 3]) -> [f32; 3] { + let chroma = (1.0 - (2.0 * lightness - 1.0).abs()) * saturation; + [0.0, 8.0, 4.0].map(|offset: f32| { + let k = (offset + hue / 30.0).rem_euclid(12.0); + lightness - chroma / 2.0 * (k - 3.0).min(9.0 - k).clamp(-1.0, 1.0) + }) } /// 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]; /// An sRGB hue, saturation and lightness as linear RGBA. pub fn hsl(hue: f32, saturation: f32, lightness: f32) -> [f32; 4] { - let chroma = (1.0 - (2.0 * lightness - 1.0).abs()) * saturation; - let channel = |offset: f32| { - let k = (offset + hue / 30.0).rem_euclid(12.0); - let value = lightness - chroma / 2.0 * (k - 3.0).min(9.0 - k).clamp(-1.0, 1.0); - (value * 255.0).round().clamp(0.0, 255.0) as u8 - }; - srgb(channel(0.0), channel(8.0), channel(4.0)) + let [red, green, blue] = from_hsl([hue, saturation, lightness]) + .map(|value| (value * 255.0).round().clamp(0.0, 255.0) as u8); + srgb(red, green, blue) } #[cfg(all(test, feature = "wgpu"))] @@ -2547,13 +2586,13 @@ mod tests { target.draw(&mut renderer, &page(&primitives)).unwrap(); } assert_eq!( - renderer.atlas_side(), + renderer.gpu.atlas_side(), ATLAS_SIZE, "frames each needing a third of the atlas evict, never grow it" ); let primitives = icons(0..3 * one_atlas); target.draw(&mut renderer, &page(&primitives)).unwrap(); - assert!(renderer.atlas_side() > ATLAS_SIZE); + assert!(renderer.gpu.atlas_side() > ATLAS_SIZE); assert!(renderer.glyphs.len() >= 3 * one_atlas); let capture = target.capture(&renderer); let pixel = |x: usize, y: usize| &capture[(y * 512 + x) * 4..(y * 512 + x) * 4 + 4]; diff --git a/crates/draw/src/render/d3d11.rs b/crates/draw/src/render/d3d11.rs new file mode 100644 index 0000000000000000000000000000000000000000..610bc2bb616d9485aaead77c8972364b75381746 --- /dev/null +++ b/crates/draw/src/render/d3d11.rs @@ -0,0 +1,585 @@ +//! Submission through Direct3D 11 from feature level 10_0, or WARP where no device has it: +//! the Direct3D Windows 7 has. Frames draw into an sRGB texture, copied to a DXGI 1.1 swap +//! chain that blits into the window's redirection surface, as Windows 7 has no flip model, +//! or where the window has none, to a flip-model one that DirectComposition shows. +use super::*; +use std::{ffi::c_void, ptr}; +use windows::{ + Win32::{ + Foundation::{HMODULE, HWND, RECT}, + Graphics::{ + Direct3D::*, + Direct3D11::*, + DirectComposition::{IDCompositionDevice, IDCompositionTarget, IDCompositionVisual}, + Dxgi::{Common::*, *}, + }, + System::LibraryLoader::{GetProcAddress, LoadLibraryW}, + UI::WindowsAndMessaging::{ + GWL_EXSTYLE, GetClientRect, GetWindowLongPtrW, WS_EX_NOREDIRECTIONBITMAP, + }, + }, + core::{Error, GUID, HRESULT, Interface, PCSTR, Result, s, w}, +}; + +// Shader model 4.0 bytecode from `dxbc/compile.ps1`: the build host has no HLSL compiler, and +// Windows 7 has a runtime one only with an optional update. +const DRAW_VERTEX: &[u8] = include_bytes!("dxbc/draw.vertex.dxbc"); +const DRAW_FRAGMENT: &[u8] = include_bytes!("dxbc/draw.fragment.dxbc"); +const TRANSLUCENT_VERTEX: &[u8] = include_bytes!("dxbc/translucent.vertex.dxbc"); +const TRANSLUCENT_FRAGMENT: &[u8] = include_bytes!("dxbc/translucent.fragment.dxbc"); + +const FORMAT: DXGI_FORMAT = DXGI_FORMAT_R8G8B8A8_UNORM_SRGB; + +/// What a successful creating call leaves in its out-parameter. +fn made(create: impl FnOnce(&mut Option) -> Result<()>) -> Result { + let mut made = None; + create(&mut made)?; + Ok(made.expect("A successful call returns what it made")) +} + +/// An sRGB RGBA texture, `pixels` its rows if given. +fn texture( + device: &ID3D11Device, + size: [u32; 2], + bind: D3D11_BIND_FLAG, + pixels: Option<&[u8]>, +) -> Result { + let description = D3D11_TEXTURE2D_DESC { + Width: size[0], + Height: size[1], + MipLevels: 1, + ArraySize: 1, + Format: FORMAT, + SampleDesc: DXGI_SAMPLE_DESC { + Count: 1, + Quality: 0, + }, + Usage: D3D11_USAGE_DEFAULT, + BindFlags: bind.0 as u32, + ..Default::default() + }; + let data = pixels.map(|pixels| D3D11_SUBRESOURCE_DATA { + pSysMem: pixels.as_ptr().cast(), + SysMemPitch: size[0] * 4, + SysMemSlicePitch: 0, + }); + made(|out| unsafe { + device.CreateTexture2D(&description, data.as_ref().map(ptr::from_ref), Some(out)) + }) +} + +fn description(texture: &ID3D11Texture2D) -> D3D11_TEXTURE2D_DESC { + let mut description = D3D11_TEXTURE2D_DESC::default(); + unsafe { texture.GetDesc(&mut description) }; + description +} + +fn resource(device: &ID3D11Device, texture: &ID3D11Texture2D) -> Result { + made(|out| unsafe { device.CreateShaderResourceView(texture, None, Some(out)) }) +} + +/// A texture as the fragment shader samples it, or none where Direct3D made none, as when +/// the device is lost. +pub(super) struct Image(Option); + +/// An offscreen frame, which `Renderer::present` shows in the window. +pub struct Target { + texture: ID3D11Texture2D, + view: ID3D11RenderTargetView, + resource: ID3D11ShaderResourceView, +} + +impl Target { + fn new(device: &ID3D11Device, size: [u32; 2]) -> Result { + let bind = D3D11_BIND_RENDER_TARGET | D3D11_BIND_SHADER_RESOURCE; + let texture = texture(device, size, bind, None)?; + let view = made(|out| unsafe { device.CreateRenderTargetView(&texture, None, Some(out)) })?; + let resource = resource(device, &texture)?; + Ok(Self { + texture, + view, + resource, + }) + } + + pub fn size(&self) -> [u32; 2] { + let description = description(&self.texture); + [description.Width, description.Height] + } +} + +pub(super) struct Gpu { + device: ID3D11Device, + context: ID3D11DeviceContext, + swap_chain: IDXGISwapChain, + /// What shows the swap chain in a window without a redirection surface. + _composition: Option, + layout: ID3D11InputLayout, + vertex: ID3D11VertexShader, + fragment: ID3D11PixelShader, + translucent: (ID3D11VertexShader, ID3D11PixelShader), + /// One for each of `Blend::STATES`. + blends: [ID3D11BlendState; 4], + rasterizer: ID3D11RasterizerState, + nearest: ID3D11SamplerState, + linear: ID3D11SamplerState, + buffer: ID3D11Buffer, + atlas: (ID3D11Texture2D, ID3D11ShaderResourceView), + /// Offscreen pictures for groups, the target's size. + groups: Vec, +} + +impl Gpu { + /// A device and a swap chain on `window`, an `HWND`, and the adapter's description. + pub(super) fn new(window: *mut c_void) -> Result<(Self, String)> { + let levels = [ + D3D_FEATURE_LEVEL_11_0, + D3D_FEATURE_LEVEL_10_1, + D3D_FEATURE_LEVEL_10_0, + ]; + let create = |driver| { + let (mut device, mut context) = (None, None); + unsafe { + D3D11CreateDevice( + None, + driver, + HMODULE::default(), + D3D11_CREATE_DEVICE_FLAG(0), + Some(&levels), + D3D11_SDK_VERSION, + Some(&mut device), + None, + Some(&mut context), + ) + } + .map(|()| (device.expect("A device"), context.expect("A context"))) + }; + let (device, context) = + create(D3D_DRIVER_TYPE_HARDWARE).or_else(|_| create(D3D_DRIVER_TYPE_WARP))?; + let adapter = unsafe { device.cast::()?.GetAdapter()? }; + let name = unsafe { adapter.GetDesc()? }.Description; + let name = String::from_utf16_lossy(name.split(|&c| c == 0).next().unwrap_or(&[])); + let level = unsafe { device.GetFeatureLevel() }.0; + let [major, minor] = [level >> 12, (level >> 8) & 0xf]; + let description = format!("{name}, feature level {major}_{minor}"); + let hwnd = HWND(window); + let factory: IDXGIFactory = unsafe { adapter.GetParent()? }; + // Windows 10's window has no redirection surface, made for wgpu's DirectComposition. + let style = unsafe { GetWindowLongPtrW(hwnd, GWL_EXSTYLE) } as u32; + let (swap_chain, composition) = if style & WS_EX_NOREDIRECTIONBITMAP.0 != 0 { + let (swap_chain, composition) = compose(&device, &factory, hwnd)?; + (swap_chain, Some(composition)) + } else { + let description = DXGI_SWAP_CHAIN_DESC { + // Windows 7's desktop composes a blit swap chain's alpha only from BGRA. + BufferDesc: DXGI_MODE_DESC { + Format: DXGI_FORMAT_B8G8R8A8_UNORM, + ..Default::default() + }, + SampleDesc: DXGI_SAMPLE_DESC { + Count: 1, + Quality: 0, + }, + BufferUsage: DXGI_USAGE_RENDER_TARGET_OUTPUT, + BufferCount: 1, + OutputWindow: hwnd, + Windowed: true.into(), + SwapEffect: DXGI_SWAP_EFFECT_DISCARD, + Flags: 0, + }; + let swap_chain = + made(|out| unsafe { factory.CreateSwapChain(&device, &description, out).ok() })?; + (swap_chain, None) + }; + // DXGI would otherwise watch the window's messages and take Alt+Enter to full screen. + unsafe { + factory + .MakeWindowAssociation(hwnd, DXGI_MWA_NO_WINDOW_CHANGES | DXGI_MWA_NO_ALT_ENTER)?; + } + + let elements: [_; 9] = std::array::from_fn(|index| { + let (_, floats, offset) = ATTRIBUTES[index]; + D3D11_INPUT_ELEMENT_DESC { + SemanticName: PCSTR(c"TEXCOORD".as_ptr().cast()), + SemanticIndex: index as u32, + Format: [ + DXGI_FORMAT_R32_FLOAT, + DXGI_FORMAT_R32G32_FLOAT, + DXGI_FORMAT_R32G32B32_FLOAT, + DXGI_FORMAT_R32G32B32A32_FLOAT, + ][floats - 1], + InputSlot: 0, + AlignedByteOffset: offset as u32, + InputSlotClass: D3D11_INPUT_PER_VERTEX_DATA, + InstanceDataStepRate: 0, + } + }); + let layout = + made(|out| unsafe { device.CreateInputLayout(&elements, DRAW_VERTEX, Some(out)) })?; + let vertex_shader = + |code| made(|out| unsafe { device.CreateVertexShader(code, None, Some(out)) }); + let pixel_shader = + |code| made(|out| unsafe { device.CreatePixelShader(code, None, Some(out)) }); + + let blend = |factors: [Factor; 4]| { + let [src, dst, src_alpha, dst_alpha] = factors.map(|factor| match factor { + Factor::Zero => D3D11_BLEND_ZERO, + Factor::One => D3D11_BLEND_ONE, + Factor::SourceAlpha => D3D11_BLEND_SRC_ALPHA, + Factor::OneMinusSourceAlpha => D3D11_BLEND_INV_SRC_ALPHA, + Factor::Destination => D3D11_BLEND_DEST_COLOR, + }); + let mut description = D3D11_BLEND_DESC::default(); + description.RenderTarget[0] = D3D11_RENDER_TARGET_BLEND_DESC { + BlendEnable: true.into(), + SrcBlend: src, + DestBlend: dst, + BlendOp: D3D11_BLEND_OP_ADD, + SrcBlendAlpha: src_alpha, + DestBlendAlpha: dst_alpha, + BlendOpAlpha: D3D11_BLEND_OP_ADD, + RenderTargetWriteMask: D3D11_COLOR_WRITE_ENABLE_ALL.0 as u8, + }; + made(|out| unsafe { device.CreateBlendState(&description, Some(out)) }) + }; + let sampler = |filter| { + let description = D3D11_SAMPLER_DESC { + Filter: filter, + AddressU: D3D11_TEXTURE_ADDRESS_CLAMP, + AddressV: D3D11_TEXTURE_ADDRESS_CLAMP, + AddressW: D3D11_TEXTURE_ADDRESS_CLAMP, + ComparisonFunc: D3D11_COMPARISON_NEVER, + MaxLOD: f32::MAX, + ..Default::default() + }; + made(|out| unsafe { device.CreateSamplerState(&description, Some(out)) }) + }; + let rasterizer = D3D11_RASTERIZER_DESC { + FillMode: D3D11_FILL_SOLID, + CullMode: D3D11_CULL_NONE, + DepthClipEnable: true.into(), + ScissorEnable: true.into(), + ..Default::default() + }; + let buffer = D3D11_BUFFER_DESC { + ByteWidth: VERTEX_BUFFER_BYTES as u32, + Usage: D3D11_USAGE_DYNAMIC, + BindFlags: D3D11_BIND_VERTEX_BUFFER.0 as u32, + CPUAccessFlags: D3D11_CPU_ACCESS_WRITE.0 as u32, + ..Default::default() + }; + let [over, picture, erase, multiply] = Blend::STATES; + let gpu = Self { + layout, + vertex: vertex_shader(DRAW_VERTEX)?, + fragment: pixel_shader(DRAW_FRAGMENT)?, + translucent: ( + vertex_shader(TRANSLUCENT_VERTEX)?, + pixel_shader(TRANSLUCENT_FRAGMENT)?, + ), + blends: [ + blend(over)?, + blend(picture)?, + blend(erase)?, + blend(multiply)?, + ], + rasterizer: made(|out| unsafe { + device.CreateRasterizerState(&rasterizer, Some(out)) + })?, + nearest: sampler(D3D11_FILTER_MIN_MAG_MIP_POINT)?, + linear: sampler(D3D11_FILTER_MIN_MAG_MIP_LINEAR)?, + buffer: made(|out| unsafe { device.CreateBuffer(&buffer, None, Some(out)) })?, + atlas: atlas(&device, ATLAS_SIZE)?, + groups: Vec::new(), + device, + context, + swap_chain, + _composition: composition, + }; + Ok((gpu, description)) + } + + pub(super) fn target(&self, size: [u32; 2]) -> Result { + Target::new(&self.device, size) + } + + /// Copies `target` to the window, premultiplying each pixel again in sRGB, as the + /// desktop compositing the window over its backdrop needs, which leaves opaque ones be. + pub(super) fn present(&self, target: &Target) -> Result<()> { + let context = &self.context; + let size = target.size(); + let [width, height] = size; + unsafe { + let buffers = self.swap_chain.GetDesc()?.BufferDesc; + if [buffers.Width, buffers.Height] != size { + let flags = DXGI_SWAP_CHAIN_FLAG(0); + (self.swap_chain).ResizeBuffers(0, width, height, DXGI_FORMAT_UNKNOWN, flags)?; + } + let buffer: ID3D11Texture2D = self.swap_chain.GetBuffer(0)?; + let view = made(|out| self.device.CreateRenderTargetView(&buffer, None, Some(out)))?; + let (vertex, fragment) = &self.translucent; + self.begin(&view, size); + context.OMSetBlendState(None, None, u32::MAX); + context.VSSetShader(vertex, None); + context.PSSetShader(fragment, None); + context.PSSetShaderResources(0, Some(&[Some(target.resource.clone())])); + context.Draw(3, 0); + context.PSSetShaderResources(0, Some(&[None])); + context.OMSetRenderTargets(None, None); + self.swap_chain.Present(1, DXGI_PRESENT(0)).ok() + } + } + + /// sRGB-encoded RGBA rows of `target`, top first. + pub(super) fn read_pixels(&self, target: &Target) -> Result> { + let mut description = description(&target.texture); + let [width, height] = [description.Width, description.Height]; + description.Usage = D3D11_USAGE_STAGING; + description.BindFlags = 0; + description.CPUAccessFlags = D3D11_CPU_ACCESS_READ.0 as u32; + let row = width as usize * 4; + let mut pixels = Vec::with_capacity(row * height as usize); + unsafe { + let staging = made(|out| self.device.CreateTexture2D(&description, None, Some(out)))?; + self.context.CopyResource(&staging, &target.texture); + let mut mapped = D3D11_MAPPED_SUBRESOURCE::default(); + self.context + .Map(&staging, 0, D3D11_MAP_READ, 0, Some(&mut mapped))?; + for y in 0..height as usize { + let start = mapped.pData.cast::().add(y * mapped.RowPitch as usize); + pixels.extend_from_slice(std::slice::from_raw_parts(start, row)); + } + self.context.Unmap(&staging, 0); + } + Ok(pixels) + } + + /// Offscreen pictures' rows run top first. + pub(super) fn flipped(&self) -> bool { + false + } + + pub(super) fn max_texture_dimension(&self) -> u32 { + if unsafe { self.device.GetFeatureLevel() }.0 >= D3D_FEATURE_LEVEL_11_0.0 { + D3D11_REQ_TEXTURE2D_U_OR_V_DIMENSION + } else { + // Feature level 10's. + 8192 + } + } + + pub(super) fn atlas_side(&self) -> u32 { + description(&self.atlas.0).Width + } + + pub(super) fn new_atlas(&mut self, side: u32) { + match atlas(&self.device, side) { + Ok(atlas) => self.atlas = atlas, + Err(error) => eprintln!("No glyph atlas {side} pixels square: {error}"), + } + } + + pub(super) fn write_atlas(&self, origin: [u32; 2], size: [u32; 2], rgba: &[u8]) { + let area = D3D11_BOX { + left: origin[0], + top: origin[1], + front: 0, + right: origin[0] + size[0], + bottom: origin[1] + size[1], + back: 1, + }; + unsafe { + self.context.UpdateSubresource( + &self.atlas.0, + 0, + Some(&area), + rgba.as_ptr().cast(), + size[0] * 4, + 0, + ); + } + } + + pub(super) fn upload_image(&self, image: &RasterImage) -> Image { + let texture = texture( + &self.device, + image.size, + D3D11_BIND_SHADER_RESOURCE, + Some(image.pixels()), + ); + let view = texture.and_then(|texture| resource(&self.device, &texture)); + Image( + view.inspect_err(|error| eprintln!("No image texture: {error}")) + .ok(), + ) + } + + /// Binds `view`, `size` pixels, as the render target, with the pipeline's state. + fn begin(&self, view: &ID3D11RenderTargetView, size: [u32; 2]) { + let context = &self.context; + unsafe { + context.PSSetShaderResources(0, Some(&[None])); + context.OMSetRenderTargets(Some(&[Some(view.clone())]), None); + context.IASetPrimitiveTopology(D3D_PRIMITIVE_TOPOLOGY_TRIANGLELIST); + context.RSSetState(&self.rasterizer); + context.RSSetViewports(Some(&[D3D11_VIEWPORT { + TopLeftX: 0.0, + TopLeftY: 0.0, + Width: size[0] as f32, + Height: size[1] as f32, + MinDepth: 0.0, + MaxDepth: 1.0, + }])); + context.RSSetScissorRects(Some(&[RECT { + left: 0, + top: 0, + right: size[0] as i32, + bottom: size[1] as i32, + }])); + } + } + + /// Clears `target`, `size` device pixels, to linear `clear` and draws the prepared + /// batches, each group's offscreen first. + pub(super) fn submit( + &mut self, + frame: &Frame<'_>, + target: &Target, + size: [u32; 2], + clear: [f32; 4], + ) { + if self + .groups + .first() + .is_some_and(|picture| picture.size() != size) + { + self.groups.clear(); + } + while self.groups.len() < frame.groups.len() { + match Target::new(&self.device, size) { + Ok(picture) => self.groups.push(picture), + Err(error) => { + eprintln!("No offscreen picture: {error}"); + return; + } + } + } + let gpu = &*self; + let context = &gpu.context; + let bytes: &[u8] = bytemuck::cast_slice(frame.vertices); + let bytes = &bytes[..bytes.len().min(VERTEX_BUFFER_BYTES as usize)]; + unsafe { + let mut mapped = D3D11_MAPPED_SUBRESOURCE::default(); + if let Err(error) = context.Map( + &gpu.buffer, + 0, + D3D11_MAP_WRITE_DISCARD, + 0, + Some(&mut mapped), + ) { + eprintln!("No vertex buffer: {error}"); + return; + } + ptr::copy_nonoverlapping(bytes.as_ptr(), mapped.pData.cast(), bytes.len()); + context.Unmap(&gpu.buffer, 0); + context.IASetInputLayout(&gpu.layout); + let buffer = Some(gpu.buffer.clone()); + let stride = size_of::() as u32; + context.IASetVertexBuffers(0, 1, Some(&buffer), Some(&stride), Some(&0)); + } + let paint = |target: &Target, clear: [f32; 4], batches: &[Batch]| unsafe { + gpu.begin(&target.view, size); + context.VSSetShader(&gpu.vertex, None); + context.PSSetShader(&gpu.fragment, None); + context.ClearRenderTargetView(&target.view, &clear); + for batch in batches { + let [x, y, w, h] = batch.scissor.map(|value| value as i32); + let rect = RECT { + left: x, + top: y, + right: x + w, + bottom: y + h, + }; + context.RSSetScissorRects(Some(&[rect])); + let (resource, sampler) = match batch.blend { + Blend::Image(id) => (frame.image::(id).0.as_ref(), &gpu.linear), + Blend::Group(index) => (Some(&gpu.groups[index].resource), &gpu.linear), + Blend::Over | Blend::Erase | Blend::Multiply => { + (Some(&gpu.atlas.1), &gpu.nearest) + } + }; + context.OMSetBlendState(&gpu.blends[batch.blend.state()], None, u32::MAX); + context.PSSetSamplers(0, Some(&[Some(sampler.clone())])); + context.PSSetShaderResources(0, Some(&[resource.cloned()])); + context.Draw(batch.vertices.len() as u32, batch.vertices.start); + } + }; + for (batches, picture) in frame.groups.iter().zip(&gpu.groups) { + paint(picture, [0.0; 4], batches); + } + paint(target, clear, frame.batches); + unsafe { + context.PSSetShaderResources(0, Some(&[None])); + context.OMSetRenderTargets(None, None); + } + } +} + +type Composition = ( + IDCompositionDevice, + IDCompositionTarget, + IDCompositionVisual, +); + +/// A flip-model swap chain with premultiplied alpha, which DirectComposition shows over +/// all of `hwnd`. +fn compose( + device: &ID3D11Device, + factory: &IDXGIFactory, + hwnd: HWND, +) -> Result<(IDXGISwapChain, Composition)> { + let mut client = RECT::default(); + unsafe { GetClientRect(hwnd, &mut client)? }; + let description = DXGI_SWAP_CHAIN_DESC1 { + Width: client.right.max(1) as u32, + Height: client.bottom.max(1) as u32, + Format: DXGI_FORMAT_B8G8R8A8_UNORM, + SampleDesc: DXGI_SAMPLE_DESC { + Count: 1, + Quality: 0, + }, + BufferUsage: DXGI_USAGE_RENDER_TARGET_OUTPUT, + BufferCount: 2, + Scaling: DXGI_SCALING_STRETCH, + SwapEffect: DXGI_SWAP_EFFECT_FLIP_SEQUENTIAL, + AlphaMode: DXGI_ALPHA_MODE_PREMULTIPLIED, + ..Default::default() + }; + // Windows 7 has no dcomp.dll, so it is looked up rather than linked. + type Create = unsafe extern "system" fn(*mut c_void, *const GUID, *mut *mut c_void) -> HRESULT; + unsafe { + let factory: IDXGIFactory2 = factory.cast()?; + let swap_chain = factory.CreateSwapChainForComposition(device, &description, None)?; + let library = LoadLibraryW(w!("dcomp.dll"))?; + let create = GetProcAddress(library, s!("DCompositionCreateDevice")) + .ok_or_else(Error::from_thread)?; + let create = std::mem::transmute:: isize, Create>(create); + let mut composition = ptr::null_mut(); + let dxgi: IDXGIDevice = device.cast()?; + create(dxgi.as_raw(), &IDCompositionDevice::IID, &mut composition).ok()?; + let composition = IDCompositionDevice::from_raw(composition); + let target = composition.CreateTargetForHwnd(hwnd, true)?; + let visual = composition.CreateVisual()?; + visual.SetContent(&swap_chain)?; + target.SetRoot(&visual)?; + composition.Commit()?; + Ok((swap_chain.cast()?, (composition, target, visual))) + } +} + +/// An empty atlas `side` texels square. +fn atlas(device: &ID3D11Device, side: u32) -> Result<(ID3D11Texture2D, ID3D11ShaderResourceView)> { + let texture = texture(device, [side; 2], D3D11_BIND_SHADER_RESOURCE, None)?; + let view = resource(device, &texture)?; + Ok((texture, view)) +} diff --git a/crates/draw/src/render/dual.rs b/crates/draw/src/render/dual.rs index de7d1d57c0d433edba7b2a822e43e1fdb6ae0082..109863cc65eaeff408064db1eb153c08f23a087d 100644 --- a/crates/draw/src/render/dual.rs +++ b/crates/draw/src/render/dual.rs @@ -1,10 +1,13 @@ -//! Both backends in one build, for Windows: wgpu where Direct3D 12 answers, and OpenGL 2.1 -//! where it doesn't, as on Windows 7. Whoever makes the renderer picks one. -use super::{gl, webgpu, *}; +//! Every backend in one build, for Windows: wgpu where Direct3D 12 answers, Direct3D 11 +//! where it doesn't, as on Windows 7, and OpenGL 2.1 where neither does. Whoever makes the +//! renderer picks one. +use super::{d3d11, gl, webgpu, *}; +use std::ffi::c_void; /// What a frame is drawn into, for the backend the renderer draws with. pub enum Target { Wgpu(wgpu::TextureView), + D3d11(d3d11::Target), Gl(gl::Target), } @@ -14,14 +17,19 @@ impl From for Target { } } -impl From for Target { - fn from(target: gl::Target) -> Self { - Self::Gl(target) +impl Target { + pub fn size(&self) -> [u32; 2] { + match self { + Self::Wgpu(view) => [view.texture().width(), view.texture().height()], + Self::D3d11(target) => target.size(), + Self::Gl(target) => target.size(), + } } } pub(super) enum Image { Wgpu(webgpu::Image), + D3d11(d3d11::Image), Gl(gl::Image), } @@ -29,7 +37,16 @@ impl AsRef for Image { fn as_ref(&self) -> &webgpu::Image { match self { Self::Wgpu(image) => image, - Self::Gl(_) => unreachable!("Images belong to the renderer's backend"), + _ => unreachable!("Images belong to the renderer's backend"), + } + } +} + +impl AsRef for Image { + fn as_ref(&self) -> &d3d11::Image { + match self { + Self::D3d11(image) => image, + _ => unreachable!("Images belong to the renderer's backend"), } } } @@ -38,74 +55,109 @@ impl AsRef for Image { fn as_ref(&self) -> &gl::Image { match self { Self::Gl(image) => image, - Self::Wgpu(_) => unreachable!("Images belong to the renderer's backend"), + _ => unreachable!("Images belong to the renderer's backend"), } } } pub(super) enum Gpu { Wgpu(webgpu::Gpu), + D3d11(d3d11::Gpu), Gl(gl::Gpu), } +impl From for Gpu { + fn from(gpu: webgpu::Gpu) -> Self { + Self::Wgpu(gpu) + } +} + +impl From for Gpu { + fn from(gpu: gl::Gpu) -> Self { + Self::Gl(gpu) + } +} + impl Renderer { - pub fn new(device: wgpu::Device, queue: wgpu::Queue, format: wgpu::TextureFormat) -> Self { - Self::with_gpu(Gpu::Wgpu(webgpu::Gpu::new(device, queue, format))) + /// Draws with Direct3D 11 into a swap chain on `window`, an `HWND`; with the adapter's + /// description. + pub fn direct3d11(window: *mut c_void) -> Result<(Self, String), String> { + d3d11::Gpu::new(window) + .map(|(gpu, adapter)| (Self::with_gpu(Gpu::D3d11(gpu)), adapter)) + .map_err(|error| error.to_string()) } +} - /// Draws with the OpenGL context current on this thread, which must stay current - /// whenever the renderer or a `Target` is used. - pub fn opengl() -> Result { - gl::Gpu::new().map(|gpu| Self::with_gpu(Gpu::Gl(gpu))) - } - - /// Copies `target` to the OpenGL context's window, as `gl`'s renderer does. - pub fn present_translucent(&self, target: &gl::Target) { - if let Gpu::Gl(gpu) = &self.gpu { - gpu.present_translucent(target); +/// `$gpu` as whichever backend it holds, named `$backend` in `$body`. +macro_rules! each { + ($gpu:expr, $backend:ident => $body:expr) => { + match $gpu { + Gpu::Wgpu($backend) => $body, + Gpu::D3d11($backend) => $body, + Gpu::Gl($backend) => $body, } - } + }; } impl Gpu { + pub(super) fn target(&self, size: [u32; 2]) -> Result { + match self { + Self::D3d11(gpu) => gpu + .target(size) + .map(Target::D3d11) + .map_err(|error| error.to_string()), + Self::Gl(gpu) => gpu.target(size).map(Target::Gl), + Self::Wgpu(_) => unreachable!("wgpu's targets are its surface's"), + } + } + + pub(super) fn present(&self, target: &Target, translucent: bool) { + match (self, target) { + // Direct3D 11 premultiplies every frame again, which leaves opaque ones be. + (Self::D3d11(gpu), Target::D3d11(target)) => { + if let Err(error) = gpu.present(target) { + eprintln!("Presenting failed: {error}"); + } + } + (Self::Gl(gpu), Target::Gl(target)) => gpu.present(target, translucent), + _ => unreachable!("Frames are drawn into the renderer's backend's targets"), + } + } + + pub(super) fn read_pixels(&self, target: &Target) -> Result, String> { + match (self, target) { + (Self::D3d11(gpu), Target::D3d11(target)) => { + gpu.read_pixels(target).map_err(|error| error.to_string()) + } + (Self::Gl(gpu), Target::Gl(target)) => gpu.read_pixels(target), + _ => unreachable!("Snapshots come from the renderer's backend's targets"), + } + } + pub(super) fn flipped(&self) -> bool { - match self { - Self::Wgpu(gpu) => gpu.flipped(), - Self::Gl(gpu) => gpu.flipped(), - } + each!(self, gpu => gpu.flipped()) } pub(super) fn max_texture_dimension(&self) -> u32 { - match self { - Self::Wgpu(gpu) => gpu.max_texture_dimension(), - Self::Gl(gpu) => gpu.max_texture_dimension(), - } + each!(self, gpu => gpu.max_texture_dimension()) } pub(super) fn atlas_side(&self) -> u32 { - match self { - Self::Wgpu(gpu) => gpu.atlas_side(), - Self::Gl(gpu) => gpu.atlas_side(), - } + each!(self, gpu => gpu.atlas_side()) } pub(super) fn new_atlas(&mut self, side: u32) { - match self { - Self::Wgpu(gpu) => gpu.new_atlas(side), - Self::Gl(gpu) => gpu.new_atlas(side), - } + each!(self, gpu => gpu.new_atlas(side)) } pub(super) fn write_atlas(&self, origin: [u32; 2], size: [u32; 2], rgba: &[u8]) { - match self { - Self::Wgpu(gpu) => gpu.write_atlas(origin, size, rgba), - Self::Gl(gpu) => gpu.write_atlas(origin, size, rgba), - } + each!(self, gpu => gpu.write_atlas(origin, size, rgba)) } pub(super) fn upload_image(&self, image: &RasterImage) -> Image { match self { Self::Wgpu(gpu) => Image::Wgpu(gpu.upload_image(image)), + Self::D3d11(gpu) => Image::D3d11(gpu.upload_image(image)), Self::Gl(gpu) => Image::Gl(gpu.upload_image(image)), } } @@ -119,6 +171,7 @@ impl Gpu { ) { match (self, target) { (Self::Wgpu(gpu), Target::Wgpu(view)) => gpu.submit(frame, view, size, clear), + (Self::D3d11(gpu), Target::D3d11(target)) => gpu.submit(frame, target, size, clear), (Self::Gl(gpu), Target::Gl(target)) => gpu.submit(frame, target, size, clear), _ => unreachable!("Frames are drawn into the renderer's backend's targets"), } diff --git a/crates/draw/src/render/dxbc/compile.ps1 b/crates/draw/src/render/dxbc/compile.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..91aa7b22b81b2fb201f72b10fbb77651c05db057 --- /dev/null +++ b/crates/draw/src/render/dxbc/compile.ps1 @@ -0,0 +1,52 @@ +# Compiles draw.hlsl and translucent.hlsl to the shader model 4.0 bytecode d3d11.rs embeds, +# with the d3dcompiler_47.dll Windows 8.1 and later ship: `powershell -File compile.ps1`. +$ErrorActionPreference = 'Stop' +Add-Type -TypeDefinition @' +using System; +using System.IO; +using System.Runtime.InteropServices; +using System.Text; + +[ComImport, Guid("8BA5FB08-5195-40e2-AC58-0D989C3A0102"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] +public interface ID3DBlob { + [PreserveSig] IntPtr GetBufferPointer(); + [PreserveSig] UIntPtr GetBufferSize(); +} + +public static class Fxc { + [DllImport("d3dcompiler_47.dll", CharSet = CharSet.Ansi)] + static extern int D3DCompile(byte[] source, UIntPtr size, string name, IntPtr defines, + IntPtr include, string entry, string target, uint flags, uint effectFlags, + out ID3DBlob code, out ID3DBlob errors); + + static byte[] Bytes(ID3DBlob blob) { + var bytes = new byte[(int)blob.GetBufferSize()]; + Marshal.Copy(blob.GetBufferPointer(), bytes, 0, bytes.Length); + return bytes; + } + + public static byte[] Compile(string path, string entry, string target) { + var source = File.ReadAllBytes(path); + ID3DBlob code, errors; + // D3DCOMPILE_ENABLE_STRICTNESS | D3DCOMPILE_OPTIMIZATION_LEVEL3 + int result = D3DCompile(source, (UIntPtr)source.Length, Path.GetFileName(path), + IntPtr.Zero, IntPtr.Zero, entry, target, (1u << 11) | (1u << 15), 0, + out code, out errors); + if (result < 0) { + throw new Exception(errors == null ? "D3DCompile: 0x" + result.ToString("x8") + : Encoding.ASCII.GetString(Bytes(errors))); + } + return Bytes(code); + } +} +'@ +foreach ($shader in @( + @('..\..\draw.hlsl', 'draw'), + @('..\translucent.hlsl', 'translucent') +)) { + $source = Join-Path $PSScriptRoot $shader[0] + foreach ($stage in @(@('vertex', 'vs_4_0'), @('fragment', 'ps_4_0'))) { + $out = Join-Path $PSScriptRoot ($shader[1] + '.' + $stage[0] + '.dxbc') + [IO.File]::WriteAllBytes($out, [Fxc]::Compile($source, $stage[0], $stage[1])) + } +} diff --git a/crates/draw/src/render/dxbc/draw.fragment.dxbc b/crates/draw/src/render/dxbc/draw.fragment.dxbc new file mode 100644 index 0000000000000000000000000000000000000000..eaba5e5dce67e4fc4f79b1ab31cede72742e9173 Binary files /dev/null and b/crates/draw/src/render/dxbc/draw.fragment.dxbc differ diff --git a/crates/draw/src/render/dxbc/draw.vertex.dxbc b/crates/draw/src/render/dxbc/draw.vertex.dxbc new file mode 100644 index 0000000000000000000000000000000000000000..9ecc97f615f4002b66e4c4c347aa302abfb3800a Binary files /dev/null and b/crates/draw/src/render/dxbc/draw.vertex.dxbc differ diff --git a/crates/draw/src/render/dxbc/translucent.fragment.dxbc b/crates/draw/src/render/dxbc/translucent.fragment.dxbc new file mode 100644 index 0000000000000000000000000000000000000000..019db90b967390456e30cbcd5e2f18776bb27909 Binary files /dev/null and b/crates/draw/src/render/dxbc/translucent.fragment.dxbc differ diff --git a/crates/draw/src/render/dxbc/translucent.vertex.dxbc b/crates/draw/src/render/dxbc/translucent.vertex.dxbc new file mode 100644 index 0000000000000000000000000000000000000000..2e4d58702fdce1b9a291fcf8f3d6bd1fb404ce0a Binary files /dev/null and b/crates/draw/src/render/dxbc/translucent.vertex.dxbc differ diff --git a/crates/draw/src/render/gl.rs b/crates/draw/src/render/gl.rs index 396ab186e4738d319638c81f69070a547b58ac61..0b9e27e09ae8ee3bd4651ce96d1cbcdbc7e2e393 100644 --- a/crates/draw/src/render/gl.rs +++ b/crates/draw/src/render/gl.rs @@ -7,7 +7,6 @@ use super::*; use std::{ ffi::{CStr, CString, c_char, c_void}, - mem::offset_of, ptr, }; @@ -250,9 +249,6 @@ functions! { ); } -/// Blending a premultiplied picture over what lies beneath. -const PREMULTIPLIED: [GLenum; 4] = [ONE, ONE_MINUS_SRC_ALPHA, ONE, ONE_MINUS_SRC_ALPHA]; - /// An sRGB RGBA texture, deleted with its value. pub(super) struct Image { name: GLuint, @@ -305,9 +301,9 @@ pub struct Target { } impl Target { - /// Needs the renderer's context current, as every method here does. - pub fn new(size: [u32; 2]) -> Result { - let texture = Image::new(size, NEAREST, None); + /// Sampled with `filter`. + fn new(size: [u32; 2], filter: GLint) -> Result { + let texture = Image::new(size, filter, None); let mut framebuffer = 0; unsafe { glGenFramebuffersEXT(1, &mut framebuffer); @@ -329,53 +325,6 @@ impl Target { pub fn size(&self) -> [u32; 2] { self.texture.size } - - /// Copies the frame to the context's window, which is the same size. - pub fn present(&self) { - let [width, height] = self.size().map(|side| side as GLint); - unsafe { - glBindFramebufferEXT(READ_FRAMEBUFFER, self.framebuffer); - glBindFramebufferEXT(DRAW_FRAMEBUFFER, 0); - glBlitFramebufferEXT( - 0, - 0, - width, - height, - 0, - 0, - width, - height, - COLOR_BUFFER_BIT, - NEAREST as GLenum, - ); - glBindFramebufferEXT(FRAMEBUFFER, 0); - } - } - - /// sRGB-encoded RGBA rows, top first. - pub fn read_pixels(&self) -> Vec { - let [width, height] = self.size(); - let mut pixels = vec![0; (width * height * 4) as usize]; - unsafe { - glBindFramebufferEXT(FRAMEBUFFER, self.framebuffer); - glReadPixels( - 0, - 0, - width as GLsizei, - height as GLsizei, - RGBA, - UNSIGNED_BYTE, - pixels.as_mut_ptr().cast(), - ); - glBindFramebufferEXT(FRAMEBUFFER, 0); - } - pixels - .chunks(width as usize * 4) - .rev() - .flatten() - .copied() - .collect() - } } impl Drop for Target { @@ -396,38 +345,10 @@ pub(super) struct Gpu { groups: Vec, } -/// Each attribute's name, component count and offset, bound to its index in the shader. -const ATTRIBUTES: [(&CStr, GLint, usize); 9] = [ - (c"position", 2, offset_of!(Vertex, position)), - (c"uv", 2, offset_of!(Vertex, uv)), - (c"color", 4, offset_of!(Vertex, color)), - (c"local", 2, offset_of!(Vertex, local)), - (c"shape", 4, offset_of!(Vertex, shape)), - (c"stroke", 1, offset_of!(Vertex, stroke)), - (c"clip_local", 2, offset_of!(Vertex, clip_local)), - (c"clip", 3, offset_of!(Vertex, clip)), - (c"blur", 1, offset_of!(Vertex, blur)), -]; - -#[cfg(not(feature = "wgpu"))] -impl Renderer { - /// Draws with the OpenGL context current on this thread, which must stay current - /// whenever the renderer or a `Target` is used. - pub fn opengl() -> Result { - Gpu::new().map(Self::with_gpu) - } - - /// Copies `target` to the context's window, the same size, premultiplying each pixel - /// again in sRGB, as a window server compositing a transparent surface needs. - pub fn present_translucent(&self, target: &Target) { - self.gpu.present_translucent(target); - } -} - impl Gpu { pub(super) fn new() -> Result { load()?; - let names: Vec<_> = ATTRIBUTES.iter().map(|(name, _, _)| *name).collect(); + let names = ATTRIBUTES.map(|(name, _, _)| name); let draw = unsafe { program(include_str!("../draw.glsl"), &names)? }; let translucent = unsafe { program(include_str!("translucent.glsl"), &[c"position"])? }; let [mut buffer, mut triangle] = [0; 2]; @@ -445,7 +366,7 @@ impl Gpu { ); glGetIntegerv(MAX_TEXTURE_SIZE, &mut max_texture); } - Ok(Gpu { + Ok(Self { program: draw, buffer, translucent, @@ -456,7 +377,36 @@ impl Gpu { }) } - pub(super) fn present_translucent(&self, target: &Target) { + /// Needs the renderer's context current, as every method here does. + pub(super) fn target(&self, size: [u32; 2]) -> Result { + Target::new(size, NEAREST) + } + + /// Copies `target` to the context's window, the same size; if `translucent`, + /// premultiplying each pixel again in sRGB, as a window server compositing a transparent + /// surface needs. + pub(super) fn present(&self, target: &Target, translucent: bool) { + if !translucent { + let [width, height] = target.size().map(|side| side as GLint); + unsafe { + glBindFramebufferEXT(READ_FRAMEBUFFER, target.framebuffer); + glBindFramebufferEXT(DRAW_FRAMEBUFFER, 0); + glBlitFramebufferEXT( + 0, + 0, + width, + height, + 0, + 0, + width, + height, + COLOR_BUFFER_BIT, + NEAREST as GLenum, + ); + glBindFramebufferEXT(FRAMEBUFFER, 0); + } + return; + } let [width, height] = target.size().map(|side| side as f32); let program = self.translucent; unsafe { @@ -518,6 +468,31 @@ impl Gpu { } } + /// sRGB-encoded RGBA rows, top first. + pub(super) fn read_pixels(&self, target: &Target) -> Result, String> { + let [width, height] = target.size(); + let mut pixels = vec![0; (width * height * 4) as usize]; + unsafe { + glBindFramebufferEXT(FRAMEBUFFER, target.framebuffer); + glReadPixels( + 0, + 0, + width as GLsizei, + height as GLsizei, + RGBA, + UNSIGNED_BYTE, + pixels.as_mut_ptr().cast(), + ); + glBindFramebufferEXT(FRAMEBUFFER, 0); + } + Ok(pixels + .chunks(width as usize * 4) + .rev() + .flatten() + .copied() + .collect()) + } + pub(super) fn upload_image(&self, image: &RasterImage) -> Image { Image::new(image.size, LINEAR, Some(image.pixels())) } @@ -539,14 +514,9 @@ impl Gpu { self.groups.clear(); } while self.groups.len() < frame.groups.len() { - match Target::new(size) { - Ok(picture) => unsafe { - // Filtered, so a picture leaning back stays smooth. - glBindTexture(TEXTURE_2D, picture.texture.name); - glTexParameteri(TEXTURE_2D, TEXTURE_MIN_FILTER, LINEAR); - glTexParameteri(TEXTURE_2D, TEXTURE_MAG_FILTER, LINEAR); - self.groups.push(picture); - }, + // Filtered, so a picture leaning back stays smooth. + match Target::new(size, LINEAR) { + Ok(picture) => self.groups.push(picture), Err(error) => { eprintln!("{error}"); return; @@ -555,36 +525,30 @@ impl Gpu { } let [width, height] = size.map(|side| side as GLsizei); let gpu = &*self; - let begin = |target: &Target, clear: [f32; 4]| unsafe { + let paint = |target: &Target, clear: [f32; 4], batches: &[Batch]| unsafe { glBindFramebufferEXT(FRAMEBUFFER, target.framebuffer); glDisable(SCISSOR_TEST); glClearColor(clear[0], clear[1], clear[2], clear[3]); glClear(COLOR_BUFFER_BIT); glEnable(SCISSOR_TEST); - }; - let draw = |batches: &[Batch]| unsafe { for batch in batches { let [x, y, w, h] = batch.scissor.map(|value| value as GLint); glScissor(x, height - y - h, w, h); - let (texture, [src_rgb, dst_rgb, src_alpha, dst_alpha]) = match batch.blend { - Blend::Over => ( - gpu.atlas.name, - [SRC_ALPHA, ONE_MINUS_SRC_ALPHA, ONE, ONE_MINUS_SRC_ALPHA], - ), - Blend::Image(id) => { - let image: &Image = frame.images[&id].texture.as_ref(); - (image.name, PREMULTIPLIED) - } - Blend::Erase => ( - gpu.atlas.name, - [ZERO, ONE_MINUS_SRC_ALPHA, ZERO, ONE_MINUS_SRC_ALPHA], - ), - Blend::Multiply => { - (gpu.atlas.name, [DST_COLOR, ONE_MINUS_SRC_ALPHA, ZERO, ONE]) - } - }; + let [src_rgb, dst_rgb, src_alpha, dst_alpha] = Blend::STATES[batch.blend.state()] + .map(|factor| match factor { + Factor::Zero => ZERO, + Factor::One => ONE, + Factor::SourceAlpha => SRC_ALPHA, + Factor::OneMinusSourceAlpha => ONE_MINUS_SRC_ALPHA, + Factor::Destination => DST_COLOR, + }); glBlendFuncSeparate(src_rgb, dst_rgb, src_alpha, dst_alpha); - glBindTexture(TEXTURE_2D, texture); + let texture = match batch.blend { + Blend::Image(id) => frame.image::(id), + Blend::Group(index) => &gpu.groups[index].texture, + Blend::Over | Blend::Erase | Blend::Multiply => &gpu.atlas, + }; + glBindTexture(TEXTURE_2D, texture.name); glDrawArrays( TRIANGLES, batch.vertices.start as GLint, @@ -605,38 +569,22 @@ impl Gpu { frame.vertices.as_ptr().cast(), STREAM_DRAW, ); - for (index, (_, components, offset)) in ATTRIBUTES.iter().enumerate() { + for (index, (_, floats, offset)) in ATTRIBUTES.into_iter().enumerate() { glEnableVertexAttribArray(index as GLuint); glVertexAttribPointer( index as GLuint, - *components, + floats as GLint, FLOAT, 0, size_of::() as GLsizei, - ptr::without_provenance(*offset), + ptr::without_provenance(offset), ); } glEnable(BLEND); - for (group, picture) in frame.groups.iter().zip(&gpu.groups) { - begin(picture, [0.0; 4]); - draw(&frame.batches[group.batches.clone()]); + for (batches, picture) in frame.groups.iter().zip(&gpu.groups) { + paint(picture, [0.0; 4], batches); } - begin(target, clear); - let mut next = 0; - for (group, picture) in frame.groups.iter().zip(&gpu.groups) { - draw(&frame.batches[next..group.batches.start]); - glScissor(0, 0, width, height); - let [src_rgb, dst_rgb, src_alpha, dst_alpha] = PREMULTIPLIED; - glBlendFuncSeparate(src_rgb, dst_rgb, src_alpha, dst_alpha); - glBindTexture(TEXTURE_2D, picture.texture.name); - glDrawArrays( - TRIANGLES, - group.composite.start as GLint, - group.composite.len() as GLsizei, - ); - next = group.batches.end; - } - draw(&frame.batches[next..]); + paint(target, clear, frame.batches); glDisable(SCISSOR_TEST); glDisable(BLEND); glBindFramebufferEXT(FRAMEBUFFER, 0); diff --git a/crates/draw/src/render/icon.rs b/crates/draw/src/render/icon.rs index fb5c47a6184bd03dd676a496a497c4fd4bad4bd1..8b4574381d3835d3a2f988e0e18c874529c495c2 100644 --- a/crates/draw/src/render/icon.rs +++ b/crates/draw/src/render/icon.rs @@ -1,3 +1,4 @@ +use super::{from_hsl, to_hsl}; use swash::{ scale::image::{Content, Image}, 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 let [x, y] = [index % side as usize, index / side as usize] .map(|device| device as f32 + 0.5) .map(|device| device * 16.0 / size); - let color = color([x, y]); - for channel in 0..3 { - pixel[channel] = color[channel] * alpha + pixel[channel] * (1.0 - alpha); - } - pixel[3] = alpha + pixel[3] * (1.0 - alpha); + over(pixel, color([x, y]), alpha); } } } @@ -224,15 +221,18 @@ fn picture(node: roxmltree::Node, side: u32, pixels: &mut [[f32; 4]]) { for (column, row, color) in shown.enumerate_pixels() { let pixel = &mut pixels[((top + row) * side + left + column) as usize]; let [r, g, b, a] = color.0; - let alpha = f32::from(a) / 255.0; - let color = super::srgb(r, g, b); - for channel in 0..3 { - pixel[channel] = color[channel] * alpha + pixel[channel] * (1.0 - alpha); - } - pixel[3] = alpha + pixel[3] * (1.0 - alpha); + over(pixel, super::srgb(r, g, b), f32::from(a) / 255.0); } } +/// Paints `color` at `alpha` over premultiplied `pixel`. +fn over(pixel: &mut [f32; 4], color: [f32; 4], alpha: f32) { + for channel in 0..3 { + pixel[channel] = color[channel] * alpha + pixel[channel] * (1.0 - alpha); + } + pixel[3] = alpha + pixel[3] * (1.0 - alpha); +} + fn hex(value: &str) -> [f32; 4] { let [_, r, g, b] = u32::from_str_radix(value.strip_prefix('#').unwrap(), 16) .unwrap() @@ -364,11 +364,11 @@ impl Shift { let mean: [f32; 3] = std::array::from_fn(|channel| { colors .iter() - .map(|color| encode(color[channel])) + .map(|color| crate::encode(color[channel])) .sum::() / count }); - let [from, to] = [mean, target.map(encode)].map(hsl); + let [from, to] = [mean, target.map(crate::encode)].map(to_hsl); (count > 0.0).then(|| { Self([ to[0] - from[0], @@ -379,61 +379,19 @@ impl Shift { } fn apply(&self, color: [f32; 4]) -> [f32; 4] { - let [hue, saturation, lightness] = hsl([color[0], color[1], color[2]].map(encode)); + let [hue, saturation, lightness] = + to_hsl([color[0], color[1], color[2]].map(crate::encode)); let [turn, scale, lift] = self.0; - let [r, g, b] = rgb([ + let [r, g, b] = from_hsl([ (hue + turn).rem_euclid(360.0), (saturation * scale).clamp(0.0, 1.0), (lightness + lift).clamp(0.0, 1.0), ]) - .map(decode); + .map(crate::linear); [r, g, b, color[3]] } } -fn encode(linear: f32) -> f32 { - if linear <= 0.003_130_8 { - linear * 12.92 - } else { - 1.055 * linear.powf(1.0 / 2.4) - 0.055 - } -} - -fn decode(encoded: f32) -> f32 { - if encoded <= 0.040_45 { - encoded / 12.92 - } else { - ((encoded + 0.055) / 1.055).powf(2.4) - } -} - -/// Hue in degrees, saturation and lightness of an encoded sRGB colour. -fn hsl([r, g, b]: [f32; 3]) -> [f32; 3] { - let [max, min] = [r.max(g).max(b), r.min(g).min(b)]; - let lightness = (max + min) / 2.0; - let range = max - min; - if range == 0.0 { - return [0.0, 0.0, lightness]; - } - let saturation = range / (1.0 - (2.0 * lightness - 1.0).abs()); - let sector = if max == r { - (g - b) / range - } else if max == g { - (b - r) / range + 2.0 - } else { - (r - g) / range + 4.0 - }; - [(sector * 60.0).rem_euclid(360.0), saturation, lightness] -} - -fn rgb([hue, saturation, lightness]: [f32; 3]) -> [f32; 3] { - let chroma = (1.0 - (2.0 * lightness - 1.0).abs()) * saturation; - [0.0, 8.0, 4.0].map(|offset: f32| { - let k = (offset + hue / 30.0).rem_euclid(12.0); - lightness - chroma / 2.0 * (k - 3.0).min(9.0 - k).clamp(-1.0, 1.0) - }) -} - /// The tight box around a path's geometry, curves included, as SVG's bounding box is. fn bounds(data: &str) -> [[f32; 2]; 2] { struct Sampled { @@ -567,11 +525,11 @@ mod tests { let image = rasterize(&[ART], 16, [0.0; 3], palette); let at = (y * 16 + 8) * 4; let [r, g, b] = [0, 1, 2].map(|channel| f32::from(image.data[at + channel]) / 255.0); - hsl([r, g, b]) + to_hsl([r, g, b]) }; let plain = Palette::default(); let green = Palette { - accent: Some([0.0, 1.0, 0.0].map(decode)), + accent: Some([0.0, 1.0, 0.0].map(crate::linear)), ..plain }; let [light, dark] = [2, 6].map(|y| pixel(&green, y)); @@ -583,7 +541,7 @@ mod tests { assert!(((light[2] + dark[2]) / 2.0 - 0.5).abs() < 0.02); assert_eq!(pixel(&green, 10), pixel(&plain, 10)); assert_eq!(pixel(&green, 14), [0.0, 0.0, 0.0]); - assert!((pixel(&plain, 2)[0] - hsl([0.5, 0.627, 1.0])[0]).abs() < 2.0); + assert!((pixel(&plain, 2)[0] - to_hsl([0.5, 0.627, 1.0])[0]).abs() < 2.0); } #[test] diff --git a/crates/draw/src/render/pdf.rs b/crates/draw/src/render/pdf.rs index 064a130032576ccea9ff37dd11cb75db6ad5a6e1..3e4c11e2559f4985cb2b19764079f9696b03bafd 100644 --- a/crates/draw/src/render/pdf.rs +++ b/crates/draw/src/render/pdf.rs @@ -37,7 +37,6 @@ pub fn pdf(title: &str, sheets: &[Sheet<'_>]) -> Result, RenderError> { images: HashMap::new(), states: HashMap::new(), scale: ScaleContext::new(), - rows: None, }; let catalog = document.reserve(); let tree = document.reserve(); @@ -101,9 +100,6 @@ struct Document { /// Graphics states by fill and stroke opacity bits and whether they multiply. states: HashMap<(u32, bool), Ref>, scale: ScaleContext, - /// The top and bottom of the layer drawn, in its units, where it clips; lines of text - /// outside it are left out, so that no sheet's text layer holds text it does not show. - rows: Option<[f32; 2]>, } #[derive(Hash, PartialEq, Eq)] @@ -258,7 +254,9 @@ impl Document { .clip_nonzero() .end_path(); } - self.rows = layer + // Lines of text outside a clipping layer are left out, so that no sheet's text + // layer holds text it does not show. + let rows = layer .clip .map(|clip| [clip[1], clip[3]].map(|y| (y - layer.origin[1]) / layer.scale)); content.transform([ @@ -270,7 +268,7 @@ impl Document { layer.origin[1], ]); for primitive in layer.primitives { - self.primitive(&mut content, &mut used, primitive)?; + self.primitive(&mut content, &mut used, primitive, rows)?; } content.restore_state(); } @@ -290,28 +288,30 @@ impl Document { content.set_fill_rgb(red, green, blue); } if color[3] < 1.0 { - let state = self.state(color[3], false); - Used::add(&mut used.states, state); - content.set_parameters(Name(format!("G{}", state.get()).as_bytes())); + self.opacity(content, used, color[3], false); } } - fn state(&mut self, opacity: f32, multiply: bool) -> Ref { - let key = (opacity.clamp(0.0, 1.0).to_bits(), multiply); - if let Some(state) = self.states.get(&key) { - return *state; - } - let state = self.reserve(); - let mut writer = self.pdf.ext_graphics(state); - writer - .non_stroking_alpha(opacity.clamp(0.0, 1.0)) - .stroking_alpha(opacity.clamp(0.0, 1.0)); - if multiply { - writer.blend_mode(BlendMode::Multiply); - } - writer.finish(); - self.states.insert(key, state); - state + /// Paints at `opacity`, multiplying what lies beneath if `multiply`. + fn opacity(&mut self, content: &mut Content, used: &mut Used, opacity: f32, multiply: bool) { + let opacity = opacity.clamp(0.0, 1.0); + let key = (opacity.to_bits(), multiply); + let state = match self.states.get(&key) { + Some(state) => *state, + None => { + let state = self.reserve(); + let mut writer = self.pdf.ext_graphics(state); + writer.non_stroking_alpha(opacity).stroking_alpha(opacity); + if multiply { + writer.blend_mode(BlendMode::Multiply); + } + writer.finish(); + self.states.insert(key, state); + state + } + }; + Used::add(&mut used.states, state); + content.set_parameters(Name(format!("G{}", state.get()).as_bytes())); } fn primitive( @@ -319,6 +319,7 @@ impl Document { content: &mut Content, used: &mut Used, primitive: &Primitive<'_>, + rows: Option<[f32; 2]>, ) -> Result<(), RenderError> { match primitive { Primitive::Text { @@ -334,7 +335,7 @@ impl Document { .clip_nonzero() .end_path(); } - text.runs(&mut |run| self.run(content, used, run, *origin, *ink))?; + text.runs(&mut |run| self.run(content, used, run, *origin, *ink, rows))?; content.restore_state(); } Primitive::Icon { @@ -364,9 +365,7 @@ impl Document { }; content.save_state(); if tint[3] < 1.0 { - let state = self.state(tint[3], false); - Used::add(&mut used.states, state); - content.set_parameters(Name(format!("G{}", state.get()).as_bytes())); + self.opacity(content, used, tint[3], false); } self.place( content, @@ -603,9 +602,7 @@ impl Document { color, } => { content.save_state(); - let state = self.state(color[3], true); - Used::add(&mut used.states, state); - content.set_parameters(Name(format!("G{}", state.get()).as_bytes())); + self.opacity(content, used, color[3], true); let [red, green, blue] = encoded(*color); content .set_stroke_rgb(red, green, blue) @@ -649,7 +646,7 @@ impl Document { return [pixel[0], pixel[1], pixel[2], alpha]; } let [red, green, blue] = [pixel[0], pixel[1], pixel[2]].map(|byte| { - let linear = super::linear(f32::from(byte) / 255.0); + let linear = crate::linear(f32::from(byte) / 255.0); srgb_byte((linear * 255.0 / f32::from(alpha)).min(1.0)) }); [red, green, blue, alpha] @@ -756,15 +753,13 @@ impl Document { run: GlyphRun<'_>, origin: [f32; 2], ink: [f32; 4], + rows: Option<[f32; 2]>, ) -> Result<(), RenderError> { let [top, bottom] = [ origin[1] + run.line[0], origin[1] + run.line[0] + run.line[1], ]; - if self - .rows - .is_some_and(|[first, last]| bottom <= first || top >= last) - { + if rows.is_some_and(|[first, last]| bottom <= first || top >= last) { return Ok(()); } let font = self.font(run.font, run.index)?; diff --git a/crates/draw/src/render/translucent.hlsl b/crates/draw/src/render/translucent.hlsl new file mode 100644 index 0000000000000000000000000000000000000000..eb692e477b57f3bd3b4808be5a87acdf5fa42c0d --- /dev/null +++ b/crates/draw/src/render/translucent.hlsl @@ -0,0 +1,21 @@ +// Shader model 4.0 twin of translucent.wgsl: the frame, blended in linear light, premultiplied +// again in sRGB for a window server that composites that way. +Texture2D frame : register(t0); + +float4 vertex(uint index : SV_VertexID) : SV_Position { + float2 corner = float2((index << 1) & 2, index & 2); + return float4(corner * 2.0 - 1.0, 0.0, 1.0); +} + +float3 encode(float3 linear_light) { + return linear_light <= 0.0031308 ? linear_light * 12.92 + : 1.055 * pow(linear_light, 1.0 / 2.4) - 0.055; +} + +float4 fragment(float4 at : SV_Position) : SV_Target { + float4 pixel = frame.Load(int3(at.xy, 0)); + if (pixel.a == 0.0) { + return 0.0; + } + return float4(encode(pixel.rgb / pixel.a) * pixel.a, pixel.a); +} diff --git a/crates/draw/src/render/webgpu.rs b/crates/draw/src/render/webgpu.rs index 1aca9a1cd6f86ce33c3803c68f9559dd578aeadf..dae2dc48ed6b7cb531f7d11f3b833eaac670cbb3 100644 --- a/crates/draw/src/render/webgpu.rs +++ b/crates/draw/src/render/webgpu.rs @@ -17,30 +17,29 @@ impl AsRef for Image { pub(super) struct Gpu { device: wgpu::Device, queue: wgpu::Queue, - pipeline: wgpu::RenderPipeline, - image_pipeline: wgpu::RenderPipeline, - erase_pipeline: wgpu::RenderPipeline, - multiply_pipeline: wgpu::RenderPipeline, - atlas: wgpu::Texture, - bind_group: wgpu::BindGroup, - image_sampler: wgpu::Sampler, + layout: wgpu::BindGroupLayout, + /// A pipeline for each of `Blend::STATES`. + pipelines: [wgpu::RenderPipeline; 4], + atlas: (wgpu::Texture, wgpu::BindGroup), + nearest: wgpu::Sampler, + linear: wgpu::Sampler, vertex_buffer: wgpu::Buffer, format: wgpu::TextureFormat, /// Offscreen pictures for groups, the target's size, and how each is sampled. - groups: Vec<(wgpu::TextureView, wgpu::BindGroup)>, - groups_size: [u32; 2], + groups: Vec<(wgpu::Texture, wgpu::BindGroup)>, } -#[cfg(not(windows))] impl Renderer { pub fn new(device: wgpu::Device, queue: wgpu::Queue, format: wgpu::TextureFormat) -> Self { Self::with_gpu(Gpu::new(device, queue, format)) } + #[cfg(not(windows))] pub fn device(&self) -> &wgpu::Device { &self.gpu.device } + #[cfg(not(windows))] pub fn queue(&self) -> &wgpu::Queue { &self.gpu.queue } @@ -53,7 +52,7 @@ impl Gpu { format: wgpu::TextureFormat, ) -> Self { let shader = device.create_shader_module(wgpu::include_wgsl!("../draw.wgsl")); - let binding_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { label: Some("Draw texture"), entries: &[ wgpu::BindGroupLayoutEntry { @@ -76,10 +75,23 @@ impl Gpu { }); let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { label: Some("Draw"), - bind_group_layouts: &[Some(&binding_layout)], + bind_group_layouts: &[Some(&layout)], ..Default::default() }); - let make_pipeline = |blend| { + let attributes: [_; 9] = std::array::from_fn(|index| { + let (_, floats, offset) = ATTRIBUTES[index]; + wgpu::VertexAttribute { + format: [ + wgpu::VertexFormat::Float32, + wgpu::VertexFormat::Float32x2, + wgpu::VertexFormat::Float32x3, + wgpu::VertexFormat::Float32x4, + ][floats - 1], + offset: offset as u64, + shader_location: index as u32, + } + }); + let pipelines = Blend::STATES.map(|[color, dst_color, alpha, dst_alpha]| { device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { label: Some("Draw"), layout: Some(&pipeline_layout), @@ -90,7 +102,7 @@ impl Gpu { buffers: &[Some(wgpu::VertexBufferLayout { array_stride: size_of::() as u64, step_mode: wgpu::VertexStepMode::Vertex, - attributes: &wgpu::vertex_attr_array![0 => Float32x2, 1 => Float32x2, 2 => Float32x4, 3 => Float32x2, 4 => Float32x4, 5 => Float32, 6 => Float32x2, 7 => Float32x3, 8 => Float32], + attributes: &attributes, })], }, fragment: Some(wgpu::FragmentState { @@ -99,7 +111,10 @@ impl Gpu { compilation_options: Default::default(), targets: &[Some(wgpu::ColorTargetState { format, - blend: Some(blend), + blend: Some(wgpu::BlendState { + color: component([color, dst_color]), + alpha: component([alpha, dst_alpha]), + }), write_mask: wgpu::ColorWrites::ALL, })], }), @@ -109,63 +124,33 @@ impl Gpu { multiview_mask: None, cache: None, }) - }; - // Coverage accumulates in alpha, leaving premultiplied colour over a transparent clear. - let pipeline = make_pipeline(wgpu::BlendState { - color: wgpu::BlendState::ALPHA_BLENDING.color, - alpha: wgpu::BlendComponent::OVER, }); - let image_pipeline = make_pipeline(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING); - let erase = wgpu::BlendComponent { - src_factor: wgpu::BlendFactor::Zero, - dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha, - operation: wgpu::BlendOperation::Add, - }; - let erase_pipeline = make_pipeline(wgpu::BlendState { - color: erase, - alpha: erase, - }); - // The fragment's colour is scaled by its coverage, so this leaves the target times - // the colour where covered and the target elsewhere; alpha stays. - let multiply_pipeline = make_pipeline(wgpu::BlendState { - color: wgpu::BlendComponent { - src_factor: wgpu::BlendFactor::Dst, - dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha, - operation: wgpu::BlendOperation::Add, - }, - alpha: wgpu::BlendComponent { - src_factor: wgpu::BlendFactor::Zero, - dst_factor: wgpu::BlendFactor::One, - operation: wgpu::BlendOperation::Add, - }, - }); - let (atlas, bind_group) = atlas(&device, &pipeline, ATLAS_SIZE); let vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor { label: Some("Draw vertices"), size: VERTEX_BUFFER_BYTES, usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false, }); - let image_sampler = device.create_sampler(&wgpu::SamplerDescriptor { - label: Some("Draw image filtering"), - mag_filter: wgpu::FilterMode::Linear, - min_filter: wgpu::FilterMode::Linear, - ..Default::default() - }); + let sampler = |filter| { + device.create_sampler(&wgpu::SamplerDescriptor { + mag_filter: filter, + min_filter: filter, + ..Default::default() + }) + }; + let nearest = sampler(wgpu::FilterMode::Nearest); + let atlas = atlas(&device, &layout, &nearest, ATLAS_SIZE); Self { + linear: sampler(wgpu::FilterMode::Linear), + nearest, + atlas, device, queue, - pipeline, - image_pipeline, - erase_pipeline, - multiply_pipeline, - atlas, - bind_group, - image_sampler, + layout, + pipelines, vertex_buffer, format, groups: Vec::new(), - groups_size: [0; 2], } } @@ -178,11 +163,11 @@ impl Gpu { } pub(super) fn atlas_side(&self) -> u32 { - self.atlas.width() + self.atlas.0.width() } pub(super) fn new_atlas(&mut self, side: u32) { - (self.atlas, self.bind_group) = atlas(&self.device, &self.pipeline, side); + self.atlas = atlas(&self.device, &self.layout, &self.nearest, side); } pub(super) fn write_atlas(&self, origin: [u32; 2], size: [u32; 2], rgba: &[u8]) { @@ -193,7 +178,7 @@ impl Gpu { y: origin[1], z: 0, }, - ..self.atlas.as_image_copy() + ..self.atlas.0.as_image_copy() }, rgba, wgpu::TexelCopyBufferLayout { @@ -210,21 +195,12 @@ impl Gpu { } pub(super) fn upload_image(&self, image: &RasterImage) -> Image { - let size = wgpu::Extent3d { - width: image.size[0], - height: image.size[1], - depth_or_array_layers: 1, - }; - let texture = self.device.create_texture(&wgpu::TextureDescriptor { - label: Some("Draw image"), - size, - mip_level_count: 1, - sample_count: 1, - dimension: wgpu::TextureDimension::D2, - format: wgpu::TextureFormat::Rgba8UnormSrgb, - usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, - view_formats: &[], - }); + let texture = texture( + &self.device, + image.size, + wgpu::TextureFormat::Rgba8UnormSrgb, + wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + ); self.queue.write_texture( texture.as_image_copy(), image.pixels(), @@ -233,66 +209,9 @@ impl Gpu { bytes_per_row: Some(image.size[0] * 4), rows_per_image: Some(image.size[1]), }, - size, + texture.size(), ); - Image(self.device.create_bind_group(&wgpu::BindGroupDescriptor { - label: Some("Draw image"), - layout: &self.pipeline.get_bind_group_layout(0), - entries: &[ - wgpu::BindGroupEntry { - binding: 0, - resource: wgpu::BindingResource::TextureView( - &texture.create_view(&Default::default()), - ), - }, - wgpu::BindGroupEntry { - binding: 1, - resource: wgpu::BindingResource::Sampler(&self.image_sampler), - }, - ], - })) - } - - /// An offscreen picture `size` large for each group, kept for later frames. - fn group_pictures(&mut self, size: [u32; 2], count: usize) { - let gpu = self; - if gpu.groups_size != size { - gpu.groups.clear(); - gpu.groups_size = size; - } - while gpu.groups.len() < count { - let texture = gpu.device.create_texture(&wgpu::TextureDescriptor { - label: Some("Draw group"), - size: wgpu::Extent3d { - width: size[0], - height: size[1], - depth_or_array_layers: 1, - }, - mip_level_count: 1, - sample_count: 1, - dimension: wgpu::TextureDimension::D2, - format: gpu.format, - usage: wgpu::TextureUsages::RENDER_ATTACHMENT - | wgpu::TextureUsages::TEXTURE_BINDING, - view_formats: &[], - }); - let view = texture.create_view(&Default::default()); - let binding = gpu.device.create_bind_group(&wgpu::BindGroupDescriptor { - label: Some("Draw group"), - layout: &gpu.pipeline.get_bind_group_layout(0), - entries: &[ - wgpu::BindGroupEntry { - binding: 0, - resource: wgpu::BindingResource::TextureView(&view), - }, - wgpu::BindGroupEntry { - binding: 1, - resource: wgpu::BindingResource::Sampler(&gpu.image_sampler), - }, - ], - }); - gpu.groups.push((view, binding)); - } + Image(binding(&self.device, &self.layout, &texture, &self.linear)) } /// Clears `target`, `size` device pixels, to linear `clear` and draws the prepared @@ -304,30 +223,28 @@ impl Gpu { size: [u32; 2], clear: [f32; 4], ) { - self.group_pictures(size, frame.groups.len()); + if self + .groups + .first() + .is_some_and(|(picture, _)| [picture.width(), picture.height()] != size) + { + self.groups.clear(); + } + while self.groups.len() < frame.groups.len() { + let picture = texture( + &self.device, + size, + self.format, + wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING, + ); + let binding = binding(&self.device, &self.layout, &picture, &self.linear); + self.groups.push((picture, binding)); + } let gpu = &*self; gpu.queue .write_buffer(&gpu.vertex_buffer, 0, bytemuck::cast_slice(frame.vertices)); let mut encoder = gpu.device.create_command_encoder(&Default::default()); - let draw = |pass: &mut wgpu::RenderPass, batches: &[Batch]| { - for batch in batches { - let [x, y, width, height] = batch.scissor; - pass.set_scissor_rect(x, y, width, height); - let (pipeline, binding) = match batch.blend { - Blend::Over => (&gpu.pipeline, &gpu.bind_group), - Blend::Image(id) => { - let image: &Image = frame.images[&id].texture.as_ref(); - (&gpu.image_pipeline, &image.0) - } - Blend::Erase => (&gpu.erase_pipeline, &gpu.bind_group), - Blend::Multiply => (&gpu.multiply_pipeline, &gpu.bind_group), - }; - pass.set_pipeline(pipeline); - pass.set_bind_group(0, binding, &[]); - pass.draw(batch.vertices.clone(), 0..1); - } - }; - let begin = |encoder: &mut wgpu::CommandEncoder, view, clear: [f32; 4]| { + let mut paint = |view: &wgpu::TextureView, clear: [f32; 4], batches: &[Batch]| { let [r, g, b, a] = clear.map(f64::from); let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { label: Some("Draw"), @@ -343,67 +260,102 @@ impl Gpu { ..Default::default() }); pass.set_vertex_buffer(0, gpu.vertex_buffer.slice(..)); - pass.forget_lifetime() + for batch in batches { + let [x, y, width, height] = batch.scissor; + pass.set_scissor_rect(x, y, width, height); + pass.set_pipeline(&gpu.pipelines[batch.blend.state()]); + let binding = match batch.blend { + Blend::Image(id) => &frame.image::(id).0, + Blend::Group(index) => &gpu.groups[index].1, + Blend::Over | Blend::Erase | Blend::Multiply => &gpu.atlas.1, + }; + pass.set_bind_group(0, binding, &[]); + pass.draw(batch.vertices.clone(), 0..1); + } }; - for (group, (view, _)) in frame.groups.iter().zip(&gpu.groups) { - let mut pass = begin(&mut encoder, view, [0.0; 4]); - draw(&mut pass, &frame.batches[group.batches.clone()]); + for (batches, (picture, _)) in frame.groups.iter().zip(&gpu.groups) { + paint(&picture.create_view(&Default::default()), [0.0; 4], batches); } - let mut pass = begin(&mut encoder, target, clear); - let mut next = 0; - for (group, (_, binding)) in frame.groups.iter().zip(&gpu.groups) { - draw(&mut pass, &frame.batches[next..group.batches.start]); - pass.set_scissor_rect(0, 0, size[0], size[1]); - pass.set_pipeline(&gpu.image_pipeline); - pass.set_bind_group(0, binding, &[]); - pass.draw(group.composite.clone(), 0..1); - next = group.batches.end; - } - draw(&mut pass, &frame.batches[next..]); - drop(pass); + paint(target, clear, frame.batches); gpu.queue.submit([encoder.finish()]); } } -/// An empty atlas `side` texels square, and how the pipeline samples it. -fn atlas( +fn component([source, destination]: [Factor; 2]) -> wgpu::BlendComponent { + let factor = |factor| match factor { + Factor::Zero => wgpu::BlendFactor::Zero, + Factor::One => wgpu::BlendFactor::One, + Factor::SourceAlpha => wgpu::BlendFactor::SrcAlpha, + Factor::OneMinusSourceAlpha => wgpu::BlendFactor::OneMinusSrcAlpha, + Factor::Destination => wgpu::BlendFactor::Dst, + }; + wgpu::BlendComponent { + src_factor: factor(source), + dst_factor: factor(destination), + operation: wgpu::BlendOperation::Add, + } +} + +fn texture( device: &wgpu::Device, - pipeline: &wgpu::RenderPipeline, - side: u32, -) -> (wgpu::Texture, wgpu::BindGroup) { - let atlas = device.create_texture(&wgpu::TextureDescriptor { - label: Some("Glyph atlas"), + [width, height]: [u32; 2], + format: wgpu::TextureFormat, + usage: wgpu::TextureUsages, +) -> wgpu::Texture { + device.create_texture(&wgpu::TextureDescriptor { + label: Some("Draw"), size: wgpu::Extent3d { - width: side, - height: side, + width, + height, depth_or_array_layers: 1, }, mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, - format: wgpu::TextureFormat::Rgba8UnormSrgb, - usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + format, + usage, view_formats: &[], - }); - let view = atlas.create_view(&Default::default()); - let sampler = device.create_sampler(&wgpu::SamplerDescriptor { - mag_filter: wgpu::FilterMode::Nearest, - min_filter: wgpu::FilterMode::Nearest, - ..Default::default() - }); - let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { - label: Some("Glyph atlas"), - layout: &pipeline.get_bind_group_layout(0), + }) +} + +/// How the pipelines sample `texture` through `sampler`. +fn binding( + device: &wgpu::Device, + layout: &wgpu::BindGroupLayout, + texture: &wgpu::Texture, + sampler: &wgpu::Sampler, +) -> wgpu::BindGroup { + device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("Draw"), + layout, entries: &[ wgpu::BindGroupEntry { binding: 0, - resource: wgpu::BindingResource::TextureView(&view), + resource: wgpu::BindingResource::TextureView( + &texture.create_view(&Default::default()), + ), }, wgpu::BindGroupEntry { binding: 1, - resource: wgpu::BindingResource::Sampler(&sampler), + resource: wgpu::BindingResource::Sampler(sampler), }, ], - }); - (atlas, bind_group) + }) +} + +/// An empty atlas `side` texels square, and how the pipelines sample it. +fn atlas( + device: &wgpu::Device, + layout: &wgpu::BindGroupLayout, + nearest: &wgpu::Sampler, + side: u32, +) -> (wgpu::Texture, wgpu::BindGroup) { + let atlas = texture( + device, + [side; 2], + wgpu::TextureFormat::Rgba8UnormSrgb, + wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + ); + let binding = binding(device, layout, &atlas, nearest); + (atlas, binding) } diff --git a/crates/mobile/Cargo.toml b/crates/mobile/Cargo.toml index 8848bbc9c7c15caf7c4007e8e6e7617fe0064312..c6fe6fa3f52730bfbb7dc5c5c5985c3ab92c7bd3 100644 --- a/crates/mobile/Cargo.toml +++ b/crates/mobile/Cargo.toml @@ -19,6 +19,8 @@ pollster = "0.4" resvg = { version = "0.48.1", default-features = false } serde = { version = "1", features = ["derive"] } serde_json = "1" +# Clears a password handed in to unlock a section. +zeroize = "1.9.0" [target.'cfg(target_os = "ios")'.dependencies] objc2 = "0.5.2" diff --git a/crates/mobile/include/snowbound.h b/crates/mobile/include/snowbound.h index f46002ad893265e3cb5bc5ef31712bc6c4ea5060..a8355fb8c626d69e498f71f5aea64adb2248b5cc 100644 --- a/crates/mobile/include/snowbound.h +++ b/crates/mobile/include/snowbound.h @@ -33,6 +33,8 @@ char *sb_library_new_section(const Library *library, const char *folder, const c const char *date, const char *time); char *sb_library_search(const Library *library, const Section *open, const char *path, const char *query); char *sb_library_tagged(const Library *library, const Section *open, const char *path); +bool sb_library_unlock(const Library *library, const char *path, const char *password); +void sb_library_lock_all(const Library *library); char *sb_library_sync_status(const Library *library); void sb_library_set_offline(const Library *library, bool offline); void sb_library_touched(const Library *library, const char *path); @@ -56,6 +58,7 @@ char *sb_section_themes(const Section *section, const char *id); bool sb_section_set_theme(const Section *section, const char *id, uint8_t scope, const char *theme); bool sb_section_flush(const Section *section, double seconds); void sb_section_wake(const Section *section); +void sb_section_set_author(const Section *section, const char *author); void sb_section_free(Section *section); View *sb_view_new(void *layer, const Section *section, const char *id, float width, float height, float scale); @@ -65,6 +68,8 @@ bool sb_view_render(View *view); bool sb_view_frame_pending(const View *view); void sb_view_set_dark(View *view, bool dark); bool sb_view_read_only(const View *view); +char *sb_view_reading(const View *view, float width); +bool sb_view_set_reading(View *view, float width); bool sb_view_reload(View *view, bool discard); void sb_view_focus(View *view, bool focused); void sb_view_content(View *view, float bounds[4]); diff --git a/crates/mobile/src/lib.rs b/crates/mobile/src/lib.rs index 9aaccd99f466b2fa5065948f522574a471a351b7..f21d0ed19663ab86f042a66c3f9e0719e4fc4725 100644 --- a/crates/mobile/src/lib.rs +++ b/crates/mobile/src/lib.rs @@ -37,7 +37,7 @@ use std::{ ffi::{CStr, CString, c_char, c_void}, mem::ManuallyDrop, sync::{ - Arc, Mutex, + Arc, LazyLock, Mutex, MutexGuard, atomic::{AtomicBool, Ordering}, }, task::{Wake, Waker}, @@ -125,29 +125,19 @@ impl Canvas { /// The page on `paper` as OneNote 2010 prints it, footers naming `section`. fn pdf(&self, paper: [f32; 2], section: &str) -> Option> { let page = self.page.editor.page().ok()?; - let mut engine = ENGINES - .lock() - .ok() - .and_then(|mut engines| engines.pop()) - .unwrap_or_default(); + let mut engine = pooled_engine(); let setup = canvas::print::Setup { paper, fit_width: true, footer: canvas::print::Footer::SectionAndPage, }; let pdf = canvas::print::pdf(vec![(section.to_owned(), vec![page])], &mut engine, &setup); - if let Ok(mut engines) = ENGINES.lock() { - engines.push(engine); - } + pool_engine(engine); pdf.ok() } fn new(space: ExGuid, page: Page, pixels: [u32; 2], scale: f32) -> Result { - let mut engine = ENGINES - .lock() - .ok() - .and_then(|mut engines| engines.pop()) - .unwrap_or_default(); + let mut engine = pooled_engine(); let (scene, editor) = PageScene::from_page(page, &mut engine)?; let mut page = PageView::new( editor, @@ -159,7 +149,7 @@ impl Canvas { ); page.touch = true; page.host_viewport = true; - (page.snap_to_grid, page.editor.default_font) = options(); + (page.snap_to_grid, page.editor.default_font) = options().clone(); Ok(Self { page, space, @@ -198,19 +188,7 @@ impl Canvas { /// What the page took since the last call as one edit, or none when it took nothing. fn edit(&mut self) -> Result> { let ops = self.page.editor.take_ops()?; - if ops.is_empty() { - return Ok(None); - } - Ok(Some(Edit { - at: library::filetime(), - ops: ops - .into_iter() - .map(|op| Op::Page { - space: self.space, - op, - }) - .collect(), - })) + Ok((!ops.is_empty()).then(|| page_edit(self.space, ops))) } fn device(&self, point: [f32; 2]) -> [f32; 2] { @@ -633,23 +611,33 @@ fn gpu() -> std::sync::MutexGuard<'static, Option> { /// rather than enumerating the system's fonts again. static ENGINES: Mutex> = Mutex::new(Vec::new()); +fn pooled_engine() -> TextEngine { + ENGINES + .lock() + .ok() + .and_then(|mut engines| engines.pop()) + .unwrap_or_default() +} + +fn pool_engine(engine: TextEngine) { + if let Ok(mut engines) = ENGINES.lock() { + engines.push(engine); + } +} + /// The reader's Snap To Grid and Default font, which every page opened from then on takes, /// and new pages' titles the font's face. -static OPTIONS: Mutex> = Mutex::new(None); +static OPTIONS: LazyLock> = + LazyLock::new(|| Mutex::new((true, canvas::editor::DefaultFont::default()))); -fn options() -> (bool, canvas::editor::DefaultFont) { - OPTIONS - .lock() - .unwrap_or_else(|error| error.into_inner()) - .clone() - .unwrap_or((true, canvas::editor::DefaultFont::default())) +fn options() -> MutexGuard<'static, (bool, canvas::editor::DefaultFont)> { + OPTIONS.lock().unwrap_or_else(|error| error.into_inner()) } /// OneNote's Snap To Grid: taps, drags and shapes land on the placement grid. #[unsafe(no_mangle)] pub extern "C" fn sb_set_snap_to_grid(on: bool) { - let font = options().1; - *OPTIONS.lock().unwrap_or_else(|error| error.into_inner()) = Some((on, font)); + options().0 = on; } /// 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) { /// `face` is NUL-terminated UTF-8. #[unsafe(no_mangle)] pub unsafe extern "C" fn sb_set_default_font(face: *const c_char, size: f32) { - let snap = options().0; - let font = canvas::editor::DefaultFont { + options().1 = canvas::editor::DefaultFont { face: string(face), size, color: None, }; - *OPTIONS.lock().unwrap_or_else(|error| error.into_inner()) = Some((snap, font)); } pub struct View { @@ -676,6 +662,8 @@ pub struct View { section: Arc, /// A conflict page, which shows what is stored and takes no edits. read_only: bool, + /// The column, in page points, a reading view reflows the page into; it takes no edits. + reading: Option, } impl View { @@ -731,13 +719,7 @@ impl View { .unwrap_or_default(); let restyle = onestore::op::restyle(&page, &sheet)?; if !read_only && !restyle.is_empty() { - section.shared.apply(Edit { - at: library::filetime(), - ops: restyle - .into_iter() - .map(|op| Op::Page { space, op }) - .collect(), - })?; + section.shared.apply(page_edit(space, restyle))?; page = section.shared.page(space)?.0; } let frame = Arc::::default(); @@ -757,6 +739,7 @@ impl View { frame, section: Arc::clone(§ion.shared), read_only, + reading: None, }) } @@ -795,7 +778,7 @@ impl View { fn stored(&mut self, result: Result) -> bool { let changed = report(result).unwrap_or(false); match report(self.canvas.edit()) { - Some(Some(_)) if self.read_only => { + Some(Some(_)) if self.read_only || self.reading.is_some() => { let _ = report(self.reload()); } Some(Some(edit)) => { @@ -830,17 +813,54 @@ impl View { } let (page, read_only) = self.section.page(self.canvas.space)?; self.read_only = read_only; + if let Some(column) = self.reading { + return self.show(page, Some(column)); + } Ok(moved(self.canvas.page.refresh(page)?)) } + + /// Shows `page` anew, reflowed into `column` where given and the page reflows; whether + /// it shows reflowed. + fn show(&mut self, page: Page, column: Option) -> Result { + let mut engine = pooled_engine(); + let reflowed = match column { + Some(column) => canvas::reading::read(&page, column, &mut engine)?.page, + None => None, + }; + pool_engine(engine); + self.reading = column.filter(|_| reflowed.is_some()); + let pixels = [self.config.width, self.config.height]; + let mut canvas = Canvas::new( + self.canvas.space, + reflowed.unwrap_or(page), + pixels, + self.canvas.scale, + )?; + canvas.paper = self.canvas.paper; + canvas.page.spelling = self.canvas.page.spelling.take(); + let _ = canvas.page.focus_changed(false)?; + canvas.page.editor.styles = std::mem::take(&mut self.canvas.page.editor.styles); + // SAFETY: `self.canvas` is replaced at once and not used in between. + let old = unsafe { ManuallyDrop::take(&mut self.canvas) }; + self.canvas = ManuallyDrop::new(canvas); + pool_engine(old.page.engine); + Ok(self.reading.is_some()) + } } impl Drop for View { fn drop(&mut self) { // SAFETY: `canvas` is not used again. let canvas = unsafe { ManuallyDrop::take(&mut self.canvas) }; - if let Ok(mut engines) = ENGINES.lock() { - engines.push(canvas.page.engine); - } + pool_engine(canvas.page.engine); + } +} + +/// What a page took as one edit of the page in `space`, made now. +fn page_edit(space: ExGuid, ops: Vec) -> Edit { + Edit { + at: library::filetime(), + ops: ops.into_iter().map(|op| Op::Page { space, op }).collect(), } } @@ -918,10 +938,39 @@ pub extern "C" fn sb_view_set_dark(view: &mut View, dark: bool) { view.canvas.set_dark(dark); } -/// A conflict page: it shows what is stored, and any edit returns it there. +/// A conflict page or a reading view: it shows what is stored, and any edit returns it there. #[unsafe(no_mangle)] pub extern "C" fn sb_view_read_only(view: &View) -> bool { - view.read_only + view.read_only || view.reading.is_some() +} + +/// Whether the page has a reading view `width` points wide, as JSON: `offered`, and the +/// `verdict` saying how it reflows or why not. +#[unsafe(no_mangle)] +pub extern "C" fn sb_view_reading(view: &View, width: f32) -> *mut c_char { + let result = view.section.page(view.canvas.space).and_then(|(page, _)| { + let mut engine = pooled_engine(); + let read = canvas::reading::read(&page, width / POINT, &mut engine); + pool_engine(engine); + Ok(read?.verdict) + }); + report(result).map_or(std::ptr::null_mut(), |verdict| { + let json = + serde_json::json!({ "offered": verdict.offered(), "verdict": format!("{verdict:?}") }); + owned(json.to_string()) + }) +} + +/// Shows the page reflowed into a column `width` points wide, read-only, or with 0 as laid +/// out; whether it now shows reflowed. +#[unsafe(no_mangle)] +pub extern "C" fn sb_view_set_reading(view: &mut View, width: f32) -> bool { + let column = (width > 0.0).then_some(width / POINT); + let shown = view + .section + .page(view.canvas.space) + .and_then(|(page, _)| view.show(page, column)); + report(shown).unwrap_or(false) } /// Shows the page as stored after a change made elsewhere, keeping the caret; with @@ -1234,7 +1283,7 @@ struct Choices { templates: Vec<&'static str>, } -fn srgb(colorref: u32) -> [u8; 3] { +pub(crate) fn srgb(colorref: u32) -> [u8; 3] { let [red, green, blue, _] = colorref.to_le_bytes(); [red, green, blue] } @@ -1399,7 +1448,7 @@ pub extern "C" fn sb_view_date_request(view: &mut View, seconds: &mut i64) -> i8 let Some(date) = view.canvas.page.editor.date() else { return -1; }; - *seconds = (date.timestamp() / 10_000_000) as i64 - 11_644_473_600; + *seconds = library::unix(date.timestamp()); field as i8 } diff --git a/crates/mobile/src/library.rs b/crates/mobile/src/library.rs index be36bdb89a276b9c63e333e481de6f311ef619a0..f28813276c4ee3aa780407c9ad927026c328702b 100644 --- a/crates/mobile/src/library.rs +++ b/crates/mobile/src/library.rs @@ -2,7 +2,7 @@ //! SMB share through the embedded client. A section opens through its replica in the app's //! cache, which stores each edit at once and publishes it in the background. -use crate::{Result, owned, report, string}; +use crate::{Result, owned, report, srgb, string}; use canvas::search::{Entry, Index, Query, Tagged}; use notebook::{ Remote, Replica, @@ -14,6 +14,7 @@ use onestore::{ CommitError, CommitState, ExGuid, PageCreation, PageEdit, Stamp, Transaction, op::{Edit, Op, SectionOp}, page::Page, + protected::Key, }; use std::{ collections::{BTreeMap, HashMap}, @@ -249,6 +250,15 @@ pub(crate) fn files(folder: &Folder, files: &mut BTreeMap) { } } +/// Catalog `path` from the top of the share holding a notebook at `root`. +pub(crate) fn on_share(root: &str, path: &str) -> String { + if root.is_empty() { + path.to_owned() + } else { + format!("{root}/{path}") + } +} + /// When a section's file was last checked for changes, and how it was then. struct Checked { at: Instant, @@ -267,6 +277,8 @@ pub struct Library { open: Mutex)>>, /// Work Offline, which sections opened later follow too. offline: AtomicBool, + /// Keys of the protected sections unlocked this run, by catalog path; in memory only. + keys: Mutex>, /// How long edits wait for a pause in typing before they publish (`Section::set_pause`): /// a file provider such as iCloud Drive uploads each publication, and makes one concurrent /// with another device's a conflict version. @@ -285,8 +297,12 @@ pub(crate) struct Tab { pub(crate) group: String, /// The tab colour in sRGB. pub(crate) color: [u8; 3], - /// Password-protected or unreadable sections list but do not open. + /// Password-protected or unreadable sections list but do not open; a protected one opens + /// once unlocked. pub(crate) readable: bool, + /// Password protected and not unlocked (`sb_library_unlock`). + #[serde(default)] + pub(crate) locked: bool, /// Not on this device yet, as iCloud Drive keeps it elsewhere; the host downloads it. #[serde(default)] pub(crate) downloading: bool, @@ -295,9 +311,9 @@ pub(crate) struct Tab { pub(crate) problem: Option, } -fn rgb(colorref: u32) -> [u8; 3] { - let [red, green, blue, _] = colorref.to_le_bytes(); - [red, green, blue] +/// Titles keep OneNote's line breaks, which a one-line list shows as spaces. +fn one_line(title: &str) -> String { + title.replace(|char: char| char.is_control(), " ") } fn stem(path: &str) -> String { @@ -309,18 +325,21 @@ fn stem(path: &str) -> String { /// A folder's sections, its groups' after them, leaving out the recycle bin OneNote keeps /// deleted sections and pages in. -fn tabs(folder: &Folder, tabs: &mut Vec) { +fn tabs(folder: &Folder, unlocked: &HashMap, tabs: &mut Vec) { for section in &folder.sections { let (name, color, readable) = match §ion.state { SectionState::Readable { name, color, .. } => (name.clone(), *color, true), - SectionState::Locked | SectionState::Unreadable(_) => (None, None, false), + SectionState::Locked => (None, None, unlocked.contains_key(§ion.path)), + SectionState::Unreadable(_) => (None, None, false), }; + let locked = matches!(section.state, SectionState::Locked) && !readable; tabs.push(Tab { name: name.unwrap_or_else(|| stem(§ion.path)), path: section.path.clone(), group: folder.path.clone(), - color: rgb(color.unwrap_or(SECTION_COLOR)), + color: srgb(color.unwrap_or(SECTION_COLOR)), readable, + locked, downloading: false, problem: None, }); @@ -330,8 +349,9 @@ fn tabs(folder: &Folder, tabs: &mut Vec) { name: stem(&entry.path), path: entry.path.clone(), group: folder.path.clone(), - color: rgb(SECTION_COLOR), + color: srgb(SECTION_COLOR), readable: false, + locked: false, downloading: entry.reason == Reason::Evicted, problem: match entry.reason { Reason::InUse => Some("Section in use".into()), @@ -342,7 +362,7 @@ fn tabs(folder: &Folder, tabs: &mut Vec) { } for group in &folder.groups { if !group.path.ends_with("OneNote_RecycleBin") { - self::tabs(group, tabs); + self::tabs(group, unlocked, tabs); } } } @@ -377,6 +397,7 @@ impl Library { background, open: Mutex::default(), offline: AtomicBool::new(false), + keys: Default::default(), pause: if local { Duration::ZERO } else { PAUSE }, }) } @@ -399,6 +420,7 @@ impl Library { background: Some(background), open: Mutex::default(), offline: AtomicBool::new(false), + keys: Default::default(), pause: Duration::ZERO, }; let reached = library.with_notebook(false, |_| Ok(())); @@ -501,6 +523,24 @@ impl Library { change(reopened) } + fn unlocked(&self) -> std::sync::MutexGuard<'_, HashMap> { + self.keys.lock().unwrap_or_else(|error| error.into_inner()) + } + + /// Unlocks the protected section at catalog `path` with `password`; whether it matched. + pub(crate) fn unlock(&self, path: &str, password: &str) -> Result { + let key = self.with_notebook(false, |notebook| match notebook.unlock(path, password) { + Ok(key) => Ok(Some(key)), + Err(notebook::Error::Protected(_)) => Ok(None), + Err(error) => Err(error.into()), + })?; + let unlocked = key.is_some(); + if let Some(key) = key { + self.unlocked().insert(path.to_owned(), key); + } + Ok(unlocked) + } + /// The notebook's sections, read again. pub(crate) fn tabs(&self) -> Result> { if let Place::File(file) = &self.place { @@ -509,8 +549,9 @@ impl Library { name: stem(&path), path, group: String::new(), - color: rgb(SECTION_COLOR), + color: srgb(SECTION_COLOR), readable: true, + locked: false, downloading: false, problem: None, }]); @@ -521,7 +562,7 @@ impl Library { tabs: Vec::new(), files: BTreeMap::new(), }; - tabs(notebook.catalog(), &mut listed.tabs); + tabs(notebook.catalog(), &self.unlocked(), &mut listed.tabs); files(notebook.catalog(), &mut listed.files); if let (Place::Folder(_), Some(background)) = (&self.place, &self.background) { background.watch(notebook.replicas()); @@ -586,25 +627,36 @@ impl Library { |file| Ok(Coordinated(file.to_owned())), notify, )?), - Place::Folder(_) => Ok(self - .notebook() - .as_ref() - .ok_or("A lone section has no notebook")? - .section_with(path, |file| Ok(Coordinated(file.to_owned())), notify)?), + Place::Folder(_) => { + let notebook = self.notebook(); + let notebook = notebook.as_ref().ok_or("A lone section has no notebook")?; + let connect = |file: &Path| Ok(Coordinated(file.to_owned())); + Ok(match self.unlocked().get(path) { + Some(key) => notebook.section_unlocked_with(path, key, connect, notify)?, + None => notebook.section_with(path, connect, notify)?, + }) + } Place::Share { server, root, .. } => { - let file = if root.is_empty() { - path.to_owned() - } else { - format!("{root}/{path}") + let file = on_share(root, path); + let key = self.unlocked().get(path).cloned(); + // A protected section's replica is named by the identity its file holds. + let cache = match &key { + Some(_) => self + .notebook() + .as_ref() + .ok_or("The notebook hasn’t been read")? + .replica_path(path)?, + None => { + let identity = self + .listed() + .and_then(|mut listed| listed.files.remove(path)) + .ok_or("The notebook hasn’t listed this section")?; + let replicas = self.share_replicas().ok_or("Not a share")?; + replicas.join(format!("{identity}.sqlite")) + } }; - let identity = self - .listed() - .and_then(|mut listed| listed.files.remove(path)) - .ok_or("The notebook hasn’t listed this section")?; - let replicas = self.share_replicas().ok_or("Not a share")?; - let cache = replicas.join(format!("{identity}.sqlite")); - std::fs::create_dir_all(&replicas)?; - let replica = Replica::open_or_create(&cache, || { + std::fs::create_dir_all(cache.parent().ok_or("No cache")?)?; + let replica = Replica::open_or_create(&cache, key.as_ref(), || { let client = self.client().ok_or_else(|| { std::io::Error::new( std::io::ErrorKind::NotConnected, @@ -642,13 +694,20 @@ pub(crate) enum Status { pub(crate) struct Shared { pub section: session::Section, /// Who the edits name, as OneNote names the Office user. - pub author: String, + pub author: Mutex, pub status: AtomicU8, } impl Shared { + pub fn author(&self) -> String { + self.author + .lock() + .unwrap_or_else(|error| error.into_inner()) + .clone() + } + pub fn apply(&self, edit: Edit) -> Result<()> { - self.section.apply(&self.author, edit)?; + self.section.apply(&self.author(), edit)?; self.status.store(Status::Saving as u8, Ordering::Relaxed); Ok(()) } @@ -694,7 +753,7 @@ pub(crate) struct Version { pub(crate) created: Option, } -fn unix(filetime: u64) -> i64 { +pub(crate) fn unix(filetime: u64) -> i64 { (filetime / 10_000_000) as i64 - 11_644_473_600 } @@ -709,7 +768,7 @@ pub(crate) fn filetime() -> u64 { /// A page OneNote 2010 would create now, titled `date` and `time` as the host formats /// them: the long date and the short time; titled in the Default font's face. fn dated(author: &str, date: &str, time: &str) -> Result { - let font = crate::options().1; + let font = crate::options().1.clone(); Ok(PageCreation::new(None, Some(""), author)? .titled_in(&font.face, font.color)? .dated(date, time)?) @@ -718,12 +777,8 @@ fn dated(author: &str, date: &str, time: &str) -> Result { impl Section { /// The notebook's themes; none for a lone section. pub(crate) fn themes(&self) -> notebook::sidecar::themes::Themes { - let notebook = self - .library - .notebook - .lock() - .unwrap_or_else(|error| error.into_inner()); - notebook + self.library + .notebook() .as_ref() .and_then(|notebook| crate::report(notebook.themes().map_err(Into::into))) .unwrap_or_default() @@ -731,12 +786,8 @@ impl Section { /// Merges `change` into the notebook's themes. pub(crate) fn save_themes(&self, change: notebook::sidecar::themes::Themes) -> Result<()> { - let notebook = self - .library - .notebook - .lock() - .unwrap_or_else(|error| error.into_inner()); - notebook + self.library + .notebook() .as_ref() .ok_or("A section on its own keeps no themes")? .save_themes(change)?; @@ -772,7 +823,7 @@ impl Section { ) -> Result { let shared = Arc::new(Shared { section: library.section(path, notify)?, - author, + author: Mutex::new(author), status: AtomicU8::new(0), }); let mut open = library @@ -792,8 +843,7 @@ impl Section { .into_iter() .map(|(space, title, level)| Row { id: space.to_string(), - // Titles keep OneNote's line breaks, which a one-line list shows as spaces. - title: title.replace(|char: char| char.is_control(), " "), + title: one_line(&title), level, versions: conflicts .iter() @@ -821,7 +871,7 @@ impl Section { date: &str, time: &str, ) -> Result { - let creation = dated(&self.shared.author, date, time)?; + let creation = dated(&self.shared.author(), date, time)?; let space = creation.space(); let mut ops = vec![Op::Section(SectionOp::Create(creation))]; if let Some(parent) = parent { @@ -857,14 +907,14 @@ impl Section { if listed.iter().any(|(page, ..)| *page == space) { if self.library.notebook().is_some() { let page = section.page(space)?; - let author = &self.shared.author; + let author = self.shared.author(); self.library.with_notebook(true, |notebook| { - Ok(notebook.recycle_pages(std::slice::from_ref(&page), author)?) + Ok(notebook.recycle_pages(std::slice::from_ref(&page), &author)?) })?; } if listed.len() == 1 { ops.push(Op::Section(SectionOp::Create(dated( - &self.shared.author, + &self.shared.author(), date, time, )?))); @@ -958,7 +1008,7 @@ impl Library { .map(|found| Found { section: found.section, page: found.space.to_string(), - title: found.title.replace(|char: char| char.is_control(), " "), + title: one_line(&found.title), snippet: found.snippet, }) .collect() @@ -975,7 +1025,7 @@ impl Library { .map(|tagged: Tagged| Tag { section: tagged.section, page: tagged.space.to_string(), - title: tagged.title.replace(|char: char| char.is_control(), " "), + title: one_line(&tagged.title), paragraph: tagged.paragraph.to_string(), name: tagged.name, shape: tagged.shape, @@ -1169,11 +1219,7 @@ impl Library { let (stamp, read): (Vec, Reader) = match &self.place { Place::Share { root, .. } => { let client = self.client().ok_or("Not a share")?; - let file = if root.is_empty() { - path.to_owned() - } else { - format!("{root}/{path}") - }; + let file = on_share(root, path); let stamp = client.stamp(&file)?; let mut key = stamp.header.to_vec(); key.extend(stamp.length.to_le_bytes()); @@ -1240,6 +1286,16 @@ fn boxed(result: Result, error: *mut *mut c_char) -> *mut T { } } +fn shared(result: Result, error: *mut *mut c_char) -> *const Library { + match result { + Ok(library) => Arc::into_raw(Arc::new(library)), + Err(cause) => { + failed(cause, error); + std::ptr::null() + } + } +} + pub(crate) fn json(value: Result) -> *mut c_char { report(value.and_then(|value| Ok(serde_json::to_string(&value)?))) .map_or(std::ptr::null_mut(), owned) @@ -1249,10 +1305,6 @@ pub(crate) fn optional(text: *const c_char) -> Option { (!text.is_null()).then(|| string(text)) } -fn space(text: *const c_char) -> Result { - Ok(string(text).parse()?) -} - /// The notebook folder or lone section file at `path`, its section replicas kept in the /// directory `cache`; null with `error` set if it cannot be read. A folder `local` to this /// 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( local: bool, error: *mut *mut c_char, ) -> *const Library { - match Library::open(Path::new(&string(path)), Path::new(&string(cache)), local) { - Ok(library) => Arc::into_raw(Arc::new(library)), - Err(cause) => { - failed(cause, error); - std::ptr::null() - } - } + shared( + Library::open(Path::new(&string(path)), Path::new(&string(cache)), local), + error, + ) } /// 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( error: *mut *mut c_char, ) -> *const Library { let server = server(address, share, user, password, domain); - match Library::server(server, &string(root), Path::new(&string(cache))) { - Ok(library) => Arc::into_raw(Arc::new(library)), - Err(cause) => { - failed(cause, error); - std::ptr::null() - } - } + shared( + Library::server(server, &string(root), Path::new(&string(cache))), + error, + ) } /// 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( json(library.tagged(open.map(|section| (path.as_str(), section)))) } -/// Each section's sync status as JSON, in the notebook's order: `path`, `state` (0 up to -/// date, 1 syncing, 2 in use elsewhere, 3 not connected, 4 read-only, 5 failed), `synced` -/// (seconds since 1970, or null before the file was reached), `queued` changes and `error`. +/// Unlocks the password-protected section at catalog `path` with `password`, as OneNote's +/// Protected Section dialog does; false for a wrong password. The key stays in memory until +/// `sb_library_lock_all`. +/// +/// # Safety +/// `path` and `password` are NUL-terminated UTF-8. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn sb_library_unlock( + library: &Library, + path: *const c_char, + password: *const c_char, +) -> bool { + let password = zeroize::Zeroizing::new(string(password)); + report(library.unlock(&string(path), &password)).unwrap_or(false) +} + +/// Locks every protected section of the notebook, forgetting their keys: they open again only +/// once unlocked. +#[unsafe(no_mangle)] +pub extern "C" fn sb_library_lock_all(library: &Library) { + library.unlocked().clear(); +} + +/// Each section's sync status as JSON, in the notebook's order: `path`, `state` (`SyncState` +/// by its place, best first), `synced` (seconds since 1970, or null before the file was +/// reached), `queued` changes and `error`. #[unsafe(no_mangle)] pub extern "C" fn sb_library_sync_status(library: &Library) -> *mut c_char { json(Ok(library.sync_status())) @@ -1735,8 +1804,11 @@ pub unsafe extern "C" fn sb_section_delete_page( date: *const c_char, time: *const c_char, ) -> bool { - report(space(id).and_then(|space| section.delete_page(space, &string(date), &string(time)))) - .is_some() + let deleted = string(id) + .parse() + .map_err(Into::into) + .and_then(|space| section.delete_page(space, &string(date), &string(time))); + report(deleted).is_some() } /// 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 { section.flush(Duration::from_secs_f64(seconds)) } +/// Names `author` in the section's edits from now on, as when the reader changes their name. +/// +/// # Safety +/// `author` is NUL-terminated UTF-8. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn sb_section_set_author(section: &Section, author: *const c_char) { + *section + .shared + .author + .lock() + .unwrap_or_else(|error| error.into_inner()) = string(author); +} + /// Asks the section to look for changes made elsewhere now, as when the app returns. #[unsafe(no_mangle)] pub extern "C" fn sb_section_wake(section: &Section) { diff --git a/crates/mobile/src/tests.rs b/crates/mobile/src/tests.rs index 261fb38afe3b03b136c89f42d96df4e126093d15..9d562ec7e0227b9ccca29422c54ba0b480f091ae 100644 --- a/crates/mobile/src/tests.rs +++ b/crates/mobile/src/tests.rs @@ -697,7 +697,31 @@ fn tapping_the_date_asks_for_it_and_a_new_date_is_stored() { section.shared.apply(edit).unwrap(); let stored = section.shared.page(canvas.space).unwrap().0.date_text(); assert_eq!(stored.unwrap()[0], "Friday, January 2, 2026"); - assert_ne!(canvas.page.editor.date().unwrap().timestamp(), before); + let after = canvas.page.editor.date().unwrap().timestamp(); + assert_ne!(after, before); + // `sb_view_date_request` hands the host the date in its seconds. + assert_eq!(library::unix(after), 1_767_349_800); +} + +#[test] +fn pages_open_with_the_snap_and_default_font_options_set() { + // OneNote's defaults, so pages other tests open meanwhile see no change. + sb_set_snap_to_grid(true); + unsafe { sb_set_default_font(c"Calibri".as_ptr(), 11.0) }; + assert_eq!(*options(), (true, canvas::editor::DefaultFont::default())); + let (_directory, section) = features(); + let canvas = canvas(§ion, "Paragraph controls"); + assert!(canvas.page.snap_to_grid); + assert_eq!(canvas.page.editor.default_font.face, "Calibri"); +} + +#[test] +fn a_share_notebook_names_its_files_from_the_share_s_top() { + assert_eq!(library::on_share("", "Notes.one"), "Notes.one"); + assert_eq!( + library::on_share("Team/Book", "Group/Notes.one"), + "Team/Book/Group/Notes.one" + ); } /// A notebook copy with its library, and a section of it open for editing. diff --git a/crates/notebook/Cargo.toml b/crates/notebook/Cargo.toml index 851557dd3182bb5006faec812b122ceb012b636e..14589453f38a6197506a1921b47763638c8841cd 100644 --- a/crates/notebook/Cargo.toml +++ b/crates/notebook/Cargo.toml @@ -6,7 +6,6 @@ publish = false [features] smb = ["dep:smb2", "dep:tokio"] -protected = ["onestore/protected", "dep:zeroize"] [dependencies] onestore = { path = "../onestore" } @@ -18,7 +17,14 @@ tempfile = "3" sha2 = "0.11" smb2 = { version = "=0.21.0", optional = true } tokio = { version = "1", features = ["rt-multi-thread", "time"], optional = true } -zeroize = { version = "1.9.0", optional = true } +zeroize = "1.9.0" +# Queued edits of a password-protected section are sealed under a key derived from its own. +aes-gcm = "0.11.1" +base64 = "0.22.1" +getrandom = "0.4.3" +hmac = "0.13.0" +# OneNote packages (.onepkg) are cabinet files. +cab = "0.6" [target.'cfg(target_vendor = "apple")'.dependencies] nix = { version = "0.31", default-features = false, features = ["fs"] } diff --git a/crates/notebook/README.md b/crates/notebook/README.md index 5755beacc3e293d73af0f229b7e2df42e398063f..1ce771bbc5895ddccb5036a54215a8b49122d1b9 100644 --- a/crates/notebook/README.md +++ b/crates/notebook/README.md @@ -196,7 +196,9 @@ or worker panic. Dropping it requests cancellation without waiting. Remote operations and callbacks must have bounded execution times if shutdown latency matters. Credentials belong to the factory, not the cache database. -Creation refuses existing paths. Opening validates database integrity and replays +`create` refuses an existing path; `open_or_create(path, key, source)` opens the cache at +`path`, reading `source` only to make it where there is none, and opens one another thread +made meanwhile. Opening validates database integrity and replays the queue on its base. SQLite runs in WAL mode under exclusive locking with FULL synchronization and fullfsync, each queried back: every commit is durable, and the only file beside the cache is `-wal`, which holds committed pages until a checkpoint @@ -324,7 +326,9 @@ identities, titles, dates and creation times, as OneNote does (`corpus/notebook-management`); `unrecycle_pages` takes them out again by identity, as OneNote's Undo of a page delete does. A bin or deleted-pages file its TOC does not list is placed and listed again, a bin whose TOC OneNote named otherwise keeps it, and an -unavailable bin refuses the delete (`corpus/recycle-bin-repair`). An entry a TOC still +unavailable bin refuses the delete (`corpus/recycle-bin-repair`). `empty_recycle_bin` is OneNote's +Empty Recycle Bin: Deleted Pages loses every page in one revision and each binned section's +file goes, the bin's TOC left as it was (`corpus/recycle-bin-view`). An entry a TOC still lists for a file gone from its folder gives way to the file an edit gives its name. Edits keep entries in the order their ordering numbers give; a rename or colour keeps the numbers. Every created, renamed or moved file is placed with @@ -346,13 +350,34 @@ targets are `None`. replica, and `session::stored_pages(image)` builds its pages with each page's `LastModifiedTime`: the app's search indexes the sections it has not opened this way. -With the optional `protected` feature, `Notebook::unlock(path, password)` -reads a `Locked` section as `Unlocked { pages, .. }`, and -`Notebook::apply_unlocked(path, password, &mut unlocked, author, edit)` -applies page ops and stores them under the section's key, straight to the file: -nothing of a protected section is cached or queued, a section written since -`unlocked` was read refuses the edit, and a wrong password is -`Error::Protected(PasswordMismatch)`. +A password-protected section lists as `Locked`. `Notebook::unlock(path, password)` opens +its key (a wrong password is `Error::Protected(PasswordMismatch)`), and +`section_unlocked(path, &key, notify)` (`section_unlocked_with`, or +`Replica::open_or_create(path, Some(&key), source)` for a host that opens replicas itself) +opens it as any section opens: edits queue, publish, merge and become conflict pages as an ordinary section's do, +each revision sealed under the section's key. The replica keeps none of it in the clear: +its base and transactions are the file's own ciphertext, and its queued edits and payloads +are sealed with AES-256-GCM (a random nonce each, the row's kind as associated data) under +a key HMAC-SHA256 derives from the section's, payloads named by HMAC rather than SHA-256. +Only the author's name, payload names and revision identities stay readable, and the +queue opens only under the key (a recovery archive with `Recovery::open_unlocked`). +`stored_pages_unlocked(image, &key)` reads a protected section's pages for search. The +background never opens a locked section. + +`Notebook::set_password(path, key, password)` sets, changes or removes a password as +OneNote 2010 does: the section is written anew under new identities +(`onestore::protected::rekey`), next to the file as a dot file that `Storage::supersede` +puts in its place under the writers' coordination while the file's stamp holds (on a share as +OneNote's maintenance does it: the old file aside, the new one in, the old one deleted), and +its TOC entry takes the new identity (`TocEdit::Reidentify`). The old replica, a cache of the +old file, is deleted, so the section's session must be closed and its edits published first; +a section whose edits wait refuses. + +Another device learns of the password when it reads the notebook again (`open`, `refresh`): +a replica of the file the protected section superseded, found by the placement the rewritten +header keeps, is deleted when nothing waits in it. Edits that wait stay until `unlock` on that +device queues them under the key, each page they changed coming back as a copy, as a page +another client removed does. A session open meanwhile stops as `SyncState::Protected`. `Section::import_page(page, author)` copies a page, usually read from another section, to the end of this one as one `SectionOp::Import` queued like the @@ -369,12 +394,27 @@ is `Reordered`. A failed read returns the error and keeps the previous catalog, so an unreachable share never reads as an emptied notebook. +## Packages + +`package` reads and writes OneNote packages (`.onepkg`), cabinet files of a notebook's tables +of contents and sections at their paths in its folder. `notebook_files(notebook, image)` gathers +what OneNote 2010's Save As packs (each folder's TOC and sections, groups after, the recycle bin +and copies left out, its TOC entry kept), taking a section the caller holds edits for from +`image` (a replica's `snapshot`); `pack` writes them MSZIP-compressed, each file outside any +notebook (no `guidAncestor` or `crcName`), as OneNote's are. `read` takes MSZIP or OneNote's LZX, +refusing a path leaving the folder or more than a byte limit unpacked, and `unpack(files, root)` +writes a new folder as Unpack Notebook does: every file takes an identity of its own +(`onestore::reidentify`) and is placed under its folder's TOC, whose entries follow it where +OneNote lists each anew beside the stale one. `section_copy` and `page_section` are Save As's +section and page: a copy under a new identity, and a new section holding the page with its +identity, both outside any notebook (`corpus/notebook-package`). + ## Snowbound's folder `sidecar` keeps Snowbound-only data in the notebook's `.snowbound` folder, which OneNote 2010 skips because it has the Windows hidden attribute: `Storage::hide` sets it -through `smb::Client::hide` (SET_INFO FileBasicInformation, always, since Samba reports a -dot name hidden without storing it), `SetFileAttributesW` on Windows, or macOS's +on a share with SET_INFO FileBasicInformation (always, since Samba reports a dot name hidden +without storing it), `SetFileAttributesW` on Windows, or macOS's `UF_HIDDEN`, which an smbfs mount passes to the share; Linux keeps no such attribute. Discovery skips dot names, and a notebook without the folder is whole. @@ -475,8 +515,7 @@ probing the connection, so an idle watch sends nothing. `changed` hears each bat paths relative to the directory, `""` when the server lost count, and an error when the watch ends with its connection. -`Client::read_asset(path, byte_limit)` reads an external payload under a read-only -share handle that excludes writes and deletion. Empty files succeed; limits, +An external payload on a share (`discover::Smb`) is read under a read-only share handle that excludes writes and deletion. Empty files succeed; limits, interrupted reads and failed close never return partial bytes. This payload read does not parse a OneStore header or acquire its reader-coordination bytes. diff --git a/crates/notebook/examples/document.rs b/crates/notebook/examples/document.rs index d1a68175db8f8b35932e59946be4ff683f581a7b..c0e82f8c2e305a16cc91b89c0f9c898c50e6e74c 100644 --- a/crates/notebook/examples/document.rs +++ b/crates/notebook/examples/document.rs @@ -39,7 +39,6 @@ fn main() -> Result<(), Box> { let bytes = onestore::read_file(&source_path)?; let store = Store::parse(&bytes)?; let index = RevisionIndex::parse(&store)?; - #[cfg(feature = "protected")] let unlocked = if let Some(path) = &password_file { use std::io::Read; let mut password = zeroize::Zeroizing::new(String::new()); @@ -57,20 +56,10 @@ fn main() -> Result<(), Box> { } else { None }; - #[cfg(not(feature = "protected"))] - if password_file.is_some() { - return Err( - "Build this exporter with the protected feature to open a password-protected section." - .into(), - ); - } - #[cfg(feature = "protected")] let document = match &unlocked { Some(section) => section.document()?, None => Document::parse(&index)?, }; - #[cfg(not(feature = "protected"))] - let document = Document::parse(&index)?; if let Some(destination) = destination { #[cfg(unix)] { diff --git a/crates/notebook/examples/keystroke_probe.rs b/crates/notebook/examples/keystroke_probe.rs index c45738831aa25a2046eb1bf7d1fb0df216e49473..892d062a00dad7064af7d07bfc5da93d3fe43114 100644 --- a/crates/notebook/examples/keystroke_probe.rs +++ b/crates/notebook/examples/keystroke_probe.rs @@ -135,6 +135,7 @@ fn main() -> Result<(), Box> { identity: None, created: None, margin_origin: [0.0; 2], + rtl: false, color: None, rule_lines: None, objects: vec![PageObject::Outline(Outline { diff --git a/crates/notebook/examples/open_probe.rs b/crates/notebook/examples/open_probe.rs index dd7db91cbe429a8f1a23e77dad6a72f7a91a3364..aa7c8c2fd10ca6e61a6eacbaa4549f57ccbccbc7 100644 --- a/crates/notebook/examples/open_probe.rs +++ b/crates/notebook/examples/open_probe.rs @@ -44,6 +44,7 @@ fn generated(count: usize) -> Result, Box> { identity: None, created: None, margin_origin: [0.0; 2], + rtl: false, color: None, rule_lines: None, objects: vec![PageObject::Outline(Outline { diff --git a/crates/notebook/examples/save_probe.rs b/crates/notebook/examples/save_probe.rs index bcd4563af8cab94d082f6a44a91cc76e0df08a24..44fa95f0e0e5e23d21370453b04c105f663bea1c 100644 --- a/crates/notebook/examples/save_probe.rs +++ b/crates/notebook/examples/save_probe.rs @@ -50,6 +50,7 @@ fn main() -> Result<(), Box> { identity: None, created: None, margin_origin: [0.0; 2], + rtl: false, color: None, rule_lines: None, objects: vec![PageObject::Outline(Outline { diff --git a/crates/notebook/src/background.rs b/crates/notebook/src/background.rs index 278860329fcffce16017a78c9012de30d4ef9b08..e90889ca0f485562e1e5413d8d100c373e3db2ee 100644 --- a/crates/notebook/src/background.rs +++ b/crates/notebook/src/background.rs @@ -30,8 +30,6 @@ const STAGGER: Duration = Duration::from_millis(100); /// How long a reported change settles before its section is checked, so the writes of one /// commit cost one check. const SETTLE: Duration = Duration::from_secs(1); -/// The most entries a folder listing takes. -const ENTRIES: usize = 100_000; /// Keeps each watched section in sync: a section is checked when a watch on the notebook's /// folder reports it changed, when it could not be reached, and otherwise every interval. @@ -66,6 +64,7 @@ pub(crate) struct Shared { } /// A connection's report of its notebook folder's changes, from a watch armed on connecting. +#[cfg_attr(not(feature = "smb"), allow(dead_code))] pub(crate) struct Reports { shared: Weak, connection: u64, @@ -338,7 +337,8 @@ impl Background { let root = root.clone(); let list = move |folder: &str| { use crate::discover::Source; - crate::discover::Smb::new(&client, &root)?.entries(folder, ENTRIES) + crate::discover::Smb::new(&client, &root)? + .entries(folder, crate::session::LIMITS.entries) }; Ok(((bind, list), reported)) }, @@ -361,6 +361,7 @@ impl Background { pub fn hold(&self, path: &str, section: &Section) { let replica = section.replica(); let Some(worker) = replica + .section .worker .lock() .ok() @@ -487,6 +488,7 @@ impl Shared { } } +#[cfg_attr(not(feature = "smb"), allow(dead_code))] impl Reports { /// The sections at or below these paths changed. pub(crate) fn touched(&self, paths: &[String]) { @@ -741,7 +743,10 @@ fn list( /// Deletes the replica at `replica` if it holds nothing unpublished and no one holds it. fn discard(replica: &Path) { - if matches!(crate::peek(replica), Ok((_, 0))) { + if matches!( + crate::closed(replica).and_then(|held| crate::peek(&held)), + Ok((_, 0)) + ) { for suffix in ["-wal", "-shm", ""] { let mut file = replica.as_os_str().to_owned(); file.push(suffix); @@ -837,7 +842,7 @@ fn step( if let Some(folder) = replica.parent() { std::fs::create_dir_all(folder)?; } - Replica::seed(replica, &image)?; + Replica::seed(replica, &image, None)?; Ok(Stamp::of(&image)?) })(); return match copied { @@ -854,7 +859,7 @@ fn step( Ok(versions) => versions, Err(error) => return (None, Err(Error::RemoteIo(error))), }; - match crate::peek(replica) { + match crate::closed(replica).and_then(|held| crate::peek(&held)) { Ok((base, 0)) if base == stamp && versions.is_empty() => { return (Some(0), Ok((stamp, moved))); } diff --git a/crates/notebook/src/base.rs b/crates/notebook/src/base.rs index 070c665191a3bdb64808dc633d43e1f349098934..d102659d50a4120ec8db6c7929a928c5f64ced7f 100644 --- a/crates/notebook/src/base.rs +++ b/crates/notebook/src/base.rs @@ -28,6 +28,20 @@ fn damaged() -> crate::Error { io::Error::new(io::ErrorKind::InvalidData, "Damaged cached image").into() } +/// The base image, which every cache holds. +pub(crate) fn base(connection: &Connection) -> Result> { + read(connection, Image::Base)?.ok_or_else(missing) +} + +/// The base image's stamp. +pub(crate) fn base_stamp(connection: &Connection) -> Result { + stamp(connection, Image::Base)?.ok_or_else(missing) +} + +fn missing() -> crate::Error { + io::Error::new(io::ErrorKind::InvalidData, "The cache has no base image").into() +} + /// The whole image, or `None` when none is stored. pub(crate) fn read(connection: &Connection, image: Image) -> Result>> { let mut query = connection.prepare_cached(&format!( diff --git a/crates/notebook/src/discover.rs b/crates/notebook/src/discover.rs index 35ff81ab5f8ec29198aa2760f1a9c0861be577f9..80b65461a241fc42e7cad78b564b7c5a3faffe67 100644 --- a/crates/notebook/src/discover.rs +++ b/crates/notebook/src/discover.rs @@ -50,10 +50,30 @@ pub struct Folder { pub toc: Option, pub sections: Vec
, pub groups: Vec, + /// The paths of `sections` and `groups` together, in the order the TOC lists them, those + /// it doesn't last by path. + pub order: Vec, /// Child section files and groups that could not be read. pub unavailable: Vec, } +impl Folder { + /// This folder and every group under it, in catalog order. + pub(crate) fn folders(&self) -> impl Iterator { + let mut stack = vec![self]; + std::iter::from_fn(move || { + let folder = stack.pop()?; + stack.extend(folder.groups.iter().rev()); + Some(folder) + }) + } + + /// The sections of this folder and every group under it, in catalog order. + pub(crate) fn sections(&self) -> impl Iterator { + self.folders().flat_map(|folder| &folder.sections) + } +} + /// A section file or group folder denied or gone while listing; each discovery tries it again. #[derive(Debug, Serialize)] pub struct Unavailable { @@ -346,6 +366,7 @@ fn scan( toc: None, sections: Vec::new(), groups: Vec::new(), + order: Vec::new(), unavailable: Vec::new(), }; for entry in &listing { @@ -551,26 +572,26 @@ fn scan( .flat_map(|toc| &toc.unresolved) .map(|entry| (entry.file, entry.order)) .collect(); - result.sections.sort_by(|a, b| { - (order.get(&a.file_id).copied().unwrap_or(u32::MAX), &a.path) - .cmp(&(order.get(&b.file_id).copied().unwrap_or(u32::MAX), &b.path)) - }); - result.groups.sort_by(|a, b| { - ( - a.toc - .as_ref() - .and_then(|toc| order.get(&toc.file_id).copied()) - .unwrap_or(u32::MAX), - &a.path, + let rank = |file: Option<[u8; 16]>| file.and_then(|file| order.get(&file).copied()); + let ranked = |file, path: &String| (rank(file).unwrap_or(u32::MAX), path.clone()); + result + .sections + .sort_by_cached_key(|section| ranked(Some(section.file_id), §ion.path)); + let group_file = |group: &Folder| group.toc.as_ref().map(|toc| toc.file_id); + result + .groups + .sort_by_cached_key(|group| ranked(group_file(group), &group.path)); + let mut entries: Vec<(u32, String)> = (result.sections.iter()) + .map(|section| ranked(Some(section.file_id), §ion.path)) + .chain( + result + .groups + .iter() + .map(|group| ranked(group_file(group), &group.path)), ) - .cmp(&( - b.toc - .as_ref() - .and_then(|toc| order.get(&toc.file_id).copied()) - .unwrap_or(u32::MAX), - &b.path, - )) - }); + .collect(); + entries.sort(); + result.order = entries.into_iter().map(|(_, path)| path).collect(); let listed = |file: [u8; 16], path: &str| { let name = path.rsplit('/').next().unwrap_or(path); result diff --git a/crates/notebook/src/discover/source.rs b/crates/notebook/src/discover/source.rs index ee287e3a00da7a8d7534dfbb4ac6e6bc68181ef7..37c912c0c39807f8a6742e5734df84c93024749b 100644 --- a/crates/notebook/src/discover/source.rs +++ b/crates/notebook/src/discover/source.rs @@ -194,6 +194,10 @@ impl Source for Smb<'_> { return None; } let stamp = self.client.stamp(&self.path(path).ok()?).ok()?; - crate::copied(&replica, &stamp).ok().flatten() + let connection = crate::closed(&replica).ok()?; + let base = crate::base::Image::Base; + (crate::base::stamp(&connection, base).ok()?? == stamp) + .then(|| crate::base::read(&connection, base).ok()?) + .flatten() } } diff --git a/crates/notebook/src/lib.rs b/crates/notebook/src/lib.rs index 57cd05807d7330971d171e21a64cd9eb1d67900f..5545185fe49a4c7a2b3f711e91e43ed80f6aa827 100644 --- a/crates/notebook/src/lib.rs +++ b/crates/notebook/src/lib.rs @@ -10,6 +10,7 @@ use onestore::{ ExGuid, op::{Edit, OpError}, page::Page, + protected::Key, }; use rusqlite::{Connection, OpenFlags, TransactionBehavior, params}; use std::{ @@ -26,6 +27,7 @@ mod base; pub mod location; mod merge; mod migrate; +pub mod package; mod queue; mod recovery; mod resolve; @@ -61,7 +63,7 @@ pub enum Error { Rejected(#[from] OpError), #[error("External payload identity now refers to different bytes")] AssetChanged, - #[cfg(feature = "protected")] + /// A password-protected section's password did not match, or its format is unknown. #[error(transparent)] Protected(#[from] onestore::protected::Error), } @@ -98,10 +100,8 @@ pub enum Resolution { /// Edits apply on the cache's section thread, which keeps the section parsed; SQLite's /// exclusive connection retains ownership between local transactions. pub struct Replica { - connection: Arc>, section: Arc, synchronization: Mutex<()>, - worker: Arc>>, /// The section's root object space, which names the document. root: ExGuid, /// Last, so that it runs once the fields above have closed the cache. @@ -124,30 +124,31 @@ impl Replica { /// Seeds a new cache from a validated section image, refusing any existing path. /// An initialization error preserves the created file for inspection. pub fn create(path: impl AsRef, source: &[u8]) -> Result { - Self::seed(path.as_ref(), source)?; - Self::start(cache_connection(path.as_ref())?) + Self::seed(path.as_ref(), source, None)?; + Self::start(cache_connection(path.as_ref())?, None) } - /// Opens the cache at `path`, first creating it from the image `source` reads where there - /// is none. A cache another thread creates meanwhile, as the background makes an offline - /// copy, is opened instead. + /// Opens the cache at `path`, a protected section's under `key`, first creating it from + /// the image `source` reads where there is none. A cache another thread creates meanwhile, + /// as the background makes an offline copy, is opened instead. pub fn open_or_create( path: impl AsRef, + key: Option<&Key>, source: impl FnOnce() -> Result>, ) -> Result { let path = path.as_ref(); if !path.exists() { - match Self::seed(path, &source()?) { + match Self::seed(path, &source()?, key) { Err(Error::Io(error)) if error.kind() == io::ErrorKind::AlreadyExists => {} seeded => seeded?, } } - Self::open(path) + Self::open_with(path, key.cloned()) } /// `create` without opening the cache it made, as for an offline copy. - pub(crate) fn seed(path: &Path, source: &[u8]) -> Result<()> { - validate(source)?; + pub(crate) fn seed(path: &Path, source: &[u8], key: Option<&Key>) -> Result<()> { + validate(source, key)?; // Built under a name of its own and linked into place whole, so that a cache that // exists is complete, and two threads making the same one never share a build. static BUILDS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); @@ -201,7 +202,10 @@ impl Replica { /// a conversion that cannot reproduce every queued page leaves it untouched. /// Unrecognized databases and unsupported journal modes are rejected. pub fn open(path: impl AsRef) -> Result { - let path = path.as_ref(); + Self::open_with(path.as_ref(), None) + } + + fn open_with(path: &Path, key: Option) -> Result { let mut connection = cache_connection(path)?; let application: u32 = connection.pragma_query_value(None, "application_id", |row| row.get(0))?; @@ -233,25 +237,21 @@ impl Replica { } } write_ahead(&connection)?; - Self::start(connection) + Self::start(connection, key) } - fn start(connection: Connection) -> Result { - let connection = Arc::new(Mutex::new(connection)); - let worker = Arc::new(Mutex::new(std::sync::Weak::new())); - let (section, root) = working::spawn(Arc::clone(&connection), Arc::clone(&worker))?; + fn start(connection: Connection, key: Option) -> Result { + let (section, root) = working::spawn(connection, key)?; Ok(Self { - connection, section, synchronization: Mutex::new(()), - worker, root, released: Released::default(), }) } fn lock(&self) -> Result> { - lock(&self.connection) + lock(&self.section.connection) } /// Hands `request` to the section thread. @@ -329,9 +329,9 @@ impl Replica { /// The section image the queued edits leave, the unsealed ones sealed as one more /// revision whose identities differ per call: O(section). - pub(crate) fn snapshot(&self) -> Result> { + pub fn snapshot(&self) -> Result> { self.written()?; - working::image(&*self.lock()?) + working::image(&*self.lock()?, self.section.key.as_ref()) } /// Waits until the edits applied before it are written to the queue, as reads answer @@ -342,18 +342,51 @@ impl Replica { /// The section file's identity, which internal links name as `section-id`. pub fn identity(&self) -> Result<[u8; 16]> { - let stamp = base::stamp(&*self.lock()?, base::Image::Base)? - .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "Damaged cached image"))?; + let stamp = base::base_stamp(&*self.lock()?)?; Ok(onestore::Header::parse(&stamp.header)?.file_id) } /// Queued edits, oldest first. pub fn pending(&self) -> Result> { - pending(&*self.lock()?) + pending(&*self.lock()?, self.section.key.as_ref()) + } + + /// The edits putting each page the queue changed, as the queue leaves it, into another + /// section as a copy under fresh identities, by the author of the first edit changing it; + /// content outside the page model stays behind. + pub(crate) fn copies(&self) -> Result> { + let arena = onestore::Arena::default(); + let base = base::base(&*self.lock()?)?; + let base = working::open(&arena, base, self.section.key.as_ref())?; + let mut seen = std::collections::BTreeSet::from([self.root]); + let mut copies = Vec::new(); + for queued in self.pending()? { + for space in queue::spaces(&queued.edit, self.root) { + if !seen.insert(space) { + continue; + } + let Ok(mut page) = self.page(space) else { + continue; + }; + if base.page(space).is_ok_and(|base| base == page) { + continue; + } + page.objects + .retain(|object| !matches!(object, onestore::page::PageObject::Unsupported(_))); + let creation = + onestore::PageCreation::new(None, Some(&page.title), &queued.author)?; + let ops = vec![onestore::op::Op::Section(onestore::op::SectionOp::Import { + creation, + page: page.copy()?, + })]; + copies.push((queued.author.clone(), Edit { at: now(), ops })); + } + } + Ok(copies) } fn wake_sync(&self) { - wake(&self.worker); + wake(&self.section.worker); } } @@ -386,10 +419,14 @@ fn wake(worker: &Mutex>) { } } -/// Fully validates a section image, returning its root object space. -fn validate(source: &[u8]) -> Result { +/// Fully validates a section image, a protected one under `key`, returning its root object +/// space. +fn validate(source: &[u8], key: Option<&Key>) -> Result { let arena = onestore::Arena::default(); - Ok(onestore::Section::open(&arena, source.to_vec())?.root()) + Ok(match key { + Some(key) => onestore::Section::unlock(&arena, source.to_vec(), key)?.root(), + None => onestore::Section::open(&arena, source.to_vec())?.root(), + }) } /// A closed cache, without opening the replica. @@ -405,25 +442,14 @@ fn closed(path: &Path) -> Result { } /// A closed cache's base stamp and how many edits wait. -fn peek(path: &Path) -> Result<(onestore::Stamp, u64)> { - let connection = closed(path)?; - let base = base::stamp(&connection, base::Image::Base)? - .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "The cache has no base image"))?; +fn peek(connection: &Connection) -> Result<(onestore::Stamp, u64)> { + let base = base::base_stamp(connection)?; let queued: i64 = connection.query_row("SELECT count(*) FROM edits", [], |row| row.get(0))?; Ok((base, unsigned(queued)?)) } -/// A closed cache's base image, if its stamp is `stamp`: the section file as it stands. -fn copied(path: &Path, stamp: &onestore::Stamp) -> Result>> { - let connection = closed(path)?; - if base::stamp(&connection, base::Image::Base)?.as_ref() != Some(stamp) { - return Ok(None); - } - base::read(&connection, base::Image::Base) -} - -fn pending(connection: &Connection) -> Result> { - queue::load(connection, None) +fn pending(connection: &Connection, key: Option<&Key>) -> Result> { + queue::load(connection, key, None) } fn unsigned(value: i64) -> Result { diff --git a/crates/notebook/src/location.rs b/crates/notebook/src/location.rs index 30317f9c476f58bcd5aed6fd6660d2558ad0ab25..7e3cc33e0113b0a7ba86f88f7ea1bdbf9731c788 100644 --- a/crates/notebook/src/location.rs +++ b/crates/notebook/src/location.rs @@ -110,7 +110,7 @@ pub(crate) fn claim( if !replica.exists() { continue; } - let adoptable = match crate::peek(&replica) { + let adoptable = match crate::closed(&replica).and_then(|held| crate::peek(&held)) { Ok((base, _)) => { legacy || stamp(identity).is_some_and(|file| follows(&file, &base)) } diff --git a/crates/notebook/src/migrate.rs b/crates/notebook/src/migrate.rs index 023a6dd8deeb773f3debd54d840cb5ca92465aa8..05106f6634369e02297f40bfe42f8cf351fd67e5 100644 --- a/crates/notebook/src/migrate.rs +++ b/crates/notebook/src/migrate.rs @@ -314,6 +314,7 @@ fn convert(transaction: &rusqlite::Transaction<'_>) -> Result<()> { } queue::insert( transaction, + None, Some(crate::unsigned(edit.id)?), current, &edit.author, diff --git a/crates/notebook/src/package.rs b/crates/notebook/src/package.rs new file mode 100644 index 0000000000000000000000000000000000000000..62c3f3b9c332e3d785e6b6ee3be644ddac871aac --- /dev/null +++ b/crates/notebook/src/package.rs @@ -0,0 +1,236 @@ +//! OneNote packages (`.onepkg`) and the other copies OneNote 2010's Save As writes. A +//! package is a cabinet holding a notebook's tables of contents and sections at their paths +//! in its folder, the recycle bin left out though its entry stays; OneNote writes each file +//! outside any notebook (no `guidAncestor` or `crcName`). Its Unpack Notebook gives each file +//! an identity of its own and lists it anew beside its stale entry; here the entry follows +//! the file instead (`corpus/notebook-package`). OneNote compresses with LZX; this writes +//! MSZIP, which every cabinet reader takes, and reads either. + +use crate::{ + Error, Result, + session::{Notebook, lists, moved}, +}; +use onestore::{TocEdit, op::Edit, page::Page}; +use std::{ + collections::BTreeMap, + io::{self, Cursor, Read, Write}, + path::Path, +}; + +/// A package's files: each one's path in the notebook's folder, `/`-separated, and bytes. +pub type Files = Vec<(String, Vec)>; + +fn invalid(message: &str) -> Error { + io::Error::new(io::ErrorKind::InvalidData, message.to_owned()).into() +} + +/// The files of `notebook` a package holds, as OneNote 2010 packs them: each folder's table +/// of contents and sections, then its groups', the recycle bin and copies left out. `image` +/// gives a section the caller holds edits for, by catalog path, as its replica leaves it. +pub fn notebook_files( + notebook: &Notebook, + mut image: impl FnMut(&str) -> Option>, +) -> Result { + let mut files = Vec::new(); + let mut folders = vec![notebook.catalog()]; + while let Some(folder) = folders.pop() { + let Some(toc) = &folder.toc else { + continue; + }; + files.push(( + join(&folder.path, &toc.filename), + notebook.read_toc(&folder.path)?, + )); + for section in folder.sections.iter().filter(|section| !section.copy) { + let bytes = match image(§ion.path) { + Some(bytes) => bytes, + None => notebook.read_section(§ion.path)?, + }; + files.push((section.path.clone(), bytes)); + } + folders.extend( + (folder.groups.iter().rev()) + .filter(|group| group.path.rsplit('/').next() != Some("OneNote_RecycleBin")), + ); + } + Ok(files) +} + +fn join(folder: &str, name: &str) -> String { + if folder.is_empty() { + name.to_owned() + } else { + format!("{folder}/{name}") + } +} + +/// A package holding `files`, each taken out of its notebook as OneNote writes them. +pub fn pack(files: Files) -> Result> { + let mut builder = cab::CabinetBuilder::new(); + let folder = builder.add_folder(cab::CompressionType::MsZip); + for (path, _) in &files { + folder.add_file(path.replace('/', "\\")); + } + let mut writer = builder.build(Cursor::new(Vec::new()))?; + let mut files = files.into_iter(); + while let Some(mut file) = writer.next_file()? { + let (_, mut bytes) = files.next().ok_or_else(|| invalid("A file went missing"))?; + onestore::place_image(&mut bytes, None)?; + file.write_all(&bytes)?; + } + Ok(writer.finish()?.into_inner()) +} + +/// The files the package `bytes` holds, refusing a path that leaves the notebook's folder +/// and more than `limit` bytes unpacked. +pub fn read(bytes: &[u8], limit: u64) -> Result { + let mut cabinet = cab::Cabinet::new(Cursor::new(bytes))?; + let mut listed = Vec::new(); + let mut total = 0u64; + for folder in cabinet.folder_entries() { + for file in folder.file_entries() { + total += u64::from(file.uncompressed_size()); + listed.push(file.name().to_owned()); + } + } + if total > limit { + return Err(io::Error::from(io::ErrorKind::FileTooLarge).into()); + } + let mut files = Vec::new(); + for name in listed { + let path = name.replace('\\', "/"); + if !path.split('/').all(component) { + return Err(invalid("The package names a file outside its notebook")); + } + let mut bytes = Vec::new(); + cabinet.read_file(&name)?.read_to_end(&mut bytes)?; + files.push((path, bytes)); + } + Ok(files) +} + +fn component(name: &str) -> bool { + !name.is_empty() && !name.contains([':', '\0']) && name != "." && name != ".." +} + +/// Writes package `files` to the new folder `root` as OneNote 2010's Unpack Notebook does: +/// each table of contents and section takes a new identity, placed under its folder's table +/// of contents, whose entries follow. +pub fn unpack(mut files: Files, root: &Path) -> Result<()> { + let folder = |path: &str| { + path.rsplit_once('/') + .map_or("", |(folder, _)| folder) + .to_owned() + }; + let toc = |path: &str| path.to_ascii_lowercase().ends_with(".onetoc2"); + let mut tocs: BTreeMap = BTreeMap::new(); + for (at, (path, _)) in files.iter().enumerate() { + if toc(path) && tocs.insert(folder(path), at).is_some() { + return Err(invalid( + "A folder of the package holds two tables of contents", + )); + } + } + // Parents first: a group's table of contents is placed under its parent's new identity. + let mut identities: BTreeMap = BTreeMap::new(); + let mut ordered: Vec<(&String, &usize)> = tocs.iter().collect(); + ordered.sort_by_key(|(folder, _)| folder.split('/').count() * usize::from(!folder.is_empty())); + for (path, at) in ordered { + let image = &mut files[*at].1; + let old = onestore::Store::parse(image)?.header.file_id; + let new = onestore::reidentify(image)?; + let parent = (!path.is_empty()).then(|| folder(path)); + let placed = match &parent { + Some(parent) => { + let (_, ancestor) = identities + .get(parent) + .ok_or_else(|| invalid("A section group's folder has no parent"))?; + Some((*ancestor, path.rsplit('/').next().unwrap_or_default())) + } + None => None, + }; + onestore::place_image(image, placed)?; + identities.insert(path.clone(), (old, new)); + } + // The entries each table of contents lists, by their files' old identities. + let mut followed: BTreeMap> = BTreeMap::new(); + for (path, image) in &mut files { + if toc(path) { + continue; + } + let home = folder(path); + let Some((_, ancestor)) = identities.get(&home) else { + return Err(invalid("A section's folder has no table of contents")); + }; + let old = onestore::Store::parse(image)?.header.file_id; + let new = onestore::reidentify(image)?; + onestore::place_image( + image, + Some((*ancestor, path.rsplit('/').next().unwrap_or_default())), + )?; + (followed.entry(home).or_default()).push(TocEdit::Reidentify { + identity: old, + with: new, + }); + } + for (path, (old, new)) in &identities { + if !path.is_empty() { + let edit = TocEdit::Reidentify { + identity: *old, + with: *new, + }; + followed.entry(folder(path)).or_default().push(edit); + } + } + for (home, edits) in followed { + let image = &mut files[tocs[&home]].1; + let mut listed = Vec::new(); + for edit in edits { + if let TocEdit::Reidentify { identity, .. } = &edit + && lists(image, *identity)? + { + listed.push(edit); + } + } + if let Some(transaction) = onestore::edit_table_of_contents(image, &listed)? { + transaction.apply(image)?; + } + } + std::fs::create_dir(root)?; + for (path, bytes) in &files { + let file = root.join(path); + if let Some(parent) = file.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::File::create_new(file)?.write_all(bytes)?; + } + Ok(()) +} + +/// The section `image` holds as Save As writes a copy of it: an identity of its own, outside +/// any notebook. +pub fn section_copy(mut image: Vec) -> Result> { + onestore::reidentify(&mut image)?; + onestore::place_image(&mut image, None)?; + Ok(image) +} + +/// A new section, named `name` and coloured `color` (COLORREF), holding `page` as Save As +/// writes a page: the page keeps its identity, title, date and creation time. +pub fn page_section(page: &Page, name: &str, color: Option, author: &str) -> Result> { + let mut image = onestore::create_empty_section(name, color)?; + let arena = onestore::Arena::default(); + let mut section = onestore::Section::open(&arena, image.clone())?; + section.apply( + author, + &Edit { + at: crate::now(), + ops: vec![moved(page, author)?], + }, + )?; + if let Some(transaction) = section.seal()? { + transaction.apply(&mut image)?; + } + onestore::place_image(&mut image, None)?; + Ok(image) +} diff --git a/crates/notebook/src/queue.rs b/crates/notebook/src/queue.rs index 6a3772541091f64fde08a160170b3f0fb844103c..1314ce7987bddadca3f0207fa038dd80e437bc92 100644 --- a/crates/notebook/src/queue.rs +++ b/crates/notebook/src/queue.rs @@ -1,14 +1,23 @@ //! Edit rows. An edit is stored as its serialized ops with picture and attachment bytes //! moved to the `payloads` table, named by SHA-256 in the order `slots` visits them. +//! +//! A password-protected section's queue keeps none of it in the clear: each edit and +//! payload is sealed with AES-256-GCM (a random nonce, the row's kind as associated data) +//! and payloads are named by HMAC-SHA256, both under keys HMAC-SHA256 derives from the +//! section's own key. Only the author's name and the payload names stay readable. use crate::{PendingEdit, Result, signed, unsigned}; +use aes_gcm::{Aes256Gcm, KeyInit, aead::Aead}; +use hmac::{Hmac, Mac}; use onestore::{ op::{Edit, Op, PageOp, SectionOp, TableEdit}, page::{PageObject, PageParagraph, ParagraphContent, TableCell}, + protected::Key, }; use rusqlite::{Connection, OptionalExtension, params}; use sha2::{Digest, Sha256}; use std::{io, sync::Arc}; +use zeroize::Zeroizing; type Payload = Option>; @@ -92,16 +101,79 @@ fn damaged(message: &'static str) -> crate::Error { io::Error::new(io::ErrorKind::InvalidData, message).into() } -/// Stores `edit` in `batch` under `id`, or the next id, its payloads once each by hash; +/// A key `key` derives for `purpose`. +fn derived(key: &Key, purpose: &[u8]) -> Zeroizing<[u8; 32]> { + let mut mac = + as hmac::KeyInit>::new_from_slice(key.secret()).expect("any key length"); + mac.update(purpose); + Zeroizing::new(mac.finalize().into_bytes().into()) +} + +fn cipher(key: &Key) -> Aes256Gcm { + Aes256Gcm::new_from_slice(&*derived(key, b"Snowbound queue v1")).expect("a 32-byte key") +} + +/// A payload's name: its SHA-256, or in a protected section's queue an HMAC. +fn name(key: Option<&Key>, bytes: &[u8]) -> String { + match key { + Some(key) => { + let mut mac = as hmac::KeyInit>::new_from_slice(&*derived( + key, + b"Snowbound payload names v1", + )) + .expect("any key length"); + mac.update(bytes); + hex(&mac.finalize().into_bytes()) + } + None => hex(&Sha256::digest(bytes)), + } +} + +/// `clear` sealed under `key` as `kind`: a random nonce, then the ciphertext and tag. +fn seal(key: &Key, kind: &[u8], clear: &[u8]) -> Result> { + let mut nonce = [0; 12]; + getrandom::fill(&mut nonce).map_err(|_| io::Error::other("System random source failed"))?; + let sealed = cipher(key) + .encrypt( + &nonce.into(), + aes_gcm::aead::Payload { + msg: clear, + aad: kind, + }, + ) + .map_err(|_| io::Error::other("A queued edit could not be sealed"))?; + Ok([&nonce[..], &sealed].concat()) +} + +/// The inverse of `seal`. +fn open(key: &Key, kind: &[u8], sealed: &[u8]) -> Result>> { + let (nonce, sealed) = sealed + .split_first_chunk::<12>() + .ok_or_else(|| damaged("A sealed queue row is truncated"))?; + Ok(Zeroizing::new( + cipher(key) + .decrypt( + &(*nonce).into(), + aes_gcm::aead::Payload { + msg: sealed, + aad: kind, + }, + ) + .map_err(|_| damaged("A sealed queue row does not open under the section's key"))?, + )) +} + +/// Stores `edit` in `batch` under `id`, or the next id, its payloads once each by name; /// returns the edit's id. pub(crate) fn insert( connection: &Connection, + key: Option<&Key>, id: Option, batch: i64, author: &str, edit: &Edit, ) -> Result { - let (text, names) = encode(connection, edit)?; + let (text, names) = encode(connection, key, edit)?; connection.execute( "INSERT INTO edits(id, batch, author, edit, payloads) VALUES (?1, ?2, ?3, ?4, ?5)", params![id.map(signed).transpose()?, batch, author, text, names], @@ -114,8 +186,13 @@ fn hex(digest: &[u8]) -> String { } /// Replaces a stored edit's ops. -pub(crate) fn rewrite(connection: &Connection, id: u64, edit: &Edit) -> Result<()> { - let (text, names) = encode(connection, edit)?; +pub(crate) fn rewrite( + connection: &Connection, + key: Option<&Key>, + id: u64, + edit: &Edit, +) -> Result<()> { + let (text, names) = encode(connection, key, edit)?; connection.execute( "UPDATE edits SET edit=?1, payloads=?2 WHERE id=?3", params![text, names, signed(id)?], @@ -123,26 +200,38 @@ pub(crate) fn rewrite(connection: &Connection, id: u64, edit: &Edit) -> Result<( Ok(()) } -/// The serialized edit without payload bytes and the payload names, storing the payloads. -fn encode(connection: &Connection, edit: &Edit) -> Result<(String, Option)> { +/// The serialized edit without payload bytes and the payload names, storing the payloads; +/// sealed under `key` for a protected section. +fn encode( + connection: &Connection, + key: Option<&Key>, + edit: &Edit, +) -> Result<(String, Option)> { let mut edit = edit.clone(); let mut names: Vec> = Vec::new(); let mut failure = None; slots(&mut edit, |payload| { let name = payload.take().map(|bytes| { - let digest = Sha256::digest(&bytes); - if let Err(error) = connection.execute( - "INSERT INTO payloads(sha256, bytes) VALUES (?1, ?2) ON CONFLICT DO NOTHING", - params![&digest[..], &bytes[..]], - ) { + let name = name(key, &bytes); + let stored = match key { + Some(key) => seal(key, b"payload", &bytes), + None => Ok(bytes.to_vec()), + }; + let stored = stored.and_then(|stored| { + Ok(connection.execute( + "INSERT INTO payloads(sha256, bytes) VALUES (unhex(?1), ?2) ON CONFLICT DO NOTHING", + params![&name, &stored[..]], + )?) + }); + if let Err(error) = stored { failure.get_or_insert(error); } - hex(&digest) + name }); names.push(name); }); if let Some(error) = failure { - return Err(error.into()); + return Err(error); } let names = names .iter() @@ -150,15 +239,39 @@ fn encode(connection: &Connection, edit: &Edit) -> Result<(String, Option { + use base64::Engine; + base64::engine::general_purpose::STANDARD.encode(seal(key, b"edit", text.as_bytes())?) + } + None => text.to_string(), + }; + Ok((text, names)) } -fn decode(connection: &Connection, text: &str, names: Option) -> Result { - let mut edit: Edit = serde_json::from_str(text) - .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; +/// A stored edit's ops, without its payloads. +pub(crate) fn parse(key: Option<&Key>, text: &str) -> Result { + Ok(match key { + Some(key) => { + use base64::Engine; + let sealed = base64::engine::general_purpose::STANDARD + .decode(text) + .map_err(|_| damaged("A sealed queued edit is damaged"))?; + serde_json::from_slice(&open(key, b"edit", &sealed)?) + } + None => serde_json::from_str(text), + } + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?) +} + +fn decode( + connection: &Connection, + key: Option<&Key>, + text: &str, + names: Option, +) -> Result { + let mut edit = parse(key, text)?; let Some(names) = names else { return Ok(edit); }; @@ -176,18 +289,24 @@ fn decode(connection: &Connection, text: &str, names: Option) -> Result< let Some(name) = name else { return; }; - match query + let bytes = query .query_row([&name], |row| row.get::<_, Vec>(0)) - .optional() - { - Ok(Some(bytes)) if hex(&Sha256::digest(&bytes)) == name => { + .optional(); + let bytes = match (key, bytes) { + (Some(key), Ok(Some(sealed))) => { + open(key, b"payload", &sealed).map(|bytes| Some(bytes.to_vec())) + } + (_, bytes) => bytes.map_err(Into::into), + }; + match bytes { + Ok(Some(bytes)) if self::name(key, &bytes) == name => { *payload = Some(Arc::from(bytes)); } Ok(_) => { failure.get_or_insert(damaged("A queued payload is missing or damaged")); } Err(error) => { - failure.get_or_insert(error.into()); + failure.get_or_insert(error); } } }); @@ -201,7 +320,11 @@ fn decode(connection: &Connection, text: &str, names: Option) -> Result< } /// Queued edits, oldest first; of one batch when `batch` is given. -pub(crate) fn load(connection: &Connection, batch: Option) -> Result> { +pub(crate) fn load( + connection: &Connection, + key: Option<&Key>, + batch: Option, +) -> Result> { let mut query = connection.prepare_cached( "SELECT id, author, edit, payloads FROM edits WHERE ?1 IS NULL OR batch=?1 ORDER BY id", )?; @@ -211,7 +334,7 @@ pub(crate) fn load(connection: &Connection, batch: Option) -> Result(2)?, row.get(3)?)?, + edit: decode(connection, key, &row.get::<_, String>(2)?, row.get(3)?)?, }); } Ok(edits) @@ -304,7 +427,7 @@ mod tests { }) .collect(), }; - let id = insert(&connection, None, 1, "Author", &edit).unwrap(); + let id = insert(&connection, None, None, 1, "Author", &edit).unwrap(); let stored: String = connection .query_row("SELECT edit FROM edits WHERE id=?1", [id as i64], |row| { row.get(0) @@ -315,7 +438,7 @@ mod tests { .query_row("SELECT count(*) FROM payloads", [], |row| row.get(0)) .unwrap(); assert_eq!(payloads, 1); - let loaded = load(&connection, None).unwrap(); + let loaded = load(&connection, None, None).unwrap(); assert_eq!(loaded[0].edit, edit); let Op::Page { op: diff --git a/crates/notebook/src/recovery.rs b/crates/notebook/src/recovery.rs index d4a660e51ca851869de8c66355b2aa22b592a1e4..1a7dd86fed59a510204658d1ae1b6b4d6cebf228 100644 --- a/crates/notebook/src/recovery.rs +++ b/crates/notebook/src/recovery.rs @@ -21,11 +21,22 @@ pub struct RecoverySummary { /// Read-only recovery evidence; it cannot publish or acknowledge an edit. pub struct Recovery { connection: Connection, + /// A password-protected section's key, which opens its sealed queue. + key: Option, } impl Recovery { /// Opens an exported archive without migration or conversion into a writable replica. pub fn open(path: impl AsRef) -> Result { + Self::open_with(path.as_ref(), None) + } + + /// `open` for an archive of a password-protected section, under its `key`. + pub fn open_unlocked(path: impl AsRef, key: &Key) -> Result { + Self::open_with(path.as_ref(), Some(key.clone())) + } + + fn open_with(path: &Path, key: Option) -> Result { let connection = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY)?; connection.busy_timeout(Duration::ZERO)?; connection.execute_batch("BEGIN")?; @@ -48,10 +59,10 @@ impl Recovery { ) .into()); } - let recovery = Self { connection }; + let recovery = Self { connection, key }; recovery.snapshot()?; - for edit in pending(&recovery.connection)? { - sync::status(&recovery.connection, edit.id)?; + for edit in recovery.pending()? { + recovery.status(edit.id)?; } receipts(&recovery.connection)?; Ok(recovery) @@ -63,31 +74,23 @@ impl Recovery { /// The image the archived queue leaves, as `Replica::snapshot` gives it. pub fn snapshot(&self) -> Result> { - working::image(&self.connection) - } - - fn base(&self) -> Result> { - Ok( - base::read(&self.connection, base::Image::Base)?.ok_or_else(|| { - io::Error::new(io::ErrorKind::InvalidData, "The archive has no base image") - })?, - ) + working::image(&self.connection, self.key.as_ref()) } /// The last observed remote image. pub fn remote_snapshot(&self) -> Result> { match base::read(&self.connection, base::Image::Remote)? { Some(image) => Ok(image), - None => self.base(), + None => base::base(&self.connection), } } pub fn pending(&self) -> Result> { - pending(&self.connection) + pending(&self.connection, self.key.as_ref()) } pub fn status(&self, id: u64) -> Result> { - sync::status(&self.connection, id) + sync::status(&self.connection, id, self.key.as_ref()) } pub fn receipts(&self) -> Result> { diff --git a/crates/notebook/src/session.rs b/crates/notebook/src/session.rs index 0a0cff293d77544aeaaf4ee8a1fd460f6f11083b..4cbc83aeb2754d5185aeae814144aab200182590 100644 --- a/crates/notebook/src/session.rs +++ b/crates/notebook/src/session.rs @@ -10,9 +10,10 @@ use onestore::{ document::Document, op::{Edit, Op, SectionOp}, page::Page, + protected, }; use std::{ - collections::BTreeMap, + collections::{BTreeMap, BTreeSet}, io, io::Write, path::{Path, PathBuf}, @@ -99,14 +100,9 @@ pub trait Storage: Send + Sync { fn place(&self, path: &str, ancestor: [u8; 16], name: &str) -> Result<()>; /// Publishes a transaction made on the file's current image. fn commit(&self, path: &str, transaction: &Transaction) -> Result<()>; -} - -/// A password-protected section as `Notebook::unlock` read it. -#[cfg(feature = "protected")] -pub struct Unlocked { - pub pages: Vec<(ExGuid, Page)>, - /// The stored section the pages came from, which an edit must still find in place. - snapshot: Vec, + /// Puts the file `with` in the place of the section or TOC at `path` under the coordination + /// its writers take, provided `path` still has `base`'s stamp. + fn supersede(&self, path: &str, base: &Stamp, with: &str) -> Result<()>; } /// A mounted notebook directory. @@ -235,6 +231,14 @@ impl Storage for Directory { fn commit(&self, path: &str, transaction: &Transaction) -> Result<()> { Ok(transaction.commit_file(self.path(path))?) } + + fn supersede(&self, path: &str, base: &Stamp, with: &str) -> Result<()> { + Ok(onestore::supersede_file( + self.path(path), + base, + self.path(with), + )?) + } } /// A notebook directory on an SMB share, reached through the native-compatible client. @@ -275,7 +279,7 @@ impl Storage for Share { fn exists(&self, path: &str) -> bool { let (folder, name) = split(path); self.client - .read_dir(&self.path(folder), 100_000) + .read_dir(&self.path(folder), LIMITS.entries) .is_ok_and(|entries| entries.iter().any(|entry| entry.name == name)) } @@ -322,9 +326,15 @@ impl Storage for Share { .client .commit_transaction(&self.path(path), transaction)?) } + + fn supersede(&self, path: &str, base: &Stamp, with: &str) -> Result<()> { + Ok(self + .client + .supersede(&self.path(path), base, &self.path(with))?) + } } -const LIMITS: discover::Limits = discover::Limits { +pub(crate) const LIMITS: discover::Limits = discover::Limits { entries: 100_000, bytes_per_file: 256 * 1024 * 1024, depth: 64, @@ -415,20 +425,16 @@ impl Notebook { let catalog = storage.discover(&mut read, LIMITS)?; let location = storage.location(); let mut claims: BTreeMap> = BTreeMap::new(); - let mut folders = vec![&catalog]; - while let Some(folder) = folders.pop() { - for section in &folder.sections { - let identity = match (&root, §ion.state) { - (Some(_), discover::SectionState::Readable { document, .. }) => *document, - (None, discover::SectionState::Readable { .. }) => section.file_id, - _ => continue, - }; - claims - .entry(replica_location(&location, section)) - .or_default() - .push((identity, §ion.path)); - } - folders.extend(&folder.groups); + for section in catalog.sections() { + let identity = match (&root, §ion.state) { + (Some(_), discover::SectionState::Readable { document, .. }) => *document, + (None, discover::SectionState::Readable { .. }) => section.file_id, + _ => continue, + }; + claims + .entry(replica_location(&location, section)) + .or_default() + .push((identity, §ion.path)); } for (at, sections) in &claims { let identities: Vec<[u8; 16]> = @@ -447,6 +453,7 @@ impl Notebook { listing, }; notebook.keep_listing(); + notebook.forget_superseded(); Ok(notebook) } @@ -545,30 +552,23 @@ impl Notebook { } } self.catalog = catalog; + self.forget_superseded(); Ok(changes) } fn folder(&self, path: &str) -> Result<&discover::Folder> { - let mut folders = vec![&self.catalog]; - while let Some(folder) = folders.pop() { - if folder.path == path { - return Ok(folder); - } - folders.extend(&folder.groups); - } - Err(io::Error::from(io::ErrorKind::NotFound).into()) + self.catalog + .folders() + .find(|folder| folder.path == path) + .ok_or_else(|| io::Error::from(io::ErrorKind::NotFound).into()) } /// A catalog section's path, refusing paths the catalog does not list. fn section_path(&self, path: &str) -> Result<&discover::Section> { - let mut folders = vec![&self.catalog]; - while let Some(folder) = folders.pop() { - if let Some(section) = folder.sections.iter().find(|section| section.path == path) { - return Ok(section); - } - folders.extend(&folder.groups); - } - Err(io::Error::from(io::ErrorKind::NotFound).into()) + self.catalog + .sections() + .find(|section| section.path == path) + .ok_or_else(|| io::Error::from(io::ErrorKind::NotFound).into()) } /// A folder's TOC path and file identity, creating the TOC when the folder has none @@ -1051,6 +1051,56 @@ impl Notebook { Ok(purged) } + /// Empties the recycle bin as OneNote 2010's Empty Recycle Bin does: every page of + /// Deleted Pages goes in one revision, and every binned section's file goes, its entry + /// left in the bin's TOC (`corpus/recycle-bin-view`). + pub fn empty_recycle_bin(&mut self) -> Result<()> { + let Ok(bin) = self.folder(RECYCLE_BIN) else { + return Ok(()); + }; + let sections: Vec = (bin.sections.iter()) + .filter(|section| !section.copy) + .map(|section| section.path.clone()) + .collect(); + for path in sections { + if !split(&path) + .1 + .eq_ignore_ascii_case("OneNote_DeletedPages.one") + { + self.storage.delete(&path)?; + continue; + } + let arena = onestore::Arena::default(); + let mut section = onestore::Section::open(&arena, self.storage.read(&path)?)?; + let pages: Vec = section + .pages()? + .into_iter() + .map(|(space, ..)| space) + .collect(); + if pages.is_empty() { + continue; + } + section.apply( + "", + &Edit { + at: crate::now(), + ops: vec![Op::Section(SectionOp::Delete(pages))], + }, + )?; + if let Some(transaction) = section.seal()? { + self.storage.commit(&path, &transaction)?; + } + } + self.refresh().map(drop) + } + + /// The table of contents of the folder at catalog path `folder` as its file stores it. + pub fn read_toc(&self, folder: &str) -> Result> { + let toc = self.folder(folder)?.toc.as_ref(); + let toc = toc.ok_or_else(|| io::Error::from(io::ErrorKind::NotFound))?; + self.storage.read(&catalog_path(folder, &toc.filename)) + } + /// Moves a section or section group into the folder at catalog path `folder`, last, as /// OneNote moves one dragged onto a group: the file or folder moves and each TOC's /// entry follows it. Returns its new catalog path. @@ -1133,14 +1183,11 @@ impl Notebook { let Some(link) = onestore::page::link::parse_internal_link(url) else { return Ok(None); }; - let mut sections = Vec::new(); - let mut folders = vec![&self.catalog]; - while let Some(folder) = folders.pop() { - sections.extend(folder.sections.iter().filter(|section| { - matches!(section.state, discover::SectionState::Readable { .. }) - })); - folders.extend(&folder.groups); - } + let mut sections: Vec<_> = self + .catalog + .sections() + .filter(|section| matches!(section.state, discover::SectionState::Readable { .. })) + .collect(); sections.sort_by_key(|section| section.file_id != link.section); let Some(page) = link.page else { return Ok(sections @@ -1168,56 +1215,172 @@ impl Notebook { self.storage.read(&self.section_path(path)?.path) } - /// The pages of a password-protected section: nothing is cached, and the decoded - /// buffers go when the pages have been built. - #[cfg(feature = "protected")] - pub fn unlock(&self, path: &str, password: &str) -> Result { - let path = self.section_path(path)?.path.clone(); - let snapshot = self.storage.read(&path)?; - let pages = { - let store = Store::parse(&snapshot)?; - let index = RevisionIndex::parse(&store)?; - let unlocked = onestore::protected::UnlockedSection::open( - &index, - password, - onestore::protected::Limits::default(), - )?; - let document = unlocked.document()?; - document - .pages()? - .into_iter() - .map(|(space, _)| Ok((space, Page::from_space(&document, space)?))) - .collect::>()? - }; - Ok(Unlocked { pages, snapshot }) + /// The key of the password-protected section at catalog `path`, opened with `password`. + /// Edits this device queued before the section was protected elsewhere, held since, are + /// queued again under the key: each page they changed comes back as a copy, as a page + /// another client removed does, the section's pages having taken new identities. + pub fn unlock(&self, path: &str, password: &str) -> Result { + let section = self.section_path(path)?; + let image = self.storage.read(§ion.path)?; + let key = protected::Key::open(&image, password)?; + let held: Vec = self + .superseded(&[section]) + .into_iter() + .filter(|(.., queued)| *queued > 0) + .map(|(replica, ..)| replica) + .collect(); + if !held.is_empty() { + let replica = + Replica::open_or_create(self.replica_path(path)?, Some(&key), || Ok(image))?; + for path in held { + // A queue sealed under an earlier password waits for that password. + let Ok(old) = Replica::open(&path) else { + continue; + }; + for (author, edit) in old.copies()? { + replica.apply(&author, edit)?; + } + drop(old); + remove_replica(&path)?; + } + } + Ok(key) } - /// Applies `edit` to pages of `unlocked` and stores it under the section's key, straight - /// to the file: nothing of a protected section is cached or queued. A section written - /// since `unlocked` was read refuses the edit; unlock again for its pages. - #[cfg(feature = "protected")] - pub fn apply_unlocked( + /// The replicas of the files the protected `locked` sections superseded when their + /// passwords were set (`onestore::protected::rekey` keeps the placement a file's header + /// names), each held so that no one opens it meanwhile, with how many edits it queues. Only + /// replicas no readable section names are read; one in use is left. + fn superseded( &self, + locked: &[&discover::Section], + ) -> Vec<(PathBuf, rusqlite::Connection, u64)> { + let named: BTreeSet = self + .catalog + .sections() + .filter(|section| matches!(section.state, discover::SectionState::Readable { .. })) + .filter_map(|section| self.replica_path(§ion.path).ok()) + .collect(); + let placed: Vec<_> = locked + .iter() + .filter_map(|section| { + let (_, stamp) = self.read.found(§ion.path)?; + Some((section.file_id, stamp.header, self.replica_folder(section))) + }) + .collect(); + let folders: BTreeSet<&PathBuf> = placed.iter().map(|(.., folder)| folder).collect(); + let mut superseded = Vec::new(); + for folder in folders { + let Ok(entries) = std::fs::read_dir(folder) else { + continue; + }; + for path in entries.filter_map(|entry| Some(entry.ok()?.path())) { + if path + .extension() + .is_none_or(|extension| extension != "sqlite") + || named.contains(&path) + { + continue; + } + let Ok(held) = crate::closed(&path) else { + continue; + }; + let Ok((base, queued)) = crate::peek(&held) else { + continue; + }; + let Ok(header) = onestore::Header::parse(&base.header) else { + continue; + }; + if placed.iter().any(|(file, stamp, at)| { + at == folder + && base.header[128..148] == stamp[128..148] + && header.file_id != *file + }) { + superseded.push((path, held, queued)); + } + } + } + superseded + } + + /// Deletes the replicas holding nothing unpublished of files a protected section + /// superseded: their plaintext is the section's before its password. + fn forget_superseded(&self) { + let locked: Vec<_> = self + .catalog + .sections() + .filter(|section| matches!(section.state, discover::SectionState::Locked)) + .collect(); + if locked.is_empty() { + return; + } + for (replica, held, queued) in self.superseded(&locked) { + drop(held); + if queued == 0 { + let _ = remove_replica(&replica); + } + } + } + + /// Sets, changes or removes the password of the section at catalog `path`, as OneNote + /// 2010 does: the section is written anew under new identities (`onestore::protected:: + /// rekey`), the file replaces the old one and its folder's TOC follows it. `key` opens + /// it as it stands; `password` protects it anew, or `None` leaves it unprotected. Its + /// replica, a cache of the old file, goes too, so its session must be closed and its + /// edits published first. Returns the new key. + pub fn set_password( + &mut self, path: &str, - password: &str, - unlocked: &mut Unlocked, - author: &str, - edit: &Edit, - ) -> Result<()> { - let path = self.section_path(path)?.path.clone(); - let transaction = { - let store = Store::parse(&unlocked.snapshot)?; - let index = RevisionIndex::parse(&store)?; - onestore::protected::UnlockedSection::open( - &index, - password, - onestore::protected::Limits::default(), - )? - .apply(author, edit)? - }; - self.storage.commit(&path, &transaction)?; - transaction.apply(&mut unlocked.snapshot)?; - Ok(()) + key: Option<&protected::Key>, + password: Option<&str>, + ) -> Result> { + let section = self.section_path(path)?; + let replica = self.replica_path(path)?; + // Held until the file is replaced, so that no session queues edits to the old file. + let held = replica + .exists() + .then(|| crate::closed(&replica)) + .transpose()?; + if let Some(held) = &held + && crate::peek(held)?.1 > 0 + { + return Err(io::Error::new( + io::ErrorKind::WouldBlock, + "Edits to the section wait to be published", + ) + .into()); + } + let source = self.storage.read(§ion.path)?; + let new = password.map(protected::Key::new).transpose()?; + let image = onestore::protected::rekey(&source, key, new.as_ref())?; + let (folder, filename) = split(§ion.path); + // A dot file, which discovery and OneNote pass over, until it replaces the section. + let written = catalog_path(folder, &format!(".{filename}.snowbound")); + if self.storage.exists(&written) { + self.storage.delete(&written)?; + } + self.storage.create(&written, &image)?; + if let Err(error) = self + .storage + .supersede(§ion.path, &Stamp::of(&source)?, &written) + { + let _ = self.storage.delete(&written); + return Err(error); + } + drop(held); + remove_replica(&replica)?; + let parent = self.folder(folder)?; + if let Some(identity) = Entry::of(parent, section).identity { + self.edit_toc( + folder, + &[onestore::TocEdit::Reidentify { + identity, + with: Store::parse(&image)?.header.file_id, + }], + )?; + } + self.refresh()?; + Ok(new) } /// Where the replica of the section at catalog `path` lives, in the notebook's @@ -1250,22 +1413,16 @@ impl Notebook { /// discovery read so that each is read once. pub fn replicas(&mut self) -> Vec { let mut images = self.read.take(); - let mut sections = Vec::new(); - let mut folders = vec![&self.catalog]; - while let Some(folder) = folders.pop() { - for section in &folder.sections { - if matches!(section.state, discover::SectionState::Readable { .. }) { - sections.push(Known { - path: section.path.clone(), - replica: self.replica_path(§ion.path).ok(), - found: self.read.found(§ion.path), - image: images.remove(§ion.path), - }); - } - } - folders.extend(folder.groups.iter().rev()); - } - sections + self.catalog + .sections() + .filter(|section| matches!(section.state, discover::SectionState::Readable { .. })) + .map(|section| Known { + path: section.path.clone(), + replica: self.replica_path(§ion.path).ok(), + found: self.read.found(§ion.path), + image: images.remove(§ion.path), + }) + .collect() } /// Keeps the sections of a mounted notebook in sync while they are not open @@ -1326,6 +1483,37 @@ impl Notebook { path: &str, connect: impl FnMut(&Path) -> io::Result + Send + 'static, notify: impl Fn() + Send + 'static, + ) -> Result
{ + self.open_section(path, None, connect, notify) + } + + /// `section` for a password-protected section, under the `key` `unlock` gave. + pub fn section_unlocked( + &self, + path: &str, + key: &protected::Key, + notify: impl Fn() + Send + 'static, + ) -> Result
{ + self.section_unlocked_with(path, key, |file| Ok(FileRemote(file.to_owned())), notify) + } + + /// `section_with` for a password-protected section, under its `key`. + pub fn section_unlocked_with( + &self, + path: &str, + key: &protected::Key, + connect: impl FnMut(&Path) -> io::Result + Send + 'static, + notify: impl Fn() + Send + 'static, + ) -> Result
{ + self.open_section(path, Some(key), connect, notify) + } + + fn open_section( + &self, + path: &str, + key: Option<&protected::Key>, + connect: impl FnMut(&Path) -> io::Result + Send + 'static, + notify: impl Fn() + Send + 'static, ) -> Result
{ let section = self.section_path(path)?; let (path, replicas) = (section.path.clone(), self.replica_folder(section)); @@ -1355,7 +1543,7 @@ impl Notebook { std::fs::create_dir_all(&replicas)?; Ok(replica_file(&replicas, identity)) }; - Section::open_in(file, replica, connect, notify) + Section::open_in(file, key, replica, connect, notify) } } @@ -1411,7 +1599,18 @@ pub struct StoredPage { pub fn stored_pages(image: &[u8]) -> Result> { let store = Store::parse(image)?; let index = RevisionIndex::parse(&store)?; - let document = Document::parse(&index)?; + pages_of(&Document::parse(&index)?) +} + +/// `stored_pages` of a password-protected section, under its `key`. +pub fn stored_pages_unlocked(image: &[u8], key: &protected::Key) -> Result> { + let store = Store::parse(image)?; + let index = RevisionIndex::parse(&store)?; + let unlocked = protected::UnlockedSection::unlock(&index, key, Default::default())?; + pages_of(&unlocked.document()?) +} + +fn pages_of(document: &Document<'_>) -> Result> { Ok(document .pages()? .into_iter() @@ -1461,7 +1660,7 @@ fn component(name: &str) -> bool { } /// Whether the TOC `image` holds has an entry for the file identity `file`. -fn lists(image: &[u8], file: [u8; 16]) -> Result { +pub(crate) fn lists(image: &[u8], file: [u8; 16]) -> Result { let store = Store::parse(image)?; let index = RevisionIndex::parse(&store)?; let document = Document::parse(&index)?; @@ -1508,6 +1707,19 @@ fn replica_location(notebook: &str, section: &discover::Section) -> String { } } +/// Deletes the closed replica at `replica`, with the files SQLite keeps beside it. +fn remove_replica(replica: &Path) -> io::Result<()> { + for suffix in ["", "-wal", "-shm"] { + let mut file = replica.as_os_str().to_owned(); + file.push(suffix); + match std::fs::remove_file(file) { + Err(error) if error.kind() != io::ErrorKind::NotFound => return Err(error), + _ => {} + } + } + Ok(()) +} + /// The replica in `cache` of the section `identity` names. pub(crate) fn replica_file(cache: &Path, identity: &[u8; 16]) -> PathBuf { let name: String = identity.iter().map(|byte| format!("{byte:02x}")).collect(); @@ -1635,13 +1847,14 @@ impl Section { })?; Ok(replica_file(&folder, identity)) }; - Self::open_in(file, replicas, connect, notify) + Self::open_in(file, None, replicas, connect, notify) } - /// Opens the canonical section `file` through the replica `replica` names for its document - /// identity and image. + /// Opens the canonical section `file`, a protected one under `key`, through the replica + /// `replica` names for its document identity and image. fn open_in( file: PathBuf, + key: Option<&protected::Key>, replica: impl FnOnce(&[u8; 16], &[u8]) -> Result, mut connect: impl FnMut(&Path) -> io::Result + Send + 'static, notify: impl Fn() + Send + 'static, @@ -1650,7 +1863,7 @@ impl Section { let store = Store::parse(&source)?; let identity = RevisionIndex::parse(&store)?.root; let cache = replica(&identity.guid, &source)?; - let replica = Replica::open_or_create(&cache, || Ok(source))?; + let replica = Replica::open_or_create(&cache, key, || Ok(source))?; let remote = file.clone(); Self::start(file, replica, move || connect(&remote), notify) } @@ -2015,3 +2228,6 @@ impl Drop for Section { drop(self.worker.take()); } } + +#[cfg(test)] +mod tests; diff --git a/crates/notebook/src/session/tests.rs b/crates/notebook/src/session/tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..7559087d22808d42cf4e16069d354a7b1f5501eb --- /dev/null +++ b/crates/notebook/src/session/tests.rs @@ -0,0 +1,123 @@ +use super::*; + +/// A mounted notebook where another writer commits `revision` to the section file just +/// before Snowbound's rewritten file takes its place. +struct Racing { + inner: Directory, + revision: Mutex>, +} + +impl Racing { + fn race(&self, path: &str) { + let revision = self.revision.lock().unwrap().take(); + if let Some((section, transaction)) = revision { + assert_eq!(section, path); + self.inner.commit(§ion, &transaction).unwrap(); + } + } +} + +impl Storage for Racing { + fn discover( + &self, + cache: &mut discover::Cache, + limits: discover::Limits, + ) -> Result { + self.inner.discover(cache, limits) + } + fn location(&self) -> String { + self.inner.location() + } + fn exists(&self, path: &str) -> bool { + self.inner.exists(path) + } + fn read(&self, path: &str) -> Result> { + self.inner.read(path) + } + fn read_file(&self, path: &str, limit: usize) -> Result> { + self.inner.read_file(path, limit) + } + fn create(&self, path: &str, bytes: &[u8]) -> Result<()> { + self.inner.create(path, bytes) + } + fn create_directory(&self, path: &str) -> Result<()> { + self.inner.create_directory(path) + } + fn hide(&self, path: &str) -> Result<()> { + self.inner.hide(path) + } + fn rename(&self, from: &str, to: &str) -> Result<()> { + self.inner.rename(from, to) + } + fn replace(&self, from: &str, to: &str) -> Result<()> { + self.inner.replace(from, to) + } + fn delete(&self, path: &str) -> Result<()> { + self.inner.delete(path) + } + fn place(&self, path: &str, ancestor: [u8; 16], name: &str) -> Result<()> { + self.inner.place(path, ancestor, name) + } + fn commit(&self, path: &str, transaction: &Transaction) -> Result<()> { + self.inner.commit(path, transaction) + } + fn supersede(&self, path: &str, base: &Stamp, with: &str) -> Result<()> { + self.race(path); + self.inner.supersede(path, base, with) + } +} + +/// A revision another writer commits while a password is being set is never overwritten: +/// the section keeps it, and the password waits for a later try. +#[test] +fn a_password_never_overwrites_a_concurrent_revision() { + let temporary = tempfile::tempdir().unwrap(); + let folder = temporary.path().join("notebook"); + let cache = temporary.path().join("cache"); + let creation = PageCreation::new(None, Some("Mine"), "Fixture").unwrap(); + let path = Notebook::create(&folder, &cache, 0x00f0_a0c0, &creation) + .unwrap() + .catalog() + .sections[0] + .path + .clone(); + let file = folder.canonicalize().unwrap().join(&path); + let image = std::fs::read(&file).unwrap(); + let arena = onestore::Arena::default(); + let mut section = onestore::Section::open(&arena, image).unwrap(); + let theirs = PageCreation::new(None, Some("Theirs"), "OneNote").unwrap(); + section + .apply( + "OneNote", + &Edit { + at: crate::now(), + ops: vec![Op::Section(SectionOp::Create(theirs))], + }, + ) + .unwrap(); + let revision = section.seal().unwrap().unwrap(); + + let root = folder.canonicalize().unwrap(); + let storage = Racing { + inner: Directory(root.clone()), + revision: Mutex::new(Some((path.clone(), revision))), + }; + let mut notebook = Notebook::with(Box::new(storage), Some(root), &cache).unwrap(); + let set = notebook.set_password(&path, None, Some("secret")); + let titles = |image: &[u8]| -> Vec { + stored_pages(image) + .unwrap() + .into_iter() + .map(|page| page.page.title) + .collect() + }; + let stored = std::fs::read(&file).unwrap(); + match set { + Ok(key) => { + let key = key.unwrap(); + let pages = stored_pages_unlocked(&stored, &key).unwrap(); + assert!(pages.iter().any(|page| page.page.title == "Theirs")); + } + Err(_) => assert_eq!(titles(&stored), ["Mine", "Theirs"]), + } +} diff --git a/crates/notebook/src/sidecar/tests.rs b/crates/notebook/src/sidecar/tests.rs index ce34bdd42eec244d26d0c14b1961a6e20e6598be..a5d3d72c08955711f659846212ad10acbec053b9 100644 --- a/crates/notebook/src/sidecar/tests.rs +++ b/crates/notebook/src/sidecar/tests.rs @@ -165,6 +165,10 @@ impl Storage for Folder { fn commit(&self, _: &str, _: &Transaction) -> Result<()> { unimplemented!() } + + fn supersede(&self, _: &str, _: &onestore::Stamp, _: &str) -> Result<()> { + unimplemented!() + } } #[test] diff --git a/crates/notebook/src/smb/mod.rs b/crates/notebook/src/smb/mod.rs index 2f551539e23d0f779fd474a37268d1c646f81cf2..a290414060cf65ce538c6ec8023bf5057bc7b098 100644 --- a/crates/notebook/src/smb/mod.rs +++ b/crates/notebook/src/smb/mod.rs @@ -331,7 +331,7 @@ impl Client { /// Gives a file or directory the hidden attribute, keeping its others, as OneNote 2010 /// skips a hidden folder. - pub fn hide(&self, path: &str) -> io::Result<()> { + pub(crate) fn hide(&self, path: &str) -> io::Result<()> { // FILE_READ_ATTRIBUTES and FILE_WRITE_ATTRIBUTES. let file = self.open_with(path, 0x180, 7, CreateDisposition::FileOpen, 0)?; let file_id = file.id.ok_or(io::ErrorKind::InvalidInput)?; @@ -389,7 +389,7 @@ impl Client { /// Reads a bounded external payload while denying concurrent writes and deletion. /// Empty files succeed; limits, sharing contention and failed close return no payload. - pub fn read_asset(&self, path: &str, limit: usize) -> io::Result> { + pub(crate) fn read_asset(&self, path: &str, limit: usize) -> io::Result> { let mut file = self.open_shared(path, false, 1)?; let mut bytes = Vec::new(); let mut block = [0; 65536]; @@ -493,6 +493,33 @@ impl Client { self.commit(path, &checked(base), |file| onestore::confirm(file, base)) } + /// Puts the file `with` in the place of the revision store at `path`, provided `path` still + /// has `base`'s stamp, as OneNote 2010's maintenance puts a file it wrote anew in place: + /// under writer coordination the old file goes aside, `with` takes its name, and the old + /// file is deleted once released. A server renames nothing over an open file. + pub(crate) fn supersede( + &self, + path: &str, + base: &onestore::Stamp, + with: &str, + ) -> Result<(), CommitError> { + let aside = format!("{with}.old"); + let failed = |state| move |error| CommitError { state, error }; + self.commit(path, &checked(base), |file| { + base.check(file) + .and_then(|()| self.rename(path, &aside)) + .map_err(failed(CommitState::NotCommitted))?; + self.rename(with, path).map_err(|error| CommitError { + state: match self.rename(&aside, path) { + Ok(()) => CommitState::NotCommitted, + Err(_) => CommitState::Unknown, + }, + error, + }) + })?; + self.delete(&aside).map_err(failed(CommitState::Committed)) + } + /// Opens `path` for writing under OneNote's coordination, making `reads` with the locks. fn commit( &self, diff --git a/crates/notebook/src/smb/tests.rs b/crates/notebook/src/smb/tests.rs index 45bb709dd4018c36071cc963fda83e86257f3d19..9ec2e446c8516c7c468f7e9940ab1b95631c4853 100644 --- a/crates/notebook/src/smb/tests.rs +++ b/crates/notebook/src/smb/tests.rs @@ -463,9 +463,16 @@ fn live_watch() { #[ignore = "requires ONESTORE_SMB_LAB pointing to disposable Samba"] fn live_storage_inspection() { let reader = client(); - for (index, fixture) in [ - "native-encrypted/encrypted-01/notebook/synthetic.one", - "native-encrypted/cold-encrypted-02/notebook/Open Notebook.one", + // Stably unreadable, never contention: a protected section, and a file too damaged to say. + for (index, (fixture, unreadable)) in [ + ( + "native-encrypted/encrypted-01/notebook/synthetic.one", + io::ErrorKind::Unsupported, + ), + ( + "native-encrypted/cold-encrypted-02/notebook/Open Notebook.one", + io::ErrorKind::InvalidData, + ), ] .into_iter() .enumerate() @@ -482,7 +489,7 @@ fn live_storage_inspection() { assert_eq!(reader.read_storage(&path, source.len()).unwrap(), source); assert_eq!( reader.read(&path, source.len()).unwrap_err().kind(), - io::ErrorKind::WouldBlock + unreadable ); let maintenance = client(); let guard = maintenance.open(&path, false).unwrap(); @@ -777,6 +784,42 @@ fn live_structure() { assert_eq!(header.ancestor, toc_id); assert_eq!(header.name_crc, 0x6108912a); assert!(fresh.section("Renamed.one", || {}).is_err()); + // A password supersedes the file under writer coordination, never over a newer stamp, and + // while a reader holds it open, as OneNote's readers share it with deletion. + let section = format!("{root}/Renamed.one"); + let written = format!("{root}/.Renamed.one.snowbound"); + client.create(&written, &renamed).unwrap(); + let mut stale = onestore::Stamp::of(&renamed).unwrap(); + stale.length += 1; + let refused = client.supersede(§ion, &stale, &written).unwrap_err(); + assert_eq!(refused.state, CommitState::NotCommitted); + assert_eq!(client.read_storage(§ion, 1 << 20).unwrap(), renamed); + client.delete(&written).unwrap(); + let mut fresh = fresh; + let reader = client.open(§ion, false).unwrap(); + let key = fresh + .set_password("Renamed.one", None, Some("secret")) + .unwrap() + .unwrap(); + reader.close().unwrap(); + let protected = client.read_storage(§ion, 1 << 20).unwrap(); + assert_eq!( + onestore::protected::Key::open(&protected, "secret") + .unwrap() + .secret(), + key.secret() + ); + assert!(matches!( + fresh.catalog().sections[0].state, + crate::discover::SectionState::Locked + )); + let names: Vec = client + .read_dir(&root, 100) + .unwrap() + .into_iter() + .map(|entry| entry.name) + .collect(); + assert!(!names.iter().any(|name| name.starts_with('.')), "{names:?}"); for path in [ "Renamed.one", "Kept/Inner.one", diff --git a/crates/notebook/src/sync.rs b/crates/notebook/src/sync.rs index 30b89bfbb69159b28cf1e29a0458d8752d6c2e11..2f0f0a0628b751d31a9bc0597bb254b4b0ca16d3 100644 --- a/crates/notebook/src/sync.rs +++ b/crates/notebook/src/sync.rs @@ -84,8 +84,7 @@ struct Blocked { } fn state(connection: &Connection) -> Result { - let base = base::stamp(connection, base::Image::Base)? - .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "The cache has no base image"))?; + let base = base::base_stamp(connection)?; let blocked = connection .query_row( "SELECT id, revisions FROM batches WHERE attempted=1 ORDER BY id LIMIT 1", @@ -158,20 +157,17 @@ fn newest(connection: &Connection, batch: i64) -> Result { impl Replica { /// Returns a durable receipt or the persisted state of a locally acknowledged edit. pub fn status(&self, id: u64) -> Result> { - status(&*self.lock()?, id) + status(&*self.lock()?, id, self.section.key.as_ref()) } /// The last observed remote image. Observation alone does not acknowledge any pending /// edit's remote durability. pub(crate) fn remote_snapshot(&self) -> Result> { let connection = self.lock()?; - let image = match base::read(&connection, base::Image::Remote)? { - Some(image) => Some(image), - None => base::read(&connection, base::Image::Base)?, - }; - Ok(image.ok_or_else(|| { - io::Error::new(io::ErrorKind::InvalidData, "The cache has no base image") - })?) + match base::read(&connection, base::Image::Remote)? { + Some(image) => Ok(image), + None => base::base(&connection), + } } /// Publishes the oldest unpublished batch, rebasing the queue first when the remote @@ -208,7 +204,8 @@ impl Replica { return Ok(Synced { edit: Some(( id, - status(&*self.lock()?, id)?.unwrap_or(EditStatus::Pending), + status(&*self.lock()?, id, self.section.key.as_ref())? + .unwrap_or(EditStatus::Pending), )), changed: Vec::new(), }); @@ -217,9 +214,15 @@ impl Replica { observed if observed == state.base => None, _ => { let image = remote.read().map_err(Error::RemoteIo)?; + // Protected elsewhere: a section written anew, which only its key reads. + if self.section.key.is_none() && crate::discover::locked(&Store::parse(&image)?) { + return Err(Error::RemoteIo(io::Error::new( + io::ErrorKind::Unsupported, + "Password protected", + ))); + } // A read image is compared whole: a stamp stands for it only when read alone. - let base = base::read(&*self.lock()?, base::Image::Base)?; - (base.as_deref() != Some(&image[..])).then_some(image) + (base::base(&*self.lock()?)? != image).then_some(image) } }; let mut changed = Vec::new(); @@ -265,7 +268,8 @@ impl Replica { return Ok(Synced { edit: Some(( id, - status(&*self.lock()?, id)?.unwrap_or(EditStatus::Pending), + status(&*self.lock()?, id, self.section.key.as_ref())? + .unwrap_or(EditStatus::Pending), )), changed, }); @@ -301,7 +305,8 @@ impl Replica { return Ok(Synced { edit: Some(( id, - status(&*self.lock()?, id)?.unwrap_or(EditStatus::Pending), + status(&*self.lock()?, id, self.section.key.as_ref())? + .unwrap_or(EditStatus::Pending), )), changed, }); @@ -329,9 +334,7 @@ impl Replica { let id = newest(&*self.lock()?, batch)?; let Some(transaction) = transaction else { // Edits that changed nothing are published once the remote's image is durable. - let base = base::stamp(&*self.lock()?, base::Image::Base)?.ok_or_else(|| { - io::Error::new(io::ErrorKind::InvalidData, "The cache holds no base image") - })?; + let base = base::base_stamp(&*self.lock()?)?; if let Err(error) = remote.confirm(&base) { if error.state == CommitState::Committed { self.acknowledge(batch, None, None)?; @@ -373,15 +376,13 @@ impl Replica { sealed: Option<&Transaction>, revisions: &BTreeMap, ) -> Result { - let base = base::read(&*self.lock()?, base::Image::Base)?.ok_or_else(|| { - io::Error::new(io::ErrorKind::InvalidData, "The cache has no base image") - })?; + let base = base::base(&*self.lock()?)?; let (local, remote) = (onestore::Arena::default(), onestore::Arena::default()); - let mut local = onestore::Section::open(&local, base)?; + let mut local = working::open(&local, base, self.section.key.as_ref())?; if let Some(sealed) = sealed { local.replay(sealed)?; } - let mut remote = onestore::Section::open(&remote, image.to_vec())?; + let mut remote = working::open(&remote, image.to_vec(), self.section.key.as_ref())?; // A page's versions change without its page changing. let versions = |section: &mut onestore::Section<'_>| { section.versions().map(|versions| { @@ -403,7 +404,7 @@ impl Replica { } fn receipt(&self, id: u64) -> Result { - match status(&*self.lock()?, id)? { + match status(&*self.lock()?, id, self.section.key.as_ref())? { Some(EditStatus::Published { revision }) => Ok(revision), _ => Err(io::Error::other("A published edit has no receipt").into()), } @@ -431,7 +432,7 @@ impl Replica { |row| row.get::<_, String>(0), )?)?, }; - for queued in queue::load(&database, Some(batch))? { + for queued in queue::load(&database, self.section.key.as_ref(), Some(batch))? { database.execute( "INSERT INTO receipts(edit_id, revision) VALUES (?1, ?2)", params![ @@ -527,7 +528,11 @@ impl Replica { } } -pub(crate) fn status(connection: &Connection, id: u64) -> Result> { +pub(crate) fn status( + connection: &Connection, + id: u64, + key: Option<&onestore::protected::Key>, +) -> Result> { let id = signed(id)?; if let Some(revision) = connection .query_row( @@ -563,8 +568,7 @@ pub(crate) fn status(connection: &Connection, id: u64) -> Result None, Some((true, revisions, edit)) => { let revisions = decode_revisions(revisions.as_deref().unwrap_or("{}"))?; - let edit: onestore::op::Edit = serde_json::from_str(&edit) - .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; + let edit = queue::parse(key, &edit)?; let revision = revision_of(&edit, &revisions)?; Some(EditStatus::AwaitingConfirmation { revision }) } diff --git a/crates/notebook/src/worker.rs b/crates/notebook/src/worker.rs index 0ab15018e2f9a00dfde82567b1e7d9b2c04bb9e4..be6537a1ab685878d74084c4888379d483db9363 100644 --- a/crates/notebook/src/worker.rs +++ b/crates/notebook/src/worker.rs @@ -166,6 +166,7 @@ impl Replica { )); } let mut owner = self + .section .worker .lock() .map_err(|_| io::Error::other("Synchronization worker registration panicked"))?; diff --git a/crates/notebook/src/working.rs b/crates/notebook/src/working.rs index c0f7f1f0830939da3a955406df265ab444631a62..0ccb3fbeea69fc76e86c6df393900e63e9f3259c 100644 --- a/crates/notebook/src/working.rs +++ b/crates/notebook/src/working.rs @@ -8,6 +8,7 @@ use onestore::{ Arena, ExGuid, Section, Transaction, op::{Edit, OpError}, page::Page, + protected::Key, }; use rusqlite::{Connection, OptionalExtension, TransactionBehavior, params}; use std::{ @@ -96,18 +97,19 @@ impl Request { } } -type Worker = Arc>>; - /// The section thread as the replica sees it. Rereading the section after the queue was /// replaced (a rebase, a released attempt) happens on a new thread while the current one /// keeps answering page reads from the section as it was; the new thread then takes the /// requests over, so reads never wait for a rebuild. pub(crate) struct Thread { - connection: Arc>, - worker: Worker, + pub(crate) connection: Mutex, + /// The sync worker, which a durable burst of edits wakes. + pub(crate) worker: Mutex>, /// Taken when the replica drops, which ends every section thread. sender: Mutex>>, threads: Mutex>>, + /// A password-protected section's key, under which its queue is sealed too. + pub(crate) key: Option, } impl Thread { @@ -148,16 +150,14 @@ impl Thread { } /// Starts the section thread once the queue opens. -pub(crate) fn spawn( - connection: Arc>, - worker: Worker, -) -> Result<(Arc, ExGuid)> { +pub(crate) fn spawn(connection: Connection, key: Option) -> Result<(Arc, ExGuid)> { let (sender, requests) = mpsc::channel(); let shared = Arc::new(Thread { - connection, - worker, + connection: Mutex::new(connection), + worker: Mutex::new(Weak::new()), sender: Mutex::new(Some(sender)), threads: Mutex::new(Vec::new()), + key, }); let (ready, opened) = mpsc::sync_channel(1); shared.start(move |shared| run(shared, requests, VecDeque::new(), Some(ready)))?; @@ -188,7 +188,7 @@ fn run( ) { loop { let arena = Arena::default(); - let working = match Working::open(&arena, &shared.connection) { + let working = match Working::open(&arena, &shared.connection, shared.key.as_ref()) { Ok(working) => working, Err(error) => { if let Some(ready) = ready.take() { @@ -237,17 +237,19 @@ enum Job { fn build(shared: Arc, job: Job, signal: mpsc::Sender) { let answer: Box = match job { Job::Reopen { reply } => Box::new(move || reply(Ok(()))), - Job::Rebase { image, reply } => match rebase(&shared.connection, image) { - Ok(changed) => Box::new(move || reply(Ok(changed))), - Err(error) => { - reply(Err(error)); - let _ = signal.send(Request::Resume); - return; + Job::Rebase { image, reply } => { + match rebase(&shared.connection, image, shared.key.as_ref()) { + Ok(changed) => Box::new(move || reply(Ok(changed))), + Err(error) => { + reply(Err(error)); + let _ = signal.send(Request::Resume); + return; + } } - }, + } }; let arena = Arena::default(); - let opened = Working::open(&arena, &shared.connection); + let opened = Working::open(&arena, &shared.connection, shared.key.as_ref()); let (to, from) = mpsc::sync_channel(1); if signal.send(Request::Handover(to)).is_err() { return; @@ -286,8 +288,8 @@ struct Working<'a> { } impl<'a> Working<'a> { - fn open(arena: &'a Arena, connection: &Mutex) -> Result { - let (section, open, touched) = replay(arena, &*lock(connection)?)?; + fn open(arena: &'a Arena, connection: &Mutex, key: Option<&Key>) -> Result { + let (section, open, touched) = replay(arena, &*lock(connection)?, key)?; Ok(Self { section, open, @@ -301,7 +303,7 @@ impl<'a> Working<'a> { requests: &mpsc::Receiver, backlog: &mut VecDeque, ) -> Next { - let (connection, worker) = (&*shared.connection, &shared.worker); + let connection = &shared.connection; // While another thread rebuilds the section, reads answer from this one and every // other request waits for the rebuilt section. let mut held: Option> = None; @@ -356,7 +358,7 @@ impl<'a> Working<'a> { edit, reply, } => self - .accept(author, edit, reply, &mut accepted, connection, worker) + .accept(author, edit, reply, &mut accepted, shared) .err() .unwrap_or(false), Request::Page { space, reply } => { @@ -390,7 +392,7 @@ impl<'a> Working<'a> { false } Request::Flush { reply } => { - let written = self.flush(connection, worker, &mut accepted); + let written = self.flush(shared, &mut accepted); reply(if written { Ok(()) } else { @@ -399,7 +401,7 @@ impl<'a> Working<'a> { !written } Request::Seal { reply } => { - if !self.flush(connection, worker, &mut accepted) { + if !self.flush(shared, &mut accepted) { reply(Err( io::Error::other("The queue could not be written").into() )); @@ -412,7 +414,7 @@ impl<'a> Working<'a> { } } Request::Rebase { image, reply } => { - if self.flush(connection, worker, &mut accepted) { + if self.flush(shared, &mut accepted) { held = self .rebuild(shared, Job::Rebase { image, reply }) .then(VecDeque::new); @@ -425,7 +427,7 @@ impl<'a> Working<'a> { } } Request::Reopen { reply } => { - self.flush(connection, worker, &mut accepted); + self.flush(shared, &mut accepted); held = self .rebuild(shared, Job::Reopen { reply }) .then(VecDeque::new); @@ -438,7 +440,7 @@ impl<'a> Working<'a> { return Next::Reopen; } } - if !self.flush(connection, worker, &mut accepted) { + if !self.flush(shared, &mut accepted) { return Next::Reopen; } } @@ -476,8 +478,7 @@ impl<'a> Working<'a> { edit: Edit, reply: Reply, accepted: &mut Vec, - connection: &Mutex, - worker: &Worker, + shared: &Thread, ) -> std::result::Result<(), bool> { match self.section.apply(&author, &edit) { Ok(()) => { @@ -493,7 +494,7 @@ impl<'a> Working<'a> { Err(error) => { let broken = matches!(error, OpError::Failed(_)); if broken { - self.flush(connection, worker, accepted); + self.flush(shared, accepted); } reply(Err(error.into())); Err(broken) @@ -503,17 +504,12 @@ impl<'a> Working<'a> { /// Writes the accepted edits in one transaction, then answers their senders; false /// when the write failed and the section holds edits the queue lacks. - fn flush( - &mut self, - connection: &Mutex, - worker: &Worker, - accepted: &mut Vec, - ) -> bool { + fn flush(&mut self, shared: &Thread, accepted: &mut Vec) -> bool { if accepted.is_empty() { return true; } let written = (|| -> Result> { - let mut connection = lock(connection)?; + let mut connection = lock(&shared.connection)?; let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?; let batch = match self.open { @@ -527,6 +523,7 @@ impl<'a> Working<'a> { for edit in accepted.iter() { ids.push(queue::insert( &transaction, + shared.key.as_ref(), None, batch, &edit.author, @@ -543,7 +540,7 @@ impl<'a> Working<'a> { for (edit, id) in accepted.drain(..).zip(ids) { (edit.reply)(Ok(id)); } - crate::edited(worker); + crate::edited(&shared.worker); } Err(error) => { let message = error.to_string(); @@ -600,7 +597,11 @@ impl<'a> Working<'a> { /// Replays every queued edit on `image`, which becomes the base; each page whose local /// version the remote could not take gains a conflict page holding it, queued as a new edit. /// Returns the pages the remote changed. -fn rebase(connection: &Mutex, image: Option>) -> Result> { +fn rebase( + connection: &Mutex, + image: Option>, + key: Option<&Key>, +) -> Result> { let (base_image, image, edits) = { let connection = lock(connection)?; if connection.query_row( @@ -614,22 +615,20 @@ fn rebase(connection: &Mutex, image: Option>) -> Result image, None => { base::read(&connection, base::Image::Remote)?.unwrap_or_else(|| base_image.clone()) } }; - (base_image, image, queue::load(&connection, None)?) + (base_image, image, queue::load(&connection, key, None)?) }; let old_arena = Arena::default(); - let mut old = Section::open(&old_arena, base_image)?; + let mut old = open(&old_arena, base_image, key)?; let before: BTreeMap = old.revisions().collect(); let remote_arena = Arena::default(); - let remote = Section::open(&remote_arena, image.clone())?; + let remote = open(&remote_arena, image.clone(), key)?; if old.root() != remote.root() { return Err(io::Error::new( io::ErrorKind::InvalidInput, @@ -645,10 +644,10 @@ fn rebase(connection: &Mutex, image: Option>) -> Result = merged .conflicts @@ -694,10 +693,10 @@ fn rebase(connection: &Mutex, image: Option>) -> Result?1", [batch])?; for (id, edit) in rewritten { - queue::rewrite(&transaction, id, &edit)?; + queue::rewrite(&transaction, key, id, &edit)?; } for (author, edit) in &added { - queue::insert(&transaction, None, batch, author, edit)?; + queue::insert(&transaction, key, None, batch, author, edit)?; } if transaction.query_row("SELECT count(*) FROM edits", [], |row| row.get::<_, i64>(0))? == 0 { transaction.execute("DELETE FROM batches", [])?; @@ -743,10 +742,10 @@ fn batches(connection: &Connection) -> Result( arena: &'a Arena, connection: &Connection, + key: Option<&Key>, ) -> Result<(Section<'a>, Option, BTreeSet)> { - let image = base::read(connection, base::Image::Base)? - .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "The cache has no base image"))?; - let mut section = Section::open(arena, image)?; + let image = base::base(connection)?; + let mut section = open(arena, image, key)?; let mut open = None; let mut touched = BTreeSet::new(); for (batch, sealed) in batches(connection)? { @@ -761,7 +760,7 @@ fn replay<'a>( Some(Some(transaction)) => section.replay(&transaction)?, Some(None) => {} None => { - for queued in queue::load(connection, Some(batch))? { + for queued in queue::load(connection, key, Some(batch))? { section .apply(&queued.author, &queued.edit) .map_err(|error| { @@ -781,13 +780,21 @@ fn replay<'a>( /// The image the queue leaves, its unsealed edits sealed as one more revision; that /// revision's identities are fresh on every call. O(section), for tests and recovery. -pub(crate) fn image(connection: &Connection) -> Result> { +pub(crate) fn image(connection: &Connection, key: Option<&Key>) -> Result> { let arena = Arena::default(); - let (mut section, ..) = replay(&arena, connection)?; + let (mut section, ..) = replay(&arena, connection, key)?; section.seal()?; Ok(section.image()) } +/// A section image, a password-protected one under `key`. +pub(crate) fn open<'a>(arena: &'a Arena, image: Vec, key: Option<&Key>) -> Result> { + Ok(match key { + Some(key) => Section::unlock(arena, image, key)?, + None => Section::open(arena, image)?, + }) +} + /// The sealed batch waiting for publication, if any. pub(crate) fn sealed(connection: &Connection) -> Result> { let row: Option<(i64, String)> = connection diff --git a/crates/notebook/tests/cache.rs b/crates/notebook/tests/cache.rs index b30c501790eaa6de68ff82ce6f967278e3c69ee7..f547954ad14140124ec2f7e612965d2ef622d6b7 100644 --- a/crates/notebook/tests/cache.rs +++ b/crates/notebook/tests/cache.rs @@ -66,14 +66,14 @@ fn a_cache_created_meanwhile_opens() { let source = onestore::create_section("Raced.one", "Raced", "Fixture").unwrap(); let directory = tempfile::tempdir().unwrap(); let path = directory.path().join("raced.sqlite"); - let replica = Replica::open_or_create(&path, || { + let replica = Replica::open_or_create(&path, None, || { drop(Replica::create(&path, &source)?); Ok(source.clone()) }) .unwrap(); assert_eq!(replica.pages().unwrap()[0].1, "Raced"); drop(replica); - let reopened = Replica::open_or_create(&path, || unreachable!()).unwrap(); + let reopened = Replica::open_or_create(&path, None, || unreachable!()).unwrap(); assert_eq!(reopened.pages().unwrap()[0].1, "Raced"); } diff --git a/crates/notebook/tests/package.rs b/crates/notebook/tests/package.rs new file mode 100644 index 0000000000000000000000000000000000000000..1227410baf531b8063a76134c53ccf7a6209e998 --- /dev/null +++ b/crates/notebook/tests/package.rs @@ -0,0 +1,265 @@ +//! OneNote packages and the copies Save As writes (`corpus/notebook-package`). + +use notebook::{package, session::Notebook}; +use std::path::Path; + +fn header(image: &[u8]) -> onestore::Header { + onestore::Store::parse(image).unwrap().header +} + +/// Each entry of the table of contents `image` holds: its filename and file identity. +fn listed(image: &[u8]) -> Vec<(String, [u8; 16])> { + let store = onestore::Store::parse(image).unwrap(); + let index = onestore::RevisionIndex::parse(&store).unwrap(); + let document = onestore::document::Document::parse(&index).unwrap(); + let revision = document.active(document.root).unwrap(); + let onestore::document::Kind::Toc { entries, .. } = &revision.nodes[&revision.roots[&1]].kind + else { + panic!() + }; + (entries.iter()) + .map(|id| match &revision.nodes[id].kind { + onestore::document::Kind::Toc { + filename, identity, .. + } => (filename.clone().unwrap_or_default(), identity.unwrap()), + _ => panic!(), + }) + .collect() +} + +/// A notebook of a section, a group holding one, and a page deleted into the recycle bin. +fn notebook(root: &Path, cache: &Path) -> Notebook { + let page = |title: &str| onestore::PageCreation::new(None, Some(title), "Author").unwrap(); + let mut notebook = Notebook::create(root, cache, 0x00d7ff, &page("First")).unwrap(); + notebook.create_group("", "Group").unwrap(); + notebook + .create_section("Group", "Inner", &page("Inside")) + .unwrap(); + notebook + .create_section("", "Doomed", &page("Binned")) + .unwrap(); + let image = notebook.read_section("Doomed.one").unwrap(); + let pages = notebook::session::stored_pages(&image).unwrap(); + notebook + .recycle_pages(&[pages[0].page.clone()], "Author") + .unwrap(); + notebook.delete("Doomed.one").unwrap(); + notebook +} + +/// Pack writes the notebook's tables of contents and sections, the recycle bin left out, +/// outside any notebook; Unpack Notebook gives every file a new identity, placed under its +/// folder's table of contents, whose entries follow, and the notebook opens as it was. +/// `NOTEBOOK_PACKAGE_EXPORT` names a new directory receiving the package and the notebook +/// unpacked, for OneNote 2010 to unpack and to open cold. +#[test] +fn a_notebook_packs_and_unpacks_as_onenote_packs_one() { + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path().join("Packed"); + let cache = temporary.path().join("cache"); + let notebook = notebook(&root, &cache); + let files = package::notebook_files(¬ebook, |_| None).unwrap(); + let paths: Vec<&str> = files.iter().map(|(path, _)| path.as_str()).collect(); + assert_eq!( + paths, + [ + "Open Notebook.onetoc2", + "New Section 1.one", + "Group/Open Notebook.onetoc2", + "Group/Inner.one" + ] + ); + let packed = package::pack(files.clone()).unwrap(); + let read = package::read(&packed, 1 << 30).unwrap(); + assert_eq!(read.len(), files.len()); + for ((path, bytes), (stored, original)) in read.iter().zip(&files) { + assert_eq!(path, stored); + let (unplaced, placed) = (header(bytes), header(original)); + assert_eq!( + unplaced.file_id, placed.file_id, + "{path} keeps its identity" + ); + assert_eq!( + (unplaced.ancestor, unplaced.name_crc), + ([0; 16], 0), + "{path}" + ); + assert_eq!(bytes[..128], original[..128]); + assert_eq!(bytes[148..], original[148..]); + } + + let unpacked = temporary.path().join("Unpacked"); + package::unpack(read, &unpacked).unwrap(); + let opened = Notebook::open(&unpacked, &cache).unwrap(); + let catalog = opened.catalog(); + let sections = |folder: ¬ebook::discover::Folder| -> Vec { + (folder.sections.iter()) + .map(|section| section.path.clone()) + .collect() + }; + assert_eq!(sections(catalog), ["New Section 1.one"]); + assert_eq!(sections(&catalog.groups[0]), ["Group/Inner.one"]); + assert!(catalog.unavailable.is_empty() && catalog.groups[0].unavailable.is_empty()); + let read = |path: &str| std::fs::read(unpacked.join(path)).unwrap(); + let root_toc = read("Open Notebook.onetoc2"); + let group_toc = read("Group/Open Notebook.onetoc2"); + let section = read("New Section 1.one"); + let inner = read("Group/Inner.one"); + for (unpacked, (_, original)) in [&root_toc, §ion, &group_toc, &inner] + .into_iter() + .zip(&files) + { + assert_ne!(header(unpacked).file_id, header(original).file_id); + } + assert_eq!(header(&root_toc).ancestor, [0; 16]); + assert_eq!(header(§ion).ancestor, header(&root_toc).file_id); + assert_eq!(header(&group_toc).ancestor, header(&root_toc).file_id); + assert_eq!(header(&inner).ancestor, header(&group_toc).file_id); + // The recycle bin's entry stays, as in OneNote's packages, naming a folder not there. + let bin = listed(&files[0].1)[2].clone(); + assert_eq!(bin.0, "OneNote_RecycleBin"); + assert_eq!( + listed(&root_toc), + [ + ("New Section 1.one".to_owned(), header(§ion).file_id), + ("Group".to_owned(), header(&group_toc).file_id), + bin + ] + ); + assert_eq!( + listed(&group_toc), + [("Inner.one".to_owned(), header(&inner).file_id)] + ); + let pages = notebook::session::stored_pages(&inner).unwrap(); + assert_eq!(pages[0].page.title, "Inside"); + // Placed as they are, nothing is re-identified when the notebook is read again. + drop(opened); + let reopened = Notebook::open(&unpacked, &cache).unwrap(); + assert_eq!( + reopened.catalog().sections[0].file_id, + header(§ion).file_id + ); + assert!( + package::unpack(Vec::new(), &unpacked).is_err(), + "the folder is new" + ); + + if let Some(directory) = std::env::var_os("NOTEBOOK_PACKAGE_EXPORT") { + let directory = Path::new(&directory); + std::fs::create_dir(directory).unwrap(); + std::fs::write(directory.join("Packed.onepkg"), &packed).unwrap(); + copy(&unpacked, &directory.join("Unpacked")); + } +} + +fn copy(from: &Path, to: &Path) { + std::fs::create_dir(to).unwrap(); + for entry in std::fs::read_dir(from).unwrap() { + let entry = entry.unwrap(); + if entry.file_type().unwrap().is_dir() { + copy(&entry.path(), &to.join(entry.file_name())); + } else { + std::fs::copy(entry.path(), to.join(entry.file_name())).unwrap(); + } + } +} + +/// OneNote 2010's own package (LZX) unpacks and opens. +#[test] +fn onenotes_package_unpacks() { + let package = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../corpus/notebook-package/native/Lab.onepkg"); + let files = package::read(&std::fs::read(package).unwrap(), 1 << 30).unwrap(); + let paths: Vec<&str> = files.iter().map(|(path, _)| path.as_str()).collect(); + assert_eq!(paths, ["Alpha.one", "Beta.one", "Open Notebook.onetoc2"]); + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path().join("Lab"); + package::unpack(files, &root).unwrap(); + let notebook = Notebook::open(&root, temporary.path().join("cache")).unwrap(); + let sections: Vec<&str> = (notebook.catalog().sections.iter()) + .map(|section| section.path.as_str()) + .collect(); + assert_eq!(sections, ["Alpha.one", "Beta.one"]); + let alpha = + notebook::session::stored_pages(¬ebook.read_section("Alpha.one").unwrap()).unwrap(); + let titles: Vec<&str> = alpha.iter().map(|page| page.page.title.as_str()).collect(); + assert_eq!(titles, ["Alpha one", "Alpha three"]); +} + +/// A package naming a file outside its folder, or unpacking past the limit, is refused. +#[test] +fn a_package_stays_in_its_folder() { + let section = onestore::create_empty_section("A.one", None).unwrap(); + for path in ["../A.one", "Group/../../A.one", "C:/A.one", ""] { + let packed = package::pack(vec![(path.to_owned(), section.clone())]).unwrap(); + assert!(package::read(&packed, 1 << 30).is_err(), "{path}"); + } + let packed = package::pack(vec![("A.one".to_owned(), section.clone())]).unwrap(); + assert!(package::read(&packed, 10).is_err()); +} + +/// Save As writes a section as a copy outside any notebook, and a page as a section of its +/// own holding it, as OneNote 2010 does. `NOTEBOOK_SAVE_AS_EXPORT` names a new directory +/// receiving both, for OneNote 2010 to open cold. +#[test] +fn save_as_writes_copies_outside_the_notebook() { + let temporary = tempfile::tempdir().unwrap(); + let notebook = notebook( + &temporary.path().join("Notebook"), + &temporary.path().join("cache"), + ); + let image = notebook.read_section("Group/Inner.one").unwrap(); + let copy = package::section_copy(image.clone()).unwrap(); + assert_ne!(header(©).file_id, header(&image).file_id); + assert_eq!(header(©).ancestor, [0; 16]); + assert_eq!( + notebook::session::stored_pages(©).unwrap()[0] + .page + .title, + "Inside" + ); + + let page = notebook::session::stored_pages(&image) + .unwrap() + .remove(0) + .page; + let saved = package::page_section(&page, "Inside.one", Some(0xe4a88a), "Author").unwrap(); + let pages = notebook::session::stored_pages(&saved).unwrap(); + assert_eq!(pages.len(), 1); + assert_eq!( + (pages[0].page.title.as_str(), pages[0].page.identity), + ("Inside", page.identity) + ); + assert_eq!(header(&saved).ancestor, [0; 16]); + if let Some(directory) = std::env::var_os("NOTEBOOK_SAVE_AS_EXPORT") { + let directory = Path::new(&directory); + std::fs::create_dir(directory).unwrap(); + std::fs::write(directory.join("Inner copy.one"), ©).unwrap(); + std::fs::write(directory.join("Inside.one"), &saved).unwrap(); + } +} + +/// Empty Recycle Bin deletes Deleted Pages' pages and every binned section's file, leaving the +/// bin's table of contents as it was, as OneNote 2010 does. +#[test] +fn emptying_the_recycle_bin_leaves_its_table_of_contents() { + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path().join("Notebook"); + let mut notebook = notebook(&root, &temporary.path().join("cache")); + let bin = root.join("OneNote_RecycleBin"); + let toc = std::fs::read(bin.join("Open Notebook.onetoc2")).unwrap(); + assert!(bin.join("Doomed.one").exists()); + notebook.empty_recycle_bin().unwrap(); + assert!(!bin.join("Doomed.one").exists()); + let deleted = std::fs::read(bin.join("OneNote_DeletedPages.one")).unwrap(); + assert!( + notebook::session::stored_pages(&deleted) + .unwrap() + .is_empty() + ); + assert_eq!( + std::fs::read(bin.join("Open Notebook.onetoc2")).unwrap(), + toc + ); + notebook.empty_recycle_bin().unwrap(); +} diff --git a/crates/notebook/tests/protected.rs b/crates/notebook/tests/protected.rs index 926222b59e1fe7bece32bb17de561b5a64cbd82a..feb4f6196ba8de50b34b28a03eda6ec9d2c5dc3e 100644 --- a/crates/notebook/tests/protected.rs +++ b/crates/notebook/tests/protected.rs @@ -1,133 +1,666 @@ -#![cfg(feature = "protected")] +//! Password-protected sections: unlocking, sealed queues, publishing, merging and conflict +//! pages, and setting, changing and removing a password. -use notebook::{discover::SectionState, session::Notebook}; +use notebook::{EditStatus, Replica, discover::SectionState, session::Notebook}; +use onestore::{ + Arena, ExGuid, Section, + op::{Edit, Op, PageOp}, + page::{Page, PageObject}, + protected::{Error, Key, rekey}, +}; use std::path::Path; -/// A locked section is discovered as such and reads with its password, page by page. -#[test] -fn a_locked_section_unlocks_for_reading() { - let root = Path::new(concat!( - env!("CARGO_MANIFEST_DIR"), - "/../../corpus/native-protected-boundaries" - )); +#[path = "support/server.rs"] +mod server; +use server::Server; + +fn fixture(name: &str) -> (std::path::PathBuf, String) { + let root = Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../corpus")).join(name); let manifest: serde_json::Value = serde_json::from_slice(&std::fs::read(root.join("manifest.json")).unwrap()).unwrap(); - let password = manifest["password"].as_str().unwrap(); - let temporary = tempfile::tempdir().unwrap(); - let notebook = Notebook::open(root.join("notebook"), temporary.path()).unwrap(); - let section = ¬ebook.catalog().sections[0]; - assert!(matches!(section.state, SectionState::Locked)); - let pages = notebook.unlock(§ion.path, password).unwrap().pages; - assert_eq!(pages.len(), 11); - assert!(pages.iter().all(|(_, page)| !page.title.is_empty())); - assert!(matches!( - notebook.unlock(§ion.path, "wrong"), - Err(notebook::Error::Protected( - onestore::protected::Error::PasswordMismatch - )) - )); + (root, manifest["password"].as_str().unwrap().to_owned()) } -/// An unlocked page is edited as ops stored under the section's key, then reads back with -/// the same password. `ONESTORE_PROTECTED_EXPORT` names a directory receiving the notebook for -/// a cold reopen in OneNote. -#[test] -fn an_unlocked_page_is_saved_under_the_section_key() { - use onestore::page::{PageObject, Paragraph, text::new_id}; - let root = Path::new(concat!( - env!("CARGO_MANIFEST_DIR"), - "/../../corpus/native-encrypted" - )); - let manifest: serde_json::Value = - serde_json::from_slice(&std::fs::read(root.join("manifest.json")).unwrap()).unwrap(); - let password = manifest["password"].as_str().unwrap(); - let temporary = tempfile::tempdir().unwrap(); - let copy = temporary.path().join("notebook"); - std::fs::create_dir(©).unwrap(); - for entry in std::fs::read_dir(root.join("encrypted-01/notebook")).unwrap() { +/// A copy of a notebook folder. +fn copy(from: &Path, to: &Path) { + std::fs::create_dir_all(to).unwrap(); + for entry in std::fs::read_dir(from).unwrap() { let entry = entry.unwrap(); - std::fs::copy(entry.path(), copy.join(entry.file_name())).unwrap(); + std::fs::copy(entry.path(), to.join(entry.file_name())).unwrap(); } - let notebook = Notebook::open(©, temporary.path().join("cache")).unwrap(); - let section = notebook - .catalog() - .sections +} + +/// A protected one-page section holding `text`, with its key. +fn protected(text: &str) -> (Vec, Key) { + let plain = onestore::create_section("sealed.one", text, "Fixture").unwrap(); + let key = Key::new("fixture password").unwrap(); + (rekey(&plain, None, Some(&key)).unwrap(), key) +} + +/// The first text object of `image` with its page. +fn first_text(image: &[u8], key: &Key) -> (ExGuid, ExGuid, String) { + let arena = Arena::default(); + let mut section = Section::unlock(&arena, image.to_vec(), key).unwrap(); + let (space, ..) = section.pages().unwrap()[0]; + let page = section.page(space).unwrap(); + texts(&page) + .into_iter() + .next() + .map(|(id, text)| (space, id, text)) + .unwrap() +} + +fn texts(page: &Page) -> Vec<(ExGuid, String)> { + page.objects .iter() - .find(|section| matches!(section.state, SectionState::Locked)) - .unwrap() - .path - .clone(); - let mut unlocked = notebook.unlock(§ion, password).unwrap(); - let (space, mut page) = unlocked.pages[0].clone(); - let before = page.clone(); - let paragraphs = page - .objects - .iter_mut() - .find_map(|object| match object { - PageObject::Outline(outline) if !outline.title => Some(&mut outline.paragraphs), + .filter_map(|object| match object { + PageObject::Outline(outline) => Some(&outline.paragraphs), _ => None, }) - .unwrap(); - let at = paragraphs.iter().position(|p| p.text().is_some()).unwrap(); - let mut added = paragraphs[at].clone(); - added.id = new_id().unwrap(); - added.style = None; - added.lists.clear(); - added.tags.clear(); - let text = added.text_mut().unwrap(); - text.id = new_id().unwrap(); - let format = text.text.format_at(0).unwrap().clone(); - text.text = Paragraph::new( - "Written while protected 🦀".to_owned(), - onestore::document::Format { - font: Some("Calibri".into()), - font_size: Some(11.0), - language: Some(1033), - ..Default::default() - }, - ); - paragraphs.insert(at + 1, added); - paragraphs[at] - .text_mut() - .unwrap() - .text - .append(Paragraph::new( - " and edited under its key".to_owned(), - format, - )) - .unwrap(); - let edit = onestore::op::Edit { + .flatten() + .filter_map(|paragraph| { + let text = paragraph.text()?; + Some((text.id, text.text.text().to_owned())) + }) + .collect() +} + +fn replace(space: ExGuid, text: ExGuid, range: std::ops::Range, with: &str) -> Edit { + Edit { at: 134_000_000_000_000_000, - ops: onestore::op::lower_page(&before, &page) - .unwrap() - .into_iter() - .map(|op| onestore::op::Op::Page { space, op }) - .collect(), + ops: vec![Op::Page { + space, + op: PageOp::Text { + text, + range, + with: with.into(), + }, + }], + } +} + +/// Whether any file of a replica holds `clear`, as UTF-8 or UTF-16. +fn leaks(replica: &Path, clear: &str) -> bool { + let utf16: Vec = clear.encode_utf16().flat_map(u16::to_le_bytes).collect(); + ["", "-wal", "-shm"].iter().any(|suffix| { + let mut file = replica.as_os_str().to_owned(); + file.push(suffix); + std::fs::read(file).is_ok_and(|bytes| { + [clear.as_bytes(), &utf16[..]] + .iter() + .any(|clear| bytes.windows(clear.len()).any(|w| w == *clear)) + }) + }) +} + +/// A locked section is discovered as such, unlocks with its password and opens as a session. +#[test] +fn a_locked_section_unlocks_into_a_session() { + let (root, password) = fixture("native-protected-boundaries"); + let temporary = tempfile::tempdir().unwrap(); + let folder = temporary.path().join("notebook"); + copy(&root.join("notebook"), &folder); + let notebook = Notebook::open(&folder, temporary.path().join("cache")).unwrap(); + let section = ¬ebook.catalog().sections[0]; + assert!(matches!(section.state, SectionState::Locked)); + assert!(matches!( + notebook.unlock(§ion.path, "wrong"), + Err(notebook::Error::Protected(Error::PasswordMismatch)) + )); + assert!(notebook.section(§ion.path, || {}).is_err()); + let key = notebook.unlock(§ion.path, &password).unwrap(); + let session = notebook + .section_unlocked(§ion.path, &key, || {}) + .unwrap(); + let pages = session.pages().unwrap(); + assert_eq!(pages.len(), 11); + assert!(pages.iter().all(|(_, title, _)| !title.is_empty())); + let title = &pages[0].1; + session.close().unwrap(); + // The replica holds the section as its file does: encrypted. + assert!(!leaks( + ¬ebook.replica_path(§ion.path).unwrap(), + title + )); +} + +/// Queued edits and their payloads are sealed in the replica, which opens again only under +/// the section's key, and publish into the file under it. +#[test] +fn queued_edits_are_sealed_and_publish_under_the_key() { + const SECRET: &str = "Queued in confidence"; + let (source, key) = protected("Opening line"); + let (space, text, _) = first_text(&source, &key); + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("cache.sqlite"); + let cache = Replica::open_or_create(&path, Some(&key), || Ok(source.clone())).unwrap(); + cache + .apply("Fixture", replace(space, text, 0..0, SECRET)) + .unwrap(); + let mut page = cache.page(space).unwrap(); + let PageObject::Outline(outline) = page + .objects + .iter_mut() + .find(|object| matches!(object, PageObject::Outline(_))) + .unwrap() + else { + unreachable!() }; + let mut file = outline.paragraphs[0].clone(); + file.id = onestore::page::text::new_id().unwrap(); + file.content = onestore::page::ParagraphContent::Attachment(onestore::page::Attachment { + id: onestore::page::text::new_id().unwrap(), + filename: "sealed.txt".into(), + source_path: None, + size: Some([24.0, 24.0]), + layout: Default::default(), + bytes: Some(std::sync::Arc::from(SECRET.as_bytes())), + preview: None, + recording: None, + tags: Vec::new(), + }); + outline.paragraphs.push(file); + let before = cache.page(space).unwrap(); + let ops = onestore::op::lower_page(&before, &page).unwrap(); + cache + .apply( + "Fixture", + Edit { + at: 134_000_000_000_000_001, + ops: ops.into_iter().map(|op| Op::Page { space, op }).collect(), + }, + ) + .unwrap(); + drop(cache); + assert!(!leaks(&path, SECRET)); + assert!(Replica::open(&path).is_err()); + assert!( + Replica::open_or_create( + &path, + Some(&Key::new("another").unwrap()), + || unreachable!() + ) + .is_err() + ); + let cache = Replica::open_or_create(&path, Some(&key), || unreachable!()).unwrap(); + assert_eq!(cache.pending().unwrap().len(), 2); + let mut server = Server::new(&source); + assert!(matches!( + cache.sync_once(&mut server).unwrap().edit, + Some((_, EditStatus::Published { .. })) + )); + drop(cache); + assert!(!leaks(&path, SECRET)); + assert!( + !server + .durable + .windows(SECRET.len()) + .any(|w| w == SECRET.as_bytes()) + ); + let arena = Arena::default(); + let stored = Section::unlock(&arena, server.durable.clone(), &key).unwrap(); + let published = stored.page(space).unwrap(); + assert!(texts(&published)[0].1.starts_with(SECRET)); + assert!(format!("{published:?}").contains("sealed.txt")); + let archived = directory.path().join("recovery.sqlite"); + Replica::open_or_create(&path, Some(&key), || unreachable!()) + .unwrap() + .export_recovery(&archived) + .unwrap(); + assert!(!leaks(&archived, SECRET)); + assert!(notebook::Recovery::open(&archived).is_err()); + assert!(notebook::Recovery::open_unlocked(&archived, &key).is_ok()); +} + +/// Edits of the same text on two copies of a protected section merge as an ordinary +/// section's do: the remote's stays the page, the local one becomes a conflict page, and +/// every revision stays under the key. +#[test] +fn a_conflict_in_a_protected_section_becomes_a_conflict_page() { + let (source, key) = protected("alpha beta gamma"); + let (space, text, _) = first_text(&source, &key); + let directory = tempfile::tempdir().unwrap(); + let cache = Replica::open_or_create(directory.path().join("c.sqlite"), Some(&key), || { + Ok(source.clone()) + }) + .unwrap(); + cache + .apply("Local", replace(space, text, 6..10, "LOCAL")) + .unwrap(); + let remote = { + let arena = Arena::default(); + let mut section = Section::unlock(&arena, source.clone(), &key).unwrap(); + section + .apply("Remote", &replace(space, text, 6..10, "REMOTE")) + .unwrap(); + section.seal().unwrap(); + section.image() + }; + let mut server = Server::new(&remote); + assert!(matches!( + cache.sync_once(&mut server).unwrap().edit, + Some((_, EditStatus::Published { .. })) + )); + let arena = Arena::default(); + let mut merged = Section::unlock(&arena, server.durable.clone(), &key).unwrap(); + assert_eq!( + texts(&merged.page(space).unwrap())[0].1, + "alpha REMOTE gamma" + ); + let conflicts = merged.conflicts().unwrap(); + assert_eq!(conflicts.len(), 1); + let conflict = merged.page(conflicts[0].1[0].space).unwrap(); + assert_eq!(texts(&conflict)[0].1, "alpha LOCAL gamma"); + let store = onestore::Store::parse(&server.durable).unwrap(); + assert!( + onestore::RevisionIndex::parse(&store) + .unwrap() + .spaces + .values() + .flat_map(|space| space.revisions.values()) + .all(|revision| revision.encrypted) + ); +} + +/// Setting a password rewrites the section under new identities that its TOC follows and +/// drops the plaintext replica; changing and removing it rewrite it again. +#[test] +fn a_password_is_set_changed_and_removed_in_a_notebook() { + const TEXT: &str = "Kept through every password"; + let temporary = tempfile::tempdir().unwrap(); + let folder = temporary.path().join("notebook"); + let cache = temporary.path().join("cache"); + let creation = onestore::PageCreation::new(None, Some(TEXT), "Fixture").unwrap(); + let mut notebook = Notebook::create(&folder, &cache, 0x00f0_a0c0, &creation).unwrap(); + let path = notebook.catalog().sections[0].path.clone(); + let plain = notebook.replica_path(&path).unwrap(); + notebook.section(&path, || {}).unwrap().close().unwrap(); + assert!(plain.exists()); + let identity = notebook.catalog().sections[0].file_id; + + let first = notebook + .set_password(&path, None, Some("first")) + .unwrap() + .unwrap(); + assert!(!plain.exists()); + let section = ¬ebook.catalog().sections[0]; + assert!(matches!(section.state, SectionState::Locked)); + assert_ne!(section.file_id, identity); + assert!( + notebook + .catalog() + .toc + .as_ref() + .unwrap() + .unresolved + .is_empty() + ); + let image = notebook.read_section(&path).unwrap(); + let utf16: Vec = TEXT.encode_utf16().flat_map(u16::to_le_bytes).collect(); + assert!(!image.windows(utf16.len()).any(|w| w == utf16)); + let key = notebook.unlock(&path, "first").unwrap(); + assert_eq!(key.secret(), first.secret()); + let session = notebook.section_unlocked(&path, &key, || {}).unwrap(); + assert_eq!(session.pages().unwrap()[0].1, TEXT); + session.close().unwrap(); + assert!(!leaks(¬ebook.replica_path(&path).unwrap(), TEXT)); + + assert!(notebook.set_password(&path, None, Some("second")).is_err()); + let second = notebook + .set_password(&path, Some(&key), Some("second")) + .unwrap() + .unwrap(); + assert!(notebook.unlock(&path, "first").is_err()); + let session = notebook.section_unlocked(&path, &second, || {}).unwrap(); + assert_eq!(session.pages().unwrap()[0].1, TEXT); + session.close().unwrap(); + + assert!( + notebook + .set_password(&path, Some(&second), None) + .unwrap() + .is_none() + ); + let section = ¬ebook.catalog().sections[0]; + assert!(matches!(section.state, SectionState::Readable { .. })); + let session = notebook.section(&path, || {}).unwrap(); + assert_eq!(session.pages().unwrap()[0].1, TEXT); +} + +/// A section whose edits wait to be published keeps its password until they are. +#[test] +fn a_password_waits_for_queued_edits() { + let temporary = tempfile::tempdir().unwrap(); + let folder = temporary.path().join("notebook"); + let creation = onestore::PageCreation::new(None, Some("Waiting"), "Fixture").unwrap(); + let mut notebook = Notebook::create( + &folder, + temporary.path().join("cache"), + 0x00f0_a0c0, + &creation, + ) + .unwrap(); + let path = notebook.catalog().sections[0].path.clone(); + let session = notebook.section(&path, || {}).unwrap(); + session.set_offline(true); + let (space, ..) = session.pages().unwrap()[0].clone(); + let page = session.page(space).unwrap(); + let mut edited = page.clone(); + edited.title = "Waiting still".into(); + let ops = onestore::op::lower_page(&page, &edited).unwrap(); + session + .apply( + "Fixture", + Edit { + at: 134_000_000_000_000_000, + ops: ops.into_iter().map(|op| Op::Page { space, op }).collect(), + }, + ) + .unwrap(); + session.written().unwrap(); + session.close().unwrap(); + assert!(notebook.set_password(&path, None, Some("early")).is_err()); + assert!(matches!( + notebook.catalog().sections[0].state, + SectionState::Readable { .. } + )); +} + +/// A notebook `here` makes, also opened `there` as another device opens it, with its first +/// section's path and the page title it starts with. +fn two_devices(temporary: &Path, title: &str) -> (Notebook, Notebook, String) { + let folder = temporary.join("notebook"); + let creation = onestore::PageCreation::new(None, Some(title), "Fixture").unwrap(); + let here = Notebook::create(&folder, temporary.join("here"), 0x00f0_a0c0, &creation).unwrap(); + let path = here.catalog().sections[0].path.clone(); + let there = Notebook::open(&folder, temporary.join("there")).unwrap(); + (here, there, path) +} + +/// Another device's replica of a section, plaintext, holds none of it once the section is +/// protected here and that device reads the notebook again. +#[test] +fn a_replica_drops_its_plaintext_once_its_section_is_protected_elsewhere() { + const TITLE: &str = "Read on the other device"; + let temporary = tempfile::tempdir().unwrap(); + let (mut here, mut there, path) = two_devices(temporary.path(), TITLE); + there.section(&path, || {}).unwrap().close().unwrap(); + let replica = there.replica_path(&path).unwrap(); + assert!(leaks(&replica, TITLE)); + here.set_password(&path, None, Some("secret")).unwrap(); + there.refresh().unwrap(); + assert!(!leaks(&replica, TITLE)); + let again = Notebook::open( + temporary.path().join("notebook"), + temporary.path().join("there"), + ); assert!(matches!( - notebook.apply_unlocked(§ion, "wrong", &mut unlocked, "Rust", &edit), - Err(notebook::Error::Protected( - onestore::protected::Error::PasswordMismatch - )) + again.unwrap().catalog().sections[0].state, + SectionState::Locked )); - let mut stale = notebook.unlock(§ion, password).unwrap(); - notebook - .apply_unlocked(§ion, password, &mut unlocked, "Rust", &edit) + assert!(!leaks(&replica, TITLE)); +} + +/// An open section that another device protects stops synchronizing as protected, and its +/// plaintext goes once it closes. +#[test] +fn an_open_section_protected_elsewhere_stops_as_protected() { + const TITLE: &str = "Open while protected"; + let temporary = tempfile::tempdir().unwrap(); + let (mut here, mut there, path) = two_devices(temporary.path(), TITLE); + let session = there.section(&path, || {}).unwrap(); + let replica = there.replica_path(&path).unwrap(); + here.set_password(&path, None, Some("secret")).unwrap(); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); + loop { + session.wake(); + let state = session.sync_status().unwrap().state(); + if state == notebook::session::SyncState::Protected { + break; + } + assert!(std::time::Instant::now() < deadline, "still {state:?}"); + std::thread::sleep(std::time::Duration::from_millis(50)); + } + session.close().unwrap(); + there.refresh().unwrap(); + assert!(!leaks(&replica, TITLE)); +} + +/// Edits another device queued before the section was protected wait in its replica until +/// that device unlocks the section, then publish under the key, the page they changed coming +/// back as a copy, as a page another client removed does; nothing stays in the clear. +#[test] +fn edits_queued_before_a_password_publish_under_it_once_unlocked() { + const TITLE: &str = "Protected meanwhile"; + const TYPED: &str = "Typed offline meanwhile"; + let temporary = tempfile::tempdir().unwrap(); + let (mut here, mut there, path) = two_devices(temporary.path(), TITLE); + let session = there.section(&path, || {}).unwrap(); + session.set_offline(true); + let (space, ..) = session.pages().unwrap()[0].clone(); + let page = session.page(space).unwrap(); + let Some(PageObject::Title(title)) = page.objects.first() else { + panic!("no title") + }; + let text = title.outlines[0].paragraphs[0].text().unwrap().id; + let end = TITLE.encode_utf16().count() as u32; + session + .apply("Fixture", replace(space, text, 0..end, TYPED)) .unwrap(); - // A view read before that edit no longer matches the file. + session.written().unwrap(); + session.close().unwrap(); + let held = there.replica_path(&path).unwrap(); + + here.set_password(&path, None, Some("secret")).unwrap(); + there.refresh().unwrap(); + assert_eq!(Replica::open(&held).unwrap().pending().unwrap().len(), 1); + + let key = there.unlock(&path, "secret").unwrap(); + assert!(!held.exists()); + let session = there.section_unlocked(&path, &key, || {}).unwrap(); + let titles: Vec = session + .pages() + .unwrap() + .into_iter() + .map(|(_, title, _)| title) + .collect(); + assert_eq!(titles, [TITLE, TYPED]); + published(&session); + session.close().unwrap(); + assert!(!leaks(&there.replica_path(&path).unwrap(), TYPED)); + let image = there.read_section(&path).unwrap(); + let utf16: Vec = TYPED.encode_utf16().flat_map(u16::to_le_bytes).collect(); + assert!(!image.windows(utf16.len()).any(|w| w == utf16)); + let stored = notebook::session::stored_pages_unlocked(&image, &key).unwrap(); + assert!(stored.iter().any(|page| page.page.title == TYPED)); +} + +/// Waits until `section` has published every edit. +fn published(section: ¬ebook::session::Section) { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); + section.wake(); + while !section.pending().unwrap().is_empty() { + assert!( + std::time::Instant::now() < deadline, + "edits were not published" + ); + std::thread::sleep(std::time::Duration::from_millis(20)); + } +} + +/// Appends `text` to the first paragraph of every page of `section`. +fn append(section: ¬ebook::session::Section, text: &str) { + for (space, ..) in section.pages().unwrap() { + let page = section.page(space).unwrap(); + let Some((id, current)) = texts(&page).into_iter().next() else { + continue; + }; + let end = current.encode_utf16().count() as u32; + section + .apply("Snowbound Test", replace(space, id, end..end, text)) + .unwrap(); + } + section.written().unwrap(); +} + +/// The native gate's candidate: a notebook whose sections Snowbound protected, edited under +/// their keys, gave another password, unprotected, and merged into a conflict page, each +/// from OneNote 2010's pages. `ONESTORE_PROTECTED_EXPORT` names a new directory receiving it +/// and its passwords for a cold open in OneNote. +#[test] +fn a_notebook_protected_through_its_sessions() { + let source = std::fs::read(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../corpus/protected-sections/source/synthetic.one" + )) + .unwrap(); + let pages = notebook::session::stored_pages(&source).unwrap(); + let temporary = tempfile::tempdir().unwrap(); + let folder = temporary.path().join("notebook"); + let cache = temporary.path().join("cache"); + let creation = onestore::PageCreation::new(None, Some("Gate"), "Snowbound Test").unwrap(); + let mut notebook = Notebook::create(&folder, &cache, 0x00f0_a0c0, &creation).unwrap(); + let names = ["Protected", "Edited", "Changed", "Removed", "Conflicted"]; + for name in names { + let path = notebook.create_section("", name, &creation).unwrap(); + let section = notebook.section(&path, || {}).unwrap(); + for stored in &pages { + section.import_page(&stored.page, "Snowbound Test").unwrap(); + } + published(§ion); + section.close().unwrap(); + } + let path = |name: &str| format!("{name}.one"); + let mut passwords = serde_json::Map::new(); + let mut keys = std::collections::BTreeMap::new(); + for name in names { + let password = format!("{name} gate password"); + let key = notebook + .set_password(&path(name), None, Some(&password)) + .unwrap() + .unwrap(); + passwords.insert(path(name), password.into()); + keys.insert(name, key); + } + // Edited under its key: text on every page, published as dependent revisions. + let section = notebook + .section_unlocked(&path("Edited"), &keys["Edited"], || {}) + .unwrap(); + for round in ["first", "second", "third"] { + append(§ion, &format!(" Edited {round} under the key.")); + published(§ion); + } + section.close().unwrap(); + // Changed: another password. + let changed = notebook + .set_password( + &path("Changed"), + Some(&keys["Changed"]), + Some("Changed again"), + ) + .unwrap() + .unwrap(); + passwords.insert(path("Changed"), "Changed again".into()); + let section = notebook + .section_unlocked(&path("Changed"), &changed, || {}) + .unwrap(); + append(§ion, " Written under the changed password."); + published(§ion); + section.close().unwrap(); + // Removed: no password. + assert!( + notebook + .set_password(&path("Removed"), Some(&keys["Removed"]), None) + .unwrap() + .is_none() + ); + passwords.insert(path("Removed"), serde_json::Value::Null); + // Conflicted: two copies edit one paragraph offline; the second to publish keeps a + // conflict page. + let second = Notebook::open(&folder, temporary.path().join("second")).unwrap(); + let key = &keys["Conflicted"]; + let ours = notebook + .section_unlocked(&path("Conflicted"), key, || {}) + .unwrap(); + let theirs = second + .section_unlocked(&path("Conflicted"), key, || {}) + .unwrap(); + for (section, word) in [(&ours, " Ours offline."), (&theirs, " Theirs offline.")] { + section.set_offline(true); + append(section, word); + } + ours.set_offline(false); + published(&ours); + theirs.set_offline(false); + published(&theirs); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); + while theirs.conflicts().unwrap().is_empty() { + assert!(std::time::Instant::now() < deadline, "no conflict page"); + std::thread::sleep(std::time::Duration::from_millis(20)); + } + published(&theirs); + ours.close().unwrap(); + theirs.close().unwrap(); + + let notebook = Notebook::open(&folder, temporary.path().join("check")).unwrap(); + for name in names { + let image = notebook.read_section(&path(name)).unwrap(); + let clear = |word: &str| { + let utf16: Vec = word.encode_utf16().flat_map(u16::to_le_bytes).collect(); + image.windows(utf16.len()).any(|w| w == utf16) + || image.windows(word.len()).any(|w| w == word.as_bytes()) + }; + for word in [ + "Edited first", + "Ours offline", + "Theirs offline", + "changed password", + ] { + assert!(!clear(word), "{name} holds {word} in the clear"); + } + assert_eq!(clear("positioned outline"), name == "Removed", "{name}"); + let state = ¬ebook + .catalog() + .sections + .iter() + .find(|section| section.path == path(name)) + .unwrap() + .state; + assert_eq!( + matches!(state, SectionState::Locked), + name != "Removed", + "{name}" + ); + } assert!( notebook - .apply_unlocked(§ion, password, &mut stale, "Rust", &edit) - .is_err() + .catalog() + .toc + .as_ref() + .unwrap() + .unresolved + .is_empty() ); - let (_, stored) = notebook.unlock(§ion, password).unwrap().pages.remove(0); - assert!(stored.objects == page.objects); + let key = notebook + .unlock(&path("Conflicted"), "Conflicted gate password") + .unwrap(); + let stored = Section::unlock( + &Arena::default(), + notebook.read_section(&path("Conflicted")).unwrap(), + &key, + ) + .unwrap() + .conflicts() + .unwrap(); + // Each page's first paragraph clashed, so each keeps a conflict page. + assert_eq!(stored.len(), 2); if let Some(export) = std::env::var_os("ONESTORE_PROTECTED_EXPORT") { let export = Path::new(&export); - std::fs::create_dir_all(export).unwrap(); - for entry in std::fs::read_dir(©).unwrap() { - let entry = entry.unwrap(); - std::fs::copy(entry.path(), export.join(entry.file_name())).unwrap(); - } + copy(&folder, &export.join("notebook")); + std::fs::write( + export.join("passwords.json"), + serde_json::to_vec_pretty(&passwords).unwrap(), + ) + .unwrap(); } } diff --git a/crates/notebook/tests/support/page_schedule.rs b/crates/notebook/tests/support/page_schedule.rs index 3fef6572686f99b94b5e8e0769fd47ddb5b8effe..d5226f90432e71a67bd44eb1382e0d62a7964ccb 100644 --- a/crates/notebook/tests/support/page_schedule.rs +++ b/crates/notebook/tests/support/page_schedule.rs @@ -5,12 +5,9 @@ use crate::{ use notebook::{Remote, Replica}; use onestore::{ CommitError, ExGuid, PageEdit, RevisionIndex, Stamp, Store, Transaction, - document::{Document, Format, Kind, Layout}, + document::{Document, Kind}, op::SectionOp, - page::{ - Outline, Page, PageObject, PageParagraph, Paragraph, ParagraphContent, TextObject, - text::new_id, - }, + page::{Page, PageObject}, }; use std::{io, sync::LazyLock}; @@ -54,50 +51,15 @@ fn pages(source: &[u8]) -> Vec<(ExGuid, ExGuid, u32)> { /// Adds a body outline holding one plain paragraph, returning its text identity. A page the /// section just created has no body text for `model_ops::insert_outline` to copy formatting from. pub fn body_outline(page: &mut Page, text: &str) -> ExGuid { - // The writer creates outline text with the store's default style; the model must state it. - let format = Format { - font: Some("Calibri".to_owned()), - font_size: Some(11.0), - language: Some(0x409), - ..Default::default() - }; - let content = TextObject { - id: new_id().unwrap(), - date_field: None, - text: Paragraph::new(text.into(), format), - tags: Vec::new(), - }; - let id = content.id; - let outline = Outline { - id: new_id().unwrap(), - title: false, - min_width: None, - layout: Layout { - x: Some(36.0), - y: Some(36.0), - ..Default::default() - }, - indents: Vec::new(), - paragraphs: vec![PageParagraph { - id: new_id().unwrap(), - parent: None, - level: 1, - style: None, - format: Default::default(), - content: ParagraphContent::Text(content), - lists: Vec::new(), - tags: Vec::new(), - media: Default::default(), - collapsed: false, - }], - unsupported: Vec::new(), - }; + let paragraph = ops::paragraph(text); + let id = paragraph.text().unwrap().id; let at = page .objects .iter() .position(|object| matches!(object, PageObject::Title(_))) .unwrap_or(page.objects.len()); - page.objects.insert(at, PageObject::Outline(outline)); + page.objects + .insert(at, ops::outline(36.0, 36.0, vec![paragraph])); id } diff --git a/crates/onestore/Cargo.toml b/crates/onestore/Cargo.toml index 2ed9a944a1fcb4fd27ef124559f00c92497c80a9..e0134c9a34ebc2834e8c1f6605f101f4c4131e45 100644 --- a/crates/onestore/Cargo.toml +++ b/crates/onestore/Cargo.toml @@ -4,9 +4,6 @@ version = "0.1.0" edition = "2024" publish = false -[features] -protected = ["dep:aes", "dep:cbc", "dep:roxmltree", "dep:sha1", "dep:subtle", "dep:zeroize"] - [dependencies] base64 = "0.22.1" bumpalo = "3.20.3" @@ -14,12 +11,12 @@ serde_json = "1.0.151" md5 = "0.8.1" getrandom = "0.4.3" serde = { version = "1.0.229", features = ["derive"] } -aes = { version = "0.9.3", features = ["zeroize"], optional = true } -cbc = { version = "0.2.1", features = ["zeroize"], optional = true } -roxmltree = { version = "0.21.1", optional = true } -sha1 = { version = "0.11.0", features = ["zeroize"], optional = true } -subtle = { version = "2.6.1", optional = true } -zeroize = { version = "1.9.0", optional = true } +aes = { version = "0.9.3", features = ["zeroize"] } +cbc = { version = "0.2.1", features = ["zeroize"] } +roxmltree = "0.21.1" +sha1 = { version = "0.11.0", features = ["zeroize"] } +subtle = "2.6.1" +zeroize = "1.9.0" [target.'cfg(target_os = "macos")'.dependencies] nix = { version = "0.31.3", default-features = false, features = ["fs"] } diff --git a/crates/onestore/README.md b/crates/onestore/README.md index 3f7f9b98c2c192cd879732ec221ccb7f1346db93..b96a852d7c890aaea1ccc22932ae849d06308221 100644 --- a/crates/onestore/README.md +++ b/crates/onestore/README.md @@ -47,7 +47,9 @@ harness also accepts `--client-profile release`. | `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 | | `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 | | `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` | -| `protected::UnlockedSection` (optional feature) | Own decoded buffers for explicit known-password inspection; clear those buffers on drop; derived document strings/exports remain caller-owned | +| `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) | +| `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 | +| `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 | | `create_section` | Create one page containing one plain-text paragraph and an author, including Unicode | | `PageCreation`, `SectionOp::Create` | Add an empty top-level page and its section entry atomically, under identities the intent retains across retries | | `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`. | `create_table_of_contents` | Create ordered section entries from filenames and file identities | | `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 | | `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 | +| `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`) | | `TextAttribute`, `PageOp::Format` | Change character formatting over a UTF-16 range while sharing immutable styles; preserve unselected runs | | `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 | | `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`. | `op::predict` | The page an op leaves, as the section stores it and reading it back shows it | | `read_file` | Read a snapshot under whole-file exclusion | | `read_snapshot` | Read a validated snapshot through fresh positioned I/O while the caller excludes maintenance | -| `CommitIo`, `confirm`, `confirm_file` | Supply another storage backend with equivalent exclusion and ordered durability; check that a stamp still holds | +| `CommitIo`, `confirm`, `confirm_file`, `Stamp::check` | Supply another storage backend with equivalent exclusion and ordered durability; check that a stamp still holds | +| `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 | Edits enter a kept-open `Section` as ops and leave as one appended revision per changed space; only section and page creation, imports and opening files handle @@ -78,14 +82,12 @@ navigation titles update in the same transaction; unsupported fields, protected objects and split surrogate pairs are rejected before writing. Appended snapshots cap revision dependency depth at 512 while retaining historical revisions. TOC snapshots can remap encoded CompactIDs -without changing their resolved references. Password-protected -sections retain their encrypted structure and payloads. With the optional -`protected` feature, `protected::UnlockedSection` opens native OneNote 2010 -AES-128/CBC, SHA-1 password wrappers into a borrowed document view. Incorrect -passwords, unsupported protection profiles and work-limit failures remain distinct. -The source stays encrypted. `UnlockedSection::apply` applies page ops and seals them -under the section's key (fresh IV per object, payloads under the file IV); `Section` -rejects protected sections. +without changing their resolved references. A password-protected section opens with +`Section::unlock` under the `Key` its password opens, and every revision it seals names +the key (MS-ONESTORE 2.5.19 asks that of each revision; OneNote names it only in those +without a dependency, and reads both). `Section::open` refuses one. Incorrect +passwords, unsupported protection profiles and work-limit failures remain distinct +(`protected::Error`). `PageCreation::new` appends, or inserts before the first page space of an existing series. `Some("")` creates an empty title field; `None` omits the title node. `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 and occurrence to withhold. The captured trace and result files are the regression oracle; replaying the native experiments requires the supplied Windows/share setup. -## Protected inspection +## Password-protected sections + +OneNote 2010 wraps an AES-128 key in Office's Agile password encryption (MS-OFFCRYPTO: +SHA-1 of a 16-byte salt and the UTF-16LE password, then 100,000 rounds of SHA-1 over the +round number and the hash; three block keys decrypt, with the salt as IV, the verifier +input, its SHA-1 zero-padded to 32 bytes, and the key). The XML sits after the words +`3, length, 16, length - 16` and the version `4.4` with flags `0x40`, inside the +encryption-data container every revision names. Each property object is stored as its +reference streams, a length, a random IV and the CBC encryption of a padding count, the +property bytes and random padding; read-only objects hash the clear bytes zero-padded to +8 bytes. Payloads are their length and bytes, randomly padded, under CBC with the IV +SHA-1(key data salt, block 0). Setting, changing or removing a password writes the section +anew, as OneNote does, under fresh identities so that no cache confuses the old file's +objects or payloads with the new ones (`corpus/protected-sections`). ```rust,no_run -# #[cfg(feature = "protected")] -# fn example() { -use onestore::{RevisionIndex, Store, protected::{Limits, UnlockedSection}}; -# fn inspect(bytes: &[u8], password: &str) -> Result<(), Box> { -let store = Store::parse(bytes)?; -let index = RevisionIndex::parse(&store)?; -let unlocked = UnlockedSection::open(&index, password, Limits::default())?; -let document = unlocked.document()?; -assert!(!document.pages()?.is_empty()); -drop(document); -drop(unlocked); +use onestore::{Arena, Section, protected::{Key, rekey}}; +# fn example(image: Vec, password: &str) -> Result<(), Box> { +let key = Key::open(&image, password)?; +let arena = Arena::default(); +let mut section = Section::unlock(&arena, image.clone(), &key)?; +assert!(!section.pages()?.is_empty()); +let changed = rekey(&image, Some(&key), Some(&Key::new("another password")?))?; +# drop(changed); # Ok(()) } -# } ``` -This example requires `features = ["protected"]`. The owner retains no password; -its derived key is cleared on drop. Its source-buffer views cannot outlive it; copies of parsed -strings, serialized models and exports have their own lifetimes. These copies are -plaintext, and dropping the unlock owner does not clear them. CBC has no general +A `Key` holds no password; its key is cleared when its last clone drops. A `Section` +decodes into its `Arena`, which is not cleared; drop both when the section locks. +`UnlockedSection` owns its decoded buffers and clears them on drop; the views it lends +cannot outlive it, while copies of parsed strings, serialized models and exports are +plaintext with lifetimes of their own. CBC has no general ciphertext-authentication guarantee; native read-only hashes and model validation check the corresponding structure. Internal payloads are decoded; external payload references remain references, and their protected decoding is not implemented. -For a deliberate plaintext diagnostic export, build the notebook exporter with -`--features protected` and pass `--password-file PATH` after the source and optional +For a deliberate plaintext diagnostic export, run the notebook exporter +(`examples/document`) with `--password-file PATH` after the source and optional new output directory. The file contains exact UTF-8 password bytes; no newline is removed or Unicode normalization applied. The exporter creates protected exports under an owner-only directory on Unix and reports protected external payloads as diff --git a/crates/onestore/examples/scratch_dump.rs b/crates/onestore/examples/scratch_dump.rs deleted file mode 100644 index cfe266dde6fec24fc8582ab552d823e8306dd21b..0000000000000000000000000000000000000000 --- a/crates/onestore/examples/scratch_dump.rs +++ /dev/null @@ -1,53 +0,0 @@ -use onestore::{ObjectData, PropertySets, RevisionIndex, Store, Value}; -use std::{env, fs}; - -fn main() -> Result<(), Box> { - let want: Vec = env::var("IDS") - .unwrap_or_default() - .split(',') - .filter(|s| !s.is_empty()) - .map(|s| u32::from_str_radix(s.trim_start_matches("0x"), 16).unwrap()) - .collect(); - for path in env::args_os().skip(1) { - let bytes = fs::read(&path)?; - let store = Store::parse(&bytes)?; - let index = RevisionIndex::parse(&store)?; - println!("== {path:?}"); - for space in index.spaces.keys() { - let Ok(resolved) = index.resolve_active(*space) else { continue }; - for (id, object) in &resolved.objects { - let ObjectData::Properties(data) = object.data else { continue }; - let sets = PropertySets::parse(data)?; - let root = &sets.sets[0]; - if !want.is_empty() && !root.iter().any(|p| want.contains(&p.id)) { - continue; - } - if env::var("LINE").is_ok() { - let get = |want: u32| root.iter().find(|p| p.id == want).and_then(|p| match p.value { Value::Bytes(b) => Some(b), _ => None }); - 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::>()))).unwrap_or_default(); - let ids: Vec = root.iter().map(|p| format!("{:x}", p.id)).collect(); - println!("{:#x} cc8={:02x?} media={} text={text:?} ids={}", object.jcid, get(0x1c001cc8), get(0x1c001c98).is_some(), ids.join(",")); - continue; - } - println!("{space:?} {id:?} jcid {:#x}", object.jcid); - for p in root { - match p.value { - Value::Bytes(b) => { - let text = if p.id == 0x1c001c22 { - String::from_utf16_lossy(&b.chunks_exact(2).map(|c| u16::from_le_bytes([c[0], c[1]])).collect::>()) - } else if p.id == 0x1c003498 { - String::from_utf8_lossy(b).into_owned() - } else { - String::new() - }; - println!(" {:#010x} {:02x?} {text}", p.id, &b[..b.len().min(48)]) - } - Value::NoData => println!(" {:#010x} -", p.id), - ref v => println!(" {:#010x} {v:?}", p.id), - } - } - } - } - } - Ok(()) -} diff --git a/crates/onestore/src/active.rs b/crates/onestore/src/active.rs index ccbce21a1520e6287d455d74c35eb3a2fc51d207..4413eedcf249dab2661cc25e2adb01c9b67ebc7c 100644 --- a/crates/onestore/src/active.rs +++ b/crates/onestore/src/active.rs @@ -4,11 +4,7 @@ use crate::{ write::{Commit, LiveRevision, PropertyObject, declared, differing}, }; #[cfg(test)] -use crate::{ - RevisionIndex, Store, - document::{Document, Space}, - write::chain_depth, -}; +use crate::{RevisionIndex, Store, document::Document, write::chain_depth}; use bumpalo::Bump; use std::{ collections::{BTreeMap, BTreeSet}, @@ -50,7 +46,10 @@ impl<'a> ActivePage<'a> { let view = document .spaces .remove(&space) - .and_then(Space::into_active) + .and_then(|mut space| { + let active = *space.contexts.get(&ExGuid::default())?; + space.revisions.remove(&active) + }) .ok_or(Error { offset: 0, message: "The active page is unavailable", diff --git a/crates/onestore/src/commit.rs b/crates/onestore/src/commit.rs index f01ce266633b9b0a75448f71244e369ae270c0b7..a1c8544768ef2008adc12a79c6ade2487beb5d34 100644 --- a/crates/onestore/src/commit.rs +++ b/crates/onestore/src/commit.rs @@ -65,9 +65,17 @@ impl FileIo { } })?); #[cfg(unix)] - let file = options.open(path)?; + let file = options.open(path.as_ref())?; #[cfg(all(unix, not(target_os = "macos")))] - file.try_lock()?; + { + use std::os::unix::fs::MetadataExt; + file.try_lock()?; + // Locked after it was opened, the file may since have been superseded. + let (open, named) = (file.metadata()?, std::fs::metadata(path)?); + if (open.dev(), open.ino()) != (named.dev(), named.ino()) { + return Err(ErrorKind::ResourceBusy.into()); + } + } Ok(Self { #[cfg(windows)] locks: onenote_locks(&file, write)?, @@ -255,6 +263,45 @@ pub fn confirm_file(path: impl AsRef, base: &Stamp) -> Result<(), CommitEr io.finish(result) } +/// Puts the file at `with` in the place of the file at `path`, provided `path` still has +/// `base`'s stamp, under the exclusion `commit_file` takes: the whole-image write OneNote 2010 +/// makes when it writes a section anew. +#[cfg(any(unix, windows))] +pub fn supersede_file( + path: impl AsRef, + base: &Stamp, + with: impl AsRef, +) -> Result<(), CommitError> { + let (path, with) = (path.as_ref(), with.as_ref()); + let failed = |state| move |error| CommitError { state, error }; + // Windows renames nothing over an open file, so there the old file goes aside first and is + // deleted once released, as OneNote's maintenance does. + let mut aside = path.as_os_str().to_owned(); + if cfg!(windows) { + aside.push(".old"); + } + let aside = std::path::PathBuf::from(aside); + let mut io = FileIo::open(path, true).map_err(failed(CommitState::NotCommitted))?; + let result = base + .check(&mut io) + .and_then(|()| std::fs::rename(path, &aside)) + .map_err(failed(CommitState::NotCommitted)) + .and_then(|()| { + std::fs::rename(with, path).map_err(|error| CommitError { + state: match std::fs::rename(&aside, path) { + Ok(()) => CommitState::NotCommitted, + Err(_) => CommitState::Unknown, + }, + error, + }) + }); + io.finish(result)?; + if aside != path { + std::fs::remove_file(aside).map_err(failed(CommitState::Committed))?; + } + Ok(()) +} + /// Checks that the file still has `base`'s stamp and flushes it, then refreshes its header /// version metadata. No revision is added; reread before committing on `base` again. /// The caller must hold OneNote-compatible exclusion and independently establish which @@ -357,8 +404,10 @@ impl Stamp { }) } - /// Reads the header and probes the length, without reading the body. - fn check(&self, io: &mut impl CommitIo) -> io::Result<()> { + /// Checks that the file `io` reads still has this stamp, reading its header and probing its + /// length without reading the body; `ResourceBusy` when it moved on. The caller holds + /// OneNote-compatible exclusion for whatever the check guards. + pub fn check(&self, io: &mut impl CommitIo) -> io::Result<()> { let mut header = [0; 1024]; crate::snapshot::read_exact( &mut |offset, output| io.read_at(offset, output), diff --git a/crates/onestore/src/create.rs b/crates/onestore/src/create.rs index 82ef2506ebdf24894c74acb4557fcfaa3a7de74f..cb60c5d436039370866608c35da7b4ece4a9b992 100644 --- a/crates/onestore/src/create.rs +++ b/crates/onestore/src/create.rs @@ -615,6 +615,30 @@ fn create(file_name: &str, file_type: FileType, spaces: Vec) -> Result } else { None }; + finish( + &mut output, + file_type, + log, + hashed_chunk, + root_chunk, + placement([0; 16], file_name), + )?; + let store = Store::parse(&output)?; + RevisionIndex::parse(&store)?.validate_current()?; + Ok(output) +} + +/// Ends an image whose lists `output` holds: the transaction log committing `log`'s +/// entries, then the header naming it, the root list and the hashed-chunk list. +fn finish( + output: &mut Vec, + file_type: FileType, + mut log: Vec, + hashed: Option, + root: Chunk, + placement: [u8; 20], +) -> Result<()> { + let is_section = file_type == FileType::Section; let checksum = if is_section { !crc(u32::MAX, &log, file_type) } else { @@ -624,7 +648,7 @@ fn create(file_name: &str, file_type: FileType, spaces: Vec) -> Result log.extend_from_slice(&checksum.to_le_bytes()); log.extend_from_slice(&u64::MAX.to_le_bytes()); log.extend_from_slice(&0_u32.to_le_bytes()); - let log_chunk = append(&mut output, &log)?; + let log_chunk = append(output, &log)?; output[..16].copy_from_slice(&[ 0xe4, 0x52, 0x5c, 0x7b, 0x8c, 0xd8, 0xa7, 0x4d, 0xae, 0xb1, 0x53, 0x78, 0xd0, 0x29, 0x96, 0xd3, @@ -648,11 +672,11 @@ fn create(file_name: &str, file_type: FileType, spaces: Vec) -> Result output[at..at + 4].copy_from_slice(&u32::MAX.to_le_bytes()); } output[96..100].copy_from_slice(&1_u32.to_le_bytes()); - output[128..148].copy_from_slice(&placement([0; 16], file_name)); - for (at, chunk) in hashed_chunk + output[128..148].copy_from_slice(&placement); + for (at, chunk) in hashed .map(|chunk| (148, chunk)) .into_iter() - .chain([(160, log_chunk), (172, root_chunk)]) + .chain([(160, log_chunk), (172, root)]) { output[at..at + 8].copy_from_slice(&chunk.offset.to_le_bytes()); 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) -> Result output[212..228].copy_from_slice(&fresh_guid()?); output[228..236].copy_from_slice(&1_u64.to_le_bytes()); output[236..252].copy_from_slice(&fresh_guid()?); - let store = Store::parse(&output)?; - RevisionIndex::parse(&store)?.validate_current()?; + Ok(()) +} + +/// A new section file declaring only its root object space `root`, which has no revision +/// yet, placed as `placement`: what a whole section's revisions are appended to. +pub(crate) fn skeleton(root: ExGuid, placement: [u8; 20]) -> Result> { + let mut output = vec![0; 1024]; + let mut payload = Vec::new(); + root.encode(&mut payload); + let mut start = payload.clone(); + start.extend_from_slice(&0_u32.to_le_bytes()); + let manifest = append_list(&mut output, 16, &[node(0x14, None, &start)?])?; + let space = [ + node(0xc, None, &payload)?, + node(0x10, Some(Reference::NodeList(manifest)), &[])?, + ]; + let space = append_list(&mut output, 17, &space)?; + let roots = [ + node(8, Some(Reference::NodeList(space)), &payload)?, + node(4, None, &payload)?, + ]; + let roots = append_list(&mut output, 18, &roots)?; + let mut log = Vec::new(); + for (id, count) in [(16_u32, 1_u32), (17, 2), (18, 2)] { + log.extend_from_slice(&id.to_le_bytes()); + log.extend_from_slice(&count.to_le_bytes()); + } + finish(&mut output, FileType::Section, log, None, roots, placement)?; Ok(output) } diff --git a/crates/onestore/src/document.rs b/crates/onestore/src/document.rs index 1df66edf660b164bbbe1964d4813b3e76c8f55e8..e49c25558d71f3c1663d866cf945caecef75de41 100644 --- a/crates/onestore/src/document.rs +++ b/crates/onestore/src/document.rs @@ -39,11 +39,6 @@ impl<'a> Space<'a> { pub fn active(&self) -> Option<&Revision<'a>> { self.revisions.get(self.contexts.get(&ExGuid::default())?) } - - pub fn into_active(mut self) -> Option> { - self.revisions - .remove(self.contexts.get(&ExGuid::default())?) - } } #[derive(Debug, Clone, Serialize)] diff --git a/crates/onestore/src/identity.rs b/crates/onestore/src/identity.rs new file mode 100644 index 0000000000000000000000000000000000000000..69efe9245e07b87a98fa59545ca65cb58e9745f5 --- /dev/null +++ b/crates/onestore/src/identity.rs @@ -0,0 +1,54 @@ +//! A file's identity and place, rewritten in an image held whole: OneNote 2010 saves a +//! section as a copy, and writes a package's files, outside any notebook, and gives each +//! file it unpacks an identity of its own (`corpus/notebook-package`). + +use crate::{Error, Header, create::placement, write::fresh_guid}; + +type Result = std::result::Result; + +/// Gives the file `image` holds a new `guidFile` and version (`guidFileVersion`, +/// `nFileVersionGeneration`, `guidDenyReadFileVersion`), returning the identity. +pub fn reidentify(image: &mut [u8]) -> Result<[u8; 16]> { + let header = Header::parse(image)?; + let identity = fresh_guid()?; + let generation = header.generation.checked_add(1).ok_or(Error { + offset: 228, + message: "File generation counter is exhausted", + })?; + image[16..32].copy_from_slice(&identity); + image[212..228].copy_from_slice(&fresh_guid()?); + image[228..236].copy_from_slice(&generation.to_le_bytes()); + image[236..252].copy_from_slice(&fresh_guid()?); + Ok(identity) +} + +/// Places the file `image` holds as `place` does a file on disk; without `at`, outside any +/// notebook, its `guidAncestor` and `crcName` zero. +pub fn place_image(image: &mut [u8], at: Option<([u8; 16], &str)>) -> Result<()> { + Header::parse(image)?; + image[128..148] + .copy_from_slice(&at.map_or([0; 20], |(ancestor, name)| placement(ancestor, name))); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_file_takes_a_new_identity_and_leaves_its_notebook() { + let mut image = crate::create_empty_section("A.one", None).unwrap(); + let before = Header::parse(&image).unwrap(); + place_image(&mut image, Some(([7; 16], "A.one"))).unwrap(); + assert_eq!(Header::parse(&image).unwrap().ancestor, [7; 16]); + let identity = reidentify(&mut image).unwrap(); + place_image(&mut image, None).unwrap(); + let after = Header::parse(&image).unwrap(); + assert_eq!(after.file_id, identity); + assert_ne!(after.file_id, before.file_id); + assert_ne!(after.version_id, before.version_id); + assert_eq!(after.generation, before.generation + 1); + assert_eq!((after.ancestor, after.name_crc), ([0; 16], 0)); + crate::Store::parse(&image).unwrap(); + } +} diff --git a/crates/onestore/src/lib.rs b/crates/onestore/src/lib.rs index d0a66ab0c5d0ea0d17dc754bfc1494f6858eeca5..3c63bd1870901eb7d0376719601b8991d0bf202c 100644 --- a/crates/onestore/src/lib.rs +++ b/crates/onestore/src/lib.rs @@ -10,6 +10,7 @@ mod edit; mod files; mod flush; mod formatting; +mod identity; mod insertion; mod objects; pub mod op; @@ -18,7 +19,6 @@ pub mod page; mod pages; mod paragraph; mod properties; -#[cfg(feature = "protected")] pub mod protected; mod revisions; mod section; @@ -33,10 +33,11 @@ mod write; pub use commit::{CommitError, CommitIo, CommitState, Stamp, Transaction, confirm, place}; #[cfg(any(unix, windows))] -pub use commit::{confirm_file, place_file, read_file, read_file_limited}; +pub use commit::{confirm_file, place_file, read_file, read_file_limited, supersede_file}; pub use create::{create_empty_section, create_section, create_table_of_contents}; pub use files::FileDataReference; pub use formatting::{FONT_SIZES, TextAttribute}; +pub use identity::{place_image, reidentify}; pub(crate) use insertion::Insertion; pub use objects::{Object, ObjectData, ObjectReferences, ResolvedRevision}; pub use outline::OutlineEdit; diff --git a/crates/onestore/src/objects.rs b/crates/onestore/src/objects.rs index 67f4d47fc5e9ed525d42998e30642101eb15ee4c..c031fbfbfbae80b43f9e99f5369f2d55b41eef96 100644 --- a/crates/onestore/src/objects.rs +++ b/crates/onestore/src/objects.rs @@ -190,11 +190,20 @@ impl<'a> Cursor<'a> { impl<'a> RevisionIndex<'a> { pub fn validate_current(&self) -> Result<()> { + self.validate_with(|space, rid| self.resolve(space, rid)) + } + + /// `validate_current`, each revision read through `resolve`, as a protected section's + /// decoded. + pub(crate) fn validate_with<'r>( + &self, + resolve: impl Fn(ExGuid, ExGuid) -> Result>, + ) -> Result<()> { let mut edges = BTreeMap::new(); let mut incoming = BTreeMap::<_, usize>::new(); for (osid, space) in &self.spaces { for rid in space.labels.values().copied().collect::>() { - let revision = self.resolve(*osid, rid)?; + let revision = resolve(*osid, rid)?; let mut targets = BTreeSet::new(); for oid in revision.reachable()? { let references = revision.objects[&oid].references()?; diff --git a/crates/onestore/src/op/content.rs b/crates/onestore/src/op/content.rs index c6d8bd2f322691db477ce155192fdf0a80545eba..66beee779032e6a1b64b079418aed314bf53e869 100644 --- a/crates/onestore/src/op/content.rs +++ b/crates/onestore/src/op/content.rs @@ -16,7 +16,7 @@ fn invalid(message: &'static str) -> Error { } /// The `{GUID}` form a file-data declaration uses to name a payload in the file-data store. -fn payload_reference(guid: [u8; 16]) -> String { +pub(crate) fn payload_reference(guid: [u8; 16]) -> String { let id = ExGuid { guid, n: 0 }.to_string(); format!("{}", id.split(',').next().unwrap()) } diff --git a/crates/onestore/src/op/lower.rs b/crates/onestore/src/op/lower.rs index 7f6f5638c06495ef977aebd2380b4fa496af92a9..9be09d99c47e29467816e25799fc71d1b9e985ef 100644 --- a/crates/onestore/src/op/lower.rs +++ b/crates/onestore/src/op/lower.rs @@ -44,6 +44,7 @@ pub fn lower( identity: None, created: None, margin_origin: [0.0; 2], + rtl: false, color: None, rule_lines: None, objects: vec![PageObject::Outline(Outline { diff --git a/crates/onestore/src/page/mod.rs b/crates/onestore/src/page/mod.rs index 5f96483457a6c5701258e20b1a6a61ab3a5de5b0..f8e0afa3d96195589ddda9de739ac154280c6316 100644 --- a/crates/onestore/src/page/mod.rs +++ b/crates/onestore/src/page/mod.rs @@ -159,6 +159,9 @@ pub struct Page { /// FILETIME ticks from the page's TopologyCreationTimeStamp. pub created: Option, pub margin_origin: [f32; 2], + /// Right-to-left page direction, whose positions keep the left-to-right frame. + #[serde(default)] + pub rtl: bool, /// The page's colour (View, Page Color), COLORREF; `None` is OneNote's "No color". #[serde(default)] pub color: Option, @@ -814,6 +817,7 @@ impl Page { let Kind::Page { margin_origin_x, margin_origin_y, + rtl, .. } = &root.kind else { @@ -845,6 +849,7 @@ impl Page { margin_origin_x.unwrap_or(1.0_f32 * 36.0), margin_origin_y.unwrap_or(0.4_f32 * 36.0), ], + rtl: rtl.unwrap_or(false), color: root .extra .first() diff --git a/crates/onestore/src/protected/crypto.rs b/crates/onestore/src/protected/crypto.rs index fa5863eab5211ac43249755892eac6b2997890b1..6f23363ca18c9138cef190684fc6828a6bc0486a 100644 --- a/crates/onestore/src/protected/crypto.rs +++ b/crates/onestore/src/protected/crypto.rs @@ -89,6 +89,48 @@ fn encrypt(key: &[u8; 16], iv: &[u8; 16], bytes: &mut [u8]) { .expect("block aligned"); } +/// Password-hash rounds OneNote 2010 writes. +const SPINS: u32 = 100_000; +/// MS-OFFCRYPTO's block keys for the verifier input, its hash and the wrapped key. +const VERIFIER_INPUT: [u8; 8] = [0xfe, 0xa7, 0xd2, 0x76, 0x3b, 0x4b, 0x9e, 0x79]; +const VERIFIER_VALUE: [u8; 8] = [0xd7, 0xaa, 0x0f, 0x6d, 0x30, 0x61, 0x34, 0x4e]; +const KEY_VALUE: [u8; 8] = [0x14, 0x6e, 0x0b, 0xe7, 0xab, 0xac, 0xd0, 0xd6]; + +/// The iterated password hash: SHA-1 of salt and UTF-16LE password, then `count` rounds of +/// SHA-1 over the round number and the previous hash. +fn seed(salt: &[u8; 16], password: &str, count: u32) -> Zeroizing<[u8; 20]> { + let mut hash = Sha1::new(); + hash.update(salt); + for word in password.encode_utf16() { + hash.update(word.to_le_bytes()); + } + let mut seed = Zeroizing::new(<[u8; 20]>::from(hash.finalize())); + for index in 0..count { + let mut hash = Sha1::new(); + hash.update(index.to_le_bytes()); + hash.update(seed.as_ref()); + *seed = hash.finalize().into(); + } + seed +} + +/// The AES key a block key derives from the password hash. +fn block(seed: &[u8; 20], label: [u8; 8]) -> Zeroizing<[u8; 16]> { + let mut hash = Sha1::new(); + hash.update(seed); + hash.update(label); + let derived = Zeroizing::new(<[u8; 20]>::from(hash.finalize())); + Zeroizing::new(derived[..16].try_into().unwrap()) +} + +/// The IV of payloads: SHA-1 of the key data's salt and block 0. +fn file_iv(salt: &[u8; 16]) -> [u8; 16] { + let mut hash = Sha1::new(); + hash.update(salt); + hash.update(0_u32.to_le_bytes()); + hash.finalize()[..16].try_into().unwrap() +} + impl Key { pub(super) fn open(data: &[u8], password: &str, rounds: &mut u64) -> Result { if data.len() > 65536 || password.len() > 65536 { @@ -146,47 +188,83 @@ impl Key { let mut verifier = Zeroizing::new(decoded::<16>(wrapped, "encryptedVerifierHashInput")?); let mut expected = Zeroizing::new(decoded::<32>(wrapped, "encryptedVerifierHashValue")?); let mut value = Zeroizing::new(decoded::<16>(wrapped, "encryptedKeyValue")?); - let mut hash = Sha1::new(); - hash.update(salt); - for word in password.encode_utf16() { - hash.update(word.to_le_bytes()); - } - let mut seed = Zeroizing::new(<[u8; 20]>::from(hash.finalize())); - for index in 0..count { - let mut hash = Sha1::new(); - hash.update(index.to_le_bytes()); - hash.update(seed.as_ref()); - *seed = hash.finalize().into(); - } + let seed = seed(&salt, password, count); for (label, bytes) in [ - ( - [0xfe, 0xa7, 0xd2, 0x76, 0x3b, 0x4b, 0x9e, 0x79], - verifier.as_mut_slice(), - ), - ( - [0xd7, 0xaa, 0x0f, 0x6d, 0x30, 0x61, 0x34, 0x4e], - expected.as_mut_slice(), - ), - ( - [0x14, 0x6e, 0x0b, 0xe7, 0xab, 0xac, 0xd0, 0xd6], - value.as_mut_slice(), - ), + (VERIFIER_INPUT, verifier.as_mut_slice()), + (VERIFIER_VALUE, expected.as_mut_slice()), + (KEY_VALUE, value.as_mut_slice()), ] { - let mut hash = Sha1::new(); - hash.update(seed.as_ref()); - hash.update(label); - let derived = Zeroizing::new(<[u8; 20]>::from(hash.finalize())); - decrypt(derived[..16].try_into().unwrap(), &salt, bytes)?; + decrypt(&block(&seed, label), &salt, bytes)?; } let actual = Zeroizing::new(<[u8; 20]>::from(Sha1::digest(verifier.as_slice()))); if !bool::from(actual.as_slice().ct_eq(&expected[..20])) { return Err(Error::PasswordMismatch); } - let mut hash = Sha1::new(); - hash.update(decoded::<16>(data_key, "saltValue")?); - hash.update(0_u32.to_le_bytes()); - let file_iv = hash.finalize()[..16].try_into().unwrap(); - Ok(Self { value, file_iv }) + Ok(Self { + value, + file_iv: file_iv(&decoded::<16>(data_key, "saltValue")?), + }) + } + + /// A fresh key for `password` and the encryption data that opens it, as OneNote 2010 + /// writes them: random salts, key and verifier, 100,000 SHA-1 rounds, no integrity block. + pub(super) fn create(password: &str) -> Result<(Self, Vec)> { + if password.len() > 65536 { + return Err(Error::Limit); + } + let random = |bytes: &mut [u8]| { + getrandom::fill(bytes).map_err(|_| invalid("System random source failed")) + }; + let (mut data_salt, mut salt) = ([0; 16], [0; 16]); + let mut value = Zeroizing::new([0; 16]); + let mut verifier = Zeroizing::new([0; 16]); + random(&mut data_salt)?; + random(&mut salt)?; + random(value.as_mut_slice())?; + random(verifier.as_mut_slice())?; + let seed = seed(&salt, password, SPINS); + let mut hash = Zeroizing::new([0; 32]); + hash[..20].copy_from_slice(&Sha1::digest(verifier.as_slice())); + let mut wrapped_value = *value; + let mut wrapped_verifier = *verifier; + let mut wrapped_hash = *hash; + encrypt(&block(&seed, VERIFIER_INPUT), &salt, &mut wrapped_verifier); + encrypt(&block(&seed, VERIFIER_VALUE), &salt, &mut wrapped_hash); + encrypt(&block(&seed, KEY_VALUE), &salt, &mut wrapped_value); + let profile = r#"saltSize="16" blockSize="16" keyBits="128" hashSize="20" cipherAlgorithm="AES" cipherChaining="ChainingModeCBC" hashAlgorithm="SHA1""#; + let xml = format!( + "\r\n\ + \ + \ + \ + ", + STANDARD.encode(data_salt), + STANDARD.encode(salt), + STANDARD.encode(wrapped_verifier), + STANDARD.encode(wrapped_hash), + STANDARD.encode(wrapped_value), + ); + let length = u32::try_from(24 + xml.len()).map_err(|_| Error::Limit)?; + let mut data = Vec::with_capacity(length as usize); + for word in [3, length, 16, length - 16] { + data.extend_from_slice(&word.to_le_bytes()); + } + data.extend_from_slice(&[4, 0, 4, 0, 64, 0, 0, 0]); + data.extend_from_slice(xml.as_bytes()); + Ok(( + Self { + value, + file_iv: file_iv(&data_salt), + }, + data, + )) + } + + /// The AES key the section's objects and payloads are encrypted under. + pub(super) fn value(&self) -> &[u8; 16] { + &self.value } pub(super) fn property(&self, input: &[u8]) -> Result>> { @@ -246,17 +324,21 @@ impl Key { Ok(output) } - /// The stored form of a file payload, the inverse of `file`. - pub(super) fn seal_file(&self, clear: &[u8]) -> Vec { + /// The stored form of a file payload, the inverse of `file`: its length, the bytes and + /// random padding to the block, as OneNote pads. + pub(super) fn seal_file(&self, clear: &[u8]) -> Result> { if clear.is_empty() { - return Vec::new(); + return Ok(Vec::new()); } - let mut output = Vec::with_capacity((8 + clear.len()).next_multiple_of(16)); + let length = (8 + clear.len()).next_multiple_of(16); + let mut output = Vec::with_capacity(length); output.extend_from_slice(&(clear.len() as u64).to_le_bytes()); output.extend_from_slice(clear); - output.resize(output.len().next_multiple_of(16), 0); + let end = output.len(); + output.resize(length, 0); + getrandom::fill(&mut output[end..]).map_err(|_| invalid("System random source failed"))?; encrypt(&self.value, &self.file_iv, &mut output); - output + Ok(output) } pub(super) fn file(&self, input: &[u8]) -> Result>> { diff --git a/crates/onestore/src/protected/mod.rs b/crates/onestore/src/protected/mod.rs index 44dfeffcc498bca32680a5d745f5a43e24e0a627..6896fcd68dbd57b9588ee8eca9964ad7ecd9bcac 100644 --- a/crates/onestore/src/protected/mod.rs +++ b/crates/onestore/src/protected/mod.rs @@ -1,24 +1,26 @@ -//! Explicit, in-memory opening of native OneNote 2010 protected sections. -//! -//! AES-128/CBC and SHA-1 Agile password wrappers are supported. Unknown wrappers -//! remain opaque through the ordinary storage/document APIs. CBC provides no -//! general ciphertext authentication; native read-only hashes and model checks -//! detect structural inconsistencies, not arbitrary changes to all content. +//! OneNote 2010's password-protected sections: an Office Agile password wrapper +//! (MS-OFFCRYPTO; SHA-1, 100,000 rounds) around an AES-128 key that encrypts every +//! property object (CBC, an IV of its own) and payload (CBC, one IV from the key data's +//! salt). Unknown wrappers stay opaque. CBC authenticates nothing; read-only hashes and +//! model checks catch structural damage, not every change to content. mod crypto; use crate::{ - ExGuid, FileDataReference, ObjectData, Reference, RevisionIndex, Transaction, + Chunk, ExGuid, FileDataReference, ObjectData, Reference, RevisionIndex, Store, document::Document, - op::{Edit, Op, OpError}, }; +use bumpalo::Bump; use std::{ + cell::RefCell, collections::{BTreeMap, BTreeSet}, fmt, + sync::Arc, }; use zeroize::Zeroizing; type Result = std::result::Result; +type Format = std::result::Result; #[derive(Debug)] pub enum Error { @@ -34,15 +36,34 @@ impl From for Error { } } +impl Error { + fn message(&self) -> &'static str { + match self { + Self::PasswordMismatch => "The password did not match the section verifier", + Self::Unsupported => "This protection format is not supported", + Self::Limit => "Opening the protected section exceeded its work limit", + Self::Invalid(error) => error.message, + } + } +} + +impl From for crate::Error { + fn from(error: Error) -> Self { + match error { + Error::Invalid(error) => error, + other => crate::Error { + offset: 0, + message: other.message(), + }, + } + } +} + impl fmt::Display for Error { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - Self::PasswordMismatch => { - f.write_str("The password did not match the section verifier") - } - Self::Unsupported => f.write_str("This protection format is not supported"), - Self::Limit => f.write_str("Opening the protected section exceeded its work limit"), Self::Invalid(error) => error.fmt(f), + other => f.write_str(other.message()), } } } @@ -79,17 +100,10 @@ impl Default for Limits { } } -struct Decoded<'a> { - stored: &'a [u8], - clear: Zeroizing>, -} - -/// Owns decoded buffers until dropped; returned views cannot outlive this owner. -/// -/// Passwords are not retained. Dropping this owner clears its derived key and -/// decoded buffers; a protected save works on ordinary buffers, which are not cleared. Owned strings or exports made from a `Document` are separate -/// caller-owned copies and must be disposed of by the caller when locking. -/// Opening never rewrites the source image or changes its protection state. +/// A protected section's labelled revisions decoded for reading as a `Document`, which +/// cannot outlive it. Holds no password; dropping it clears its keys and decoded buffers, +/// while strings and exports made from the `Document` are the caller's to dispose of. +/// Edits go through `Section::unlock`. /// /// ```compile_fail /// # use onestore::{RevisionIndex, protected::{Limits, UnlockedSection}}; @@ -103,22 +117,30 @@ struct Decoded<'a> { pub struct UnlockedSection<'a> { index: &'a RevisionIndex<'a>, /// By object space and stored address. - objects: BTreeMap<(ExGuid, usize), Decoded<'a>>, + objects: BTreeMap<(ExGuid, usize), Zeroizing>>, files: BTreeMap<[u8; 16], Zeroizing>>, - keys: BTreeMap<&'a [u8], crypto::Key>, } impl<'a> UnlockedSection<'a> { /// Verifies the password and every labeled revision before exposing a view. /// Metadata and password input are each bounded to 64 KiB. pub fn open(index: &'a RevisionIndex<'a>, password: &str, limits: Limits) -> Result { - Self::unlock(index, limits, |metadata, rounds| { + Self::open_with(index, limits, |metadata, rounds| { crypto::Key::open(metadata, password, rounds) }) } - /// `open` with each distinct encryption metadata's key from `key`. - fn unlock( + /// `open` with the key `Key::open` gave, which every space's encryption data must name. + pub fn unlock(index: &'a RevisionIndex<'a>, key: &Key, limits: Limits) -> Result { + Self::open_with(index, limits, |metadata, _| { + match metadata == &key.metadata[..] { + true => Ok(key.key.clone()), + false => Err(Error::PasswordMismatch), + } + }) + } + + fn open_with( index: &'a RevisionIndex<'a>, mut limits: Limits, mut key: impl FnMut(&[u8], &mut u64) -> Result, @@ -133,31 +155,10 @@ impl<'a> UnlockedSection<'a> { index, objects: BTreeMap::new(), files: BTreeMap::new(), - keys: BTreeMap::new(), }; let mut keys = BTreeMap::new(); let mut file_keys = BTreeMap::new(); - let mut hashes = BTreeMap::new(); - for node in index.store.lists.values().flat_map(|list| &list.nodes) { - if !matches!(node.id, 0xc4 | 0xc5) { - continue; - } - let Some(Reference::Data(chunk)) = node.reference else { - return Err(invalid("Read-only object has no data reference")); - }; - let bytes = index.store.chunk_data(chunk)?; - let expected: [u8; 16] = node - .payload - .last_chunk::<16>() - .copied() - .ok_or_else(|| invalid("Missing read-only object hash"))?; - if hashes - .insert(bytes.as_ptr().addr(), expected) - .is_some_and(|old| old != expected) - { - return Err(invalid("Inconsistent read-only hashes for one payload")); - } - } + let hashes = hashes(index.store)?; for (space_id, space) in &index.spaces { let mut metadata = None; for revision in space.revisions.values() { @@ -203,23 +204,10 @@ impl<'a> UnlockedSection<'a> { .checked_sub(bytes.len()) .ok_or(Error::Limit)?; let decoded = key.property(bytes)?; - if let Some(expected) = expected { - let mut hash = md5::Context::new(); - hash.consume(&decoded); - hash.consume(&[0; 7][..(8 - decoded.len() % 8) % 8]); - if hash.finalize().0 != *expected { - return Err(invalid( - "Decrypted read-only object hash mismatch", - )); - } + if expected.is_some_and(|expected| digest(&decoded) != *expected) { + return Err(invalid("Decrypted read-only object hash mismatch")); } - result.objects.insert( - identity, - Decoded { - stored: bytes, - clear: decoded, - }, - ); + result.objects.insert(identity, decoded); } ObjectData::File { .. } => { if let Some(FileDataReference::Internal(guid)) = @@ -251,7 +239,6 @@ impl<'a> UnlockedSection<'a> { } } } - result.keys = keys; for (space, info) in &index.spaces { for rid in info.labels.values().copied().collect::>() { result.resolve(*space, rid)?.reachable()?; @@ -276,7 +263,7 @@ impl<'a> UnlockedSection<'a> { offset: 0, message: "Protected object was not decoded", })?; - object.data = ObjectData::Properties(&decoded.clear); + object.data = ObjectData::Properties(decoded); } } Ok(revision) @@ -299,216 +286,345 @@ impl<'a> UnlockedSection<'a> { } } -impl UnlockedSection<'_> { - /// A plaintext section holding every object space's current revision and payloads - /// under their own identities, for writers that read ordinary sections. - fn twin(&self) -> std::result::Result>, crate::Error> { - use crate::write::{PropertyObject, RevisionEdit, append_revisions}; - let copy = |space: ExGuid, revision: ExGuid| { - let revision = self.resolve(space, revision)?; - // A revision still declares objects its edits detached; a new one holds live ones. - let live = revision.reachable()?; +/// A protected section's key, opened with its password or made for a new one. Holds no +/// password; the key is cleared when its last clone drops. +#[derive(Clone)] +pub struct Key { + key: crypto::Key, + /// The encryption data (MS-ONESTORE 2.5.19) that opens the key. + metadata: Arc<[u8]>, +} + +impl fmt::Debug for Key { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("Key") + } +} + +impl Key { + /// Opens the key of the protected section `image` with `password`. + pub fn open(image: &[u8], password: &str) -> Result { + let store = Store::parse(image)?; + let metadata = metadata(&store)?.ok_or(Error::Unsupported)?; + Ok(Self { + key: crypto::Key::open(metadata, password, &mut Limits::default().kdf_rounds)?, + metadata: Arc::from(metadata), + }) + } + + /// A fresh key for `password`, with OneNote 2010's encryption data. + pub fn new(password: &str) -> Result { + let (key, metadata) = crypto::Key::create(password)?; + Ok(Self { + key, + metadata: Arc::from(metadata), + }) + } + + /// The AES key the section's content is encrypted under, for keys derived from it. + pub fn secret(&self) -> &[u8; 16] { + self.key.value() + } + + pub(crate) fn metadata(&self) -> &[u8] { + &self.metadata + } +} + +/// The section `image` written anew, under `new` or in the clear, as OneNote 2010 rewrites +/// a section whose password it sets, changes or removes: the file and its object spaces +/// take new identities, each space keeps its labelled revisions, every one a checkpoint, +/// and the payloads come along. A protected `image` opens under `key`. +pub fn rekey(image: &[u8], key: Option<&Key>, new: Option<&Key>) -> Result> { + let store = Store::parse(image)?; + if !store.checksum_mismatches.is_empty() { + return Err(invalid( + "Cannot write a file with transaction checksum damage", + )); + } + let index = RevisionIndex::parse(&store)?; + let opened = match (key, metadata(&store)?) { + (Some(key), Some(_)) => Some(Opened::new(&store, key)?), + (None, None) => None, + (None, Some(_)) => return Err(Error::PasswordMismatch), + (Some(_), None) => return Err(invalid("The section is not protected")), + }; + let arena = Bump::new(); + let mut fresh = BTreeMap::new(); + for space in index.spaces.keys() { + if let std::collections::btree_map::Entry::Vacant(entry) = fresh.entry(space.guid) { + entry.insert(crate::write::fresh_guid()?); + } + } + // OneNote gives the payloads new identities too. + let mut files = BTreeMap::new(); + for guid in crate::write::declared_payloads(&store) { + files.insert(guid, crate::write::fresh_guid()?); + } + let rename = |id: ExGuid| ExGuid { + guid: fresh.get(&id.guid).copied().unwrap_or(id.guid), + n: id.n, + }; + let mut spaces = Vec::new(); + for (space, info) in &index.spaces { + let mut labelled: Vec<(ExGuid, Vec<(ExGuid, u32)>)> = Vec::new(); + // The current revision first, as OneNote writes it. + let mut labels: Vec<_> = info.labels.iter().collect(); + labels.sort_by_key(|(label, _)| **label != (ExGuid::default(), 1)); + for (label, rid) in labels { + match labelled.iter_mut().find(|(known, _)| known == rid) { + Some((_, names)) => names.push(*label), + None => labelled.push((*rid, vec![*label])), + } + } + let mut revisions = Vec::new(); + for (rid, labels) in labelled { + let mut resolved = index.resolve(*space, rid)?; + if let Some(opened) = &opened { + for object in resolved.objects.values_mut() { + if let ObjectData::Encrypted(stored) = object.data { + object.data = ObjectData::Properties(opened.property(&arena, stored)?); + } + } + } let mut objects = BTreeMap::new(); - for (id, object) in revision.objects.iter().filter(|(id, _)| live.contains(id)) { - let copy = match object.data { - ObjectData::File { - reference, + for id in resolved.reachable()? { + let mut object = resolved.objects[&id].clone(); + if let (Some(FileDataReference::Internal(guid)), ObjectData::File { extension, .. }) = + (object.file_reference()?, object.data) + && let Some(renamed) = files.get(&guid) + { + let reference: Vec = crate::op::content::payload_reference(*renamed) + .encode_utf16() + .flat_map(u16::to_le_bytes) + .collect(); + object.data = ObjectData::File { + reference: arena.alloc_slice_copy(&reference), extension, - } => { - let text = |bytes: &[u8]| { - String::from_utf16( - &bytes - .chunks_exact(2) - .map(|pair| u16::from_le_bytes([pair[0], pair[1]])) - .collect::>(), - ) - .map_err(|_| crate::Error { - offset: 0, - message: "Invalid UTF-16 file-data declaration", - }) - }; - let mut copy = - PropertyObject::file(*id, &text(reference)?, &text(extension)?)?; - copy.jcid = object.jcid; - copy.global_ids = std::sync::Arc::clone(&object.global_ids); - copy - } - _ => PropertyObject::from_object(object)?, - }; - objects.insert(*id, copy); - } - Ok::<_, crate::Error>((revision.roots, objects)) - }; - let payloads: Vec<_> = self - .files - .iter() - .map(|(id, bytes)| (*id, bytes.as_slice())) - .collect(); - let current = (ExGuid::default(), 1); - // The scaffold's root space takes the section's identity, then its content. - let mut scaffold = crate::create_section("twin.one", "", "")?; - let identity = |id: ExGuid| { - let mut bytes = Vec::new(); - id.encode(&mut bytes); - bytes - }; - let store = crate::Store::parse(&scaffold)?; - let placeholder = identity(RevisionIndex::parse(&store)?.root); - for at in 0..=scaffold.len() - 20 { - if scaffold[at..at + 20] == placeholder { - scaffold[at..at + 20].copy_from_slice(&identity(self.index.root)); - } - } - let mut twin = Zeroizing::new(append_revisions(&scaffold, &payloads, None, |_| { - let mut spaces = BTreeMap::new(); - for id in self.index.spaces.keys() { - let (roots, objects) = copy(*id, self.index.active(*id)?)?; - let edit = if *id == self.index.root { - RevisionEdit::Label { - context: current.0, - role: current.1, - roots, - objects, - } - } else { - RevisionEdit::Create { roots, objects } - }; - spaces.insert(*id, edit); - } - Ok(spaces) - })?); - // One revision per call and space: the remaining labels follow in rounds. - for round in 0.. { - let mut spaces = BTreeMap::new(); - for (id, space) in &self.index.spaces { - let label = space.labels.iter().filter(|(label, _)| **label != current); - if let Some(((context, role), revision)) = label.clone().nth(round) { - let (roots, objects) = copy(*id, *revision)?; - spaces.insert( - *id, - RevisionEdit::Label { - context: *context, - role: *role, - roots, - objects, - }, - ); + }; } + object.global_ids = Arc::new( + object + .global_ids + .iter() + .map(|(entry, guid)| (*entry, fresh.get(guid).copied().unwrap_or(*guid))) + .collect(), + ); + objects.insert(rename(id), object); } - if spaces.is_empty() { - break; - } - twin = Zeroizing::new(append_revisions(&twin, &[], None, |_| Ok(spaces))?); + let roots = resolved + .roots + .iter() + .map(|(role, id)| (*role, rename(*id))) + .collect(); + revisions.push(crate::write::Labelled { + labels, + revision: crate::ResolvedRevision { roots, objects }, + }); + } + spaces.push((rename(*space), revisions)); + } + let mut payloads = Vec::new(); + for (guid, renamed) in &files { + let stored = store.file_data(*guid)?; + let clear = match &opened { + Some(opened) => opened.file(&arena, stored)?, + None => stored, + }; + payloads.push((*renamed, clear)); + } + let placement = image[128..148].try_into().unwrap(); + let skeleton = crate::create::skeleton(rename(index.root), placement)?; + let written = crate::write::rewrite(skeleton, &spaces, &payloads, new)?; + let arena = crate::Arena::default(); + match new { + Some(new) => drop(crate::Section::unlock(&arena, written.clone(), new)?), + None => drop(crate::Section::open(&arena, written.clone())?), + } + Ok(written) +} + +/// The encryption data every revision of `store` names; none in a section without any. +fn metadata<'a>(store: &Store<'a>) -> Result> { + let mut found = None; + for node in store.lists.values().flat_map(|list| &list.nodes) { + if node.id != 0x7c { + continue; + } + let Some(Reference::Data(chunk)) = node.reference else { + return Err(invalid("Missing encryption key reference")); + }; + let data = store.encryption_key(chunk)?; + if found.replace(data).is_some_and(|old| old != data) { + return Err(Error::Unsupported); } - Ok(twin) } + Ok(found) } -impl UnlockedSection<'_> { - /// Applies `edit` to this section as `Section::apply` applies it to an ordinary one and - /// seals the result as one revision per changed space, stored under the section's key. - /// Page ops only: a protected section's page list is not edited. - pub fn apply(&self, author: &str, edit: &Edit) -> std::result::Result { - let failed = |error: crate::Error| OpError::Failed(error); - if self.keys.len() != 1 { - return Err(OpError::Unsupported( - "Only a section under one key takes edits", - )); +/// The hashes read-only declarations give their stored bytes, by address. +fn hashes(store: &Store<'_>) -> Result> { + let mut hashes = BTreeMap::new(); + for node in store.lists.values().flat_map(|list| &list.nodes) { + if !matches!(node.id, 0xc4 | 0xc5) { + continue; } - if edit.ops.iter().any(|op| matches!(op, Op::Section(_))) { - return Err(OpError::Unsupported( - "A protected section's page list is not edited", - )); + let Some(Reference::Data(chunk)) = node.reference else { + return Err(invalid("Read-only object has no data reference")); + }; + let bytes = store.chunk_data(chunk)?; + let expected: [u8; 16] = node + .payload + .last_chunk::<16>() + .copied() + .ok_or_else(|| invalid("Missing read-only object hash"))?; + if hashes + .insert(bytes.as_ptr().addr(), expected) + .is_some_and(|old| old != expected) + { + return Err(invalid("Inconsistent read-only hashes for one payload")); + } + } + Ok(hashes) +} + +/// A protected read-only declaration's hash: MD5 of the clear bytes zero-padded to 8. +pub(crate) fn digest(clear: &[u8]) -> [u8; 16] { + let mut hash = md5::Context::new(); + hash.consume(clear); + hash.consume(&[0; 7][..(8 - clear.len() % 8) % 8]); + hash.finalize().0 +} + +/// A `Section`'s key, with what it decoded under it: clear bytes by the address of the +/// stored bytes they decode, and stored bytes by the address of their clear bytes. +pub(crate) struct Opened<'a> { + key: Key, + /// The encryption data each revision names. + chunk: Chunk, + /// Read-only declarations' hashes by the address of their stored bytes. + hashes: BTreeMap, + clear: RefCell>, + stored: RefCell>, +} + +impl<'a> Opened<'a> { + /// `key` for the section `store` holds, every object space of which it must encrypt. + pub(crate) fn new(store: &Store<'_>, key: &Key) -> Result { + if metadata(store)?.ok_or(Error::Unsupported)? != &key.metadata[..] { + return Err(Error::PasswordMismatch); } - let twin = self.twin().map_err(failed)?; - let arena = crate::Arena::default(); - let mut section = crate::Section::open(&arena, twin.to_vec()).map_err(failed)?; - section.apply(author, edit)?; - section.seal().map_err(failed)?; - let applied = Zeroizing::new(section.image()); - drop(section); - let applied_store = crate::Store::parse(&applied).map_err(failed)?; - let applied_index = RevisionIndex::parse(&applied_store).map_err(failed)?; - // The twin's scaffold spaces are not the section's. - let edited = self - .index + let index = RevisionIndex::parse(store)?; + if index .spaces - .keys() - .map(|space| Ok((*space, applied_index.resolve_active(*space)?))) - .collect::, crate::Error>>() - .map_err(failed)?; - let edited: Vec<_> = edited - .iter() - .map(|(space, after)| (*space, after)) - .collect(); - let payloads = crate::write::declared_payloads(&applied_store) - .into_iter() - .map(|guid| Ok((guid, applied_store.file_data(guid)?))) - .collect::, crate::Error>>() - .map_err(failed)?; - let transaction = crate::write::squash(self.index, &edited, &payloads, Some(self)) - .map_err(failed)? - .ok_or(OpError::Unsupported("The edit changes nothing"))?; - let written = - crate::write::applied(self.index.store.data, Some(&transaction)).map_err(failed)?; - let store = crate::Store::parse(&written).map_err(failed)?; - let index = RevisionIndex::parse(&store).map_err(failed)?; - UnlockedSection::unlock(&index, Limits::default(), |metadata, _| { - self.keys - .get(metadata) - .cloned() - .ok_or(Error::PasswordMismatch) - }) - .map_err(|error| match error { - Error::Invalid(error) => failed(error), - _ => failed(crate::Error { - offset: 0, - message: "The written section does not unlock under its key", - }), - })?; - Ok(transaction) - } -} - -impl UnlockedSection<'_> { - /// The section key; `apply` admits sections with exactly one. - fn key(&self) -> &crypto::Key { - self.keys.values().next().expect("one section key") - } -} - -impl crate::write::Protection for UnlockedSection<'_> { - fn resolve( - &self, - space: ExGuid, - revision: ExGuid, - ) -> std::result::Result, crate::Error> { - self.resolve(space, revision) - } - - fn stored(&self, clear: &[u8]) -> Option<&[u8]> { - self.objects .values() - .find(|decoded| std::ptr::eq(decoded.clear.as_ptr(), clear.as_ptr())) - .map(|decoded| decoded.stored) - } - - fn seal_property(&self, clear: &[u8]) -> std::result::Result, crate::Error> { - let random = crate::Error { - offset: 0, - message: "System random source failed", - }; - let mut iv = [0; 16]; - getrandom::fill(&mut iv).map_err(|_| random)?; - self.key() - .seal_property(clear, iv) - .map_err(|error| match error { - Error::Invalid(error) => error, - _ => random, + .flat_map(|space| space.revisions.values()) + .any(|revision| !revision.encrypted) + { + return Err(invalid( + "A protected section stores a revision in the clear", + )); + } + let chunk = store + .lists + .values() + .flat_map(|list| &list.nodes) + .find_map(|node| match node.reference { + Some(Reference::Data(chunk)) if node.id == 0x7c => Some(chunk), + _ => None, }) + .ok_or(Error::Unsupported)?; + Ok(Self { + key: key.clone(), + chunk, + hashes: hashes(store)?, + clear: RefCell::default(), + stored: RefCell::default(), + }) } - fn seal_file(&self, clear: &[u8]) -> Vec { - self.key().seal_file(clear) + /// `key` for writing a new image whose encryption data lies at `chunk`. + pub(crate) fn sealing(key: &Key, chunk: Chunk) -> Self { + Self { + key: key.clone(), + chunk, + hashes: BTreeMap::new(), + clear: RefCell::default(), + stored: RefCell::default(), + } } + + /// The clear bytes of a stored object, decoded once into `arena`. + pub(crate) fn property(&self, arena: &'a Bump, stored: &'a [u8]) -> Format<&'a [u8]> { + if let Some(clear) = self.clear.borrow().get(&stored.as_ptr().addr()) { + return Ok(clear); + } + let decoded = self.key.key.property(stored).map_err(crate::Error::from)?; + if let Some(expected) = self.hashes.get(&stored.as_ptr().addr()) + && digest(&decoded) != *expected + { + return Err(crate::Error { + offset: 0, + message: "Decrypted read-only object hash mismatch", + }); + } + let clear: &'a [u8] = arena.alloc_slice_copy(&decoded); + self.sealed(clear, stored); + Ok(clear) + } + + /// The clear bytes of a stored payload, in `arena`. + pub(crate) fn file(&self, arena: &'a Bump, stored: &[u8]) -> Format<&'a [u8]> { + Ok(arena.alloc_slice_copy(&self.key.key.file(stored).map_err(crate::Error::from)?)) + } + + pub(crate) fn key(&self) -> &Key { + &self.key + } + + /// Records that `clear` is stored as `stored`. + pub(crate) fn sealed(&self, clear: &'a [u8], stored: &'a [u8]) { + self.clear + .borrow_mut() + .insert(stored.as_ptr().addr(), clear); + self.stored + .borrow_mut() + .insert(clear.as_ptr().addr(), stored); + } +} + +impl crate::write::Protection for Opened<'_> { + fn stored(&self, clear: &[u8]) -> Option<&[u8]> { + self.stored.borrow().get(&clear.as_ptr().addr()).copied() + } + + fn seal_property(&self, clear: &[u8]) -> Format> { + seal_property(&self.key, clear) + } + + fn seal_file(&self, clear: &[u8]) -> Format> { + self.key.key.seal_file(clear).map_err(crate::Error::from) + } + + fn open_property(&self, stored: &[u8]) -> Format>> { + self.key.key.property(stored).map_err(crate::Error::from) + } + + fn open_file(&self, stored: &[u8]) -> Format>> { + self.key.key.file(stored).map_err(crate::Error::from) + } + + fn metadata(&self) -> Chunk { + self.chunk + } +} + +/// A property object's stored form under `key`, with a fresh IV. +fn seal_property(key: &Key, clear: &[u8]) -> Format> { + let mut iv = [0; 16]; + getrandom::fill(&mut iv).map_err(|_| crate::Error { + offset: 0, + message: "System random source failed", + })?; + key.key.seal_property(clear, iv).map_err(crate::Error::from) } diff --git a/crates/onestore/src/section.rs b/crates/onestore/src/section.rs index 5e15d6e1754b7e9642a407329bd9820781295bbd..dfa489a32ab926a48810a488da8dfa4d41fe4f8b 100644 --- a/crates/onestore/src/section.rs +++ b/crates/onestore/src/section.rs @@ -7,9 +7,10 @@ use crate::{ active::{ActivePage, Changes, Files}, document::Revision, page::Page, + protected::{Key, Opened}, store::StoreState, write::{ - Appending, Commit, LiveRevision, PropertyObject, Sealing, Written, chain_depth, + Appending, Commit, LiveRevision, PropertyObject, Protection, Sealing, Written, chain_depth, check_transaction, declared, label_payload, node, replacement, unchanged, }, }; @@ -34,8 +35,8 @@ pub struct Section<'a> { /// The in-place writes of the sealed transactions, in order. patches: Vec<(u64, Vec)>, state: StoreState, - /// Payloads the opened images embed, by identity, with the image holding each. - files: &'a [([u8; 16], Chunk, &'a [u8])], + /// Payloads the opened images embed, by identity, as they read. + files: &'a [([u8; 16], Result<&'a [u8]>)], /// Payload identities the file-data store declares. declared: BTreeSet<[u8; 16]>, spaces: BTreeMap>, @@ -48,6 +49,8 @@ pub struct Section<'a> { root: ExGuid, /// While an edit applies, the spaces it changed as they were before it. undo: Option>, + /// A password-protected section's key and what it decoded. + unlocked: Option>, } #[derive(Default)] @@ -139,9 +142,17 @@ struct Open<'a> { impl<'a> Section<'a> { /// Parses and fully validates a section image. pub fn open(arena: &'a Arena, image: Vec) -> Result { - Self::open_with(arena, vec![image], |_, space| { - space.labels.get(&(ExGuid::default(), 1)).copied() - }) + Ok(Self::open_with(arena, vec![image], None, current)?) + } + + /// `open` for a password-protected section, decoding it under `key`, which edits are + /// sealed under too. Decoded bytes live in `arena` until it drops. + pub fn unlock( + arena: &'a Arena, + image: Vec, + key: &Key, + ) -> std::result::Result { + Self::open_with(arena, vec![image], Some(key), current) } /// Opens an earlier state of a section: each object space in `revisions` at the revision @@ -153,7 +164,7 @@ impl<'a> Section<'a> { images: Vec>, revisions: &BTreeMap, ) -> Result { - let section = Self::open_with(arena, images, |id, space| { + let section = Self::open_with(arena, images, None, |id, space| { revisions .get(id) .copied() @@ -170,15 +181,17 @@ impl<'a> Section<'a> { /// `open`, each object space at the revision `revision` chooses in the first image where it /// chooses one; a space it chooses none for is left out. Payloads are read from the image - /// that first declares them. + /// that first declares them. A protected section opens under `key`. fn open_with( arena: &'a Arena, images: Vec>, + key: Option<&Key>, revision: impl Fn(&ExGuid, &crate::ObjectSpace<'_>) -> Option, - ) -> Result { + ) -> std::result::Result { let mut opened = None; + let mut unlocked = None; let mut spaces = BTreeMap::new(); - let mut files: Vec<([u8; 16], Chunk, &'a [u8])> = Vec::new(); + let mut files: Vec<([u8; 16], Result<&'a [u8]>)> = Vec::new(); for image in images { let bytes: &'a [u8] = arena.0.alloc_slice_copy(&image); drop(image); @@ -187,28 +200,40 @@ impl<'a> Section<'a> { return Err(Error { offset: *offset, message: "Cannot write a file with transaction checksum damage", - }); + } + .into()); } if store.header.file_type != FileType::Section { return Err(Error { offset: 0, message: "Choose a section file", - }); + } + .into()); } let index = RevisionIndex::parse(&store)?; - index.validate_current()?; + if let (Some(key), None) = (key, &unlocked) { + unlocked = Some(Opened::new(&store, key)?); + } + // The parse borrows `bytes` only as long as its store; its slices lie in `bytes`, + // which the arena keeps. + let bound = |resolved| { + bind(arena, unlocked.as_ref(), resolved, |part| { + Ok(within(bytes, part)) + }) + }; + index.validate_with(|space, rid| bound(index.resolve(space, rid)?))?; for (id, space) in &index.spaces { let Some(rid) = revision(id, space).filter(|_| !spaces.contains_key(id)) else { continue; }; - let revision = bound(bytes, index.resolve(*id, rid)?); + let revision = bound(index.resolve(*id, rid)?)?; let history = space .labels .get(&(versions::HISTORY, 1)) .map(|history| -> Result> { Ok(History { rid: *history, - revision: bound(bytes, index.resolve(*id, *history)?), + revision: bound(index.resolve(*id, *history)?)?, depth: chain_depth(&index, *id, *history), pending: BTreeMap::new(), }) @@ -231,11 +256,24 @@ impl<'a> Section<'a> { return Err(Error { offset: node.offset, message: "File-data object lacks a data reference", - }); + } + .into()); }; // A payload an earlier image declares is the same bytes: it names them. if !files[..known].iter().any(|(declared, ..)| declared == guid) { - files.push((*guid, chunk, bytes)); + let (offset, length) = (chunk.offset as usize, chunk.length as usize); + let payload = bytes + .get(offset..offset + length) + .ok_or(Error { + offset, + message: "Chunk extends outside the data area", + }) + .and_then(|blob| crate::files::payload(blob, offset)) + .and_then(|payload| match &unlocked { + Some(unlocked) => unlocked.file(&arena.0, payload), + None => Ok(payload), + }); + files.push((*guid, payload)); } } if opened.is_none() { @@ -259,6 +297,7 @@ impl<'a> Section<'a> { broken: false, root, undo: None, + unlocked, }) } @@ -479,25 +518,14 @@ impl<'a> Section<'a> { fn files(&self) -> Files<'a> { let files = self.files; std::rc::Rc::new(move |guid| { - let start = files.partition_point(|(id, ..)| *id < guid); - match files[start..] - .iter() - .take_while(|(id, ..)| *id == guid) - .count() - { - 0 => Err(Error { + let start = files.partition_point(|(id, _)| *id < guid); + let mut declared = files[start..].iter().take_while(|(id, _)| *id == guid); + match (declared.next(), declared.next()) { + (None, _) => Err(Error { offset: 0, message: "File-data object is not declared", }), - 1 => { - let (_, chunk, bytes) = files[start]; - let (offset, length) = (chunk.offset as usize, chunk.length as usize); - let blob = bytes.get(offset..offset + length).ok_or(Error { - offset, - message: "Chunk extends outside the data area", - })?; - crate::files::payload(blob, offset) - } + (Some((_, payload)), None) => *payload, _ => Err(Error { offset: 0, message: "Duplicate file-data identity", @@ -706,6 +734,10 @@ impl<'a> Section<'a> { } self.broken = true; let arena = &self.arena.0; + let protection = self + .unlocked + .as_ref() + .map(|unlocked| unlocked as &dyn Protection); let mut appending = Appending::new(self.state.clone()); let mut sealed = Vec::new(); for (id, space) in &mut self.spaces { @@ -726,8 +758,7 @@ impl<'a> Section<'a> { created: &frozen.revised.created, }, &self.segments, - None, - None, + protection, &mut manifest, )?; } @@ -757,8 +788,7 @@ impl<'a> Section<'a> { created: &created, }, &self.segments, - None, - None, + protection, &mut manifest, )?; if commit.checkpoint { @@ -805,8 +835,7 @@ impl<'a> Section<'a> { created: &created, }, &self.segments, - None, - None, + protection, &mut manifest, )?; if commit.checkpoint { @@ -834,7 +863,7 @@ impl<'a> Section<'a> { .filter(|(guid, _)| !self.declared.contains(guid)) .copied() .collect(); - appending.payloads(&payloads, None)?; + appending.payloads(&payloads, protection)?; #[cfg_attr(not(test), expect(unused_mut))] let mut transaction = appending.finish()?; #[cfg(test)] @@ -892,6 +921,7 @@ impl<'a> Section<'a> { &written, &labels, &payloads, + protection, )?; let base = transaction.base.length; let segment: &'a [u8] = arena.alloc_slice_copy(&transaction.append); @@ -899,11 +929,20 @@ impl<'a> Section<'a> { self.patches.extend(transaction.patches.iter().cloned()); self.state = state.clone(); self.declared.extend(payloads.iter().map(|(guid, _)| *guid)); - let bind = |revision: &mut ResolvedRevision<'a>, stored: &[(ExGuid, crate::Chunk)]| { + // An object now reads from where it is stored, or a protected one's clear bytes + // from where they are. + let unlocked = &self.unlocked; + let rebind = |revision: &mut ResolvedRevision<'a>, stored: &[(ExGuid, Chunk)]| { for (object_id, chunk) in stored { let start = (chunk.offset - base) as usize; - revision.objects.get_mut(object_id).unwrap().data = - ObjectData::Properties(&segment[start..start + chunk.length as usize]); + let bytes = &segment[start..start + chunk.length as usize]; + let data = &mut revision.objects.get_mut(object_id).unwrap().data; + match (unlocked, *data) { + (Some(unlocked), ObjectData::Properties(clear)) => { + unlocked.sealed(clear, bytes) + } + _ => *data = ObjectData::Properties(bytes), + } } }; for (id, page, history) in &mut sealed { @@ -916,11 +955,11 @@ impl<'a> Section<'a> { space.rid = Some(*rid); space.restored = None; space.newest = Some(*rid); - bind(&mut open.file.revision, stored); + rebind(&mut open.file.revision, stored); } if let Some((rid, stored, live, _)) = history.take() { let mut revision = live.revision; - bind(&mut revision, &stored); + rebind(&mut revision, &stored); space.history = Some(History { rid, revision, @@ -969,7 +1008,11 @@ impl<'a> Section<'a> { } let mut image = self.image(); transaction.apply(&mut image)?; - *self = Self::open(self.arena, image)?; + let key = self + .unlocked + .as_ref() + .map(|unlocked| unlocked.key().clone()); + *self = Self::open_with(self.arena, vec![image], key.as_ref(), current)?; Ok(()) } } @@ -1022,37 +1065,50 @@ fn changed<'a>( })) } -/// `resolved`, parsed from `bytes`, borrowing them for their lifetime. -fn bound<'a>(bytes: &'a [u8], resolved: ResolvedRevision<'_>) -> ResolvedRevision<'a> { - ResolvedRevision { +/// `resolved` with every slice moved into bytes that live as long as the section by `bytes`, +/// a protected section's objects decoded under `unlocked`. +fn bind<'a>( + arena: &'a Arena, + unlocked: Option<&Opened<'a>>, + resolved: ResolvedRevision<'_>, + bytes: impl Fn(&[u8]) -> Result<&'a [u8]>, +) -> Result> { + let mut objects = BTreeMap::new(); + for (id, object) in resolved.objects { + let data = match (object.data, unlocked) { + (ObjectData::Properties(data), _) => ObjectData::Properties(bytes(data)?), + (ObjectData::Encrypted(data), Some(unlocked)) => { + ObjectData::Properties(unlocked.property(&arena.0, bytes(data)?)?) + } + (ObjectData::Encrypted(data), None) => ObjectData::Encrypted(bytes(data)?), + ( + ObjectData::File { + reference, + extension, + }, + _, + ) => ObjectData::File { + reference: bytes(reference)?, + extension: bytes(extension)?, + }, + }; + let object = crate::Object { + jcid: object.jcid, + reference_count: object.reference_count, + data, + global_ids: object.global_ids, + }; + objects.insert(id, object); + } + Ok(ResolvedRevision { roots: resolved.roots, - objects: resolved - .objects - .into_iter() - .map(|(id, object)| { - // The parse borrows `bytes` only as long as its store; its slices lie in - // `bytes`, which the arena keeps. - let data = match object.data { - ObjectData::Properties(data) => ObjectData::Properties(within(bytes, data)), - ObjectData::Encrypted(data) => ObjectData::Encrypted(within(bytes, data)), - ObjectData::File { - reference, - extension, - } => ObjectData::File { - reference: within(bytes, reference), - extension: within(bytes, extension), - }, - }; - let object = crate::Object { - jcid: object.jcid, - reference_count: object.reference_count, - data, - global_ids: object.global_ids, - }; - (id, object) - }) - .collect(), - } + objects, + }) +} + +/// The revision a space is current under. +fn current(_: &ExGuid, space: &crate::ObjectSpace<'_>) -> Option { + space.labels.get(&(ExGuid::default(), 1)).copied() } /// `part`, a slice of `image`, with the image's lifetime. @@ -1097,7 +1153,7 @@ mod tests { edited: &[(ExGuid, &ResolvedRevision<'_>)], payloads: &[([u8; 16], &[u8])], ) -> Vec { - let transaction = crate::write::squash(index, edited, payloads, None).unwrap(); + let transaction = crate::write::squash(index, edited, payloads).unwrap(); crate::write::applied(index.store.data, transaction.as_ref()).unwrap() } diff --git a/crates/onestore/src/section/versions.rs b/crates/onestore/src/section/versions.rs index d54a0f75ac9eb3f50e57d8d4757ee022f4cb52e6..45272c568e81ae68f82e3e8cb0eeb3641cde825e 100644 --- a/crates/onestore/src/section/versions.rs +++ b/crates/onestore/src/section/versions.rs @@ -6,7 +6,7 @@ use super::{Frozen, Open, Section, SpaceState, changed, one_page}; use crate::{ - Error, ExGuid, FileType, Object, ObjectData, ResolvedRevision, RevisionIndex, Store, + Error, ExGuid, FileType, Object, ResolvedRevision, RevisionIndex, Store, active::{ActivePage, manifest_pages}, create::{author_properties, current_timestamps, properties}, document::{Element, Kind, Revision}, @@ -144,8 +144,13 @@ impl<'a> Section<'a> { let image = self.image(); let store = Store::parse(&image)?; let resolved = RevisionIndex::parse(&store)?.resolve(space, revision)?; + let resolved = self.resident(&image, resolved)?; let (view, _) = Revision::parse(space, &resolved, FileType::Section, &mut |guid| { - store.file_data(guid) + let stored = store.file_data(guid)?; + match &self.unlocked { + Some(unlocked) => unlocked.file(&self.arena.0, stored), + None => Ok(stored), + } })?; one_page(&view, &manifest_pages(&view)) } @@ -258,8 +263,17 @@ impl<'a> Section<'a> { let store = Store::parse(&image)?; let index = RevisionIndex::parse(&store)?; let rid = self.version_revision(&index, space, context)?; - let resolved = index.resolve(space, rid)?; - // Object bytes lie in the section's segments, which outlive this image. + let revision = self.resident(&image, index.resolve(space, rid)?)?; + Ok((rid, revision, chain_depth(&index, space, rid))) + } + + /// `resolved`, read from `image`, the section's sealed image, with its bytes in the + /// section's segments, which outlive the image; a protected section's decoded. + fn resident( + &self, + image: &[u8], + resolved: ResolvedRevision<'_>, + ) -> Result> { let resident = |part: &[u8]| -> Result<&'a [u8]> { let offset = (part.as_ptr().addr() - image.as_ptr().addr()) as u64; self.segments @@ -275,34 +289,7 @@ impl<'a> Section<'a> { message: "A version's bytes lie outside the section", }) }; - let mut objects = BTreeMap::new(); - for (id, object) in resolved.objects { - let data = match object.data { - ObjectData::Properties(data) => ObjectData::Properties(resident(data)?), - ObjectData::Encrypted(data) => ObjectData::Encrypted(resident(data)?), - ObjectData::File { - reference, - extension, - } => ObjectData::File { - reference: resident(reference)?, - extension: resident(extension)?, - }, - }; - objects.insert( - id, - Object { - jcid: object.jcid, - reference_count: object.reference_count, - data, - global_ids: object.global_ids, - }, - ); - } - let revision = ResolvedRevision { - roots: resolved.roots, - objects, - }; - Ok((rid, revision, chain_depth(&index, space, rid))) + super::bind(self.arena, self.unlocked.as_ref(), resolved, resident) } /// Restores version `context` of page `space` as OneNote 2010's Restore Version does: the diff --git a/crates/onestore/src/toc.rs b/crates/onestore/src/toc.rs index 49fdd9854d36d96c8124f904f0509f367eadbe63..6930869c0d9990ff980b1b9430af67693482ba98 100644 --- a/crates/onestore/src/toc.rs +++ b/crates/onestore/src/toc.rs @@ -35,6 +35,12 @@ pub enum TocEdit { Remove { identity: [u8; 16], }, + /// The entry's file took the identity `with`, as a section does whose password is set, + /// changed or removed. + Reidentify { + identity: [u8; 16], + with: [u8; 16], + }, } fn component(name: &str) -> bool { @@ -87,6 +93,7 @@ pub fn edit_table_of_contents( stored.into_iter().map(|(_, entry)| entry).collect(); let mut created: BTreeMap = BTreeMap::new(); let mut renamed = BTreeSet::new(); + let mut reidentified = BTreeSet::new(); let mut color = None; for edit in edits { let position = |identity: &[u8; 16]| { @@ -178,6 +185,14 @@ pub fn edit_table_of_contents( let (id, _, _) = listed.remove(at); created.remove(&id); } + TocEdit::Reidentify { identity, with } => { + let at = position(identity)?; + if *with == [0; 16] || listed.iter().any(|(_, known, _)| known == with) { + return Err(invalid("The table of contents already lists that identity")); + } + listed[at].1 = *with; + reidentified.insert(listed[at].0); + } } } let listed = listed; @@ -188,12 +203,12 @@ pub fn edit_table_of_contents( TocEdit::Add { .. } | TocEdit::Order(_) | TocEdit::Remove { .. } ) }); - let transaction = build_on(&index, &[], None, |index| { + let transaction = build_on(&index, &[], |index| { let raw = index.resolve_active(space)?; let mut changed = BTreeMap::new(); let mut root_object = PropertyObject::from_object(&raw.objects[&root])?; let mut references = Vec::new(); - for (order, (id, _, filename)) in listed.iter().enumerate() { + for (order, (id, identity, filename)) in listed.iter().enumerate() { let mut object = match created.remove(id) { Some(object) => object, None => PropertyObject::from_object(&raw.objects[id])?, @@ -213,6 +228,9 @@ pub fn edit_table_of_contents( if renamed.contains(id) { updates.push((0x1c001d6b, crate::create::string(filename))); } + if reidentified.contains(id) { + updates.push((0x1c001d94, identity.to_vec())); + } let updates: Vec<(u32, &[u8])> = updates .iter() .map(|(id, bytes)| (*id, bytes.as_slice())) @@ -287,7 +305,7 @@ mod tests { let index = RevisionIndex::parse(&store).unwrap(); let space = Document::parse(&index).unwrap().root; let listed = entries(&toc); - let transaction = build_on(&index, &[], None, |index| { + let transaction = build_on(&index, &[], |index| { let raw = index.resolve_active(space)?; let mut changed = BTreeMap::new(); for ((id, _, _), order) in listed.iter().zip([7u32, 2, 2]) { diff --git a/crates/onestore/src/write.rs b/crates/onestore/src/write.rs index 3b517b11a5c411334ead07dcbab6a9f782839be1..6d0e7b337b9269050b2707573f173990cad3fc55 100644 --- a/crates/onestore/src/write.rs +++ b/crates/onestore/src/write.rs @@ -487,19 +487,11 @@ pub(crate) struct PropertyObject { pub(crate) enum RevisionEdit { Update(BTreeMap), - #[cfg(any(test, feature = "protected"))] + #[cfg(test)] Create { roots: BTreeMap, objects: BTreeMap, }, - /// A complete revision of an existing space under a context and role, without history. - #[cfg(feature = "protected")] - Label { - context: ExGuid, - role: u32, - roots: BTreeMap, - objects: BTreeMap, - }, } impl PropertyObject { @@ -801,14 +793,16 @@ impl PropertyObject { } } -/// A password-protected section's unlocked view, through which its revisions are -/// read as plaintext and written back in their stored form. +/// A password-protected section's key, under which a writer stores what it appends. pub(crate) trait Protection { - fn resolve(&self, space: ExGuid, revision: ExGuid) -> Result>; - /// The stored bytes a resolved object's plaintext was decoded from. + /// The stored bytes an object's clear bytes were decoded from. fn stored(&self, clear: &[u8]) -> Option<&[u8]>; fn seal_property(&self, clear: &[u8]) -> Result>; - fn seal_file(&self, clear: &[u8]) -> Vec; + fn seal_file(&self, clear: &[u8]) -> Result>; + fn open_property(&self, stored: &[u8]) -> Result>>; + fn open_file(&self, stored: &[u8]) -> Result>>; + /// The encryption data's chunk, which every revision names. + fn metadata(&self) -> Chunk; } /// The global-ID entries a group of objects declared together uses: each object's own @@ -1281,7 +1275,7 @@ pub(crate) fn revisions( let store = Store::parse(source)?; let index = RevisionIndex::parse(&store)?; index.validate_current()?; - build_on(&index, &[], None, edit) + build_on(&index, &[], edit) } /// A test fixture: `source` with `edit`'s revisions appended. @@ -1290,32 +1284,17 @@ pub(crate) fn write_revisions( source: &[u8], edit: impl FnOnce(&RevisionIndex<'_>) -> Result>, ) -> Result> { - publish(source, &[], None, true, edit) + publish(source, true, edit) } -/// `source` with `edit`'s revisions and the `payloads` appended, without validating that -/// current revisions are complete: a protected section is validated by unlocking it, -/// through `protection`. -#[cfg(feature = "protected")] -pub(crate) fn append_revisions( - source: &[u8], - payloads: &[([u8; 16], &[u8])], - protection: Option<&dyn Protection>, - edit: impl FnOnce(&RevisionIndex<'_>) -> Result>, -) -> Result> { - publish(source, payloads, protection, false, edit) -} - -#[cfg(any(test, feature = "protected"))] +#[cfg(test)] fn publish( source: &[u8], - payloads: &[([u8; 16], &[u8])], - protection: Option<&dyn Protection>, validate: bool, edit: impl FnOnce(&RevisionIndex<'_>) -> Result>, ) -> Result> { // The parsed source is released before the result is parsed. - let output = build(source, payloads, protection, validate, edit)?; + let output = build(source, validate, edit)?; check(&output, validate)?; Ok(output) } @@ -1332,11 +1311,9 @@ pub(crate) fn check(output: &[u8], validate: bool) -> Result<()> { /// The written image, unchecked: `check` follows once the caller has released whatever /// `edit` borrowed. -#[cfg(any(test, feature = "protected"))] +#[cfg(test)] fn build( source: &[u8], - payloads: &[([u8; 16], &[u8])], - protection: Option<&dyn Protection>, validate: bool, edit: impl FnOnce(&RevisionIndex<'_>) -> Result>, ) -> Result> { @@ -1345,10 +1322,7 @@ fn build( if validate { index.validate_current()?; } - applied( - source, - build_on(&index, payloads, protection, edit)?.as_ref(), - ) + applied(source, build_on(&index, &[], edit)?.as_ref()) } /// `write_revision` on a source the caller has parsed and validated. @@ -1360,7 +1334,7 @@ pub(crate) fn write_revision_on( ) -> Result> { let output = applied( index.store.data, - build_on(index, &[], None, update(space, edit))?.as_ref(), + build_on(index, &[], update(space, edit))?.as_ref(), )?; check(&output, true)?; Ok(output) @@ -1377,7 +1351,6 @@ thread_local! { pub(crate) fn build_on( index: &RevisionIndex<'_>, payloads: &[([u8; 16], &[u8])], - protection: Option<&dyn Protection>, edit: impl FnOnce(&RevisionIndex<'_>) -> Result>, ) -> Result> { #[cfg(test)] @@ -1391,10 +1364,6 @@ pub(crate) fn build_on( message: "Cannot write a file with transaction checksum damage", }); } - let resolve = |space, rid| match protection { - Some(protection) => protection.resolve(space, rid), - None => index.resolve(space, rid), - }; let changes = edit(index)?; let mut appending = Appending::new(store.state()?); for (space, change) in changes { @@ -1402,9 +1371,15 @@ pub(crate) fn build_on( let (rid, label, revision, replacements, new_space) = match change { RevisionEdit::Update(objects) => { let rid = index.active(space)?; - (Some(rid), current, resolve(space, rid)?, objects, false) + ( + Some(rid), + current, + index.resolve(space, rid)?, + objects, + false, + ) } - #[cfg(any(test, feature = "protected"))] + #[cfg(test)] RevisionEdit::Create { roots, objects } => { if !is_section || space.guid == [0; 16] || index.spaces.contains_key(&space) { return Err(Error { @@ -1429,30 +1404,6 @@ pub(crate) fn build_on( true, ) } - #[cfg(feature = "protected")] - RevisionEdit::Label { - context, - role, - roots, - objects, - } => { - if !is_section || role > 0xffff || !index.spaces.contains_key(&space) { - return Err(Error { - offset: 0, - message: "Choose a label in a section's object space", - }); - } - ( - None, - (context, role), - crate::ResolvedRevision { - roots, - objects: BTreeMap::new(), - }, - objects, - false, - ) - } }; let depth = rid.map_or(0, |rid| chain_depth(index, space, rid)); let mut live = LiveRevision::new(revision, depth)?; @@ -1478,25 +1429,6 @@ pub(crate) fn build_on( }) .collect::>>()?, )?; - // A dependent revision inherits its key, as OneNote writes it. - let key = if protection.is_some() && commit.checkpoint { - Some( - index - .spaces - .get(&space) - .and_then(|space| { - space.revisions.values().find_map(|revision| { - revision.nodes.first().filter(|node| node.id == 0x7c) - }) - }) - .ok_or(Error { - offset: 0, - message: "Protected revisions continue a protected object space", - })?, - ) - } else { - None - }; appending.revision( &Sealing { space, @@ -1510,11 +1442,10 @@ pub(crate) fn build_on( created: &created, }, &[(0, source)], - protection, - key, + None, )?; } - appending.payloads(payloads, protection)?; + appending.payloads(payloads, None)?; Ok(appending.finish()?.map(|(transaction, _)| transaction)) } @@ -1528,7 +1459,6 @@ pub(crate) fn applied(source: &[u8], transaction: Option<&crate::Transaction>) - } /// Payload identities the file-data store of `store` declares, in order. -#[cfg(any(test, feature = "protected"))] pub(crate) fn declared_payloads(store: &Store<'_>) -> Vec<[u8; 16]> { store .lists @@ -1541,13 +1471,12 @@ pub(crate) fn declared_payloads(store: &Store<'_>) -> Vec<[u8; 16]> { /// Writes the live objects of each `edited` revision that differ from its space's active /// revision in the validated `source` as one revision per space, embedding the `payloads` -/// it lacks. A protected `source` takes the revisions its plaintext twin gained. -#[cfg(any(test, feature = "protected"))] +/// it lacks. +#[cfg(test)] pub(crate) fn squash( source: &RevisionIndex<'_>, edited: &[(ExGuid, &crate::ResolvedRevision<'_>)], payloads: &[([u8; 16], &[u8])], - protection: Option<&dyn Protection>, ) -> Result> { let existing = declared_payloads(source.store); let payloads: Vec<_> = payloads @@ -1555,13 +1484,10 @@ pub(crate) fn squash( .filter(|(guid, _)| !existing.contains(guid)) .copied() .collect(); - build_on(source, &payloads, protection, |index| { + build_on(source, &payloads, |index| { let mut changes = BTreeMap::new(); for (space, after) in edited { - let before = match protection { - Some(protection) => protection.resolve(*space, index.active(*space)?)?, - None => index.resolve_active(*space)?, - }; + let before = index.resolve_active(*space)?; if before.roots != after.roots { return Err(Error { offset: 0, @@ -1590,6 +1516,111 @@ pub(crate) fn squash( }) } +/// A whole revision for `rewrite`, with the labels it is current under. +pub(crate) struct Labelled<'a> { + pub labels: Vec<(ExGuid, u32)>, + pub revision: crate::ResolvedRevision<'a>, +} + +/// `skeleton` (`create::skeleton`) with `spaces` appended, each revision a checkpoint under +/// its labels, and `payloads`, all stored under `key` when one is given. +pub(crate) fn rewrite( + skeleton: Vec, + spaces: &[(ExGuid, Vec>)], + payloads: &[([u8; 16], &[u8])], + key: Option<&crate::protected::Key>, +) -> Result> { + let store = Store::parse(&skeleton)?; + let state = store.state()?; + let declared: BTreeSet = state.spaces.keys().copied().collect(); + let mut appending = Appending::new(state); + let opened = key + .map(|key| -> Result<_> { + let mut container = 0xfb6ba385dad1a067_u64.to_le_bytes().to_vec(); + container.extend_from_slice(key.metadata()); + container.extend_from_slice(&0x2649294f8e198b3c_u64.to_le_bytes()); + Ok(crate::protected::Opened::sealing( + key, + appending.append(&container)?, + )) + }) + .transpose()?; + let protection = opened.as_ref().map(|opened| opened as &dyn Protection); + for (space, revisions) in spaces { + let new = !declared.contains(space); + let mut manifest = Vec::new(); + let mut labels = Vec::new(); + for ( + index, + Labelled { + labels: names, + revision, + }, + ) in revisions.iter().enumerate() + { + let mut live = LiveRevision::new( + crate::ResolvedRevision { + roots: revision.roots.clone(), + objects: BTreeMap::new(), + }, + 0, + )?; + let commit = live.commit( + revision + .objects + .iter() + .map(|(id, object)| { + let mut object = object.clone(); + object.reference_count = 0; + (*id, object) + }) + .collect(), + )?; + let all: BTreeSet = live.revision.objects.keys().copied().collect(); + let (first, others) = names.split_first().ok_or(Error { + offset: 0, + message: "A rewritten revision needs a label", + })?; + let (rid, _) = appending.manifest( + &Sealing { + space: *space, + previous: None, + new_space: new && index == 0, + label: *first, + rid: None, + live: &live, + commit: &commit, + replaced: &all, + created: &all, + }, + &[], + protection, + &mut manifest, + )?; + for (context, role) in others { + let mut payload = Vec::new(); + rid.encode(&mut payload); + payload.extend_from_slice(&role.to_le_bytes()); + let id = if *context == ExGuid::default() { + 0x5c + } else { + context.encode(&mut payload); + 0x5d + }; + labels.push(node(id, None, &payload)?); + } + } + manifest.extend(labels); + appending.close(*space, new, &manifest)?; + } + appending.payloads(payloads, protection)?; + let (transaction, _) = appending.finish()?.ok_or(Error { + offset: 0, + message: "A rewritten section holds nothing", + })?; + applied(&skeleton, Some(&transaction)) +} + /// A space's revision as `LiveRevision::commit` left it, ready to append. pub(crate) struct Sealing<'r, 'a> { pub space: ExGuid, @@ -1664,7 +1695,7 @@ impl Appending { self.base.length + self.append.len() as u64 } - fn append(&mut self, bytes: &[u8]) -> Result { + pub(crate) fn append(&mut self, bytes: &[u8]) -> Result { let length = u64::from(u32::try_from(bytes.len()).map_err(|_| Error { offset: 0, message: "Chunk exceeds the encoded length limit", @@ -1743,10 +1774,9 @@ impl Appending { sealing: &Sealing<'_, '_>, segments: &[(u64, &[u8])], protection: Option<&dyn Protection>, - key: Option<&crate::Node<'_>>, ) -> Result<(ExGuid, Vec<(ExGuid, Chunk)>)> { let mut manifest = Vec::new(); - let written = self.manifest(sealing, segments, protection, key, &mut manifest)?; + let written = self.manifest(sealing, segments, protection, &mut manifest)?; self.close(sealing.space, sealing.new_space, &manifest)?; Ok(written) } @@ -1759,7 +1789,6 @@ impl Appending { sealing: &Sealing<'_, '_>, segments: &[(u64, &[u8])], protection: Option<&dyn Protection>, - key: Option<&crate::Node<'_>>, manifest: &mut Vec>, ) -> Result<(ExGuid, Vec<(ExGuid, Chunk)>)> { let is_section = self.state.file_type == FileType::Section; @@ -1863,12 +1892,14 @@ impl Appending { None, &start, )?); - if protection.is_some() && checkpoint { - let key = key.ok_or(Error { - offset: 0, - message: "Protected revisions continue a protected object space", - })?; - manifest.push(node(0x7c, key.reference, key.payload)?); + // MS-ONESTORE 2.5.19 has every revision of a protected space name its key; OneNote + // names it only in revisions without a dependency, and reads either. + if let Some(protection) = protection { + manifest.push(node( + 0x7c, + Some(Reference::Data(protection.metadata())), + &[], + )?); } let mut stored = Vec::new(); for (table, objects) in groups { @@ -2084,7 +2115,9 @@ impl Appending { 0xe7, 0x16, 0xe3, 0xbd, 0x65, 0x26, 0x11, 0x45, 0xa4, 0xc4, 0x8d, 0x4d, 0x0b, 0x7a, 0x9e, 0xac, ]; - let sealed = protection.map(|protection| protection.seal_file(payload)); + let sealed = protection + .map(|protection| protection.seal_file(payload)) + .transpose()?; let payload = sealed.as_deref().unwrap_or(*payload); blob.extend_from_slice(&(payload.len() as u64).to_le_bytes()); blob.extend_from_slice(&[0; 12]); @@ -2279,6 +2312,7 @@ pub(crate) struct Written<'r, 'a> { /// object parses, names identities its group's table holds and objects reachable after its /// revision, carries its incremental reference count and, if read-only, its MD5; roots stay /// unless a space is new. Validates nothing the transaction leaves as it was. +#[allow(clippy::too_many_arguments)] pub(crate) fn check_transaction( before: &StoreState, after: &StoreState, @@ -2287,8 +2321,10 @@ pub(crate) fn check_transaction( revisions: &[Written<'_, '_>], labels: &[(ExGuid, ExGuid, ExGuid)], payloads: &[([u8; 16], &[u8])], + protection: Option<&dyn Protection>, ) -> Result<()> { let wrong = |message| Error { offset: 0, message }; + let encoding: [u8; 2] = if protection.is_some() { [2, 0] } else { [0, 0] }; let base = transaction.base.length; // A chunk's bytes with this transaction's writes: nothing read here is patched earlier. let read = |chunk: Chunk| -> Result> { @@ -2449,13 +2485,20 @@ pub(crate) fn check_transaction( || c.exguid()? != rid || c.exguid()? != dependency || c.read::<4>()? != label.1.to_le_bytes() - || c.read::<2>()? != [0, 0] + || c.read::<2>()? != encoding || (contextual && c.exguid()? != label.0) { return Err(wrong( "A revision starts with the wrong identity, dependency or label", )); } + if let Some(protection) = protection { + let node = next()?; + if node.id != 0x7c || node.reference != Some(Reference::Data(protection.metadata())) + { + return Err(wrong("A protected revision does not name its key")); + } + } let mut declared = BTreeSet::new(); let mut roots = BTreeMap::new(); loop { @@ -2513,10 +2556,14 @@ pub(crate) fn check_transaction( return Err(wrong("An object declaration lacks its data")); }; let data = read(at)?; + let clear = match protection { + Some(protection) => protection.open_property(&data)?, + None => zeroize::Zeroizing::new(data.clone()), + }; let stored = crate::Object { jcid, reference_count: count, - data: ObjectData::Properties(&data), + data: ObjectData::Properties(&clear), global_ids: Arc::new(table.clone()), }; let references = stored.references()?; @@ -2525,11 +2572,14 @@ pub(crate) fn check_transaction( !references.object_spaces.is_empty() || !references.contexts.is_empty(), ) << 1); - if object.data != ObjectData::Properties(&data) + let hash = match protection { + Some(_) => crate::protected::digest(&clear), + None => md5::compute(&data).0, + }; + if object.data != ObjectData::Properties(&clear) || flags != Some(expected) || (item.id == 0xc5) != (jcid & 0x100000 != 0) - || (item.id == 0xc5 - && c.read::<16>()? != md5::compute(&data).0) + || (item.id == 0xc5 && c.read::<16>()? != hash) { return Err(wrong( "An object's stored bytes differ from its revision", @@ -2640,8 +2690,12 @@ pub(crate) fn check_transaction( return Err(wrong("A payload declaration lacks its data")); }; let data = read(at)?; - let blob = crate::files::payload(&data, at.offset as usize); - if node.id != 0x94 || node.payload != guid || blob.ok() != Some(*payload) { + let blob = crate::files::payload(&data, at.offset as usize)?; + let blob = match protection { + Some(protection) => protection.open_file(blob)?, + None => zeroize::Zeroizing::new(blob.to_vec()), + }; + if node.id != 0x94 || node.payload != guid || blob.as_slice() != *payload { return Err(wrong("An embedded payload differs from its declaration")); } } diff --git a/crates/onestore/tests/protected.rs b/crates/onestore/tests/protected.rs index 0cfe4a135dfd392f9cda9b269dc7fbd235b50033..597d45df2713783de91b385599da2003fd105ed4 100644 --- a/crates/onestore/tests/protected.rs +++ b/crates/onestore/tests/protected.rs @@ -1,52 +1,54 @@ -#![cfg(feature = "protected")] - use onestore::{ - RevisionIndex, Store, + Arena, ExGuid, RevisionIndex, Section, Store, document::{Document, Kind}, - op::{Edit, Op, PageOp}, - protected::{Error, Limits, UnlockedSection}, + op::{Edit, Op}, + page::Page, + protected::{Error, Key, Limits, UnlockedSection, rekey}, }; use std::{fs, path::Path}; +/// The native fixtures with their passwords. +fn fixtures() -> Vec<(Vec, String)> { + [ + ("native-encrypted", "encrypted-01/notebook/synthetic.one"), + ("native-protected-boundaries", "notebook/synthetic.one"), + ] + .into_iter() + .map(|(root, notebook)| { + let root = Path::new("../../corpus").join(root); + let manifest: serde_json::Value = + serde_json::from_slice(&fs::read(root.join("manifest.json")).unwrap()).unwrap(); + ( + fs::read(root.join(notebook)).unwrap(), + manifest["password"].as_str().unwrap().to_owned(), + ) + }) + .collect() +} + +/// Every page of a section `Section::open` or `Section::unlock` reads, in order. +fn pages(section: &mut Section<'_>) -> Vec<(ExGuid, Page)> { + section + .pages() + .unwrap() + .into_iter() + .map(|(space, ..)| (space, section.page(space).unwrap())) + .collect() +} + +fn objects(pages: &[(ExGuid, Page)]) -> Vec<&Vec> { + pages.iter().map(|(_, page)| &page.objects).collect() +} + #[test] fn known_passwords_open_independent_native_fixtures() { - for (root, notebook, pages) in [ - ("native-encrypted", "encrypted-01/notebook/synthetic.one", 1), - ("native-protected-boundaries", "notebook/synthetic.one", 11), - ] { - let root = Path::new("../../corpus").join(root); - let manifest: serde_json::Value = - serde_json::from_slice(&fs::read(root.join("manifest.json")).unwrap()).unwrap(); - let password = manifest["password"].as_str().unwrap(); - let bytes = fs::read(root.join(notebook)).unwrap(); - let before = bytes.clone(); + for ((bytes, password), count) in fixtures().into_iter().zip([1, 11]) { let store = Store::parse(&bytes).unwrap(); let index = RevisionIndex::parse(&store).unwrap(); assert!(Document::parse(&index).unwrap().pages().unwrap().is_empty()); - let unlocked = UnlockedSection::open(&index, password, Limits::default()).unwrap(); + let unlocked = UnlockedSection::open(&index, &password, Limits::default()).unwrap(); let document = unlocked.document().unwrap(); - assert_eq!(document.pages().unwrap().len(), pages); - let (space, _) = document.pages().unwrap()[0]; - let current = &document.spaces[&space]; - let revision = ¤t.revisions[¤t.contexts[&onestore::ExGuid::default()]]; - let (object, _) = revision - .nodes - .iter() - .find(|(_, node)| matches!(node.kind, Kind::RichText { .. })) - .unwrap(); - // The ordinary section writer refuses a protected section. - let arena = onestore::Arena::default(); - assert!(onestore::Section::open(&arena, bytes.clone()).is_err()); - let op = PageOp::Text { - text: *object, - range: 0..0, - with: "Must remain protected".into(), - }; - let edit = Edit { - at: 134_000_000_000_000_000, - ops: vec![Op::Page { space, op }], - }; - assert!(unlocked.apply("Rust", &edit).is_ok()); + assert_eq!(document.pages().unwrap().len(), count); assert!( document .spaces @@ -55,26 +57,26 @@ fn known_passwords_open_independent_native_fixtures() { .flat_map(|r| r.nodes.values()) .all(|n| !matches!(n.kind, Kind::Encrypted { .. })) ); + // A section opened under its key reads as the document does. + let arena = Arena::default(); + assert!(Section::open(&arena, bytes.clone()).is_err()); + let key = Key::open(&bytes, &password).unwrap(); + let mut section = Section::unlock(&arena, bytes.clone(), &key).unwrap(); + let read = pages(&mut section); + assert_eq!(read.len(), count); + for (space, page) in &read { + assert_eq!(*page, Page::from_space(&document, *space).unwrap()); + } assert!(matches!( - UnlockedSection::open( - &index, - "deliberately incorrect fixture password", - Limits::default() - ), + Key::open(&bytes, "deliberately incorrect fixture password"), Err(Error::PasswordMismatch) )); - assert!(Document::parse(&index).unwrap().pages().unwrap().is_empty()); - assert_eq!(bytes, before); } } #[test] fn limits_and_password_bytes_are_explicit() { - let root = Path::new("../../corpus/native-protected-boundaries"); - let manifest: serde_json::Value = - serde_json::from_slice(&fs::read(root.join("manifest.json")).unwrap()).unwrap(); - let password = manifest["password"].as_str().unwrap(); - let bytes = fs::read(root.join("notebook/synthetic.one")).unwrap(); + let (bytes, password) = fixtures().remove(1); let store = Store::parse(&bytes).unwrap(); let index = RevisionIndex::parse(&store).unwrap(); for limits in [ @@ -92,7 +94,7 @@ fn limits_and_password_bytes_are_explicit() { }, ] { assert!(matches!( - UnlockedSection::open(&index, password, limits), + UnlockedSection::open(&index, &password, limits), Err(Error::Limit) )); } @@ -102,111 +104,111 @@ fn limits_and_password_bytes_are_explicit() { password.to_uppercase(), ] { assert!(matches!( - UnlockedSection::open(&index, &changed, Limits::default()), + Key::open(&bytes, &changed), Err(Error::PasswordMismatch) )); } assert!(matches!( - UnlockedSection::open(&index, &"x".repeat(65537), Limits::default()), + Key::open(&bytes, &"x".repeat(65537)), Err(Error::Limit) )); let ordinary = onestore::create_section("ordinary.one", "Fictitious", "Author").unwrap(); - let store = Store::parse(&ordinary).unwrap(); - let index = RevisionIndex::parse(&store).unwrap(); assert!(matches!( - UnlockedSection::open(&index, password, Limits::default()), + Key::open(&ordinary, &password), Err(Error::Unsupported) )); + // A key opens only the section whose encryption data it came from. + let other = Key::new(&password).unwrap(); + assert!(matches!( + Section::unlock(&Arena::default(), bytes, &other), + Err(Error::PasswordMismatch) + )); } -/// The first text paragraph of every page gains text; the written file stays protected, -/// opens with the same password and reads back as the edited model. +/// Appends an attachment and text to the first text paragraph of `page`; none without one. +fn edit(space: ExGuid, page: &Page) -> Option<(Edit, Page)> { + use onestore::page::{Attachment, PageObject, Paragraph, ParagraphContent}; + let mut edited = page.clone(); + let paragraphs = edited.objects.iter_mut().find_map(|object| match object { + PageObject::Outline(outline) if outline.paragraphs.iter().any(|p| p.text().is_some()) => { + Some(&mut outline.paragraphs) + } + _ => None, + })?; + let at = paragraphs.iter().position(|p| p.text().is_some()).unwrap(); + let mut file = paragraphs[at].clone(); + file.id = onestore::page::text::new_id().unwrap(); + file.lists.clear(); + file.tags.clear(); + file.style = None; + file.format = Default::default(); + file.content = ParagraphContent::Attachment(Attachment { + id: onestore::page::text::new_id().unwrap(), + filename: "sealed.txt".into(), + source_path: None, + size: Some([24.0, 24.0]), + layout: Default::default(), + bytes: Some(std::sync::Arc::from(&b"A payload that stays sealed"[..])), + preview: None, + recording: None, + tags: Vec::new(), + }); + paragraphs.insert(at + 1, file); + let text = paragraphs[at].text_mut().unwrap(); + let format = text.text.format_at(0).unwrap().clone(); + text.text + .append(Paragraph::new(" still protected".to_owned(), format)) + .unwrap(); + let ops = onestore::op::lower_page(page, &edited).unwrap(); + let edit = Edit { + at: 134_000_000_000_000_000, + ops: ops.into_iter().map(|op| Op::Page { space, op }).collect(), + }; + Some((edit, edited)) +} + +/// Every revision of every space of `bytes` names its key, as MS-ONESTORE 2.5.19 asks. +fn keyed(bytes: &[u8]) -> bool { + let store = Store::parse(bytes).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + index + .spaces + .values() + .flat_map(|space| space.revisions.values()) + .all(|revision| revision.encrypted && revision.nodes[0].id == 0x7c) +} + +/// The first text paragraph of every page gains text and an attachment; each seal appends +/// one revision to that page alone, stores none of it in the clear, and reads back as the +/// edited model under the same password. #[test] fn a_protected_page_edit_is_stored_under_the_section_key() { - use onestore::page::{Page, PageObject, Paragraph}; - for (root, notebook) in [ - ("native-encrypted", "encrypted-01/notebook/synthetic.one"), - ("native-protected-boundaries", "notebook/synthetic.one"), - ] { - let root = Path::new("../../corpus").join(root); - let manifest: serde_json::Value = - serde_json::from_slice(&fs::read(root.join("manifest.json")).unwrap()).unwrap(); - let password = manifest["password"].as_str().unwrap(); - let mut bytes = fs::read(root.join(notebook)).unwrap(); - let pages = |bytes: &[u8]| -> Vec<(onestore::ExGuid, Page)> { - let store = Store::parse(bytes).unwrap(); - let index = RevisionIndex::parse(&store).unwrap(); - assert!(Document::parse(&index).unwrap().pages().unwrap().is_empty()); - let unlocked = UnlockedSection::open(&index, password, Limits::default()).unwrap(); - let document = unlocked.document().unwrap(); - document - .pages() - .unwrap() - .into_iter() - .map(|(space, _)| (space, Page::from_space(&document, space).unwrap())) - .collect() - }; - let mut expected = pages(&bytes); + for (mut bytes, password) in fixtures() { + let key = Key::open(&bytes, &password).unwrap(); + let mut expected = + pages(&mut Section::unlock(&Arena::default(), bytes.clone(), &key).unwrap()); let mut edited = 0; for (space, page) in &mut expected { - let before = page.clone(); - let paragraphs = page.objects.iter_mut().find_map(|object| match object { - PageObject::Outline(outline) - if outline.paragraphs.iter().any(|p| p.text().is_some()) => - { - Some(&mut outline.paragraphs) - } - _ => None, - }); - let Some(paragraphs) = paragraphs else { + let Some((edit, after)) = edit(*space, page) else { continue; }; + *page = after; edited += 1; - let at = paragraphs.iter().position(|p| p.text().is_some()).unwrap(); - let mut file = paragraphs[at].clone(); - file.id = onestore::page::text::new_id().unwrap(); - file.lists.clear(); - file.tags.clear(); - file.style = None; - file.format = Default::default(); - file.content = - onestore::page::ParagraphContent::Attachment(onestore::page::Attachment { - id: onestore::page::text::new_id().unwrap(), - filename: "sealed.txt".into(), - source_path: None, - size: Some([24.0, 24.0]), - layout: Default::default(), - bytes: Some(std::sync::Arc::from(&b"A payload that stays sealed"[..])), - preview: None, - recording: None, - tags: Vec::new(), - }); - paragraphs.insert(at + 1, file); - let text = paragraphs[at].text_mut().unwrap(); - let format = text.text.format_at(0).unwrap().clone(); - text.text - .append(Paragraph::new(" still protected".to_owned(), format)) - .unwrap(); - let ops = onestore::op::lower_page(&before, page).unwrap(); - let edit = Edit { - at: 134_000_000_000_000_000, - ops: ops - .into_iter() - .map(|op| Op::Page { space: *space, op }) - .collect(), - }; - let store = Store::parse(&bytes).unwrap(); - let index = RevisionIndex::parse(&store).unwrap(); - assert!(matches!( - UnlockedSection::open(&index, "wrong", Limits::default()), - Err(Error::PasswordMismatch) - )); - let unlocked = UnlockedSection::open(&index, password, Limits::default()).unwrap(); - let transaction = unlocked.apply("Rust", &edit).unwrap(); + let arena = Arena::default(); + let mut section = Section::unlock(&arena, bytes.clone(), &key).unwrap(); + section.apply("Rust", &edit).unwrap(); + let transaction = section.seal().unwrap().unwrap(); let mut written = bytes.clone(); transaction.apply(&mut written).unwrap(); + assert_eq!(written, section.image()); for clear in [&b" still protected"[..], b"stays sealed"] { - assert!(!written.windows(clear.len()).any(|w| w == clear)); + let utf16: Vec = String::from_utf8_lossy(clear) + .encode_utf16() + .flat_map(u16::to_le_bytes) + .collect(); + for clear in [clear, &utf16[..]] { + assert!(!written.windows(clear.len()).any(|w| w == clear)); + } } let revisions = |bytes: &[u8]| { let store = Store::parse(bytes).unwrap(); @@ -224,13 +226,23 @@ fn a_protected_page_edit_is_stored_under_the_section_key() { .map(|(before, _)| before.0) .collect(); assert_eq!(grown, [*space]); + // The section kept open reads its own seal back. + assert_eq!(section.page(*space).unwrap().objects, page.objects); bytes = written; } assert!(edited > 0); - let stored = pages(&bytes); - assert_eq!(stored.len(), expected.len()); - for ((_, stored), (_, expected)) in stored.iter().zip(&expected) { - assert_eq!(stored.objects, expected.objects); + let stored = pages(&mut Section::unlock(&Arena::default(), bytes.clone(), &key).unwrap()); + assert_eq!(objects(&stored), objects(&expected)); + // The document reader agrees, under the password alone. + let store = Store::parse(&bytes).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let unlocked = UnlockedSection::open(&index, &password, Limits::default()).unwrap(); + let document = unlocked.document().unwrap(); + for (space, page) in &expected { + assert_eq!( + Page::from_space(&document, *space).unwrap().objects, + page.objects + ); } } } @@ -239,9 +251,7 @@ fn a_protected_page_edit_is_stored_under_the_section_key() { #[test] fn a_native_revision_inherits_the_key_of_its_dependency() { let bytes = fs::read("../../corpus/protected-edit/native-after/synthetic.one").unwrap(); - let manifest: serde_json::Value = - serde_json::from_slice(&fs::read("../../corpus/native-encrypted/manifest.json").unwrap()) - .unwrap(); + let (_, password) = fixtures().remove(0); let store = Store::parse(&bytes).unwrap(); let index = RevisionIndex::parse(&store).unwrap(); assert!(index.spaces.values().any(|space| { @@ -249,14 +259,149 @@ fn a_native_revision_inherits_the_key_of_its_dependency() { revision.encrypted && revision.nodes.first().is_none_or(|node| node.id != 0x7c) }) })); - let unlocked = UnlockedSection::open( - &index, - manifest["password"].as_str().unwrap(), - Limits::default(), - ) - .unwrap(); - let document = unlocked.document().unwrap(); - let (space, _) = document.pages().unwrap()[0]; - let page = onestore::page::Page::from_space(&document, space).unwrap(); - assert!(format!("{:?}", page.objects).contains("Native edit after Rust.")); + let key = Key::open(&bytes, &password).unwrap(); + let read = pages(&mut Section::unlock(&Arena::default(), bytes, &key).unwrap()); + assert!(format!("{:?}", read[0].1.objects).contains("Native edit after Rust.")); +} + +/// Setting, changing and removing a password rewrites the section under new identities, +/// keeping every page, version and payload; each password opens only its own image. +#[test] +fn a_password_is_set_changed_and_removed() { + let plain = fs::read("../../corpus/page-versions/native/step-09/notebook/History.one").unwrap(); + let arena = Arena::default(); + let mut section = Section::open(&arena, plain.clone()).unwrap(); + let before = pages(&mut section); + let versions = section.versions().unwrap(); + assert!(!versions.is_empty()); + let identity = |bytes: &[u8]| { + let store = Store::parse(bytes).unwrap(); + ( + store.header.file_id, + RevisionIndex::parse(&store).unwrap().root, + ) + }; + + let first = Key::new("first password").unwrap(); + let protected = rekey(&plain, None, Some(&first)).unwrap(); + assert!(keyed(&protected)); + assert_eq!(protected[128..148], plain[128..148]); + assert_ne!(identity(&protected).0, identity(&plain).0); + assert_ne!(identity(&protected).1, identity(&plain).1); + assert!(Section::open(&Arena::default(), protected.clone()).is_err()); + // The encryption data OneNote writes, which the password alone opens again. + let reopened = Key::open(&protected, "first password").unwrap(); + assert_eq!(reopened.secret(), first.secret()); + assert!(matches!( + Key::open(&protected, "First password"), + Err(Error::PasswordMismatch) + )); + let arena = Arena::default(); + let mut section = Section::unlock(&arena, protected.clone(), &reopened).unwrap(); + assert_eq!(objects(&pages(&mut section)), objects(&before)); + assert_eq!( + section + .versions() + .unwrap() + .iter() + .map(|(_, versions)| versions.len()) + .collect::>(), + versions + .iter() + .map(|(_, versions)| versions.len()) + .collect::>() + ); + let store = Store::parse(&protected).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let unlocked = UnlockedSection::open(&index, "first password", Limits::default()).unwrap(); + assert_eq!( + unlocked.document().unwrap().pages().unwrap().len(), + before.len() + ); + + let second = Key::new("second password").unwrap(); + assert!(matches!( + rekey(&protected, None, Some(&second)), + Err(Error::PasswordMismatch) + )); + let changed = rekey(&protected, Some(&first), Some(&second)).unwrap(); + assert_ne!(first.secret(), second.secret()); + assert!(matches!( + Key::open(&changed, "first password"), + Err(Error::PasswordMismatch) + )); + assert!(matches!( + Section::unlock(&Arena::default(), changed.clone(), &first), + Err(Error::PasswordMismatch) + )); + let arena = Arena::default(); + let mut section = Section::unlock(&arena, changed.clone(), &second).unwrap(); + assert_eq!(objects(&pages(&mut section)), objects(&before)); + + let removed = rekey(&changed, Some(&second), None).unwrap(); + assert!( + RevisionIndex::parse(&Store::parse(&removed).unwrap()) + .unwrap() + .spaces + .values() + .flat_map(|space| space.revisions.values()) + .all(|revision| !revision.encrypted) + ); + let arena = Arena::default(); + let mut section = Section::open(&arena, removed).unwrap(); + assert_eq!(objects(&pages(&mut section)), objects(&before)); + assert_eq!(section.versions().unwrap().len(), versions.len()); +} + +/// Payloads survive a password of their own: every native attachment boundary length. +#[test] +fn payloads_survive_a_new_password() { + let (bytes, password) = fixtures().remove(1); + let key = Key::open(&bytes, &password).unwrap(); + let before = pages(&mut Section::unlock(&Arena::default(), bytes.clone(), &key).unwrap()); + let new = Key::new("another").unwrap(); + let changed = rekey(&bytes, Some(&key), Some(&new)).unwrap(); + let after = pages(&mut Section::unlock(&Arena::default(), changed, &new).unwrap()); + assert_eq!(objects(&after), objects(&before)); +} + +/// What OneNote 2010 wrote setting, changing and removing a password in the lab, and its edit +/// of a section Snowbound protected, edited and gave another password, each read here. +#[test] +fn onenote_protected_changed_and_removed_sections_read_back() { + let root = Path::new("../../corpus/protected-sections"); + let manifest: serde_json::Value = + serde_json::from_slice(&fs::read(root.join("manifest.json")).unwrap()).unwrap(); + let plain = fs::read(root.join("source/synthetic.one")).unwrap(); + let titles = |section: &mut Section<'_>| -> Vec { + section + .pages() + .unwrap() + .into_iter() + .map(|(_, title, _)| title) + .collect() + }; + let expected = titles(&mut Section::open(&Arena::default(), plain).unwrap()); + assert_eq!(expected.len(), 2); + for (file, password) in manifest["native"].as_object().unwrap() { + let bytes = fs::read(root.join("native").join(file)).unwrap(); + let arena = Arena::default(); + let mut section = match password.as_str() { + Some(password) => { + let key = Key::open(&bytes, password).unwrap(); + assert!(Section::open(&Arena::default(), bytes.clone()).is_err()); + Section::unlock(&arena, bytes, &key).unwrap() + } + None => Section::open(&arena, bytes).unwrap(), + }; + let read = titles(&mut section); + assert_eq!(read.len(), 2, "{file}"); + if file.starts_with("snowbound") { + let (space, ..) = section.pages().unwrap()[0]; + let page = format!("{:?}", section.page(space).unwrap().objects); + assert!(page.contains("typed by OneNote"), "{file}"); + } else { + assert_eq!(read, expected, "{file}"); + } + } } diff --git a/crates/snowbound/Cargo.toml b/crates/snowbound/Cargo.toml index ce1831e53f3b44c9059a51f0cf9bdb7fbe622be6..bb9f15f76372692676bd06ecb1bc1303d1ce4474 100644 --- a/crates/snowbound/Cargo.toml +++ b/crates/snowbound/Cargo.toml @@ -31,6 +31,8 @@ accesskit_winit = { version = "0.34.0", default-features = false, features = ["r # Updates: fetched over HTTPS, verified with ed25519 and SHA-256. ureq = { version = "3.4", default-features = false, features = ["rustls"] } ring = "0.17" +# Clears passwords typed into the password dialogs. +zeroize = "1.9.0" [target.'cfg(target_os = "macos")'.dependencies] libc = "0.2" diff --git a/crates/snowbound/assets/icons/info.svg b/crates/snowbound/assets/icons/info.svg new file mode 100644 index 0000000000000000000000000000000000000000..72c38283cd647174a0d5e8b85d5bef7a94afd19c --- /dev/null +++ b/crates/snowbound/assets/icons/info.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/crates/snowbound/assets/icons/warning.svg b/crates/snowbound/assets/icons/warning.svg new file mode 100644 index 0000000000000000000000000000000000000000..7a2439cc201a6a7bacec72ea7a2d6ec5511c2209 --- /dev/null +++ b/crates/snowbound/assets/icons/warning.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/crates/snowbound/linux/onenote.xml b/crates/snowbound/linux/onenote.xml index ce77b5a79d8fc04c0e149428eec7fc065da419e3..d085e40083015e2df1b07a3cd19c12160e7cf59a 100644 --- a/crates/snowbound/linux/onenote.xml +++ b/crates/snowbound/linux/onenote.xml @@ -5,5 +5,6 @@ + diff --git a/crates/snowbound/linux/package.sh b/crates/snowbound/linux/package.sh index fa7b4d5a86b8b209c74e60c0fb5e99ea34fb4beb..10610342cc8e1fc9c2a0f6f80a40b633b02b374d 100755 --- a/crates/snowbound/linux/package.sh +++ b/crates/snowbound/linux/package.sh @@ -1,39 +1,17 @@ #!/bin/sh # Builds target/dist/snowbound-linux-ARCH.tar.gz for each ARCH given (x86_64, aarch64; -# both by default), cross-linking with zig so it runs from macOS or Linux. +# both by default), cross-linking with zig (`platform/linux/cargo.sh`) so it runs from macOS or Linux. set -eu root=$(cd "$(dirname "$0")/../../.." && pwd) here="$root/crates/snowbound/linux" target="$root/target" -tools="$target/zig" -mkdir -p "$tools" "$target/dist" -command -v zig >/dev/null || { echo "zig is required as the cross linker" >&2; exit 1; } - -printf '#!/bin/sh\nexec zig ar "$@"\n' > "$tools/ar" -chmod +x "$tools/ar" +mkdir -p "$target/dist" [ $# -gt 0 ] || set -- x86_64 aarch64 for arch do triple=$arch-unknown-linux-gnu - variable=$(echo "$triple" | tr 'a-z-' 'A-Z_') - linker="$tools/cc-$arch" - # glibc 2.17, Rust's own floor, reaches back to RHEL 7, Debian 8 and Ubuntu 14.04; zig - # rejects rustc's --target, --no-undefined-version and -O1. - cat > "$linker" </dev/null - env "CARGO_TARGET_${variable}_LINKER=$linker" \ - "CC_$(echo "$triple" | tr - _)=$linker" \ - "AR_$(echo "$triple" | tr - _)=$tools/ar" \ - CARGO_PROFILE_RELEASE_STRIP=symbols \ - cargo build --manifest-path "$root/Cargo.toml" --release --target "$triple" -p snowbound + CARGO_PROFILE_RELEASE_STRIP=symbols sh "$root/platform/linux/cargo.sh" "$arch" build \ + --manifest-path "$root/Cargo.toml" --release -p snowbound name=snowbound-linux-$arch stage="$target/dist/$name" diff --git a/crates/snowbound/src/aqua.rs b/crates/snowbound/src/aqua.rs index b3609539cd44fa1e2a4ba81e4d11d20859f0c0e6..b168d63705560d4cc1e60df9e994eafd66d1231d 100644 --- a/crates/snowbound/src/aqua.rs +++ b/crates/snowbound/src/aqua.rs @@ -17,10 +17,7 @@ use std::{ sync::atomic::{AtomicBool, Ordering}, }; use ui::{Axis, PaintedScroller, Scroller, ScrollerPart}; -use winit::{ - raw_window_handle::{HasWindowHandle, RawWindowHandle}, - window::{CursorIcon, Window}, -}; +use winit::window::{CursorIcon, Window}; static PRETEND: AtomicBool = AtomicBool::new(false); @@ -49,14 +46,8 @@ pub fn textured(window: &Window, row: f32) -> bool { return false; } TEXTURED.store(true, Ordering::Relaxed); - let RawWindowHandle::AppKit(handle) = - window.window_handle().expect("Live AppKit window").as_raw() - else { - unreachable!() - }; + let window = crate::platform::ns_window(window); unsafe { - let view = &*handle.ns_view.as_ptr().cast::(); - let window: Retained = msg_send_id![view, window]; let title: Retained = msg_send_id![&window, title]; let mask: usize = msg_send![&window, styleMask]; // NSTexturedBackgroundWindowMask. @@ -450,16 +441,8 @@ pub fn resize_grip(ui: &mut ui::Ui, window: &Window, size: [f32; 2]) { } static HIDDEN: std::sync::Once = std::sync::Once::new(); HIDDEN.call_once(|| { - let RawWindowHandle::AppKit(handle) = - window.window_handle().expect("Live AppKit window").as_raw() - else { - unreachable!() - }; - unsafe { - let view = &*handle.ns_view.as_ptr().cast::(); - let window: Retained = msg_send_id![view, window]; - let _: () = msg_send![&window, setShowsResizeIndicator: false]; - } + let window = crate::platform::ns_window(window); + let _: () = unsafe { msg_send![&window, setShowsResizeIndicator: false] }; }); thread_local! { static GRIP: draw::RasterImage = { diff --git a/crates/snowbound/src/art.rs b/crates/snowbound/src/art.rs index 932c3b08551e5ca5a90e440750eb5a16c2144e3f..88d3ad5095f03532c027cec6f37bbcd6ef701166 100644 --- a/crates/snowbound/src/art.rs +++ b/crates/snowbound/src/art.rs @@ -24,6 +24,7 @@ pub const ICLOUD: &[&str] = art!("icons/icloud"); pub const FONT_COLOR: &[&str] = art!("icons/font-color"); pub const HIGHLIGHTER: &[&str] = art!("icons/highlighter"); pub const INDENT: &[&str] = art!("icons/indent"); +pub const INFO: &[&str] = art!("icons/info"); pub const ITALIC: &[&str] = art!("icons/italic"); pub const LINK: &[&str] = art!("icons/link"); pub const NOTEBOOK: &[&str] = art!("icons/notebook"); @@ -46,6 +47,7 @@ pub const SUBSCRIPT: &[&str] = art!("icons/subscript"); pub const SUPERSCRIPT: &[&str] = art!("icons/superscript"); pub const TABLE: &[&str] = art!("icons/table"); pub const UNDERLINE: &[&str] = art!("icons/underline"); +pub const WARNING: &[&str] = art!("icons/warning"); pub const UNDO: &[&str] = art!("icons/undo"); pub const ZOOM_IN: &[&str] = art!("icons/zoom-in"); pub const ZOOM_OUT: &[&str] = art!("icons/zoom-out"); diff --git a/crates/snowbound/src/commands.rs b/crates/snowbound/src/commands.rs index 42ce6b730edd494d8b9aa0acdd5f724d9eb09e1a..7a4214dda37361482278b682d2b97609a95fb65e 100644 --- a/crates/snowbound/src/commands.rs +++ b/crates/snowbound/src/commands.rs @@ -26,7 +26,22 @@ pub enum Id { NewSubpage, PageVersions, CopyPageLink, + /// Password Protection on the section shown. + PasswordProtect, + /// Locks every password-protected section unlocked. + LockAll, ShowNotebook, + /// Save As: the page, section or notebook as a OneNote section, package or PDF. + SaveAs, + /// The notebook's recycle bin, shown in place of its sections, or left. + RecycleBin, + EmptyRecycleBin, + /// Marks the page shown read, or unread. + MarkRead, + MarkNotebookRead, + /// Show Unread Changes in This Notebook. + ShowUnread, + NextUnread, ExportPdf, Print, Undo, @@ -238,6 +253,18 @@ pub const COMMANDS: &[Command] = &[ ), row!(Id::PageVersions, "Page Versions", NONE, NONE), row!(Id::CopyPageLink, "Copy Link to Page", NONE, NONE), + row!( + Id::PasswordProtect, + "Password Protect This Section…", + NONE, + NONE + ), + row!( + Id::LockAll, + "Lock All Sections", + &[cmd('l').option()], + &[cmd('l').option()] + ), row!( Id::ShowNotebook, if cfg!(target_os = "macos") { @@ -248,6 +275,24 @@ pub const COMMANDS: &[Command] = &[ NONE, NONE, ), + row!( + Id::SaveAs, + "Save As…", + &[cmd('s').shift()], + &[cmd('s').shift()] + ), + row!(Id::RecycleBin, "Notebook Recycle Bin", NONE, NONE), + row!(Id::EmptyRecycleBin, "Empty Recycle Bin", NONE, NONE), + // OneNote 2010's Ctrl+Q; macOS keeps Command-Q for Quit. + row!(Id::MarkRead, "Mark as Read", NONE, &[cmd('q')]), + row!(Id::MarkNotebookRead, "Mark Notebook as Read", NONE, NONE), + row!( + Id::ShowUnread, + "Show Unread Changes in This Notebook", + NONE, + NONE + ), + row!(Id::NextUnread, "Next Unread", NONE, NONE), row!(Id::ExportPdf, "Export as PDF…", NONE, NONE), // Go to takes OneNote's Ctrl+P, and Pause its Ctrl+Alt+P. row!( @@ -1003,7 +1048,7 @@ impl State { /// ⌘K, to the open palette's actions. pub(crate) fn statuses(&self) -> Vec { // A chord Options records reaches it past the menu bar's disabled items. - if self.recording_chord() { + if (self.options.as_ref()).is_some_and(|options| options.keyboard.recording()) { return vec![Status::default(); choices().count()]; } let format = self.format_state(); @@ -1020,6 +1065,7 @@ impl State { let session = self.session.as_ref(); let welcome = session.is_none() && !self.temporary && self.sectionless.is_none(); let modal = self.options.is_some() + || self.password.is_some() || self.themes.is_some() || self.printing.open() || self.link.is_some() @@ -1071,6 +1117,39 @@ impl State { | Id::Help | Id::CheckForUpdates => enabled(!modal), Id::CloseNotebook => enabled(!modal && self.notebook().is_some()), + Id::PasswordProtect => enabled(!modal && self.shown_section().is_some()), + Id::SaveAs => enabled(!modal && self.notebook().is_some()), + Id::RecycleBin => Status { + enabled: !modal + && self + .notebook() + .is_some_and(|library| library.catalog().is_some()), + checked: Some(self.in_recycle_bin()), + }, + Id::EmptyRecycleBin => enabled( + !modal + && self + .notebook() + .is_some_and(|library| !library.tabs(crate::recycle::BIN).is_empty()), + ), + Id::MarkRead => Status { + enabled: !modal && session.is_some() && !self.in_recycle_bin(), + checked: Some(!self.page_unread()), + }, + Id::MarkNotebookRead | Id::NextUnread => enabled( + !modal + && self + .notebook() + .is_some_and(|library| self.unread_notebook(library)), + ), + Id::ShowUnread => Status { + enabled: !modal && self.notebook().is_some(), + checked: Some( + self.notebook() + .is_some_and(|library| self.reads.shown(&library.location)), + ), + }, + Id::LockAll => enabled(!modal), Id::ShowNotebook => enabled( !modal && self @@ -1083,13 +1162,13 @@ impl State { .notebook() .is_some_and(|library| library.catalog().is_some()), ), - Id::NewPage - | Id::NewSubpage - | Id::CopyPageLink - | Id::Find - | Id::Search - | Id::ExportPdf - | Id::Print => enabled(!modal && session.is_some()), + // The recycle bin takes no new pages. + Id::NewPage | Id::NewSubpage => { + enabled(!modal && session.is_some() && !self.in_recycle_bin()) + } + Id::CopyPageLink | Id::Find | Id::Search | Id::ExportPdf | Id::Print => { + enabled(!modal && session.is_some()) + } Id::PageVersions => session.filter(|_| !modal).map_or( Status { enabled: false, @@ -1338,6 +1417,40 @@ impl State { platform::reveal(&self.notebook().ok_or("No notebook is open")?.location); return Ok(()); } + Id::PasswordProtect => { + let (library, path) = self.shown_section().ok_or("No section is open")?; + self.password_protection(library, path); + return Ok(()); + } + Id::LockAll => return self.lock_all(), + Id::SaveAs => { + let library = Arc::clone(self.notebook().ok_or("No notebook is open")?); + let scope = if self.session.is_some() { + crate::save_as::Scope::Page + } else { + crate::save_as::Scope::Notebook + }; + self.open_save_as(library, None, scope); + return Ok(()); + } + Id::RecycleBin | Id::EmptyRecycleBin | Id::MarkNotebookRead | Id::ShowUnread => { + let library = Arc::clone(self.notebook().ok_or("No notebook is open")?); + match id { + Id::RecycleBin => self.toggle_recycle_bin(library), + Id::EmptyRecycleBin => self.empty_recycle_bin(library), + Id::MarkNotebookRead => self.mark_notebook_read(&library), + _ => self.toggle_unread_shown(&library), + } + return Ok(()); + } + Id::MarkRead => { + self.toggle_read(); + return Ok(()); + } + Id::NextUnread => { + self.next_unread(); + return Ok(()); + } Id::ExportPdf | Id::Print => { self.open_print(id == Id::ExportPdf); return Ok(()); diff --git a/crates/snowbound/src/conflict_render.rs b/crates/snowbound/src/conflict_render.rs index d0605d76e734b2328541b237b7b35b105ea755e2..1920946e5548f70788e3770972b8908c8f96ada2 100644 --- a/crates/snowbound/src/conflict_render.rs +++ b/crates/snowbound/src/conflict_render.rs @@ -64,7 +64,7 @@ fn layout(ui: &mut Ui, session: &Session, menu: bool) -> Vec { &theme, §ion, session, - &HashSet::new(), + [&HashSet::new(); 2], (0.0, false), None, None, diff --git a/crates/snowbound/src/desktop_linux.rs b/crates/snowbound/src/desktop_linux.rs index 8ef12227f170e360ec127dd9eadb2bcc9bd733f8..6441d75197be2470ca28f94347d63fe86de2ee40 100644 --- a/crates/snowbound/src/desktop_linux.rs +++ b/crates/snowbound/src/desktop_linux.rs @@ -36,7 +36,7 @@ use winit::{ /// Wayland's app ID and X11's WM_CLASS, which name the desktop entry and the icon. pub const APP_ID: &str = "net.paperclover.snowbound"; const TEMPLATE: &str = include_str!("../linux/snowbound.desktop"); -/// The type of `.one` and `.onetoc2` files, which no shared-mime-info release names. +/// The type of `.one`, `.onetoc2` and `.onepkg` files, which no shared-mime-info release names. const MIME_TYPES: &str = include_str!("../linux/onenote.xml"); /// 512 pixels square; the smaller sizes are scaled from it. const ICON: &[u8] = include_bytes!("../linux/snowbound.png"); diff --git a/crates/snowbound/src/history.rs b/crates/snowbound/src/history.rs index e1cbe2581fb5fd6749d1be89a1cc234c6e32d445..b18c231bc475a2bae92327f84083191e0e8cad7c 100644 --- a/crates/snowbound/src/history.rs +++ b/crates/snowbound/src/history.rs @@ -23,9 +23,12 @@ impl Session { .map_or(&[], |(_, versions)| versions) } - /// Whether the open page takes no edits: a conflict page or a version. + /// Whether the open page takes no edits: a conflict page, a version, or a page of the + /// recycle bin. pub(crate) fn read_only(&self) -> bool { - self.version.is_some() || self.conflict(self.space).is_some() + self.version.is_some() + || self.conflict(self.space).is_some() + || crate::recycle::binned(&self.tabs[self.tab].path) } /// Version `version` of page `space` as the editor shows it: read-only, what it changed @@ -166,6 +169,7 @@ impl State { .section .restore_version(page, version, &self.author)?; session.refresh_history()?; + self.edited(vec![page]); self.commands.push(Command::OpenPage(page)); Ok(()) } @@ -207,19 +211,7 @@ impl State { ) { return Ok(()); } - let all: Vec<(ExGuid, Vec)> = session - .history - .iter() - .map(|(page, versions)| { - ( - *page, - versions.iter().map(|version| version.context).collect(), - ) - }) - .collect(); - if !all.is_empty() { - session.section.delete_versions(&all)?; - } + clear(&session.section)?; session.refresh_history()?; if session.version.is_some() { self.commands.push(Command::OpenPage(session.space)); @@ -242,22 +234,7 @@ impl State { let cleared = library .open(&path, notify(proxy.clone())) .and_then(|section| { - let all: Vec<(ExGuid, Vec)> = section - .versions()? - .into_iter() - .map(|(page, versions)| { - ( - page, - versions - .into_iter() - .map(|version| version.context) - .collect(), - ) - }) - .collect(); - if !all.is_empty() { - section.delete_versions(&all)?; - } + clear(§ion)?; Ok(section.close()?) }); if let Err(error) = cleared { @@ -269,31 +246,36 @@ impl State { } } +/// Deletes every page version `section` holds. +fn clear(section: ¬ebook::session::Section) -> Result<(), notebook::Error> { + let all: Vec<(ExGuid, Vec)> = (section.versions()?.into_iter()) + .map(|(page, versions)| { + ( + page, + versions.iter().map(|version| version.context).collect(), + ) + }) + .collect(); + if !all.is_empty() { + section.delete_versions(&all)?; + } + Ok(()) +} + /// The readable sections at catalog path `folder` and in the groups inside it, leaving out /// the recycle bin. fn sections(library: &Library, folder: &str) -> Vec { let Some(catalog) = library.catalog() else { return vec![library.location.clone()]; }; - let mut pending = vec![catalog]; - let mut root = None; - while let Some(candidate) = pending.pop() { - if candidate.path == folder { - root = Some(candidate); - break; - } - pending.extend(&candidate.groups); - } - let mut pending: Vec<_> = root.into_iter().collect(); - let mut paths = Vec::new(); - while let Some(folder) = pending.pop() { - paths.extend(library.tabs(&folder.path).into_iter().map(|tab| tab.path)); - pending.extend( - folder - .groups - .iter() - .filter(|group| !library::recycle_bin(&group.path)), - ); - } - paths + let root = library::folders(catalog, |_| true) + .into_iter() + .find(|candidate| candidate.path == folder); + root.map_or_else(Vec::new, |root| { + library::folders(root, |group| !library::recycle_bin(&group.path)) + }) + .into_iter() + .flat_map(|folder| library.tabs(&folder.path)) + .map(|tab| tab.path) + .collect() } diff --git a/crates/snowbound/src/icloud_linux.rs b/crates/snowbound/src/icloud_linux.rs index c195b985b382f0fe78fe86c4426e0dd5aedf503f..ba171f1840ffecbae43ddadcd6c4204bf2b7b494 100644 --- a/crates/snowbound/src/icloud_linux.rs +++ b/crates/snowbound/src/icloud_linux.rs @@ -25,9 +25,13 @@ pub fn section( notebook: &Notebook, _: &Path, path: &str, + key: Option<&onestore::protected::Key>, notify: impl Fn() + Send + 'static, ) -> Result { - notebook.section(path, notify) + match key { + Some(key) => notebook.section_unlocked(path, key, notify), + None => notebook.section(path, notify), + } } pub fn lone_section( diff --git a/crates/snowbound/src/icloud_macos.rs b/crates/snowbound/src/icloud_macos.rs index b02b53b9533ccd82b990fe91f869b09a29f34c31..abbe1410faf3e911dde64e16e441bf286ba87fed 100644 --- a/crates/snowbound/src/icloud_macos.rs +++ b/crates/snowbound/src/icloud_macos.rs @@ -363,12 +363,17 @@ pub fn section( notebook: &Notebook, root: &Path, path: &str, + key: Option<&onestore::protected::Key>, notify: impl Fn() + Send + 'static, ) -> Result { if !notebook.replica_path(path)?.exists() { fetch(&root.join(path))?; } - let section = notebook.section_with(path, |file| Ok(Coordinated(file.to_owned())), notify)?; + let connect = |file: &Path| Ok(Coordinated(file.to_owned())); + let section = match key { + Some(key) => notebook.section_unlocked_with(path, key, connect, notify)?, + None => notebook.section_with(path, connect, notify)?, + }; section.set_pause(PAUSE); Ok(section) } @@ -576,7 +581,7 @@ mod tests { let notebook = Notebook::create(&root, &cache, Notebook::NEW_COLOR, &page).unwrap(); assert!(ubiquitous(&root)); let path = notebook.catalog().sections[0].path.clone(); - let section = section(¬ebook, &root, &path, || {}).unwrap(); + let section = section(¬ebook, &root, &path, None, || {}).unwrap(); let (space, ..) = section.pages().unwrap()[0].clone(); let title = section .page(space) diff --git a/crates/snowbound/src/keys.rs b/crates/snowbound/src/keys.rs index aaa39dc4df6d50f87fa80215ce8033156807bf7c..e167d7eb0232484c4ddf8f35b8d4827ea58dfc7c 100644 --- a/crates/snowbound/src/keys.rs +++ b/crates/snowbound/src/keys.rs @@ -78,7 +78,7 @@ fn label(chord: Chord) -> String { fn title(id: Command) -> String { match id { Command::Tag(place) => format!("Tag {}", place + 1), - id => commands::command(id).title.trim_end_matches('…').to_owned(), + id => crate::title(id).to_owned(), } } @@ -430,13 +430,6 @@ impl crate::State { taken } - /// Whether Options is recording a chord, which the menu bar must then leave to it. - pub(crate) fn recording_chord(&self) -> bool { - self.options - .as_ref() - .is_some_and(|options| options.keyboard.recording()) - } - /// Puts `keymap` in use, and in the menu bar where it changes. pub(crate) fn install_keymap(&self, keymap: Keymap) { if keymap.install() { diff --git a/crates/snowbound/src/library.rs b/crates/snowbound/src/library.rs index fcfe45dff9be0371c87d1c7b371aea8edc394934..95c278c47562f9b6463fcc404c78a404fb205120 100644 --- a/crates/snowbound/src/library.rs +++ b/crates/snowbound/src/library.rs @@ -5,6 +5,7 @@ use notebook::discover::{Folder, SectionState}; use notebook::session::{Background, Notebook, Section}; use notebook::sidecar::themes::Themes; use notebook::smb::{Client, Credentials}; +use onestore::protected::Key; use std::{ error::Error, io, @@ -151,14 +152,14 @@ impl Mount { let mut url = String::from("smb://"); if let Some(user) = &self.user { if !self.domain.is_empty() { - url += &format!("{};", encode(&self.domain)); + url += &format!("{};", encode(self.domain.as_bytes())); } - url += &format!("{}@", encode(user)); + url += &format!("{}@", encode(user.as_bytes())); } url += &self.server; for part in [&self.share, &self.root] { if !part.is_empty() { - url += &format!("/{}", encode(part)); + url += &format!("/{}", encode(part.as_bytes())); } } url @@ -232,10 +233,11 @@ fn decode(text: &str) -> String { String::from_utf8_lossy(&decoded).into_owned() } -/// `text` escaped for an address, `/` kept. -fn encode(text: &str) -> String { - text.bytes() - .map(|byte| match byte { +/// `bytes` escaped for an address, outside RFC 3986's unreserved set and `/`. +pub(crate) fn encode(bytes: &[u8]) -> String { + bytes + .iter() + .map(|&byte| match byte { b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' | b'/' => { char::from(byte).to_string() } @@ -318,9 +320,34 @@ pub struct Library { kept: Mutex>, /// The notebook's style themes, and when they were read. themes: Mutex)>>, + /// The keys of its password-protected sections unlocked this run, kept as it is read again. + keys: Arc, } +/// Keys of password-protected sections, by file identity, with when each section was last +/// worked in; in memory only, each cleared as it goes. +#[derive(Default)] +pub struct Keys(Mutex>); + impl Library { + /// `location` named `name`, with no notebook read, server, sync or kept sections. + fn new(location: &str, name: String, cache: &Path) -> Self { + Self { + location: location.to_owned(), + name, + notebook: Ok(None), + server: None, + notice: None, + cache: cache.to_owned(), + background: None, + watch: None, + tag_art: Default::default(), + kept: Mutex::new(crate::prefetch::Recent::new(KEPT)), + themes: Default::default(), + keys: Default::default(), + } + } + /// The notebook in the folder at `location`, or why it cannot be read. A folder on a /// mounted SMB share opens through Snowbound's own SMB client, signed in with the /// account the system keeps for the mount, as OneNote's own client coordinates with @@ -333,18 +360,12 @@ impl Library { }; return login .and_then(|login| Self::on_share(location, mount.clone(), login, cache)) - .unwrap_or_else(|reason| Self { - location: location.to_owned(), - name: display_name(cache, location).unwrap_or_else(|| mount.name()), - notebook: Err(reason), - server: None, - notice: None, - cache: cache.to_owned(), - background: None, - watch: None, - tag_art: Default::default(), - kept: Mutex::new(crate::prefetch::Recent::new(KEPT)), - themes: Default::default(), + .unwrap_or_else(|reason| { + let name = display_name(cache, location).unwrap_or_else(|| mount.name()); + Self { + notebook: Err(reason), + ..Self::new(location, name, cache) + } }); } let mut notice = None; @@ -364,20 +385,16 @@ impl Library { Ok(notebook) => local_background(notebook, location), Err(_) => (None, None), }; + let name = display_name(cache, location).unwrap_or_else(|| file_name(Path::new(location))); Self { - location: location.to_owned(), - name: display_name(cache, location).unwrap_or_else(|| file_name(Path::new(location))), background, watch, tag_art: Mutex::new(Arc::new( notebook.as_ref().map(read_tag_art).unwrap_or_default(), )), - kept: Mutex::new(crate::prefetch::Recent::new(KEPT)), - themes: Default::default(), notebook: notebook.map(Some).map_err(|error| error.to_string()), - server: None, notice, - cache: cache.to_owned(), + ..Self::new(location, name, cache) } } @@ -405,21 +422,17 @@ impl Library { .map_err(|error| error.to_string())?; background.set_offline(offline()); background.watch(notebook.replicas()); - Ok(Self { - location: location.to_owned(), - name: display_name(cache, location).unwrap_or_else(|| match server_address(location) { + let name = + display_name(cache, location).unwrap_or_else(|| match server_address(location) { Some(_) => server.mount.name(), None => file_name(Path::new(location)), - }), + }); + Ok(Self { tag_art: Mutex::new(Arc::new(read_tag_art(¬ebook))), - kept: Mutex::new(crate::prefetch::Recent::new(KEPT)), - themes: Default::default(), notebook: Ok(Some(notebook)), server: Some(server), - notice: None, - cache: cache.to_owned(), background: Some(Arc::new(background)), - watch: None, + ..Self::new(location, name, cache) }) } @@ -576,18 +589,16 @@ impl Library { } _ => self.watch.clone(), }; + let name = display_name(&self.cache, &self.location).unwrap_or_else(|| self.name.clone()); Self { - location: self.location.clone(), - name: display_name(&self.cache, &self.location).unwrap_or_else(|| self.name.clone()), notebook: Ok(Some(notebook)), server: self.server.clone(), notice: self.notice.clone(), - cache: self.cache.clone(), background: self.background.clone(), watch, tag_art: Mutex::new(self.tag_art()), - kept: Mutex::new(crate::prefetch::Recent::new(KEPT)), - themes: Default::default(), + keys: Arc::clone(&self.keys), + ..Self::new(&self.location, name, &self.cache) } } @@ -595,35 +606,20 @@ impl Library { pub fn created(location: &str, mut notebook: Notebook, cache: &Path) -> Self { let (background, watch) = local_background(&mut notebook, location); Self { - location: location.to_owned(), - name: file_name(Path::new(location)), background, watch, - tag_art: Default::default(), - kept: Mutex::new(crate::prefetch::Recent::new(KEPT)), - themes: Default::default(), notebook: Ok(Some(notebook)), - server: None, - notice: None, - cache: cache.to_owned(), + ..Self::new(location, file_name(Path::new(location)), cache) } } /// The section file at `file` as a notebook of one tab. pub fn section(file: &Path, cache: &Path) -> Self { - Self { - location: file.to_string_lossy().into_owned(), - name: file_name(file.parent().unwrap_or(file)), - notebook: Ok(None), - server: None, - notice: None, - cache: cache.to_owned(), - background: None, - watch: None, - tag_art: Default::default(), - kept: Mutex::new(crate::prefetch::Recent::new(KEPT)), - themes: Default::default(), - } + Self::new( + &file.to_string_lossy(), + file_name(file.parent().unwrap_or(file)), + cache, + ) } /// Opens the section at catalog `path`; on a share, through a replica named by its @@ -654,6 +650,11 @@ impl Library { } } }; + let key = self.unlocked(path); + self.touch(path); + if key.is_none() && self.protected(path) { + return Err("The section is password protected".into()); + } let section = loop { if let Some(kept) = self .kept @@ -671,7 +672,7 @@ impl Library { }; let cache = notebook.replica_path(path)?; std::fs::create_dir_all(cache.parent().unwrap_or(&self.cache))?; - let replica = notebook::Replica::open_or_create(&cache, || { + let replica = notebook::Replica::open_or_create(&cache, key.as_ref(), || { Ok(server.connect()?.read_storage(&file, LIMIT)?) }); replica.and_then(|replica| { @@ -681,9 +682,13 @@ impl Library { }) } (Ok(Some(notebook)), None) if self.in_icloud() => { - crate::icloud::section(notebook, Path::new(&self.location), path, notifier()) + let root = Path::new(&self.location); + crate::icloud::section(notebook, root, path, key.as_ref(), notifier()) } - (Ok(Some(notebook)), None) => notebook.section(path, notifier()), + (Ok(Some(notebook)), None) => match &key { + Some(key) => notebook.section_unlocked(path, key, notifier()), + None => notebook.section(path, notifier()), + }, (Ok(None), _) if self.in_icloud() => { crate::icloud::lone_section(Path::new(path), &self.cache, notifier()) } @@ -835,16 +840,10 @@ impl Library { /// The readable sections of the folder at catalog path `folder`, as tabs in its order. pub fn tabs(&self, folder: &str) -> Vec { match &self.notebook { - Ok(Some(notebook)) => { - let mut folders = vec![notebook.catalog()]; - while let Some(candidate) = folders.pop() { - if candidate.path == folder { - return tabs(candidate); - } - folders.extend(&candidate.groups); - } - Vec::new() - } + Ok(Some(notebook)) => folders(notebook.catalog(), |_| true) + .into_iter() + .find(|candidate| candidate.path == folder) + .map_or_else(Vec::new, tabs), Ok(None) => vec![Tab { path: self.location.clone(), name: section_name(&self.location, &None), @@ -856,38 +855,23 @@ impl Library { /// The file identity of the section at catalog `path`. pub fn section_identity(&self, path: &str) -> Option<[u8; 16]> { - let Ok(Some(notebook)) = &self.notebook else { - return None; - }; - let mut folders = vec![notebook.catalog()]; - while let Some(folder) = folders.pop() { - if let Some(section) = folder.sections.iter().find(|section| section.path == path) { - return Some(section.file_id); - } - folders.extend(&folder.groups); - } - None + folders(self.catalog()?, |_| true) + .into_iter() + .flat_map(|folder| &folder.sections) + .find(|section| section.path == path) + .map(|section| section.file_id) } /// The first readable section, searching groups after sections, as OneNote opens a /// notebook. pub fn first_section(&self) -> Option { - let Ok(Some(notebook)) = &self.notebook else { + let Some(catalog) = self.catalog() else { return self.tabs("").pop().map(|tab| tab.path); }; - let mut folders = std::collections::VecDeque::from([notebook.catalog()]); - while let Some(folder) = folders.pop_front() { - if let Some(tab) = tabs(folder).into_iter().next() { - return Some(tab.path); - } - folders.extend( - folder - .groups - .iter() - .filter(|group| !recycle_bin(&group.path)), - ); - } - None + folders(catalog, |group| !recycle_bin(&group.path)) + .into_iter() + .find_map(|folder| tabs(folder).into_iter().next()) + .map(|tab| tab.path) } /// Whether the notebook lists a section at catalog path `path`. @@ -895,14 +879,9 @@ impl Library { let Some(catalog) = self.catalog() else { return self.location == path; }; - let mut folders = vec![catalog]; - while let Some(folder) = folders.pop() { - if folder.sections.iter().any(|section| section.path == path) { - return true; - } - folders.extend(&folder.groups); - } - false + folders(catalog, |_| true) + .iter() + .any(|folder| folder.sections.iter().any(|section| section.path == path)) } /// The notebook's colour, COLORREF, as its table of contents holds it. @@ -913,21 +892,12 @@ impl Library { /// Whether the notebook lists sections not on this device yet, as iCloud Drive keeps /// them elsewhere. pub fn downloading(&self) -> bool { - let Some(catalog) = self.catalog() else { - return false; - }; - let mut folders = vec![catalog]; - while let Some(folder) = folders.pop() { - if folder - .unavailable - .iter() - .any(|entry| entry.reason == notebook::discover::Reason::Evicted) - { - return true; - } - folders.extend(&folder.groups); - } - false + self.catalog().is_some_and(|catalog| { + folders(catalog, |_| true).iter().any(|folder| { + (folder.unavailable.iter()) + .any(|entry| entry.reason == notebook::discover::Reason::Evicted) + }) + }) } /// The notebook's folders of sections, for the sidebar. @@ -937,6 +907,89 @@ impl Library { _ => None, } } + + /// Whether the section at catalog `path` is password protected. + pub fn protected(&self, path: &str) -> bool { + self.catalog().is_some_and(|catalog| { + folders(catalog, |_| true) + .into_iter() + .flat_map(|folder| &folder.sections) + .any(|section| { + section.path == path && matches!(section.state, SectionState::Locked) + }) + }) + } + + /// Whether the section at catalog `path` is password protected and not unlocked. + pub fn locked(&self, path: &str) -> bool { + self.protected(path) && self.unlocked(path).is_none() + } + + /// The key the section at catalog `path` was unlocked with. + pub fn unlocked(&self, path: &str) -> Option { + let identity = self.section_identity(path)?; + let keys = self.keys.0.lock().ok()?; + keys.get(&identity).map(|(key, _)| key.clone()) + } + + /// Marks the unlocked section at catalog `path` worked in now. + pub fn touch(&self, path: &str) { + if let (Some(identity), Ok(mut keys)) = (self.section_identity(path), self.keys.0.lock()) + && let Some((_, used)) = keys.get_mut(&identity) + { + *used = Instant::now(); + } + } + + /// Unlocks the section at catalog `path` with `password`, as OneNote's Protected Section + /// dialog does; a wrong password is `PasswordMismatch`. + pub fn unlock(&self, path: &str, password: &str) -> Result<(), notebook::Error> { + let Ok(Some(notebook)) = &self.notebook else { + return Err(io::Error::from(io::ErrorKind::NotFound).into()); + }; + let key = notebook.unlock(path, password)?; + self.keep_key(path, key); + Ok(()) + } + + /// Keeps `key` for the section at catalog `path`, as setting its password gives one. + pub fn keep_key(&self, path: &str, key: Key) { + if let (Some(identity), Ok(mut keys)) = (self.section_identity(path), self.keys.0.lock()) { + keys.insert(identity, (key, Instant::now())); + } + } + + /// Locks the protected sections unlocked here that `chosen` picks by catalog path and + /// time since last worked in, closing the kept sessions that hold them; returns the paths + /// locked. + pub fn lock(&self, mut chosen: impl FnMut(&str, Duration) -> bool) -> Vec { + let Some(catalog) = self.catalog() else { + return Vec::new(); + }; + let mut locked = Vec::new(); + if let Ok(mut keys) = self.keys.0.lock() { + for section in folders(catalog, |_| true) + .into_iter() + .flat_map(|folder| &folder.sections) + { + let stale = keys + .get(§ion.file_id) + .is_some_and(|(_, used)| chosen(§ion.path, used.elapsed())); + if stale { + keys.remove(§ion.file_id); + locked.push(section.path.clone()); + } + } + } + if let Ok(mut kept) = self.kept.lock() { + for path in &locked { + if let Some(section) = kept.take(path) { + std::thread::spawn(move || close(vec![section])); + } + } + } + locked + } } /// 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 { .flatten() } -/// Whether the folder at `path` is the notebook's recycle bin, which OneNote keeps out of its lists. /// `Library::key` for the notebook at `location`, open or not. pub fn key(location: &str, path: &str) -> String { format!("{location}\n{path}") @@ -971,13 +1023,28 @@ fn display_name(cache: &Path, location: &str) -> Option { read_display_names(&display_names(cache)).remove(location) } +/// `folder` and the groups below it, breadth first, going into those `enter` takes. +pub fn folders(folder: &Folder, enter: impl Fn(&Folder) -> bool) -> Vec<&Folder> { + let mut all = vec![folder]; + let mut at = 0; + while let Some(folder) = all.get(at) { + all.extend(folder.groups.iter().filter(|group| enter(group))); + at += 1; + } + all +} + +/// A section's or group's name, as its catalog path ends. +pub fn entry_name(path: &str) -> &str { + let name = path.rsplit('/').next().unwrap_or(path); + name.strip_suffix(".one").unwrap_or(name) +} + +/// Whether the folder at `path` is the notebook's recycle bin, which OneNote keeps out of its lists. pub fn recycle_bin(path: &str) -> bool { path.rsplit('/').next() == Some("OneNote_RecycleBin") } -/// A mounted notebook's background sync, following Work Offline, and the watch that reports -/// the folder's changes to it. A folder on a network volume is kept in offline copies, as on a -/// share, and where the system does not report its server's changes, checked more often. /// The art `notebook` maps its tags to, each picture read once a run. fn read_tag_art(notebook: &Notebook) -> TagArt { let mut read = TagArt::default(); @@ -999,6 +1066,9 @@ fn read_tag_art(notebook: &Notebook) -> TagArt { read } +/// A mounted notebook's background sync, following Work Offline, and the watch that reports +/// the folder's changes to it. A folder on a network volume is kept in offline copies, as on a +/// share, and where the system does not report its server's changes, checked more often. fn local_background( notebook: &mut Notebook, location: &str, @@ -1061,6 +1131,10 @@ pub fn file_name(path: &Path) -> String { /// A section's name: its display name, or its file's. pub fn section_name(path: &str, name: &Option) -> String { name.clone().unwrap_or_else(|| { + // OneNote names the recycle bin's file of deleted pages so. + if path.ends_with("OneNote_RecycleBin/OneNote_DeletedPages.one") { + return "Deleted Pages".to_owned(); + } Path::new(path) .file_stem() .map(|stem| stem.to_string_lossy().into_owned()) @@ -1079,6 +1153,12 @@ fn tabs(catalog: &Folder) -> Vec { path: section.path.clone(), color: *color, }), + // Its name and colour are inside the encryption, but its file is named for it. + SectionState::Locked => Some(Tab { + name: section_name(§ion.path, &None), + path: section.path.clone(), + color: None, + }), _ => None, }) .collect() @@ -1094,6 +1174,8 @@ pub enum Located { }, /// A section file outside any notebook. Section(PathBuf), + /// A OneNote package, which unpacks into a notebook. + Package(PathBuf), Nothing, } @@ -1111,7 +1193,7 @@ fn has_toc(folder: &Path) -> bool { /// What `path` opens: a folder is a notebook; a table of contents opens its folder's; a /// section opens in the notebook the folders above it with tables of contents make up, -/// section groups included, or alone. +/// section groups included, or alone; a package asks to be unpacked. pub fn locate(path: &Path) -> Located { let extension = path .extension() @@ -1121,6 +1203,7 @@ pub fn locate(path: &Path) -> Located { root: path.to_owned(), section: None, }, + Some("onepkg") => Located::Package(path.to_owned()), Some("onetoc2") => match path.parent() { Some(root) => Located::Notebook { root: root.to_owned(), @@ -1327,9 +1410,6 @@ mod tests { #[test] fn share_relative_sections_show_under_their_mount() { let library = |root: &str| Library { - location: "/Volumes/agent/lab".into(), - name: "lab".into(), - notebook: Ok(None), server: Some(Arc::new(Server { mount: Mount { server: "nas".into(), @@ -1344,13 +1424,7 @@ mod tests { domain: String::new(), }, })), - notice: None, - cache: PathBuf::new(), - background: None, - watch: None, - tag_art: Default::default(), - kept: Mutex::new(crate::prefetch::Recent::new(KEPT)), - themes: Default::default(), + ..Library::new("/Volumes/agent/lab", "lab".into(), Path::new("")) }; let shown = |root, file| library(root).local(Path::new(file)); let under = Some(PathBuf::from("/Volumes/agent/lab/Group/New Section 1.one")); @@ -1447,6 +1521,10 @@ mod tests { locate(&root.join("Loose.one")), Located::Section(root.join("Loose.one")) ); + assert_eq!( + locate(&root.join("Shared.onepkg")), + Located::Package(root.join("Shared.onepkg")) + ); assert_eq!(locate(&root.join("missing.txt")), Located::Nothing); std::fs::remove_dir_all(&root).unwrap(); } diff --git a/crates/snowbound/src/link.rs b/crates/snowbound/src/link.rs index 30aa1783395918063e17a8a4749d597218d9c9b3..fa9547b60abfb4afdb3767712869c8e65e0a58ef 100644 --- a/crates/snowbound/src/link.rs +++ b/crates/snowbound/src/link.rs @@ -226,22 +226,7 @@ impl State { } } ui.close(); - ui.open( - "buttons", - Spec { - size: [fill(), children()], - pad: [0.0, 8.0], - gap: 8.0, - ..Spec::default() - }, - ); - ui.leaf( - "space", - Spec { - size: [fill(), px(1.0)], - ..Spec::default() - }, - ); + crate::buttons(ui); let ok = ui::button(ui, "ok", "OK").clicked || entered; let cancel = ui::button(ui, "cancel", "Cancel").clicked; ui.close(); diff --git a/crates/snowbound/src/linux.rs b/crates/snowbound/src/linux.rs index 9be8864468553eb285beb3418644a4fa3cded821..ca841cadfe1a417e8603b849882a187cb93b65b5 100644 --- a/crates/snowbound/src/linux.rs +++ b/crates/snowbound/src/linux.rs @@ -72,12 +72,12 @@ pub fn corner_radius(window: &Window) -> f32 { (units * scale).round() / scale } +/// Winit's theme already holds the window to the app's appearance. +pub fn follow_appearance(_: &Window, _: Theme) {} + /// Whether the window erases its own bottom corners to transparent pixels: under winit's /// Adwaita frame on GNOME's Wayland, which draws libadwaita's window edge. Mutter's X11 /// frames stay square at the bottom, as libadwaita's own `ssd-frame` style has them. -/// Winit's theme already holds the window to the app's appearance. -pub fn follow_appearance(_: &Window, _: Theme) {} - pub fn cuts_corners() -> bool { sctk_adwaita::LIBADWAITA_EDGE.load(Ordering::Relaxed) } @@ -617,6 +617,11 @@ pub fn resize_direction(window: &Window, pointer: [f32; 2]) -> Option String { +pub(crate) fn file_uri(path: &std::path::Path) -> String { use std::os::unix::ffi::OsStrExt; - let mut uri = "file://".to_owned(); - for &byte in path.as_os_str().as_bytes() { - match byte { - b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' | b'/' => { - uri.push(byte as char) - } - _ => uri.push_str(&format!("%{byte:02X}")), - } - } - uri + format!( + "file://{}", + crate::library::encode(path.as_os_str().as_bytes()) + ) +} + +/// The function `name` in `library`, of C signature `F`. +pub(crate) unsafe fn symbol(library: *mut libc::c_void, name: &CStr) -> Option { + let symbol = unsafe { libc::dlsym(library, name.as_ptr()) }; + (!symbol.is_null()) + .then(|| unsafe { std::mem::transmute_copy::<*mut libc::c_void, F>(&symbol) }) } pub fn cache_dir() -> Option { @@ -1124,6 +1130,17 @@ fn dialog( .then(|| String::from_utf8_lossy(&output.stdout).trim().to_owned())) } +/// The path a file dialog answered; none when cancelled or when no tool could ask. +fn picked(asked: Result, &str>) -> Option { + asked + .unwrap_or_else(|error| { + eprintln!("{error}"); + None + }) + .filter(|path| !path.is_empty()) + .map(PathBuf::from) +} + /// Asks for a file to insert, one of `types` (extensions) unless empty, titled `title`. pub fn pick_file(title: &str, types: &[&str]) -> Option { let patterns = match types { @@ -1134,21 +1151,14 @@ pub fn pick_file(title: &str, types: &[&str]) -> Option { .collect::>() .join(" "), }; - let asked = dialog( + picked(dialog( [ "--file-selection", &format!("--title={title}"), &format!("--file-filter={patterns}"), ], ["--getopenfilename", ".", &patterns, "--title", title], - ); - asked - .unwrap_or_else(|error| { - eprintln!("{error}"); - None - }) - .filter(|path| !path.is_empty()) - .map(PathBuf::from) + )) } /// 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 /// Asks for a notebook's table of contents or a section file, titled `title`; None when /// cancelled or when no tool can ask. pub fn pick_notebook(title: &str) -> Option { - let asked = dialog( + picked(dialog( [ "--file-selection", &format!("--title={title}"), - "--file-filter=OneNote notebooks and sections | *.onetoc2 *.one", + "--file-filter=OneNote notebooks, sections and packages | *.onetoc2 *.one *.onepkg", ], [ "--getopenfilename", ".", - "*.onetoc2 *.one", + "*.onetoc2 *.one *.onepkg", "--title", title, ], - ); - asked - .unwrap_or_else(|error| { - eprintln!("{error}"); - None - }) - .filter(|path| !path.is_empty()) - .map(PathBuf::from) + )) } /// Asks where to put something named `name` by default; the dialog names its own button. @@ -1240,7 +1243,7 @@ pub fn pick_new( _action: &str, _folder: Option<&std::path::Path>, ) -> Option { - let asked = dialog( + picked(dialog( [ "--file-selection", "--save", @@ -1248,14 +1251,7 @@ pub fn pick_new( &format!("--filename={name}"), ], ["--getsavefilename", name, "--title", title], - ); - asked - .unwrap_or_else(|error| { - eprintln!("{error}"); - None - }) - .filter(|path| !path.is_empty()) - .map(PathBuf::from) + )) } /// Tells the user something they asked for could not be done: `message`, then what to do. diff --git a/crates/snowbound/src/macos.rs b/crates/snowbound/src/macos.rs index 9903eb806c7a910cf140ff2ba56327a867364287..0e8fc115a284072a21635aae2e131ca709650c8f 100644 --- a/crates/snowbound/src/macos.rs +++ b/crates/snowbound/src/macos.rs @@ -37,7 +37,7 @@ pub fn with_pool(run: impl FnOnce() -> R) -> R { pub use crate::aqua::{cover_border_line, move_cursor, resize_grip, system_interface}; -static QUIT: OnceLock> = OnceLock::new(); +static PROXY: OnceLock> = OnceLock::new(); static INPUT_CLASS: OnceLock<&'static AnyClass> = OnceLock::new(); static BACKDROP_CLASS: OnceLock<&'static AnyClass> = OnceLock::new(); static DELEGATE_CLASS: OnceLock<&'static AnyClass> = OnceLock::new(); @@ -71,23 +71,25 @@ unsafe extern "C" fn insert_text(view: &AnyObject, _: Sel, text: &AnyObject, ran } else { msg_send_id![text, copy] }; - if let Some(proxy) = QUIT.get() { + if let Some(proxy) = PROXY.get() { let _ = proxy.send_event(crate::UserEvent::InsertText(string.to_string())); } } } } -fn ns_window(window: &Window) -> Retained { +/// The view winit draws the window's content in. +fn ns_view(window: &Window) -> Retained { let RawWindowHandle::AppKit(handle) = window.window_handle().expect("Live AppKit window").as_raw() else { unreachable!() }; - unsafe { - let view = &*handle.ns_view.as_ptr().cast::(); - msg_send_id![view, window] - } + unsafe { Retained::retain(handle.ns_view.as_ptr().cast()) }.expect("A live window has a view") +} + +pub(crate) fn ns_window(window: &Window) -> Retained { + unsafe { msg_send_id![&ns_view(window), window] } } /// Room the traffic lights take at the title bar's leading edge. @@ -107,11 +109,11 @@ pub fn corner_radius(_: &Window) -> f32 { } } -/// AppKit clips the window's corners itself, except where the OpenGL surface covers -/// 10.6's textured window, whose rounded bottom corners the app leaves transparent. /// Winit's theme already holds the window to the app's appearance. pub fn follow_appearance(_: &Window, _: winit::window::Theme) {} +/// AppKit clips the window's corners itself, except where the OpenGL surface covers +/// 10.6's textured window, whose rounded bottom corners the app leaves transparent. pub fn cuts_corners() -> bool { crate::aqua::before_lion() } @@ -332,13 +334,8 @@ pub fn install_backdrop(window: &Window) -> bool { let Some(effect) = AnyClass::get("NSVisualEffectView") else { return crate::aqua::textured(window, crate::TITLE + crate::TAB_ROW); }; - let RawWindowHandle::AppKit(handle) = - window.window_handle().expect("Live AppKit window").as_raw() - else { - unreachable!() - }; + let view = &*ns_view(window); unsafe { - let view = &*handle.ns_view.as_ptr().cast::(); let class = BACKDROP_CLASS.get_or_init(|| { let mut class = ClassBuilder::new("SnowboundBackdrop", effect).expect("Unique backdrop class"); @@ -881,6 +878,11 @@ pub fn appearance(window: &Window) -> winit::window::Theme { window.theme().unwrap_or(winit::window::Theme::Dark) } +/// Keeps the window's client area at least `size` points. +pub fn set_min_size(window: &Window, size: [f32; 2]) { + window.set_min_inner_size(Some(winit::dpi::LogicalSize::new(size[0], size[1]))); +} + /// Zooms the window once the current event is handled: AppKit's zoom animation runs its /// own loop, and started from inside winit's handler it would hold every resize until the /// end, stretching the last frame instead of drawing each step. @@ -899,13 +901,8 @@ pub fn zoom(window: &Window) { /// Install before AccessKit subclasses the same view, preserving its restoration chain. pub fn install_text_input(window: &Window) { MainThreadMarker::new().expect("Text input belongs to the main thread"); - let RawWindowHandle::AppKit(handle) = - window.window_handle().expect("Live AppKit window").as_raw() - else { - unreachable!() - }; + let view = &*ns_view(window); unsafe { - let view = &*handle.ns_view.as_ptr().cast::(); let class = INPUT_CLASS.get_or_init(|| { let mut class = ClassBuilder::new("SnowboundTextInputView", view.class()) .expect("Unique text input class"); @@ -965,9 +962,7 @@ pub fn edit_date( } picker.setCalendar(Some(&calendar)); picker.setTimeZone(Some(&calendar.timeZone())); - picker.setDateValue(&NSDate::dateWithTimeIntervalSince1970( - (timestamp / 10_000_000) as f64 - 11_644_473_600.0, - )); + picker.setDateValue(&ns_date(timestamp)); picker.setMinDate(Some(&NSDate::dateWithTimeIntervalSince1970( -11_644_473_600.0, ))); @@ -996,9 +991,7 @@ fn merge_date( calendar: &NSCalendar, ) -> Result<(u64, [String; 2]), &'static str> { unsafe { - let original = NSDate::dateWithTimeIntervalSince1970( - (timestamp / 10_000_000) as f64 - 11_644_473_600.0, - ); + let original = ns_date(timestamp); let units = NSCalendarUnit::Era | NSCalendarUnit::Year | NSCalendarUnit::Month @@ -1053,11 +1046,14 @@ fn labels(date: &NSDate, calendar: &NSCalendar) -> [String; 2] { /// FILETIME as a new page's title shows it: the long date and the short time. pub fn date_text(filetime: u64) -> [String; 2] { + let calendar = unsafe { NSCalendar::currentCalendar() }; + labels(&ns_date(filetime), &calendar) +} + +/// FILETIME as an `NSDate`, to the second. +fn ns_date(filetime: u64) -> Retained { unsafe { - let date = NSDate::dateWithTimeIntervalSince1970( - (filetime / 10_000_000) as f64 - 11_644_473_600.0, - ); - labels(&date, &NSCalendar::currentCalendar()) + NSDate::dateWithTimeIntervalSince1970((filetime / 10_000_000) as f64 - 11_644_473_600.0) } } @@ -1069,9 +1065,7 @@ pub fn user_name() -> String { /// FILETIME as the system's short date, as OneNote labels a conflict page. pub fn short_date(filetime: u64) -> String { unsafe { - let date = NSDate::dateWithTimeIntervalSince1970( - (filetime / 10_000_000) as f64 - 11_644_473_600.0, - ); + let date = ns_date(filetime); let formatter = NSDateFormatter::new(); formatter.setDateStyle(NSDateFormatterStyle::NSDateFormatterShortStyle); formatter.setTimeStyle(NSDateFormatterStyle::NSDateFormatterNoStyle); @@ -1231,13 +1225,13 @@ declare_class!( #[method(terminate:)] fn terminate(&self, _sender: Option<&AnyObject>) { - if let Some(proxy) = QUIT.get() { let _ = proxy.send_event(crate::UserEvent::Quit); } + if let Some(proxy) = PROXY.get() { let _ = proxy.send_event(crate::UserEvent::Quit); } } #[method(choose:)] fn choose(&self, sender: &NSMenuItem) { let tag = usize::try_from(unsafe { sender.tag() }).ok(); - if let (Some(proxy), Some(choice)) = (QUIT.get(), tag.and_then(|tag| commands::choices().nth(tag))) { + if let (Some(proxy), Some(choice)) = (PROXY.get(), tag.and_then(|tag| commands::choices().nth(tag))) { let _ = proxy.send_event(crate::UserEvent::Choose(choice)); } } @@ -1302,7 +1296,8 @@ pub fn event_loop(headless: bool) -> Result, EventLo .with_activate_ignoring_other_apps(false); } let event_loop = builder.build()?; - QUIT.set(event_loop.create_proxy()) + PROXY + .set(event_loop.create_proxy()) .expect("Only one application event loop is created"); // Winit's delegate gains -application:openFiles:, which every macOS from 10.6 sends for // 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( app: &NSApplication, files: &objc2_foundation::NSArray, ) { - if let Some(proxy) = QUIT.get() { + if let Some(proxy) = PROXY.get() { let paths = files.iter().map(|file| file.to_string().into()).collect(); let _ = proxy.send_event(crate::UserEvent::Open(paths)); } @@ -1392,14 +1387,9 @@ pub fn confirm(message: &str, detail: &str, cancel: &str, action: &str) -> bool /// End AppKit preedit after a canvas action commits or cancels the composition. pub fn clear_marked_text(window: &Window) { MainThreadMarker::new().expect("Text input belongs to the main thread"); - let RawWindowHandle::AppKit(handle) = - window.window_handle().expect("Live AppKit window").as_raw() - else { - unreachable!() - }; // Winit owns this view and implements the NSTextInputClient selectors. + let view = &*ns_view(window); unsafe { - let view = &*handle.ns_view.as_ptr().cast::(); let marked: bool = msg_send![view, hasMarkedText]; if marked { let _: () = msg_send![view, unmarkText]; diff --git a/crates/snowbound/src/main.rs b/crates/snowbound/src/main.rs index 23b57e6d1b76dd0b7d9324217f12efc565b027b4..7b28d6d7aedf09d4eae4e86fd5de3995689c652e 100644 --- a/crates/snowbound/src/main.rs +++ b/crates/snowbound/src/main.rs @@ -40,13 +40,16 @@ mod paste; mod platform; mod prefetch; mod print; -mod properties; #[cfg_attr(target_os = "linux", path = "print_linux.rs")] #[cfg_attr(target_os = "macos", path = "print_macos.rs")] #[cfg_attr(windows, path = "print_windows.rs")] mod printer; +mod properties; +mod protection; mod recording; +mod recycle; mod rename; +mod save_as; mod screenshot; mod search; mod server; @@ -64,6 +67,8 @@ mod tags; mod templates; mod themes; mod undo; +mod unpack; +mod unread; mod update; mod video; mod watch; @@ -106,6 +111,12 @@ use winit::{ const TITLE: f32 = 38.0; /// Space between toolbar groups. const GAP: f32 = 6.0; +/// A tool face's room beside its buttons. +const FACE_PAD: f32 = 5.0; +/// How far a tool face stands above and below its buttons. +const FACE_RISE: f32 = 3.0; +/// Space between groups on faces of their own. +const FACE_GAP: f32 = 2.0; const TAB_ROW: f32 = 28.0; /// Width of the section colour around the page, and the rows' margin at the window's sides. const FRAME: f32 = 6.0; @@ -115,7 +126,6 @@ const PAGE_LIST: f32 = 240.0; const MIN_SIZE: [f32; 2] = [600.0, 400.0]; /// Extensions of the pictures Insert, Picture offers: those the page both stores and draws. const PICTURE_TYPES: [&str; 4] = ["png", "jpg", "jpeg", "gif"]; -/// Font sizes the size box offers, OneNote's list in points. /// Fonts the font box offers first, OneNote's default leading. const FONTS: [&str; 4] = ["Calibri", "Arial", "Times New Roman", "Courier New"]; /// Picks the font box and the list galleries offer as recent, at most, as OneNote's @@ -579,6 +589,8 @@ enum Command { }, /// Deletes pages of the open section to the notebook's recycle bin. DeletePages(Vec), + /// Restores or deletes for good pages of the recycle bin's open section. + Recycle(recycle::Request), /// Moves or indents pages of the open section. Pages(Vec), /// Moves a page of the open section to the end of another section of its folder. @@ -672,7 +684,7 @@ struct State { cache: PathBuf, /// Whether the notebook sidebar is expanded. sidebar: bool, - /// Notebooks and section groups whose rows the sidebar folds, by `sidebar::fold_key`. + /// Notebooks and section groups whose rows the sidebar folds, by `Library::key`. folded: HashSet, /// The open context menu: what it was opened on, and where. menu: Option<(menus::Target, [f32; 2])>, @@ -693,6 +705,12 @@ struct State { /// The Link dialog's fields while it is open. link: Option, properties: Option, + /// Save As while it is open. + save_as: Option, + /// Unpack Notebook while it is open. + unpacking: Option, + /// What has been read in each notebook, on this computer. + reads: unread::Reads, /// Open Notebook from Server while it is open. server: Option, /// New iCloud Notebook while it is open. @@ -728,6 +746,12 @@ struct State { /// The notebook shown while it has no section open, as one whose last section was /// deleted. sectionless: Option>, + /// A password-protected section shown locked, in place of an open one. + locked: Option, + /// The password dialog while it is open. + password: Option, + /// Options' Passwords. + passwords: settings::Passwords, /// What the pointer is dragging: a page's tab, a section tab or a sidebar row. drag: Option, /// Pages whose template strip was dismissed this run. @@ -879,6 +903,7 @@ impl State { let mut notebooks = Vec::new(); let mut session = None; let mut sectionless = None; + let mut locked = None; let (editor, scene) = match input { Input::Notes { document, @@ -933,7 +958,7 @@ impl State { .chain(¬ebooks) .find_map(|library| Some((library, library.first_section()?))); match shown { - Some((library, path)) => { + Some((library, path)) if !library.locked(&path) => { let section = library.open(&path, notify(proxy.clone()))?; let (opened, page) = read_session(section, Arc::clone(library), path, None)?; @@ -941,11 +966,17 @@ impl State { session = Some(opened); (editor, Some((scene, [0.0; 2]))) } - None => { + shown => { + // A first section still locked shows its locked page. + locked = shown.map(|(library, path)| protection::Locked { + library: Arc::clone(library), + path, + }); sectionless = notebooks .iter() .find(|library| Some(&library.location) == current.as_ref()) .or(notebooks.first()) + .filter(|_| locked.is_none()) .cloned(); let blank = TextDocument::new(vec![Paragraph::new( String::new(), @@ -1014,6 +1045,7 @@ impl State { .map(|dictionary| canvas::spelling::Spelling::new(dictionary, redraw.clone())); let updates = update::Updates::start(visible && !stored.manual_updates, proxy.clone()); let prefetch = prefetch::Prefetch::new(Arc::clone(&layouts), redraw.clone()); + let reads = unread::Reads::load(&cache); let mut state = Self { author: stored.user_name.unwrap_or_else(platform::user_name), window, @@ -1045,6 +1077,9 @@ impl State { after_open: None, link: None, properties: None, + save_as: None, + unpacking: None, + reads, server: None, new_icloud: None, icloud_reading: HashSet::new(), @@ -1062,6 +1097,9 @@ impl State { titlebar, renaming: None, sectionless, + locked, + password: None, + passwords: stored.passwords, drag: None, templates: templates::View::Strip, media: Default::default(), @@ -1142,6 +1180,7 @@ impl State { eprintln!("{error}"); } lap("open", start); + self.follow_reading(); let size = self.window.inner_size(); let scale = self.scale(); if scale != self.ui.scale() { @@ -1170,6 +1209,7 @@ impl State { self.ime_allowed = ime; self.window.set_ime_allowed(ime); } + self.lock_idle()?; self.draw()?; lap("drawn", start); self.sync_index(false, Vec::new()); @@ -1211,6 +1251,9 @@ impl State { self.new_icloud_dialog(); self.link_dialog()?; self.properties_dialog(); + self.save_as_dialog(); + self.unpack_dialog(); + self.password_dialog()?; self.customize_tags(); self.palette(); self.sync_popup()?; @@ -1314,7 +1357,10 @@ impl State { let mut theme = self.ui.theme.clone(); theme.paper = self.paper().color; platform::update_menu(|| self.statuses()); - let welcome = self.session.is_none() && !self.temporary && self.sectionless.is_none(); + let welcome = self.session.is_none() + && !self.temporary + && self.sectionless.is_none() + && self.locked.is_none(); self.toolbar(&theme, !welcome)?; if welcome { self.welcome(&theme); @@ -1380,7 +1426,9 @@ impl State { ..Spec::default() }, ); + self.recycle_heading(&theme); let row = sections(); + let unread = self.unread_keys(); let (clicked, open_tab) = match &self.session { Some(session) => { // A tab being renamed takes the name typed, which its field covers. @@ -1394,7 +1442,8 @@ impl State { } _ => tab.name.as_str(), }; - (name, section_color(tab.color)) + let unread = unread.contains(&session.library.key(&tab.path)); + (name, section_color(tab.color), unread) }) .collect(); let lit = self.page_drop(row); @@ -1453,8 +1502,9 @@ impl State { self.tab_rename_field(&theme, row, tab_row); (clicked, open_tab) } + None if self.locked.is_some() => self.locked_tabs(row, §ion, theme.strip), None => { - let tabs = [("Temporary page", section_color(None))]; + let tabs = [("Temporary page", section_color(None), false)]; let shown = if self.temporary { &tabs[..] } else { &[] }; let open_tab = ui::shell::section_tabs( &mut self.ui, @@ -1537,6 +1587,22 @@ impl State { self.commands .extend(conflict_bar(&mut self.ui, bar, §ions, steps)); } + if self.in_recycle_bin() { + recycle::bar(&mut self.ui); + } + if self.session.is_none() && self.locked.is_some() { + self.locked_page(&theme); + self.ui.close(); + self.ui.close(); + self.ui.close(); + if self.navigation_bar_right { + self.sidebar(&theme, sidebar); + } + self.sidebar_button(&theme, height); + self.ui.close(); + self.context_menu(); + return Ok((section, open_tab, None)); + } if let Some(library) = self.sectionless.clone().filter(|_| self.session.is_none()) { self.no_sections(&theme, library); self.ui.close(); @@ -1816,7 +1882,6 @@ impl State { Flags::default() }, size: [fill(), px(TITLE)], - fill: Some(theme.strip), pad: [0.0, (TITLE - ui::shell::TOOL) / 2.0], gap: GAP, role: Some(accesskit::Role::Toolbar), @@ -1827,6 +1892,20 @@ impl State { } else { self.ui.open("toolbar", spec) }; + // The strip stops short of the window's controls, where Windows 7 draws its caption + // buttons beneath the window's pixels. + let edge = (TITLE - ui::shell::TOOL) / 2.0; + self.ui.open( + "bar", + Spec { + size: [fill(), px(TITLE)], + fill: Some(theme.strip), + pad: [0.0, edge], + offset: [0.0, -edge], + gap: GAP, + ..Spec::default() + }, + ); let lead = if title { platform::LEADING } else { FRAME }; self.ui.leaf( "lead", @@ -1838,16 +1917,21 @@ impl State { if tools { // The groups fold in the room the window's controls leave, and whatever still // overflows is cut there, so the controls always show. - let edge = (TITLE - ui::shell::TOOL) / 2.0; + let panel = theme.tool[3] > 0.0 && theme.tool_panel; + let face = tool_face(theme, panel, FACE_RISE - edge); self.ui.open( "tools", Spec { flags: Flags::CLIP, size: [fill(), px(TITLE)], - pad: [0.0, edge], + pad: [face.pad[0], edge], offset: [0.0, -edge], - gap: GAP, - ..Spec::default() + gap: if theme.tool[3] > 0.0 && !panel { + FACE_GAP + } else { + GAP + }, + ..face }, ); self.tools(theme); @@ -1861,6 +1945,7 @@ impl State { }, ); } + self.ui.close(); if title { platform::window_controls(&mut self.ui, &self.window); } @@ -1878,8 +1963,7 @@ impl State { .map_or(MIN_SIZE[0], |row| row.ceil().max(MIN_SIZE[0])); if narrowest != self.min_width { self.min_width = narrowest; - self.window - .set_min_inner_size(Some(LogicalSize::new(narrowest, MIN_SIZE[1]))); + platform::set_min_size(&self.window, [narrowest, MIN_SIZE[1]]); } Ok(()) } @@ -2006,7 +2090,7 @@ impl State { "font", Spec { gap: 1.0, - ..Spec::default() + ..tool_face(theme, grouped(theme), FACE_RISE) }, ); divider(ui, theme); @@ -2705,7 +2789,7 @@ impl State { eprintln!("{error}"); } if ui::shell::tool_button(&mut self.ui, "new", art::PLUS, theme.text, None).clicked { - self.commands.push(Command::NewPage { under: None }); + self.choose(commands::Choice::Command(commands::Id::NewPage)); } tip(&mut self.ui, commands::Id::NewPage); let toggle = if self.pages_open { @@ -2749,9 +2833,8 @@ impl State { self.ui.close(); return None; } - let found = self - .search - .found_in(&session.library.key(&session.tabs[session.tab].path)); + let found = self.search.found_in(&session.key()); + let unread = self.unread_pages(); let rounding = self.rounding(); let dragged = self.dragged_page(); let rows = page_rows( @@ -2759,7 +2842,7 @@ impl State { theme, section, session, - &found, + [&found, &unread], (rounding, self.page_tabs_left), self.renaming.as_mut(), dragged, @@ -2813,7 +2896,8 @@ impl State { let response = match event { ui::Event::PointerMoved(point) => self.view.pointer_moved(device(point))?, ui::Event::Pressure(pressure) => { - self.view.set_pressure(pressure.filter(|_| self.pen_pressure)); + self.view + .set_pressure(pressure.filter(|_| self.pen_pressure)); continue; } ui::Event::PointerLeft => self.view.pointer_left(), @@ -2900,6 +2984,9 @@ impl State { fn apply(&mut self, command: Command) -> Result<(), Box> { match command { Command::OpenSection(library, path) => { + if library.locked(&path) { + return self.show_locked(library, path); + } let tab = match &self.session { // Back to the open section before another one opened. Some(session) @@ -2953,7 +3040,7 @@ impl State { self.commands.push(Command::OpenPage(page)); } Command::CopyVersion { version, tab } => { - let session = self.session.as_ref().ok_or("No section is open")?; + let session = self.session.as_mut().ok_or("No section is open")?; let page = match session.version { Some(open) if open == version => { session.section.version(session.space, open)? @@ -2962,6 +3049,8 @@ impl State { }; if tab == session.tab { session.section.import_page(&page, &self.author)?; + session.pages = session.section.pages()?; + self.edited(Vec::new()); } else { let library = Arc::clone(&session.library); let path = session.tabs[tab].path.clone(); @@ -3010,6 +3099,7 @@ impl State { self.delete_pages(pages)?; self.edited(Vec::new()); } + Command::Recycle(request) => self.recycle(request)?, Command::Pages(edits) => self.edit_pages(edits)?, Command::MovePage { space, path } => { self.move_page(space, path)?; @@ -3108,11 +3198,7 @@ impl State { let Some(mut opening) = self.opening.take() else { return Ok(()); }; - let paper = canvas::gpu::Paper { - color: self.ui.theme.paper, - ink: self.ui.theme.paper_ink, - } - .colored(opening.editor.page_color()); + let paper = paper.colored(opening.editor.page_color()); if !self .view .prepare(&mut opening.scene, &opening.editor, paper, &self.redraw) @@ -3151,23 +3237,15 @@ impl State { .session .as_ref() .is_none_or(|open| open.library.location != session.library.location); - // A notebook read again after a change replaces the one it was. - match self - .notebooks - .iter_mut() - .find(|library| library.location == session.library.location) - { - Some(library) => *library = Arc::clone(&session.library), - None => self.notebooks.push(Arc::clone(&session.library)), - } - if session.library.in_icloud() && session.library.downloading() { - self.fetch(Arc::clone(&session.library), None); - } + self.list(&session.library); // The section left stays open a while for coming back to; the notebook's // background sync takes it over once it is closed. + self.locked = None; if let Some(previous) = self.session.replace(*session) { + let path = &previous.tabs[previous.tab].path; + let locked = self.leave(&previous.library, path); let library = &previous.library; - if self.notebooks.iter().any(|open| Arc::ptr_eq(open, library)) { + if !locked && self.notebooks.iter().any(|open| Arc::ptr_eq(open, library)) { library.keep(&previous.tabs[previous.tab].path, previous.section); } else { let section = previous.section; @@ -3197,7 +3275,7 @@ impl State { } } let place = self.session.as_ref().and_then(|session| { - let key = session.library.key(&session.tabs[session.tab].path); + let key = session.key(); self.places.get(&(key, session.space)).copied() }); let left = self.view.open(opening.editor, Some(opening.scene), place); @@ -3217,7 +3295,10 @@ impl State { self.switching = None; self.visited(); if let Some((space, action)) = self.after_open.take() - && let Some(session) = self.session.as_ref().filter(|session| session.space == space) + && let Some(session) = self + .session + .as_ref() + .filter(|session| session.space == space) { let target = menus::Target::Page { library: Arc::clone(&session.library), @@ -3259,17 +3340,7 @@ impl State { /// the open section, now at catalog `path` in it, stays open, reopened where it moved. /// Without `path` the notebook has no sections left, and one of its shows none. fn adopt(&mut self, library: Arc, path: Option<&str>) -> Result<(), Box> { - if library.in_icloud() && library.downloading() { - self.fetch(Arc::clone(&library), None); - } - match self - .notebooks - .iter_mut() - .find(|open| open.location == library.location) - { - Some(open) => *open = Arc::clone(&library), - None => self.notebooks.push(Arc::clone(&library)), - } + self.list(&library); let shown = |shown: &Library| shown.location == library.location; let Some(path) = path else { if self.sectionless.as_deref().is_some_and(shown) @@ -3317,6 +3388,18 @@ impl State { Ok(()) } + /// Lists `library` in place of the notebook read before at its location, or last, and + /// brings down what iCloud Drive keeps elsewhere of it. + fn list(&mut self, library: &Arc) { + match (self.notebooks.iter_mut()).find(|open| open.location == library.location) { + Some(open) => *open = Arc::clone(library), + None => self.notebooks.push(Arc::clone(library)), + } + if library.in_icloud() && library.downloading() { + self.fetch(Arc::clone(library), None); + } + } + /// Follows a page shown in place of another. fn opened(&mut self) -> Result<(), Box> { if let Err(error) = self.wear_theme() { @@ -3369,7 +3452,14 @@ impl State { .file_name() .map(|name| name.to_string_lossy().into_owned()) .unwrap_or_default(); - format!("{} · {file}", session.title()) + // OneNote titles a page of the recycle bin so. + let binned = recycle::binned(&session.tabs[session.tab].path); + let bin = if binned { + " (Read-Only - Recycle Bin)" + } else { + "" + }; + format!("{}{bin} · {file}", session.title()) } None if !self.temporary => "Snowbound".to_owned(), None => { @@ -3486,7 +3576,10 @@ impl State { loop { session.section.wake(); if Instant::now() >= deadline - || session.section.pending().is_ok_and(|pending| pending.is_empty()) + || session + .section + .pending() + .is_ok_and(|pending| pending.is_empty()) { return; } @@ -3575,16 +3668,17 @@ impl State { /// last poll. fn synced(&mut self) -> Result<(), Box> { // Every notebook's changes are taken, so none is reported again. - let changed: Vec = self - .notebooks - .iter() - .filter_map(|library| Some((library, library.background.as_ref()?))) - .flat_map(|(library, background)| { - background - .changed() - .into_iter() - .map(|path| library.key(&path)) + let reported: Vec<(Arc, Vec)> = (self.notebooks.iter()) + .filter_map(|library| { + Some((Arc::clone(library), library.background.as_ref()?.changed())) }) + .filter(|(_, paths)| !paths.is_empty()) + .collect(); + for (library, paths) in &reported { + self.sections_changed(library, paths.clone()); + } + let changed: Vec = (reported.iter()) + .flat_map(|(library, paths)| paths.iter().map(|path| library.key(path))) .collect(); if !changed.is_empty() { self.sync_index(false, changed); @@ -3597,17 +3691,16 @@ impl State { let mut listed = false; let mut changed = false; let mut rejected = None; + let mut elsewhere = Vec::new(); for event in session.section.events() { use notebook::session::Event; match event { Event::Changed(spaces) => { listed = true; changed |= spaces.contains(&session.space); - self.search.changed( - session.library.key(&session.tabs[session.tab].path), - session.section.replica(), - spaces, - ); + elsewhere.extend_from_slice(&spaces); + self.search + .changed(session.key(), session.section.replica(), spaces); } Event::Rejected { spaces, error } => { if spaces.contains(&session.space) { @@ -3621,6 +3714,12 @@ impl State { } } session.sync = session.section.sync_status()?; + if !elsewhere.is_empty() { + self.changed_elsewhere(&elsewhere); + } + let Some(session) = &mut self.session else { + return Ok(()); + }; if listed { let back = session.shown; session.pages = session.section.pages()?; @@ -3967,9 +4066,15 @@ impl State { }) .collect(); trace_input(&("Draw", viewport.origin, viewport.scale)); + // Where the system's material shows through, only what the chrome fills covers it. + let clear = if self.surface.translucent() { + [0.0; 4] + } else { + self.ui.theme.strip + }; let start = Instant::now(); self.renderer - .draw(target, self.surface.size, self.ui.theme.strip, &layers) + .draw(target, self.surface.size, clear, &layers) .map_err(|error| format!("Canvas drawing failed: {error:?}"))?; lap("render", start); Ok(()) @@ -3988,9 +4093,6 @@ impl State { self.surface.read(&self.renderer, offscreen) } - /// Queues input for the next frame. A press on the strip moves the window: at once on - /// macOS, while AppKit still holds the press, and elsewhere once the pointer moves, as - /// the window manager's move would swallow a second click; a double press zooms it. fn over_page(&self) -> bool { let [x0, y0, x1, y1] = self.ui.rect(page()).unwrap_or_default(); let [x, y] = self.pointer; @@ -4023,6 +4125,9 @@ impl State { } } + /// Queues input for the next frame. A press on the strip moves the window: at once on + /// macOS, while AppKit still holds the press, and elsewhere once the pointer moves, as + /// the window manager's move would swallow a second click; a double press zooms it. fn input(&mut self, event: ui::Event) { if let ui::Event::PointerMoved(point) = event { self.pointer = point; @@ -4335,8 +4440,8 @@ fn page_list_top() -> &'static str { "top" } -/// The page list's rows, a page's conflict pages beneath it while shown and the pages a -/// search `found` marked. A `dragged` page's tab follows the pointer over the others, which +/// The page list's rows, a page's conflict pages beneath it while shown, and `marked` the +/// pages a search found and those unread. A `dragged` page's tab follows the pointer over the others, which /// slide aside to open the gap it would land in; once let go it eases into its place. #[allow(clippy::too_many_arguments)] fn page_rows( @@ -4344,7 +4449,7 @@ fn page_rows( theme: &Theme, section: &ui::Section, session: &Session, - found: &HashSet, + marked: [&HashSet; 2], shape: (f32, bool), mut renaming: Option<&mut rename::Renaming>, dragged: Option, @@ -4389,7 +4494,7 @@ fn page_rows( theme, section, session, - found, + marked, shape, renaming.as_deref_mut(), (space, title, *level), @@ -4416,7 +4521,7 @@ fn page_rows( theme, section, session, - found, + marked, shape, renaming, (space, title, *level), @@ -4437,7 +4542,7 @@ fn page_row( theme: &Theme, section: &ui::Section, session: &Session, - found: &HashSet, + [found, unread]: [&HashSet; 2], shape: (f32, bool), renaming: Option<&mut rename::Renaming>, (space, title, level): (&ExGuid, &String, u32), @@ -4457,6 +4562,7 @@ fn page_row( indent: level.saturating_sub(1), conflicted: !versions.is_empty(), found: found.contains(space), + unread: unread.contains(space), renaming: renaming .filter(|renaming| renaming.page(*space)) .map(|renaming| &mut renaming.name), @@ -4493,62 +4599,45 @@ fn page_row( tab: ui::mix(section.tab, theme.chip, 0.6), ..*section }; - if session.shown_history == Some(*space) { - for version in session.page_versions(*space) { - let label = history::label(version); - let tab = PageTab { - label: &label, - dim: false, - indent: level.saturating_sub(1), - conflicted: false, - found: false, - renaming: None, - shift, - lifted: false, + // Each row: its id, its label, whether it is open, and whether it is a page version. + let history = (session.page_versions(*space).iter()) + .filter(|_| session.shown_history == Some(*space)) + .map(|version| { + let open = session.version == Some(version.context); + (version.context, history::label(version), open, true) + }); + let conflicts = (versions.iter()) + .filter(|_| session.shown == Some(*space)) + .map(|version| { + let label = match version.created { + Some(created) => format!("{} {}", platform::short_date(created), version.user), + None => version.user.clone(), }; - let selected = session.version == Some(version.context); - if selected { - rows.open = Some(ui.id(version.context)); - } - height += ROW; - if page_tab(ui, theme, &muted, &version.context, tab, selected, shape) - .0 - .clicked - && !selected - { - rows.version = Some((*space, version.context)); - } - } - } - if session.shown != Some(*space) { - return height; - } - for version in versions { - let label = match version.created { - Some(created) => format!("{} {}", platform::short_date(created), version.user), - None => version.user.clone(), - }; + (version.space, label, version.space == session.space, false) + }); + for (id, label, selected, version) in history.chain(conflicts) { let tab = PageTab { label: &label, dim: false, indent: level.saturating_sub(1), conflicted: false, found: false, + unread: false, renaming: None, shift, lifted: false, }; - let selected = version.space == session.space; if selected { - rows.open = Some(ui.id(version.space)); + rows.open = Some(ui.id(id)); } height += ROW; - if page_tab(ui, theme, &muted, &version.space, tab, selected, shape) + let clicked = page_tab(ui, theme, &muted, &id, tab, selected, shape) .0 - .clicked - && !selected - { - rows.clicked = Some(version.space); + .clicked; + if clicked && !selected && version { + rows.version = Some((*space, id)); + } else if clicked && !selected { + rows.clicked = Some(id); } } height @@ -4564,6 +4653,8 @@ struct PageTab<'a> { conflicted: bool, /// A search found the page, which OneNote marks yellow. found: bool, + /// Another author changed it since it was last viewed, which OneNote sets bold. + unread: bool, /// The name typed in the tab's rename field, while it shows one. renaming: Option<&'a mut String>, /// How far down from its place in the list it is drawn. @@ -4652,6 +4743,7 @@ fn page_tab( Spec { size: [fill(), px(ROW - ROW_GAP)], text: Some(tab.label), + bold: tab.unread, overflow: ui::Overflow::Ellipsis, color: Some(color), ..Spec::default() @@ -4793,11 +4885,12 @@ fn group( full: impl FnOnce(&mut Ui) -> Option, folded: impl FnOnce(&mut Ui) -> Option, ) -> Option { + let face = tool_face(&ui.theme, grouped(&ui.theme), FACE_RISE); ui.open( part, Spec { fold: Some(priority), - ..Spec::default() + ..face }, ); ui.open( @@ -5010,6 +5103,26 @@ fn dropdown( } } +/// Opens the trailing row of a dialog, whose buttons then stand at its right. +fn buttons(ui: &mut Ui) { + ui.open( + "buttons", + Spec { + size: [fill(), children()], + pad: [0.0, 8.0], + gap: 8.0, + ..Spec::default() + }, + ); + ui.leaf( + "space", + Spec { + size: [fill(), px(1.0)], + ..Spec::default() + }, + ); +} + /// Names box `id` to assistive technology, where no tooltip does. fn name(ui: &mut Ui, id: Id, label: &str) { if let Some(node) = ui.access(id) { @@ -5179,8 +5292,33 @@ fn title(id: commands::Id) -> &'static str { commands::command(id).title.trim_end_matches('…') } -/// The line leading a toolbar group, as far from its buttons as the groups are apart. +/// Whether each toolbar group stands on a face of its own. +fn grouped(theme: &Theme) -> bool { + theme.tool[3] > 0.0 && !theme.tool_panel +} + +/// The theme's face for tools where `shown`, padded beside its buttons and standing `rise` +/// past the box's top and bottom. +fn tool_face(theme: &Theme, shown: bool, rise: f32) -> Spec<'static> { + if !shown { + return Spec::default(); + } + Spec { + fill: Some(theme.tool), + border: Some(theme.chip), + radius: 4.0, + inset: [0.0, -rise, 0.0, -rise], + pad: [FACE_PAD, 0.0], + ..Spec::default() + } +} + +/// The line leading a toolbar group, as far from its buttons as the groups are apart; groups +/// on faces of their own stand apart without one. fn divider(ui: &mut Ui, theme: &Theme) { + if grouped(theme) { + return; + } ui.leaf( "divider", Spec { @@ -5312,147 +5450,103 @@ const QUIT_PUBLISH: std::time::Duration = std::time::Duration::from_secs(3); impl ApplicationHandler for App { fn user_event(&mut self, event_loop: &ActiveEventLoop, event: UserEvent) { + let Some(state) = &mut self.state else { + match event { + UserEvent::Quit => self.close(event_loop), + // A cold launch's documents arrive before the window opens. + UserEvent::Open(paths) => self.opening.extend(paths), + _ => {} + } + return; + }; let event = match event { + UserEvent::Quit | UserEvent::Replay(Replay::Quit) => return self.close(event_loop), UserEvent::Picture(bytes) => { - if let Some(state) = &mut self.state { - if let Err(error) = state.insert_picture(bytes, None) { - eprintln!("{error}"); - } - state.window.request_redraw(); + if let Err(error) = state.insert_picture(bytes, None) { + eprintln!("{error}"); } - return; + return state.window.request_redraw(); } UserEvent::InsertText(text) => { - if let Some(state) = &mut self.state { - if state.ui.focused() == Some(page()) { - match state.view.insert_text(text) { - Ok(response) => state.respond(response), - Err(error) => eprintln!("{error}"), - } - state.window.request_redraw(); - } else { - state.input(ui::Event::Ime(Ime::Commit(text))); + if state.ui.focused() == Some(page()) { + match state.view.insert_text(text) { + Ok(response) => state.respond(response), + Err(error) => eprintln!("{error}"), } + state.window.request_redraw(); + } else { + state.input(ui::Event::Ime(Ime::Commit(text))); } return; } - UserEvent::Quit => { - self.close(event_loop); - return; - } UserEvent::Then(then) => { - if let Some(state) = &mut self.state { - if let Err(error) = then(state) { - eprintln!("{error}"); - } - state.window.request_redraw(); + if let Err(error) = then(state) { + eprintln!("{error}"); } - return; - } - UserEvent::ICloudFolder => { - if let Some(state) = &mut self.state { - state.list_icloud(); - } - return; + return state.window.request_redraw(); } + UserEvent::ICloudFolder => return state.list_icloud(), UserEvent::ICloudAccount => { - if let Some(state) = &mut self.state { - state.icloud_account_changed(); - state.window.request_redraw(); - } - return; + state.icloud_account_changed(); + return state.window.request_redraw(); } UserEvent::Open(paths) => { - match &mut self.state { - Some(state) => { - for path in paths { - state.open_path(&path); - } - state.window.set_minimized(false); - state.window.focus_window(); - state.window.request_redraw(); - } - // A cold launch's documents arrive before the window opens. - None => self.opening.extend(paths), + for path in paths { + state.open_path(&path); } - return; + state.window.set_minimized(false); + state.window.focus_window(); + return state.window.request_redraw(); } UserEvent::Update => { - if let Some(state) = &mut self.state { - state.updated(); - state.window.request_redraw(); - } - return; + state.updated(); + return state.window.request_redraw(); } UserEvent::Sync => { - if let Some(state) = &mut self.state - && let Err(error) = state.synced() - { + if let Err(error) = state.synced() { eprintln!("{error}"); } return; } - UserEvent::Choose(choice) => { - if let Some(state) = &mut self.state { - state.choose(choice); - } - return; - } + UserEvent::Choose(choice) => return state.choose(choice), UserEvent::Appearance => { - if let Some(state) = &mut self.state { - state.follow_color_scheme(); - state.window.request_redraw(); - } - return; - } - UserEvent::Redraw => { - if let Some(state) = &self.state { - state.window.request_redraw(); - } - return; + state.follow_color_scheme(); + return state.window.request_redraw(); } + UserEvent::Redraw => return state.window.request_redraw(), UserEvent::Replay(replay) => { - if let Some(state) = &mut self.state { - match replay { - Replay::Input(event) => state.input(event), - Replay::Pinch(factor) => { - if let Err(error) = state.pinch(factor) { - eprintln!("{error}"); - } + match replay { + Replay::Input(event) => state.input(event), + Replay::Pinch(factor) => { + if let Err(error) = state.pinch(factor) { + eprintln!("{error}"); } - Replay::Snapshot(path) => state.snapshot = Some(path), - Replay::Accessibility(path) => { - if let Err(error) = state.write_accessibility(&path) { - eprintln!("{error}"); - } - } - Replay::Tick => {} - Replay::Appearance(appearance) => { - state.window.set_theme(Some(appearance)); - state.set_appearance(appearance); - } - Replay::Resize([width, height]) => { - let _ = state - .window - .request_inner_size(LogicalSize::new(width, height)); - } - Replay::Quit => { - self.close(event_loop); - return; + } + Replay::Snapshot(path) => state.snapshot = Some(path), + Replay::Accessibility(path) => { + if let Err(error) = state.write_accessibility(&path) { + eprintln!("{error}"); } } - // A covered window gets no redraws, so each step draws its own frame. - if let Err(error) = state.frame() { - eprintln!("{error}"); + Replay::Tick | Replay::Quit => {} + Replay::Appearance(appearance) => { + state.window.set_theme(Some(appearance)); + state.set_appearance(appearance); + } + Replay::Resize([width, height]) => { + let _ = state + .window + .request_inner_size(LogicalSize::new(width, height)); } } + // A covered window gets no redraws, so each step draws its own frame. + if let Err(error) = state.frame() { + eprintln!("{error}"); + } return; } UserEvent::Accessibility(event) => event, }; - let Some(state) = &mut self.state else { - return; - }; if event.window_id != state.window.id() { return; } @@ -5679,9 +5773,10 @@ fn write_png(path: &Path, size: [u32; 2], pixels: &[u8]) -> Result<(), Box) -> Result<(), Box> { let mut steps = Vec::new(); for line in script.lines().filter(|line| !line.trim().is_empty()) { diff --git a/crates/snowbound/src/manage.rs b/crates/snowbound/src/manage.rs index ae9ff57bfdc9ba62adeba811ceebf64a52bd5e5c..4aebad7a5cc7ef9215f3cdd2fa316f11c85e7d4c 100644 --- a/crates/snowbound/src/manage.rs +++ b/crates/snowbound/src/manage.rs @@ -27,6 +27,8 @@ pub enum Structure { Delete { path: String, }, + /// Deletes for good what the recycle bin holds. + EmptyRecycleBin, /// Moves a section or group into another folder. Move { path: String, @@ -55,6 +57,12 @@ pub enum Structure { name: String, color: Option, }, + /// Sets, changes or removes a section's password: `key` opens it as it stands. + Password { + path: String, + key: Option, + password: Option>, + }, } /// New iCloud Notebook while it is open: the name typed, and why the last was refused. @@ -234,7 +242,7 @@ impl State { } /// Applies `ops` to the open page as one edit and shows the result. - fn edit_page(&mut self, ops: Vec) -> Result<(), Box> { + pub(crate) fn edit_page(&mut self, ops: Vec) -> Result<(), Box> { if ops.is_empty() { return Ok(()); } @@ -247,6 +255,7 @@ impl State { ops: ops.into_iter().map(|op| Op::Page { space, op }).collect(), }, )?; + self.edited(vec![space]); self.refresh() } @@ -351,22 +360,7 @@ impl State { }, ); } - ui.open( - "buttons", - Spec { - size: [fill(), children()], - pad: [0.0, 8.0], - gap: 8.0, - ..Spec::default() - }, - ); - ui.leaf( - "space", - Spec { - size: [fill(), px(1.0)], - ..Spec::default() - }, - ); + crate::buttons(ui); let cancel = ui::button(ui, "cancel", "Cancel").clicked; let create = ui::button(ui, "create", "Create").clicked || entered; ui.close(); @@ -660,23 +654,15 @@ impl State { library.close_kept(); let mut notebook = library.reopen()?; let names = |notebook: &Notebook, folder: &str| -> Vec { - let mut folders = vec![notebook.catalog()]; - while let Some(candidate) = folders.pop() { - if candidate.path == folder { - return candidate - .sections - .iter() - .map(|section| section.path.clone()) - .chain(candidate.groups.iter().map(|group| group.path.clone())) - .map(|path| { - let name = path.rsplit('/').next().unwrap_or_default(); - name.strip_suffix(".one").unwrap_or(name).to_owned() - }) - .collect(); - } - folders.extend(&candidate.groups); - } - Vec::new() + let found = crate::library::folders(notebook.catalog(), |_| true) + .into_iter() + .find(|candidate| candidate.path == folder); + found.map_or_else(Vec::new, |folder| { + (folder.sections.iter().map(|section| §ion.path)) + .chain(folder.groups.iter().map(|group| &group.path)) + .map(|path| crate::library::entry_name(path).to_owned()) + .collect() + }) }; // A notebook not shown stays so through a change of its colours or name. let stays = matches!( @@ -709,6 +695,11 @@ impl State { notebook.delete(&path)?; (current, None) } + // A section of the bin open goes with it. + Structure::EmptyRecycleBin => { + notebook.empty_recycle_bin()?; + (current.filter(|path| !crate::recycle::binned(path)), None) + } Structure::Move { path, folder } => { let moved = notebook.move_entry(&path, &folder)?; (current.map(|current| follow(¤t, &path, &moved)), None) @@ -732,6 +723,24 @@ impl State { notebook.set_section_color(&path, color)?; (current, None) } + Structure::Password { + path, + key, + password, + } => { + let key = notebook.set_password( + &path, + key.as_ref(), + password.as_deref().map(String::as_str), + )?; + let library = Arc::new(library.with(notebook)); + if let Some(key) = key { + library.keep_key(&path, key); + } + let section = library.open(&path, notify)?; + let (session, page) = read_session(section, library, path, None)?; + return Ok(Loaded::Section(Box::new(session), page)); + } Structure::Properties { name, color } => { if name != library.name { library.set_display_name(&name)?; @@ -1187,7 +1196,8 @@ mod tests { let root = temporary.join("Emptied"); let location = root.to_str().unwrap(); let cache = temporary.join("cache"); - let mut notebook = Notebook::create(location, &cache, Notebook::NEW_COLOR, &dated()).unwrap(); + let mut notebook = + Notebook::create(location, &cache, Notebook::NEW_COLOR, &dated()).unwrap(); notebook.create_group("", "Group").unwrap(); notebook.create_section("Group", "Inner", &dated()).unwrap(); diff --git a/crates/snowbound/src/media_linux.rs b/crates/snowbound/src/media_linux.rs index 951989d12522f2d06a5864dd8d38b86636ccea98..906fda14dfdc579b2a2a37fbfe2ca37c1b4b0e82 100644 --- a/crates/snowbound/src/media_linux.rs +++ b/crates/snowbound/src/media_linux.rs @@ -3,7 +3,7 @@ //! with gst-libav), the default microphone records as PCM WAV, and the default camera with //! it as the Motion JPEG AVI file OneNote plays. Recording needs the Base and Good plug-ins. -use crate::video; +use crate::{platform::symbol, video}; use std::{ cell::Cell, ffi::{CStr, CString, c_char, c_int, c_void}, @@ -50,12 +50,6 @@ struct Gst { unsafe impl Send for Gst {} unsafe impl Sync for Gst {} -/// The function `name` in `library`, of C signature `F`. -unsafe fn symbol(library: *mut c_void, name: &CStr) -> Option { - let symbol = unsafe { libc::dlsym(library, name.as_ptr()) }; - (!symbol.is_null()).then(|| unsafe { std::mem::transmute_copy::<*mut c_void, F>(&symbol) }) -} - /// GStreamer, initialized; none where it is not installed. fn gst() -> Option<&'static Gst> { static GST: OnceLock> = OnceLock::new(); @@ -84,20 +78,6 @@ fn gst() -> Option<&'static Gst> { .as_ref() } -/// `path` as a `file://` URI, every byte outside the unreserved set escaped. -fn uri(path: &Path) -> String { - let mut uri = String::from("file://"); - for byte in path.as_os_str().as_encoded_bytes() { - match byte { - b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' | b'/' => { - uri.push(char::from(*byte)); - } - _ => uri.push_str(&format!("%{byte:02X}")), - } - } - uri -} - /// A pipeline described as `gst-launch` takes one, paused. struct Pipeline { gst: &'static Gst, @@ -191,7 +171,7 @@ impl Player { }; let pipeline = Pipeline::launch(&format!( "playbin uri=\"{}\" video-sink=fakesink{silent}", - uri(path) + crate::platform::file_uri(path) ))?; // Prerolling reports a file no plugin decodes as an error. if pipeline.wait(PATIENCE, ASYNC_DONE | ERROR) != Some(ASYNC_DONE) { diff --git a/crates/snowbound/src/menubar.rs b/crates/snowbound/src/menubar.rs index ce1ac69d08e6185b24372108814818ebd362ee43..2487c8bb5b79326b0019a7b6105c7dd5a2b5fc05 100644 --- a/crates/snowbound/src/menubar.rs +++ b/crates/snowbound/src/menubar.rs @@ -67,6 +67,18 @@ const MENUS: &[Item] = &[ C(Id::CopyPageLink), C(Id::ShowNotebook), S, + C(Id::PasswordProtect), + C(Id::LockAll), + S, + C(Id::MarkRead), + C(Id::MarkNotebookRead), + C(Id::NextUnread), + C(Id::ShowUnread), + S, + C(Id::RecycleBin), + C(Id::EmptyRecycleBin), + S, + C(Id::SaveAs), C(Id::ExportPdf), S, C(Id::Print), diff --git a/crates/snowbound/src/menus.rs b/crates/snowbound/src/menus.rs index 318bec8efc14036f32ee84d7c228a9d41c870ec5..47d44af69a5686045ae4ace76a985b5d9cfc728d 100644 --- a/crates/snowbound/src/menus.rs +++ b/crates/snowbound/src/menus.rs @@ -6,6 +6,7 @@ use crate::{ commands::{self, Choice}, manage::Structure, platform, + recycle::Request, }; use onestore::{ExGuid, PageEdit}; use std::sync::Arc; @@ -87,6 +88,15 @@ pub enum Action { Move, /// Moves it to a section, or into a folder, by catalog path. MoveTo(String), + /// Opens the submenu of the sections `CopyTo` copies a page of the recycle bin to. + Copy, + CopyTo(String), + /// Save As on a section or notebook. + SaveAs, + /// Shows the notebook's recycle bin. + RecycleBin, + EmptyRecycleBin, + MarkNotebookRead, CopyLink, NewPage, NewSubpage, @@ -110,6 +120,8 @@ pub enum Action { /// Moves a notebook up the notebook list, or down. Raise(bool), Properties, + /// Opens Password Protection on a section. + Password, } /// OneNote 2010's section and notebook colours, COLORREF, with their names in its Section @@ -259,7 +271,7 @@ impl State { pub(crate) fn actions(&self, target: &Target) -> Vec<(Action, Item<'static>)> { // An action, its label, whether it is disabled, and whether a rule starts its group. let item = |action: Action, text, disabled, separated| { - let submenu = matches!(action, Action::Move | Action::Colors); + let submenu = matches!(action, Action::Move | Action::Copy | Action::Colors); let item = Item { text, disabled, @@ -271,6 +283,18 @@ impl State { }; let nowhere = || self.destinations(target).is_empty(); match target { + // OneNote's Move or Copy takes a page out of the bin; nothing else changes it. + Target::Page { path, .. } if crate::recycle::binned(path) => vec![ + item(Action::Delete, "Delete", false, false), + item(Action::Move, "Restore To", nowhere(), true), + item(Action::Copy, "Copy To", nowhere(), false), + ], + Target::Section { path, .. } if crate::recycle::binned(path) => vec![ + item(Action::SaveAs, "Save As…", false, false), + item(Action::Move, "Restore To", nowhere(), false), + item(Action::EmptyRecycleBin, "Empty Recycle Bin", false, true), + item(Action::Colors, "Section Color", false, true), + ], Target::Page { library, path, @@ -321,16 +345,26 @@ impl State { actions } Target::Section { library, path } | Target::Group { library, path } => { - let mut actions = vec![ - item(Action::Rename, "Rename", false, false), + let section = matches!(target, Target::Section { .. }); + let mut actions = vec![item(Action::Rename, "Rename", false, false)]; + if section { + actions.push(item(Action::SaveAs, "Save As…", false, false)); + } + actions.extend([ item(Action::Delete, "Delete", false, false), item(Action::Move, "Move", nowhere(), false), item(Action::NewSection, "New Section", false, true), item(Action::NewGroup, "New Section Group", false, false), - ]; - if matches!(target, Target::Section { .. }) { + ]); + if section { actions.extend([ - item(Action::Colors, "Section Color", false, true), + item( + Action::Password, + "Password Protect This Section…", + library.catalog().is_none(), + true, + ), + item(Action::Colors, "Section Color", false, false), item( Action::Reveal, platform::SHOW_FILE, @@ -345,8 +379,15 @@ impl State { Target::Notebook(library) => { let listed = (self.notebooks.iter()).position(|open| Arc::ptr_eq(open, library)); let last = self.notebooks.len().saturating_sub(1); + let unread = self.unread_notebook(library); vec![ item(Action::Rename, "Rename…", false, false), + item( + Action::SaveAs, + "Save As…", + library.catalog().is_none(), + false, + ), item( Action::Sync, "Sync This Notebook Now", @@ -369,6 +410,12 @@ impl State { false, false, ), + item( + Action::MarkNotebookRead, + "Mark Notebook as Read", + !unread, + true, + ), item( Action::Raise(true), "Move Up", @@ -388,6 +435,12 @@ impl State { true, ), item(Action::Theme, "Theme…", false, false), + item( + Action::RecycleBin, + "Notebook Recycle Bin", + library.catalog().is_none(), + true, + ), item( Action::Properties, "Properties…", @@ -416,13 +469,22 @@ impl State { let items: Vec = actions.iter().map(|(_, item)| *item).collect(); let chosen = ui::popup::menu(&mut self.ui, id, anchor, &items, filter); let places = id.child("move"); + let copies = id.child("copy"); let colors = id.child("colors"); ui::popup::submenus(&mut self.ui, id, &items, |index| match actions[index].0 { Action::Move => Some(places), + Action::Copy => Some(copies), Action::Colors => Some(colors), _ => None, }); - let moved = if self.ui.popup_open(places) { + let mut moved = None; + for (submenu, chosen) in [ + (places, Action::MoveTo as fn(String) -> Action), + (copies, Action::CopyTo), + ] { + if !self.ui.popup_open(submenu) { + continue; + } let destinations = self.destinations(target); let items: Vec = (destinations.iter()) .map(|place| Item { @@ -432,11 +494,9 @@ impl State { ..Item::default() }) .collect(); - ui::popup::menu(&mut self.ui, places, anchor, &items, None) - .map(|index| Action::MoveTo(destinations[index].path.clone())) - } else { - None - }; + moved = moved.or(ui::popup::menu(&mut self.ui, submenu, anchor, &items, None) + .map(|index| chosen(destinations[index].path.clone()))); + } let colored = match target { Target::Section { library, path } if self.ui.popup_open(colors) => { let current = (library.tabs(&folder(path)).into_iter()) @@ -474,6 +534,17 @@ impl State { fn destinations(&self, target: &Target) -> Vec { let theme = &self.ui.theme; match target { + Target::Page { library, path, .. } if crate::recycle::binned(path) => { + (folders(library).into_iter()) + .flat_map(|place| library.tabs(&place.path)) + .map(|tab| Destination { + tint: Some(theme.section(crate::section_color(tab.color)).accent), + name: tab.name, + path: tab.path, + icon: crate::art::SECTION, + }) + .collect() + } Target::Page { library, path, .. } => (library.tabs(&folder(path)).into_iter()) .filter(|tab| tab.path != *path) .map(|tab| Destination { @@ -536,6 +607,33 @@ impl State { (Target::Section { library, path }, Action::Open) => { Some(Command::OpenSection(library, path)) } + (Target::Section { library, path }, Action::SaveAs) => { + self.open_save_as(library, Some(path), crate::save_as::Scope::Section); + None + } + (Target::Notebook(library), Action::SaveAs) => { + self.open_save_as(library, None, crate::save_as::Scope::Notebook); + None + } + ( + Target::Section { library, .. } | Target::Notebook(library), + Action::EmptyRecycleBin, + ) => { + self.empty_recycle_bin(library); + None + } + (Target::Notebook(library), Action::RecycleBin) => { + self.toggle_recycle_bin(library); + None + } + (Target::Notebook(library), Action::MarkNotebookRead) => { + self.mark_notebook_read(&library); + None + } + (Target::Section { library, path }, Action::Password) => { + self.password_protection(library, path); + None + } (Target::Notebook(library), Action::Open) => { self.open_notebook(library.location.clone(), None); None @@ -665,7 +763,22 @@ impl State { self.rename(crate::rename::Target::Page(space)); None } + Action::Delete if self.in_recycle_bin() => { + Some(Command::Recycle(Request::Purge(vec![space]))) + } Action::Delete => Some(Command::DeletePages(vec![space])), + Action::MoveTo(path) if self.in_recycle_bin() => { + Some(Command::Recycle(Request::Restore { + space, + path, + copy: false, + })) + } + Action::CopyTo(path) => Some(Command::Recycle(Request::Restore { + space, + path, + copy: true, + })), Action::MoveTo(path) => Some(Command::MovePage { space, path }), Action::CopyLink => { let copied = self @@ -915,15 +1028,9 @@ fn new(action: &Action, folder: String) -> Option { /// `library`'s folders of sections, the notebook's first, then its section groups breadth /// first, but for the recycle bin; none for a section opened on its own. pub(crate) fn folders(library: &Library) -> Vec<¬ebook::discover::Folder> { - let mut folders: Vec<_> = library.catalog().into_iter().collect(); - let mut at = 0; - while let Some(&folder) = folders.get(at) { - folders.extend( - (folder.groups.iter()).filter(|group| !crate::library::recycle_bin(&group.path)), - ); - at += 1; - } - folders + library.catalog().map_or_else(Vec::new, |catalog| { + crate::library::folders(catalog, |group| !crate::library::recycle_bin(&group.path)) + }) } #[cfg(test)] diff --git a/crates/snowbound/src/navigation.rs b/crates/snowbound/src/navigation.rs index 6c378795ee935aeb1a0b317406aa5b310dbd3bfb..843cc45ec1bdb1f773a001f1e43070019e8aa0fe 100644 --- a/crates/snowbound/src/navigation.rs +++ b/crates/snowbound/src/navigation.rs @@ -288,6 +288,7 @@ mod tests { identity: None, created: None, margin_origin: [0.0; 2], + rtl: false, color: None, rule_lines: None, objects: Vec::new(), diff --git a/crates/snowbound/src/options.rs b/crates/snowbound/src/options.rs index e32feff63f8f568aa0b2306cf10ffde4efcac8cb..56ce5c9e505c3b26a5aa96d08151c947063ccf90 100644 --- a/crates/snowbound/src/options.rs +++ b/crates/snowbound/src/options.rs @@ -125,6 +125,28 @@ const SECTIONS: &[Section] = &[ control: Control::Check(|options| &mut options.pen_pressure), }], }, + Group { + heading: "Passwords", + rows: &[ + Row { + label: "Lock password protected sections after I have not worked in \ + them for:", + keywords: "protect protected lock unlock idle minutes timeout security", + control: Control::Check(|options| &mut options.lock_idle), + }, + Row { + label: "Amount of time:", + keywords: "password protect protected lock idle minutes hours timeout", + control: Control::Field(lock_after), + }, + Row { + label: "Lock password protected sections as soon as I navigate away \ + from them", + keywords: "password protect protected lock leave switch security", + control: Control::Check(|options| &mut options.lock_on_leave), + }, + ], + }, ], }, Section { @@ -210,6 +232,10 @@ pub struct Options { default_font: DefaultFont, page_tabs_left: bool, navigation_bar_left: bool, + lock_idle: bool, + /// Minutes, as `protection::AFTER` lists them. + lock_minutes: u32, + lock_on_leave: bool, pub(crate) keyboard: crate::keys::Keyboard, } @@ -241,6 +267,10 @@ fn sizes() -> Id { id().child("sizes") } +fn lock_times() -> Id { + id().child("lock-times") +} + fn font_colors() -> Id { id().child("font-colors") } @@ -298,6 +328,9 @@ impl State { default_font: self.view.editor.default_font.clone(), page_tabs_left: self.page_tabs_left, navigation_bar_left: !self.navigation_bar_right, + lock_idle: self.passwords.lock_after.is_some(), + lock_minutes: self.passwords.lock_after.unwrap_or(10), + lock_on_leave: self.passwords.lock_on_leave, keyboard: crate::keys::Keyboard::new(), }); self.ui.open_popup(id()); @@ -600,6 +633,10 @@ impl State { self.view.editor.default_font = options.default_font; self.page_tabs_left = options.page_tabs_left; self.navigation_bar_right = !options.navigation_bar_left; + self.passwords = crate::settings::Passwords { + lock_after: options.lock_idle.then_some(options.lock_minutes), + lock_on_leave: options.lock_on_leave, + }; self.updates.set_automatic(options.automatic_updates); self.show_spelling(); self.follow_color_scheme(); @@ -679,6 +716,35 @@ fn appearance(state: &mut State, options: &mut Options) { } } +fn lock_after(state: &mut State, options: &mut Options) { + let ui = &mut state.ui; + let combo = ui.id("combo"); + let current = crate::protection::AFTER + .iter() + .find(|(minutes, _)| *minutes == options.lock_minutes) + .map_or("", |(_, name)| name); + ui::shell::combo( + ui, + "combo", + "Amount of time", + current, + 140.0, + lock_times(), + true, + ); + let items = crate::protection::AFTER.map(|(minutes, name)| Item { + text: name, + checked: Some(minutes == options.lock_minutes), + current: minutes == options.lock_minutes, + ..Item::default() + }); + let anchor = Anchor::Below(ui.rect(combo).unwrap_or_default()); + if let Some(index) = ui::popup::menu(ui, lock_times(), anchor, &items, None) { + options.lock_minutes = crate::protection::AFTER[index].0; + options.lock_idle = true; + } +} + fn user_name_field(state: &mut State, options: &mut Options) { let ui = &mut state.ui; let theme = &ui.theme; diff --git a/crates/snowbound/src/pane.rs b/crates/snowbound/src/pane.rs index 5ab37abf056e316a355e491df9ea5039aec1dd45..af0eebc0ed1b29aa2f6cac3cbd101de2d13c530d 100644 --- a/crates/snowbound/src/pane.rs +++ b/crates/snowbound/src/pane.rs @@ -929,7 +929,7 @@ impl State { /// The tagged paragraphs `scope` takes in, only those not checked off when `unchecked`. fn tagged(&self, scope: TagScope, unchecked: bool) -> Vec { let session = self.session.as_ref(); - let open = session.map(|session| session.library.key(&session.tabs[session.tab].path)); + let open = session.map(crate::Session::key); // The open page with its subpages, or the page it is a subpage of with its others. let group: Vec<_> = session .and_then(|session| { diff --git a/crates/snowbound/src/prefetch.rs b/crates/snowbound/src/prefetch.rs index de956b5a5a1d9ed1f03673c73bccfd2beb7f7b76..73d5df0ff3a348272e78c300c31ec2464c11fda3 100644 --- a/crates/snowbound/src/prefetch.rs +++ b/crates/snowbound/src/prefetch.rs @@ -38,6 +38,11 @@ impl Recent { self.entries.iter().any(|(held, _)| held == key) } + /// Lets go of the entries `keep` refuses. + pub fn retain(&mut self, keep: impl Fn(&K) -> bool) { + self.entries.retain(|(key, _)| keep(key)); + } + pub fn values_mut(&mut self) -> impl Iterator { self.entries.iter_mut().map(|(_, value)| value) } @@ -143,6 +148,14 @@ impl Prefetch { } } + /// Lets go of the scenes kept of the section `section` names, as a locked one's. + pub fn forget(&mut self, section: &str) { + if let Ok(mut scenes) = self.scenes.lock() { + scenes.retain(|(kept, _)| kept != section); + } + self.asked.retain(|(asked, _)| asked != section); + } + /// Whether `target` is new since it was last asked for, noting it. fn ask(&mut self, target: (String, Option)) -> bool { let new = !self.asked.contains(&target); diff --git a/crates/snowbound/src/print.rs b/crates/snowbound/src/print.rs index d2ed1c9594ef9459943aa740b08541c218d299ec..c7dcf65a55e7129922ed7857df19861638baf7f9 100644 --- a/crates/snowbound/src/print.rs +++ b/crates/snowbound/src/print.rs @@ -146,6 +146,14 @@ impl State { self.ui.open_popup(id()); } + /// Opens Export as PDF on `scope`, as Save As's PDF does. + pub(crate) fn export_pdf(&mut self, scope: Scope) { + self.open_print(true); + if let Some(dialog) = &mut self.printing.dialog { + dialog.setup.scope = scope; + } + } + /// Builds the dialog while it is open; Print or Export goes on with its choices, which /// the next opening starts from. pub(crate) fn print_dialog(&mut self) { @@ -247,22 +255,7 @@ impl State { setup.footer = FOOTERS[at]; } }); - ui.open( - "buttons", - Spec { - size: [fill(), children()], - pad: [0.0, 8.0], - gap: 8.0, - ..Spec::default() - }, - ); - ui.leaf( - "space", - Spec { - size: [fill(), px(1.0)], - ..Spec::default() - }, - ); + crate::buttons(ui); let cancel = ui::button(ui, "cancel", "Cancel").clicked; let action = if dialog.export { "Export…" @@ -349,6 +342,7 @@ impl State { fit_width: setup.fit_width, footer: setup.footer, }; + let whole = matches!(setup.scope, Scope::Section | Scope::Notebook); std::thread::spawn(move || { let made = (|| -> Result, Box> { let mut open = Some(open); @@ -359,6 +353,7 @@ impl State { None => replica.page(space).map_err(Into::into), }) .collect::, Box>>()?; + let this = if whole { printed(this) } else { this }; let mut this = Some((open_tab.1.clone(), this)); let sections = if others.is_empty() { this.into_iter().collect() @@ -377,7 +372,7 @@ impl State { .into_iter() .map(|(space, ..)| section.page(space)) .collect::, _>>()?; - Ok((name, pages)) + Ok((name, printed(pages))) }) .collect::, Box>>()? }; @@ -419,18 +414,20 @@ fn group(levels: &[u32], at: usize) -> std::ops::Range { start..end } -/// Every readable section of `library`, catalog path and name, a folder's sections before -/// its groups', as OneNote orders them. +/// Every readable section of `library` but the recycle bin's, catalog path and name, in the +/// order each folder's TOC lists its sections and groups, as OneNote's notebook PDF prints them. fn notebook_tabs(library: &Library) -> Vec<(String, String)> { fn walk(library: &Library, folder: &Folder, tabs: &mut Vec<(String, String)>) { - tabs.extend( - library - .tabs(&folder.path) - .into_iter() - .map(|tab| (tab.path, tab.name)), - ); - for group in &folder.groups { - walk(library, group, tabs); + let mut sections = library.tabs(&folder.path); + for path in &folder.order { + if let Some(at) = sections.iter().position(|tab| tab.path == *path) { + let tab = sections.remove(at); + tabs.push((tab.path, tab.name)); + } else if let Some(group) = (folder.groups.iter()) + .find(|group| group.path == *path && !crate::library::recycle_bin(&group.path)) + { + walk(library, group, tabs); + } } } let mut tabs = Vec::new(); @@ -441,8 +438,17 @@ fn notebook_tabs(library: &Library) -> Vec<(String, String)> { tabs } +/// The pages of `pages` a section's or notebook's PDF prints: all but untitled ones holding +/// only their date. +fn printed(pages: Vec) -> Vec { + pages + .into_iter() + .filter(|page| !crate::undo::blank(page)) + .collect() +} + /// `title` as a file name: without the characters Windows and macOS refuse in one. -fn file_name(title: &str) -> String { +pub(crate) fn file_name(title: &str) -> String { let name: String = title .chars() .map(|c| match c { @@ -486,4 +492,137 @@ mod tests { assert_eq!(group(&levels, 3), 3..4); assert_eq!(group(&levels, 5), 4..6); } + + /// A notebook whose root lists section A, group G (holding C) and section B in that order, + /// A holding a titled page, an untitled page with only its date and an untitled page with + /// text, each read through the app's sections as printing reads them. + fn ordered_notebook(temporary: &std::path::Path) -> Library { + use notebook::session::Notebook; + use onestore::{ + PageCreation, + op::{Edit, Op, SectionOp}, + }; + let _ = std::fs::remove_dir_all(temporary); + std::fs::create_dir_all(temporary).unwrap(); + let root = temporary.join("Ordered"); + let cache = temporary.join("cache"); + let page = |title: &str| { + PageCreation::new(None, Some(title), "Author") + .unwrap() + .dated("Thursday, October 1, 2026", "12:30 AM") + .unwrap() + }; + let mut notebook = + Notebook::create(&root, &cache, Notebook::NEW_COLOR, &page("A titled")).unwrap(); + notebook.rename("New Section 1.one", "A").unwrap(); + notebook.create_section("", "B", &page("B titled")).unwrap(); + notebook.create_group("", "G").unwrap(); + notebook + .create_section("G", "C", &page("C titled")) + .unwrap(); + notebook.reorder("", &["A.one", "G", "B.one"]).unwrap(); + let library = Library::created(root.to_str().unwrap(), notebook, &cache); + let section = library.open("A.one", || {}).unwrap(); + let (dated, written) = (page(""), page("")); + let space = written.space(); + let edit = |ops| Edit { + at: crate::filetime(), + ops, + }; + let create = |creation| Op::Section(SectionOp::Create(creation)); + (section.apply("Author", edit(vec![create(dated), create(written)]))).unwrap(); + let meeting = crate::templates::Choice::Template("Informal Meeting Notes"); + let ops = crate::manage::template_ops(§ion.page(space).unwrap(), meeting, Vec::new()) + .unwrap() + .into_iter() + .map(|op| Op::Page { space, op }) + .collect(); + section.apply("Author", edit(ops)).unwrap(); + // The template titles the page; its title goes, its text stays. + let untitled = crate::rename::retitled(§ion.page(space).unwrap(), space, String::new()); + section + .apply("Author", edit(vec![untitled.unwrap()])) + .unwrap(); + library.keep("A.one", section); + library + } + + /// A notebook exports in the order its tables of contents list sections and groups, as + /// OneNote 2010's Save As, Notebook, PDF prints A, then G's C, then B, though its own + /// navigation lists A, B, then G (lab, 2026-10-01). + #[test] + fn a_notebook_exports_in_the_order_it_lists_sections_and_groups() { + let temporary = + std::env::temp_dir().join(format!("snowbound-print-order-{}", std::process::id())); + let library = ordered_notebook(&temporary); + let tabs = notebook_tabs(&library); + library.close_kept(); + drop(library); + let _ = std::fs::remove_dir_all(&temporary); + let paths: Vec<&str> = tabs.iter().map(|(path, _)| path.as_str()).collect(); + assert_eq!(paths, ["A.one", "G/C.one", "B.one"]); + } + + /// An untitled page holding only its date is left out, and one holding text is printed, + /// as OneNote 2010's Save As, Section and Notebook, PDF do (lab, 2026-10-01). + #[test] + fn untitled_pages_holding_only_their_date_are_left_out() { + let temporary = + std::env::temp_dir().join(format!("snowbound-print-blank-{}", std::process::id())); + let library = ordered_notebook(&temporary); + let section = library.open("A.one", || {}).unwrap(); + let pages: Vec = (section.pages().unwrap().into_iter()) + .map(|(space, ..)| section.page(space).unwrap()) + .collect(); + section.close().unwrap(); + drop(library); + let _ = std::fs::remove_dir_all(&temporary); + assert_eq!(pages.len(), 3); + // A page's title as typed, which its title object holds. + let typed = |page: &Page| { + page.objects + .iter() + .find_map(|object| match object { + onestore::page::PageObject::Title(title) => { + let text = title.outlines.first()?.paragraphs.first()?.text()?; + Some(text.text.text().to_owned()) + } + _ => None, + }) + .unwrap_or_default() + }; + let printed: Vec<(String, usize)> = printed(pages) + .iter() + .map(|page| (typed(page), page.objects.len())) + .collect(); + assert_eq!(printed.len(), 2, "{printed:?}"); + assert_eq!(printed[0].0, "A titled"); + assert!(printed[1].0.is_empty() && printed[1].1 > 1, "{printed:?}"); + } + + /// A notebook exported whole leaves out its recycle bin, as OneNote 2010's Save As, + /// Notebook, PDF does. + #[test] + fn a_notebook_exports_without_its_recycle_bin() { + use notebook::session::Notebook; + let temporary = + std::env::temp_dir().join(format!("snowbound-print-bin-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&temporary); + std::fs::create_dir_all(&temporary).unwrap(); + let root = temporary.join("Printed"); + let cache = temporary.join("cache"); + let page = || onestore::PageCreation::new(None, Some(""), "Author").unwrap(); + let mut notebook = Notebook::create(&root, &cache, Notebook::NEW_COLOR, &page()).unwrap(); + notebook.create_group("", "Group").unwrap(); + notebook.create_section("Group", "Inner", &page()).unwrap(); + notebook.create_section("", "Binned", &page()).unwrap(); + notebook.delete("Binned.one").unwrap(); + let library = Library::created(root.to_str().unwrap(), notebook, &cache); + let paths: Vec = notebook_tabs(&library) + .into_iter() + .map(|(path, _)| path) + .collect(); + std::fs::remove_dir_all(&temporary).unwrap(); + assert_eq!(paths, ["New Section 1.one", "Group/Inner.one"]); + } } diff --git a/crates/snowbound/src/properties.rs b/crates/snowbound/src/properties.rs index 3a80591cd984ecab479ef97e355826920940c48a..2ad450211c1a1fa49d0801f0f04f46d00108a3ce 100644 --- a/crates/snowbound/src/properties.rs +++ b/crates/snowbound/src/properties.rs @@ -151,27 +151,11 @@ impl State { } label(ui, "Location:"); dim(ui, "location", &dialog.library.location); - let folder = dialog.library.folder().map(std::path::Path::to_owned); let reveal = commands::command(commands::Id::ShowNotebook).title; - if folder.is_some() && ui::button(ui, "reveal", reveal).clicked { + if dialog.library.folder().is_some() && ui::button(ui, "reveal", reveal).clicked { crate::platform::reveal(&dialog.library.location); } - ui.open( - "buttons", - Spec { - size: [fill(), children()], - pad: [0.0, 8.0], - gap: 8.0, - ..Spec::default() - }, - ); - ui.leaf( - "space", - Spec { - size: [fill(), px(1.0)], - ..Spec::default() - }, - ); + crate::buttons(ui); let ok = ui::button(ui, "ok", "OK").clicked || entered; let cancel = ui::button(ui, "cancel", "Cancel").clicked; ui.close(); diff --git a/crates/snowbound/src/protection.rs b/crates/snowbound/src/protection.rs new file mode 100644 index 0000000000000000000000000000000000000000..9c0c3c6aee01dc2716c463159c292ac5f92839bf --- /dev/null +++ b/crates/snowbound/src/protection.rs @@ -0,0 +1,642 @@ +//! Password-protected sections as OneNote 2010 shows them: a locked section's page, the +//! dialogs that unlock one and set, change or remove its password, Lock All, and locking a +//! section after a while unused or as it is left. Keys live in memory only (`Library`). + +use crate::{Command, Library, State, Theme, art, manage::Structure}; +use accesskit::Role; +use std::{error::Error, sync::Arc, time::Duration}; +use ui::{Anchor, Axis, Flags, Id, Spec, Ui, children, fill, px}; +use winit::keyboard::NamedKey; +use zeroize::Zeroizing; + +const WIDTH: f32 = 440.0; + +/// What Options' "Lock password protected sections after…" offers, in minutes, as +/// OneNote 2010's list does. +pub const AFTER: [(u32, &str); 11] = [ + (1, "1 Minute"), + (5, "5 Minutes"), + (10, "10 Minutes"), + (15, "15 Minutes"), + (30, "30 Minutes"), + (60, "1 Hour"), + (120, "2 Hours"), + (240, "4 Hours"), + (480, "8 Hours"), + (720, "12 Hours"), + (1440, "1 Day"), +]; + +/// A password-protected section shown locked, in place of its pages. +pub struct Locked { + pub library: Arc, + pub path: String, +} + +/// A password dialog on a section. +pub struct Asking { + library: Arc, + path: String, + dialog: Dialog, +} + +enum Dialog { + /// Protected Section: the password that unlocks it; whether the last was wrong. + Unlock(Zeroizing, bool), + /// Password Protection: whether it is protected, and the ways to change that. + Protection, + /// Sets a password: it, its confirmation, and why the last try was refused. + Set([Zeroizing; 2], Option<&'static str>), + /// Change Password: the old password, the new one and its confirmation. + Change([Zeroizing; 3], Option<&'static str>), + /// Remove Password: the current password. + Remove(Zeroizing, bool), +} + +fn id() -> Id { + Id::ROOT.child("password") +} + +fn field(index: usize) -> Id { + id().child(("field", index)) +} + +/// The locked page's notice, which a click or Enter unlocks. +fn notice() -> Id { + Id::ROOT.child("locked-notice") +} + +fn section_name(path: &str) -> &str { + let name = path.rsplit('/').next().unwrap_or(path); + name.strip_suffix(".one").unwrap_or(name) +} + +impl State { + /// Shows the locked section `path` of `library` in place of the open section, which stays + /// open a while for coming back to. + pub(crate) fn show_locked( + &mut self, + library: Arc, + path: String, + ) -> Result<(), Box> { + self.stop_loading(); + self.persist()?; + if let Some(previous) = self.session.take() { + let path = &previous.tabs[previous.tab].path; + if self.leave(&previous.library, path) { + previous.section.close()?; + } else { + previous.library.keep(path, previous.section); + } + } + self.sectionless = None; + self.locked = Some(Locked { library, path }); + self.ui.set_focus(Some(notice())); + self.title(); + Ok(()) + } + + /// Locks `path` of `library` as it is left, where Options asks for that; whether it did. + pub(crate) fn leave(&mut self, library: &Arc, path: &str) -> bool { + if self.passwords.lock_on_leave && library.protected(path) { + library.lock(|locked, _| locked == path); + self.prefetch.forget(&library.key(path)); + return true; + } + false + } + + /// The section tabs of the locked section's folder, its own shown. + pub(crate) fn locked_tabs( + &mut self, + row: Id, + section: &ui::Section, + strip: [f32; 4], + ) -> (Option, Id) { + let Some(locked) = &self.locked else { + return (None, row); + }; + let library = Arc::clone(&locked.library); + let tabs = library.tabs(&crate::menus::folder(&locked.path)); + let shown = tabs + .iter() + .position(|tab| tab.path == locked.path) + .unwrap_or(0); + let names: Vec<_> = tabs + .iter() + .map(|tab| (tab.name.as_str(), crate::section_color(tab.color), false)) + .collect(); + let drawn = ui::shell::section_tabs( + &mut self.ui, + row, + &names, + shown, + None, + None, + section, + crate::TAB_ROW, + strip, + ); + if let Some((tab, point)) = drawn.context { + self.menu = Some(( + crate::menus::Target::Section { + library: Arc::clone(&library), + path: tabs[tab].path.clone(), + }, + point, + )); + self.ui.open_popup(crate::menus::id()); + } + let clicked = drawn + .clicked + .filter(|tab| *tab != shown) + .map(|tab| Command::OpenSection(library, tabs[tab].path.clone())); + (clicked, drawn.open) + } + + /// The locked section's page, as OneNote 2010 draws it: a click or Enter unlocks. + pub(crate) fn locked_page(&mut self, theme: &Theme) { + let Some(locked) = &self.locked else { + return; + }; + let (library, path) = (Arc::clone(&locked.library), locked.path.clone()); + let ui = &mut self.ui; + let entered = + ui::popup::navigation(ui, &[notice()], &[NamedKey::Enter]).contains(&NamedKey::Enter); + let area = ui.open( + "locked", + Spec { + size: [fill(), fill()], + fill: Some(ui::mix(theme.paper, theme.chip, 0.6)), + ..Spec::default() + }, + ); + let [left, top, right, bottom] = ui.rect(area).unwrap_or_default(); + let width = 380.0_f32.min(right - left); + let open = ui.open_as( + notice(), + Spec { + flags: Flags::FLOAT | Flags::CLICKABLE | Flags::FOCUSABLE, + size: [px(width), children()], + position: [ + ((right - left - width) / 2.0).max(0.0), + ((bottom - top) * 0.2).max(0.0), + ], + gap: 12.0, + role: Some(Role::Button), + ..Spec::default() + }, + ); + if let Some(node) = ui.access(open) { + node.set_label("This section is password protected. Unlock it"); + } + ui.leaf( + "icon", + Spec { + size: [px(20.0), px(theme.font_size * 1.6)], + icon: Some(art::INFO), + ..Spec::default() + }, + ); + ui.open( + "words", + Spec { + axis: Axis::Y, + size: [fill(), children()], + gap: 14.0, + ..Spec::default() + }, + ); + let line = |ui: &mut Ui, part, text, bold| { + ui.leaf( + part, + Spec { + size: [fill(), px(theme.font_size * 1.6)], + text: Some(text), + bold, + font_size: Some(theme.font_size * 1.15), + ..Spec::default() + }, + ); + }; + line(ui, "title", "This section is password protected.", true); + line( + ui, + "how", + "Click here or press ENTER to unlock this section.", + false, + ); + ui.close(); + ui.close(); + ui.close(); + if ui.signal(open).clicked || entered { + self.ask_password(library, path, Dialog::Unlock(Zeroizing::default(), false)); + } + } + + /// The section shown, open or locked, with its notebook. + pub(crate) fn shown_section(&self) -> Option<(Arc, String)> { + match (&self.session, &self.locked) { + (Some(session), _) if session.library.catalog().is_some() => Some(( + Arc::clone(&session.library), + session.tabs[session.tab].path.clone(), + )), + (None, Some(locked)) => Some((Arc::clone(&locked.library), locked.path.clone())), + _ => None, + } + } + + /// Opens Password Protection on the section at `path` of `library`, as its menu's + /// Password Protect This Section… does. + pub(crate) fn password_protection(&mut self, library: Arc, path: String) { + self.ask_password(library, path, Dialog::Protection); + } + + fn ask_password(&mut self, library: Arc, path: String, dialog: Dialog) { + self.password = Some(Asking { + library, + path, + dialog, + }); + self.ui.open_popup(id()); + self.ui.set_focus(None); + } + + /// Locks every protected section unlocked this run, as Lock All and Ctrl+Alt+L do. + pub(crate) fn lock_all(&mut self) -> Result<(), Box> { + let mut locked = Vec::new(); + for library in self.notebooks.clone() { + let paths = library.lock(|_, _| true); + locked.extend(paths.iter().map(|path| library.key(path))); + } + self.sync_index(true, locked); + self.relock() + } + + /// Locks the sections not worked in for as long as Options says, and wakes in time to + /// lock the next. + pub(crate) fn lock_idle(&mut self) -> Result<(), Box> { + let Some(minutes) = self.passwords.lock_after else { + return Ok(()); + }; + let after = Duration::from_secs(u64::from(minutes) * 60); + // Working in the open section keeps it unlocked. + if self.changed + && let Some(session) = &self.session + { + session.library.touch(&session.tabs[session.tab].path); + } + let (mut waiting, mut locked) = (false, Vec::new()); + for library in self.notebooks.clone() { + let paths = library.lock(|_, idle| { + waiting |= idle < after; + idle >= after + }); + locked.extend(paths.iter().map(|path| library.key(path))); + } + if waiting { + self.ui.wake_after(Duration::from_secs(30)); + } + if !locked.is_empty() { + self.sync_index(true, locked); + } + self.relock() + } + + /// Shows the open section locked once its key is gone. + fn relock(&mut self) -> Result<(), Box> { + let Some(session) = &self.session else { + return Ok(()); + }; + let (library, path) = ( + Arc::clone(&session.library), + session.tabs[session.tab].path.clone(), + ); + if !library.locked(&path) { + return Ok(()); + } + self.persist()?; + if let Some(session) = self.session.take() { + session.section.close()?; + } + self.prefetch.forget(&library.key(&path)); + self.password = None; + self.ui.close_popup(id()); + self.show_locked(library, path) + } + + /// The open password dialog; what its OK does. + pub(crate) fn password_dialog(&mut self) -> Result<(), Box> { + let Some(mut asking) = self.password.take() else { + return Ok(()); + }; + if !self.ui.popup_open(id()) { + return Ok(()); + } + let theme = self.ui.theme.clone(); + let row = theme.font_size * 2.0; + let ui = &mut self.ui; + let fields = match &asking.dialog { + Dialog::Unlock(..) | Dialog::Remove(..) => 1, + Dialog::Protection => 0, + Dialog::Set(..) => 2, + Dialog::Change(..) => 3, + }; + let owners: Vec = (0..fields).map(field).collect(); + // A dialog asking for a password has one of its fields focused, the first at first. + if !owners.is_empty() && !ui.focused().is_some_and(|focus| owners.contains(&focus)) { + ui.set_focus(Some(field(0))); + } + let entered = + ui::popup::navigation(ui, &owners, &[NamedKey::Enter]).contains(&NamedKey::Enter); + let protected = asking.library.protected(&asking.path); + let title = match asking.dialog { + Dialog::Unlock(..) => "Protected Section", + Dialog::Protection | Dialog::Set(..) => "Password Protection", + Dialog::Change(..) => "Change Password", + Dialog::Remove(..) => "Remove Password", + }; + ui.open_as( + id(), + Spec { + axis: Axis::Y, + size: [px(WIDTH), children()], + fill: Some(theme.popup), + border: Some(theme.chip), + shadow: Some(theme.shadow), + radius: 8.0, + pad: [16.0, 12.0], + gap: 8.0, + anchor: Some(Anchor::Dialog), + role: Some(Role::Dialog), + ..Spec::default() + }, + ); + if let Some(node) = ui.access(id()) { + node.set_label(title); + } + let text = |ui: &mut Ui, part: &str, text: &str, bold| { + ui.leaf( + part, + Spec { + size: [fill(), px(row)], + text: Some(text), + bold, + ..Spec::default() + }, + ); + }; + text(ui, "title", title, true); + let name = section_name(&asking.path); + let field_spec = Spec { + size: [fill(), px(row)], + fill: Some(theme.base), + border: Some(theme.accent), + radius: 4.0, + pad: [6.0, 0.0], + ..Spec::default() + }; + let password = |ui: &mut Ui, index, label: &str, value: &mut String| { + text(ui, &format!("label {index}"), label, false); + ui::password_field(ui, field(index), value, "", field_spec.clone()); + if let Some(node) = ui.access(field(index)) { + node.set_label(label.trim_end_matches(':')); + } + }; + let problem = |ui: &mut Ui, problem: &str| { + ui.leaf( + "problem", + Spec { + size: [fill(), px(row)], + icon: Some(art::WARNING), + text: Some(problem), + bold: true, + gap: 6.0, + role: Some(Role::Alert), + ..Spec::default() + }, + ); + }; + let mut buttons: &[&str] = &["Cancel", "OK"]; + match &mut asking.dialog { + Dialog::Unlock(value, wrong) => { + text( + ui, + "about", + &format!("Section \u{201c}{name}\u{201d} is password protected."), + false, + ); + if *wrong { + problem(ui, "Password is incorrect."); + } + password(ui, 0, "Enter Password:", value); + } + Dialog::Protection => { + let status = if protected { + format!("Section \u{201c}{name}\u{201d} is password protected.") + } else { + format!("Section \u{201c}{name}\u{201d} is not password protected.") + }; + text(ui, "status", &status, false); + buttons = if protected { + &["Change Password…", "Remove Password…", "Lock All", "Close"] + } else { + &["Set Password…", "Lock All", "Close"] + }; + } + Dialog::Set([value, confirm], refused) => { + if let Some(refused) = refused { + problem(ui, refused); + } + password(ui, 0, "Enter Password:", value); + password(ui, 1, "Confirm Password:", confirm); + text(ui, "caution", "Caution", true); + ui.leaf( + "caution-text", + Spec { + size: [fill(), ui::fit()], + text: Some( + "If you lose or forget the password, Snowbound cannot recover \ + your data. (Remember that passwords are case-sensitive.)", + ), + overflow: ui::Overflow::Wrap, + ..Spec::default() + }, + ); + } + Dialog::Change([old, value, confirm], refused) => { + if let Some(refused) = refused { + problem(ui, refused); + } + password(ui, 0, "Old Password:", old); + password(ui, 1, "Enter New Password:", value); + password(ui, 2, "Confirm Password:", confirm); + } + Dialog::Remove(value, wrong) => { + if *wrong { + problem(ui, "Password is incorrect."); + } + password( + ui, + 0, + "To remove password protection, enter the current password:", + value, + ); + } + } + ui.open( + "buttons", + Spec { + size: [fill(), children()], + gap: 8.0, + ..Spec::default() + }, + ); + ui.leaf( + "space", + Spec { + size: [fill(), px(1.0)], + ..Spec::default() + }, + ); + let pressed = buttons + .iter() + .position(|label| ui::button(ui, *label, label).clicked); + ui.close(); + ui.close(); + let chosen = pressed.map(|at| buttons[at]).or(entered.then_some("OK")); + let Asking { + library, + path, + dialog, + } = asking; + let next = match (chosen, dialog) { + (None, dialog) => { + self.password = Some(Asking { + library, + path, + dialog, + }); + return Ok(()); + } + (Some("Cancel" | "Close"), _) => None, + (Some("Lock All"), _) => { + self.ui.close_popup(id()); + return self.lock_all(); + } + (Some("Set Password…"), _) => Some(Dialog::Set(Default::default(), None)), + (Some("Change Password…"), _) => Some(Dialog::Change(Default::default(), None)), + (Some("Remove Password…"), _) => Some(Dialog::Remove(Zeroizing::default(), false)), + (Some(_), Dialog::Unlock(value, _)) => match library.unlock(&path, &value) { + Ok(()) => { + self.commands + .push(Command::OpenSection(Arc::clone(&library), path.clone())); + None + } + Err(notebook::Error::Protected(_)) => { + Some(Dialog::Unlock(Zeroizing::default(), true)) + } + Err(error) => { + crate::platform::alert("Couldn't unlock the section", &error.to_string()); + None + } + }, + (Some(_), Dialog::Set([value, confirm], _)) => { + if value.is_empty() { + Some(Dialog::Set(Default::default(), Some("Enter a password."))) + } else if *value != *confirm { + Some(Dialog::Set( + Default::default(), + Some("The passwords don\u{2019}t match."), + )) + } else { + self.set_password(library.clone(), path.clone(), None, Some(value)); + None + } + } + (Some(_), Dialog::Change([old, value, confirm], _)) => { + if value.is_empty() { + Some(Dialog::Change( + Default::default(), + Some("Enter a password."), + )) + } else if *value != *confirm { + Some(Dialog::Change( + Default::default(), + Some("The new passwords don\u{2019}t match."), + )) + } else { + match current(&library, &path, &old) { + Some(key) => { + self.set_password( + library.clone(), + path.clone(), + Some(key), + Some(value), + ); + None + } + None => Some(Dialog::Change( + Default::default(), + Some("Password is incorrect."), + )), + } + } + } + (Some(_), Dialog::Remove(value, _)) => match current(&library, &path, &value) { + Some(key) => { + self.set_password(library.clone(), path.clone(), Some(key), None); + None + } + None => Some(Dialog::Remove(Zeroizing::default(), true)), + }, + (Some(_), Dialog::Protection) => None, + }; + match next { + Some(dialog) => self.ask_password(library, path, dialog), + None => self.ui.close_popup(id()), + } + Ok(()) + } + + /// Sets, changes or removes the password of `path` in `library`, closing its section + /// first: the section is written anew, and its replica goes with the old file. + fn set_password( + &mut self, + library: Arc, + path: String, + key: Option, + password: Option>, + ) { + let open = self.session.as_ref().is_some_and(|session| { + Arc::ptr_eq(&session.library, &library) && session.tabs[session.tab].path == path + }); + if open { + let closed = self.persist().and_then(|()| { + if let Some(session) = self.session.take() { + session.section.close()?; + } + Ok(()) + }); + if let Err(error) = closed { + return crate::platform::alert("Couldn't set the password", &error.to_string()); + } + self.prefetch.forget(&library.key(&path)); + } + library.lock(|locked, _| locked == path); + self.restructure( + library, + Structure::Password { + path, + key, + password, + }, + ); + } +} + +/// The key `password` opens the section at `path` with, if it does. +fn current(library: &Library, path: &str, password: &str) -> Option { + library.unlock(path, password).ok()?; + library.unlocked(path) +} diff --git a/crates/snowbound/src/recycle.rs b/crates/snowbound/src/recycle.rs new file mode 100644 index 0000000000000000000000000000000000000000..f1cf7d9dd74bd5c062d4e2cb95df0d1824d3c73b --- /dev/null +++ b/crates/snowbound/src/recycle.rs @@ -0,0 +1,393 @@ +//! OneNote 2010's Notebook Recycle Bin: the notebook's `OneNote_RecycleBin` group shown as a +//! row of its own tabs, Deleted Pages and each deleted section, read-only beneath a bar +//! saying how to restore them. Pages go back to a section and sections to a folder, and Empty +//! Recycle Bin deletes what the bin holds for good. + +use crate::{Command, Library, Loaded, State, filetime, library, menus, notify, read_session}; +use onestore::{ + ExGuid, + op::{Edit, Op, SectionOp}, +}; +use std::{error::Error, sync::Arc}; +use ui::{Axis, Spec, Ui, children, fill, fit}; + +/// The bin's folder, a section group of the notebook's. +pub const BIN: &str = "OneNote_RecycleBin"; + +/// What the bin's commands ask of the section it shows. +pub enum Request { + /// Puts page `space` at the end of the section at catalog `path`, or with `copy` a copy + /// of it, as OneNote's Move or Copy takes a page out of the bin. + Restore { + space: ExGuid, + path: String, + copy: bool, + }, + /// Deletes pages for good. + Purge(Vec), +} + +/// Whether the section or group at catalog `path` lies in its notebook's recycle bin. +pub fn binned(path: &str) -> bool { + library::recycle_bin(&menus::folder(path)) +} + +impl State { + /// Whether the open section is one of the recycle bin's. + pub(crate) fn in_recycle_bin(&self) -> bool { + (self.session.as_ref()).is_some_and(|session| binned(&session.tabs[session.tab].path)) + } + + /// Notebook Recycle Bin: shows `library`'s bin at its first section holding pages, or + /// while it shows, goes back to the notebook. + pub(crate) fn toggle_recycle_bin(&mut self, library: Arc) { + if self.in_recycle_bin() { + if let Some(path) = library.first_section() { + self.commands.push(Command::OpenSection(library, path)); + } + return; + } + let paths: Vec = library.tabs(BIN).into_iter().map(|tab| tab.path).collect(); + let proxy = self.proxy.clone(); + self.load(move || { + for path in paths { + let section = library.open(&path, notify(proxy.clone()))?; + if section.pages()?.is_empty() { + section.close()?; + continue; + } + let (session, page) = read_session(section, library, path, None)?; + return Ok(Loaded::Section(Box::new(session), page)); + } + Err("The Recycle Bin is empty.".into()) + }); + } + + /// Empty Recycle Bin on `library`, once confirmed as OneNote asks. + pub(crate) fn empty_recycle_bin(&mut self, library: Arc) { + if crate::platform::confirm( + "Are you sure you want to empty the Recycle Bin for this notebook?", + "Its pages and sections are deleted for good.", + "Cancel", + "Empty Recycle Bin", + ) { + let change = crate::manage::Structure::EmptyRecycleBin; + self.commands.push(Command::Structure(library, change)); + } + } + + /// Does `request` to the bin's open section on a thread of its own, then shows the page + /// after the ones gone, or the notebook once the section holds none. + pub(crate) fn recycle(&mut self, request: Request) -> Result<(), Box> { + let gone = match &request { + Request::Restore { copy: true, .. } => Vec::new(), + Request::Restore { space, .. } => vec![*space], + Request::Purge(spaces) => spaces.clone(), + }; + if matches!(request, Request::Purge(_)) + && !crate::platform::confirm( + "Are you sure you want to delete this page for good?", + "It can't be restored.", + "Cancel", + "Delete", + ) + { + return Ok(()); + } + let session = self.session.as_ref().ok_or("No section is open")?; + let replica = Arc::clone(session.section.replica()); + let library = Arc::clone(&session.library); + let shown = session.space; + let at = (session.pages.iter()).position(|(space, ..)| gone.contains(space)); + let kept = |space: &ExGuid| !gone.contains(space); + let after = at.and_then(|at| { + let spaces = session.pages.iter().map(|(space, ..)| *space); + (spaces.clone().skip(at).find(kept)).or_else(|| spaces.take(at).rev().find(kept)) + }); + let (author, proxy) = (self.author.clone(), self.proxy.clone()); + self.load(move || { + carry_out(&library, &replica, request, &author)?; + if let Some(show) = if gone.contains(&shown) { + after + } else { + Some(shown) + } { + return Ok(Loaded::Page(show, replica.page(show)?)); + } + // A section without pages shows no tab: the notebook shows instead. + let path = library + .first_section() + .ok_or("The notebook has no sections")?; + let section = library.open(&path, notify(proxy))?; + let (session, page) = read_session(section, library, path, None)?; + Ok(Loaded::Section(Box::new(session), page)) + }); + Ok(()) + } + + /// The bin's tab row leads with a way back to the notebook and where it is, as + /// OneNote's "Recycle Bin for" does. + pub(crate) fn recycle_heading(&mut self, theme: &ui::Theme) { + let Some(session) = self.session.as_ref().filter(|_| self.in_recycle_bin()) else { + return; + }; + let library = Arc::clone(&session.library); + let heading = format!("Recycle Bin for \u{201c}{}\u{201d}:", library.name); + let back = ui::shell::tool_button( + &mut self.ui, + "recycle back", + crate::art::BACK, + theme.text, + None, + ); + if let Some(node) = self.ui.access(self.ui.id("recycle back")) { + node.set_label("Back to the Notebook"); + } + self.ui.leaf( + "recycle heading", + Spec { + size: [fit(), fill()], + text: Some(&heading), + color: Some(theme.text), + pad: [6.0, 0.0], + ..Spec::default() + }, + ); + if back.clicked { + self.toggle_recycle_bin(library); + } + } +} + +/// Does `request` to the bin's section `replica` holds, in `library`, as `author`: a page +/// restored goes to its section as one op, keeping its identity, a copy under a new one, and +/// what leaves the bin goes from it as another. +fn carry_out( + library: &Library, + replica: ¬ebook::Replica, + request: Request, + author: &str, +) -> Result<(), Box> { + let gone = match request { + Request::Restore { space, path, copy } => { + let page = replica.page(space)?; + let section = library.open(&path, || {})?; + if copy { + section.import_page(&page, author)?; + } else { + let ops = vec![notebook::session::moved(&page, author)?]; + section.replica().apply( + author, + Edit { + at: filetime(), + ops, + }, + )?; + } + section.close()?; + (!copy).then_some(vec![space]).unwrap_or_default() + } + Request::Purge(spaces) => spaces, + }; + if !gone.is_empty() { + let ops = vec![Op::Section(SectionOp::Delete(gone))]; + replica.apply( + author, + Edit { + at: filetime(), + ops, + }, + )?; + } + Ok(()) +} + +/// The bar above a page of the recycle bin, in OneNote 2010's words but for the menu item +/// that restores. +pub fn bar(ui: &mut Ui) { + ui.open( + "recycle bar", + Spec { + axis: Axis::Y, + size: [fill(), children()], + fill: Some(draw::srgb(0xff, 0xee, 0xc2)), + pad: [12.0, 6.0], + role: Some(accesskit::Role::Banner), + ..Spec::default() + }, + ); + for (part, text) in [ + ( + "restore", + "To restore a page or section, right-click it and choose Restore To.", + ), + ("purge", "Content here is deleted after 60 days."), + ] { + ui.leaf( + part, + Spec { + size: [fill(), fit()], + text: Some(text), + overflow: ui::Overflow::Wrap, + color: Some(draw::srgb(0x20, 0x20, 0x20)), + ..Spec::default() + }, + ); + } + ui.close(); +} + +#[cfg(test)] +mod tests { + use super::*; + use notebook::session::{Notebook, stored_pages}; + use onestore::PageCreation; + + const AUTHOR: &str = "Author"; + const FIRST: &str = "New Section 1.one"; + + /// Waits until a section's edits are in its file. + fn published(library: &Library, path: &str) { + let section = library.open(path, || {}).unwrap(); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); + while section.sync_status().unwrap().queued > 0 { + assert!( + std::time::Instant::now() < deadline, + "{path} never published" + ); + std::thread::sleep(std::time::Duration::from_millis(20)); + } + section.close().unwrap(); + } + + fn titles(root: &std::path::Path, path: &str) -> Vec<(String, Option<[u8; 16]>)> { + let pages = stored_pages(&std::fs::read(root.join(path)).unwrap()).unwrap(); + (pages.into_iter()) + .map(|page| (page.page.title, page.page.identity)) + .collect() + } + + /// Pages leave the recycle bin for a section, keeping their identity, or as copies under + /// new ones, and deleted for good, each as an op; a section leaves for the notebook's + /// folder, and Empty Recycle Bin takes the rest. `SNOWBOUND_RECYCLE_EXPORT` names a new + /// directory receiving the notebook `before` and `after`, for cold reopens in OneNote 2010 + /// (`corpus/recycle-bin-view`). + #[test] + fn pages_and_sections_leave_the_recycle_bin_as_onenote_restores_them() { + let folder = std::env::temp_dir().join(format!("snowbound-recycle-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&folder); + std::fs::create_dir_all(&folder).unwrap(); + let (root, cache) = (folder.join("Recycled"), folder.join("cache")); + let page = |title: &str| PageCreation::new(None, Some(title), AUTHOR).unwrap(); + let mut notebook = + Notebook::create(&root, &cache, Notebook::NEW_COLOR, &page("Kept")).unwrap(); + for (name, title) in [ + ("Back", "Restored page"), + ("Copied", "Copied page"), + ("Purged", "Purged page"), + ] { + notebook.create_section("", name, &page(title)).unwrap(); + } + notebook + .create_section("", "Restored", &page("In a restored section")) + .unwrap(); + notebook + .create_section("", "Emptied", &page("In an emptied section")) + .unwrap(); + for name in ["Back", "Copied", "Purged"] { + let image = notebook.read_section(&format!("{name}.one")).unwrap(); + let pages = stored_pages(&image).unwrap(); + notebook + .recycle_pages(&[pages[0].page.clone()], AUTHOR) + .unwrap(); + } + for name in ["Back", "Copied", "Purged", "Restored", "Emptied"] { + notebook.delete(&format!("{name}.one")).unwrap(); + } + let export = std::env::var_os("SNOWBOUND_RECYCLE_EXPORT").map(std::path::PathBuf::from); + if let Some(directory) = &export { + copy(&root, &directory.join("before")); + } + let library = Library::created(root.to_str().unwrap(), notebook, &cache); + let deleted = format!("{BIN}/OneNote_DeletedPages.one"); + let binned = titles(&root, &deleted); + let bin = library.open(&deleted, || {}).unwrap(); + let space = |title: &str| { + let pages = bin.replica().pages().unwrap(); + pages + .into_iter() + .find(|(_, listed, _)| listed == title) + .unwrap() + .0 + }; + let (back, copied, purged) = ( + space("Restored page"), + space("Copied page"), + space("Purged page"), + ); + let restore = |space, copy| Request::Restore { + space, + path: FIRST.into(), + copy, + }; + carry_out(&library, bin.replica(), restore(back, false), AUTHOR).unwrap(); + carry_out(&library, bin.replica(), restore(copied, true), AUTHOR).unwrap(); + carry_out( + &library, + bin.replica(), + Request::Purge(vec![purged]), + AUTHOR, + ) + .unwrap(); + bin.close().unwrap(); + published(&library, FIRST); + published(&library, &deleted); + let identity = |title: &str| binned.iter().find(|(listed, _)| listed == title).unwrap().1; + let first = titles(&root, FIRST); + let listed: Vec<&str> = first.iter().map(|(title, _)| title.as_str()).collect(); + assert_eq!(listed, ["Kept", "Restored page", "Copied page"]); + assert_eq!( + first[1].1, + identity("Restored page"), + "a restored page keeps its identity" + ); + assert_ne!( + first[2].1, + identity("Copied page"), + "a copy takes a new one" + ); + let left: Vec = titles(&root, &deleted) + .into_iter() + .map(|(title, _)| title) + .collect(); + assert_eq!(left, ["Copied page"]); + + let mut notebook = library.reopen().unwrap(); + notebook + .move_entry(&format!("{BIN}/Restored.one"), "") + .unwrap(); + notebook.empty_recycle_bin().unwrap(); + let sections: Vec<&str> = (notebook.catalog().sections.iter()) + .map(|section| section.path.as_str()) + .collect(); + assert_eq!(sections, [FIRST, "Restored.one"]); + assert!(titles(&root, &deleted).is_empty()); + assert!(!root.join(BIN).join("Emptied.one").exists()); + if let Some(directory) = &export { + copy(&root, &directory.join("after")); + } + std::fs::remove_dir_all(&folder).unwrap(); + } + + fn copy(from: &std::path::Path, to: &std::path::Path) { + std::fs::create_dir_all(to).unwrap(); + for entry in std::fs::read_dir(from).unwrap().flatten() { + let target = to.join(entry.file_name()); + if entry.file_type().unwrap().is_dir() { + copy(&entry.path(), &target); + } else { + std::fs::copy(entry.path(), target).unwrap(); + } + } + } +} diff --git a/crates/snowbound/src/rename.rs b/crates/snowbound/src/rename.rs index 075a271bdaa9d9c97d21215916148a0d991f8783..7779c3fbbc2dffddcde22e876f5b3307cb8153b4 100644 --- a/crates/snowbound/src/rename.rs +++ b/crates/snowbound/src/rename.rs @@ -81,8 +81,7 @@ impl State { let name = match &target { Target::Entry { path, in_tab, .. } => { self.sidebar |= !in_tab; - let name = path.rsplit('/').next().unwrap_or_default(); - name.strip_suffix(".one").unwrap_or(name).to_owned() + crate::library::entry_name(path).to_owned() } Target::Page(space) => self .session @@ -107,8 +106,7 @@ impl State { } match target { Target::Entry { library, path, .. } => { - let old = path.rsplit('/').next().unwrap_or_default(); - if name != old.strip_suffix(".one").unwrap_or(old) { + if name != crate::library::entry_name(&path) { self.commands.push(Command::Structure( library, Structure::Rename { path, name }, diff --git a/crates/snowbound/src/save_as.rs b/crates/snowbound/src/save_as.rs new file mode 100644 index 0000000000000000000000000000000000000000..e72867bfc7f68fc050757221c42df5ff8acaaadc --- /dev/null +++ b/crates/snowbound/src/save_as.rs @@ -0,0 +1,271 @@ +//! File, Save As, as OneNote 2010's: the page, its section or its notebook, as a OneNote +//! section (`.one`), a package (`.onepkg`) or a PDF. A page or section saved as a section is +//! a copy outside any notebook; a notebook's package holds its sections as they stand here, +//! the open one's queued edits included (`notebook::package`). + +use crate::{Library, State, UserEvent, platform, print}; +use accesskit::Role; +use notebook::package; +use std::{error::Error, sync::Arc}; +use ui::{Anchor, Axis, Id, Spec, children, fill, popup::Item, px}; +use winit::keyboard::NamedKey; + +/// What Save As saves. +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum Scope { + Page, + Section, + Notebook, +} + +const SCOPES: [(Scope, &str); 3] = [ + (Scope::Page, "Page"), + (Scope::Section, "Section"), + (Scope::Notebook, "Notebook"), +]; + +/// OneNote 2010's formats for each scope, as Save As lists them. +fn formats(scope: Scope) -> [(&'static str, &'static str); 2] { + match scope { + Scope::Notebook => [ + ("OneNote Package (*.onepkg)", "onepkg"), + ("PDF (*.pdf)", "pdf"), + ], + _ => [ + ("OneNote 2010 Section (*.one)", "one"), + ("PDF (*.pdf)", "pdf"), + ], + } +} + +/// The dialog's choices while it is open: the section a scope of Section saves, by catalog +/// path, and the place in `formats` chosen. +pub struct Dialog { + library: Arc, + section: Option, + scope: Scope, + format: usize, +} + +fn id() -> Id { + Id::ROOT.child("save-as") +} + +impl State { + /// Opens Save As on `scope`: the open page or section, or `section` of `library` when + /// given, or the notebook. + pub(crate) fn open_save_as( + &mut self, + library: Arc, + section: Option, + scope: Scope, + ) { + let section = section.or_else(|| { + let session = self.session.as_ref()?; + Some(session.tabs[session.tab].path.clone()) + }); + self.save_as = Some(Dialog { + library, + section, + scope, + format: 0, + }); + self.ui.open_popup(id()); + } + + /// Builds Save As while it is open; Save As… asks where, then writes on a thread. + pub(crate) fn save_as_dialog(&mut self) { + let Some(dialog) = &mut self.save_as else { + return; + }; + let ui = &mut self.ui; + if !ui.popup_open(id()) { + self.save_as = None; + return; + } + let theme = ui.theme.clone(); + let row = theme.font_size * 2.0; + let entered = ui::popup::navigation(ui, &[], &[NamedKey::Enter]).contains(&NamedKey::Enter); + ui.open_as( + id(), + Spec { + axis: Axis::Y, + size: [px(360.0), children()], + fill: Some(theme.popup), + border: Some(theme.chip), + shadow: Some(theme.shadow), + radius: 8.0, + pad: [16.0, 12.0], + gap: 6.0, + anchor: Some(Anchor::Dialog), + role: Some(Role::Dialog), + ..Spec::default() + }, + ); + if let Some(node) = ui.access(id()) { + node.set_label("Save As"); + } + ui.leaf( + "title", + Spec { + size: [fill(), px(row)], + text: Some("Save As"), + bold: true, + role: Some(Role::Heading), + ..Spec::default() + }, + ); + // A page or section is only there to save with one open, or picked. + let offered: Vec<(Scope, &str)> = (SCOPES.iter().copied()) + .filter(|(scope, _)| match scope { + Scope::Page => self.session.is_some(), + Scope::Section => dialog.section.is_some(), + Scope::Notebook => dialog.library.catalog().is_some(), + }) + .collect(); + let current = offered.iter().position(|(scope, _)| *scope == dialog.scope); + let names: Vec<&str> = offered.iter().map(|(_, name)| *name).collect(); + if let Some(at) = choose(ui, "Save current", &names, current.unwrap_or(0)) { + dialog.scope = offered[at].0; + dialog.format = 0; + } + let names = formats(dialog.scope).map(|(name, _)| name); + if let Some(at) = choose(ui, "Format", &names, dialog.format) { + dialog.format = at; + } + crate::buttons(ui); + let cancel = ui::button(ui, "cancel", "Cancel").clicked; + let go = ui::button(ui, "go", "Save As…").clicked || entered; + ui.close(); + ui.close(); + if !go && !cancel { + return; + } + let dialog = self.save_as.take().expect("The dialog is open"); + self.ui.close_popup(id()); + if go && let Err(error) = self.save(dialog) { + platform::alert("Couldn't save", &error.to_string()); + } + } + + /// Asks where to save what `dialog` chose, then writes it on a thread of its own; a PDF + /// goes through Export as PDF's settings. + fn save(&mut self, dialog: Dialog) -> Result<(), Box> { + let (_, extension) = formats(dialog.scope)[dialog.format]; + if extension == "pdf" { + self.export_pdf(match dialog.scope { + Scope::Page => print::Scope::Page, + Scope::Section => print::Scope::Section, + Scope::Notebook => print::Scope::Notebook, + }); + return Ok(()); + } + self.persist()?; + let library = dialog.library; + let open = (self.session.as_ref()) + .filter(|session| Arc::ptr_eq(&session.library, &library)) + .map(|session| { + let path = session.tabs[session.tab].path.clone(); + (path, Arc::clone(session.section.replica()), session.space) + }); + let section = dialog.section.unwrap_or_default(); + let title = match dialog.scope { + Scope::Page => self.view.editor.page()?.title, + Scope::Section => crate::library::section_name(§ion, &None), + Scope::Notebook => library.name.clone(), + }; + let name = format!("{}.{extension}", print::file_name(&title)); + let Some(mut path) = platform::pick_new("Save As", &name, "Save", None) else { + return Ok(()); + }; + if path.extension().is_none() { + path.set_extension(extension); + } + let (scope, author, proxy) = (dialog.scope, self.author.clone(), self.proxy.clone()); + let color = (library + .tabs(crate::menus::folder(§ion).as_str()) + .into_iter()) + .find(|tab| tab.path == section) + .and_then(|tab| tab.color); + std::thread::spawn(move || { + let written = (|| -> Result<(), Box> { + let image = |path: &str| { + let (_, replica, _) = open.as_ref().filter(|(open, ..)| open == path)?; + replica.snapshot().ok() + }; + let bytes = match scope { + Scope::Page => { + let (_, replica, space) = open.as_ref().ok_or("No page is open")?; + let page = replica.page(*space)?; + let file = file_name(&path); + package::page_section(&page, &file, color, &author)? + } + Scope::Section => { + let stored = match image(§ion) { + Some(image) => image, + None => library.reopen()?.read_section(§ion)?, + }; + package::section_copy(stored)? + } + Scope::Notebook => { + let notebook = library.reopen()?; + package::pack(package::notebook_files(¬ebook, image)?)? + } + }; + std::fs::write(&path, bytes)?; + Ok(()) + })(); + let written = written.map_err(|error| error.to_string()); + let _ = proxy.send_event(UserEvent::Then(Box::new(move |_| { + written.map_err(|error| { + platform::alert("Couldn't save", &error); + error.into() + }) + }))); + }); + Ok(()) + } +} + +fn file_name(path: &std::path::Path) -> String { + path.file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_default() +} + +/// A labelled combo listing `names`, choosing among them. +fn choose(ui: &mut ui::Ui, label: &str, names: &[&str], current: usize) -> Option { + let row = ui.theme.font_size * 2.0; + ui.open( + label, + Spec { + size: [fill(), px(row)], + gap: 8.0, + ..Spec::default() + }, + ); + ui.leaf( + "label", + Spec { + size: [px(110.0), px(row)], + text: Some(label), + ..Spec::default() + }, + ); + let menu = id().child(label); + let combo = ui.id(label); + let shown = names.get(current).copied().unwrap_or_default(); + ui::shell::combo(ui, label, label, shown, 210.0, menu, true); + let items: Vec = (names.iter().enumerate()) + .map(|(at, text)| Item { + text, + checked: Some(at == current), + current: at == current, + ..Item::default() + }) + .collect(); + let anchor = Anchor::Below(ui.rect(combo).unwrap_or_default()); + let chosen = ui::popup::menu(ui, menu, anchor, &items, None); + ui.close(); + chosen +} diff --git a/crates/snowbound/src/search.rs b/crates/snowbound/src/search.rs index 2609b5e4489510f4b601e75a89f40ae020e619f8..7c35021267de88f920737a5dec07179b7898123c 100644 --- a/crates/snowbound/src/search.rs +++ b/crates/snowbound/src/search.rs @@ -272,29 +272,21 @@ fn run( fn sections(library: &Library) -> Vec { match &library.notebook { Ok(Some(notebook)) => { - let mut paths = Vec::new(); - let mut folders = vec![notebook.catalog()]; - while let Some(folder) = folders.pop() { - paths.extend( - folder - .sections - .iter() - .filter(|section| { - matches!( - section.state, - notebook::discover::SectionState::Readable { .. } - ) - }) - .map(|section| section.path.clone()), - ); - folders.extend( - folder - .groups - .iter() - .filter(|group| !crate::library::recycle_bin(&group.path)), - ); - } - paths + let enter = + |group: ¬ebook::discover::Folder| !crate::library::recycle_bin(&group.path); + crate::library::folders(notebook.catalog(), enter) + .into_iter() + .flat_map(|folder| &folder.sections) + .filter(|section| match section.state { + notebook::discover::SectionState::Readable { .. } => true, + // OneNote searches a protected section only while it is unlocked. + notebook::discover::SectionState::Locked => { + library.unlocked(§ion.path).is_some() + } + _ => false, + }) + .map(|section| section.path.clone()) + .collect() } Ok(None) => vec![library.location.clone()], Err(_) => Vec::new(), @@ -339,7 +331,10 @@ fn sync( Some(notebook) => notebook.read_section(&path)?, None => onestore::read_file(&file)?, }; - let stored = notebook::session::stored_pages(&bytes)?; + let stored = match library.unlocked(&path) { + Some(key) => notebook::session::stored_pages_unlocked(&bytes, &key)?, + None => notebook::session::stored_pages(&bytes)?, + }; let modified = |space: ExGuid| { stored .iter() @@ -485,12 +480,10 @@ impl State { /// Brings the index to the open notebooks and section when they changed, and when /// `always` or sections `changed` names, by key, to the section files changed since. pub(crate) fn sync_index(&mut self, always: bool, changed: Vec) { - let open = self.session.as_ref().map(|session| { - ( - session.library.key(&session.tabs[session.tab].path), - Arc::downgrade(session.section.replica()), - ) - }); + let open = self + .session + .as_ref() + .map(|session| (session.key(), Arc::downgrade(session.section.replica()))); let identity: Vec = self .notebooks .iter() @@ -511,11 +504,8 @@ impl State { /// Tells the index an edit changed page `space` of the open section. pub(crate) fn edited(&self, spaces: Vec) { if let Some(session) = &self.session { - self.search.changed( - session.library.key(&session.tabs[session.tab].path), - session.section.replica(), - spaces, - ); + self.search + .changed(session.key(), session.section.replica(), spaces); } } @@ -544,7 +534,7 @@ impl State { let open = self .session .as_ref() - .map(|session| session.library.key(&session.tabs[session.tab].path)) + .map(crate::Session::key) .unwrap_or_default(); let wanted = (query.clone(), self.search.scope, version, open); if self.search.found_for.as_ref() == Some(&wanted) { @@ -673,12 +663,10 @@ impl State { ) -> Result<(), Box> { let (location, path) = section.split_once('\n').unwrap_or_default(); self.search.reveal = Some((space, paragraph)); - let open = self.session.as_ref().map(|session| { - ( - session.library.key(&session.tabs[session.tab].path), - session.space, - ) - }); + let open = self + .session + .as_ref() + .map(|session| (session.key(), session.space)); match open { Some((key, shown)) if key == section && shown == space => self.refind(true)?, Some((key, _)) if key == section => self.commands.push(Command::OpenPage(space)), diff --git a/crates/snowbound/src/settings.rs b/crates/snowbound/src/settings.rs index 55853a3756e5bbbd33da6c071458b26ae9172ec8..91a50af7c09eab1e9d82ccfc5659cb7fbb64ea1c 100644 --- a/crates/snowbound/src/settings.rs +++ b/crates/snowbound/src/settings.rs @@ -48,6 +48,27 @@ pub struct Settings { pub keys: std::collections::BTreeMap>, /// Pages shown lately, latest first, which the palette lists first. pub recent: Vec, + pub passwords: Passwords, +} + +/// Options' Passwords, as OneNote 2010's Advanced page keeps them. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct Passwords { + /// Minutes a protected section stays unlocked without being worked in; none keeps it. + pub lock_after: Option, + /// Locks a protected section as soon as another is shown. + pub lock_on_leave: bool, +} + +impl Default for Passwords { + /// OneNote 2010's: locked after 10 minutes. + fn default() -> Self { + Self { + lock_after: Some(10), + lock_on_leave: false, + } + } } /// What the toolbar's buttons apply from their menus' last picks. @@ -166,6 +187,7 @@ impl crate::State { servers: self.servers.clone(), keys: crate::commands::Keymap::current().saved(), recent: self.trail.recent.clone(), + passwords: self.passwords, }; if let Err(error) = settings.save(path) { eprintln!("Cannot save the settings in {}: {error}", path.display()); @@ -244,6 +266,10 @@ mod tests { n: 1, }, }], + passwords: Passwords { + lock_after: None, + lock_on_leave: true, + }, }; settings.save(&path).unwrap(); assert_eq!(Settings::load(&path), settings); diff --git a/crates/snowbound/src/sidebar.rs b/crates/snowbound/src/sidebar.rs index 9cbd5f171296411d66da5c138392168608e8b396..075a04ec9015a144aa3c947ed55bbce8d0f3058b 100644 --- a/crates/snowbound/src/sidebar.rs +++ b/crates/snowbound/src/sidebar.rs @@ -28,7 +28,7 @@ pub enum Action { notebook: usize, path: String, }, - /// Folds or unfolds a notebook's or section group's rows, by `fold_key`. + /// Folds or unfolds a notebook's or section group's rows, by `Library::key`. Fold(String), NewNotebook, NewICloudNotebook, @@ -45,11 +45,6 @@ pub enum Action { Unavailable(String, String), } -/// Names a notebook's or group's rows for folding them. -pub fn fold_key(library: &Library, path: &str) -> String { - format!("{}\n{path}", library.location) -} - /// What the tree's rows read and what they were asked. pub struct Tree<'a> { theme: &'a Theme, @@ -68,6 +63,8 @@ pub struct Tree<'a> { ghost: Option, /// Where the gap the dragged row would land in stands. gap: Option<[f32; 4]>, + /// The notebooks, groups and sections holding unread pages, by `Library::key`. + unread: HashSet, } /// 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 { depth: u32, dim: bool, selected: Option<[f32; 4]>, + bold: bool, } impl<'a> Tree<'a> { @@ -105,6 +103,7 @@ impl<'a> Tree<'a> { lifted: None, ghost: None, gap: None, + unread: HashSet::new(), } } } @@ -137,14 +136,13 @@ pub fn header() -> Id { Id::ROOT.child("sidebar header") } -/// The sidebar's header row, `header` tall and dragging the window when `drags`, then +/// The sidebar's header row, as tall as the tab row and dragging the window when `drags`, then /// with `rows` the tree of `notebooks` with the open section marked. The notebook button /// floats over the header's end at the window's edge, the `right` one or the left. fn sidebar( ui: &mut Ui, tree: &mut Tree, notebooks: &[Arc], - header: f32, drags: bool, rows: bool, right: bool, @@ -158,8 +156,8 @@ fn sidebar( } else { Flags::default() }, - size: [fill(), px(header)], - pad: [crate::FRAME, (header - ui::shell::TOOL) / 2.0 + DROP], + size: [fill(), px(RAIL)], + pad: [crate::FRAME, (RAIL - ui::shell::TOOL) / 2.0 + DROP], gap: 6.0, ..Spec::default() }, @@ -204,7 +202,7 @@ fn sidebar( }, ); for (index, library) in notebooks.iter().enumerate() { - let key = fold_key(library, ""); + let key = library.key(""); let unfolded = !folded.contains(&key); let (row, fold) = tree_row( ui, @@ -222,6 +220,7 @@ fn sidebar( fold: Some(unfolded), selected: None, renamed: false, + bold: tree.unread.contains(&key), }, ); let unsigned = library.notebook.is_err() @@ -281,6 +280,7 @@ fn sidebar( fold: None, selected: None, renamed: false, + bold: false, }, ); if row.clicked { @@ -315,6 +315,12 @@ fn folder( *color, true, ), + // Opens on its locked page, which unlocks it. + SectionState::Locked => ( + crate::library::section_name(§ion.path, &None), + None, + true, + ), _ => ( crate::library::section_name(§ion.path, &None), None, @@ -332,6 +338,7 @@ fn folder( fold: None, selected: (open == Some(section.path.as_str())).then_some(section_color(color)), renamed: renaming, + bold: tree.unread.contains(&library.key(§ion.path)), }; if lifts(tree, notebook, §ion.path, &row) { continue; @@ -357,7 +364,7 @@ fn folder( if crate::library::recycle_bin(&group.path) { continue; } - let key = fold_key(library, &group.path); + let key = library.key(&group.path); let unfolded = !tree.folded.contains(&key); let name = group.path.rsplit('/').next().unwrap_or_default(); gap(ui, tree, &folder.path, Some(&group.path)); @@ -371,6 +378,7 @@ fn folder( fold: Some(unfolded), selected: None, renamed: renaming, + bold: tree.unread.contains(&key), }; // A dragged group's rows fold away beneath it. let lifted = lifts(tree, notebook, &group.path, &row); @@ -425,6 +433,7 @@ fn folder( fold: None, selected: None, renamed: false, + bold: false, }, ); if row.clicked { @@ -499,6 +508,7 @@ fn lifts(tree: &mut Tree, notebook: usize, path: &str, row: &Row) -> bool { depth: row.depth, dim: row.dim, selected: row.selected, + bold: row.bold, }); } lifted @@ -594,6 +604,8 @@ struct Row<'a> { selected: Option<[f32; 4]>, /// The row is being renamed: its label is the tree's rename field. renamed: bool, + /// It holds unread pages, which OneNote sets bold. + bold: bool, } /// 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) { Spec { size: [fill(), px(ROW)], text: Some(row.label), + bold: row.bold, color: Some(color), ..Spec::default() }, @@ -729,15 +742,22 @@ impl crate::State { }, ); crate::name(&mut self.ui, rows_id, "Notebooks"); - let open = self.session.as_ref().and_then(|session| { + let shown = match (&self.session, &self.locked) { + (Some(session), _) => Some((&session.library, session.tabs[session.tab].path.as_str())), + (None, Some(locked)) => Some((&locked.library, locked.path.as_str())), + (None, None) => None, + }; + let open = shown.and_then(|(shown, path)| { let index = self .notebooks .iter() - .position(|library| Arc::ptr_eq(library, &session.library))?; - Some((index, session.tabs[session.tab].path.as_str())) + .position(|library| Arc::ptr_eq(library, shown))?; + Some((index, path)) }); let drags = self.chrome_drags(); + let unread = self.unread_keys(); let mut tree = Tree::new(theme, open, &self.folded, self.renaming.as_mut()); + tree.unread = unread; tree.lifted = self .drag .as_ref() @@ -750,7 +770,6 @@ impl crate::State { &mut self.ui, &mut tree, &self.notebooks, - crate::TAB_ROW, drags, width > 0.5, self.navigation_bar_right, @@ -800,9 +819,6 @@ impl crate::State { } } - /// The sidebar's button in its square at the window's edge, and the room the section - /// tabs, standing `width` from that edge, leave it; the button stays put as the sidebar - /// eases open and shut. /// The notebook button, floating at the body's corner over the section tabs' row, `height` /// tall as it eases, or over the sidebar's header while that is open. pub(crate) fn sidebar_button(&mut self, theme: &Theme, height: f32) { @@ -903,6 +919,7 @@ impl crate::State { fold: None, selected: ghost.selected, renamed: false, + bold: ghost.bold, }, ); self.ui.close(); @@ -1049,9 +1066,10 @@ impl crate::State { self.commands .push(crate::Command::OpenSection(library, path)); } + crate::library::Located::Package(package) => self.open_unpack(&package), crate::library::Located::Nothing => crate::platform::alert( "Couldn't open", - "Choose a notebook folder, its Open Notebook file, or a section file.", + "Choose a notebook folder, its Open Notebook file, a section file or a package.", ), } } diff --git a/crates/snowbound/src/spell_linux.rs b/crates/snowbound/src/spell_linux.rs index 5fc3258b0760952b44b28efdf7838f879b476619..39caba7a0db709c19913d570d5094ea53784ff33 100644 --- a/crates/snowbound/src/spell_linux.rs +++ b/crates/snowbound/src/spell_linux.rs @@ -1,6 +1,7 @@ //! Spelling through Enchant, loaded at run time with whichever dictionaries its providers //! (Hunspell, Nuspell, Aspell) have. Without Enchant, words go unmarked. +use crate::platform::symbol; use canvas::spelling::{Dictionary, pick}; use std::collections::HashMap; use std::ffi::{CStr, CString, c_char, c_int, c_void}; @@ -98,12 +99,6 @@ impl Dictionary for Checker { } } -/// The function `name` in `library`, of C signature `F`. -unsafe fn symbol(library: *mut c_void, name: &CStr) -> Option { - let symbol = unsafe { libc::dlsym(library, name.as_ptr()) }; - (!symbol.is_null()).then(|| unsafe { std::mem::transmute_copy::<*mut c_void, F>(&symbol) }) -} - unsafe extern "C" fn describe( tag: *const c_char, _: *const c_char, diff --git a/crates/snowbound/src/surface_gl.rs b/crates/snowbound/src/surface_gl.rs index ef60f62dbcb27a4548003990a8ddeb6465706a04..f1a2e0105ef1a10290ca04606cb4fa45df1adc39 100644 --- a/crates/snowbound/src/surface_gl.rs +++ b/crates/snowbound/src/surface_gl.rs @@ -92,36 +92,36 @@ impl Surface { } /// The window's next frame, or none to skip this one. - pub fn frame(&mut self, _: &Renderer) -> Result, Box> { + pub fn frame(&mut self, renderer: &Renderer) -> Result, Box> { if self.size.contains(&0) { return Ok(None); } let target = match self.target.take() { Some(target) if target.size() == self.size => target, - _ => Target::new(self.size)?, + _ => renderer.target(self.size)?, }; Ok(Some(Frame { target })) } pub fn present(&mut self, renderer: &Renderer, frame: Frame) { - if self.translucent { - renderer.present_translucent(&frame.target); - } else { - frame.target.present(); - } + renderer.present(&frame.target, self.translucent); let _: () = unsafe { msg_send![&self.context, flushBuffer] }; self.target = Some(frame.target); } /// A target the size of the window's frames that `read` reads back. - pub fn offscreen(&self, _: &Renderer) -> Result> { + pub fn offscreen(&self, renderer: &Renderer) -> Result> { Ok(Frame { - target: Target::new(self.size)?, + target: renderer.target(self.size)?, }) } /// The offscreen target's sRGB RGBA rows, top first. - pub fn read(&self, _: &Renderer, offscreen: Offscreen) -> Result, Box> { - Ok(offscreen.target.read_pixels()) + pub fn read( + &self, + renderer: &Renderer, + offscreen: Offscreen, + ) -> Result, Box> { + Ok(renderer.read_pixels(&offscreen.target)?) } } diff --git a/crates/snowbound/src/surface_windows.rs b/crates/snowbound/src/surface_windows.rs index 9a5633e59ab095921a4b1ba3c1115b4b6a1b9fee..5bd9c2e615ee42a438aad0dae4e436e90946dfe8 100644 --- a/crates/snowbound/src/surface_windows.rs +++ b/crates/snowbound/src/surface_windows.rs @@ -1,11 +1,13 @@ -//! Frames reach the window through Direct3D 12 where Windows has it (10 and 11), and -//! otherwise, as on Windows 7, through OpenGL 2.1 on a WGL context: frames draw into an sRGB -//! target, copied to the window's back buffer and swapped. +//! Frames reach the window through Direct3D 12 where Windows has it (10 and 11), otherwise +//! through Direct3D 11, as on Windows 7, and failing both through OpenGL 2.1 on a WGL +//! context. The last two draw into an sRGB target, copied to the window's back buffer. +//! `SNOWBOUND_RENDERER` set to `d3d11` or `gl` picks one of those, and to anything else +//! insists on Direct3D 12. #[path = "surface.rs"] mod webgpu; -use draw::{GlTarget, Renderer, Target}; +use draw::{Renderer, Target}; use std::{error::Error, sync::Arc}; use windows_sys::Win32::{ Foundation::HWND, @@ -27,13 +29,14 @@ pub struct Surface { enum Backend { Wgpu(Box), - Gl { - /// The window's device context, which the context draws to. - device: HDC, + /// Direct3D 11 or OpenGL, which present the renderer's own targets. + Native { + /// For OpenGL, the window's device context, which the context draws to. + opengl: Option, /// Frames leave the desktop's glass showing through their transparent pixels. translucent: bool, /// The frame target, kept between frames. - target: Option, + target: Option, }, } @@ -57,22 +60,42 @@ impl Surface { backdrop: bool, ) -> Result<(Self, Renderer), Box> { let forced = std::env::var("SNOWBOUND_RENDERER").ok(); - if forced.as_deref() != Some("gl") { + let forced = forced.as_deref(); + let size = window.inner_size(); + let size = [size.width, size.height]; + let native = |opengl, translucent, renderer| { + let backend = Backend::Native { + opengl, + translucent, + target: None, + }; + Ok((Self { size, backend }, renderer)) + }; + if !matches!(forced, Some("d3d11" | "gl")) { match webgpu::Surface::new(window.clone(), backdrop).await { Ok((surface, renderer)) => { let backend = Backend::Wgpu(Box::new(surface)); - let size = window.inner_size(); - let size = [size.width, size.height]; return Ok((Self { size, backend }, renderer)); } Err(error) if forced.is_some() => return Err(error), - Err(error) => eprintln!("No Direct3D 12 ({error}); drawing with OpenGL"), + Err(error) => eprintln!("No Direct3D 12 ({error})"), } } let RawWindowHandle::Win32(handle) = window.window_handle()?.as_raw() else { unreachable!() }; - let device = unsafe { GetDC(handle.hwnd.get() as HWND) }; + let hwnd = handle.hwnd.get() as HWND; + if forced != Some("gl") { + match Renderer::direct3d11(hwnd) { + Ok((renderer, adapter)) => { + eprintln!("Canvas GPU: Direct3D 11 ({adapter})"); + return native(None, backdrop, renderer); + } + Err(error) if forced.is_some() => return Err(error.into()), + Err(error) => eprintln!("No Direct3D 11 ({error}); drawing with OpenGL"), + } + } + let device = unsafe { GetDC(hwnd) }; let request = |alpha| gl::PIXELFORMATDESCRIPTOR { nSize: size_of::() as u16, nVersion: 1, @@ -147,25 +170,14 @@ impl Surface { the driver from your graphics card's maker. ({error})" ) })?; - let size = window.inner_size(); - Ok(( - Self { - size: [size.width, size.height], - backend: Backend::Gl { - device, - translucent, - target: None, - }, - }, - renderer, - )) + native(Some(device), translucent, renderer) } /// Whether frames leave the system's backdrop showing. pub fn translucent(&self) -> bool { match &self.backend { Backend::Wgpu(surface) => surface.translucent(), - Backend::Gl { translucent, .. } => *translucent, + Backend::Native { translucent, .. } => *translucent, } } @@ -189,13 +201,13 @@ impl Surface { texture: Some((frame.texture, frame.reconfigure)), })) } - Backend::Gl { target, .. } => { + Backend::Native { target, .. } => { let target = match target.take() { Some(target) if target.size() == self.size => target, - _ => GlTarget::new(self.size)?, + _ => renderer.target(self.size)?, }; Ok(Some(Frame { - target: target.into(), + target, texture: None, })) } @@ -213,20 +225,18 @@ impl Surface { surface.present(renderer, frame); } ( - Backend::Gl { - device, + Backend::Native { + opengl, translucent, target: kept, }, - Target::Gl(target), + target, None, ) => { - if *translucent { - renderer.present_translucent(&target); - } else { - target.present(); + renderer.present(&target, *translucent); + if let Some(device) = opengl { + unsafe { gl::SwapBuffers(*device) }; } - unsafe { gl::SwapBuffers(*device) }; *kept = Some(target); } _ => unreachable!("Frames come from the surface's own backend"), @@ -244,8 +254,8 @@ impl Surface { texture: Some(offscreen.texture), }) } - Backend::Gl { .. } => Ok(Offscreen { - target: GlTarget::new(self.size)?.into(), + Backend::Native { .. } => Ok(Offscreen { + target: renderer.target(self.size)?, texture: None, }), } @@ -261,7 +271,7 @@ impl Surface { (Backend::Wgpu(surface), target, Some(texture)) => { surface.read(renderer, webgpu::Offscreen { target, texture }) } - (Backend::Gl { .. }, Target::Gl(target), None) => Ok(target.read_pixels()), + (Backend::Native { .. }, target, None) => Ok(renderer.read_pixels(&target)?), _ => unreachable!("Snapshots come from the surface's own backend"), } } diff --git a/crates/snowbound/src/sync.rs b/crates/snowbound/src/sync.rs index 80b9597377f5e7b315056d745e28cc00b770f89c..ead43de22709b19b0129ea75e550977b44fb3123 100644 --- a/crates/snowbound/src/sync.rs +++ b/crates/snowbound/src/sync.rs @@ -39,7 +39,7 @@ fn describe( quiet: bool, ) -> (&'static str, &'static [&'static str]) { if offline { - return ("Working offline", art::SYNC_OFFLINE); + return ("Offline", art::SYNC_OFFLINE); } match sync.state() { SyncState::ReadOnly => ("Can’t save changes", art::SYNC_ERROR), @@ -78,14 +78,6 @@ fn brief(sync: &SyncStatus, offline: bool) -> String { /// out the recycle bin, unless something is wrong there. fn sections(library: &Library, session: &Session) -> Vec<(String, SyncStatus)> { let open = &session.tabs[session.tab].path; - let copy = |sync: &SyncStatus| SyncStatus { - synced: sync.synced, - error: sync - .error - .as_ref() - .map(|error| std::io::Error::new(error.kind(), error.to_string())), - queued: sync.queued, - }; let mut sections = library .background .as_ref() @@ -120,13 +112,21 @@ fn overall(sections: &[(String, SyncStatus)]) -> SyncStatus { .map(|(_, sync)| sync.synced) .collect::>>() .and_then(|times| times.into_iter().min()), - error: worst - .and_then(|sync| sync.error.as_ref()) - .map(|error| std::io::Error::new(error.kind(), error.to_string())), + error: worst.and_then(|sync| copy(sync).error), queued: sections.iter().map(|(_, sync)| sync.queued).sum(), } } +/// `sync` again; its error, which can't be cloned, as one of the same kind and message. +fn copy(sync: &SyncStatus) -> SyncStatus { + SyncStatus { + synced: sync.synced, + error: (sync.error.as_ref()) + .map(|error| std::io::Error::new(error.kind(), error.to_string())), + queued: sync.queued, + } +} + fn changes(count: u64) -> String { match count { 1 => "1 change".to_owned(), @@ -175,8 +175,8 @@ const TITLE: &str = "Notebook Sync Status"; fn update_note(update: &update::Status) -> Option { match update { update::Status::Downloading(version) => Some(format!("Downloading {version}…")), - update::Status::Ready(version, _) => Some(format!("{version} is ready")), - update::Status::Available(version) => Some(format!("{version} is available")), + update::Status::Ready(version, ..) => Some(format!("{version} is ready")), + update::Status::Available(version, ..) => Some(format!("{version} is available")), _ => None, } } @@ -209,7 +209,7 @@ pub(crate) fn control(ui: &mut Ui, session: &Session, update: &update::Status, t ); let waiting = matches!( update, - update::Status::Ready(..) | update::Status::Available(_) + update::Status::Ready(..) | update::Status::Available(..) ); if waiting { ui.leaf( @@ -259,6 +259,8 @@ struct Facts<'a> { conflicts: Vec<(ExGuid, &'a str)>, offline: bool, update: &'a update::Status, + /// Whether the update's changes are listed. + changes_listed: bool, /// Whether the open section's file can be shown in the file manager. local: bool, } @@ -273,6 +275,126 @@ struct Picked { conflict: Option, build_folder: bool, restart: bool, + list_changes: bool, +} + +/// What a newer build changes: `summary`, which unfolds the titles under their kinds when +/// `listed`. Returns whether the summary was clicked. +fn changes_list(ui: &mut Ui, summary: &str, changes: &[update::Change], listed: bool) -> bool { + let theme = ui.theme.clone(); + let line = theme.font_size * 1.6; + let toggle = ui.open( + "summary", + Spec { + flags: Flags::CLICKABLE, + size: [fill(), px(line)], + gap: 4.0, + role: Some(accesskit::Role::Button), + ..Spec::default() + }, + ); + if let Some(node) = ui.access(toggle) { + node.set_label(summary); + node.set_expanded(listed); + } + ui.leaf( + "label", + Spec { + size: [fit(), px(line)], + text: Some(summary), + overflow: Overflow::Ellipsis, + ..Spec::default() + }, + ); + ui.leaf( + "chevron", + Spec { + size: [fit(), px(line)], + icon: Some(if listed { + art::CHEVRON_UP + } else { + ui::shell::CHEVRON + }), + color: Some(theme.text_dim), + ..Spec::default() + }, + ); + ui.close(); + // Unfolds to the titles' height, scrolling past a few dozen lines. + let list = ui.id("changes"); + let rows = ui + .rect(list.child("rows")) + .map_or(0.0, |rect| rect[3] - rect[1]); + let height = ui.animate(list, if listed { rows.min(line * 9.0) } else { 0.0 }); + if listed || height > 0.0 { + ui.open_as( + list, + Spec { + flags: Flags::SCROLL | Flags::CLIP, + axis: Axis::Y, + size: [fill(), px(height)], + role: Some(accesskit::Role::List), + ..Spec::default() + }, + ); + ui.open( + "rows", + Spec { + axis: Axis::Y, + size: [fill(), children()], + pad: [0.0, 2.0], + gap: 2.0, + ..Spec::default() + }, + ); + let mut kind = None; + for (index, change) in changes.iter().enumerate() { + if kind != Some(change.kind) { + kind = Some(change.kind); + ui.leaf( + ("heading", index), + Spec { + size: [fill(), px(line)], + text: Some(change.kind.heading()), + color: Some(theme.text_dim), + bold: true, + ..Spec::default() + }, + ); + } + ui.open( + ("change", index), + Spec { + size: [fill(), children()], + gap: 6.0, + role: Some(accesskit::Role::ListItem), + ..Spec::default() + }, + ); + ui.leaf( + "bullet", + Spec { + size: [fit(), fit()], + text: Some("•"), + color: Some(theme.text_dim), + ..Spec::default() + }, + ); + ui.leaf( + "title", + Spec { + size: [fill(), fit()], + text: Some(&change.title), + overflow: Overflow::Wrap, + ..Spec::default() + }, + ); + ui.close(); + } + ui.close(); + ui.close(); + } + ui.signal(toggle).clicked } /// Lays out the open popup's contents. @@ -320,10 +442,7 @@ fn build(ui: &mut Ui, facts: &Facts) -> Picked { let waiting = changes(sync.queued); let conflict; let advice = if facts.offline { - Some(match sync.queued { - 0 => "Turn off Work offline to sync.".to_owned(), - _ => format!("{waiting} will sync when you go back online."), - }) + None } else { match state { SyncState::NotConnected => Some(match sync.queued { @@ -354,7 +473,9 @@ fn build(ui: &mut Ui, facts: &Facts) -> Picked { icon = art::SYNC_WARNING; } - // The status: headline, when it last synced or what to do, and a running sync's bar. + // The status: headline and Work offline's switch, what to do, then one line of when it + // last synced or how a sync runs, over a running sync's bar. Its height holds as Work + // offline turns on and off. ui.open( "status", Spec { @@ -367,10 +488,19 @@ fn build(ui: &mut Ui, facts: &Facts) -> Picked { if let Some(node) = ui.access(ui.current()) { node.set_live(accesskit::Live::Polite); } + let height = theme.font_size * 2.0; + ui.open( + "header", + Spec { + size: [fill(), px(height)], + gap: 8.0, + ..Spec::default() + }, + ); ui.leaf( "headline", Spec { - size: [fill(), px(theme.font_size * 2.0)], + size: [fill(), px(height)], text: Some(headline), icon: Some(icon), font_size: Some(theme.font_size * 1.2), @@ -379,12 +509,118 @@ fn build(ui: &mut Ui, facts: &Facts) -> Picked { ..Spec::default() }, ); + let switch = ui.open( + "offline", + Spec { + flags: Flags::CLICKABLE, + size: [children(), px(height)], + gap: 8.0, + role: Some(accesskit::Role::Switch), + ..Spec::default() + }, + ); + if let Some(node) = ui.access(switch) { + node.set_label("Work offline"); + node.set_toggled(facts.offline.into()); + } + let signal = ui.signal(switch); + picked.offline = signal.clicked; + ui.leaf( + "label", + Spec { + size: [fit(), px(height)], + text: Some("Work offline"), + color: Some(theme.text_dim), + ..Spec::default() + }, + ); + let [width, side] = [30.0, 18.0]; + let on = ui.animate(switch.child("on"), f32::from(u8::from(facts.offline))); + let mix = |from: [f32; 4], to: [f32; 4]| { + std::array::from_fn(|at| from[at] + (to[at] - from[at]) * on) + }; + ui.open( + "track", + Spec { + size: [px(width), px(height)], + inset: [0.0, (height - side) / 2.0, 0.0, (height - side) / 2.0], + fill: Some(mix(theme.chip, theme.accent)), + border: signal.hovered.then_some(theme.accent), + radius: side / 2.0, + ..Spec::default() + }, + ); + let knob = side - 4.0; + let left = 2.0 + (width - side) * on; + let top = (height - side) / 2.0 + 2.0; + ui.mark([left, top, left + knob, top + knob], [1.0; 4], knob / 2.0); + ui.close(); + ui.close(); + ui.close(); + if let Some(advice) = &advice { + text(ui, "advice", advice, theme.text); + } else if !facts.conflicts.is_empty() && !showing { + text( + ui, + "advice", + "Both versions are kept. Open the page to compare them.", + theme.text, + ); + } + let fraction = match total { + 0 => 1.0, + _ if state == SyncState::UpToDate => 1.0, + _ => done as f32 / total as f32, + }; + let subtitle = if showing { + let mut caption = match total { + 0 | 1 => String::new(), + _ => format!("{done} of {total} sections synced"), + }; + if sync.queued > 0 { + if !caption.is_empty() { + caption += " · "; + } + caption += &format!("{waiting} to send"); + } + caption + } else if facts.offline && sync.queued > 0 { + format!("{waiting} waiting") + } else if let Some(synced) = sync.synced { + ui.wake_after(Duration::from_secs(30)); + let prefix = match advice.is_some() || facts.offline { + true => "Last synced", + false => "Synced", + }; + format!("{prefix} {}", ago(synced)) + } else { + String::new() + }; + let subtitle = match subtitle.is_empty() { + true => "Checking for changes…", + false => &subtitle, + }; + ui.leaf( + "subtitle", + Spec { + size: [fill(), px(theme.font_size * 1.5)], + text: Some(subtitle), + color: Some(theme.text_dim), + overflow: Overflow::Ellipsis, + ..Spec::default() + }, + ); + ui.close(); + + // A running sync's bar takes the rule's place, which keeps the bar's height. + ui.open( + "progress", + Spec { + size: [fill(), px(6.0)], + ..Spec::default() + }, + ); if showing { - let fraction = match total { - 0 => 1.0, - _ if state == SyncState::UpToDate => 1.0, - _ => done as f32 / total as f32, - }; let fraction = ui.animate(bar.child("fraction"), fraction); let running = ui.lasted(bar.child("running"), true).as_secs_f32(); ui.open_as( @@ -419,48 +655,18 @@ fn build(ui: &mut Ui, facts: &Facts) -> Picked { node.set_numeric_value(f64::from((fraction * 100.0).round())); } ui.close(); - let mut caption = match total { - 0 | 1 => String::new(), - _ => format!("{done} of {total} sections synced"), - }; - if sync.queued > 0 { - if !caption.is_empty() { - caption += " · "; - } - caption += &format!("{waiting} to send"); - } - if caption.is_empty() { - caption = "Checking for changes…".to_owned(); - } - text(ui, "caption", &caption, theme.text_dim); } else { - if let Some(advice) = &advice { - text(ui, "advice", advice, theme.text); - } else if !facts.conflicts.is_empty() && headline != "Syncing…" { - text( - ui, - "advice", - "Both versions are kept. Open the page to compare them.", - theme.text, - ); - } - if let Some(synced) = sync.synced { - let prefix = match advice { - Some(_) => "Last synced", - None => "Synced", - }; - text( - ui, - "synced", - &format!("{prefix} {}", ago(synced)), - theme.text_dim, - ); - ui.wake_after(Duration::from_secs(30)); - } + ui.leaf( + "rule", + Spec { + size: [fill(), px(6.0)], + inset: [0.0, 2.5, 0.0, 2.5], + fill: Some(theme.chip), + ..Spec::default() + }, + ); } ui.close(); - - rule(ui, "identity-rule"); // Which notebook, and where it lives; the full location in the tooltip. ui.open( "identity", @@ -528,9 +734,7 @@ fn build(ui: &mut Ui, facts: &Facts) -> Picked { ); // Where the whole notebook can't be reached, the headline says so for each section // without changes waiting. - let unreached = |sync: &SyncStatus| { - !facts.offline && sync.state() == SyncState::NotConnected && sync.queued == 0 - }; + let unreached = |sync: &SyncStatus| sync.state() == SyncState::NotConnected && sync.queued == 0; let behind: Vec<_> = facts .sections .iter() @@ -650,17 +854,32 @@ fn build(ui: &mut Ui, facts: &Facts) -> Picked { ui.open( "update", Spec { + axis: Axis::Y, size: [fill(), children()], + gap: 4.0, + ..Spec::default() + }, + ); + text(ui, "note", &format!("Snowbound {note}"), theme.text); + if let update::Status::Ready(_, _, changes) | update::Status::Available(_, changes) = + facts.update + && let Some(summary) = update::summary(changes) + { + picked.list_changes = changes_list(ui, &summary, changes, facts.changes_listed); + } + ui.open( + "actions", + Spec { + size: [fill(), children()], + pad: [0.0, 4.0], gap: 8.0, ..Spec::default() }, ); ui.leaf( - "note", + "space", Spec { - size: [fill(), px(theme.font_size * 2.0)], - text: Some(&format!("Snowbound {note}")), - overflow: Overflow::Ellipsis, + size: [fill(), px(1.0)], ..Spec::default() }, ); @@ -669,6 +888,7 @@ fn build(ui: &mut Ui, facts: &Facts) -> Picked { picked.restart = ui::button(ui, "restart", "Restart to Update").clicked; } ui.close(); + ui.close(); } ui.open( @@ -680,7 +900,6 @@ fn build(ui: &mut Ui, facts: &Facts) -> Picked { ..Spec::default() }, ); - picked.offline = ui::check_box(ui, "offline", "Work offline", facts.offline).clicked; ui.leaf( "space", Spec { @@ -688,30 +907,32 @@ fn build(ui: &mut Ui, facts: &Facts) -> Picked { ..Spec::default() }, ); - picked.show_file = match facts.local { - true => ui::button(ui, "show-file", platform::SHOW_FILE).clicked, - false => { - ui.leaf( - "show-file", - Spec { - size: [fit(), px(theme.font_size * 2.0)], - text: Some(platform::SHOW_FILE), - color: Some(theme.text_dim), - fill: Some(theme.chip), - radius: 4.0, - pad: [theme.font_size * 0.75, 0.0], - center: true, - role: Some(accesskit::Role::Button), - ..Spec::default() - }, - ); - if let Some(node) = ui.access(ui.id("show-file")) { - node.set_disabled(); - } - false + // A button that can't act now, dimmed. + let button = |ui: &mut Ui, part: &str, label: &str, enabled: bool| { + if enabled { + return ui::button(ui, part, label).clicked; } + ui.leaf( + part, + Spec { + size: [fit(), px(theme.font_size * 2.0)], + text: Some(label), + color: Some(theme.text_dim), + fill: Some(theme.chip), + radius: 4.0, + pad: [theme.font_size * 0.75, 0.0], + center: true, + role: Some(accesskit::Role::Button), + ..Spec::default() + }, + ); + if let Some(node) = ui.access(ui.id(part)) { + node.set_disabled(); + } + false }; - picked.sync_now = ui::button(ui, "sync-now", "Sync Now").clicked; + picked.show_file = button(ui, "show-file", platform::SHOW_FILE, facts.local); + picked.sync_now = button(ui, "sync-now", "Sync Now", !facts.offline); ui.close(); picked } @@ -817,6 +1038,7 @@ impl State { .collect(), offline, update: &update, + changes_listed: self.updates.changes_listed, local: file.is_some(), }; open(ui, anchor); @@ -852,13 +1074,14 @@ impl State { } match update { update::Status::Downloading(version) - | update::Status::Ready(version, _) - | update::Status::Available(version) + | update::Status::Ready(version, ..) + | update::Status::Available(version, ..) if picked.build_folder => { update::show_build(&version) } _ if picked.restart => self.restart_to_update(), + _ if picked.list_changes => self.updates.changes_listed ^= true, _ => {} } Ok(()) @@ -915,6 +1138,7 @@ mod tests { conflicts: Vec::new(), offline: false, update: &IDLE, + changes_listed: false, local: true, } } @@ -1110,16 +1334,11 @@ mod tests { assert!(!bar(&ui)); } - #[cfg(all(feature = "wgpu", not(windows)))] - #[test] - fn popup_renders_each_state() { - let output = std::env::var_os("SNOWBOUND_SYNC_RENDER").map(std::path::PathBuf::from); - let mut gpu = output.as_ref().map(|output| { - std::fs::create_dir_all(output).unwrap(); - Gpu::new() - }); - type Made = fn() -> Facts<'static>; - let states: Vec<(&str, Made)> = vec![ + type Made = fn() -> Facts<'static>; + + /// Each state the popup shows, by name. + fn states() -> Vec<(&'static str, Made)> { + vec![ ("up-to-date", || facts(sections(|_| status(true, 0, None)))), ("syncing", || { facts(sections(|index| match index { @@ -1178,7 +1397,71 @@ mod tests { place: vec!["iCloud Drive".into(), "Notes".into()], ..facts(sections(|_| status(true, 0, None))) }), - ]; + ("update", || Facts { + update: ready(), + ..facts(sections(|_| status(true, 0, None))) + }), + ("update-listed", || Facts { + update: ready(), + changes_listed: true, + ..facts(sections(|_| status(true, 0, None))) + }), + ] + } + + /// A staged build that brings a little of each kind. + fn ready() -> &'static update::Status { + let changes = serde_json::from_value(serde_json::json!([ + {"version": "2026-10-01-r3", "kind": "feature", "title": "Styles and themes"}, + {"version": "2026-10-01-r3", "kind": "feature", "title": "Settings redesign with search"}, + {"version": "2026-10-01-r3", "kind": "feature", "title": "Undo across pages"}, + {"version": "2026-10-01-r4", "kind": "fix", "title": "Section copies keep their own TOC entries"}, + {"version": "2026-10-01-r4", "kind": "fix", "title": "Pasted pictures keep the size OneNote 2010 gives them"}, + {"version": "2026-10-01-r5", "kind": "other", "title": "Drop leftover debug output"}, + ])) + .unwrap(); + Box::leak(Box::new(update::Status::Ready( + update::Version::parse("2026-10-01-r5").unwrap(), + std::path::PathBuf::new(), + changes, + ))) + } + + /// Work offline's switch stays put as it turns on and off, and so do the buttons below + /// but where a problem's advice, which offline leaves out, goes and comes back. + #[test] + fn toggling_offline_moves_nothing() { + let switch = id().child("status").child("header").child("offline"); + let controls = id().child("controls"); + for (name, made) in states() { + let advised = !matches!( + overall(&made().sections).state(), + SyncState::UpToDate | SyncState::Syncing + ); + let mut places = Vec::new(); + for offline in [false, true, false] { + let mut ui = ui(Appearance::Light); + let mut now = Instant::now(); + let facts = Facts { offline, ..made() }; + settle(&mut ui, &mut now, &facts); + places.push((ui.rect(switch).unwrap(), ui.rect(controls).unwrap())); + } + for pair in places.windows(2) { + assert_eq!(pair[0].0, pair[1].0, "{name}"); + assert!(advised || pair[0].1 == pair[1].1, "{name}: {places:?}"); + } + } + } + + #[cfg(all(feature = "wgpu", not(windows)))] + #[test] + fn popup_renders_each_state() { + let output = std::env::var_os("SNOWBOUND_SYNC_RENDER").map(std::path::PathBuf::from); + let mut gpu = output.as_ref().map(|output| { + std::fs::create_dir_all(output).unwrap(); + Gpu::new() + }); + let states = states(); for (appearance, theme) in [(Appearance::Light, "light"), (Appearance::Dark, "dark")] { for (name, facts) in &states { let mut ui = ui(appearance); @@ -1226,6 +1509,32 @@ mod tests { shot += 1; } } + // Work offline on, an edit waiting, then off: the edit sends and nothing moves. + let frames = output.join(format!("toggle-{theme}")); + std::fs::create_dir_all(&frames).unwrap(); + let mut ui = self::ui(appearance); + let mut now = Instant::now(); + settle( + &mut ui, + &mut now, + &facts(sections(|_| status(true, 0, None))), + ); + for tick in 0..360 { + let seconds = tick as f32 / 60.0; + let queued = u64::from((2.0..4.6).contains(&seconds)) * 2; + let facts = Facts { + offline: (1.0..3.2).contains(&seconds), + ..facts(sections(|index| { + status(true, u64::from(index == 1) * queued, None) + })) + }; + now += Duration::from_micros(16_667); + frame(&mut ui, now, &facts); + if tick % 2 == 0 { + let path = frames.join(format!("{:03}.png", tick / 2)); + gpu.save(&ui, &path, Some(360.0)); + } + } } } } diff --git a/crates/snowbound/src/tags.rs b/crates/snowbound/src/tags.rs index c3a6f15c50048b858e990702b48058773e739a8d..fe2b3e2745f8fcaf0500ddf8d622db711c92a9bd 100644 --- a/crates/snowbound/src/tags.rs +++ b/crates/snowbound/src/tags.rs @@ -345,26 +345,6 @@ fn picker( Anchor::Below(ui.rect(button).unwrap_or_default()) } -/// The trailing row of a dialog: its buttons at the right. -fn buttons(ui: &mut Ui) { - ui.open( - "buttons", - Spec { - size: [fill(), children()], - pad: [0.0, 8.0], - gap: 8.0, - ..Spec::default() - }, - ); - ui.leaf( - "space", - Spec { - size: [fill(), px(1.0)], - ..Spec::default() - }, - ); -} - /// The frame of a dialog titled `title`, centred in the window. fn dialog(ui: &mut Ui, id: Id, title: &str, width: f32) { let theme = ui.theme.clone(); @@ -545,7 +525,7 @@ impl State { .flatten() }); ui.close(); - buttons(ui); + crate::buttons(ui); let ok = button(ui, "ok", "OK", list.tags != self.tags); let cancel = ui::button(ui, "cancel", "Cancel").clicked; ui.close(); @@ -880,7 +860,7 @@ fn tag_editor( ..Spec::default() }, ); - buttons(ui); + crate::buttons(ui); let named = !tag.label.trim().is_empty(); let ok = button(ui, "ok", "OK", named) || entered && named; let cancel = ui::button(ui, "cancel", "Cancel").clicked; diff --git a/crates/snowbound/src/themes.rs b/crates/snowbound/src/themes.rs index 88a2a1cb6879b40958f080b019101a82bb6bda18..94d5d6d81592bd3ae8b26e6d3e78290748afc973 100644 --- a/crates/snowbound/src/themes.rs +++ b/crates/snowbound/src/themes.rs @@ -156,22 +156,7 @@ impl State { return Ok(()); } self.persist()?; - let Some(session) = &self.session else { - return Ok(()); - }; - let space = session.space; - session.section.apply( - &self.author, - onestore::op::Edit { - at: filetime(), - ops: ops - .into_iter() - .map(|op| onestore::op::Op::Page { space, op }) - .collect(), - }, - )?; - self.edited(vec![space]); - self.refresh() + self.edit_page(ops) } /// Opens the Themes dialog choosing a theme for `scope` of the open page. diff --git a/crates/snowbound/src/undo.rs b/crates/snowbound/src/undo.rs index 912c47b8da9f57d01a4754948cf29d2f79a6c2c4..8e6f22084509255f261e4135b5e16945b5c3e219 100644 --- a/crates/snowbound/src/undo.rs +++ b/crates/snowbound/src/undo.rs @@ -3,6 +3,7 @@ //! window keeps what was done to pages and sections between those edits, so Undo takes back //! whichever came last where the user is. Every step taken back is an edit of its own. +use crate::library::{entry_name, folders}; use crate::{Command, Library, Loaded, State, UserEvent}; use canvas::editor::CanvasEditor; use notebook::{Replica, discover::Folder}; @@ -287,7 +288,7 @@ fn drop_newest(steps: &mut Vec, page: ExGuid, count: usize) { } /// Whether `page` holds nothing but its title, untitled. -fn blank(page: &Page) -> bool { +pub(crate) fn blank(page: &Page) -> bool { page.title.trim().is_empty() && page .objects @@ -353,6 +354,46 @@ impl Site { Ok(()) } + /// The page shown once `gone` leave `listed`, preferring `prefer`, and the page `ops` gain + /// where none stays. + fn after( + &self, + listed: &[(ExGuid, String, u32)], + gone: &[ExGuid], + prefer: Option, + ops: &mut Vec, + ) -> (ExGuid, Option) { + match neighbor(listed, gone, prefer) { + Some(show) => (show, None), + None => { + let space = self.fresh.space(); + ops.push(Op::Section(SectionOp::Create(self.fresh.clone()))); + (space, Some(space)) + } + } + } + + /// Takes out `added`, the page a section left without pages gained, in `ops` while + /// `listed` holds it and it holds nothing; the pages going. + fn take_added( + &self, + added: Option, + listed: &[(ExGuid, String, u32)], + ops: &mut Vec, + ) -> Vec { + let gone: Vec = added + .filter(|added| { + listed.iter().any(|(space, ..)| space == added) + && self.replica.page(*added).is_ok_and(|page| blank(&page)) + }) + .into_iter() + .collect(); + if !gone.is_empty() { + ops.push(Op::Section(SectionOp::Delete(gone.clone()))); + } + gone + } + /// The catalog path of the section `identity` names. fn section_path(&self, identity: [u8; 16]) -> Option { entry_of(self.library.catalog()?, identity).map(|(_, path)| path) @@ -397,14 +438,7 @@ impl Site { }) .collect(); let mut ops = vec![Op::Section(SectionOp::Delete(pages.clone()))]; - let (show, added) = match neighbor(&listed, &pages, show) { - Some(show) => (show, None), - None => { - let space = self.fresh.space(); - ops.push(Op::Section(SectionOp::Create(self.fresh.clone()))); - (space, Some(space)) - } - }; + let (show, added) = self.after(&listed, &pages, show, &mut ops); if !created && binned { let pages: Vec = kept.iter().map(|kept| kept.page.clone()).collect(); self.library.reopen()?.recycle_pages(&pages, &self.author)?; @@ -452,15 +486,7 @@ impl Site { )?]))); spaces.push(space); } - let gone: Vec = added - .filter(|added| { - at(*added).is_some() && self.replica.page(*added).is_ok_and(|p| blank(&p)) - }) - .into_iter() - .collect(); - if !gone.is_empty() { - ops.push(Op::Section(SectionOp::Delete(gone.clone()))); - } + let gone = self.take_added(added, &listed, &mut ops); self.apply(ops)?; if !created && binned { let identities: Vec<[u8; 16]> = @@ -531,14 +557,7 @@ impl Site { let before = listed[from + 1..].first().map(|(space, ..)| *space); let level = listed[from].2; let mut ops = vec![Op::Section(SectionOp::Delete(vec![page]))]; - let (show, added) = match neighbor(&listed, &[page], None) { - Some(show) => (show, None), - None => { - let space = self.fresh.space(); - ops.push(Op::Section(SectionOp::Create(self.fresh.clone()))); - (space, Some(space)) - } - }; + let (show, added) = self.after(&listed, &[page], None, &mut ops); let section = self.library.open(&path, || {})?; section.replica().apply( &self.author, @@ -590,15 +609,7 @@ impl Site { level, )?])), ]; - let gone: Vec = added - .filter(|added| { - at(*added).is_some() && self.replica.page(*added).is_ok_and(|p| blank(&p)) - }) - .into_iter() - .collect(); - if !gone.is_empty() { - ops.push(Op::Section(SectionOp::Delete(gone.clone()))); - } + let gone = self.take_added(added, &listed, &mut ops); self.apply(ops)?; section.replica().apply( &self.author, @@ -641,21 +652,10 @@ fn folder_id(folder: &Folder) -> Option<[u8; 16]> { (!folder.path.is_empty()).then_some(toc.file_id) } -/// Every folder of `catalog`, the notebook's own first. -fn folders(catalog: &Folder) -> Vec<&Folder> { - let mut all = vec![catalog]; - let mut at = 0; - while let Some(folder) = all.get(at) { - all.extend(&folder.groups); - at += 1; - } - all -} - /// The folder `identity` names, the notebook's own without one. fn folder_of(catalog: &Folder, identity: Option<[u8; 16]>) -> Option<&Folder> { match identity { - Some(_) => folders(catalog) + Some(_) => folders(catalog, |_| true) .into_iter() .find(|folder| folder_id(folder) == identity), None => Some(catalog), @@ -664,7 +664,7 @@ fn folder_of(catalog: &Folder, identity: Option<[u8; 16]>) -> Option<&Folder> { /// The section or group `identity` names: its folder and catalog path. fn entry_of(catalog: &Folder, identity: [u8; 16]) -> Option<(&Folder, String)> { - folders(catalog).into_iter().find_map(|folder| { + folders(catalog, |_| true).into_iter().find_map(|folder| { let (_, path) = entries(folder) .into_iter() .find(|(id, _)| *id == identity)?; @@ -674,18 +674,12 @@ fn entry_of(catalog: &Folder, identity: [u8; 16]) -> Option<(&Folder, String)> { /// The identity of the section or group at catalog `path`. fn identity_at(catalog: &Folder, path: &str) -> Option<[u8; 16]> { - folders(catalog).into_iter().find_map(|folder| { + folders(catalog, |_| true).into_iter().find_map(|folder| { let (id, _) = entries(folder).into_iter().find(|(_, at)| at == path)?; Some(id) }) } -/// A section or group's name, as its catalog path ends. -fn name_of(path: &str) -> &str { - let name = path.rsplit('/').next().unwrap_or(path); - name.strip_suffix(".one").unwrap_or(name) -} - /// The action taking back `change` to `library`'s sections and groups, which is about to be /// made; none for changes Undo does not take back, as OneNote 2010 takes back none. pub fn structure_undo(library: &Library, change: &crate::manage::Structure) -> Option { @@ -695,7 +689,7 @@ pub fn structure_undo(library: &Library, change: &crate::manage::Structure) -> O Structure::Rename { path, name } => Change::Rename { entry: identity_at(catalog, path)?, from: name.clone(), - to: name_of(path).to_owned(), + to: entry_name(path).to_owned(), }, Structure::Move { path, .. } => { let entry = identity_at(catalog, path)?; @@ -707,7 +701,7 @@ pub fn structure_undo(library: &Library, change: &crate::manage::Structure) -> O } } Structure::Reorder { folder, .. } => { - let folder = folders(catalog) + let folder = folders(catalog, |_| true) .into_iter() .find(|listed| listed.path == *folder)?; Change::Place { @@ -733,7 +727,7 @@ fn restructuring(library: &Library, change: &Change) -> Option<(crate::manage::S let (structure, undo) = match change { Change::Rename { entry, from, to } => { let (_, path) = entry_of(catalog, *entry)?; - if name_of(&path) != from { + if entry_name(&path) != from { return None; } ( @@ -1450,7 +1444,7 @@ mod tests { } notebook.create_group("", "Group").unwrap(); let order = |notebook: &Notebook, folder: &str| -> Vec { - let folder = folders(notebook.catalog()) + let folder = folders(notebook.catalog(), |_| true) .into_iter() .find(|listed| listed.path == folder) .unwrap(); diff --git a/crates/snowbound/src/unpack.rs b/crates/snowbound/src/unpack.rs new file mode 100644 index 0000000000000000000000000000000000000000..c33cb6f78f47122ce367ca5fdc7dee4fc6d42f14 --- /dev/null +++ b/crates/snowbound/src/unpack.rs @@ -0,0 +1,235 @@ +//! OneNote 2010's Unpack Notebook: opening a package (`.onepkg`) asks for the notebook's name, +//! colour and the folder it goes in, then writes it there (`notebook::package::unpack`) and +//! opens it. + +use crate::{State, UserEvent, art, menus::SECTION_COLORS, platform}; +use accesskit::Role; +use notebook::session::Notebook; +use std::path::{Path, PathBuf}; +use ui::{Anchor, Axis, Id, Spec, children, fill, popup::Item, px}; +use winit::keyboard::NamedKey; + +const TITLE: &str = "Unpack Notebook"; +/// The most a package may unpack to. +const LIMIT: u64 = 16 << 30; + +/// The dialog's fields while it is open. +pub struct Dialog { + package: PathBuf, + name: String, + /// A colour picked, COLORREF; none keeps the package's. + color: Option, + folder: PathBuf, +} + +fn id() -> Id { + Id::ROOT.child("unpack") +} + +fn name_field() -> Id { + id().child("name") +} + +impl State { + /// Opens Unpack Notebook on the package at `package`, named for its file and going in the + /// documents folder, as OneNote's starts in its notebooks folder. + pub(crate) fn open_unpack(&mut self, package: &Path) { + let name = package + .file_stem() + .map_or_else(String::new, |stem| stem.to_string_lossy().into_owned()); + let folder = (platform::documents_dir()) + .or_else(|| package.parent().map(Path::to_owned)) + .unwrap_or_default(); + self.unpacking = Some(Dialog { + package: package.to_owned(), + name, + color: None, + folder, + }); + self.ui.open_popup(id()); + self.ui.focus_all(name_field()); + } + + /// Builds Unpack Notebook while it is open. Create, or Enter in the name, unpacks into a + /// new folder of that name. + pub(crate) fn unpack_dialog(&mut self) { + let Some(dialog) = &mut self.unpacking else { + return; + }; + let ui = &mut self.ui; + if !ui.popup_open(id()) { + self.unpacking = None; + return; + } + let theme = ui.theme.clone(); + let row = theme.font_size * 2.0; + let entered = ui::popup::navigation(ui, &[name_field()], &[NamedKey::Enter]) + .contains(&NamedKey::Enter); + ui.open_as( + id(), + Spec { + axis: Axis::Y, + size: [px(420.0), children()], + fill: Some(theme.popup), + border: Some(theme.chip), + shadow: Some(theme.shadow), + radius: 8.0, + pad: [16.0, 12.0], + gap: 4.0, + anchor: Some(Anchor::Dialog), + role: Some(Role::Dialog), + ..Spec::default() + }, + ); + if let Some(node) = ui.access(id()) { + node.set_label(TITLE); + } + ui.leaf( + "title", + Spec { + size: [fill(), px(row)], + text: Some(TITLE), + bold: true, + role: Some(Role::Heading), + ..Spec::default() + }, + ); + let label = |ui: &mut ui::Ui, text: &str| { + ui.leaf( + text, + Spec { + size: [fill(), px(row * 0.8)], + text: Some(text), + ..Spec::default() + }, + ); + }; + label(ui, "Name:"); + ui::text_field( + ui, + name_field(), + &mut dialog.name, + "", + Spec { + size: [fill(), px(row)], + fill: Some(theme.base), + border: Some(theme.accent), + radius: 4.0, + pad: [6.0, 0.0], + ..Spec::default() + }, + ); + if let Some(node) = ui.access(name_field()) { + node.set_label("Name"); + } + label(ui, "Color:"); + let colors = id().child("colors"); + let named = (SECTION_COLORS.iter()) + .find(|(color, _)| Some(*color) == dialog.color) + .map_or("As packaged", |(_, name)| name); + let combo = ui.id("combo"); + ui::shell::combo(ui, "combo", "Color", named, 160.0, colors, true); + let items: Vec = (SECTION_COLORS.iter()) + .map(|&(color, text)| Item { + text, + icon: Some(art::SECTION), + tint: Some(theme.section(crate::section_color(Some(color))).accent), + current: Some(color) == dialog.color, + ..Item::default() + }) + .collect(); + let anchor = Anchor::Below(ui.rect(combo).unwrap_or_default()); + if let Some(index) = ui::popup::menu(ui, colors, anchor, &items, None) { + dialog.color = Some(SECTION_COLORS[index].0); + } + label(ui, "Path:"); + let folder = dialog.folder.to_string_lossy().into_owned(); + ui.leaf( + "folder", + Spec { + flags: ui::Flags::CLIP, + size: [fill(), px(row * 0.8)], + text: Some(&folder), + color: Some(theme.text_dim), + ..Spec::default() + }, + ); + let browse = ui::button(ui, "browse", "Browse…").clicked; + let root = dialog.folder.join(dialog.name.trim()); + let full = format!("Full path: {}", root.display()); + ui.leaf( + "full", + Spec { + flags: ui::Flags::CLIP, + size: [fill(), px(row * 0.8)], + text: Some(&full), + color: Some(theme.text_dim), + ..Spec::default() + }, + ); + crate::buttons(ui); + let cancel = ui::button(ui, "cancel", "Cancel").clicked; + let create = ui::button(ui, "create", "Create").clicked || entered; + ui.close(); + ui.close(); + if browse + && let Some(chosen) = + platform::pick_new(TITLE, dialog.name.trim(), "Choose", Some(&dialog.folder)) + { + if let Some(name) = chosen.file_name() { + dialog.name = name.to_string_lossy().into_owned(); + } + if let Some(parent) = chosen.parent() { + dialog.folder = parent.to_owned(); + } + } + let name = dialog.name.trim(); + let valid = + !name.is_empty() && !name.contains(['/', '\\', ':']) && name != "." && name != ".."; + if create && !valid { + return platform::alert("Couldn't unpack the notebook", "Give the notebook a name."); + } + if create && root.exists() { + return platform::alert( + "Couldn't unpack the notebook", + &format!( + "{} already exists. Choose another name or path.", + root.display() + ), + ); + } + if !create && !cancel { + return; + } + let dialog = self.unpacking.take().expect("The dialog is open"); + self.ui.close_popup(id()); + if create { + self.unpack(dialog.package, root, dialog.color); + } + } + + /// Unpacks `package` into the new folder `root` on a thread of its own, colours it + /// `color` where one was picked, and opens it. + fn unpack(&mut self, package: PathBuf, root: PathBuf, color: Option) { + let (cache, proxy) = (self.cache.clone(), self.proxy.clone()); + std::thread::spawn(move || { + let unpacked = (|| -> Result> { + let files = notebook::package::read(&std::fs::read(&package)?, LIMIT)?; + notebook::package::unpack(files, &root)?; + let location = std::path::absolute(&root)?.to_string_lossy().into_owned(); + if let Some(color) = color { + Notebook::open(&location, &cache)?.set_color(color)?; + } + Ok(location) + })() + .map_err(|error| error.to_string()); + let _ = proxy.send_event(UserEvent::Then(Box::new(move |state: &mut State| { + match unpacked { + Ok(location) => state.open_notebook(location, None), + Err(error) => platform::alert("Couldn't unpack the notebook", &error), + } + Ok(()) + }))); + }); + } +} diff --git a/crates/snowbound/src/unread.rs b/crates/snowbound/src/unread.rs new file mode 100644 index 0000000000000000000000000000000000000000..db7006f2c2b72b89e53b6c5feb698443073e9304 --- /dev/null +++ b/crates/snowbound/src/unread.rs @@ -0,0 +1,431 @@ +//! Unread changes, as OneNote 2010 shows them: a page another author changed since it was +//! last viewed is bold in the page list, and so are its section's tab and row and the groups +//! and notebook holding it, until the page is viewed and left or marked as read. OneNote keeps +//! this, and whether a notebook shows it, per user and machine in its cache, never in the +//! notebook's files; this keeps it in the cache folder's `read.json`. + +use crate::{Library, State, UserEvent, menus, recycle}; +use onestore::ExGuid; +use std::{ + collections::{BTreeMap, BTreeSet, HashSet}, + path::{Path, PathBuf}, + sync::Arc, +}; + +/// Seconds from 1970 to 1980, where OneNote's Time32 starts. +const TIME32: u64 = 315_532_800; + +/// What has been read, by notebook location. +#[derive(Default, serde::Serialize, serde::Deserialize)] +pub struct Reads { + notebooks: BTreeMap, + #[serde(skip)] + file: PathBuf, + /// The page shown: its notebook, section and space, and whether Mark as Unread left it + /// unread, which leaving it then keeps. + #[serde(skip)] + viewing: Option<(String, [u8; 16], ExGuid, bool)>, + /// The notebooks whose sections were last taken in, by `Arc` address. + #[serde(skip)] + taken: Vec, +} + +#[derive(Default, serde::Serialize, serde::Deserialize)] +struct Kept { + /// Show Unread Changes in This Notebook turned off. + #[serde(default)] + hidden: bool, + /// By section file identity, in hexadecimal. + sections: BTreeMap, +} + +#[derive(Default, serde::Serialize, serde::Deserialize)] +struct Section { + /// Time32 up to which changes to the section's pages are known. + read: u32, + unread: BTreeSet, +} + +fn hex(identity: [u8; 16]) -> String { + identity.iter().map(|byte| format!("{byte:02x}")).collect() +} + +fn now() -> u32 { + let unix = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |since| since.as_secs()); + u32::try_from(unix.saturating_sub(TIME32)).unwrap_or(u32::MAX) +} + +impl Reads { + /// What the cache folder `cache` keeps. + pub fn load(cache: &Path) -> Self { + let file = cache.join("read.json"); + let reads: Self = (std::fs::read(&file).ok()) + .and_then(|bytes| serde_json::from_slice(&bytes).ok()) + .unwrap_or_default(); + Self { file, ..reads } + } + + fn save(&self) { + let partial = self.file.with_extension("partial"); + let saved = serde_json::to_vec(self) + .map_err(std::io::Error::from) + .and_then(|bytes| std::fs::write(&partial, bytes)) + .and_then(|()| std::fs::rename(&partial, &self.file)); + if let Err(error) = saved { + eprintln!("Keeping what was read failed: {error}"); + } + } + + fn section(&self, notebook: &str, section: [u8; 16]) -> Option<&Section> { + let kept = self.notebooks.get(notebook).filter(|kept| !kept.hidden)?; + kept.sections.get(&hex(section)) + } + + fn section_mut(&mut self, notebook: &str, section: [u8; 16]) -> &mut Section { + let kept = self.notebooks.entry(notebook.to_owned()).or_default(); + (kept.sections.entry(hex(section))).or_insert_with(|| Section { + read: now(), + unread: BTreeSet::new(), + }) + } + + /// Whether Show Unread Changes is on for the notebook at `notebook`. + pub fn shown(&self, notebook: &str) -> bool { + self.notebooks.get(notebook).is_none_or(|kept| !kept.hidden) + } + + /// The unread pages of a section. + pub fn pages(&self, notebook: &str, section: [u8; 16]) -> Option<&BTreeSet> { + self.section(notebook, section) + .map(|section| §ion.unread) + } + + /// Takes in the pages of a section another author changed, by space. + fn changed( + &mut self, + notebook: &str, + section: [u8; 16], + pages: impl IntoIterator, + ) { + self.section_mut(notebook, section).unread.extend(pages); + self.save(); + } + + /// Takes in a section as its file stands, its pages with their `LastModifiedTime`: those + /// changed since it was last known are unread. + fn arrived( + &mut self, + notebook: &str, + section: [u8; 16], + pages: impl IntoIterator)>, + ) { + let now = now(); + let kept = self.section_mut(notebook, section); + let read = kept.read; + let mut newest = read.max(now); + for (space, modified) in pages { + if modified.is_some_and(|modified| modified > read) { + kept.unread.insert(space); + } + newest = newest.max(modified.unwrap_or(0)); + } + kept.read = newest; + self.save(); + } +} + +impl State { + /// The open section's unread pages. + pub(crate) fn unread_pages(&self) -> HashSet { + let Some(session) = &self.session else { + return HashSet::new(); + }; + let section = session + .library + .section_identity(&session.tabs[session.tab].path); + (section.and_then(|section| self.reads.pages(&session.library.location, section))) + .map_or_else(HashSet::new, |pages| pages.iter().copied().collect()) + } + + /// The sections of `library` holding unread pages, but the recycle bin's. + fn unread_sections<'a>(&'a self, library: &'a Library) -> impl Iterator + 'a { + (menus::folders(library).into_iter()) + .flat_map(|folder| &folder.sections) + .filter(|section| { + (self.reads.pages(&library.location, section.file_id)) + .is_some_and(|pages| !pages.is_empty()) + }) + .map(|section| section.path.as_str()) + } + + /// The notebooks, groups and sections holding unread pages, by `Library::key`. + pub(crate) fn unread_keys(&self) -> HashSet { + let mut keys = HashSet::new(); + for library in &self.notebooks { + for mut path in self.unread_sections(library) { + keys.insert(library.key(path)); + while let Some((folder, _)) = path.rsplit_once('/') { + keys.insert(library.key(folder)); + path = folder; + } + keys.insert(library.key("")); + } + } + keys + } + + /// Whether `library` has an unread page anywhere. + pub(crate) fn unread_notebook(&self, library: &Library) -> bool { + self.unread_sections(library).next().is_some() + } + + /// Follows what is read, each frame: a notebook's sections are known from when it opens, + /// and the page shown turns read once left, its section known up to then. + pub(crate) fn follow_reading(&mut self) { + let taken: Vec = self + .notebooks + .iter() + .map(|library| Arc::as_ptr(library) as usize) + .collect(); + if taken != self.reads.taken { + let mut added = false; + for library in &self.notebooks { + for folder in menus::folders(library) { + for section in &folder.sections { + let kept = self + .reads + .notebooks + .entry(library.location.clone()) + .or_default(); + if !kept.sections.contains_key(&hex(section.file_id)) { + self.reads.section_mut(&library.location, section.file_id); + added = true; + } + } + } + } + if added { + self.reads.save(); + } + self.reads.taken = taken; + } + let viewing = self.session.as_ref().and_then(|session| { + let path = &session.tabs[session.tab].path; + let section = session.library.section_identity(path)?; + (session.version.is_none() && !recycle::binned(path)) + .then(|| (session.library.location.clone(), section, session.space)) + }); + let same = |(notebook, section, space, _): &(String, [u8; 16], ExGuid, bool)| { + viewing.as_ref() == Some(&(notebook.clone(), *section, *space)) + }; + if self.reads.viewing.as_ref().is_some_and(same) { + return; + } + if let Some((notebook, section, space, kept)) = self.reads.viewing.take() { + let left = self.reads.section_mut(¬ebook, section); + let removed = !kept && left.unread.remove(&space); + let moved = viewing.as_ref().is_none_or(|(_, now, _)| *now != section); + if moved { + left.read = left.read.max(now()); + } + if removed || moved { + self.reads.save(); + } + } + self.reads.viewing = + viewing.map(|(notebook, section, space)| (notebook, section, space, false)); + } + + /// Pages of the open section another author changed, as its sync reports them. + pub(crate) fn changed_elsewhere(&mut self, spaces: &[ExGuid]) { + if let Some(session) = &self.session + && let Some(section) = session + .library + .section_identity(&session.tabs[session.tab].path) + { + self.reads + .changed(&session.library.location, section, spaces.iter().copied()); + } + } + + /// Sections of `library` at catalog `paths` whose files another client changed: their + /// pages are read on a thread of their own, those changed since unread. + pub(crate) fn sections_changed(&self, library: &Arc, paths: Vec) { + let (library, proxy) = (Arc::clone(library), self.proxy.clone()); + std::thread::spawn(move || { + let Ok(Some(notebook)) = &library.notebook else { + return; + }; + let read: Vec<_> = (paths.iter()) + .filter(|path| !recycle::binned(path)) + .filter_map(|path| { + let section = library.section_identity(path)?; + let image = notebook.read_section(path).ok()?; + let pages = notebook::session::stored_pages(&image).ok()?; + Some((section, pages)) + }) + .collect(); + let location = library.location.clone(); + let _ = proxy.send_event(UserEvent::Then(Box::new(move |state: &mut State| { + for (section, pages) in read { + let pages = pages.iter().map(|page| (page.space, page.modified)); + state.reads.arrived(&location, section, pages); + } + Ok(()) + }))); + }); + } + + /// Mark as Read, or as Unread, on the page shown, as OneNote 2010's Ctrl+Q does. + pub(crate) fn toggle_read(&mut self) { + let Some((notebook, section, space, kept)) = self.reads.viewing.as_mut() else { + return; + }; + let pages = &mut self.reads.notebooks.entry(notebook.clone()).or_default(); + let unread = &mut (pages.sections.entry(hex(*section)).or_default()).unread; + *kept = unread.insert(*space); + if !*kept { + unread.remove(space); + } + self.reads.save(); + } + + /// Whether the page shown is unread. + pub(crate) fn page_unread(&self) -> bool { + self.reads + .viewing + .as_ref() + .is_some_and(|(notebook, section, space, _)| { + self.reads + .pages(notebook, *section) + .is_some_and(|pages| pages.contains(space)) + }) + } + + /// Mark Notebook as Read. + pub(crate) fn mark_notebook_read(&mut self, library: &Library) { + if let Some(kept) = self.reads.notebooks.get_mut(&library.location) { + kept.sections + .values_mut() + .for_each(|section| section.unread.clear()); + if let Some((.., kept)) = &mut self.reads.viewing { + *kept = false; + } + self.reads.save(); + } + } + + /// Show Unread Changes in This Notebook, on or off. + pub(crate) fn toggle_unread_shown(&mut self, library: &Library) { + let kept = self + .reads + .notebooks + .entry(library.location.clone()) + .or_default(); + kept.hidden = !kept.hidden; + self.reads.save(); + } + + /// Next Unread: the next unread page of the open section, else the first of the next + /// section, in the notebook's order, holding one. + pub(crate) fn next_unread(&mut self) { + let Some(session) = &self.session else { + return; + }; + let unread = self.unread_pages(); + let at = session + .pages + .iter() + .position(|(space, ..)| *space == session.space); + let after = (session.pages.iter().skip(at.map_or(0, |at| at + 1))) + .find(|(space, ..)| unread.contains(space)); + if let Some((space, ..)) = after { + self.commands.push(crate::Command::OpenPage(*space)); + return; + } + let library = Arc::clone(&session.library); + let here = session.tabs[session.tab].path.clone(); + let paths: Vec = (menus::folders(&library).into_iter()) + .flat_map(|folder| library.tabs(&folder.path)) + .map(|tab| tab.path) + .collect(); + let start = paths + .iter() + .position(|path| *path == here) + .map_or(0, |at| at + 1); + let next = (paths[start..].iter().chain(&paths[..start])).find_map(|path| { + let section = library.section_identity(path)?; + let first = self.reads.pages(&library.location, section)?.first()?; + Some((path.clone(), *first)) + }); + if let Some((path, space)) = next { + self.last_pages.insert(library.key(&path), space); + self.commands + .push(crate::Command::OpenSection(library, path)); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn page(n: u32, modified: Option) -> (ExGuid, Option) { + ( + ExGuid { + guid: [n as u8; 16], + n, + }, + modified, + ) + } + + /// A section first known has nothing unread; pages another client changed after are, + /// and stay so across a reload, until read; a notebook not showing them shows none. + #[test] + fn pages_changed_elsewhere_stay_unread_until_read() { + let folder = std::env::temp_dir().join(format!("snowbound-unread-{}", std::process::id())); + std::fs::create_dir_all(&folder).unwrap(); + let (notebook, section) = ("/notebook", [3; 16]); + let mut reads = Reads::load(&folder); + let known = reads.section_mut(notebook, section).read; + reads.arrived( + notebook, + section, + [page(1, Some(known - 10)), page(2, Some(known + 5))], + ); + let unread = |reads: &Reads| -> Vec { + (reads.pages(notebook, section).into_iter().flatten()) + .map(|space| space.n) + .collect() + }; + assert_eq!(unread(&reads), [2]); + reads.changed( + notebook, + section, + [ExGuid { + guid: [4; 16], + n: 4, + }], + ); + let mut reads = Reads::load(&folder); + assert_eq!(unread(&reads), [2, 4]); + // Known up to the newest change: the same page arriving again is no news. + reads.section_mut(notebook, section).unread.clear(); + reads.arrived(notebook, section, [page(2, Some(known + 5))]); + assert!(unread(&reads).is_empty()); + reads.changed( + notebook, + section, + [ExGuid { + guid: [4; 16], + n: 4, + }], + ); + reads.notebooks.get_mut(notebook).unwrap().hidden = true; + assert!(reads.pages(notebook, section).is_none() && !reads.shown(notebook)); + std::fs::remove_dir_all(&folder).unwrap(); + } +} diff --git a/crates/snowbound/src/update.rs b/crates/snowbound/src/update.rs index 36a056908bfa734a0ab008b825b66dffd7d371c4..a97ab4b6deb089c5e74aa683d69bb93318b8bc17 100644 --- a/crates/snowbound/src/update.rs +++ b/crates/snowbound/src/update.rs @@ -73,7 +73,7 @@ pub struct Version { } impl Version { - fn parse(name: &str) -> Option { + pub(crate) fn parse(name: &str) -> Option { let (date, revision) = name.split_once("-r")?; let digits = |range: std::ops::Range| { date.get(range) @@ -120,6 +120,64 @@ pub fn describe_running() -> String { struct Build { version: String, archives: HashMap, + /// Every change since the first published build, oldest first; builds before these + /// were listed have none. + #[serde(default)] + changes: Vec, +} + +/// One feature, bug fix or other change a build brings, as `tools/release.py` lists them. +#[derive(Clone, Debug, Deserialize, PartialEq)] +pub struct Change { + /// The version of the commit that made it. + version: String, + pub kind: Kind, + pub title: String, +} + +#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +#[serde(rename_all = "lowercase")] +pub enum Kind { + Feature, + Fix, + #[serde(other)] + Other, +} + +impl Kind { + /// The kind's heading in a list of changes. + pub fn heading(self) -> &'static str { + match self { + Kind::Feature => "Features", + Kind::Fix => "Bug fixes", + Kind::Other => "Other changes", + } + } +} + +/// What `changes` amount to, as "3 features, 5 bug fixes, and 2 other changes"; none when +/// there are none. +pub fn summary(changes: &[Change]) -> Option { + let parts: Vec = [ + (Kind::Feature, "feature", "features"), + (Kind::Fix, "bug fix", "bug fixes"), + (Kind::Other, "other change", "other changes"), + ] + .into_iter() + .filter_map(|(kind, one, many)| { + match changes.iter().filter(|change| change.kind == kind).count() { + 0 => None, + 1 => Some(format!("1 {one}")), + count => Some(format!("{count} {many}")), + } + }) + .collect(); + match parts.as_slice() { + [] => None, + [one] => Some(one.clone()), + [first, second] => Some(format!("{first} and {second}")), + [rest @ .., last] => Some(format!("{}, and {last}", rest.join(", "))), + } } #[derive(Clone, Debug, Deserialize)] @@ -169,25 +227,35 @@ fn newer( } /// `platform`'s archive in the build `build.json` describes, once its signature holds and -/// it describes `version`. +/// it describes `version`, and its changes since `running`, features first. fn archive( key: &[u8], build: &[u8], signature: &[u8], version: &Version, platform: &str, -) -> Result { + running: Option<&Version>, +) -> Result<(Archive, Vec), String> { verify(key, build, &String::from_utf8_lossy(signature))?; - let build: Build = + let mut build: Build = serde_json::from_slice(build).map_err(|error| format!("build.json: {error}"))?; if Version::parse(&build.version).as_ref() != Some(version) { return Err(format!("build.json describes {}", build.version)); } - build + let archive = build .archives - .get(platform) - .cloned() - .ok_or_else(|| format!("{} has no {platform} archive", version.name())) + .remove(platform) + .ok_or_else(|| format!("{} has no {platform} archive", version.name()))?; + let mut changes: Vec = build + .changes + .into_iter() + .filter(|change| { + Version::parse(&change.version) + .is_some_and(|made| running.is_none_or(|running| made > *running)) + }) + .collect(); + changes.sort_by_key(|change| change.kind); + Ok((archive, changes)) } fn check_archive(key: &[u8], archive: &Archive, bytes: &[u8]) -> Result<(), String> { @@ -302,10 +370,11 @@ pub enum Status { Idle, UpToDate, Downloading(Version), - /// Verified and unpacked beside the install, waiting for Restart to Update. - Ready(Version, PathBuf), + /// Verified and unpacked beside the install, waiting for Restart to Update, with what it + /// changes. + Ready(Version, PathBuf, Vec), /// Newer than this build, which can't install it itself: its folder has the download. - Available(Version), + Available(Version, Vec), Failed(&'static str), } @@ -326,6 +395,7 @@ fn check( progress: &dyn Fn(Status), ) -> Status { let platform = platform(); + let since = running; // Under Rosetta the native build of the same version is still the update. let running = running.filter(|_| !translated()); let fetch = |path: &str, limit| { @@ -346,15 +416,16 @@ fn check( }; let build = fetch(&format!("{}build.json", version.folder()), 1 << 20)?; let signature = fetch(&format!("{}build.json.sig", version.folder()), 1 << 10)?; - let archive = archive(key, &build, &signature, &version, &platform).map_err(unverified)?; + let (archive, changes) = + archive(key, &build, &signature, &version, &platform, since).map_err(unverified)?; let Some(folder) = install.and_then(staging) else { - return Ok(Status::Available(version)); + return Ok(Status::Available(version, changes)); }; let item = staged(&folder); if std::fs::read_to_string(folder.join("version")).is_ok_and(|name| name == version.name()) && item.exists() { - return Ok(Status::Ready(version, item)); + return Ok(Status::Ready(version, item, changes)); } progress(Status::Downloading(version.clone())); let bytes = fetch( @@ -363,10 +434,10 @@ fn check( )?; check_archive(key, &archive, &bytes).map_err(unverified)?; Ok(match stage(&bytes, &folder, &version) { - Ok(item) => Status::Ready(version, item), + Ok(item) => Status::Ready(version, item, changes), Err(error) => { eprintln!("Cannot stage the update in {}: {error}", folder.display()); - Status::Available(version) + Status::Available(version, changes) } }) })(); @@ -410,6 +481,8 @@ pub struct Updates { thread: std::thread::Thread, /// Restart to Update was chosen: swap the staged build in once the app quits. restart: bool, + /// The sync popup lists what the update changes. + pub(crate) changes_listed: bool, } impl Updates { @@ -474,6 +547,7 @@ impl Updates { shared, thread, restart: false, + changes_listed: false, } } @@ -499,7 +573,7 @@ impl Updates { /// The staged build to swap in, once Restart to Update has quit the app. pub fn restarting(&self) -> Option { match self.status() { - Status::Ready(_, staged) if self.restart => Some(staged), + Status::Ready(_, staged, _) if self.restart => Some(staged), _ => None, } } @@ -518,20 +592,26 @@ impl State { return; }; match status { - Status::Ready(version, _) => { + Status::Ready(version, _, changes) => { if platform::confirm( "Update ready", - &format!("Snowbound {version} is ready to install."), + &described( + format!("Snowbound {version} is ready to install."), + &changes, + ), "Later", "Restart to Update", ) { self.restart_to_update(); } } - Status::Available(version) => { + Status::Available(version, changes) => { if platform::confirm( "Update available", - &format!("Download Snowbound {version} from its build folder."), + &described( + format!("Download Snowbound {version} from its build folder."), + &changes, + ), "Later", "Open Build Folder", ) { @@ -548,6 +628,28 @@ impl State { } } +/// `lead`, then what `changes` amount to and the first dozen of their titles under their +/// kinds, as a dialog's detail. +fn described(lead: String, changes: &[Change]) -> String { + const LISTED: usize = 12; + let Some(summary) = summary(changes) else { + return lead; + }; + let mut detail = format!("{lead} It brings {summary}.\n"); + let mut kind = None; + for change in changes.iter().take(LISTED) { + if kind != Some(change.kind) { + kind = Some(change.kind); + detail += &format!("\n{}\n", change.kind.heading()); + } + detail += &format!("• {}\n", change.title); + } + if changes.len() > LISTED { + detail += &format!("and {} more\n", changes.len() - LISTED); + } + detail.trim_end().to_owned() +} + /// Opens the folder a build is published in. pub fn show_build(version: &Version) { platform::reveal(format!("{BASE}{}", version.folder())); @@ -696,6 +798,18 @@ mod tests { assert!(newer(b"", "macos-aarch64", None).is_err()); } + /// What each build `publish` describes lists: r9 and r10 published, r7 and r8 skipped. + fn changes() -> serde_json::Value { + serde_json::json!([ + {"version": "2026-09-29-r7", "kind": "fix", "title": "Old fix"}, + {"version": "2026-09-29-r8", "kind": "fix", "title": "Pasted pictures keep their size"}, + {"version": "2026-09-29-r8", "kind": "feature", "title": "Pinch zoom"}, + {"version": "2026-09-29-r9", "kind": "release", "title": "Stable download names"}, + {"version": "2026-09-29-r10", "kind": "feature", "title": "Styles and themes"}, + {"version": "someday", "kind": "feature", "title": "Unversioned"}, + ]) + } + /// A build.json for `archive`'s bytes under `platform`, with its signature. fn publish( pair: &Ed25519KeyPair, @@ -708,6 +822,7 @@ mod tests { let build = serde_json::to_vec_pretty(&serde_json::json!({ "version": name, "commit": "0123456789abcdef", + "changes": changes(), "archives": {platform: { "file": file, "size": bytes.len(), @@ -728,35 +843,37 @@ mod tests { let bytes = b"an archive".to_vec(); let (build, signature) = publish(&pair, "2026-09-29-r10", "linux-x86_64", "a.tar.gz", &bytes); - let found = archive(key, &build, &signature, &tenth, "linux-x86_64").unwrap(); + let (found, _) = archive(key, &build, &signature, &tenth, "linux-x86_64", None).unwrap(); check_archive(key, &found, &bytes).unwrap(); let mut tampered = build.clone(); let at = tampered.iter().position(|&byte| byte == b'a').unwrap(); tampered[at] = b'b'; - assert!(archive(key, &tampered, &signature, &tenth, "linux-x86_64").is_err()); + assert!(archive(key, &tampered, &signature, &tenth, "linux-x86_64", None).is_err()); assert!( archive( generate().public_key().as_ref(), &build, &signature, &tenth, - "linux-x86_64" + "linux-x86_64", + None ) .is_err() ); - assert!(archive(key, &build, b"zz", &tenth, "linux-x86_64").is_err()); + assert!(archive(key, &build, b"zz", &tenth, "linux-x86_64", None).is_err()); assert!( archive( key, &build, &signature, &version("2026-09-29-r11"), - "linux-x86_64" + "linux-x86_64", + None ) .is_err() ); - assert!(archive(key, &build, &signature, &tenth, "macos-aarch64").is_err()); + assert!(archive(key, &build, &signature, &tenth, "macos-aarch64", None).is_err()); assert!( check_archive(key, &found, b"an archivf") @@ -780,6 +897,98 @@ mod tests { ); } + fn change(kind: Kind, title: &str) -> Change { + Change { + version: "2026-09-29-r10".to_owned(), + kind, + title: title.to_owned(), + } + } + + #[test] + fn summaries_name_each_kind_with_plurals_and_list_grammar() { + let fix = || change(Kind::Fix, "A fix"); + let feature = || change(Kind::Feature, "A feature"); + let other = || change(Kind::Other, "Another change"); + assert_eq!(summary(&[]), None); + assert_eq!(summary(&[fix()]).unwrap(), "1 bug fix"); + assert_eq!(summary(&[other(), other()]).unwrap(), "2 other changes"); + assert_eq!( + summary(&[fix(), feature(), fix()]).unwrap(), + "1 feature and 2 bug fixes" + ); + let mut all = vec![feature(), feature(), feature(), other(), other()]; + all.extend(std::iter::repeat_with(fix).take(5)); + assert_eq!( + summary(&all).unwrap(), + "3 features, 5 bug fixes, and 2 other changes" + ); + assert_eq!( + described("Ready.".to_owned(), &[feature(), fix(), fix()]), + "Ready. It brings 1 feature and 2 bug fixes.\n\nFeatures\n• A feature\n\nBug fixes\n• A fix\n• A fix" + ); + assert_eq!(described("Ready.".to_owned(), &[]), "Ready."); + } + + /// A build lists what every build since the first brought; a check sums those newer than + /// the running build, skipped releases included, features first, and an unknown kind is + /// another change. + #[test] + fn changes_sum_across_skipped_releases() { + let pair = generate(); + let key = pair.public_key().as_ref(); + let tenth = version("2026-09-29-r10"); + let (build, signature) = publish(&pair, "2026-09-29-r10", "linux-x86_64", "a", b"a"); + let since = |running: Option<&str>| { + let running = running.map(version); + let (_, changes) = archive( + key, + &build, + &signature, + &tenth, + "linux-x86_64", + running.as_ref(), + ) + .unwrap(); + changes + .into_iter() + .map(|change| (change.kind, change.title)) + .collect::>() + }; + assert_eq!( + since(Some("2026-09-29-r9")), + [(Kind::Feature, "Styles and themes".to_owned())] + ); + assert_eq!( + since(Some("2026-09-29-r7")), + [ + (Kind::Feature, "Pinch zoom".to_owned()), + (Kind::Feature, "Styles and themes".to_owned()), + (Kind::Fix, "Pasted pictures keep their size".to_owned()), + (Kind::Other, "Stable download names".to_owned()), + ] + ); + assert_eq!(since(Some("2026-09-29-r10")), []); + assert_eq!(since(None).len(), 5); + + // Builds published before changes were listed still read, as builds listing them do + // for clients that predate them. + let old = serde_json::json!({"version": "2026-09-29-r10", "archives": {"linux-x86_64": { + "file": "a", "size": 1, "sha256": "", "signature": "", + }}}); + let old = serde_json::to_vec(&old).unwrap(); + let signature = hex(pair.sign(&old).as_ref()).into_bytes(); + let (_, changes) = archive(key, &old, &signature, &tenth, "linux-x86_64", None).unwrap(); + assert_eq!(changes, []); + #[derive(Deserialize)] + #[allow(dead_code)] + struct Earlier { + version: String, + archives: HashMap, + } + assert!(serde_json::from_slice::(&build).is_ok()); + } + /// An archive as `tools/release.py` packs this platform's, holding `marker`. fn pack(folder: &Path, marker: &str) -> Vec { if !cfg!(target_os = "macos") { @@ -856,10 +1065,14 @@ mod tests { let status = check(&fetch, &key, Some(&running), Some(&install), &|status| { progress.lock().unwrap().push(status) }); - let Status::Ready(found, item) = status else { + let Status::Ready(found, item, listed) = status else { panic!("{status:?}"); }; assert_eq!(found, version("2026-09-29-r10")); + assert_eq!( + summary(&listed).unwrap(), + "2 features, 2 bug fixes, and 1 other change" + ); assert_eq!( *progress.lock().unwrap(), [Status::Downloading(found.clone())] @@ -869,7 +1082,10 @@ mod tests { fetched.lock().unwrap().clear(); let again = check(&fetch, &key, Some(&running), Some(&install), &|_| {}); - assert_eq!(again, Status::Ready(found.clone(), item.clone())); + assert_eq!( + again, + Status::Ready(found.clone(), item.clone(), listed.clone()) + ); assert!( !fetched .lock() @@ -884,7 +1100,7 @@ mod tests { ); assert_eq!( check(&fetch, &key, Some(&running), None, &|_| {}), - Status::Available(found.clone()) + Status::Available(found.clone(), listed.clone()) ); assert_eq!( check( @@ -955,6 +1171,13 @@ mod tests { fn the_published_build_installs() { let folder = scratch("published"); let install = staged(&folder); + let placeholder = pack(&folder, "old"); + let unpacked = stage( + &placeholder, + &folder.join("unpacked"), + &version("2000-01-01-r1"), + ); + std::fs::rename(unpacked.unwrap(), &install).unwrap(); let old = version("2000-01-01-r1"); let status = check( &download, @@ -963,7 +1186,7 @@ mod tests { Some(&install), &|_| {}, ); - let Status::Ready(found, staged) = status else { + let Status::Ready(found, staged, _) = status else { panic!("{status:?}"); }; apply(&staged, &install).unwrap(); diff --git a/crates/snowbound/src/windows.rs b/crates/snowbound/src/windows.rs index f15a3b1dd36d58fa9ce10f0297c419537b19be26..307378e100e1b69860579e46fdc08aaf41de4086 100644 --- a/crates/snowbound/src/windows.rs +++ b/crates/snowbound/src/windows.rs @@ -51,6 +51,8 @@ static PREVIOUS: AtomicIsize = AtomicIsize::new(0); static COMPOSED: AtomicBool = AtomicBool::new(false); /// The window's scale factor, as `f32` bits, for hit-testing outside a frame. static SCALE: AtomicU32 = AtomicU32::new(0x3f80_0000); +/// The window's smallest client size in points, as `f32` bits. +static MIN_SIZE: [AtomicU32; 2] = [AtomicU32::new(0), AtomicU32::new(0)]; /// The caption button under the pointer, by its hit-test code: as the system reports it /// over Mica, where the system runs them, and as the row saw it for the close button, /// whose glyph turns white on red. @@ -219,7 +221,7 @@ fn offer_to_open() { None, &format!("\"{executable}\" \"%1\""), ); - for extension in [".one", ".onetoc2"] { + for extension in [".one", ".onetoc2", ".onepkg"] { set(&format!(r"{extension}\OpenWithProgids"), Some(PROG_ID), ""); } } @@ -259,6 +261,46 @@ fn reframe(hwnd: HWND) { unsafe { wm::SetWindowPos(hwnd, std::ptr::null_mut(), 0, 0, 0, 0, flags) }; } +/// Keeps the window's client area at least `size` points, widening it now if narrower. +/// winit's `set_min_inner_size` would size the window to its client area with the caption +/// the row took over, growing it by the caption's height each time. +pub fn set_min_size(window: &Window, size: [f32; 2]) { + for (bound, side) in MIN_SIZE.iter().zip(size) { + bound.store(side.to_bits(), Ordering::Relaxed); + } + let hwnd = hwnd(window); + let mut current = RECT::default(); + unsafe { wm::GetWindowRect(hwnd, &mut current) }; + let [width, height] = outer(hwnd, min_size()); + let (now, high) = (current.right - current.left, current.bottom - current.top); + if now < width || high < height { + let flags = wm::SWP_NOMOVE | wm::SWP_NOZORDER | wm::SWP_NOACTIVATE; + let [width, height] = [now.max(width), high.max(height)]; + unsafe { wm::SetWindowPos(hwnd, std::ptr::null_mut(), 0, 0, width, height, flags) }; + } +} + +/// The smallest client size in device pixels. +fn min_size() -> [i32; 2] { + let scale = f32::from_bits(SCALE.load(Ordering::Relaxed)); + MIN_SIZE + .each_ref() + .map(|side| (f32::from_bits(side.load(Ordering::Relaxed)) * scale).ceil() as i32) +} + +/// The window size whose client area is `client`, with the window's frame as it is now. +fn outer(hwnd: HWND, client: [i32; 2]) -> [i32; 2] { + let (mut window, mut inner) = (RECT::default(), RECT::default()); + unsafe { + wm::GetWindowRect(hwnd, &mut window); + wm::GetClientRect(hwnd, &mut inner); + } + [ + client[0] + (window.right - window.left) - inner.right, + client[1] + (window.bottom - window.top) - inner.bottom, + ] +} + fn set_attribute(hwnd: HWND, attribute: u32, value: &T) -> bool { unsafe { Dwm::DwmSetWindowAttribute( @@ -292,6 +334,14 @@ unsafe extern "system" fn frame_procedure( let procedure: wm::WNDPROC = std::mem::transmute(PREVIOUS.load(Ordering::Relaxed)); wm::CallWindowProcW(procedure, hwnd, message, wparam, lparam) }; + if message == wm::WM_GETMINMAXINFO { + let result = previous(message, wparam, lparam); + let info = unsafe { &mut *(lparam as *mut wm::MINMAXINFO) }; + let [width, height] = outer(hwnd, min_size()); + let least = &mut info.ptMinTrackSize; + (least.x, least.y) = (least.x.max(width), least.y.max(height)); + return result; + } match message { // Accent colours and transparency effects reach the window only as settings. wm::WM_SETTINGCHANGE | wm::WM_DWMCOLORIZATIONCOLORCHANGED => { @@ -652,23 +702,58 @@ pub fn titlebar(appearance: Theme) -> Option<[[f32; 4]; 2]> { }) } -/// The theme over the window's material. Over Windows 7's glass, fields and tool buttons -/// take white faces, as Internet Explorer's do there, so their text and icons stay legible, -/// and the sidebar stays opaque. -pub fn over_backdrop(theme: ui::Theme, _: Theme) -> ui::Theme { - let sidebar = theme.sidebar; - let theme = theme.over_backdrop(); - if caption() != Caption::Glass || eleven() { - return theme; +/// The theme over the window's material. Aero glass takes whatever lies behind the window +/// and any colour the user picks, so on Windows 7 the toolbar keeps opaque faces under its +/// icons, as `SNOWBOUND_W7_CHROME` picks: `bar`, Explorer's opaque command bar under the glass +/// frame; `pills`, a face per group of tools over the glass; `frost`, the strip see-through +/// enough to tint it; or `tint`, one panel of tools in the glass's colour, and `tint-tiles`, +/// a tile per group. +pub fn over_backdrop(theme: ui::Theme, appearance: Theme) -> ui::Theme { + if version() >= (6, 2, 0) { + return theme.over_backdrop(); } - ui::Theme { - sidebar, - base: [1.0, 1.0, 1.0, 0.8], - tool: [1.0, 1.0, 1.0, 0.35], - ..theme + match std::env::var("SNOWBOUND_W7_CHROME").as_deref() { + Ok("pills") => ui::Theme { + strip: [0.0; 4], + tool: theme.base, + ..theme + }, + Ok("frost") => ui::Theme { + strip: [theme.strip[0], theme.strip[1], theme.strip[2], 0.85], + ..theme + }, + Ok(chrome @ ("tint" | "tint-tiles")) => ui::Theme { + strip: [0.0; 4], + tool: glass_tint(appearance == Theme::Dark), + tool_panel: chrome == "tint", + chip: [0.0, 0.0, 0.0, 0.35], + ..theme + }, + _ => theme, } } +/// The colour Windows 7 tints its glass with, at a lightness text keeps its contrast on, as +/// Mica takes the wallpaper's: more of it as the colour's intensity rises. +fn glass_tint(dark: bool) -> [f32; 4] { + let (mut color, mut opaque) = (0u32, 0); + unsafe { Dwm::DwmGetColorizationColor(&mut color, &mut opaque) }; + let [blue, green, red, intensity] = color.to_le_bytes(); + let [high, low] = [red.max(green).max(blue), red.min(green).min(blue)].map(f32::from); + let lightness = (high + low) / 510.0; + let saturation = if high == low { + 0.0 + } else { + (high - low) / 255.0 / (1.0 - (2.0 * lightness - 1.0).abs()) + }; + let strength = 0.85 * (0.6 + 0.4 * f32::from(intensity) / 255.0); + draw::hsl( + draw::hue(draw::srgb(red, green, blue)), + saturation * strength, + if dark { 0.22 } else { 0.84 }, + ) +} + /// None: the kit's own menus. pub fn menu(_: Theme) -> Option { None @@ -1402,7 +1487,10 @@ pub fn edit_date( pub fn pick_notebook(title: &str) -> Option { pick( title, - &[("OneNote notebooks and sections", "*.onetoc2;*.one")], + &[( + "OneNote notebooks, sections and packages", + "*.onetoc2;*.one;*.onepkg", + )], None, false, ) diff --git a/crates/snowbound/tests/replay.rs b/crates/snowbound/tests/replay.rs index 5784a87790897a2b952824201abbbc95e2462d2f..c00ce5eb935ebe24f7f9e5408439ccbf07036e2f 100644 --- a/crates/snowbound/tests/replay.rs +++ b/crates/snowbound/tests/replay.rs @@ -252,3 +252,101 @@ fn undo_walks_back_through_new_pages_and_their_titles() { assert_eq!(tabs, expected, "{tree}"); } } + +/// The search box's results in `tree`, one a line. +fn search_results(tree: &str) -> Vec<&str> { + tree.lines() + .skip_while(|line| line.trim() != r#"Dialog "Search""#) + .filter(|line| line.trim_start().starts_with("ListBoxOption")) + .collect() +} + +/// Steps typing `query` into the search box, then writing the tree as `name`. +fn search(query: &str, name: &str) -> Vec { + ["modifiers command", "key e", "modifiers"] + .into_iter() + .map(String::from) + .chain([ + format!("type {query}"), + "wait 2500".into(), + format!("accessibility {name}"), + ]) + .collect() +} + +/// Steps opening the page versions notebook's first version and its bar's menu. +fn version_menu() -> Vec { + let mut steps: Vec = ["modifiers command shift", "key p", "modifiers"] + .into_iter() + .chain(["type Page Versions", "wait 300", "key Enter", "wait 1000"]) + .map(String::from) + .collect(); + // The version row under the page, then the yellow bar above the version. + for point in ["1003 114", "177 94"] { + steps.extend([format!("move {point}"), "press".into(), "release".into()]); + steps.push("wait 1200".into()); + } + steps +} + +fn run(scratch: &Scratch, notebook: &Path, steps: &[String]) -> Vec { + let steps: Vec<&str> = steps.iter().map(String::as_str).collect(); + replay(scratch, Some(notebook), &steps) +} + +/// A template's content is found by search once it is on the page. +#[test] +fn search_finds_what_a_template_put_on_the_page() { + let scratch = Scratch::new("template-search"); + let notebook = + Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/cross-container/candidate"); + let mut steps: Vec = ["modifiers command", "key n", "modifiers", "wait 1200"] + .into_iter() + // The Meeting tile of the template strip over the new page. + .chain(["move 615 287", "press", "release", "wait 3000"]) + .map(String::from) + .collect(); + steps.extend(search("Attendees", "found")); + let [found] = run(&scratch, ¬ebook, &steps).try_into().unwrap(); + assert!( + search_results(&found) + .iter() + .any(|line| line.contains("Attendees")), + "{found}" + ); +} + +/// A version restored is found by search. +#[test] +fn search_finds_a_restored_version() { + let scratch = Scratch::new("restore-search"); + let notebook = + Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/page-versions/candidate-restore"); + let mut steps = version_menu(); + // Restore Version. + steps.extend(["key Down", "key Enter", "wait 2000"].map(String::from)); + steps.extend(search("Second author", "found")); + let [found] = run(&scratch, ¬ebook, &steps).try_into().unwrap(); + assert!(!search_results(&found).is_empty(), "{found}"); +} + +/// A version copied into its own section is listed there at once, and found by search. +#[test] +fn a_version_copied_into_its_section_is_listed_and_found() { + let scratch = Scratch::new("copy-version"); + let notebook = + Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/page-versions/candidate-restore"); + let mut steps = version_menu(); + // Copy Page To…, then the section itself. + steps.extend(["key Down", "key Down", "key Down", "key Enter", "wait 600"].map(String::from)); + steps.extend(["key Down", "key Enter", "wait 2000", "accessibility copied"].map(String::from)); + steps.extend(search("Second author", "found")); + let [copied, found] = run(&scratch, ¬ebook, &steps).try_into().unwrap(); + let pages = page_tabs(&copied); + let versioned = pages + .iter() + .filter(|tab| tab.ends_with("Versioned")) + .count(); + assert_eq!(versioned, 3, "{pages:?}"); + assert!(!search_results(&found).is_empty(), "{found}"); +} diff --git a/crates/ui/src/layout.rs b/crates/ui/src/layout.rs index 45c29680eb70c5ef39f06f18cc571488fd5ca92e..385398abda719214bcd30cf53e9346eb2db461c6 100644 --- a/crates/ui/src/layout.rs +++ b/crates/ui/src/layout.rs @@ -316,9 +316,14 @@ fn in_flow(nodes: &[Built], index: usize) -> impl Iterator + '_ { /// The children's extent on `axis`: summed with gaps along the flow, otherwise the largest. fn flow(nodes: &[Built], index: usize, axis: usize) -> f32 { let sizes = in_flow(nodes, index).map(|child| nodes[child].computed[axis]); - if along(&nodes[index], axis) { + span(&nodes[index], axis, sizes) +} + +/// The extent of children `sizes` long on `axis` of `node`, as `flow` measures it. +fn span(node: &Built, axis: usize, sizes: impl Iterator) -> f32 { + if along(node, axis) { let (sum, count) = sizes.fold((0.0, 0), |(sum, count), size| (sum + size, count + 1)); - sum + nodes[index].gap * (count.max(1) - 1) as f32 + sum + node.gap * (count.max(1) - 1) as f32 } else { sizes.fold(0.0, f32::max) } @@ -335,13 +340,7 @@ pub(crate) fn narrowest(nodes: &[Built], index: usize) -> f32 { !nodes[**child].flags.contains(Flags::FLOAT) && nodes[**child].anchor.is_none() }) .map(|child| least(nodes, *child)); - let content = if along(node, 0) { - let (sum, count) = sizes.fold((0.0, 0), |(sum, count), size| (sum + size, count + 1)); - sum + node.gap * (count.max(1) - 1) as f32 - } else { - sizes.fold(0.0, f32::max) - }; - content + 2.0 * node.pad[0] + span(node, 0, sizes) + 2.0 * node.pad[0] } /// The least width `index` takes without clipping its children. diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs index cff8eade2267a91433a280a7019ff8fe8fc6bc77..ddcddcfada81b1dab9d2e5439eef4cf8faee9fe6 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -324,6 +324,15 @@ struct Motion { opacity: f32, } +impl Motion { + const NONE: Motion = Motion { + zoom: 1.0, + pivot: [0.0; 2], + tilt: 0.0, + opacity: 1.0, + }; +} + /// What a box is this frame. Colours are linear RGBA. #[derive(Clone, Default)] pub struct Spec<'a> { @@ -332,13 +341,13 @@ pub struct Spec<'a> { /// The axis its children flow along. pub axis: Axis, pub text: Option<&'a str>, - /// The family the label is shaped in, where it has the glyphs; the interface's otherwise. /// The label's size in logical pixels; the theme's otherwise. pub font_size: Option, /// Shapes the label semibold. pub bold: bool, /// Shapes the label italic. pub italic: bool, + /// The family the label is shaped in, where it has the glyphs; the interface's otherwise. pub font: Option<&'a str>, pub overflow: Overflow, /// The label's colour; the theme's text colour otherwise. @@ -463,12 +472,7 @@ impl Primitives<'_> { pivot, tilt, opacity, - } = self.motion.unwrap_or(Motion { - zoom: 1.0, - pivot: [0.0; 2], - tilt: 0.0, - opacity: 1.0, - }); + } = self.motion.unwrap_or(Motion::NONE); let device = |point: [f32; 2]| point.map(|value| value * scale); let moved = |point: [f32; 2]| { device(std::array::from_fn(|axis| { @@ -797,8 +801,11 @@ impl Ui { } /// Asks for a frame `after` this one, as a running clock does. - pub fn wake_after(&mut self, after: std::time::Duration) { - let due = self.now + after; + pub fn wake_after(&mut self, after: Duration) { + self.wake_by(self.now + after); + } + + fn wake_by(&mut self, due: Instant) { self.wake = Some(self.wake.map_or(due, |wake| wake.min(due))); } @@ -1196,7 +1203,7 @@ impl Ui { /// The id a box built next under the current parent would take. pub fn id(&self, part: impl Hash) -> Id { - self.nodes[*self.stack.last().unwrap()].id.child(part) + self.current().child(part) } /// Adds a box and makes it the parent of the boxes built until `close`. @@ -1247,7 +1254,6 @@ impl Ui { self.signal(id) } - /// Eases towards `target` over the next frames, starting there the first time `id` asks. /// Holds animated value `id` at `value` without easing, as a dragged box follows the /// pointer; it eases from there once animated again. pub fn hold(&mut self, id: Id, value: f32) -> f32 { @@ -1257,6 +1263,7 @@ impl Ui { value } + /// Eases towards `target` over the next frames, starting there the first time `id` asks. pub fn animate(&mut self, id: Id, target: f32) -> f32 { let state = self.states.entry(id).or_default(); state.touched = self.frame; @@ -1293,7 +1300,7 @@ impl Ui { /// The size of `text` as a label, in logical pixels. pub fn measure(&mut self, text: &str) -> [f32; 2] { self.texts - .label(text, self.theme.font_size, false, None, self.frame) + .label(text, self.theme.font_size, self.frame) .size } @@ -1316,8 +1323,7 @@ impl Ui { self.caret = Some((id, start, self.frame)); let (opacity, hold) = draw::edit::caret_blink(self.now.saturating_duration_since(start)); if let Some(hold) = hold { - let due = self.now + hold; - self.wake = Some(self.wake.map_or(due, |wake| wake.min(due))); + self.wake_after(hold); } opacity } @@ -1468,10 +1474,8 @@ impl Ui { { let opacity = self.progress(due, TIP_FADE); self.nodes[index].motion = Some(Motion { - zoom: 1.0, - pivot: [0.0; 2], - tilt: 0.0, opacity, + ..Motion::NONE }); self.paint(index, None, None); } @@ -1961,28 +1965,8 @@ impl Ui { layers } - pub(crate) fn texts(&mut self) -> (&mut Texts, u64) { - (&mut self.texts, self.frame) - } - - pub(crate) fn field( - &mut self, - id: Id, - ) -> ( - &mut Selection, - &mut (Selection, SelectionUnit), - &mut Option<[usize; 2]>, - ) { - let state = self.states.entry(id).or_default(); - (&mut state.selection, &mut state.press, &mut state.select) - } - - pub(crate) fn grab(&mut self, id: Id) -> &mut f32 { - &mut self.states.entry(id).or_default().grab - } - - pub(crate) fn held(&mut self, id: Id) -> &mut Option<(ScrollerPart, Instant)> { - &mut self.states.entry(id).or_default().held + pub(crate) fn state(&mut self, id: Id) -> &mut State { + self.states.entry(id).or_default() } } diff --git a/crates/ui/src/popup.rs b/crates/ui/src/popup.rs index 6b20f19b7d423c091d15201b51424223d0934701..d90201efd2d7ffc80f8e1fbdb41cf11fe10420fd 100644 --- a/crates/ui/src/popup.rs +++ b/crates/ui/src/popup.rs @@ -86,6 +86,13 @@ impl Item<'_> { } } +/// Whether `items`' rows leave room for an icon or a check. +fn icons(items: &[Item]) -> bool { + items + .iter() + .any(|item| item.icon.is_some() || item.checked == Some(true)) +} + /// Builds popup `id` as a menu of `items` beside `anchor` while it is open, under a /// filter field showing `filter` while empty when given. Returns the index of the item /// chosen. @@ -100,11 +107,7 @@ pub fn menu( return None; } let style = ui.theme.menu(); - let mut measure = |text| { - ui.texts - .label(text, style.font_size, false, None, ui.frame) - .size[0] - }; + let mut measure = |text| ui.texts.label(text, style.font_size, ui.frame).size[0]; let [text, shortcut] = items.iter().fold([0.0_f32; 2], |[text, shortcut], item| { [ text.max( @@ -118,15 +121,12 @@ pub fn menu( shortcut.max(measure(item.trailing())), ] }); - let icons = items - .iter() - .any(|item| item.icon.is_some() || item.checked == Some(true)); let width = text + if shortcut > 0.0 { 3.0 * ICON_GAP + shortcut } else { 0.0 - } + if icons { ICON + ICON_GAP } else { 0.0 } + } + if icons(items) { ICON + ICON_GAP } else { 0.0 } + if items.len() as f32 > ROWS { GUTTER } else { @@ -189,7 +189,7 @@ pub fn submenus(ui: &mut Ui, id: Id, items: &[Item], submenu: impl Fn(usize) -> return; }; if now < due { - ui.wake = Some(ui.wake.map_or(due, |wake| wake.min(due))); + ui.wake_by(due); return; } popup.submenu = Some((key, None)); @@ -283,7 +283,7 @@ fn tooltip_below( return; }; if now < due { - ui.wake = Some(ui.wake.map_or(due, |wake| wake.min(due))); + ui.wake_by(due); return; } let title = if keys.is_empty() { @@ -723,34 +723,8 @@ pub fn colors( // The button is cell 0 and the swatches follow it. let cell = |index: usize| id.child(("cell", index)); let count = swatches.len() + 1; - let keys = navigation( - ui, - &[id], - &[ - NamedKey::ArrowLeft, - NamedKey::ArrowRight, - NamedKey::ArrowUp, - NamedKey::ArrowDown, - NamedKey::Enter, - ], - ); - let mut highlight = state(ui, id).highlight.map(|cell| cell as usize); - let mut chosen = None; - for index in 0..count { - let signal = ui.signal(cell(index)); - if signal.hovered && ui.moved { - highlight = Some(index); - } - if signal.clicked { - chosen = Some(index); - } - } - for key in keys { - highlight = Some(match (key, highlight) { - (NamedKey::Enter, _) => { - chosen = chosen.or(highlight); - continue; - } + let (highlight, chosen) = pick_cell(ui, id, count, cell, |key, highlight| { + match (key, highlight) { (_, None) => 0, (NamedKey::ArrowLeft, Some(at)) => at.saturating_sub(1), (NamedKey::ArrowRight, Some(at)) => (at + 1).min(count - 1), @@ -758,8 +732,8 @@ pub fn colors( (_, Some(0)) => 1, (_, Some(at)) if at + columns < count => at + columns, (_, Some(at)) => at, - }); - } + } + }); if let Some(index) = chosen { ui.close_popup(id); return Some((index > 0).then(|| swatches[index - 1].0)); @@ -834,7 +808,6 @@ pub fn colors( } highlighted(ui, id, highlight.map(cell)); ui.close(); - state(ui, id).highlight = highlight.map(|cell| cell as u64); None } @@ -845,6 +818,47 @@ fn highlighted(ui: &mut Ui, id: Id, cell: Option) { } } +/// Routes the pointer and keys over open popup `id`'s `count` cells, keeping its highlight, +/// which `step` moves for each arrow. Returns the highlight and the cell a click or Enter chose. +fn pick_cell( + ui: &mut Ui, + id: Id, + count: usize, + cell: impl Fn(usize) -> Id, + step: impl Fn(NamedKey, Option) -> usize, +) -> (Option, Option) { + let keys = navigation( + ui, + &[id], + &[ + NamedKey::ArrowLeft, + NamedKey::ArrowRight, + NamedKey::ArrowUp, + NamedKey::ArrowDown, + NamedKey::Enter, + ], + ); + let mut highlight = state(ui, id).highlight.map(|cell| cell as usize); + let mut chosen = None; + for index in 0..count { + let signal = ui.signal(cell(index)); + if signal.hovered && ui.moved { + highlight = Some(index); + } + if signal.clicked { + chosen = Some(index); + } + } + for key in keys { + match key { + NamedKey::Enter => chosen = chosen.or(highlight), + _ => highlight = Some(step(key, highlight)), + } + } + state(ui, id).highlight = highlight.map(|cell| cell as u64); + (highlight, chosen) +} + /// Builds popup `id` as a grid of `size` columns and rows beside `anchor` while it is open, /// lit from its corner to the cell pointed at, as Office's table picker is. Returns the /// columns and rows chosen. @@ -853,44 +867,19 @@ pub fn table_picker(ui: &mut Ui, id: Id, anchor: Anchor, size: [usize; 2]) -> Op return None; } let cell = |index: usize| id.child(("cell", index)); - let keys = navigation( - ui, - &[id], - &[ - NamedKey::ArrowLeft, - NamedKey::ArrowRight, - NamedKey::ArrowUp, - NamedKey::ArrowDown, - NamedKey::Enter, - ], - ); let [columns, rows] = size; - let mut highlight = state(ui, id).highlight.map(|cell| cell as usize); - let mut chosen = None; - for index in 0..columns * rows { - let signal = ui.signal(cell(index)); - if signal.hovered && ui.moved { - highlight = Some(index); - } - if signal.clicked { - chosen = Some(index); - } - } - for key in keys { - let at = highlight.unwrap_or(0); + let (highlight, chosen) = pick_cell(ui, id, columns * rows, cell, |key, highlight| { + let Some(at) = highlight else { + return 0; + }; let [column, row] = [at % columns, at / columns]; - highlight = Some(match key { - NamedKey::Enter => { - chosen = chosen.or(highlight); - continue; - } - _ if highlight.is_none() => 0, + match key { NamedKey::ArrowLeft => at - usize::from(column > 0), NamedKey::ArrowRight => at + usize::from(column + 1 < columns), NamedKey::ArrowUp => at - if row > 0 { columns } else { 0 }, _ => at + if row + 1 < rows { columns } else { 0 }, - }); - } + } + }); let extent = |index: usize| [index % columns + 1, index / columns + 1]; if let Some(index) = chosen { ui.close_popup(id); @@ -971,7 +960,6 @@ pub fn table_picker(ui: &mut Ui, id: Id, anchor: Anchor, size: [usize; 2]) -> Op ui.close(); highlighted(ui, id, highlight.map(cell)); ui.close(); - state(ui, id).highlight = highlight.map(|cell| cell as u64); None } @@ -1011,41 +999,15 @@ pub fn gallery( }) .map_or(1, |group| group.columns) }; - let keys = navigation( - ui, - &[id], - &[ - NamedKey::ArrowLeft, - NamedKey::ArrowRight, - NamedKey::ArrowUp, - NamedKey::ArrowDown, - NamedKey::Enter, - ], - ); - let mut highlight = state(ui, id).highlight.map(|cell| cell as usize); - let mut chosen = None; - for index in 0..count { - let signal = ui.signal(cell_id(index)); - if signal.hovered && ui.moved { - highlight = Some(index); - } - if signal.clicked { - chosen = Some(index); - } - } - for key in keys { + let (highlight, chosen) = pick_cell(ui, id, count, cell_id, |key, highlight| { let at = highlight.or(current.first().copied()).unwrap_or(0); - highlight = Some(match key { - NamedKey::Enter => { - chosen = chosen.or(highlight); - continue; - } + match key { NamedKey::ArrowLeft => at.saturating_sub(1), NamedKey::ArrowRight => (at + 1).min(count - 1), NamedKey::ArrowUp => at.saturating_sub(columns(at)), _ => (at + columns(at)).min(count - 1), - }); - } + } + }); if let Some(index) = chosen { ui.close_popup(id); return Some(index); @@ -1113,7 +1075,6 @@ pub fn gallery( } highlighted(ui, id, highlight.map(cell_id)); ui.close(); - state(ui, id).highlight = highlight.map(|cell| cell as u64); None } @@ -1488,9 +1449,7 @@ impl<'a> Matches<'a> { rules, ruled, rule, - icons: items - .iter() - .any(|item| item.icon.is_some() || item.checked == Some(true)), + icons: icons(items), } } } diff --git a/crates/ui/src/shell.rs b/crates/ui/src/shell.rs index 24bf0eaca67761237db02018b67d05ad2a0d60f1..8cee9f9eaf614fbd0ab377f0f51125e6e5a3af1f 100644 --- a/crates/ui/src/shell.rs +++ b/crates/ui/src/shell.rs @@ -34,12 +34,12 @@ const EDGE: f32 = 20.0; /// The row is as wide as the tabs, and gives up all of that where its own row lacks room. /// Then the tabs scroll sideways under the wheel, either way it turns, fading out at the /// ends they are cut at into the row's `fill`, and the open tab scrolls into view as it -/// opens. +/// opens. A tab given `true` after its colour reads bold, as an unread section's does. #[allow(clippy::too_many_arguments)] pub fn section_tabs( ui: &mut Ui, row: Id, - tabs: &[(&str, [f32; 4])], + tabs: &[(&str, [f32; 4], bool)], active: usize, lit: Option, dragged: Option, @@ -52,8 +52,11 @@ pub fn section_tabs( let mut left = crate::SHADOW[0]; let placed: Vec<_> = tabs .iter() - .map(|(name, _)| { - let width = TAB_PAD + ui.measure(name)[0] + 4.0 + lean(height); + .map(|&(name, _, bold)| { + let label = ui + .texts + .styled(name, ui.theme.font_size, bold, false, None, ui.frame); + let width = TAB_PAD + label.size[0] + 4.0 + lean(height); left += width; (left - width, width) }) @@ -129,7 +132,7 @@ pub fn section_tabs( .chain((active < tabs.len() && Some(active) != lifted).then_some(active)) .chain(lifted); for index in order { - let (name, color) = tabs[index]; + let (name, color, bold) = tabs[index]; let (x, width) = placed[index]; // Keyed by name, so a tab eases from where it stood when the tabs are reordered. let key = ui.id(("x", name)); @@ -164,6 +167,7 @@ pub fn section_tabs( size: [px(width), px(tall)], position: [x - offset, height - tall], text: Some(name), + bold, color: Some(mix(mix(theme.ink, fill, 0.2), theme.ink, open)), fill: Some(if lit == Some(index) { mix(colors.frame[0], [1.0; 4], 0.08) @@ -306,6 +310,11 @@ fn opens(ui: &mut Ui, id: Id, menu: Id) { } } +/// `color` as a control that does not apply now shows it. +fn faded([red, green, blue, alpha]: [f32; 4]) -> [f32; 4] { + [red, green, blue, alpha * 0.35] +} + /// A tool button, with a menu arrow where `menu`, whose command does not apply now: `icon` /// tinted by `tint` shows faded, and it takes no clicks. pub fn unavailable( @@ -315,7 +324,6 @@ pub fn unavailable( tint: [f32; 4], menu: bool, ) { - let faded = |[red, green, blue, alpha]: [f32; 4]| [red, green, blue, alpha * 0.35]; let arrow = faded(ui.theme.text_dim); let id = ui.open( part, @@ -530,9 +538,7 @@ pub fn combo( enabled: bool, ) { let theme = ui.theme.clone(); - let faded = |[red, green, blue, alpha]: [f32; 4]| { - [red, green, blue, if enabled { alpha } else { alpha * 0.35 }] - }; + let faded = |color| if enabled { color } else { faded(color) }; let id = ui.open( part, Spec { diff --git a/crates/ui/src/tests.rs b/crates/ui/src/tests.rs index 6d13f603bc016374b6ec65446a28c2ffc5f527c6..22e36ee77a917b6ce1a7c54a07726c151e9ed9fa 100644 --- a/crates/ui/src/tests.rs +++ b/crates/ui/src/tests.rs @@ -605,8 +605,8 @@ fn focused_field(text: &str) -> (Ui, String) { /// Moves the pointer over the caret before byte `index` of the field's `text`. fn point_to(ui: &mut Ui, text: &str, index: usize) { let size = ui.theme.font_size; - let (texts, frame) = ui.texts(); - let layout = &texts.label(text, size, false, None, frame).layout; + let (texts, frame) = (&mut ui.texts, ui.frame); + let layout = &texts.label(text, size, frame).layout; let x = parley::editing::Cursor::from_byte_index(layout, index, parley::Affinity::Downstream) .geometry(layout, 1.0) .x0 as f32; @@ -2817,7 +2817,7 @@ fn tab_row(ui: &mut Ui, active: usize) -> Id { ]; let tabs: Vec<_> = names .iter() - .map(|name| (*name, [0.5, 0.6, 0.9, 1.0])) + .map(|name| (*name, [0.5, 0.6, 0.9, 1.0], false)) .collect(); let section = ui.theme.section([0.5, 0.6, 0.9, 1.0]); let row = Id::ROOT.child("tabs"); diff --git a/crates/ui/src/text.rs b/crates/ui/src/text.rs index c8076a82a2281aacdc8eaa357b53abd75f458a28..57274e5e991c0b47b145ba5b1d9f90e07d48f6a1 100644 --- a/crates/ui/src/text.rs +++ b/crates/ui/src/text.rs @@ -24,19 +24,12 @@ pub(crate) struct Texts { } impl Texts { - /// `size` is in logical pixels; `font` names a family to prefer over the interface's. - pub fn label( - &mut self, - text: &str, - size: f32, - bold: bool, - font: Option<&str>, - frame: u64, - ) -> Rc