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); +}