summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-02-05 15:13:57 -0600
committerCraig Jennings <c@cjennings.net>2026-02-05 15:13:57 -0600
commitb7cb1c51e5663419344d8b55766635801f3ee4c8 (patch)
treea13d903c1d7d82d8b49fe7edbd5f9b7652592c23
parent12f36cb887c3e84741bc2f3d6afd9e71c6ffddd7 (diff)
feat(calendar-sync): add event details — attendees, organizer, status, URL
Add ICS text unescaping (RFC 5545), HTML stripping, and new fields (attendees/status, organizer, meeting URL) to calendar-sync.el. event-to-org now outputs org property drawers. 88 new tests across 10 test files, 146/146 pass. Also fix pre-existing test require order and keymap guard issues.
-rw-r--r--modules/calendar-sync.el224
-rw-r--r--modules/custom-buffer-file.el3
-rw-r--r--tests/test-calendar-sync--clean-text.el58
-rw-r--r--tests/test-calendar-sync--convert-tz-to-local.el2
-rw-r--r--tests/test-calendar-sync--event-to-org.el126
-rw-r--r--tests/test-calendar-sync--expand-weekly.el2
-rw-r--r--tests/test-calendar-sync--extract-meeting-url.el54
-rw-r--r--tests/test-calendar-sync--extract-tzid.el1
-rw-r--r--tests/test-calendar-sync--find-user-status.el89
-rw-r--r--tests/test-calendar-sync--get-all-property-lines.el61
-rw-r--r--tests/test-calendar-sync--get-property.el1
-rw-r--r--tests/test-calendar-sync--helpers.el1
-rw-r--r--tests/test-calendar-sync--normalize-line-endings.el1
-rw-r--r--tests/test-calendar-sync--parse-attendee-line.el97
-rw-r--r--tests/test-calendar-sync--parse-organizer.el50
-rw-r--r--tests/test-calendar-sync--parse-rrule.el1
-rw-r--r--tests/test-calendar-sync--strip-html.el99
-rw-r--r--tests/test-calendar-sync--unescape-ics-text.el79
-rw-r--r--tests/test-calendar-sync-properties.el2
-rw-r--r--tests/test-calendar-sync.el2
-rw-r--r--tests/test-integration-calendar-sync-event-details.el177
-rw-r--r--tests/test-org-agenda-build-list.el7
22 files changed, 1113 insertions, 24 deletions
diff --git a/modules/calendar-sync.el b/modules/calendar-sync.el
index d3b6880a..fadad6c0 100644
--- a/modules/calendar-sync.el
+++ b/modules/calendar-sync.el
@@ -111,6 +111,12 @@ Default: 60 minutes (1 hour).")
If non-nil, sync starts automatically when calendar-sync is loaded.
If nil, user must manually call `calendar-sync-start'.")
+(defvar calendar-sync-user-emails
+ '("craigmartinjennings@gmail.com" "craig.jennings@deepsat.com" "c@cjennings.net")
+ "List of user email addresses for determining acceptance status.
+Used by `calendar-sync--find-user-status' to look up the user's
+PARTSTAT in event attendee lists.")
+
(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.")
@@ -228,6 +234,47 @@ Returns CONTENT with all \\r characters removed."
content
(replace-regexp-in-string "\r" "" content)))
+;;; Text Cleaning (ICS unescape + HTML strip)
+
+(defun calendar-sync--unescape-ics-text (text)
+ "Unescape RFC 5545 escape sequences in TEXT.
+Converts: \\n→newline, \\,→comma, \\\\→backslash, \\;→semicolon.
+Returns nil for nil input."
+ (when text
+ ;; Use placeholder for literal backslash to avoid double-unescaping.
+ ;; replace-regexp-in-string with LITERAL=t avoids backslash interpretation.
+ (let ((result (replace-regexp-in-string "\\\\\\\\" "\000" text)))
+ (setq result (replace-regexp-in-string "\\\\n" "\n" result t t))
+ (setq result (replace-regexp-in-string "\\\\," "," result t t))
+ (setq result (replace-regexp-in-string "\\\\;" ";" result t t))
+ (replace-regexp-in-string "\000" "\\" result t t))))
+
+(defun calendar-sync--strip-html (text)
+ "Strip HTML tags from TEXT and decode common HTML entities.
+Converts <br>, <br/>, <br /> to newlines. Strips all other tags.
+Decodes &amp; &lt; &gt; &quot;. Collapses excessive blank lines.
+Returns nil for nil input."
+ (when text
+ (let ((result text))
+ ;; Convert <br> variants to newline (must come before tag stripping)
+ (setq result (replace-regexp-in-string "<br[ \t]*/?>[ \t]*" "\n" result))
+ ;; Strip all remaining HTML tags
+ (setq result (replace-regexp-in-string "<[^>]*>" "" result))
+ ;; Decode HTML entities
+ (setq result (replace-regexp-in-string "&amp;" "&" result))
+ (setq result (replace-regexp-in-string "&lt;" "<" result))
+ (setq result (replace-regexp-in-string "&gt;" ">" result))
+ (setq result (replace-regexp-in-string "&quot;" "\"" result))
+ ;; Collapse 3+ consecutive newlines to 2
+ (setq result (replace-regexp-in-string "\n\\{3,\\}" "\n\n" result))
+ result)))
+
+(defun calendar-sync--clean-text (text)
+ "Clean TEXT by unescaping ICS sequences, stripping HTML, and trimming.
+Returns nil for nil input. Returns empty string for whitespace-only input."
+ (when text
+ (string-trim (calendar-sync--strip-html (calendar-sync--unescape-ics-text text)))))
+
;;; Date Utilities
(defun calendar-sync--add-months (date months)
@@ -438,6 +485,13 @@ Compares year, month, day, hour, minute."
(plist-put result :description (plist-get exception :description)))
(when (plist-get exception :location)
(plist-put result :location (plist-get exception :location)))
+ ;; Pass through new fields if exception overrides them
+ (when (plist-get exception :attendees)
+ (plist-put result :attendees (plist-get exception :attendees)))
+ (when (plist-get exception :organizer)
+ (plist-put result :organizer (plist-get exception :organizer)))
+ (when (plist-get exception :url)
+ (plist-put result :url (plist-get exception :url)))
result))
(defun calendar-sync--apply-recurrence-exceptions (occurrences exceptions)
@@ -618,6 +672,102 @@ Returns nil if property not found."
(when (string-match (format "^\\(%s[^\n]*\\)$" (regexp-quote property)) event)
(match-string 1 event)))
+(defun calendar-sync--get-all-property-lines (event property)
+ "Extract ALL lines matching PROPERTY from EVENT string.
+Unlike `calendar-sync--get-property-line' which returns the first match,
+this returns a list of all matching lines. Handles continuation lines
+\(lines starting with space or tab).
+Returns nil if EVENT or PROPERTY is nil, or no matches found."
+ (when (and event property (stringp event) (not (string-empty-p event)))
+ (let ((lines '())
+ (pattern (format "^%s[^\n]*" (regexp-quote property)))
+ (pos 0))
+ (while (string-match pattern event pos)
+ (let ((line (match-string 0 event))
+ (end (match-end 0)))
+ ;; Handle continuation lines (start with space or tab after newline)
+ (while (and (< end (length event))
+ (string-match "\n[ \t]\\([^\n]*\\)" event end)
+ (= (match-beginning 0) end))
+ (setq line (concat line (match-string 1 event)))
+ (setq end (match-end 0)))
+ (push line lines)
+ (setq pos (if (< end (length event)) (1+ end) end))))
+ (nreverse lines))))
+
+(defun calendar-sync--parse-attendee-line (line)
+ "Parse single ATTENDEE LINE into plist.
+Returns plist (:cn NAME :email EMAIL :partstat STATUS :role ROLE).
+Returns nil for nil, empty, or malformed input."
+ (when (and line (stringp line) (not (string-empty-p line))
+ (string-match-p "^ATTENDEE" line))
+ (let ((cn nil)
+ (email nil)
+ (partstat nil)
+ (role nil))
+ ;; Extract CN parameter
+ (when (string-match ";CN=\\([^;:]+\\)" line)
+ (setq cn (match-string 1 line))
+ ;; Strip surrounding quotes if present
+ (when (and (string-prefix-p "\"" cn) (string-suffix-p "\"" cn))
+ (setq cn (substring cn 1 -1))))
+ ;; Extract PARTSTAT parameter
+ (when (string-match ";PARTSTAT=\\([^;:]+\\)" line)
+ (setq partstat (match-string 1 line)))
+ ;; Extract ROLE parameter
+ (when (string-match ";ROLE=\\([^;:]+\\)" line)
+ (setq role (match-string 1 line)))
+ ;; Extract email from mailto: value
+ (when (string-match "mailto:\\([^>\n ]+\\)" line)
+ (setq email (match-string 1 line)))
+ (when email
+ (list :cn cn :email email :partstat partstat :role role)))))
+
+(defun calendar-sync--find-user-status (attendees user-emails)
+ "Find user's PARTSTAT from ATTENDEES list using USER-EMAILS.
+ATTENDEES is list of plists from `calendar-sync--parse-attendee-line'.
+USER-EMAILS is list of email strings to match against.
+Returns lowercase status string (\"accepted\", \"declined\", etc.) or nil."
+ (when (and attendees user-emails)
+ (let ((user-emails-lower (mapcar #'downcase user-emails))
+ (found nil))
+ (cl-dolist (attendee attendees)
+ (let ((attendee-email (downcase (or (plist-get attendee :email) ""))))
+ (when (member attendee-email user-emails-lower)
+ (let ((partstat (plist-get attendee :partstat)))
+ (when partstat
+ (setq found (downcase partstat))
+ (cl-return found))))))
+ found)))
+
+(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."
+ (when (and event-str (stringp event-str))
+ (let ((line (calendar-sync--get-property-line event-str "ORGANIZER")))
+ (when line
+ (let ((cn nil)
+ (email nil))
+ ;; Extract CN parameter
+ (when (string-match ";CN=\\([^;:]+\\)" line)
+ (setq cn (match-string 1 line))
+ ;; Strip surrounding quotes if present
+ (when (and (string-prefix-p "\"" cn) (string-suffix-p "\"" cn))
+ (setq cn (substring cn 1 -1))))
+ ;; Extract email from mailto: value
+ (when (string-match "mailto:\\([^>\n ]+\\)" line)
+ (setq email (match-string 1 line)))
+ (when email
+ (list :cn cn :email email)))))))
+
+(defun calendar-sync--extract-meeting-url (event-str)
+ "Extract meeting URL from EVENT-STR.
+Prefers X-GOOGLE-CONFERENCE over URL property.
+Returns URL string or nil."
+ (when (and event-str (stringp event-str))
+ (or (calendar-sync--get-property event-str "X-GOOGLE-CONFERENCE")
+ (calendar-sync--get-property event-str "URL"))))
+
(defun calendar-sync--extract-tzid (property-line)
"Extract TZID parameter value from PROPERTY-LINE.
PROPERTY-LINE is like 'DTSTART;TZID=Europe/Lisbon:20260202T190000'.
@@ -938,17 +1088,22 @@ Filters out dates excluded via EXDATE properties."
(defun calendar-sync--parse-event (event-str)
"Parse single VEVENT string EVENT-STR into plist.
-Returns plist with :uid :summary :description :location :start :end.
+Returns plist with :uid :summary :description :location :start :end
+:attendees :organizer :url :status.
Returns nil if event lacks required fields (DTSTART, SUMMARY).
Skips events with RECURRENCE-ID (individual instances of recurring events
are handled separately via exception collection).
-Handles TZID-qualified timestamps by converting to local time."
+Handles TZID-qualified timestamps by converting to local time.
+Cleans text fields (description, location, summary) via `calendar-sync--clean-text'."
;; Skip individual instances of recurring events (they're collected as exceptions)
(unless (calendar-sync--get-property event-str "RECURRENCE-ID")
(let* ((uid (calendar-sync--get-property event-str "UID"))
- (summary (calendar-sync--get-property event-str "SUMMARY"))
- (description (calendar-sync--get-property event-str "DESCRIPTION"))
- (location (calendar-sync--get-property event-str "LOCATION"))
+ (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")))
;; Get raw property values
(dtstart (calendar-sync--get-property event-str "DTSTART"))
(dtend (calendar-sync--get-property event-str "DTEND"))
@@ -956,7 +1111,15 @@ Handles TZID-qualified timestamps by converting to local time."
(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)))
+ (end-tzid (calendar-sync--extract-tzid dtend-line))
+ ;; Extract attendees
+ (attendee-lines (calendar-sync--get-all-property-lines event-str "ATTENDEE"))
+ (attendees (delq nil (mapcar #'calendar-sync--parse-attendee-line attendee-lines)))
+ ;; Extract organizer and URL
+ (organizer (calendar-sync--parse-organizer event-str))
+ (url (calendar-sync--extract-meeting-url event-str))
+ ;; Determine user status from attendees
+ (status (calendar-sync--find-user-status attendees calendar-sync-user-emails)))
(when (and summary dtstart)
(let ((start-parsed (calendar-sync--parse-timestamp dtstart start-tzid))
(end-parsed (and dtend (calendar-sync--parse-timestamp dtend end-tzid))))
@@ -966,23 +1129,52 @@ Handles TZID-qualified timestamps by converting to local time."
:description description
:location location
:start start-parsed
- :end end-parsed)))))))
+ :end end-parsed
+ :attendees attendees
+ :organizer organizer
+ :url url
+ :status status)))))))
(defun calendar-sync--event-to-org (event)
- "Convert parsed EVENT plist to org entry string."
- (let* ((summary (plist-get event :summary))
+ "Convert parsed EVENT plist to org entry string.
+Produces property drawer with LOCATION, ORGANIZER, STATUS, URL when present.
+Description appears as body text after the drawer."
+ (let* ((summary (or (plist-get event :summary) "(No Title)"))
(description (plist-get event :description))
(location (plist-get event :location))
(start (plist-get event :start))
(end (plist-get event :end))
+ (organizer (plist-get event :organizer))
+ (status (plist-get event :status))
+ (url (plist-get event :url))
(timestamp (calendar-sync--format-timestamp start end))
- (parts (list (format "* %s" summary))))
- (push timestamp parts)
- (when description
- (push description parts))
- (when location
- (push (format "Location: %s" location) parts))
- (string-join (nreverse parts) "\n")))
+ ;; Build property drawer entries
+ (props '()))
+ ;; Collect non-nil properties
+ (when (and location (not (string-empty-p location)))
+ (push (format ":LOCATION: %s" location) props))
+ (when organizer
+ (let ((org-name (or (plist-get organizer :cn)
+ (plist-get organizer :email))))
+ (when org-name
+ (push (format ":ORGANIZER: %s" org-name) props))))
+ (when (and status (not (string-empty-p status)))
+ (push (format ":STATUS: %s" status) props))
+ (when (and url (not (string-empty-p url)))
+ (push (format ":URL: %s" url) props))
+ (setq props (nreverse props))
+ ;; Build output
+ (let ((parts (list (format "* %s" summary) timestamp)))
+ ;; Add property drawer if any properties exist
+ (when props
+ (push ":PROPERTIES:" parts)
+ (dolist (prop props)
+ (push prop parts))
+ (push ":END:" parts))
+ ;; Add description as body text
+ (when (and description (not (string-empty-p description)))
+ (push description parts))
+ (string-join (nreverse parts) "\n"))))
(defun calendar-sync--event-start-time (event)
"Extract comparable start time from EVENT plist.
diff --git a/modules/custom-buffer-file.el b/modules/custom-buffer-file.el
index 7ff38250..92e73c3d 100644
--- a/modules/custom-buffer-file.el
+++ b/modules/custom-buffer-file.el
@@ -447,7 +447,8 @@ Signals an error if:
"g" #'revert-buffer
"w" #'cj/view-buffer-in-eww
"e" #'cj/view-email-in-buffer)
-(keymap-set cj/custom-keymap "b" cj/buffer-and-file-map)
+(when (boundp 'cj/custom-keymap)
+ (keymap-set cj/custom-keymap "b" cj/buffer-and-file-map))
(with-eval-after-load 'which-key
(which-key-add-key-based-replacements
diff --git a/tests/test-calendar-sync--clean-text.el b/tests/test-calendar-sync--clean-text.el
new file mode 100644
index 00000000..86c8532b
--- /dev/null
+++ b/tests/test-calendar-sync--clean-text.el
@@ -0,0 +1,58 @@
+;;; test-calendar-sync--clean-text.el --- Tests for clean-text composition -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Unit tests for calendar-sync--clean-text function.
+;; Composes unescape-ics-text + strip-html, trims whitespace. Returns nil for nil.
+;; Covers Normal, Boundary, and Error cases.
+
+;;; Code:
+
+(require 'ert)
+(require 'testutil-calendar-sync)
+(require 'calendar-sync)
+
+;;; Normal Cases
+
+(ert-deftest test-calendar-sync--clean-text-normal-both-html-and-ics ()
+ "Test text with both HTML tags and ICS escapes."
+ (should (string= "Hello, World\nNext line"
+ (calendar-sync--clean-text "Hello\\, World<br>Next line"))))
+
+(ert-deftest test-calendar-sync--clean-text-normal-pure-ics-escapes ()
+ "Test text with only ICS escapes."
+ (should (string= "a, b; c"
+ (calendar-sync--clean-text "a\\, b\\; c"))))
+
+(ert-deftest test-calendar-sync--clean-text-normal-pure-html ()
+ "Test text with only HTML."
+ (should (string= "bold and italic"
+ (calendar-sync--clean-text "<b>bold</b> and <i>italic</i>"))))
+
+;;; Boundary Cases
+
+(ert-deftest test-calendar-sync--clean-text-boundary-already-clean ()
+ "Test already-clean text passes through unchanged."
+ (should (string= "no escapes here"
+ (calendar-sync--clean-text "no escapes here"))))
+
+(ert-deftest test-calendar-sync--clean-text-boundary-empty-string ()
+ "Test empty string returns empty string."
+ (should (string= "" (calendar-sync--clean-text ""))))
+
+(ert-deftest test-calendar-sync--clean-text-boundary-whitespace-only ()
+ "Test whitespace-only string returns empty after trim."
+ (should (string= "" (calendar-sync--clean-text " \n \t "))))
+
+(ert-deftest test-calendar-sync--clean-text-boundary-leading-trailing-whitespace ()
+ "Test leading/trailing whitespace is trimmed."
+ (should (string= "content"
+ (calendar-sync--clean-text " content "))))
+
+;;; Error Cases
+
+(ert-deftest test-calendar-sync--clean-text-error-nil-input ()
+ "Test nil input returns nil."
+ (should (null (calendar-sync--clean-text nil))))
+
+(provide 'test-calendar-sync--clean-text)
+;;; test-calendar-sync--clean-text.el ends here
diff --git a/tests/test-calendar-sync--convert-tz-to-local.el b/tests/test-calendar-sync--convert-tz-to-local.el
index cf45aa61..07822780 100644
--- a/tests/test-calendar-sync--convert-tz-to-local.el
+++ b/tests/test-calendar-sync--convert-tz-to-local.el
@@ -9,8 +9,8 @@
;;; Code:
(require 'ert)
-(require 'calendar-sync)
(require 'testutil-calendar-sync)
+(require 'calendar-sync)
;;; Normal Cases
diff --git a/tests/test-calendar-sync--event-to-org.el b/tests/test-calendar-sync--event-to-org.el
new file mode 100644
index 00000000..e6609e20
--- /dev/null
+++ b/tests/test-calendar-sync--event-to-org.el
@@ -0,0 +1,126 @@
+;;; test-calendar-sync--event-to-org.el --- Tests for updated event-to-org formatter -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Unit tests for calendar-sync--event-to-org function (updated version).
+;; Tests the new property drawer format with LOCATION, ORGANIZER, STATUS, URL.
+;; Covers Normal, Boundary, and Error cases.
+
+;;; Code:
+
+(require 'ert)
+(require 'testutil-calendar-sync)
+(require 'calendar-sync)
+
+;;; Normal Cases
+
+(ert-deftest test-calendar-sync--event-to-org-normal-all-fields ()
+ "Test event with all fields produces property drawer + description."
+ (let* ((start (test-calendar-sync-time-days-from-now 5 14 0))
+ (end (test-calendar-sync-time-days-from-now 5 15 0))
+ (event (list :summary "Team Standup"
+ :start start
+ :end end
+ :description "Daily sync meeting"
+ :location "Conference Room A"
+ :organizer (list :cn "John Smith" :email "john@example.com")
+ :status "accepted"
+ :url "https://meet.google.com/abc-defg-hij")))
+ (let ((result (calendar-sync--event-to-org event)))
+ (should (string-match-p "\\* Team Standup" result))
+ (should (string-match-p ":PROPERTIES:" result))
+ (should (string-match-p ":LOCATION: Conference Room A" result))
+ (should (string-match-p ":ORGANIZER: John Smith" result))
+ (should (string-match-p ":STATUS: accepted" result))
+ (should (string-match-p ":URL: https://meet.google.com/abc-defg-hij" result))
+ (should (string-match-p ":END:" result))
+ (should (string-match-p "Daily sync meeting" result)))))
+
+(ert-deftest test-calendar-sync--event-to-org-normal-summary-and-time-only ()
+ "Test event with only summary and time (no drawer)."
+ (let* ((start (test-calendar-sync-time-days-from-now 5 14 0))
+ (end (test-calendar-sync-time-days-from-now 5 15 0))
+ (event (list :summary "Quick Chat"
+ :start start
+ :end end)))
+ (let ((result (calendar-sync--event-to-org event)))
+ (should (string-match-p "\\* Quick Chat" result))
+ (should-not (string-match-p ":PROPERTIES:" result)))))
+
+;;; Boundary Cases
+
+(ert-deftest test-calendar-sync--event-to-org-boundary-no-description ()
+ "Test event with location but no description."
+ (let* ((start (test-calendar-sync-time-days-from-now 5 14 0))
+ (end (test-calendar-sync-time-days-from-now 5 15 0))
+ (event (list :summary "Meeting"
+ :start start
+ :end end
+ :location "Room B")))
+ (let ((result (calendar-sync--event-to-org event)))
+ (should (string-match-p ":LOCATION: Room B" result))
+ ;; After :END: there should be no body text
+ (should-not (string-match-p ":END:\n." result)))))
+
+(ert-deftest test-calendar-sync--event-to-org-boundary-no-location-no-attendees ()
+ "Test event without location or attendees produces no drawer."
+ (let* ((start (test-calendar-sync-time-days-from-now 5 14 0))
+ (end (test-calendar-sync-time-days-from-now 5 15 0))
+ (event (list :summary "Simple Event"
+ :start start
+ :end end
+ :description "Just a note")))
+ (let ((result (calendar-sync--event-to-org event)))
+ (should (string-match-p "\\* Simple Event" result))
+ ;; Description goes after timestamp, no drawer needed
+ (should (string-match-p "Just a note" result)))))
+
+(ert-deftest test-calendar-sync--event-to-org-boundary-only-status ()
+ "Test event with only status produces drawer."
+ (let* ((start (test-calendar-sync-time-days-from-now 5 14 0))
+ (end (test-calendar-sync-time-days-from-now 5 15 0))
+ (event (list :summary "Status Only"
+ :start start
+ :end end
+ :status "tentative")))
+ (let ((result (calendar-sync--event-to-org event)))
+ (should (string-match-p ":PROPERTIES:" result))
+ (should (string-match-p ":STATUS: tentative" result))
+ (should (string-match-p ":END:" result)))))
+
+(ert-deftest test-calendar-sync--event-to-org-boundary-description-with-asterisks ()
+ "Test event description containing org-special asterisks."
+ (let* ((start (test-calendar-sync-time-days-from-now 5 14 0))
+ (end (test-calendar-sync-time-days-from-now 5 15 0))
+ (event (list :summary "Meeting"
+ :start start
+ :end end
+ :description "* agenda item 1\n** sub-item")))
+ (let ((result (calendar-sync--event-to-org event)))
+ ;; Description should be present
+ (should (string-match-p "agenda item" result)))))
+
+(ert-deftest test-calendar-sync--event-to-org-boundary-organizer-email-only ()
+ "Test organizer without CN shows email."
+ (let* ((start (test-calendar-sync-time-days-from-now 5 14 0))
+ (end (test-calendar-sync-time-days-from-now 5 15 0))
+ (event (list :summary "Meeting"
+ :start start
+ :end end
+ :organizer (list :cn nil :email "org@example.com"))))
+ (let ((result (calendar-sync--event-to-org event)))
+ (should (string-match-p ":ORGANIZER: org@example.com" result)))))
+
+;;; Error Cases
+
+(ert-deftest test-calendar-sync--event-to-org-error-missing-summary ()
+ "Test event with nil summary still produces output."
+ (let* ((start (test-calendar-sync-time-days-from-now 5 14 0))
+ (end (test-calendar-sync-time-days-from-now 5 15 0))
+ (event (list :summary nil
+ :start start
+ :end end)))
+ ;; Should not error - produce some output
+ (should (stringp (calendar-sync--event-to-org event)))))
+
+(provide 'test-calendar-sync--event-to-org)
+;;; test-calendar-sync--event-to-org.el ends here
diff --git a/tests/test-calendar-sync--expand-weekly.el b/tests/test-calendar-sync--expand-weekly.el
index fe333c98..a6143bce 100644
--- a/tests/test-calendar-sync--expand-weekly.el
+++ b/tests/test-calendar-sync--expand-weekly.el
@@ -8,8 +8,8 @@
;;; Code:
(require 'ert)
-(require 'calendar-sync)
(require 'testutil-calendar-sync)
+(require 'calendar-sync)
;;; Setup and Teardown
diff --git a/tests/test-calendar-sync--extract-meeting-url.el b/tests/test-calendar-sync--extract-meeting-url.el
new file mode 100644
index 00000000..2f677991
--- /dev/null
+++ b/tests/test-calendar-sync--extract-meeting-url.el
@@ -0,0 +1,54 @@
+;;; test-calendar-sync--extract-meeting-url.el --- Tests for meeting URL extraction -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Unit tests for calendar-sync--extract-meeting-url function.
+;; Extracts URL from X-GOOGLE-CONFERENCE (preferred) or URL property.
+;; Covers Normal, Boundary, and Error cases.
+
+;;; Code:
+
+(require 'ert)
+(require 'testutil-calendar-sync)
+(require 'calendar-sync)
+
+;;; Normal Cases
+
+(ert-deftest test-calendar-sync--extract-meeting-url-normal-google-conference ()
+ "Test extracting X-GOOGLE-CONFERENCE URL."
+ (let ((event "BEGIN:VEVENT\nX-GOOGLE-CONFERENCE:https://meet.google.com/abc-defg-hij\nSUMMARY:Test\nEND:VEVENT"))
+ (should (string= "https://meet.google.com/abc-defg-hij"
+ (calendar-sync--extract-meeting-url event)))))
+
+(ert-deftest test-calendar-sync--extract-meeting-url-normal-url-property ()
+ "Test extracting URL property."
+ (let ((event "BEGIN:VEVENT\nURL:https://zoom.us/j/123456\nSUMMARY:Test\nEND:VEVENT"))
+ (should (string= "https://zoom.us/j/123456"
+ (calendar-sync--extract-meeting-url event)))))
+
+;;; Boundary Cases
+
+(ert-deftest test-calendar-sync--extract-meeting-url-boundary-both-present ()
+ "Test X-GOOGLE-CONFERENCE is preferred when both present."
+ (let ((event "BEGIN:VEVENT\nURL:https://zoom.us/j/123456\nX-GOOGLE-CONFERENCE:https://meet.google.com/abc\nSUMMARY:Test\nEND:VEVENT"))
+ (should (string= "https://meet.google.com/abc"
+ (calendar-sync--extract-meeting-url event)))))
+
+(ert-deftest test-calendar-sync--extract-meeting-url-boundary-neither-present ()
+ "Test returns nil when neither URL property exists."
+ (let ((event "BEGIN:VEVENT\nSUMMARY:Test\nDTSTART:20260210T140000Z\nEND:VEVENT"))
+ (should (null (calendar-sync--extract-meeting-url event)))))
+
+(ert-deftest test-calendar-sync--extract-meeting-url-boundary-url-with-params ()
+ "Test URL property with parameters."
+ (let ((event "BEGIN:VEVENT\nURL;VALUE=URI:https://teams.microsoft.com/l/meetup-join/abc\nSUMMARY:Test\nEND:VEVENT"))
+ (should (string-match-p "teams.microsoft.com"
+ (calendar-sync--extract-meeting-url event)))))
+
+;;; Error Cases
+
+(ert-deftest test-calendar-sync--extract-meeting-url-error-nil-event ()
+ "Test nil event returns nil."
+ (should (null (calendar-sync--extract-meeting-url nil))))
+
+(provide 'test-calendar-sync--extract-meeting-url)
+;;; test-calendar-sync--extract-meeting-url.el ends here
diff --git a/tests/test-calendar-sync--extract-tzid.el b/tests/test-calendar-sync--extract-tzid.el
index d16aae40..95f51143 100644
--- a/tests/test-calendar-sync--extract-tzid.el
+++ b/tests/test-calendar-sync--extract-tzid.el
@@ -8,6 +8,7 @@
;;; Code:
(require 'ert)
+(require 'testutil-calendar-sync)
(require 'calendar-sync)
;;; Normal Cases
diff --git a/tests/test-calendar-sync--find-user-status.el b/tests/test-calendar-sync--find-user-status.el
new file mode 100644
index 00000000..523f9a9f
--- /dev/null
+++ b/tests/test-calendar-sync--find-user-status.el
@@ -0,0 +1,89 @@
+;;; test-calendar-sync--find-user-status.el --- Tests for find-user-status -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Unit tests for calendar-sync--find-user-status function.
+;; Given attendees list and user emails, returns user's PARTSTAT as lowercase string.
+;; Covers Normal, Boundary, and Error cases.
+
+;;; Code:
+
+(require 'ert)
+(require 'testutil-calendar-sync)
+(require 'calendar-sync)
+
+;;; Test Data
+
+(defun test-find-user-status--make-attendees ()
+ "Create sample attendees list for testing."
+ (list (list :cn "Alice" :email "alice@example.com" :partstat "ACCEPTED" :role "REQ-PARTICIPANT")
+ (list :cn "Craig" :email "craig@example.com" :partstat "ACCEPTED" :role "REQ-PARTICIPANT")
+ (list :cn "Bob" :email "bob@example.com" :partstat "DECLINED" :role "OPT-PARTICIPANT")))
+
+;;; Normal Cases
+
+(ert-deftest test-calendar-sync--find-user-status-normal-accepted ()
+ "Test finding user who accepted."
+ (let ((attendees (test-find-user-status--make-attendees))
+ (emails '("craig@example.com")))
+ (should (string= "accepted"
+ (calendar-sync--find-user-status attendees emails)))))
+
+(ert-deftest test-calendar-sync--find-user-status-normal-declined ()
+ "Test finding user who declined."
+ (let ((attendees (test-find-user-status--make-attendees))
+ (emails '("bob@example.com")))
+ (should (string= "declined"
+ (calendar-sync--find-user-status attendees emails)))))
+
+(ert-deftest test-calendar-sync--find-user-status-normal-tentative ()
+ "Test finding user with tentative status."
+ (let ((attendees (list (list :cn "Test" :email "test@example.com" :partstat "TENTATIVE")))
+ (emails '("test@example.com")))
+ (should (string= "tentative"
+ (calendar-sync--find-user-status attendees emails)))))
+
+(ert-deftest test-calendar-sync--find-user-status-normal-needs-action ()
+ "Test finding user with needs-action status."
+ (let ((attendees (list (list :cn "Test" :email "test@example.com" :partstat "NEEDS-ACTION")))
+ (emails '("test@example.com")))
+ (should (string= "needs-action"
+ (calendar-sync--find-user-status attendees emails)))))
+
+;;; Boundary Cases
+
+(ert-deftest test-calendar-sync--find-user-status-boundary-not-in-list ()
+ "Test user not in attendee list returns nil."
+ (let ((attendees (test-find-user-status--make-attendees))
+ (emails '("stranger@example.com")))
+ (should (null (calendar-sync--find-user-status attendees emails)))))
+
+(ert-deftest test-calendar-sync--find-user-status-boundary-empty-attendees ()
+ "Test empty attendee list returns nil."
+ (should (null (calendar-sync--find-user-status '() '("test@example.com")))))
+
+(ert-deftest test-calendar-sync--find-user-status-boundary-multiple-emails ()
+ "Test matching on second email in user emails list."
+ (let ((attendees (test-find-user-status--make-attendees))
+ (emails '("primary@other.com" "craig@example.com")))
+ (should (string= "accepted"
+ (calendar-sync--find-user-status attendees emails)))))
+
+(ert-deftest test-calendar-sync--find-user-status-boundary-case-insensitive ()
+ "Test case-insensitive email matching."
+ (let ((attendees (list (list :cn "Test" :email "Craig@Example.COM" :partstat "ACCEPTED")))
+ (emails '("craig@example.com")))
+ (should (string= "accepted"
+ (calendar-sync--find-user-status attendees emails)))))
+
+;;; Error Cases
+
+(ert-deftest test-calendar-sync--find-user-status-error-nil-attendees ()
+ "Test nil attendees returns nil."
+ (should (null (calendar-sync--find-user-status nil '("test@example.com")))))
+
+(ert-deftest test-calendar-sync--find-user-status-error-nil-emails ()
+ "Test nil emails list returns nil."
+ (should (null (calendar-sync--find-user-status (test-find-user-status--make-attendees) nil))))
+
+(provide 'test-calendar-sync--find-user-status)
+;;; test-calendar-sync--find-user-status.el ends here
diff --git a/tests/test-calendar-sync--get-all-property-lines.el b/tests/test-calendar-sync--get-all-property-lines.el
new file mode 100644
index 00000000..c95041c9
--- /dev/null
+++ b/tests/test-calendar-sync--get-all-property-lines.el
@@ -0,0 +1,61 @@
+;;; test-calendar-sync--get-all-property-lines.el --- Tests for get-all-property-lines -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Unit tests for calendar-sync--get-all-property-lines function.
+;; Like get-property-line but returns ALL matching lines.
+;; Needed because ATTENDEE appears multiple times in an event.
+;; Covers Normal, Boundary, and Error cases.
+
+;;; Code:
+
+(require 'ert)
+(require 'testutil-calendar-sync)
+(require 'calendar-sync)
+
+;;; Normal Cases
+
+(ert-deftest test-calendar-sync--get-all-property-lines-normal-multiple ()
+ "Test extracting multiple ATTENDEE lines."
+ (let ((event "BEGIN:VEVENT\nATTENDEE;CN=Alice:mailto:alice@example.com\nATTENDEE;CN=Bob:mailto:bob@example.com\nEND:VEVENT"))
+ (let ((result (calendar-sync--get-all-property-lines event "ATTENDEE")))
+ (should (= 2 (length result)))
+ (should (string-match-p "Alice" (nth 0 result)))
+ (should (string-match-p "Bob" (nth 1 result))))))
+
+(ert-deftest test-calendar-sync--get-all-property-lines-normal-single ()
+ "Test extracting single matching line."
+ (let ((event "BEGIN:VEVENT\nATTENDEE;CN=Alice:mailto:alice@example.com\nSUMMARY:Test\nEND:VEVENT"))
+ (let ((result (calendar-sync--get-all-property-lines event "ATTENDEE")))
+ (should (= 1 (length result)))
+ (should (string-match-p "Alice" (car result))))))
+
+;;; Boundary Cases
+
+(ert-deftest test-calendar-sync--get-all-property-lines-boundary-no-match ()
+ "Test no matching property returns empty list."
+ (let ((event "BEGIN:VEVENT\nSUMMARY:Test\nEND:VEVENT"))
+ (should (null (calendar-sync--get-all-property-lines event "ATTENDEE")))))
+
+(ert-deftest test-calendar-sync--get-all-property-lines-boundary-continuation ()
+ "Test handling of continuation lines (lines starting with space)."
+ (let ((event "BEGIN:VEVENT\nATTENDEE;CN=Very Long Name;PARTSTAT=ACCEPTED:\n mailto:longname@example.com\nSUMMARY:Test\nEND:VEVENT"))
+ (let ((result (calendar-sync--get-all-property-lines event "ATTENDEE")))
+ (should (= 1 (length result)))
+ (should (string-match-p "longname@example.com" (car result))))))
+
+;;; Error Cases
+
+(ert-deftest test-calendar-sync--get-all-property-lines-error-nil-event ()
+ "Test nil event returns nil."
+ (should (null (calendar-sync--get-all-property-lines nil "ATTENDEE"))))
+
+(ert-deftest test-calendar-sync--get-all-property-lines-error-nil-property ()
+ "Test nil property returns nil."
+ (should (null (calendar-sync--get-all-property-lines "BEGIN:VEVENT\nEND:VEVENT" nil))))
+
+(ert-deftest test-calendar-sync--get-all-property-lines-error-empty-event ()
+ "Test empty event string returns nil."
+ (should (null (calendar-sync--get-all-property-lines "" "ATTENDEE"))))
+
+(provide 'test-calendar-sync--get-all-property-lines)
+;;; test-calendar-sync--get-all-property-lines.el ends here
diff --git a/tests/test-calendar-sync--get-property.el b/tests/test-calendar-sync--get-property.el
index 79fefc8f..8b71f8e3 100644
--- a/tests/test-calendar-sync--get-property.el
+++ b/tests/test-calendar-sync--get-property.el
@@ -12,6 +12,7 @@
;;; Code:
(require 'ert)
+(require 'testutil-calendar-sync)
(require 'calendar-sync)
;;; Setup and Teardown
diff --git a/tests/test-calendar-sync--helpers.el b/tests/test-calendar-sync--helpers.el
index eb868952..864faa7f 100644
--- a/tests/test-calendar-sync--helpers.el
+++ b/tests/test-calendar-sync--helpers.el
@@ -7,6 +7,7 @@
;;; Code:
(require 'ert)
+(require 'testutil-calendar-sync)
(require 'calendar-sync)
;;; Setup and Teardown
diff --git a/tests/test-calendar-sync--normalize-line-endings.el b/tests/test-calendar-sync--normalize-line-endings.el
index 7f0830cc..cbada921 100644
--- a/tests/test-calendar-sync--normalize-line-endings.el
+++ b/tests/test-calendar-sync--normalize-line-endings.el
@@ -12,6 +12,7 @@
;;; Code:
(require 'ert)
+(require 'testutil-calendar-sync)
(require 'calendar-sync)
;;; Normal Cases
diff --git a/tests/test-calendar-sync--parse-attendee-line.el b/tests/test-calendar-sync--parse-attendee-line.el
new file mode 100644
index 00000000..5da7aacd
--- /dev/null
+++ b/tests/test-calendar-sync--parse-attendee-line.el
@@ -0,0 +1,97 @@
+;;; test-calendar-sync--parse-attendee-line.el --- Tests for attendee parsing -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Unit tests for calendar-sync--parse-attendee-line function.
+;; Parses single ATTENDEE line into plist (:cn NAME :email EMAIL :partstat STATUS :role ROLE).
+;; Covers Normal, Boundary, and Error cases.
+
+;;; Code:
+
+(require 'ert)
+(require 'testutil-calendar-sync)
+(require 'calendar-sync)
+
+;;; Normal Cases
+
+(ert-deftest test-calendar-sync--parse-attendee-line-normal-accepted ()
+ "Test parsing attendee with PARTSTAT=ACCEPTED."
+ (let ((line "ATTENDEE;CN=Alice Smith;PARTSTAT=ACCEPTED;ROLE=REQ-PARTICIPANT:mailto:alice@example.com"))
+ (let ((result (calendar-sync--parse-attendee-line line)))
+ (should (string= "Alice Smith" (plist-get result :cn)))
+ (should (string= "alice@example.com" (plist-get result :email)))
+ (should (string= "ACCEPTED" (plist-get result :partstat)))
+ (should (string= "REQ-PARTICIPANT" (plist-get result :role))))))
+
+(ert-deftest test-calendar-sync--parse-attendee-line-normal-declined ()
+ "Test parsing attendee with PARTSTAT=DECLINED."
+ (let ((line "ATTENDEE;CN=Bob Jones;PARTSTAT=DECLINED:mailto:bob@example.com"))
+ (let ((result (calendar-sync--parse-attendee-line line)))
+ (should (string= "Bob Jones" (plist-get result :cn)))
+ (should (string= "bob@example.com" (plist-get result :email)))
+ (should (string= "DECLINED" (plist-get result :partstat))))))
+
+(ert-deftest test-calendar-sync--parse-attendee-line-normal-tentative ()
+ "Test parsing attendee with PARTSTAT=TENTATIVE."
+ (let ((line "ATTENDEE;CN=Carol;PARTSTAT=TENTATIVE:mailto:carol@example.com"))
+ (let ((result (calendar-sync--parse-attendee-line line)))
+ (should (string= "TENTATIVE" (plist-get result :partstat))))))
+
+(ert-deftest test-calendar-sync--parse-attendee-line-normal-needs-action ()
+ "Test parsing attendee with PARTSTAT=NEEDS-ACTION."
+ (let ((line "ATTENDEE;CN=Dave;PARTSTAT=NEEDS-ACTION:mailto:dave@example.com"))
+ (let ((result (calendar-sync--parse-attendee-line line)))
+ (should (string= "NEEDS-ACTION" (plist-get result :partstat))))))
+
+;;; Boundary Cases
+
+(ert-deftest test-calendar-sync--parse-attendee-line-boundary-no-cn ()
+ "Test attendee without CN uses email as fallback."
+ (let ((line "ATTENDEE;PARTSTAT=ACCEPTED:mailto:noCN@example.com"))
+ (let ((result (calendar-sync--parse-attendee-line line)))
+ (should (null (plist-get result :cn)))
+ (should (string= "noCN@example.com" (plist-get result :email))))))
+
+(ert-deftest test-calendar-sync--parse-attendee-line-boundary-no-partstat ()
+ "Test attendee without PARTSTAT."
+ (let ((line "ATTENDEE;CN=Eve:mailto:eve@example.com"))
+ (let ((result (calendar-sync--parse-attendee-line line)))
+ (should (string= "Eve" (plist-get result :cn)))
+ (should (string= "eve@example.com" (plist-get result :email)))
+ (should (null (plist-get result :partstat))))))
+
+(ert-deftest test-calendar-sync--parse-attendee-line-boundary-minimal ()
+ "Test minimal attendee line with just mailto."
+ (let ((line "ATTENDEE:mailto:min@example.com"))
+ (let ((result (calendar-sync--parse-attendee-line line)))
+ (should (string= "min@example.com" (plist-get result :email))))))
+
+(ert-deftest test-calendar-sync--parse-attendee-line-boundary-cutype-resource ()
+ "Test attendee with CUTYPE=RESOURCE."
+ (let ((line "ATTENDEE;CN=Room A;CUTYPE=RESOURCE;PARTSTAT=ACCEPTED:mailto:room-a@example.com"))
+ (let ((result (calendar-sync--parse-attendee-line line)))
+ (should (string= "Room A" (plist-get result :cn)))
+ (should (string= "ACCEPTED" (plist-get result :partstat))))))
+
+(ert-deftest test-calendar-sync--parse-attendee-line-boundary-extra-params ()
+ "Test attendee with extra unknown parameters."
+ (let ((line "ATTENDEE;CN=Frank;RSVP=TRUE;X-NUM-GUESTS=0;PARTSTAT=ACCEPTED:mailto:frank@example.com"))
+ (let ((result (calendar-sync--parse-attendee-line line)))
+ (should (string= "Frank" (plist-get result :cn)))
+ (should (string= "ACCEPTED" (plist-get result :partstat))))))
+
+;;; Error Cases
+
+(ert-deftest test-calendar-sync--parse-attendee-line-error-nil ()
+ "Test nil input returns nil."
+ (should (null (calendar-sync--parse-attendee-line nil))))
+
+(ert-deftest test-calendar-sync--parse-attendee-line-error-empty ()
+ "Test empty string returns nil."
+ (should (null (calendar-sync--parse-attendee-line ""))))
+
+(ert-deftest test-calendar-sync--parse-attendee-line-error-malformed ()
+ "Test malformed line returns nil."
+ (should (null (calendar-sync--parse-attendee-line "not an attendee line"))))
+
+(provide 'test-calendar-sync--parse-attendee-line)
+;;; test-calendar-sync--parse-attendee-line.el ends here
diff --git a/tests/test-calendar-sync--parse-organizer.el b/tests/test-calendar-sync--parse-organizer.el
new file mode 100644
index 00000000..5f21d902
--- /dev/null
+++ b/tests/test-calendar-sync--parse-organizer.el
@@ -0,0 +1,50 @@
+;;; test-calendar-sync--parse-organizer.el --- Tests for organizer parsing -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Unit tests for calendar-sync--parse-organizer function.
+;; Parses ORGANIZER property line into plist (:cn NAME :email EMAIL).
+;; Covers Normal, Boundary, and Error cases.
+
+;;; Code:
+
+(require 'ert)
+(require 'testutil-calendar-sync)
+(require 'calendar-sync)
+
+;;; Normal Cases
+
+(ert-deftest test-calendar-sync--parse-organizer-normal-cn-and-email ()
+ "Test parsing organizer with both CN and email."
+ (let ((event "BEGIN:VEVENT\nORGANIZER;CN=John Smith:mailto:john@example.com\nSUMMARY:Test\nEND:VEVENT"))
+ (let ((result (calendar-sync--parse-organizer event)))
+ (should (string= "John Smith" (plist-get result :cn)))
+ (should (string= "john@example.com" (plist-get result :email))))))
+
+(ert-deftest test-calendar-sync--parse-organizer-normal-no-cn ()
+ "Test parsing organizer without CN."
+ (let ((event "BEGIN:VEVENT\nORGANIZER:mailto:organizer@example.com\nSUMMARY:Test\nEND:VEVENT"))
+ (let ((result (calendar-sync--parse-organizer event)))
+ (should (null (plist-get result :cn)))
+ (should (string= "organizer@example.com" (plist-get result :email))))))
+
+;;; Boundary Cases
+
+(ert-deftest test-calendar-sync--parse-organizer-boundary-no-organizer ()
+ "Test event without ORGANIZER returns nil."
+ (let ((event "BEGIN:VEVENT\nSUMMARY:Test\nDTSTART:20260210T140000Z\nEND:VEVENT"))
+ (should (null (calendar-sync--parse-organizer event)))))
+
+(ert-deftest test-calendar-sync--parse-organizer-boundary-complex-cn ()
+ "Test organizer with complex CN (quotes, special chars)."
+ (let ((event "BEGIN:VEVENT\nORGANIZER;CN=\"Dr. Jane O'Brien\":mailto:jane@example.com\nSUMMARY:Test\nEND:VEVENT"))
+ (let ((result (calendar-sync--parse-organizer event)))
+ (should (plist-get result :email)))))
+
+;;; Error Cases
+
+(ert-deftest test-calendar-sync--parse-organizer-error-nil-event ()
+ "Test nil event returns nil."
+ (should (null (calendar-sync--parse-organizer nil))))
+
+(provide 'test-calendar-sync--parse-organizer)
+;;; test-calendar-sync--parse-organizer.el ends here
diff --git a/tests/test-calendar-sync--parse-rrule.el b/tests/test-calendar-sync--parse-rrule.el
index 123caa5c..099e4e44 100644
--- a/tests/test-calendar-sync--parse-rrule.el
+++ b/tests/test-calendar-sync--parse-rrule.el
@@ -7,6 +7,7 @@
;;; Code:
(require 'ert)
+(require 'testutil-calendar-sync)
(require 'calendar-sync)
;;; Setup and Teardown
diff --git a/tests/test-calendar-sync--strip-html.el b/tests/test-calendar-sync--strip-html.el
new file mode 100644
index 00000000..fda2bbc5
--- /dev/null
+++ b/tests/test-calendar-sync--strip-html.el
@@ -0,0 +1,99 @@
+;;; test-calendar-sync--strip-html.el --- Tests for HTML stripping -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Unit tests for calendar-sync--strip-html function.
+;; Converts <br>/<br/> to newline, strips all other tags, decodes HTML entities.
+;; Covers Normal, Boundary, and Error cases.
+
+;;; Code:
+
+(require 'ert)
+(require 'testutil-calendar-sync)
+(require 'calendar-sync)
+
+;;; Normal Cases
+
+(ert-deftest test-calendar-sync--strip-html-normal-br-to-newline ()
+ "Test <br> converted to newline."
+ (should (string= "line1\nline2"
+ (calendar-sync--strip-html "line1<br>line2"))))
+
+(ert-deftest test-calendar-sync--strip-html-normal-br-self-closing ()
+ "Test <br/> converted to newline."
+ (should (string= "line1\nline2"
+ (calendar-sync--strip-html "line1<br/>line2"))))
+
+(ert-deftest test-calendar-sync--strip-html-normal-br-space-self-closing ()
+ "Test <br /> converted to newline."
+ (should (string= "line1\nline2"
+ (calendar-sync--strip-html "line1<br />line2"))))
+
+(ert-deftest test-calendar-sync--strip-html-normal-strip-p-tags ()
+ "Test <p> and </p> tags are stripped."
+ (should (string= "paragraph text"
+ (calendar-sync--strip-html "<p>paragraph text</p>"))))
+
+(ert-deftest test-calendar-sync--strip-html-normal-strip-bold ()
+ "Test <b> and </b> tags are stripped."
+ (should (string= "bold text"
+ (calendar-sync--strip-html "<b>bold text</b>"))))
+
+(ert-deftest test-calendar-sync--strip-html-normal-combined-tags ()
+ "Test mixed tags are handled."
+ (should (string= "Hello\nWorld"
+ (calendar-sync--strip-html "<p>Hello</p><br><b>World</b>"))))
+
+(ert-deftest test-calendar-sync--strip-html-normal-entity-amp ()
+ "Test &amp; decoded to &."
+ (should (string= "A & B"
+ (calendar-sync--strip-html "A &amp; B"))))
+
+(ert-deftest test-calendar-sync--strip-html-normal-entity-lt ()
+ "Test &lt; decoded to <."
+ (should (string= "a < b"
+ (calendar-sync--strip-html "a &lt; b"))))
+
+(ert-deftest test-calendar-sync--strip-html-normal-entity-gt ()
+ "Test &gt; decoded to >."
+ (should (string= "a > b"
+ (calendar-sync--strip-html "a &gt; b"))))
+
+(ert-deftest test-calendar-sync--strip-html-normal-entity-quot ()
+ "Test &quot; decoded to double quote."
+ (should (string= "say \"hello\""
+ (calendar-sync--strip-html "say &quot;hello&quot;"))))
+
+;;; Boundary Cases
+
+(ert-deftest test-calendar-sync--strip-html-boundary-empty-string ()
+ "Test empty string returns empty string."
+ (should (string= "" (calendar-sync--strip-html ""))))
+
+(ert-deftest test-calendar-sync--strip-html-boundary-no-html ()
+ "Test plain text passes through unchanged."
+ (should (string= "just plain text"
+ (calendar-sync--strip-html "just plain text"))))
+
+(ert-deftest test-calendar-sync--strip-html-boundary-only-tags ()
+ "Test string of only tags returns empty."
+ (should (string= ""
+ (calendar-sync--strip-html "<p><b></b></p>"))))
+
+(ert-deftest test-calendar-sync--strip-html-boundary-multiple-br ()
+ "Test multiple consecutive <br> collapse."
+ (should (string-match-p "^line1\n+line2$"
+ (calendar-sync--strip-html "line1<br><br><br>line2"))))
+
+(ert-deftest test-calendar-sync--strip-html-boundary-nested-tags ()
+ "Test nested tags are stripped correctly."
+ (should (string= "nested text"
+ (calendar-sync--strip-html "<div><p><b>nested text</b></p></div>"))))
+
+;;; Error Cases
+
+(ert-deftest test-calendar-sync--strip-html-error-nil-input ()
+ "Test nil input returns nil."
+ (should (null (calendar-sync--strip-html nil))))
+
+(provide 'test-calendar-sync--strip-html)
+;;; test-calendar-sync--strip-html.el ends here
diff --git a/tests/test-calendar-sync--unescape-ics-text.el b/tests/test-calendar-sync--unescape-ics-text.el
new file mode 100644
index 00000000..a83e97d3
--- /dev/null
+++ b/tests/test-calendar-sync--unescape-ics-text.el
@@ -0,0 +1,79 @@
+;;; test-calendar-sync--unescape-ics-text.el --- Tests for ICS text unescaping -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Unit tests for calendar-sync--unescape-ics-text function.
+;; RFC 5545 defines escape sequences: \n→newline, \,→comma, \\→backslash, \;→semicolon.
+;; Covers Normal, Boundary, and Error cases.
+
+;;; Code:
+
+(require 'ert)
+(require 'testutil-calendar-sync)
+(require 'calendar-sync)
+
+;;; Normal Cases
+
+(ert-deftest test-calendar-sync--unescape-ics-text-normal-newline ()
+ "Test \\n escape is converted to actual newline."
+ (should (string= "line1\nline2"
+ (calendar-sync--unescape-ics-text "line1\\nline2"))))
+
+(ert-deftest test-calendar-sync--unescape-ics-text-normal-comma ()
+ "Test \\, escape is converted to comma."
+ (should (string= "one, two"
+ (calendar-sync--unescape-ics-text "one\\, two"))))
+
+(ert-deftest test-calendar-sync--unescape-ics-text-normal-backslash ()
+ "Test \\\\ escape is converted to single backslash."
+ (should (string= "path\\file"
+ (calendar-sync--unescape-ics-text "path\\\\file"))))
+
+(ert-deftest test-calendar-sync--unescape-ics-text-normal-semicolon ()
+ "Test \\; escape is converted to semicolon."
+ (should (string= "a;b"
+ (calendar-sync--unescape-ics-text "a\\;b"))))
+
+(ert-deftest test-calendar-sync--unescape-ics-text-normal-mixed ()
+ "Test multiple different escapes in one string."
+ (should (string= "Hello, World\nPath\\to;file"
+ (calendar-sync--unescape-ics-text "Hello\\, World\\nPath\\\\to\\;file"))))
+
+;;; Boundary Cases
+
+(ert-deftest test-calendar-sync--unescape-ics-text-boundary-empty-string ()
+ "Test empty string returns empty string."
+ (should (string= "" (calendar-sync--unescape-ics-text ""))))
+
+(ert-deftest test-calendar-sync--unescape-ics-text-boundary-no-escapes ()
+ "Test string with no escapes passes through unchanged."
+ (should (string= "plain text"
+ (calendar-sync--unescape-ics-text "plain text"))))
+
+(ert-deftest test-calendar-sync--unescape-ics-text-boundary-escape-at-start ()
+ "Test escape sequence at string start."
+ (should (string= "\nfoo"
+ (calendar-sync--unescape-ics-text "\\nfoo"))))
+
+(ert-deftest test-calendar-sync--unescape-ics-text-boundary-escape-at-end ()
+ "Test escape sequence at string end."
+ (should (string= "foo\n"
+ (calendar-sync--unescape-ics-text "foo\\n"))))
+
+(ert-deftest test-calendar-sync--unescape-ics-text-boundary-consecutive-escapes ()
+ "Test consecutive escape sequences."
+ (should (string= "\n\n"
+ (calendar-sync--unescape-ics-text "\\n\\n"))))
+
+(ert-deftest test-calendar-sync--unescape-ics-text-boundary-only-escapes ()
+ "Test string composed entirely of escapes."
+ (should (string= ",;\n\\"
+ (calendar-sync--unescape-ics-text "\\,\\;\\n\\\\"))))
+
+;;; Error Cases
+
+(ert-deftest test-calendar-sync--unescape-ics-text-error-nil-input ()
+ "Test nil input returns nil."
+ (should (null (calendar-sync--unescape-ics-text nil))))
+
+(provide 'test-calendar-sync--unescape-ics-text)
+;;; test-calendar-sync--unescape-ics-text.el ends here
diff --git a/tests/test-calendar-sync-properties.el b/tests/test-calendar-sync-properties.el
index 6054fc5e..c25bb99f 100644
--- a/tests/test-calendar-sync-properties.el
+++ b/tests/test-calendar-sync-properties.el
@@ -19,8 +19,8 @@
;;; Code:
(require 'ert)
-(require 'calendar-sync)
(require 'testutil-calendar-sync)
+(require 'calendar-sync)
(defconst test-calendar-sync-property-trials 30
"Number of random trials to run for each property test.
diff --git a/tests/test-calendar-sync.el b/tests/test-calendar-sync.el
index 7cda5e73..144d6486 100644
--- a/tests/test-calendar-sync.el
+++ b/tests/test-calendar-sync.el
@@ -8,8 +8,8 @@
;;; Code:
(require 'ert)
-(require 'calendar-sync)
(require 'testutil-calendar-sync)
+(require 'calendar-sync)
;;; Test Data
diff --git a/tests/test-integration-calendar-sync-event-details.el b/tests/test-integration-calendar-sync-event-details.el
new file mode 100644
index 00000000..2ecc73e0
--- /dev/null
+++ b/tests/test-integration-calendar-sync-event-details.el
@@ -0,0 +1,177 @@
+;;; test-integration-calendar-sync-event-details.el --- Integration tests for event details -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Integration tests for the enhanced event details workflow.
+;; Tests the complete flow from raw ICS with HTML description, attendees,
+;; organizer through parse-ics to verify org output.
+;;
+;; Components integrated:
+;; - calendar-sync--unescape-ics-text (ICS text unescaping)
+;; - calendar-sync--strip-html (HTML tag removal)
+;; - calendar-sync--clean-text (combined cleaning)
+;; - calendar-sync--get-all-property-lines (multi-line property extraction)
+;; - calendar-sync--parse-attendee-line (attendee parsing)
+;; - calendar-sync--find-user-status (user status lookup)
+;; - calendar-sync--parse-organizer (organizer extraction)
+;; - calendar-sync--extract-meeting-url (meeting URL extraction)
+;; - calendar-sync--parse-event (full event parsing)
+;; - calendar-sync--event-to-org (org format output with property drawer)
+;; - calendar-sync--parse-ics (full ICS pipeline)
+
+;;; Code:
+
+(require 'ert)
+(require 'testutil-calendar-sync)
+(require 'calendar-sync)
+
+;;; Test Helpers
+
+(defun test-integration-details--make-full-ics (start-time)
+ "Create ICS with HTML description, attendees, organizer, and meeting URL.
+START-TIME is (year month day hour minute)."
+ (let ((dtstart (test-calendar-sync-ics-datetime start-time))
+ (dtend (test-calendar-sync-ics-datetime
+ (list (nth 0 start-time) (nth 1 start-time) (nth 2 start-time)
+ (1+ (nth 3 start-time)) (nth 4 start-time)))))
+ (concat "BEGIN:VCALENDAR\n"
+ "VERSION:2.0\n"
+ "PRODID:-//Google Inc//Google Calendar//EN\n"
+ "BEGIN:VEVENT\n"
+ "UID:full-test@example.com\n"
+ "SUMMARY:Team Planning\n"
+ "DTSTART:" dtstart "\n"
+ "DTEND:" dtend "\n"
+ "DESCRIPTION:Discuss Q1 goals<br>Review metrics\\, update roadmap\n"
+ "LOCATION:Conference Room B\n"
+ "ORGANIZER;CN=John Smith:mailto:john@example.com\n"
+ "ATTENDEE;CN=John Smith;PARTSTAT=ACCEPTED:mailto:john@example.com\n"
+ "ATTENDEE;CN=Craig Jennings;PARTSTAT=ACCEPTED:mailto:craigmartinjennings@gmail.com\n"
+ "ATTENDEE;CN=Alice;PARTSTAT=DECLINED:mailto:alice@example.com\n"
+ "X-GOOGLE-CONFERENCE:https://meet.google.com/abc-defg-hij\n"
+ "END:VEVENT\n"
+ "END:VCALENDAR")))
+
+(defun test-integration-details--make-plain-ics (start-time)
+ "Create simple ICS without attendees or special properties.
+START-TIME is (year month day hour minute)."
+ (let ((dtstart (test-calendar-sync-ics-datetime start-time))
+ (dtend (test-calendar-sync-ics-datetime
+ (list (nth 0 start-time) (nth 1 start-time) (nth 2 start-time)
+ (1+ (nth 3 start-time)) (nth 4 start-time)))))
+ (concat "BEGIN:VCALENDAR\n"
+ "VERSION:2.0\n"
+ "PRODID:-//Test//Test//EN\n"
+ "BEGIN:VEVENT\n"
+ "UID:plain-test@example.com\n"
+ "SUMMARY:Simple Reminder\n"
+ "DTSTART:" dtstart "\n"
+ "DTEND:" dtend "\n"
+ "END:VEVENT\n"
+ "END:VCALENDAR")))
+
+(defun test-integration-details--make-recurring-with-attendees (start-time)
+ "Create ICS with recurring event that has attendees.
+START-TIME is (year month day hour minute)."
+ (let ((dtstart (test-calendar-sync-ics-datetime start-time))
+ (dtend (test-calendar-sync-ics-datetime
+ (list (nth 0 start-time) (nth 1 start-time) (nth 2 start-time)
+ (1+ (nth 3 start-time)) (nth 4 start-time)))))
+ (concat "BEGIN:VCALENDAR\n"
+ "VERSION:2.0\n"
+ "PRODID:-//Test//Test//EN\n"
+ "BEGIN:VEVENT\n"
+ "UID:recurring-attendees@example.com\n"
+ "SUMMARY:Weekly Standup\n"
+ "DTSTART:" dtstart "\n"
+ "DTEND:" dtend "\n"
+ "RRULE:FREQ=WEEKLY;COUNT=3\n"
+ "ORGANIZER;CN=Manager:mailto:manager@example.com\n"
+ "ATTENDEE;CN=Craig;PARTSTAT=ACCEPTED:mailto:craigmartinjennings@gmail.com\n"
+ "LOCATION:Virtual\n"
+ "END:VEVENT\n"
+ "END:VCALENDAR")))
+
+(defun test-integration-details--make-ics-escaped-location (start-time)
+ "Create ICS with ICS-escaped location field.
+START-TIME is (year month day hour minute)."
+ (let ((dtstart (test-calendar-sync-ics-datetime start-time))
+ (dtend (test-calendar-sync-ics-datetime
+ (list (nth 0 start-time) (nth 1 start-time) (nth 2 start-time)
+ (1+ (nth 3 start-time)) (nth 4 start-time)))))
+ (concat "BEGIN:VCALENDAR\n"
+ "VERSION:2.0\n"
+ "PRODID:-//Test//Test//EN\n"
+ "BEGIN:VEVENT\n"
+ "UID:escaped-loc@example.com\n"
+ "SUMMARY:Offsite Meeting\n"
+ "DTSTART:" dtstart "\n"
+ "DTEND:" dtend "\n"
+ "LOCATION:123 Main St\\, Suite 400\\, New Orleans\\, LA\n"
+ "END:VEVENT\n"
+ "END:VCALENDAR")))
+
+;;; Integration Tests
+
+(ert-deftest test-integration-details-full-pipeline ()
+ "Test full pipeline: ICS with HTML + attendees + organizer → clean org output.
+Verifies:
+- HTML in description is cleaned
+- ICS escapes in description are unescaped
+- Property drawer contains LOCATION, ORGANIZER, STATUS, URL
+- Description appears as body text after drawer"
+ (let* ((start-time (test-calendar-sync-time-days-from-now 7 14 0))
+ (ics (test-integration-details--make-full-ics start-time))
+ (calendar-sync-user-emails '("craigmartinjennings@gmail.com"))
+ (org-output (calendar-sync--parse-ics ics)))
+ (should org-output)
+ ;; Summary present
+ (should (string-match-p "Team Planning" org-output))
+ ;; Clean description (HTML <br> → newline, ICS \, → comma)
+ (should (string-match-p "Discuss Q1 goals" org-output))
+ (should (string-match-p "Review metrics, update roadmap" org-output))
+ (should-not (string-match-p "<br>" org-output))
+ ;; Property drawer
+ (should (string-match-p ":PROPERTIES:" org-output))
+ (should (string-match-p ":LOCATION: Conference Room B" org-output))
+ (should (string-match-p ":ORGANIZER: John Smith" org-output))
+ (should (string-match-p ":STATUS: accepted" org-output))
+ (should (string-match-p ":URL: https://meet.google.com/abc-defg-hij" org-output))
+ (should (string-match-p ":END:" org-output))))
+
+(ert-deftest test-integration-details-plain-event-no-drawer ()
+ "Test plain event without attendees/location produces no property drawer."
+ (let* ((start-time (test-calendar-sync-time-days-from-now 7 14 0))
+ (ics (test-integration-details--make-plain-ics start-time))
+ (org-output (calendar-sync--parse-ics ics)))
+ (should org-output)
+ (should (string-match-p "Simple Reminder" org-output))
+ (should-not (string-match-p ":PROPERTIES:" org-output))))
+
+(ert-deftest test-integration-details-recurring-with-attendees ()
+ "Test recurring event with attendees flows details to expanded instances."
+ (let* ((start-time (test-calendar-sync-time-days-from-now 7 14 0))
+ (ics (test-integration-details--make-recurring-with-attendees start-time))
+ (calendar-sync-user-emails '("craigmartinjennings@gmail.com"))
+ (org-output (calendar-sync--parse-ics ics)))
+ (should org-output)
+ ;; Should have multiple occurrences of Weekly Standup
+ (let ((count 0)
+ (pos 0))
+ (while (string-match "Weekly Standup" org-output pos)
+ (setq count (1+ count))
+ (setq pos (match-end 0)))
+ (should (>= count 2)))))
+
+(ert-deftest test-integration-details-escaped-location ()
+ "Test ICS escapes in location are cleaned in org output."
+ (let* ((start-time (test-calendar-sync-time-days-from-now 7 14 0))
+ (ics (test-integration-details--make-ics-escaped-location start-time))
+ (org-output (calendar-sync--parse-ics ics)))
+ (should org-output)
+ (should (string-match-p "Offsite Meeting" org-output))
+ ;; Location should have real commas, not escaped
+ (should (string-match-p "123 Main St, Suite 400" org-output))
+ (should-not (string-match-p "\\\\," org-output))))
+
+(provide 'test-integration-calendar-sync-event-details)
+;;; test-integration-calendar-sync-event-details.el ends here
diff --git a/tests/test-org-agenda-build-list.el b/tests/test-org-agenda-build-list.el
index 9b9ba7f3..94e89a3d 100644
--- a/tests/test-org-agenda-build-list.el
+++ b/tests/test-org-agenda-build-list.el
@@ -16,6 +16,7 @@
(defvar schedule-file "/tmp/test-schedule.org")
(defvar gcal-file "/tmp/test-gcal.org")
(defvar pcal-file "/tmp/test-pcal.org")
+(defvar dcal-file "/tmp/test-dcal.org")
(defvar projects-dir "/tmp/test-projects/")
;; Now load the actual production module
@@ -184,12 +185,12 @@ When directory scan returns empty:
(cj/build-org-agenda-list)
- ;; Should have base files only (inbox, schedule, gcal, pcal)
- (should (= (length org-agenda-files) 4))
+ ;; Should have base files only (inbox, schedule, gcal, pcal, dcal)
+ (should (= (length org-agenda-files) 5))
;; Cache should contain base files
(should cj/org-agenda-files-cache)
- (should (= (length cj/org-agenda-files-cache) 4)))
+ (should (= (length cj/org-agenda-files-cache) 5)))
(test-org-agenda-teardown)))
(ert-deftest test-org-agenda-build-list-boundary-building-flag-set-during-build ()