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