aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-06-30 15:12:12 -0400
committerCraig Jennings <c@cjennings.net>2026-06-30 15:12:12 -0400
commit582ed54ed9a3c600e7e1e68beeea8b16ce1ed613 (patch)
tree0f148b549dee9de8fc57b72e0b2c8f54ddd1fbd2
parent0801bf7fd35d4d37d2cba3c984622022cc1528fc (diff)
downloaddotemacs-582ed54ed9a3c600e7e1e68beeea8b16ce1ed613.tar.gz
dotemacs-582ed54ed9a3c600e7e1e68beeea8b16ce1ed613.zip
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.
-rw-r--r--modules/calendar-sync-recurrence.el20
-rw-r--r--tests/test-calendar-sync--parse-exception-event.el22
-rw-r--r--tests/test-integration-calendar-sync-recurrence-exceptions.el44
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