From 582ed54ed9a3c600e7e1e68beeea8b16ce1ed613 Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Tue, 30 Jun 2026 15:12:12 -0400 Subject: fix(calendar-sync): drop singly-declined recurring occurrences Declining one occurrence of a recurring meeting left it on the agenda. Google emits that decline as a RECURRENCE-ID override carrying the user's PARTSTAT=DECLINED. But calendar-sync--parse-exception-event never read the override's attendee block, so the occurrence kept the series' inherited "accepted" status and the declined filter never dropped it. The apply side already re-derives status from an override's attendees. The parse side just wasn't supplying them. The fix parses the override's ATTENDEE lines into :attendees, the same way parse-event does. A unit test covers the extraction. An integration test runs the full parse/apply/filter chain on a declined week. --- modules/calendar-sync-recurrence.el | 20 ++++++++-- tests/test-calendar-sync--parse-exception-event.el | 22 +++++++++++ ...egration-calendar-sync-recurrence-exceptions.el | 44 ++++++++++++++++++++++ 3 files changed, 83 insertions(+), 3 deletions(-) diff --git a/modules/calendar-sync-recurrence.el b/modules/calendar-sync-recurrence.el index d4f70b7d1..72576a6f7 100644 --- a/modules/calendar-sync-recurrence.el +++ b/modules/calendar-sync-recurrence.el @@ -51,7 +51,15 @@ Returns nil if not found." "Parse a RECURRENCE-ID override EVENT-STR into an exception plist, or nil. Returns nil when EVENT-STR carries no RECURRENCE-ID, or its recurrence-id / start time fail to parse. The plist holds :recurrence-id (localized), -:recurrence-id-raw, :start, :end, :summary, :description, :location." +:recurrence-id-raw, :start, :end, :summary, :description, :location, +:attendees. + +:attendees is carried so `calendar-sync--apply-single-exception' can +re-derive the user's status when a single occurrence is declined: a +RECURRENCE-ID override is exactly how a calendar marks one occurrence of a +recurring series declined, and without the attendee block here the override +inherits the series' \"accepted\" status and the declined occurrence is never +dropped by `calendar-sync--filter-declined'." (let ((recurrence-id (calendar-sync--get-recurrence-id event-str))) (when recurrence-id (let* ((recurrence-id-line (calendar-sync--get-recurrence-id-line event-str)) @@ -72,7 +80,12 @@ start time fail to parse. The plist holds :recurrence-id (localized), (description (calendar-sync--clean-text (calendar-sync--get-property event-str "DESCRIPTION"))) (location (calendar-sync--clean-text - (calendar-sync--get-property event-str "LOCATION")))) + (calendar-sync--get-property event-str "LOCATION"))) + ;; Carry the override's attendee block so a singly-declined + ;; occurrence can re-derive the user's status downstream. + (attendee-lines (calendar-sync--get-all-property-lines event-str "ATTENDEE")) + (attendees (delq nil (mapcar #'calendar-sync--parse-attendee-line + attendee-lines)))) (when (and recurrence-id-parsed start-parsed) (list :recurrence-id (calendar-sync--localize-parsed-datetime recurrence-id-parsed recurrence-id-is-utc recurrence-id-tzid) @@ -81,7 +94,8 @@ start time fail to parse. The plist holds :recurrence-id (localized), :end end-parsed :summary summary :description description - :location location)))))) + :location location + :attendees attendees)))))) (defun calendar-sync--collect-recurrence-exceptions (ics-content) "Collect all RECURRENCE-ID events from ICS-CONTENT. diff --git a/tests/test-calendar-sync--parse-exception-event.el b/tests/test-calendar-sync--parse-exception-event.el index 1935d3ebb..a26a7418c 100644 --- a/tests/test-calendar-sync--parse-exception-event.el +++ b/tests/test-calendar-sync--parse-exception-event.el @@ -47,6 +47,28 @@ (event (test-calendar-sync-make-vevent "Regular Event" start end))) (should-not (calendar-sync--parse-exception-event event)))) +;;; Normal Cases — attendee extraction (singly-declined occurrence) + +(ert-deftest test-calendar-sync--parse-exception-event-extracts-attendees () + "Normal: a RECURRENCE-ID override carrying an ATTENDEE block parses :attendees, +so a singly-declined occurrence can have its user status re-derived downstream +by `calendar-sync--apply-single-exception'." + (let* ((start (test-calendar-sync-time-days-from-now 7 10 0)) + (end (test-calendar-sync-time-days-from-now 7 11 0)) + (event (concat "BEGIN:VEVENT\n" + "UID:override@google.com\n" + "RECURRENCE-ID:20260203T090000Z\n" + "SUMMARY:1:1 with Hayk\n" + "ATTENDEE;CN=Craig;PARTSTAT=DECLINED:mailto:craig@example.com\n" + "DTSTART:" (test-calendar-sync-ics-datetime start) "\n" + "DTEND:" (test-calendar-sync-ics-datetime end) "\n" + "END:VEVENT")) + (plist (calendar-sync--parse-exception-event event)) + (attendees (plist-get plist :attendees))) + (should attendees) + (should (equal "craig@example.com" (plist-get (car attendees) :email))) + (should (equal "DECLINED" (plist-get (car attendees) :partstat))))) + ;;; Error Cases (ert-deftest test-calendar-sync--parse-exception-event-error-unparseable-times () diff --git a/tests/test-integration-calendar-sync-recurrence-exceptions.el b/tests/test-integration-calendar-sync-recurrence-exceptions.el index 0a9b5af1f..dde0603a2 100644 --- a/tests/test-integration-calendar-sync-recurrence-exceptions.el +++ b/tests/test-integration-calendar-sync-recurrence-exceptions.el @@ -162,5 +162,49 @@ Weekly meeting with one instance rescheduled from 09:00 to 10:00." (should (string-match-p "10:00" org-output)) (should (string-match-p "15:00" org-output)))) +(ert-deftest test-integration-declined-single-occurrence-is-dropped () + "A recurring event with one occurrence declined via a RECURRENCE-ID override +is filtered out end-to-end, while the other occurrences survive. + +This is the singly-declined-occurrence case: declining one instance of a series +in Google Calendar emits a RECURRENCE-ID override carrying the user's +PARTSTAT=DECLINED. The override must carry its attendee block all the way from +`calendar-sync--parse-exception-event' through +`calendar-sync--apply-single-exception' (status re-derivation) to +`calendar-sync--filter-declined' for the drop to happen." + (let* ((calendar-sync-skip-declined t) + (calendar-sync-user-emails '("craig@example.com")) + (base-start (test-calendar-sync-time-days-from-now 0 9 0)) + (base-end (test-calendar-sync-time-days-from-now 0 10 0)) + (rec-id (test-calendar-sync-time-days-from-now 7 9 0)) + (decl-start (test-calendar-sync-time-days-from-now 7 9 0)) + (decl-end (test-calendar-sync-time-days-from-now 7 10 0)) + (ics-content + (concat "BEGIN:VCALENDAR\n" + "VERSION:2.0\n" + "BEGIN:VEVENT\n" + "UID:1on1@google.com\n" + "SUMMARY:1on1 with Hayk\n" + "DTSTART:" (test-calendar-sync-ics-datetime-local base-start) "\n" + "DTEND:" (test-calendar-sync-ics-datetime-local base-end) "\n" + "RRULE:FREQ=WEEKLY;COUNT=4\n" + "END:VEVENT\n" + ;; Week 2 declined: RECURRENCE-ID override with PARTSTAT=DECLINED + "BEGIN:VEVENT\n" + "UID:1on1@google.com\n" + "RECURRENCE-ID:" (test-calendar-sync-ics-datetime-local rec-id) "\n" + "SUMMARY:1on1 with Hayk DECLINEDWEEK\n" + "ATTENDEE;CN=Craig;PARTSTAT=DECLINED:mailto:craig@example.com\n" + "DTSTART:" (test-calendar-sync-ics-datetime-local decl-start) "\n" + "DTEND:" (test-calendar-sync-ics-datetime-local decl-end) "\n" + "END:VEVENT\n" + "END:VCALENDAR")) + (org-output (calendar-sync--parse-ics ics-content))) + (should (stringp org-output)) + ;; The non-declined occurrences survive. + (should (string-match-p "1on1 with Hayk" org-output)) + ;; The declined occurrence (unique marker) is dropped. + (should-not (string-match-p "DECLINEDWEEK" org-output)))) + (provide 'test-integration-calendar-sync-recurrence-exceptions) ;;; test-integration-calendar-sync-recurrence-exceptions.el ends here -- cgit v1.2.3