krz/orgstar

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

Sources/OrgstarMobile/MobileModel.swift

3a3dedba062790e1580c66d38606eda4b02cb501
orgstar/Sources/OrgstarMobile/MobileModel.swift history · blame · raw

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