Sources/OrgstarMobile/MobileModel.swift
185 lines · 7737 bytes
11 symbols in this file
1#if os(iOS)
2import Foundation
3import Observation
4import OrgApp
5import OrgCore
6import OrgIndex
7import OrgPresentation
8import OrgWorkspace
9import UIKit
10
11/// The app's models, and the settings from the configuration folder: a folder you choose
12/// (a synced copy of the Mac's `~/.config/orgstar`) with `config.toml` and `capture.toml`.
13@MainActor
14@Observable
15final class MobileModel {
16 /// One per process: Shortcuts runs the capture action without a window.
17 static let shared = MobileModel()
18
19 let workspace: WorkspaceModel?
20 let failure: String?
21 let session = DocumentSession()
22 let agenda: AgendaModel?
23 let scheduler = ReminderScheduler(center: SystemReminderCenter())
24 let clock = ClockModel()
25 let clockPrompts = ClockPrompter()
26 let spotlight = SpotlightIndexer()
27 /// Captures from the share extension; nil without the app group.
28 let inbox = CaptureInbox()
29 private(set) var configFolder: URL?
30 private(set) var configProblems: [String] = []
31 /// An entry to show, from a tapped reminder.
32 var openRequest: ReaderRoute?
33 /// Agenda days to show, from a followed timestamp: the first (a `Days` number) and how many.
34 var agendaRequest: AgendaDays?
35
36 struct AgendaDays: Equatable {
37 let start: Int
38 let count: Int
39 }
40 /// Bumped when settings apply, so views that read them update.
41 private(set) var settingsVersion = 0
42 /// The theme from `config.toml` and its theme file, with the type settings.
43 private(set) var theme = ThemeSpec.default
44 /// `show-markup`, toggled in the editor; the folder's value applies when it changes.
45 var showMarkup = false
46 @ObservationIgnored private var configShowMarkup: Bool?
47 @ObservationIgnored private var presenter: FolderPresenter?
48 @ObservationIgnored private var sizeObserver: NSObjectProtocol?
49
50 private static let bookmarkKey = "configFolderBookmark"
51
52 private init() {
53 // Development: `ORGSTAR_CONFIG_DIR` stands in for a chosen folder.
54 let folder = Self.resolveBookmark() ?? ProcessInfo.processInfo.environment["ORGSTAR_CONFIG_DIR"].map { URL(fileURLWithPath: $0) }
55 UserConfig.directoryOverride = folder
56 let (problems, colors) = Self.readConfig(folder)
57 let settings = OrgPreferences.orgSettings()
58 do {
59 let workspace = try WorkspaceModel(directory: WorkspaceModel.defaultDirectory, settings: .with(todoKeywords: settings))
60 self.workspace = workspace
61 agenda = AgendaModel(workspace: workspace)
62 failure = nil
63 } catch {
64 workspace = nil
65 agenda = nil
66 failure = error.localizedDescription
67 }
68 configFolder = folder
69 configProblems = problems
70 applySettings(colors)
71 shareTemplates()
72 watchConfig()
73 // A new Dynamic Type size restyles editors and the reader.
74 sizeObserver = NotificationCenter.default.addObserver(forName: UIContentSizeCategory.didChangeNotification, object: nil, queue: .main) { [weak self] _ in
75 MainActor.assumeIsolated { self?.settingsVersion += 1 }
76 }
77 }
78
79 /// Chooses the configuration folder, or with nil stops using one; its settings apply now.
80 func setConfigFolder(_ url: URL?) {
81 if let url {
82 _ = url.startAccessingSecurityScopedResource()
83 if let bookmark = try? url.bookmarkData() { UserDefaults.standard.set(bookmark, forKey: Self.bookmarkKey) }
84 } else {
85 UserDefaults.standard.removeObject(forKey: Self.bookmarkKey)
86 // What the folder set no longer applies.
87 for setting in UserConfig.settings { UserDefaults.standard.removeObject(forKey: setting.defaultsKey) }
88 }
89 configFolder = url
90 reloadConfig()
91 watchConfig()
92 }
93
94 func reloadConfig() {
95 UserConfig.directoryOverride = configFolder
96 let (problems, colors) = Self.readConfig(configFolder)
97 configProblems = problems
98 applySettings(colors)
99 shareTemplates()
100 }
101
102 private func applySettings(_ colors: ThemeSpec) {
103 theme = OrgPreferences.theme(colors)
104 let markup = OrgPreferences.showMarkup()
105 if markup != configShowMarkup {
106 configShowMarkup = markup
107 showMarkup = markup
108 }
109 let settings = OrgPreferences.orgSettings()
110 session.defaults = settings
111 session.options = OrgPreferences.options()
112 workspace?.settings = .with(todoKeywords: settings)
113 if let agenda {
114 agenda.span = OrgPreferences.agendaSpan()
115 agenda.startOffset = OrgPreferences.agendaStartOffset()
116 agenda.includeSubfolders = OrgPreferences.agendaSubfolders()
117 }
118 settingsVersion += 1
119 }
120
121 /// The capture templates, for the share extension to offer.
122 private func shareTemplates() {
123 try? inbox?.setTemplates(CaptureTemplates.load().templates.map { CaptureInbox.Template(key: $0.key, name: $0.name) })
124 }
125
126 /// Edits to the folder's files, synced from the Mac, apply as they arrive.
127 private func watchConfig() {
128 presenter?.stop()
129 presenter = nil
130 guard let configFolder else { return }
131 let presenter = FolderPresenter(root: configFolder) { [weak self] _ in
132 Task { @MainActor [weak self] in self?.reloadConfig() }
133 }
134 presenter.start()
135 self.presenter = presenter
136 }
137
138 private static func resolveBookmark() -> URL? {
139 guard let data = UserDefaults.standard.data(forKey: bookmarkKey) else { return nil }
140 var stale = false
141 guard let url = try? URL(resolvingBookmarkData: data, bookmarkDataIsStale: &stale) else { return nil }
142 _ = url.startAccessingSecurityScopedResource()
143 if stale, let fresh = try? url.bookmarkData() { UserDefaults.standard.set(fresh, forKey: bookmarkKey) }
144 return url
145 }
146
147 /// Applies the folder's `config.toml` to the settings; returns its problems and the theme's
148 /// colors.
149 private static func readConfig(_ folder: URL?) -> ([String], ThemeSpec) {
150 guard let folder else { return ([], .default) }
151 guard let text = read(folder.appendingPathComponent("config.toml")) else {
152 return (["No config.toml in \(folder.lastPathComponent); the defaults apply."], .default)
153 }
154 let problems = UserConfig.apply(text, to: .standard)
155 guard let theme = UserConfig.theme(config: text, themeFile: UserDefaults.standard.string(forKey: "themeFile"), in: folder, read: read) else {
156 return (problems, .default)
157 }
158 return (problems + theme.problems, theme.theme)
159 }
160
161 /// A file of the folder, read in coordination with the app that syncs it.
162 private static func read(_ file: URL) -> String? {
163 var text: String?
164 var error: NSError?
165 NSFileCoordinator(filePresenter: nil).coordinate(readingItemAt: file, options: [], error: &error) { url in
166 text = try? String(contentsOf: url, encoding: .utf8)
167 }
168 return text
169 }
170
171 /// Notifications for the next week's timed entries, at most 64, as iOS allows.
172 func updateReminders() async {
173 guard let workspace else { return }
174 guard OrgPreferences.reminders() else {
175 await scheduler.clear()
176 return
177 }
178 guard !workspace.files.isEmpty else { return }
179 let agenda = AgendaModel(workspace: workspace)
180 agenda.includeSubfolders = OrgPreferences.agendaSubfolders()
181 let reminders = await agenda.reminders(open: session.openTexts, lead: OrgPreferences.reminderLead())
182 await scheduler.reconcile(reminders)
183 }
184}
185#endif