Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 48 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down Expand Up @@ -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": []
Expand Down Expand Up @@ -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:**
Expand Down
2 changes: 1 addition & 1 deletion Sources/ekctl/Ekctl.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
70 changes: 66 additions & 4 deletions Sources/ekctlCore/EventKitManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
65 changes: 65 additions & 0 deletions Tests/ekctlTests/ekctlTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down