aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-05-19 18:33:15 -0400
committerCraig Jennings <c@cjennings.net>2026-05-19 18:33:15 -0400
commit8911d161f7ef38f8a1b03fba6316b71da1011174 (patch)
tree70074f921d5bf0d815e85bd8673789bd8124a939
parent47a42a329a5ae61f7971ed8aebd36ce1d4c3a96b (diff)
downloaddotemacs-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.el25
-rw-r--r--tests/test-calendar-sync--filter-declined.el129
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