From 130e7b1bd5ad7e9aac3a3b4e1dadb6ab3452cb27 Mon Sep 17 00:00:00 2001 From: clover caruso Date: Sun, 27 Sep 2026 11:00:59 -0700 Subject: [PATCH] feat(ios): saving, embedded SMB, format bar, search and page management The iOS app saves through the notebook session under the owner's name, opens notebooks over the embedded SMB client with Keychain credentials and works offline, coordinates Files access, and adds a format bar, tag toggles, pictures, notebook search, page/section creation and deletion, the date picker and iPad shortcuts. One GPU device and text engines are shared across page views, and the host owns scrolling. Assisted-by: claude-opus-5.5 Assisted-by: claude-opus-5 --- apps/ios/Info.plist | 8 + apps/ios/Snowbound/App.swift | 429 +++---- apps/ios/Snowbound/CanvasView.swift | 194 ++- apps/ios/Snowbound/FormatBar.swift | 113 ++ apps/ios/Snowbound/Lists.swift | 531 ++++++++ apps/ios/Snowbound/Notebooks.swift | 424 +++++++ apps/ios/Snowbound/PageViewController.swift | 306 +++++ apps/ios/Snowbound/Script.swift | 40 +- apps/ios/Snowbound/Server.swift | 258 ++++ crates/mobile/Cargo.toml | 5 +- crates/mobile/include/snowbound.h | 52 +- crates/mobile/src/lib.rs | 828 ++++++++----- crates/mobile/src/library.rs | 1204 +++++++++++++++++++ crates/mobile/src/tests.rs | 526 +++++++- 14 files changed, 4220 insertions(+), 698 deletions(-) create mode 100644 apps/ios/Snowbound/FormatBar.swift create mode 100644 apps/ios/Snowbound/Lists.swift create mode 100644 apps/ios/Snowbound/Notebooks.swift create mode 100644 apps/ios/Snowbound/PageViewController.swift create mode 100644 apps/ios/Snowbound/Server.swift create mode 100644 crates/mobile/src/library.rs diff --git a/apps/ios/Info.plist b/apps/ios/Info.plist index fa335c4fb30e8abae2a23db7b8149bd129770c09..ab475a47a5afa0062c2d7d809d6a6a1299034982 100644 --- a/apps/ios/Info.plist +++ b/apps/ios/Info.plist @@ -4,11 +4,19 @@ CADisableMinimumFrameDurationOnPhone + LSSupportsOpeningDocumentsInPlace + + NSCameraUsageDescription + Take a photo to put on a page. + NSLocalNetworkUsageDescription + Snowbound opens notebooks on file servers on your network. UIApplicationSceneManifest UIApplicationSupportsMultipleScenes + UIFileSharingEnabled + UILaunchScreen UISupportedInterfaceOrientations diff --git a/apps/ios/Snowbound/App.swift b/apps/ios/Snowbound/App.swift index ffc4257758c4ff3a0c04f76b444a8f862fcc8530..88845507d9930415dab3827ebcffc87e2a38aa6e 100644 --- a/apps/ios/Snowbound/App.swift +++ b/apps/ios/Snowbound/App.swift @@ -1,8 +1,30 @@ import UIKit -import UniformTypeIdentifiers + +/// Runs the library's file access under `NSFileCoordinator`, so file providers such as +/// iCloud Drive download before it reads and learn of what it writes. +private let coordinate: sb_coordinator = { path, write, body, context in + guard let path, let body else { return } + let url = URL(fileURLWithPath: String(cString: path)) + var error: NSError? + if write { + NSFileCoordinator().coordinate(writingItemAt: url, options: .forMerging, error: &error) { _ in body(context) } + } else { + NSFileCoordinator().coordinate(readingItemAt: url, options: [], error: &error) { _ in body(context) } + } +} @main final class AppDelegate: UIResponder, UIApplicationDelegate { + func application( + _ application: UIApplication, didFinishLaunchingWithOptions options: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + sb_set_coordinator(coordinate) + #if DEBUG + Presenter.watch() + #endif + return true + } + func application( _ application: UIApplication, configurationForConnecting session: UISceneSession, @@ -12,331 +34,182 @@ final class AppDelegate: UIResponder, UIApplicationDelegate { configuration.delegateClass = SceneDelegate.self return configuration } + + /// Edits are in the cache once stored; this gives publishing a moment before the app goes. + func applicationWillTerminate(_ application: UIApplication) { + NotificationCenter.default.post(name: PageViewController.leaving, object: nil) + for section in Section.all { _ = section.flush(2) } + } +} + +/// Where the reader was, reopened on the next launch as Notes reopens its last note. +private struct Place: Codable { + let notebook: UUID + let section: String + let page: String? + + static let key = "place" + + static var saved: Place? { + get { UserDefaults.standard.data(forKey: key).flatMap { try? JSONDecoder().decode(Place.self, from: $0) } } + set { UserDefaults.standard.set(try? JSONEncoder().encode(newValue), forKey: key) } + } } -/// Notebook, section and page columns as Notes lays out folders, notes and a note: side by -/// side on a wide screen, a navigation stack on a phone. +/// Notebooks and sections, pages and a page, as Notes lays out folders, notes and a note: +/// side by side on a wide screen, a navigation stack on a phone. final class SceneDelegate: UIResponder, UIWindowSceneDelegate, UISplitViewControllerDelegate { var window: UIWindow? - private let split = UISplitViewController(style: .tripleColumn) - private let sections = SectionsViewController() + private let split = RootViewController(style: .tripleColumn) + private let notebooks = NotebooksViewController() private let pages = PagesViewController() private var showingPage = false + private var background = UIBackgroundTaskIdentifier.invalid func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options: UIScene.ConnectionOptions) { guard let scene = scene as? UIWindowScene else { return } let window = UIWindow(windowScene: scene) split.delegate = self + split.scene = self split.preferredDisplayMode = .oneBesideSecondary split.preferredSplitBehavior = .tile - split.setViewController(sections, for: .primary) + split.setViewController(notebooks, for: .primary) split.setViewController(pages, for: .supplementary) split.setViewController(UIViewController(), for: .secondary) - sections.onOpen = { [weak self] section in self?.show(section: section) } - pages.onOpen = { [weak self] section, page in self?.show(section: section, page: page) } + notebooks.onOpen = { [weak self] tab, notebook in self?.open(tab, of: notebook) } + pages.onOpen = { [weak self] section, id, find in self?.show(section: section, page: id, find: find) } + pages.onOpenSection = { [weak self] notebook, path, id, find in + guard let tab = notebook.tabs.first(where: { $0.path == path }) else { return } + self?.open(tab, of: notebook, page: id, find: find) + } window.rootViewController = split window.makeKeyAndVisible() self.window = window - sections.load(Library.opening()) + Notebooks.load() + notebooks.reload() + for notebook in Notebooks.all { + notebook.open { [weak self] in + self?.notebooks.reload() + self?.restore(notebook) + } + } + } + + /// Reopens the section and page shown last, or the scripted one. + private func restore(_ notebook: Notebook) { let environment = ProcessInfo.processInfo.environment - // The split view collapses into a stack once the window is on screen. - DispatchQueue.main.async { [self] in - if let index = environment["SNOWBOUND_SECTION"].flatMap(Int.init), let notebook = sections.notebook, - index < notebook.sections.count - { - show(section: notebook.sections[index]) - if let page = environment["SNOWBOUND_PAGE"].flatMap(Int.init), let section = pages.section, - page < section.headings.count - { - show(section: section, page: page) + if let index = environment["SNOWBOUND_SECTION"].flatMap(Int.init) { + let readable = notebook.tabs.filter(\.readable) + guard index < readable.count else { return } + open(readable[index], of: notebook) { [weak self] section in + guard let page = environment["SNOWBOUND_PAGE"].flatMap(Int.init), page < section.rows.count else { + return } + self?.show(section: section, page: section.rows[page].id) } + return } + guard let place = Place.saved, place.notebook == notebook.id, + let tab = notebook.tabs.first(where: { $0.path == place.section }) + else { return } + open(tab, of: notebook, page: place.page) } - private func show(section tab: Tab) { - pages.load(tab) + func open( + _ tab: Tab, of notebook: Notebook, page: String? = nil, find: String? = nil, + then: ((Section) -> Void)? = nil + ) { + guard Author.name != nil else { + return Author.ask(from: split) { [weak self] in self?.open(tab, of: notebook, page: page, find: find, then: then) } + } + pages.loading(tab) split.show(.supplementary) + Section.open(tab, of: notebook) { [weak self] section, problem in + guard let self else { return } + guard let section else { + pages.failed(tab, problem) + return + } + pages.load(section) + notebooks.select(tab, of: notebook) + Place.saved = Place(notebook: notebook.id, section: tab.path, page: page) + if let page, section.row(of: page) != nil { + show(section: section, page: page, find: find) + } + then?(section) + } } - private func show(section: Section, page: Int) { + func show(section: Section, page: String, find: String? = nil, titleFocus: Bool = false) { showingPage = true - let controller = PageViewController(section: section, page: page) + let controller = PageViewController(section: section, page: page, find: find, titleFocus: titleFocus) + controller.onOpen = { [weak self] id in self?.show(section: section, page: id) } split.setViewController(UINavigationController(rootViewController: controller), for: .secondary) split.show(.secondary) + pages.select(page) + Place.saved = Place(notebook: section.notebook.id, section: section.tab.path, page: page) } - /// A phone opens on the notebook's sections, as Notes opens on its folders. + /// Adds a page to the open section and opens it, its title ready for typing. + func newPage(subpage: Bool = false) { + guard let section = pages.section else { return } + let parent = subpage ? (Place.saved?.page).flatMap { section.row(of: $0)?.row.id } : nil + guard let id = section.newPage(under: parent) else { return } + show(section: section, page: id, titleFocus: true) + } + + func search() { + split.show(.supplementary) + pages.startSearching() + } + + /// A phone opens on the notebooks, as Notes opens on its folders. func splitViewController( _ svc: UISplitViewController, topColumnForCollapsingToProposedTopColumn proposedTopColumn: UISplitViewController.Column ) -> UISplitViewController.Column { showingPage ? proposedTopColumn : .primary } -} - -/// A notebook's listing from `sb_notebook`. -struct Notebook: Decodable { - let name: String - let sections: [Tab] -} - -struct Tab: Decodable { - let name: String - let path: String - /// The section group holding it, `/`-separated; empty at the notebook's top. - let group: String - let color: [UInt8] - let readable: Bool - - var uiColor: UIColor { - UIColor(red: CGFloat(color[0]) / 255, green: CGFloat(color[1]) / 255, blue: CGFloat(color[2]) / 255, alpha: 1) - } -} - -/// Where the notebook shown comes from: the one last opened from Files, kept as a bookmark, -/// or the bundled sample. -enum Library { - private static let bookmarkKey = "notebook" - /// The Files location being read, held open while it is shown. - private static var accessed: URL? - static func opening() -> Notebook? { - if let path = ProcessInfo.processInfo.environment["SNOWBOUND_NOTEBOOK"] { return read(path) } - var stale = false - if let data = UserDefaults.standard.data(forKey: bookmarkKey), - let url = try? URL(resolvingBookmarkData: data, bookmarkDataIsStale: &stale), - let notebook = open(url) - { - return notebook + /// 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) + let sections = Section.all + guard !sections.isEmpty, background == .invalid else { return } + background = UIApplication.shared.beginBackgroundTask(withName: "Saving") { [weak self] in + self?.finishBackground() } - // The bundled folder keeps its corpus name. - return Bundle.main.path(forResource: "notebook", ofType: nil).flatMap(read).map { - Notebook(name: "Sample", sections: $0.sections) + DispatchQueue.global(qos: .userInitiated).async { + for section in sections { _ = section.flush(20) } + DispatchQueue.main.async { [weak self] in self?.finishBackground() } } } - /// A notebook folder or section file chosen in Files, remembered for the next launch. - static func open(_ url: URL) -> Notebook? { - accessed?.stopAccessingSecurityScopedResource() - accessed = url.startAccessingSecurityScopedResource() ? url : nil - guard let notebook = read(url.path) else { return nil } - if let data = try? url.bookmarkData() { UserDefaults.standard.set(data, forKey: bookmarkKey) } - return notebook + private func finishBackground() { + guard background != .invalid else { return } + UIApplication.shared.endBackgroundTask(background) + background = .invalid } - private static func read(_ path: String) -> Notebook? { - guard let json = sb_notebook(path) else { return nil } - defer { sb_string_free(json) } - return try? JSONDecoder().decode(Notebook.self, from: Data(String(cString: json).utf8)) + /// Changes made elsewhere while away show at once. + func sceneWillEnterForeground(_ scene: UIScene) { + for section in Section.all { section.wake() } } } -/// A parsed .one section; pages are read-only copies until saving lands. -final class Section { - let handle: OpaquePointer? - let name: String - let color: UIColor - /// Each page's title and level in the page list, 1 at the top. - let headings: [(title: String, level: Int)] - - init(_ tab: Tab) { - handle = sb_section_open(tab.path) - name = tab.name - color = tab.uiColor - headings = handle.map { handle in - (0.. Void)? - - init() { - super.init(style: .insetGrouped) - navigationItem.largeTitleDisplayMode = .always - } - - required init?(coder: NSCoder) { fatalError() } +/// Takes the app's keyboard shortcuts wherever focus is. +final class RootViewController: UISplitViewController { + weak var scene: SceneDelegate? - override func viewDidLoad() { - super.viewDidLoad() - tableView.register(UITableViewCell.self, forCellReuseIdentifier: "section") - let open = UIBarButtonItem( - title: "Open Notebook", image: UIImage(systemName: "folder"), target: self, action: #selector(open)) - navigationItem.rightBarButtonItem = open + override var keyCommands: [UIKeyCommand]? { + [ + UIKeyCommand(title: "New Page", action: #selector(newPage), input: "n", modifierFlags: .command), + UIKeyCommand( + title: "New Subpage", action: #selector(newSubpage), input: "n", modifierFlags: [.command, .shift]), + UIKeyCommand(title: "Search", action: #selector(search), input: "f", modifierFlags: [.command, .alternate]), + ] } - override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - navigationController?.navigationBar.prefersLargeTitles = true - } - - func load(_ notebook: Notebook?) { - self.notebook = notebook - title = notebook?.name - var groups: [(name: String, sections: [Tab])] = [] - for tab in notebook?.sections ?? [] { - if groups.last?.name == tab.group { - groups[groups.count - 1].sections.append(tab) - } else { - groups.append((tab.group, [tab])) - } - } - self.groups = groups - tableView.reloadData() - var empty = UIContentUnavailableConfiguration.empty() - empty.text = notebook == nil ? "No Notebook Open" : "No Sections" - empty.secondaryText = notebook == nil ? "Open a notebook folder or section from Files." : nil - contentUnavailableConfiguration = groups.isEmpty ? empty : nil - } - - @objc private func open() { - let picker = UIDocumentPickerViewController( - forOpeningContentTypes: [.folder, UTType(filenameExtension: "one") ?? .data]) - picker.delegate = self - present(picker, animated: true) - } - - func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { - guard let url = urls.first else { return } - guard let notebook = Library.open(url) else { - let alert = UIAlertController( - title: "Can’t Open Notebook", message: "Choose a OneNote notebook folder or a section file.", - preferredStyle: .alert) - alert.addAction(UIAlertAction(title: "OK", style: .default)) - present(alert, animated: true) - return - } - load(notebook) - } - - override func numberOfSections(in tableView: UITableView) -> Int { groups.count } - - override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { - groups[section].name.isEmpty ? nil : groups[section].name.replacingOccurrences(of: "/", with: " › ") - } - - override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - groups[section].sections.count - } - - override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let tab = groups[indexPath.section].sections[indexPath.row] - let cell = tableView.dequeueReusableCell(withIdentifier: "section", for: indexPath) - var content = cell.defaultContentConfiguration() - content.text = tab.name - content.image = UIImage(systemName: tab.readable ? "rectangle.portrait.fill" : "lock.fill") - content.imageProperties.tintColor = tab.uiColor - if !tab.readable { - content.secondaryText = "Can’t be opened here" - content.textProperties.color = .secondaryLabel - } - cell.contentConfiguration = content - cell.accessoryType = tab.readable ? .disclosureIndicator : .none - cell.selectionStyle = tab.readable ? .default : .none - return cell - } - - override func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? { - groups[indexPath.section].sections[indexPath.row].readable ? indexPath : nil - } - - override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - onOpen?(groups[indexPath.section].sections[indexPath.row]) - } -} - -final class PagesViewController: UITableViewController { - private(set) var section: Section? - var onOpen: ((Section, Int) -> Void)? - - init() { - super.init(style: .plain) - } - - required init?(coder: NSCoder) { fatalError() } - - override func viewDidLoad() { - super.viewDidLoad() - tableView.register(UITableViewCell.self, forCellReuseIdentifier: "page") - } - - func load(_ tab: Tab) { - let section = Section(tab) - self.section = section - title = section.name - tableView.reloadData() - var empty = UIContentUnavailableConfiguration.empty() - empty.text = section.handle == nil ? "Can’t Open Section" : "No Pages" - contentUnavailableConfiguration = section.headings.isEmpty ? empty : nil - } - - override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - self.section?.headings.count ?? 0 - } - - override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let heading = section!.headings[indexPath.row] - let cell = tableView.dequeueReusableCell(withIdentifier: "page", for: indexPath) - var content = cell.defaultContentConfiguration() - content.text = heading.title.isEmpty ? "Untitled Page" : heading.title - if heading.level > 1 { content.textProperties.color = .secondaryLabel } - // Subpages sit under their page, indented as in OneNote's page list. - content.directionalLayoutMargins.leading += CGFloat(heading.level - 1) * 20 - cell.contentConfiguration = content - return cell - } - - override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - guard let section else { return } - onOpen?(section, indexPath.row) - } -} - -final class PageViewController: UIViewController { - private let section: Section - private let page: Int - private lazy var canvas = CanvasView(section: section, page: page) - private lazy var done = UIBarButtonItem( - systemItem: .done, primaryAction: UIAction { [weak self] _ in _ = self?.canvas.resignFirstResponder() }) - private lazy var undo = UIBarButtonItem( - systemItem: .undo, primaryAction: UIAction { [weak self] _ in self?.canvas.undo(redo: false) }) - private lazy var redo = UIBarButtonItem( - systemItem: .redo, primaryAction: UIAction { [weak self] _ in self?.canvas.undo(redo: true) }) - - init(section: Section, page: Int) { - self.section = section - self.page = page - super.init(nibName: nil, bundle: nil) - title = section.headings[page].title - navigationItem.largeTitleDisplayMode = .never - } - - required init?(coder: NSCoder) { fatalError() } - - override func loadView() { - view = canvas - canvas.onChange = { [weak self] in self?.editingChanged() } - } - - /// Notes' checkmark ends editing and puts the keyboard away; undo and redo sit beside it. - private func editingChanged() { - guard canvas.isFirstResponder else { - navigationItem.rightBarButtonItems = nil - return - } - undo.isEnabled = canvas.canUndo(redo: false) - redo.isEnabled = canvas.canUndo(redo: true) - if navigationItem.rightBarButtonItems?.first !== done { - navigationItem.rightBarButtonItems = [done, redo, undo] - } - } + @objc private func newPage() { scene?.newPage() } + @objc private func newSubpage() { scene?.newPage(subpage: true) } + @objc private func search() { scene?.search() } } diff --git a/apps/ios/Snowbound/CanvasView.swift b/apps/ios/Snowbound/CanvasView.swift index f9c7f4fedbc7fe8491756f738567da42c0db67f8..409d7c20738d158f69e01ebb6953e5543c69331b 100644 --- a/apps/ios/Snowbound/CanvasView.swift +++ b/apps/ios/Snowbound/CanvasView.swift @@ -38,6 +38,11 @@ private extension UITextRange { var upper: Int { (self as! Range).hi } } +/// The page date's fields, from `sb_view_date_request`. +enum DateField: Int8 { + case date, time +} + /// What a touch lands on; see `sb_view_target`. private enum Target: UInt8 { case page, activeText, text, grip @@ -64,7 +69,7 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI private static let zoomKey = "zoom" private let section: Section - private let page: Int + private let page: String private let metal = MetalView() /// Sized to the page content at 100%, so the scroll view's zoom scale is the page zoom. private let content = UIView() @@ -106,8 +111,21 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI lazy var tokenizer: UITextInputTokenizer = LineTokenizer(canvas: self) /// Called when editing starts or stops and after each change, for the bar buttons. var onChange: (() -> Void)? + /// Called once the page is first on screen. + var onOpened: (() -> Void)? + /// Called when a tap on the page date asks to change it, with the date it shows. + var onDate: ((DateField, Date) -> Void)? + /// Formatting above the keyboard. + let formatBar = FormatBar() + /// Room above the page for a bar over it. + var topInset: CGFloat = 0 { + didSet { + contentInset.top = topInset + home() + } + } - init(section: Section, page: Int) { + init(section: Section, page: String) { self.section = section self.page = page super.init(frame: .zero) @@ -143,6 +161,8 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI self, selector: #selector(keyboardChanged), name: UIResponder.keyboardWillChangeFrameNotification, object: nil) registerForTraitChanges([UITraitUserInterfaceStyle.self]) { (view: CanvasView, _) in view.paper() } + formatBar.onApply = { [weak self] command in self?.apply(command) } + formatBar.onDismiss = { [weak self] in _ = self?.resignFirstResponder() } } required init?(coder: NSCoder) { fatalError() } @@ -168,9 +188,9 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI guard (metal.layer as! CAMetalLayer).drawableSize != pixels else { return } sb_view_resize(handle, Float(bounds.width), Float(bounds.height), Float(scale)) transform() - } else if let sectionHandle = section.handle { + } else { handle = sb_view_new( - Unmanaged.passUnretained(metal.layer).toOpaque(), sectionHandle, page, + Unmanaged.passUnretained(metal.layer).toOpaque(), section.handle, page, Float(bounds.width), Float(bounds.height), Float(scale)) guard let handle else { return } sb_view_focus(handle, false) @@ -181,6 +201,7 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI zoomScale = resting left = skip * resting home() + onOpened?() #if DEBUG runScript() #endif @@ -203,7 +224,11 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI if sb_view_frame_pending(handle) { dirty = true } guard dirty else { return } dirty = false + // A frame presented with a transaction shows only once one commits, which an edit + // that moves no UIKit view would not otherwise cause. + CATransaction.begin() sb_view_render(handle) + CATransaction.commit() } /// The page follows the system's appearance, on the desktop's dark paper in dark mode. @@ -243,23 +268,21 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI dirty = true } - /// The zoom the reader last pinched to, within reason; before that, OneNote's 100%, where its 11 pt - /// body text shows at 15 points, or up to 140% where the whole page fits across. + /// 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 { - if let zoom = UserDefaults.standard.object(forKey: Self.zoomKey) as? Double { - // A page opens readable however far the last one was pinched. - return min(2, max(0.75, zoom)) - } - let width = bounds.inset(by: safeAreaInsets).width - return min(1.4, max(1, width / (content.bounds.width - margin))) + 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 empty page left of its first outline, which a phone skips on opening. + /// The empty page left of its first outline, which a phone skips on opening, keeping + /// room for the tags OneNote hangs left of the text. private var margin: CGFloat { guard let handle else { return 0 } var block: [Float] = [0, 0, 0, 0] guard sb_view_block(handle, 0, 0, &block) else { return 0 } - return max(0, CGFloat(block[0]) - origin.x - 12) + return max(0, CGFloat(block[0]) - origin.x - 34) } /// Until the reader scrolls, the page's corner stays below the navigation bar as its @@ -346,9 +369,10 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI override func gestureRecognizerShouldBegin(_ recognizer: UIGestureRecognizer) -> Bool { switch recognizer { case tap: - [.page, .grip].contains(target(recognizer.location(in: self))) && !onHandle(recognizer.location(in: self)) + !readOnly && [.page, .grip].contains(target(recognizer.location(in: self))) + && !onHandle(recognizer.location(in: self)) case doubleTap: target(recognizer.location(in: self)) == .page - case grip: target(touchStart) == .grip + case grip: !readOnly && target(touchStart) == .grip case handleDrag: onHandle(touchStart) default: super.gestureRecognizerShouldBegin(recognizer) } @@ -357,6 +381,7 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI /// The system's text interaction works in the outline taking input; a touch on another /// outline's text focuses it first, as a tap there would. func interactionShouldBegin(_ interaction: UITextInteraction, at point: CGPoint) -> Bool { + if readOnly { return false } if onHandle(point) { return true } switch target(point) { case .activeText: break @@ -383,7 +408,19 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI /// starts a new one. func tap(at point: CGPoint) { press(at: point) - if !isFirstResponder { _ = becomeFirstResponder() } + var seconds: Int64 = 0 + if let handle, let field = DateField(rawValue: sb_view_date_request(handle, &seconds)) { + onDate?(field, Date(timeIntervalSince1970: TimeInterval(seconds))) + } else if !isFirstResponder { + _ = becomeFirstResponder() + } + } + + /// Gives the page `date`, which the date and time under its title show. + func changeDate(_ date: Date) { + guard let handle else { return } + let (day, time) = titleDate(date) + edit(external: true) { sb_view_change_date(handle, Int64(date.timeIntervalSince1970.rounded()), day, time) } } private func press(at point: CGPoint) { @@ -460,7 +497,6 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI inputDelegate?.textWillChange(self) } let changed = change() - dropEdits() if external { inputDelegate?.textDidChange(self) inputDelegate?.selectionDidChange(self) @@ -471,32 +507,36 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI revealCaret() } display.setNeedsSelectionUpdate() - onChange?() + changedSelection() } - /// Takes the edit the page recorded, so its ops do not pile up; the host drops it - /// until it holds a notebook session that stores edits. - private func dropEdits() { - guard let handle, let edit = sb_view_edit(handle) else { return } - sb_string_free(edit) + /// Tells the bar buttons and the format bar what the selection now has. + private func changedSelection() { + if let handle { formatBar.show(sb_view_format(handle)) } + onChange?() } private func revealCaret() { guard handle != nil, isFirstResponder, let range = selectedTextRange else { return } - let caret = caretRect(for: range.end) - scrollRectToVisible(caret.insetBy(dx: -8, dy: -16), animated: false) + reveal(caretRect(for: range.end)) + } + + private func reveal(_ rect: CGRect) { + scrollRectToVisible(rect.insetBy(dx: -8, dy: -16), animated: false) } // MARK: Responder - override var canBecomeFirstResponder: Bool { handle != nil } + override var canBecomeFirstResponder: Bool { handle != nil && !readOnly } + + override var inputAccessoryView: UIView? { formatBar } override func becomeFirstResponder() -> Bool { guard super.becomeFirstResponder() else { return false } if let handle { sb_view_focus(handle, true) } display.isActivated = true dirty = true - onChange?() + changedSelection() return true } @@ -569,15 +609,100 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI edit(external: true) { choose(Range(0, endOfDocument.offset)) } } - override func toggleBoldface(_ sender: Any?) { toggle(0) } - override func toggleItalics(_ sender: Any?) { toggle(1) } - override func toggleUnderline(_ sender: Any?) { toggle(2) } + override func toggleBoldface(_ sender: Any?) { apply(0) } + override func toggleItalics(_ sender: Any?) { apply(1) } + override func toggleUnderline(_ sender: Any?) { apply(2) } - private func toggle(_ style: UInt8) { + // MARK: Page + + /// A conflict page, which shows what is stored and takes no edits. + var readOnly: Bool { handle.map(sb_view_read_only) ?? false } + + /// The title as typed; nil on a page without an editable title. + var pageTitle: String? { handle.flatMap { take(sb_view_title($0)) } } + + var pageText: String { handle.flatMap { take(sb_view_page_text($0)) } ?? "" } + + /// Applies `sb_view_apply` formatting to the selection. + func apply(_ command: UInt8) { guard let handle else { return } - edit(external: true) { sb_view_toggle(handle, style) } + edit(external: true) { sb_view_apply(handle, command) } + } + + /// Shows the page as stored; with `discard`, after an edit was refused. + func reload(discard: Bool) { + guard let handle else { return } + edit(external: true) { sb_view_reload(handle, discard) } + } + + /// 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 + } + if found { reveal(selectedTextRange.map { firstRect(for: $0) } ?? .zero) } + return found + } + + /// 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 } + /// Puts a JPEG or PNG after the caret's paragraph at `size` points. + func insertPicture(_ data: Data, size: CGSize) { + guard let handle else { return } + edit(external: true) { + data.withUnsafeBytes { bytes in + sb_view_insert_picture( + handle, bytes.bindMemory(to: UInt8.self).baseAddress, bytes.count, Float(size.width), + Float(size.height)) + } + } + } + + /// Commits marked text, so it is stored before the app leaves the screen. + func commitComposition() { + guard markedTextRange != nil else { return } + inputDelegate?.textWillChange(self) + unmarkText() + inputDelegate?.textDidChange(self) + } + + override var keyCommands: [UIKeyCommand]? { + guard isFirstResponder else { return nil } + // Ctrl+1 to Ctrl+9 in OneNote, where the Mac's Command stands for Control. + let tags = FormatBar.tags.enumerated().map { index, name in + let command = UIKeyCommand( + title: name, action: #selector(tagged), input: "\(index + 1)", modifierFlags: .command, + propertyList: index) + command.wantsPriorityOverSystemBehavior = true + return command + } + let outdent = UIKeyCommand( + title: "Decrease Indent", action: #selector(outdented), input: "\t", modifierFlags: .shift) + outdent.wantsPriorityOverSystemBehavior = true + let done = UIKeyCommand(title: "Done", action: #selector(finished), input: UIKeyCommand.inputEscape) + return tags + [outdent, done] + } + + @objc private func tagged(_ command: UIKeyCommand) { + guard let index = command.propertyList as? Int else { return } + apply(16 + UInt8(index)) + } + + @objc private func outdented() { apply(7) } + @objc private func finished() { _ = resignFirstResponder() } + // MARK: Coordinates /// A point in the scroll view's bounds as the canvas view's points from its corner. @@ -639,7 +764,7 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI _ = choose(newValue) display.setNeedsSelectionUpdate() dirty = true - onChange?() + changedSelection() } } @@ -663,7 +788,6 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI func unmarkText() { guard let handle else { return } sb_unmark(handle) - dropEdits() dirty = true } diff --git a/apps/ios/Snowbound/FormatBar.swift b/apps/ios/Snowbound/FormatBar.swift new file mode 100644 index 0000000000000000000000000000000000000000..56ff25f1127105b2ed585a93ef0855b1ed301167 --- /dev/null +++ b/apps/ios/Snowbound/FormatBar.swift @@ -0,0 +1,113 @@ +import UIKit + +/// Formatting above the keyboard, as Notes offers it: tags and lists first, then text +/// styles, indentation, tags and pictures, the keyboard's dismissal at the end. +final class FormatBar: UIInputView { + /// `sb_view_apply` commands and `sb_view_format` bits. + private static let buttons: [(symbol: String, label: String, command: UInt8)] = [ + ("checklist", "To Do", 16), + ("bold", "Bold", 0), + ("italic", "Italic", 1), + ("underline", "Underline", 2), + ("strikethrough", "Strikethrough", 3), + ("list.bullet", "Bullets", 4), + ("list.number", "Numbering", 5), + ("decrease.indent", "Decrease Indent", 7), + ("increase.indent", "Increase Indent", 6), + ] + /// OneNote 2010's default tags in their order, Ctrl+1 to Ctrl+9. + static let tags = [ + "To Do", "Important", "Question", "Remember for Later", "Definition", "Highlight", "Contact", "Address", + "Phone Number", + ] + + private var toggles: [(UIButton, UInt8)] = [] + var onApply: ((UInt8) -> Void)? + /// Asks for a picture from the camera (true) or the photo library. + var onPicture: ((Bool) -> Void)? + var onDismiss: (() -> Void)? + + init() { + super.init(frame: CGRect(x: 0, y: 0, width: 0, height: 48), inputViewStyle: .keyboard) + allowsSelfSizing = true + // Over the page when a hardware keyboard hides the software one. + let backdrop = UIVisualEffectView(effect: UIBlurEffect(style: .systemChromeMaterial)) + backdrop.frame = bounds + backdrop.autoresizingMask = [.flexibleWidth, .flexibleHeight] + addSubview(backdrop) + let row = UIStackView() + row.spacing = 2 + for (symbol, label, command) in Self.buttons { + let button = Self.button(symbol, label) + button.addAction(UIAction { [weak self] _ in self?.onApply?(command) }, for: .primaryActionTriggered) + toggles.append((button, command)) + row.addArrangedSubview(button) + } + let tag = Self.button("tag", "Tags") + tag.menu = UIMenu(children: Self.tags.enumerated().map { index, name in + UIAction(title: name) { [weak self] _ in self?.onApply?(16 + UInt8(index)) } + }) + tag.showsMenuAsPrimaryAction = true + row.addArrangedSubview(tag) + let picture = Self.button("photo", "Insert Picture") + picture.menu = UIMenu(children: [ + UIAction(title: "Photo Library", image: UIImage(systemName: "photo.on.rectangle")) { [weak self] _ in + self?.onPicture?(false) + }, + UIAction( + title: "Take Photo", image: UIImage(systemName: "camera"), + attributes: UIImagePickerController.isSourceTypeAvailable(.camera) ? [] : .disabled + ) { [weak self] _ in self?.onPicture?(true) }, + ]) + picture.showsMenuAsPrimaryAction = true + row.addArrangedSubview(picture) + let scroll = UIScrollView() + scroll.showsHorizontalScrollIndicator = false + scroll.addSubview(row) + let dismiss = Self.button("keyboard.chevron.compact.down", "Hide Keyboard") + dismiss.addAction(UIAction { [weak self] _ in self?.onDismiss?() }, for: .primaryActionTriggered) + for view in [scroll, dismiss, row] as [UIView] { view.translatesAutoresizingMaskIntoConstraints = false } + addSubview(scroll) + addSubview(dismiss) + NSLayoutConstraint.activate([ + heightAnchor.constraint(equalToConstant: 48), + scroll.leadingAnchor.constraint(equalTo: safeAreaLayoutGuide.leadingAnchor, constant: 4), + scroll.topAnchor.constraint(equalTo: topAnchor), + scroll.bottomAnchor.constraint(equalTo: bottomAnchor), + scroll.trailingAnchor.constraint(equalTo: dismiss.leadingAnchor, constant: -4), + dismiss.trailingAnchor.constraint(equalTo: safeAreaLayoutGuide.trailingAnchor, constant: -4), + dismiss.centerYAnchor.constraint(equalTo: centerYAnchor), + row.leadingAnchor.constraint(equalTo: scroll.contentLayoutGuide.leadingAnchor), + row.trailingAnchor.constraint(equalTo: scroll.contentLayoutGuide.trailingAnchor), + 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() } + + private static func button(_ symbol: String, _ label: String) -> UIButton { + var configuration = UIButton.Configuration.plain() + configuration.image = UIImage(systemName: symbol) + configuration.preferredSymbolConfigurationForImage = UIImage.SymbolConfiguration(pointSize: 17) + configuration.baseForegroundColor = .label + configuration.cornerStyle = .medium + let button = UIButton(configuration: configuration) + button.accessibilityLabel = label + button.widthAnchor.constraint(equalToConstant: 42).isActive = true + button.configurationUpdateHandler = { button in + button.configuration?.background.backgroundColor = + button.isSelected ? UIColor.tintColor.withAlphaComponent(0.18) : .clear + button.configuration?.baseForegroundColor = button.isSelected ? .tintColor : .label + } + return button + } + + /// Marks what the selection has, from `sb_view_format`. + func show(_ bits: UInt32) { + for (button, command) in toggles { + button.isSelected = bits & (1 << UInt32(command)) != 0 + } + } +} diff --git a/apps/ios/Snowbound/Lists.swift b/apps/ios/Snowbound/Lists.swift new file mode 100644 index 0000000000000000000000000000000000000000..8d3fc289f0f0e3a9340b79a67eca2c848b22f3b8 --- /dev/null +++ b/apps/ios/Snowbound/Lists.swift @@ -0,0 +1,531 @@ +import UIKit +import UniformTypeIdentifiers + +/// Every open notebook with its sections, as Notes lists each account's folders. +final class NotebooksViewController: UITableViewController, UIDocumentPickerDelegate { + private enum Item { + case group(String, depth: Int) + case section(Tab, depth: Int) + case opening + case problem(String) + } + + private var items: [[Item]] = [] + private var selected: (notebook: UUID, path: String)? + var onOpen: ((Tab, Notebook) -> Void)? + + init() { + super.init(style: .insetGrouped) + title = "Notebooks" + navigationItem.largeTitleDisplayMode = .always + } + + required init?(coder: NSCoder) { fatalError() } + + override func viewDidLoad() { + super.viewDidLoad() + tableView.register(UITableViewCell.self, forCellReuseIdentifier: "row") + let add = UIMenu(children: [ + UIAction(title: "Open from Files…", image: UIImage(systemName: "folder")) { [weak self] _ in + self?.openFromFiles() + }, + UIAction(title: "Connect to Server…", image: UIImage(systemName: "server.rack")) { [weak self] _ in + self?.connect() + }, + UIAction(title: "Change Your Name…", image: UIImage(systemName: "person.crop.circle")) { [weak self] _ in + guard let self else { return } + Author.ask(from: self) {} + }, + ]) + navigationItem.rightBarButtonItem = UIBarButtonItem( + title: "Add Notebook", image: UIImage(systemName: "plus"), menu: add) + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + navigationController?.navigationBar.prefersLargeTitles = true + } + + func reload() { + items = Notebooks.all.map { notebook in + if notebook.handle == nil { + return [notebook.problem.map(Item.problem) ?? .opening] + } + var rows: [Item] = [] + var group = "" + for tab in notebook.tabs { + if tab.group != group { + // Each group level not yet shown gets its own row. + let parts = tab.group.split(separator: "/").map(String.init) + let shown = group.split(separator: "/").map(String.init) + let common = zip(parts, shown).prefix { $0 == $1 }.count + for depth in common.. Int { items.count } + + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + items[section].count + } + + override func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { + let notebook = Notebooks.all[section] + var title = UIButton.Configuration.plain() + title.title = notebook.name + title.image = UIImage(systemName: "ellipsis.circle") + title.imagePlacement = .trailing + title.imagePadding = 6 + title.contentInsets = NSDirectionalEdgeInsets(top: 8, leading: 4, bottom: 4, trailing: 4) + title.baseForegroundColor = .label + title.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { + var attributes = $0 + attributes.font = UIFont.preferredFont(forTextStyle: .title3).withTraits(.traitBold) + return attributes + } + let button = UIButton(configuration: title) + button.contentHorizontalAlignment = .leading + button.showsMenuAsPrimaryAction = true + button.menu = UIMenu(children: [ + UIAction(title: "New Section…", image: UIImage(systemName: "plus.rectangle.portrait")) { + [weak self] _ in self?.newSection(in: notebook) + }, + UIAction(title: "Close Notebook", image: UIImage(systemName: "xmark.circle"), attributes: .destructive) { + [weak self] _ in + Notebooks.remove(notebook) + self?.reload() + }, + ]) + return button + } + + override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: "row", for: indexPath) + var content = cell.defaultContentConfiguration() + cell.accessoryType = .none + cell.selectionStyle = .none + cell.accessoryView = nil + switch items[indexPath.section][indexPath.row] { + case .group(let name, let depth): + content.text = name + content.image = UIImage(systemName: "folder") + content.textProperties.color = .secondaryLabel + content.directionalLayoutMargins.leading += CGFloat(depth) * 20 + case .section(let tab, let depth): + content.text = tab.name + content.image = UIImage(systemName: tab.readable ? "rectangle.portrait.fill" : "lock.fill") + content.imageProperties.tintColor = tab.uiColor + content.directionalLayoutMargins.leading += CGFloat(depth) * 20 + if tab.readable { + cell.accessoryType = .disclosureIndicator + cell.selectionStyle = .default + } else { + content.secondaryText = "Can’t be opened here" + content.textProperties.color = .secondaryLabel + } + + case .opening: + content.text = "Opening…" + content.textProperties.color = .secondaryLabel + let spinner = UIActivityIndicatorView(style: .medium) + spinner.startAnimating() + cell.accessoryView = spinner + case .problem(let problem): + content.text = "Can’t Open Notebook" + content.secondaryText = problem + content.image = UIImage(systemName: "exclamationmark.triangle") + content.imageProperties.tintColor = .systemOrange + cell.accessoryView = UIImageView(image: UIImage(systemName: "arrow.clockwise")) + cell.selectionStyle = .default + } + cell.contentConfiguration = content + return cell + } + + override func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? { + switch items[indexPath.section][indexPath.row] { + case .section(let tab, _): tab.readable ? indexPath : nil + case .problem: indexPath + default: nil + } + } + + override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + let notebook = Notebooks.all[indexPath.section] + switch items[indexPath.section][indexPath.row] { + case .section(let tab, _): onOpen?(tab, notebook) + case .problem: + items[indexPath.section] = [.opening] + tableView.reloadSections([indexPath.section], with: .none) + notebook.open { [weak self] in self?.reload() } + default: break + } + } +} + +extension UIFont { + func withTraits(_ traits: UIFontDescriptor.SymbolicTraits) -> UIFont { + fontDescriptor.withSymbolicTraits(traits).map { UIFont(descriptor: $0, size: 0) } ?? self + } +} + +/// A section's pages, subpages indented, each page's conflict pages beneath it. +final class PagesViewController: UITableViewController, UISearchResultsUpdating { + private struct Item { + let row: Row + let version: Row.Version? + var id: String { version?.id ?? row.id } + } + + private(set) var section: Section? + private var items: [Item] = [] + private var selected: String? + private let results = SearchViewController() + private lazy var search = UISearchController(searchResultsController: results) + private var searching: DispatchWorkItem? + /// Opens a page of the section. + var onOpen: ((Section, String, String?) -> Void)? + /// Opens a page of another section of the notebook, found by search. + var onOpenSection: ((Notebook, String, String, String?) -> Void)? + + init() { + super.init(style: .plain) + } + + required init?(coder: NSCoder) { fatalError() } + + override func viewDidLoad() { + super.viewDidLoad() + tableView.register(UITableViewCell.self, forCellReuseIdentifier: "page") + search.searchResultsUpdater = self + search.searchBar.placeholder = "Search Notebook" + navigationItem.searchController = search + navigationItem.hidesSearchBarWhenScrolling = false + results.onOpen = { [weak self] found, query in + guard let self, let section, let notebook = Optional(section.notebook) else { return } + if found.section == section.tab.path { + onOpen?(section, found.page, query) + } else { + onOpenSection?(notebook, found.section, found.page, query) + } + } + let compose = UIBarButtonItem( + title: "New Page", image: UIImage(systemName: "square.and.pencil"), + primaryAction: UIAction { [weak self] _ in self?.newPage(under: nil) }) + toolbarItems = [.flexibleSpace(), compose] + NotificationCenter.default.addObserver( + self, selector: #selector(changed), name: Section.changed, object: nil) + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + navigationController?.setToolbarHidden(section == nil, animated: false) + } + + func loading(_ tab: Tab) { + section = nil + items = [] + title = tab.name + tableView.reloadData() + var loading = UIContentUnavailableConfiguration.loading() + loading.text = "Opening…" + contentUnavailableConfiguration = loading + navigationController?.setToolbarHidden(true, animated: false) + } + + func failed(_ tab: Tab, _ problem: String?) { + var empty = UIContentUnavailableConfiguration.empty() + empty.text = "Can’t Open Section" + empty.secondaryText = problem + contentUnavailableConfiguration = empty + } + + func load(_ section: Section) { + self.section = section + title = section.tab.name + navigationController?.setToolbarHidden(false, animated: false) + reload() + } + + func select(_ id: String) { + selected = id + if let index = items.firstIndex(where: { $0.id == id }) { + tableView.selectRow(at: IndexPath(row: index, section: 0), animated: false, scrollPosition: .none) + } + } + + func startSearching() { + search.isActive = true + DispatchQueue.main.async { self.search.searchBar.becomeFirstResponder() } + } + + private func reload() { + items = (section?.rows ?? []).flatMap { row in + [Item(row: row, version: nil)] + row.versions.map { Item(row: row, version: $0) } + } + tableView.reloadData() + if let selected { select(selected) } + var empty = UIContentUnavailableConfiguration.empty() + empty.text = "No Pages" + contentUnavailableConfiguration = section != nil && items.isEmpty ? empty : nil + } + + @objc private func changed(_ notification: Notification) { + guard notification.object as AnyObject? === section else { return } + reload() + } + + private func newPage(under parent: String?) { + guard let section, let id = section.newPage(under: parent) else { return } + (view.window?.windowScene?.delegate as? SceneDelegate)?.show(section: section, page: id, titleFocus: true) + } + + private func delete(_ item: Item) { + section?.delete(item.id) { _ in } + } + + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { items.count } + + override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let item = items[indexPath.row] + let cell = tableView.dequeueReusableCell(withIdentifier: "page", for: indexPath) + var content = cell.defaultContentConfiguration() + let level = CGFloat(item.row.level - 1) + if let version = item.version { + // OneNote lists a conflict page by whose version it is and when the merge made it. + content.text = version.user.isEmpty ? "Conflicting Version" : version.user + content.secondaryText = version.created.map { + DateFormatter.localizedString( + from: Date(timeIntervalSince1970: $0), dateStyle: .short, timeStyle: .short) + } + content.image = UIImage(systemName: "doc.on.doc") + content.imageProperties.tintColor = .systemOrange + content.textProperties.color = .secondaryLabel + content.directionalLayoutMargins.leading += (level + 1) * 20 + } else { + content.text = item.row.title.isEmpty ? "Untitled Page" : item.row.title + if item.row.title.isEmpty || item.row.level > 1 { content.textProperties.color = .secondaryLabel } + content.directionalLayoutMargins.leading += level * 20 + if !item.row.versions.isEmpty { + content.image = UIImage(systemName: "exclamationmark.triangle.fill") + content.imageProperties.tintColor = .systemOrange + } + } + cell.contentConfiguration = content + return cell + } + + override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + guard let section else { return } + selected = items[indexPath.row].id + onOpen?(section, items[indexPath.row].id, nil) + } + + override func tableView( + _ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath + ) -> UISwipeActionsConfiguration? { + let item = items[indexPath.row] + let delete = UIContextualAction(style: .destructive, title: "Delete") { [weak self] _, _, done in + self?.delete(item) + done(true) + } + delete.image = UIImage(systemName: "trash") + return UISwipeActionsConfiguration(actions: [delete]) + } + + override func tableView( + _ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint + ) -> UIContextMenuConfiguration? { + let item = items[indexPath.row] + return UIContextMenuConfiguration(actionProvider: { [weak self] _ in + var actions: [UIMenuElement] = [] + if item.version == nil { + actions.append(UIAction(title: "New Subpage", image: UIImage(systemName: "text.badge.plus")) { _ in + self?.newPage(under: item.row.id) + }) + } + actions.append( + UIAction( + title: item.version == nil ? "Delete Page" : "Delete Conflict Page", + image: UIImage(systemName: "trash"), attributes: .destructive + ) { _ in self?.delete(item) }) + return UIMenu(children: actions) + }) + } + + func updateSearchResults(for controller: UISearchController) { + searching?.cancel() + let query = controller.searchBar.text?.trimmingCharacters(in: .whitespaces) ?? "" + guard let section, let library = section.notebook.handle, !query.isEmpty else { + results.show([], query: query) + return + } + // Each keystroke waits for the next before the notebook is read. + let work = DispatchWorkItem { [weak self] in + let (library, open, path) = (Int(bitPattern: library), Int(bitPattern: section.handle), section.tab.path) + self?.results.searching(query) + background({ + decode( + [Found].self, + sb_library_search( + OpaquePointer(bitPattern: library), OpaquePointer(bitPattern: open), path, query)) ?? [] + }) { [weak self] found in + guard controller.searchBar.text?.trimmingCharacters(in: .whitespaces) == query else { return } + self?.results.show( + found, query: query, + sections: Dictionary( + section.notebook.tabs.map { ($0.path, $0.name) }, uniquingKeysWith: { first, _ in first })) + } + } + searching = work + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25, execute: work) + } +} + +/// A page `sb_library_search` found. +struct Found: Decodable { + let section: String + let page: String + let title: String + let snippet: String +} + +final class SearchViewController: UITableViewController { + private var found: [Found] = [] + private var sections: [String: String] = [:] + private var query = "" + var onOpen: ((Found, String) -> Void)? + + override func viewDidLoad() { + super.viewDidLoad() + tableView.register(UITableViewCell.self, forCellReuseIdentifier: "found") + } + + func searching(_ query: String) { + guard found.isEmpty else { return } + var loading = UIContentUnavailableConfiguration.loading() + loading.text = "Searching…" + contentUnavailableConfiguration = loading + } + + func show(_ found: [Found], query: String, sections: [String: String] = [:]) { + self.found = found + self.query = query + self.sections = sections + tableView.reloadData() + contentUnavailableConfiguration = found.isEmpty && !query.isEmpty ? UIContentUnavailableConfiguration.search() : nil + } + + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { found.count } + + override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let found = found[indexPath.row] + let cell = tableView.dequeueReusableCell(withIdentifier: "found", for: indexPath) + var content = UIListContentConfiguration.subtitleCell() + content.text = found.title.isEmpty ? "Untitled Page" : found.title + let section = sections[found.section] ?? "" + content.secondaryAttributedText = highlighted( + [section, found.snippet].filter { !$0.isEmpty }.joined(separator: " · ")) + content.secondaryTextProperties.numberOfLines = 2 + cell.contentConfiguration = content + return cell + } + + /// `text` with the query in bold where it occurs. + private func highlighted(_ text: String) -> NSAttributedString { + let font = UIFont.preferredFont(forTextStyle: .subheadline) + let attributed = NSMutableAttributedString( + string: text, attributes: [.font: font, .foregroundColor: UIColor.secondaryLabel]) + var range = text.startIndex..?) -> String? { + guard let text else { return nil } + defer { sb_string_free(text) } + return String(cString: text) +} + +func decode(_ type: T.Type, _ json: UnsafeMutablePointer?) -> T? { + take(json).flatMap { try? JSONDecoder().decode(type, from: Data($0.utf8)) } +} + +/// A page's title date and time, as OneNote writes them: the long date and short time. +func titleDate(_ date: Date = Date()) -> (date: String, time: String) { + let formatter = DateFormatter() + formatter.dateStyle = .full + formatter.timeStyle = .none + let day = formatter.string(from: date) + formatter.dateStyle = .none + formatter.timeStyle = .short + return (day, formatter.string(from: date)) +} + +/// Who edits name, as OneNote names the Office user; asked once. +enum Author { + private static let key = "author" + + static var name: String? { + get { UserDefaults.standard.string(forKey: key) } + set { UserDefaults.standard.set(newValue, forKey: key) } + } + + /// Asks for the name OneNote shows beside changes, then runs `then`. + static func ask(from controller: UIViewController, then: @escaping () -> Void) { + let alert = UIAlertController( + title: "What’s 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 + name = alert?.textFields?.first?.text?.trimmingCharacters(in: .whitespaces) + then() + } + alert.addTextField { field in + field.text = name + field.placeholder = "Name" + field.textContentType = .name + field.autocapitalizationType = .words + save.isEnabled = !(field.text ?? "").isEmpty + NotificationCenter.default.addObserver( + forName: UITextField.textDidChangeNotification, object: field, queue: .main + ) { _ in save.isEnabled = !(field.text ?? "").trimmingCharacters(in: .whitespaces).isEmpty } + } + alert.addAction(save) + alert.preferredAction = save + controller.present(alert, animated: true) + } +} + +/// An SMB share and the folder of a notebook on it. +struct Server: Codable, Equatable { + /// `host` or `host:port`. + var host: String + var share: String + /// Empty for a guest. + var user: String + /// The notebook folder, `/`-separated from the share's top. + var root: String + + var hostName: String { host.split(separator: ":").first.map(String.init) ?? host } + var port: Int { host.split(separator: ":").dropFirst().first.flatMap { Int($0) } ?? 445 } +} + +/// The password of an SMB account, kept in the Keychain as Files keeps a server's. +enum Keychain { + private static func query(_ server: Server) -> [String: Any] { + [ + kSecClass as String: kSecClassInternetPassword, + kSecAttrServer as String: server.hostName, + kSecAttrPort as String: server.port, + kSecAttrProtocol as String: kSecAttrProtocolSMB, + kSecAttrAccount as String: server.user, + ] + } + + static func password(_ server: Server) -> String? { + var query = query(server) + query[kSecReturnData as String] = true + var result: AnyObject? + guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess, let data = result as? Data + else { return nil } + return String(data: data, encoding: .utf8) + } + + static func save(_ password: String, for server: Server) { + SecItemDelete(query(server) as CFDictionary) + var item = query(server) + item[kSecValueData as String] = Data(password.utf8) + item[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock + SecItemAdd(item as CFDictionary, nil) + } +} + +/// Where a notebook lives, as the notebook list keeps it. +enum Source: Codable, Equatable { + /// A folder or section file chosen in Files, as a security-scoped bookmark. + case files(bookmark: Data) + /// A folder in the app's Documents, which Files shows as Snowbound's. + case documents(path: String) + /// A notebook folder on an SMB share, opened through Snowbound's own client. + case server(Server) + /// A folder or section at an absolute path, for scripted runs. + case path(String) +} + +/// A section tab from `sb_library_sections`. +struct Tab: Decodable, Equatable { + let name: String + /// The catalog path `sb_section_open` takes. + let path: String + /// The section group holding it, `/`-separated; empty at the notebook's top. + let group: String + let color: [UInt8] + let readable: Bool + + var uiColor: UIColor { + UIColor(red: CGFloat(color[0]) / 255, green: CGFloat(color[1]) / 255, blue: CGFloat(color[2]) / 255, alpha: 1) + } +} + +/// Runs `work` off the main thread, then `done` with its result on it. +func background(_ work: @escaping () -> T, done: @escaping (T) -> Void) { + DispatchQueue.global(qos: .userInitiated).async { + let result = work() + DispatchQueue.main.async { done(result) } + } +} + +/// Where section replicas live: kept, as they hold edits not yet published. +let cacheDirectory: URL = { + let url = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + .appendingPathComponent("Sections", isDirectory: true) + try? FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url +}() + +let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + +/// An open notebook: its Rust library and the sections it lists. +final class Notebook { + let id: UUID + let source: Source + private(set) var name: String + private(set) var handle: OpaquePointer? + private(set) var tabs: [Tab] = [] + /// Why the notebook could not be opened, while it cannot. + private(set) var problem: String? + /// The Files location being read, held open while the notebook is. + private var accessed: URL? + + init(id: UUID = UUID(), name: String, source: Source) { + self.id = id + self.name = name + self.source = source + } + + deinit { + if let handle { sb_library_free(handle) } + accessed?.stopAccessingSecurityScopedResource() + } + + /// Opens the library off the main thread and lists its sections; `done` runs on the main + /// thread either way. + func open(done: @escaping () -> Void) { + let source = source + background({ () -> (OpaquePointer?, URL?, String?) in + var error: UnsafeMutablePointer? + switch source { + case .server(let server): + let library = sb_library_server( + server.host, server.share, server.user, Keychain.password(server) ?? "", "", server.root, + cacheDirectory.path, &error) + return (library, nil, take(error)) + case .files(let bookmark): + var stale = false + guard let url = try? URL(resolvingBookmarkData: bookmark, bookmarkDataIsStale: &stale) else { + return (nil, nil, "The notebook was moved or deleted.") + } + let accessed = url.startAccessingSecurityScopedResource() ? url : nil + 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, &error) + } + return (library, accessed, take(error) ?? coordination?.localizedDescription) + case .documents(let path): + let library = sb_library_open( + documentsDirectory.appendingPathComponent(path).path, cacheDirectory.path, &error) + return (library, nil, take(error)) + case .path(let path): + return (sb_library_open(path, cacheDirectory.path, &error), nil, take(error)) + } + }) { [self] (library, accessed, problem) in + self.accessed?.stopAccessingSecurityScopedResource() + self.accessed = accessed + if let handle { sb_library_free(handle) } + handle = library + self.problem = library == nil ? (problem ?? "The notebook can’t be read.") : nil + reload(done: done) + } + } + + /// Lists the sections again, off the main thread. + func reload(done: @escaping () -> Void) { + guard let handle else { + tabs = [] + return done() + } + let pointer = Int(bitPattern: handle) + background({ decode([Tab].self, sb_library_sections(OpaquePointer(bitPattern: pointer))) }) { [self] tabs in + if let tabs { self.tabs = tabs } + done() + } + } +} + +/// The notebooks the list shows, kept across launches. +enum Notebooks { + private struct Entry: Codable { + let id: UUID + let name: String + let source: Source + } + + private static let key = "notebooks" + private(set) static var all: [Notebook] = [] + + /// The kept notebooks, or on first launch a copy of the bundled sample in Documents. + static func load() { + if let path = ProcessInfo.processInfo.environment["SNOWBOUND_NOTEBOOK"] { + all = [Notebook(name: URL(fileURLWithPath: path).deletingPathExtension().lastPathComponent, source: .path(path))] + return + } + if let data = UserDefaults.standard.data(forKey: key), + let entries = try? JSONDecoder().decode([Entry].self, from: data) + { + all = entries.map { Notebook(id: $0.id, name: $0.name, source: $0.source) } + return + } + 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) + } + all = [Notebook(name: "Sample", source: .documents(path: "Sample"))] + // The notebook the first version remembered from Files. + if let bookmark = UserDefaults.standard.data(forKey: "notebook") { + var stale = false + let name = (try? URL(resolvingBookmarkData: bookmark, bookmarkDataIsStale: &stale))? + .deletingPathExtension().lastPathComponent + all.append(Notebook(name: name ?? "Notebook", source: .files(bookmark: bookmark))) + } + save() + } + + static func add(_ notebook: Notebook) { + all.append(notebook) + save() + } + + static func remove(_ notebook: Notebook) { + all.removeAll { $0 === notebook } + save() + } + + private static func save() { + guard ProcessInfo.processInfo.environment["SNOWBOUND_NOTEBOOK"] == nil else { return } + let entries = all.map { Entry(id: $0.id, name: $0.name, source: $0.source) } + UserDefaults.standard.set(try? JSONEncoder().encode(entries), forKey: key) + } +} + +/// A page row from `sb_section_pages`. +struct Row: Decodable, Equatable { + struct Version: Decodable, Equatable { + let id: String + let user: String + /// Seconds since 1970. + let created: Double? + } + + let id: String + var title: String + /// 1 at the top, 2 for a subpage and so on. + let level: Int + /// Conflict pages: versions with changes a merge could not take. + let versions: [Version] +} + +/// Saving as the section reports it, from `sb_section_poll`. +enum SaveStatus: UInt8 { + case unknown, saved, saving, offline, notSaving +} + +private let wakeSection: sb_wake = { token in + DispatchQueue.main.async { Section.open[Int(token)]?.value?.poll() } +} + +private struct Weak { weak var value: T? } + +/// A section open for editing: pages listed and saved through its replica. +final class Section { + /// Sent on the main thread with `flags` from `sb_section_poll` after each poll. + static let changed = Notification.Name("SectionChanged") + static let listed: UInt32 = 1 + static let remote: UInt32 = 2 + static let rejected: UInt32 = 4 + + fileprivate static var open: [Int: Weak
] = [:] + private static var tokens = 0 + + let notebook: Notebook + let tab: Tab + let handle: OpaquePointer + private let token: Int + private(set) var rows: [Row] = [] + private(set) var status = SaveStatus.unknown + + static var all: [Section] { open.values.compactMap(\.value) } + + private init(notebook: Notebook, tab: Tab, handle: OpaquePointer, token: Int) { + self.notebook = notebook + self.tab = tab + self.handle = handle + self.token = token + reloadRows() + } + + deinit { + Self.open[token] = nil + sb_section_free(handle) + } + + /// Opens `tab` of `notebook` off the main thread, or hands back the section already open + /// there, as its replica takes one owner at a time. + static func open(_ tab: Tab, of notebook: Notebook, done: @escaping (Section?, String?) -> Void) { + if let section = all.first(where: { $0.notebook === notebook && $0.tab.path == tab.path }) { + return done(section, nil) + } + guard let library = notebook.handle else { return done(nil, notebook.problem) } + tokens += 1 + let token = tokens + let pointer = Int(bitPattern: library) + let author = Author.name ?? "" + background({ () -> (Int, String?) in + var error: UnsafeMutablePointer? + let handle = sb_section_open( + OpaquePointer(bitPattern: pointer), tab.path, author, wakeSection, UInt(token), &error) + return (Int(bitPattern: handle), take(error)) + }) { handle, error in + guard let handle = OpaquePointer(bitPattern: handle) else { return done(nil, error) } + let section = Section(notebook: notebook, tab: tab, handle: handle, token: token) + open[token] = Weak(value: section) + done(section, nil) + } + } + + func reloadRows() { + rows = decode([Row].self, sb_section_pages(handle)) ?? rows + } + + /// The row listing page or conflict page `id`, and the version when it is one. + func row(of id: String) -> (row: Row, version: Row.Version?)? { + for row in rows { + if row.id == id { return (row, nil) } + if let version = row.versions.first(where: { $0.id == id }) { return (row, version) } + } + return nil + } + + fileprivate func poll() { + var status: UInt8 = 0 + let flags = sb_section_poll(handle, &status) + let changed = SaveStatus(rawValue: status) ?? .unknown + guard flags != 0 || changed != self.status else { return } + self.status = changed + if flags & Self.listed != 0 { reloadRows() } + NotificationCenter.default.post(name: Self.changed, object: self, userInfo: ["flags": flags]) + } + + /// A page's title as typed, shown in the list before the section reports it. + func retitle(_ id: String, _ title: String) { + guard let index = rows.firstIndex(where: { $0.id == id }), rows[index].title != title else { return } + rows[index].title = title + NotificationCenter.default.post(name: Self.changed, object: self, userInfo: ["flags": UInt32(0)]) + } + + /// Adds a page, or a subpage of `parent`; returns its id. + func newPage(under parent: String? = nil) -> String? { + let (date, time) = titleDate() + let id = take(sb_section_new_page(handle, parent, date, time)) + reloadRows() + NotificationCenter.default.post(name: Self.changed, object: self, userInfo: ["flags": Self.listed]) + return id + } + + func delete(_ id: String, done: @escaping (Bool) -> Void) { + let (date, time) = titleDate() + let pointer = Int(bitPattern: handle) + background({ sb_section_delete_page(OpaquePointer(bitPattern: pointer), id, date, time) }) { [self] deleted in + reloadRows() + NotificationCenter.default.post(name: Self.changed, object: self, userInfo: ["flags": Self.listed]) + done(deleted) + } + } + + /// Stores every edit and publishes them within `seconds`; call off the main thread. + func flush(_ seconds: Double) -> Bool { sb_section_flush(handle, seconds) } + + func wake() { sb_section_wake(handle) } +} diff --git a/apps/ios/Snowbound/PageViewController.swift b/apps/ios/Snowbound/PageViewController.swift new file mode 100644 index 0000000000000000000000000000000000000000..8b35e356073c6998f3cc0547bdeee476b12604b6 --- /dev/null +++ b/apps/ios/Snowbound/PageViewController.swift @@ -0,0 +1,306 @@ +import PhotosUI +import UIKit + +final class PageViewController: UIViewController, PHPickerViewControllerDelegate, + UIImagePickerControllerDelegate, UINavigationControllerDelegate +{ + /// Posted when the app leaves the screen or quits: marked text is committed so it saves. + static let leaving = Notification.Name("SnowboundLeaving") + + private let section: Section + private let page: String + private let find: String? + private let titleFocus: Bool + private lazy var canvas = CanvasView(section: section, page: page) + private let bar = ConflictBar() + /// Opens another page or conflict page of the section. + var onOpen: ((String) -> Void)? + private lazy var done = UIBarButtonItem( + systemItem: .done, primaryAction: UIAction { [weak self] _ in _ = self?.canvas.resignFirstResponder() }) + private lazy var undo = UIBarButtonItem( + systemItem: .undo, primaryAction: UIAction { [weak self] _ in self?.canvas.undo(redo: false) }) + private lazy var redo = UIBarButtonItem( + systemItem: .redo, primaryAction: UIAction { [weak self] _ in self?.canvas.undo(redo: true) }) + + init(section: Section, page: String, find: String? = nil, titleFocus: Bool = false) { + self.section = section + self.page = page + self.find = find + self.titleFocus = titleFocus + super.init(nibName: nil, bundle: nil) + navigationItem.largeTitleDisplayMode = .never + showTitle(section.row(of: page)?.row.title ?? "") + } + + required init?(coder: NSCoder) { fatalError() } + + override func loadView() { + let view = UIView() + view.backgroundColor = .systemBackground + canvas.translatesAutoresizingMaskIntoConstraints = false + bar.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(canvas) + view.addSubview(bar) + NSLayoutConstraint.activate([ + canvas.leadingAnchor.constraint(equalTo: view.leadingAnchor), + canvas.trailingAnchor.constraint(equalTo: view.trailingAnchor), + canvas.topAnchor.constraint(equalTo: view.topAnchor), + canvas.bottomAnchor.constraint(equalTo: view.bottomAnchor), + bar.leadingAnchor.constraint(equalTo: view.leadingAnchor), + bar.trailingAnchor.constraint(equalTo: view.trailingAnchor), + bar.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + ]) + self.view = view + canvas.onChange = { [weak self] in self?.editingChanged() } + canvas.onOpened = { [weak self] in self?.opened() } + canvas.onDate = { [weak self] field, date in self?.pickDate(field, date) } + canvas.formatBar.onPicture = { [weak self] camera in self?.pickPicture(camera: camera) } + bar.onAction = { [weak self] in self?.conflictAction() } + NotificationCenter.default.addObserver( + self, selector: #selector(sectionChanged), name: Section.changed, object: section) + NotificationCenter.default.addObserver(self, selector: #selector(leaving), name: Self.leaving, object: nil) + conflictChanged() + } + + /// The page is on screen: a found word is selected, a new page's title takes the caret. + private func opened() { + if let find, canvas.find(find) { + _ = canvas.becomeFirstResponder() + } else if titleFocus, canvas.focusTitle() { + _ = canvas.becomeFirstResponder() + } + } + + private func showTitle(_ title: String) { + self.title = title.isEmpty ? "Untitled Page" : title + } + + /// Notes' checkmark ends editing and puts the keyboard away; undo and redo sit beside it. + private func editingChanged() { + if !canvas.readOnly, let title = canvas.pageTitle { + showTitle(title) + section.retitle(page, title) + } + guard canvas.isFirstResponder else { + navigationItem.rightBarButtonItems = nil + return + } + undo.isEnabled = canvas.canUndo(redo: false) + redo.isEnabled = canvas.canUndo(redo: true) + if navigationItem.rightBarButtonItems?.first !== done { + navigationItem.rightBarButtonItems = [done, redo, undo] + } + } + + @objc private func leaving() { canvas.commitComposition() } + + @objc private func sectionChanged(_ notification: Notification) { + let flags = notification.userInfo?["flags"] as? UInt32 ?? 0 + if flags & Section.rejected != 0 { + // 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) + } else if flags & Section.remote != 0 { + canvas.reload(discard: false) + } + if flags & Section.listed != 0 { + if section.row(of: page) == nil { + // Deleted elsewhere, or a conflict page resolved: back to what the section lists. + if let first = section.rows.first { onOpen?(first.id) } + return + } + conflictChanged() + } + showStatus() + } + + /// Only trouble shows: saving goes on quietly, as Notes saves. + private func showStatus() { + let status: String? = + switch section.status { + case .offline: "Offline" + case .notSaving: "Not Saving" + default: nil + } + if #available(iOS 26, *) { + navigationItem.subtitle = status + } else { + navigationItem.prompt = status + } + } + + // MARK: Conflicts + + private func conflictChanged() { + guard let (row, version) = section.row(of: page) else { return bar.show(nil) } + if version != nil { + // Deleting a conflict page cannot be undone, so the button asks first. + let delete = UIAction( + title: "Delete Conflict Page", image: UIImage(systemName: "trash"), attributes: .destructive + ) { [weak self] _ in self?.conflictAction() } + bar.show( + "Changes in red couldn’t be merged. This version can’t be edited.", action: "Delete", + menu: UIMenu(children: [delete])) + } else if !row.versions.isEmpty { + bar.show("This page has changes that couldn’t be merged.", action: "See Version") + } else { + bar.show(nil) + } + canvas.topInset = bar.isHidden ? 0 : bar.systemLayoutSizeFitting(view.bounds.size).height + } + + 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) } + } else if let newest = row.versions.max(by: { ($0.created ?? 0) < ($1.created ?? 0) }) { + onOpen?(newest.id) + } + } + + // MARK: Date + + /// Changes the page's date or time, as OneNote's date and time under the title do. + private func pickDate(_ field: DateField, _ shown: Date) { + let picker = UIDatePicker() + picker.datePickerMode = field == .date ? .date : .time + picker.preferredDatePickerStyle = field == .date ? .inline : .wheels + picker.date = shown + picker.minimumDate = Date(timeIntervalSince1970: -11_644_473_600) + picker.accessibilityLabel = field == .date ? "Page date" : "Page time" + let controller = UIViewController() + controller.title = field == .date ? "Change Page Date" : "Change Page Time" + picker.translatesAutoresizingMaskIntoConstraints = false + controller.view.backgroundColor = .systemBackground + controller.view.addSubview(picker) + NSLayoutConstraint.activate([ + picker.centerXAnchor.constraint(equalTo: controller.view.centerXAnchor), + picker.topAnchor.constraint(equalTo: controller.view.safeAreaLayoutGuide.topAnchor, constant: 8), + picker.widthAnchor.constraint(lessThanOrEqualTo: controller.view.widthAnchor, constant: -32), + ]) + controller.navigationItem.leftBarButtonItem = UIBarButtonItem( + systemItem: .cancel, primaryAction: UIAction { [weak self] _ in self?.dismiss(animated: true) }) + controller.navigationItem.rightBarButtonItem = UIBarButtonItem( + title: "Apply", + primaryAction: UIAction { [weak self] _ in + // The field not being changed keeps what the page had. + let calendar = Calendar.current + let kept: Set = field == .date ? [.hour, .minute, .second] : [.era, .year, .month, .day] + let changed: Set = field == .date ? [.era, .year, .month, .day] : [.hour, .minute] + var components = calendar.dateComponents(kept, from: shown) + let chosen = calendar.dateComponents(changed, from: picker.date) + for component in changed { components.setValue(chosen.value(for: component), for: component) } + if field == .time { components.second = calendar.component(.second, from: shown) } + self?.dismiss(animated: true) + if let date = calendar.date(from: components) { self?.canvas.changeDate(date) } + }) + let navigation = UINavigationController(rootViewController: controller) + navigation.sheetPresentationController?.detents = field == .date ? [.large()] : [.medium()] + present(navigation, animated: true) + } + + // MARK: Pictures + + private func pickPicture(camera: Bool) { + if camera { + guard UIImagePickerController.isSourceTypeAvailable(.camera) else { return } + let picker = UIImagePickerController() + picker.sourceType = .camera + picker.delegate = self + present(picker, animated: true) + } else { + var configuration = PHPickerConfiguration() + configuration.filter = .images + let picker = PHPickerViewController(configuration: configuration) + picker.delegate = self + present(picker, animated: true) + } + } + + func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { + picker.dismiss(animated: true) + guard let provider = results.first?.itemProvider, provider.canLoadObject(ofClass: UIImage.self) else { return } + provider.loadObject(ofClass: UIImage.self) { [weak self] image, _ in + guard let image = image as? UIImage else { return } + DispatchQueue.main.async { self?.insert(image) } + } + } + + func imagePickerController( + _ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any] + ) { + picker.dismiss(animated: true) + if let image = info[.originalImage] as? UIImage { insert(image) } + } + + /// A photo as OneNote 2010 can read it: JPEG, at most 2048 pixels across, shown at most + /// 220 points wide. + private func insert(_ image: UIImage) { + let longest: CGFloat = 2048 + let scale = min(1, longest / max(image.size.width * image.scale, image.size.height * image.scale)) + let pixels = CGSize( + width: (image.size.width * image.scale * scale).rounded(), + height: (image.size.height * image.scale * scale).rounded()) + let format = UIGraphicsImageRendererFormat() + format.scale = 1 + let drawn = UIGraphicsImageRenderer(size: pixels, format: format).image { _ in + image.draw(in: CGRect(origin: .zero, size: pixels)) + } + guard let data = drawn.jpegData(compressionQuality: 0.85) else { return } + // OneNote shows a picture's pixels at 96 per inch. + var size = CGSize(width: pixels.width * 0.75, height: pixels.height * 0.75) + if size.width > 220 { size = CGSize(width: 220, height: size.height * 220 / size.width) } + canvas.insertPicture(data, size: size) + } +} + +/// OneNote's information bar above a page whose changes could not all be merged. +final class ConflictBar: UIView { + private let label = UILabel() + private let button = UIButton(configuration: .bordered()) + var onAction: (() -> Void)? + + init() { + super.init(frame: .zero) + backgroundColor = UIColor { $0.userInterfaceStyle == .dark ? UIColor(red: 0.35, green: 0.28, blue: 0.1, alpha: 1) : UIColor(red: 1, green: 0.93, blue: 0.76, alpha: 1) } + let icon = UIImageView(image: UIImage(systemName: "exclamationmark.triangle.fill")) + icon.tintColor = .systemOrange + icon.setContentHuggingPriority(.required, for: .horizontal) + label.font = .preferredFont(forTextStyle: .footnote) + label.numberOfLines = 0 + button.addAction( + UIAction { [weak self] _ in + if self?.button.menu == nil { self?.onAction?() } + }, for: .primaryActionTriggered) + button.setContentHuggingPriority(.required, for: .horizontal) + let stack = UIStackView(arrangedSubviews: [icon, label, button]) + stack.spacing = 10 + stack.alignment = .center + stack.translatesAutoresizingMaskIntoConstraints = false + addSubview(stack) + NSLayoutConstraint.activate([ + stack.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor), + stack.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor), + stack.topAnchor.constraint(equalTo: topAnchor, constant: 8), + stack.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -8), + ]) + isHidden = true + } + + required init?(coder: NSCoder) { fatalError() } + + /// Shows `message` with a button titled `action`, which opens `menu` when there is one. + func show(_ message: String?, action: String = "", menu: UIMenu? = nil) { + isHidden = message == nil + label.text = message + button.configuration?.title = action + button.menu = menu + button.showsMenuAsPrimaryAction = menu != nil + } +} diff --git a/apps/ios/Snowbound/Script.swift b/apps/ios/Snowbound/Script.swift index db0ca924baf54af000d6b151013bfc853e58299b..144d15f91d2ec924a4b17c257ee8ceca33952c5c 100644 --- a/apps/ios/Snowbound/Script.swift +++ b/apps/ios/Snowbound/Script.swift @@ -1,16 +1,22 @@ #if DEBUG import UIKit +private var scriptedPage = false + extension CanvasView { /// Replays `SNOWBOUND_SCRIPT` through the calls touch and the keyboard make, to check /// input without a finger: steps joined by `|`, such as /// `tap:120,300|type:hi|mark:かな|unmark|return|delete|select:2,9|scroll:0,600|zoom:1.5|shot:a`. /// `tap` and `doubletap` take points from the view's corner, `select` text offsets, - /// `scroll` a content offset; `done` ends editing, `tree` saves the view hierarchy to + /// `scroll` a content offset; `format:N` applies `sb_view_apply` formatting, `find:word` + /// selects a match, `title` edits the title, `picture` inserts a drawn picture; `done` + /// ends editing, `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() { - guard let script = ProcessInfo.processInfo.environment["SNOWBOUND_SCRIPT"] else { return } + // Only the first page shown runs it, not every page opened afterwards. + guard !scriptedPage, let script = ProcessInfo.processInfo.environment["SNOWBOUND_SCRIPT"] else { return } + scriptedPage = true for (index, step) in script.split(separator: "|").enumerated() { DispatchQueue.main.asyncAfter(deadline: .now() + 1 + Double(index) * 0.4) { [weak self] in self?.perform(String(step)) @@ -48,6 +54,17 @@ extension CanvasView { let values = argument.split(separator: ",").compactMap { Double($0) } zoom(at: CGPoint(x: values[0] + contentOffset.x, y: values[1] + contentOffset.y)) case "done": _ = resignFirstResponder() + case "format": apply(UInt8(argument) ?? 0) + case "find": _ = find(argument) + case "title": _ = focusTitle() + case "picture": + let image = UIGraphicsImageRenderer(size: CGSize(width: 320, height: 200)).image { context in + UIColor.systemTeal.setFill() + context.fill(CGRect(x: 0, y: 0, width: 320, height: 200)) + UIColor.systemYellow.setFill() + context.cgContext.fillEllipse(in: CGRect(x: 110, y: 50, width: 100, height: 100)) + } + if let data = image.jpegData(compressionQuality: 0.9) { insertPicture(data, size: CGSize(width: 240, height: 150)) } case "tree": let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] try? "\(value(forKey: "recursiveDescription") ?? "")".write( @@ -66,4 +83,23 @@ extension CanvasView { } } } + +/// Logs `presenter changed` whenever a coordinated write reaches `SNOWBOUND_PRESENTER`, a +/// file, to check that saving tells file providers about it. +final class Presenter: NSObject, NSFilePresenter { + private static var watching: Presenter? + let presentedItemURL: URL? + let presentedItemOperationQueue = OperationQueue() + + private init(_ url: URL) { presentedItemURL = url } + + static func watch() { + guard let path = ProcessInfo.processInfo.environment["SNOWBOUND_PRESENTER"] else { return } + let presenter = Presenter(URL(fileURLWithPath: path)) + NSFileCoordinator.addFilePresenter(presenter) + watching = presenter + } + + func presentedItemDidChange() { NSLog("presenter changed") } +} #endif diff --git a/apps/ios/Snowbound/Server.swift b/apps/ios/Snowbound/Server.swift new file mode 100644 index 0000000000000000000000000000000000000000..b7de342be353709c8e4ab954d35d3a9a160057f3 --- /dev/null +++ b/apps/ios/Snowbound/Server.swift @@ -0,0 +1,258 @@ +import UIKit + +/// Connect to Server, as Files and the Finder offer it: an `smb://` address, then a guest +/// or an account whose password goes in the Keychain, then a folder to open. +final class ServerViewController: UITableViewController, UITextFieldDelegate { + private static let lastKey = "server" + + private let address = UITextField() + private let name = UITextField() + private let password = UITextField() + private let guest = UISegmentedControl(items: ["Guest", "Registered User"]) + private var connecting = false + var onOpen: ((Notebook) -> Void)? + + init() { + super.init(style: .insetGrouped) + title = "Connect to Server" + } + + required init?(coder: NSCoder) { fatalError() } + + override func viewDidLoad() { + super.viewDidLoad() + navigationItem.leftBarButtonItem = UIBarButtonItem( + systemItem: .cancel, primaryAction: UIAction { [weak self] _ in self?.dismiss(animated: true) }) + navigationItem.rightBarButtonItem = UIBarButtonItem( + title: "Connect", primaryAction: UIAction { [weak self] _ in self?.connect() }) + address.placeholder = "smb://server/share" + address.text = UserDefaults.standard.string(forKey: Self.lastKey) + address.keyboardType = .URL + address.autocapitalizationType = .none + address.autocorrectionType = .no + address.clearButtonMode = .whileEditing + name.placeholder = "Name" + name.textContentType = .username + name.autocapitalizationType = .none + name.autocorrectionType = .no + password.placeholder = "Password" + password.textContentType = .password + password.isSecureTextEntry = true + for field in [address, name, password] { + field.delegate = self + field.returnKeyType = field === password || field === address ? .go : .next + } + guest.selectedSegmentIndex = 1 + guest.addAction(UIAction { [weak self] _ in self?.tableView.reloadData() }, for: .valueChanged) + tableView.register(UITableViewCell.self, forCellReuseIdentifier: "field") + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + address.becomeFirstResponder() + } + + func textFieldShouldReturn(_ field: UITextField) -> Bool { + if field === name { + password.becomeFirstResponder() + } else { + connect() + } + return false + } + + private var registered: Bool { guest.selectedSegmentIndex == 1 } + + /// `smb://user@host:port/share/folder`, or the same without a scheme. + private func server() -> Server? { + var text = address.text?.trimmingCharacters(in: .whitespaces) ?? "" + if !text.contains("://") { text = "smb://" + text } + guard let url = URLComponents(string: text), url.scheme?.lowercased() == "smb", let host = url.host, + !host.isEmpty + else { return nil } + let parts = url.path.split(separator: "/").map(String.init) + guard let share = parts.first else { return nil } + let user = url.user ?? (registered ? name.text ?? "" : "") + return Server( + host: url.port.map { "\(host):\($0)" } ?? host, share: share, user: user, + root: parts.dropFirst().joined(separator: "/")) + } + + private func connect() { + guard !connecting else { return } + guard let server = server() else { + return alert("Enter a Server Address", "Use the form smb://server/share.") + } + let secret = registered ? password.text ?? "" : "" + connecting = true + navigationItem.rightBarButtonItem?.isEnabled = false + let spinner = UIActivityIndicatorView(style: .medium) + spinner.startAnimating() + navigationItem.titleView = spinner + background({ () -> (Int, String?) in + var error: UnsafeMutablePointer? + let share = sb_share_connect(server.host, server.share, server.user, secret, "", &error) + return (Int(bitPattern: share), take(error)) + }) { [weak self] share, _ in + guard let self else { return } + connecting = false + navigationItem.rightBarButtonItem?.isEnabled = true + navigationItem.titleView = nil + guard let share = OpaquePointer(bitPattern: share) else { + return alert( + "Can’t Connect", "Check the address, name and password, and that the server is on this network.") + } + if registered { Keychain.save(secret, for: server) } + UserDefaults.standard.set(address.text, forKey: Self.lastKey) + let browser = ShareViewController(share: SharedConnection(share), server: server, path: server.root) + browser.onOpen = onOpen + navigationController?.pushViewController(browser, animated: true) + } + } + + 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 } + + override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: "field", for: indexPath) + cell.contentView.subviews.forEach { $0.removeFromSuperview() } + cell.selectionStyle = .none + let view: UIView = + switch (indexPath.section, indexPath.row) { + case (0, 0): address + case (0, _): guest + case (_, 0): name + default: password + } + view.translatesAutoresizingMaskIntoConstraints = false + cell.contentView.addSubview(view) + let margins = cell.contentView.layoutMarginsGuide + NSLayoutConstraint.activate([ + view.leadingAnchor.constraint(equalTo: margins.leadingAnchor), + view.trailingAnchor.constraint(equalTo: margins.trailingAnchor), + view.topAnchor.constraint(equalTo: margins.topAnchor), + view.bottomAnchor.constraint(equalTo: margins.bottomAnchor), + view.heightAnchor.constraint(greaterThanOrEqualToConstant: 32), + ]) + return cell + } +} + +/// A share connection the browser's folders share, closed with the last of them. +final class SharedConnection { + let handle: OpaquePointer + init(_ handle: OpaquePointer) { self.handle = handle } + deinit { sb_share_free(handle) } +} + +/// A folder on a share: its folders to go into, and Open where it holds a notebook. +final class ShareViewController: UITableViewController { + private struct Listing: Decodable { + let notebook: Bool + let folders: [String] + let sections: [String] + } + + private let share: SharedConnection + private let server: Server + private let path: String + private var listing: Listing? + var onOpen: ((Notebook) -> Void)? + + init(share: SharedConnection, server: Server, path: String) { + self.share = share + self.server = server + self.path = path + super.init(style: .insetGrouped) + title = path.split(separator: "/").last.map(String.init) ?? server.share + } + + required init?(coder: NSCoder) { fatalError() } + + override func viewDidLoad() { + super.viewDidLoad() + tableView.register(UITableViewCell.self, forCellReuseIdentifier: "entry") + let open = UIBarButtonItem(title: "Open", primaryAction: UIAction { [weak self] _ in self?.open() }) + open.style = .done + open.isEnabled = false + navigationItem.rightBarButtonItem = open + var loading = UIContentUnavailableConfiguration.loading() + loading.text = "Loading…" + contentUnavailableConfiguration = loading + let (handle, path) = (Int(bitPattern: share.handle), path) + background({ + decode(Listing.self, sb_share_list(OpaquePointer(bitPattern: handle), path)) + }) { [weak self] listing in + guard let self else { return } + self.listing = listing + tableView.reloadData() + open.isEnabled = listing.map { $0.notebook || !$0.sections.isEmpty } ?? false + var empty = UIContentUnavailableConfiguration.empty() + if listing == nil { + empty.text = "Can’t Read Folder" + empty.secondaryText = "Check that this account can open it." + } else { + empty.text = "No Folders" + empty.secondaryText = "OneNote notebooks are folders with sections in them." + } + contentUnavailableConfiguration = + listing.map { $0.folders.isEmpty && $0.sections.isEmpty } ?? true ? empty : nil + } + } + + private func open() { + var server = server + server.root = path + let name = path.split(separator: "/").last.map(String.init) ?? server.share + onOpen?(Notebook(name: name, source: .server(server))) + } + + override func numberOfSections(in tableView: UITableView) -> Int { 2 } + + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + section == 0 ? listing?.folders.count ?? 0 : listing?.sections.count ?? 0 + } + + override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { + section == 1 && !(listing?.sections.isEmpty ?? true) ? "Sections" : nil + } + + override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: "entry", for: indexPath) + var content = cell.defaultContentConfiguration() + if indexPath.section == 0 { + content.text = listing?.folders[indexPath.row] + content.image = UIImage(systemName: "folder.fill") + cell.accessoryType = .disclosureIndicator + cell.selectionStyle = .default + } else { + content.text = listing.map { URL(fileURLWithPath: $0.sections[indexPath.row]).deletingPathExtension().lastPathComponent } + content.image = UIImage(systemName: "rectangle.portrait.fill") + content.imageProperties.tintColor = .secondaryLabel + content.textProperties.color = .secondaryLabel + cell.accessoryType = .none + cell.selectionStyle = .none + } + cell.contentConfiguration = content + return cell + } + + override func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? { + indexPath.section == 0 ? indexPath : nil + } + + override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + guard let folder = listing?.folders[indexPath.row] else { return } + let child = ShareViewController( + share: share, server: server, path: path.isEmpty ? folder : "\(path)/\(folder)") + child.onOpen = onOpen + navigationController?.pushViewController(child, animated: true) + } +} diff --git a/crates/mobile/Cargo.toml b/crates/mobile/Cargo.toml index 753337b7784456db049aef9851324e567340d06c..7343a704e5c6be351751db17753626ff59994cea 100644 --- a/crates/mobile/Cargo.toml +++ b/crates/mobile/Cargo.toml @@ -11,10 +11,13 @@ crate-type = ["staticlib"] [dependencies] canvas = { path = "../canvas", features = ["interaction"] } draw = { path = "../draw" } -notebook = { path = "../notebook" } +notebook = { path = "../notebook", features = ["smb"] } onestore = { path = "../onestore" } wgpu.workspace = true parley.workspace = true pollster = "0.4" serde = { version = "1", features = ["derive"] } serde_json = "1" + +[dev-dependencies] +tempfile = "3" diff --git a/crates/mobile/include/snowbound.h b/crates/mobile/include/snowbound.h index c5bfc33bc6cc384f3f7715b772b8604d9822e67c..7976c1fcf7c50efa2c4a6e083a3ad64100d03bcb 100644 --- a/crates/mobile/include/snowbound.h +++ b/crates/mobile/include/snowbound.h @@ -1,27 +1,53 @@ -// The C surface of crates/mobile; see its lib.rs for each call's contract. +// The C surface of crates/mobile; see its lib.rs and library.rs for each call's contract. #pragma once #include #include #include +typedef struct Library Library; +typedef struct Share Share; typedef struct Section Section; typedef struct View View; -char *sb_notebook(const char *path); +void sb_string_free(char *text); -Section *sb_section_open(const char *path); -size_t sb_section_count(const Section *section); -const char *sb_section_title(const Section *section, size_t index); -uint32_t sb_section_level(const Section *section, size_t index); +typedef void (*sb_coordinator)(const char *path, bool write, void (*body)(void *), void *context); +void sb_set_coordinator(sb_coordinator coordinator); + +const Library *sb_library_open(const char *path, const char *cache, char **error); +const Library *sb_library_server(const char *address, const char *share, const char *user, const char *password, + const char *domain, const char *root, const char *cache, char **error); +char *sb_library_sections(const Library *library); +char *sb_library_new_section(const Library *library, const char *folder, const char *name, const char *author, + const char *date, const char *time); +char *sb_library_search(const Library *library, const Section *open, const char *path, const char *query); +void sb_library_free(const Library *library); + +Share *sb_share_connect(const char *address, const char *share, const char *user, const char *password, + const char *domain, char **error); +char *sb_share_list(const Share *share, const char *path); +void sb_share_free(Share *share); + +typedef void (*sb_wake)(uintptr_t token); +Section *sb_section_open(const Library *library, const char *path, const char *author, sb_wake wake, + uintptr_t token, char **error); +char *sb_section_pages(const Section *section); +uint32_t sb_section_poll(const Section *section, uint8_t *status); +char *sb_section_new_page(const Section *section, const char *parent, const char *date, const char *time); +bool sb_section_delete_page(const Section *section, const char *id, const char *date, const char *time); +bool sb_section_flush(const Section *section, double seconds); +void sb_section_wake(const Section *section); void sb_section_free(Section *section); -View *sb_view_new(void *layer, const Section *section, size_t index, float width, float height, float scale); +View *sb_view_new(void *layer, const Section *section, const char *id, float width, float height, float scale); void sb_view_free(View *view); void sb_view_resize(View *view, float width, float height, float scale); 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); +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]); bool sb_view_block(const View *view, float x, float y, float rect[4]); @@ -32,9 +58,15 @@ bool sb_view_drag(View *view, float x, float y); bool sb_view_release(View *view); bool sb_view_undo(View *view, bool redo); bool sb_view_can_undo(const View *view, bool redo); -bool sb_view_toggle(View *view, uint8_t toggle); -char *sb_view_edit(View *view); -void sb_string_free(char *text); +uint32_t sb_view_format(const View *view); +bool sb_view_apply(View *view, uint8_t command); +char *sb_view_title(const View *view); +bool sb_view_focus_title(View *view); +bool sb_view_find(View *view, const char *query); +char *sb_view_page_text(const View *view); +int8_t sb_view_date_request(View *view, int64_t *seconds); +bool sb_view_change_date(View *view, int64_t seconds, const char *date, const char *time); +bool sb_view_insert_picture(View *view, const uint8_t *bytes, size_t length, float width, float height); uint32_t sb_text_length(const View *view); char *sb_text(const View *view, uint32_t start, uint32_t end); diff --git a/crates/mobile/src/lib.rs b/crates/mobile/src/lib.rs index b1536b6f8a28b4d8b79ac9276e7abe9708217ea9..5ffcb0bd7eec49f6ff08100088b9f0980d22822b 100644 --- a/crates/mobile/src/lib.rs +++ b/crates/mobile/src/lib.rs @@ -1,33 +1,38 @@ -//! A canvas page behind a C surface for native mobile shells: a notebook's sections, a -//! section's pages, one page drawn into a `CAMetalLayer`, and the active outline's text as -//! the flat UTF-16 model UIKit's `UITextInput` speaks (see `TextOutline::shown_text`). +//! A canvas page behind a C surface for native mobile shells: notebooks and their sections +//! (`library`), one page drawn into a `CAMetalLayer`, and the active outline's text as the +//! flat UTF-16 model UIKit's `UITextInput` speaks (see `TextOutline::shown_text`). Every +//! edit a view takes is stored at once through its section. //! //! Lengths and positions are in the host's points; the view converts through its display //! scale. Calls returning `bool` report whether the page or selection changed, after which //! the host redraws and rereads `sb_view_content`. +mod library; + +use library::Shared; +pub use library::{Library, Section, Share}; + use canvas::{ document::TextPosition, - editor::{Formatting, Selection, TextOutline, Toggle}, + editor::{Formatting, NoteTag, Selection, TextOutline, Toggle}, gpu::{Paper, Viewport, page::PageScene}, - interaction::{Hit, PageView, Response, TextColors}, + date::DateField, + interaction::{Hit, ObjectFocus, PageView, Request, Response, TextColors}, layout::TextEngine, }; use draw::edit::{Key, NamedKey, SelectionUnit}; -use notebook::discover::{self, Folder, SectionState}; use onestore::{ - ExGuid, RevisionIndex, Store, - document::Document, - op::{Edit, Op}, - page::Page, + ExGuid, + op::{Edit, Op, PageOp}, + page::{Image, Page}, }; use parley::{Affinity, BoundingBox}; use std::{ error::Error, ffi::{CStr, CString, c_char, c_void}, - path::Path, + mem::ManuallyDrop, sync::{ - Arc, + Arc, Mutex, atomic::{AtomicBool, Ordering}, }, task::{Wake, Waker}, @@ -36,122 +41,9 @@ use std::{ type Result = std::result::Result>; -/// Host points per document point at 100% zoom, as OneNote's 96 pixels per inch. -const POINT: f32 = 96.0 / 72.0; - -/// The tab colour OneNote gives a section that stores none, as the desktop shows it. -const SECTION_COLOR: u32 = 0x00e4_a88a; - -#[derive(serde::Serialize)] -struct Listing { - name: String, - sections: Vec, -} - -#[derive(serde::Serialize)] -struct Tab { - name: String, - /// The section file's absolute path, for `sb_section_open`. - path: String, - /// The section group holding it, `/`-separated; empty at the notebook's top. - group: String, - /// The tab colour in sRGB. - color: [u8; 3], - /// Password-protected or unreadable sections list but do not open. - readable: bool, -} - -/// A notebook folder's sections in its order, or a lone section file as a notebook of one. -fn listing(path: &Path) -> Result { - let stem = |path: &Path| { - path.file_stem() - .map(|stem| stem.to_string_lossy().into_owned()) - .unwrap_or_default() - }; - if path.is_file() { - return Ok(Listing { - name: stem(path), - sections: vec![Tab { - name: stem(path), - path: path.to_string_lossy().into_owned(), - group: String::new(), - color: rgb(SECTION_COLOR), - readable: true, - }], - }); - } - let folder = discover::discover( - &mut discover::Local::open(path)?, - discover::Limits { - entries: 10_000, - bytes_per_file: 1 << 30, - depth: 16, - }, - )?; - let mut sections = Vec::new(); - fn walk(folder: &Folder, root: &Path, sections: &mut Vec) { - for section in &folder.sections { - let file = root.join(§ion.path); - let (name, color, readable) = match §ion.state { - SectionState::Readable { name, color } => (name.clone(), *color, true), - SectionState::Locked | SectionState::Unreadable(_) => (None, None, false), - }; - sections.push(Tab { - name: name.unwrap_or_else(|| { - file.file_stem() - .map(|stem| stem.to_string_lossy().into_owned()) - .unwrap_or_default() - }), - path: file.to_string_lossy().into_owned(), - group: folder.path.clone(), - color: rgb(color.unwrap_or(SECTION_COLOR)), - readable, - }); - } - for group in &folder.groups { - // OneNote keeps deleted sections and pages here, out of the notebook's view. - if !group.path.ends_with("OneNote_RecycleBin") { - walk(group, root, sections); - } - } - } - walk(&folder, path, &mut sections); - Ok(Listing { - name: stem(path), - sections, - }) -} - -fn rgb(colorref: u32) -> [u8; 3] { - let [red, green, blue, _] = colorref.to_le_bytes(); - [red, green, blue] -} - -pub struct Section { - /// Each page with the object space holding it. - pages: Vec<(ExGuid, Page)>, - /// The page list's title and outline level (1 at the top) for each page. - headings: Vec<(CString, u32)>, -} - -impl Section { - fn open(bytes: &[u8]) -> Result { - let store = Store::parse(bytes)?; - let index = RevisionIndex::parse(&store)?; - let document = Document::parse(&index)?; - let mut pages = Vec::new(); - let mut headings = Vec::new(); - for (space, id) in document.pages()? { - let revision = document.active(space)?; - let (title, level) = Page::heading(revision, id); - pages.push((space, Page::from_revision(revision, id)?)); - // Titles keep OneNote's line breaks, which a one-line list shows as spaces. - let title = title.replace(|char: char| char.is_control(), " "); - headings.push((CString::new(title)?, level)); - } - Ok(Self { pages, headings }) - } -} +/// Host points per document point at the zoom the host calls 100%: OneNote's 100% (96 +/// pixels per inch) enlarged by 15%. +const POINT: f32 = 96.0 / 72.0 * 1.15; /// Set off the main thread when a picture raster lands and the page should be drawn again. #[derive(Default)] @@ -182,21 +74,53 @@ enum Target { /// Another outline's text, which a touch focuses before the platform's text interaction /// takes over. Text = 2, - /// The focused outline's move or width grip. + /// The focused outline's move or width grip, or the selected picture and its handles. Grip = 3, } +/// Formatting the toolbar shows and applies, by bit in `sb_view_format` and by number in +/// `sb_view_apply`: toggles, lists, indentation, then OneNote's nine default tags from 16. +const FORMATS: [Formatting; 8] = [ + Formatting::Toggle(Toggle::Bold), + Formatting::Toggle(Toggle::Italic), + Formatting::Toggle(Toggle::Underline), + Formatting::Toggle(Toggle::Strikethrough), + Formatting::Bullets, + Formatting::Numbering, + Formatting::Indent, + Formatting::Outdent, +]; +const TAGS: [NoteTag; 9] = [ + NoteTag::ToDo, + NoteTag::Important, + NoteTag::Question, + NoteTag::RememberForLater, + NoteTag::Definition, + NoteTag::Highlight, + NoteTag::Contact, + NoteTag::Address, + NoteTag::PhoneNumber, +]; + /// The page and its text model, apart from the surface it is drawn on. struct Canvas { page: PageView, /// The object space of the page shown, which its edits name. space: ExGuid, paper: Paper, + /// Device pixels per host point. + scale: f32, + /// The page date field a tap asked to change, for the host's picker. + asked: Option, } impl Canvas { - fn new((space, page): (ExGuid, Page), pixels: [u32; 2], scale: f32) -> Result { - let mut engine = TextEngine::default(); + 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 (scene, editor) = PageScene::from_page(page, &mut engine)?; let mut page = PageView::new( editor, @@ -207,10 +131,13 @@ impl Canvas { Duration::from_millis(350), ); page.touch = true; + page.host_viewport = true; Ok(Self { page, space, paper: Paper::WHITE, + scale, + asked: None, }) } @@ -219,10 +146,15 @@ impl Canvas { TextColors { caret: [0.0; 4], selection: [0.0; 4], - paper: self.paper, + paper: self.page_paper(), } } + /// The paper the page lies on: the appearance's, in the page's colour. + fn page_paper(&self) -> Paper { + self.paper.colored(self.page.editor.page_color()) + } + /// The desktop's dark page, or white paper. fn set_dark(&mut self, dark: bool) { self.paper = if dark { @@ -241,11 +173,8 @@ impl Canvas { if ops.is_empty() { return Ok(None); } - let unix = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)?; Ok(Some(Edit { - // FILETIME: 100 ns ticks since 1601. - at: (unix.as_secs() + 11_644_473_600) * 10_000_000 - + u64::from(unix.subsec_nanos() / 100), + at: library::filetime(), ops: ops .into_iter() .map(|op| Op::Page { @@ -256,13 +185,8 @@ impl Canvas { })) } - /// Device pixels per host point. - fn display_scale(&self) -> f32 { - self.page.viewport.scale / (self.page.zoom() * POINT) - } - fn device(&self, point: [f32; 2]) -> [f32; 2] { - point.map(|value| value * self.display_scale()) + point.map(|value| value * self.scale) } /// The page content's extent in document points: the scroll limits of a view with no @@ -280,9 +204,8 @@ impl Canvas { } fn set_transform(&mut self, zoom: f32, corner: [f32; 2]) { - let scale = self.display_scale(); - self.page.viewport.scale = scale * POINT * zoom; - self.page.viewport.origin = corner.map(|value| -value * scale * zoom); + self.page.viewport.scale = self.scale * POINT * zoom; + self.page.viewport.origin = corner.map(|value| -value * self.scale * zoom); } fn outline(&self, id: ExGuid) -> Option<&TextOutline> { @@ -296,6 +219,11 @@ impl Canvas { fn target(&self, point: [f32; 2]) -> Target { match self.page.hit(self.device(point)) { Some(Hit::Handle { .. } | Hit::Resize { .. }) => Target::Grip, + Some(Hit::Image { id, .. }) + if self.page.object_focus() == Some(ObjectFocus::Image(id)) => + { + Target::Grip + } Some(Hit::Text { id, point }) if !self .outline(id) @@ -311,38 +239,32 @@ impl Canvas { } } - /// Runs `change` on the page, then puts the view back where the host's scroll view has - /// it: the canvas clamps and reveals knowing neither the navigation bar nor the keyboard, - /// and the host reveals the caret itself. - fn hold(&mut self, change: impl FnOnce(&mut PageView) -> Result) -> Result { - let Viewport { scale, origin, .. } = self.page.viewport; - let result = change(&mut self.page); - self.page.viewport.scale = scale; - self.page.viewport.origin = origin; - result - } - /// A finger down at `point`: the start of a tap or of a grip drag. fn press(&mut self, point: [f32; 2]) -> Result { let point = self.device(point); - self.hold(|page| { - let changed = moved(page.pointer_moved(point)?); - Ok(moved(page.pointer_pressed(Instant::now())?) || changed) - }) + let changed = moved(self.page.pointer_moved(point)?); + let pressed = self.page.pointer_pressed(Instant::now())?; + Ok(self.respond(pressed) || changed) + } + + /// Whether `response` moved the page, keeping the date field it asks to change. + fn respond(&mut self, response: Response) -> bool { + if let Some(Request::EditDate(field)) = response.request { + self.asked = Some(field); + } + moved(response) } fn drag(&mut self, point: [f32; 2]) -> Result { - let point = self.device(point); - self.hold(|page| Ok(moved(page.pointer_moved(point)?))) + Ok(moved(self.page.pointer_moved(self.device(point))?)) } fn release(&mut self) -> Result { - self.hold(|page| { - let changed = moved(page.pointer_released()?); - // A finger leaves no hover behind. - let _ = page.pointer_left(); - Ok(changed) - }) + let released = self.page.pointer_released()?; + let changed = self.respond(released); + // A finger leaves no hover behind. + let _ = self.page.pointer_left(); + Ok(changed) } /// The outline nearest `point`, framed as its chrome is, in points at 100% zoom. @@ -429,31 +351,152 @@ impl Canvas { .map_or(text.len(), |(byte, _)| byte) }; let cursor = (byte(selected[0]), byte(selected[1])); - self.hold(|page| Ok(moved(page.compose(text, Some(cursor))?))) + Ok(moved(self.page.compose(text, Some(cursor))?)) } + /// Types `text`; a lone newline or tab is the Return or Tab key. fn insert(&mut self, text: String) -> Result { - self.hold(|page| { - Ok(moved(if text == "\n" { - page.key(&Key::Named(NamedKey::Enter), None)? - } else { - page.commit_text(text)? - })) - }) + Ok(moved(match text.as_str() { + "\n" => self.page.key(&Key::Named(NamedKey::Enter), None)?, + "\t" => self.page.key(&Key::Named(NamedKey::Tab), None)?, + _ => self.page.commit_text(text)?, + })) + } + + /// The formatting the selection has, as `FORMATS` and `TAGS` bits. + fn format_bits(&self) -> Result { + let state = self.page.editor.format_state()?; + let mut bits = 0; + for (bit, format) in FORMATS.iter().enumerate() { + let on = match format { + Formatting::Toggle(toggle) => state.toggles.contains(toggle), + Formatting::Bullets => state.bullets, + Formatting::Numbering => state.numbering, + _ => false, + }; + bits |= u32::from(on) << bit; + } + for (bit, tag) in TAGS.iter().enumerate() { + bits |= u32::from(state.tags.contains(tag)) << (16 + bit); + } + Ok(bits) + } + + fn format(&mut self, command: u8) -> Result { + let command = match command { + 16.. => Formatting::Tag(*TAGS.get(usize::from(command - 16)).ok_or("No such tag")?), + _ => FORMATS + .get(usize::from(command)) + .ok_or("No such formatting")? + .clone(), + }; + Ok(moved(self.page.format(command)?)) + } + + /// The title outline, where the page has an editable one. + fn title(&self) -> Option<&TextOutline> { + self.page + .editor + .outlines() + .iter() + .find(|outline| outline.title) + } + + /// Puts the caret at the end of the page title. + fn focus_title(&mut self) -> Result { + let Some(id) = self.title().map(|title| title.id) else { + return Ok(false); + }; + self.page.editor.focus_outline(id)?; + let end = self.length(); + self.select([end, end])?; + Ok(true) + } + + /// Selects the first place `query` occurs on the page, ignoring case. + fn find(&mut self, query: &str) -> Result { + let lower: Vec = query.to_lowercase().encode_utf16().collect(); + if lower.is_empty() { + return Ok(false); + } + let found = self.page.editor.outlines().iter().find_map(|outline| { + let text: Vec = outline.shown_text().to_lowercase().encode_utf16().collect(); + let at = text + .windows(lower.len()) + .position(|window| window == lower)?; + // Lowercasing can change the length; the match is then only found in place. + (text.len() == outline.shown_text().encode_utf16().count()) + .then_some((outline.id, at as u32)) + }); + let Some((id, at)) = found else { + return Ok(false); + }; + self.page.editor.focus_outline(id)?; + self.select([at, at + lower.len() as u32])?; + Ok(true) + } + + /// The page's text, outline by outline, as the desktop puts a refused edit's on the + /// clipboard. + fn page_text(&self) -> String { + let editor = &self.page.editor; + editor + .visible_outlines() + .chain(editor.caret_outline()) + .map(|outline| { + outline + .document() + .paragraphs() + .map(library::shown) + .collect::>() + .join("\n") + }) + .filter(|text| !text.trim().is_empty()) + .collect::>() + .join("\n\n") + } + + /// Puts a picture of `size` points after the caret's paragraph. + fn insert_picture(&mut self, bytes: &[u8], size: [f32; 2]) -> Result { + let image = Image { + id: onestore::page::text::new_id()?, + layout: onestore::document::Layout { + max_width: Some(size[0]), + max_height: Some(size[1]), + ..Default::default() + }, + size: Some(size), + bytes: Some(bytes.into()), + alt: None, + background: false, + }; + let page = &mut self.page; + page.editor.insert_picture(&mut page.engine, image)?; + Ok(true) + } + + /// Gives the page date `seconds` since 1970 (keeping the stored fraction of a second), + /// titled `text`: the long date and the short time as the host formats them. + fn change_date(&mut self, seconds: i64, text: [String; 2]) -> Result { + let date = self.page.editor.date().ok_or("The page has no date")?; + let timestamp = u64::try_from(seconds + 11_644_473_600)? + .checked_mul(10_000_000) + .and_then(|ticks| ticks.checked_add(date.timestamp() % 10_000_000)) + .ok_or("The date is out of range")?; + Ok(moved(self.page.change_date(timestamp, text)?)) } /// A rectangle in the active outline's coordinates, in the view's points as `[x, y, /// width, height]`. fn view_rect(&self, rect: BoundingBox) -> [f32; 4] { let viewport = self.page.viewport; - let points = self.display_scale(); let origin = self.active().origin(); let [x, y] = [rect.x0 as f32 + origin[0], rect.y0 as f32 + origin[1]]; [ - (x * viewport.scale + viewport.origin[0]) / points, - (y * viewport.scale + viewport.origin[1]) / points, - rect.width() as f32 * viewport.scale / points, - rect.height() as f32 * viewport.scale / points, + (x * viewport.scale + viewport.origin[0]) / self.scale, + (y * viewport.scale + viewport.origin[1]) / self.scale, + rect.width() as f32 * viewport.scale / self.scale, + rect.height() as f32 * viewport.scale / self.scale, ] } @@ -487,72 +530,173 @@ impl Canvas { } } +/// The GPU every page view draws with, and the renderer whose glyph atlas they share. +struct Gpu { + instance: wgpu::Instance, + adapter: wgpu::Adapter, + renderer: draw::Renderer, +} + +/// Made with the first view's surface, whose adapter every later layer can use. +static GPU: Mutex> = Mutex::new(None); + +fn gpu() -> std::sync::MutexGuard<'static, Option> { + GPU.lock().unwrap_or_else(|error| error.into_inner()) +} + +/// Text engines of closed views, whose fonts and shaping caches the next view takes over +/// rather than enumerating the system's fonts again. +static ENGINES: Mutex> = Mutex::new(Vec::new()); + pub struct View { - canvas: Canvas, + /// Taken apart on drop, which returns its text engine to `ENGINES`. + canvas: ManuallyDrop, surface: wgpu::Surface<'static>, config: wgpu::SurfaceConfiguration, - renderer: draw::Renderer, frame: Arc, + section: Arc, + /// A conflict page, which shows what is stored and takes no edits. + read_only: bool, } impl View { - fn new(layer: *mut c_void, page: (ExGuid, Page), size: [f32; 2], scale: f32) -> Result { - let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle()); + fn new( + layer: *mut c_void, + section: &Section, + space: ExGuid, + size: [f32; 2], + scale: f32, + ) -> Result { + let pixels = size.map(|side| (side * scale).round().max(1.0) as u32); + let mut gpu = gpu(); + let instance = match &*gpu { + Some(gpu) => gpu.instance.clone(), + None => wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle()), + }; // SAFETY: the host passes a live CAMetalLayer that outlives the view. let surface = unsafe { instance.create_surface_unsafe(wgpu::SurfaceTargetUnsafe::CoreAnimationLayer(layer)) }?; - let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions { - compatible_surface: Some(&surface), - ..Default::default() - }))?; - // The simulator's adapter falls short of wgpu's default limits. - let (device, queue) = - pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor { - required_limits: adapter.limits(), - ..Default::default() - }))?; - let pixels = size.map(|side| (side * scale).round().max(1.0) as u32); + if gpu.is_none() { + let adapter = + pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions { + compatible_surface: Some(&surface), + ..Default::default() + }))?; + // The simulator's adapter falls short of wgpu's default limits. + let (device, queue) = + pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor { + required_limits: adapter.limits(), + ..Default::default() + }))?; + let format = surface + .get_default_config(&adapter, pixels[0], pixels[1]) + .ok_or("No supported surface configuration")? + .format; + *gpu = Some(Gpu { + instance, + renderer: draw::Renderer::new(device, queue, format), + adapter, + }); + } + let gpu = gpu.as_ref().ok_or("No GPU")?; let config = surface - .get_default_config(&adapter, pixels[0], pixels[1]) + .get_default_config(&gpu.adapter, pixels[0], pixels[1]) .ok_or("No supported surface configuration")?; - surface.configure(&device, &config); - let renderer = draw::Renderer::new(device, queue, config.format); + surface.configure(&gpu.renderer.device, &config); + let (page, read_only) = section.shared.page(space)?; Ok(Self { - canvas: Canvas::new(page, pixels, scale)?, + canvas: ManuallyDrop::new(Canvas::new(space, page, pixels, scale)?), surface, config, - renderer, frame: Arc::default(), + section: Arc::clone(§ion.shared), + read_only, }) } fn render(&mut self) -> Result<()> { + let mut gpu = gpu(); + let renderer = &mut gpu.as_mut().ok_or("No GPU")?.renderer; let frame = match self.surface.get_current_texture() { wgpu::CurrentSurfaceTexture::Success(frame) | wgpu::CurrentSurfaceTexture::Suboptimal(frame) => frame, wgpu::CurrentSurfaceTexture::Outdated | wgpu::CurrentSurfaceTexture::Lost => { - self.surface.configure(&self.renderer.device, &self.config); + self.surface.configure(&renderer.device, &self.config); return Ok(()); } _ => return Ok(()), }; - let canvas = &mut self.canvas; + let canvas: &mut Canvas = &mut self.canvas; + let paper = canvas.page_paper(); canvas .page - .update_pictures(canvas.paper, &Waker::from(self.frame.clone())); + .update_pictures(paper, &Waker::from(self.frame.clone())); let primitives = canvas.page.primitives(canvas.colors())?; - self.renderer + renderer .draw( &frame.texture.create_view(&Default::default()), [self.config.width, self.config.height], - canvas.paper.color, + paper.color, &[canvas.page.viewport.layer(&primitives)], ) .map_err(|error| format!("Page drawing failed: {error:?}"))?; - self.renderer.queue.present(frame); + renderer.queue.present(frame); Ok(()) } + + /// Hands the section what the page took, after `result`, the change that took it. A + /// conflict page returns to what is stored instead. + 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 => { + let _ = report(self.reload()); + } + Some(Some(edit)) => { + // Pictures that came or went, by insertion or undo, are drawn or dropped. + if edit.ops.iter().any(|op| { + matches!( + op, + Op::Page { + op: PageOp::Insert { .. } | PageOp::Add { .. } | PageOp::Delete { .. }, + .. + } + ) + }) { + let page = &mut self.canvas.page; + if let Some((scene, _)) = &mut page.scene { + let refreshed = scene.refresh(&mut page.editor, &mut page.engine); + let _ = report(refreshed.map_err(Into::into)); + } + } + let _ = report(self.section.apply(edit)); + } + _ => {} + } + changed + } + + /// Shows the page as stored, in place: the caret, selection and drawn pictures stay. + /// Waits for marked text to end. + fn reload(&mut self) -> Result { + if self.canvas.page.editor.marked_range().is_some() { + return Ok(false); + } + let (page, read_only) = self.section.page(self.canvas.space)?; + self.read_only = read_only; + Ok(moved(self.canvas.page.refresh(page)?)) + } +} + +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); + } + } } fn string(text: *const c_char) -> String { @@ -566,78 +710,26 @@ fn owned(text: String) -> *mut c_char { CString::new(text.replace('\0', "")).map_or(std::ptr::null_mut(), CString::into_raw) } -/// The notebook folder at `path`, or the lone section file there, as JSON: `name`, and -/// `sections` in the notebook's order, each with `name`, `path`, `group`, `color` as sRGB -/// bytes and `readable`. Freed with `sb_string_free`; null if it cannot be read. +/// A view drawing page `id` of `section` into `layer`, a `CAMetalLayer`, `size` points at +/// `scale` pixels per point; null if it cannot. /// /// # Safety -/// `path` is NUL-terminated UTF-8. -#[unsafe(no_mangle)] -pub unsafe extern "C" fn sb_notebook(path: *const c_char) -> *mut c_char { - report( - listing(Path::new(&string(path))).and_then(|listing| Ok(serde_json::to_string(&listing)?)), - ) - .map_or(std::ptr::null_mut(), owned) -} - -/// # Safety -/// `path` is NUL-terminated UTF-8. -#[unsafe(no_mangle)] -pub unsafe extern "C" fn sb_section_open(path: *const c_char) -> *mut Section { - report( - std::fs::read(string(path)) - .map_err(Into::into) - .and_then(|bytes| Section::open(&bytes)), - ) - .map_or(std::ptr::null_mut(), |section| { - Box::into_raw(Box::new(section)) - }) -} - -#[unsafe(no_mangle)] -pub extern "C" fn sb_section_count(section: &Section) -> usize { - section.pages.len() -} - -/// The page's title, alive as long as the section. -#[unsafe(no_mangle)] -pub extern "C" fn sb_section_title(section: &Section, index: usize) -> *const c_char { - section.headings[index].0.as_ptr() -} - -/// The page's level in the page list: 1 at the top, 2 for a subpage and so on. -#[unsafe(no_mangle)] -pub extern "C" fn sb_section_level(section: &Section, index: usize) -> u32 { - section.headings[index].1 -} - -/// # Safety -/// `section` came from `sb_section_open` and is not used again. -#[unsafe(no_mangle)] -pub unsafe extern "C" fn sb_section_free(section: *mut Section) { - drop(unsafe { Box::from_raw(section) }); -} - -/// A view drawing page `index` of `section` into `layer`, a `CAMetalLayer`, `size` points -/// at `scale` pixels per point; null if it cannot. -/// -/// # Safety -/// `layer` is a live `CAMetalLayer` that outlives the view. +/// `layer` is a live `CAMetalLayer` that outlives the view; `id` is NUL-terminated UTF-8. #[unsafe(no_mangle)] pub unsafe extern "C" fn sb_view_new( layer: *mut c_void, section: &Section, - index: usize, + id: *const c_char, width: f32, height: f32, scale: f32, ) -> *mut View { - report(View::new( - layer, - section.pages[index].clone(), - [width, height], - scale, - )) + report( + string(id) + .parse() + .map_err(Into::into) + .and_then(|space| View::new(layer, section, space, [width, height], scale)), + ) .map_or(std::ptr::null_mut(), |view| Box::into_raw(Box::new(view))) } @@ -651,14 +743,17 @@ pub unsafe extern "C" fn sb_view_free(view: *mut View) { #[unsafe(no_mangle)] pub extern "C" fn sb_view_resize(view: &mut View, width: f32, height: f32, scale: f32) { let pixels = [width, height].map(|side| (side * scale).round().max(1.0) as u32); - if scale != view.canvas.display_scale() { - let _ = report(view.canvas.page.scale_factor_changed(scale)); + let canvas = &mut view.canvas; + if scale != canvas.scale { + canvas.scale = scale; + let _ = report(canvas.page.scale_factor_changed(scale)); } - let page = &mut view.canvas.page; - let _ = report(page.resized(pixels)); + let _ = report(canvas.page.resized(pixels)); view.config.width = pixels[0]; view.config.height = pixels[1]; - view.surface.configure(&view.renderer.device, &view.config); + if let Some(gpu) = &*gpu() { + view.surface.configure(&gpu.renderer.device, &view.config); + } } #[unsafe(no_mangle)] @@ -678,10 +773,27 @@ 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. +#[unsafe(no_mangle)] +pub extern "C" fn sb_view_read_only(view: &View) -> bool { + view.read_only +} + +/// Shows the page as stored after a change made elsewhere, keeping the caret; with +/// `discard`, after the section refused an edit, drops what the page took since. +#[unsafe(no_mangle)] +pub extern "C" fn sb_view_reload(view: &mut View, discard: bool) -> bool { + if discard { + let _ = view.canvas.page.editor.take_ops(); + } + report(view.reload()).unwrap_or(false) +} + /// Whether the view has input focus: the focused outline shows its frame and grips only then. #[unsafe(no_mangle)] pub extern "C" fn sb_view_focus(view: &mut View, focused: bool) { - let _ = report(view.canvas.hold(|page| page.focus_changed(focused))); + let result = view.canvas.page.focus_changed(focused).map(moved); + view.stored(result); } /// The page content's `[left, top, right, bottom]` in points at 100% zoom, bottom and right @@ -710,7 +822,7 @@ pub extern "C" fn sb_view_set_transform(view: &mut View, zoom: f32, x: f32, y: f /// What the view point `x`, `y` lands on: 0 for the page, where the canvas takes a tap; 1 /// for the text taking input; 2 for another outline's text; 3 for the focused outline's -/// move or width grip. +/// move or width grip, or the selected picture and its handles. #[unsafe(no_mangle)] pub extern "C" fn sb_view_target(view: &View, x: f32, y: f32) -> u8 { view.canvas.target([x, y]) as u8 @@ -720,7 +832,8 @@ pub extern "C" fn sb_view_target(view: &View, x: f32, y: f32) -> u8 { /// or starts a new one; with `sb_view_drag` between, a grip moves or widens an outline. #[unsafe(no_mangle)] pub extern "C" fn sb_view_press(view: &mut View, x: f32, y: f32) -> bool { - report(view.canvas.press([x, y])).unwrap_or(false) + let result = view.canvas.press([x, y]); + view.stored(result) } #[unsafe(no_mangle)] @@ -730,13 +843,15 @@ pub extern "C" fn sb_view_drag(view: &mut View, x: f32, y: f32) -> bool { #[unsafe(no_mangle)] pub extern "C" fn sb_view_release(view: &mut View) -> bool { - report(view.canvas.release()).unwrap_or(false) + let result = view.canvas.release(); + view.stored(result) } /// Undoes the last edit, or with `redo` redoes the last undone one. #[unsafe(no_mangle)] pub extern "C" fn sb_view_undo(view: &mut View, redo: bool) -> bool { - report(view.canvas.hold(|page| page.undo(redo).map(moved))).unwrap_or(false) + let result = view.canvas.page.undo(redo).map(moved); + view.stored(result) } #[unsafe(no_mangle)] @@ -749,30 +864,98 @@ pub extern "C" fn sb_view_can_undo(view: &View, redo: bool) -> bool { } } -/// Toggles 0 bold, 1 italic or 2 underline on the selection. +/// The selection's formatting as bits: 0 bold, 1 italic, 2 underline, 3 strikethrough, 4 +/// bulleted, 5 numbered, and from 16 OneNote's default tags in order, To Do first. #[unsafe(no_mangle)] -pub extern "C" fn sb_view_toggle(view: &mut View, toggle: u8) -> bool { - let toggle = match toggle { - 0 => Toggle::Bold, - 1 => Toggle::Italic, - _ => Toggle::Underline, +pub extern "C" fn sb_view_format(view: &View) -> u32 { + report(view.canvas.format_bits()).unwrap_or(0) +} + +/// Applies formatting to the selection: 0 bold, 1 italic, 2 underline, 3 strikethrough, 4 +/// bullets, 5 numbering, 6 indent, 7 outdent, each toggling where it can; from 16 toggles +/// OneNote's default tag `command - 16`, To Do first. +#[unsafe(no_mangle)] +pub extern "C" fn sb_view_apply(view: &mut View, command: u8) -> bool { + let result = view.canvas.format(command); + view.stored(result) +} + +/// The page title's text; null on a page without an editable title. +#[unsafe(no_mangle)] +pub extern "C" fn sb_view_title(view: &View) -> *mut c_char { + view.canvas + .title() + .map_or(std::ptr::null_mut(), |title| owned(title.shown_text())) +} + +/// Puts the caret at the end of the page title; false on a page without one. +#[unsafe(no_mangle)] +pub extern "C" fn sb_view_focus_title(view: &mut View) -> bool { + report(view.canvas.focus_title()).unwrap_or(false) +} + +/// Selects the first place `query` occurs on the page, ignoring case. +/// +/// # Safety +/// `query` is NUL-terminated UTF-8. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn sb_view_find(view: &mut View, query: *const c_char) -> bool { + report(view.canvas.find(&string(query))).unwrap_or(false) +} + +/// The page's text, for the clipboard when an edit is refused; freed with `sb_string_free`. +#[unsafe(no_mangle)] +pub extern "C" fn sb_view_page_text(view: &View) -> *mut c_char { + owned(view.canvas.page_text()) +} + +/// Puts the picture file `bytes` (JPEG or PNG), `width` × `height` points, after the +/// caret's paragraph. +/// +/// # Safety +/// `bytes` holds `length` bytes. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn sb_view_insert_picture( + view: &mut View, + bytes: *const u8, + length: usize, + width: f32, + height: f32, +) -> bool { + // SAFETY: the caller's contract. + let bytes = unsafe { std::slice::from_raw_parts(bytes, length) }; + let result = view.canvas.insert_picture(bytes, [width, height]); + view.stored(result) +} + +/// The page date field the last tap asked to change, taken: 0 the date, 1 the time, or -1 +/// for none; `seconds` gets the page's date in seconds since 1970. +#[unsafe(no_mangle)] +pub extern "C" fn sb_view_date_request(view: &mut View, seconds: &mut i64) -> i8 { + let Some(field) = view.canvas.asked.take() else { + return -1; + }; + let Some(date) = view.canvas.page.editor.date() else { + return -1; }; - report( - view.canvas - .hold(|page| page.format(Formatting::Toggle(toggle)).map(moved)), - ) - .unwrap_or(false) -} - -/// The edit the page took since the last call, as `onestore::op::Edit` JSON for the -/// section to apply, freed with `sb_string_free`; null when it took none or cannot be -/// stored, in which case the page should be opened again. -#[unsafe(no_mangle)] -pub extern "C" fn sb_view_edit(view: &mut View) -> *mut c_char { - report(view.canvas.edit()) - .flatten() - .and_then(|edit| serde_json::to_string(&edit).ok()) - .map_or(std::ptr::null_mut(), owned) + *seconds = (date.timestamp() / 10_000_000) as i64 - 11_644_473_600; + field as i8 +} + +/// Gives the page date `seconds` since 1970, shown as `date` and `time`: the long date and +/// the short time, as OneNote titles a page. +/// +/// # Safety +/// `date` and `time` are NUL-terminated UTF-8. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn sb_view_change_date( + view: &mut View, + seconds: i64, + date: *const c_char, + time: *const c_char, +) -> bool { + let result = view.canvas.change_date(seconds, [string(date), string(time)]); + view.stored(result) } /// # Safety @@ -822,25 +1005,26 @@ pub unsafe extern "C" fn sb_set_marked( selected_start: u32, selected_end: u32, ) -> bool { - report( - view.canvas - .set_marked(string(text), [selected_start, selected_end]), - ) - .unwrap_or(false) + let result = view + .canvas + .set_marked(string(text), [selected_start, selected_end]); + view.stored(result) } #[unsafe(no_mangle)] pub extern "C" fn sb_unmark(view: &mut View) { view.canvas.page.editor.finish_composition(); + view.stored(Ok(false)); } -/// Types `text`, a lone `\n` as the Return key. +/// Types `text`, a lone `\n` as the Return key and a lone `\t` as the Tab key. /// /// # Safety /// `text` is NUL-terminated UTF-8. #[unsafe(no_mangle)] pub unsafe extern "C" fn sb_insert(view: &mut View, text: *const c_char) -> bool { - report(view.canvas.insert(string(text))).unwrap_or(false) + let result = view.canvas.insert(string(text)); + view.stored(result) } /// Replaces `start..end` with `text`, as autocorrection, dictation and cutting do. @@ -855,12 +1039,10 @@ pub unsafe extern "C" fn sb_replace( text: *const c_char, ) -> bool { let canvas = &mut view.canvas; - report( - canvas - .select([start, end]) - .and_then(|()| canvas.hold(|page| Ok(moved(page.commit_text(string(text))?)))), - ) - .unwrap_or(false) + let result = canvas + .select([start, end]) + .and_then(|()| Ok(moved(canvas.page.commit_text(string(text))?))); + view.stored(result) } /// Pastes clipboard `text` in `language`, the keyboard's BCP-47 tag. @@ -874,20 +1056,18 @@ pub unsafe extern "C" fn sb_paste( language: *const c_char, ) -> bool { let (text, language) = (string(text), canvas::language::lcid(&string(language))); - report( - view.canvas - .hold(|page| page.paste(&text, language).map(moved)), - ) - .unwrap_or(false) + let result = view.canvas.page.paste(&text, language).map(moved); + view.stored(result) } #[unsafe(no_mangle)] pub extern "C" fn sb_delete_backward(view: &mut View) -> bool { - report( - view.canvas - .hold(|page| page.key(&Key::Named(NamedKey::Backspace), None).map(moved)), - ) - .unwrap_or(false) + let result = view + .canvas + .page + .key(&Key::Named(NamedKey::Backspace), None) + .map(moved); + view.stored(result) } /// The caret at `offset` as `[x, y, width, height]` in the view's points. diff --git a/crates/mobile/src/library.rs b/crates/mobile/src/library.rs new file mode 100644 index 0000000000000000000000000000000000000000..30f780a626019555c659c33dc360be3ecedc68c1 --- /dev/null +++ b/crates/mobile/src/library.rs @@ -0,0 +1,1204 @@ +//! Notebooks behind the C surface: a folder or lone section from Files, or a folder on an +//! 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 notebook::{ + Remote, Replica, + discover::{Folder, SectionState}, + session::{self, Event, Notebook}, + smb::{Client, Credentials}, +}; +use onestore::{ + CommitError, CommitState, ExGuid, PageCreation, PageEdit, RevisionIndex, Stamp, Store, + Transaction, + document::Document, + op::{Edit, Op, SectionOp}, + page::{Page, PageObject, PageParagraph, ParagraphContent}, +}; +use std::{ + collections::{BTreeMap, HashMap}, + ffi::{CString, c_char, c_void}, + io, + path::{Path, PathBuf}, + sync::{ + Arc, Mutex, OnceLock, + atomic::{AtomicU8, Ordering}, + }, + time::{Duration, Instant}, +}; + +/// How long an SMB request may take before the share counts as unreachable. +const TIMEOUT: Duration = Duration::from_secs(10); +/// The largest section file read whole. +const LIMIT: usize = 256 * 1024 * 1024; +/// The tab colour OneNote gives a section that stores none, as the desktop shows it. +const SECTION_COLOR: u32 = 0x00e4_a88a; + +/// Runs `body(context)` within the host's coordinated reading or writing of `path`, as +/// `NSFileCoordinator` has other processes (file providers) wait for it, or not at all when +/// it cannot coordinate. +pub type Coordinator = extern "C" fn( + path: *const c_char, + write: bool, + body: extern "C" fn(*mut c_void), + context: *mut c_void, +); + +static COORDINATOR: OnceLock = OnceLock::new(); + +/// Coordinates every read and write of a local notebook's files through `coordinator` from +/// now on; set once, before any library opens. +#[unsafe(no_mangle)] +pub extern "C" fn sb_set_coordinator(coordinator: Coordinator) { + let _ = COORDINATOR.set(coordinator); +} + +/// `work` within the host's coordinated access to `path`, or directly when the host +/// coordinates nothing. +pub(crate) fn coordinated T, T>(path: &Path, write: bool, work: F) -> io::Result { + let Some(coordinator) = COORDINATOR.get() else { + return Ok(work()); + }; + extern "C" fn run T, T>(context: *mut c_void) { + // SAFETY: `context` is the pair below, alive for the host's call. + let (work, result) = unsafe { &mut *context.cast::<(Option, Option)>() }; + *result = work.take().map(|work| work()); + } + let mut pair = (Some(work), None); + let path = CString::new(path.as_os_str().as_encoded_bytes())?; + coordinator(path.as_ptr(), write, run::, (&raw mut pair).cast()); + pair.1 + .ok_or_else(|| io::Error::other("Another app is using the notebook's file; try again.")) +} + +/// A local section file read and published under the host's file coordination. +struct Coordinated(PathBuf); + +impl Remote for Coordinated { + fn read(&mut self) -> io::Result> { + coordinated(&self.0, false, || onestore::read_file(&self.0))? + } + + /// The header and length, as the file remote's uncoordinated change detector reads them. + fn stamp(&mut self) -> io::Result { + coordinated(&self.0, false, || { + use std::io::Read; + let mut file = std::fs::File::open(&self.0)?; + let mut header = [0; 1024]; + file.read_exact(&mut header)?; + Ok(Stamp { + header, + length: file.metadata()?.len(), + }) + })? + } + + fn publish(&mut self, transaction: &Transaction) -> std::result::Result<(), CommitError> { + coordinated(&self.0, true, || transaction.commit_file(&self.0)).map_err(uncommitted)? + } + + fn confirm(&mut self, base: &Stamp) -> std::result::Result<(), CommitError> { + coordinated(&self.0, true, || onestore::confirm_file(&self.0, base)).map_err(uncommitted)? + } +} + +fn uncommitted(error: io::Error) -> CommitError { + CommitError { + state: CommitState::NotCommitted, + error, + } +} + +/// A server and share with the account that opens it, kept to reconnect. +pub(crate) struct Server { + pub(crate) address: String, + pub(crate) share: String, + pub(crate) user: String, + pub(crate) password: String, + pub(crate) domain: String, +} + +impl Server { + pub(crate) fn connect(&self) -> io::Result { + Client::connect( + &self.address, + &self.share, + Credentials { + username: &self.user, + password: &self.password, + domain: &self.domain, + }, + TIMEOUT, + ) + } +} + +/// A connected share, for browsing to a notebook folder. +pub struct Share(Client); + +enum Place { + Folder(PathBuf), + /// A section file opened on its own. + File(PathBuf), + /// A notebook folder at `root` on a share; no client while the server cannot be reached. + Share { + server: Arc, + client: Mutex>>, + root: String, + }, +} + +/// A share notebook's sections as last listed, so they open from their replicas while the +/// server cannot be reached. +#[derive(serde::Serialize, serde::Deserialize)] +struct Listed { + tabs: Vec, + /// Each section's file identity in hex, which names its replica, by catalog path. + files: BTreeMap, +} + +fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|byte| format!("{byte:02x}")).collect() +} + +/// Every section's file identity in `folder` and its groups, by catalog path. +fn files(folder: &Folder, files: &mut BTreeMap) { + for section in &folder.sections { + files.insert(section.path.clone(), hex(§ion.file_id)); + } + for group in &folder.groups { + self::files(group, files); + } +} + +/// Page texts of a section as last read, and when its file was last checked. +struct Indexed { + checked: Instant, + stamp: Vec, + pages: Vec<(ExGuid, String, String)>, +} + +pub struct Library { + /// `None` for a lone section. + notebook: Mutex>, + place: Place, + cache: PathBuf, + index: Mutex>, +} + +#[derive(serde::Serialize, serde::Deserialize)] +pub(crate) struct Tab { + pub(crate) name: String, + /// The catalog path `sb_section_open` takes. + pub(crate) path: String, + /// The section group holding it, `/`-separated; empty at the notebook's top. + pub(crate) group: String, + /// The tab colour in sRGB. + pub(crate) color: [u8; 3], + /// Password-protected or unreadable sections list but do not open. + pub(crate) readable: bool, +} + +fn rgb(colorref: u32) -> [u8; 3] { + let [red, green, blue, _] = colorref.to_le_bytes(); + [red, green, blue] +} + +fn stem(path: &str) -> String { + Path::new(path) + .file_stem() + .map(|stem| stem.to_string_lossy().into_owned()) + .unwrap_or_default() +} + +/// 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) { + 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), + }; + 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)), + readable, + }); + } + for group in &folder.groups { + if !group.path.ends_with("OneNote_RecycleBin") { + self::tabs(group, tabs); + } + } +} + +impl Library { + pub(crate) fn open(path: &Path, cache: &Path) -> Result { + let (notebook, place) = if path.is_file() { + (None, Place::File(path.to_owned())) + } else { + ( + Some(Notebook::open(path, cache)?), + Place::Folder(path.to_owned()), + ) + }; + Ok(Self { + notebook: Mutex::new(notebook), + place, + cache: cache.to_owned(), + index: Mutex::default(), + }) + } + + /// The notebook folder `root` on `server`; while the server cannot be reached, its sections + /// as last listed, if it was listed before. + pub(crate) fn server(server: Server, root: &str, cache: &Path) -> Result { + let library = Self { + notebook: Mutex::default(), + place: Place::Share { + server: Arc::new(server), + client: Mutex::default(), + root: root.to_owned(), + }, + cache: cache.to_owned(), + index: Mutex::default(), + }; + if let Err(error) = library.with_notebook(false, |_| Ok(())) + && library.listed().is_none() + { + return Err(error); + } + Ok(library) + } + + /// Where a share notebook's last listing is kept. + fn listing(&self) -> Option { + let Place::Share { server, root, .. } = &self.place else { + return None; + }; + let name: String = format!("{}/{}/{root}", server.address, server.share) + .chars() + .map(|char| if char.is_alphanumeric() { char } else { '_' }) + .collect(); + Some(self.cache.join("smb").join(format!("{name}.json"))) + } + + fn listed(&self) -> Option { + let bytes = std::fs::read(self.listing()?).ok()?; + serde_json::from_slice(&bytes).ok() + } + + fn notebook(&self) -> std::sync::MutexGuard<'_, Option> { + self.notebook + .lock() + .unwrap_or_else(|error| error.into_inner()) + } + + fn client(&self) -> Option> { + match &self.place { + Place::Share { client, .. } => client + .lock() + .unwrap_or_else(|error| error.into_inner()) + .clone(), + _ => None, + } + } + + /// Runs `change`, which reads or with `write` changes the notebook's files, on the + /// notebook: a local folder under the host's file coordination; on a share not reached + /// yet or whose connection dropped, connecting again and retrying once. + fn with_notebook( + &self, + write: bool, + mut change: impl FnMut(&mut Notebook) -> Result, + ) -> Result { + let mut notebook = self.notebook(); + let Place::Share { + server, + client, + root, + } = &self.place + else { + let notebook = notebook.as_mut().ok_or("A lone section has no notebook")?; + return match &self.place { + Place::Folder(root) => coordinated(root, write, || change(notebook))?, + _ => change(notebook), + }; + }; + if let Some(current) = notebook.as_mut() + && let Ok(result) = change(current) + { + return Ok(result); + } + let fresh = Arc::new(server.connect()?); + *client.lock().unwrap_or_else(|error| error.into_inner()) = Some(Arc::clone(&fresh)); + let reopened = notebook.insert(Notebook::open_smb(fresh, root, &self.cache)?); + change(reopened) + } + + /// The notebook's sections, read again. + pub(crate) fn tabs(&self) -> Result> { + if let Place::File(file) = &self.place { + let path = file.to_string_lossy().into_owned(); + return Ok(vec![Tab { + name: stem(&path), + path, + group: String::new(), + color: rgb(SECTION_COLOR), + readable: true, + }]); + } + let listed = self.with_notebook(false, |notebook| { + notebook.refresh()?; + let mut listed = Listed { + tabs: Vec::new(), + files: BTreeMap::new(), + }; + tabs(notebook.catalog(), &mut listed.tabs); + files(notebook.catalog(), &mut listed.files); + Ok(listed) + }); + let Some(listing) = self.listing() else { + return Ok(listed?.tabs); + }; + match listed { + Ok(listed) => { + std::fs::create_dir_all(listing.parent().ok_or("No cache")?)?; + std::fs::write(&listing, serde_json::to_vec(&listed)?)?; + Ok(listed.tabs) + } + Err(error) => Ok(self.listed().ok_or(error)?.tabs), + } + } + + /// The section at catalog `path`, through a replica in the cache. + pub(crate) fn section( + &self, + path: &str, + notify: impl Fn() + Send + 'static, + ) -> Result { + match &self.place { + Place::File(file) => Ok(session::Section::open_with( + file, + &self.cache, + |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::Share { server, root, .. } => { + let file = if root.is_empty() { + path.to_owned() + } else { + format!("{root}/{path}") + }; + let identity = self + .listed() + .and_then(|mut listed| listed.files.remove(path)) + .ok_or("The notebook hasn’t listed this section")?; + let cache = self.cache.join("smb").join(format!("{identity}.sqlite")); + let replica = if cache.exists() { + Replica::open(&cache)? + } else { + let client = self + .client() + .ok_or("The server can’t be reached, and this section hasn’t been opened here before.")?; + Replica::create(&cache, &client.read_storage(&file, LIMIT)?)? + }; + let server = Arc::clone(server); + Ok(session::Section::resume_smb( + file, + replica, + LIMIT, + move || server.connect(), + notify, + )?) + } + } + } +} + +/// What saving has come to, as the page shows it. +#[repr(u8)] +#[derive(Clone, Copy)] +pub(crate) enum Status { + Saved = 1, + Saving = 2, + /// The file cannot be reached; edits wait in the cache. + Offline = 3, + /// An edit was refused or the replica failed. + NotSaving = 4, +} + +/// An open section as its page views share it. +pub(crate) struct Shared { + pub section: session::Section, + /// Who the edits name, as OneNote names the Office user. + pub author: String, + pub status: AtomicU8, +} + +impl Shared { + pub fn apply(&self, edit: Edit) -> Result<()> { + self.section.apply(&self.author, edit)?; + self.status.store(Status::Saving as u8, Ordering::Relaxed); + Ok(()) + } + + /// The page in `space` as a view shows it: a conflict page highlighted and read-only. + pub fn page(&self, space: ExGuid) -> Result<(Page, bool)> { + let page = self.section.page(space)?; + let conflict = self + .section + .conflicts()? + .into_iter() + .flat_map(|(_, versions)| versions) + .find(|version| version.space == space); + Ok(match conflict { + Some(version) => (canvas::conflict::highlighted(page, &version.objects), true), + None => (page, false), + }) + } +} + +/// A section open for editing, with the notebook it belongs to. +pub struct Section { + pub(crate) shared: Arc, + pub(crate) library: Arc, +} + +#[derive(serde::Serialize)] +pub(crate) struct Row { + pub(crate) id: String, + pub(crate) title: String, + /// 1 at the top, 2 for a subpage and so on. + pub(crate) level: u32, + /// Conflict pages: versions with changes a merge could not take. + pub(crate) versions: Vec, +} + +#[derive(serde::Serialize)] +pub(crate) struct Version { + pub(crate) id: String, + /// Whose version it is. + pub(crate) user: String, + /// When the merge made it, in seconds since 1970. + pub(crate) created: Option, +} + +fn unix(filetime: u64) -> i64 { + (filetime / 10_000_000) as i64 - 11_644_473_600 +} + +/// FILETIME now: when an edit happened, which its modification times record. +pub(crate) fn filetime() -> u64 { + let unix = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default(); + (unix.as_secs() + 11_644_473_600) * 10_000_000 + u64::from(unix.subsec_nanos() / 100) +} + +/// A page OneNote 2010 would create now, titled `date` and `time` as the host formats +/// them: the long date and the short time. +fn dated(author: &str, date: &str, time: &str) -> Result { + Ok(PageCreation::new(None, Some(""), author)?.dated(date, time)?) +} + +impl Section { + pub(crate) fn rows(&self) -> Result> { + let section = &self.shared.section; + let conflicts = section.conflicts()?; + Ok(section + .pages()? + .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(), " "), + level, + versions: conflicts + .iter() + .find(|(page, _)| *page == space) + .map(|(_, versions)| { + versions + .iter() + .map(|version| Version { + id: version.space.to_string(), + user: version.user.clone(), + created: version.created.map(unix), + }) + .collect() + }) + .unwrap_or_default(), + }) + .collect()) + } + + /// Adds a page at the end, or a subpage after `parent` and its subpages, as OneNote's + /// New Page and New Subpage do; returns its space. + pub(crate) fn new_page( + &self, + parent: Option, + date: &str, + time: &str, + ) -> Result { + 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 { + let pages = self.shared.section.pages()?; + let at = pages + .iter() + .position(|(space, ..)| *space == parent) + .ok_or("The page is not listed")?; + let level = pages[at].2; + let next = pages[at + 1..] + .iter() + .find(|(.., below)| *below <= level) + .map(|(space, ..)| *space); + ops.push(Op::Section(SectionOp::Pages(vec![PageEdit::move_to( + space, + next, + (level + 1).min(3), + )?]))); + } + self.shared.apply(Edit { + at: filetime(), + ops, + })?; + Ok(space) + } + + /// Deletes a page as OneNote 2010 does, keeping a copy in the notebook's recycle bin; + /// a conflict page goes for good. A section left empty gets a new page. + pub(crate) fn delete_page(&self, space: ExGuid, date: &str, time: &str) -> Result<()> { + let section = &self.shared.section; + let listed = section.pages()?; + let mut ops = vec![Op::Section(SectionOp::Delete(vec![space]))]; + if listed.iter().any(|(page, ..)| *page == space) { + if self.library.notebook().is_some() { + let page = section.page(space)?; + let author = &self.shared.author; + self.library.with_notebook(true, |notebook| { + Ok(notebook.recycle_pages(std::slice::from_ref(&page), author)?) + })?; + } + if listed.len() == 1 { + ops.push(Op::Section(SectionOp::Create(dated( + &self.shared.author, + date, + time, + )?))); + } + } + self.shared.apply(Edit { + at: filetime(), + ops, + }) + } + + /// Applies the events since the last poll to the shared status. + pub(crate) fn poll(&self) -> u32 { + let mut flags = 0; + for event in self.shared.section.events() { + let status = match event { + Event::Changed(_) => { + flags |= LISTED | CHANGED; + continue; + } + Event::Rejected { error, .. } => { + eprintln!("snowbound: saving failed: {error}"); + flags |= REJECTED; + Status::NotSaving + } + Event::Attempt { + status: notebook::EditStatus::Published { .. }, + .. + } => Status::Saved, + Event::Attempt { .. } => Status::Saving, + Event::Unreachable(error) => { + eprintln!("snowbound: the section cannot be reached: {error}"); + Status::Offline + } + Event::Failed(error) => { + eprintln!("snowbound: synchronization stopped: {error}"); + Status::NotSaving + } + }; + self.shared.status.store(status as u8, Ordering::Relaxed); + } + flags + } + + /// Waits until every edit is in the cache, then publishes them within `limit`: true when + /// nothing waits to be published. + pub(crate) fn flush(&self, limit: Duration) -> bool { + let section = &self.shared.section; + // The section thread answers in order, so an answer means earlier edits are stored. + if report(section.pages().map_err(Into::into)).is_none() { + return false; + } + let deadline = Instant::now() + limit; + loop { + match section.pending() { + Ok(pending) if pending.is_empty() => return true, + Ok(_) if Instant::now() < deadline => { + section.wake(); + std::thread::sleep(Duration::from_millis(50)); + } + _ => return false, + } + } + } +} + +/// `sb_section_poll`: the page list or conflict pages changed. +const LISTED: u32 = 1; +/// A change made elsewhere reached the section; open pages reload. +const CHANGED: u32 = 2; +/// The section refused an edit; the page shown returns to what is stored. +const REJECTED: u32 = 4; + +/// The text a paragraph shows, without hidden field codes. +pub(crate) fn shown(paragraph: &onestore::page::text::Paragraph) -> String { + let mut start = 0; + paragraph + .spans() + .iter() + .filter_map(|span| { + let run = ¶graph.text()[start..span.end]; + start = span.end; + (span.format.hidden != Some(true)).then_some(run) + }) + .collect() +} + +/// A page's text, paragraph by paragraph, the title first. +pub(crate) fn page_text(page: &Page) -> String { + fn walk(paragraphs: &[PageParagraph], out: &mut Vec) { + for paragraph in paragraphs { + match ¶graph.content { + ParagraphContent::Text(text) => out.push(shown(&text.text)), + 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::Title(title) => { + for outline in &title.outlines { + walk(&outline.paragraphs, &mut out); + } + } + PageObject::Outline(outline) => walk(&outline.paragraphs, &mut out), + _ => {} + } + } + out.join("\n") +} + +/// Each page of a section file with its title and text. +fn section_texts(bytes: &[u8]) -> Result> { + let store = Store::parse(bytes)?; + let index = RevisionIndex::parse(&store)?; + let document = Document::parse(&index)?; + let mut pages = Vec::new(); + for (space, id) in document.pages()? { + let revision = document.active(space)?; + let (title, _) = Page::heading(revision, id); + let page = Page::from_revision(revision, id)?; + pages.push((space, title, page_text(&page))); + } + Ok(pages) +} + +#[derive(serde::Serialize)] +pub(crate) struct Found { + /// The section's catalog path. + pub(crate) section: String, + pub(crate) page: String, + pub(crate) title: String, + /// The line around the first match. + pub(crate) snippet: String, +} + +/// Up to 60 characters either side of the first place `lower` occurs in `text`, on its line. +fn snippet(text: &str, lower: &str) -> Option { + let line = text + .lines() + .find(|line| line.to_lowercase().contains(lower))?; + let chars: Vec = line.chars().collect(); + let folded: Vec = line.to_lowercase().chars().collect(); + let needle: Vec = lower.chars().collect(); + // Lowercasing can change a line's length; then the match is shown from the line start. + let at = (folded.len() == chars.len()) + .then(|| { + folded + .windows(needle.len()) + .position(|window| window == needle) + }) + .flatten() + .unwrap_or(0); + let start = at.saturating_sub(60); + let end = (at + needle.len() + 60).min(chars.len()); + let mut text: String = chars[start..end].iter().collect(); + if start > 0 { + text.insert(0, '…'); + } + if end < chars.len() { + text.push('…'); + } + Some(text) +} + +impl Library { + /// Pages of the notebook whose title or text holds `query`, ignoring case: the open + /// section as its edits leave it, others as stored, reread when their file changed. + pub(crate) fn search(&self, open: Option<(&str, &Section)>, query: &str) -> Result> { + let lower = query.to_lowercase(); + let mut found = Vec::new(); + for tab in self.tabs()?.into_iter().filter(|tab| tab.readable) { + let pages = match open.filter(|(path, _)| *path == tab.path) { + Some((_, section)) => { + let section = §ion.shared.section; + section + .pages()? + .into_iter() + .map(|(space, title, _)| { + Ok((space, title, page_text(§ion.page(space)?))) + }) + .collect::>>()? + } + None => match report(self.indexed(&tab.path)) { + Some(pages) => pages, + None => continue, + }, + }; + for (space, title, text) in pages { + let snippet = snippet(&text, &lower); + if title.to_lowercase().contains(&lower) || snippet.is_some() { + found.push(Found { + section: tab.path.clone(), + page: space.to_string(), + title: title.replace(|char: char| char.is_control(), " "), + snippet: snippet.unwrap_or_default(), + }); + } + } + } + Ok(found) + } + + /// The pages of the section at catalog `path` with their texts, read again only when + /// the file changed, and checked at most every 30 seconds. + fn indexed(&self, path: &str) -> Result> { + let mut index = self.index.lock().unwrap_or_else(|error| error.into_inner()); + if let Some(indexed) = index.get(path) + && indexed.checked.elapsed() < Duration::from_secs(30) + { + return Ok(indexed.pages.clone()); + } + 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 stamp = client.stamp(&file)?; + let mut key = stamp.header.to_vec(); + key.extend(stamp.length.to_le_bytes()); + ( + key, + Box::new(move || Ok(client.read_storage(&file, LIMIT)?)), + ) + } + Place::Folder(root) => local(root.join(path))?, + Place::File(file) => local(file.clone())?, + }; + let pages = match index.remove(path) { + Some(indexed) if indexed.stamp == stamp => indexed.pages, + _ => section_texts(&read()?)?, + }; + index.insert( + path.to_owned(), + Indexed { + checked: Instant::now(), + stamp, + pages: pages.clone(), + }, + ); + Ok(pages) + } +} + +type Reader = Box Result>>; + +/// A local file's change key, its length and modification time, with its reader. +fn local(file: PathBuf) -> Result<(Vec, Reader)> { + let metadata = std::fs::metadata(&file)?; + let modified = metadata + .modified()? + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default(); + let mut key = metadata.len().to_le_bytes().to_vec(); + key.extend(modified.as_nanos().to_le_bytes()); + Ok(( + key, + Box::new(move || Ok(coordinated(&file, false, || onestore::read_file(&file))??)), + )) +} + +/// Writes `error` for the host to show, freed with `sb_string_free`. +fn failed(error: Box, out: *mut *mut c_char) { + eprintln!("snowbound: {error}"); + if !out.is_null() { + // SAFETY: the caller passes null or a place for one string. + unsafe { out.write(owned(error.to_string())) }; + } +} + +fn boxed(result: Result, error: *mut *mut c_char) -> *mut T { + match result { + Ok(value) => Box::into_raw(Box::new(value)), + Err(cause) => { + failed(cause, error); + std::ptr::null_mut() + } + } +} + +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) +} + +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. +/// +/// # Safety +/// `path` and `cache` are NUL-terminated UTF-8; `error` is null or a place for a string. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn sb_library_open( + path: *const c_char, + cache: *const c_char, + error: *mut *mut c_char, +) -> *const Library { + match Library::open(Path::new(&string(path)), Path::new(&string(cache))) { + Ok(library) => Arc::into_raw(Arc::new(library)), + Err(cause) => { + failed(cause, error); + std::ptr::null() + } + } +} + +/// The notebook folder `root`, `/`-separated from the top of `share` on the server at +/// `address` (`host` or `host:port`), opened as `user` or as a guest when `user` is empty. +/// While the server cannot be reached, a notebook listed before opens with its sections as +/// last listed. Blocks for up to ten seconds. +/// +/// # Safety +/// Every string is NUL-terminated UTF-8; `error` is null or a place for a string. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn sb_library_server( + address: *const c_char, + share: *const c_char, + user: *const c_char, + password: *const c_char, + domain: *const c_char, + root: *const c_char, + cache: *const c_char, + 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() + } + } +} + +/// The notebook's sections read again, as JSON: each with `name`, `path`, `group`, +/// `color` as sRGB bytes and `readable`, in the notebook's order; null if it cannot be read. +#[unsafe(no_mangle)] +pub extern "C" fn sb_library_sections(library: &Library) -> *mut c_char { + json(library.tabs()) +} + +/// Creates a section named `name` in the group at catalog path `folder` ("" for the +/// notebook's top) holding one page titled with `date` and `time`, as OneNote creates one; +/// returns its catalog path, or null. +/// +/// # Safety +/// Every string is NUL-terminated UTF-8. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn sb_library_new_section( + library: &Library, + folder: *const c_char, + name: *const c_char, + author: *const c_char, + date: *const c_char, + time: *const c_char, +) -> *mut c_char { + let (folder, name) = (string(folder), string(name)); + let page = dated(&string(author), &string(date), &string(time)); + report(page.and_then(|page| { + library.with_notebook(true, |notebook| Ok(notebook.create_section(&folder, &name, &page)?)) + })) + .map_or(std::ptr::null_mut(), owned) +} + +/// Pages of the notebook whose title or text holds `query`, as JSON: each with `section` +/// (a catalog path), `page`, `title` and `snippet`. `open` is the section being edited, at +/// catalog path `path`, or null. Reads the notebook's sections, so call it off the main +/// thread. +/// +/// # Safety +/// `query` and a non-null `path` are NUL-terminated UTF-8. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn sb_library_search( + library: &Library, + open: Option<&Section>, + path: *const c_char, + query: *const c_char, +) -> *mut c_char { + let path = optional(path).unwrap_or_default(); + json(library.search(open.map(|section| (path.as_str(), section)), &string(query))) +} + +/// # Safety +/// `library` came from `sb_library_open` or `sb_library_server` and is not used again. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn sb_library_free(library: *const Library) { + drop(unsafe { Arc::from_raw(library) }); +} + +/// Connects to `share` on the server at `address` (`host` or `host:port`) as `user`, or +/// as a guest when `user` is empty. Blocks for up to ten seconds. +/// +/// # Safety +/// Every string is NUL-terminated UTF-8; `error` is null or a place for a string. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn sb_share_connect( + address: *const c_char, + share: *const c_char, + user: *const c_char, + password: *const c_char, + domain: *const c_char, + error: *mut *mut c_char, +) -> *mut Share { + let server = server(address, share, user, password, domain); + boxed(server.connect().map_err(Into::into).map(Share), error) +} + +fn server( + address: *const c_char, + share: *const c_char, + user: *const c_char, + password: *const c_char, + domain: *const c_char, +) -> Server { + let mut address = string(address); + if !address.contains(':') { + address.push_str(":445"); + } + Server { + address, + share: string(share), + user: string(user), + password: string(password), + domain: string(domain), + } +} + +#[derive(serde::Serialize)] +struct Listing { + /// The folder holds a notebook's table of contents. + notebook: bool, + /// Folders, then section files, by name. + folders: Vec, + sections: Vec, +} + +/// The folder at `path` on the share, as JSON: `notebook`, `folders` and `sections`. +/// +/// # Safety +/// `path` is NUL-terminated UTF-8. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn sb_share_list(share: &Share, path: *const c_char) -> *mut c_char { + json( + share + .0 + .read_dir(&string(path), 100_000) + .map_err(Into::into) + .map(|entries| { + let extension = |name: &str, wanted: &str| { + Path::new(name) + .extension() + .is_some_and(|extension| extension.eq_ignore_ascii_case(wanted)) + }; + // Hidden and system folders, OneNote's recycle bin among them, stay out. + let shown = |name: &str| !name.starts_with('.') && name != "OneNote_RecycleBin"; + let mut listing = Listing { + notebook: entries + .iter() + .any(|entry| extension(&entry.name, "onetoc2")), + folders: Vec::new(), + sections: Vec::new(), + }; + for entry in entries.into_iter().filter(|entry| shown(&entry.name)) { + if entry.attributes & 0x10 != 0 { + listing.folders.push(entry.name); + } else if extension(&entry.name, "one") { + listing.sections.push(entry.name); + } + } + listing.folders.sort_by_key(|name| name.to_lowercase()); + listing.sections.sort_by_key(|name| name.to_lowercase()); + listing + }), + ) +} + +/// # Safety +/// `share` came from `sb_share_connect` and is not used again. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn sb_share_free(share: *mut Share) { + drop(unsafe { Box::from_raw(share) }); +} + +/// Called on a background thread with the token `sb_section_open` took when the section +/// has events for `sb_section_poll`. +pub type Wake = extern "C" fn(token: usize); + +/// Opens the section at catalog `path` of `library` for editing, its edits naming +/// `author`. Reads the whole section the first time, so call it off the main thread. +/// +/// # Safety +/// `path` and `author` are NUL-terminated UTF-8; `error` is null or a place for a string. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn sb_section_open( + library: &Library, + path: *const c_char, + author: *const c_char, + wake: Wake, + token: usize, + error: *mut *mut c_char, +) -> *mut Section { + // SAFETY: the host keeps the library alive while this call runs. + let library = unsafe { + Arc::increment_strong_count(library); + Arc::from_raw(library as *const Library) + }; + boxed( + library + .section(&string(path), move || wake(token)) + .map(|section| Section { + shared: Arc::new(Shared { + section, + author: string(author), + status: AtomicU8::new(0), + }), + library, + }), + error, + ) +} + +/// The section's pages in order as JSON: each with `id`, `title`, `level` and `versions`, +/// its conflict pages, each with `id`, `user` and `created` (seconds since 1970, or null). +#[unsafe(no_mangle)] +pub extern "C" fn sb_section_pages(section: &Section) -> *mut c_char { + json(section.rows()) +} + +/// Takes the section's events: `LISTED` (1) the page list or conflict pages changed, +/// `CHANGED` (2) a change made elsewhere arrived, so open pages reload; `REJECTED` (4) an +/// edit was refused. Writes the saving status: 1 saved, 2 saving, 3 offline, 4 not saving, +/// or 0 before anything happened. +#[unsafe(no_mangle)] +pub extern "C" fn sb_section_poll(section: &Section, status: &mut u8) -> u32 { + let flags = section.poll(); + *status = section.shared.status.load(Ordering::Relaxed); + flags +} + +/// Adds a page, or with `parent` a subpage beneath that page, titled with `date` and +/// `time`; returns its id, or null. +/// +/// # Safety +/// `date` and `time` are NUL-terminated UTF-8; `parent` too unless null. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn sb_section_new_page( + section: &Section, + parent: *const c_char, + date: *const c_char, + time: *const c_char, +) -> *mut c_char { + let page = optional(parent) + .map(|parent| Ok::<_, Box>(parent.parse()?)) + .transpose() + .and_then(|parent| section.new_page(parent, &string(date), &string(time))); + report(page).map_or(std::ptr::null_mut(), |space| owned(space.to_string())) +} + +/// Deletes page `id` to the notebook's recycle bin, or a conflict page for good; a section +/// left without pages gets one titled `date` and `time`. Writes to the notebook, so call it +/// off the main thread. +/// +/// # Safety +/// Every string is NUL-terminated UTF-8. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn sb_section_delete_page( + section: &Section, + id: *const c_char, + 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() +} + +/// Waits for every edit to be stored in the cache, then up to `seconds` for them to be +/// published; true when nothing waits. For the host's background time. +#[unsafe(no_mangle)] +pub extern "C" fn sb_section_flush(section: &Section, seconds: f64) -> bool { + section.flush(Duration::from_secs_f64(seconds)) +} + +/// 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) { + section.shared.section.wake(); +} + +/// # Safety +/// `section` came from `sb_section_open` and is not used again. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn sb_section_free(section: *mut Section) { + drop(unsafe { Box::from_raw(section) }); +} diff --git a/crates/mobile/src/tests.rs b/crates/mobile/src/tests.rs index 1d46514a8c9971107b17ae4adbedc3ba3b7463ff..1797fdf97aef780a10b2f5e02db9691f9bc255d4 100644 --- a/crates/mobile/src/tests.rs +++ b/crates/mobile/src/tests.rs @@ -1,24 +1,69 @@ use super::*; +use std::path::{Path, PathBuf}; -const FEATURES: &str = concat!( - env!("CARGO_MANIFEST_DIR"), - "/../../corpus/media-edit/candidate/Features.one" -); +const CORPUS: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../../corpus"); -/// A page of the corpus section on a 402 × 874 point phone at 3 pixels per point, at 100% -/// with the page origin at the view's corner. -fn canvas(title: &str) -> Canvas { - let section = Section::open(&std::fs::read(FEATURES).unwrap()).unwrap(); - let index = section - .headings - .iter() - .position(|(heading, _)| heading.to_str().unwrap() == title) +fn corpus(path: &str) -> PathBuf { + Path::new(CORPUS).join(path) +} + +/// A copy of the corpus section or notebook folder at `path` in a fresh directory, with a +/// cache beside it. +fn copy(path: &str) -> (tempfile::TempDir, PathBuf) { + let directory = tempfile::tempdir().unwrap(); + let source = corpus(path); + let target = directory.path().join(source.file_name().unwrap()); + let status = std::process::Command::new("cp") + .arg("-R") + .arg(&source) + .arg(&target) + .status() .unwrap(); - let mut canvas = Canvas::new(section.pages[index].clone(), [1206, 2622], 3.0).unwrap(); + assert!(status.success()); + (directory, target) +} + +fn open(library: &Arc, path: &str) -> Section { + let section = library.section(path, || {}).unwrap(); + Section { + shared: Arc::new(Shared { + section, + author: "Clover Test".into(), + status: Default::default(), + }), + library: Arc::clone(library), + } +} + +fn space(section: &Section, title: &str) -> ExGuid { + section + .shared + .section + .pages() + .unwrap() + .into_iter() + .find(|(_, heading, _)| heading == title) + .unwrap() + .0 +} + +/// A page of the Features section on a 402 × 874 point phone at 3 pixels per point, at +/// 100% with the page origin at the view's corner. +fn canvas(section: &Section, title: &str) -> Canvas { + let space = space(section, title); + let (page, _) = section.shared.page(space).unwrap(); + let mut canvas = Canvas::new(space, page, [1206, 2622], 3.0).unwrap(); canvas.set_transform(1.0, [0.0; 2]); canvas } +fn features() -> (tempfile::TempDir, Section) { + let (directory, file) = copy("media-edit/candidate/Features.one"); + let library = Arc::new(Library::open(&file, &directory.path().join("cache")).unwrap()); + let section = open(&library, &file.to_string_lossy()); + (directory, section) +} + /// A view point on the outline holding `text`, `fraction` of the way along its first line. fn point_on(canvas: &Canvas, text: &str, fraction: f32) -> [f32; 2] { let outline = canvas @@ -33,40 +78,37 @@ fn point_on(canvas: &Canvas, text: &str, fraction: f32) -> [f32; 2] { [x * POINT, y * POINT] } -#[test] -fn the_section_lists_titles_and_levels() { - let section = Section::open(&std::fs::read(FEATURES).unwrap()).unwrap(); - assert!( - section - .headings - .iter() - .any(|(title, _)| title.to_str() == Ok("Paragraph controls")) - ); - assert!(section.headings.iter().all(|(_, level)| *level >= 1)); +/// Focuses the outline holding `text` with a tap, and hands the section the edit that took. +fn focus(canvas: &mut Canvas, text: &str) { + let point = point_on(canvas, text, 0.1); + canvas.press(point).unwrap(); + canvas.release().unwrap(); } #[test] fn a_notebook_folder_lists_sections_in_order_with_groups_and_colours() { - let root = concat!( - env!("CARGO_MANIFEST_DIR"), - "/../../corpus/m6/native-features-01/notebook" - ); - let notebook = listing(Path::new(root)).unwrap(); - let names: Vec<_> = notebook - .sections + let directory = tempfile::tempdir().unwrap(); + let library = + Library::open(&corpus("m6/native-features-01/notebook"), directory.path()).unwrap(); + let tabs = library.tabs().unwrap(); + let names: Vec<_> = tabs .iter() .map(|tab| (tab.group.as_str(), tab.name.as_str())) .collect(); - eprintln!("{names:?}"); - assert!(names.contains(&("", "Features"))); - assert!(notebook.sections.iter().any(|tab| !tab.group.is_empty())); - let lone = listing(Path::new(FEATURES)).unwrap(); - assert_eq!(lone.sections.len(), 1); + assert!(names.contains(&("", "Features")), "{names:?}"); + assert!(tabs.iter().any(|tab| !tab.group.is_empty())); + let lone = Library::open( + &corpus("media-edit/candidate/Features.one"), + directory.path(), + ) + .unwrap(); + assert_eq!(lone.tabs().unwrap().len(), 1); } #[test] fn touches_route_to_text_the_page_and_the_focused_outlines_grip() { - let mut canvas = canvas("Paragraph controls"); + let (_directory, section) = features(); + let mut canvas = canvas(§ion, "Paragraph controls"); let text = point_on(&canvas, "Collapsed parent", 0.1); let first = canvas.target(text); assert!( @@ -90,10 +132,9 @@ fn touches_route_to_text_the_page_and_the_focused_outlines_grip() { #[test] fn dragging_the_grip_moves_the_outline_onto_the_grid_as_an_edit() { - let mut canvas = canvas("Paragraph controls"); - let text = point_on(&canvas, "Collapsed parent", 0.1); - canvas.press(text).unwrap(); - canvas.release().unwrap(); + let (_directory, section) = features(); + let mut canvas = canvas(§ion, "Paragraph controls"); + focus(&mut canvas, "Collapsed parent"); canvas.edit().unwrap(); let before = canvas.active().origin(); let grip = [(before[0] + 40.0) * POINT, (before[1] - 8.0) * POINT]; @@ -120,12 +161,23 @@ fn dragging_the_grip_moves_the_outline_onto_the_grid_as_an_edit() { assert!(canvas.page.editor.can_undo()); } +#[test] +fn edits_leave_the_viewport_where_the_host_put_it() { + let (_directory, section) = features(); + let mut canvas = canvas(§ion, "Paragraph controls"); + // Scrolled far past the content, where the canvas would clamp and reveal. + canvas.set_transform(1.0, [-2000.0, 5000.0]); + let origin = canvas.page.viewport.origin; + focus(&mut canvas, "Collapsed parent"); + canvas.insert("typed".into()).unwrap(); + assert_eq!(canvas.page.viewport.origin, origin); +} + #[test] fn the_text_model_skips_collapsed_paragraphs_and_round_trips_selection() { - let mut canvas = canvas("Paragraph controls"); - let text = point_on(&canvas, "Collapsed parent", 0.1); - canvas.press(text).unwrap(); - canvas.release().unwrap(); + let (_directory, section) = features(); + let mut canvas = canvas(§ion, "Paragraph controls"); + focus(&mut canvas, "Collapsed parent"); let shown = canvas.active().shown_text(); let length = canvas.length(); assert_eq!(length as usize, shown.encode_utf16().count()); @@ -147,10 +199,9 @@ fn the_text_model_skips_collapsed_paragraphs_and_round_trips_selection() { #[test] fn undo_restores_typing() { - let mut canvas = canvas("Paragraph controls"); - let text = point_on(&canvas, "Collapsed parent", 0.1); - canvas.press(text).unwrap(); - canvas.release().unwrap(); + let (_directory, section) = features(); + let mut canvas = canvas(§ion, "Paragraph controls"); + focus(&mut canvas, "Collapsed parent"); let before = canvas.active().shown_text(); canvas.insert("hi".into()).unwrap(); assert_ne!(canvas.active().shown_text(), before); @@ -158,3 +209,382 @@ fn undo_restores_typing() { assert_eq!(canvas.active().shown_text(), before); assert!(canvas.page.editor.can_redo()); } + +/// The text of the page `space` of the section file `file`, read cold. +fn stored_text(file: &Path, space: ExGuid) -> String { + let arena = onestore::Arena::default(); + let section = onestore::Section::open(&arena, onestore::read_file(file).unwrap()).unwrap(); + library::page_text(§ion.page(space).unwrap()) +} + +#[test] +fn typing_is_stored_in_the_cache_and_published_to_the_file() { + let (directory, file) = copy("media-edit/candidate/Features.one"); + let cache = directory.path().join("cache"); + let library = Arc::new(Library::open(&file, &cache).unwrap()); + let section = open(&library, &file.to_string_lossy()); + let mut canvas = canvas(§ion, "Paragraph controls"); + focus(&mut canvas, "Collapsed parent"); + let shown = canvas.active().shown_text(); + let at = shown[..shown.find("Collapsed parent").unwrap()] + .encode_utf16() + .count() as u32; + canvas.select([at, at]).unwrap(); + canvas.insert("Saved ".into()).unwrap(); + section + .shared + .apply(canvas.edit().unwrap().unwrap()) + .unwrap(); + assert!(section.flush(Duration::from_secs(20))); + let space = canvas.space; + assert!(stored_text(&file, space).contains("Saved Collapsed parent")); + // The replica reopens from the cache as the file now holds it. + let shared = Arc::clone(§ion.shared); + drop((canvas, section)); + Arc::into_inner(shared).unwrap().section.close().unwrap(); + let reopened = open(&library, &file.to_string_lossy()); + let (page, read_only) = reopened.shared.page(space).unwrap(); + assert!(!read_only); + assert!(library::page_text(&page).contains("Saved Collapsed parent")); +} + +#[test] +fn a_new_page_and_subpage_list_after_their_parent_and_delete_to_the_recycle_bin() { + let (directory, root) = copy("m6/native-features-01/notebook"); + let library = Arc::new(Library::open(&root, &directory.path().join("cache")).unwrap()); + let section = open(&library, "Features.one"); + let first = section.rows().unwrap()[0].id.parse().unwrap(); + let page = section + .new_page(None, "Sunday, September 27, 2026", "3:04 AM") + .unwrap(); + let subpage = section + .new_page(Some(first), "Sunday, September 27, 2026", "3:05 AM") + .unwrap(); + let rows = section.rows().unwrap(); + assert_eq!(rows.last().unwrap().id, page.to_string()); + assert_eq!(rows[1].id, subpage.to_string()); + assert_eq!(rows[1].level, rows[0].level + 1); + let mut canvas = Canvas::new( + subpage, + section.shared.page(subpage).unwrap().0, + [1206, 2622], + 3.0, + ) + .unwrap(); + assert!(canvas.focus_title().unwrap()); + canvas.insert("Sketches".into()).unwrap(); + assert_eq!(canvas.title().unwrap().shown_text(), "Sketches"); + section + .shared + .apply(canvas.edit().unwrap().unwrap()) + .unwrap(); + assert!( + section + .rows() + .unwrap() + .iter() + .any(|row| row.title == "Sketches") + ); + section + .delete_page(subpage, "Sunday, September 27, 2026", "3:06 AM") + .unwrap(); + assert!( + section + .rows() + .unwrap() + .iter() + .all(|row| row.id != subpage.to_string()) + ); + assert!( + root.join("OneNote_RecycleBin/OneNote_DeletedPages.one") + .exists() + ); +} + +#[test] +fn conflict_pages_list_under_their_page_and_take_no_edits() { + let (directory, root) = copy("conflict-page/candidate"); + let library = Arc::new(Library::open(&root, &directory.path().join("cache")).unwrap()); + let section = open(&library, "synthetic.one"); + let rows = section.rows().unwrap(); + let version = rows + .iter() + .find_map(|row| row.versions.first()) + .expect("a conflict page"); + assert!(!version.user.is_empty()); + let (page, read_only) = section.shared.page(version.id.parse().unwrap()).unwrap(); + assert!(read_only); + // Conflicting paragraphs carry OneNote's highlight. + let highlighted = page.objects.iter().any(|object| match object { + onestore::page::PageObject::Outline(outline) => outline + .paragraphs + .iter() + .any(|paragraph| paragraph.format.highlight == Some(canvas::conflict::CONFLICTING)), + _ => false, + }); + assert!(highlighted); +} + +#[test] +fn search_finds_pages_by_title_and_text_across_sections() { + let directory = tempfile::tempdir().unwrap(); + let library = + Library::open(&corpus("m6/native-features-01/notebook"), directory.path()).unwrap(); + let found = library.search(None, "collapsed PARENT").unwrap(); + assert!(!found.is_empty()); + assert!(found[0].snippet.to_lowercase().contains("collapsed parent")); + assert!( + library + .search(None, "no such words anywhere") + .unwrap() + .is_empty() + ); +} + +#[test] +fn find_selects_the_match_and_formatting_reports_and_toggles() { + let (_directory, section) = features(); + let mut canvas = canvas(§ion, "Paragraph controls"); + assert!(canvas.find("collapsed PARENT").unwrap()); + let [start, end] = canvas.selection().unwrap(); + assert_eq!(canvas.text([start, end]).to_lowercase(), "collapsed parent"); + assert_eq!(canvas.format_bits().unwrap() & 1, 0); + assert!(canvas.format(0).unwrap()); + assert_eq!(canvas.format_bits().unwrap() & 1, 1); + // To Do on the paragraph. + assert!(canvas.format(16).unwrap()); + assert_ne!(canvas.format_bits().unwrap() & 1 << 16, 0); + assert!(canvas.edit().unwrap().is_some()); +} + +#[test] +fn a_picture_goes_after_the_caret_paragraph_and_undoes() { + let (_directory, section) = features(); + let mut canvas = canvas(§ion, "Paragraph controls"); + focus(&mut canvas, "Collapsed parent"); + canvas.edit().unwrap(); + let png = std::fs::read(corpus( + "../crates/snowbound/assets/icon/Snowbound-SnowLeopard.png", + )) + .unwrap(); + assert!(canvas.insert_picture(&png, [64.0, 64.0]).unwrap()); + let edit = canvas.edit().unwrap().unwrap(); + assert!(edit.ops.iter().any(|op| matches!( + op, + Op::Page { + op: onestore::op::PageOp::Insert { .. }, + .. + } + ))); + section.shared.apply(edit).unwrap(); + let pictures = |page: &Page| { + page.objects + .iter() + .filter_map(|object| match object { + onestore::page::PageObject::Outline(outline) => Some( + outline + .paragraphs + .iter() + .filter(|paragraph| { + matches!( + paragraph.content, + onestore::page::ParagraphContent::Image(_) + ) + }) + .count(), + ), + _ => None, + }) + .sum::() + }; + let before = pictures(§ion.shared.page(canvas.space).unwrap().0); + assert!(moved(canvas.page.undo(false).unwrap())); + section + .shared + .apply(canvas.edit().unwrap().unwrap()) + .unwrap(); + assert_eq!( + pictures(§ion.shared.page(canvas.space).unwrap().0), + before - 1 + ); +} + +#[test] +fn to_do_marks_a_new_pages_body() { + let (directory, root) = copy("m6/native-features-01/notebook"); + let library = Arc::new(Library::open(&root, &directory.path().join("cache")).unwrap()); + let section = open(&library, "Features.one"); + let page = section + .new_page(None, "Sunday, September 27, 2026", "3:04 AM") + .unwrap(); + let mut canvas = Canvas::new( + page, + section.shared.page(page).unwrap().0, + [1206, 2622], + 3.0, + ) + .unwrap(); + canvas.set_transform(1.0, [0.0; 2]); + assert!(canvas.focus_title().unwrap()); + canvas.insert("Groceries".into()).unwrap(); + canvas.insert("\n".into()).unwrap(); + canvas.insert("Oat milk".into()).unwrap(); + canvas.insert("\n".into()).unwrap(); + canvas.insert("Pears".into()).unwrap(); + assert!(canvas.format(16).unwrap()); + section + .shared + .apply(canvas.edit().unwrap().unwrap()) + .unwrap(); + let stored = section.shared.page(page).unwrap().0; + let tags: usize = stored + .objects + .iter() + .map(|object| match object { + onestore::page::PageObject::Outline(outline) => outline + .paragraphs + .iter() + .filter_map(|paragraph| paragraph.text()) + .map(|text| text.tags.len()) + .sum(), + _ => 0, + }) + .sum(); + assert_eq!(tags, 1); +} + +/// Against the Samba lab (`ONESTORE_SMB_LAB=127.0.0.1:PORT`, share `agent`, guest): a +/// notebook folder copied to the share lists, a section opens and publishes typing, and the +/// listing kept in the cache opens the section again while the server cannot be reached. +#[test] +#[ignore = "requires ONESTORE_SMB_LAB pointing to disposable Samba"] +fn a_notebook_on_a_share_saves_and_opens_offline() { + let address = std::env::var("ONESTORE_SMB_LAB").unwrap(); + let server = |address: &str| library::Server { + address: address.into(), + share: "agent".into(), + user: String::new(), + password: String::new(), + domain: String::new(), + }; + let client = server(&address).connect().unwrap(); + let root = format!("mobile-{}", std::process::id()); + client.create_directory(&root).unwrap(); + let source = corpus("media-edit/candidate/Features.one"); + client + .create( + &format!("{root}/Features.one"), + &std::fs::read(&source).unwrap(), + ) + .unwrap(); + let cache = tempfile::tempdir().unwrap(); + let library = Arc::new(Library::server(server(&address), &root, cache.path()).unwrap()); + assert_eq!(library.tabs().unwrap()[0].path, "Features.one"); + let section = open(&library, "Features.one"); + let mut canvas = canvas(§ion, "Paragraph controls"); + focus(&mut canvas, "Collapsed parent"); + canvas.insert("Shared ".into()).unwrap(); + section + .shared + .apply(canvas.edit().unwrap().unwrap()) + .unwrap(); + assert!(section.flush(Duration::from_secs(30))); + let published = client + .read(&format!("{root}/Features.one"), 1 << 26) + .unwrap(); + let arena = onestore::Arena::default(); + let stored = onestore::Section::open(&arena, published).unwrap(); + assert!(library::page_text(&stored.page(canvas.space).unwrap()).contains("Shared ")); + let shared = Arc::clone(§ion.shared); + drop((canvas, section, library)); + Arc::into_inner(shared).unwrap().section.close().unwrap(); + // The same notebook while its server does not answer: a closed port on the lab's host. + let host = address.split(':').next().unwrap(); + let mut unreachable = server(&format!("{host}:9")); + unreachable.share = "agent".into(); + let listing = |address: &str| { + cache.path().join("smb").join(format!( + "{}.json", + format!("{address}/agent/{root}").replace(|c: char| !c.is_alphanumeric(), "_") + )) + }; + std::fs::copy(listing(&address), listing(&format!("{host}:9"))).unwrap(); + let offline = Arc::new(Library::server(unreachable, &root, cache.path()).unwrap()); + assert_eq!(offline.tabs().unwrap()[0].path, "Features.one"); + let reopened = open(&offline, "Features.one"); + let page = reopened + .shared + .page(space(&reopened, "Paragraph controls")) + .unwrap() + .0; + assert!(library::page_text(&page).contains("Shared ")); +} + +/// Paths the test coordinator was asked for, with whether to write. +static COORDINATED: Mutex> = Mutex::new(Vec::new()); + +extern "C" fn coordinator( + path: *const c_char, + write: bool, + body: extern "C" fn(*mut c_void), + context: *mut c_void, +) { + COORDINATED.lock().unwrap().push((string(path), write)); + body(context); +} + +#[test] +fn a_local_sections_reads_and_publications_go_through_the_hosts_coordination() { + library::sb_set_coordinator(coordinator); + let (directory, root) = copy("m6/native-features-01/notebook"); + let library = Arc::new(Library::open(&root, &directory.path().join("cache")).unwrap()); + let section = open(&library, "Features.one"); + let mut canvas = canvas(§ion, "Paragraph controls"); + focus(&mut canvas, "Collapsed parent"); + canvas.insert("Coordinated ".into()).unwrap(); + section.shared.apply(canvas.edit().unwrap().unwrap()).unwrap(); + assert!(section.flush(Duration::from_secs(20))); + let file = root.canonicalize().unwrap().join("Features.one"); + let file = file.to_string_lossy(); + let coordinated = COORDINATED.lock().unwrap(); + assert!(coordinated.iter().any(|(path, write)| path == &*file && !write)); + assert!(coordinated.iter().any(|(path, write)| path == &*file && *write)); +} + +#[test] +fn tapping_the_date_asks_for_it_and_a_new_date_is_stored() { + let (_directory, section) = features(); + let mut canvas = canvas(§ion, "Paragraph controls"); + // The first view point, scanning down the page, that lands on the date. + let date = (0..400) + .flat_map(|y| (0..130).map(move |x| [x as f32 * 3.0, y as f32 * 3.0])) + .find(|point| { + matches!( + canvas.page.hit(canvas.device(*point)), + Some(Hit::Date(DateField::Date)) + ) + }) + .expect("the page shows its date"); + canvas.press(date).unwrap(); + canvas.release().unwrap(); + assert_eq!(canvas.asked.take(), Some(DateField::Date)); + let before = canvas.page.editor.date().unwrap().timestamp(); + // 2 January 2026, 10:30 UTC. + assert!( + canvas + .change_date(1_767_349_800, ["Friday, January 2, 2026".into(), "10:30 AM".into()]) + .unwrap() + ); + let edit = canvas.edit().unwrap().unwrap(); + assert!(edit.ops.iter().any(|op| matches!( + op, + Op::Page { + op: PageOp::Date { .. }, + .. + } + ))); + section.shared.apply(edit).unwrap(); + let stored = library::page_text(§ion.shared.page(canvas.space).unwrap().0); + assert!(stored.contains("Friday, January 2, 2026"), "{stored}"); + assert_ne!(canvas.page.editor.date().unwrap().timestamp(), before); +} -- 2.54.0