krz/orgstar

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

Sources/OrgApp/AgendaSnapshot.swift

2fa201330f61c801f777baa3f2943d49d8758cda
orgstar/Sources/OrgApp/AgendaSnapshot.swift history · blame · raw

272 lines · 11303 bytes

  1import Foundation
  2import OrgCore
  3import OrgPresentation
  4
  5/// The next days of the agenda for widgets, which can't read the folders: the app writes it
  6/// as JSON into the app group's container, the widgets read it.
  7public struct AgendaSnapshot: Codable, Equatable, Sendable {
  8    /// Days from today the snapshot holds.
  9    public static let dayCount = 7
 10
 11    /// A color in light and dark appearance, `#rrggbb` or `#rrggbbaa`.
 12    public struct Hex: Codable, Equatable, Sendable {
 13        public var light: String
 14        public var dark: String
 15
 16        public init(light: String, dark: String) {
 17            self.light = light
 18            self.dark = dark
 19        }
 20
 21        init?(_ color: (Bool) -> RGBA?) {
 22            guard let light = color(false) ?? color(true), let dark = color(true) ?? color(false) else { return nil }
 23            self.init(light: light.hex, dark: dark.hex)
 24        }
 25    }
 26
 27    public struct Row: Codable, Equatable, Sendable, Identifiable {
 28        public var id: String
 29        /// `AgendaRow.gutter`: the time or a short status.
 30        public var gutter: String
 31        /// `AgendaRow.Status`'s raw value.
 32        public var status: String
 33        public var title: String
 34        public var category: String
 35        public var todo: String?
 36        public var priority: String?
 37        public var isDone: Bool
 38        public var isEvent: Bool
 39        /// When a timed row starts and ends.
 40        public var start: Date?
 41        public var end: Date?
 42        /// The Org entry, for a link to it.
 43        public var path: String?
 44        public var offset: Int?
 45        public var statusColor: Hex?
 46        public var todoColor: Hex?
 47        public var priorityColor: Hex?
 48        public var categoryColor: Hex?
 49
 50        public init(
 51            id: String, gutter: String, status: String, title: String, category: String, todo: String?, priority: String?, isDone: Bool,
 52            isEvent: Bool, start: Date?, end: Date?, path: String?, offset: Int?, statusColor: Hex?, todoColor: Hex?, priorityColor: Hex?,
 53            categoryColor: Hex?
 54        ) {
 55            self.id = id
 56            self.gutter = gutter
 57            self.status = status
 58            self.title = title
 59            self.category = category
 60            self.todo = todo
 61            self.priority = priority
 62            self.isDone = isDone
 63            self.isEvent = isEvent
 64            self.start = start
 65            self.end = end
 66            self.path = path
 67            self.offset = offset
 68            self.statusColor = statusColor
 69            self.todoColor = todoColor
 70            self.priorityColor = priorityColor
 71            self.categoryColor = categoryColor
 72        }
 73
 74        public var link: AppLink? {
 75            guard let path, let offset else { return nil }
 76            return .entry(path: path, offset: offset)
 77        }
 78
 79        /// Not done and not a calendar event.
 80        public var isOpen: Bool { !isDone && !isEvent }
 81    }
 82
 83    public struct Day: Codable, Equatable, Sendable, Identifiable {
 84        /// As `Days` numbers it.
 85        public var day: Int
 86        /// Midnight starting the day.
 87        public var date: Date
 88        public var header: String
 89        /// Today only: deadlines and scheduled entries from before today.
 90        public var overdue: [Row]
 91        public var rows: [Row]
 92
 93        public var id: Int { day }
 94
 95        public init(day: Int, date: Date, header: String, overdue: [Row], rows: [Row]) {
 96            self.day = day
 97            self.date = date
 98            self.header = header
 99            self.overdue = overdue
100            self.rows = rows
101        }
102    }
103
104    public var generated: Date
105    public var days: [Day]
106    /// The agenda's colors the widgets use: `agenda-today`, `agenda-time`, `agenda-date`,
107    /// `foreground`, and `agenda-background` when the theme sets one.
108    public var colors: [String: Hex]
109
110    public init(generated: Date, days: [Day], colors: [String: Hex]) {
111        self.generated = generated
112        self.days = days
113        self.colors = colors
114    }
115
116    /// The groups from `today` through `dayCount - 1` days later, colored by `theme`.
117    public init(
118        groups: [AgendaDayGroup], today: Int, dayCount: Int = AgendaSnapshot.dayCount, theme: ThemeSpec, generated: Date,
119        calendar: Calendar = .current
120    ) {
121        func row(_ row: AgendaRow, midnight: Date) -> Row {
122            func at(_ hhmm: Int?) -> Date? {
123                hhmm.flatMap { calendar.date(byAdding: .minute, value: ($0 / 100) * 60 + $0 % 100, to: midnight) }
124            }
125            let item = row.item
126            return Row(
127                id: row.id, gutter: row.gutter, status: row.status.rawValue, title: row.title, category: row.category, todo: row.todo,
128                priority: row.priority, isDone: row.isDone, isEvent: row.isEvent, start: at(row.start), end: at(row.end),
129                path: item?.path, offset: item.flatMap { $0.headingOffset ?? $0.markerOffset },
130                statusColor: Hex { theme.statusColor(row.status, dark: $0) },
131                todoColor: row.todo.flatMap { todo in Hex { theme.todoColor(todo, isDone: row.isDone, dark: $0) } },
132                priorityColor: row.priority.flatMap { priority in Hex { theme.priorityColor(priority, dark: $0) } },
133                categoryColor: Hex { dark -> RGBA? in
134                    if let event = row.event { return theme.eventColor(event, dark: dark) }
135                    return theme.categoryColor(row.category, dark: dark)
136                }
137            )
138        }
139        days = groups.filter { $0.day >= today && $0.day < today + dayCount }.map { group in
140            let midnight = group.date(calendar: calendar)
141            return Day(
142                day: group.day, date: midnight, header: group.header,
143                overdue: group.overdue.map { row($0, midnight: midnight) }, rows: group.rows.map { row($0, midnight: midnight) }
144            )
145        }
146        var colors: [String: Hex] = [:]
147        for key in ["agenda-today", "agenda-time", "agenda-date", "foreground", "agenda-background"] {
148            colors[key] = Hex { theme.color(key, dark: $0) }
149        }
150        self.colors = colors
151        self.generated = generated
152    }
153
154    // MARK: - What the widgets show
155
156    /// The snapshot's day that `now` falls on.
157    public func day(at now: Date, calendar: Calendar = .current) -> Day? {
158        days.first { calendar.isDate($0.date, inSameDayAs: now) }
159    }
160
161    /// The day after `now`'s.
162    public func day(after now: Date, calendar: Calendar = .current) -> Day? {
163        guard let next = calendar.date(byAdding: .day, value: 1, to: calendar.startOfDay(for: now)) else { return nil }
164        return day(at: next, calendar: calendar)
165    }
166
167    /// What's left of `now`'s day: its rows not done, without timed rows that have ended
168    /// (or, with no end, started) before `now`, then the overdue entries.
169    public func upcoming(at now: Date, calendar: Calendar = .current) -> [Row] {
170        guard let day = day(at: now, calendar: calendar) else { return [] }
171        return (day.rows + day.overdue).filter { row in
172            guard !row.isDone else { return false }
173            guard let time = row.end ?? row.start else { return true }
174            return time >= now
175        }
176    }
177
178    /// Entries of `now`'s day not done, overdue ones included; calendar events don't count.
179    public func openCount(at now: Date, calendar: Calendar = .current) -> Int {
180        guard let day = day(at: now, calendar: calendar) else { return 0 }
181        return (day.rows + day.overdue).filter(\.isOpen).count
182    }
183
184    public func overdueCount(at now: Date, calendar: Calendar = .current) -> Int {
185        day(at: now, calendar: calendar)?.overdue.filter(\.isOpen).count ?? 0
186    }
187
188    /// When what `upcoming` shows changes after `now`: a minute after each timed row of the
189    /// day ends (or starts), and the next midnight.
190    public func changes(after now: Date, calendar: Calendar = .current) -> [Date] {
191        var dates = Set<Date>()
192        for row in day(at: now, calendar: calendar).map({ $0.rows + $0.overdue }) ?? [] {
193            if let time = row.end ?? row.start, time.addingTimeInterval(60) > now { dates.insert(time.addingTimeInterval(60)) }
194        }
195        if let midnight = calendar.date(byAdding: .day, value: 1, to: calendar.startOfDay(for: now)) { dates.insert(midnight) }
196        return dates.sorted()
197    }
198
199    // MARK: - The file
200
201    /// `Widgets/agenda.json` in the app group's container; nil without the group.
202    public static func file(group: String = CaptureInbox.group) -> URL? {
203        FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: group)?
204            .appendingPathComponent("Widgets").appendingPathComponent("agenda.json")
205    }
206
207    public static func read(from file: URL) -> AgendaSnapshot? {
208        guard let data = try? Data(contentsOf: file) else { return nil }
209        return try? decoder.decode(AgendaSnapshot.self, from: data)
210    }
211
212    /// Writes the snapshot unless the file holds the same days and colors; true when it wrote.
213    @discardableResult
214    public func write(to file: URL) throws -> Bool {
215        if let old = Self.read(from: file), old.days == days, old.colors == colors { return false }
216        try FileManager.default.createDirectory(at: file.deletingLastPathComponent(), withIntermediateDirectories: true)
217        try Self.encoder.encode(self).write(to: file, options: .atomic)
218        return true
219    }
220
221    static var encoder: JSONEncoder {
222        let encoder = JSONEncoder()
223        encoder.dateEncodingStrategy = .iso8601
224        encoder.outputFormatting = [.sortedKeys]
225        return encoder
226    }
227
228    static var decoder: JSONDecoder {
229        let decoder = JSONDecoder()
230        decoder.dateDecodingStrategy = .iso8601
231        return decoder
232    }
233}
234
235/// The app's own links, `orgstar://`, which widgets and the Live Activity open.
236public enum AppLink: Equatable, Sendable {
237    case agenda
238    /// An Org entry: its file and an offset in it.
239    case entry(path: String, offset: Int)
240    /// The entry of the running clock.
241    case clock
242
243    public static let scheme = "orgstar"
244
245    public var url: URL {
246        var components = URLComponents()
247        components.scheme = Self.scheme
248        switch self {
249        case .agenda:
250            components.host = "agenda"
251        case .clock:
252            components.host = "clock"
253        case .entry(let path, let offset):
254            components.host = "entry"
255            components.queryItems = [URLQueryItem(name: "path", value: path), URLQueryItem(name: "offset", value: String(offset))]
256        }
257        return components.url!
258    }
259
260    public init?(url: URL) {
261        guard url.scheme == Self.scheme, let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return nil }
262        func query(_ name: String) -> String? { components.queryItems?.first { $0.name == name }?.value }
263        switch components.host {
264        case "agenda": self = .agenda
265        case "clock": self = .clock
266        case "entry":
267            guard let path = query("path"), let offset = query("offset").flatMap(Int.init) else { return nil }
268            self = .entry(path: path, offset: offset)
269        default: return nil
270        }
271    }
272}