diff options
| author | Craig Jennings <c@cjennings.net> | 2026-06-29 05:23:56 -0400 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-06-29 05:23:56 -0400 |
| commit | 54250d958f2829ff0f44a223a1481b6ec55a6d91 (patch) | |
| tree | a3e86f5aebc39e934fee12b75e81fc56b016e5da | |
| parent | 3ca9a9f17daa3d6258aa9f32a1a56a9f9e19642c (diff) | |
| download | dotemacs-54250d958f2829ff0f44a223a1481b6ec55a6d91.tar.gz dotemacs-54250d958f2829ff0f44a223a1481b6ec55a6d91.zip | |
refactor: split calendar-sync.el into layered modules
Break the 1724-line calendar-sync.el into a thin public face plus four layered libraries, moving every function verbatim so behavior and public names are unchanged:
- calendar-sync-ics.el — base parsing: RFC 5545 text cleaning, VEVENT property extraction, attendee/organizer/URL parsing, timezone and timestamp conversion, date arithmetic, single-event parsing. Depends on neither of the other new modules.
- calendar-sync-recurrence.el — RRULE/EXDATE/RECURRENCE-ID expansion.
- calendar-sync-org.el — Org rendering and atomic file output.
- calendar-sync-source.el — sync state and persistence, async .ics fetch, the batch conversion worker, and the Google Calendar API path.
calendar-sync.el keeps configuration, the parse orchestrator, sync dispatch, the user commands, the timer, and the C-; g keymap, and requires the four layers. Each layer forward-declares the config defvars it reads, so no layer requires the top module back. The batch worker loads the whole graph, so source forward-declares the two functions it calls there.
Every public name is preserved, so all 574 existing calendar-sync tests pass unchanged through the require chain. The four new modules carry the load-graph and package headers and join the header-contract allowlist.
Claude-Session: https://claude.ai/code/session_014fyKMTTqLrZpL3rDF3dYc3
| -rw-r--r-- | modules/calendar-sync-ics.el | 582 | ||||
| -rw-r--r-- | modules/calendar-sync-org.el | 94 | ||||
| -rw-r--r-- | modules/calendar-sync-recurrence.el | 405 | ||||
| -rw-r--r-- | modules/calendar-sync-source.el | 426 | ||||
| -rw-r--r-- | modules/calendar-sync.el | 1367 | ||||
| -rw-r--r-- | tests/test-init-module-headers.el | 4 |
6 files changed, 1529 insertions, 1349 deletions
diff --git a/modules/calendar-sync-ics.el b/modules/calendar-sync-ics.el new file mode 100644 index 000000000..9cb57e96b --- /dev/null +++ b/modules/calendar-sync-ics.el @@ -0,0 +1,582 @@ +;;; calendar-sync-ics.el --- iCalendar parsing primitives for calendar-sync -*- coding: utf-8; lexical-binding: t; -*- + +;; Author: Craig Jennings <c@cjennings.net> +;; Created: 2025-11-16 + +;;; Commentary: +;; +;; Layer: 3 (Domain Workflow). +;; Category: D. +;; Load shape: library. +;; Top-level side effects: none (defuns plus one internal state defvar). +;; Runtime requires: cl-lib, subr-x. +;; Direct test load: yes. +;; +;; Base layer of the calendar-sync parser: RFC 5545 text cleaning, VEVENT +;; property extraction, attendee/organizer/URL parsing, timezone and +;; timestamp conversion, date arithmetic, and single-event parsing. It has +;; no dependency on the other calendar-sync modules, so the recurrence, org, +;; and source layers build on it. The sync-window and user-identity +;; configuration it reads is owned by calendar-sync.el and forward-declared +;; here so this base layer never requires the top module back. + +;;; Code: + +(require 'cl-lib) +(require 'subr-x) + +;; Configuration owned by calendar-sync.el; declared special here so this +;; base module reads it without a back-require onto the top module. +(defvar calendar-sync-past-months) +(defvar calendar-sync-future-months) +(defvar calendar-sync-user-emails) +(defvar calendar-sync-skip-declined) + +;;; Logging + +(defun calendar-sync--log-silently (format-string &rest args) + "Log FORMAT-STRING with ARGS without requiring the full config." + (if (fboundp 'cj/log-silently) + (apply #'cj/log-silently format-string args) + (apply #'message format-string args))) + +;;; Internal state + +(defvar calendar-sync--last-timezone-offset nil + "Timezone offset in seconds from UTC at last sync. +Used to detect timezone changes (e.g., when traveling).") + +;;; Timezone Detection + +(defun calendar-sync--current-timezone-offset () + "Get current timezone offset in seconds from UTC. +Returns negative for west of UTC, positive for east. +Example: -21600 for CST (UTC-6), -28800 for PST (UTC-8)." + (car (current-time-zone))) + +(defun calendar-sync--format-timezone-offset (offset) + "Format timezone OFFSET (in seconds) as human-readable string. +Example: -21600 → `UTC-6' or `UTC-6:00'." + (if (null offset) + "unknown" + (let* ((hours (/ offset 3600)) + (minutes (abs (mod (/ offset 60) 60))) + (sign (if (>= hours 0) "+" "-")) + (abs-hours (abs hours))) + (if (= minutes 0) + (format "UTC%s%d" sign abs-hours) + (format "UTC%s%d:%02d" sign abs-hours minutes))))) + +(defun calendar-sync--timezone-changed-p () + "Return t if timezone has changed since last sync." + (and calendar-sync--last-timezone-offset + (not (= (calendar-sync--current-timezone-offset) + calendar-sync--last-timezone-offset)))) + +;;; Line Ending Normalization + +(defun calendar-sync--normalize-line-endings (content) + "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 +endings throughout the parsing pipeline. + +Returns CONTENT with all \\r characters removed." + (if (not (stringp content)) + 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 & < > ". 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 "&" "&" result)) + (setq result (replace-regexp-in-string "<" "<" result)) + (setq result (replace-regexp-in-string ">" ">" result)) + (setq result (replace-regexp-in-string """ "\"" 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) + "Add MONTHS to DATE. +DATE is (year month day), returns new (year month day)." + (let* ((year (nth 0 date)) + (month (nth 1 date)) + (day (nth 2 date)) + (total-months (+ (* year 12) month -1 months)) + (new-year (/ total-months 12)) + (new-month (1+ (mod total-months 12)))) + (list new-year new-month day))) + +(defun calendar-sync--get-date-range () + "Get date range for event expansion as (start-time end-time). +Returns time values for -3 months and +12 months from today." + (let* ((now (decode-time)) + (today (list (nth 5 now) (nth 4 now) (nth 3 now))) + (start-date (calendar-sync--add-months today (- calendar-sync-past-months))) + (end-date (calendar-sync--add-months today calendar-sync-future-months)) + (start-time (apply #'encode-time 0 0 0 (reverse start-date))) + (end-time (apply #'encode-time 0 0 0 (reverse end-date)))) + (list start-time end-time))) + +(defun calendar-sync--date-in-range-p (date range) + "Check if DATE is within RANGE. +DATE is (year month day hour minute), RANGE is (start-time end-time)." + (let* ((year (nth 0 date)) + (month (nth 1 date)) + (day (nth 2 date)) + (date-time (encode-time 0 0 0 day month year)) + (start-time (nth 0 range)) + (end-time (nth 1 range))) + (and (time-less-p start-time date-time) + (time-less-p date-time end-time)))) + +(defun calendar-sync--weekday-to-number (weekday) + "Convert WEEKDAY string (MO, TU, etc.) to number (1-7). +Monday = 1, Sunday = 7." + (pcase weekday + ("MO" 1) + ("TU" 2) + ("WE" 3) + ("TH" 4) + ("FR" 5) + ("SA" 6) + ("SU" 7) + (_ nil))) + +(defun calendar-sync--date-weekday (date) + "Get weekday number for DATE (year month day). +Monday = 1, Sunday = 7." + (let* ((year (nth 0 date)) + (month (nth 1 date)) + (day (nth 2 date)) + (time (encode-time 0 0 0 day month year)) + (decoded (decode-time time)) + (dow (nth 6 decoded))) ; 0 = Sunday, 1 = Monday, etc. + (if (= dow 0) 7 dow))) + +(defun calendar-sync--add-days (date days) + "Add DAYS to DATE (year month day). +Returns new (year month day). +Uses noon internally to avoid DST boundary issues where adding +86400 seconds to midnight can land on the same calendar date +during fall-back transitions." + (let* ((year (nth 0 date)) + (month (nth 1 date)) + (day (nth 2 date)) + (time (encode-time 0 0 12 day month year)) + (new-time (time-add time (days-to-time days))) + (decoded (decode-time new-time))) + (list (nth 5 decoded) (nth 4 decoded) (nth 3 decoded)))) + +(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." + (let ((day (nth 2 date)) + (month (nth 1 date)) + (year (nth 0 date))) + (encode-time 0 0 0 day month year))) + +(defun calendar-sync--before-date-p (date1 date2) + "Return t if DATE1 is before DATE2. +Both dates should be lists like (year month day)." + (time-less-p (calendar-sync--date-to-time date1) + (calendar-sync--date-to-time date2))) + +;;; Datetime Parsing + +(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). +Handles formats: 20260203T090000Z, 20260203T090000, 20260203." + (when (and value + (stringp value) + (not (string-empty-p value))) + (cond + ;; DateTime format: 20260203T090000Z or 20260203T090000 + ((string-match "\\`\\([0-9]\\{4\\}\\)\\([0-9]\\{2\\}\\)\\([0-9]\\{2\\}\\)T\\([0-9]\\{2\\}\\)\\([0-9]\\{2\\}\\)\\([0-9]\\{2\\}\\)Z?\\'" value) + (list (string-to-number (match-string 1 value)) + (string-to-number (match-string 2 value)) + (string-to-number (match-string 3 value)) + (string-to-number (match-string 4 value)) + (string-to-number (match-string 5 value)))) + ;; Date-only format: 20260203 + ((string-match "\\`\\([0-9]\\{4\\}\\)\\([0-9]\\{2\\}\\)\\([0-9]\\{2\\}\\)\\'" value) + (list (string-to-number (match-string 1 value)) + (string-to-number (match-string 2 value)) + (string-to-number (match-string 3 value)) + nil nil)) + (t nil)))) + +;;; .ics Property Extraction + +(defun calendar-sync--split-events (ics-content) + "Split ICS-CONTENT into individual VEVENT blocks. +Returns list of strings, each containing one VEVENT block." + (let ((events '())) + (with-temp-buffer + (insert ics-content) + (goto-char (point-min)) + (while (search-forward "BEGIN:VEVENT" nil t) + (let ((start (match-beginning 0))) + (when (search-forward "END:VEVENT" nil t) + (push (buffer-substring-no-properties start (point)) events))))) + (nreverse events))) + +(defun calendar-sync--unfold-continuation (text value start) + "Unfold RFC 5545 continuation lines from TEXT starting at START. +VALUE is the initial content to append to. Continuation lines begin +with a space or tab after a newline. Returns (unfolded-value . new-pos)." + (while (and (< start (length text)) + (string-match "\n[ \t]\\([^\n]*\\)" text start) + (= (match-beginning 0) start)) + (setq value (concat value (match-string 1 text))) + (setq start (match-end 0))) + (cons value start)) + +(defun calendar-sync--get-property (event property) + "Extract PROPERTY value from EVENT string. +Handles property parameters (e.g., DTSTART;TZID=America/Chicago:value). +Handles multi-line values (lines starting with space). +Returns nil if property not found." + (when (string-match (format "^%s[^:\n]*:\\(.*\\)$" (regexp-quote property)) event) + (car (calendar-sync--unfold-continuation + event (match-string 1 event) (match-end 0))))) + +(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 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* ((result (calendar-sync--unfold-continuation + event (match-string 0 event) (match-end 0))) + (line (car result)) + (end (cdr result))) + (push line lines) + (setq pos (if (< end (length event)) (1+ end) end)))) + (nreverse lines)))) + +(defun calendar-sync--extract-cn (line) + "Extract and dequote CN parameter from iCal LINE. +Returns the CN value string, or nil if not found." + (when (string-match ";CN=\\([^;:]+\\)" line) + (let ((cn (match-string 1 line))) + (if (and (string-prefix-p "\"" cn) (string-suffix-p "\"" cn)) + (substring cn 1 -1) + cn)))) + +(defun calendar-sync--extract-email (line) + "Extract email address from mailto: value in iCal LINE. +Returns email string, or nil if not found." + (when (string-match "mailto:\\([^>\n ]+\\)" line) + (match-string 1 line))) + +(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 (calendar-sync--extract-cn line)) + (email (calendar-sync--extract-email line)) + (partstat nil) + (role nil)) + (when (string-match ";PARTSTAT=\\([^;:]+\\)" line) + (setq partstat (match-string 1 line))) + (when (string-match ";ROLE=\\([^;:]+\\)" line) + (setq role (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--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." + (when (and event-str (stringp event-str)) + (let ((line (calendar-sync--get-property-line event-str "ORGANIZER"))) + (when line + (let ((email (calendar-sync--extract-email line))) + (when email + (list :cn (calendar-sync--extract-cn line) :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'. +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) + ;; Must have colon (property:value format) + (string-match-p ":" property-line) + (string-match ";TZID=\\([^;:]+\\)" property-line)) + (match-string 1 property-line))) + +;;; Timezone / Timestamp Conversion + +(defun calendar-sync--convert-utc-to-local (year month day hour minute second) + "Convert UTC datetime to local time. +Returns list (year month day hour minute) in local timezone." + (let* ((utc-time (encode-time second minute hour day month year 0)) + (local-time (decode-time utc-time))) + (list (nth 5 local-time) ; year + (nth 4 local-time) ; month + (nth 3 local-time) ; day + (nth 2 local-time) ; hour + (nth 1 local-time)))) + +(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'. +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 +argument) for fast, subprocess-free conversion. Uses the same system +TZ database as the `date' command." + (when (and source-tz (not (string-empty-p source-tz))) + (condition-case err + (let* ((abs-time (encode-time 0 minute hour day month year source-tz)) + (local (decode-time abs-time))) + (list (nth 5 local) ; year + (nth 4 local) ; month + (nth 3 local) ; day + (nth 2 local) ; hour + (nth 1 local))) ; minute + (error + (calendar-sync--log-silently "calendar-sync: Error converting timezone %s: %s" + source-tz (error-message-string err)) + nil)))) + +(defun calendar-sync--localize-parsed-datetime (parsed is-utc tzid) + "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." + (cond + (is-utc + (calendar-sync--convert-utc-to-local + (nth 0 parsed) (nth 1 parsed) (nth 2 parsed) + (or (nth 3 parsed) 0) (or (nth 4 parsed) 0) 0)) + (tzid + (or (calendar-sync--convert-tz-to-local + (nth 0 parsed) (nth 1 parsed) (nth 2 parsed) + (or (nth 3 parsed) 0) (or (nth 4 parsed) 0) + tzid) + parsed)) + (t parsed))) + +(defun calendar-sync--parse-timestamp (timestamp-str &optional tzid) + "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. +Returns nil if parsing fails." + (cond + ;; DateTime format: 20251116T140000Z or 20251116T140000 + ((string-match "\\([0-9]\\{4\\}\\)\\([0-9]\\{2\\}\\)\\([0-9]\\{2\\}\\)T\\([0-9]\\{2\\}\\)\\([0-9]\\{2\\}\\)\\([0-9]\\{2\\}\\)\\(Z\\)?" timestamp-str) + (let* ((year (string-to-number (match-string 1 timestamp-str))) + (month (string-to-number (match-string 2 timestamp-str))) + (day (string-to-number (match-string 3 timestamp-str))) + (hour (string-to-number (match-string 4 timestamp-str))) + (minute (string-to-number (match-string 5 timestamp-str))) + (second (string-to-number (match-string 6 timestamp-str))) + (is-utc (match-string 7 timestamp-str))) + (cond + ;; UTC timestamp (Z suffix) - convert from UTC + (is-utc + (calendar-sync--convert-utc-to-local year month day hour minute second)) + ;; TZID provided - convert from that timezone + (tzid + (or (calendar-sync--convert-tz-to-local year month day hour minute tzid) + ;; Fallback to raw time if conversion fails + (list year month day hour minute))) + ;; No timezone info - assume local time + (t + (list year month day hour minute))))) + ;; Date format: 20251116 + ((string-match "\\([0-9]\\{4\\}\\)\\([0-9]\\{2\\}\\)\\([0-9]\\{2\\}\\)" timestamp-str) + (list (string-to-number (match-string 1 timestamp-str)) + (string-to-number (match-string 2 timestamp-str)) + (string-to-number (match-string 3 timestamp-str)))) + (t nil))) + +(defun calendar-sync--format-timestamp (start end) + "Format START and END timestamps as org timestamp. +START and END are lists from `calendar-sync--parse-timestamp'. +Returns string like '<2025-11-16 Sun 14:00-15:00>' or '<2025-11-16 Sun>'." + (let* ((year (nth 0 start)) + (month (nth 1 start)) + (day (nth 2 start)) + (start-hour (nth 3 start)) + (start-min (nth 4 start)) + (end-hour (and end (nth 3 end))) + (end-min (and end (nth 4 end))) + (date-str (format-time-string + "<%Y-%m-%d %a" + (encode-time 0 0 0 day month year))) + (time-str (when (and start-hour end-hour) + (format " %02d:%02d-%02d:%02d" + start-hour start-min end-hour end-min)))) + (concat date-str time-str ">"))) + +;;; Single Event Parsing + +(defun calendar-sync--parse-event (event-str) + "Parse single VEVENT string EVENT-STR into plist. +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. +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--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")) + ;; Extract TZID from property lines (if present) + (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)) + ;; 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)))) + (when start-parsed + (list :uid uid + :summary summary + :description description + :location location + :start start-parsed + :end end-parsed + :attendees attendees + :organizer organizer + :url url + :status status))))))) + +(defun calendar-sync--event-start-time (event) + "Extract comparable start time from EVENT plist. +Returns time value suitable for comparison, or 0 if no start time." + (let ((start (plist-get event :start))) + (if start + (apply #'encode-time + 0 ; second + (or (nth 4 start) 0) ; minute + (or (nth 3 start) 0) ; hour + (nth 2 start) ; day + (nth 1 start) ; month + (nth 0 start) ; year + nil) + 0))) + +(provide 'calendar-sync-ics) +;;; calendar-sync-ics.el ends here diff --git a/modules/calendar-sync-org.el b/modules/calendar-sync-org.el new file mode 100644 index 000000000..9ea5a129d --- /dev/null +++ b/modules/calendar-sync-org.el @@ -0,0 +1,94 @@ +;;; calendar-sync-org.el --- Org rendering and atomic file output -*- coding: utf-8; lexical-binding: t; -*- + +;; Author: Craig Jennings <c@cjennings.net> +;; Created: 2025-11-16 + +;;; Commentary: +;; +;; Layer: 3 (Domain Workflow). +;; Category: D. +;; Load shape: library. +;; Top-level side effects: none (defuns only). +;; Runtime requires: subr-x, cj-org-text-lib, calendar-sync-ics. +;; Direct test load: yes (requires calendar-sync-ics explicitly). +;; +;; Output layer of the calendar-sync parser: render a parsed event plist +;; into an Org entry (heading, property drawer, body) and write generated +;; content to disk atomically via a same-directory temp file plus rename, +;; so a reader never sees a half-written calendar. + +;;; Code: + +(require 'subr-x) +(require 'cj-org-text-lib) +(require 'calendar-sync-ics) + +;;; Org Rendering + +(defun calendar-sync--event-to-org (event) + "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 (cj/org-sanitize-heading + (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)) + ;; Build property drawer entries + (props '())) + ;; Collect non-nil properties + (when (and location (not (string-empty-p location))) + (push (format ":LOCATION: %s" + (cj/org-sanitize-property-value location)) + props)) + (when organizer + (let ((org-name (or (plist-get organizer :cn) + (plist-get organizer :email)))) + (when org-name + (push (format ":ORGANIZER: %s" + (cj/org-sanitize-property-value org-name)) + props)))) + (when (and status (not (string-empty-p status))) + (push (format ":STATUS: %s" + (cj/org-sanitize-property-value status)) + props)) + (when (and url (not (string-empty-p url))) + (push (format ":URL: %s" + (cj/org-sanitize-property-value url)) + props)) + (setq props (nreverse props)) + ;; Build output + (let ((parts (list timestamp (format "* %s" summary)))) + ;; 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 (sanitized to prevent org heading conflicts) + (when (and description (not (string-empty-p description))) + (push (cj/org-sanitize-body-text description) parts)) + (string-join (nreverse parts) "\n")))) + +;;; Atomic File Output + +(defun calendar-sync--write-file (content file) + "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)) + (let ((tmp (make-temp-file (expand-file-name ".calendar-sync-" dir)))) + (with-temp-file tmp + (insert content)) + (rename-file tmp file t)))) + +(provide 'calendar-sync-org) +;;; calendar-sync-org.el ends here diff --git a/modules/calendar-sync-recurrence.el b/modules/calendar-sync-recurrence.el new file mode 100644 index 000000000..d4f70b7d1 --- /dev/null +++ b/modules/calendar-sync-recurrence.el @@ -0,0 +1,405 @@ +;;; calendar-sync-recurrence.el --- RRULE / EXDATE / RECURRENCE-ID expansion -*- coding: utf-8; lexical-binding: t; -*- + +;; Author: Craig Jennings <c@cjennings.net> +;; Created: 2025-11-16 + +;;; Commentary: +;; +;; Layer: 3 (Domain Workflow). +;; Category: D. +;; Load shape: library. +;; Top-level side effects: none (defuns and defaliases only). +;; Runtime requires: cl-lib, subr-x, calendar-sync-ics. +;; Direct test load: yes (requires calendar-sync-ics explicitly). +;; +;; Recurrence layer of the calendar-sync parser: RECURRENCE-ID exception +;; collection and application, EXDATE exclusion, RRULE parsing, and +;; expansion of daily/weekly/monthly/yearly series into concrete +;; occurrences. Builds on calendar-sync-ics for property extraction, +;; timestamp parsing, date arithmetic, and single-event parsing. + +;;; Code: + +(require 'cl-lib) +(require 'subr-x) +(require 'calendar-sync-ics) + +;; Configuration owned by calendar-sync.el; declared special here. +(defvar calendar-sync-user-emails) + +;;; RECURRENCE-ID Exception Handling + +(defun calendar-sync--get-recurrence-id (event-str) + "Extract RECURRENCE-ID value from EVENT-STR. +Returns the datetime value (without TZID parameter), or nil if not found. +Handles both simple values and values with parameters like TZID." + (when (and event-str (stringp event-str)) + (calendar-sync--get-property event-str "RECURRENCE-ID"))) + +(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 nil if not found." + (when (and event-str (stringp event-str)) + (calendar-sync--get-property-line event-str "RECURRENCE-ID"))) + +(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)) + (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) + "Check if OCCURRENCE matches EXCEPTION's recurrence-id. +Compares year, month, day, hour, minute." + (let ((occ-start (plist-get occurrence :start)) + (exc-recid (plist-get exception :recurrence-id))) + (and occ-start exc-recid + (= (nth 0 occ-start) (nth 0 exc-recid)) ; year + (= (nth 1 occ-start) (nth 1 exc-recid)) ; month + (= (nth 2 occ-start) (nth 2 exc-recid)) ; day + ;; Hour/minute check (handle nil for all-day events) + (or (and (null (nth 3 occ-start)) (null (nth 3 exc-recid))) + (and (nth 3 occ-start) (nth 3 exc-recid) + (= (nth 3 occ-start) (nth 3 exc-recid)) + (= (or (nth 4 occ-start) 0) (or (nth 4 exc-recid) 0))))))) + +(defun calendar-sync--apply-single-exception (occurrence exception) + "Apply EXCEPTION to OCCURRENCE, returning modified occurrence." + (let ((result (copy-sequence occurrence))) + ;; Update time from exception + (plist-put result :start (plist-get exception :start)) + (when (plist-get exception :end) + (plist-put result :end (plist-get exception :end))) + ;; Update summary if exception has one + (when (plist-get exception :summary) + (plist-put result :summary (plist-get exception :summary))) + ;; Update other fields + (when (plist-get exception :description) + (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)) + ;; 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) + (plist-put result :url (plist-get exception :url))) + result)) + +(defun calendar-sync--apply-recurrence-exceptions (occurrences exceptions) + "Apply EXCEPTIONS to OCCURRENCES list. +OCCURRENCES is list of event plists from RRULE expansion. +EXCEPTIONS is hash table from `calendar-sync--collect-recurrence-exceptions'. +Returns new list with matching occurrences replaced by exception times." + (if (or (null occurrences) (null exceptions)) + occurrences + (mapcar + (lambda (occurrence) + (let* ((uid (plist-get occurrence :uid)) + (uid-exceptions (and uid (gethash uid exceptions)))) + (if (null uid-exceptions) + occurrence + ;; Check if any exception matches this occurrence + (let ((matching-exception + (cl-find-if (lambda (exc) + (calendar-sync--occurrence-matches-exception-p occurrence exc)) + uid-exceptions))) + (if matching-exception + (calendar-sync--apply-single-exception occurrence matching-exception) + occurrence))))) + occurrences))) + +;;; EXDATE (Excluded Date) Handling + +(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. +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 '()) + (pos 0)) + ;; Find all EXDATE lines + (while (string-match "^EXDATE[^:\n]*:\\([^\n]+\\)" event-str pos) + (push (match-string 1 event-str) exdates) + (setq pos (match-end 0))) + (nreverse exdates)))) + +(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 nil if not found." + (when (and event-str (stringp event-str) exdate-value) + (let ((pattern (format "^\\(EXDATE[^:]*:%s\\)" (regexp-quote exdate-value)))) + (when (string-match pattern event-str) + (match-string 1 event-str))))) + +(defalias 'calendar-sync--parse-exdate #'calendar-sync--parse-ics-datetime + "Parse EXDATE value. See `calendar-sync--parse-ics-datetime'.") + +(defun calendar-sync--collect-exdates (event-str) + "Collect all excluded dates from EVENT-STR, handling timezone conversion. +Returns list of parsed datetime lists (year month day hour minute). +Converts TZID-qualified and UTC times to local time." + (if (or (null event-str) + (not (stringp event-str)) + (string-empty-p event-str)) + '() + (let ((exdate-values (calendar-sync--get-exdates event-str)) + (result '())) + (dolist (exdate-value exdate-values) + (let* ((exdate-line (calendar-sync--get-exdate-line event-str exdate-value)) + (exdate-tzid (and exdate-line (calendar-sync--extract-tzid exdate-line))) + (exdate-is-utc (and exdate-value (string-suffix-p "Z" exdate-value))) + (exdate-parsed (calendar-sync--parse-exdate exdate-value))) + (when exdate-parsed + (push (calendar-sync--localize-parsed-datetime + exdate-parsed exdate-is-utc exdate-tzid) + result)))) + (nreverse result)))) + +(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. +Date-only EXDATE matches any time on that day." + (and occurrence-start exdate + (= (nth 0 occurrence-start) (nth 0 exdate)) ; year + (= (nth 1 occurrence-start) (nth 1 exdate)) ; month + (= (nth 2 occurrence-start) (nth 2 exdate)) ; day + ;; If EXDATE has nil hour/minute (date-only), match any time + (or (null (nth 3 exdate)) + (and (nth 3 occurrence-start) + (= (nth 3 occurrence-start) (nth 3 exdate)) + (= (or (nth 4 occurrence-start) 0) (or (nth 4 exdate) 0)))))) + +(defun calendar-sync--filter-exdates (occurrences exdates) + "Filter OCCURRENCES list to remove entries matching EXDATES. +OCCURRENCES is list of event plists with :start key. +EXDATES is list of parsed datetime lists from `calendar-sync--collect-exdates'. +Returns filtered list with excluded dates removed." + (if (or (null occurrences) (null exdates)) + (or occurrences '()) + (cl-remove-if + (lambda (occurrence) + (let ((occ-start (plist-get occurrence :start))) + (cl-some (lambda (exdate) + (calendar-sync--exdate-matches-p occ-start exdate)) + exdates))) + occurrences))) + +;;; RRULE Parsing and Expansion + +(defun calendar-sync--create-occurrence (base-event occurrence-date) + "Create an occurrence from BASE-EVENT with OCCURRENCE-DATE. +OCCURRENCE-DATE should be a list (year month day hour minute second)." + (let* ((occurrence (copy-sequence base-event)) + (end (plist-get base-event :end))) + (plist-put occurrence :start occurrence-date) + (when end + ;; Use the date from occurrence-date but keep the time from the original end + (let ((date-only (list (nth 0 occurrence-date) + (nth 1 occurrence-date) + (nth 2 occurrence-date)))) + (plist-put occurrence :end (append date-only (nthcdr 3 end))))) + occurrence)) + +(defun calendar-sync--parse-rrule (rrule-str) + "Parse RRULE string into plist. +Returns plist with :freq :interval :byday :until :count." + (let ((parts (split-string rrule-str ";")) + (result '())) + (dolist (part parts) + (when (string-match "\\([^=]+\\)=\\(.+\\)" part) + (let ((key (match-string 1 part)) + (value (match-string 2 part))) + (pcase key + ("FREQ" (setq result (plist-put result :freq (intern (downcase value))))) + ("INTERVAL" (setq result (plist-put result :interval (string-to-number value)))) + ("BYDAY" (setq result (plist-put result :byday (split-string value ",")))) + ("UNTIL" (setq result (plist-put result :until (calendar-sync--parse-timestamp value)))) + ("COUNT" (setq result (plist-put result :count (string-to-number value)))))))) + ;; Set defaults + (unless (plist-get result :interval) + (setq result (plist-put result :interval 1))) + result)) + +(defun calendar-sync--expand-simple-recurrence (base-event rrule range advance-fn) + "Expand a simple (non-weekly) recurring event using ADVANCE-FN to step dates. +BASE-EVENT is the event plist, RRULE is parsed rrule, RANGE is date range. +ADVANCE-FN takes (current-date interval) and returns the next date." + (let* ((start (plist-get base-event :start)) + (interval (plist-get rrule :interval)) + (until (plist-get rrule :until)) + (count (plist-get rrule :count)) + (occurrences '()) + (current-date (list (nth 0 start) (nth 1 start) (nth 2 start))) + (num-generated 0) + (range-end-time (cadr range))) + (while (and (or count until (time-less-p (calendar-sync--date-to-time current-date) range-end-time)) + (or (not until) (calendar-sync--before-date-p current-date until)) + (or (not count) (< num-generated count))) + (let ((occurrence-datetime (append current-date (nthcdr 3 start)))) + (setq num-generated (1+ num-generated)) + (when (calendar-sync--date-in-range-p occurrence-datetime range) + (push (calendar-sync--create-occurrence base-event occurrence-datetime) + occurrences))) + (setq current-date (funcall advance-fn current-date interval))) + (nreverse occurrences))) + +(defun calendar-sync--expand-daily (base-event rrule range) + "Expand daily recurring event. +BASE-EVENT is the event plist, RRULE is parsed rrule, RANGE is date range." + (calendar-sync--expand-simple-recurrence + base-event rrule range #'calendar-sync--add-days)) + +(defun calendar-sync--expand-weekly (base-event rrule range) + "Expand weekly recurring event. +BASE-EVENT is the event plist, RRULE is parsed rrule, RANGE is date range." + (let* ((start (plist-get base-event :start)) + (interval (plist-get rrule :interval)) + (byday (plist-get rrule :byday)) + (until (plist-get rrule :until)) + (count (plist-get rrule :count)) + (occurrences '()) + (current-date (list (nth 0 start) (nth 1 start) (nth 2 start))) + (num-generated 0) + (range-end-time (cadr range)) + (max-iterations 1000) ;; Safety: prevent infinite loops + (iterations 0) + (weekdays (if byday + (mapcar #'calendar-sync--weekday-to-number byday) + (list (calendar-sync--date-weekday current-date))))) + ;; Validate interval + (when (<= interval 0) + (error "Invalid RRULE interval: %s (must be > 0)" interval)) + ;; Start from the first week + ;; For infinite recurrence (no COUNT/UNTIL), stop at range-end for performance + ;; For COUNT, generate all occurrences from start regardless of range + (while (and (< iterations max-iterations) + (or count until (time-less-p (calendar-sync--date-to-time current-date) range-end-time)) + (or (not count) (< num-generated count)) + (or (not until) (calendar-sync--before-date-p current-date until))) + (setq iterations (1+ iterations)) + ;; Generate occurrences for each weekday in this week + (dolist (weekday weekdays) + (let* ((current-weekday (calendar-sync--date-weekday current-date)) + (days-ahead (mod (- weekday current-weekday) 7)) + (occurrence-date (calendar-sync--add-days current-date days-ahead)) + (occurrence-datetime (append occurrence-date (nthcdr 3 start)))) + ;; Check UNTIL date first + (when (or (not until) (calendar-sync--before-date-p occurrence-date until)) + ;; Check COUNT - increment BEFORE range check so COUNT is absolute from start + (when (or (not count) (< num-generated count)) + (setq num-generated (1+ num-generated)) + ;; Only add to output if within date range + (when (calendar-sync--date-in-range-p occurrence-datetime range) + (push (calendar-sync--create-occurrence base-event occurrence-datetime) + occurrences)))))) + ;; Move to next interval week + (setq current-date (calendar-sync--add-days current-date (* 7 interval)))) + (when (>= iterations max-iterations) + (calendar-sync--log-silently "calendar-sync: WARNING: Hit max iterations (%d) expanding weekly event" max-iterations)) + (nreverse occurrences))) + +(defun calendar-sync--expand-monthly (base-event rrule range) + "Expand monthly recurring event. +BASE-EVENT is the event plist, RRULE is parsed rrule, RANGE is date range." + (calendar-sync--expand-simple-recurrence + base-event rrule range #'calendar-sync--add-months)) + +(defun calendar-sync--expand-yearly (base-event rrule range) + "Expand yearly recurring event. +BASE-EVENT is the event plist, RRULE is parsed rrule, RANGE is date range." + (calendar-sync--expand-simple-recurrence + base-event rrule range + (lambda (date interval) (calendar-sync--add-months date (* 12 interval))))) + +(defun calendar-sync--expand-recurring-event (event-str range) + "Expand recurring event EVENT-STR into individual occurrences within RANGE. +Returns list of event plists, or nil if not a recurring event. +Filters out dates excluded via EXDATE properties." + (let ((rrule (calendar-sync--get-property event-str "RRULE"))) + (when rrule + (let* ((base-event (calendar-sync--parse-event event-str)) + (parsed-rrule (calendar-sync--parse-rrule rrule)) + (freq (plist-get parsed-rrule :freq)) + (exdates (calendar-sync--collect-exdates event-str))) + (when base-event + (let ((occurrences + (pcase freq + ('daily (calendar-sync--expand-daily base-event parsed-rrule range)) + ('weekly (calendar-sync--expand-weekly base-event parsed-rrule range)) + ('monthly (calendar-sync--expand-monthly base-event parsed-rrule range)) + ('yearly (calendar-sync--expand-yearly base-event parsed-rrule range)) + (_ (calendar-sync--log-silently "calendar-sync: Unsupported RRULE frequency: %s" freq) + nil)))) + ;; Filter out EXDATE occurrences + (if exdates + (calendar-sync--filter-exdates occurrences exdates) + occurrences))))))) + +(provide 'calendar-sync-recurrence) +;;; calendar-sync-recurrence.el ends here diff --git a/modules/calendar-sync-source.el b/modules/calendar-sync-source.el new file mode 100644 index 000000000..d9efc885b --- /dev/null +++ b/modules/calendar-sync-source.el @@ -0,0 +1,426 @@ +;;; calendar-sync-source.el --- Feed fetch, state, and conversion workers -*- coding: utf-8; lexical-binding: t; -*- + +;; Author: Craig Jennings <c@cjennings.net> +;; Created: 2025-11-16 + +;;; Commentary: +;; +;; Layer: 3 (Domain Workflow). +;; Category: D/S. +;; Load shape: library. +;; Top-level side effects: none (defuns plus internal state defvars). +;; Runtime requires: subr-x, system-lib, calendar-sync-ics. +;; Direct test load: yes (requires calendar-sync-ics explicitly). +;; +;; Source layer of calendar-sync: per-calendar sync state and its on-disk +;; persistence, asynchronous .ics fetching via curl, the batch Emacs +;; conversion worker, and the Google Calendar API fetch path. Drives a +;; single calendar from either its .ics feed or the API helper. +;; +;; The batch worker loads the top calendar-sync module (whose path is held +;; in `calendar-sync--module-file') and there calls `calendar-sync--parse-ics' +;; and `calendar-sync--write-file'. Those live in the top and org modules +;; respectively, which require this one, so they are forward-declared here +;; rather than required (the worker has the full graph loaded, and these +;; functions are only ever invoked inside it). + +;;; Code: + +(require 'subr-x) +(require 'system-lib) ;; provides cj/auth-source-secret-value +(require 'calendar-sync-ics) + +;; Owned by calendar-sync.el (config) / calendar-sync-org.el (output); +;; forward-declared so this module compiles and reads them without a cycle. +(defvar calendar-sync-calendars) +(defvar calendar-sync-fetch-timeout) +(defvar calendar-sync-python-command) +(defvar calendar-sync-past-months) +(defvar calendar-sync-future-months) +(defvar calendar-sync-user-emails) +(defvar calendar-sync-skip-declined) +(defvar calendar-sync-private-config-file) +(defvar calendar-sync--module-file) +(declare-function calendar-sync--parse-ics "calendar-sync" (ics-content)) +(declare-function calendar-sync--write-file "calendar-sync-org" (content file)) + +;;; Internal state + +(defvar calendar-sync--calendar-states (make-hash-table :test 'equal) + "Per-calendar sync state. +Hash table mapping calendar name (string) to state plist with: + :last-sync - Time of last successful sync + :status - Symbol: ok, error, or syncing + :last-error - Error message string, or nil") + +(defvar calendar-sync--state-file + (expand-file-name "persist/calendar-sync-state.el" user-emacs-directory) + "File to persist sync state across Emacs sessions.") + +;;; State Persistence + +(defun calendar-sync--save-state () + "Save sync state to disk for persistence across sessions." + (let* ((calendar-states-alist + (let ((result '())) + (maphash (lambda (name state) + (push (cons name state) result)) + calendar-sync--calendar-states) + result)) + (state `((timezone-offset . ,calendar-sync--last-timezone-offset) + (calendar-states . ,calendar-states-alist))) + (dir (file-name-directory calendar-sync--state-file))) + (unless (file-directory-p dir) + (make-directory dir t)) + (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." + (when (file-exists-p calendar-sync--state-file) + (condition-case err + (with-temp-buffer + (insert-file-contents calendar-sync--state-file) + (let ((state (read (current-buffer)))) + (setq calendar-sync--last-timezone-offset + (alist-get 'timezone-offset state)) + ;; Load per-calendar states + (let ((cal-states (alist-get 'calendar-states state))) + (clrhash calendar-sync--calendar-states) + (dolist (entry cal-states) + (puthash (car entry) (cdr entry) calendar-sync--calendar-states))))) + (error + (calendar-sync--log-silently "calendar-sync: Error loading state: %s" (error-message-string err)))))) + +(defun calendar-sync--get-calendar-state (calendar-name) + "Get state plist for CALENDAR-NAME, or nil if not found." + (gethash calendar-name calendar-sync--calendar-states)) + +(defun calendar-sync--set-calendar-state (calendar-name state) + "Set STATE plist for CALENDAR-NAME." + (puthash calendar-name state calendar-sync--calendar-states)) + +;;; Debug Logging + +(defun calendar-sync--debug-p () + "Return non-nil if calendar-sync debug logging is enabled. +Checks `cj/debug-modules' for symbol `calendar-sync' or t (all)." + (and (boundp 'cj/debug-modules) + (or (eq cj/debug-modules t) + (memq 'calendar-sync cj/debug-modules)))) + +;;; Private Config + +(defun calendar-sync--load-private-config () + "Load private calendar-sync configuration when available." + (when (file-readable-p calendar-sync-private-config-file) + (condition-case err + (load calendar-sync-private-config-file nil t) + (error + (message "calendar-sync: Failed to load private config %s: %s" + (abbreviate-file-name calendar-sync-private-config-file) + (error-message-string err)))))) + +;;; .ics Fetch + +(defun calendar-sync--fetch-ics (url callback) + "Fetch .ics file from URL asynchronously using curl. +Calls CALLBACK with the .ics content as string (normalized to Unix line endings) +or nil on error. CALLBACK signature: (lambda (content) ...). + +The fetch happens asynchronously and doesn't block Emacs. The callback is +invoked when the fetch completes, either successfully or with an error." + (condition-case err + (let ((buffer (generate-new-buffer " *calendar-sync-curl*"))) + (make-process + :name "calendar-sync-curl" + :buffer buffer + :command (list "curl" "-s" "-L" "--fail" + "--connect-timeout" "10" + "--max-time" (number-to-string calendar-sync-fetch-timeout) + url) + :sentinel + (lambda (process event) + (when (memq (process-status process) '(exit signal)) + (let ((buf (process-buffer process))) + (when (buffer-live-p buf) + (let ((content + (with-current-buffer buf + (if (and (eq (process-status process) 'exit) + (= (process-exit-status process) 0)) + (calendar-sync--normalize-line-endings (buffer-string)) + (calendar-sync--log-silently "calendar-sync: Fetch error: curl failed: %s" (string-trim event)) + nil)))) + (kill-buffer buf) + (funcall callback content)))))))) + (error + (calendar-sync--log-silently "calendar-sync: Fetch error: %s" (error-message-string err)) + (funcall callback nil)))) + +(defun calendar-sync--fetch-ics-file (url callback) + "Fetch .ics from URL to a temp file asynchronously. +Calls CALLBACK with the temp file path on success, or nil on error. The caller +owns deleting the temp file after a successful callback." + (condition-case err + (let ((buffer (generate-new-buffer " *calendar-sync-curl*")) + (temp-file (make-temp-file "calendar-sync-" nil ".ics"))) + (make-process + :name "calendar-sync-curl" + :buffer buffer + :command (list "curl" "-s" "-L" "--fail" + "--connect-timeout" "10" + "--max-time" (number-to-string calendar-sync-fetch-timeout) + "-o" temp-file + url) + :sentinel + (lambda (process event) + (when (memq (process-status process) '(exit signal)) + (let ((buf (process-buffer process)) + (success (and (eq (process-status process) 'exit) + (= (process-exit-status process) 0)))) + (when (buffer-live-p buf) + (unless success + (calendar-sync--log-silently "calendar-sync: Fetch error: curl failed: %s" + (string-trim event))) + (kill-buffer buf)) + (if success + (funcall callback temp-file) + (when (file-exists-p temp-file) + (delete-file temp-file)) + (funcall callback nil))))))) + (error + (calendar-sync--log-silently "calendar-sync: Fetch error: %s" (error-message-string err)) + (funcall callback nil)))) + +;;; Batch Conversion Worker + +(defun calendar-sync--emacs-binary () + "Return the Emacs executable to use for calendar conversion workers." + (let ((candidate (expand-file-name invocation-name invocation-directory))) + (if (file-executable-p candidate) + candidate + invocation-name))) + +(defun calendar-sync--batch-convert-file (ics-file output-file past-months future-months user-emails) + "Convert ICS-FILE to Org format and write OUTPUT-FILE. +PAST-MONTHS, FUTURE-MONTHS, and USER-EMAILS mirror the interactive session's +calendar conversion settings. This is intended for noninteractive worker +processes, not direct interactive use." + (setq calendar-sync-past-months past-months + calendar-sync-future-months future-months + calendar-sync-user-emails user-emails) + (let* ((ics-content + (with-temp-buffer + (insert-file-contents ics-file) + (calendar-sync--normalize-line-endings (buffer-string)))) + (org-content (calendar-sync--parse-ics ics-content))) + (unless org-content + (error "calendar-sync: parse failed")) + (calendar-sync--write-file org-content output-file))) + +(defun calendar-sync--worker-command (ics-file output-file) + "Build the batch Emacs command that converts ICS-FILE to OUTPUT-FILE." + (let ((module-dir (file-name-directory calendar-sync--module-file)) + (private-config-file + (make-temp-name (expand-file-name "calendar-sync-worker-config-" + temporary-file-directory))) + (state-file + (make-temp-name (expand-file-name "calendar-sync-worker-state-" + temporary-file-directory)))) + (list (calendar-sync--emacs-binary) + "--batch" + "--no-site-file" + "--no-site-lisp" + "--eval" (format "(setq load-prefer-newer t calendar-sync-auto-start nil calendar-sync-private-config-file %S calendar-sync--state-file %S)" + private-config-file state-file) + "-L" module-dir + "-l" calendar-sync--module-file + "--eval" (format "(calendar-sync--batch-convert-file %S %S %S %S '%S)" + ics-file + output-file + calendar-sync-past-months + calendar-sync-future-months + calendar-sync-user-emails)))) + +(defun calendar-sync--convert-ics-file-async (ics-file output-file callback) + "Convert ICS-FILE to OUTPUT-FILE in a batch Emacs worker. +Calls CALLBACK as (CALLBACK SUCCESS ERROR-MESSAGE). Deletes ICS-FILE after the +worker exits." + (condition-case err + (let ((buffer (generate-new-buffer " *calendar-sync-worker*"))) + (make-process + :name "calendar-sync-worker" + :buffer buffer + :command (calendar-sync--worker-command ics-file output-file) + :sentinel + (lambda (process _event) + (when (memq (process-status process) '(exit signal)) + (let* ((buf (process-buffer process)) + (success (and (eq (process-status process) 'exit) + (= (process-exit-status process) 0))) + (error-message + (when (buffer-live-p buf) + (with-current-buffer buf + (string-trim (buffer-string)))))) + (when (file-exists-p ics-file) + (delete-file ics-file)) + (when (buffer-live-p buf) + (kill-buffer buf)) + (funcall callback success error-message)))))) + (error + (when (file-exists-p ics-file) + (delete-file ics-file)) + (funcall callback nil (error-message-string err))))) + +(defun calendar-sync--mark-sync-failed (name reason) + "Record failed sync state for calendar NAME with REASON." + (calendar-sync--set-calendar-state + name + (list :status 'error + :last-sync (plist-get (calendar-sync--get-calendar-state name) :last-sync) + :last-error reason)) + (calendar-sync--save-state) + (message "calendar-sync: [%s] Sync failed (see *Messages*)" name)) + +;;; Google Calendar API Fetch Path + +(defun calendar-sync--api-script () + "Return the absolute path to the Google Calendar API helper script. +Resolved relative to this module so batch workers and tests don't depend +on `user-emacs-directory'." + (let ((module-dir (file-name-directory calendar-sync--module-file))) + (expand-file-name "calendar_sync_api.py" + (expand-file-name "scripts" + (file-name-parent-directory module-dir))))) + +(defun calendar-sync--api-command (account calendar-id output-file) + "Build the command list that runs the API helper. +ACCOUNT and CALENDAR-ID select the OAuth account and calendar; OUTPUT-FILE +is where the helper writes rendered org content. The past/future window +mirrors the .ics path's `calendar-sync-past-months' / +`calendar-sync-future-months'. When `calendar-sync-skip-declined' is nil, +passes --keep-declined so the API path honors the same toggle." + (append + (list calendar-sync-python-command + (calendar-sync--api-script) + "--account" account + "--calendar-id" calendar-id + "--output" output-file + "--past-months" (number-to-string calendar-sync-past-months) + "--future-months" (number-to-string calendar-sync-future-months)) + (unless calendar-sync-skip-declined + (list "--keep-declined")))) + +(defun calendar-sync--sync-calendar-api (calendar) + "Sync a single Google CALENDAR via the API helper script. +CALENDAR is a plist with :name, :account, :calendar-id, and :file keys. +The helper fetches, filters, and renders org in one pass and writes :file +directly, so it runs in a single external process off the interactive thread." + (let* ((name (plist-get calendar :name)) + (account (plist-get calendar :account)) + (calendar-id (plist-get calendar :calendar-id)) + (file (plist-get calendar :file)) + (fetch-start (float-time))) + (calendar-sync--set-calendar-state name '(:status syncing)) + (calendar-sync--log-silently "calendar-sync: [%s] Syncing (API)..." name) + (condition-case err + (let ((buffer (generate-new-buffer " *calendar-sync-api*"))) + (make-process + :name "calendar-sync-api" + :buffer buffer + :command (calendar-sync--api-command account calendar-id file) + :sentinel + (lambda (process _event) + (when (memq (process-status process) '(exit signal)) + (let* ((buf (process-buffer process)) + (success (and (eq (process-status process) 'exit) + (= (process-exit-status process) 0))) + (output (when (buffer-live-p buf) + (with-current-buffer buf + (string-trim (buffer-string)))))) + (when (buffer-live-p buf) + (kill-buffer buf)) + (if (not success) + (calendar-sync--mark-sync-failed + name (if (or (null output) (string-empty-p output)) + "API helper failed" + output)) + (calendar-sync--set-calendar-state + name + (list :status 'ok + :last-sync (current-time) + :last-error nil)) + (setq calendar-sync--last-timezone-offset + (calendar-sync--current-timezone-offset)) + (calendar-sync--save-state) + (let ((total-elapsed (- (float-time) fetch-start))) + (message "calendar-sync: [%s] Sync complete (%.1fs total) → %s" + name total-elapsed file)))))))) + (error + (calendar-sync--log-silently "calendar-sync: [%s] API helper error: %s" + name (error-message-string err)) + (calendar-sync--mark-sync-failed name (error-message-string err)))))) + +;;; .ics Sync Path + +(defun calendar-sync--calendar-url (calendar) + "Return the .ics feed URL for CALENDAR, or nil if none is configured. +An explicit :url wins. Otherwise :secret-host names an auth-source host +whose stored secret is the URL (kept in auth-source because the .ics URL +is itself a token)." + (or (plist-get calendar :url) + (when-let* ((host (plist-get calendar :secret-host))) + (cj/auth-source-secret-value host)))) + +(defun calendar-sync--sync-calendar-ics (calendar) + "Sync a single CALENDAR from its .ics feed asynchronously. +CALENDAR is a plist with :name, :file, and a feed URL resolved by +`calendar-sync--calendar-url' (an explicit :url, or a :secret-host +looked up in auth-source)." + (let ((name (plist-get calendar :name)) + (url (calendar-sync--calendar-url calendar)) + (file (plist-get calendar :file)) + (fetch-start (float-time))) + (calendar-sync--set-calendar-state name '(:status syncing)) + (calendar-sync--log-silently "calendar-sync: [%s] Syncing..." name) + (calendar-sync--fetch-ics-file + url + (lambda (ics-file) + (let ((fetch-elapsed (- (float-time) fetch-start))) + (if (null ics-file) + (progn + (calendar-sync--log-silently "calendar-sync: [%s] Fetch failed" name) + (calendar-sync--mark-sync-failed name "Fetch failed")) + (when (calendar-sync--debug-p) + (calendar-sync--log-silently "calendar-sync: [%s] Fetched in %.1fs" + name fetch-elapsed)) + (calendar-sync--convert-ics-file-async + ics-file + file + (lambda (success error-message) + (if (not success) + (progn + (calendar-sync--log-silently "calendar-sync: [%s] Conversion failed: %s" + name error-message) + (calendar-sync--mark-sync-failed + name + (if (or (null error-message) + (string-empty-p error-message)) + "Conversion failed" + error-message))) + (calendar-sync--set-calendar-state + name + (list :status 'ok + :last-sync (current-time) + :last-error nil)) + (setq calendar-sync--last-timezone-offset + (calendar-sync--current-timezone-offset)) + (calendar-sync--save-state) + (let ((total-elapsed (- (float-time) fetch-start))) + (message "calendar-sync: [%s] Sync complete (%.1fs total) → %s" + name total-elapsed file))))))))))) + +(provide 'calendar-sync-source) +;;; calendar-sync-source.el ends here diff --git a/modules/calendar-sync.el b/modules/calendar-sync.el index b684330c8..1079a72be 100644 --- a/modules/calendar-sync.el +++ b/modules/calendar-sync.el @@ -10,15 +10,20 @@ ;; Load shape: eager only when calendar-sync.local.el configures calendars. ;; Eager reason: daily agenda workflow; timers and network fetches are guarded. ;; Top-level side effects: defines C-; g map; starts sync only when configured. -;; Runtime requires: cl-lib, subr-x, system-lib, cj-org-text-lib, keybindings. +;; Runtime requires: cl-lib, subr-x, system-lib, cj-org-text-lib, keybindings, +;; calendar-sync-ics, calendar-sync-recurrence, calendar-sync-org, +;; calendar-sync-source. ;; Direct test load: yes. ;; ;; One-way calendar synchronization from configured .ics/API sources into Org ;; files. Feed URLs may be inline or resolved from auth-source via :secret-host. ;; -;; The parser expands recurring events into a rolling window around today, -;; regenerates target Org files on each sync, and keeps source calendars -;; read-only. Commands under C-; g start, stop, toggle, inspect, and run syncs. +;; This is the public face of the module: it owns configuration, the parse +;; pipeline orchestrator, the sync dispatch, the user commands, the timer, and +;; the C-; g keymap. The parsing, recurrence expansion, Org rendering, and +;; fetch/worker code live in the calendar-sync-ics / -recurrence / -org / +;; -source layers, which this module requires. Every public name is unchanged +;; so existing (require 'calendar-sync) callers and tests keep working. ;;; Code: @@ -27,12 +32,10 @@ (require 'system-lib) ;; provides cj/auth-source-secret-value (leaf; no ai-config dep) (require 'cj-org-text-lib) (require 'keybindings) ;; provides cj/custom-keymap - -(defun calendar-sync--log-silently (format-string &rest args) - "Log FORMAT-STRING with ARGS without requiring the full config." - (if (fboundp 'cj/log-silently) - (apply #'cj/log-silently format-string args) - (apply #'message format-string args))) +(require 'calendar-sync-ics) +(require 'calendar-sync-recurrence) +(require 'calendar-sync-org) +(require 'calendar-sync-source) ;;; Configuration @@ -140,1017 +143,7 @@ without loading the user's init file.") (defvar calendar-sync--timer nil "Timer object for automatic syncing.") -(defvar calendar-sync--calendar-states (make-hash-table :test 'equal) - "Per-calendar sync state. -Hash table mapping calendar name (string) to state plist with: - :last-sync - Time of last successful sync - :status - Symbol: ok, error, or syncing - :last-error - Error message string, or nil") - -(defvar calendar-sync--last-timezone-offset nil - "Timezone offset in seconds from UTC at last sync. -Used to detect timezone changes (e.g., when traveling).") - -(defvar calendar-sync--state-file - (expand-file-name "persist/calendar-sync-state.el" user-emacs-directory) - "File to persist sync state across Emacs sessions.") - -;;; Timezone Detection - -(defun calendar-sync--current-timezone-offset () - "Get current timezone offset in seconds from UTC. -Returns negative for west of UTC, positive for east. -Example: -21600 for CST (UTC-6), -28800 for PST (UTC-8)." - (car (current-time-zone))) - -(defun calendar-sync--format-timezone-offset (offset) - "Format timezone OFFSET (in seconds) as human-readable string. -Example: -21600 → `UTC-6' or `UTC-6:00'." - (if (null offset) - "unknown" - (let* ((hours (/ offset 3600)) - (minutes (abs (mod (/ offset 60) 60))) - (sign (if (>= hours 0) "+" "-")) - (abs-hours (abs hours))) - (if (= minutes 0) - (format "UTC%s%d" sign abs-hours) - (format "UTC%s%d:%02d" sign abs-hours minutes))))) - -(defun calendar-sync--timezone-changed-p () - "Return t if timezone has changed since last sync." - (and calendar-sync--last-timezone-offset - (not (= (calendar-sync--current-timezone-offset) - calendar-sync--last-timezone-offset)))) - -;;; State Persistence - -(defun calendar-sync--save-state () - "Save sync state to disk for persistence across sessions." - (let* ((calendar-states-alist - (let ((result '())) - (maphash (lambda (name state) - (push (cons name state) result)) - calendar-sync--calendar-states) - result)) - (state `((timezone-offset . ,calendar-sync--last-timezone-offset) - (calendar-states . ,calendar-states-alist))) - (dir (file-name-directory calendar-sync--state-file))) - (unless (file-directory-p dir) - (make-directory dir t)) - (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." - (when (file-exists-p calendar-sync--state-file) - (condition-case err - (with-temp-buffer - (insert-file-contents calendar-sync--state-file) - (let ((state (read (current-buffer)))) - (setq calendar-sync--last-timezone-offset - (alist-get 'timezone-offset state)) - ;; Load per-calendar states - (let ((cal-states (alist-get 'calendar-states state))) - (clrhash calendar-sync--calendar-states) - (dolist (entry cal-states) - (puthash (car entry) (cdr entry) calendar-sync--calendar-states))))) - (error - (calendar-sync--log-silently "calendar-sync: Error loading state: %s" (error-message-string err)))))) - -(defun calendar-sync--get-calendar-state (calendar-name) - "Get state plist for CALENDAR-NAME, or nil if not found." - (gethash calendar-name calendar-sync--calendar-states)) - -(defun calendar-sync--set-calendar-state (calendar-name state) - "Set STATE plist for CALENDAR-NAME." - (puthash calendar-name state calendar-sync--calendar-states)) - -;;; Line Ending Normalization - -(defun calendar-sync--normalize-line-endings (content) - "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 -endings throughout the parsing pipeline. - -Returns CONTENT with all \\r characters removed." - (if (not (stringp content)) - 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 & < > ". 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 "&" "&" result)) - (setq result (replace-regexp-in-string "<" "<" result)) - (setq result (replace-regexp-in-string ">" ">" result)) - (setq result (replace-regexp-in-string """ "\"" 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) - "Add MONTHS to DATE. -DATE is (year month day), returns new (year month day)." - (let* ((year (nth 0 date)) - (month (nth 1 date)) - (day (nth 2 date)) - (total-months (+ (* year 12) month -1 months)) - (new-year (/ total-months 12)) - (new-month (1+ (mod total-months 12)))) - (list new-year new-month day))) - -(defun calendar-sync--get-date-range () - "Get date range for event expansion as (start-time end-time). -Returns time values for -3 months and +12 months from today." - (let* ((now (decode-time)) - (today (list (nth 5 now) (nth 4 now) (nth 3 now))) - (start-date (calendar-sync--add-months today (- calendar-sync-past-months))) - (end-date (calendar-sync--add-months today calendar-sync-future-months)) - (start-time (apply #'encode-time 0 0 0 (reverse start-date))) - (end-time (apply #'encode-time 0 0 0 (reverse end-date)))) - (list start-time end-time))) - -(defun calendar-sync--date-in-range-p (date range) - "Check if DATE is within RANGE. -DATE is (year month day hour minute), RANGE is (start-time end-time)." - (let* ((year (nth 0 date)) - (month (nth 1 date)) - (day (nth 2 date)) - (date-time (encode-time 0 0 0 day month year)) - (start-time (nth 0 range)) - (end-time (nth 1 range))) - (and (time-less-p start-time date-time) - (time-less-p date-time end-time)))) - -(defun calendar-sync--weekday-to-number (weekday) - "Convert WEEKDAY string (MO, TU, etc.) to number (1-7). -Monday = 1, Sunday = 7." - (pcase weekday - ("MO" 1) - ("TU" 2) - ("WE" 3) - ("TH" 4) - ("FR" 5) - ("SA" 6) - ("SU" 7) - (_ nil))) - -(defun calendar-sync--date-weekday (date) - "Get weekday number for DATE (year month day). -Monday = 1, Sunday = 7." - (let* ((year (nth 0 date)) - (month (nth 1 date)) - (day (nth 2 date)) - (time (encode-time 0 0 0 day month year)) - (decoded (decode-time time)) - (dow (nth 6 decoded))) ; 0 = Sunday, 1 = Monday, etc. - (if (= dow 0) 7 dow))) ; Convert to 1-7 with Monday=1 - -(defun calendar-sync--add-days (date days) - "Add DAYS to DATE (year month day). -Returns new (year month day). -Uses noon internally to avoid DST boundary issues where adding -86400 seconds to midnight can land on the same calendar date -during fall-back transitions." - (let* ((year (nth 0 date)) - (month (nth 1 date)) - (day (nth 2 date)) - (time (encode-time 0 0 12 day month year)) - (new-time (time-add time (days-to-time days))) - (decoded (decode-time new-time))) - (list (nth 5 decoded) (nth 4 decoded) (nth 3 decoded)))) - -;;; RECURRENCE-ID Exception Handling - -(defun calendar-sync--get-recurrence-id (event-str) - "Extract RECURRENCE-ID value from EVENT-STR. -Returns the datetime value (without TZID parameter), or nil if not found. -Handles both simple values and values with parameters like TZID." - (when (and event-str (stringp event-str)) - (calendar-sync--get-property event-str "RECURRENCE-ID"))) - -(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 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). -Handles formats: 20260203T090000Z, 20260203T090000, 20260203." - (when (and value - (stringp value) - (not (string-empty-p value))) - (cond - ;; DateTime format: 20260203T090000Z or 20260203T090000 - ((string-match "\\`\\([0-9]\\{4\\}\\)\\([0-9]\\{2\\}\\)\\([0-9]\\{2\\}\\)T\\([0-9]\\{2\\}\\)\\([0-9]\\{2\\}\\)\\([0-9]\\{2\\}\\)Z?\\'" value) - (list (string-to-number (match-string 1 value)) - (string-to-number (match-string 2 value)) - (string-to-number (match-string 3 value)) - (string-to-number (match-string 4 value)) - (string-to-number (match-string 5 value)))) - ;; Date-only format: 20260203 - ((string-match "\\`\\([0-9]\\{4\\}\\)\\([0-9]\\{2\\}\\)\\([0-9]\\{2\\}\\)\\'" value) - (list (string-to-number (match-string 1 value)) - (string-to-number (match-string 2 value)) - (string-to-number (match-string 3 value)) - nil nil)) - (t nil)))) - -(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)) - (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) - "Check if OCCURRENCE matches EXCEPTION's recurrence-id. -Compares year, month, day, hour, minute." - (let ((occ-start (plist-get occurrence :start)) - (exc-recid (plist-get exception :recurrence-id))) - (and occ-start exc-recid - (= (nth 0 occ-start) (nth 0 exc-recid)) ; year - (= (nth 1 occ-start) (nth 1 exc-recid)) ; month - (= (nth 2 occ-start) (nth 2 exc-recid)) ; day - ;; Hour/minute check (handle nil for all-day events) - (or (and (null (nth 3 occ-start)) (null (nth 3 exc-recid))) - (and (nth 3 occ-start) (nth 3 exc-recid) - (= (nth 3 occ-start) (nth 3 exc-recid)) - (= (or (nth 4 occ-start) 0) (or (nth 4 exc-recid) 0))))))) - -(defun calendar-sync--apply-single-exception (occurrence exception) - "Apply EXCEPTION to OCCURRENCE, returning modified occurrence." - (let ((result (copy-sequence occurrence))) - ;; Update time from exception - (plist-put result :start (plist-get exception :start)) - (when (plist-get exception :end) - (plist-put result :end (plist-get exception :end))) - ;; Update summary if exception has one - (when (plist-get exception :summary) - (plist-put result :summary (plist-get exception :summary))) - ;; Update other fields - (when (plist-get exception :description) - (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)) - ;; 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) - (plist-put result :url (plist-get exception :url))) - result)) - -(defun calendar-sync--apply-recurrence-exceptions (occurrences exceptions) - "Apply EXCEPTIONS to OCCURRENCES list. -OCCURRENCES is list of event plists from RRULE expansion. -EXCEPTIONS is hash table from `calendar-sync--collect-recurrence-exceptions'. -Returns new list with matching occurrences replaced by exception times." - (if (or (null occurrences) (null exceptions)) - occurrences - (mapcar - (lambda (occurrence) - (let* ((uid (plist-get occurrence :uid)) - (uid-exceptions (and uid (gethash uid exceptions)))) - (if (null uid-exceptions) - occurrence - ;; Check if any exception matches this occurrence - (let ((matching-exception - (cl-find-if (lambda (exc) - (calendar-sync--occurrence-matches-exception-p occurrence exc)) - uid-exceptions))) - (if matching-exception - (calendar-sync--apply-single-exception occurrence matching-exception) - occurrence))))) - occurrences))) - -;;; EXDATE (Excluded Date) Handling - -(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. -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 '()) - (pos 0)) - ;; Find all EXDATE lines - (while (string-match "^EXDATE[^:\n]*:\\([^\n]+\\)" event-str pos) - (push (match-string 1 event-str) exdates) - (setq pos (match-end 0))) - (nreverse exdates)))) - -(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 nil if not found." - (when (and event-str (stringp event-str) exdate-value) - (let ((pattern (format "^\\(EXDATE[^:]*:%s\\)" (regexp-quote exdate-value)))) - (when (string-match pattern event-str) - (match-string 1 event-str))))) - -(defalias 'calendar-sync--parse-exdate #'calendar-sync--parse-ics-datetime - "Parse EXDATE value. See `calendar-sync--parse-ics-datetime'.") - -(defun calendar-sync--collect-exdates (event-str) - "Collect all excluded dates from EVENT-STR, handling timezone conversion. -Returns list of parsed datetime lists (year month day hour minute). -Converts TZID-qualified and UTC times to local time." - (if (or (null event-str) - (not (stringp event-str)) - (string-empty-p event-str)) - '() - (let ((exdate-values (calendar-sync--get-exdates event-str)) - (result '())) - (dolist (exdate-value exdate-values) - (let* ((exdate-line (calendar-sync--get-exdate-line event-str exdate-value)) - (exdate-tzid (and exdate-line (calendar-sync--extract-tzid exdate-line))) - (exdate-is-utc (and exdate-value (string-suffix-p "Z" exdate-value))) - (exdate-parsed (calendar-sync--parse-exdate exdate-value))) - (when exdate-parsed - (push (calendar-sync--localize-parsed-datetime - exdate-parsed exdate-is-utc exdate-tzid) - result)))) - (nreverse result)))) - -(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. -Date-only EXDATE matches any time on that day." - (and occurrence-start exdate - (= (nth 0 occurrence-start) (nth 0 exdate)) ; year - (= (nth 1 occurrence-start) (nth 1 exdate)) ; month - (= (nth 2 occurrence-start) (nth 2 exdate)) ; day - ;; If EXDATE has nil hour/minute (date-only), match any time - (or (null (nth 3 exdate)) - (and (nth 3 occurrence-start) - (= (nth 3 occurrence-start) (nth 3 exdate)) - (= (or (nth 4 occurrence-start) 0) (or (nth 4 exdate) 0)))))) - -(defun calendar-sync--filter-exdates (occurrences exdates) - "Filter OCCURRENCES list to remove entries matching EXDATES. -OCCURRENCES is list of event plists with :start key. -EXDATES is list of parsed datetime lists from `calendar-sync--collect-exdates'. -Returns filtered list with excluded dates removed." - (if (or (null occurrences) (null exdates)) - (or occurrences '()) - (cl-remove-if - (lambda (occurrence) - (let ((occ-start (plist-get occurrence :start))) - (cl-some (lambda (exdate) - (calendar-sync--exdate-matches-p occ-start exdate)) - exdates))) - occurrences))) - -;;; .ics Parsing - -(defun calendar-sync--split-events (ics-content) - "Split ICS-CONTENT into individual VEVENT blocks. -Returns list of strings, each containing one VEVENT block." - (let ((events '())) - (with-temp-buffer - (insert ics-content) - (goto-char (point-min)) - (while (search-forward "BEGIN:VEVENT" nil t) - (let ((start (match-beginning 0))) - (when (search-forward "END:VEVENT" nil t) - (push (buffer-substring-no-properties start (point)) events))))) - (nreverse events))) - -(defun calendar-sync--unfold-continuation (text value start) - "Unfold RFC 5545 continuation lines from TEXT starting at START. -VALUE is the initial content to append to. Continuation lines begin -with a space or tab after a newline. Returns (unfolded-value . new-pos)." - (while (and (< start (length text)) - (string-match "\n[ \t]\\([^\n]*\\)" text start) - (= (match-beginning 0) start)) - (setq value (concat value (match-string 1 text))) - (setq start (match-end 0))) - (cons value start)) - -(defun calendar-sync--get-property (event property) - "Extract PROPERTY value from EVENT string. -Handles property parameters (e.g., DTSTART;TZID=America/Chicago:value). -Handles multi-line values (lines starting with space). -Returns nil if property not found." - (when (string-match (format "^%s[^:\n]*:\\(.*\\)$" (regexp-quote property)) event) - (car (calendar-sync--unfold-continuation - event (match-string 1 event) (match-end 0))))) - -(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 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* ((result (calendar-sync--unfold-continuation - event (match-string 0 event) (match-end 0))) - (line (car result)) - (end (cdr result))) - (push line lines) - (setq pos (if (< end (length event)) (1+ end) end)))) - (nreverse lines)))) - -(defun calendar-sync--extract-cn (line) - "Extract and dequote CN parameter from iCal LINE. -Returns the CN value string, or nil if not found." - (when (string-match ";CN=\\([^;:]+\\)" line) - (let ((cn (match-string 1 line))) - (if (and (string-prefix-p "\"" cn) (string-suffix-p "\"" cn)) - (substring cn 1 -1) - cn)))) - -(defun calendar-sync--extract-email (line) - "Extract email address from mailto: value in iCal LINE. -Returns email string, or nil if not found." - (when (string-match "mailto:\\([^>\n ]+\\)" line) - (match-string 1 line))) - -(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 (calendar-sync--extract-cn line)) - (email (calendar-sync--extract-email line)) - (partstat nil) - (role nil)) - (when (string-match ";PARTSTAT=\\([^;:]+\\)" line) - (setq partstat (match-string 1 line))) - (when (string-match ";ROLE=\\([^;:]+\\)" line) - (setq role (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--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." - (when (and event-str (stringp event-str)) - (let ((line (calendar-sync--get-property-line event-str "ORGANIZER"))) - (when line - (let ((email (calendar-sync--extract-email line))) - (when email - (list :cn (calendar-sync--extract-cn line) :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'. -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) - ;; Must have colon (property:value format) - (string-match-p ":" property-line) - (string-match ";TZID=\\([^;:]+\\)" property-line)) - (match-string 1 property-line))) - -(defun calendar-sync--convert-utc-to-local (year month day hour minute second) - "Convert UTC datetime to local time. -Returns list (year month day hour minute) in local timezone." - (let* ((utc-time (encode-time second minute hour day month year 0)) - (local-time (decode-time utc-time))) - (list (nth 5 local-time) ; year - (nth 4 local-time) ; month - (nth 3 local-time) ; day - (nth 2 local-time) ; hour - (nth 1 local-time)))) ; minute - -(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'. -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 -argument) for fast, subprocess-free conversion. Uses the same system -TZ database as the `date' command." - (when (and source-tz (not (string-empty-p source-tz))) - (condition-case err - (let* ((abs-time (encode-time 0 minute hour day month year source-tz)) - (local (decode-time abs-time))) - (list (nth 5 local) ; year - (nth 4 local) ; month - (nth 3 local) ; day - (nth 2 local) ; hour - (nth 1 local))) ; minute - (error - (calendar-sync--log-silently "calendar-sync: Error converting timezone %s: %s" - source-tz (error-message-string err)) - nil)))) - -(defun calendar-sync--localize-parsed-datetime (parsed is-utc tzid) - "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." - (cond - (is-utc - (calendar-sync--convert-utc-to-local - (nth 0 parsed) (nth 1 parsed) (nth 2 parsed) - (or (nth 3 parsed) 0) (or (nth 4 parsed) 0) 0)) - (tzid - (or (calendar-sync--convert-tz-to-local - (nth 0 parsed) (nth 1 parsed) (nth 2 parsed) - (or (nth 3 parsed) 0) (or (nth 4 parsed) 0) - tzid) - parsed)) - (t parsed))) - -(defun calendar-sync--parse-timestamp (timestamp-str &optional tzid) - "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. -Returns nil if parsing fails." - (cond - ;; DateTime format: 20251116T140000Z or 20251116T140000 - ((string-match "\\([0-9]\\{4\\}\\)\\([0-9]\\{2\\}\\)\\([0-9]\\{2\\}\\)T\\([0-9]\\{2\\}\\)\\([0-9]\\{2\\}\\)\\([0-9]\\{2\\}\\)\\(Z\\)?" timestamp-str) - (let* ((year (string-to-number (match-string 1 timestamp-str))) - (month (string-to-number (match-string 2 timestamp-str))) - (day (string-to-number (match-string 3 timestamp-str))) - (hour (string-to-number (match-string 4 timestamp-str))) - (minute (string-to-number (match-string 5 timestamp-str))) - (second (string-to-number (match-string 6 timestamp-str))) - (is-utc (match-string 7 timestamp-str))) - (cond - ;; UTC timestamp (Z suffix) - convert from UTC - (is-utc - (calendar-sync--convert-utc-to-local year month day hour minute second)) - ;; TZID provided - convert from that timezone - (tzid - (or (calendar-sync--convert-tz-to-local year month day hour minute tzid) - ;; Fallback to raw time if conversion fails - (list year month day hour minute))) - ;; No timezone info - assume local time - (t - (list year month day hour minute))))) - ;; Date format: 20251116 - ((string-match "\\([0-9]\\{4\\}\\)\\([0-9]\\{2\\}\\)\\([0-9]\\{2\\}\\)" timestamp-str) - (list (string-to-number (match-string 1 timestamp-str)) - (string-to-number (match-string 2 timestamp-str)) - (string-to-number (match-string 3 timestamp-str)))) - (t nil))) - -(defun calendar-sync--format-timestamp (start end) - "Format START and END timestamps as org timestamp. -START and END are lists from `calendar-sync--parse-timestamp'. -Returns string like '<2025-11-16 Sun 14:00-15:00>' or '<2025-11-16 Sun>'." - (let* ((year (nth 0 start)) - (month (nth 1 start)) - (day (nth 2 start)) - (start-hour (nth 3 start)) - (start-min (nth 4 start)) - (end-hour (and end (nth 3 end))) - (end-min (and end (nth 4 end))) - (date-str (format-time-string - "<%Y-%m-%d %a" - (encode-time 0 0 0 day month year))) - (time-str (when (and start-hour end-hour) - (format " %02d:%02d-%02d:%02d" - start-hour start-min end-hour end-min)))) - (concat date-str time-str ">"))) - -;;; RRULE Parsing and Expansion - -;;; Helper Functions - -(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." - (let ((day (nth 2 date)) - (month (nth 1 date)) - (year (nth 0 date))) - (encode-time 0 0 0 day month year))) - -(defun calendar-sync--before-date-p (date1 date2) - "Return t if DATE1 is before DATE2. -Both dates should be lists like (year month day)." - (time-less-p (calendar-sync--date-to-time date1) - (calendar-sync--date-to-time date2))) - -(defun calendar-sync--create-occurrence (base-event occurrence-date) - "Create an occurrence from BASE-EVENT with OCCURRENCE-DATE. -OCCURRENCE-DATE should be a list (year month day hour minute second)." - (let* ((occurrence (copy-sequence base-event)) - (end (plist-get base-event :end))) - (plist-put occurrence :start occurrence-date) - (when end - ;; Use the date from occurrence-date but keep the time from the original end - (let ((date-only (list (nth 0 occurrence-date) - (nth 1 occurrence-date) - (nth 2 occurrence-date)))) - (plist-put occurrence :end (append date-only (nthcdr 3 end))))) - occurrence)) - -(defun calendar-sync--parse-rrule (rrule-str) - "Parse RRULE string into plist. -Returns plist with :freq :interval :byday :until :count." - (let ((parts (split-string rrule-str ";")) - (result '())) - (dolist (part parts) - (when (string-match "\\([^=]+\\)=\\(.+\\)" part) - (let ((key (match-string 1 part)) - (value (match-string 2 part))) - (pcase key - ("FREQ" (setq result (plist-put result :freq (intern (downcase value))))) - ("INTERVAL" (setq result (plist-put result :interval (string-to-number value)))) - ("BYDAY" (setq result (plist-put result :byday (split-string value ",")))) - ("UNTIL" (setq result (plist-put result :until (calendar-sync--parse-timestamp value)))) - ("COUNT" (setq result (plist-put result :count (string-to-number value)))))))) - ;; Set defaults - (unless (plist-get result :interval) - (setq result (plist-put result :interval 1))) - result)) - -(defun calendar-sync--expand-simple-recurrence (base-event rrule range advance-fn) - "Expand a simple (non-weekly) recurring event using ADVANCE-FN to step dates. -BASE-EVENT is the event plist, RRULE is parsed rrule, RANGE is date range. -ADVANCE-FN takes (current-date interval) and returns the next date." - (let* ((start (plist-get base-event :start)) - (interval (plist-get rrule :interval)) - (until (plist-get rrule :until)) - (count (plist-get rrule :count)) - (occurrences '()) - (current-date (list (nth 0 start) (nth 1 start) (nth 2 start))) - (num-generated 0) - (range-end-time (cadr range))) - (while (and (or count until (time-less-p (calendar-sync--date-to-time current-date) range-end-time)) - (or (not until) (calendar-sync--before-date-p current-date until)) - (or (not count) (< num-generated count))) - (let ((occurrence-datetime (append current-date (nthcdr 3 start)))) - (setq num-generated (1+ num-generated)) - (when (calendar-sync--date-in-range-p occurrence-datetime range) - (push (calendar-sync--create-occurrence base-event occurrence-datetime) - occurrences))) - (setq current-date (funcall advance-fn current-date interval))) - (nreverse occurrences))) - -(defun calendar-sync--expand-daily (base-event rrule range) - "Expand daily recurring event. -BASE-EVENT is the event plist, RRULE is parsed rrule, RANGE is date range." - (calendar-sync--expand-simple-recurrence - base-event rrule range #'calendar-sync--add-days)) - -(defun calendar-sync--expand-weekly (base-event rrule range) - "Expand weekly recurring event. -BASE-EVENT is the event plist, RRULE is parsed rrule, RANGE is date range." - (let* ((start (plist-get base-event :start)) - (interval (plist-get rrule :interval)) - (byday (plist-get rrule :byday)) - (until (plist-get rrule :until)) - (count (plist-get rrule :count)) - (occurrences '()) - (current-date (list (nth 0 start) (nth 1 start) (nth 2 start))) - (num-generated 0) - (range-end-time (cadr range)) - (max-iterations 1000) ;; Safety: prevent infinite loops - (iterations 0) - (weekdays (if byday - (mapcar #'calendar-sync--weekday-to-number byday) - (list (calendar-sync--date-weekday current-date))))) - ;; Validate interval - (when (<= interval 0) - (error "Invalid RRULE interval: %s (must be > 0)" interval)) - ;; Start from the first week - ;; For infinite recurrence (no COUNT/UNTIL), stop at range-end for performance - ;; For COUNT, generate all occurrences from start regardless of range - (while (and (< iterations max-iterations) - (or count until (time-less-p (calendar-sync--date-to-time current-date) range-end-time)) - (or (not count) (< num-generated count)) - (or (not until) (calendar-sync--before-date-p current-date until))) - (setq iterations (1+ iterations)) - ;; Generate occurrences for each weekday in this week - (dolist (weekday weekdays) - (let* ((current-weekday (calendar-sync--date-weekday current-date)) - (days-ahead (mod (- weekday current-weekday) 7)) - (occurrence-date (calendar-sync--add-days current-date days-ahead)) - (occurrence-datetime (append occurrence-date (nthcdr 3 start)))) - ;; Check UNTIL date first - (when (or (not until) (calendar-sync--before-date-p occurrence-date until)) - ;; Check COUNT - increment BEFORE range check so COUNT is absolute from start - (when (or (not count) (< num-generated count)) - (setq num-generated (1+ num-generated)) - ;; Only add to output if within date range - (when (calendar-sync--date-in-range-p occurrence-datetime range) - (push (calendar-sync--create-occurrence base-event occurrence-datetime) - occurrences)))))) - ;; Move to next interval week - (setq current-date (calendar-sync--add-days current-date (* 7 interval)))) - (when (>= iterations max-iterations) - (calendar-sync--log-silently "calendar-sync: WARNING: Hit max iterations (%d) expanding weekly event" max-iterations)) - (nreverse occurrences))) - -(defun calendar-sync--expand-monthly (base-event rrule range) - "Expand monthly recurring event. -BASE-EVENT is the event plist, RRULE is parsed rrule, RANGE is date range." - (calendar-sync--expand-simple-recurrence - base-event rrule range #'calendar-sync--add-months)) - -(defun calendar-sync--expand-yearly (base-event rrule range) - "Expand yearly recurring event. -BASE-EVENT is the event plist, RRULE is parsed rrule, RANGE is date range." - (calendar-sync--expand-simple-recurrence - base-event rrule range - (lambda (date interval) (calendar-sync--add-months date (* 12 interval))))) - -(defun calendar-sync--expand-recurring-event (event-str range) - "Expand recurring event EVENT-STR into individual occurrences within RANGE. -Returns list of event plists, or nil if not a recurring event. -Filters out dates excluded via EXDATE properties." - (let ((rrule (calendar-sync--get-property event-str "RRULE"))) - (when rrule - (let* ((base-event (calendar-sync--parse-event event-str)) - (parsed-rrule (calendar-sync--parse-rrule rrule)) - (freq (plist-get parsed-rrule :freq)) - (exdates (calendar-sync--collect-exdates event-str))) - (when base-event - (let ((occurrences - (pcase freq - ('daily (calendar-sync--expand-daily base-event parsed-rrule range)) - ('weekly (calendar-sync--expand-weekly base-event parsed-rrule range)) - ('monthly (calendar-sync--expand-monthly base-event parsed-rrule range)) - ('yearly (calendar-sync--expand-yearly base-event parsed-rrule range)) - (_ (calendar-sync--log-silently "calendar-sync: Unsupported RRULE frequency: %s" freq) - nil)))) - ;; Filter out EXDATE occurrences - (if exdates - (calendar-sync--filter-exdates occurrences exdates) - occurrences))))))) - -(defun calendar-sync--parse-event (event-str) - "Parse single VEVENT string EVENT-STR into plist. -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. -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--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")) - ;; Extract TZID from property lines (if present) - (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)) - ;; 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)))) - (when start-parsed - (list :uid uid - :summary summary - :description description - :location location - :start start-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. -Produces property drawer with LOCATION, ORGANIZER, STATUS, URL when present. -Description appears as body text after the drawer." - (let* ((summary (cj/org-sanitize-heading - (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)) - ;; Build property drawer entries - (props '())) - ;; Collect non-nil properties - (when (and location (not (string-empty-p location))) - (push (format ":LOCATION: %s" - (cj/org-sanitize-property-value location)) - props)) - (when organizer - (let ((org-name (or (plist-get organizer :cn) - (plist-get organizer :email)))) - (when org-name - (push (format ":ORGANIZER: %s" - (cj/org-sanitize-property-value org-name)) - props)))) - (when (and status (not (string-empty-p status))) - (push (format ":STATUS: %s" - (cj/org-sanitize-property-value status)) - props)) - (when (and url (not (string-empty-p url))) - (push (format ":URL: %s" - (cj/org-sanitize-property-value url)) - props)) - (setq props (nreverse props)) - ;; Build output - (let ((parts (list timestamp (format "* %s" summary)))) - ;; 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 (sanitized to prevent org heading conflicts) - (when (and description (not (string-empty-p description))) - (push (cj/org-sanitize-body-text description) parts)) - (string-join (nreverse parts) "\n")))) - -(defun calendar-sync--event-start-time (event) - "Extract comparable start time from EVENT plist. -Returns time value suitable for comparison, or 0 if no start time." - (let ((start (plist-get event :start))) - (if start - (apply #'encode-time - 0 ; second - (or (nth 4 start) 0) ; minute - (or (nth 3 start) 0) ; hour - (nth 2 start) ; day - (nth 1 start) ; month - (nth 0 start) ; year - nil) - 0))) +;;; Parsing orchestration (defun calendar-sync--parse-ics (ics-content) "Parse ICS-CONTENT and return org-formatted string. @@ -1209,277 +202,7 @@ RECURRENCE-ID exceptions are applied to override specific occurrences." (calendar-sync--log-silently "calendar-sync: Parse error: %s" (error-message-string err)) nil))) -;;; Sync functions - -(defun calendar-sync--fetch-ics (url callback) - "Fetch .ics file from URL asynchronously using curl. -Calls CALLBACK with the .ics content as string (normalized to Unix line endings) -or nil on error. CALLBACK signature: (lambda (content) ...). - -The fetch happens asynchronously and doesn't block Emacs. The callback is -invoked when the fetch completes, either successfully or with an error." - (condition-case err - (let ((buffer (generate-new-buffer " *calendar-sync-curl*"))) - (make-process - :name "calendar-sync-curl" - :buffer buffer - :command (list "curl" "-s" "-L" "--fail" - "--connect-timeout" "10" - "--max-time" (number-to-string calendar-sync-fetch-timeout) - url) - :sentinel - (lambda (process event) - (when (memq (process-status process) '(exit signal)) - (let ((buf (process-buffer process))) - (when (buffer-live-p buf) - (let ((content - (with-current-buffer buf - (if (and (eq (process-status process) 'exit) - (= (process-exit-status process) 0)) - (calendar-sync--normalize-line-endings (buffer-string)) - (calendar-sync--log-silently "calendar-sync: Fetch error: curl failed: %s" (string-trim event)) - nil)))) - (kill-buffer buf) - (funcall callback content)))))))) - (error - (calendar-sync--log-silently "calendar-sync: Fetch error: %s" (error-message-string err)) - (funcall callback nil)))) - -(defun calendar-sync--fetch-ics-file (url callback) - "Fetch .ics from URL to a temp file asynchronously. -Calls CALLBACK with the temp file path on success, or nil on error. The caller -owns deleting the temp file after a successful callback." - (condition-case err - (let ((buffer (generate-new-buffer " *calendar-sync-curl*")) - (temp-file (make-temp-file "calendar-sync-" nil ".ics"))) - (make-process - :name "calendar-sync-curl" - :buffer buffer - :command (list "curl" "-s" "-L" "--fail" - "--connect-timeout" "10" - "--max-time" (number-to-string calendar-sync-fetch-timeout) - "-o" temp-file - url) - :sentinel - (lambda (process event) - (when (memq (process-status process) '(exit signal)) - (let ((buf (process-buffer process)) - (success (and (eq (process-status process) 'exit) - (= (process-exit-status process) 0)))) - (when (buffer-live-p buf) - (unless success - (calendar-sync--log-silently "calendar-sync: Fetch error: curl failed: %s" - (string-trim event))) - (kill-buffer buf)) - (if success - (funcall callback temp-file) - (when (file-exists-p temp-file) - (delete-file temp-file)) - (funcall callback nil))))))) - (error - (calendar-sync--log-silently "calendar-sync: Fetch error: %s" (error-message-string err)) - (funcall callback nil)))) - -(defun calendar-sync--write-file (content file) - "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)) - (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." - (let ((candidate (expand-file-name invocation-name invocation-directory))) - (if (file-executable-p candidate) - candidate - invocation-name))) - -(defun calendar-sync--batch-convert-file (ics-file output-file past-months future-months user-emails) - "Convert ICS-FILE to Org format and write OUTPUT-FILE. -PAST-MONTHS, FUTURE-MONTHS, and USER-EMAILS mirror the interactive session's -calendar conversion settings. This is intended for noninteractive worker -processes, not direct interactive use." - (setq calendar-sync-past-months past-months - calendar-sync-future-months future-months - calendar-sync-user-emails user-emails) - (let* ((ics-content - (with-temp-buffer - (insert-file-contents ics-file) - (calendar-sync--normalize-line-endings (buffer-string)))) - (org-content (calendar-sync--parse-ics ics-content))) - (unless org-content - (error "calendar-sync: parse failed")) - (calendar-sync--write-file org-content output-file))) - -(defun calendar-sync--worker-command (ics-file output-file) - "Build the batch Emacs command that converts ICS-FILE to OUTPUT-FILE." - (let ((module-dir (file-name-directory calendar-sync--module-file)) - (private-config-file - (make-temp-name (expand-file-name "calendar-sync-worker-config-" - temporary-file-directory))) - (state-file - (make-temp-name (expand-file-name "calendar-sync-worker-state-" - temporary-file-directory)))) - (list (calendar-sync--emacs-binary) - "--batch" - "--no-site-file" - "--no-site-lisp" - "--eval" (format "(setq load-prefer-newer t calendar-sync-auto-start nil calendar-sync-private-config-file %S calendar-sync--state-file %S)" - private-config-file state-file) - "-L" module-dir - "-l" calendar-sync--module-file - "--eval" (format "(calendar-sync--batch-convert-file %S %S %S %S '%S)" - ics-file - output-file - calendar-sync-past-months - calendar-sync-future-months - calendar-sync-user-emails)))) - -(defun calendar-sync--convert-ics-file-async (ics-file output-file callback) - "Convert ICS-FILE to OUTPUT-FILE in a batch Emacs worker. -Calls CALLBACK as (CALLBACK SUCCESS ERROR-MESSAGE). Deletes ICS-FILE after the -worker exits." - (condition-case err - (let ((buffer (generate-new-buffer " *calendar-sync-worker*"))) - (make-process - :name "calendar-sync-worker" - :buffer buffer - :command (calendar-sync--worker-command ics-file output-file) - :sentinel - (lambda (process _event) - (when (memq (process-status process) '(exit signal)) - (let* ((buf (process-buffer process)) - (success (and (eq (process-status process) 'exit) - (= (process-exit-status process) 0))) - (error-message - (when (buffer-live-p buf) - (with-current-buffer buf - (string-trim (buffer-string)))))) - (when (file-exists-p ics-file) - (delete-file ics-file)) - (when (buffer-live-p buf) - (kill-buffer buf)) - (funcall callback success error-message)))))) - (error - (when (file-exists-p ics-file) - (delete-file ics-file)) - (funcall callback nil (error-message-string err))))) - -(defun calendar-sync--mark-sync-failed (name reason) - "Record failed sync state for calendar NAME with REASON." - (calendar-sync--set-calendar-state - name - (list :status 'error - :last-sync (plist-get (calendar-sync--get-calendar-state name) :last-sync) - :last-error reason)) - (calendar-sync--save-state) - (message "calendar-sync: [%s] Sync failed (see *Messages*)" name)) - -;;; Debug Logging - -(defun calendar-sync--load-private-config () - "Load private calendar-sync configuration when available." - (when (file-readable-p calendar-sync-private-config-file) - (condition-case err - (load calendar-sync-private-config-file nil t) - (error - (message "calendar-sync: Failed to load private config %s: %s" - (abbreviate-file-name calendar-sync-private-config-file) - (error-message-string err)))))) - -(defun calendar-sync--debug-p () - "Return non-nil if calendar-sync debug logging is enabled. -Checks `cj/debug-modules' for symbol `calendar-sync' or t (all)." - (and (boundp 'cj/debug-modules) - (or (eq cj/debug-modules t) - (memq 'calendar-sync cj/debug-modules)))) - -;;; Google Calendar API Fetch Path - -(defun calendar-sync--api-script () - "Return the absolute path to the Google Calendar API helper script. -Resolved relative to this module so batch workers and tests don't depend -on `user-emacs-directory'." - (let ((module-dir (file-name-directory calendar-sync--module-file))) - (expand-file-name "calendar_sync_api.py" - (expand-file-name "scripts" - (file-name-parent-directory module-dir))))) - -(defun calendar-sync--api-command (account calendar-id output-file) - "Build the command list that runs the API helper. -ACCOUNT and CALENDAR-ID select the OAuth account and calendar; OUTPUT-FILE -is where the helper writes rendered org content. The past/future window -mirrors the .ics path's `calendar-sync-past-months' / -`calendar-sync-future-months'. When `calendar-sync-skip-declined' is nil, -passes --keep-declined so the API path honors the same toggle." - (append - (list calendar-sync-python-command - (calendar-sync--api-script) - "--account" account - "--calendar-id" calendar-id - "--output" output-file - "--past-months" (number-to-string calendar-sync-past-months) - "--future-months" (number-to-string calendar-sync-future-months)) - (unless calendar-sync-skip-declined - (list "--keep-declined")))) - -(defun calendar-sync--sync-calendar-api (calendar) - "Sync a single Google CALENDAR via the API helper script. -CALENDAR is a plist with :name, :account, :calendar-id, and :file keys. -The helper fetches, filters, and renders org in one pass and writes :file -directly, so it runs in a single external process off the interactive thread." - (let* ((name (plist-get calendar :name)) - (account (plist-get calendar :account)) - (calendar-id (plist-get calendar :calendar-id)) - (file (plist-get calendar :file)) - (fetch-start (float-time))) - (calendar-sync--set-calendar-state name '(:status syncing)) - (calendar-sync--log-silently "calendar-sync: [%s] Syncing (API)..." name) - (condition-case err - (let ((buffer (generate-new-buffer " *calendar-sync-api*"))) - (make-process - :name "calendar-sync-api" - :buffer buffer - :command (calendar-sync--api-command account calendar-id file) - :sentinel - (lambda (process _event) - (when (memq (process-status process) '(exit signal)) - (let* ((buf (process-buffer process)) - (success (and (eq (process-status process) 'exit) - (= (process-exit-status process) 0))) - (output (when (buffer-live-p buf) - (with-current-buffer buf - (string-trim (buffer-string)))))) - (when (buffer-live-p buf) - (kill-buffer buf)) - (if (not success) - (calendar-sync--mark-sync-failed - name (if (or (null output) (string-empty-p output)) - "API helper failed" - output)) - (calendar-sync--set-calendar-state - name - (list :status 'ok - :last-sync (current-time) - :last-error nil)) - (setq calendar-sync--last-timezone-offset - (calendar-sync--current-timezone-offset)) - (calendar-sync--save-state) - (let ((total-elapsed (- (float-time) fetch-start))) - (message "calendar-sync: [%s] Sync complete (%.1fs total) → %s" - name total-elapsed file)))))))) - (error - (calendar-sync--log-silently "calendar-sync: [%s] API helper error: %s" - name (error-message-string err)) - (calendar-sync--mark-sync-failed name (error-message-string err)))))) - -;;; Single Calendar Sync +;;; Sync dispatch (defun calendar-sync--sync-calendar (calendar) "Sync a single CALENDAR asynchronously. @@ -1493,63 +216,6 @@ calendar files do not block the interactive Emacs thread." (calendar-sync--sync-calendar-api calendar) (calendar-sync--sync-calendar-ics calendar))) -(defun calendar-sync--calendar-url (calendar) - "Return the .ics feed URL for CALENDAR, or nil if none is configured. -An explicit :url wins. Otherwise :secret-host names an auth-source host -whose stored secret is the URL (kept in auth-source because the .ics URL -is itself a token)." - (or (plist-get calendar :url) - (when-let* ((host (plist-get calendar :secret-host))) - (cj/auth-source-secret-value host)))) - -(defun calendar-sync--sync-calendar-ics (calendar) - "Sync a single CALENDAR from its .ics feed asynchronously. -CALENDAR is a plist with :name, :file, and a feed URL resolved by -`calendar-sync--calendar-url' (an explicit :url, or a :secret-host -looked up in auth-source)." - (let ((name (plist-get calendar :name)) - (url (calendar-sync--calendar-url calendar)) - (file (plist-get calendar :file)) - (fetch-start (float-time))) - (calendar-sync--set-calendar-state name '(:status syncing)) - (calendar-sync--log-silently "calendar-sync: [%s] Syncing..." name) - (calendar-sync--fetch-ics-file - url - (lambda (ics-file) - (let ((fetch-elapsed (- (float-time) fetch-start))) - (if (null ics-file) - (progn - (calendar-sync--log-silently "calendar-sync: [%s] Fetch failed" name) - (calendar-sync--mark-sync-failed name "Fetch failed")) - (when (calendar-sync--debug-p) - (calendar-sync--log-silently "calendar-sync: [%s] Fetched in %.1fs" - name fetch-elapsed)) - (calendar-sync--convert-ics-file-async - ics-file - file - (lambda (success error-message) - (if (not success) - (progn - (calendar-sync--log-silently "calendar-sync: [%s] Conversion failed: %s" - name error-message) - (calendar-sync--mark-sync-failed - name - (if (or (null error-message) - (string-empty-p error-message)) - "Conversion failed" - error-message))) - (calendar-sync--set-calendar-state - name - (list :status 'ok - :last-sync (current-time) - :last-error nil)) - (setq calendar-sync--last-timezone-offset - (calendar-sync--current-timezone-offset)) - (calendar-sync--save-state) - (let ((total-elapsed (- (float-time) fetch-start))) - (message "calendar-sync: [%s] Sync complete (%.1fs total) → %s" - name total-elapsed file))))))))))) - (defun calendar-sync--require-calendars () "Return non-nil if calendars are configured, else warn and return nil." (or calendar-sync-calendars @@ -1573,6 +239,8 @@ Each calendar syncs in parallel." (cl-find-if (lambda (cal) (string= (plist-get cal :name) name)) calendar-sync-calendars)) +;;; Commands + ;;;###autoload (defun calendar-sync-now (&optional calendar-name) "Sync calendar(s) now asynchronously. @@ -1720,5 +388,6 @@ Syncs all calendars immediately, then every `calendar-sync-interval-minutes'." (not noninteractive)) (calendar-sync-start)) + (provide 'calendar-sync) ;;; calendar-sync.el ends here diff --git a/tests/test-init-module-headers.el b/tests/test-init-module-headers.el index c01f19368..97e6aeb13 100644 --- a/tests/test-init-module-headers.el +++ b/tests/test-init-module-headers.el @@ -99,6 +99,10 @@ "ai-term" "browser-config" "calendar-sync" + "calendar-sync-ics" + "calendar-sync-recurrence" + "calendar-sync-org" + "calendar-sync-source" "calibredb-epub-config" "chrono-tools" "dirvish-config" |
