krz/orgstar

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

Sources/OrgApp/AgendaModel.swift

778058aafc7ddacee4a613b1da87c60aa0c8e113
orgstar/Sources/OrgApp/AgendaModel.swift history · blame · raw

142 lines · 6923 bytes

7 symbols in this file
  1import Foundation
  2import Observation
  3import OrgCore
  4import OrgIndex
  5
  6/// The day/week agenda over the workspace's agenda files. Sources are read from disk and
  7/// cached by modification date and size; the open file's come from its buffer, so the agenda
  8/// shows unsaved edits.
  9@MainActor
 10@Observable
 11public final class AgendaModel {
 12    public private(set) var days: [AgendaDay] = []
 13    /// The entries of a TODO list or match view.
 14    public private(set) var list: [AgendaItem] = []
 15    public var mode: AgendaMode = .agenda(span: nil, startOffset: nil)
 16    /// The first day shown, as `Days` numbers it; nil follows today.
 17    public var start: Int?
 18    public var span = 10
 19    /// Where the view starts relative to today, as Doom's `org-agenda-start-day "-3d"`.
 20    public var startOffset = -3
 21    /// Files below the top level of each folder are agenda files too.
 22    public var includeSubfolders = false
 23    public var options = AgendaOptions()
 24    public private(set) var isComputing = false
 25
 26    private let workspace: WorkspaceModel
 27    @ObservationIgnored private var cache: [String: (mtime: Date, size: Int, source: AgendaSource)] = [:]
 28    @ObservationIgnored private var generation = 0
 29
 30    public init(workspace: WorkspaceModel) {
 31        self.workspace = workspace
 32    }
 33
 34    /// Org files directly in each folder (or below it, with `includeSubfolders`), by path, as
 35    /// Emacs lists a directory in `org-agenda-files`.
 36    public var files: [String] {
 37        workspace.files
 38            // Dotfiles stay out, as `org-agenda-file-regexp` leaves them out.
 39            .filter { $0.kind == .org && !$0.name.hasPrefix(".") && (includeSubfolders || !$0.relativePath.contains("/")) }
 40            .map(\.path)
 41            .sorted()
 42    }
 43
 44    /// Recomputes the days. `open` is the open buffers' text by path.
 45    public func refresh(open: [String: String], now: Date = Date(), calendar: Calendar = .current) async {
 46        generation += 1
 47        let id = generation
 48        isComputing = true
 49        let today = Days.absolute(now, calendar: calendar)
 50        let clock = calendar.dateComponents([.hour, .minute], from: now)
 51        var span = self.span
 52        var startOffset = self.startOffset
 53        if case .agenda(let viewSpan, let viewStart) = mode {
 54            span = viewSpan ?? span
 55            startOffset = viewStart ?? startOffset
 56        }
 57        let first = start ?? today + startOffset
 58        let options = self.options
 59        let mode = self.mode
 60        let all = await sources(open: open)
 61        let result = await Task.detached {
 62            var days: [AgendaDay] = []
 63            var list: [AgendaItem] = []
 64            switch mode {
 65            case .agenda:
 66                days = Agenda.list(all, start: first, days: span, today: today, now: (clock.hour ?? 0, clock.minute ?? 0), options: options)
 67            case .todo(let keywords):
 68                list = Agenda.todoList(all, keywords: keywords, options: options)
 69            case .match(let match, let todoOnly):
 70                list = Agenda.tagsMatch(all, match: match, todoOnly: todoOnly, now: now, calendar: calendar, options: options)
 71            }
 72            return (days, list)
 73        }.value
 74        guard id == generation else { return }
 75        days = result.0
 76        list = result.1
 77        isComputing = false
 78    }
 79
 80    /// Reminders for the timed entries of the next `days` days.
 81    public func reminders(open: [String: String], now: Date = Date(), calendar: Calendar = .current, days: Int = 7, lead: Int = 12) async -> [Reminder] {
 82        let all = await sources(open: open)
 83        return await Task.detached { Reminders.upcoming(all, now: now, calendar: calendar, days: days, lead: lead) }.value
 84    }
 85
 86    /// Entries for the table and kanban views, and the keywords their files use.
 87    public func board(open: [String: String], match: String) async -> (entries: [BoardEntry], keywords: [String]) {
 88        let all = await sources(open: open)
 89        return await Task.detached { (Board.entries(all, match: match), Board.keywords(all)) }.value
 90    }
 91
 92    /// The agenda files' sources: the open file's from its text, the others from the cache
 93    /// while their modification date and size are unchanged.
 94    private func sources(open: [String: String]) async -> [AgendaSource] {
 95        let defaults = workspace.settings.org
 96        var reads: [(path: String, cached: AgendaSource?, mtime: Date, size: Int, text: String?)] = []
 97        for path in files {
 98            if let text = open[path] {
 99                reads.append((path, nil, .distantPast, -1, text))
100                continue
101            }
102            let attributes = try? FileManager.default.attributesOfItem(atPath: path)
103            let mtime = attributes?[.modificationDate] as? Date ?? .distantPast
104            let size = (attributes?[.size] as? NSNumber)?.intValue ?? -1
105            // A file reading setup files is read again: they may have changed.
106            let cached = cache[path].flatMap { $0.mtime == mtime && $0.size == size && !$0.source.readsSetupFiles ? $0.source : nil }
107            reads.append((path, cached, mtime, size, nil))
108        }
109        let loaded = await Task.detached {
110            var sources: [(path: String, mtime: Date, size: Int, source: AgendaSource, fromDisk: Bool)] = []
111            for read in reads {
112                if let cached = read.cached {
113                    sources.append((read.path, read.mtime, read.size, cached, true))
114                } else if let text = read.text {
115                    sources.append((read.path, read.mtime, read.size, AgendaSource(path: read.path, text: text, defaults: defaults.resolvingSetupFiles(in: text, path: read.path, read: OrgSettings.readFile)), false))
116                } else if let data = FileManager.default.contents(atPath: read.path) {
117                    let text = SourceText(bytes: [UInt8](data)).text
118                    sources.append((read.path, read.mtime, read.size, AgendaSource(path: read.path, text: text, defaults: defaults.resolvingSetupFiles(in: text, path: read.path, read: OrgSettings.readFile)), true))
119                }
120            }
121            return sources
122        }.value
123        for source in loaded where source.fromDisk {
124            cache[source.path] = (source.mtime, source.size, source.source)
125        }
126        return loaded.map(\.source)
127    }
128
129    public func showToday() { start = nil }
130
131    /// Moves the view by a whole span, forward or back.
132    public func shift(forward: Bool, now: Date = Date(), calendar: Calendar = .current) {
133        var span = self.span
134        var startOffset = self.startOffset
135        if case .agenda(let viewSpan, let viewStart) = mode {
136            span = viewSpan ?? span
137            startOffset = viewStart ?? startOffset
138        }
139        let current = start ?? Days.absolute(now, calendar: calendar) + startOffset
140        start = current + (forward ? span : -span)
141    }
142}