krz/orgstar

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

Sources/OrgPresentation/AgendaRows.swift

16086b4cf2caff5328774b2cd5ae3ffe1ab65ca4
orgstar/Sources/OrgPresentation/AgendaRows.swift history · blame · raw

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