krz/orgstar

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

Sources/OrgstarMobile/MobileModel.swift

191ddb506f3a91b93c755ba6e8123a4e8ac4f90d
orgstar/Sources/OrgstarMobile/MobileModel.swift history · blame · raw

231 lines · 10258 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.calendarEvents = agenda.calendarEvents
224            days.calendarNames = agenda.calendarNames
225        }
226        await days.refresh(open: session.openTexts)
227        let snapshot = AgendaSnapshot(groups: days.groups(), today: days.today, theme: theme, generated: Date())
228        if (try? snapshot.write(to: file)) == true { WidgetCenter.shared.reloadAllTimelines() }
229    }
230}
231#endif