diff options
| author | Craig Jennings <c@cjennings.net> | 2026-05-19 18:33:15 -0400 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-05-19 18:33:15 -0400 |
| commit | 8911d161f7ef38f8a1b03fba6316b71da1011174 (patch) | |
| tree | 70074f921d5bf0d815e85bd8673789bd8124a939 | |
| parent | 47a42a329a5ae61f7971ed8aebd36ce1d4c3a96b (diff) | |
| download | dotemacs-8911d161f7ef38f8a1b03fba6316b71da1011174.tar.gz dotemacs-8911d161f7ef38f8a1b03fba6316b71da1011174.zip | |
fix(calendar-sync): drop declined events from synced output
The sync parsed PARTSTAT into a :STATUS: declined property but kept
the event. Meetings I'd declined still landed in dcal.org / gcal.org
and showed on the agenda. I added a pure --filter-declined helper
called inside --parse-ics after event collection, plus the
calendar-sync-skip-declined defvar (default t) so it can be flipped
off without code changes.
The .ics feed and the Calendar API can disagree on PARTSTAT. OOO
auto-declines sometimes only write API-side, so a few declined
events may still slip through. I'm calling this out because the
filter looks absolute from the agenda but isn't.
Tests cover Normal/Boundary/Error (11 cases). Full suite is green.
| -rw-r--r-- | modules/calendar-sync.el | 25 | ||||
| -rw-r--r-- | tests/test-calendar-sync--filter-declined.el | 129 |
2 files changed, 154 insertions, 0 deletions
diff --git a/modules/calendar-sync.el b/modules/calendar-sync.el index f0d6f786..2f2b8b4f 100644 --- a/modules/calendar-sync.el +++ b/modules/calendar-sync.el @@ -124,6 +124,15 @@ If nil, user must manually call `calendar-sync-start'.") Used by `calendar-sync--find-user-status' to look up the user's PARTSTAT in event attendee lists.") +(defvar calendar-sync-skip-declined t + "When non-nil, drop events whose PARTSTAT for the user is \"declined\". +Declined events still arrive in the ICS feed, but they shouldn't show +up on the agenda. Set to nil to keep them (each entry then carries a +:STATUS: declined property drawer). +Note: the ICS feed and the Google Calendar API can disagree — auto- +declines via OOO sometimes write only on the API side, so a few +declined events may still slip through.") + (defvar calendar-sync-past-months 3 "Number of months in the past to include when expanding recurring events. Default: 3 months. This keeps recent history visible in org-agenda.") @@ -710,6 +719,21 @@ Returns lowercase status string (\"accepted\", \"declined\", etc.) or nil." (cl-return found)))))) found))) +(defun calendar-sync--filter-declined (events) + "Return EVENTS with declined entries removed when the toggle is on. +EVENTS is a list of plists produced by `calendar-sync--parse-event'. +Each plist's :status is the lowercase PARTSTAT for the user (set by +`calendar-sync--find-user-status'), or nil for events without an +attendee block. Drops only events whose :status is exactly the string +\"declined\" so that nil / accepted / tentative / needs-action all +survive. When `calendar-sync-skip-declined' is nil, returns EVENTS +unchanged." + (if (and calendar-sync-skip-declined events) + (cl-remove-if (lambda (event) + (equal (plist-get event :status) "declined")) + events) + events)) + (defun calendar-sync--parse-organizer (event-str) "Parse ORGANIZER property from EVENT-STR into plist. Returns plist (:cn NAME :email EMAIL), or nil if no ORGANIZER found." @@ -1160,6 +1184,7 @@ RECURRENCE-ID exceptions are applied to override specific occurrences." (setq events-generated (1+ events-generated)))))))) (when (>= events-generated max-events) (calendar-sync--log-silently "calendar-sync: WARNING: Hit max events limit (%d), some events may be missing" max-events)) + (setq parsed-events (calendar-sync--filter-declined parsed-events)) (calendar-sync--log-silently "calendar-sync: Processing %d events..." (length parsed-events)) ;; Sort and convert to org format (let* ((sorted-events (sort parsed-events diff --git a/tests/test-calendar-sync--filter-declined.el b/tests/test-calendar-sync--filter-declined.el new file mode 100644 index 00000000..41fc99a1 --- /dev/null +++ b/tests/test-calendar-sync--filter-declined.el @@ -0,0 +1,129 @@ +;;; test-calendar-sync--filter-declined.el --- Tests for filter-declined -*- lexical-binding: t; -*- + +;;; Commentary: +;; Unit tests for calendar-sync--filter-declined function. +;; Drops events whose :status is "declined" when calendar-sync-skip-declined +;; is non-nil; otherwise returns the input list unchanged. +;; Covers Normal, Boundary, and Error cases. + +;;; Code: + +(require 'ert) +(require 'calendar-sync) + +;;; Test Data + +(defun test-filter-declined--make-events () + "Return a small mixed-status event list." + (list (list :uid "a" :summary "Accepted meeting" :status "accepted") + (list :uid "b" :summary "Declined meeting" :status "declined") + (list :uid "c" :summary "Tentative meeting" :status "tentative") + (list :uid "d" :summary "Unknown status" :status nil) + (list :uid "e" :summary "Needs action" :status "needs-action"))) + +;;; Normal Cases + +(ert-deftest test-calendar-sync--filter-declined-normal-drops-declined () + "Normal: declined events are removed when the toggle is on." + (let ((calendar-sync-skip-declined t) + (events (test-filter-declined--make-events))) + (let ((result (calendar-sync--filter-declined events))) + (should (= 4 (length result))) + (should-not (cl-find "declined" result + :key (lambda (e) (plist-get e :status)) + :test #'equal))))) + +(ert-deftest test-calendar-sync--filter-declined-normal-keeps-accepted () + "Normal: accepted events are preserved." + (let ((calendar-sync-skip-declined t) + (events (test-filter-declined--make-events))) + (let ((result (calendar-sync--filter-declined events))) + (should (cl-find "accepted" result + :key (lambda (e) (plist-get e :status)) + :test #'equal))))) + +(ert-deftest test-calendar-sync--filter-declined-normal-keeps-tentative () + "Normal: tentative events are preserved (only declined is dropped)." + (let ((calendar-sync-skip-declined t) + (events (test-filter-declined--make-events))) + (let ((result (calendar-sync--filter-declined events))) + (should (cl-find "tentative" result + :key (lambda (e) (plist-get e :status)) + :test #'equal))))) + +(ert-deftest test-calendar-sync--filter-declined-normal-keeps-needs-action () + "Normal: needs-action events are preserved." + (let ((calendar-sync-skip-declined t) + (events (test-filter-declined--make-events))) + (let ((result (calendar-sync--filter-declined events))) + (should (cl-find "needs-action" result + :key (lambda (e) (plist-get e :status)) + :test #'equal))))) + +(ert-deftest test-calendar-sync--filter-declined-normal-keeps-nil-status () + "Normal: events with no PARTSTAT (nil :status) are preserved. +Events without an attendee block (one-person events, ICS feeds that +omit attendee data) come through with :status nil and must not be +filtered." + (let ((calendar-sync-skip-declined t) + (events (test-filter-declined--make-events))) + (let ((result (calendar-sync--filter-declined events))) + (should (cl-find nil result + :key (lambda (e) (plist-get e :status)) + :test #'equal))))) + +(ert-deftest test-calendar-sync--filter-declined-normal-toggle-off-keeps-all () + "Normal: when the toggle is nil, declined events pass through." + (let ((calendar-sync-skip-declined nil) + (events (test-filter-declined--make-events))) + (let ((result (calendar-sync--filter-declined events))) + (should (= 5 (length result))) + (should (cl-find "declined" result + :key (lambda (e) (plist-get e :status)) + :test #'equal))))) + +;;; Boundary Cases + +(ert-deftest test-calendar-sync--filter-declined-boundary-empty-list () + "Boundary: empty event list returns empty list under either toggle setting." + (let ((calendar-sync-skip-declined t)) + (should (null (calendar-sync--filter-declined '())))) + (let ((calendar-sync-skip-declined nil)) + (should (null (calendar-sync--filter-declined '()))))) + +(ert-deftest test-calendar-sync--filter-declined-boundary-all-declined () + "Boundary: list of only declined events returns empty list." + (let ((calendar-sync-skip-declined t) + (events (list (list :uid "a" :status "declined") + (list :uid "b" :status "declined") + (list :uid "c" :status "declined")))) + (should (null (calendar-sync--filter-declined events))))) + +(ert-deftest test-calendar-sync--filter-declined-boundary-none-declined () + "Boundary: list with zero declined events is returned unchanged in length." + (let ((calendar-sync-skip-declined t) + (events (list (list :uid "a" :status "accepted") + (list :uid "b" :status "tentative") + (list :uid "c" :status nil)))) + (should (= 3 (length (calendar-sync--filter-declined events)))))) + +(ert-deftest test-calendar-sync--filter-declined-boundary-case-mismatch-not-dropped () + "Boundary: a non-lowercase \"Declined\" is not dropped. +`calendar-sync--find-user-status' downcases PARTSTAT before storing, +so any :status reaching the filter is already lowercase. Anything +else (uppercase, mixed-case) is an upstream protocol mismatch and we +keep the event rather than filter it on a fuzzy match." + (let ((calendar-sync-skip-declined t) + (events (list (list :uid "a" :status "Declined") + (list :uid "b" :status "DECLINED")))) + (should (= 2 (length (calendar-sync--filter-declined events)))))) + +;;; Error Cases + +(ert-deftest test-calendar-sync--filter-declined-error-nil-input () + "Error: nil input returns nil (no crash)." + (let ((calendar-sync-skip-declined t)) + (should (null (calendar-sync--filter-declined nil))))) + +(provide 'test-calendar-sync--filter-declined) +;;; test-calendar-sync--filter-declined.el ends here |
