Sources/OrgApp/AgendaModel.swift
174 lines · 8621 bytes
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 var reads: [(path: String, cached: AgendaSource?, mtime: Date, size: Int, text: String?)] = []
118 for path in files {
119 if let text = open[path] {
120 reads.append((path, nil, .distantPast, -1, text))
121 continue
122 }
123 let attributes = try? FileManager.default.attributesOfItem(atPath: path)
124 let mtime = attributes?[.modificationDate] as? Date ?? .distantPast
125 let size = (attributes?[.size] as? NSNumber)?.intValue ?? -1
126 // A file reading setup files is read again: they may have changed.
127 let cached = cache[path].flatMap { $0.mtime == mtime && $0.size == size && !$0.source.readsSetupFiles ? $0.source : nil }
128 reads.append((path, cached, mtime, size, nil))
129 }
130 let loaded = await Task.detached {
131 var sources: [(path: String, mtime: Date, size: Int, source: AgendaSource, fromDisk: Bool)] = []
132 for read in reads {
133 if let cached = read.cached {
134 sources.append((read.path, read.mtime, read.size, cached, true))
135 } else if let text = read.text {
136 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))
137 } else if let data = FileManager.default.contents(atPath: read.path) {
138 let text = SourceText(bytes: [UInt8](data)).text
139 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))
140 }
141 }
142 return sources
143 }.value
144 for source in loaded where source.fromDisk {
145 cache[source.path] = (source.mtime, source.size, source.source)
146 }
147 return loaded.map(\.source)
148 }
149
150 /// The days as rows with the events, empty days hidden unless `showAllDates`; see
151 /// `AgendaRows.groups`.
152 public func groups(showAllDates: Bool = false, keep: (AgendaItem) -> Bool = { _ in true }) -> [AgendaDayGroup] {
153 AgendaRows.groups(days, events: events, today: today, showAllDates: showAllDates, calendar: calendar, keep: keep)
154 }
155
156 nonisolated static func date(_ day: Int, calendar: Calendar) -> Date {
157 let d = Days.date(day)
158 return calendar.date(from: DateComponents(year: d.year, month: d.month, day: d.day)) ?? Date()
159 }
160
161 public func showToday() { start = nil }
162
163 /// Moves the view by a whole span, forward or back.
164 public func shift(forward: Bool, now: Date = Date(), calendar: Calendar = .current) {
165 var span = self.span
166 var startOffset = self.startOffset
167 if case .agenda(let viewSpan, let viewStart) = mode {
168 span = viewSpan ?? span
169 startOffset = viewStart ?? startOffset
170 }
171 let current = start ?? Days.absolute(now, calendar: calendar) + startOffset
172 start = current + (forward ? span : -span)
173 }
174}