Repository navigation
Fix --scope this/future to operate on the actual occurrence (silent no-op bug) - #10
jasonkuhrt wants to merge 1 commit into
Conversation
`events delete --scope this --occurrence-start <iso>` and
`events update --scope this --occurrence-start <iso>` were silently
ignoring `occurrenceStart` in the EventKit code path. Both fetched the
series master via `store.calendarItem(withIdentifier: id)` and called
`store.remove`/`store.save` with `EKSpan.thisEvent` on that master.
EKSpan semantics require a SPECIFIC OCCURRENCE (an EKEvent whose
`startDate` matches the occurrence) — passing the master with
`.thisEvent` is undefined behaviour and silently no-ops, so the
operations returned `{"status":"deleted","ok":true}` without actually
mutating anything.
Add `resolveTargetEvent(masterEvent:scope:occurrenceStart:)` that:
- returns the master directly for `.all`
- expands the recurrence over a 1-day window around `occurrenceStart`
via `store.events(matching:)` (the same pattern already used in the
list path) and picks the matching occurrence for `.this` / `.future`
- short-circuits to the master for non-recurring events
- uses 1-second tolerance on date equality to absorb Date round-trip
drift
Both `deleteEvent` and `updateEvent` now route through the helper.
The existing `InMemoryCalendarStore` test fake does not model EventKit's
recurrence/exception semantics (its `deleteEvent` simply removes the
entry from a dict regardless of scope), so the suite passed even with
the production bug. Validated manually against a real EKEventStore
recurring series: pre-fix `--scope this` reported success but left the
occurrence in place; post-fix, the targeted occurrence is excluded and
all other occurrences and the series itself remain intact.
Reproduces in 0.3.0 (the version currently shipped via brew). Same
flaw applies to `events update --scope this/future` for the same
reason.
Session-Id: 6b41c9ab-a980-4b4f-b247-dfcc622d5108
The brew install (helmi/tap/acal 0.3.0) ships a bug where 'events delete --scope this --occurrence-start' silently no-ops on recurring events (returns ok=true status=deleted but the EventKit exception is never created). Same bug in 'events update'. Built and installed a fork at ~/projects/Helmi/acal-apple-calendar-cli/ carrying the fix; opened PR #10 upstream: Helmi/acal-apple-calendar-cli#10 When upstream merges + releases, revert the install line and 'brew install helmi/tap/acal' again. Session-Id: 6b41c9ab-a980-4b4f-b247-dfcc622d5108
There was a problem hiding this comment.
Code Review
This pull request introduces a resolveTargetEvent helper method to correctly identify specific occurrences of recurring events when applying mutations with 'this' or 'future' scopes. This ensures that EventKit's EKSpan semantics are respected. Feedback was provided regarding the event search predicate: if the event's calendar is missing, passing an empty array to the predicate results in no matches, so it is recommended to pass nil to search all calendars instead.
| let calendars = [masterEvent.calendar].compactMap { $0 } | ||
| guard | ||
| let dayBefore = Calendar.current.date(byAdding: .day, value: -1, to: occStart), | ||
| let dayAfter = Calendar.current.date(byAdding: .day, value: 1, to: occStart) | ||
| else { | ||
| throw ACalError.validation("Could not compute occurrence search window for \(DateCodec.iso8601String(from: occStart)).") | ||
| } | ||
| let predicate = store.predicateForEvents(withStart: dayBefore, end: dayAfter, calendars: calendars) |
There was a problem hiding this comment.
In EventKit, passing an empty array to predicateForEvents(withStart:end:calendars:) results in a search that matches no events. While masterEvent.calendar should not be nil for a stored event, the use of compactMap here suggests a defensive approach. If the calendar is missing, it is safer to pass nil to the predicate to search all calendars rather than searching none, which would lead to a confusing notFound error.
let calendars = [masterEvent.calendar].compactMap { $0 }
guard
let dayBefore = Calendar.current.date(byAdding: .day, value: -1, to: occStart),
let dayAfter = Calendar.current.date(byAdding: .day, value: 1, to: occStart)
else {
throw ACalError.validation("Could not compute occurrence search window for \(DateCodec.iso8601String(from: occStart)).")
}
let predicate = store.predicateForEvents(
withStart: dayBefore,
end: dayAfter,
calendars: calendars.isEmpty ? nil : calendars
)There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 9dc123536e
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| guard let occStart = occurrenceStart else { | ||
| throw ACalError.validation("--occurrence-start is required when scope is this or future.") | ||
| } |
There was a problem hiding this comment.
Allow non-recurring updates without occurrence-start
This new validation now rejects all .this/.future updates when occurrenceStart is omitted, even for non-recurring events, because it throws before the non-recurring short-circuit is reached. That regresses prior updateEvent behavior and contradicts the CLI/MCP contract text that occurrenceStart is for recurring-instance edits; a single event update like --scope this now fails with a validation error despite having only one occurrence.
Useful? React with 👍 / 👎.
Bug
events delete --scope this --occurrence-start <iso>andevents update --scope this --occurrence-start <iso>silently no-op againstEKEventStore. Both return{"status":"deleted","ok":true}(or the equivalent update payload) without actually mutating anything.Reproduction (against real EventKit, 0.3.0 from brew)
Root cause
In
Sources/EventKitAdapter/EventKitAdapter.swift, bothdeleteEventandupdateEventdid:store.calendarItem(withIdentifier: id)returns the series master, not the specific occurrence atoccurrenceStart. EventKit'sEKSpan.thisEvent(and.futureEvents) require the caller to pass a specific OCCURRENCE — passing the master is undefined behaviour and silently no-ops in practice.occurrenceStartwas parsed and validated as required for non-.allscopes but never actually used to locate the occurrence (inupdateEventit was only stamped onto the response record for display).Fix
Add a private helper
resolveTargetEvent(masterEvent:scope:occurrenceStart:)that:.all.this/.future: expands the recurrence over a 1-day window aroundoccurrenceStartviastore.predicateForEvents(withStart:end:calendars:)→store.events(matching:)(the same pattern already used inlistEventsat line ~191), filters tocalendarItemIdentifier == masterEvent.calendarItemIdentifier, and picks the matching occurrence (1-second tolerance onstartDateequality to absorb Date round-trip drift)Both
deleteEventandupdateEventnow route through the helper. The duplicate inline--occurrence-start requiredvalidation indeleteEventis removed (helper now owns it;updateEventpreviously lacked the validation entirely, which the helper also fixes).Why the test suite passes
Tests use
InMemoryCalendarStore(Sources/AppCore/InMemoryCalendarStore.swift), whosedeleteEventdoes:regardless of scope. The fake doesn't model EventKit's recurrence/exception semantics at all, so it passes whether the production code targets the master or an occurrence. The existing tests verify the envelope/IO contract but never exercise the actual EventKit codepath; the bug is invisible to CI.
A proper regression test would either need real-EventKit integration (slow, requires sandbox calendar permissions) or a substantial enhancement to
InMemoryCalendarStoreto track per-occurrence exclusion dates. Happy to follow up with whichever direction you prefer; flagging here so it's visible rather than shipping a half-baked fake.Validation
Manually validated against real
EKEventStoreon macOS 26 (acalfull_access):delete --scope this --occurrence-start <Jan-18>returnsok=truedelete --scope allcleans upAlso validated on a real production scenario: 15 distinct occurrences of recurring co-parenting routine events for a specific 23-day window, all
--scope thisdeletions correctly applied without affecting the series elsewhere.Severity
High — affects every scope-targeted op on recurring events:
events delete --scope this— silently no-op (this PR fixes)events delete --scope future— same code path, same bug (this PR fixes)events update --scope this— same code path, same bug (this PR fixes)events update --scope future— same code path, same bug (this PR fixes)The
[ok=true, status=deleted]envelope makes the failure mode invisible to scripted callers — they assume success and move on.