krz/orgstar

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

Sources/OrgPresentation/AgendaRows.swift

322a87d47526d505d903e05d14e65a5464e1468f
orgstar/Sources/OrgPresentation/AgendaRows.swift history · blame · raw

346 lines · 14895 bytes

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