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