diff options
Diffstat (limited to 'modules/calendar-sync.el')
| -rw-r--r-- | modules/calendar-sync.el | 180 |
1 files changed, 108 insertions, 72 deletions
diff --git a/modules/calendar-sync.el b/modules/calendar-sync.el index 13c74ca16..c0e0e935a 100644 --- a/modules/calendar-sync.el +++ b/modules/calendar-sync.el @@ -223,7 +223,7 @@ Example: -21600 for CST (UTC-6), -28800 for PST (UTC-8)." (defun calendar-sync--format-timezone-offset (offset) "Format timezone OFFSET (in seconds) as human-readable string. -Example: -21600 → 'UTC-6' or 'UTC-6:00'." +Example: -21600 → `UTC-6' or `UTC-6:00'." (if (null offset) "unknown" (let* ((hours (/ offset 3600)) @@ -255,8 +255,10 @@ Example: -21600 → 'UTC-6' or 'UTC-6:00'." (dir (file-name-directory calendar-sync--state-file))) (unless (file-directory-p dir) (make-directory dir t)) - (with-temp-file calendar-sync--state-file - (prin1 state (current-buffer))))) + (let ((tmp (make-temp-file (expand-file-name ".calendar-sync-state-" dir)))) + (with-temp-file tmp + (prin1 state (current-buffer))) + (rename-file tmp calendar-sync--state-file t)))) (defun calendar-sync--load-state () "Load sync state from disk." @@ -289,7 +291,7 @@ Example: -21600 → 'UTC-6' or 'UTC-6:00'." "Normalize line endings in CONTENT to Unix format (LF only). Removes all carriage return characters (\\r) from CONTENT. The iCalendar format (RFC 5545) uses CRLF line endings, but Emacs -and 'org-mode' expect LF only. This function ensures consistent line +and `org-mode' expect LF only. This function ensures consistent line endings throughout the parsing pipeline. Returns CONTENT with all \\r characters removed." @@ -423,14 +425,16 @@ Handles both simple values and values with parameters like TZID." (defun calendar-sync--get-recurrence-id-line (event-str) "Extract full RECURRENCE-ID line from EVENT-STR, including parameters. -Returns the complete line like 'RECURRENCE-ID;TZID=Europe/Tallinn:20260203T170000'. +Returns the complete line like +`RECURRENCE-ID;TZID=Europe/Tallinn:20260203T170000'. Returns nil if not found." (when (and event-str (stringp event-str)) (calendar-sync--get-property-line event-str "RECURRENCE-ID"))) (defun calendar-sync--parse-ics-datetime (value) "Parse iCal datetime VALUE into (year month day hour minute) list. -Returns nil for invalid input. For date-only values, returns (year month day nil nil). +Returns nil for invalid input. For date-only values, returns +(year month day nil nil). Handles formats: 20260203T090000Z, 20260203T090000, 20260203." (when (and value (stringp value) @@ -454,53 +458,56 @@ 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." +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) @@ -535,7 +542,15 @@ Compares year, month, day, hour, minute." (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))) + (plist-put result :attendees (plist-get exception :attendees)) + ;; Re-derive the user's status from the overridden attendees so a + ;; singly-declined occurrence drops its inherited series "accepted" + ;; (otherwise `calendar-sync--filter-declined' can't drop it). Leave the + ;; inherited status when the override doesn't name the user. + (let ((status (calendar-sync--find-user-status + (plist-get exception :attendees) calendar-sync-user-emails))) + (when status + (plist-put result :status status)))) (when (plist-get exception :organizer) (plist-put result :organizer (plist-get exception :organizer))) (when (plist-get exception :url) @@ -569,7 +584,8 @@ Returns new list with matching occurrences replaced by exception times." (defun calendar-sync--get-exdates (event-str) "Extract all EXDATE values from EVENT-STR. -Returns list of datetime strings (without TZID parameters), or nil if none found. +Returns list of datetime strings (without TZID parameters), or nil if +none found. Handles both simple values and values with parameters like TZID." (when (and event-str (stringp event-str) (not (string-empty-p event-str))) (let ((exdates '()) @@ -582,7 +598,8 @@ Handles both simple values and values with parameters like TZID." (defun calendar-sync--get-exdate-line (event-str exdate-value) "Find the full EXDATE line containing EXDATE-VALUE from EVENT-STR. -Returns the complete line like 'EXDATE;TZID=America/New_York:20260210T130000'. +Returns the complete line like +`EXDATE;TZID=America/New_York:20260210T130000'. Returns nil if not found." (when (and event-str (stringp event-str) exdate-value) (let ((pattern (format "^\\(EXDATE[^:]*:%s\\)" (regexp-quote exdate-value)))) @@ -616,7 +633,8 @@ Converts TZID-qualified and UTC times to local time." (defun calendar-sync--exdate-matches-p (occurrence-start exdate) "Check if OCCURRENCE-START matches EXDATE. OCCURRENCE-START is (year month day hour minute). -EXDATE is (year month day hour minute) or (year month day nil nil) for date-only. +EXDATE is (year month day hour minute) or (year month day nil nil) for +date-only. Date-only EXDATE matches any time on that day." (and occurrence-start exdate (= (nth 0 occurrence-start) (nth 0 exdate)) ; year @@ -680,7 +698,8 @@ Returns nil if property not found." (defun calendar-sync--get-property-line (event property) "Extract full PROPERTY line from EVENT string, including parameters. -Returns the complete line like 'DTSTART;TZID=Europe/Lisbon:20260202T190000'. +Returns the complete line like +`DTSTART;TZID=Europe/Lisbon:20260202T190000'. Returns nil if property not found." (when (string-match (format "^\\(%s[^\n]*\\)$" (regexp-quote property)) event) (match-string 1 event))) @@ -788,8 +807,8 @@ Returns URL string or nil." (defun calendar-sync--extract-tzid (property-line) "Extract TZID parameter value from PROPERTY-LINE. -PROPERTY-LINE is like 'DTSTART;TZID=Europe/Lisbon:20260202T190000'. -Returns timezone string like 'Europe/Lisbon', or nil if no TZID. +PROPERTY-LINE is like `DTSTART;TZID=Europe/Lisbon:20260202T190000'. +Returns timezone string like `Europe/Lisbon', or nil if no TZID. Returns nil for malformed lines (missing colon separator)." (when (and property-line (stringp property-line) @@ -811,7 +830,7 @@ Returns list (year month day hour minute) in local timezone." (defun calendar-sync--convert-tz-to-local (year month day hour minute source-tz) "Convert datetime from SOURCE-TZ timezone to local time. -SOURCE-TZ is a timezone name like 'Europe/Lisbon' or 'Asia/Yerevan'. +SOURCE-TZ is a timezone name like `Europe/Lisbon' or `Asia/Yerevan'. Returns list (year month day hour minute) in local timezone, or nil on error. Uses Emacs built-in timezone support (encode-time/decode-time with ZONE @@ -835,8 +854,10 @@ TZ database as the `date' command." "Convert PARSED datetime to local time using timezone info. PARSED is (year month day hour minute) or (year month day nil nil). IS-UTC non-nil means the value had a Z suffix. + TZID is a timezone string like \"Europe/Lisbon\", or nil. -Returns PARSED converted to local time, or PARSED unchanged if no conversion needed." +Returns PARSED converted to local time, or PARSED unchanged if no +conversion needed." (cond (is-utc (calendar-sync--convert-utc-to-local @@ -854,7 +875,8 @@ Returns PARSED converted to local time, or PARSED unchanged if no conversion nee "Parse iCal timestamp string TIMESTAMP-STR. Returns (year month day hour minute) or (year month day) for all-day events. Converts UTC times (ending in Z) to local time. -If TZID is provided (e.g., 'Europe/Lisbon'), converts from that timezone to local. +If TZID is provided (e.g., `Europe/Lisbon'), converts from that timezone +to local. Returns nil if parsing fails." (cond ;; DateTime format: 20251116T140000Z or 20251116T140000 @@ -911,7 +933,8 @@ Returns string like '<2025-11-16 Sun 14:00-15:00>' or '<2025-11-16 Sun>'." (defun calendar-sync--date-to-time (date) "Convert DATE to time value for comparison. DATE should be a list starting with (year month day ...). -Only the first three elements are used; extra elements (hour, minute) are ignored." +Only the first three elements are used; extra elements (hour, minute) are +ignored." (let ((day (nth 2 date)) (month (nth 1 date)) (year (nth 0 date))) @@ -1080,7 +1103,8 @@ 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. -Cleans text fields (description, location, summary) via `calendar-sync--clean-text'." +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")) @@ -1226,11 +1250,19 @@ RECURRENCE-ID exceptions are applied to override specific occurrences." (time-less-p (calendar-sync--event-start-time a) (calendar-sync--event-start-time b))))) (org-entries (mapcar #'calendar-sync--event-to-org sorted-events))) - (if org-entries - (concat "# Calendar Events\n\n" - (string-join org-entries "\n\n") - "\n") - nil))) + ;; Distinguish a healthy zero-event calendar from garbage: a real + ;; iCalendar (carries BEGIN:VCALENDAR) with no in-window events + ;; returns the header alone, so the caller writes an empty calendar + ;; and reports success. Non-iCalendar content (an HTML error page, a + ;; truncated download) has no VCALENDAR and returns nil -- a failure. + (cond + (org-entries + (concat "# Calendar Events\n\n" + (string-join org-entries "\n\n") + "\n")) + ((string-match-p "BEGIN:VCALENDAR" ics-content) + "# Calendar Events\n\n") + (t nil)))) (error (calendar-sync--log-silently "calendar-sync: Parse error: %s" (error-message-string err)) nil))) @@ -1249,7 +1281,7 @@ invoked when the fetch completes, either successfully or with an error." (make-process :name "calendar-sync-curl" :buffer buffer - :command (list "curl" "-s" "-L" + :command (list "curl" "-s" "-L" "--fail" "--connect-timeout" "10" "--max-time" (number-to-string calendar-sync-fetch-timeout) url) @@ -1281,7 +1313,7 @@ owns deleting the temp file after a successful callback." (make-process :name "calendar-sync-curl" :buffer buffer - :command (list "curl" "-s" "-L" + :command (list "curl" "-s" "-L" "--fail" "--connect-timeout" "10" "--max-time" (number-to-string calendar-sync-fetch-timeout) "-o" temp-file @@ -1307,13 +1339,17 @@ owns deleting the temp file after a successful callback." (funcall callback nil)))) (defun calendar-sync--write-file (content file) - "Write CONTENT to FILE. -Creates parent directories if needed." + "Write CONTENT to FILE atomically. +Creates parent directories if needed, then writes a temp file in the same +directory and renames it into place, so org-agenda or chime reading mid-write +never sees a half-written calendar." (let ((dir (file-name-directory file))) (unless (file-directory-p dir) - (make-directory dir t))) - (with-temp-file file - (insert content))) + (make-directory dir t)) + (let ((tmp (make-temp-file (expand-file-name ".calendar-sync-" dir)))) + (with-temp-file tmp + (insert content)) + (rename-file tmp file t)))) (defun calendar-sync--emacs-binary () "Return the Emacs executable to use for calendar conversion workers." |
