krz/orgstar

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

Commit c17e16cfba

c17e16cfba6b82a90e562cf08a88d7effacb8995

parent: 3a3dedba06

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-09 05:31 UTC

Agenda snapshot for widgets, orgstar:// links

The next seven days of agenda rows with their colors, written as JSON
into the app group's container, and the queries the widgets make of it.

Layout: unified · split

Sources/OrgApp/AgendaSnapshot.swift added +240
@@ -0,0 +1,240 @@
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 var link: AppLink? {
51 guard let path, let offset else { return nil }
52 return .entry(path: path, offset: offset)
53 }
54
55 /// Not done and not a calendar event.
56 public var isOpen: Bool { !isDone && !isEvent }
57 }
58
59 public struct Day: Codable, Equatable, Sendable, Identifiable {
60 /// As `Days` numbers it.
61 public var day: Int
62 /// Midnight starting the day.
63 public var date: Date
64 public var header: String
65 /// Today only: deadlines and scheduled entries from before today.
66 public var overdue: [Row]
67 public var rows: [Row]
68
69 public var id: Int { day }
70 }
71
72 public var generated: Date
73 public var days: [Day]
74 /// The agenda's colors the widgets use: `agenda-today`, `agenda-time`, `agenda-date`,
75 /// `foreground`, and `agenda-background` when the theme sets one.
76 public var colors: [String: Hex]
77
78 public init(generated: Date, days: [Day], colors: [String: Hex]) {
79 self.generated = generated
80 self.days = days
81 self.colors = colors
82 }
83
84 /// The groups from `today` through `dayCount - 1` days later, colored by `theme`.
85 public init(
86 groups: [AgendaDayGroup], today: Int, dayCount: Int = AgendaSnapshot.dayCount, theme: ThemeSpec, generated: Date,
87 calendar: Calendar = .current
88 ) {
89 func row(_ row: AgendaRow, midnight: Date) -> Row {
90 func at(_ hhmm: Int?) -> Date? {
91 hhmm.flatMap { calendar.date(byAdding: .minute, value: ($0 / 100) * 60 + $0 % 100, to: midnight) }
92 }
93 let item = row.item
94 return Row(
95 id: row.id, gutter: row.gutter, status: row.status.rawValue, title: row.title, category: row.category, todo: row.todo,
96 priority: row.priority, isDone: row.isDone, isEvent: row.isEvent, start: at(row.start), end: at(row.end),
97 path: item?.path, offset: item.flatMap { $0.headingOffset ?? $0.markerOffset },
98 statusColor: Hex { theme.statusColor(row.status, dark: $0) },
99 todoColor: row.todo.flatMap { todo in Hex { theme.todoColor(todo, isDone: row.isDone, dark: $0) } },
100 priorityColor: row.priority.flatMap { priority in Hex { theme.priorityColor(priority, dark: $0) } },
101 categoryColor: Hex { dark -> RGBA? in
102 if let event = row.event { return theme.eventColor(event, dark: dark) }
103 return theme.categoryColor(row.category, dark: dark)
104 }
105 )
106 }
107 days = groups.filter { $0.day >= today && $0.day < today + dayCount }.map { group in
108 let midnight = group.date(calendar: calendar)
109 return Day(
110 day: group.day, date: midnight, header: group.header,
111 overdue: group.overdue.map { row($0, midnight: midnight) }, rows: group.rows.map { row($0, midnight: midnight) }
112 )
113 }
114 var colors: [String: Hex] = [:]
115 for key in ["agenda-today", "agenda-time", "agenda-date", "foreground", "agenda-background"] {
116 colors[key] = Hex { theme.color(key, dark: $0) }
117 }
118 self.colors = colors
119 self.generated = generated
120 }
121
122 // MARK: - What the widgets show
123
124 /// The snapshot's day that `now` falls on.
125 public func day(at now: Date, calendar: Calendar = .current) -> Day? {
126 days.first { calendar.isDate($0.date, inSameDayAs: now) }
127 }
128
129 /// The day after `now`'s.
130 public func day(after now: Date, calendar: Calendar = .current) -> Day? {
131 guard let next = calendar.date(byAdding: .day, value: 1, to: calendar.startOfDay(for: now)) else { return nil }
132 return day(at: next, calendar: calendar)
133 }
134
135 /// What's left of `now`'s day: its rows not done, without timed rows that have ended
136 /// (or, with no end, started) before `now`, then the overdue entries.
137 public func upcoming(at now: Date, calendar: Calendar = .current) -> [Row] {
138 guard let day = day(at: now, calendar: calendar) else { return [] }
139 return (day.rows + day.overdue).filter { row in
140 guard !row.isDone else { return false }
141 guard let time = row.end ?? row.start else { return true }
142 return time >= now
143 }
144 }
145
146 /// Entries of `now`'s day not done, overdue ones included; calendar events don't count.
147 public func openCount(at now: Date, calendar: Calendar = .current) -> Int {
148 guard let day = day(at: now, calendar: calendar) else { return 0 }
149 return (day.rows + day.overdue).filter(\.isOpen).count
150 }
151
152 public func overdueCount(at now: Date, calendar: Calendar = .current) -> Int {
153 day(at: now, calendar: calendar)?.overdue.filter(\.isOpen).count ?? 0
154 }
155
156 /// When what `upcoming` shows changes after `now`: a minute after each timed row of the
157 /// day ends (or starts), and the next midnight.
158 public func changes(after now: Date, calendar: Calendar = .current) -> [Date] {
159 var dates = Set<Date>()
160 for row in day(at: now, calendar: calendar).map({ $0.rows + $0.overdue }) ?? [] {
161 if let time = row.end ?? row.start, time.addingTimeInterval(60) > now { dates.insert(time.addingTimeInterval(60)) }
162 }
163 if let midnight = calendar.date(byAdding: .day, value: 1, to: calendar.startOfDay(for: now)) { dates.insert(midnight) }
164 return dates.sorted()
165 }
166
167 // MARK: - The file
168
169 /// `Widgets/agenda.json` in the app group's container; nil without the group.
170 public static func file(group: String = CaptureInbox.group) -> URL? {
171 FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: group)?
172 .appendingPathComponent("Widgets").appendingPathComponent("agenda.json")
173 }
174
175 public static func read(from file: URL) -> AgendaSnapshot? {
176 guard let data = try? Data(contentsOf: file) else { return nil }
177 return try? decoder.decode(AgendaSnapshot.self, from: data)
178 }
179
180 /// Writes the snapshot unless the file holds the same days and colors; true when it wrote.
181 @discardableResult
182 public func write(to file: URL) throws -> Bool {
183 if let old = Self.read(from: file), old.days == days, old.colors == colors { return false }
184 try FileManager.default.createDirectory(at: file.deletingLastPathComponent(), withIntermediateDirectories: true)
185 try Self.encoder.encode(self).write(to: file, options: .atomic)
186 return true
187 }
188
189 static var encoder: JSONEncoder {
190 let encoder = JSONEncoder()
191 encoder.dateEncodingStrategy = .iso8601
192 encoder.outputFormatting = [.sortedKeys]
193 return encoder
194 }
195
196 static var decoder: JSONDecoder {
197 let decoder = JSONDecoder()
198 decoder.dateDecodingStrategy = .iso8601
199 return decoder
200 }
201}
202
203/// The app's own links, `orgstar://`, which widgets and the Live Activity open.
204public enum AppLink: Equatable, Sendable {
205 case agenda
206 /// An Org entry: its file and an offset in it.
207 case entry(path: String, offset: Int)
208 /// The entry of the running clock.
209 case clock
210
211 public static let scheme = "orgstar"
212
213 public var url: URL {
214 var components = URLComponents()
215 components.scheme = Self.scheme
216 switch self {
217 case .agenda:
218 components.host = "agenda"
219 case .clock:
220 components.host = "clock"
221 case .entry(let path, let offset):
222 components.host = "entry"
223 components.queryItems = [URLQueryItem(name: "path", value: path), URLQueryItem(name: "offset", value: String(offset))]
224 }
225 return components.url!
226 }
227
228 public init?(url: URL) {
229 guard url.scheme == Self.scheme, let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return nil }
230 func query(_ name: String) -> String? { components.queryItems?.first { $0.name == name }?.value }
231 switch components.host {
232 case "agenda": self = .agenda
233 case "clock": self = .clock
234 case "entry":
235 guard let path = query("path"), let offset = query("offset").flatMap(Int.init) else { return nil }
236 self = .entry(path: path, offset: offset)
237 default: return nil
238 }
239 }
240}
Tests/OrgAppTests/AgendaSnapshotTests.swift added +122
@@ -0,0 +1,122 @@
1import Foundation
2import OrgCore
3import OrgPresentation
4import Testing
5@testable import OrgApp
6
7struct AgendaSnapshotTests {
8 static let file = """
9 #+CATEGORY: life
10 * TODO [#A] Send forms
11 DEADLINE: <2026-10-02 Fri>
12 * TODO Water plants
13 SCHEDULED: <2026-10-05 Mon>
14 * DONE Took out bins
15 SCHEDULED: <2026-10-05 Mon>
16 * Standup
17 SCHEDULED: <2026-10-05 Mon 08:00>
18 * Dentist <2026-10-05 Mon 13:50-14:30>
19 * NEXT Call the bank
20 SCHEDULED: <2026-10-06 Tue 09:30>
21 * Yesterday's note <2026-10-04 Sun>
22 * Far off <2026-10-20 Tue>
23 """
24
25 static let today = Days.absolute(year: 2026, month: 10, day: 5)
26 static let utc: Calendar = {
27 var c = Calendar(identifier: .gregorian)
28 c.timeZone = TimeZone(identifier: "UTC")!
29 return c
30 }()
31
32 static func date(_ day: Int, _ hour: Int, _ minute: Int = 0) -> Date {
33 Date(timeIntervalSince1970: Double((day - 719163) * 86400 + hour * 3600 + minute * 60))
34 }
35
36 /// The agenda from three days before today for three weeks, as the app's view may be.
37 static func snapshot() -> AgendaSnapshot {
38 let source = AgendaSource(path: "/notes/life.org", text: file, defaults: OrgSettings.default.withTodoKeywords("TODO NEXT | DONE")!)
39 let days = Agenda.list([source], start: today - 3, days: 21, today: today, now: (10, 0))
40 let event = AgendaEvent(
41 id: "e1", title: "Lunch", calendarID: "c", calendarTitle: "Home", color: RGBA(hex: "#336699"),
42 start: date(today, 12), end: date(today, 13), isAllDay: false
43 )
44 let groups = AgendaRows.groups(days, events: [event], today: today, calendar: utc)
45 return AgendaSnapshot(groups: groups, today: today, theme: .default, generated: date(today, 10), calendar: utc)
46 }
47
48 @Test func holdsTodayAndTheNextDaysOnly() {
49 let snapshot = Self.snapshot()
50 #expect(snapshot.days.map(\.day) == [Self.today, Self.today + 1])
51 let today = snapshot.days[0]
52 #expect(today.date == Self.date(Self.today, 0))
53 #expect(today.overdue.map(\.title) == ["Send forms"])
54 #expect(today.rows.map(\.title) == ["Standup", "Lunch", "Dentist", "Water plants", "Took out bins"])
55 #expect(snapshot.days[1].rows.map(\.title) == ["Call the bank"])
56 }
57
58 @Test func rowsCarryTimesLinksAndColors() throws {
59 let snapshot = Self.snapshot()
60 let rows = snapshot.days[0].rows + snapshot.days[0].overdue
61 let dentist = try #require(rows.first { $0.title == "Dentist" })
62 #expect(dentist.gutter == "13:50–14:30")
63 #expect(dentist.start == Self.date(Self.today, 13, 50))
64 #expect(dentist.end == Self.date(Self.today, 14, 30))
65 #expect(dentist.path == "/notes/life.org")
66 #expect(dentist.link == .entry(path: "/notes/life.org", offset: dentist.offset!))
67 let forms = try #require(rows.first { $0.title == "Send forms" })
68 #expect(forms.status == "overdue")
69 #expect(forms.todo == "TODO")
70 #expect(forms.priority == "A")
71 #expect(forms.todoColor == .init(light: "#d70015", dark: "#ff6961"))
72 #expect(forms.priorityColor == .init(light: "#d70015", dark: "#ff6961"))
73 #expect(forms.statusColor == .init(light: "#d70015", dark: "#ff6961"))
74 let lunch = try #require(rows.first { $0.title == "Lunch" })
75 #expect(lunch.isEvent && lunch.link == nil)
76 #expect(lunch.categoryColor == .init(light: "#336699", dark: "#336699"))
77 #expect(snapshot.colors["agenda-today"] == .init(light: "#007aff", dark: "#0a84ff"))
78 #expect(snapshot.colors["agenda-background"] == nil)
79 }
80
81 @Test func encodesAndDecodes() throws {
82 let snapshot = Self.snapshot()
83 let data = try AgendaSnapshot.encoder.encode(snapshot)
84 #expect(try AgendaSnapshot.decoder.decode(AgendaSnapshot.self, from: data) == snapshot)
85 }
86
87 @Test func writesOnlyWhatChanged() throws {
88 let folder = try Folder()
89 let file = folder.url.appendingPathComponent("Widgets/agenda.json")
90 var snapshot = Self.snapshot()
91 #expect(try snapshot.write(to: file))
92 #expect(AgendaSnapshot.read(from: file) == snapshot)
93 snapshot.generated = snapshot.generated.addingTimeInterval(60)
94 #expect(try !snapshot.write(to: file))
95 snapshot.days.removeLast()
96 #expect(try snapshot.write(to: file))
97 #expect(AgendaSnapshot.read(from: file)?.days.count == 1)
98 }
99
100 @Test func whatIsLeftOfToday() {
101 let snapshot = Self.snapshot()
102 let morning = Self.date(Self.today, 7)
103 #expect(snapshot.upcoming(at: morning, calendar: Self.utc).map(\.title) == ["Standup", "Lunch", "Dentist", "Water plants", "Send forms"])
104 #expect(snapshot.upcoming(at: Self.date(Self.today, 14), calendar: Self.utc).map(\.title) == ["Dentist", "Water plants", "Send forms"])
105 #expect(snapshot.openCount(at: morning, calendar: Self.utc) == 4)
106 #expect(snapshot.overdueCount(at: morning, calendar: Self.utc) == 1)
107 #expect(snapshot.day(after: morning, calendar: Self.utc)?.day == Self.today + 1)
108 #expect(snapshot.upcoming(at: Self.date(Self.today + 3, 9), calendar: Self.utc) == [])
109 #expect(snapshot.changes(after: Self.date(Self.today, 12, 30), calendar: Self.utc) == [
110 Self.date(Self.today, 13, 1), Self.date(Self.today, 14, 31), Self.date(Self.today + 1, 0),
111 ])
112 }
113
114 @Test func linksRoundTrip() {
115 for link in [AppLink.agenda, .clock, .entry(path: "/notes/a b&c+d/ünï.org", offset: 42)] {
116 #expect(AppLink(url: link.url) == link)
117 }
118 #expect(AppLink.agenda.url.absoluteString == "orgstar://agenda")
119 #expect(AppLink(url: URL(string: "org-protocol://capture?body=x")!) == nil)
120 #expect(AppLink(url: URL(string: "orgstar://entry?path=/a.org")!) == nil)
121 }
122}