krz/orgstar

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

Sources/OrgApp/AgendaModel.swift

b98a6509c5ae75e5172dd333d1b6105fd6ceee0e
orgstar/Sources/OrgApp/AgendaModel.swift history · blame · raw

175 lines · 8848 bytes

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