From 1625791241df0813b87b4398fc5030526d6d2bd3 Mon Sep 17 00:00:00 2001 From: Marcus Schappi Date: Mon, 31 Aug 2026 09:07:17 +1000 Subject: [PATCH] Expose alarms and travel time in event output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `--alarms` and `--travel-time` were write-only: nothing in the event JSON reflected them, so there was no way to confirm from the CLI that either flag had taken effect. `hasAlarms` looked like it served that purpose but doesn't — calendars apply a default alarm to new events, so it reports true even for an event whose alarms you never touched. Add `alarms` and `travelTimeMinutes` to the event dictionary, which carries them through show/list/add/update in all three output formats. Both render in the same units the flags accept, so output round-trips back into input: a relative alarm 10 minutes before the start reads `minutesBeforeStart: 10`, matching `--alarms "10"`, and one 15 minutes after reads -15, matching `--alarms "+15"`. Alarms created outside ekctl may be absolute rather than relative, hence the `type` discriminator. Guard both KVC writes to the undocumented `travelTime` property while we're here. KVC against a key the class doesn't define raises NSUnknownKeyException, which Swift cannot catch, so if a future macOS drops the property the existing unguarded `setValue` would hard-crash the CLI. Adding the read doubled that exposure. `--travel-time` now returns an error instead. Bump to 1.6.0. --- README.md | 49 ++++++++++++++++- Sources/ekctl/Ekctl.swift | 2 +- Sources/ekctlCore/EventKitManager.swift | 70 +++++++++++++++++++++++-- Tests/ekctlTests/ekctlTests.swift | 65 +++++++++++++++++++++++ 4 files changed, 180 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index e9cdc39..07bdaf3 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ Every release ships a prebuilt universal (Apple Silicon + Intel) binary — pick the latest from the [releases page](https://github.com/schappim/ekctl/releases): ```bash -curl -L -o ekctl.tar.gz https://github.com/schappim/ekctl/releases/download/v1.5.0/ekctl-v1.5.0.tar.gz +curl -L -o ekctl.tar.gz https://github.com/schappim/ekctl/releases/download/v1.6.0/ekctl-v1.6.0.tar.gz tar -xzf ekctl.tar.gz xattr -d com.apple.quarantine ekctl # release binaries are ad-hoc signed, not notarized sudo mv ekctl /usr/local/bin/ @@ -212,6 +212,10 @@ ekctl list events --calendar work --from "$NOWISH" --to "$TOMORROW" --search sta "notes": null, "allDay": false, "hasAlarms": true, + "alarms": [ + { "type": "relative", "minutesBeforeStart": 10 } + ], + "travelTimeMinutes": null, "hasRecurrenceRules": false, "availability": "busy", "attendees": [] @@ -369,11 +373,54 @@ ekctl update event EVENT_ID \ "notes": "Updated notes", "allDay": false, "hasAlarms": true, + "alarms": [ + { "type": "relative", "minutesBeforeStart": 10 }, + { "type": "relative", "minutesBeforeStart": 30 } + ], + "travelTimeMinutes": 20, "hasRecurrenceRules": false } } ``` +### Alarms and travel time + +`--alarms` takes comma-separated minutes and **replaces** every existing alarm on +the event (it does not append). A bare number means minutes *before* the start; a +leading `+` means minutes *after*: + +```bash +ekctl update event EVENT_ID --alarms "10,60" # 10 min and 1 hour before +ekctl update event EVENT_ID --alarms "+15" # 15 min after the start +ekctl update event EVENT_ID --alarms "0" # at the start +``` + +Both fields are echoed back in the event JSON, in the same units the flags accept, +so output round-trips into input: + +```json +"alarms": [ + { "type": "relative", "minutesBeforeStart": 10 }, + { "type": "relative", "minutesBeforeStart": -15 }, + { "type": "absolute", "date": "2026-02-15T08:00:00+11:00" } +], +"travelTimeMinutes": 20 +``` + +`minutesBeforeStart` is negative for an alarm that fires *after* the start, matching +the `+` flag form. Alarms set outside ekctl may be absolute rather than relative, in +which case they carry a `date` instead. EventKit does not preserve the order alarms +were supplied in, so read them as a set. + +Two things worth knowing: + +- `hasAlarms` is **not** evidence that your `--alarms` took effect. Calendars apply a + default alarm to new events, so a freshly created event usually reports + `hasAlarms: true` with an alarm you never asked for. Check `alarms` instead. +- `travelTimeMinutes` is `null` when unset. EventKit exposes no public API for travel + time, so ekctl reads and writes it through KVC on an undocumented property; if a + future macOS drops it, `--travel-time` returns a clear error rather than crashing. + ### Delete Event **Command:** diff --git a/Sources/ekctl/Ekctl.swift b/Sources/ekctl/Ekctl.swift index c9df031..b11978c 100644 --- a/Sources/ekctl/Ekctl.swift +++ b/Sources/ekctl/Ekctl.swift @@ -27,7 +27,7 @@ struct Ekctl: ParsableCommand { commandName: "ekctl", abstract: "A command-line tool for managing macOS Calendar events and Reminders using EventKit.", - version: "1.5.0", + version: "1.6.0", subcommands: [ List.self, Show.self, Add.self, Update.self, Delete.self, Complete.self, Alias.self, CalendarCmd.self, diff --git a/Sources/ekctlCore/EventKitManager.swift b/Sources/ekctlCore/EventKitManager.swift index 2fc8c4c..3619934 100644 --- a/Sources/ekctlCore/EventKitManager.swift +++ b/Sources/ekctlCore/EventKitManager.swift @@ -358,8 +358,13 @@ public class EventKitManager { if let travelTime = travelTime { // IMPORTANT: `travelTime` is set using KVC on a private/undocumented property name ("travelTime"). // This is intentional as EventKit does not expose a public API for travel time. - // This may break in future macOS updates. - event.setValue(travelTime, forKey: "travelTime") + // See `supportsTravelTime` for why the guard is not optional. + guard Self.supportsTravelTime(event) else { + return JSONOutput.error( + "Travel time is not supported by EventKit on this version of macOS; omit --travel-time." + ) + } + event.setValue(travelTime, forKey: Self.travelTimeKey) } if let alarms = alarms { @@ -519,9 +524,14 @@ public class EventKitManager { event.addRecurrenceRule(rule) } - // Travel Time (Manual) + // Travel Time (Manual) — see `supportsTravelTime` for the KVC guard. if let tTime = travelTime { - event.setValue(tTime, forKey: "travelTime") + guard Self.supportsTravelTime(event) else { + return JSONOutput.error( + "Travel time is not supported by EventKit on this version of macOS; omit --travel-time." + ) + } + event.setValue(tTime, forKey: Self.travelTimeKey) } do { @@ -778,6 +788,52 @@ public class EventKitManager { return formatter }() + /// The undocumented EventKit property backing an event's travel time. + /// EventKit exposes no public API for it, so both the read and the write + /// go through KVC. `responds(to:)` guards every access: KVC on a key the + /// class doesn't define raises `NSUnknownKeyException`, an Objective-C + /// exception Swift cannot catch, so an unguarded call would hard-crash the + /// CLI if Apple ever drops the property. + private static let travelTimeKey = "travelTime" + + static func supportsTravelTime(_ event: EKEvent) -> Bool { + event.responds(to: Selector((travelTimeKey))) + } + + /// Reads the event's travel time as whole minutes, or nil when unset or + /// unsupported on this OS. Mirrors the `--travel-time` flag, which is + /// also expressed in minutes. + static func travelTimeMinutes(of event: EKEvent) -> Int? { + guard supportsTravelTime(event), + let seconds = event.value(forKey: travelTimeKey) as? Double, + seconds > 0 + else { return nil } + return Int((seconds / 60).rounded()) + } + + /// Renders alarms in the same units the `--alarms` flag accepts, so output + /// round-trips back into input: a relative alarm 10 minutes *before* the + /// start reads `minutesBeforeStart: 10`, matching `--alarms "10"`, and one + /// 15 minutes *after* reads `-15`, matching `--alarms "+15"`. + /// + /// Exposed for tests; `formatter` renders absolute alarm dates so this + /// stays free of the manager's instance state. + public static func alarmDicts( + _ alarms: [EKAlarm]?, + formatter: (Date) -> String + ) -> [[String: Any]] { + guard let alarms = alarms else { return [] } + return alarms.map { alarm in + if let absoluteDate = alarm.absoluteDate { + return ["type": "absolute", "date": formatter(absoluteDate)] + } + // EventKit stores relative offsets as negative seconds for "before + // start"; the flag treats positive as "before", so the sign flips. + let minutes = Int((-alarm.relativeOffset / 60).rounded()) + return ["type": "relative", "minutesBeforeStart": minutes] + } + } + /// Converts an EKEvent to a dictionary for JSON output private func eventToDict(_ event: EKEvent) -> [String: Any] { let formatter = localDateFormatter @@ -813,6 +869,12 @@ public class EventKitManager { } dict["hasAlarms"] = event.hasAlarms + dict["alarms"] = Self.alarmDicts(event.alarms) { formatter.string(from: $0) } + if let travelTimeMinutes = Self.travelTimeMinutes(of: event) { + dict["travelTimeMinutes"] = travelTimeMinutes + } else { + dict["travelTimeMinutes"] = NSNull() + } dict["hasRecurrenceRules"] = event.hasRecurrenceRules dict["availability"] = Self.availabilityString(event.availability) diff --git a/Tests/ekctlTests/ekctlTests.swift b/Tests/ekctlTests/ekctlTests.swift index 2941f28..1d355fa 100644 --- a/Tests/ekctlTests/ekctlTests.swift +++ b/Tests/ekctlTests/ekctlTests.swift @@ -988,6 +988,71 @@ final class DateValidationTests: XCTestCase { XCTAssertNil(travelTimeSeconds(from: "")) } + // ── Alarm rendering (inverse of --alarms) ──────────────────────────────── + + /// Absolute alarms are the only branch needing a date; a fixed string keeps + /// these assertions independent of timezone and formatter settings. + private func alarmDicts(_ alarms: [EKAlarm]) -> [[String: Any]] { + EventKitManager.alarmDicts(alarms) { _ in "FORMATTED_DATE" } + } + + func testAlarmBeforeStartRendersAsPositiveMinutes() { + // `--alarms "10"` stores -600s; output must read back as 10. + let dicts = alarmDicts([EKAlarm(relativeOffset: -600)]) + XCTAssertEqual(dicts.count, 1) + XCTAssertEqual(dicts[0]["type"] as? String, "relative") + XCTAssertEqual(dicts[0]["minutesBeforeStart"] as? Int, 10) + } + + func testAlarmAfterStartRendersAsNegativeMinutes() { + // `--alarms "+15"` stores +900s; output must read back as -15. + let dicts = alarmDicts([EKAlarm(relativeOffset: 900)]) + XCTAssertEqual(dicts[0]["minutesBeforeStart"] as? Int, -15) + } + + func testAlarmAtStartRendersAsZero() { + XCTAssertEqual(alarmDicts([EKAlarm(relativeOffset: 0)])[0]["minutesBeforeStart"] as? Int, 0) + } + + /// The round trip that makes the field useful: whatever `--alarms` parses, + /// the output renders back into the same flag value. + func testAlarmRenderingRoundTripsThroughAlarmParsing() { + for flagValue in ["10", "30", "+15", "0", "1440"] { + let offsets = AlarmParsing.parse(flagValue)! + let dicts = alarmDicts(offsets.map { EKAlarm(relativeOffset: $0) }) + let minutes = dicts[0]["minutesBeforeStart"] as? Int + let expected = flagValue.hasPrefix("+") ? -Int(flagValue.dropFirst())! : Int(flagValue)! + XCTAssertEqual(minutes, expected, "round trip failed for --alarms \(flagValue)") + } + } + + func testAbsoluteAlarmRendersDateNotOffset() { + let alarm = EKAlarm(absoluteDate: Date(timeIntervalSince1970: 0)) + let dicts = alarmDicts([alarm]) + XCTAssertEqual(dicts[0]["type"] as? String, "absolute") + XCTAssertEqual(dicts[0]["date"] as? String, "FORMATTED_DATE") + XCTAssertNil(dicts[0]["minutesBeforeStart"]) + } + + func testMultipleAlarmsArePreservedInOrder() { + let dicts = alarmDicts([EKAlarm(relativeOffset: -600), EKAlarm(relativeOffset: -1800)]) + XCTAssertEqual(dicts.map { $0["minutesBeforeStart"] as? Int }, [10, 30]) + } + + func testNilAlarmsRenderAsEmptyArray() { + XCTAssertTrue(EventKitManager.alarmDicts(nil) { _ in "" }.isEmpty) + } + + func testEmptyAlarmsRenderAsEmptyArray() { + XCTAssertTrue(alarmDicts([]).isEmpty) + } + + func testSubMinuteOffsetRoundsToNearestMinute() { + // EventKit permits second-level offsets the flag cannot express; they + // must round rather than truncate toward zero. + XCTAssertEqual(alarmDicts([EKAlarm(relativeOffset: -110)])[0]["minutesBeforeStart"] as? Int, 2) + } + // ── Recurrence interval fallback ───────────────────────────────────────── func testRecurrenceIntervalParsesValidInt() {