aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-06-20 14:57:28 -0400
committerCraig Jennings <c@cjennings.net>2026-06-20 14:57:28 -0400
commit23f405b412457454da02f28d7a8c0e0c02c2d14d (patch)
tree4e356054be25e398b7a9314e7adc2a33dde15338
parent62fee96b22e5899ea9ce485dc1bec5212071edde (diff)
downloaddotemacs-23f405b412457454da02f28d7a8c0e0c02c2d14d.tar.gz
dotemacs-23f405b412457454da02f28d7a8c0e0c02c2d14d.zip
refactor(calendar-sync): extract per-event recurrence-exception parser
Lift the 14-binding let* body out of calendar-sync--collect-recurrence-exceptions into calendar-sync--parse-exception-event, which returns the exception plist (or nil) for one VEVENT; the collector's dolist becomes a thin uid + puthash. Add Normal/Boundary/Error tests for the new pure helper.
-rw-r--r--modules/calendar-sync.el84
-rw-r--r--tests/test-calendar-sync--parse-exception-event.el64
2 files changed, 107 insertions, 41 deletions
diff --git a/modules/calendar-sync.el b/modules/calendar-sync.el
index 13c74ca16..2ff535668 100644
--- a/modules/calendar-sync.el
+++ b/modules/calendar-sync.el
@@ -454,53 +454,55 @@ Handles formats: 20260203T090000Z, 20260203T090000, 20260203."
(defalias 'calendar-sync--parse-recurrence-id #'calendar-sync--parse-ics-datetime
"Parse RECURRENCE-ID value. See `calendar-sync--parse-ics-datetime'.")
+(defun calendar-sync--parse-exception-event (event-str)
+ "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."
+ (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))
+ (recurrence-id-tzid (calendar-sync--extract-tzid recurrence-id-line))
+ (recurrence-id-is-utc (string-suffix-p "Z" recurrence-id))
+ (recurrence-id-parsed (calendar-sync--parse-recurrence-id recurrence-id))
+ ;; Parse the new times from the exception
+ (dtstart (calendar-sync--get-property event-str "DTSTART"))
+ (dtend (calendar-sync--get-property event-str "DTEND"))
+ (dtstart-line (calendar-sync--get-property-line event-str "DTSTART"))
+ (dtend-line (calendar-sync--get-property-line event-str "DTEND"))
+ (start-tzid (calendar-sync--extract-tzid dtstart-line))
+ (end-tzid (calendar-sync--extract-tzid dtend-line))
+ (start-parsed (calendar-sync--parse-timestamp dtstart start-tzid))
+ (end-parsed (and dtend (calendar-sync--parse-timestamp dtend end-tzid)))
+ (summary (calendar-sync--clean-text
+ (calendar-sync--get-property event-str "SUMMARY")))
+ (description (calendar-sync--clean-text
+ (calendar-sync--get-property event-str "DESCRIPTION")))
+ (location (calendar-sync--clean-text
+ (calendar-sync--get-property event-str "LOCATION"))))
+ (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)
+ :recurrence-id-raw recurrence-id
+ :start start-parsed
+ :end end-parsed
+ :summary summary
+ :description description
+ :location location))))))
+
(defun calendar-sync--collect-recurrence-exceptions (ics-content)
"Collect all RECURRENCE-ID events from ICS-CONTENT.
Returns hash table mapping UID to list of exception event plists.
Each exception plist contains :recurrence-id (parsed), :start, :end, :summary, etc."
(let ((exceptions (make-hash-table :test 'equal)))
(when (and ics-content (stringp ics-content))
- (let ((events (calendar-sync--split-events ics-content)))
- (dolist (event-str events)
- (let ((recurrence-id (calendar-sync--get-recurrence-id event-str))
- (uid (calendar-sync--get-property event-str "UID")))
- (when (and recurrence-id uid)
- ;; Parse the exception event
- (let* ((recurrence-id-line (calendar-sync--get-recurrence-id-line event-str))
- (recurrence-id-tzid (calendar-sync--extract-tzid recurrence-id-line))
- (recurrence-id-is-utc (and recurrence-id
- (string-suffix-p "Z" recurrence-id)))
- (recurrence-id-parsed (calendar-sync--parse-recurrence-id recurrence-id))
- ;; Parse the new times from the exception
- (dtstart (calendar-sync--get-property event-str "DTSTART"))
- (dtend (calendar-sync--get-property event-str "DTEND"))
- (dtstart-line (calendar-sync--get-property-line event-str "DTSTART"))
- (dtend-line (calendar-sync--get-property-line event-str "DTEND"))
- (start-tzid (calendar-sync--extract-tzid dtstart-line))
- (end-tzid (calendar-sync--extract-tzid dtend-line))
- (start-parsed (calendar-sync--parse-timestamp dtstart start-tzid))
- (end-parsed (and dtend (calendar-sync--parse-timestamp dtend end-tzid)))
- (summary (calendar-sync--clean-text
- (calendar-sync--get-property event-str "SUMMARY")))
- (description (calendar-sync--clean-text
- (calendar-sync--get-property event-str "DESCRIPTION")))
- (location (calendar-sync--clean-text
- (calendar-sync--get-property event-str "LOCATION"))))
- (when (and recurrence-id-parsed start-parsed)
- (let ((local-recurrence-id
- (calendar-sync--localize-parsed-datetime
- recurrence-id-parsed recurrence-id-is-utc recurrence-id-tzid)))
- (let ((exception-plist
- (list :recurrence-id local-recurrence-id
- :recurrence-id-raw recurrence-id
- :start start-parsed
- :end end-parsed
- :summary summary
- :description description
- :location location)))
- ;; Add to hash table
- (let ((existing (gethash uid exceptions)))
- (puthash uid (cons exception-plist existing) exceptions)))))))))))
+ (dolist (event-str (calendar-sync--split-events ics-content))
+ (let ((uid (calendar-sync--get-property event-str "UID"))
+ (exception-plist (calendar-sync--parse-exception-event event-str)))
+ (when (and uid exception-plist)
+ (puthash uid
+ (cons exception-plist (gethash uid exceptions))
+ exceptions)))))
exceptions))
(defun calendar-sync--occurrence-matches-exception-p (occurrence exception)
diff --git a/tests/test-calendar-sync--parse-exception-event.el b/tests/test-calendar-sync--parse-exception-event.el
new file mode 100644
index 000000000..1935d3ebb
--- /dev/null
+++ b/tests/test-calendar-sync--parse-exception-event.el
@@ -0,0 +1,64 @@
+;;; test-calendar-sync--parse-exception-event.el --- Tests for one-event exception parsing -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Unit tests for calendar-sync--parse-exception-event, the per-VEVENT half of
+;; calendar-sync--collect-recurrence-exceptions: it turns a single RECURRENCE-ID
+;; override VEVENT into an exception plist (or nil). One function per file.
+
+;;; Code:
+
+(require 'ert)
+(add-to-list 'load-path (expand-file-name "." (file-name-directory load-file-name)))
+(add-to-list 'load-path (expand-file-name "../modules" (file-name-directory load-file-name)))
+(require 'testutil-calendar-sync)
+(require 'calendar-sync)
+
+(defun test-cs-parse-exc--override-event (start end)
+ "Return a RECURRENCE-ID override VEVENT string for START..END."
+ (concat "BEGIN:VEVENT\n"
+ "UID:override@google.com\n"
+ "RECURRENCE-ID:20260203T090000Z\n"
+ "SUMMARY:Rescheduled Meeting\n"
+ "DTSTART:" (test-calendar-sync-ics-datetime start) "\n"
+ "DTEND:" (test-calendar-sync-ics-datetime end) "\n"
+ "END:VEVENT"))
+
+;;; Normal Cases
+
+(ert-deftest test-calendar-sync--parse-exception-event-normal-returns-plist ()
+ "Normal: a RECURRENCE-ID override parses into a plist with its overridden times."
+ (let* ((start (test-calendar-sync-time-days-from-now 7 10 0))
+ (end (test-calendar-sync-time-days-from-now 7 11 0))
+ (plist (calendar-sync--parse-exception-event
+ (test-cs-parse-exc--override-event start end))))
+ (should plist)
+ (should (plist-get plist :recurrence-id))
+ (should (equal "20260203T090000Z" (plist-get plist :recurrence-id-raw)))
+ (should (plist-get plist :start))
+ (should (plist-get plist :end))
+ (should (equal "Rescheduled Meeting" (plist-get plist :summary)))))
+
+;;; Boundary Cases
+
+(ert-deftest test-calendar-sync--parse-exception-event-boundary-no-recurrence-id ()
+ "Boundary: a VEVENT with no RECURRENCE-ID is not an override and returns nil."
+ (let* ((start (test-calendar-sync-time-days-from-now 7 10 0))
+ (end (test-calendar-sync-time-days-from-now 7 11 0))
+ (event (test-calendar-sync-make-vevent "Regular Event" start end)))
+ (should-not (calendar-sync--parse-exception-event event))))
+
+;;; Error Cases
+
+(ert-deftest test-calendar-sync--parse-exception-event-error-unparseable-times ()
+ "Error: a RECURRENCE-ID override whose times do not parse returns nil rather
+than a half-built plist."
+ (let ((event (concat "BEGIN:VEVENT\n"
+ "UID:broken@google.com\n"
+ "RECURRENCE-ID:not-a-timestamp\n"
+ "SUMMARY:Broken Override\n"
+ "DTSTART:also-garbage\n"
+ "END:VEVENT")))
+ (should-not (calendar-sync--parse-exception-event event))))
+
+(provide 'test-calendar-sync--parse-exception-event)
+;;; test-calendar-sync--parse-exception-event.el ends here