krz/orgstar

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

Commit 4e399248c2

4e399248c2b4680fb2fabcdf3e78be714e611577

parent: e7870268f3

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-09 04:50 UTC

Agenda settings and Calendar events

config.toml gains org-agenda-show-all-dates (default false),
org-agenda-skip-scheduled-if-done, org-agenda-skip-deadline-if-done,
calendar-events and calendar-event-calendars. CalendarEvents reads
events through EventKit; AgendaModel fetches them for the span.
Both apps' Info.plist describe calendar access.

Layout: unified · split

Sources/OrgApp/AgendaModel.swift +31 −1
@@ -2,6 +2,7 @@ import Foundation
22import Observation
33import OrgCore
44import OrgIndex
5import OrgPresentation
56
67/// The day/week agenda over the workspace's agenda files. Sources are read from disk and
78/// cached by modification date and size; the open file's come from its buffer, so the agenda
@@ -22,6 +23,14 @@ public final class AgendaModel {
2223 public var includeSubfolders = false
2324 public var options = AgendaOptions()
2425 public private(set) var isComputing = false
26 /// Calendar events over the days shown, from `calendarEvents` (nil reads none) and the
27 /// calendars in `calendarNames` (all when empty).
28 public private(set) var events: [AgendaEvent] = []
29 public var calendarEvents: CalendarEvents?
30 public var calendarNames: [String] = []
31 /// Today and the calendar of the last refresh.
32 public private(set) var today = Days.absolute(Date(), calendar: .current)
33 @ObservationIgnored private var calendar = Calendar.current
2534
2635 private let workspace: WorkspaceModel
2736 @ObservationIgnored private var cache: [String: (mtime: Date, size: Int, source: AgendaSource)] = [:]
@@ -57,23 +66,33 @@ public final class AgendaModel {
5766 let first = start ?? today + startOffset
5867 let options = self.options
5968 let mode = self.mode
69 let provider = calendarEvents
70 let names = calendarNames
6071 let all = await sources(open: open)
6172 let result = await Task.detached {
6273 var days: [AgendaDay] = []
6374 var list: [AgendaItem] = []
75 var events: [AgendaEvent] = []
6476 switch mode {
6577 case .agenda:
6678 days = Agenda.list(all, start: first, days: span, today: today, now: (clock.hour ?? 0, clock.minute ?? 0), options: options)
79 if let provider {
80 let from = Self.date(first, calendar: calendar)
81 events = provider.events(from: from, to: Self.date(first + span, calendar: calendar), chosen: names)
82 }
6783 case .todo(let keywords):
6884 list = Agenda.todoList(all, keywords: keywords, options: options)
6985 case .match(let match, let todoOnly):
7086 list = Agenda.tagsMatch(all, match: match, todoOnly: todoOnly, now: now, calendar: calendar, options: options)
7187 }
72 return (days, list)
88 return (days, list, events)
7389 }.value
7490 guard id == generation else { return }
7591 days = result.0
7692 list = result.1
93 events = result.2
94 self.today = today
95 self.calendar = calendar
7796 isComputing = false
7897 }
7998
@@ -126,6 +145,17 @@ public final class AgendaModel {
126145 return loaded.map(\.source)
127146 }
128147
148 /// The days as rows with the events, empty days hidden unless `showAllDates`; see
149 /// `AgendaRows.groups`.
150 public func groups(showAllDates: Bool = false, keep: (AgendaItem) -> Bool = { _ in true }) -> [AgendaDayGroup] {
151 AgendaRows.groups(days, events: events, today: today, showAllDates: showAllDates, calendar: calendar, keep: keep)
152 }
153
154 nonisolated static func date(_ day: Int, calendar: Calendar) -> Date {
155 let d = Days.date(day)
156 return calendar.date(from: DateComponents(year: d.year, month: d.month, day: d.day)) ?? Date()
157 }
158
129159 public func showToday() { start = nil }
130160
131161 /// Moves the view by a whole span, forward or back.
Sources/OrgApp/CalendarEvents.swift added +86
@@ -0,0 +1,86 @@
1import CoreGraphics
2import EventKit
3import Foundation
4import OrgCore
5import OrgPresentation
6
7/// What the agenda reads from a calendar event: `EKEvent`, or a value made in tests.
8public protocol CalendarEventRecord {
9 var recordID: String { get }
10 var recordTitle: String { get }
11 var recordStart: Date { get }
12 var recordEnd: Date { get }
13 var recordIsAllDay: Bool { get }
14 var recordLocation: String? { get }
15 var recordCalendarID: String { get }
16 var recordCalendarTitle: String { get }
17 var recordCalendarColor: CGColor? { get }
18}
19
20extension EKEvent: CalendarEventRecord {
21 /// A repeating event's occurrences share an identifier; the start tells them apart.
22 public var recordID: String { (eventIdentifier ?? calendarItemIdentifier) + "@" + String(Int(startDate.timeIntervalSince1970)) }
23 public var recordTitle: String { title ?? "" }
24 public var recordStart: Date { startDate }
25 public var recordEnd: Date { endDate }
26 public var recordIsAllDay: Bool { isAllDay }
27 public var recordLocation: String? { location.flatMap { $0.isEmpty ? nil : $0 } }
28 public var recordCalendarID: String { calendar?.calendarIdentifier ?? "" }
29 public var recordCalendarTitle: String { calendar?.title ?? "" }
30 public var recordCalendarColor: CGColor? { calendar?.cgColor }
31}
32
33/// Read-only events from the system Calendar for the agenda (`calendar-events` in
34/// config.toml), from the calendars `calendar-event-calendars` names.
35public final class CalendarEvents: @unchecked Sendable {
36 private let store = EKEventStore()
37
38 public init() {}
39
40 /// Reading events needs full access; EventKit has no read-only level.
41 public static var isAuthorized: Bool { EKEventStore.authorizationStatus(for: .event) == .fullAccess }
42
43 /// Asks for access once; later calls answer with what the person chose.
44 public func requestAccess() async -> Bool {
45 if Self.isAuthorized { return true }
46 return (try? await store.requestFullAccessToEvents()) ?? false
47 }
48
49 /// The event calendars, for choosing among them.
50 public func calendars() -> [(id: String, title: String, color: RGBA?)] {
51 guard Self.isAuthorized else { return [] }
52 return store.calendars(for: .event).map { ($0.calendarIdentifier, $0.title, $0.cgColor.flatMap(Self.rgba)) }
53 }
54
55 /// Events overlapping `start..<end` in the chosen calendars (all when `chosen` is empty),
56 /// by start. None without access.
57 public func events(from start: Date, to end: Date, chosen: [String]) -> [AgendaEvent] {
58 guard Self.isAuthorized else { return [] }
59 let calendars = store.calendars(for: .event).filter { Self.chooses(id: $0.calendarIdentifier, title: $0.title, chosen: chosen) }
60 guard !calendars.isEmpty else { return [] }
61 let predicate = store.predicateForEvents(withStart: start, end: end, calendars: calendars)
62 return store.events(matching: predicate).map(Self.event).sorted { $0.start < $1.start }
63 }
64
65 /// Whether a calendar is among those chosen by identifier or title; every calendar is when
66 /// none are.
67 public static func chooses(id: String, title: String, chosen: [String]) -> Bool {
68 chosen.isEmpty || chosen.contains(id) || chosen.contains(title)
69 }
70
71 public static func event(_ record: some CalendarEventRecord) -> AgendaEvent {
72 AgendaEvent(
73 id: record.recordID, title: record.recordTitle, calendarID: record.recordCalendarID, calendarTitle: record.recordCalendarTitle,
74 color: record.recordCalendarColor.flatMap(rgba), start: record.recordStart, end: record.recordEnd,
75 isAllDay: record.recordIsAllDay, location: record.recordLocation
76 )
77 }
78
79 /// A color in sRGB.
80 static func rgba(_ color: CGColor) -> RGBA? {
81 guard let space = CGColorSpace(name: CGColorSpace.sRGB),
82 let converted = color.converted(to: space, intent: .defaultIntent, options: nil),
83 let c = converted.components, c.count >= 3 else { return nil }
84 return RGBA(red: Double(c[0]), green: Double(c[1]), blue: Double(c[2]), alpha: Double(converted.alpha))
85 }
86}
Sources/OrgApp/OrgPreferences.swift +17
@@ -50,6 +50,23 @@ public enum OrgPreferences {
5050 public static func agendaSpan(_ defaults: UserDefaults = .standard) -> Int { integer("agendaSpan", defaults) }
5151 public static func agendaStartOffset(_ defaults: UserDefaults = .standard) -> Int { integer("agendaStartOffset", defaults) }
5252 public static func agendaSubfolders(_ defaults: UserDefaults = .standard) -> Bool { bool("agendaSubfolders", defaults) }
53 /// `org-agenda-show-all-dates`: days without entries show.
54 public static func agendaShowAllDates(_ defaults: UserDefaults = .standard) -> Bool { bool("agendaShowAllDates", defaults) }
55 public static func agendaSkipScheduledIfDone(_ defaults: UserDefaults = .standard) -> Bool { bool("agendaSkipScheduledIfDone", defaults) }
56 public static func agendaSkipDeadlineIfDone(_ defaults: UserDefaults = .standard) -> Bool { bool("agendaSkipDeadlineIfDone", defaults) }
57 /// `options` with the skip-if-done settings.
58 public static func agendaOptions(_ options: AgendaOptions, _ defaults: UserDefaults = .standard) -> AgendaOptions {
59 var options = options
60 options.skipScheduledIfDone = agendaSkipScheduledIfDone(defaults)
61 options.skipDeadlineIfDone = agendaSkipDeadlineIfDone(defaults)
62 return options
63 }
64 /// `calendar-events`: Calendar's events show in the agenda.
65 public static func calendarEvents(_ defaults: UserDefaults = .standard) -> Bool { bool("calendarEvents", defaults) }
66 /// `calendar-event-calendars`: the calendars chosen, by title or identifier; empty for all.
67 public static func calendarEventCalendars(_ defaults: UserDefaults = .standard) -> [String] {
68 string("calendarEventCalendars", defaults).split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty }
69 }
5370 public static func reminders(_ defaults: UserDefaults = .standard) -> Bool { bool("reminders", defaults) }
5471 /// `appt-message-warning-time`, in minutes.
5572 public static func reminderLead(_ defaults: UserDefaults = .standard) -> Int { integer("reminderLead", defaults) }
Sources/OrgApp/UserConfig.swift +10
@@ -141,6 +141,12 @@ public enum UserConfig {
141141 comment: "days the agenda shows", legacy: "agenda.span"),
142142 Setting(section: "", key: "org-agenda-start-day", defaultsKey: "agendaStartOffset", kind: .dayOffset, fallback: .string("-3d"),
143143 comment: "the agenda's first day: \"-3d\", \"+0d\"", legacy: "agenda.start-offset"),
144 Setting(section: "", key: "org-agenda-show-all-dates", defaultsKey: "agendaShowAllDates", kind: .bool, fallback: .bool(false),
145 comment: "show days with no entries (Emacs's default is t; Orgstar hides them); today always shows", legacy: ""),
146 Setting(section: "", key: "org-agenda-skip-scheduled-if-done", defaultsKey: "agendaSkipScheduledIfDone", kind: .bool, fallback: .bool(false),
147 comment: "leave done entries out of the agenda even on the day they are scheduled", legacy: ""),
148 Setting(section: "", key: "org-agenda-skip-deadline-if-done", defaultsKey: "agendaSkipDeadlineIfDone", kind: .bool, fallback: .bool(false),
149 comment: "leave done entries out of the agenda even on the day they are due", legacy: ""),
144150 Setting(section: "", key: "appt-message-warning-time", defaultsKey: "reminderLead", kind: .integer, fallback: .integer(12),
145151 comment: "minutes of warning before timed entries; APPT_WARNTIME overrides", legacy: "agenda.reminder-lead"),
146152 Setting(section: "", key: "org-clock-idle-time", defaultsKey: "clockIdleTime", kind: .integer, fallback: .integer(0),
@@ -183,6 +189,10 @@ public enum UserConfig {
183189 comment: "the agenda reads org files in subfolders too", legacy: "agenda.include-subfolders"),
184190 Setting(section: "orgstar", key: "reminders", defaultsKey: "reminders", kind: .bool, fallback: .bool(true),
185191 comment: "notify before timed entries", legacy: "agenda.reminders"),
192 Setting(section: "orgstar", key: "calendar-events", defaultsKey: "calendarEvents", kind: .bool, fallback: .bool(false),
193 comment: "show events from Calendar in the agenda, read-only", legacy: ""),
194 Setting(section: "orgstar", key: "calendar-event-calendars", defaultsKey: "calendarEventCalendars", kind: .string, fallback: .string(""),
195 comment: "the calendars whose events show, by title or identifier, separated by commas; \"\" for all", legacy: ""),
186196 Setting(section: "orgstar", key: "global-capture-hotkey", defaultsKey: "globalCapture", kind: .bool, fallback: .bool(true),
187197 comment: "⌃⌥Space opens Capture from any app; templates go in capture.toml", legacy: "capture.global-hotkey"),
188198 ]
Sources/Orgstar/AgendaView.swift +1
@@ -251,6 +251,7 @@ struct AgendaView: View {
251251 agenda.includeSubfolders = includeSubfolders
252252 agenda.options.prefixFormat = ["agenda": prefixAgenda, "todo": prefixTodo, "tags": prefixTags]
253253 agenda.options.logMode = logMode
254 agenda.options = OrgPreferences.agendaOptions(agenda.options)
254255 let open = session.openTexts
255256 await agenda.refresh(open: open)
256257 }
Sources/OrgstarMobile/MobileModel.swift +1
@@ -114,6 +114,7 @@ final class MobileModel {
114114 agenda.span = OrgPreferences.agendaSpan()
115115 agenda.startOffset = OrgPreferences.agendaStartOffset()
116116 agenda.includeSubfolders = OrgPreferences.agendaSubfolders()
117 agenda.options = OrgPreferences.agendaOptions(agenda.options)
117118 }
118119 settingsVersion += 1
119120 }
Tests/OrgAppTests/CalendarEventsTests.swift added +72
@@ -0,0 +1,72 @@
1import CoreGraphics
2import Foundation
3import OrgCore
4import OrgPresentation
5import Testing
6@testable import OrgApp
7
8struct AgendaSettingsTests {
9 @Test func agendaSettingsReadFromConfigToml() throws {
10 let defaults = try #require(UserDefaults(suiteName: "orgstar-agenda-settings-\(UUID().uuidString)"))
11 #expect(!OrgPreferences.agendaShowAllDates(defaults))
12 #expect(!OrgPreferences.agendaSkipScheduledIfDone(defaults) && !OrgPreferences.agendaSkipDeadlineIfDone(defaults))
13 #expect(!OrgPreferences.calendarEvents(defaults) && OrgPreferences.calendarEventCalendars(defaults).isEmpty)
14 let problems = UserConfig.apply("""
15 org-agenda-show-all-dates = true
16 org-agenda-skip-scheduled-if-done = true
17 org-agenda-skip-deadline-if-done = true
18 [orgstar]
19 calendar-events = true
20 calendar-event-calendars = "Work, Family , ,ABC-123"
21 """, to: defaults)
22 #expect(problems.isEmpty, "\(problems)")
23 #expect(OrgPreferences.agendaShowAllDates(defaults))
24 #expect(OrgPreferences.calendarEvents(defaults))
25 #expect(OrgPreferences.calendarEventCalendars(defaults) == ["Work", "Family", "ABC-123"])
26 let options = OrgPreferences.agendaOptions(AgendaOptions(), defaults)
27 #expect(options.skipScheduledIfDone && options.skipDeadlineIfDone)
28 #expect(UserConfig.template(from: defaults).contains("org-agenda-show-all-dates = true"))
29 }
30}
31
32struct CalendarEventsTests {
33 struct Record: CalendarEventRecord {
34 var recordID = "e1@0"
35 var recordTitle = "Standup"
36 var recordStart = Date(timeIntervalSince1970: 1_791_190_800)
37 var recordEnd = Date(timeIntervalSince1970: 1_791_192_600)
38 var recordIsAllDay = false
39 var recordLocation: String? = "Room 4"
40 var recordCalendarID = "cal-1"
41 var recordCalendarTitle = "Work"
42 var recordCalendarColor: CGColor? = CGColor(srgbRed: 1, green: 0.5, blue: 0, alpha: 1)
43 }
44
45 @Test func recordsBecomeAgendaEvents() {
46 let event = CalendarEvents.event(Record())
47 #expect(event.id == "e1@0" && event.title == "Standup" && event.calendarTitle == "Work" && event.calendarID == "cal-1")
48 #expect(event.color?.hex == "#ff8000" && event.location == "Room 4" && !event.isAllDay)
49 var noColor = Record()
50 noColor.recordCalendarColor = nil
51 #expect(CalendarEvents.event(noColor).color == nil)
52 }
53
54 @Test func eventsBecomeRows() {
55 var utc = Calendar(identifier: .gregorian)
56 utc.timeZone = TimeZone(identifier: "UTC")!
57 let event = CalendarEvents.event(Record())
58 let day = Days.absolute(event.start, calendar: utc)
59 let row = AgendaRow(event: event, day: day, calendar: utc)
60 #expect(row.gutter == "09:00–09:30" && row.category == "Work" && row.title == "Standup" && row.isEvent && row.icon == .event)
61 var allDay = Record()
62 allDay.recordIsAllDay = true
63 #expect(AgendaRow(event: CalendarEvents.event(allDay), day: day, calendar: utc).gutter == "All day")
64 }
65
66 @Test func calendarsChosenByTitleOrIdentifier() {
67 #expect(CalendarEvents.chooses(id: "x", title: "Work", chosen: []))
68 #expect(CalendarEvents.chooses(id: "x", title: "Work", chosen: ["Work"]))
69 #expect(CalendarEvents.chooses(id: "x", title: "Work", chosen: ["x"]))
70 #expect(!CalendarEvents.chooses(id: "x", title: "Work", chosen: ["Home"]))
71 }
72}
ios/Orgstar/Info.plist +2
@@ -27,6 +27,8 @@
2727 </array>
2828 <key>CFBundleVersion</key>
2929 <string>$(CURRENT_PROJECT_VERSION)</string>
30 <key>NSCalendarsFullAccessUsageDescription</key>
31 <string>The agenda shows your calendar events alongside your Org entries. Orgstar only reads them.</string>
3032 <key>UIApplicationSceneManifest</key>
3133 <dict>
3234 <key>UIApplicationSupportsMultipleScenes</key>
scripts/build-app.sh +2
@@ -94,6 +94,8 @@ cat > "$app/Contents/Info.plist" <<EOF
9494 <string>26.0</string>
9595 <key>LSApplicationCategoryType</key>
9696 <string>public.app-category.productivity</string>
97 <key>NSCalendarsFullAccessUsageDescription</key>
98 <string>The agenda shows your calendar events alongside your Org entries. Orgstar only reads them.</string>
9799 <key>NSHighResolutionCapable</key>
98100 <true/>
99101 <key>NSPrincipalClass</key>
scripts/build-ios-app.sh +2
@@ -47,6 +47,8 @@ cat > "$app/Info.plist" <<PLIST
4747 <array><string>iPhoneSimulator</string></array>
4848 <key>MinimumOSVersion</key>
4949 <string>26.0</string>
50 <key>NSCalendarsFullAccessUsageDescription</key>
51 <string>The agenda shows your calendar events alongside your Org entries. Orgstar only reads them.</string>
5052 <key>UIDeviceFamily</key>
5153 <array><integer>1</integer><integer>2</integer></array>
5254 <key>UILaunchScreen</key>