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