Sources/OrgPresentation/AgendaRows.swift
346 lines · 14895 bytes
19 symbols in this file
1import Foundation
2import OrgCore
3
4/// An event from the system Calendar, read-only (OrgApp's `CalendarEvents` reads them).
5public struct AgendaEvent: Sendable, Equatable, Identifiable {
6 public let id: String
7 public let title: String
8 public let calendarID: String
9 public let calendarTitle: String
10 /// The calendar's color; nil uses the theme's `agenda-event`.
11 public let color: RGBA?
12 public let start: Date
13 public let end: Date
14 public let isAllDay: Bool
15 public let location: String?
16
17 public init(
18 id: String, title: String, calendarID: String, calendarTitle: String, color: RGBA?, start: Date, end: Date,
19 isAllDay: Bool, location: String? = nil
20 ) {
21 self.id = id
22 self.title = title
23 self.calendarID = calendarID
24 self.calendarTitle = calendarTitle
25 self.color = color
26 self.start = start
27 self.end = end
28 self.isAllDay = isAllDay
29 self.location = location
30 }
31
32 /// The days (as `Days` numbers them) the event covers; an end at midnight ends the day before.
33 public func days(calendar: Calendar) -> ClosedRange<Int> {
34 let first = Days.absolute(start, calendar: calendar)
35 var last = Days.absolute(end, calendar: calendar)
36 if end > start, calendar.startOfDay(for: end) == end { last -= 1 }
37 return first...max(first, last)
38 }
39}
40
41/// One line of the agenda: an Org entry or a calendar event.
42///
43/// The gutter holds the time (`08:00`, `13:50–14:30`) or, untimed, a short status:
44/// deadlines `Due today`, `Due in 2d`, `3d late`, `Due` (on a day other than today);
45/// scheduled `Scheduled`, `4d ago`; habits `Habit`; timestamps, diary sexps and
46/// calendar events `All day`; ranges `Day 2/5`; log mode `Closed`, `Clocked`, `State`; TODO
47/// and match lists nothing. Ages use `AgendaAge.compact`.
48public struct AgendaRow: Sendable, Equatable, Identifiable {
49 /// What the gutter's color says.
50 public enum Status: String, Sendable {
51 /// Not done, with a deadline or scheduled date before today.
52 case overdue
53 /// Not done, with a deadline today or within its warning period.
54 case dueSoon = "due-soon"
55 case normal
56 /// A done TODO keyword.
57 case done
58 }
59
60 public enum Icon: String, Sendable {
61 case scheduled, deadline, habit, event, timestamp
62 }
63
64 /// Unique within a day group.
65 public internal(set) var id: String
66 public let gutter: String
67 public let status: Status
68 /// Nil for TODO and match list entries.
69 public let icon: Icon?
70 /// The category line above the title: the entry's category, or the event's calendar.
71 public let category: String
72 public let title: String
73 public let tags: [String]
74 public let todo: String?
75 public let isDone: Bool
76 /// Blocked from becoming done (`org-agenda-dim-blocked-tasks`): shown dimmed.
77 public let isBlocked: Bool
78 public let priority: String?
79 /// The statistics cookie: `1/3`, `50%`.
80 public let progress: String?
81 /// Log mode: the time clocked (`1:30`) or the state set (`DONE`).
82 public let detail: String?
83 /// `HHMM` of the start and the end, when timed.
84 public let start: Int?
85 public let end: Int?
86 /// The deadline or scheduled day, as `Days` numbers it.
87 public let planningDay: Int?
88 /// The Org entry, for its actions.
89 public let item: AgendaItem?
90 public let event: AgendaEvent?
91
92 public var isEvent: Bool { event != nil }
93 /// The calendar's color for an event.
94 public var color: RGBA? { event?.color }
95
96 /// Nil for the time grid and the current-time line.
97 public init?(item: AgendaItem, day: Int, today: Int) {
98 if item.kind == .timeGrid || item.kind == .currentTime { return nil }
99 let timed = item.timeOfDay.map { start in (start, Self.endTime(item.time)) }
100 let planning = item.planningDay
101 let late = planning.map { $0 < today } ?? false
102 var icon: Icon?
103 var status: Status = .normal
104 var untimed = ""
105 switch item.kind {
106 case .deadline, .upcomingDeadline:
107 icon = .deadline
108 let due = planning ?? day
109 if day == today {
110 untimed = due == today ? "Due today" : due < today ? "\(AgendaAge.compact(today - due)) late" : "Due in \(AgendaAge.compact(due - today))"
111 } else {
112 untimed = "Due"
113 }
114 status = late ? .overdue : day == today ? .dueSoon : .normal
115 case .scheduled, .pastScheduled:
116 if item.habit != nil {
117 icon = .habit
118 untimed = "Habit"
119 } else {
120 icon = .scheduled
121 let on = planning ?? day
122 untimed = on < day ? "\(AgendaAge.compact(day - on)) ago" : "Scheduled"
123 }
124 status = late ? .overdue : .normal
125 case .timestamp, .sexp:
126 icon = .timestamp
127 untimed = "All day"
128 case .block:
129 icon = .timestamp
130 let ns = item.extra as NSString
131 if let m = Self.rangeDay.firstMatch(in: item.extra, range: NSRange(location: 0, length: ns.length)) {
132 untimed = "Day " + ns.substring(with: m.range(at: 1))
133 } else {
134 untimed = "All day"
135 }
136 case .closed: (icon, untimed) = (.timestamp, "Closed")
137 case .clock: (icon, untimed) = (.timestamp, "Clocked")
138 case .state: (icon, untimed) = (.timestamp, "State")
139 case .todo, .tagsMatch, .search, .timeGrid, .currentTime: break
140 }
141 if item.isDone { status = .done }
142 self.id = [item.path ?? "", "\(item.headingOffset ?? -1)", "\(item.markerOffset ?? -1)", item.kind.rawValue, item.text].joined(separator: "\u{1f}")
143 self.gutter = timed.map { Self.timeText($0.0, $0.1) } ?? untimed
144 self.status = status
145 self.icon = icon
146 self.category = item.category
147 self.title = item.title
148 self.tags = item.tags
149 self.todo = item.todo
150 self.isDone = item.isDone
151 self.isBlocked = item.isBlocked
152 self.priority = item.priorityLetter
153 self.progress = item.progress
154 self.detail = item.kind == .clock || item.kind == .state ? Self.logDetail(item.extra) : nil
155 self.start = timed?.0
156 self.end = timed?.1
157 self.planningDay = planning
158 self.item = item
159 self.event = nil
160 }
161
162 /// The event's row on `day`: its times on the day it starts (the end only when it ends that
163 /// day too), `Until 10:00` on the day a longer event ends, `All day` otherwise.
164 public init(event: AgendaEvent, day: Int, calendar: Calendar) {
165 let days = event.days(calendar: calendar)
166 func hhmm(_ date: Date) -> Int {
167 let c = calendar.dateComponents([.hour, .minute], from: date)
168 return (c.hour ?? 0) * 100 + (c.minute ?? 0)
169 }
170 var start: Int?
171 var end: Int?
172 let gutter: String
173 if event.isAllDay || (day != days.lowerBound && day != days.upperBound) {
174 gutter = "All day"
175 } else if day == days.lowerBound {
176 start = hhmm(event.start)
177 if days.lowerBound == days.upperBound { end = hhmm(event.end) }
178 gutter = Self.timeText(start!, end)
179 } else {
180 gutter = "Until " + Self.timeText(hhmm(event.end), nil)
181 }
182 self.id = "event\u{1f}" + event.id
183 self.gutter = gutter
184 self.status = .normal
185 self.icon = .event
186 self.category = event.calendarTitle
187 self.title = event.title
188 self.tags = []
189 self.todo = nil
190 self.isDone = false
191 self.isBlocked = false
192 self.priority = nil
193 self.progress = nil
194 self.detail = nil
195 self.start = start
196 self.end = end
197 self.planningDay = nil
198 self.item = nil
199 self.event = event
200 }
201
202 static let rangeDay = try! NSRegularExpression(pattern: "^\\(([0-9]+/[0-9]+)\\)")
203 static let logParen = try! NSRegularExpression(pattern: "\\(([^)]+)\\)")
204 static let rangeEnd = try! NSRegularExpression(pattern: "-\\+?([0-9]{1,2}):([0-9]{2})")
205
206 /// The text in parentheses of a log row's `extra`: `Clocked: (1:30)`.
207 static func logDetail(_ extra: String) -> String? {
208 let ns = extra as NSString
209 guard let m = logParen.firstMatch(in: extra, range: NSRange(location: 0, length: ns.length)) else { return nil }
210 let text = ns.substring(with: m.range(at: 1)).trimmingCharacters(in: .whitespaces)
211 return text.isEmpty ? nil : text
212 }
213
214 /// The end of a `%t` time range (` 9:00-10:30`), as `HHMM`; past midnight (`+1:00`) adds 24 hours.
215 static func endTime(_ time: String) -> Int? {
216 let ns = time as NSString
217 guard let m = rangeEnd.firstMatch(in: time, range: NSRange(location: 0, length: ns.length)) else { return nil }
218 let overtime = ns.substring(with: m.range).contains("+") ? 24 : 0
219 return (Int(ns.substring(with: m.range(at: 1)))! + overtime) * 100 + Int(ns.substring(with: m.range(at: 2)))!
220 }
221
222 /// `08:00`, `13:50–14:30`; hours past midnight wrap.
223 static func timeText(_ start: Int, _ end: Int?) -> String {
224 func text(_ t: Int) -> String { String(format: "%02d:%02d", (t / 100) % 24, t % 100) }
225 return end.map { text(start) + "–" + text($0) } ?? text(start)
226 }
227}
228
229/// One day of the agenda: today's entries carried from earlier days in `overdue`, the rest
230/// in `rows`.
231public struct AgendaDayGroup: Sendable, Equatable, Identifiable {
232 /// As `Days` numbers it.
233 public let day: Int
234 public let isToday: Bool
235 /// Org's day header: `Thursday 8 October 2026`.
236 public let header: String
237 /// Today only: deadlines and scheduled entries from before today, oldest first.
238 public let overdue: [AgendaRow]
239 public let rows: [AgendaRow]
240
241 public var id: Int { day }
242 public var isEmpty: Bool { overdue.isEmpty && rows.isEmpty }
243
244 /// `Today`, `Tomorrow`, `Yesterday`, or nil.
245 public func relativeName(today: Int) -> String? {
246 switch day - today {
247 case 0: "Today"
248 case 1: "Tomorrow"
249 case -1: "Yesterday"
250 default: nil
251 }
252 }
253
254 /// Midnight starting the day.
255 public func date(calendar: Calendar) -> Date {
256 let d = Days.date(day)
257 return calendar.date(from: DateComponents(year: d.year, month: d.month, day: d.day))!
258 }
259
260 /// Where the current-time line goes in `rows`: before the first timed row starting after
261 /// `now` (`HHMM`), or after the last timed row when all have started. Nil on other days
262 /// and when no row is timed, as Org adds the line only among timed entries.
263 public func nowIndex(now: Int) -> Int? {
264 guard isToday else { return nil }
265 return AgendaRows.nowIndex(rows, now: now)
266 }
267}
268
269public enum AgendaRows {
270 /// The days of `Agenda.list` as rows, with `events` merged in.
271 ///
272 /// - Days with no rows are left out unless `showAllDates` (`org-agenda-show-all-dates`);
273 /// today always stays.
274 /// - Today's past-scheduled entries and past deadlines (not habits) go in `overdue`,
275 /// oldest date first, Org's order among the same date.
276 /// - Other rows keep Org's order (timed by time, then untimed by urgency, habits last).
277 /// All-day events come first, timed events go among the timed entries by start time,
278 /// after entries at the same time.
279 /// - `keep` filters the Org entries (an `AgendaFilter`); events are not filtered.
280 public static func groups(
281 _ days: [AgendaDay], events: [AgendaEvent] = [], today: Int, showAllDates: Bool = false, calendar: Calendar = .current,
282 keep: (AgendaItem) -> Bool = { _ in true }
283 ) -> [AgendaDayGroup] {
284 let eventDays = events.map { ($0, $0.days(calendar: calendar)) }
285 var groups: [AgendaDayGroup] = []
286 for day in days {
287 var overdue: [AgendaRow] = []
288 var rows: [AgendaRow] = []
289 for item in day.items where keep(item) {
290 guard let row = AgendaRow(item: item, day: day.day, today: today) else { continue }
291 if day.day == today, item.habit == nil, let planning = row.planningDay, planning < today {
292 overdue.append(row)
293 } else {
294 rows.append(row)
295 }
296 }
297 overdue = overdue.enumerated().sorted { a, b in
298 a.element.planningDay! != b.element.planningDay! ? a.element.planningDay! < b.element.planningDay! : a.offset < b.offset
299 }.map(\.element)
300 let dayEvents = eventDays.filter { $0.1.contains(day.day) }.map { AgendaRow(event: $0.0, day: day.day, calendar: calendar) }
301 rows = merge(rows, events: dayEvents)
302 let group = AgendaDayGroup(day: day.day, isToday: day.day == today, header: day.header, overdue: unique(overdue), rows: unique(rows))
303 if group.isEmpty, !group.isToday, !showAllDates { continue }
304 groups.append(group)
305 }
306 return groups
307 }
308
309 /// `org` (in Org's order) with event rows added: untimed events first, timed ones before
310 /// the first untimed or habit entry, or the first timed entry starting later.
311 static func merge(_ org: [AgendaRow], events: [AgendaRow]) -> [AgendaRow] {
312 let allDay = events.filter { $0.start == nil }
313 var result = org
314 let timed = events.filter { $0.start != nil }.enumerated().sorted { a, b in
315 a.element.start! != b.element.start! ? a.element.start! < b.element.start! : a.offset < b.offset
316 }.map(\.element)
317 for event in timed {
318 let at = result.firstIndex { row in
319 if row.isEvent { return row.start.map { $0 > event.start! } ?? false }
320 guard let start = row.start, row.item?.habit == nil else { return true }
321 return start > event.start!
322 } ?? result.count
323 result.insert(event, at: at)
324 }
325 return allDay + result
326 }
327
328 /// Ids made unique: a repeat gets `#2`, `#3`.
329 static func unique(_ rows: [AgendaRow]) -> [AgendaRow] {
330 var seen: [String: Int] = [:]
331 return rows.map { row in
332 var row = row
333 let n = seen[row.id, default: 0] + 1
334 seen[row.id] = n
335 if n > 1 { row.id += "#\(n)" }
336 return row
337 }
338 }
339
340 /// See `AgendaDayGroup.nowIndex(now:)`.
341 public static func nowIndex(_ rows: [AgendaRow], now: Int) -> Int? {
342 let timed = rows.indices.filter { rows[$0].start != nil && rows[$0].item?.habit == nil }
343 guard let last = timed.last else { return nil }
344 return timed.first { rows[$0].start! > now } ?? last + 1
345 }
346}