authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-27 11:00:59-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-27 11:01:00-07:00
log130e7b1bd5ad7e9aac3a3b4e1dadb6ab3452cb27
treee12f56d3a4cb868c755d59d086cf7105900a0137
parent16f919e13a25e8ef16fcd7e7acc311210c81688b
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

feat(ios): saving, embedded SMB, format bar, search and page management

The iOS app saves through the notebook session under the owner's name, opens notebooks over the embedded SMB client with Keychain credentials and works offline, coordinates Files access, and adds a format bar, tag toggles, pictures, notebook search, page/section creation and deletion, the date picker and iPad shortcuts. One GPU device and text engines are shared across page views, and the host owns scrolling. Assisted-by: claude-opus-5.5 Assisted-by: claude-opus-5

14 files changed, 4220 insertions(+), 698 deletions(-)

apps/ios/Info.plist+8
......@@ -4,11 +4,19 @@
44<dict>
55 <key>CADisableMinimumFrameDurationOnPhone</key>
66 <true/>
7 <key>LSSupportsOpeningDocumentsInPlace</key>
8 <true/>
9 <key>NSCameraUsageDescription</key>
10 <string>Take a photo to put on a page.</string>
11 <key>NSLocalNetworkUsageDescription</key>
12 <string>Snowbound opens notebooks on file servers on your network.</string>
713 <key>UIApplicationSceneManifest</key>
814 <dict>
915 <key>UIApplicationSupportsMultipleScenes</key>
1016 <false/>
1117 </dict>
18 <key>UIFileSharingEnabled</key>
19 <true/>
1220 <key>UILaunchScreen</key>
1321 <dict/>
1422 <key>UISupportedInterfaceOrientations</key>
apps/ios/Snowbound/App.swift+153-280
......@@ -1,8 +1,30 @@
11import UIKit
2import UniformTypeIdentifiers
2
3/// Runs the library's file access under `NSFileCoordinator`, so file providers such as
4/// iCloud Drive download before it reads and learn of what it writes.
5private let coordinate: sb_coordinator = { path, write, body, context in
6 guard let path, let body else { return }
7 let url = URL(fileURLWithPath: String(cString: path))
8 var error: NSError?
9 if write {
10 NSFileCoordinator().coordinate(writingItemAt: url, options: .forMerging, error: &error) { _ in body(context) }
11 } else {
12 NSFileCoordinator().coordinate(readingItemAt: url, options: [], error: &error) { _ in body(context) }
13 }
14}
315
416@main
517final class AppDelegate: UIResponder, UIApplicationDelegate {
18 func application(
19 _ application: UIApplication, didFinishLaunchingWithOptions options: [UIApplication.LaunchOptionsKey: Any]?
20 ) -> Bool {
21 sb_set_coordinator(coordinate)
22 #if DEBUG
23 Presenter.watch()
24 #endif
25 return true
26 }
27
628 func application(
729 _ application: UIApplication,
830 configurationForConnecting session: UISceneSession,
......@@ -12,331 +34,182 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
1234 configuration.delegateClass = SceneDelegate.self
1335 return configuration
1436 }
37
38 /// Edits are in the cache once stored; this gives publishing a moment before the app goes.
39 func applicationWillTerminate(_ application: UIApplication) {
40 NotificationCenter.default.post(name: PageViewController.leaving, object: nil)
41 for section in Section.all { _ = section.flush(2) }
42 }
43}
44
45/// Where the reader was, reopened on the next launch as Notes reopens its last note.
46private struct Place: Codable {
47 let notebook: UUID
48 let section: String
49 let page: String?
50
51 static let key = "place"
52
53 static var saved: Place? {
54 get { UserDefaults.standard.data(forKey: key).flatMap { try? JSONDecoder().decode(Place.self, from: $0) } }
55 set { UserDefaults.standard.set(try? JSONEncoder().encode(newValue), forKey: key) }
56 }
1557}
1658
17/// Notebook, section and page columns as Notes lays out folders, notes and a note: side by
18/// side on a wide screen, a navigation stack on a phone.
59/// Notebooks and sections, pages and a page, as Notes lays out folders, notes and a note:
60/// side by side on a wide screen, a navigation stack on a phone.
1961final class SceneDelegate: UIResponder, UIWindowSceneDelegate, UISplitViewControllerDelegate {
2062 var window: UIWindow?
21 private let split = UISplitViewController(style: .tripleColumn)
22 private let sections = SectionsViewController()
63 private let split = RootViewController(style: .tripleColumn)
64 private let notebooks = NotebooksViewController()
2365 private let pages = PagesViewController()
2466 private var showingPage = false
67 private var background = UIBackgroundTaskIdentifier.invalid
2568
2669 func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options: UIScene.ConnectionOptions) {
2770 guard let scene = scene as? UIWindowScene else { return }
2871 let window = UIWindow(windowScene: scene)
2972 split.delegate = self
73 split.scene = self
3074 split.preferredDisplayMode = .oneBesideSecondary
3175 split.preferredSplitBehavior = .tile
32 split.setViewController(sections, for: .primary)
76 split.setViewController(notebooks, for: .primary)
3377 split.setViewController(pages, for: .supplementary)
3478 split.setViewController(UIViewController(), for: .secondary)
35 sections.onOpen = { [weak self] section in self?.show(section: section) }
36 pages.onOpen = { [weak self] section, page in self?.show(section: section, page: page) }
79 notebooks.onOpen = { [weak self] tab, notebook in self?.open(tab, of: notebook) }
80 pages.onOpen = { [weak self] section, id, find in self?.show(section: section, page: id, find: find) }
81 pages.onOpenSection = { [weak self] notebook, path, id, find in
82 guard let tab = notebook.tabs.first(where: { $0.path == path }) else { return }
83 self?.open(tab, of: notebook, page: id, find: find)
84 }
3785 window.rootViewController = split
3886 window.makeKeyAndVisible()
3987 self.window = window
40 sections.load(Library.opening())
41 let environment = ProcessInfo.processInfo.environment
42 // The split view collapses into a stack once the window is on screen.
43 DispatchQueue.main.async { [self] in
44 if let index = environment["SNOWBOUND_SECTION"].flatMap(Int.init), let notebook = sections.notebook,
45 index < notebook.sections.count
46 {
47 show(section: notebook.sections[index])
48 if let page = environment["SNOWBOUND_PAGE"].flatMap(Int.init), let section = pages.section,
49 page < section.headings.count
50 {
51 show(section: section, page: page)
52 }
88 Notebooks.load()
89 notebooks.reload()
90 for notebook in Notebooks.all {
91 notebook.open { [weak self] in
92 self?.notebooks.reload()
93 self?.restore(notebook)
5394 }
5495 }
5596 }
5697
57 private func show(section tab: Tab) {
58 pages.load(tab)
59 split.show(.supplementary)
60 }
61
62 private func show(section: Section, page: Int) {
63 showingPage = true
64 let controller = PageViewController(section: section, page: page)
65 split.setViewController(UINavigationController(rootViewController: controller), for: .secondary)
66 split.show(.secondary)
67 }
68
69 /// A phone opens on the notebook's sections, as Notes opens on its folders.
70 func splitViewController(
71 _ svc: UISplitViewController, topColumnForCollapsingToProposedTopColumn proposedTopColumn: UISplitViewController.Column
72 ) -> UISplitViewController.Column {
73 showingPage ? proposedTopColumn : .primary
74 }
75}
76
77/// A notebook's listing from `sb_notebook`.
78struct Notebook: Decodable {
79 let name: String
80 let sections: [Tab]
81}
82
83struct Tab: Decodable {
84 let name: String
85 let path: String
86 /// The section group holding it, `/`-separated; empty at the notebook's top.
87 let group: String
88 let color: [UInt8]
89 let readable: Bool
90
91 var uiColor: UIColor {
92 UIColor(red: CGFloat(color[0]) / 255, green: CGFloat(color[1]) / 255, blue: CGFloat(color[2]) / 255, alpha: 1)
93 }
94}
95
96/// Where the notebook shown comes from: the one last opened from Files, kept as a bookmark,
97/// or the bundled sample.
98enum Library {
99 private static let bookmarkKey = "notebook"
100 /// The Files location being read, held open while it is shown.
101 private static var accessed: URL?
102
103 static func opening() -> Notebook? {
104 if let path = ProcessInfo.processInfo.environment["SNOWBOUND_NOTEBOOK"] { return read(path) }
105 var stale = false
106 if let data = UserDefaults.standard.data(forKey: bookmarkKey),
107 let url = try? URL(resolvingBookmarkData: data, bookmarkDataIsStale: &stale),
108 let notebook = open(url)
109 {
110 return notebook
98 /// Reopens the section and page shown last, or the scripted one.
99 private func restore(_ notebook: Notebook) {
100 let environment = ProcessInfo.processInfo.environment
101 if let index = environment["SNOWBOUND_SECTION"].flatMap(Int.init) {
102 let readable = notebook.tabs.filter(\.readable)
103 guard index < readable.count else { return }
104 open(readable[index], of: notebook) { [weak self] section in
105 guard let page = environment["SNOWBOUND_PAGE"].flatMap(Int.init), page < section.rows.count else {
106 return
107 }
108 self?.show(section: section, page: section.rows[page].id)
109 }
110 return
111111 }
112 // The bundled folder keeps its corpus name.
113 return Bundle.main.path(forResource: "notebook", ofType: nil).flatMap(read).map {
114 Notebook(name: "Sample", sections: $0.sections)
112 guard let place = Place.saved, place.notebook == notebook.id,
113 let tab = notebook.tabs.first(where: { $0.path == place.section })
114 else { return }
115 open(tab, of: notebook, page: place.page)
116 }
117
118 func open(
119 _ tab: Tab, of notebook: Notebook, page: String? = nil, find: String? = nil,
120 then: ((Section) -> Void)? = nil
121 ) {
122 guard Author.name != nil else {
123 return Author.ask(from: split) { [weak self] in self?.open(tab, of: notebook, page: page, find: find, then: then) }
115124 }
116 }
117
118 /// A notebook folder or section file chosen in Files, remembered for the next launch.
119 static func open(_ url: URL) -> Notebook? {
120 accessed?.stopAccessingSecurityScopedResource()
121 accessed = url.startAccessingSecurityScopedResource() ? url : nil
122 guard let notebook = read(url.path) else { return nil }
123 if let data = try? url.bookmarkData() { UserDefaults.standard.set(data, forKey: bookmarkKey) }
124 return notebook
125 }
126
127 private static func read(_ path: String) -> Notebook? {
128 guard let json = sb_notebook(path) else { return nil }
129 defer { sb_string_free(json) }
130 return try? JSONDecoder().decode(Notebook.self, from: Data(String(cString: json).utf8))
131 }
132}
133
134/// A parsed .one section; pages are read-only copies until saving lands.
135final class Section {
136 let handle: OpaquePointer?
137 let name: String
138 let color: UIColor
139 /// Each page's title and level in the page list, 1 at the top.
140 let headings: [(title: String, level: Int)]
141
142 init(_ tab: Tab) {
143 handle = sb_section_open(tab.path)
144 name = tab.name
145 color = tab.uiColor
146 headings = handle.map { handle in
147 (0..<sb_section_count(handle)).map {
148 (String(cString: sb_section_title(handle, $0)), Int(sb_section_level(handle, $0)))
125 pages.loading(tab)
126 split.show(.supplementary)
127 Section.open(tab, of: notebook) { [weak self] section, problem in
128 guard let self else { return }
129 guard let section else {
130 pages.failed(tab, problem)
131 return
149132 }
150 } ?? []
151 }
152
153 deinit { if let handle { sb_section_free(handle) } }
154}
155
156final class SectionsViewController: UITableViewController, UIDocumentPickerDelegate {
157 private(set) var notebook: Notebook?
158 /// Sections by group, in the notebook's order.
159 private var groups: [(name: String, sections: [Tab])] = []
160 var onOpen: ((Tab) -> Void)?
161
162 init() {
163 super.init(style: .insetGrouped)
164 navigationItem.largeTitleDisplayMode = .always
165 }
166
167 required init?(coder: NSCoder) { fatalError() }
168
169 override func viewDidLoad() {
170 super.viewDidLoad()
171 tableView.register(UITableViewCell.self, forCellReuseIdentifier: "section")
172 let open = UIBarButtonItem(
173 title: "Open Notebook", image: UIImage(systemName: "folder"), target: self, action: #selector(open))
174 navigationItem.rightBarButtonItem = open
175 }
176
177 override func viewWillAppear(_ animated: Bool) {
178 super.viewWillAppear(animated)
179 navigationController?.navigationBar.prefersLargeTitles = true
180 }
181
182 func load(_ notebook: Notebook?) {
183 self.notebook = notebook
184 title = notebook?.name
185 var groups: [(name: String, sections: [Tab])] = []
186 for tab in notebook?.sections ?? [] {
187 if groups.last?.name == tab.group {
188 groups[groups.count - 1].sections.append(tab)
189 } else {
190 groups.append((tab.group, [tab]))
133 pages.load(section)
134 notebooks.select(tab, of: notebook)
135 Place.saved = Place(notebook: notebook.id, section: tab.path, page: page)
136 if let page, section.row(of: page) != nil {
137 show(section: section, page: page, find: find)
191138 }
139 then?(section)
192140 }
193 self.groups = groups
194 tableView.reloadData()
195 var empty = UIContentUnavailableConfiguration.empty()
196 empty.text = notebook == nil ? "No Notebook Open" : "No Sections"
197 empty.secondaryText = notebook == nil ? "Open a notebook folder or section from Files." : nil
198 contentUnavailableConfiguration = groups.isEmpty ? empty : nil
199141 }
200142
201 @objc private func open() {
202 let picker = UIDocumentPickerViewController(
203 forOpeningContentTypes: [.folder, UTType(filenameExtension: "one") ?? .data])
204 picker.delegate = self
205 present(picker, animated: true)
143 func show(section: Section, page: String, find: String? = nil, titleFocus: Bool = false) {
144 showingPage = true
145 let controller = PageViewController(section: section, page: page, find: find, titleFocus: titleFocus)
146 controller.onOpen = { [weak self] id in self?.show(section: section, page: id) }
147 split.setViewController(UINavigationController(rootViewController: controller), for: .secondary)
148 split.show(.secondary)
149 pages.select(page)
150 Place.saved = Place(notebook: section.notebook.id, section: section.tab.path, page: page)
206151 }
207152
208 func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
209 guard let url = urls.first else { return }
210 guard let notebook = Library.open(url) else {
211 let alert = UIAlertController(
212 title: "Can’t Open Notebook", message: "Choose a OneNote notebook folder or a section file.",
213 preferredStyle: .alert)
214 alert.addAction(UIAlertAction(title: "OK", style: .default))
215 present(alert, animated: true)
216 return
217 }
218 load(notebook)
153 /// Adds a page to the open section and opens it, its title ready for typing.
154 func newPage(subpage: Bool = false) {
155 guard let section = pages.section else { return }
156 let parent = subpage ? (Place.saved?.page).flatMap { section.row(of: $0)?.row.id } : nil
157 guard let id = section.newPage(under: parent) else { return }
158 show(section: section, page: id, titleFocus: true)
219159 }
220160
221 override func numberOfSections(in tableView: UITableView) -> Int { groups.count }
222
223 override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
224 groups[section].name.isEmpty ? nil : groups[section].name.replacingOccurrences(of: "/", with: " › ")
161 func search() {
162 split.show(.supplementary)
163 pages.startSearching()
225164 }
226165
227 override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
228 groups[section].sections.count
166 /// A phone opens on the notebooks, as Notes opens on its folders.
167 func splitViewController(
168 _ svc: UISplitViewController, topColumnForCollapsingToProposedTopColumn proposedTopColumn: UISplitViewController.Column
169 ) -> UISplitViewController.Column {
170 showingPage ? proposedTopColumn : .primary
229171 }
230172
231 override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
232 let tab = groups[indexPath.section].sections[indexPath.row]
233 let cell = tableView.dequeueReusableCell(withIdentifier: "section", for: indexPath)
234 var content = cell.defaultContentConfiguration()
235 content.text = tab.name
236 content.image = UIImage(systemName: tab.readable ? "rectangle.portrait.fill" : "lock.fill")
237 content.imageProperties.tintColor = tab.uiColor
238 if !tab.readable {
239 content.secondaryText = "Can’t be opened here"
240 content.textProperties.color = .secondaryLabel
173 /// Composition ends and every edit is stored and published while the system allows.
174 func sceneDidEnterBackground(_ scene: UIScene) {
175 NotificationCenter.default.post(name: PageViewController.leaving, object: nil)
176 let sections = Section.all
177 guard !sections.isEmpty, background == .invalid else { return }
178 background = UIApplication.shared.beginBackgroundTask(withName: "Saving") { [weak self] in
179 self?.finishBackground()
180 }
181 DispatchQueue.global(qos: .userInitiated).async {
182 for section in sections { _ = section.flush(20) }
183 DispatchQueue.main.async { [weak self] in self?.finishBackground() }
241184 }
242 cell.contentConfiguration = content
243 cell.accessoryType = tab.readable ? .disclosureIndicator : .none
244 cell.selectionStyle = tab.readable ? .default : .none
245 return cell
246 }
247
248 override func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? {
249 groups[indexPath.section].sections[indexPath.row].readable ? indexPath : nil
250 }
251
252 override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
253 onOpen?(groups[indexPath.section].sections[indexPath.row])
254 }
255}
256
257final class PagesViewController: UITableViewController {
258 private(set) var section: Section?
259 var onOpen: ((Section, Int) -> Void)?
260
261 init() {
262 super.init(style: .plain)
263 }
264
265 required init?(coder: NSCoder) { fatalError() }
266
267 override func viewDidLoad() {
268 super.viewDidLoad()
269 tableView.register(UITableViewCell.self, forCellReuseIdentifier: "page")
270 }
271
272 func load(_ tab: Tab) {
273 let section = Section(tab)
274 self.section = section
275 title = section.name
276 tableView.reloadData()
277 var empty = UIContentUnavailableConfiguration.empty()
278 empty.text = section.handle == nil ? "Can’t Open Section" : "No Pages"
279 contentUnavailableConfiguration = section.headings.isEmpty ? empty : nil
280 }
281
282 override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
283 self.section?.headings.count ?? 0
284185 }
285186
286 override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
287 let heading = section!.headings[indexPath.row]
288 let cell = tableView.dequeueReusableCell(withIdentifier: "page", for: indexPath)
289 var content = cell.defaultContentConfiguration()
290 content.text = heading.title.isEmpty ? "Untitled Page" : heading.title
291 if heading.level > 1 { content.textProperties.color = .secondaryLabel }
292 // Subpages sit under their page, indented as in OneNote's page list.
293 content.directionalLayoutMargins.leading += CGFloat(heading.level - 1) * 20
294 cell.contentConfiguration = content
295 return cell
187 private func finishBackground() {
188 guard background != .invalid else { return }
189 UIApplication.shared.endBackgroundTask(background)
190 background = .invalid
296191 }
297192
298 override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
299 guard let section else { return }
300 onOpen?(section, indexPath.row)
193 /// Changes made elsewhere while away show at once.
194 func sceneWillEnterForeground(_ scene: UIScene) {
195 for section in Section.all { section.wake() }
301196 }
302197}
303198
304final class PageViewController: UIViewController {
305 private let section: Section
306 private let page: Int
307 private lazy var canvas = CanvasView(section: section, page: page)
308 private lazy var done = UIBarButtonItem(
309 systemItem: .done, primaryAction: UIAction { [weak self] _ in _ = self?.canvas.resignFirstResponder() })
310 private lazy var undo = UIBarButtonItem(
311 systemItem: .undo, primaryAction: UIAction { [weak self] _ in self?.canvas.undo(redo: false) })
312 private lazy var redo = UIBarButtonItem(
313 systemItem: .redo, primaryAction: UIAction { [weak self] _ in self?.canvas.undo(redo: true) })
314
315 init(section: Section, page: Int) {
316 self.section = section
317 self.page = page
318 super.init(nibName: nil, bundle: nil)
319 title = section.headings[page].title
320 navigationItem.largeTitleDisplayMode = .never
321 }
322
323 required init?(coder: NSCoder) { fatalError() }
199/// Takes the app's keyboard shortcuts wherever focus is.
200final class RootViewController: UISplitViewController {
201 weak var scene: SceneDelegate?
324202
325 override func loadView() {
326 view = canvas
327 canvas.onChange = { [weak self] in self?.editingChanged() }
203 override var keyCommands: [UIKeyCommand]? {
204 [
205 UIKeyCommand(title: "New Page", action: #selector(newPage), input: "n", modifierFlags: .command),
206 UIKeyCommand(
207 title: "New Subpage", action: #selector(newSubpage), input: "n", modifierFlags: [.command, .shift]),
208 UIKeyCommand(title: "Search", action: #selector(search), input: "f", modifierFlags: [.command, .alternate]),
209 ]
328210 }
329211
330 /// Notes' checkmark ends editing and puts the keyboard away; undo and redo sit beside it.
331 private func editingChanged() {
332 guard canvas.isFirstResponder else {
333 navigationItem.rightBarButtonItems = nil
334 return
335 }
336 undo.isEnabled = canvas.canUndo(redo: false)
337 redo.isEnabled = canvas.canUndo(redo: true)
338 if navigationItem.rightBarButtonItems?.first !== done {
339 navigationItem.rightBarButtonItems = [done, redo, undo]
340 }
341 }
212 @objc private func newPage() { scene?.newPage() }
213 @objc private func newSubpage() { scene?.newPage(subpage: true) }
214 @objc private func search() { scene?.search() }
342215}
apps/ios/Snowbound/CanvasView.swift+159-35
......@@ -38,6 +38,11 @@ private extension UITextRange {
3838 var upper: Int { (self as! Range).hi }
3939}
4040
41/// The page date's fields, from `sb_view_date_request`.
42enum DateField: Int8 {
43 case date, time
44}
45
4146/// What a touch lands on; see `sb_view_target`.
4247private enum Target: UInt8 {
4348 case page, activeText, text, grip
......@@ -64,7 +69,7 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
6469 private static let zoomKey = "zoom"
6570
6671 private let section: Section
67 private let page: Int
72 private let page: String
6873 private let metal = MetalView()
6974 /// Sized to the page content at 100%, so the scroll view's zoom scale is the page zoom.
7075 private let content = UIView()
......@@ -106,8 +111,21 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
106111 lazy var tokenizer: UITextInputTokenizer = LineTokenizer(canvas: self)
107112 /// Called when editing starts or stops and after each change, for the bar buttons.
108113 var onChange: (() -> Void)?
114 /// Called once the page is first on screen.
115 var onOpened: (() -> Void)?
116 /// Called when a tap on the page date asks to change it, with the date it shows.
117 var onDate: ((DateField, Date) -> Void)?
118 /// Formatting above the keyboard.
119 let formatBar = FormatBar()
120 /// Room above the page for a bar over it.
121 var topInset: CGFloat = 0 {
122 didSet {
123 contentInset.top = topInset
124 home()
125 }
126 }
109127
110 init(section: Section, page: Int) {
128 init(section: Section, page: String) {
111129 self.section = section
112130 self.page = page
113131 super.init(frame: .zero)
......@@ -143,6 +161,8 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
143161 self, selector: #selector(keyboardChanged), name: UIResponder.keyboardWillChangeFrameNotification,
144162 object: nil)
145163 registerForTraitChanges([UITraitUserInterfaceStyle.self]) { (view: CanvasView, _) in view.paper() }
164 formatBar.onApply = { [weak self] command in self?.apply(command) }
165 formatBar.onDismiss = { [weak self] in _ = self?.resignFirstResponder() }
146166 }
147167
148168 required init?(coder: NSCoder) { fatalError() }
......@@ -168,9 +188,9 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
168188 guard (metal.layer as! CAMetalLayer).drawableSize != pixels else { return }
169189 sb_view_resize(handle, Float(bounds.width), Float(bounds.height), Float(scale))
170190 transform()
171 } else if let sectionHandle = section.handle {
191 } else {
172192 handle = sb_view_new(
173 Unmanaged.passUnretained(metal.layer).toOpaque(), sectionHandle, page,
193 Unmanaged.passUnretained(metal.layer).toOpaque(), section.handle, page,
174194 Float(bounds.width), Float(bounds.height), Float(scale))
175195 guard let handle else { return }
176196 sb_view_focus(handle, false)
......@@ -181,6 +201,7 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
181201 zoomScale = resting
182202 left = skip * resting
183203 home()
204 onOpened?()
184205 #if DEBUG
185206 runScript()
186207 #endif
......@@ -203,7 +224,11 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
203224 if sb_view_frame_pending(handle) { dirty = true }
204225 guard dirty else { return }
205226 dirty = false
227 // A frame presented with a transaction shows only once one commits, which an edit
228 // that moves no UIKit view would not otherwise cause.
229 CATransaction.begin()
206230 sb_view_render(handle)
231 CATransaction.commit()
207232 }
208233
209234 /// 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
243268 dirty = true
244269 }
245270
246 /// The zoom the reader last pinched to, within reason; before that, OneNote's 100%, where its 11 pt
247 /// body text shows at 15 points, or up to 140% where the whole page fits across.
271 /// The zoom the reader last pinched to, within reason; before that, 100%, where OneNote's
272 /// 11 pt body text shows at 17 points.
248273 private func openingZoom() -> CGFloat {
249 if let zoom = UserDefaults.standard.object(forKey: Self.zoomKey) as? Double {
250 // A page opens readable however far the last one was pinched.
251 return min(2, max(0.75, zoom))
252 }
253 let width = bounds.inset(by: safeAreaInsets).width
254 return min(1.4, max(1, width / (content.bounds.width - margin)))
274 guard let zoom = UserDefaults.standard.object(forKey: Self.zoomKey) as? Double else { return 1 }
275 // A page opens readable however far the last one was pinched.
276 return min(2, max(0.75, zoom))
255277 }
256278
257 /// The empty page left of its first outline, which a phone skips on opening.
279 /// The empty page left of its first outline, which a phone skips on opening, keeping
280 /// room for the tags OneNote hangs left of the text.
258281 private var margin: CGFloat {
259282 guard let handle else { return 0 }
260283 var block: [Float] = [0, 0, 0, 0]
261284 guard sb_view_block(handle, 0, 0, &block) else { return 0 }
262 return max(0, CGFloat(block[0]) - origin.x - 12)
285 return max(0, CGFloat(block[0]) - origin.x - 34)
263286 }
264287
265288 /// 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
346369 override func gestureRecognizerShouldBegin(_ recognizer: UIGestureRecognizer) -> Bool {
347370 switch recognizer {
348371 case tap:
349 [.page, .grip].contains(target(recognizer.location(in: self))) && !onHandle(recognizer.location(in: self))
372 !readOnly && [.page, .grip].contains(target(recognizer.location(in: self)))
373 && !onHandle(recognizer.location(in: self))
350374 case doubleTap: target(recognizer.location(in: self)) == .page
351 case grip: target(touchStart) == .grip
375 case grip: !readOnly && target(touchStart) == .grip
352376 case handleDrag: onHandle(touchStart)
353377 default: super.gestureRecognizerShouldBegin(recognizer)
354378 }
......@@ -357,6 +381,7 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
357381 /// The system's text interaction works in the outline taking input; a touch on another
358382 /// outline's text focuses it first, as a tap there would.
359383 func interactionShouldBegin(_ interaction: UITextInteraction, at point: CGPoint) -> Bool {
384 if readOnly { return false }
360385 if onHandle(point) { return true }
361386 switch target(point) {
362387 case .activeText: break
......@@ -383,7 +408,19 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
383408 /// starts a new one.
384409 func tap(at point: CGPoint) {
385410 press(at: point)
386 if !isFirstResponder { _ = becomeFirstResponder() }
411 var seconds: Int64 = 0
412 if let handle, let field = DateField(rawValue: sb_view_date_request(handle, &seconds)) {
413 onDate?(field, Date(timeIntervalSince1970: TimeInterval(seconds)))
414 } else if !isFirstResponder {
415 _ = becomeFirstResponder()
416 }
417 }
418
419 /// Gives the page `date`, which the date and time under its title show.
420 func changeDate(_ date: Date) {
421 guard let handle else { return }
422 let (day, time) = titleDate(date)
423 edit(external: true) { sb_view_change_date(handle, Int64(date.timeIntervalSince1970.rounded()), day, time) }
387424 }
388425
389426 private func press(at point: CGPoint) {
......@@ -460,7 +497,6 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
460497 inputDelegate?.textWillChange(self)
461498 }
462499 let changed = change()
463 dropEdits()
464500 if external {
465501 inputDelegate?.textDidChange(self)
466502 inputDelegate?.selectionDidChange(self)
......@@ -471,32 +507,36 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
471507 revealCaret()
472508 }
473509 display.setNeedsSelectionUpdate()
474 onChange?()
510 changedSelection()
475511 }
476512
477 /// Takes the edit the page recorded, so its ops do not pile up; the host drops it
478 /// until it holds a notebook session that stores edits.
479 private func dropEdits() {
480 guard let handle, let edit = sb_view_edit(handle) else { return }
481 sb_string_free(edit)
513 /// Tells the bar buttons and the format bar what the selection now has.
514 private func changedSelection() {
515 if let handle { formatBar.show(sb_view_format(handle)) }
516 onChange?()
482517 }
483518
484519 private func revealCaret() {
485520 guard handle != nil, isFirstResponder, let range = selectedTextRange else { return }
486 let caret = caretRect(for: range.end)
487 scrollRectToVisible(caret.insetBy(dx: -8, dy: -16), animated: false)
521 reveal(caretRect(for: range.end))
522 }
523
524 private func reveal(_ rect: CGRect) {
525 scrollRectToVisible(rect.insetBy(dx: -8, dy: -16), animated: false)
488526 }
489527
490528 // MARK: Responder
491529
492 override var canBecomeFirstResponder: Bool { handle != nil }
530 override var canBecomeFirstResponder: Bool { handle != nil && !readOnly }
531
532 override var inputAccessoryView: UIView? { formatBar }
493533
494534 override func becomeFirstResponder() -> Bool {
495535 guard super.becomeFirstResponder() else { return false }
496536 if let handle { sb_view_focus(handle, true) }
497537 display.isActivated = true
498538 dirty = true
499 onChange?()
539 changedSelection()
500540 return true
501541 }
502542
......@@ -569,15 +609,100 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
569609 edit(external: true) { choose(Range(0, endOfDocument.offset)) }
570610 }
571611
572 override func toggleBoldface(_ sender: Any?) { toggle(0) }
573 override func toggleItalics(_ sender: Any?) { toggle(1) }
574 override func toggleUnderline(_ sender: Any?) { toggle(2) }
612 override func toggleBoldface(_ sender: Any?) { apply(0) }
613 override func toggleItalics(_ sender: Any?) { apply(1) }
614 override func toggleUnderline(_ sender: Any?) { apply(2) }
615
616 // MARK: Page
617
618 /// A conflict page, which shows what is stored and takes no edits.
619 var readOnly: Bool { handle.map(sb_view_read_only) ?? false }
620
621 /// The title as typed; nil on a page without an editable title.
622 var pageTitle: String? { handle.flatMap { take(sb_view_title($0)) } }
623
624 var pageText: String { handle.flatMap { take(sb_view_page_text($0)) } ?? "" }
625
626 /// Applies `sb_view_apply` formatting to the selection.
627 func apply(_ command: UInt8) {
628 guard let handle else { return }
629 edit(external: true) { sb_view_apply(handle, command) }
630 }
631
632 /// Shows the page as stored; with `discard`, after an edit was refused.
633 func reload(discard: Bool) {
634 guard let handle else { return }
635 edit(external: true) { sb_view_reload(handle, discard) }
636 }
575637
576 private func toggle(_ style: UInt8) {
638 /// Selects the first place `query` occurs and brings it into view.
639 func find(_ query: String) -> Bool {
640 guard let handle else { return false }
641 var found = false
642 edit(external: true) {
643 found = sb_view_find(handle, query)
644 return found
645 }
646 if found { reveal(selectedTextRange.map { firstRect(for: $0) } ?? .zero) }
647 return found
648 }
649
650 /// Puts the caret at the end of the page title.
651 func focusTitle() -> Bool {
652 guard let handle else { return false }
653 var focused = false
654 edit(external: true) {
655 focused = sb_view_focus_title(handle)
656 return focused
657 }
658 return focused
659 }
660
661 /// Puts a JPEG or PNG after the caret's paragraph at `size` points.
662 func insertPicture(_ data: Data, size: CGSize) {
577663 guard let handle else { return }
578 edit(external: true) { sb_view_toggle(handle, style) }
664 edit(external: true) {
665 data.withUnsafeBytes { bytes in
666 sb_view_insert_picture(
667 handle, bytes.bindMemory(to: UInt8.self).baseAddress, bytes.count, Float(size.width),
668 Float(size.height))
669 }
670 }
671 }
672
673 /// Commits marked text, so it is stored before the app leaves the screen.
674 func commitComposition() {
675 guard markedTextRange != nil else { return }
676 inputDelegate?.textWillChange(self)
677 unmarkText()
678 inputDelegate?.textDidChange(self)
579679 }
580680
681 override var keyCommands: [UIKeyCommand]? {
682 guard isFirstResponder else { return nil }
683 // Ctrl+1 to Ctrl+9 in OneNote, where the Mac's Command stands for Control.
684 let tags = FormatBar.tags.enumerated().map { index, name in
685 let command = UIKeyCommand(
686 title: name, action: #selector(tagged), input: "\(index + 1)", modifierFlags: .command,
687 propertyList: index)
688 command.wantsPriorityOverSystemBehavior = true
689 return command
690 }
691 let outdent = UIKeyCommand(
692 title: "Decrease Indent", action: #selector(outdented), input: "\t", modifierFlags: .shift)
693 outdent.wantsPriorityOverSystemBehavior = true
694 let done = UIKeyCommand(title: "Done", action: #selector(finished), input: UIKeyCommand.inputEscape)
695 return tags + [outdent, done]
696 }
697
698 @objc private func tagged(_ command: UIKeyCommand) {
699 guard let index = command.propertyList as? Int else { return }
700 apply(16 + UInt8(index))
701 }
702
703 @objc private func outdented() { apply(7) }
704 @objc private func finished() { _ = resignFirstResponder() }
705
581706 // MARK: Coordinates
582707
583708 /// 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
639764 _ = choose(newValue)
640765 display.setNeedsSelectionUpdate()
641766 dirty = true
642 onChange?()
767 changedSelection()
643768 }
644769 }
645770
......@@ -663,7 +788,6 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
663788 func unmarkText() {
664789 guard let handle else { return }
665790 sb_unmark(handle)
666 dropEdits()
667791 dirty = true
668792 }
669793
apps/ios/Snowbound/FormatBar.swift created+113
......@@ -0,0 +1,113 @@
1import UIKit
2
3/// Formatting above the keyboard, as Notes offers it: tags and lists first, then text
4/// styles, indentation, tags and pictures, the keyboard's dismissal at the end.
5final class FormatBar: UIInputView {
6 /// `sb_view_apply` commands and `sb_view_format` bits.
7 private static let buttons: [(symbol: String, label: String, command: UInt8)] = [
8 ("checklist", "To Do", 16),
9 ("bold", "Bold", 0),
10 ("italic", "Italic", 1),
11 ("underline", "Underline", 2),
12 ("strikethrough", "Strikethrough", 3),
13 ("list.bullet", "Bullets", 4),
14 ("list.number", "Numbering", 5),
15 ("decrease.indent", "Decrease Indent", 7),
16 ("increase.indent", "Increase Indent", 6),
17 ]
18 /// OneNote 2010's default tags in their order, Ctrl+1 to Ctrl+9.
19 static let tags = [
20 "To Do", "Important", "Question", "Remember for Later", "Definition", "Highlight", "Contact", "Address",
21 "Phone Number",
22 ]
23
24 private var toggles: [(UIButton, UInt8)] = []
25 var onApply: ((UInt8) -> Void)?
26 /// Asks for a picture from the camera (true) or the photo library.
27 var onPicture: ((Bool) -> Void)?
28 var onDismiss: (() -> Void)?
29
30 init() {
31 super.init(frame: CGRect(x: 0, y: 0, width: 0, height: 48), inputViewStyle: .keyboard)
32 allowsSelfSizing = true
33 // Over the page when a hardware keyboard hides the software one.
34 let backdrop = UIVisualEffectView(effect: UIBlurEffect(style: .systemChromeMaterial))
35 backdrop.frame = bounds
36 backdrop.autoresizingMask = [.flexibleWidth, .flexibleHeight]
37 addSubview(backdrop)
38 let row = UIStackView()
39 row.spacing = 2
40 for (symbol, label, command) in Self.buttons {
41 let button = Self.button(symbol, label)
42 button.addAction(UIAction { [weak self] _ in self?.onApply?(command) }, for: .primaryActionTriggered)
43 toggles.append((button, command))
44 row.addArrangedSubview(button)
45 }
46 let tag = Self.button("tag", "Tags")
47 tag.menu = UIMenu(children: Self.tags.enumerated().map { index, name in
48 UIAction(title: name) { [weak self] _ in self?.onApply?(16 + UInt8(index)) }
49 })
50 tag.showsMenuAsPrimaryAction = true
51 row.addArrangedSubview(tag)
52 let picture = Self.button("photo", "Insert Picture")
53 picture.menu = UIMenu(children: [
54 UIAction(title: "Photo Library", image: UIImage(systemName: "photo.on.rectangle")) { [weak self] _ in
55 self?.onPicture?(false)
56 },
57 UIAction(
58 title: "Take Photo", image: UIImage(systemName: "camera"),
59 attributes: UIImagePickerController.isSourceTypeAvailable(.camera) ? [] : .disabled
60 ) { [weak self] _ in self?.onPicture?(true) },
61 ])
62 picture.showsMenuAsPrimaryAction = true
63 row.addArrangedSubview(picture)
64 let scroll = UIScrollView()
65 scroll.showsHorizontalScrollIndicator = false
66 scroll.addSubview(row)
67 let dismiss = Self.button("keyboard.chevron.compact.down", "Hide Keyboard")
68 dismiss.addAction(UIAction { [weak self] _ in self?.onDismiss?() }, for: .primaryActionTriggered)
69 for view in [scroll, dismiss, row] as [UIView] { view.translatesAutoresizingMaskIntoConstraints = false }
70 addSubview(scroll)
71 addSubview(dismiss)
72 NSLayoutConstraint.activate([
73 heightAnchor.constraint(equalToConstant: 48),
74 scroll.leadingAnchor.constraint(equalTo: safeAreaLayoutGuide.leadingAnchor, constant: 4),
75 scroll.topAnchor.constraint(equalTo: topAnchor),
76 scroll.bottomAnchor.constraint(equalTo: bottomAnchor),
77 scroll.trailingAnchor.constraint(equalTo: dismiss.leadingAnchor, constant: -4),
78 dismiss.trailingAnchor.constraint(equalTo: safeAreaLayoutGuide.trailingAnchor, constant: -4),
79 dismiss.centerYAnchor.constraint(equalTo: centerYAnchor),
80 row.leadingAnchor.constraint(equalTo: scroll.contentLayoutGuide.leadingAnchor),
81 row.trailingAnchor.constraint(equalTo: scroll.contentLayoutGuide.trailingAnchor),
82 row.topAnchor.constraint(equalTo: scroll.contentLayoutGuide.topAnchor),
83 row.bottomAnchor.constraint(equalTo: scroll.contentLayoutGuide.bottomAnchor),
84 row.heightAnchor.constraint(equalTo: scroll.frameLayoutGuide.heightAnchor),
85 ])
86 }
87
88 required init?(coder: NSCoder) { fatalError() }
89
90 private static func button(_ symbol: String, _ label: String) -> UIButton {
91 var configuration = UIButton.Configuration.plain()
92 configuration.image = UIImage(systemName: symbol)
93 configuration.preferredSymbolConfigurationForImage = UIImage.SymbolConfiguration(pointSize: 17)
94 configuration.baseForegroundColor = .label
95 configuration.cornerStyle = .medium
96 let button = UIButton(configuration: configuration)
97 button.accessibilityLabel = label
98 button.widthAnchor.constraint(equalToConstant: 42).isActive = true
99 button.configurationUpdateHandler = { button in
100 button.configuration?.background.backgroundColor =
101 button.isSelected ? UIColor.tintColor.withAlphaComponent(0.18) : .clear
102 button.configuration?.baseForegroundColor = button.isSelected ? .tintColor : .label
103 }
104 return button
105 }
106
107 /// Marks what the selection has, from `sb_view_format`.
108 func show(_ bits: UInt32) {
109 for (button, command) in toggles {
110 button.isSelected = bits & (1 << UInt32(command)) != 0
111 }
112 }
113}
apps/ios/Snowbound/Lists.swift created+531
......@@ -0,0 +1,531 @@
1import UIKit
2import UniformTypeIdentifiers
3
4/// Every open notebook with its sections, as Notes lists each account's folders.
5final class NotebooksViewController: UITableViewController, UIDocumentPickerDelegate {
6 private enum Item {
7 case group(String, depth: Int)
8 case section(Tab, depth: Int)
9 case opening
10 case problem(String)
11 }
12
13 private var items: [[Item]] = []
14 private var selected: (notebook: UUID, path: String)?
15 var onOpen: ((Tab, Notebook) -> Void)?
16
17 init() {
18 super.init(style: .insetGrouped)
19 title = "Notebooks"
20 navigationItem.largeTitleDisplayMode = .always
21 }
22
23 required init?(coder: NSCoder) { fatalError() }
24
25 override func viewDidLoad() {
26 super.viewDidLoad()
27 tableView.register(UITableViewCell.self, forCellReuseIdentifier: "row")
28 let add = UIMenu(children: [
29 UIAction(title: "Open from Files…", image: UIImage(systemName: "folder")) { [weak self] _ in
30 self?.openFromFiles()
31 },
32 UIAction(title: "Connect to Server…", image: UIImage(systemName: "server.rack")) { [weak self] _ in
33 self?.connect()
34 },
35 UIAction(title: "Change Your Name…", image: UIImage(systemName: "person.crop.circle")) { [weak self] _ in
36 guard let self else { return }
37 Author.ask(from: self) {}
38 },
39 ])
40 navigationItem.rightBarButtonItem = UIBarButtonItem(
41 title: "Add Notebook", image: UIImage(systemName: "plus"), menu: add)
42 }
43
44 override func viewWillAppear(_ animated: Bool) {
45 super.viewWillAppear(animated)
46 navigationController?.navigationBar.prefersLargeTitles = true
47 }
48
49 func reload() {
50 items = Notebooks.all.map { notebook in
51 if notebook.handle == nil {
52 return [notebook.problem.map(Item.problem) ?? .opening]
53 }
54 var rows: [Item] = []
55 var group = ""
56 for tab in notebook.tabs {
57 if tab.group != group {
58 // Each group level not yet shown gets its own row.
59 let parts = tab.group.split(separator: "/").map(String.init)
60 let shown = group.split(separator: "/").map(String.init)
61 let common = zip(parts, shown).prefix { $0 == $1 }.count
62 for depth in common..<parts.count { rows.append(.group(parts[depth], depth: depth)) }
63 group = tab.group
64 }
65 rows.append(.section(tab, depth: tab.group.split(separator: "/").count))
66 }
67 return rows
68 }
69 tableView.reloadData()
70 showSelection()
71 var empty = UIContentUnavailableConfiguration.empty()
72 empty.text = "No Notebooks"
73 empty.secondaryText = "Open a OneNote notebook from Files or a server."
74 contentUnavailableConfiguration = items.isEmpty ? empty : nil
75 }
76
77 func select(_ tab: Tab, of notebook: Notebook) {
78 selected = (notebook.id, tab.path)
79 showSelection()
80 }
81
82 /// Marks the open section, as the sidebar of a wide window keeps it.
83 private func showSelection() {
84 guard let selected, let section = Notebooks.all.firstIndex(where: { $0.id == selected.notebook }),
85 let row = items[section].firstIndex(where: {
86 if case .section(let tab, _) = $0 { tab.path == selected.path } else { false }
87 })
88 else { return }
89 tableView.selectRow(at: IndexPath(row: row, section: section), animated: false, scrollPosition: .none)
90 }
91
92 private func add(_ notebook: Notebook) {
93 Notebooks.add(notebook)
94 reload()
95 notebook.open { [weak self] in
96 self?.reload()
97 // A notebook just added opens at its first section.
98 if let tab = notebook.tabs.first(where: \.readable) { self?.onOpen?(tab, notebook) }
99 }
100 }
101
102 private func openFromFiles() {
103 let picker = UIDocumentPickerViewController(
104 forOpeningContentTypes: [.folder, UTType(filenameExtension: "one") ?? .data])
105 picker.delegate = self
106 present(picker, animated: true)
107 }
108
109 func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
110 guard let url = urls.first else { return }
111 let accessing = url.startAccessingSecurityScopedResource()
112 defer { if accessing { url.stopAccessingSecurityScopedResource() } }
113 guard let bookmark = try? url.bookmarkData() else { return }
114 add(Notebook(name: url.deletingPathExtension().lastPathComponent, source: .files(bookmark: bookmark)))
115 }
116
117 private func connect() {
118 let server = ServerViewController()
119 server.onOpen = { [weak self] notebook in
120 self?.dismiss(animated: true)
121 self?.add(notebook)
122 }
123 present(UINavigationController(rootViewController: server), animated: true)
124 }
125
126 private func newSection(in notebook: Notebook) {
127 let alert = UIAlertController(title: "New Section", message: nil, preferredStyle: .alert)
128 alert.addTextField { field in
129 field.placeholder = "Name"
130 field.autocapitalizationType = .words
131 }
132 alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
133 alert.addAction(UIAlertAction(title: "Create", style: .default) { [weak self, weak alert] _ in
134 let name = alert?.textFields?.first?.text?.trimmingCharacters(in: .whitespaces) ?? ""
135 guard !name.isEmpty, let handle = notebook.handle else { return }
136 let (date, time) = titleDate()
137 let path = take(sb_library_new_section(handle, "", name, Author.name ?? "", date, time))
138 notebook.reload {
139 self?.reload()
140 if let tab = notebook.tabs.first(where: { $0.path == path }) { self?.onOpen?(tab, notebook) }
141 }
142 })
143 present(alert, animated: true)
144 }
145
146 override func numberOfSections(in tableView: UITableView) -> Int { items.count }
147
148 override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
149 items[section].count
150 }
151
152 override func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
153 let notebook = Notebooks.all[section]
154 var title = UIButton.Configuration.plain()
155 title.title = notebook.name
156 title.image = UIImage(systemName: "ellipsis.circle")
157 title.imagePlacement = .trailing
158 title.imagePadding = 6
159 title.contentInsets = NSDirectionalEdgeInsets(top: 8, leading: 4, bottom: 4, trailing: 4)
160 title.baseForegroundColor = .label
161 title.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer {
162 var attributes = $0
163 attributes.font = UIFont.preferredFont(forTextStyle: .title3).withTraits(.traitBold)
164 return attributes
165 }
166 let button = UIButton(configuration: title)
167 button.contentHorizontalAlignment = .leading
168 button.showsMenuAsPrimaryAction = true
169 button.menu = UIMenu(children: [
170 UIAction(title: "New Section…", image: UIImage(systemName: "plus.rectangle.portrait")) {
171 [weak self] _ in self?.newSection(in: notebook)
172 },
173 UIAction(title: "Close Notebook", image: UIImage(systemName: "xmark.circle"), attributes: .destructive) {
174 [weak self] _ in
175 Notebooks.remove(notebook)
176 self?.reload()
177 },
178 ])
179 return button
180 }
181
182 override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
183 let cell = tableView.dequeueReusableCell(withIdentifier: "row", for: indexPath)
184 var content = cell.defaultContentConfiguration()
185 cell.accessoryType = .none
186 cell.selectionStyle = .none
187 cell.accessoryView = nil
188 switch items[indexPath.section][indexPath.row] {
189 case .group(let name, let depth):
190 content.text = name
191 content.image = UIImage(systemName: "folder")
192 content.textProperties.color = .secondaryLabel
193 content.directionalLayoutMargins.leading += CGFloat(depth) * 20
194 case .section(let tab, let depth):
195 content.text = tab.name
196 content.image = UIImage(systemName: tab.readable ? "rectangle.portrait.fill" : "lock.fill")
197 content.imageProperties.tintColor = tab.uiColor
198 content.directionalLayoutMargins.leading += CGFloat(depth) * 20
199 if tab.readable {
200 cell.accessoryType = .disclosureIndicator
201 cell.selectionStyle = .default
202 } else {
203 content.secondaryText = "Can’t be opened here"
204 content.textProperties.color = .secondaryLabel
205 }
206
207 case .opening:
208 content.text = "Opening…"
209 content.textProperties.color = .secondaryLabel
210 let spinner = UIActivityIndicatorView(style: .medium)
211 spinner.startAnimating()
212 cell.accessoryView = spinner
213 case .problem(let problem):
214 content.text = "Can’t Open Notebook"
215 content.secondaryText = problem
216 content.image = UIImage(systemName: "exclamationmark.triangle")
217 content.imageProperties.tintColor = .systemOrange
218 cell.accessoryView = UIImageView(image: UIImage(systemName: "arrow.clockwise"))
219 cell.selectionStyle = .default
220 }
221 cell.contentConfiguration = content
222 return cell
223 }
224
225 override func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? {
226 switch items[indexPath.section][indexPath.row] {
227 case .section(let tab, _): tab.readable ? indexPath : nil
228 case .problem: indexPath
229 default: nil
230 }
231 }
232
233 override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
234 let notebook = Notebooks.all[indexPath.section]
235 switch items[indexPath.section][indexPath.row] {
236 case .section(let tab, _): onOpen?(tab, notebook)
237 case .problem:
238 items[indexPath.section] = [.opening]
239 tableView.reloadSections([indexPath.section], with: .none)
240 notebook.open { [weak self] in self?.reload() }
241 default: break
242 }
243 }
244}
245
246extension UIFont {
247 func withTraits(_ traits: UIFontDescriptor.SymbolicTraits) -> UIFont {
248 fontDescriptor.withSymbolicTraits(traits).map { UIFont(descriptor: $0, size: 0) } ?? self
249 }
250}
251
252/// A section's pages, subpages indented, each page's conflict pages beneath it.
253final class PagesViewController: UITableViewController, UISearchResultsUpdating {
254 private struct Item {
255 let row: Row
256 let version: Row.Version?
257 var id: String { version?.id ?? row.id }
258 }
259
260 private(set) var section: Section?
261 private var items: [Item] = []
262 private var selected: String?
263 private let results = SearchViewController()
264 private lazy var search = UISearchController(searchResultsController: results)
265 private var searching: DispatchWorkItem?
266 /// Opens a page of the section.
267 var onOpen: ((Section, String, String?) -> Void)?
268 /// Opens a page of another section of the notebook, found by search.
269 var onOpenSection: ((Notebook, String, String, String?) -> Void)?
270
271 init() {
272 super.init(style: .plain)
273 }
274
275 required init?(coder: NSCoder) { fatalError() }
276
277 override func viewDidLoad() {
278 super.viewDidLoad()
279 tableView.register(UITableViewCell.self, forCellReuseIdentifier: "page")
280 search.searchResultsUpdater = self
281 search.searchBar.placeholder = "Search Notebook"
282 navigationItem.searchController = search
283 navigationItem.hidesSearchBarWhenScrolling = false
284 results.onOpen = { [weak self] found, query in
285 guard let self, let section, let notebook = Optional(section.notebook) else { return }
286 if found.section == section.tab.path {
287 onOpen?(section, found.page, query)
288 } else {
289 onOpenSection?(notebook, found.section, found.page, query)
290 }
291 }
292 let compose = UIBarButtonItem(
293 title: "New Page", image: UIImage(systemName: "square.and.pencil"),
294 primaryAction: UIAction { [weak self] _ in self?.newPage(under: nil) })
295 toolbarItems = [.flexibleSpace(), compose]
296 NotificationCenter.default.addObserver(
297 self, selector: #selector(changed), name: Section.changed, object: nil)
298 }
299
300 override func viewWillAppear(_ animated: Bool) {
301 super.viewWillAppear(animated)
302 navigationController?.setToolbarHidden(section == nil, animated: false)
303 }
304
305 func loading(_ tab: Tab) {
306 section = nil
307 items = []
308 title = tab.name
309 tableView.reloadData()
310 var loading = UIContentUnavailableConfiguration.loading()
311 loading.text = "Opening…"
312 contentUnavailableConfiguration = loading
313 navigationController?.setToolbarHidden(true, animated: false)
314 }
315
316 func failed(_ tab: Tab, _ problem: String?) {
317 var empty = UIContentUnavailableConfiguration.empty()
318 empty.text = "Can’t Open Section"
319 empty.secondaryText = problem
320 contentUnavailableConfiguration = empty
321 }
322
323 func load(_ section: Section) {
324 self.section = section
325 title = section.tab.name
326 navigationController?.setToolbarHidden(false, animated: false)
327 reload()
328 }
329
330 func select(_ id: String) {
331 selected = id
332 if let index = items.firstIndex(where: { $0.id == id }) {
333 tableView.selectRow(at: IndexPath(row: index, section: 0), animated: false, scrollPosition: .none)
334 }
335 }
336
337 func startSearching() {
338 search.isActive = true
339 DispatchQueue.main.async { self.search.searchBar.becomeFirstResponder() }
340 }
341
342 private func reload() {
343 items = (section?.rows ?? []).flatMap { row in
344 [Item(row: row, version: nil)] + row.versions.map { Item(row: row, version: $0) }
345 }
346 tableView.reloadData()
347 if let selected { select(selected) }
348 var empty = UIContentUnavailableConfiguration.empty()
349 empty.text = "No Pages"
350 contentUnavailableConfiguration = section != nil && items.isEmpty ? empty : nil
351 }
352
353 @objc private func changed(_ notification: Notification) {
354 guard notification.object as AnyObject? === section else { return }
355 reload()
356 }
357
358 private func newPage(under parent: String?) {
359 guard let section, let id = section.newPage(under: parent) else { return }
360 (view.window?.windowScene?.delegate as? SceneDelegate)?.show(section: section, page: id, titleFocus: true)
361 }
362
363 private func delete(_ item: Item) {
364 section?.delete(item.id) { _ in }
365 }
366
367 override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { items.count }
368
369 override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
370 let item = items[indexPath.row]
371 let cell = tableView.dequeueReusableCell(withIdentifier: "page", for: indexPath)
372 var content = cell.defaultContentConfiguration()
373 let level = CGFloat(item.row.level - 1)
374 if let version = item.version {
375 // OneNote lists a conflict page by whose version it is and when the merge made it.
376 content.text = version.user.isEmpty ? "Conflicting Version" : version.user
377 content.secondaryText = version.created.map {
378 DateFormatter.localizedString(
379 from: Date(timeIntervalSince1970: $0), dateStyle: .short, timeStyle: .short)
380 }
381 content.image = UIImage(systemName: "doc.on.doc")
382 content.imageProperties.tintColor = .systemOrange
383 content.textProperties.color = .secondaryLabel
384 content.directionalLayoutMargins.leading += (level + 1) * 20
385 } else {
386 content.text = item.row.title.isEmpty ? "Untitled Page" : item.row.title
387 if item.row.title.isEmpty || item.row.level > 1 { content.textProperties.color = .secondaryLabel }
388 content.directionalLayoutMargins.leading += level * 20
389 if !item.row.versions.isEmpty {
390 content.image = UIImage(systemName: "exclamationmark.triangle.fill")
391 content.imageProperties.tintColor = .systemOrange
392 }
393 }
394 cell.contentConfiguration = content
395 return cell
396 }
397
398 override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
399 guard let section else { return }
400 selected = items[indexPath.row].id
401 onOpen?(section, items[indexPath.row].id, nil)
402 }
403
404 override func tableView(
405 _ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath
406 ) -> UISwipeActionsConfiguration? {
407 let item = items[indexPath.row]
408 let delete = UIContextualAction(style: .destructive, title: "Delete") { [weak self] _, _, done in
409 self?.delete(item)
410 done(true)
411 }
412 delete.image = UIImage(systemName: "trash")
413 return UISwipeActionsConfiguration(actions: [delete])
414 }
415
416 override func tableView(
417 _ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint
418 ) -> UIContextMenuConfiguration? {
419 let item = items[indexPath.row]
420 return UIContextMenuConfiguration(actionProvider: { [weak self] _ in
421 var actions: [UIMenuElement] = []
422 if item.version == nil {
423 actions.append(UIAction(title: "New Subpage", image: UIImage(systemName: "text.badge.plus")) { _ in
424 self?.newPage(under: item.row.id)
425 })
426 }
427 actions.append(
428 UIAction(
429 title: item.version == nil ? "Delete Page" : "Delete Conflict Page",
430 image: UIImage(systemName: "trash"), attributes: .destructive
431 ) { _ in self?.delete(item) })
432 return UIMenu(children: actions)
433 })
434 }
435
436 func updateSearchResults(for controller: UISearchController) {
437 searching?.cancel()
438 let query = controller.searchBar.text?.trimmingCharacters(in: .whitespaces) ?? ""
439 guard let section, let library = section.notebook.handle, !query.isEmpty else {
440 results.show([], query: query)
441 return
442 }
443 // Each keystroke waits for the next before the notebook is read.
444 let work = DispatchWorkItem { [weak self] in
445 let (library, open, path) = (Int(bitPattern: library), Int(bitPattern: section.handle), section.tab.path)
446 self?.results.searching(query)
447 background({
448 decode(
449 [Found].self,
450 sb_library_search(
451 OpaquePointer(bitPattern: library), OpaquePointer(bitPattern: open), path, query)) ?? []
452 }) { [weak self] found in
453 guard controller.searchBar.text?.trimmingCharacters(in: .whitespaces) == query else { return }
454 self?.results.show(
455 found, query: query,
456 sections: Dictionary(
457 section.notebook.tabs.map { ($0.path, $0.name) }, uniquingKeysWith: { first, _ in first }))
458 }
459 }
460 searching = work
461 DispatchQueue.main.asyncAfter(deadline: .now() + 0.25, execute: work)
462 }
463}
464
465/// A page `sb_library_search` found.
466struct Found: Decodable {
467 let section: String
468 let page: String
469 let title: String
470 let snippet: String
471}
472
473final class SearchViewController: UITableViewController {
474 private var found: [Found] = []
475 private var sections: [String: String] = [:]
476 private var query = ""
477 var onOpen: ((Found, String) -> Void)?
478
479 override func viewDidLoad() {
480 super.viewDidLoad()
481 tableView.register(UITableViewCell.self, forCellReuseIdentifier: "found")
482 }
483
484 func searching(_ query: String) {
485 guard found.isEmpty else { return }
486 var loading = UIContentUnavailableConfiguration.loading()
487 loading.text = "Searching…"
488 contentUnavailableConfiguration = loading
489 }
490
491 func show(_ found: [Found], query: String, sections: [String: String] = [:]) {
492 self.found = found
493 self.query = query
494 self.sections = sections
495 tableView.reloadData()
496 contentUnavailableConfiguration = found.isEmpty && !query.isEmpty ? UIContentUnavailableConfiguration.search() : nil
497 }
498
499 override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { found.count }
500
501 override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
502 let found = found[indexPath.row]
503 let cell = tableView.dequeueReusableCell(withIdentifier: "found", for: indexPath)
504 var content = UIListContentConfiguration.subtitleCell()
505 content.text = found.title.isEmpty ? "Untitled Page" : found.title
506 let section = sections[found.section] ?? ""
507 content.secondaryAttributedText = highlighted(
508 [section, found.snippet].filter { !$0.isEmpty }.joined(separator: " · "))
509 content.secondaryTextProperties.numberOfLines = 2
510 cell.contentConfiguration = content
511 return cell
512 }
513
514 /// `text` with the query in bold where it occurs.
515 private func highlighted(_ text: String) -> NSAttributedString {
516 let font = UIFont.preferredFont(forTextStyle: .subheadline)
517 let attributed = NSMutableAttributedString(
518 string: text, attributes: [.font: font, .foregroundColor: UIColor.secondaryLabel])
519 var range = text.startIndex..<text.endIndex
520 while let match = text.range(of: query, options: .caseInsensitive, range: range) {
521 attributed.addAttributes(
522 [.font: font.withTraits(.traitBold), .foregroundColor: UIColor.label], range: NSRange(match, in: text))
523 range = match.upperBound..<text.endIndex
524 }
525 return attributed
526 }
527
528 override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
529 onOpen?(found[indexPath.row], query)
530 }
531}
apps/ios/Snowbound/Notebooks.swift created+424
......@@ -0,0 +1,424 @@
1import Foundation
2import Security
3import UIKit
4
5/// A string the Rust library allocated, taken and freed.
6func take(_ text: UnsafeMutablePointer<CChar>?) -> String? {
7 guard let text else { return nil }
8 defer { sb_string_free(text) }
9 return String(cString: text)
10}
11
12func decode<T: Decodable>(_ type: T.Type, _ json: UnsafeMutablePointer<CChar>?) -> T? {
13 take(json).flatMap { try? JSONDecoder().decode(type, from: Data($0.utf8)) }
14}
15
16/// A page's title date and time, as OneNote writes them: the long date and short time.
17func titleDate(_ date: Date = Date()) -> (date: String, time: String) {
18 let formatter = DateFormatter()
19 formatter.dateStyle = .full
20 formatter.timeStyle = .none
21 let day = formatter.string(from: date)
22 formatter.dateStyle = .none
23 formatter.timeStyle = .short
24 return (day, formatter.string(from: date))
25}
26
27/// Who edits name, as OneNote names the Office user; asked once.
28enum Author {
29 private static let key = "author"
30
31 static var name: String? {
32 get { UserDefaults.standard.string(forKey: key) }
33 set { UserDefaults.standard.set(newValue, forKey: key) }
34 }
35
36 /// Asks for the name OneNote shows beside changes, then runs `then`.
37 static func ask(from controller: UIViewController, then: @escaping () -> Void) {
38 let alert = UIAlertController(
39 title: "What’s Your Name?",
40 message: "OneNote shows it beside the pages and changes you make.", preferredStyle: .alert)
41 let save = UIAlertAction(title: "Continue", style: .default) { [weak alert] _ in
42 name = alert?.textFields?.first?.text?.trimmingCharacters(in: .whitespaces)
43 then()
44 }
45 alert.addTextField { field in
46 field.text = name
47 field.placeholder = "Name"
48 field.textContentType = .name
49 field.autocapitalizationType = .words
50 save.isEnabled = !(field.text ?? "").isEmpty
51 NotificationCenter.default.addObserver(
52 forName: UITextField.textDidChangeNotification, object: field, queue: .main
53 ) { _ in save.isEnabled = !(field.text ?? "").trimmingCharacters(in: .whitespaces).isEmpty }
54 }
55 alert.addAction(save)
56 alert.preferredAction = save
57 controller.present(alert, animated: true)
58 }
59}
60
61/// An SMB share and the folder of a notebook on it.
62struct Server: Codable, Equatable {
63 /// `host` or `host:port`.
64 var host: String
65 var share: String
66 /// Empty for a guest.
67 var user: String
68 /// The notebook folder, `/`-separated from the share's top.
69 var root: String
70
71 var hostName: String { host.split(separator: ":").first.map(String.init) ?? host }
72 var port: Int { host.split(separator: ":").dropFirst().first.flatMap { Int($0) } ?? 445 }
73}
74
75/// The password of an SMB account, kept in the Keychain as Files keeps a server's.
76enum Keychain {
77 private static func query(_ server: Server) -> [String: Any] {
78 [
79 kSecClass as String: kSecClassInternetPassword,
80 kSecAttrServer as String: server.hostName,
81 kSecAttrPort as String: server.port,
82 kSecAttrProtocol as String: kSecAttrProtocolSMB,
83 kSecAttrAccount as String: server.user,
84 ]
85 }
86
87 static func password(_ server: Server) -> String? {
88 var query = query(server)
89 query[kSecReturnData as String] = true
90 var result: AnyObject?
91 guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess, let data = result as? Data
92 else { return nil }
93 return String(data: data, encoding: .utf8)
94 }
95
96 static func save(_ password: String, for server: Server) {
97 SecItemDelete(query(server) as CFDictionary)
98 var item = query(server)
99 item[kSecValueData as String] = Data(password.utf8)
100 item[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
101 SecItemAdd(item as CFDictionary, nil)
102 }
103}
104
105/// Where a notebook lives, as the notebook list keeps it.
106enum Source: Codable, Equatable {
107 /// A folder or section file chosen in Files, as a security-scoped bookmark.
108 case files(bookmark: Data)
109 /// A folder in the app's Documents, which Files shows as Snowbound's.
110 case documents(path: String)
111 /// A notebook folder on an SMB share, opened through Snowbound's own client.
112 case server(Server)
113 /// A folder or section at an absolute path, for scripted runs.
114 case path(String)
115}
116
117/// A section tab from `sb_library_sections`.
118struct Tab: Decodable, Equatable {
119 let name: String
120 /// The catalog path `sb_section_open` takes.
121 let path: String
122 /// The section group holding it, `/`-separated; empty at the notebook's top.
123 let group: String
124 let color: [UInt8]
125 let readable: Bool
126
127 var uiColor: UIColor {
128 UIColor(red: CGFloat(color[0]) / 255, green: CGFloat(color[1]) / 255, blue: CGFloat(color[2]) / 255, alpha: 1)
129 }
130}
131
132/// Runs `work` off the main thread, then `done` with its result on it.
133func background<T>(_ work: @escaping () -> T, done: @escaping (T) -> Void) {
134 DispatchQueue.global(qos: .userInitiated).async {
135 let result = work()
136 DispatchQueue.main.async { done(result) }
137 }
138}
139
140/// Where section replicas live: kept, as they hold edits not yet published.
141let cacheDirectory: URL = {
142 let url = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
143 .appendingPathComponent("Sections", isDirectory: true)
144 try? FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
145 return url
146}()
147
148let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
149
150/// An open notebook: its Rust library and the sections it lists.
151final class Notebook {
152 let id: UUID
153 let source: Source
154 private(set) var name: String
155 private(set) var handle: OpaquePointer?
156 private(set) var tabs: [Tab] = []
157 /// Why the notebook could not be opened, while it cannot.
158 private(set) var problem: String?
159 /// The Files location being read, held open while the notebook is.
160 private var accessed: URL?
161
162 init(id: UUID = UUID(), name: String, source: Source) {
163 self.id = id
164 self.name = name
165 self.source = source
166 }
167
168 deinit {
169 if let handle { sb_library_free(handle) }
170 accessed?.stopAccessingSecurityScopedResource()
171 }
172
173 /// Opens the library off the main thread and lists its sections; `done` runs on the main
174 /// thread either way.
175 func open(done: @escaping () -> Void) {
176 let source = source
177 background({ () -> (OpaquePointer?, URL?, String?) in
178 var error: UnsafeMutablePointer<CChar>?
179 switch source {
180 case .server(let server):
181 let library = sb_library_server(
182 server.host, server.share, server.user, Keychain.password(server) ?? "", "", server.root,
183 cacheDirectory.path, &error)
184 return (library, nil, take(error))
185 case .files(let bookmark):
186 var stale = false
187 guard let url = try? URL(resolvingBookmarkData: bookmark, bookmarkDataIsStale: &stale) else {
188 return (nil, nil, "The notebook was moved or deleted.")
189 }
190 let accessed = url.startAccessingSecurityScopedResource() ? url : nil
191 var library: OpaquePointer?
192 // A coordinated read brings a cloud folder's files down before they are read.
193 var coordination: NSError?
194 NSFileCoordinator().coordinate(readingItemAt: url, options: [], error: &coordination) { url in
195 library = sb_library_open(url.path, cacheDirectory.path, &error)
196 }
197 return (library, accessed, take(error) ?? coordination?.localizedDescription)
198 case .documents(let path):
199 let library = sb_library_open(
200 documentsDirectory.appendingPathComponent(path).path, cacheDirectory.path, &error)
201 return (library, nil, take(error))
202 case .path(let path):
203 return (sb_library_open(path, cacheDirectory.path, &error), nil, take(error))
204 }
205 }) { [self] (library, accessed, problem) in
206 self.accessed?.stopAccessingSecurityScopedResource()
207 self.accessed = accessed
208 if let handle { sb_library_free(handle) }
209 handle = library
210 self.problem = library == nil ? (problem ?? "The notebook can’t be read.") : nil
211 reload(done: done)
212 }
213 }
214
215 /// Lists the sections again, off the main thread.
216 func reload(done: @escaping () -> Void) {
217 guard let handle else {
218 tabs = []
219 return done()
220 }
221 let pointer = Int(bitPattern: handle)
222 background({ decode([Tab].self, sb_library_sections(OpaquePointer(bitPattern: pointer))) }) { [self] tabs in
223 if let tabs { self.tabs = tabs }
224 done()
225 }
226 }
227}
228
229/// The notebooks the list shows, kept across launches.
230enum Notebooks {
231 private struct Entry: Codable {
232 let id: UUID
233 let name: String
234 let source: Source
235 }
236
237 private static let key = "notebooks"
238 private(set) static var all: [Notebook] = []
239
240 /// The kept notebooks, or on first launch a copy of the bundled sample in Documents.
241 static func load() {
242 if let path = ProcessInfo.processInfo.environment["SNOWBOUND_NOTEBOOK"] {
243 all = [Notebook(name: URL(fileURLWithPath: path).deletingPathExtension().lastPathComponent, source: .path(path))]
244 return
245 }
246 if let data = UserDefaults.standard.data(forKey: key),
247 let entries = try? JSONDecoder().decode([Entry].self, from: data)
248 {
249 all = entries.map { Notebook(id: $0.id, name: $0.name, source: $0.source) }
250 return
251 }
252 let sample = documentsDirectory.appendingPathComponent("Sample")
253 if !FileManager.default.fileExists(atPath: sample.path),
254 let bundled = Bundle.main.url(forResource: "notebook", withExtension: nil)
255 {
256 try? FileManager.default.copyItem(at: bundled, to: sample)
257 }
258 all = [Notebook(name: "Sample", source: .documents(path: "Sample"))]
259 // The notebook the first version remembered from Files.
260 if let bookmark = UserDefaults.standard.data(forKey: "notebook") {
261 var stale = false
262 let name = (try? URL(resolvingBookmarkData: bookmark, bookmarkDataIsStale: &stale))?
263 .deletingPathExtension().lastPathComponent
264 all.append(Notebook(name: name ?? "Notebook", source: .files(bookmark: bookmark)))
265 }
266 save()
267 }
268
269 static func add(_ notebook: Notebook) {
270 all.append(notebook)
271 save()
272 }
273
274 static func remove(_ notebook: Notebook) {
275 all.removeAll { $0 === notebook }
276 save()
277 }
278
279 private static func save() {
280 guard ProcessInfo.processInfo.environment["SNOWBOUND_NOTEBOOK"] == nil else { return }
281 let entries = all.map { Entry(id: $0.id, name: $0.name, source: $0.source) }
282 UserDefaults.standard.set(try? JSONEncoder().encode(entries), forKey: key)
283 }
284}
285
286/// A page row from `sb_section_pages`.
287struct Row: Decodable, Equatable {
288 struct Version: Decodable, Equatable {
289 let id: String
290 let user: String
291 /// Seconds since 1970.
292 let created: Double?
293 }
294
295 let id: String
296 var title: String
297 /// 1 at the top, 2 for a subpage and so on.
298 let level: Int
299 /// Conflict pages: versions with changes a merge could not take.
300 let versions: [Version]
301}
302
303/// Saving as the section reports it, from `sb_section_poll`.
304enum SaveStatus: UInt8 {
305 case unknown, saved, saving, offline, notSaving
306}
307
308private let wakeSection: sb_wake = { token in
309 DispatchQueue.main.async { Section.open[Int(token)]?.value?.poll() }
310}
311
312private struct Weak<T: AnyObject> { weak var value: T? }
313
314/// A section open for editing: pages listed and saved through its replica.
315final class Section {
316 /// Sent on the main thread with `flags` from `sb_section_poll` after each poll.
317 static let changed = Notification.Name("SectionChanged")
318 static let listed: UInt32 = 1
319 static let remote: UInt32 = 2
320 static let rejected: UInt32 = 4
321
322 fileprivate static var open: [Int: Weak<Section>] = [:]
323 private static var tokens = 0
324
325 let notebook: Notebook
326 let tab: Tab
327 let handle: OpaquePointer
328 private let token: Int
329 private(set) var rows: [Row] = []
330 private(set) var status = SaveStatus.unknown
331
332 static var all: [Section] { open.values.compactMap(\.value) }
333
334 private init(notebook: Notebook, tab: Tab, handle: OpaquePointer, token: Int) {
335 self.notebook = notebook
336 self.tab = tab
337 self.handle = handle
338 self.token = token
339 reloadRows()
340 }
341
342 deinit {
343 Self.open[token] = nil
344 sb_section_free(handle)
345 }
346
347 /// Opens `tab` of `notebook` off the main thread, or hands back the section already open
348 /// there, as its replica takes one owner at a time.
349 static func open(_ tab: Tab, of notebook: Notebook, done: @escaping (Section?, String?) -> Void) {
350 if let section = all.first(where: { $0.notebook === notebook && $0.tab.path == tab.path }) {
351 return done(section, nil)
352 }
353 guard let library = notebook.handle else { return done(nil, notebook.problem) }
354 tokens += 1
355 let token = tokens
356 let pointer = Int(bitPattern: library)
357 let author = Author.name ?? ""
358 background({ () -> (Int, String?) in
359 var error: UnsafeMutablePointer<CChar>?
360 let handle = sb_section_open(
361 OpaquePointer(bitPattern: pointer), tab.path, author, wakeSection, UInt(token), &error)
362 return (Int(bitPattern: handle), take(error))
363 }) { handle, error in
364 guard let handle = OpaquePointer(bitPattern: handle) else { return done(nil, error) }
365 let section = Section(notebook: notebook, tab: tab, handle: handle, token: token)
366 open[token] = Weak(value: section)
367 done(section, nil)
368 }
369 }
370
371 func reloadRows() {
372 rows = decode([Row].self, sb_section_pages(handle)) ?? rows
373 }
374
375 /// The row listing page or conflict page `id`, and the version when it is one.
376 func row(of id: String) -> (row: Row, version: Row.Version?)? {
377 for row in rows {
378 if row.id == id { return (row, nil) }
379 if let version = row.versions.first(where: { $0.id == id }) { return (row, version) }
380 }
381 return nil
382 }
383
384 fileprivate func poll() {
385 var status: UInt8 = 0
386 let flags = sb_section_poll(handle, &status)
387 let changed = SaveStatus(rawValue: status) ?? .unknown
388 guard flags != 0 || changed != self.status else { return }
389 self.status = changed
390 if flags & Self.listed != 0 { reloadRows() }
391 NotificationCenter.default.post(name: Self.changed, object: self, userInfo: ["flags": flags])
392 }
393
394 /// A page's title as typed, shown in the list before the section reports it.
395 func retitle(_ id: String, _ title: String) {
396 guard let index = rows.firstIndex(where: { $0.id == id }), rows[index].title != title else { return }
397 rows[index].title = title
398 NotificationCenter.default.post(name: Self.changed, object: self, userInfo: ["flags": UInt32(0)])
399 }
400
401 /// Adds a page, or a subpage of `parent`; returns its id.
402 func newPage(under parent: String? = nil) -> String? {
403 let (date, time) = titleDate()
404 let id = take(sb_section_new_page(handle, parent, date, time))
405 reloadRows()
406 NotificationCenter.default.post(name: Self.changed, object: self, userInfo: ["flags": Self.listed])
407 return id
408 }
409
410 func delete(_ id: String, done: @escaping (Bool) -> Void) {
411 let (date, time) = titleDate()
412 let pointer = Int(bitPattern: handle)
413 background({ sb_section_delete_page(OpaquePointer(bitPattern: pointer), id, date, time) }) { [self] deleted in
414 reloadRows()
415 NotificationCenter.default.post(name: Self.changed, object: self, userInfo: ["flags": Self.listed])
416 done(deleted)
417 }
418 }
419
420 /// Stores every edit and publishes them within `seconds`; call off the main thread.
421 func flush(_ seconds: Double) -> Bool { sb_section_flush(handle, seconds) }
422
423 func wake() { sb_section_wake(handle) }
424}
apps/ios/Snowbound/PageViewController.swift created+306
......@@ -0,0 +1,306 @@
1import PhotosUI
2import UIKit
3
4final class PageViewController: UIViewController, PHPickerViewControllerDelegate,
5 UIImagePickerControllerDelegate, UINavigationControllerDelegate
6{
7 /// Posted when the app leaves the screen or quits: marked text is committed so it saves.
8 static let leaving = Notification.Name("SnowboundLeaving")
9
10 private let section: Section
11 private let page: String
12 private let find: String?
13 private let titleFocus: Bool
14 private lazy var canvas = CanvasView(section: section, page: page)
15 private let bar = ConflictBar()
16 /// Opens another page or conflict page of the section.
17 var onOpen: ((String) -> Void)?
18 private lazy var done = UIBarButtonItem(
19 systemItem: .done, primaryAction: UIAction { [weak self] _ in _ = self?.canvas.resignFirstResponder() })
20 private lazy var undo = UIBarButtonItem(
21 systemItem: .undo, primaryAction: UIAction { [weak self] _ in self?.canvas.undo(redo: false) })
22 private lazy var redo = UIBarButtonItem(
23 systemItem: .redo, primaryAction: UIAction { [weak self] _ in self?.canvas.undo(redo: true) })
24
25 init(section: Section, page: String, find: String? = nil, titleFocus: Bool = false) {
26 self.section = section
27 self.page = page
28 self.find = find
29 self.titleFocus = titleFocus
30 super.init(nibName: nil, bundle: nil)
31 navigationItem.largeTitleDisplayMode = .never
32 showTitle(section.row(of: page)?.row.title ?? "")
33 }
34
35 required init?(coder: NSCoder) { fatalError() }
36
37 override func loadView() {
38 let view = UIView()
39 view.backgroundColor = .systemBackground
40 canvas.translatesAutoresizingMaskIntoConstraints = false
41 bar.translatesAutoresizingMaskIntoConstraints = false
42 view.addSubview(canvas)
43 view.addSubview(bar)
44 NSLayoutConstraint.activate([
45 canvas.leadingAnchor.constraint(equalTo: view.leadingAnchor),
46 canvas.trailingAnchor.constraint(equalTo: view.trailingAnchor),
47 canvas.topAnchor.constraint(equalTo: view.topAnchor),
48 canvas.bottomAnchor.constraint(equalTo: view.bottomAnchor),
49 bar.leadingAnchor.constraint(equalTo: view.leadingAnchor),
50 bar.trailingAnchor.constraint(equalTo: view.trailingAnchor),
51 bar.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
52 ])
53 self.view = view
54 canvas.onChange = { [weak self] in self?.editingChanged() }
55 canvas.onOpened = { [weak self] in self?.opened() }
56 canvas.onDate = { [weak self] field, date in self?.pickDate(field, date) }
57 canvas.formatBar.onPicture = { [weak self] camera in self?.pickPicture(camera: camera) }
58 bar.onAction = { [weak self] in self?.conflictAction() }
59 NotificationCenter.default.addObserver(
60 self, selector: #selector(sectionChanged), name: Section.changed, object: section)
61 NotificationCenter.default.addObserver(self, selector: #selector(leaving), name: Self.leaving, object: nil)
62 conflictChanged()
63 }
64
65 /// The page is on screen: a found word is selected, a new page's title takes the caret.
66 private func opened() {
67 if let find, canvas.find(find) {
68 _ = canvas.becomeFirstResponder()
69 } else if titleFocus, canvas.focusTitle() {
70 _ = canvas.becomeFirstResponder()
71 }
72 }
73
74 private func showTitle(_ title: String) {
75 self.title = title.isEmpty ? "Untitled Page" : title
76 }
77
78 /// Notes' checkmark ends editing and puts the keyboard away; undo and redo sit beside it.
79 private func editingChanged() {
80 if !canvas.readOnly, let title = canvas.pageTitle {
81 showTitle(title)
82 section.retitle(page, title)
83 }
84 guard canvas.isFirstResponder else {
85 navigationItem.rightBarButtonItems = nil
86 return
87 }
88 undo.isEnabled = canvas.canUndo(redo: false)
89 redo.isEnabled = canvas.canUndo(redo: true)
90 if navigationItem.rightBarButtonItems?.first !== done {
91 navigationItem.rightBarButtonItems = [done, redo, undo]
92 }
93 }
94
95 @objc private func leaving() { canvas.commitComposition() }
96
97 @objc private func sectionChanged(_ notification: Notification) {
98 let flags = notification.userInfo?["flags"] as? UInt32 ?? 0
99 if flags & Section.rejected != 0 {
100 // Nothing typed is lost unannounced: the page's text goes on the clipboard.
101 UIPasteboard.general.string = canvas.pageText
102 canvas.reload(discard: true)
103 let alert = UIAlertController(
104 title: "Change Not Saved",
105 message: "The page shows what was last saved. Your text is on the clipboard to paste back.",
106 preferredStyle: .alert)
107 alert.addAction(UIAlertAction(title: "OK", style: .default))
108 present(alert, animated: true)
109 } else if flags & Section.remote != 0 {
110 canvas.reload(discard: false)
111 }
112 if flags & Section.listed != 0 {
113 if section.row(of: page) == nil {
114 // Deleted elsewhere, or a conflict page resolved: back to what the section lists.
115 if let first = section.rows.first { onOpen?(first.id) }
116 return
117 }
118 conflictChanged()
119 }
120 showStatus()
121 }
122
123 /// Only trouble shows: saving goes on quietly, as Notes saves.
124 private func showStatus() {
125 let status: String? =
126 switch section.status {
127 case .offline: "Offline"
128 case .notSaving: "Not Saving"
129 default: nil
130 }
131 if #available(iOS 26, *) {
132 navigationItem.subtitle = status
133 } else {
134 navigationItem.prompt = status
135 }
136 }
137
138 // MARK: Conflicts
139
140 private func conflictChanged() {
141 guard let (row, version) = section.row(of: page) else { return bar.show(nil) }
142 if version != nil {
143 // Deleting a conflict page cannot be undone, so the button asks first.
144 let delete = UIAction(
145 title: "Delete Conflict Page", image: UIImage(systemName: "trash"), attributes: .destructive
146 ) { [weak self] _ in self?.conflictAction() }
147 bar.show(
148 "Changes in red couldn’t be merged. This version can’t be edited.", action: "Delete",
149 menu: UIMenu(children: [delete]))
150 } else if !row.versions.isEmpty {
151 bar.show("This page has changes that couldn’t be merged.", action: "See Version")
152 } else {
153 bar.show(nil)
154 }
155 canvas.topInset = bar.isHidden ? 0 : bar.systemLayoutSizeFitting(view.bounds.size).height
156 }
157
158 private func conflictAction() {
159 guard let (row, version) = section.row(of: page) else { return }
160 if version != nil {
161 section.delete(page) { [weak self] _ in self?.onOpen?(row.id) }
162 } else if let newest = row.versions.max(by: { ($0.created ?? 0) < ($1.created ?? 0) }) {
163 onOpen?(newest.id)
164 }
165 }
166
167 // MARK: Date
168
169 /// Changes the page's date or time, as OneNote's date and time under the title do.
170 private func pickDate(_ field: DateField, _ shown: Date) {
171 let picker = UIDatePicker()
172 picker.datePickerMode = field == .date ? .date : .time
173 picker.preferredDatePickerStyle = field == .date ? .inline : .wheels
174 picker.date = shown
175 picker.minimumDate = Date(timeIntervalSince1970: -11_644_473_600)
176 picker.accessibilityLabel = field == .date ? "Page date" : "Page time"
177 let controller = UIViewController()
178 controller.title = field == .date ? "Change Page Date" : "Change Page Time"
179 picker.translatesAutoresizingMaskIntoConstraints = false
180 controller.view.backgroundColor = .systemBackground
181 controller.view.addSubview(picker)
182 NSLayoutConstraint.activate([
183 picker.centerXAnchor.constraint(equalTo: controller.view.centerXAnchor),
184 picker.topAnchor.constraint(equalTo: controller.view.safeAreaLayoutGuide.topAnchor, constant: 8),
185 picker.widthAnchor.constraint(lessThanOrEqualTo: controller.view.widthAnchor, constant: -32),
186 ])
187 controller.navigationItem.leftBarButtonItem = UIBarButtonItem(
188 systemItem: .cancel, primaryAction: UIAction { [weak self] _ in self?.dismiss(animated: true) })
189 controller.navigationItem.rightBarButtonItem = UIBarButtonItem(
190 title: "Apply",
191 primaryAction: UIAction { [weak self] _ in
192 // The field not being changed keeps what the page had.
193 let calendar = Calendar.current
194 let kept: Set<Calendar.Component> = field == .date ? [.hour, .minute, .second] : [.era, .year, .month, .day]
195 let changed: Set<Calendar.Component> = field == .date ? [.era, .year, .month, .day] : [.hour, .minute]
196 var components = calendar.dateComponents(kept, from: shown)
197 let chosen = calendar.dateComponents(changed, from: picker.date)
198 for component in changed { components.setValue(chosen.value(for: component), for: component) }
199 if field == .time { components.second = calendar.component(.second, from: shown) }
200 self?.dismiss(animated: true)
201 if let date = calendar.date(from: components) { self?.canvas.changeDate(date) }
202 })
203 let navigation = UINavigationController(rootViewController: controller)
204 navigation.sheetPresentationController?.detents = field == .date ? [.large()] : [.medium()]
205 present(navigation, animated: true)
206 }
207
208 // MARK: Pictures
209
210 private func pickPicture(camera: Bool) {
211 if camera {
212 guard UIImagePickerController.isSourceTypeAvailable(.camera) else { return }
213 let picker = UIImagePickerController()
214 picker.sourceType = .camera
215 picker.delegate = self
216 present(picker, animated: true)
217 } else {
218 var configuration = PHPickerConfiguration()
219 configuration.filter = .images
220 let picker = PHPickerViewController(configuration: configuration)
221 picker.delegate = self
222 present(picker, animated: true)
223 }
224 }
225
226 func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
227 picker.dismiss(animated: true)
228 guard let provider = results.first?.itemProvider, provider.canLoadObject(ofClass: UIImage.self) else { return }
229 provider.loadObject(ofClass: UIImage.self) { [weak self] image, _ in
230 guard let image = image as? UIImage else { return }
231 DispatchQueue.main.async { self?.insert(image) }
232 }
233 }
234
235 func imagePickerController(
236 _ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]
237 ) {
238 picker.dismiss(animated: true)
239 if let image = info[.originalImage] as? UIImage { insert(image) }
240 }
241
242 /// A photo as OneNote 2010 can read it: JPEG, at most 2048 pixels across, shown at most
243 /// 220 points wide.
244 private func insert(_ image: UIImage) {
245 let longest: CGFloat = 2048
246 let scale = min(1, longest / max(image.size.width * image.scale, image.size.height * image.scale))
247 let pixels = CGSize(
248 width: (image.size.width * image.scale * scale).rounded(),
249 height: (image.size.height * image.scale * scale).rounded())
250 let format = UIGraphicsImageRendererFormat()
251 format.scale = 1
252 let drawn = UIGraphicsImageRenderer(size: pixels, format: format).image { _ in
253 image.draw(in: CGRect(origin: .zero, size: pixels))
254 }
255 guard let data = drawn.jpegData(compressionQuality: 0.85) else { return }
256 // OneNote shows a picture's pixels at 96 per inch.
257 var size = CGSize(width: pixels.width * 0.75, height: pixels.height * 0.75)
258 if size.width > 220 { size = CGSize(width: 220, height: size.height * 220 / size.width) }
259 canvas.insertPicture(data, size: size)
260 }
261}
262
263/// OneNote's information bar above a page whose changes could not all be merged.
264final class ConflictBar: UIView {
265 private let label = UILabel()
266 private let button = UIButton(configuration: .bordered())
267 var onAction: (() -> Void)?
268
269 init() {
270 super.init(frame: .zero)
271 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) }
272 let icon = UIImageView(image: UIImage(systemName: "exclamationmark.triangle.fill"))
273 icon.tintColor = .systemOrange
274 icon.setContentHuggingPriority(.required, for: .horizontal)
275 label.font = .preferredFont(forTextStyle: .footnote)
276 label.numberOfLines = 0
277 button.addAction(
278 UIAction { [weak self] _ in
279 if self?.button.menu == nil { self?.onAction?() }
280 }, for: .primaryActionTriggered)
281 button.setContentHuggingPriority(.required, for: .horizontal)
282 let stack = UIStackView(arrangedSubviews: [icon, label, button])
283 stack.spacing = 10
284 stack.alignment = .center
285 stack.translatesAutoresizingMaskIntoConstraints = false
286 addSubview(stack)
287 NSLayoutConstraint.activate([
288 stack.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor),
289 stack.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor),
290 stack.topAnchor.constraint(equalTo: topAnchor, constant: 8),
291 stack.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -8),
292 ])
293 isHidden = true
294 }
295
296 required init?(coder: NSCoder) { fatalError() }
297
298 /// Shows `message` with a button titled `action`, which opens `menu` when there is one.
299 func show(_ message: String?, action: String = "", menu: UIMenu? = nil) {
300 isHidden = message == nil
301 label.text = message
302 button.configuration?.title = action
303 button.menu = menu
304 button.showsMenuAsPrimaryAction = menu != nil
305 }
306}
apps/ios/Snowbound/Script.swift+38-2
......@@ -1,16 +1,22 @@
11#if DEBUG
22import UIKit
33
4private var scriptedPage = false
5
46extension CanvasView {
57 /// Replays `SNOWBOUND_SCRIPT` through the calls touch and the keyboard make, to check
68 /// input without a finger: steps joined by `|`, such as
79 /// `tap:120,300|type:hi|mark:かな|unmark|return|delete|select:2,9|scroll:0,600|zoom:1.5|shot:a`.
810 /// `tap` and `doubletap` take points from the view's corner, `select` text offsets,
9 /// `scroll` a content offset; `done` ends editing, `tree` saves the view hierarchy to
11 /// `scroll` a content offset; `format:N` applies `sb_view_apply` formatting, `find:word`
12 /// selects a match, `title` edits the title, `picture` inserts a drawn picture; `done`
13 /// ends editing, `tree` saves the view hierarchy to
1014 /// Documents/tree.txt, and `shot:a` the window to Documents/a.png, as a device has no
1115 /// screenshot command.
1216 func runScript() {
13 guard let script = ProcessInfo.processInfo.environment["SNOWBOUND_SCRIPT"] else { return }
17 // Only the first page shown runs it, not every page opened afterwards.
18 guard !scriptedPage, let script = ProcessInfo.processInfo.environment["SNOWBOUND_SCRIPT"] else { return }
19 scriptedPage = true
1420 for (index, step) in script.split(separator: "|").enumerated() {
1521 DispatchQueue.main.asyncAfter(deadline: .now() + 1 + Double(index) * 0.4) { [weak self] in
1622 self?.perform(String(step))
......@@ -48,6 +54,17 @@ extension CanvasView {
4854 let values = argument.split(separator: ",").compactMap { Double($0) }
4955 zoom(at: CGPoint(x: values[0] + contentOffset.x, y: values[1] + contentOffset.y))
5056 case "done": _ = resignFirstResponder()
57 case "format": apply(UInt8(argument) ?? 0)
58 case "find": _ = find(argument)
59 case "title": _ = focusTitle()
60 case "picture":
61 let image = UIGraphicsImageRenderer(size: CGSize(width: 320, height: 200)).image { context in
62 UIColor.systemTeal.setFill()
63 context.fill(CGRect(x: 0, y: 0, width: 320, height: 200))
64 UIColor.systemYellow.setFill()
65 context.cgContext.fillEllipse(in: CGRect(x: 110, y: 50, width: 100, height: 100))
66 }
67 if let data = image.jpegData(compressionQuality: 0.9) { insertPicture(data, size: CGSize(width: 240, height: 150)) }
5168 case "tree":
5269 let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
5370 try? "\(value(forKey: "recursiveDescription") ?? "")".write(
......@@ -66,4 +83,23 @@ extension CanvasView {
6683 }
6784 }
6885}
86
87/// Logs `presenter changed` whenever a coordinated write reaches `SNOWBOUND_PRESENTER`, a
88/// file, to check that saving tells file providers about it.
89final class Presenter: NSObject, NSFilePresenter {
90 private static var watching: Presenter?
91 let presentedItemURL: URL?
92 let presentedItemOperationQueue = OperationQueue()
93
94 private init(_ url: URL) { presentedItemURL = url }
95
96 static func watch() {
97 guard let path = ProcessInfo.processInfo.environment["SNOWBOUND_PRESENTER"] else { return }
98 let presenter = Presenter(URL(fileURLWithPath: path))
99 NSFileCoordinator.addFilePresenter(presenter)
100 watching = presenter
101 }
102
103 func presentedItemDidChange() { NSLog("presenter changed") }
104}
69105#endif
apps/ios/Snowbound/Server.swift created+258
......@@ -0,0 +1,258 @@
1import UIKit
2
3/// Connect to Server, as Files and the Finder offer it: an `smb://` address, then a guest
4/// or an account whose password goes in the Keychain, then a folder to open.
5final class ServerViewController: UITableViewController, UITextFieldDelegate {
6 private static let lastKey = "server"
7
8 private let address = UITextField()
9 private let name = UITextField()
10 private let password = UITextField()
11 private let guest = UISegmentedControl(items: ["Guest", "Registered User"])
12 private var connecting = false
13 var onOpen: ((Notebook) -> Void)?
14
15 init() {
16 super.init(style: .insetGrouped)
17 title = "Connect to Server"
18 }
19
20 required init?(coder: NSCoder) { fatalError() }
21
22 override func viewDidLoad() {
23 super.viewDidLoad()
24 navigationItem.leftBarButtonItem = UIBarButtonItem(
25 systemItem: .cancel, primaryAction: UIAction { [weak self] _ in self?.dismiss(animated: true) })
26 navigationItem.rightBarButtonItem = UIBarButtonItem(
27 title: "Connect", primaryAction: UIAction { [weak self] _ in self?.connect() })
28 address.placeholder = "smb://server/share"
29 address.text = UserDefaults.standard.string(forKey: Self.lastKey)
30 address.keyboardType = .URL
31 address.autocapitalizationType = .none
32 address.autocorrectionType = .no
33 address.clearButtonMode = .whileEditing
34 name.placeholder = "Name"
35 name.textContentType = .username
36 name.autocapitalizationType = .none
37 name.autocorrectionType = .no
38 password.placeholder = "Password"
39 password.textContentType = .password
40 password.isSecureTextEntry = true
41 for field in [address, name, password] {
42 field.delegate = self
43 field.returnKeyType = field === password || field === address ? .go : .next
44 }
45 guest.selectedSegmentIndex = 1
46 guest.addAction(UIAction { [weak self] _ in self?.tableView.reloadData() }, for: .valueChanged)
47 tableView.register(UITableViewCell.self, forCellReuseIdentifier: "field")
48 }
49
50 override func viewDidAppear(_ animated: Bool) {
51 super.viewDidAppear(animated)
52 address.becomeFirstResponder()
53 }
54
55 func textFieldShouldReturn(_ field: UITextField) -> Bool {
56 if field === name {
57 password.becomeFirstResponder()
58 } else {
59 connect()
60 }
61 return false
62 }
63
64 private var registered: Bool { guest.selectedSegmentIndex == 1 }
65
66 /// `smb://user@host:port/share/folder`, or the same without a scheme.
67 private func server() -> Server? {
68 var text = address.text?.trimmingCharacters(in: .whitespaces) ?? ""
69 if !text.contains("://") { text = "smb://" + text }
70 guard let url = URLComponents(string: text), url.scheme?.lowercased() == "smb", let host = url.host,
71 !host.isEmpty
72 else { return nil }
73 let parts = url.path.split(separator: "/").map(String.init)
74 guard let share = parts.first else { return nil }
75 let user = url.user ?? (registered ? name.text ?? "" : "")
76 return Server(
77 host: url.port.map { "\(host):\($0)" } ?? host, share: share, user: user,
78 root: parts.dropFirst().joined(separator: "/"))
79 }
80
81 private func connect() {
82 guard !connecting else { return }
83 guard let server = server() else {
84 return alert("Enter a Server Address", "Use the form smb://server/share.")
85 }
86 let secret = registered ? password.text ?? "" : ""
87 connecting = true
88 navigationItem.rightBarButtonItem?.isEnabled = false
89 let spinner = UIActivityIndicatorView(style: .medium)
90 spinner.startAnimating()
91 navigationItem.titleView = spinner
92 background({ () -> (Int, String?) in
93 var error: UnsafeMutablePointer<CChar>?
94 let share = sb_share_connect(server.host, server.share, server.user, secret, "", &error)
95 return (Int(bitPattern: share), take(error))
96 }) { [weak self] share, _ in
97 guard let self else { return }
98 connecting = false
99 navigationItem.rightBarButtonItem?.isEnabled = true
100 navigationItem.titleView = nil
101 guard let share = OpaquePointer(bitPattern: share) else {
102 return alert(
103 "Can’t Connect", "Check the address, name and password, and that the server is on this network.")
104 }
105 if registered { Keychain.save(secret, for: server) }
106 UserDefaults.standard.set(address.text, forKey: Self.lastKey)
107 let browser = ShareViewController(share: SharedConnection(share), server: server, path: server.root)
108 browser.onOpen = onOpen
109 navigationController?.pushViewController(browser, animated: true)
110 }
111 }
112
113 private func alert(_ title: String, _ message: String) {
114 let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
115 alert.addAction(UIAlertAction(title: "OK", style: .default))
116 present(alert, animated: true)
117 }
118
119 override func numberOfSections(in tableView: UITableView) -> Int { registered ? 2 : 1 }
120
121 override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 2 }
122
123 override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
124 let cell = tableView.dequeueReusableCell(withIdentifier: "field", for: indexPath)
125 cell.contentView.subviews.forEach { $0.removeFromSuperview() }
126 cell.selectionStyle = .none
127 let view: UIView =
128 switch (indexPath.section, indexPath.row) {
129 case (0, 0): address
130 case (0, _): guest
131 case (_, 0): name
132 default: password
133 }
134 view.translatesAutoresizingMaskIntoConstraints = false
135 cell.contentView.addSubview(view)
136 let margins = cell.contentView.layoutMarginsGuide
137 NSLayoutConstraint.activate([
138 view.leadingAnchor.constraint(equalTo: margins.leadingAnchor),
139 view.trailingAnchor.constraint(equalTo: margins.trailingAnchor),
140 view.topAnchor.constraint(equalTo: margins.topAnchor),
141 view.bottomAnchor.constraint(equalTo: margins.bottomAnchor),
142 view.heightAnchor.constraint(greaterThanOrEqualToConstant: 32),
143 ])
144 return cell
145 }
146}
147
148/// A share connection the browser's folders share, closed with the last of them.
149final class SharedConnection {
150 let handle: OpaquePointer
151 init(_ handle: OpaquePointer) { self.handle = handle }
152 deinit { sb_share_free(handle) }
153}
154
155/// A folder on a share: its folders to go into, and Open where it holds a notebook.
156final class ShareViewController: UITableViewController {
157 private struct Listing: Decodable {
158 let notebook: Bool
159 let folders: [String]
160 let sections: [String]
161 }
162
163 private let share: SharedConnection
164 private let server: Server
165 private let path: String
166 private var listing: Listing?
167 var onOpen: ((Notebook) -> Void)?
168
169 init(share: SharedConnection, server: Server, path: String) {
170 self.share = share
171 self.server = server
172 self.path = path
173 super.init(style: .insetGrouped)
174 title = path.split(separator: "/").last.map(String.init) ?? server.share
175 }
176
177 required init?(coder: NSCoder) { fatalError() }
178
179 override func viewDidLoad() {
180 super.viewDidLoad()
181 tableView.register(UITableViewCell.self, forCellReuseIdentifier: "entry")
182 let open = UIBarButtonItem(title: "Open", primaryAction: UIAction { [weak self] _ in self?.open() })
183 open.style = .done
184 open.isEnabled = false
185 navigationItem.rightBarButtonItem = open
186 var loading = UIContentUnavailableConfiguration.loading()
187 loading.text = "Loading…"
188 contentUnavailableConfiguration = loading
189 let (handle, path) = (Int(bitPattern: share.handle), path)
190 background({
191 decode(Listing.self, sb_share_list(OpaquePointer(bitPattern: handle), path))
192 }) { [weak self] listing in
193 guard let self else { return }
194 self.listing = listing
195 tableView.reloadData()
196 open.isEnabled = listing.map { $0.notebook || !$0.sections.isEmpty } ?? false
197 var empty = UIContentUnavailableConfiguration.empty()
198 if listing == nil {
199 empty.text = "Can’t Read Folder"
200 empty.secondaryText = "Check that this account can open it."
201 } else {
202 empty.text = "No Folders"
203 empty.secondaryText = "OneNote notebooks are folders with sections in them."
204 }
205 contentUnavailableConfiguration =
206 listing.map { $0.folders.isEmpty && $0.sections.isEmpty } ?? true ? empty : nil
207 }
208 }
209
210 private func open() {
211 var server = server
212 server.root = path
213 let name = path.split(separator: "/").last.map(String.init) ?? server.share
214 onOpen?(Notebook(name: name, source: .server(server)))
215 }
216
217 override func numberOfSections(in tableView: UITableView) -> Int { 2 }
218
219 override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
220 section == 0 ? listing?.folders.count ?? 0 : listing?.sections.count ?? 0
221 }
222
223 override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
224 section == 1 && !(listing?.sections.isEmpty ?? true) ? "Sections" : nil
225 }
226
227 override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
228 let cell = tableView.dequeueReusableCell(withIdentifier: "entry", for: indexPath)
229 var content = cell.defaultContentConfiguration()
230 if indexPath.section == 0 {
231 content.text = listing?.folders[indexPath.row]
232 content.image = UIImage(systemName: "folder.fill")
233 cell.accessoryType = .disclosureIndicator
234 cell.selectionStyle = .default
235 } else {
236 content.text = listing.map { URL(fileURLWithPath: $0.sections[indexPath.row]).deletingPathExtension().lastPathComponent }
237 content.image = UIImage(systemName: "rectangle.portrait.fill")
238 content.imageProperties.tintColor = .secondaryLabel
239 content.textProperties.color = .secondaryLabel
240 cell.accessoryType = .none
241 cell.selectionStyle = .none
242 }
243 cell.contentConfiguration = content
244 return cell
245 }
246
247 override func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? {
248 indexPath.section == 0 ? indexPath : nil
249 }
250
251 override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
252 guard let folder = listing?.folders[indexPath.row] else { return }
253 let child = ShareViewController(
254 share: share, server: server, path: path.isEmpty ? folder : "\(path)/\(folder)")
255 child.onOpen = onOpen
256 navigationController?.pushViewController(child, animated: true)
257 }
258}
crates/mobile/Cargo.toml+4-1
......@@ -11,10 +11,13 @@ crate-type = ["staticlib"]
1111[dependencies]
1212canvas = { path = "../canvas", features = ["interaction"] }
1313draw = { path = "../draw" }
14notebook = { path = "../notebook" }
14notebook = { path = "../notebook", features = ["smb"] }
1515onestore = { path = "../onestore" }
1616wgpu.workspace = true
1717parley.workspace = true
1818pollster = "0.4"
1919serde = { version = "1", features = ["derive"] }
2020serde_json = "1"
21
22[dev-dependencies]
23tempfile = "3"
crates/mobile/include/snowbound.h+42-10
......@@ -1,27 +1,53 @@
1// The C surface of crates/mobile; see its lib.rs for each call's contract.
1// The C surface of crates/mobile; see its lib.rs and library.rs for each call's contract.
22#pragma once
33
44#include <stdbool.h>
55#include <stddef.h>
66#include <stdint.h>
77
8typedef struct Library Library;
9typedef struct Share Share;
810typedef struct Section Section;
911typedef struct View View;
1012
11char *sb_notebook(const char *path);
13void sb_string_free(char *text);
14
15typedef void (*sb_coordinator)(const char *path, bool write, void (*body)(void *), void *context);
16void sb_set_coordinator(sb_coordinator coordinator);
17
18const Library *sb_library_open(const char *path, const char *cache, char **error);
19const Library *sb_library_server(const char *address, const char *share, const char *user, const char *password,
20 const char *domain, const char *root, const char *cache, char **error);
21char *sb_library_sections(const Library *library);
22char *sb_library_new_section(const Library *library, const char *folder, const char *name, const char *author,
23 const char *date, const char *time);
24char *sb_library_search(const Library *library, const Section *open, const char *path, const char *query);
25void sb_library_free(const Library *library);
1226
13Section *sb_section_open(const char *path);
14size_t sb_section_count(const Section *section);
15const char *sb_section_title(const Section *section, size_t index);
16uint32_t sb_section_level(const Section *section, size_t index);
27Share *sb_share_connect(const char *address, const char *share, const char *user, const char *password,
28 const char *domain, char **error);
29char *sb_share_list(const Share *share, const char *path);
30void sb_share_free(Share *share);
31
32typedef void (*sb_wake)(uintptr_t token);
33Section *sb_section_open(const Library *library, const char *path, const char *author, sb_wake wake,
34 uintptr_t token, char **error);
35char *sb_section_pages(const Section *section);
36uint32_t sb_section_poll(const Section *section, uint8_t *status);
37char *sb_section_new_page(const Section *section, const char *parent, const char *date, const char *time);
38bool sb_section_delete_page(const Section *section, const char *id, const char *date, const char *time);
39bool sb_section_flush(const Section *section, double seconds);
40void sb_section_wake(const Section *section);
1741void sb_section_free(Section *section);
1842
19View *sb_view_new(void *layer, const Section *section, size_t index, float width, float height, float scale);
43View *sb_view_new(void *layer, const Section *section, const char *id, float width, float height, float scale);
2044void sb_view_free(View *view);
2145void sb_view_resize(View *view, float width, float height, float scale);
2246bool sb_view_render(View *view);
2347bool sb_view_frame_pending(const View *view);
2448void sb_view_set_dark(View *view, bool dark);
49bool sb_view_read_only(const View *view);
50bool sb_view_reload(View *view, bool discard);
2551void sb_view_focus(View *view, bool focused);
2652void sb_view_content(View *view, float bounds[4]);
2753bool 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);
3258bool sb_view_release(View *view);
3359bool sb_view_undo(View *view, bool redo);
3460bool sb_view_can_undo(const View *view, bool redo);
35bool sb_view_toggle(View *view, uint8_t toggle);
36char *sb_view_edit(View *view);
37void sb_string_free(char *text);
61uint32_t sb_view_format(const View *view);
62bool sb_view_apply(View *view, uint8_t command);
63char *sb_view_title(const View *view);
64bool sb_view_focus_title(View *view);
65bool sb_view_find(View *view, const char *query);
66char *sb_view_page_text(const View *view);
67int8_t sb_view_date_request(View *view, int64_t *seconds);
68bool sb_view_change_date(View *view, int64_t seconds, const char *date, const char *time);
69bool sb_view_insert_picture(View *view, const uint8_t *bytes, size_t length, float width, float height);
3870
3971uint32_t sb_text_length(const View *view);
4072char *sb_text(const View *view, uint32_t start, uint32_t end);
crates/mobile/src/lib.rs+501-321
......@@ -1,33 +1,38 @@
1//! A canvas page behind a C surface for native mobile shells: a notebook's sections, a
2//! section's pages, one page drawn into a `CAMetalLayer`, and the active outline's text as
3//! the flat UTF-16 model UIKit's `UITextInput` speaks (see `TextOutline::shown_text`).
1//! A canvas page behind a C surface for native mobile shells: notebooks and their sections
2//! (`library`), one page drawn into a `CAMetalLayer`, and the active outline's text as the
3//! flat UTF-16 model UIKit's `UITextInput` speaks (see `TextOutline::shown_text`). Every
4//! edit a view takes is stored at once through its section.
45//!
56//! Lengths and positions are in the host's points; the view converts through its display
67//! scale. Calls returning `bool` report whether the page or selection changed, after which
78//! the host redraws and rereads `sb_view_content`.
89
10mod library;
11
12use library::Shared;
13pub use library::{Library, Section, Share};
14
915use canvas::{
1016 document::TextPosition,
11 editor::{Formatting, Selection, TextOutline, Toggle},
17 editor::{Formatting, NoteTag, Selection, TextOutline, Toggle},
1218 gpu::{Paper, Viewport, page::PageScene},
13 interaction::{Hit, PageView, Response, TextColors},
19 date::DateField,
20 interaction::{Hit, ObjectFocus, PageView, Request, Response, TextColors},
1421 layout::TextEngine,
1522};
1623use draw::edit::{Key, NamedKey, SelectionUnit};
17use notebook::discover::{self, Folder, SectionState};
1824use onestore::{
19 ExGuid, RevisionIndex, Store,
20 document::Document,
21 op::{Edit, Op},
22 page::Page,
25 ExGuid,
26 op::{Edit, Op, PageOp},
27 page::{Image, Page},
2328};
2429use parley::{Affinity, BoundingBox};
2530use std::{
2631 error::Error,
2732 ffi::{CStr, CString, c_char, c_void},
28 path::Path,
33 mem::ManuallyDrop,
2934 sync::{
30 Arc,
35 Arc, Mutex,
3136 atomic::{AtomicBool, Ordering},
3237 },
3338 task::{Wake, Waker},
......@@ -36,122 +41,9 @@ use std::{
3641
3742type Result<T> = std::result::Result<T, Box<dyn Error>>;
3843
39/// Host points per document point at 100% zoom, as OneNote's 96 pixels per inch.
40const POINT: f32 = 96.0 / 72.0;
41
42/// The tab colour OneNote gives a section that stores none, as the desktop shows it.
43const SECTION_COLOR: u32 = 0x00e4_a88a;
44
45#[derive(serde::Serialize)]
46struct Listing {
47 name: String,
48 sections: Vec<Tab>,
49}
50
51#[derive(serde::Serialize)]
52struct Tab {
53 name: String,
54 /// The section file's absolute path, for `sb_section_open`.
55 path: String,
56 /// The section group holding it, `/`-separated; empty at the notebook's top.
57 group: String,
58 /// The tab colour in sRGB.
59 color: [u8; 3],
60 /// Password-protected or unreadable sections list but do not open.
61 readable: bool,
62}
63
64/// A notebook folder's sections in its order, or a lone section file as a notebook of one.
65fn listing(path: &Path) -> Result<Listing> {
66 let stem = |path: &Path| {
67 path.file_stem()
68 .map(|stem| stem.to_string_lossy().into_owned())
69 .unwrap_or_default()
70 };
71 if path.is_file() {
72 return Ok(Listing {
73 name: stem(path),
74 sections: vec![Tab {
75 name: stem(path),
76 path: path.to_string_lossy().into_owned(),
77 group: String::new(),
78 color: rgb(SECTION_COLOR),
79 readable: true,
80 }],
81 });
82 }
83 let folder = discover::discover(
84 &mut discover::Local::open(path)?,
85 discover::Limits {
86 entries: 10_000,
87 bytes_per_file: 1 << 30,
88 depth: 16,
89 },
90 )?;
91 let mut sections = Vec::new();
92 fn walk(folder: &Folder, root: &Path, sections: &mut Vec<Tab>) {
93 for section in &folder.sections {
94 let file = root.join(&section.path);
95 let (name, color, readable) = match &section.state {
96 SectionState::Readable { name, color } => (name.clone(), *color, true),
97 SectionState::Locked | SectionState::Unreadable(_) => (None, None, false),
98 };
99 sections.push(Tab {
100 name: name.unwrap_or_else(|| {
101 file.file_stem()
102 .map(|stem| stem.to_string_lossy().into_owned())
103 .unwrap_or_default()
104 }),
105 path: file.to_string_lossy().into_owned(),
106 group: folder.path.clone(),
107 color: rgb(color.unwrap_or(SECTION_COLOR)),
108 readable,
109 });
110 }
111 for group in &folder.groups {
112 // OneNote keeps deleted sections and pages here, out of the notebook's view.
113 if !group.path.ends_with("OneNote_RecycleBin") {
114 walk(group, root, sections);
115 }
116 }
117 }
118 walk(&folder, path, &mut sections);
119 Ok(Listing {
120 name: stem(path),
121 sections,
122 })
123}
124
125fn rgb(colorref: u32) -> [u8; 3] {
126 let [red, green, blue, _] = colorref.to_le_bytes();
127 [red, green, blue]
128}
129
130pub struct Section {
131 /// Each page with the object space holding it.
132 pages: Vec<(ExGuid, Page)>,
133 /// The page list's title and outline level (1 at the top) for each page.
134 headings: Vec<(CString, u32)>,
135}
136
137impl Section {
138 fn open(bytes: &[u8]) -> Result<Self> {
139 let store = Store::parse(bytes)?;
140 let index = RevisionIndex::parse(&store)?;
141 let document = Document::parse(&index)?;
142 let mut pages = Vec::new();
143 let mut headings = Vec::new();
144 for (space, id) in document.pages()? {
145 let revision = document.active(space)?;
146 let (title, level) = Page::heading(revision, id);
147 pages.push((space, Page::from_revision(revision, id)?));
148 // Titles keep OneNote's line breaks, which a one-line list shows as spaces.
149 let title = title.replace(|char: char| char.is_control(), " ");
150 headings.push((CString::new(title)?, level));
151 }
152 Ok(Self { pages, headings })
153 }
154}
44/// Host points per document point at the zoom the host calls 100%: OneNote's 100% (96
45/// pixels per inch) enlarged by 15%.
46const POINT: f32 = 96.0 / 72.0 * 1.15;
15547
15648/// Set off the main thread when a picture raster lands and the page should be drawn again.
15749#[derive(Default)]
......@@ -182,21 +74,53 @@ enum Target {
18274 /// Another outline's text, which a touch focuses before the platform's text interaction
18375 /// takes over.
18476 Text = 2,
185 /// The focused outline's move or width grip.
77 /// The focused outline's move or width grip, or the selected picture and its handles.
18678 Grip = 3,
18779}
18880
81/// Formatting the toolbar shows and applies, by bit in `sb_view_format` and by number in
82/// `sb_view_apply`: toggles, lists, indentation, then OneNote's nine default tags from 16.
83const FORMATS: [Formatting; 8] = [
84 Formatting::Toggle(Toggle::Bold),
85 Formatting::Toggle(Toggle::Italic),
86 Formatting::Toggle(Toggle::Underline),
87 Formatting::Toggle(Toggle::Strikethrough),
88 Formatting::Bullets,
89 Formatting::Numbering,
90 Formatting::Indent,
91 Formatting::Outdent,
92];
93const TAGS: [NoteTag; 9] = [
94 NoteTag::ToDo,
95 NoteTag::Important,
96 NoteTag::Question,
97 NoteTag::RememberForLater,
98 NoteTag::Definition,
99 NoteTag::Highlight,
100 NoteTag::Contact,
101 NoteTag::Address,
102 NoteTag::PhoneNumber,
103];
104
189105/// The page and its text model, apart from the surface it is drawn on.
190106struct Canvas {
191107 page: PageView,
192108 /// The object space of the page shown, which its edits name.
193109 space: ExGuid,
194110 paper: Paper,
111 /// Device pixels per host point.
112 scale: f32,
113 /// The page date field a tap asked to change, for the host's picker.
114 asked: Option<DateField>,
195115}
196116
197117impl Canvas {
198 fn new((space, page): (ExGuid, Page), pixels: [u32; 2], scale: f32) -> Result<Self> {
199 let mut engine = TextEngine::default();
118 fn new(space: ExGuid, page: Page, pixels: [u32; 2], scale: f32) -> Result<Self> {
119 let mut engine = ENGINES
120 .lock()
121 .ok()
122 .and_then(|mut engines| engines.pop())
123 .unwrap_or_default();
200124 let (scene, editor) = PageScene::from_page(page, &mut engine)?;
201125 let mut page = PageView::new(
202126 editor,
......@@ -207,10 +131,13 @@ impl Canvas {
207131 Duration::from_millis(350),
208132 );
209133 page.touch = true;
134 page.host_viewport = true;
210135 Ok(Self {
211136 page,
212137 space,
213138 paper: Paper::WHITE,
139 scale,
140 asked: None,
214141 })
215142 }
216143
......@@ -219,10 +146,15 @@ impl Canvas {
219146 TextColors {
220147 caret: [0.0; 4],
221148 selection: [0.0; 4],
222 paper: self.paper,
149 paper: self.page_paper(),
223150 }
224151 }
225152
153 /// The paper the page lies on: the appearance's, in the page's colour.
154 fn page_paper(&self) -> Paper {
155 self.paper.colored(self.page.editor.page_color())
156 }
157
226158 /// The desktop's dark page, or white paper.
227159 fn set_dark(&mut self, dark: bool) {
228160 self.paper = if dark {
......@@ -241,11 +173,8 @@ impl Canvas {
241173 if ops.is_empty() {
242174 return Ok(None);
243175 }
244 let unix = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)?;
245176 Ok(Some(Edit {
246 // FILETIME: 100 ns ticks since 1601.
247 at: (unix.as_secs() + 11_644_473_600) * 10_000_000
248 + u64::from(unix.subsec_nanos() / 100),
177 at: library::filetime(),
249178 ops: ops
250179 .into_iter()
251180 .map(|op| Op::Page {
......@@ -256,13 +185,8 @@ impl Canvas {
256185 }))
257186 }
258187
259 /// Device pixels per host point.
260 fn display_scale(&self) -> f32 {
261 self.page.viewport.scale / (self.page.zoom() * POINT)
262 }
263
264188 fn device(&self, point: [f32; 2]) -> [f32; 2] {
265 point.map(|value| value * self.display_scale())
189 point.map(|value| value * self.scale)
266190 }
267191
268192 /// The page content's extent in document points: the scroll limits of a view with no
......@@ -280,9 +204,8 @@ impl Canvas {
280204 }
281205
282206 fn set_transform(&mut self, zoom: f32, corner: [f32; 2]) {
283 let scale = self.display_scale();
284 self.page.viewport.scale = scale * POINT * zoom;
285 self.page.viewport.origin = corner.map(|value| -value * scale * zoom);
207 self.page.viewport.scale = self.scale * POINT * zoom;
208 self.page.viewport.origin = corner.map(|value| -value * self.scale * zoom);
286209 }
287210
288211 fn outline(&self, id: ExGuid) -> Option<&TextOutline> {
......@@ -296,6 +219,11 @@ impl Canvas {
296219 fn target(&self, point: [f32; 2]) -> Target {
297220 match self.page.hit(self.device(point)) {
298221 Some(Hit::Handle { .. } | Hit::Resize { .. }) => Target::Grip,
222 Some(Hit::Image { id, .. })
223 if self.page.object_focus() == Some(ObjectFocus::Image(id)) =>
224 {
225 Target::Grip
226 }
299227 Some(Hit::Text { id, point })
300228 if !self
301229 .outline(id)
......@@ -311,38 +239,32 @@ impl Canvas {
311239 }
312240 }
313241
314 /// Runs `change` on the page, then puts the view back where the host's scroll view has
315 /// it: the canvas clamps and reveals knowing neither the navigation bar nor the keyboard,
316 /// and the host reveals the caret itself.
317 fn hold<T>(&mut self, change: impl FnOnce(&mut PageView) -> Result<T>) -> Result<T> {
318 let Viewport { scale, origin, .. } = self.page.viewport;
319 let result = change(&mut self.page);
320 self.page.viewport.scale = scale;
321 self.page.viewport.origin = origin;
322 result
323 }
324
325242 /// A finger down at `point`: the start of a tap or of a grip drag.
326243 fn press(&mut self, point: [f32; 2]) -> Result<bool> {
327244 let point = self.device(point);
328 self.hold(|page| {
329 let changed = moved(page.pointer_moved(point)?);
330 Ok(moved(page.pointer_pressed(Instant::now())?) || changed)
331 })
245 let changed = moved(self.page.pointer_moved(point)?);
246 let pressed = self.page.pointer_pressed(Instant::now())?;
247 Ok(self.respond(pressed) || changed)
248 }
249
250 /// Whether `response` moved the page, keeping the date field it asks to change.
251 fn respond(&mut self, response: Response) -> bool {
252 if let Some(Request::EditDate(field)) = response.request {
253 self.asked = Some(field);
254 }
255 moved(response)
332256 }
333257
334258 fn drag(&mut self, point: [f32; 2]) -> Result<bool> {
335 let point = self.device(point);
336 self.hold(|page| Ok(moved(page.pointer_moved(point)?)))
259 Ok(moved(self.page.pointer_moved(self.device(point))?))
337260 }
338261
339262 fn release(&mut self) -> Result<bool> {
340 self.hold(|page| {
341 let changed = moved(page.pointer_released()?);
342 // A finger leaves no hover behind.
343 let _ = page.pointer_left();
344 Ok(changed)
345 })
263 let released = self.page.pointer_released()?;
264 let changed = self.respond(released);
265 // A finger leaves no hover behind.
266 let _ = self.page.pointer_left();
267 Ok(changed)
346268 }
347269
348270 /// The outline nearest `point`, framed as its chrome is, in points at 100% zoom.
......@@ -429,31 +351,152 @@ impl Canvas {
429351 .map_or(text.len(), |(byte, _)| byte)
430352 };
431353 let cursor = (byte(selected[0]), byte(selected[1]));
432 self.hold(|page| Ok(moved(page.compose(text, Some(cursor))?)))
354 Ok(moved(self.page.compose(text, Some(cursor))?))
433355 }
434356
357 /// Types `text`; a lone newline or tab is the Return or Tab key.
435358 fn insert(&mut self, text: String) -> Result<bool> {
436 self.hold(|page| {
437 Ok(moved(if text == "\n" {
438 page.key(&Key::Named(NamedKey::Enter), None)?
439 } else {
440 page.commit_text(text)?
441 }))
442 })
359 Ok(moved(match text.as_str() {
360 "\n" => self.page.key(&Key::Named(NamedKey::Enter), None)?,
361 "\t" => self.page.key(&Key::Named(NamedKey::Tab), None)?,
362 _ => self.page.commit_text(text)?,
363 }))
364 }
365
366 /// The formatting the selection has, as `FORMATS` and `TAGS` bits.
367 fn format_bits(&self) -> Result<u32> {
368 let state = self.page.editor.format_state()?;
369 let mut bits = 0;
370 for (bit, format) in FORMATS.iter().enumerate() {
371 let on = match format {
372 Formatting::Toggle(toggle) => state.toggles.contains(toggle),
373 Formatting::Bullets => state.bullets,
374 Formatting::Numbering => state.numbering,
375 _ => false,
376 };
377 bits |= u32::from(on) << bit;
378 }
379 for (bit, tag) in TAGS.iter().enumerate() {
380 bits |= u32::from(state.tags.contains(tag)) << (16 + bit);
381 }
382 Ok(bits)
383 }
384
385 fn format(&mut self, command: u8) -> Result<bool> {
386 let command = match command {
387 16.. => Formatting::Tag(*TAGS.get(usize::from(command - 16)).ok_or("No such tag")?),
388 _ => FORMATS
389 .get(usize::from(command))
390 .ok_or("No such formatting")?
391 .clone(),
392 };
393 Ok(moved(self.page.format(command)?))
394 }
395
396 /// The title outline, where the page has an editable one.
397 fn title(&self) -> Option<&TextOutline> {
398 self.page
399 .editor
400 .outlines()
401 .iter()
402 .find(|outline| outline.title)
403 }
404
405 /// Puts the caret at the end of the page title.
406 fn focus_title(&mut self) -> Result<bool> {
407 let Some(id) = self.title().map(|title| title.id) else {
408 return Ok(false);
409 };
410 self.page.editor.focus_outline(id)?;
411 let end = self.length();
412 self.select([end, end])?;
413 Ok(true)
414 }
415
416 /// Selects the first place `query` occurs on the page, ignoring case.
417 fn find(&mut self, query: &str) -> Result<bool> {
418 let lower: Vec<u16> = query.to_lowercase().encode_utf16().collect();
419 if lower.is_empty() {
420 return Ok(false);
421 }
422 let found = self.page.editor.outlines().iter().find_map(|outline| {
423 let text: Vec<u16> = outline.shown_text().to_lowercase().encode_utf16().collect();
424 let at = text
425 .windows(lower.len())
426 .position(|window| window == lower)?;
427 // Lowercasing can change the length; the match is then only found in place.
428 (text.len() == outline.shown_text().encode_utf16().count())
429 .then_some((outline.id, at as u32))
430 });
431 let Some((id, at)) = found else {
432 return Ok(false);
433 };
434 self.page.editor.focus_outline(id)?;
435 self.select([at, at + lower.len() as u32])?;
436 Ok(true)
437 }
438
439 /// The page's text, outline by outline, as the desktop puts a refused edit's on the
440 /// clipboard.
441 fn page_text(&self) -> String {
442 let editor = &self.page.editor;
443 editor
444 .visible_outlines()
445 .chain(editor.caret_outline())
446 .map(|outline| {
447 outline
448 .document()
449 .paragraphs()
450 .map(library::shown)
451 .collect::<Vec<_>>()
452 .join("\n")
453 })
454 .filter(|text| !text.trim().is_empty())
455 .collect::<Vec<_>>()
456 .join("\n\n")
457 }
458
459 /// Puts a picture of `size` points after the caret's paragraph.
460 fn insert_picture(&mut self, bytes: &[u8], size: [f32; 2]) -> Result<bool> {
461 let image = Image {
462 id: onestore::page::text::new_id()?,
463 layout: onestore::document::Layout {
464 max_width: Some(size[0]),
465 max_height: Some(size[1]),
466 ..Default::default()
467 },
468 size: Some(size),
469 bytes: Some(bytes.into()),
470 alt: None,
471 background: false,
472 };
473 let page = &mut self.page;
474 page.editor.insert_picture(&mut page.engine, image)?;
475 Ok(true)
476 }
477
478 /// Gives the page date `seconds` since 1970 (keeping the stored fraction of a second),
479 /// titled `text`: the long date and the short time as the host formats them.
480 fn change_date(&mut self, seconds: i64, text: [String; 2]) -> Result<bool> {
481 let date = self.page.editor.date().ok_or("The page has no date")?;
482 let timestamp = u64::try_from(seconds + 11_644_473_600)?
483 .checked_mul(10_000_000)
484 .and_then(|ticks| ticks.checked_add(date.timestamp() % 10_000_000))
485 .ok_or("The date is out of range")?;
486 Ok(moved(self.page.change_date(timestamp, text)?))
443487 }
444488
445489 /// A rectangle in the active outline's coordinates, in the view's points as `[x, y,
446490 /// width, height]`.
447491 fn view_rect(&self, rect: BoundingBox) -> [f32; 4] {
448492 let viewport = self.page.viewport;
449 let points = self.display_scale();
450493 let origin = self.active().origin();
451494 let [x, y] = [rect.x0 as f32 + origin[0], rect.y0 as f32 + origin[1]];
452495 [
453 (x * viewport.scale + viewport.origin[0]) / points,
454 (y * viewport.scale + viewport.origin[1]) / points,
455 rect.width() as f32 * viewport.scale / points,
456 rect.height() as f32 * viewport.scale / points,
496 (x * viewport.scale + viewport.origin[0]) / self.scale,
497 (y * viewport.scale + viewport.origin[1]) / self.scale,
498 rect.width() as f32 * viewport.scale / self.scale,
499 rect.height() as f32 * viewport.scale / self.scale,
457500 ]
458501 }
459502
......@@ -487,72 +530,173 @@ impl Canvas {
487530 }
488531}
489532
533/// The GPU every page view draws with, and the renderer whose glyph atlas they share.
534struct Gpu {
535 instance: wgpu::Instance,
536 adapter: wgpu::Adapter,
537 renderer: draw::Renderer,
538}
539
540/// Made with the first view's surface, whose adapter every later layer can use.
541static GPU: Mutex<Option<Gpu>> = Mutex::new(None);
542
543fn gpu() -> std::sync::MutexGuard<'static, Option<Gpu>> {
544 GPU.lock().unwrap_or_else(|error| error.into_inner())
545}
546
547/// Text engines of closed views, whose fonts and shaping caches the next view takes over
548/// rather than enumerating the system's fonts again.
549static ENGINES: Mutex<Vec<TextEngine>> = Mutex::new(Vec::new());
550
490551pub struct View {
491 canvas: Canvas,
552 /// Taken apart on drop, which returns its text engine to `ENGINES`.
553 canvas: ManuallyDrop<Canvas>,
492554 surface: wgpu::Surface<'static>,
493555 config: wgpu::SurfaceConfiguration,
494 renderer: draw::Renderer,
495556 frame: Arc<Frame>,
557 section: Arc<Shared>,
558 /// A conflict page, which shows what is stored and takes no edits.
559 read_only: bool,
496560}
497561
498562impl View {
499 fn new(layer: *mut c_void, page: (ExGuid, Page), size: [f32; 2], scale: f32) -> Result<Self> {
500 let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
563 fn new(
564 layer: *mut c_void,
565 section: &Section,
566 space: ExGuid,
567 size: [f32; 2],
568 scale: f32,
569 ) -> Result<Self> {
570 let pixels = size.map(|side| (side * scale).round().max(1.0) as u32);
571 let mut gpu = gpu();
572 let instance = match &*gpu {
573 Some(gpu) => gpu.instance.clone(),
574 None => wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle()),
575 };
501576 // SAFETY: the host passes a live CAMetalLayer that outlives the view.
502577 let surface = unsafe {
503578 instance.create_surface_unsafe(wgpu::SurfaceTargetUnsafe::CoreAnimationLayer(layer))
504579 }?;
505 let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
506 compatible_surface: Some(&surface),
507 ..Default::default()
508 }))?;
509 // The simulator's adapter falls short of wgpu's default limits.
510 let (device, queue) =
511 pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
512 required_limits: adapter.limits(),
513 ..Default::default()
514 }))?;
515 let pixels = size.map(|side| (side * scale).round().max(1.0) as u32);
580 if gpu.is_none() {
581 let adapter =
582 pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
583 compatible_surface: Some(&surface),
584 ..Default::default()
585 }))?;
586 // The simulator's adapter falls short of wgpu's default limits.
587 let (device, queue) =
588 pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
589 required_limits: adapter.limits(),
590 ..Default::default()
591 }))?;
592 let format = surface
593 .get_default_config(&adapter, pixels[0], pixels[1])
594 .ok_or("No supported surface configuration")?
595 .format;
596 *gpu = Some(Gpu {
597 instance,
598 renderer: draw::Renderer::new(device, queue, format),
599 adapter,
600 });
601 }
602 let gpu = gpu.as_ref().ok_or("No GPU")?;
516603 let config = surface
517 .get_default_config(&adapter, pixels[0], pixels[1])
604 .get_default_config(&gpu.adapter, pixels[0], pixels[1])
518605 .ok_or("No supported surface configuration")?;
519 surface.configure(&device, &config);
520 let renderer = draw::Renderer::new(device, queue, config.format);
606 surface.configure(&gpu.renderer.device, &config);
607 let (page, read_only) = section.shared.page(space)?;
521608 Ok(Self {
522 canvas: Canvas::new(page, pixels, scale)?,
609 canvas: ManuallyDrop::new(Canvas::new(space, page, pixels, scale)?),
523610 surface,
524611 config,
525 renderer,
526612 frame: Arc::default(),
613 section: Arc::clone(&section.shared),
614 read_only,
527615 })
528616 }
529617
530618 fn render(&mut self) -> Result<()> {
619 let mut gpu = gpu();
620 let renderer = &mut gpu.as_mut().ok_or("No GPU")?.renderer;
531621 let frame = match self.surface.get_current_texture() {
532622 wgpu::CurrentSurfaceTexture::Success(frame)
533623 | wgpu::CurrentSurfaceTexture::Suboptimal(frame) => frame,
534624 wgpu::CurrentSurfaceTexture::Outdated | wgpu::CurrentSurfaceTexture::Lost => {
535 self.surface.configure(&self.renderer.device, &self.config);
625 self.surface.configure(&renderer.device, &self.config);
536626 return Ok(());
537627 }
538628 _ => return Ok(()),
539629 };
540 let canvas = &mut self.canvas;
630 let canvas: &mut Canvas = &mut self.canvas;
631 let paper = canvas.page_paper();
541632 canvas
542633 .page
543 .update_pictures(canvas.paper, &Waker::from(self.frame.clone()));
634 .update_pictures(paper, &Waker::from(self.frame.clone()));
544635 let primitives = canvas.page.primitives(canvas.colors())?;
545 self.renderer
636 renderer
546637 .draw(
547638 &frame.texture.create_view(&Default::default()),
548639 [self.config.width, self.config.height],
549 canvas.paper.color,
640 paper.color,
550641 &[canvas.page.viewport.layer(&primitives)],
551642 )
552643 .map_err(|error| format!("Page drawing failed: {error:?}"))?;
553 self.renderer.queue.present(frame);
644 renderer.queue.present(frame);
554645 Ok(())
555646 }
647
648 /// Hands the section what the page took, after `result`, the change that took it. A
649 /// conflict page returns to what is stored instead.
650 fn stored(&mut self, result: Result<bool>) -> bool {
651 let changed = report(result).unwrap_or(false);
652 match report(self.canvas.edit()) {
653 Some(Some(_)) if self.read_only => {
654 let _ = report(self.reload());
655 }
656 Some(Some(edit)) => {
657 // Pictures that came or went, by insertion or undo, are drawn or dropped.
658 if edit.ops.iter().any(|op| {
659 matches!(
660 op,
661 Op::Page {
662 op: PageOp::Insert { .. } | PageOp::Add { .. } | PageOp::Delete { .. },
663 ..
664 }
665 )
666 }) {
667 let page = &mut self.canvas.page;
668 if let Some((scene, _)) = &mut page.scene {
669 let refreshed = scene.refresh(&mut page.editor, &mut page.engine);
670 let _ = report(refreshed.map_err(Into::into));
671 }
672 }
673 let _ = report(self.section.apply(edit));
674 }
675 _ => {}
676 }
677 changed
678 }
679
680 /// Shows the page as stored, in place: the caret, selection and drawn pictures stay.
681 /// Waits for marked text to end.
682 fn reload(&mut self) -> Result<bool> {
683 if self.canvas.page.editor.marked_range().is_some() {
684 return Ok(false);
685 }
686 let (page, read_only) = self.section.page(self.canvas.space)?;
687 self.read_only = read_only;
688 Ok(moved(self.canvas.page.refresh(page)?))
689 }
690}
691
692impl Drop for View {
693 fn drop(&mut self) {
694 // SAFETY: `canvas` is not used again.
695 let canvas = unsafe { ManuallyDrop::take(&mut self.canvas) };
696 if let Ok(mut engines) = ENGINES.lock() {
697 engines.push(canvas.page.engine);
698 }
699 }
556700}
557701
558702fn string(text: *const c_char) -> String {
......@@ -566,78 +710,26 @@ fn owned(text: String) -> *mut c_char {
566710 CString::new(text.replace('\0', "")).map_or(std::ptr::null_mut(), CString::into_raw)
567711}
568712
569/// The notebook folder at `path`, or the lone section file there, as JSON: `name`, and
570/// `sections` in the notebook's order, each with `name`, `path`, `group`, `color` as sRGB
571/// bytes and `readable`. Freed with `sb_string_free`; null if it cannot be read.
713/// A view drawing page `id` of `section` into `layer`, a `CAMetalLayer`, `size` points at
714/// `scale` pixels per point; null if it cannot.
572715///
573716/// # Safety
574/// `path` is NUL-terminated UTF-8.
575#[unsafe(no_mangle)]
576pub unsafe extern "C" fn sb_notebook(path: *const c_char) -> *mut c_char {
577 report(
578 listing(Path::new(&string(path))).and_then(|listing| Ok(serde_json::to_string(&listing)?)),
579 )
580 .map_or(std::ptr::null_mut(), owned)
581}
582
583/// # Safety
584/// `path` is NUL-terminated UTF-8.
585#[unsafe(no_mangle)]
586pub unsafe extern "C" fn sb_section_open(path: *const c_char) -> *mut Section {
587 report(
588 std::fs::read(string(path))
589 .map_err(Into::into)
590 .and_then(|bytes| Section::open(&bytes)),
591 )
592 .map_or(std::ptr::null_mut(), |section| {
593 Box::into_raw(Box::new(section))
594 })
595}
596
597#[unsafe(no_mangle)]
598pub extern "C" fn sb_section_count(section: &Section) -> usize {
599 section.pages.len()
600}
601
602/// The page's title, alive as long as the section.
603#[unsafe(no_mangle)]
604pub extern "C" fn sb_section_title(section: &Section, index: usize) -> *const c_char {
605 section.headings[index].0.as_ptr()
606}
607
608/// The page's level in the page list: 1 at the top, 2 for a subpage and so on.
609#[unsafe(no_mangle)]
610pub extern "C" fn sb_section_level(section: &Section, index: usize) -> u32 {
611 section.headings[index].1
612}
613
614/// # Safety
615/// `section` came from `sb_section_open` and is not used again.
616#[unsafe(no_mangle)]
617pub unsafe extern "C" fn sb_section_free(section: *mut Section) {
618 drop(unsafe { Box::from_raw(section) });
619}
620
621/// A view drawing page `index` of `section` into `layer`, a `CAMetalLayer`, `size` points
622/// at `scale` pixels per point; null if it cannot.
623///
624/// # Safety
625/// `layer` is a live `CAMetalLayer` that outlives the view.
717/// `layer` is a live `CAMetalLayer` that outlives the view; `id` is NUL-terminated UTF-8.
626718#[unsafe(no_mangle)]
627719pub unsafe extern "C" fn sb_view_new(
628720 layer: *mut c_void,
629721 section: &Section,
630 index: usize,
722 id: *const c_char,
631723 width: f32,
632724 height: f32,
633725 scale: f32,
634726) -> *mut View {
635 report(View::new(
636 layer,
637 section.pages[index].clone(),
638 [width, height],
639 scale,
640 ))
727 report(
728 string(id)
729 .parse()
730 .map_err(Into::into)
731 .and_then(|space| View::new(layer, section, space, [width, height], scale)),
732 )
641733 .map_or(std::ptr::null_mut(), |view| Box::into_raw(Box::new(view)))
642734}
643735
......@@ -651,14 +743,17 @@ pub unsafe extern "C" fn sb_view_free(view: *mut View) {
651743#[unsafe(no_mangle)]
652744pub extern "C" fn sb_view_resize(view: &mut View, width: f32, height: f32, scale: f32) {
653745 let pixels = [width, height].map(|side| (side * scale).round().max(1.0) as u32);
654 if scale != view.canvas.display_scale() {
655 let _ = report(view.canvas.page.scale_factor_changed(scale));
746 let canvas = &mut view.canvas;
747 if scale != canvas.scale {
748 canvas.scale = scale;
749 let _ = report(canvas.page.scale_factor_changed(scale));
656750 }
657 let page = &mut view.canvas.page;
658 let _ = report(page.resized(pixels));
751 let _ = report(canvas.page.resized(pixels));
659752 view.config.width = pixels[0];
660753 view.config.height = pixels[1];
661 view.surface.configure(&view.renderer.device, &view.config);
754 if let Some(gpu) = &*gpu() {
755 view.surface.configure(&gpu.renderer.device, &view.config);
756 }
662757}
663758
664759#[unsafe(no_mangle)]
......@@ -678,10 +773,27 @@ pub extern "C" fn sb_view_set_dark(view: &mut View, dark: bool) {
678773 view.canvas.set_dark(dark);
679774}
680775
776/// A conflict page: it shows what is stored, and any edit returns it there.
777#[unsafe(no_mangle)]
778pub extern "C" fn sb_view_read_only(view: &View) -> bool {
779 view.read_only
780}
781
782/// Shows the page as stored after a change made elsewhere, keeping the caret; with
783/// `discard`, after the section refused an edit, drops what the page took since.
784#[unsafe(no_mangle)]
785pub extern "C" fn sb_view_reload(view: &mut View, discard: bool) -> bool {
786 if discard {
787 let _ = view.canvas.page.editor.take_ops();
788 }
789 report(view.reload()).unwrap_or(false)
790}
791
681792/// Whether the view has input focus: the focused outline shows its frame and grips only then.
682793#[unsafe(no_mangle)]
683794pub extern "C" fn sb_view_focus(view: &mut View, focused: bool) {
684 let _ = report(view.canvas.hold(|page| page.focus_changed(focused)));
795 let result = view.canvas.page.focus_changed(focused).map(moved);
796 view.stored(result);
685797}
686798
687799/// 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
710822
711823/// What the view point `x`, `y` lands on: 0 for the page, where the canvas takes a tap; 1
712824/// for the text taking input; 2 for another outline's text; 3 for the focused outline's
713/// move or width grip.
825/// move or width grip, or the selected picture and its handles.
714826#[unsafe(no_mangle)]
715827pub extern "C" fn sb_view_target(view: &View, x: f32, y: f32) -> u8 {
716828 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 {
720832/// or starts a new one; with `sb_view_drag` between, a grip moves or widens an outline.
721833#[unsafe(no_mangle)]
722834pub extern "C" fn sb_view_press(view: &mut View, x: f32, y: f32) -> bool {
723 report(view.canvas.press([x, y])).unwrap_or(false)
835 let result = view.canvas.press([x, y]);
836 view.stored(result)
724837}
725838
726839#[unsafe(no_mangle)]
......@@ -730,13 +843,15 @@ pub extern "C" fn sb_view_drag(view: &mut View, x: f32, y: f32) -> bool {
730843
731844#[unsafe(no_mangle)]
732845pub extern "C" fn sb_view_release(view: &mut View) -> bool {
733 report(view.canvas.release()).unwrap_or(false)
846 let result = view.canvas.release();
847 view.stored(result)
734848}
735849
736850/// Undoes the last edit, or with `redo` redoes the last undone one.
737851#[unsafe(no_mangle)]
738852pub extern "C" fn sb_view_undo(view: &mut View, redo: bool) -> bool {
739 report(view.canvas.hold(|page| page.undo(redo).map(moved))).unwrap_or(false)
853 let result = view.canvas.page.undo(redo).map(moved);
854 view.stored(result)
740855}
741856
742857#[unsafe(no_mangle)]
......@@ -749,30 +864,98 @@ pub extern "C" fn sb_view_can_undo(view: &View, redo: bool) -> bool {
749864 }
750865}
751866
752/// Toggles 0 bold, 1 italic or 2 underline on the selection.
867/// The selection's formatting as bits: 0 bold, 1 italic, 2 underline, 3 strikethrough, 4
868/// bulleted, 5 numbered, and from 16 OneNote's default tags in order, To Do first.
869#[unsafe(no_mangle)]
870pub extern "C" fn sb_view_format(view: &View) -> u32 {
871 report(view.canvas.format_bits()).unwrap_or(0)
872}
873
874/// Applies formatting to the selection: 0 bold, 1 italic, 2 underline, 3 strikethrough, 4
875/// bullets, 5 numbering, 6 indent, 7 outdent, each toggling where it can; from 16 toggles
876/// OneNote's default tag `command - 16`, To Do first.
877#[unsafe(no_mangle)]
878pub extern "C" fn sb_view_apply(view: &mut View, command: u8) -> bool {
879 let result = view.canvas.format(command);
880 view.stored(result)
881}
882
883/// The page title's text; null on a page without an editable title.
884#[unsafe(no_mangle)]
885pub extern "C" fn sb_view_title(view: &View) -> *mut c_char {
886 view.canvas
887 .title()
888 .map_or(std::ptr::null_mut(), |title| owned(title.shown_text()))
889}
890
891/// Puts the caret at the end of the page title; false on a page without one.
753892#[unsafe(no_mangle)]
754pub extern "C" fn sb_view_toggle(view: &mut View, toggle: u8) -> bool {
755 let toggle = match toggle {
756 0 => Toggle::Bold,
757 1 => Toggle::Italic,
758 _ => Toggle::Underline,
893pub extern "C" fn sb_view_focus_title(view: &mut View) -> bool {
894 report(view.canvas.focus_title()).unwrap_or(false)
895}
896
897/// Selects the first place `query` occurs on the page, ignoring case.
898///
899/// # Safety
900/// `query` is NUL-terminated UTF-8.
901#[unsafe(no_mangle)]
902pub unsafe extern "C" fn sb_view_find(view: &mut View, query: *const c_char) -> bool {
903 report(view.canvas.find(&string(query))).unwrap_or(false)
904}
905
906/// The page's text, for the clipboard when an edit is refused; freed with `sb_string_free`.
907#[unsafe(no_mangle)]
908pub extern "C" fn sb_view_page_text(view: &View) -> *mut c_char {
909 owned(view.canvas.page_text())
910}
911
912/// Puts the picture file `bytes` (JPEG or PNG), `width` × `height` points, after the
913/// caret's paragraph.
914///
915/// # Safety
916/// `bytes` holds `length` bytes.
917#[unsafe(no_mangle)]
918pub unsafe extern "C" fn sb_view_insert_picture(
919 view: &mut View,
920 bytes: *const u8,
921 length: usize,
922 width: f32,
923 height: f32,
924) -> bool {
925 // SAFETY: the caller's contract.
926 let bytes = unsafe { std::slice::from_raw_parts(bytes, length) };
927 let result = view.canvas.insert_picture(bytes, [width, height]);
928 view.stored(result)
929}
930
931/// The page date field the last tap asked to change, taken: 0 the date, 1 the time, or -1
932/// for none; `seconds` gets the page's date in seconds since 1970.
933#[unsafe(no_mangle)]
934pub extern "C" fn sb_view_date_request(view: &mut View, seconds: &mut i64) -> i8 {
935 let Some(field) = view.canvas.asked.take() else {
936 return -1;
759937 };
760 report(
761 view.canvas
762 .hold(|page| page.format(Formatting::Toggle(toggle)).map(moved)),
763 )
764 .unwrap_or(false)
938 let Some(date) = view.canvas.page.editor.date() else {
939 return -1;
940 };
941 *seconds = (date.timestamp() / 10_000_000) as i64 - 11_644_473_600;
942 field as i8
765943}
766944
767/// The edit the page took since the last call, as `onestore::op::Edit` JSON for the
768/// section to apply, freed with `sb_string_free`; null when it took none or cannot be
769/// stored, in which case the page should be opened again.
945/// Gives the page date `seconds` since 1970, shown as `date` and `time`: the long date and
946/// the short time, as OneNote titles a page.
947///
948/// # Safety
949/// `date` and `time` are NUL-terminated UTF-8.
770950#[unsafe(no_mangle)]
771pub extern "C" fn sb_view_edit(view: &mut View) -> *mut c_char {
772 report(view.canvas.edit())
773 .flatten()
774 .and_then(|edit| serde_json::to_string(&edit).ok())
775 .map_or(std::ptr::null_mut(), owned)
951pub unsafe extern "C" fn sb_view_change_date(
952 view: &mut View,
953 seconds: i64,
954 date: *const c_char,
955 time: *const c_char,
956) -> bool {
957 let result = view.canvas.change_date(seconds, [string(date), string(time)]);
958 view.stored(result)
776959}
777960
778961/// # Safety
......@@ -822,25 +1005,26 @@ pub unsafe extern "C" fn sb_set_marked(
8221005 selected_start: u32,
8231006 selected_end: u32,
8241007) -> bool {
825 report(
826 view.canvas
827 .set_marked(string(text), [selected_start, selected_end]),
828 )
829 .unwrap_or(false)
1008 let result = view
1009 .canvas
1010 .set_marked(string(text), [selected_start, selected_end]);
1011 view.stored(result)
8301012}
8311013
8321014#[unsafe(no_mangle)]
8331015pub extern "C" fn sb_unmark(view: &mut View) {
8341016 view.canvas.page.editor.finish_composition();
1017 view.stored(Ok(false));
8351018}
8361019
837/// Types `text`, a lone `\n` as the Return key.
1020/// Types `text`, a lone `\n` as the Return key and a lone `\t` as the Tab key.
8381021///
8391022/// # Safety
8401023/// `text` is NUL-terminated UTF-8.
8411024#[unsafe(no_mangle)]
8421025pub unsafe extern "C" fn sb_insert(view: &mut View, text: *const c_char) -> bool {
843 report(view.canvas.insert(string(text))).unwrap_or(false)
1026 let result = view.canvas.insert(string(text));
1027 view.stored(result)
8441028}
8451029
8461030/// Replaces `start..end` with `text`, as autocorrection, dictation and cutting do.
......@@ -855,12 +1039,10 @@ pub unsafe extern "C" fn sb_replace(
8551039 text: *const c_char,
8561040) -> bool {
8571041 let canvas = &mut view.canvas;
858 report(
859 canvas
860 .select([start, end])
861 .and_then(|()| canvas.hold(|page| Ok(moved(page.commit_text(string(text))?)))),
862 )
863 .unwrap_or(false)
1042 let result = canvas
1043 .select([start, end])
1044 .and_then(|()| Ok(moved(canvas.page.commit_text(string(text))?)));
1045 view.stored(result)
8641046}
8651047
8661048/// Pastes clipboard `text` in `language`, the keyboard's BCP-47 tag.
......@@ -874,20 +1056,18 @@ pub unsafe extern "C" fn sb_paste(
8741056 language: *const c_char,
8751057) -> bool {
8761058 let (text, language) = (string(text), canvas::language::lcid(&string(language)));
877 report(
878 view.canvas
879 .hold(|page| page.paste(&text, language).map(moved)),
880 )
881 .unwrap_or(false)
1059 let result = view.canvas.page.paste(&text, language).map(moved);
1060 view.stored(result)
8821061}
8831062
8841063#[unsafe(no_mangle)]
8851064pub extern "C" fn sb_delete_backward(view: &mut View) -> bool {
886 report(
887 view.canvas
888 .hold(|page| page.key(&Key::Named(NamedKey::Backspace), None).map(moved)),
889 )
890 .unwrap_or(false)
1065 let result = view
1066 .canvas
1067 .page
1068 .key(&Key::Named(NamedKey::Backspace), None)
1069 .map(moved);
1070 view.stored(result)
8911071}
8921072
8931073/// The caret at `offset` as `[x, y, width, height]` in the view's points.
crates/mobile/src/library.rs created+1204
......@@ -0,0 +1,1204 @@
1//! Notebooks behind the C surface: a folder or lone section from Files, or a folder on an
2//! SMB share through the embedded client. A section opens through its replica in the app's
3//! cache, which stores each edit at once and publishes it in the background.
4
5use crate::{Result, owned, report, string};
6use notebook::{
7 Remote, Replica,
8 discover::{Folder, SectionState},
9 session::{self, Event, Notebook},
10 smb::{Client, Credentials},
11};
12use onestore::{
13 CommitError, CommitState, ExGuid, PageCreation, PageEdit, RevisionIndex, Stamp, Store,
14 Transaction,
15 document::Document,
16 op::{Edit, Op, SectionOp},
17 page::{Page, PageObject, PageParagraph, ParagraphContent},
18};
19use std::{
20 collections::{BTreeMap, HashMap},
21 ffi::{CString, c_char, c_void},
22 io,
23 path::{Path, PathBuf},
24 sync::{
25 Arc, Mutex, OnceLock,
26 atomic::{AtomicU8, Ordering},
27 },
28 time::{Duration, Instant},
29};
30
31/// How long an SMB request may take before the share counts as unreachable.
32const TIMEOUT: Duration = Duration::from_secs(10);
33/// The largest section file read whole.
34const LIMIT: usize = 256 * 1024 * 1024;
35/// The tab colour OneNote gives a section that stores none, as the desktop shows it.
36const SECTION_COLOR: u32 = 0x00e4_a88a;
37
38/// Runs `body(context)` within the host's coordinated reading or writing of `path`, as
39/// `NSFileCoordinator` has other processes (file providers) wait for it, or not at all when
40/// it cannot coordinate.
41pub type Coordinator = extern "C" fn(
42 path: *const c_char,
43 write: bool,
44 body: extern "C" fn(*mut c_void),
45 context: *mut c_void,
46);
47
48static COORDINATOR: OnceLock<Coordinator> = OnceLock::new();
49
50/// Coordinates every read and write of a local notebook's files through `coordinator` from
51/// now on; set once, before any library opens.
52#[unsafe(no_mangle)]
53pub extern "C" fn sb_set_coordinator(coordinator: Coordinator) {
54 let _ = COORDINATOR.set(coordinator);
55}
56
57/// `work` within the host's coordinated access to `path`, or directly when the host
58/// coordinates nothing.
59pub(crate) fn coordinated<F: FnOnce() -> T, T>(path: &Path, write: bool, work: F) -> io::Result<T> {
60 let Some(coordinator) = COORDINATOR.get() else {
61 return Ok(work());
62 };
63 extern "C" fn run<F: FnOnce() -> T, T>(context: *mut c_void) {
64 // SAFETY: `context` is the pair below, alive for the host's call.
65 let (work, result) = unsafe { &mut *context.cast::<(Option<F>, Option<T>)>() };
66 *result = work.take().map(|work| work());
67 }
68 let mut pair = (Some(work), None);
69 let path = CString::new(path.as_os_str().as_encoded_bytes())?;
70 coordinator(path.as_ptr(), write, run::<F, T>, (&raw mut pair).cast());
71 pair.1
72 .ok_or_else(|| io::Error::other("Another app is using the notebook's file; try again."))
73}
74
75/// A local section file read and published under the host's file coordination.
76struct Coordinated(PathBuf);
77
78impl Remote for Coordinated {
79 fn read(&mut self) -> io::Result<Vec<u8>> {
80 coordinated(&self.0, false, || onestore::read_file(&self.0))?
81 }
82
83 /// The header and length, as the file remote's uncoordinated change detector reads them.
84 fn stamp(&mut self) -> io::Result<Stamp> {
85 coordinated(&self.0, false, || {
86 use std::io::Read;
87 let mut file = std::fs::File::open(&self.0)?;
88 let mut header = [0; 1024];
89 file.read_exact(&mut header)?;
90 Ok(Stamp {
91 header,
92 length: file.metadata()?.len(),
93 })
94 })?
95 }
96
97 fn publish(&mut self, transaction: &Transaction) -> std::result::Result<(), CommitError> {
98 coordinated(&self.0, true, || transaction.commit_file(&self.0)).map_err(uncommitted)?
99 }
100
101 fn confirm(&mut self, base: &Stamp) -> std::result::Result<(), CommitError> {
102 coordinated(&self.0, true, || onestore::confirm_file(&self.0, base)).map_err(uncommitted)?
103 }
104}
105
106fn uncommitted(error: io::Error) -> CommitError {
107 CommitError {
108 state: CommitState::NotCommitted,
109 error,
110 }
111}
112
113/// A server and share with the account that opens it, kept to reconnect.
114pub(crate) struct Server {
115 pub(crate) address: String,
116 pub(crate) share: String,
117 pub(crate) user: String,
118 pub(crate) password: String,
119 pub(crate) domain: String,
120}
121
122impl Server {
123 pub(crate) fn connect(&self) -> io::Result<Client> {
124 Client::connect(
125 &self.address,
126 &self.share,
127 Credentials {
128 username: &self.user,
129 password: &self.password,
130 domain: &self.domain,
131 },
132 TIMEOUT,
133 )
134 }
135}
136
137/// A connected share, for browsing to a notebook folder.
138pub struct Share(Client);
139
140enum Place {
141 Folder(PathBuf),
142 /// A section file opened on its own.
143 File(PathBuf),
144 /// A notebook folder at `root` on a share; no client while the server cannot be reached.
145 Share {
146 server: Arc<Server>,
147 client: Mutex<Option<Arc<Client>>>,
148 root: String,
149 },
150}
151
152/// A share notebook's sections as last listed, so they open from their replicas while the
153/// server cannot be reached.
154#[derive(serde::Serialize, serde::Deserialize)]
155struct Listed {
156 tabs: Vec<Tab>,
157 /// Each section's file identity in hex, which names its replica, by catalog path.
158 files: BTreeMap<String, String>,
159}
160
161fn hex(bytes: &[u8]) -> String {
162 bytes.iter().map(|byte| format!("{byte:02x}")).collect()
163}
164
165/// Every section's file identity in `folder` and its groups, by catalog path.
166fn files(folder: &Folder, files: &mut BTreeMap<String, String>) {
167 for section in &folder.sections {
168 files.insert(section.path.clone(), hex(&section.file_id));
169 }
170 for group in &folder.groups {
171 self::files(group, files);
172 }
173}
174
175/// Page texts of a section as last read, and when its file was last checked.
176struct Indexed {
177 checked: Instant,
178 stamp: Vec<u8>,
179 pages: Vec<(ExGuid, String, String)>,
180}
181
182pub struct Library {
183 /// `None` for a lone section.
184 notebook: Mutex<Option<Notebook>>,
185 place: Place,
186 cache: PathBuf,
187 index: Mutex<HashMap<String, Indexed>>,
188}
189
190#[derive(serde::Serialize, serde::Deserialize)]
191pub(crate) struct Tab {
192 pub(crate) name: String,
193 /// The catalog path `sb_section_open` takes.
194 pub(crate) path: String,
195 /// The section group holding it, `/`-separated; empty at the notebook's top.
196 pub(crate) group: String,
197 /// The tab colour in sRGB.
198 pub(crate) color: [u8; 3],
199 /// Password-protected or unreadable sections list but do not open.
200 pub(crate) readable: bool,
201}
202
203fn rgb(colorref: u32) -> [u8; 3] {
204 let [red, green, blue, _] = colorref.to_le_bytes();
205 [red, green, blue]
206}
207
208fn stem(path: &str) -> String {
209 Path::new(path)
210 .file_stem()
211 .map(|stem| stem.to_string_lossy().into_owned())
212 .unwrap_or_default()
213}
214
215/// A folder's sections, its groups' after them, leaving out the recycle bin OneNote keeps
216/// deleted sections and pages in.
217fn tabs(folder: &Folder, tabs: &mut Vec<Tab>) {
218 for section in &folder.sections {
219 let (name, color, readable) = match &section.state {
220 SectionState::Readable { name, color } => (name.clone(), *color, true),
221 SectionState::Locked | SectionState::Unreadable(_) => (None, None, false),
222 };
223 tabs.push(Tab {
224 name: name.unwrap_or_else(|| stem(&section.path)),
225 path: section.path.clone(),
226 group: folder.path.clone(),
227 color: rgb(color.unwrap_or(SECTION_COLOR)),
228 readable,
229 });
230 }
231 for group in &folder.groups {
232 if !group.path.ends_with("OneNote_RecycleBin") {
233 self::tabs(group, tabs);
234 }
235 }
236}
237
238impl Library {
239 pub(crate) fn open(path: &Path, cache: &Path) -> Result<Self> {
240 let (notebook, place) = if path.is_file() {
241 (None, Place::File(path.to_owned()))
242 } else {
243 (
244 Some(Notebook::open(path, cache)?),
245 Place::Folder(path.to_owned()),
246 )
247 };
248 Ok(Self {
249 notebook: Mutex::new(notebook),
250 place,
251 cache: cache.to_owned(),
252 index: Mutex::default(),
253 })
254 }
255
256 /// The notebook folder `root` on `server`; while the server cannot be reached, its sections
257 /// as last listed, if it was listed before.
258 pub(crate) fn server(server: Server, root: &str, cache: &Path) -> Result<Self> {
259 let library = Self {
260 notebook: Mutex::default(),
261 place: Place::Share {
262 server: Arc::new(server),
263 client: Mutex::default(),
264 root: root.to_owned(),
265 },
266 cache: cache.to_owned(),
267 index: Mutex::default(),
268 };
269 if let Err(error) = library.with_notebook(false, |_| Ok(()))
270 && library.listed().is_none()
271 {
272 return Err(error);
273 }
274 Ok(library)
275 }
276
277 /// Where a share notebook's last listing is kept.
278 fn listing(&self) -> Option<PathBuf> {
279 let Place::Share { server, root, .. } = &self.place else {
280 return None;
281 };
282 let name: String = format!("{}/{}/{root}", server.address, server.share)
283 .chars()
284 .map(|char| if char.is_alphanumeric() { char } else { '_' })
285 .collect();
286 Some(self.cache.join("smb").join(format!("{name}.json")))
287 }
288
289 fn listed(&self) -> Option<Listed> {
290 let bytes = std::fs::read(self.listing()?).ok()?;
291 serde_json::from_slice(&bytes).ok()
292 }
293
294 fn notebook(&self) -> std::sync::MutexGuard<'_, Option<Notebook>> {
295 self.notebook
296 .lock()
297 .unwrap_or_else(|error| error.into_inner())
298 }
299
300 fn client(&self) -> Option<Arc<Client>> {
301 match &self.place {
302 Place::Share { client, .. } => client
303 .lock()
304 .unwrap_or_else(|error| error.into_inner())
305 .clone(),
306 _ => None,
307 }
308 }
309
310 /// Runs `change`, which reads or with `write` changes the notebook's files, on the
311 /// notebook: a local folder under the host's file coordination; on a share not reached
312 /// yet or whose connection dropped, connecting again and retrying once.
313 fn with_notebook<T>(
314 &self,
315 write: bool,
316 mut change: impl FnMut(&mut Notebook) -> Result<T>,
317 ) -> Result<T> {
318 let mut notebook = self.notebook();
319 let Place::Share {
320 server,
321 client,
322 root,
323 } = &self.place
324 else {
325 let notebook = notebook.as_mut().ok_or("A lone section has no notebook")?;
326 return match &self.place {
327 Place::Folder(root) => coordinated(root, write, || change(notebook))?,
328 _ => change(notebook),
329 };
330 };
331 if let Some(current) = notebook.as_mut()
332 && let Ok(result) = change(current)
333 {
334 return Ok(result);
335 }
336 let fresh = Arc::new(server.connect()?);
337 *client.lock().unwrap_or_else(|error| error.into_inner()) = Some(Arc::clone(&fresh));
338 let reopened = notebook.insert(Notebook::open_smb(fresh, root, &self.cache)?);
339 change(reopened)
340 }
341
342 /// The notebook's sections, read again.
343 pub(crate) fn tabs(&self) -> Result<Vec<Tab>> {
344 if let Place::File(file) = &self.place {
345 let path = file.to_string_lossy().into_owned();
346 return Ok(vec![Tab {
347 name: stem(&path),
348 path,
349 group: String::new(),
350 color: rgb(SECTION_COLOR),
351 readable: true,
352 }]);
353 }
354 let listed = self.with_notebook(false, |notebook| {
355 notebook.refresh()?;
356 let mut listed = Listed {
357 tabs: Vec::new(),
358 files: BTreeMap::new(),
359 };
360 tabs(notebook.catalog(), &mut listed.tabs);
361 files(notebook.catalog(), &mut listed.files);
362 Ok(listed)
363 });
364 let Some(listing) = self.listing() else {
365 return Ok(listed?.tabs);
366 };
367 match listed {
368 Ok(listed) => {
369 std::fs::create_dir_all(listing.parent().ok_or("No cache")?)?;
370 std::fs::write(&listing, serde_json::to_vec(&listed)?)?;
371 Ok(listed.tabs)
372 }
373 Err(error) => Ok(self.listed().ok_or(error)?.tabs),
374 }
375 }
376
377 /// The section at catalog `path`, through a replica in the cache.
378 pub(crate) fn section(
379 &self,
380 path: &str,
381 notify: impl Fn() + Send + 'static,
382 ) -> Result<session::Section> {
383 match &self.place {
384 Place::File(file) => Ok(session::Section::open_with(
385 file,
386 &self.cache,
387 |file| Ok(Coordinated(file.to_owned())),
388 notify,
389 )?),
390 Place::Folder(_) => Ok(self
391 .notebook()
392 .as_ref()
393 .ok_or("A lone section has no notebook")?
394 .section_with(path, |file| Ok(Coordinated(file.to_owned())), notify)?),
395 Place::Share { server, root, .. } => {
396 let file = if root.is_empty() {
397 path.to_owned()
398 } else {
399 format!("{root}/{path}")
400 };
401 let identity = self
402 .listed()
403 .and_then(|mut listed| listed.files.remove(path))
404 .ok_or("The notebook hasn’t listed this section")?;
405 let cache = self.cache.join("smb").join(format!("{identity}.sqlite"));
406 let replica = if cache.exists() {
407 Replica::open(&cache)?
408 } else {
409 let client = self
410 .client()
411 .ok_or("The server can’t be reached, and this section hasn’t been opened here before.")?;
412 Replica::create(&cache, &client.read_storage(&file, LIMIT)?)?
413 };
414 let server = Arc::clone(server);
415 Ok(session::Section::resume_smb(
416 file,
417 replica,
418 LIMIT,
419 move || server.connect(),
420 notify,
421 )?)
422 }
423 }
424 }
425}
426
427/// What saving has come to, as the page shows it.
428#[repr(u8)]
429#[derive(Clone, Copy)]
430pub(crate) enum Status {
431 Saved = 1,
432 Saving = 2,
433 /// The file cannot be reached; edits wait in the cache.
434 Offline = 3,
435 /// An edit was refused or the replica failed.
436 NotSaving = 4,
437}
438
439/// An open section as its page views share it.
440pub(crate) struct Shared {
441 pub section: session::Section,
442 /// Who the edits name, as OneNote names the Office user.
443 pub author: String,
444 pub status: AtomicU8,
445}
446
447impl Shared {
448 pub fn apply(&self, edit: Edit) -> Result<()> {
449 self.section.apply(&self.author, edit)?;
450 self.status.store(Status::Saving as u8, Ordering::Relaxed);
451 Ok(())
452 }
453
454 /// The page in `space` as a view shows it: a conflict page highlighted and read-only.
455 pub fn page(&self, space: ExGuid) -> Result<(Page, bool)> {
456 let page = self.section.page(space)?;
457 let conflict = self
458 .section
459 .conflicts()?
460 .into_iter()
461 .flat_map(|(_, versions)| versions)
462 .find(|version| version.space == space);
463 Ok(match conflict {
464 Some(version) => (canvas::conflict::highlighted(page, &version.objects), true),
465 None => (page, false),
466 })
467 }
468}
469
470/// A section open for editing, with the notebook it belongs to.
471pub struct Section {
472 pub(crate) shared: Arc<Shared>,
473 pub(crate) library: Arc<Library>,
474}
475
476#[derive(serde::Serialize)]
477pub(crate) struct Row {
478 pub(crate) id: String,
479 pub(crate) title: String,
480 /// 1 at the top, 2 for a subpage and so on.
481 pub(crate) level: u32,
482 /// Conflict pages: versions with changes a merge could not take.
483 pub(crate) versions: Vec<Version>,
484}
485
486#[derive(serde::Serialize)]
487pub(crate) struct Version {
488 pub(crate) id: String,
489 /// Whose version it is.
490 pub(crate) user: String,
491 /// When the merge made it, in seconds since 1970.
492 pub(crate) created: Option<i64>,
493}
494
495fn unix(filetime: u64) -> i64 {
496 (filetime / 10_000_000) as i64 - 11_644_473_600
497}
498
499/// FILETIME now: when an edit happened, which its modification times record.
500pub(crate) fn filetime() -> u64 {
501 let unix = std::time::SystemTime::now()
502 .duration_since(std::time::UNIX_EPOCH)
503 .unwrap_or_default();
504 (unix.as_secs() + 11_644_473_600) * 10_000_000 + u64::from(unix.subsec_nanos() / 100)
505}
506
507/// A page OneNote 2010 would create now, titled `date` and `time` as the host formats
508/// them: the long date and the short time.
509fn dated(author: &str, date: &str, time: &str) -> Result<PageCreation> {
510 Ok(PageCreation::new(None, Some(""), author)?.dated(date, time)?)
511}
512
513impl Section {
514 pub(crate) fn rows(&self) -> Result<Vec<Row>> {
515 let section = &self.shared.section;
516 let conflicts = section.conflicts()?;
517 Ok(section
518 .pages()?
519 .into_iter()
520 .map(|(space, title, level)| Row {
521 id: space.to_string(),
522 // Titles keep OneNote's line breaks, which a one-line list shows as spaces.
523 title: title.replace(|char: char| char.is_control(), " "),
524 level,
525 versions: conflicts
526 .iter()
527 .find(|(page, _)| *page == space)
528 .map(|(_, versions)| {
529 versions
530 .iter()
531 .map(|version| Version {
532 id: version.space.to_string(),
533 user: version.user.clone(),
534 created: version.created.map(unix),
535 })
536 .collect()
537 })
538 .unwrap_or_default(),
539 })
540 .collect())
541 }
542
543 /// Adds a page at the end, or a subpage after `parent` and its subpages, as OneNote's
544 /// New Page and New Subpage do; returns its space.
545 pub(crate) fn new_page(
546 &self,
547 parent: Option<ExGuid>,
548 date: &str,
549 time: &str,
550 ) -> Result<ExGuid> {
551 let creation = dated(&self.shared.author, date, time)?;
552 let space = creation.space();
553 let mut ops = vec![Op::Section(SectionOp::Create(creation))];
554 if let Some(parent) = parent {
555 let pages = self.shared.section.pages()?;
556 let at = pages
557 .iter()
558 .position(|(space, ..)| *space == parent)
559 .ok_or("The page is not listed")?;
560 let level = pages[at].2;
561 let next = pages[at + 1..]
562 .iter()
563 .find(|(.., below)| *below <= level)
564 .map(|(space, ..)| *space);
565 ops.push(Op::Section(SectionOp::Pages(vec![PageEdit::move_to(
566 space,
567 next,
568 (level + 1).min(3),
569 )?])));
570 }
571 self.shared.apply(Edit {
572 at: filetime(),
573 ops,
574 })?;
575 Ok(space)
576 }
577
578 /// Deletes a page as OneNote 2010 does, keeping a copy in the notebook's recycle bin;
579 /// a conflict page goes for good. A section left empty gets a new page.
580 pub(crate) fn delete_page(&self, space: ExGuid, date: &str, time: &str) -> Result<()> {
581 let section = &self.shared.section;
582 let listed = section.pages()?;
583 let mut ops = vec![Op::Section(SectionOp::Delete(vec![space]))];
584 if listed.iter().any(|(page, ..)| *page == space) {
585 if self.library.notebook().is_some() {
586 let page = section.page(space)?;
587 let author = &self.shared.author;
588 self.library.with_notebook(true, |notebook| {
589 Ok(notebook.recycle_pages(std::slice::from_ref(&page), author)?)
590 })?;
591 }
592 if listed.len() == 1 {
593 ops.push(Op::Section(SectionOp::Create(dated(
594 &self.shared.author,
595 date,
596 time,
597 )?)));
598 }
599 }
600 self.shared.apply(Edit {
601 at: filetime(),
602 ops,
603 })
604 }
605
606 /// Applies the events since the last poll to the shared status.
607 pub(crate) fn poll(&self) -> u32 {
608 let mut flags = 0;
609 for event in self.shared.section.events() {
610 let status = match event {
611 Event::Changed(_) => {
612 flags |= LISTED | CHANGED;
613 continue;
614 }
615 Event::Rejected { error, .. } => {
616 eprintln!("snowbound: saving failed: {error}");
617 flags |= REJECTED;
618 Status::NotSaving
619 }
620 Event::Attempt {
621 status: notebook::EditStatus::Published { .. },
622 ..
623 } => Status::Saved,
624 Event::Attempt { .. } => Status::Saving,
625 Event::Unreachable(error) => {
626 eprintln!("snowbound: the section cannot be reached: {error}");
627 Status::Offline
628 }
629 Event::Failed(error) => {
630 eprintln!("snowbound: synchronization stopped: {error}");
631 Status::NotSaving
632 }
633 };
634 self.shared.status.store(status as u8, Ordering::Relaxed);
635 }
636 flags
637 }
638
639 /// Waits until every edit is in the cache, then publishes them within `limit`: true when
640 /// nothing waits to be published.
641 pub(crate) fn flush(&self, limit: Duration) -> bool {
642 let section = &self.shared.section;
643 // The section thread answers in order, so an answer means earlier edits are stored.
644 if report(section.pages().map_err(Into::into)).is_none() {
645 return false;
646 }
647 let deadline = Instant::now() + limit;
648 loop {
649 match section.pending() {
650 Ok(pending) if pending.is_empty() => return true,
651 Ok(_) if Instant::now() < deadline => {
652 section.wake();
653 std::thread::sleep(Duration::from_millis(50));
654 }
655 _ => return false,
656 }
657 }
658 }
659}
660
661/// `sb_section_poll`: the page list or conflict pages changed.
662const LISTED: u32 = 1;
663/// A change made elsewhere reached the section; open pages reload.
664const CHANGED: u32 = 2;
665/// The section refused an edit; the page shown returns to what is stored.
666const REJECTED: u32 = 4;
667
668/// The text a paragraph shows, without hidden field codes.
669pub(crate) fn shown(paragraph: &onestore::page::text::Paragraph) -> String {
670 let mut start = 0;
671 paragraph
672 .spans()
673 .iter()
674 .filter_map(|span| {
675 let run = &paragraph.text()[start..span.end];
676 start = span.end;
677 (span.format.hidden != Some(true)).then_some(run)
678 })
679 .collect()
680}
681
682/// A page's text, paragraph by paragraph, the title first.
683pub(crate) fn page_text(page: &Page) -> String {
684 fn walk(paragraphs: &[PageParagraph], out: &mut Vec<String>) {
685 for paragraph in paragraphs {
686 match &paragraph.content {
687 ParagraphContent::Text(text) => out.push(shown(&text.text)),
688 ParagraphContent::Table(table) => {
689 for cell in table.rows.iter().flat_map(|row| &row.cells) {
690 walk(&cell.paragraphs, out);
691 }
692 }
693 _ => {}
694 }
695 }
696 }
697 let mut out = Vec::new();
698 for object in &page.objects {
699 match object {
700 PageObject::Title(title) => {
701 for outline in &title.outlines {
702 walk(&outline.paragraphs, &mut out);
703 }
704 }
705 PageObject::Outline(outline) => walk(&outline.paragraphs, &mut out),
706 _ => {}
707 }
708 }
709 out.join("\n")
710}
711
712/// Each page of a section file with its title and text.
713fn section_texts(bytes: &[u8]) -> Result<Vec<(ExGuid, String, String)>> {
714 let store = Store::parse(bytes)?;
715 let index = RevisionIndex::parse(&store)?;
716 let document = Document::parse(&index)?;
717 let mut pages = Vec::new();
718 for (space, id) in document.pages()? {
719 let revision = document.active(space)?;
720 let (title, _) = Page::heading(revision, id);
721 let page = Page::from_revision(revision, id)?;
722 pages.push((space, title, page_text(&page)));
723 }
724 Ok(pages)
725}
726
727#[derive(serde::Serialize)]
728pub(crate) struct Found {
729 /// The section's catalog path.
730 pub(crate) section: String,
731 pub(crate) page: String,
732 pub(crate) title: String,
733 /// The line around the first match.
734 pub(crate) snippet: String,
735}
736
737/// Up to 60 characters either side of the first place `lower` occurs in `text`, on its line.
738fn snippet(text: &str, lower: &str) -> Option<String> {
739 let line = text
740 .lines()
741 .find(|line| line.to_lowercase().contains(lower))?;
742 let chars: Vec<char> = line.chars().collect();
743 let folded: Vec<char> = line.to_lowercase().chars().collect();
744 let needle: Vec<char> = lower.chars().collect();
745 // Lowercasing can change a line's length; then the match is shown from the line start.
746 let at = (folded.len() == chars.len())
747 .then(|| {
748 folded
749 .windows(needle.len())
750 .position(|window| window == needle)
751 })
752 .flatten()
753 .unwrap_or(0);
754 let start = at.saturating_sub(60);
755 let end = (at + needle.len() + 60).min(chars.len());
756 let mut text: String = chars[start..end].iter().collect();
757 if start > 0 {
758 text.insert(0, '…');
759 }
760 if end < chars.len() {
761 text.push('…');
762 }
763 Some(text)
764}
765
766impl Library {
767 /// Pages of the notebook whose title or text holds `query`, ignoring case: the open
768 /// section as its edits leave it, others as stored, reread when their file changed.
769 pub(crate) fn search(&self, open: Option<(&str, &Section)>, query: &str) -> Result<Vec<Found>> {
770 let lower = query.to_lowercase();
771 let mut found = Vec::new();
772 for tab in self.tabs()?.into_iter().filter(|tab| tab.readable) {
773 let pages = match open.filter(|(path, _)| *path == tab.path) {
774 Some((_, section)) => {
775 let section = &section.shared.section;
776 section
777 .pages()?
778 .into_iter()
779 .map(|(space, title, _)| {
780 Ok((space, title, page_text(&section.page(space)?)))
781 })
782 .collect::<Result<Vec<_>>>()?
783 }
784 None => match report(self.indexed(&tab.path)) {
785 Some(pages) => pages,
786 None => continue,
787 },
788 };
789 for (space, title, text) in pages {
790 let snippet = snippet(&text, &lower);
791 if title.to_lowercase().contains(&lower) || snippet.is_some() {
792 found.push(Found {
793 section: tab.path.clone(),
794 page: space.to_string(),
795 title: title.replace(|char: char| char.is_control(), " "),
796 snippet: snippet.unwrap_or_default(),
797 });
798 }
799 }
800 }
801 Ok(found)
802 }
803
804 /// The pages of the section at catalog `path` with their texts, read again only when
805 /// the file changed, and checked at most every 30 seconds.
806 fn indexed(&self, path: &str) -> Result<Vec<(ExGuid, String, String)>> {
807 let mut index = self.index.lock().unwrap_or_else(|error| error.into_inner());
808 if let Some(indexed) = index.get(path)
809 && indexed.checked.elapsed() < Duration::from_secs(30)
810 {
811 return Ok(indexed.pages.clone());
812 }
813 let (stamp, read): (Vec<u8>, Reader) = match &self.place {
814 Place::Share { root, .. } => {
815 let client = self.client().ok_or("Not a share")?;
816 let file = if root.is_empty() {
817 path.to_owned()
818 } else {
819 format!("{root}/{path}")
820 };
821 let stamp = client.stamp(&file)?;
822 let mut key = stamp.header.to_vec();
823 key.extend(stamp.length.to_le_bytes());
824 (
825 key,
826 Box::new(move || Ok(client.read_storage(&file, LIMIT)?)),
827 )
828 }
829 Place::Folder(root) => local(root.join(path))?,
830 Place::File(file) => local(file.clone())?,
831 };
832 let pages = match index.remove(path) {
833 Some(indexed) if indexed.stamp == stamp => indexed.pages,
834 _ => section_texts(&read()?)?,
835 };
836 index.insert(
837 path.to_owned(),
838 Indexed {
839 checked: Instant::now(),
840 stamp,
841 pages: pages.clone(),
842 },
843 );
844 Ok(pages)
845 }
846}
847
848type Reader = Box<dyn Fn() -> Result<Vec<u8>>>;
849
850/// A local file's change key, its length and modification time, with its reader.
851fn local(file: PathBuf) -> Result<(Vec<u8>, Reader)> {
852 let metadata = std::fs::metadata(&file)?;
853 let modified = metadata
854 .modified()?
855 .duration_since(std::time::UNIX_EPOCH)
856 .unwrap_or_default();
857 let mut key = metadata.len().to_le_bytes().to_vec();
858 key.extend(modified.as_nanos().to_le_bytes());
859 Ok((
860 key,
861 Box::new(move || Ok(coordinated(&file, false, || onestore::read_file(&file))??)),
862 ))
863}
864
865/// Writes `error` for the host to show, freed with `sb_string_free`.
866fn failed(error: Box<dyn std::error::Error>, out: *mut *mut c_char) {
867 eprintln!("snowbound: {error}");
868 if !out.is_null() {
869 // SAFETY: the caller passes null or a place for one string.
870 unsafe { out.write(owned(error.to_string())) };
871 }
872}
873
874fn boxed<T>(result: Result<T>, error: *mut *mut c_char) -> *mut T {
875 match result {
876 Ok(value) => Box::into_raw(Box::new(value)),
877 Err(cause) => {
878 failed(cause, error);
879 std::ptr::null_mut()
880 }
881 }
882}
883
884fn json(value: Result<impl serde::Serialize>) -> *mut c_char {
885 report(value.and_then(|value| Ok(serde_json::to_string(&value)?)))
886 .map_or(std::ptr::null_mut(), owned)
887}
888
889fn optional(text: *const c_char) -> Option<String> {
890 (!text.is_null()).then(|| string(text))
891}
892
893fn space(text: *const c_char) -> Result<ExGuid> {
894 Ok(string(text).parse()?)
895}
896
897/// The notebook folder or lone section file at `path`, its section replicas kept in the
898/// directory `cache`; null with `error` set if it cannot be read.
899///
900/// # Safety
901/// `path` and `cache` are NUL-terminated UTF-8; `error` is null or a place for a string.
902#[unsafe(no_mangle)]
903pub unsafe extern "C" fn sb_library_open(
904 path: *const c_char,
905 cache: *const c_char,
906 error: *mut *mut c_char,
907) -> *const Library {
908 match Library::open(Path::new(&string(path)), Path::new(&string(cache))) {
909 Ok(library) => Arc::into_raw(Arc::new(library)),
910 Err(cause) => {
911 failed(cause, error);
912 std::ptr::null()
913 }
914 }
915}
916
917/// The notebook folder `root`, `/`-separated from the top of `share` on the server at
918/// `address` (`host` or `host:port`), opened as `user` or as a guest when `user` is empty.
919/// While the server cannot be reached, a notebook listed before opens with its sections as
920/// last listed. Blocks for up to ten seconds.
921///
922/// # Safety
923/// Every string is NUL-terminated UTF-8; `error` is null or a place for a string.
924#[unsafe(no_mangle)]
925pub unsafe extern "C" fn sb_library_server(
926 address: *const c_char,
927 share: *const c_char,
928 user: *const c_char,
929 password: *const c_char,
930 domain: *const c_char,
931 root: *const c_char,
932 cache: *const c_char,
933 error: *mut *mut c_char,
934) -> *const Library {
935 let server = server(address, share, user, password, domain);
936 match Library::server(server, &string(root), Path::new(&string(cache))) {
937 Ok(library) => Arc::into_raw(Arc::new(library)),
938 Err(cause) => {
939 failed(cause, error);
940 std::ptr::null()
941 }
942 }
943}
944
945/// The notebook's sections read again, as JSON: each with `name`, `path`, `group`,
946/// `color` as sRGB bytes and `readable`, in the notebook's order; null if it cannot be read.
947#[unsafe(no_mangle)]
948pub extern "C" fn sb_library_sections(library: &Library) -> *mut c_char {
949 json(library.tabs())
950}
951
952/// Creates a section named `name` in the group at catalog path `folder` ("" for the
953/// notebook's top) holding one page titled with `date` and `time`, as OneNote creates one;
954/// returns its catalog path, or null.
955///
956/// # Safety
957/// Every string is NUL-terminated UTF-8.
958#[unsafe(no_mangle)]
959pub unsafe extern "C" fn sb_library_new_section(
960 library: &Library,
961 folder: *const c_char,
962 name: *const c_char,
963 author: *const c_char,
964 date: *const c_char,
965 time: *const c_char,
966) -> *mut c_char {
967 let (folder, name) = (string(folder), string(name));
968 let page = dated(&string(author), &string(date), &string(time));
969 report(page.and_then(|page| {
970 library.with_notebook(true, |notebook| Ok(notebook.create_section(&folder, &name, &page)?))
971 }))
972 .map_or(std::ptr::null_mut(), owned)
973}
974
975/// Pages of the notebook whose title or text holds `query`, as JSON: each with `section`
976/// (a catalog path), `page`, `title` and `snippet`. `open` is the section being edited, at
977/// catalog path `path`, or null. Reads the notebook's sections, so call it off the main
978/// thread.
979///
980/// # Safety
981/// `query` and a non-null `path` are NUL-terminated UTF-8.
982#[unsafe(no_mangle)]
983pub unsafe extern "C" fn sb_library_search(
984 library: &Library,
985 open: Option<&Section>,
986 path: *const c_char,
987 query: *const c_char,
988) -> *mut c_char {
989 let path = optional(path).unwrap_or_default();
990 json(library.search(open.map(|section| (path.as_str(), section)), &string(query)))
991}
992
993/// # Safety
994/// `library` came from `sb_library_open` or `sb_library_server` and is not used again.
995#[unsafe(no_mangle)]
996pub unsafe extern "C" fn sb_library_free(library: *const Library) {
997 drop(unsafe { Arc::from_raw(library) });
998}
999
1000/// Connects to `share` on the server at `address` (`host` or `host:port`) as `user`, or
1001/// as a guest when `user` is empty. Blocks for up to ten seconds.
1002///
1003/// # Safety
1004/// Every string is NUL-terminated UTF-8; `error` is null or a place for a string.
1005#[unsafe(no_mangle)]
1006pub unsafe extern "C" fn sb_share_connect(
1007 address: *const c_char,
1008 share: *const c_char,
1009 user: *const c_char,
1010 password: *const c_char,
1011 domain: *const c_char,
1012 error: *mut *mut c_char,
1013) -> *mut Share {
1014 let server = server(address, share, user, password, domain);
1015 boxed(server.connect().map_err(Into::into).map(Share), error)
1016}
1017
1018fn server(
1019 address: *const c_char,
1020 share: *const c_char,
1021 user: *const c_char,
1022 password: *const c_char,
1023 domain: *const c_char,
1024) -> Server {
1025 let mut address = string(address);
1026 if !address.contains(':') {
1027 address.push_str(":445");
1028 }
1029 Server {
1030 address,
1031 share: string(share),
1032 user: string(user),
1033 password: string(password),
1034 domain: string(domain),
1035 }
1036}
1037
1038#[derive(serde::Serialize)]
1039struct Listing {
1040 /// The folder holds a notebook's table of contents.
1041 notebook: bool,
1042 /// Folders, then section files, by name.
1043 folders: Vec<String>,
1044 sections: Vec<String>,
1045}
1046
1047/// The folder at `path` on the share, as JSON: `notebook`, `folders` and `sections`.
1048///
1049/// # Safety
1050/// `path` is NUL-terminated UTF-8.
1051#[unsafe(no_mangle)]
1052pub unsafe extern "C" fn sb_share_list(share: &Share, path: *const c_char) -> *mut c_char {
1053 json(
1054 share
1055 .0
1056 .read_dir(&string(path), 100_000)
1057 .map_err(Into::into)
1058 .map(|entries| {
1059 let extension = |name: &str, wanted: &str| {
1060 Path::new(name)
1061 .extension()
1062 .is_some_and(|extension| extension.eq_ignore_ascii_case(wanted))
1063 };
1064 // Hidden and system folders, OneNote's recycle bin among them, stay out.
1065 let shown = |name: &str| !name.starts_with('.') && name != "OneNote_RecycleBin";
1066 let mut listing = Listing {
1067 notebook: entries
1068 .iter()
1069 .any(|entry| extension(&entry.name, "onetoc2")),
1070 folders: Vec::new(),
1071 sections: Vec::new(),
1072 };
1073 for entry in entries.into_iter().filter(|entry| shown(&entry.name)) {
1074 if entry.attributes & 0x10 != 0 {
1075 listing.folders.push(entry.name);
1076 } else if extension(&entry.name, "one") {
1077 listing.sections.push(entry.name);
1078 }
1079 }
1080 listing.folders.sort_by_key(|name| name.to_lowercase());
1081 listing.sections.sort_by_key(|name| name.to_lowercase());
1082 listing
1083 }),
1084 )
1085}
1086
1087/// # Safety
1088/// `share` came from `sb_share_connect` and is not used again.
1089#[unsafe(no_mangle)]
1090pub unsafe extern "C" fn sb_share_free(share: *mut Share) {
1091 drop(unsafe { Box::from_raw(share) });
1092}
1093
1094/// Called on a background thread with the token `sb_section_open` took when the section
1095/// has events for `sb_section_poll`.
1096pub type Wake = extern "C" fn(token: usize);
1097
1098/// Opens the section at catalog `path` of `library` for editing, its edits naming
1099/// `author`. Reads the whole section the first time, so call it off the main thread.
1100///
1101/// # Safety
1102/// `path` and `author` are NUL-terminated UTF-8; `error` is null or a place for a string.
1103#[unsafe(no_mangle)]
1104pub unsafe extern "C" fn sb_section_open(
1105 library: &Library,
1106 path: *const c_char,
1107 author: *const c_char,
1108 wake: Wake,
1109 token: usize,
1110 error: *mut *mut c_char,
1111) -> *mut Section {
1112 // SAFETY: the host keeps the library alive while this call runs.
1113 let library = unsafe {
1114 Arc::increment_strong_count(library);
1115 Arc::from_raw(library as *const Library)
1116 };
1117 boxed(
1118 library
1119 .section(&string(path), move || wake(token))
1120 .map(|section| Section {
1121 shared: Arc::new(Shared {
1122 section,
1123 author: string(author),
1124 status: AtomicU8::new(0),
1125 }),
1126 library,
1127 }),
1128 error,
1129 )
1130}
1131
1132/// The section's pages in order as JSON: each with `id`, `title`, `level` and `versions`,
1133/// its conflict pages, each with `id`, `user` and `created` (seconds since 1970, or null).
1134#[unsafe(no_mangle)]
1135pub extern "C" fn sb_section_pages(section: &Section) -> *mut c_char {
1136 json(section.rows())
1137}
1138
1139/// Takes the section's events: `LISTED` (1) the page list or conflict pages changed,
1140/// `CHANGED` (2) a change made elsewhere arrived, so open pages reload; `REJECTED` (4) an
1141/// edit was refused. Writes the saving status: 1 saved, 2 saving, 3 offline, 4 not saving,
1142/// or 0 before anything happened.
1143#[unsafe(no_mangle)]
1144pub extern "C" fn sb_section_poll(section: &Section, status: &mut u8) -> u32 {
1145 let flags = section.poll();
1146 *status = section.shared.status.load(Ordering::Relaxed);
1147 flags
1148}
1149
1150/// Adds a page, or with `parent` a subpage beneath that page, titled with `date` and
1151/// `time`; returns its id, or null.
1152///
1153/// # Safety
1154/// `date` and `time` are NUL-terminated UTF-8; `parent` too unless null.
1155#[unsafe(no_mangle)]
1156pub unsafe extern "C" fn sb_section_new_page(
1157 section: &Section,
1158 parent: *const c_char,
1159 date: *const c_char,
1160 time: *const c_char,
1161) -> *mut c_char {
1162 let page = optional(parent)
1163 .map(|parent| Ok::<_, Box<dyn std::error::Error>>(parent.parse()?))
1164 .transpose()
1165 .and_then(|parent| section.new_page(parent, &string(date), &string(time)));
1166 report(page).map_or(std::ptr::null_mut(), |space| owned(space.to_string()))
1167}
1168
1169/// Deletes page `id` to the notebook's recycle bin, or a conflict page for good; a section
1170/// left without pages gets one titled `date` and `time`. Writes to the notebook, so call it
1171/// off the main thread.
1172///
1173/// # Safety
1174/// Every string is NUL-terminated UTF-8.
1175#[unsafe(no_mangle)]
1176pub unsafe extern "C" fn sb_section_delete_page(
1177 section: &Section,
1178 id: *const c_char,
1179 date: *const c_char,
1180 time: *const c_char,
1181) -> bool {
1182 report(space(id).and_then(|space| section.delete_page(space, &string(date), &string(time))))
1183 .is_some()
1184}
1185
1186/// Waits for every edit to be stored in the cache, then up to `seconds` for them to be
1187/// published; true when nothing waits. For the host's background time.
1188#[unsafe(no_mangle)]
1189pub extern "C" fn sb_section_flush(section: &Section, seconds: f64) -> bool {
1190 section.flush(Duration::from_secs_f64(seconds))
1191}
1192
1193/// Asks the section to look for changes made elsewhere now, as when the app returns.
1194#[unsafe(no_mangle)]
1195pub extern "C" fn sb_section_wake(section: &Section) {
1196 section.shared.section.wake();
1197}
1198
1199/// # Safety
1200/// `section` came from `sb_section_open` and is not used again.
1201#[unsafe(no_mangle)]
1202pub unsafe extern "C" fn sb_section_free(section: *mut Section) {
1203 drop(unsafe { Box::from_raw(section) });
1204}
crates/mobile/src/tests.rs+479-49
......@@ -1,24 +1,69 @@
11use super::*;
2use std::path::{Path, PathBuf};
23
3const FEATURES: &str = concat!(
4 env!("CARGO_MANIFEST_DIR"),
5 "/../../corpus/media-edit/candidate/Features.one"
6);
7
8/// A page of the corpus section on a 402 × 874 point phone at 3 pixels per point, at 100%
9/// with the page origin at the view's corner.
10fn canvas(title: &str) -> Canvas {
11 let section = Section::open(&std::fs::read(FEATURES).unwrap()).unwrap();
12 let index = section
13 .headings
14 .iter()
15 .position(|(heading, _)| heading.to_str().unwrap() == title)
4const CORPUS: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../../corpus");
5
6fn corpus(path: &str) -> PathBuf {
7 Path::new(CORPUS).join(path)
8}
9
10/// A copy of the corpus section or notebook folder at `path` in a fresh directory, with a
11/// cache beside it.
12fn copy(path: &str) -> (tempfile::TempDir, PathBuf) {
13 let directory = tempfile::tempdir().unwrap();
14 let source = corpus(path);
15 let target = directory.path().join(source.file_name().unwrap());
16 let status = std::process::Command::new("cp")
17 .arg("-R")
18 .arg(&source)
19 .arg(&target)
20 .status()
1621 .unwrap();
17 let mut canvas = Canvas::new(section.pages[index].clone(), [1206, 2622], 3.0).unwrap();
22 assert!(status.success());
23 (directory, target)
24}
25
26fn open(library: &Arc<Library>, path: &str) -> Section {
27 let section = library.section(path, || {}).unwrap();
28 Section {
29 shared: Arc::new(Shared {
30 section,
31 author: "Clover Test".into(),
32 status: Default::default(),
33 }),
34 library: Arc::clone(library),
35 }
36}
37
38fn space(section: &Section, title: &str) -> ExGuid {
39 section
40 .shared
41 .section
42 .pages()
43 .unwrap()
44 .into_iter()
45 .find(|(_, heading, _)| heading == title)
46 .unwrap()
47 .0
48}
49
50/// A page of the Features section on a 402 × 874 point phone at 3 pixels per point, at
51/// 100% with the page origin at the view's corner.
52fn canvas(section: &Section, title: &str) -> Canvas {
53 let space = space(section, title);
54 let (page, _) = section.shared.page(space).unwrap();
55 let mut canvas = Canvas::new(space, page, [1206, 2622], 3.0).unwrap();
1856 canvas.set_transform(1.0, [0.0; 2]);
1957 canvas
2058}
2159
60fn features() -> (tempfile::TempDir, Section) {
61 let (directory, file) = copy("media-edit/candidate/Features.one");
62 let library = Arc::new(Library::open(&file, &directory.path().join("cache")).unwrap());
63 let section = open(&library, &file.to_string_lossy());
64 (directory, section)
65}
66
2267/// A view point on the outline holding `text`, `fraction` of the way along its first line.
2368fn point_on(canvas: &Canvas, text: &str, fraction: f32) -> [f32; 2] {
2469 let outline = canvas
......@@ -33,40 +78,37 @@ fn point_on(canvas: &Canvas, text: &str, fraction: f32) -> [f32; 2] {
3378 [x * POINT, y * POINT]
3479}
3580
36#[test]
37fn the_section_lists_titles_and_levels() {
38 let section = Section::open(&std::fs::read(FEATURES).unwrap()).unwrap();
39 assert!(
40 section
41 .headings
42 .iter()
43 .any(|(title, _)| title.to_str() == Ok("Paragraph controls"))
44 );
45 assert!(section.headings.iter().all(|(_, level)| *level >= 1));
81/// Focuses the outline holding `text` with a tap, and hands the section the edit that took.
82fn focus(canvas: &mut Canvas, text: &str) {
83 let point = point_on(canvas, text, 0.1);
84 canvas.press(point).unwrap();
85 canvas.release().unwrap();
4686}
4787
4888#[test]
4989fn a_notebook_folder_lists_sections_in_order_with_groups_and_colours() {
50 let root = concat!(
51 env!("CARGO_MANIFEST_DIR"),
52 "/../../corpus/m6/native-features-01/notebook"
53 );
54 let notebook = listing(Path::new(root)).unwrap();
55 let names: Vec<_> = notebook
56 .sections
90 let directory = tempfile::tempdir().unwrap();
91 let library =
92 Library::open(&corpus("m6/native-features-01/notebook"), directory.path()).unwrap();
93 let tabs = library.tabs().unwrap();
94 let names: Vec<_> = tabs
5795 .iter()
5896 .map(|tab| (tab.group.as_str(), tab.name.as_str()))
5997 .collect();
60 eprintln!("{names:?}");
61 assert!(names.contains(&("", "Features")));
62 assert!(notebook.sections.iter().any(|tab| !tab.group.is_empty()));
63 let lone = listing(Path::new(FEATURES)).unwrap();
64 assert_eq!(lone.sections.len(), 1);
98 assert!(names.contains(&("", "Features")), "{names:?}");
99 assert!(tabs.iter().any(|tab| !tab.group.is_empty()));
100 let lone = Library::open(
101 &corpus("media-edit/candidate/Features.one"),
102 directory.path(),
103 )
104 .unwrap();
105 assert_eq!(lone.tabs().unwrap().len(), 1);
65106}
66107
67108#[test]
68109fn touches_route_to_text_the_page_and_the_focused_outlines_grip() {
69 let mut canvas = canvas("Paragraph controls");
110 let (_directory, section) = features();
111 let mut canvas = canvas(&section, "Paragraph controls");
70112 let text = point_on(&canvas, "Collapsed parent", 0.1);
71113 let first = canvas.target(text);
72114 assert!(
......@@ -90,10 +132,9 @@ fn touches_route_to_text_the_page_and_the_focused_outlines_grip() {
90132
91133#[test]
92134fn dragging_the_grip_moves_the_outline_onto_the_grid_as_an_edit() {
93 let mut canvas = canvas("Paragraph controls");
94 let text = point_on(&canvas, "Collapsed parent", 0.1);
95 canvas.press(text).unwrap();
96 canvas.release().unwrap();
135 let (_directory, section) = features();
136 let mut canvas = canvas(&section, "Paragraph controls");
137 focus(&mut canvas, "Collapsed parent");
97138 canvas.edit().unwrap();
98139 let before = canvas.active().origin();
99140 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() {
120161 assert!(canvas.page.editor.can_undo());
121162}
122163
164#[test]
165fn edits_leave_the_viewport_where_the_host_put_it() {
166 let (_directory, section) = features();
167 let mut canvas = canvas(&section, "Paragraph controls");
168 // Scrolled far past the content, where the canvas would clamp and reveal.
169 canvas.set_transform(1.0, [-2000.0, 5000.0]);
170 let origin = canvas.page.viewport.origin;
171 focus(&mut canvas, "Collapsed parent");
172 canvas.insert("typed".into()).unwrap();
173 assert_eq!(canvas.page.viewport.origin, origin);
174}
175
123176#[test]
124177fn the_text_model_skips_collapsed_paragraphs_and_round_trips_selection() {
125 let mut canvas = canvas("Paragraph controls");
126 let text = point_on(&canvas, "Collapsed parent", 0.1);
127 canvas.press(text).unwrap();
128 canvas.release().unwrap();
178 let (_directory, section) = features();
179 let mut canvas = canvas(&section, "Paragraph controls");
180 focus(&mut canvas, "Collapsed parent");
129181 let shown = canvas.active().shown_text();
130182 let length = canvas.length();
131183 assert_eq!(length as usize, shown.encode_utf16().count());
......@@ -147,10 +199,9 @@ fn the_text_model_skips_collapsed_paragraphs_and_round_trips_selection() {
147199
148200#[test]
149201fn undo_restores_typing() {
150 let mut canvas = canvas("Paragraph controls");
151 let text = point_on(&canvas, "Collapsed parent", 0.1);
152 canvas.press(text).unwrap();
153 canvas.release().unwrap();
202 let (_directory, section) = features();
203 let mut canvas = canvas(&section, "Paragraph controls");
204 focus(&mut canvas, "Collapsed parent");
154205 let before = canvas.active().shown_text();
155206 canvas.insert("hi".into()).unwrap();
156207 assert_ne!(canvas.active().shown_text(), before);
......@@ -158,3 +209,382 @@ fn undo_restores_typing() {
158209 assert_eq!(canvas.active().shown_text(), before);
159210 assert!(canvas.page.editor.can_redo());
160211}
212
213/// The text of the page `space` of the section file `file`, read cold.
214fn stored_text(file: &Path, space: ExGuid) -> String {
215 let arena = onestore::Arena::default();
216 let section = onestore::Section::open(&arena, onestore::read_file(file).unwrap()).unwrap();
217 library::page_text(&section.page(space).unwrap())
218}
219
220#[test]
221fn typing_is_stored_in_the_cache_and_published_to_the_file() {
222 let (directory, file) = copy("media-edit/candidate/Features.one");
223 let cache = directory.path().join("cache");
224 let library = Arc::new(Library::open(&file, &cache).unwrap());
225 let section = open(&library, &file.to_string_lossy());
226 let mut canvas = canvas(&section, "Paragraph controls");
227 focus(&mut canvas, "Collapsed parent");
228 let shown = canvas.active().shown_text();
229 let at = shown[..shown.find("Collapsed parent").unwrap()]
230 .encode_utf16()
231 .count() as u32;
232 canvas.select([at, at]).unwrap();
233 canvas.insert("Saved ".into()).unwrap();
234 section
235 .shared
236 .apply(canvas.edit().unwrap().unwrap())
237 .unwrap();
238 assert!(section.flush(Duration::from_secs(20)));
239 let space = canvas.space;
240 assert!(stored_text(&file, space).contains("Saved Collapsed parent"));
241 // The replica reopens from the cache as the file now holds it.
242 let shared = Arc::clone(&section.shared);
243 drop((canvas, section));
244 Arc::into_inner(shared).unwrap().section.close().unwrap();
245 let reopened = open(&library, &file.to_string_lossy());
246 let (page, read_only) = reopened.shared.page(space).unwrap();
247 assert!(!read_only);
248 assert!(library::page_text(&page).contains("Saved Collapsed parent"));
249}
250
251#[test]
252fn a_new_page_and_subpage_list_after_their_parent_and_delete_to_the_recycle_bin() {
253 let (directory, root) = copy("m6/native-features-01/notebook");
254 let library = Arc::new(Library::open(&root, &directory.path().join("cache")).unwrap());
255 let section = open(&library, "Features.one");
256 let first = section.rows().unwrap()[0].id.parse().unwrap();
257 let page = section
258 .new_page(None, "Sunday, September 27, 2026", "3:04 AM")
259 .unwrap();
260 let subpage = section
261 .new_page(Some(first), "Sunday, September 27, 2026", "3:05 AM")
262 .unwrap();
263 let rows = section.rows().unwrap();
264 assert_eq!(rows.last().unwrap().id, page.to_string());
265 assert_eq!(rows[1].id, subpage.to_string());
266 assert_eq!(rows[1].level, rows[0].level + 1);
267 let mut canvas = Canvas::new(
268 subpage,
269 section.shared.page(subpage).unwrap().0,
270 [1206, 2622],
271 3.0,
272 )
273 .unwrap();
274 assert!(canvas.focus_title().unwrap());
275 canvas.insert("Sketches".into()).unwrap();
276 assert_eq!(canvas.title().unwrap().shown_text(), "Sketches");
277 section
278 .shared
279 .apply(canvas.edit().unwrap().unwrap())
280 .unwrap();
281 assert!(
282 section
283 .rows()
284 .unwrap()
285 .iter()
286 .any(|row| row.title == "Sketches")
287 );
288 section
289 .delete_page(subpage, "Sunday, September 27, 2026", "3:06 AM")
290 .unwrap();
291 assert!(
292 section
293 .rows()
294 .unwrap()
295 .iter()
296 .all(|row| row.id != subpage.to_string())
297 );
298 assert!(
299 root.join("OneNote_RecycleBin/OneNote_DeletedPages.one")
300 .exists()
301 );
302}
303
304#[test]
305fn conflict_pages_list_under_their_page_and_take_no_edits() {
306 let (directory, root) = copy("conflict-page/candidate");
307 let library = Arc::new(Library::open(&root, &directory.path().join("cache")).unwrap());
308 let section = open(&library, "synthetic.one");
309 let rows = section.rows().unwrap();
310 let version = rows
311 .iter()
312 .find_map(|row| row.versions.first())
313 .expect("a conflict page");
314 assert!(!version.user.is_empty());
315 let (page, read_only) = section.shared.page(version.id.parse().unwrap()).unwrap();
316 assert!(read_only);
317 // Conflicting paragraphs carry OneNote's highlight.
318 let highlighted = page.objects.iter().any(|object| match object {
319 onestore::page::PageObject::Outline(outline) => outline
320 .paragraphs
321 .iter()
322 .any(|paragraph| paragraph.format.highlight == Some(canvas::conflict::CONFLICTING)),
323 _ => false,
324 });
325 assert!(highlighted);
326}
327
328#[test]
329fn search_finds_pages_by_title_and_text_across_sections() {
330 let directory = tempfile::tempdir().unwrap();
331 let library =
332 Library::open(&corpus("m6/native-features-01/notebook"), directory.path()).unwrap();
333 let found = library.search(None, "collapsed PARENT").unwrap();
334 assert!(!found.is_empty());
335 assert!(found[0].snippet.to_lowercase().contains("collapsed parent"));
336 assert!(
337 library
338 .search(None, "no such words anywhere")
339 .unwrap()
340 .is_empty()
341 );
342}
343
344#[test]
345fn find_selects_the_match_and_formatting_reports_and_toggles() {
346 let (_directory, section) = features();
347 let mut canvas = canvas(&section, "Paragraph controls");
348 assert!(canvas.find("collapsed PARENT").unwrap());
349 let [start, end] = canvas.selection().unwrap();
350 assert_eq!(canvas.text([start, end]).to_lowercase(), "collapsed parent");
351 assert_eq!(canvas.format_bits().unwrap() & 1, 0);
352 assert!(canvas.format(0).unwrap());
353 assert_eq!(canvas.format_bits().unwrap() & 1, 1);
354 // To Do on the paragraph.
355 assert!(canvas.format(16).unwrap());
356 assert_ne!(canvas.format_bits().unwrap() & 1 << 16, 0);
357 assert!(canvas.edit().unwrap().is_some());
358}
359
360#[test]
361fn a_picture_goes_after_the_caret_paragraph_and_undoes() {
362 let (_directory, section) = features();
363 let mut canvas = canvas(&section, "Paragraph controls");
364 focus(&mut canvas, "Collapsed parent");
365 canvas.edit().unwrap();
366 let png = std::fs::read(corpus(
367 "../crates/snowbound/assets/icon/Snowbound-SnowLeopard.png",
368 ))
369 .unwrap();
370 assert!(canvas.insert_picture(&png, [64.0, 64.0]).unwrap());
371 let edit = canvas.edit().unwrap().unwrap();
372 assert!(edit.ops.iter().any(|op| matches!(
373 op,
374 Op::Page {
375 op: onestore::op::PageOp::Insert { .. },
376 ..
377 }
378 )));
379 section.shared.apply(edit).unwrap();
380 let pictures = |page: &Page| {
381 page.objects
382 .iter()
383 .filter_map(|object| match object {
384 onestore::page::PageObject::Outline(outline) => Some(
385 outline
386 .paragraphs
387 .iter()
388 .filter(|paragraph| {
389 matches!(
390 paragraph.content,
391 onestore::page::ParagraphContent::Image(_)
392 )
393 })
394 .count(),
395 ),
396 _ => None,
397 })
398 .sum::<usize>()
399 };
400 let before = pictures(&section.shared.page(canvas.space).unwrap().0);
401 assert!(moved(canvas.page.undo(false).unwrap()));
402 section
403 .shared
404 .apply(canvas.edit().unwrap().unwrap())
405 .unwrap();
406 assert_eq!(
407 pictures(&section.shared.page(canvas.space).unwrap().0),
408 before - 1
409 );
410}
411
412#[test]
413fn to_do_marks_a_new_pages_body() {
414 let (directory, root) = copy("m6/native-features-01/notebook");
415 let library = Arc::new(Library::open(&root, &directory.path().join("cache")).unwrap());
416 let section = open(&library, "Features.one");
417 let page = section
418 .new_page(None, "Sunday, September 27, 2026", "3:04 AM")
419 .unwrap();
420 let mut canvas = Canvas::new(
421 page,
422 section.shared.page(page).unwrap().0,
423 [1206, 2622],
424 3.0,
425 )
426 .unwrap();
427 canvas.set_transform(1.0, [0.0; 2]);
428 assert!(canvas.focus_title().unwrap());
429 canvas.insert("Groceries".into()).unwrap();
430 canvas.insert("\n".into()).unwrap();
431 canvas.insert("Oat milk".into()).unwrap();
432 canvas.insert("\n".into()).unwrap();
433 canvas.insert("Pears".into()).unwrap();
434 assert!(canvas.format(16).unwrap());
435 section
436 .shared
437 .apply(canvas.edit().unwrap().unwrap())
438 .unwrap();
439 let stored = section.shared.page(page).unwrap().0;
440 let tags: usize = stored
441 .objects
442 .iter()
443 .map(|object| match object {
444 onestore::page::PageObject::Outline(outline) => outline
445 .paragraphs
446 .iter()
447 .filter_map(|paragraph| paragraph.text())
448 .map(|text| text.tags.len())
449 .sum(),
450 _ => 0,
451 })
452 .sum();
453 assert_eq!(tags, 1);
454}
455
456/// Against the Samba lab (`ONESTORE_SMB_LAB=127.0.0.1:PORT`, share `agent`, guest): a
457/// notebook folder copied to the share lists, a section opens and publishes typing, and the
458/// listing kept in the cache opens the section again while the server cannot be reached.
459#[test]
460#[ignore = "requires ONESTORE_SMB_LAB pointing to disposable Samba"]
461fn a_notebook_on_a_share_saves_and_opens_offline() {
462 let address = std::env::var("ONESTORE_SMB_LAB").unwrap();
463 let server = |address: &str| library::Server {
464 address: address.into(),
465 share: "agent".into(),
466 user: String::new(),
467 password: String::new(),
468 domain: String::new(),
469 };
470 let client = server(&address).connect().unwrap();
471 let root = format!("mobile-{}", std::process::id());
472 client.create_directory(&root).unwrap();
473 let source = corpus("media-edit/candidate/Features.one");
474 client
475 .create(
476 &format!("{root}/Features.one"),
477 &std::fs::read(&source).unwrap(),
478 )
479 .unwrap();
480 let cache = tempfile::tempdir().unwrap();
481 let library = Arc::new(Library::server(server(&address), &root, cache.path()).unwrap());
482 assert_eq!(library.tabs().unwrap()[0].path, "Features.one");
483 let section = open(&library, "Features.one");
484 let mut canvas = canvas(&section, "Paragraph controls");
485 focus(&mut canvas, "Collapsed parent");
486 canvas.insert("Shared ".into()).unwrap();
487 section
488 .shared
489 .apply(canvas.edit().unwrap().unwrap())
490 .unwrap();
491 assert!(section.flush(Duration::from_secs(30)));
492 let published = client
493 .read(&format!("{root}/Features.one"), 1 << 26)
494 .unwrap();
495 let arena = onestore::Arena::default();
496 let stored = onestore::Section::open(&arena, published).unwrap();
497 assert!(library::page_text(&stored.page(canvas.space).unwrap()).contains("Shared "));
498 let shared = Arc::clone(&section.shared);
499 drop((canvas, section, library));
500 Arc::into_inner(shared).unwrap().section.close().unwrap();
501 // The same notebook while its server does not answer: a closed port on the lab's host.
502 let host = address.split(':').next().unwrap();
503 let mut unreachable = server(&format!("{host}:9"));
504 unreachable.share = "agent".into();
505 let listing = |address: &str| {
506 cache.path().join("smb").join(format!(
507 "{}.json",
508 format!("{address}/agent/{root}").replace(|c: char| !c.is_alphanumeric(), "_")
509 ))
510 };
511 std::fs::copy(listing(&address), listing(&format!("{host}:9"))).unwrap();
512 let offline = Arc::new(Library::server(unreachable, &root, cache.path()).unwrap());
513 assert_eq!(offline.tabs().unwrap()[0].path, "Features.one");
514 let reopened = open(&offline, "Features.one");
515 let page = reopened
516 .shared
517 .page(space(&reopened, "Paragraph controls"))
518 .unwrap()
519 .0;
520 assert!(library::page_text(&page).contains("Shared "));
521}
522
523/// Paths the test coordinator was asked for, with whether to write.
524static COORDINATED: Mutex<Vec<(String, bool)>> = Mutex::new(Vec::new());
525
526extern "C" fn coordinator(
527 path: *const c_char,
528 write: bool,
529 body: extern "C" fn(*mut c_void),
530 context: *mut c_void,
531) {
532 COORDINATED.lock().unwrap().push((string(path), write));
533 body(context);
534}
535
536#[test]
537fn a_local_sections_reads_and_publications_go_through_the_hosts_coordination() {
538 library::sb_set_coordinator(coordinator);
539 let (directory, root) = copy("m6/native-features-01/notebook");
540 let library = Arc::new(Library::open(&root, &directory.path().join("cache")).unwrap());
541 let section = open(&library, "Features.one");
542 let mut canvas = canvas(&section, "Paragraph controls");
543 focus(&mut canvas, "Collapsed parent");
544 canvas.insert("Coordinated ".into()).unwrap();
545 section.shared.apply(canvas.edit().unwrap().unwrap()).unwrap();
546 assert!(section.flush(Duration::from_secs(20)));
547 let file = root.canonicalize().unwrap().join("Features.one");
548 let file = file.to_string_lossy();
549 let coordinated = COORDINATED.lock().unwrap();
550 assert!(coordinated.iter().any(|(path, write)| path == &*file && !write));
551 assert!(coordinated.iter().any(|(path, write)| path == &*file && *write));
552}
553
554#[test]
555fn tapping_the_date_asks_for_it_and_a_new_date_is_stored() {
556 let (_directory, section) = features();
557 let mut canvas = canvas(&section, "Paragraph controls");
558 // The first view point, scanning down the page, that lands on the date.
559 let date = (0..400)
560 .flat_map(|y| (0..130).map(move |x| [x as f32 * 3.0, y as f32 * 3.0]))
561 .find(|point| {
562 matches!(
563 canvas.page.hit(canvas.device(*point)),
564 Some(Hit::Date(DateField::Date))
565 )
566 })
567 .expect("the page shows its date");
568 canvas.press(date).unwrap();
569 canvas.release().unwrap();
570 assert_eq!(canvas.asked.take(), Some(DateField::Date));
571 let before = canvas.page.editor.date().unwrap().timestamp();
572 // 2 January 2026, 10:30 UTC.
573 assert!(
574 canvas
575 .change_date(1_767_349_800, ["Friday, January 2, 2026".into(), "10:30 AM".into()])
576 .unwrap()
577 );
578 let edit = canvas.edit().unwrap().unwrap();
579 assert!(edit.ops.iter().any(|op| matches!(
580 op,
581 Op::Page {
582 op: PageOp::Date { .. },
583 ..
584 }
585 )));
586 section.shared.apply(edit).unwrap();
587 let stored = library::page_text(&section.shared.page(canvas.space).unwrap().0);
588 assert!(stored.contains("Friday, January 2, 2026"), "{stored}");
589 assert_ne!(canvas.page.editor.date().unwrap().timestamp(), before);
590}