aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-06-29 05:23:56 -0400
committerCraig Jennings <c@cjennings.net>2026-06-29 05:23:56 -0400
commit54250d958f2829ff0f44a223a1481b6ec55a6d91 (patch)
treea3e86f5aebc39e934fee12b75e81fc56b016e5da
parent3ca9a9f17daa3d6258aa9f32a1a56a9f9e19642c (diff)
downloaddotemacs-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.el582
-rw-r--r--modules/calendar-sync-org.el94
-rw-r--r--modules/calendar-sync-recurrence.el405
-rw-r--r--modules/calendar-sync-source.el426
-rw-r--r--modules/calendar-sync.el1367
-rw-r--r--tests/test-init-module-headers.el4
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 &amp; &lt; &gt; &quot;. Collapses excessive blank lines.
+Returns nil for nil input."
+ (when text
+ (let ((result text))
+ ;; Convert <br> variants to newline (must come before tag stripping)
+ (setq result (replace-regexp-in-string "<br[ \t]*/?>[ \t]*" "\n" result))
+ ;; Strip all remaining HTML tags
+ (setq result (replace-regexp-in-string "<[^>]*>" "" result))
+ ;; Decode HTML entities
+ (setq result (replace-regexp-in-string "&amp;" "&" result))
+ (setq result (replace-regexp-in-string "&lt;" "<" result))
+ (setq result (replace-regexp-in-string "&gt;" ">" result))
+ (setq result (replace-regexp-in-string "&quot;" "\"" result))
+ ;; Collapse 3+ consecutive newlines to 2
+ (setq result (replace-regexp-in-string "\n\\{3,\\}" "\n\n" result))
+ result)))
+
+(defun calendar-sync--clean-text (text)
+ "Clean TEXT by unescaping ICS sequences, stripping HTML, and trimming.
+Returns nil for nil input. Returns empty string for whitespace-only input."
+ (when text
+ (string-trim (calendar-sync--strip-html (calendar-sync--unescape-ics-text text)))))
+
+;;; Date Utilities
+
+(defun calendar-sync--add-months (date months)
+ "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 &amp; &lt; &gt; &quot;. Collapses excessive blank lines.
-Returns nil for nil input."
- (when text
- (let ((result text))
- ;; Convert <br> variants to newline (must come before tag stripping)
- (setq result (replace-regexp-in-string "<br[ \t]*/?>[ \t]*" "\n" result))
- ;; Strip all remaining HTML tags
- (setq result (replace-regexp-in-string "<[^>]*>" "" result))
- ;; Decode HTML entities
- (setq result (replace-regexp-in-string "&amp;" "&" result))
- (setq result (replace-regexp-in-string "&lt;" "<" result))
- (setq result (replace-regexp-in-string "&gt;" ">" result))
- (setq result (replace-regexp-in-string "&quot;" "\"" result))
- ;; Collapse 3+ consecutive newlines to 2
- (setq result (replace-regexp-in-string "\n\\{3,\\}" "\n\n" result))
- result)))
-
-(defun calendar-sync--clean-text (text)
- "Clean TEXT by unescaping ICS sequences, stripping HTML, and trimming.
-Returns nil for nil input. Returns empty string for whitespace-only input."
- (when text
- (string-trim (calendar-sync--strip-html (calendar-sync--unescape-ics-text text)))))
-
-;;; Date Utilities
-
-(defun calendar-sync--add-months (date months)
- "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"