krz/orgstar

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

Sources/OrgstarMobile/MobileModel.swift

185 lines · 7737 bytes

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