krz/orgstar

A native macOS editor for org-mode files. editor org-mode swift

Sources/OrgCore/Agenda/Agenda.swift

191ddb506f3a91b93c755ba6e8123a4e8ac4f90d
orgstar/Sources/OrgCore/Agenda/Agenda.swift history · blame · raw

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