krz/orgstar

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

Sources/OrgCore/Agenda/Agenda.swift

b8d091bdcbf2889c220b12b2d0640d50cea17c24
orgstar/Sources/OrgCore/Agenda/Agenda.swift history · blame · raw

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}