1import Foundation
2import Security
3import UIKit
4
5/// A string the Rust library allocated, taken and freed.
6func take(_ text: UnsafeMutablePointer<CChar>?) -> String? {
7 guard let text else { return nil }
8 defer { sb_string_free(text) }
9 return String(cString: text)
10}
11
12func decode<T: Decodable>(_ type: T.Type, _ json: UnsafeMutablePointer<CChar>?) -> T? {
13 take(json).flatMap { try? JSONDecoder().decode(type, from: Data($0.utf8)) }
14}
15
16/// A page's title date and time, as OneNote writes them: the long date and short time.
17func titleDate(_ date: Date = Date()) -> (date: String, time: String) {
18 let formatter = DateFormatter()
19 formatter.dateStyle = .full
20 formatter.timeStyle = .none
21 let day = formatter.string(from: date)
22 formatter.dateStyle = .none
23 formatter.timeStyle = .short
24 return (day, formatter.string(from: date))
25}
26
27/// The user name edits are stored under, OneNote's Personalize setting; asked once.
28enum Author {
29 private static let key = "author"
30
31 static var name: String? {
32 get { UserDefaults.standard.string(forKey: key) }
33 set {
34 UserDefaults.standard.set(newValue, forKey: key)
35 // Open sections name it in their edits from now on.
36 for section in Section.all { sb_section_set_author(section.handle, newValue ?? "") }
37 }
38 }
39
40 /// Asks for the user name OneNote shows beside changes, then runs `then`.
41 static func ask(from controller: UIViewController, then: @escaping () -> Void) {
42 let alert = UIAlertController(
43 title: "Personalize",
44 message: "OneNote shows your user name beside the pages and changes you make.",
45 preferredStyle: .alert)
46 let save = UIAlertAction(title: "Continue", style: .default) { [weak alert] _ in
47 name = alert?.textFields?.first?.text?.trimmingCharacters(in: .whitespaces)
48 then()
49 }
50 alert.addTextField { field in
51 field.text = name
52 field.placeholder = "User name"
53 field.textContentType = .name
54 field.autocapitalizationType = .words
55 save.isEnabled = !(field.text ?? "").isEmpty
56 NotificationCenter.default.addObserver(
57 forName: UITextField.textDidChangeNotification, object: field, queue: .main
58 ) { _ in save.isEnabled = !(field.text ?? "").trimmingCharacters(in: .whitespaces).isEmpty }
59 }
60 alert.addAction(save)
61 alert.preferredAction = save
62 controller.present(alert, animated: true)
63 }
64}
65
66/// An SMB share and the folder of a notebook on it.
67struct Server: Codable, Equatable {
68 /// `host` or `host:port`.
69 var host: String
70 var share: String
71 /// Empty for a guest.
72 var user: String
73 /// The notebook folder, `/`-separated from the share's top.
74 var root: String
75
76 var hostName: String { host.split(separator: ":").first.map(String.init) ?? host }
77 var port: Int { host.split(separator: ":").dropFirst().first.flatMap { Int($0) } ?? 445 }
78}
79
80/// The password of an SMB account, kept in the Keychain as Files keeps a server's.
81enum Keychain {
82 private static func query(_ server: Server) -> [String: Any] {
83 [
84 kSecClass as String: kSecClassInternetPassword,
85 kSecAttrServer as String: server.hostName,
86 kSecAttrPort as String: server.port,
87 kSecAttrProtocol as String: kSecAttrProtocolSMB,
88 kSecAttrAccount as String: server.user,
89 ]
90 }
91
92 static func password(_ server: Server) -> String? {
93 var query = query(server)
94 query[kSecReturnData as String] = true
95 var result: AnyObject?
96 guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess, let data = result as? Data
97 else { return nil }
98 return String(data: data, encoding: .utf8)
99 }
100
101 static func save(_ password: String, for server: Server) {
102 SecItemDelete(query(server) as CFDictionary)
103 var item = query(server)
104 item[kSecValueData as String] = Data(password.utf8)
105 item[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
106 SecItemAdd(item as CFDictionary, nil)
107 }
108}
109
110/// Where a notebook lives, as the notebook list keeps it.
111enum Source: Codable, Equatable {
112 /// A folder or section file chosen in Files, as a security-scoped bookmark.
113 case files(bookmark: Data)
114 /// A folder in the app's Documents, which Files shows as Snowbound's.
115 case documents(path: String)
116 /// A folder in Snowbound's folder in iCloud Drive (`ICloud.documents`).
117 case icloud(path: String)
118 /// A notebook folder on an SMB share, opened through Snowbound's own client.
119 case server(Server)
120 /// A folder or section at an absolute path, for scripted runs.
121 case path(String)
122}
123
124/// A section tab from `sb_library_sections`.
125struct Tab: Decodable, Equatable {
126 let name: String
127 /// The catalog path `sb_section_open` takes.
128 let path: String
129 /// The section group holding it, `/`-separated; empty at the notebook's top.
130 let group: String
131 let color: [UInt8]
132 let readable: Bool
133 /// Password protected and not unlocked: `Notebook.unlock` opens it.
134 let locked: Bool
135 /// Not on this device yet; `Notebook` asks iCloud Drive for it.
136 let downloading: Bool
137 /// Why the file could not be read, where retrying or repair may help.
138 let problem: String?
139
140 var uiColor: UIColor { UIColor(rgb: color) }
141}
142
143extension UIImage {
144 /// A notebook's binder, the desktop's art, `side` points square: its cover in `color`, or
145 /// the art's own orange.
146 static func notebook(_ color: UIColor?, side: CGFloat = 28) -> UIImage {
147 let frame = CGRect(x: 0, y: 0, width: side, height: side)
148 let cover = color ?? UIColor(rgb: [0xf3, 0x9c, 0x28])
149 return UIGraphicsImageRenderer(size: frame.size).image { _ in
150 UIImage(named: "NotebookBack")?.draw(in: frame)
151 UIImage(named: "NotebookCover")?.withTintColor(cover, renderingMode: .alwaysOriginal).draw(in: frame)
152 UIImage(named: "Notebook")?.draw(in: frame)
153 }.withRenderingMode(.alwaysOriginal)
154 }
155}
156
157extension UIViewController {
158 /// Tells the reader `title` and `message` in an alert they dismiss with OK.
159 func alert(_ title: String, _ message: String) {
160 let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
161 alert.addAction(UIAlertAction(title: "OK", style: .default))
162 present(alert, animated: true)
163 }
164}
165
166/// Runs `work` off the main thread, then `done` with its result on it.
167func background<T>(_ work: @escaping () -> T, done: @escaping (T) -> Void) {
168 DispatchQueue.global(qos: .userInitiated).async {
169 let result = work()
170 DispatchQueue.main.async { done(result) }
171 }
172}
173
174/// Where section replicas live: kept, as they hold edits not yet published.
175let cacheDirectory: URL = {
176 let url = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
177 .appendingPathComponent("Sections", isDirectory: true)
178 try? FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
179 return url
180}()
181
182let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
183
184/// An open notebook: its Rust library and the sections it lists.
185final class Notebook {
186 /// A UUID, or `Documents/` and the folder's name for a notebook On My iPhone.
187 let id: String
188 let source: Source
189 private(set) var name: String
190 private(set) var handle: OpaquePointer?
191 private(set) var tabs: [Tab] = []
192 /// The notebook's colour, which its binder's cover takes in the list; none where it has none.
193 private(set) var color: UIColor?
194 /// Why the notebook could not be opened, while it cannot.
195 private(set) var problem: String?
196 /// The Files location being read, held open while the notebook is.
197 private var accessed: URL?
198 /// Reports other apps' changes to a folder on this device to the library.
199 private var presenter: FolderPresenter?
200 /// Lists the sections again while iCloud Drive downloads some.
201 private var recheck: DispatchWorkItem?
202 /// Posted on the main thread when the sections list again on their own.
203 static let listed = Notification.Name("NotebookListed")
204
205 init(id: String = UUID().uuidString, name: String, source: Source) {
206 self.id = id
207 self.name = name
208 self.source = source
209 }
210
211 deinit {
212 presenter?.stop()
213 if let handle { sb_library_free(handle) }
214 accessed?.stopAccessingSecurityScopedResource()
215 }
216
217 /// Opens the library off the main thread and lists its sections; `done` runs on the main
218 /// thread either way.
219 func open(done: @escaping () -> Void) {
220 let source = source
221 // The library, the Files location held open, why it failed, and a local folder to watch.
222 background({ () -> (OpaquePointer?, URL?, String?, URL?) in
223 var error: UnsafeMutablePointer<CChar>?
224 switch source {
225 case .server(let server):
226 let library = sb_library_server(
227 server.host, server.share, server.user, Keychain.password(server) ?? "", "", server.root,
228 cacheDirectory.path, &error)
229 return (library, nil, take(error), nil)
230 case .files(let bookmark):
231 var stale = false
232 guard let url = try? URL(resolvingBookmarkData: bookmark, bookmarkDataIsStale: &stale) else {
233 return (nil, nil, "The notebook was moved or deleted.", nil)
234 }
235 let accessed = url.startAccessingSecurityScopedResource() ? url : nil
236 let local = onThisDevice(url)
237 let (library, problem) = coordinatedOpen(url, local: local)
238 return (library, accessed, problem, local ? url : nil)
239 case .documents(let path):
240 let url = documentsDirectory.appendingPathComponent(path)
241 let library = sb_library_open(url.path, cacheDirectory.path, true, &error)
242 return (library, nil, take(error), url)
243 case .icloud(let path):
244 guard let url = ICloud.documents?.appendingPathComponent(path) else {
245 return (nil, nil, "Turn on iCloud Drive in Settings to open this notebook.", nil)
246 }
247 let (library, problem) = coordinatedOpen(url, local: false)
248 return (library, nil, problem, url)
249 case .path(let path):
250 let url = URL(fileURLWithPath: path)
251 let local = onThisDevice(url)
252 return (sb_library_open(path, cacheDirectory.path, local, &error), nil, take(error), local ? url : nil)
253 }
254 }) { [self] (library, accessed, problem, watched) in
255 self.accessed?.stopAccessingSecurityScopedResource()
256 self.accessed = accessed
257 presenter?.stop()
258 presenter = nil
259 if let handle { sb_library_free(handle) }
260 handle = library
261 if let library, let watched { presenter = FolderPresenter(watching: watched, for: library) }
262 self.problem = library == nil ? (problem ?? "The notebook can’t be read.") : nil
263 follow()
264 reload(done: done)
265 }
266 }
267
268 /// Works offline or online, as Work Offline stands.
269 func follow() {
270 if let handle { sb_library_set_offline(handle, Sync.offline) }
271 }
272
273 /// Where the notebook is kept, as the sync sheet names it.
274 var location: String {
275 switch source {
276 case .files: "In Files"
277 case .documents: "On My \(UIDevice.current.model), in Snowbound’s folder"
278 case .icloud: "In iCloud Drive"
279 case .server(let server):
280 "smb://\(server.host)/\([server.share, server.root].filter { !$0.isEmpty }.joined(separator: "/"))"
281 case .path(let path): path
282 }
283 }
284
285 /// Unlocks the protected section at `path` with `password` off the main thread, then lists
286 /// the sections again; `done` learns whether the password matched.
287 func unlock(_ path: String, password: String, done: @escaping (Bool) -> Void) {
288 guard let handle else { return done(false) }
289 let pointer = Int(bitPattern: handle)
290 background({ sb_library_unlock(OpaquePointer(bitPattern: pointer), path, password) }) { [self] unlocked in
291 reload { done(unlocked) }
292 }
293 }
294
295 /// Locks every protected section, as OneNote's Lock All does.
296 func lockAll() {
297 if let handle { sb_library_lock_all(handle) }
298 }
299
300 /// Lists the sections again, off the main thread.
301 func reload(done: @escaping () -> Void) {
302 guard let handle else {
303 tabs = []
304 return done()
305 }
306 let pointer = Int(bitPattern: handle)
307 background({ () -> ([Tab]?, Int32) in
308 let library = OpaquePointer(bitPattern: pointer)
309 return (decode([Tab].self, sb_library_sections(library)), sb_library_color(library))
310 }) { [self] (tabs, color) in
311 if let tabs { self.tabs = tabs }
312 self.color = color < 0 ? nil : UIColor(rgb: [16, 8, 0].map { UInt8(truncatingIfNeeded: color >> $0) })
313 download()
314 done()
315 }
316 }
317
318 /// Asks iCloud Drive for the notebook files it keeps elsewhere, which iOS lists as
319 /// `.Name.icloud` or dataless under their names, and lists the sections again until they
320 /// arrive.
321 private func download() {
322 let folder: URL? =
323 switch source {
324 case .files: accessed
325 case .documents(let path): documentsDirectory.appendingPathComponent(path)
326 case .icloud(let path): ICloud.documents?.appendingPathComponent(path)
327 case .path(let path): URL(fileURLWithPath: path)
328 case .server: nil
329 }
330 guard let folder, recheck == nil,
331 let files = FileManager.default.enumerator(
332 at: folder, includingPropertiesForKeys: [.ubiquitousItemDownloadingStatusKey])
333 else { return }
334 var waiting = false
335 for case let file as URL in files {
336 let stub = file.lastPathComponent.hasPrefix(".") && file.pathExtension == "icloud"
337 let status = try? file.resourceValues(forKeys: [.ubiquitousItemDownloadingStatusKey])
338 guard stub || status?.ubiquitousItemDownloadingStatus == .notDownloaded else { continue }
339 let real = file.deletingLastPathComponent().appendingPathComponent(
340 String(file.deletingPathExtension().lastPathComponent.dropFirst()))
341 try? FileManager.default.startDownloadingUbiquitousItem(at: stub ? real : file)
342 waiting = true
343 }
344 guard waiting else { return }
345 let recheck = DispatchWorkItem { [weak self] in
346 self?.recheck = nil
347 self?.reload { NotificationCenter.default.post(name: Self.listed, object: self) }
348 }
349 self.recheck = recheck
350 DispatchQueue.main.asyncAfter(deadline: .now() + 5, execute: recheck)
351 }
352
353 /// Stops hearing other apps' writes while in the background, where a suspended presenter
354 /// can deadlock their coordinated writes.
355 func pause() { presenter?.stop() }
356
357 /// Hears other apps' writes again, and checks the sections and their list for those
358 /// missed meanwhile.
359 func resume() {
360 presenter?.resume()
361 reload { NotificationCenter.default.post(name: Self.listed, object: self) }
362 }
363}
364
365/// Opens the library at `url` within a coordinated read, which brings a cloud folder's files
366/// down before they are read; nil with why where it cannot.
367private func coordinatedOpen(_ url: URL, local: Bool) -> (OpaquePointer?, String?) {
368 var library: OpaquePointer?
369 var error: UnsafeMutablePointer<CChar>?
370 var coordination: NSError?
371 NSFileCoordinator().coordinate(readingItemAt: url, options: [], error: &coordination) { url in
372 library = sb_library_open(url.path, cacheDirectory.path, local, &error)
373 }
374 return (library, take(error) ?? coordination?.localizedDescription)
375}
376
377/// Whether `url` is kept on this device, rather than by a file provider that keeps it elsewhere
378/// too, as iCloud Drive does.
379private func onThisDevice(_ url: URL) -> Bool {
380 let values = try? url.resourceValues(forKeys: [.volumeIsLocalKey, .isUbiquitousItemKey])
381 return values?.volumeIsLocal == true && values?.isUbiquitousItem != true
382}
383
384/// Tells a library which files of its folder on this device changed, as file coordination
385/// reports other apps' writes; the library checks just those sections.
386final class FolderPresenter: NSObject, NSFilePresenter {
387 let presentedItemURL: URL?
388 let presentedItemOperationQueue: OperationQueue = {
389 let queue = OperationQueue()
390 queue.maxConcurrentOperationCount = 1
391 return queue
392 }()
393 private let root: String
394 private let library: OpaquePointer
395
396 init(watching url: URL, for library: OpaquePointer) {
397 presentedItemURL = url
398 root = url.standardizedFileURL.resolvingSymlinksInPath().path
399 self.library = library
400 super.init()
401 NSFileCoordinator.addFilePresenter(self)
402 }
403
404 func resume() {
405 NSFileCoordinator.addFilePresenter(self)
406 touched("")
407 }
408
409 /// Stops reporting, before the library goes or the app leaves the foreground.
410 func stop() {
411 NSFileCoordinator.removeFilePresenter(self)
412 presentedItemOperationQueue.waitUntilAllOperationsAreFinished()
413 }
414
415 func presentedItemDidChange() { touched("") }
416 func presentedSubitemDidChange(at url: URL) { touched(url) }
417 func presentedSubitemDidAppear(at url: URL) { touched(url) }
418 /// Another device's commit that lost in iCloud Drive, which changes no file.
419 func presentedSubitem(at url: URL, didGain version: NSFileVersion) { touched(url) }
420 func presentedSubitem(at oldURL: URL, didMoveTo newURL: URL) {
421 touched(oldURL)
422 touched(newURL)
423 }
424 func accommodatePresentedSubitemDeletion(at url: URL, completionHandler: @escaping (Error?) -> Void) {
425 touched(url)
426 completionHandler(nil)
427 }
428
429 private func touched(_ url: URL) {
430 let path = url.standardizedFileURL.resolvingSymlinksInPath().path
431 touched(path.hasPrefix(root + "/") ? String(path.dropFirst(root.count + 1)) : "")
432 }
433
434 private func touched(_ path: String) {
435 sb_library_touched(library, path)
436 }
437}
438
439/// The notebooks the list shows: those in Documents, and those opened from elsewhere, kept
440/// across launches.
441enum Notebooks {
442 private struct Entry: Codable {
443 let id: String
444 let name: String
445 let source: Source
446 }
447
448 private static let key = "notebooks"
449 private static let hidden = "hidesOnDevice"
450 /// The notebooks' ids in the order the reader put them in.
451 private static let order = "notebookOrder"
452 /// The Snowbound Guide's folder, in the app and in Documents once copied there.
453 static let guide = "Snowbound Guide"
454 /// Whether the list offers the guide; off until Clover has read it through.
455 static let guideOffered = false
456 private static let scripted = ProcessInfo.processInfo.environment["SNOWBOUND_NOTEBOOK"]
457 /// The folders and sections in Documents, as On My iPhone lists them.
458 private(set) static var onDevice: [Notebook] = []
459 /// The folders in Snowbound's folder in iCloud Drive.
460 private(set) static var inCloud: [Notebook] = []
461 private(set) static var elsewhere: [Notebook] = []
462 static var all: [Notebook] { inCloud + onDevice + elsewhere }
463
464 /// Whether the list shows On My iPhone, as Files lets a location be hidden.
465 static var showsOnDevice: Bool {
466 get { scripted == nil && !UserDefaults.standard.bool(forKey: hidden) }
467 set { UserDefaults.standard.set(!newValue, forKey: hidden) }
468 }
469
470 /// The kept notebooks and those in Documents.
471 static func load() {
472 if let scripted {
473 elsewhere = [
474 Notebook(
475 name: URL(fileURLWithPath: scripted).deletingPathExtension().lastPathComponent,
476 source: .path(scripted))
477 ]
478 return
479 }
480 if let data = UserDefaults.standard.data(forKey: key),
481 let entries = try? JSONDecoder().decode([Entry].self, from: data)
482 {
483 elsewhere = ordered(entries.compactMap { entry in
484 switch entry.source {
485 case .documents, .icloud: return nil
486 default: break
487 }
488 return Notebook(id: entry.id, name: entry.name, source: entry.source)
489 })
490 } else {
491 // The notebook the first version remembered from Files.
492 if let bookmark = UserDefaults.standard.data(forKey: "notebook") {
493 var stale = false
494 let name = (try? URL(resolvingBookmarkData: bookmark, bookmarkDataIsStale: &stale))?
495 .deletingPathExtension().lastPathComponent
496 elsewhere.append(Notebook(name: name ?? "Notebook", source: .files(bookmark: bookmark)))
497 }
498 save()
499 }
500 scan()
501 }
502
503 /// Lists Documents again, keeping the notebooks still there; returns those new to the list.
504 @discardableResult
505 static func scan() -> [Notebook] {
506 let folder = showsOnDevice ? documentsDirectory : nil
507 return rescan(folder, "Documents/", &onDevice) { .documents(path: $0) }
508 }
509
510 /// Lists Snowbound's folder in iCloud Drive again, as `scan` lists Documents; none while
511 /// iCloud Drive is off.
512 @discardableResult
513 static func scanICloud() -> [Notebook] {
514 rescan(scripted == nil ? ICloud.documents : nil, "iCloud/", &inCloud) { .icloud(path: $0) }
515 }
516
517 /// Lists `folder`'s notebooks into `list`, keeping those still there and naming new ones by
518 /// `prefix` and their `source`; returns the new ones.
519 private static func rescan(
520 _ folder: URL?, _ prefix: String, _ list: inout [Notebook], source: (String) -> Source
521 ) -> [Notebook] {
522 guard let folder else {
523 list = []
524 return []
525 }
526 var added: [Notebook] = []
527 list = ordered(
528 notebooks(in: folder).map { [list] name in
529 if let kept = list.first(where: { $0.id == prefix + name }) { return kept }
530 let notebook = Notebook(
531 id: prefix + name, name: (name as NSString).deletingPathExtension, source: source(name))
532 added.append(notebook)
533 return notebook
534 })
535 return added
536 }
537
538 /// `list` in the reader's order, those it doesn't name after in their own.
539 private static func ordered(_ list: [Notebook]) -> [Notebook] {
540 let order = UserDefaults.standard.stringArray(forKey: order) ?? []
541 let rank = { (notebook: Notebook) in order.firstIndex(of: notebook.id) ?? order.count }
542 return list.enumerated().sorted { (rank($0.element), $0.offset) < (rank($1.element), $1.offset) }.map(\.element)
543 }
544
545 /// The notebooks listed in the same place as `notebook`, in order.
546 static func siblings(of notebook: Notebook) -> [Notebook] {
547 [inCloud, onDevice, elsewhere].first { $0.contains { $0 === notebook } } ?? []
548 }
549
550 /// Puts `notebook` at `index` among the notebooks of its place, and keeps that order.
551 static func move(_ notebook: Notebook, to index: Int) {
552 func move(in list: inout [Notebook]) {
553 guard let at = list.firstIndex(where: { $0 === notebook }) else { return }
554 let moved = list.remove(at: at)
555 list.insert(moved, at: min(max(index, 0), list.count))
556 }
557 move(in: &inCloud)
558 move(in: &onDevice)
559 move(in: &elsewhere)
560 guard scripted == nil else { return }
561 UserDefaults.standard.set(all.map(\.id), forKey: order)
562 }
563
564 /// Whether Snowbound's folder in iCloud Drive holds other notebooks than the list shows, as
565 /// when another device added or removed one.
566 static var inCloudChanged: Bool {
567 guard let folder = ICloud.documents, scripted == nil else { return false }
568 return notebooks(in: folder).map { "iCloud/" + $0 } != inCloud.map(\.id)
569 }
570
571 /// The notebook folders and section files in `folder`, by name, as Files orders them.
572 private static func notebooks(in folder: URL) -> [String] {
573 let listed = try? FileManager.default.contentsOfDirectory(
574 at: folder, includingPropertiesForKeys: [.isDirectoryKey], options: .skipsHiddenFiles)
575 return (listed ?? []).filter { url in
576 (try? url.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true
577 || url.pathExtension.lowercased() == "one"
578 }
579 .map(\.lastPathComponent)
580 .sorted { $0.localizedStandardCompare($1) == .orderedAscending }
581 }
582
583 static func add(_ notebook: Notebook) {
584 elsewhere.append(notebook)
585 save()
586 }
587
588 static func remove(_ notebook: Notebook) {
589 elsewhere.removeAll { $0 === notebook }
590 save()
591 }
592
593 private static func save() {
594 guard scripted == nil else { return }
595 let entries = elsewhere.map { Entry(id: $0.id, name: $0.name, source: $0.source) }
596 UserDefaults.standard.set(try? JSONEncoder().encode(entries), forKey: key)
597 }
598}
599
600/// A page row from `sb_section_pages`.
601struct Row: Decodable, Equatable {
602 struct Version: Decodable, Equatable {
603 let id: String
604 let user: String
605 /// Seconds since 1970.
606 let created: Double?
607 }
608
609 let id: String
610 var title: String
611 /// 1 at the top, 2 for a subpage and so on.
612 let level: Int
613 /// Conflict pages: versions with changes a merge could not take.
614 let versions: [Version]
615}
616
617/// Saving as the section reports it, from `sb_section_poll`.
618enum SaveStatus: UInt8 {
619 case unknown, saved, saving, offline, notSaving
620}
621
622private let wakeSection: sb_wake = { token in
623 DispatchQueue.main.async { Section.open[Int(token)]?.value?.poll() }
624}
625
626private struct Weak<T: AnyObject> { weak var value: T? }
627
628/// A section open for editing: pages listed and saved through its replica.
629final class Section {
630 /// Sent on the main thread with `flags` from `sb_section_poll` after each poll.
631 static let changed = Notification.Name("SectionChanged")
632 static let listed: UInt32 = 1
633 static let remote: UInt32 = 2
634 static let rejected: UInt32 = 4
635
636 fileprivate static var open: [Int: Weak<Section>] = [:]
637 private static var tokens = 0
638
639 let notebook: Notebook
640 let tab: Tab
641 let handle: OpaquePointer
642 private let token: Int
643 private(set) var rows: [Row] = []
644 private(set) var status = SaveStatus.unknown
645
646 static var all: [Section] { open.values.compactMap(\.value) }
647
648 private init(notebook: Notebook, tab: Tab, handle: OpaquePointer, token: Int) {
649 self.notebook = notebook
650 self.tab = tab
651 self.handle = handle
652 self.token = token
653 reloadRows()
654 }
655
656 deinit {
657 Self.open[token] = nil
658 sb_section_free(handle)
659 }
660
661 /// Opens `tab` of `notebook` off the main thread, or hands back the section already open
662 /// there, as its replica takes one owner at a time.
663 static func open(_ tab: Tab, of notebook: Notebook, done: @escaping (Section?, String?) -> Void) {
664 if let section = all.first(where: { $0.notebook === notebook && $0.tab.path == tab.path }) {
665 return done(section, nil)
666 }
667 guard let library = notebook.handle else { return done(nil, notebook.problem) }
668 tokens += 1
669 let token = tokens
670 let pointer = Int(bitPattern: library)
671 let author = Author.name ?? ""
672 background({ () -> (Int, String?) in
673 var error: UnsafeMutablePointer<CChar>?
674 let handle = sb_section_open(
675 OpaquePointer(bitPattern: pointer), tab.path, author, wakeSection, UInt(token), &error)
676 return (Int(bitPattern: handle), take(error))
677 }) { handle, error in
678 guard let handle = OpaquePointer(bitPattern: handle) else { return done(nil, error) }
679 let section = Section(notebook: notebook, tab: tab, handle: handle, token: token)
680 open[token] = Weak(value: section)
681 done(section, nil)
682 }
683 }
684
685 func reloadRows() {
686 rows = decode([Row].self, sb_section_pages(handle)) ?? rows
687 }
688
689 /// The row listing page or conflict page `id`, and the version when it is one.
690 func row(of id: String) -> (row: Row, version: Row.Version?)? {
691 for row in rows {
692 if row.id == id { return (row, nil) }
693 if let version = row.versions.first(where: { $0.id == id }) { return (row, version) }
694 }
695 return nil
696 }
697
698 fileprivate func poll() {
699 var status: UInt8 = 0
700 let flags = sb_section_poll(handle, &status)
701 let changed = SaveStatus(rawValue: status) ?? .unknown
702 guard flags != 0 || changed != self.status else { return }
703 self.status = changed
704 if flags & Self.listed != 0 { reloadRows() }
705 NotificationCenter.default.post(name: Self.changed, object: self, userInfo: ["flags": flags])
706 }
707
708 /// A page's title as typed, shown in the list before the section reports it.
709 func retitle(_ id: String, _ title: String) {
710 guard let index = rows.firstIndex(where: { $0.id == id }), rows[index].title != title else { return }
711 rows[index].title = title
712 NotificationCenter.default.post(name: Self.changed, object: self, userInfo: ["flags": UInt32(0)])
713 }
714
715 /// Adds a page, or a subpage of `parent`; returns its id.
716 func newPage(under parent: String? = nil) -> String? {
717 let (date, time) = titleDate()
718 let id = take(sb_section_new_page(handle, parent, date, time))
719 reloadRows()
720 NotificationCenter.default.post(name: Self.changed, object: self, userInfo: ["flags": Self.listed])
721 return id
722 }
723
724 func delete(_ id: String, done: @escaping () -> Void = {}) {
725 let (date, time) = titleDate()
726 let pointer = Int(bitPattern: handle)
727 background({ sb_section_delete_page(OpaquePointer(bitPattern: pointer), id, date, time) }) { [self] _ in
728 reloadRows()
729 NotificationCenter.default.post(name: Self.changed, object: self, userInfo: ["flags": Self.listed])
730 done()
731 }
732 }
733
734 /// The notebook's themes, and the one page `id`, its section and its notebook each name.
735 struct Themes: Decodable {
736 struct Listed: Decodable {
737 let id: String
738 let name: String
739 }
740 let themes: [Listed]
741 let page: String?
742 let section: String?
743 let notebook: String?
744 }
745
746 func themes(of id: String) -> Themes? { decode(Themes.self, sb_section_themes(handle, id)) }
747
748 /// Gives `theme`, or none of its own, to page `id` (scope 0), its section (1) or its
749 /// notebook (2).
750 func setTheme(_ theme: String?, scope: UInt8, of id: String, done: @escaping (Bool) -> Void) {
751 let pointer = Int(bitPattern: handle)
752 background({ sb_section_set_theme(OpaquePointer(bitPattern: pointer), id, scope, theme) }, done: done)
753 }
754
755 /// Stores every edit and publishes them within `seconds`; call off the main thread.
756 func flush(_ seconds: Double) -> Bool { sb_section_flush(handle, seconds) }
757
758 func wake() { sb_section_wake(handle) }
759}