Sources/OrgstarMobile/MobileModel.swift
213 lines · 9352 bytes
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 /// A capture to show, from the agenda.
36 var captureRequest: CaptureRequest?
37 /// Calendar's events for the agenda, with `calendar-events`.
38 let calendarEvents = CalendarEvents()
39
40 struct AgendaDays: Equatable {
41 let start: Int
42 let count: Int
43 }
44 /// Bumped when settings apply, so views that read them update.
45 private(set) var settingsVersion = 0
46 /// The theme from `config.toml` and its theme file, with the type settings.
47 private(set) var theme = ThemeSpec.default
48 /// `show-markup`, toggled in the editor; the folder's value applies when it changes.
49 var showMarkup = false
50 @ObservationIgnored private var configShowMarkup: Bool?
51 @ObservationIgnored private var presenter: FolderPresenter?
52 @ObservationIgnored private var sizeObserver: NSObjectProtocol?
53
54 private static let bookmarkKey = "configFolderBookmark"
55
56 private init() {
57 // Development: `ORGSTAR_CONFIG_DIR` stands in for a chosen folder.
58 let folder = Self.resolveBookmark() ?? ProcessInfo.processInfo.environment["ORGSTAR_CONFIG_DIR"].map { URL(fileURLWithPath: $0) }
59 UserConfig.directoryOverride = folder
60 let (problems, colors) = Self.readConfig(folder)
61 let settings = OrgPreferences.orgSettings()
62 do {
63 let workspace = try WorkspaceModel(directory: WorkspaceModel.defaultDirectory, settings: .with(todoKeywords: settings))
64 self.workspace = workspace
65 agenda = AgendaModel(workspace: workspace)
66 failure = nil
67 } catch {
68 workspace = nil
69 agenda = nil
70 failure = error.localizedDescription
71 }
72 configFolder = folder
73 configProblems = problems
74 applySettings(colors)
75 shareTemplates()
76 watchConfig()
77 // A new Dynamic Type size restyles editors and the reader.
78 sizeObserver = NotificationCenter.default.addObserver(forName: UIContentSizeCategory.didChangeNotification, object: nil, queue: .main) { [weak self] _ in
79 MainActor.assumeIsolated { self?.settingsVersion += 1 }
80 }
81 }
82
83 /// Chooses the configuration folder, or with nil stops using one; its settings apply now.
84 func setConfigFolder(_ url: URL?) {
85 if let url {
86 _ = url.startAccessingSecurityScopedResource()
87 if let bookmark = try? url.bookmarkData() { UserDefaults.standard.set(bookmark, forKey: Self.bookmarkKey) }
88 } else {
89 UserDefaults.standard.removeObject(forKey: Self.bookmarkKey)
90 // What the folder set no longer applies.
91 for setting in UserConfig.settings { UserDefaults.standard.removeObject(forKey: setting.defaultsKey) }
92 }
93 configFolder = url
94 reloadConfig()
95 watchConfig()
96 }
97
98 func reloadConfig() {
99 UserConfig.directoryOverride = configFolder
100 let (problems, colors) = Self.readConfig(configFolder)
101 configProblems = problems
102 applySettings(colors)
103 shareTemplates()
104 }
105
106 private func applySettings(_ colors: ThemeSpec) {
107 theme = OrgPreferences.theme(colors)
108 let markup = OrgPreferences.showMarkup()
109 if markup != configShowMarkup {
110 configShowMarkup = markup
111 showMarkup = markup
112 }
113 let settings = OrgPreferences.orgSettings()
114 session.defaults = settings
115 session.options = OrgPreferences.options()
116 workspace?.settings = .with(todoKeywords: settings)
117 if let agenda {
118 agenda.span = OrgPreferences.agendaSpan()
119 agenda.startOffset = OrgPreferences.agendaStartOffset()
120 agenda.includeSubfolders = OrgPreferences.agendaSubfolders()
121 agenda.options = OrgPreferences.agendaOptions(agenda.options)
122 agenda.calendarEvents = OrgPreferences.calendarEvents() ? calendarEvents : nil
123 agenda.calendarNames = OrgPreferences.calendarEventCalendars()
124 }
125 settingsVersion += 1
126 }
127
128 /// Changes a setting from the app: in the folder's config.toml when there is a folder, as
129 /// the Mac's Settings write it, else in this device's settings.
130 func set(_ defaultsKey: String, to value: TOML.Value) {
131 guard let setting = UserConfig.settings.first(where: { $0.defaultsKey == defaultsKey }) else { return }
132 if let configFolder {
133 let file = configFolder.appendingPathComponent("config.toml")
134 var error: NSError?
135 NSFileCoordinator(filePresenter: nil).coordinate(writingItemAt: file, options: [], error: &error) { url in
136 let text = (try? String(contentsOf: url, encoding: .utf8)) ?? ""
137 try? UserConfig.setting(text, section: setting.section, key: setting.key, to: value).write(to: url, atomically: true, encoding: .utf8)
138 }
139 } else {
140 switch value {
141 case .bool(let b): UserDefaults.standard.set(b, forKey: defaultsKey)
142 case .string(let s): UserDefaults.standard.set(s, forKey: defaultsKey)
143 case .integer(let i): UserDefaults.standard.set(i, forKey: defaultsKey)
144 }
145 }
146 reloadConfig()
147 }
148
149 /// The capture templates, for the share extension to offer.
150 private func shareTemplates() {
151 try? inbox?.setTemplates(CaptureTemplates.load().templates.map { CaptureInbox.Template(key: $0.key, name: $0.name) })
152 }
153
154 /// Edits to the folder's files, synced from the Mac, apply as they arrive.
155 private func watchConfig() {
156 presenter?.stop()
157 presenter = nil
158 guard let configFolder else { return }
159 let presenter = FolderPresenter(root: configFolder) { [weak self] _ in
160 Task { @MainActor [weak self] in self?.reloadConfig() }
161 }
162 presenter.start()
163 self.presenter = presenter
164 }
165
166 private static func resolveBookmark() -> URL? {
167 guard let data = UserDefaults.standard.data(forKey: bookmarkKey) else { return nil }
168 var stale = false
169 guard let url = try? URL(resolvingBookmarkData: data, bookmarkDataIsStale: &stale) else { return nil }
170 _ = url.startAccessingSecurityScopedResource()
171 if stale, let fresh = try? url.bookmarkData() { UserDefaults.standard.set(fresh, forKey: bookmarkKey) }
172 return url
173 }
174
175 /// Applies the folder's `config.toml` to the settings; returns its problems and the theme's
176 /// colors.
177 private static func readConfig(_ folder: URL?) -> ([String], ThemeSpec) {
178 guard let folder else { return ([], .default) }
179 guard let text = read(folder.appendingPathComponent("config.toml")) else {
180 return (["No config.toml in \(folder.lastPathComponent); the defaults apply."], .default)
181 }
182 let problems = UserConfig.apply(text, to: .standard)
183 guard let theme = UserConfig.theme(config: text, themeFile: UserDefaults.standard.string(forKey: "themeFile"), in: folder, read: read) else {
184 return (problems, .default)
185 }
186 return (problems + theme.problems, theme.theme)
187 }
188
189 /// A file of the folder, read in coordination with the app that syncs it.
190 private static func read(_ file: URL) -> String? {
191 var text: String?
192 var error: NSError?
193 NSFileCoordinator(filePresenter: nil).coordinate(readingItemAt: file, options: [], error: &error) { url in
194 text = try? String(contentsOf: url, encoding: .utf8)
195 }
196 return text
197 }
198
199 /// Notifications for the next week's timed entries, at most 64, as iOS allows.
200 func updateReminders() async {
201 guard let workspace else { return }
202 guard OrgPreferences.reminders() else {
203 await scheduler.clear()
204 return
205 }
206 guard !workspace.files.isEmpty else { return }
207 let agenda = AgendaModel(workspace: workspace)
208 agenda.includeSubfolders = OrgPreferences.agendaSubfolders()
209 let reminders = await agenda.reminders(open: session.openTexts, lead: OrgPreferences.reminderLead())
210 await scheduler.reconcile(reminders)
211 }
212}
213#endif