| 1 | import Foundation |
| 2 | import Security |
| 3 | import UIKit |
| 4 | |
| 5 | /// A string the Rust library allocated, taken and freed. |
| 6 | func 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 | |
| 12 | func 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. |
| 17 | func 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. |
| 28 | enum 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. |
| 67 | struct 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. |
| 81 | enum 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. |
| 111 | enum 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`. |
| 125 | struct 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 | |
| 143 | extension 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 | |
| 157 | extension 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. |
| 167 | func 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. |
| 175 | let 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 | |
| 182 | let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] |
| 183 | |
| 184 | /// An open notebook: its Rust library and the sections it lists. |
| 185 | final 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. |
| 367 | private 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. |
| 379 | private 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. |
| 386 | final 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. |
| 441 | enum 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`. |
| 601 | struct 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`. |
| 618 | enum SaveStatus: UInt8 { |
| 619 | case unknown, saved, saving, offline, notSaving |
| 620 | } |
| 621 | |
| 622 | private let wakeSection: sb_wake = { token in |
| 623 | DispatchQueue.main.async { Section.open[Int(token)]?.value?.poll() } |
| 624 | } |
| 625 | |
| 626 | private struct Weak<T: AnyObject> { weak var value: T? } |
| 627 | |
| 628 | /// A section open for editing: pages listed and saved through its replica. |
| 629 | final 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 | } |