Sources/OrgCore/Agenda/Agenda.swift
439 lines · 22361 bytes
1import Foundation
2
3public struct AgendaItem: Sendable, Equatable {
4 public enum Kind: String, Sendable {
5 case deadline
6 case upcomingDeadline = "upcoming-deadline"
7 case scheduled
8 case pastScheduled = "past-scheduled"
9 case timestamp
10 case block
11 case timeGrid
12 case currentTime
13 case todo
14 case tagsMatch = "tagsmatch"
15 }
16
17 public let kind: Kind
18 public let path: String?
19 /// Start of the entry's heading line.
20 public let headingOffset: Int?
21 /// The heading line as it was read, from its stars, to check the offsets still hold.
22 public let headingLine: String?
23 /// Where org's marker points: the timestamp, or the end of a range.
24 public let markerOffset: Int?
25 public let category: String
26 /// `HHMM`, when the item has a time of day.
27 public let timeOfDay: Int?
28 /// `14:00-15:00`, ` 9:00......`, or empty.
29 public let time: String
30 /// The leader: `Deadline: `, `Sched. 2x: `, `(1/3): `.
31 public let extra: String
32 /// The heading text as shown: TODO keyword, priority, title, without tags.
33 public let text: String
34 public let tags: [String]
35 public let todo: String?
36 public let isDone: Bool
37 public let urgency: Int
38 /// `APPT_WARNTIME`: minutes of warning before the entry's time.
39 public let warntime: Int?
40 /// The habit whose consistency graph goes on the line (scheduled habits).
41 public let habit: Habit?
42 /// The line as org prints it, with one space before the tags.
43 public let line: String
44}
45
46public struct AgendaDay: Sendable, Equatable {
47 /// Emacs's absolute day number (`Days`).
48 public let day: Int
49 public let isToday: Bool
50 /// `Monday 5 October 2026 W41`.
51 public let header: String
52 public let items: [AgendaItem]
53}
54
55public struct AgendaOptions: Sendable, Equatable {
56 public var deadlineWarningDays = 14
57 public var gridTimes = [800, 1000, 1200, 1400, 1600, 1800, 2000]
58 public var gridTrailing = " ┄┄┄┄┄ "
59 public var gridLine = "┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄"
60 public var currentTimeLine = "← now ───────────────────────────────────────────────"
61
62 public init() {}
63
64 /// What Emacs shows without a graphical display.
65 public static let terminal: AgendaOptions = {
66 var options = AgendaOptions()
67 options.gridTrailing = "......"
68 options.gridLine = "----------------"
69 options.currentTimeLine = "now - - - - - - - - - - - - - - - - - - - - - - - - -"
70 return options
71 }()
72}
73
74/// `org-agenda-list`.
75public enum Agenda {
76 public static func list(
77 _ sources: [AgendaSource], start: Int, days: Int, today: Int, now: (hour: Int, minute: Int),
78 options: AgendaOptions = AgendaOptions()
79 ) -> [AgendaDay] {
80 (start..<(start + days)).map { day in
81 var items: [AgendaItem] = []
82 for source in sources { items += entries(source, day: day, today: today, options: options) }
83 let isToday = day == today
84 if (isToday || days == 1), items.contains(where: { $0.timeOfDay != nil }) {
85 items = grid(day: day, isToday: isToday, now: now, options: options) + items
86 }
87 return AgendaDay(day: day, isToday: isToday, header: header(day), items: sort(items))
88 }
89 }
90
91 /// `org-agenda-get-day-entries` with the default entry types.
92 static func entries(_ source: AgendaSource, day: Int, today: Int, options: AgendaOptions) -> [AgendaItem] {
93 let deadlines = self.deadlines(source, day: day, today: today, options: options)
94 return deadlines + scheduled(source, day: day, today: today, options: options) + blocks(source, day: day, options: options)
95 + timestamps(source, day: day, today: today, options: options)
96 }
97
98 // MARK: - Collectors
99
100 static func deadlines(_ source: AgendaSource, day current: Int, today: Int, options: AgendaOptions) -> [AgendaItem] {
101 let isToday = current == today
102 var items: [AgendaItem] = []
103 for heading in source.headings {
104 guard !heading.skipped, let (s, offset) = heading.deadline, let deadline = Days.absolute(of: s) else { continue }
105 let repeat_ = current <= today ? deadline : (Days.closest(s, to: current, prefer: .future) ?? deadline)
106 let diff = deadline - current
107 let warningDays = Days.warningDays(s, delay: false, defaultDays: options.deadlineWarningDays)
108 if current != deadline, current != repeat_ {
109 if !isToday { continue }
110 if deadline > current {
111 if diff > warningDays { continue }
112 } else if 10000 < -diff {
113 continue
114 }
115 }
116 if heading.isDone, deadline != current { continue }
117 let time: Dotime? = current != deadline && current != repeat_ ? nil : (timeIn(s).map { .string($0) } ?? .headline)
118 let extra: String
119 if isToday, deadline < today {
120 extra = String(format: "%2d d. ago: ", -diff)
121 } else if isToday, deadline > today {
122 extra = String(format: "In %3d d.: ", diff)
123 } else {
124 extra = "Deadline: "
125 }
126 let upcoming = isToday && deadline > today
127 items.append(format(
128 source, heading, kind: upcoming ? .upcomingDeadline : .deadline, marker: offset, extra: extra,
129 dotime: time, removing: nil, trailing: options.gridTrailing, urgency: { (isToday ? -diff : 0) + $0 }
130 ))
131 }
132 return items
133 }
134
135 static func scheduled(_ source: AgendaSource, day current: Int, today: Int, options: AgendaOptions) -> [AgendaItem] {
136 let isToday = current == today
137 var items: [AgendaItem] = []
138 for heading in source.headings {
139 guard !heading.skipped, let (s, offset) = heading.scheduled, let schedule = Days.absolute(of: s) else { continue }
140 let repeat_ = current <= today ? schedule : (Days.closest(s, to: current, prefer: .future) ?? schedule)
141 let diff = current - schedule
142 let past = schedule < today
143 let delay = Days.warningDays(s, delay: true, defaultDays: 0)
144 if (delay > 0 && diff < delay) || diff > 10000 || schedule > current
145 || (current != schedule && current != today && current != repeat_) {
146 continue
147 }
148 if heading.isDone, schedule != current { continue }
149 // org-habit: habits only on today's agenda, and not when done.
150 if heading.isHabit, heading.isDone || !isToday { continue }
151 let time: Dotime? = !heading.isHabit && current != schedule && current != repeat_ ? nil : (timeIn(s).map { .string($0) } ?? .headline)
152 let extra = heading.isHabit ? "" : isToday && past ? String(format: "Sched.%2dx: ", diff) : "Scheduled: "
153 let habit = heading.isHabit ? heading.habit : nil
154 items.append(format(
155 source, heading, kind: past ? .pastScheduled : .scheduled, marker: offset, extra: extra,
156 dotime: time, removing: nil, trailing: options.gridTrailing, habit: habit,
157 urgency: { habit?.urgency(today: today) ?? 99 + diff + $0 }
158 ))
159 }
160 return items
161 }
162
163 static func blocks(_ source: AgendaSource, day current: Int, options: AgendaOptions) -> [AgendaItem] {
164 var items: [AgendaItem] = []
165 for block in source.blocks {
166 guard let startDay = Days.absolute(of: block.start), let endDay = Days.absolute(of: block.end),
167 current >= startDay, endDay >= current else { continue }
168 let heading = source.headings[block.heading]
169 let extra = startDay == endDay ? "" : "(\(current - startDay + 1)/\(endDay - startDay + 1)): "
170 let dotime: Dotime?
171 if startDay == current, endDay == current {
172 dotime = .string("<\(block.start)>--<\(block.end)>")
173 } else if startDay == current {
174 dotime = .string("<\(block.start)>")
175 } else if endDay == current {
176 dotime = .string("<\(block.end)>")
177 } else {
178 dotime = nil
179 }
180 items.append(format(source, heading, kind: .block, marker: block.marker, extra: extra, dotime: dotime, removing: nil, trailing: options.gridTrailing, urgency: { $0 }))
181 }
182 return items
183 }
184
185 static let repeating = try! NSRegularExpression(pattern: "^<[0-9]+-[0-9]+-[0-9]+[^>\\n]+?\\+[0-9]+[hdwmy]>")
186 static let stampBoth = try! NSRegularExpression(pattern: "^[\\[<](\(AgendaSource.tsInternal))[\\]>]")
187 static let stampActive = try! NSRegularExpression(pattern: "<(\(AgendaSource.tsInternal))>")
188 static let stampAny = try! NSRegularExpression(pattern: "[\\[<](\(AgendaSource.tsInternal))[\\]>]")
189
190 static func timestamps(_ source: AgendaSource, day current: Int, today: Int, options: AgendaOptions) -> [AgendaItem] {
191 let date = Days.date(current)
192 let literal = String(format: "<%04d-%02d-%02d", date.year, date.month, date.day)
193 var items: [AgendaItem] = []
194 for stamp in source.stamps {
195 let rest = stamp.rest as NSString
196 let all = NSRange(location: 0, length: rest.length)
197 let timestamp: String
198 if stamp.rest.hasPrefix(literal) {
199 guard let m = stampBoth.firstMatch(in: stamp.rest, range: all) else { continue }
200 timestamp = rest.substring(with: m.range)
201 } else if let m = repeating.firstMatch(in: stamp.rest, range: all) {
202 let repeat_ = rest.substring(with: m.range)
203 guard let past = Days.closest(repeat_, to: current > today ? today : current, prefer: .past) else { continue }
204 let future = current <= today ? past : (Days.closest(repeat_, to: current, prefer: .future) ?? past)
205 if current != past, current != future { continue }
206 timestamp = repeat_
207 } else {
208 continue
209 }
210 let heading = source.headings[stamp.heading]
211 items.append(format(
212 source, heading, kind: .timestamp, marker: stamp.offset, extra: "",
213 dotime: .string(timestamp), removing: stampActive, trailing: options.gridTrailing,
214 urgency: { heading.isHabit ? heading.habit?.urgency(today: today) ?? $0 : $0 }
215 ))
216 }
217 return items
218 }
219
220 /// `" HH:MM"` in a planning timestamp: the time and everything after it.
221 static func timeIn(_ s: String) -> String? {
222 guard let r = s.range(of: " [012]?[0-9]:[0-9][0-9]", options: .regularExpression) else { return nil }
223 return String(s[s.index(after: r.lowerBound)...]) + " "
224 }
225
226 // MARK: - Formatting
227
228 enum Dotime {
229 case string(String)
230 /// Look for a time in the heading only.
231 case headline
232 }
233
234 static let tagGroup = try! NSRegularExpression(pattern: "[ \\t]+(:([\\p{L}\\p{N}_@#%:]+):)[ \\t]*$")
235 static let stampTime = try! NSRegularExpression(
236 pattern: "<([0-9]{4}-[0-9]{2}-[0-9]{2} +[\\p{L}\\p{N}_]+ +)([012][0-9]:[0-5][0-9])(-([012][0-9]:[0-5][0-9]))?[^\\n\\r>]*?>(--?<\\1([012][0-9]:[0-5][0-9])>)?"
237 )
238 static let plainTimePart = "((?<![\\p{L}\\p{N}_])[012]?[0-9]((:([0-5][0-9]([AaPp][Mm])?))|([AaPp][Mm]))(?![\\p{L}\\p{N}_]))"
239 static let plainTime = try! NSRegularExpression(pattern: "\(plainTimePart)(--?\(plainTimePart))?")
240 static let timeOfDay = try! NSRegularExpression(
241 pattern: "(?<![\\p{L}\\p{N}_])([012]?[0-9])(?::([0-5][0-9])(am|pm)?|(am|pm))(?![\\p{L}\\p{N}_])",
242 options: .caseInsensitive
243 )
244
245 /// `org-agenda-format-item` with the default agenda prefix ` %i %-12:c%?-12t% s`, then
246 /// `org-agenda-highlight-todo`.
247 static func format(
248 _ source: AgendaSource, _ heading: AgendaSource.Heading, kind: AgendaItem.Kind, marker: Int,
249 extra: String, dotime: Dotime?, removing: NSRegularExpression?, trailing: String, hasTime: Bool = true,
250 habit: Habit? = nil, urgency: (Int) -> Int
251 ) -> AgendaItem {
252 var txt = heading.head.trimmingCharacters(in: .whitespaces)
253 // `org-agenda-fix-displayed-tags`: the heading's tags are replaced by the full list.
254 let tags = heading.tags
255 if let m = tagGroup.firstMatch(in: txt, range: NSRange(location: 0, length: (txt as NSString).length)) {
256 txt = (txt as NSString).substring(to: m.range.location)
257 }
258 var tagString = ""
259 if !tags.isEmpty {
260 var haveInherited = tags[0].inherited
261 var parts: [String] = []
262 for tag in tags {
263 if haveInherited, !tag.inherited {
264 haveInherited = false
265 parts.append(":" + tag.name)
266 } else {
267 parts.append(tag.name)
268 }
269 }
270 tagString = ":" + parts.joined(separator: ":") + (haveInherited ? "::" : ":")
271 }
272
273 let timed = timeFields(&txt, tagString: tagString, dotime: dotime, trailing: trailing, removeTimes: hasTime)
274 if let removing {
275 while let m = removing.firstMatch(in: txt, range: NSRange(location: 0, length: (txt as NSString).length)) {
276 txt = (txt as NSString).replacingCharacters(in: m.range, with: "")
277 }
278 }
279 let category = heading.category
280 // ` %i %-12:c%?-12t% s` in the agenda, ` %i %-12:c` in TODO and tags lists.
281 let prefix = " " + pad(category.isEmpty ? "" : category + ":", 12)
282 + (hasTime ? (timed.time.isEmpty ? "" : pad(timed.time, 12)) + (extra.isEmpty ? "" : extra + " ") : "")
283 txt = highlightTodo(txt, keywords: source.keywords)
284 let priority = Self.priority(prefix + txt, source.priorities)
285 return AgendaItem(
286 kind: kind, path: source.path, headingOffset: heading.start, headingLine: heading.line, markerOffset: marker, category: category,
287 timeOfDay: timed.timeOfDay, time: timed.time, extra: extra, text: txt, tags: tags.map(\.name),
288 todo: heading.todo, isDone: heading.isDone, urgency: urgency(priority),
289 warntime: heading.properties["APPT_WARNTIME"].flatMap { Int($0.trimmingCharacters(in: .whitespaces)) }, habit: habit,
290 line: prefix + txt + (tagString.isEmpty ? "" : " " + tagString)
291 )
292 }
293
294 /// The time-of-day part of `org-agenda-format-item`: finds the time, removes it from the
295 /// text, and formats the `%t` field.
296 static func timeFields(_ txt: inout String, tagString: String, dotime: Dotime?, trailing: String, removeTimes: Bool = true) -> (timeOfDay: Int?, time: String) {
297 guard let dotime else { return (nil, "") }
298 let full = tagString.isEmpty ? txt : txt + " " + tagString
299 let withoutStamps = stampAny.stringByReplacingMatches(in: full, range: NSRange(location: 0, length: (full as NSString).length), withTemplate: "")
300 let containing: String
301 switch dotime {
302 case .string(let s): containing = s + withoutStamps
303 case .headline: containing = withoutStamps
304 }
305 guard let timeOfDay = time(of: containing) else { return (nil, "") }
306 let ns = containing as NSString
307 let all = NSRange(location: 0, length: ns.length)
308 var start: String?
309 var end: String?
310 if let m = stampTime.firstMatch(in: containing, range: all) {
311 start = ns.substring(with: m.range(at: 2))
312 let endGroup = m.range(at: 3).location != NSNotFound ? 4 : 6
313 if m.range(at: endGroup).location != NSNotFound { end = ns.substring(with: m.range(at: endGroup)) }
314 if removeTimes { removeTime(ns.substring(with: m.range), from: &txt) }
315 } else if let m = plainTime.firstMatch(in: containing, range: all) {
316 start = ns.substring(with: m.range(at: 1))
317 if m.range(at: 8).location != NSNotFound { end = ns.substring(with: m.range(at: 8)) }
318 if removeTimes { removeTime(ns.substring(with: m.range), from: &txt) }
319 }
320 let startText = start.flatMap(formattedTime).map { String(repeating: " ", count: max(0, 5 - $0.count)) + $0 }
321 let endText = end.flatMap(formattedTime)
322 let time: String
323 if let startText, let endText {
324 time = startText + "-" + endText
325 } else if let startText {
326 time = startText + trailing
327 } else {
328 time = ""
329 }
330 return (timeOfDay, time)
331 }
332
333 /// `org-agenda-remove-times-when-in-prefix`.
334 static func removeTime(_ timeString: String, from txt: inout String) {
335 let ns = txt as NSString
336 let regex = try! NSRegularExpression(pattern: NSRegularExpression.escapedPattern(for: timeString) + " *")
337 guard let m = regex.firstMatch(in: txt, range: NSRange(location: 0, length: ns.length)) else { return }
338 if NSMaxRange(m.range) < ns.length, ns.character(at: NSMaxRange(m.range)) == 93 { return }
339 txt = ns.replacingCharacters(in: m.range, with: "")
340 }
341
342 /// `org-get-time-of-day` as a number.
343 static func time(of s: String) -> Int? {
344 let ns = s as NSString
345 guard let m = timeOfDay.firstMatch(in: s, range: NSRange(location: 0, length: ns.length)) else { return nil }
346 var hours = Int(ns.substring(with: m.range(at: 1)))!
347 let minutes = m.range(at: 2).location != NSNotFound ? Int(ns.substring(with: m.range(at: 2)))! : 0
348 let ampmRange = m.range(at: 3).location != NSNotFound ? m.range(at: 3) : m.range(at: 4)
349 if ampmRange.location != NSNotFound {
350 let am = ns.substring(with: ampmRange).lowercased() == "am"
351 hours = hours == 12 ? (am ? 0 : 12) : hours + (am ? 0 : 12)
352 }
353 return hours * 100 + minutes
354 }
355
356 /// `org-get-time-of-day` with `overtime`: `H:MM`, or `+H:MM` past midnight.
357 static func formattedTime(_ s: String) -> String? {
358 guard let value = time(of: s) else { return nil }
359 let hours = value / 100
360 let minutes = value % 100
361 if hours > 24 || (hours == 24 && minutes > 0) { return String(format: "+%d:%02d", hours - 24, minutes) }
362 return String(format: "%d:%02d", hours, minutes)
363 }
364
365 /// `format "%-Ns"`: pads to a display width.
366 static func pad(_ s: String, _ width: Int) -> String {
367 s + String(repeating: " ", count: max(0, width - displayWidth(s)))
368 }
369
370 /// `org-agenda-highlight-todo` with `org-agenda-todo-keyword-format` `%-1s`: the spaces
371 /// after a leading TODO keyword become one.
372 static func highlightTodo(_ txt: String, keywords: [String]) -> String {
373 let dots = txt.prefix { $0 == "." }
374 let rest = txt.dropFirst(dots.count)
375 for keyword in keywords.sorted(by: { $0.count > $1.count }) where rest.hasPrefix(keyword) {
376 let after = rest.dropFirst(keyword.count)
377 guard after.first == " " else { continue }
378 return dots + keyword + " " + after.drop { $0 == " " }
379 }
380 return txt
381 }
382
383 /// `org-get-priority`.
384 static func priority(_ s: String, _ priorities: Priorities) -> Int {
385 func value(_ p: String) -> Int {
386 p.allSatisfy(\.isNumber) ? Int(p)! : Int(p.unicodeScalars.first!.value)
387 }
388 let lowest = value(priorities.lowest)
389 if let r = s.range(of: "\\[#([A-Z]|[0-9]|[1-5][0-9]|6[0-4])\\]", options: .regularExpression) {
390 return 1000 * (lowest - value(String(s[r].dropFirst(2).dropLast())))
391 }
392 return 1000 * (lowest - value(priorities.default))
393 }
394
395 // MARK: - Grid, sorting, headers
396
397 /// `org-agenda-add-time-grid-maybe`.
398 static func grid(day: Int, isToday: Bool, now: (hour: Int, minute: Int), options: AgendaOptions) -> [AgendaItem] {
399 func item(_ kind: AgendaItem.Kind, _ text: String, _ dotime: String) -> AgendaItem {
400 var txt = text
401 let timed = timeFields(&txt, tagString: "", dotime: .string(dotime), trailing: options.gridTrailing)
402 return AgendaItem(
403 kind: kind, path: nil, headingOffset: nil, headingLine: nil, markerOffset: nil, category: "", timeOfDay: timed.timeOfDay,
404 time: timed.time, extra: "", text: txt, tags: [], todo: nil, isDone: false, urgency: 0, warntime: nil, habit: nil,
405 line: " " + pad("", 12) + pad(timed.time, 12) + txt
406 )
407 }
408 var new = options.gridTimes.reversed().map { t in
409 item(.timeGrid, options.gridLine, String(format: "%02d:%02d", t / 100, t % 100))
410 }
411 if isToday {
412 new.insert(item(.currentTime, options.currentTimeLine, String(format: "%02d:%02d ", now.hour, now.minute)), at: 0)
413 }
414 return new
415 }
416
417 /// `org-entries-lessp` with `(habit-down time-up urgency-down category-keep)`, stably.
418 static func sort(_ items: [AgendaItem]) -> [AgendaItem] {
419 items.enumerated().sorted { a, b in
420 if (a.element.habit == nil) != (b.element.habit == nil) { return a.element.habit == nil }
421 let ta = a.element.timeOfDay ?? 9901
422 let tb = b.element.timeOfDay ?? 9901
423 if ta != tb { return ta < tb }
424 if a.element.urgency != b.element.urgency { return a.element.urgency > b.element.urgency }
425 return a.offset < b.offset
426 }.map(\.element)
427 }
428
429 static let dayNames = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]
430 static let monthNames = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]
431
432 /// `org-agenda-format-date-aligned`.
433 public static func header(_ day: Int) -> String {
434 let date = Days.date(day)
435 let weekday = Days.weekday(day)
436 let week = weekday == 1 ? String(format: " W%02d", Days.isoWeek(day)) : ""
437 return pad(dayNames[weekday], 10) + String(format: " %2d ", date.day) + monthNames[date.month - 1] + String(format: " %4d", date.year) + week
438 }
439}