1import UIKit
2import UniformTypeIdentifiers
3
4/// The places notebooks are kept, each listing its notebooks with their sections, as Files
5/// lists its locations and Notes each account's folders.
6final class NotebooksViewController: UICollectionViewController, UIDocumentPickerDelegate {
7 enum Location: Hashable {
8 /// Snowbound's folder in iCloud Drive, where new notebooks go while iCloud Drive is on.
9 case icloud
10 /// Snowbound's Documents, which Files shows as Snowbound's folder.
11 case onDevice
12 /// Notebooks opened from Files or a server.
13 case elsewhere
14 }
15
16 enum Item: Hashable {
17 case location(Location)
18 case notebook(String)
19 /// A section group, by its `/`-separated path.
20 case group(notebook: String, path: String)
21 case section(notebook: String, path: String)
22 /// Opening, or why the notebook cannot be.
23 case status(notebook: String)
24 }
25
26 var dataSource: UICollectionViewDiffableDataSource<Location, Item>!
27 private var collapsed: Set<Item> = []
28 /// What each notebook's row shows of its sync status, by notebook id.
29 private var attention: [String: Sync.Attention] = [:]
30 private var selected: Item?
31 var onOpen: ((Tab, Notebook) -> Void)?
32
33 private static let device = "On My \(UIDevice.current.model)"
34
35 init() {
36 super.init(collectionViewLayout: UICollectionViewFlowLayout())
37 title = "Notebooks"
38 navigationItem.largeTitleDisplayMode = .always
39 }
40
41 required init?(coder: NSCoder) { fatalError() }
42
43 override func viewDidLoad() {
44 super.viewDidLoad()
45 collectionView.collectionViewLayout = UICollectionViewCompositionalLayout { [weak self] _, environment in
46 let compact = environment.traitCollection.horizontalSizeClass == .compact
47 var list = UICollectionLayoutListConfiguration(appearance: compact ? .insetGrouped : .sidebar)
48 list.headerMode = .firstItemInSection
49 list.trailingSwipeActionsConfigurationProvider = { indexPath in
50 guard let self, let item = self.dataSource.itemIdentifier(for: indexPath) else { return nil }
51 if case .notebook(let id) = item, let notebook = self.notebook(id) {
52 return UISwipeActionsConfiguration(actions: self.swipeActions(for: notebook, at: indexPath))
53 }
54 guard item == .location(.onDevice) else { return nil }
55 let hide = UIContextualAction(style: .normal, title: "Hide") { [weak self] _, _, done in
56 self?.showOnDevice(false)
57 done(true)
58 }
59 hide.image = UIImage(systemName: "eye.slash")
60 return UISwipeActionsConfiguration(actions: [hide])
61 }
62 return NSCollectionLayoutSection.list(using: list, layoutEnvironment: environment)
63 }
64 let cell = UICollectionView.CellRegistration<UICollectionViewListCell, Item> { [weak self] cell, _, item in
65 self?.configure(cell, item)
66 self?.arranging(cell, item)
67 }
68 dataSource = UICollectionViewDiffableDataSource(collectionView: collectionView) { view, indexPath, item in
69 view.dequeueConfiguredReusableCell(using: cell, for: indexPath, item: item)
70 }
71 dataSource.sectionSnapshotHandlers.willCollapseItem = { [weak self] in self?.collapsed.insert($0) }
72 dataSource.sectionSnapshotHandlers.willExpandItem = { [weak self] in self?.collapsed.remove($0) }
73
74 let add = UIMenu(children: [
75 UIDeferredMenuElement.uncached { [weak self] provide in
76 guard let self else { return provide([]) }
77 var places: [UIMenuElement] = []
78 if ICloud.documents != nil {
79 places.append(
80 UIAction(title: "iCloud Drive", image: UIImage(systemName: "icloud")) { [weak self] _ in
81 self?.newNotebook(inCloud: true)
82 })
83 } else if !ICloud.signedIn {
84 places.append(
85 UIAction(title: "Turn On iCloud Drive", image: UIImage(systemName: "icloud")) { _ in
86 if let settings = URL(string: UIApplication.openSettingsURLString) {
87 UIApplication.shared.open(settings)
88 }
89 })
90 }
91 places.append(
92 UIAction(title: Self.device, image: UIImage(systemName: "iphone")) { [weak self] _ in
93 self?.newNotebook(inCloud: false)
94 })
95 var actions: [UIMenuElement] = [
96 UIMenu(title: "New Notebook", options: .displayInline, children: places),
97 UIAction(title: "Open from Server", image: UIImage(systemName: "server.rack")) { [weak self] _ in
98 self?.connect()
99 },
100 UIAction(title: "Open Folder", image: UIImage(systemName: "folder")) { [weak self] _ in
101 self?.openFolder()
102 },
103 ]
104 let copied = Notebooks.onDevice.contains { $0.source == .documents(path: Notebooks.guide) }
105 if Notebooks.guideOffered && !copied {
106 actions.append(
107 UIAction(title: "Open the Snowbound Guide", image: UIImage(systemName: "book")) { [weak self] _ in
108 self?.openGuide()
109 })
110 }
111 provide(actions)
112 }
113 ])
114 let more = UIMenu(children: [
115 UIDeferredMenuElement.uncached { [weak self] provide in
116 var actions: [UIMenuElement] = [
117 UIAction(title: "Sync Status", image: UIImage(systemName: "arrow.triangle.2.circlepath")) {
118 [weak self] _ in self.map(SyncViewController.present)
119 }
120 ]
121 if !Notebooks.showsOnDevice {
122 actions.append(
123 UIAction(title: "Show \(Self.device)", image: UIImage(systemName: "eye")) { [weak self] _ in
124 self?.showOnDevice(true)
125 })
126 }
127 actions.append(
128 UIAction(title: "Personalize", image: UIImage(systemName: "person.crop.circle")) { [weak self] _ in
129 guard let self else { return }
130 Author.ask(from: self) {}
131 })
132 actions.append(Appearance.menu())
133 actions.append(Editing.fontMenu())
134 actions.append(Editing.markdownAction())
135 provide(actions)
136 }
137 ])
138 navigationItem.rightBarButtonItems = [
139 UIBarButtonItem(title: "Add Notebook", image: UIImage(systemName: "plus"), menu: add),
140 UIBarButtonItem(title: "More", image: UIImage(systemName: "ellipsis"), menu: more),
141 ]
142 navigationItem.searchController = SearchViewController.controller()
143 setUpArranging()
144 let corner = Prototype.quickNote ? [QuickNote.item()] : []
145 if #available(iOS 26, *) {
146 // Search sits in the bottom toolbar, as Notes and Files put it.
147 navigationItem.preferredSearchBarPlacement = .integrated
148 toolbarItems = [navigationItem.searchBarPlacementBarButtonItem] + (corner.isEmpty ? [] : [.fixedSpace()] + corner)
149 } else {
150 toolbarItems = corner.isEmpty ? [] : [.flexibleSpace()] + corner
151 }
152 for name in [Sync.changed, Section.changed] {
153 NotificationCenter.default.addObserver(self, selector: #selector(refreshSync), name: name, object: nil)
154 }
155 NotificationCenter.default.addObserver(forName: Notebook.listed, object: nil, queue: .main) { [weak self] _ in
156 guard let self else { return }
157 reload(animated: true)
158 var shown = dataSource.snapshot()
159 shown.reconfigureItems(shown.itemIdentifiers)
160 dataSource.apply(shown, animatingDifferences: false)
161 }
162 reload()
163 }
164
165 override func viewWillAppear(_ animated: Bool) {
166 super.viewWillAppear(animated)
167 navigationController?.navigationBar.prefersLargeTitles = true
168 navigationController?.setToolbarHidden(toolbarItems?.isEmpty != false, animated: false)
169 }
170
171 func notebook(_ id: String) -> Notebook? { Notebooks.all.first { $0.id == id } }
172
173 func reload(animated: Bool = false) {
174 guard isViewLoaded else { return }
175 if Prototype.welcome {
176 let welcome = Prototype.welcome(
177 new: { [weak self] in self?.newNotebook(inCloud: ICloud.documents != nil) },
178 openFolder: { [weak self] in self?.openFolder() }, connect: { [weak self] in self?.connect() })
179 contentUnavailableConfiguration = welcome
180 if welcome != nil { return dataSource.apply(.init(), animatingDifferences: false) }
181 }
182 // A place with no notebooks is left out; the Add Notebook menu reaches every place.
183 let locations: [(Location, [Notebook])] = [
184 (.icloud, Notebooks.inCloud), (.onDevice, Notebooks.onDevice), (.elsewhere, Notebooks.elsewhere),
185 ].filter { !$0.1.isEmpty }
186 if locations.isEmpty {
187 var empty = UIContentUnavailableConfiguration.empty()
188 empty.text = "No Notebooks"
189 empty.secondaryText = "Create a notebook, or open one from a server or folder."
190 var button = UIButton.Configuration.borderedProminent()
191 button.title = "New Notebook"
192 empty.button = button
193 empty.buttonProperties.primaryAction = UIAction { [weak self] _ in
194 self?.newNotebook(inCloud: ICloud.documents != nil)
195 }
196 contentUnavailableConfiguration = empty
197 } else {
198 contentUnavailableConfiguration = nil
199 }
200 // Locations kept keep their rows, so a move animates.
201 if dataSource.snapshot().sectionIdentifiers != locations.map(\.0) {
202 var sections = NSDiffableDataSourceSnapshot<Location, Item>()
203 sections.appendSections(locations.map(\.0))
204 dataSource.apply(sections, animatingDifferences: false)
205 }
206 for (location, notebooks) in locations {
207 var list = NSDiffableDataSourceSectionSnapshot<Item>()
208 let header = Item.location(location)
209 list.append([header])
210 list.append(notebooks.map { .notebook($0.id) }, to: header)
211 for notebook in notebooks {
212 let parent = Item.notebook(notebook.id)
213 guard notebook.handle != nil else {
214 list.append([.status(notebook: notebook.id)], to: parent)
215 continue
216 }
217 for tab in notebook.tabs {
218 var parent = parent
219 var path = ""
220 for part in tab.group.split(separator: "/") {
221 path += (path.isEmpty ? "" : "/") + part
222 let group = Item.group(notebook: notebook.id, path: path)
223 if !list.contains(group) { list.append([group], to: parent) }
224 parent = group
225 }
226 list.append([.section(notebook: notebook.id, path: tab.path)], to: parent)
227 }
228 }
229 list.expand(list.items.filter { !collapsed.contains($0) })
230 dataSource.apply(list, to: location, animatingDifferences: animated)
231 }
232 showSelection()
233 refreshSync()
234 }
235
236 /// Reads every notebook's sync status, marks the rows it changed, and tells of a notebook
237 /// that has just stopped syncing with edits waiting.
238 @objc private func refreshSync() {
239 Sync.status { [weak self] notebooks in
240 guard let self else { return }
241 var changed: [Item] = []
242 for (notebook, sections) in notebooks {
243 let before = attention[notebook.id]
244 let after = Sync.attention(sections)
245 guard before != after else { continue }
246 attention[notebook.id] = after
247 changed.append(.notebook(notebook.id))
248 // The list is off screen on a phone while a page shows.
249 if let toast = Sync.toast(notebook.name, from: before, to: after),
250 let window = splitViewController?.view.window ?? view.window
251 {
252 SyncToast.show(toast.title, toast.detail, in: window) { [weak self] in
253 self.map(SyncViewController.present)
254 }
255 }
256 }
257 var shown = dataSource.snapshot()
258 let items = changed.filter { shown.indexOfItem($0) != nil }
259 guard !items.isEmpty else { return }
260 shown.reconfigureItems(items)
261 dataSource.apply(shown, animatingDifferences: false)
262 }
263 }
264
265 /// Lists Documents and iCloud Drive again and opens the notebooks new there; `opened` runs
266 /// as each opens.
267 func rescan(opened: ((Notebook) -> Void)? = nil) {
268 for notebook in Notebooks.scanICloud() + Notebooks.scan() {
269 notebook.open { [weak self] in
270 self?.reload()
271 opened?(notebook)
272 }
273 }
274 reload()
275 }
276
277 /// Lists the notebooks again and opens the first section of the one from `source`.
278 private func rescan(opening source: Source) {
279 rescan { [weak self] notebook in
280 guard notebook.source == source, let tab = notebook.tabs.first(where: \.readable) else { return }
281 self?.onOpen?(tab, notebook)
282 }
283 }
284
285 private func showOnDevice(_ shown: Bool) {
286 Notebooks.showsOnDevice = shown
287 rescan()
288 }
289
290 func select(_ tab: Tab, of notebook: Notebook) {
291 selected = .section(notebook: notebook.id, path: tab.path)
292 showSelection()
293 }
294
295 /// Marks the open section, as the sidebar of a wide window keeps it.
296 private func showSelection() {
297 guard let selected, let indexPath = dataSource.indexPath(for: selected) else { return }
298 collectionView.selectItem(at: indexPath, animated: false, scrollPosition: [])
299 }
300
301 private func configure(_ cell: UICollectionViewListCell, _ item: Item) {
302 var content = cell.defaultContentConfiguration()
303 var accessories: [UICellAccessory] = []
304 switch item {
305 case .location(let location):
306 content.text =
307 switch location {
308 case .icloud: "iCloud Drive"
309 case .onDevice: Self.device
310 case .elsewhere: "Other Locations"
311 }
312 accessories = [.outlineDisclosure(options: .init(style: .header))]
313 case .notebook(let id):
314 guard let notebook = notebook(id) else { break }
315 content.text = notebook.name
316 content.image = .notebook(notebook.color)
317 var details: [String] = []
318 if case .server(let server) = notebook.source { details.append(server.host) }
319 if let state = attention[id]?.state {
320 details.append(Sync.label(state))
321 let mark = UIImageView(image: UIImage(systemName: Sync.symbol(state)))
322 mark.tintColor = state == .notConnected || Sync.offline ? .secondaryLabel : .systemOrange
323 mark.preferredSymbolConfiguration = UIImage.SymbolConfiguration(textStyle: .body)
324 accessories.append(.customView(configuration: .init(customView: mark, placement: .trailing(displayed: .always))))
325 }
326 content.secondaryText = details.isEmpty ? nil : details.joined(separator: " · ")
327 content.secondaryTextProperties.color = .secondaryLabel
328 let more = UIButton(type: .system)
329 more.setImage(UIImage(systemName: "ellipsis.circle"), for: .normal)
330 more.accessibilityLabel = "More"
331 more.showsMenuAsPrimaryAction = true
332 more.menu = menu(for: notebook)
333 accessories += [
334 .customView(configuration: .init(customView: more, placement: .trailing(displayed: .always))),
335 .outlineDisclosure(options: .init(style: .cell)),
336 ]
337 case .group(_, let path):
338 content.text = path.split(separator: "/").last.map(String.init)
339 content.image = UIImage(systemName: "folder")
340 content.textProperties.color = .secondaryLabel
341 accessories = [.outlineDisclosure(options: .init(style: .cell))]
342 case .section(let id, let path):
343 guard let tab = notebook(id)?.tabs.first(where: { $0.path == path }) else { break }
344 content.text = tab.name
345 content.image = UIImage(
346 systemName: tab.readable ? "rectangle.portrait.fill" : tab.downloading ? "icloud.and.arrow.down" : "lock.fill")
347 content.imageProperties.tintColor = tab.uiColor
348 if tab.locked {
349 content.secondaryText = "Password protected"
350 } else if !tab.readable {
351 content.secondaryText = tab.downloading ? "Downloading…" : tab.problem ?? "Can’t be opened here"
352 content.textProperties.color = .secondaryLabel
353 }
354 case .status(let id):
355 if let problem = notebook(id)?.problem {
356 content.text = "Can’t Open Notebook"
357 content.secondaryText = problem
358 content.image = UIImage(systemName: "exclamationmark.triangle")
359 content.imageProperties.tintColor = .systemOrange
360 let retry = UIImageView(image: UIImage(systemName: "arrow.clockwise"))
361 accessories = [.customView(configuration: .init(customView: retry, placement: .trailing()))]
362 } else {
363 content.text = "Opening…"
364 content.textProperties.color = .secondaryLabel
365 let spinner = UIActivityIndicatorView(style: .medium)
366 spinner.startAnimating()
367 accessories = [.customView(configuration: .init(customView: spinner, placement: .trailing()))]
368 }
369 }
370 cell.contentConfiguration = content
371 cell.accessories = accessories
372 }
373
374 private func menu(for notebook: Notebook) -> UIMenu {
375 // Why the notebook isn't syncing and when it last did, while it isn't.
376 let status: [UIMenuElement] =
377 attention[notebook.id].map { attention in
378 let details = UIAction(
379 title: Sync.label(attention.state), subtitle: attention.detail.isEmpty ? nil : attention.detail,
380 image: UIImage(systemName: Sync.symbol(attention.state))
381 ) { [weak self] _ in self.map(SyncViewController.present) }
382 let now = UIAction(title: "Sync Now", image: UIImage(systemName: "arrow.clockwise")) { _ in
383 if let handle = notebook.handle { sb_library_sync_now(handle) }
384 }
385 return [UIMenu(options: .displayInline, children: [details, now])]
386 } ?? []
387 let actions = [
388 UIAction(title: "New Section", image: UIImage(systemName: "plus.rectangle.portrait")) {
389 [weak self] _ in self?.newSection(in: notebook)
390 },
391 UIAction(title: "Tags Summary", image: UIImage(systemName: "tag")) { [weak self] _ in
392 guard let self else { return }
393 (view.window?.windowScene?.delegate as? SceneDelegate)?.showTags(of: notebook, from: self)
394 },
395 ]
396 // A notebook On My iPhone is its folder there, handled as Files handles a folder.
397 guard let folder = folder(of: notebook) else {
398 let image = UIImage(systemName: "xmark.circle")
399 let close = UIAction(title: "Close Notebook", image: image, attributes: .destructive) { [weak self] _ in
400 Notebooks.remove(notebook)
401 self?.reload()
402 }
403 return UIMenu(children: status + actions + [close])
404 }
405 let file = [
406 UIAction(title: "Rename", image: UIImage(systemName: "pencil")) { [weak self] _ in
407 self?.rename(folder, of: notebook)
408 },
409 UIAction(title: "Duplicate", image: UIImage(systemName: "plus.square.on.square")) { [weak self] _ in
410 self?.duplicate(folder, of: notebook)
411 },
412 UIAction(title: "Share", image: UIImage(systemName: "square.and.arrow.up")) { [weak self] _ in
413 self?.share(folder, from: notebook)
414 },
415 UIAction(title: "Show in Files", image: UIImage(systemName: "folder")) { _ in
416 // Files opens the folder a `shareddocuments` URL names.
417 if let url = URL(string: "shareddocuments://" + folder.path) { UIApplication.shared.open(url) }
418 },
419 ]
420 let delete = UIAction(title: "Delete", image: UIImage(systemName: "trash"), attributes: .destructive) {
421 [weak self] _ in self?.delete(folder, of: notebook)
422 }
423 return UIMenu(children: status + [
424 UIMenu(options: .displayInline, children: actions), UIMenu(options: .displayInline, children: file), delete,
425 ])
426 }
427
428 private func swipeActions(for notebook: Notebook, at indexPath: IndexPath) -> [UIContextualAction] {
429 guard let folder = folder(of: notebook) else {
430 let close = UIContextualAction(style: .destructive, title: "Close") { [weak self] _, _, done in
431 Notebooks.remove(notebook)
432 self?.reload()
433 done(true)
434 }
435 close.image = UIImage(systemName: "xmark.circle")
436 return [close]
437 }
438 let delete = UIContextualAction(style: .destructive, title: "Delete") { [weak self] _, _, done in
439 self?.delete(folder, of: notebook)
440 done(true)
441 }
442 delete.image = UIImage(systemName: "trash")
443 let share = UIContextualAction(style: .normal, title: "Share") { [weak self] _, _, done in
444 self?.share(folder, from: notebook)
445 done(true)
446 }
447 share.image = UIImage(systemName: "square.and.arrow.up")
448 share.backgroundColor = .systemBlue
449 return [delete, share]
450 }
451
452 /// The notebook's folder or section file On My iPhone; nil for one opened elsewhere.
453 private func folder(of notebook: Notebook) -> URL? {
454 guard case .documents(let path) = notebook.source else { return nil }
455 return documentsDirectory.appendingPathComponent(path)
456 }
457
458 /// Publishes the notebook's edits and lets go of it, closing any section of it shown,
459 /// then runs `act`; refuses with why while an edit can't be published, as moving the
460 /// folder then would strand it.
461 private func close(_ notebook: Notebook, refusing title: String, then act: @escaping () -> Void) {
462 for scene in UIApplication.shared.connectedScenes {
463 (scene.delegate as? SceneDelegate)?.close(notebook)
464 }
465 guard let handle = notebook.handle else { return act() }
466 let pointer = Int(bitPattern: handle)
467 background({ () -> String? in
468 var error: UnsafeMutablePointer<CChar>?
469 let closed = sb_library_close(OpaquePointer(bitPattern: pointer), 10, &error)
470 let problem = take(error)
471 return closed ? nil : problem ?? ""
472 }) { [weak self] problem in
473 if let problem {
474 self?.alert(title, problem)
475 return
476 }
477 notebook.pause()
478 act()
479 }
480 }
481
482 private func rename(_ folder: URL, of notebook: Notebook) {
483 let alert = UIAlertController(title: "Rename", message: nil, preferredStyle: .alert)
484 let isFile = folder.pathExtension.lowercased() == "one"
485 let rename = UIAlertAction(title: "Rename", style: .default) { [weak self, weak alert] _ in
486 let name = alert?.textFields?.first?.text?.trimmingCharacters(in: .whitespaces) ?? ""
487 let target = folder.deletingLastPathComponent().appendingPathComponent(
488 isFile ? name + "." + folder.pathExtension : name)
489 guard target != folder else { return }
490 guard !FileManager.default.fileExists(atPath: target.path) else {
491 self?.alert("“\(name)” Already Exists", "Choose a different name.")
492 return
493 }
494 self?.close(notebook, refusing: "Can’t Rename") {
495 do {
496 try FileManager.default.moveItem(at: folder, to: target)
497 _ = sb_notebook_moved(cacheDirectory.path, folder.path, target.path, nil)
498 } catch {
499 self?.alert("Can’t Rename", error.localizedDescription)
500 }
501 self?.rescan()
502 }
503 }
504 alert.addTextField { field in
505 field.text = folder.deletingPathExtension().lastPathComponent
506 field.autocapitalizationType = .words
507 field.clearButtonMode = .whileEditing
508 NotificationCenter.default.addObserver(
509 forName: UITextField.textDidChangeNotification, object: field, queue: .main
510 ) { _ in rename.isEnabled = Self.namesFile(field) }
511 }
512 alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
513 alert.addAction(rename)
514 alert.preferredAction = rename
515 present(alert, animated: true)
516 }
517
518 /// Whether `field` holds a name a notebook's folder can take.
519 private static func namesFile(_ field: UITextField) -> Bool {
520 let name = (field.text ?? "").trimmingCharacters(in: .whitespaces)
521 return !name.isEmpty && !name.hasPrefix(".") && !name.contains("/")
522 }
523
524 /// Copies the notebook beside itself, named as Files names a duplicate.
525 private func duplicate(_ folder: URL, of notebook: Notebook) {
526 // The copy takes what the open sections have stored.
527 for section in Section.all where section.notebook === notebook && !section.flush(10) {
528 return alert("Can’t Duplicate", "Some changes haven’t been saved to the notebook yet.")
529 }
530 let base = folder.deletingPathExtension().lastPathComponent
531 let ext = folder.pathExtension
532 var copy = folder
533 for n in 2... {
534 let name = base + " \(n)"
535 copy = folder.deletingLastPathComponent().appendingPathComponent(ext.isEmpty ? name : name + "." + ext)
536 if !FileManager.default.fileExists(atPath: copy.path) { break }
537 }
538 let target = copy
539 background({ () -> String? in
540 do { try FileManager.default.copyItem(at: folder, to: target) } catch { return error.localizedDescription }
541 return nil
542 }) { [weak self] problem in
543 if let problem { self?.alert("Can’t Duplicate", problem) }
544 self?.rescan()
545 }
546 }
547
548 private func share(_ folder: URL, from notebook: Notebook) {
549 let sheet = UIActivityViewController(activityItems: [folder], applicationActivities: nil)
550 if let popover = sheet.popoverPresentationController {
551 if let indexPath = dataSource.indexPath(for: .notebook(notebook.id)),
552 let cell = collectionView.cellForItem(at: indexPath)
553 {
554 popover.sourceView = cell
555 popover.sourceRect = cell.bounds
556 } else {
557 popover.sourceView = view
558 }
559 }
560 present(sheet, animated: true)
561 }
562
563 /// Moves the notebook to Recently Deleted, or, where the system keeps none, removes it
564 /// once confirmed.
565 private func delete(_ folder: URL, of notebook: Notebook) {
566 close(notebook, refusing: "Can’t Delete") { [weak self] in self?.remove(folder, of: notebook) }
567 }
568
569 private func remove(_ folder: URL, of notebook: Notebook) {
570 if (try? FileManager.default.trashItem(at: folder, resultingItemURL: nil)) != nil {
571 return rescan()
572 }
573 let name = folder.deletingPathExtension().lastPathComponent
574 let alert = UIAlertController(
575 title: "Delete “\(name)”?", message: "This notebook will be deleted immediately. You can’t undo this action.",
576 preferredStyle: .alert)
577 alert.addAction(UIAlertAction(title: "Cancel", style: .cancel) { [weak self] _ in
578 notebook.open { self?.reload() }
579 })
580 alert.addAction(UIAlertAction(title: "Delete", style: .destructive) { [weak self] _ in
581 do { try FileManager.default.removeItem(at: folder) } catch {
582 self?.alert("Can’t Delete", error.localizedDescription)
583 }
584 self?.rescan()
585 })
586 present(alert, animated: true)
587 }
588
589 override func collectionView(
590 _ collectionView: UICollectionView, contextMenuConfigurationForItemsAt indexPaths: [IndexPath], point: CGPoint
591 ) -> UIContextMenuConfiguration? {
592 guard indexPaths.count == 1, let item = dataSource.itemIdentifier(for: indexPaths[0]) else { return nil }
593 guard case .notebook(let id) = item, let notebook = notebook(id) else {
594 let moves = arrangeMenu(item)
595 return moves.isEmpty ? nil : UIContextMenuConfiguration(actionProvider: { _ in UIMenu(children: moves) })
596 }
597 return UIContextMenuConfiguration(actionProvider: { [weak self] _ in
598 UIMenu(children: (self?.menu(for: notebook).children ?? []) + (self?.arrangeMenu(item) ?? []))
599 })
600 }
601
602 override func collectionView(_ collectionView: UICollectionView, shouldSelectItemAt indexPath: IndexPath) -> Bool {
603 guard let item = dataSource.itemIdentifier(for: indexPath) else { return false }
604 switch item {
605 case .section(let id, let path):
606 let tab = notebook(id)?.tabs.first(where: { $0.path == path })
607 return tab?.readable == true || tab?.locked == true
608 case .status(let id):
609 guard let notebook = notebook(id), notebook.problem != nil else { return false }
610 let spinner = UIActivityIndicatorView(style: .medium)
611 spinner.startAnimating()
612 (collectionView.cellForItem(at: indexPath) as? UICollectionViewListCell)?.accessories = [
613 .customView(configuration: .init(customView: spinner, placement: .trailing()))
614 ]
615 notebook.open { [weak self] in self?.reload() }
616 case .notebook, .group:
617 // The row opens and closes, as a folder of Files' sidebar does.
618 guard let location = dataSource.sectionIdentifier(for: indexPath.section) else { return false }
619 var list = dataSource.snapshot(for: location)
620 if list.isExpanded(item) {
621 list.collapse([item])
622 collapsed.insert(item)
623 } else {
624 list.expand([item])
625 collapsed.remove(item)
626 }
627 dataSource.apply(list, to: location)
628 case .location: break
629 }
630 return false
631 }
632
633 override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
634 guard case .section(let id, let path) = dataSource.itemIdentifier(for: indexPath), let notebook = notebook(id),
635 let tab = notebook.tabs.first(where: { $0.path == path })
636 else { return }
637 if tab.locked { return unlock(tab, of: notebook, wrong: false) }
638 onOpen?(tab, notebook)
639 }
640
641 /// Asks for a protected section's password, as OneNote's Protected Section dialog does,
642 /// and opens it once unlocked.
643 private func unlock(_ tab: Tab, of notebook: Notebook, wrong: Bool) {
644 let alert = UIAlertController(
645 title: "Protected Section",
646 message: wrong ? "Password is incorrect." : "Section “\(tab.name)” is password protected.",
647 preferredStyle: .alert)
648 alert.addTextField { field in
649 field.isSecureTextEntry = true
650 field.placeholder = "Password"
651 }
652 alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
653 alert.addAction(UIAlertAction(title: "OK", style: .default) { [weak self, weak alert] _ in
654 let password = alert?.textFields?.first?.text ?? ""
655 notebook.unlock(tab.path, password: password) { unlocked in
656 self?.reload()
657 if unlocked, let opened = notebook.tabs.first(where: { $0.path == tab.path }) {
658 self?.onOpen?(opened, notebook)
659 } else {
660 self?.unlock(tab, of: notebook, wrong: true)
661 }
662 }
663 })
664 present(alert, animated: true)
665 }
666
667 private func add(_ notebook: Notebook) {
668 Notebooks.add(notebook)
669 openFirstSection(of: notebook)
670 }
671
672 private func openFirstSection(of notebook: Notebook) {
673 reload()
674 notebook.open { [weak self] in
675 self?.reload()
676 if let tab = notebook.tabs.first(where: \.readable) { self?.onOpen?(tab, notebook) }
677 }
678 }
679
680 /// Creates a notebook in iCloud Drive or On My iPhone, as OneNote's New Notebook does, and
681 /// opens it.
682 private func newNotebook(inCloud: Bool) {
683 guard let author = Author.name else {
684 return Author.ask(from: self) { [weak self] in self?.newNotebook(inCloud: inCloud) }
685 }
686 let alert = UIAlertController(
687 title: inCloud ? "New iCloud Notebook" : "New Notebook", message: nil, preferredStyle: .alert)
688 let create = UIAlertAction(title: "Create", style: .default) { [weak self, weak alert] _ in
689 let name = alert?.textFields?.first?.text?.trimmingCharacters(in: .whitespaces) ?? ""
690 self?.create(name, by: author, inCloud: inCloud)
691 }
692 alert.addTextField { field in
693 field.placeholder = "Name"
694 // A notebook in iCloud Drive starts named for it, so the list tells it apart.
695 if inCloud {
696 let taken = Set(Notebooks.inCloud.map(\.name))
697 field.text = (1...).lazy.map { $0 == 1 ? "iCloud Notebook" : "iCloud Notebook \($0)" }
698 .first { !taken.contains($0) }
699 }
700 create.isEnabled = field.text?.isEmpty == false
701 field.autocapitalizationType = .words
702 NotificationCenter.default.addObserver(
703 forName: UITextField.textDidChangeNotification, object: field, queue: .main
704 ) { _ in create.isEnabled = Self.namesFile(field) }
705 }
706 alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
707 alert.addAction(create)
708 alert.preferredAction = create
709 present(alert, animated: true)
710 }
711
712 private func create(_ name: String, by author: String, inCloud: Bool) {
713 let place = inCloud ? ICloud.documents : documentsDirectory
714 guard let folder = place?.appendingPathComponent(name) else { return }
715 guard !FileManager.default.fileExists(atPath: folder.path) else {
716 return alert("“\(name)” Already Exists", "Choose a different name.")
717 }
718 let (date, time) = titleDate()
719 background({ () -> String? in
720 var error: UnsafeMutablePointer<CChar>?
721 let made = sb_notebook_create(folder.path, cacheDirectory.path, author, date, time, &error)
722 let problem = take(error)
723 return made ? nil : problem ?? ""
724 }) { [weak self] problem in
725 guard let self else { return }
726 if let problem { return alert("Can’t Create Notebook", problem) }
727 if !inCloud && !Notebooks.showsOnDevice { Notebooks.showsOnDevice = true }
728 rescan(opening: inCloud ? .icloud(path: name) : .documents(path: name))
729 }
730 }
731
732 /// Copies the guide the app carries to On My iPhone and opens the copy, so the carried one
733 /// stays as shipped.
734 private func openGuide() {
735 let copy = documentsDirectory.appendingPathComponent(Notebooks.guide)
736 background({ () -> String? in
737 guard !FileManager.default.fileExists(atPath: copy.path) else { return nil }
738 guard let carried = Bundle.main.url(forResource: Notebooks.guide, withExtension: nil) else {
739 return "The guide is missing from this copy of Snowbound."
740 }
741 do { try FileManager.default.copyItem(at: carried, to: copy) } catch { return error.localizedDescription }
742 return nil
743 }) { [weak self] problem in
744 guard let self else { return }
745 if let problem { return alert("Can’t Open the Guide", problem) }
746 if !Notebooks.showsOnDevice { Notebooks.showsOnDevice = true }
747 rescan(opening: .documents(path: Notebooks.guide))
748 }
749 }
750
751 private func openFolder() {
752 let picker = UIDocumentPickerViewController(
753 forOpeningContentTypes: [.folder, UTType(filenameExtension: "one") ?? .data])
754 picker.delegate = self
755 present(picker, animated: true)
756 }
757
758 func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
759 if let url = urls.first { open(document: url) }
760 }
761
762 /// Opens the folder or section file at `url`, chosen here or opened from Files, at its first
763 /// section, listing it unless it is listed.
764 func open(document url: URL) {
765 let accessing = url.startAccessingSecurityScopedResource()
766 defer { if accessing { url.stopAccessingSecurityScopedResource() } }
767 let listed = Notebooks.all.first { notebook in
768 guard case .files(let bookmark) = notebook.source else { return false }
769 var stale = false
770 let location = try? URL(resolvingBookmarkData: bookmark, bookmarkDataIsStale: &stale)
771 return location?.standardizedFileURL == url.standardizedFileURL
772 }
773 if let listed {
774 // A notebook still opening at launch lists no sections yet.
775 if let tab = listed.tabs.first(where: \.readable) { onOpen?(tab, listed) } else { openFirstSection(of: listed) }
776 return
777 }
778 guard let bookmark = try? url.bookmarkData() else { return }
779 add(Notebook(name: url.deletingPathExtension().lastPathComponent, source: .files(bookmark: bookmark)))
780 }
781
782 private func connect() {
783 let server = ServerViewController()
784 server.onOpen = { [weak self] notebook in
785 self?.dismiss(animated: true)
786 self?.add(notebook)
787 }
788 present(UINavigationController(rootViewController: server), animated: true)
789 }
790
791 private func newSection(in notebook: Notebook) {
792 let alert = UIAlertController(title: "New Section", message: nil, preferredStyle: .alert)
793 alert.addTextField { field in
794 field.placeholder = "Name"
795 field.autocapitalizationType = .words
796 }
797 alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
798 alert.addAction(UIAlertAction(title: "Create", style: .default) { [weak self, weak alert] _ in
799 let name = alert?.textFields?.first?.text?.trimmingCharacters(in: .whitespaces) ?? ""
800 guard !name.isEmpty, let handle = notebook.handle else { return }
801 let (date, time) = titleDate()
802 let path = take(sb_library_new_section(handle, "", name, Author.name ?? "", date, time))
803 notebook.reload {
804 self?.reload()
805 if let tab = notebook.tabs.first(where: { $0.path == path }) { self?.onOpen?(tab, notebook) }
806 }
807 })
808 present(alert, animated: true)
809 }
810}
811
812extension UIFont {
813 func withTraits(_ traits: UIFontDescriptor.SymbolicTraits) -> UIFont {
814 fontDescriptor.withSymbolicTraits(traits).map { UIFont(descriptor: $0, size: 0) } ?? self
815 }
816}
817
818/// A section's pages, subpages indented, each page's conflict pages beneath it.
819final class PagesViewController: UITableViewController {
820 struct Item {
821 let row: Row
822 let version: Row.Version?
823 var id: String { version?.id ?? row.id }
824 }
825
826 private(set) var section: Section?
827 private(set) var items: [Item] = []
828 private var selected: String?
829 private lazy var search = SearchViewController.controller { [weak self] in self?.section }
830 /// Opens a page of the section.
831 var onOpen: ((Section, String) -> Void)?
832
833 /// The section strip prototype, over the pages.
834 private lazy var strip = SectionStrip { [weak self] tab, notebook in
835 (self?.view.window?.windowScene?.delegate as? SceneDelegate)?.open(tab, of: notebook)
836 }
837
838 init() {
839 super.init(style: .plain)
840 }
841
842 required init?(coder: NSCoder) { fatalError() }
843
844 override func viewDidLoad() {
845 super.viewDidLoad()
846 tableView.register(UITableViewCell.self, forCellReuseIdentifier: "page")
847 navigationItem.searchController = search
848 navigationItem.hidesSearchBarWhenScrolling = false
849 let add = UIBarButtonItem(
850 title: "New Page", image: UIImage(systemName: "plus"),
851 primaryAction: UIAction { [weak self] _ in self?.newPage(under: nil) })
852 if #available(iOS 26, *) {
853 navigationItem.preferredSearchBarPlacement = .integrated
854 toolbarItems = [navigationItem.searchBarPlacementBarButtonItem, .fixedSpace(), add]
855 } else {
856 toolbarItems = [.flexibleSpace(), add]
857 }
858 NotificationCenter.default.addObserver(
859 self, selector: #selector(changed), name: Section.changed, object: nil)
860 setUpArranging()
861 if Prototype.sectionStrip {
862 for direction in [UISwipeGestureRecognizer.Direction.left, .right] {
863 let swipe = UISwipeGestureRecognizer(target: self, action: #selector(swiped))
864 swipe.direction = direction
865 tableView.addGestureRecognizer(swipe)
866 }
867 }
868 }
869
870 @objc private func swiped(_ swipe: UISwipeGestureRecognizer) {
871 if let tab = strip.neighbour(swipe.direction == .left ? 1 : -1) { strip.pick(tab) }
872 }
873
874 override func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
875 Prototype.sectionStrip && strip.notebook != nil ? strip : nil
876 }
877
878 override func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat {
879 Prototype.sectionStrip && strip.notebook != nil ? 50 : 0
880 }
881
882 override func viewWillAppear(_ animated: Bool) {
883 super.viewWillAppear(animated)
884 navigationController?.setToolbarHidden(section == nil, animated: false)
885 }
886
887 func loading(_ tab: Tab) {
888 section = nil
889 items = []
890 title = tab.name
891 if Prototype.sectionStrip, let notebook = strip.notebook {
892 title = notebook.name
893 strip.select(tab.path)
894 }
895 tableView.reloadData()
896 var loading = UIContentUnavailableConfiguration.loading()
897 loading.text = "Opening…"
898 contentUnavailableConfiguration = loading
899 navigationController?.setToolbarHidden(true, animated: false)
900 }
901
902 /// Lets go of the section shown, as when its notebook goes.
903 func clear() {
904 section = nil
905 items = []
906 title = nil
907 tableView.reloadData()
908 contentUnavailableConfiguration = nil
909 navigationController?.setToolbarHidden(true, animated: false)
910 }
911
912 func failed(_ problem: String?) {
913 var empty = UIContentUnavailableConfiguration.empty()
914 empty.text = "Can’t Open Section"
915 empty.secondaryText = problem
916 contentUnavailableConfiguration = empty
917 }
918
919 func load(_ section: Section) {
920 self.section = section
921 title = section.tab.name
922 if Prototype.sectionStrip {
923 title = section.notebook.name
924 strip.show(section.notebook, selected: section.tab.path)
925 }
926 navigationController?.setToolbarHidden(false, animated: false)
927 reload()
928 }
929
930 func select(_ id: String) {
931 selected = id
932 if let index = items.firstIndex(where: { $0.id == id }) {
933 tableView.selectRow(at: IndexPath(row: index, section: 0), animated: false, scrollPosition: .none)
934 }
935 }
936
937 func startSearching() {
938 search.isActive = true
939 DispatchQueue.main.async { self.search.searchBar.becomeFirstResponder() }
940 }
941
942 private func reload() {
943 items = (section?.rows ?? []).flatMap { row in
944 [Item(row: row, version: nil)] + row.versions.map { Item(row: row, version: $0) }
945 }
946 tableView.reloadData()
947 if let selected { select(selected) }
948 var empty = UIContentUnavailableConfiguration.empty()
949 empty.text = "No Pages"
950 contentUnavailableConfiguration = section != nil && items.isEmpty ? empty : nil
951 }
952
953 @objc private func changed(_ notification: Notification) {
954 guard notification.object as AnyObject? === section else { return }
955 reload()
956 }
957
958 private func newPage(under parent: String?) {
959 guard let section, let id = section.newPage(under: parent) else { return }
960 (view.window?.windowScene?.delegate as? SceneDelegate)?.show(section: section, page: id, titleFocus: true)
961 }
962
963 private func delete(_ item: Item) {
964 section?.delete(item.id)
965 }
966
967 override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { items.count }
968
969 override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
970 let item = items[indexPath.row]
971 let cell = tableView.dequeueReusableCell(withIdentifier: "page", for: indexPath)
972 var content = cell.defaultContentConfiguration()
973 let level = CGFloat(item.row.level - 1)
974 if let version = item.version {
975 // OneNote lists a conflict page by whose version it is and when the merge made it.
976 content.text = version.user.isEmpty ? "Conflicting Version" : version.user
977 content.secondaryText = version.created.map {
978 DateFormatter.localizedString(
979 from: Date(timeIntervalSince1970: $0), dateStyle: .short, timeStyle: .short)
980 }
981 content.image = UIImage(systemName: "doc.on.doc")
982 content.imageProperties.tintColor = .systemOrange
983 content.textProperties.color = .secondaryLabel
984 content.directionalLayoutMargins.leading += (level + 1) * 20
985 } else {
986 content.text = item.row.title.isEmpty ? "Untitled Page" : item.row.title
987 if item.row.title.isEmpty || item.row.level > 1 { content.textProperties.color = .secondaryLabel }
988 content.directionalLayoutMargins.leading += level * 20
989 if !item.row.versions.isEmpty {
990 content.image = UIImage(systemName: "exclamationmark.triangle.fill")
991 content.imageProperties.tintColor = .systemOrange
992 }
993 }
994 cell.contentConfiguration = content
995 cell.accessibilityCustomActions = arrangeActions(item)
996 return cell
997 }
998
999 override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
1000 guard let section else { return }
1001 selected = items[indexPath.row].id
1002 onOpen?(section, items[indexPath.row].id)
1003 }
1004
1005 override func tableView(
1006 _ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath
1007 ) -> UISwipeActionsConfiguration? {
1008 // A swipe across the list changes section instead; the context menu deletes.
1009 if Prototype.sectionStrip { return nil }
1010 let item = items[indexPath.row]
1011 let delete = UIContextualAction(style: .destructive, title: "Delete") { [weak self] _, _, done in
1012 self?.delete(item)
1013 done(true)
1014 }
1015 delete.image = UIImage(systemName: "trash")
1016 return UISwipeActionsConfiguration(actions: [delete])
1017 }
1018
1019 override func tableView(
1020 _ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint
1021 ) -> UIContextMenuConfiguration? {
1022 let item = items[indexPath.row]
1023 return UIContextMenuConfiguration(actionProvider: { [weak self] _ in
1024 var actions: [UIMenuElement] = []
1025 if item.version == nil {
1026 actions.append(UIAction(title: "New Subpage", image: UIImage(systemName: "text.badge.plus")) { _ in
1027 self?.newPage(under: item.row.id)
1028 })
1029 actions += self?.arrangeMenu(item) ?? []
1030 }
1031 actions.append(
1032 UIAction(
1033 title: item.version == nil ? "Delete Page" : "Delete Conflict Page",
1034 image: UIImage(systemName: "trash"), attributes: .destructive
1035 ) { _ in self?.delete(item) })
1036 return UIMenu(children: actions)
1037 })
1038 }
1039}
1040
1041/// A page `sb_search` found.
1042struct Found: Decodable {
1043 let notebook: Int
1044 let section: String
1045 let page: String
1046 let title: String
1047 let inTitle: Bool
1048 let titleHits: [[Int]]
1049 let snippet: String
1050 let snippetHits: [[Int]]
1051}
1052
1053/// The desktop's search over every open notebook's pages: title matches, then body matches,
1054/// each with its notebook and section. Searched from a section, it offers OneNote's scopes,
1055/// starting at This Section.
1056final class SearchViewController: UITableViewController, UISearchResultsUpdating, UISearchBarDelegate,
1057 UISearchControllerDelegate
1058{
1059 private enum Scope: Int, CaseIterable {
1060 case section, notebook, all
1061
1062 var title: String {
1063 switch self {
1064 case .section: "This Section"
1065 case .notebook: "This Notebook"
1066 case .all: "All Notebooks"
1067 }
1068 }
1069 }
1070
1071 /// The section searched from, which the scopes narrow to.
1072 private let place: () -> Section?
1073 private weak var search: UISearchController?
1074 private var found: [(found: Found, notebook: Notebook)] = []
1075 private var query = ""
1076 private var searching: DispatchWorkItem?
1077
1078 private init(place: @escaping () -> Section?) {
1079 self.place = place
1080 super.init(style: .insetGrouped)
1081 }
1082
1083 required init?(coder: NSCoder) { fatalError() }
1084
1085 /// A search bar whose results this shows, with scopes where `place` gives the section
1086 /// it is searched from.
1087 static func controller(place: (() -> Section?)? = nil) -> UISearchController {
1088 let results = SearchViewController(place: place ?? { nil })
1089 let search = UISearchController(searchResultsController: results)
1090 results.search = search
1091 search.searchResultsUpdater = results
1092 search.delegate = results
1093 search.searchBar.delegate = results
1094 search.searchBar.placeholder = "Search"
1095 if place != nil { search.searchBar.scopeButtonTitles = Scope.allCases.map(\.title) }
1096 return search
1097 }
1098
1099 override func viewDidLoad() {
1100 super.viewDidLoad()
1101 tableView.register(UITableViewCell.self, forCellReuseIdentifier: "found")
1102 }
1103
1104 func searchBar(_ searchBar: UISearchBar, selectedScopeButtonIndexDidChange selectedScope: Int) {
1105 if let search { updateSearchResults(for: search) }
1106 }
1107
1108 /// A scope chosen lasts for the search; the next starts at This Section again.
1109 func didDismissSearchController(_ searchController: UISearchController) {
1110 searchController.searchBar.selectedScopeButtonIndex = Scope.section.rawValue
1111 }
1112
1113 func updateSearchResults(for controller: UISearchController) {
1114 searching?.cancel()
1115 let query = controller.searchBar.text?.trimmingCharacters(in: .whitespaces) ?? ""
1116 guard !query.isEmpty else { return show([], query: query) }
1117 let open = place()
1118 let scope = open == nil ? .all : Scope(rawValue: controller.searchBar.selectedScopeButtonIndex) ?? .all
1119 let notebooks = scope == .all ? Notebooks.all : open.map { [$0.notebook] } ?? []
1120 let searched = notebooks.filter { $0.handle != nil }
1121 let libraries = searched.map { Int(bitPattern: $0.handle) }
1122 let section = scope == .section ? open?.tab.path : nil
1123 // Each keystroke waits for the next before the notebooks are read.
1124 let work = DispatchWorkItem { [weak self] in
1125 if self?.found.isEmpty == true {
1126 var loading = UIContentUnavailableConfiguration.loading()
1127 loading.text = "Searching…"
1128 self?.contentUnavailableConfiguration = loading
1129 }
1130 background({
1131 let pointers = libraries.map { OpaquePointer(bitPattern: $0) }
1132 return decode([Found].self, sb_search(pointers, pointers.count, section, query)) ?? []
1133 }) { [weak self] found in
1134 let now = controller.searchBar.text?.trimmingCharacters(in: .whitespaces)
1135 guard now == query, open == nil || controller.searchBar.selectedScopeButtonIndex == scope.rawValue
1136 else { return }
1137 self?.show(found.map { ($0, searched[$0.notebook]) }, query: query)
1138 }
1139 }
1140 searching = work
1141 DispatchQueue.main.asyncAfter(deadline: .now() + 0.25, execute: work)
1142 }
1143
1144 private func show(_ found: [(found: Found, notebook: Notebook)], query: String) {
1145 self.found = found
1146 self.query = query
1147 tableView.reloadData()
1148 contentUnavailableConfiguration =
1149 found.isEmpty && !query.isEmpty ? UIContentUnavailableConfiguration.search() : nil
1150 }
1151
1152 /// The results in the title's group or the body's, as the desktop's headings split them.
1153 private func group(_ section: Int) -> ArraySlice<(found: Found, notebook: Notebook)> {
1154 let titled = found.prefix { $0.found.inTitle }
1155 return titled.isEmpty || section == 1 ? found.dropFirst(titled.count) : titled
1156 }
1157
1158 override func numberOfSections(in tableView: UITableView) -> Int {
1159 (found.first?.found.inTitle == true ? 1 : 0) + (found.last?.found.inTitle == false ? 1 : 0)
1160 }
1161
1162 override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
1163 let pages = group(section)
1164 return "\(pages.first?.found.inTitle == true ? "Title" : "Body") contains: \(query) (\(pages.count))"
1165 }
1166
1167 override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
1168 group(section).count
1169 }
1170
1171 private func result(_ indexPath: IndexPath) -> (found: Found, notebook: Notebook) {
1172 let pages = group(indexPath.section)
1173 return pages[pages.startIndex + indexPath.row]
1174 }
1175
1176 override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
1177 let (page, notebook) = result(indexPath)
1178 let cell = tableView.dequeueReusableCell(withIdentifier: "found", for: indexPath)
1179 var content = UIListContentConfiguration.subtitleCell()
1180 let font = UIFont.preferredFont(forTextStyle: .body)
1181 content.attributedText =
1182 page.title.isEmpty
1183 ? NSAttributedString(string: "Untitled Page", attributes: [.font: font])
1184 : marked(page.title, page.titleHits, font: font, color: .label)
1185 let tab = notebook.tabs.first { $0.path == page.section }
1186 content.image = UIImage(systemName: "rectangle.portrait.fill")
1187 content.imageProperties.tintColor = tab?.uiColor ?? .systemGray
1188 // Where the page is, as the desktop names it: notebook, groups and section.
1189 let groups: [String] = tab?.group.split(separator: "/").map(String.init) ?? []
1190 let place = ([notebook.name] + groups + [tab?.name ?? ""]).filter { !$0.isEmpty }.joined(separator: " › ")
1191 let detail = NSMutableAttributedString(
1192 string: place + "\n",
1193 attributes: [.font: UIFont.preferredFont(forTextStyle: .footnote), .foregroundColor: UIColor.secondaryLabel])
1194 detail.append(
1195 marked(
1196 page.snippet, page.snippetHits, font: .preferredFont(forTextStyle: .subheadline),
1197 color: .secondaryLabel))
1198 content.secondaryAttributedText = detail
1199 content.secondaryTextProperties.numberOfLines = 3
1200 cell.contentConfiguration = content
1201 return cell
1202 }
1203
1204 /// `text` with `hits`, UTF-16 ranges, marked in OneNote's yellow as the desktop marks them.
1205 private func marked(_ text: String, _ hits: [[Int]], font: UIFont, color: UIColor) -> NSAttributedString {
1206 let marked = NSMutableAttributedString(string: text, attributes: [.font: font, .foregroundColor: color])
1207 for hit in hits where hit.count == 2 && hit[1] <= marked.length {
1208 marked.addAttributes(
1209 [.backgroundColor: UIColor(red: 1, green: 0.82, blue: 0, alpha: 0.45), .foregroundColor: UIColor.label],
1210 range: NSRange(location: hit[0], length: hit[1] - hit[0]))
1211 }
1212 return marked
1213 }
1214
1215 override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
1216 let (page, notebook) = result(indexPath)
1217 (view.window?.windowScene?.delegate as? SceneDelegate)?
1218 .open(page.section, of: notebook, page: page.page, reveal: .text(query))
1219 }
1220}