Sources/OrgApp/AgendaModel.swift
142 lines · 6923 bytes
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}