krz/orgstar

A native macOS editor for org-mode files. editor org-mode swift

Sources/OrgstarMobile/MobileModel.swift

b98a6509c5ae75e5172dd333d1b6105fd6ceee0e
orgstar/Sources/OrgstarMobile/MobileModel.swift history · blame · raw

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