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