krz/orgstar

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

Sources/OrgPresentation/AgendaRows.swift

2fa201330f61c801f777baa3f2943d49d8758cda
orgstar/Sources/OrgPresentation/AgendaRows.swift history · blame · raw

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