| 1 | import UIKit |
| 2 | import 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. |
| 6 | final 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 | |
| 812 | extension 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. |
| 819 | final 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. |
| 1042 | struct 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. |
| 1056 | final 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 | } |