;;; calendar-sync.el --- Multi-calendar sync via .ics -*- lexical-binding: t; -*- ;; Author: Craig Jennings ;; Created: 2025-11-16 ;;; Commentary: ;; Simple, reliable one-way sync from multiple calendars to Org mode. ;; Downloads .ics files from calendar URLs (Google, Proton, etc.) and ;; converts to Org format. No OAuth, no API complexity, just file conversion. ;; ;; Features: ;; - Multi-calendar support (sync multiple calendars to separate files) ;; - Pure Emacs Lisp .ics parser (no external dependencies) ;; - Recurring event support (RRULE expansion) ;; - Timer-based automatic sync (every 60 minutes, configurable) ;; - Self-contained in .emacs.d (no cron, portable across machines) ;; - Read-only (can't corrupt source calendars) ;; - Works with chime.el for event notifications ;; ;; Recurring Events (RRULE): ;; ;; Calendar recurring events are defined once with an RRULE ;; (recurrence rule) rather than as individual event instances. This ;; module expands recurring events into individual org entries. ;; ;; Expansion uses a rolling window approach: ;; - Past: 3 months before today ;; - Future: 12 months after today ;; ;; Every sync regenerates the entire file based on the current date, ;; so the window automatically advances as time passes. Old events ;; naturally fall off after 3 months, and new future events appear ;; as you approach them. ;; ;; Supported RRULE patterns: ;; - FREQ=DAILY: Daily events ;; - FREQ=WEEKLY;BYDAY=MO,WE,FR: Weekly on specific days ;; - FREQ=MONTHLY: Monthly events (same day each month) ;; - FREQ=YEARLY: Yearly events (anniversaries, birthdays) ;; - INTERVAL: Repeat every N periods (e.g., every 2 weeks) ;; - UNTIL: End date for recurrence ;; - COUNT: Maximum occurrences (combined with date range limit) ;; ;; Setup: ;; 1. Configure calendars in your init.el: ;; (setq calendar-sync-calendars ;; '((:name "google" ;; :url "https://calendar.google.com/calendar/ical/.../basic.ics" ;; :file gcal-file) ;; (:name "proton" ;; :url "https://calendar.proton.me/api/calendar/v1/url/.../calendar.ics" ;; :file pcal-file))) ;; ;; 2. Load and start: ;; (require 'calendar-sync) ;; (calendar-sync-start) ;; ;; 3. Add to org-agenda (optional): ;; (dolist (cal calendar-sync-calendars) ;; (add-to-list 'org-agenda-files (plist-get cal :file))) ;; ;; Usage: ;; - M-x calendar-sync-now ; Sync all or select specific calendar ;; - M-x calendar-sync-start ; Start auto-sync ;; - M-x calendar-sync-stop ; Stop auto-sync ;; - M-x calendar-sync-toggle ; Toggle auto-sync ;; - M-x calendar-sync-status ; Show sync status for all calendars ;;; Code: (require 'org) (require 'user-constants) ; For gcal-file, pcal-file paths ;;; Configuration (defvar calendar-sync-calendars nil "List of calendars to sync. Each calendar is a plist with the following keys: :name - Display name for the calendar (used in logs and prompts) :url - URL to fetch .ics file from :file - Output file path for org format Example: (setq calendar-sync-calendars \\='((:name \"google\" :url \"https://calendar.google.com/calendar/ical/.../basic.ics\" :file gcal-file) (:name \"proton\" :url \"https://calendar.proton.me/api/calendar/v1/url/.../calendar.ics\" :file pcal-file)))") (defvar calendar-sync-interval-minutes 60 "Sync interval in minutes. Default: 60 minutes (1 hour).") (defvar calendar-sync-auto-start t "Whether to automatically start calendar sync when module loads. If non-nil, sync starts automatically when calendar-sync is loaded. If nil, user must manually call `calendar-sync-start'.") (defvar calendar-sync-past-months 3 "Number of months in the past to include when expanding recurring events. Default: 3 months. This keeps recent history visible in org-agenda.") (defvar calendar-sync-future-months 12 "Number of months in the future to include when expanding recurring events. Default: 12 months. This provides a full year of future events.") ;;; Internal state (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 "data/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--timezone-name () "Get human-readable timezone name. Returns string like 'CST' or 'PST'." (cadr (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)) (with-temp-file calendar-sync--state-file (prin1 state (current-buffer))))) (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 (cj/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))) ;;; 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)." (let* ((year (nth 0 date)) (month (nth 1 date)) (day (nth 2 date)) (time (encode-time 0 0 0 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)))) ;;; .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 '()) (start 0)) (while (string-match "BEGIN:VEVENT\\(.\\|\n\\)*?END:VEVENT" ics-content start) (push (match-string 0 ics-content) events) (setq start (match-end 0))) (nreverse events))) (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) (let ((value (match-string 1 event)) (start (match-end 0))) ;; Handle continuation lines (start with space or tab) (while (and (< start (length event)) (string-match "^\n[ \t]\\(.*\\)$" event start)) (setq value (concat value (match-string 1 event))) (setq start (match-end 0))) value))) (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--parse-timestamp (timestamp-str) "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. 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))) (if is-utc (calendar-sync--convert-utc-to-local year month day hour minute second) (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 (year month day) to time value for comparison. DATE should be a list like (year month day)." (apply #'encode-time 0 0 0 (reverse date))) (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-daily (base-event rrule range) "Expand daily 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)) (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))) ;; For infinite recurrence (no COUNT/UNTIL), stop at range-end for performance ;; For COUNT, generate all occurrences from start regardless of 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)))) ;; Check UNTIL date first (when (or (not until) (calendar-sync--before-date-p current-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))))) (setq current-date (calendar-sync--add-days current-date interval))) (nreverse occurrences))) (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)) (end (plist-get base-event :end)) (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) (cj/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." (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))) ;; For infinite recurrence (no COUNT/UNTIL), stop at range-end for performance ;; For COUNT, generate all occurrences from start regardless of 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)))) ;; Check UNTIL date first (when (or (not until) (calendar-sync--before-date-p current-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))))) (setq current-date (calendar-sync--add-months current-date interval))) (nreverse occurrences))) (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." (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))) ;; For infinite recurrence (no COUNT/UNTIL), stop at range-end for performance ;; For COUNT, generate all occurrences from start regardless of 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)))) ;; Check UNTIL date first (when (or (not until) (calendar-sync--before-date-p current-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))))) (setq current-date (calendar-sync--add-months current-date (* 12 interval)))) (nreverse occurrences))) (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." (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))) (when base-event (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)) (_ (cj/log-silently "calendar-sync: Unsupported RRULE frequency: %s" freq) nil))))))) (defun calendar-sync--parse-event (event-str) "Parse single VEVENT string EVENT-STR into plist. Returns plist with :summary :description :location :start :end. Returns nil if event lacks required fields (DTSTART, SUMMARY). Skips events with RECURRENCE-ID (individual instances of recurring events)." ;; Skip individual instances of recurring events (they're handled by RRULE expansion) (unless (calendar-sync--get-property event-str "RECURRENCE-ID") (let ((summary (calendar-sync--get-property event-str "SUMMARY")) (description (calendar-sync--get-property event-str "DESCRIPTION")) (location (calendar-sync--get-property event-str "LOCATION")) (dtstart (calendar-sync--get-property event-str "DTSTART")) (dtend (calendar-sync--get-property event-str "DTEND"))) (when (and summary dtstart) (let ((start-parsed (calendar-sync--parse-timestamp dtstart)) (end-parsed (and dtend (calendar-sync--parse-timestamp dtend)))) (when start-parsed (list :summary summary :description description :location location :start start-parsed :end end-parsed))))))) (defun calendar-sync--event-to-org (event) "Convert parsed EVENT plist to org entry string." (let* ((summary (plist-get event :summary)) (description (plist-get event :description)) (location (plist-get event :location)) (start (plist-get event :start)) (end (plist-get event :end)) (timestamp (calendar-sync--format-timestamp start end)) (parts (list (format "* %s" summary)))) (push timestamp parts) (when description (push description parts)) (when location (push (format "Location: %s" location) parts)) (string-join (nreverse parts) "\n"))) (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))) (defun calendar-sync--parse-ics (ics-content) "Parse ICS-CONTENT and return org-formatted string. Returns nil if parsing fails. Events are sorted chronologically by start time. Recurring events are expanded into individual occurrences." (condition-case err (let* ((range (calendar-sync--get-date-range)) (events (calendar-sync--split-events ics-content)) (parsed-events '()) (max-events 5000) ; Safety limit to prevent Emacs from hanging (events-generated 0)) ;; Process each event (dolist (event-str events) (when (< events-generated max-events) (let ((expanded (calendar-sync--expand-recurring-event event-str range))) (if expanded ;; Recurring event - add all occurrences (progn (setq parsed-events (append parsed-events expanded)) (setq events-generated (+ events-generated (length expanded)))) ;; Non-recurring event - parse normally (let ((parsed (calendar-sync--parse-event event-str))) (when (and parsed (calendar-sync--date-in-range-p (plist-get parsed :start) range)) (push parsed parsed-events) (setq events-generated (1+ events-generated)))))))) (when (>= events-generated max-events) (cj/log-silently "calendar-sync: WARNING: Hit max events limit (%d), some events may be missing" max-events)) (cj/log-silently "calendar-sync: Processing %d events..." (length parsed-events)) ;; Sort and convert to org format (let* ((sorted-events (sort parsed-events (lambda (a b) (time-less-p (calendar-sync--event-start-time a) (calendar-sync--event-start-time b))))) (org-entries (mapcar #'calendar-sync--event-to-org sorted-events))) (if org-entries (concat "# Calendar Events\n\n" (string-join org-entries "\n\n") "\n") nil))) (error (setq calendar-sync--last-error (error-message-string err)) (cj/log-silently "calendar-sync: Parse error: %s" calendar-sync--last-error) 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" "-m" "30" 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)) (setq calendar-sync--last-error (format "curl failed: %s" (string-trim event))) (cj/log-silently "calendar-sync: Fetch error: %s" calendar-sync--last-error) nil)))) (kill-buffer buf) (funcall callback content)))))))) (error (setq calendar-sync--last-error (error-message-string err)) (cj/log-silently "calendar-sync: Fetch error: %s" calendar-sync--last-error) (funcall callback nil)))) (defun calendar-sync--write-file (content file) "Write CONTENT to FILE. Creates parent directories if needed." (let ((dir (file-name-directory file))) (unless (file-directory-p dir) (make-directory dir t))) (with-temp-file file (insert content))) ;;; Single Calendar Sync (defun calendar-sync--sync-calendar (calendar) "Sync a single CALENDAR asynchronously. CALENDAR is a plist with :name, :url, and :file keys. Updates calendar state and saves to disk on completion." (let ((name (plist-get calendar :name)) (url (plist-get calendar :url)) (file (plist-get calendar :file))) ;; Mark as syncing (calendar-sync--set-calendar-state name '(:status syncing)) (cj/log-silently "calendar-sync: [%s] Syncing..." name) (calendar-sync--fetch-ics url (lambda (ics-content) (let ((org-content (and ics-content (calendar-sync--parse-ics ics-content)))) (if org-content (progn (calendar-sync--write-file org-content file) (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) (message "calendar-sync: [%s] Sync complete → %s" name file)) (calendar-sync--set-calendar-state name (list :status 'error :last-sync (plist-get (calendar-sync--get-calendar-state name) :last-sync) :last-error "Parse failed")) (calendar-sync--save-state) (message "calendar-sync: [%s] Sync failed (see *Messages*)" name))))))) (defun calendar-sync--sync-all-calendars () "Sync all configured calendars asynchronously. Each calendar syncs in parallel." (if (null calendar-sync-calendars) (message "calendar-sync: No calendars configured (set calendar-sync-calendars)") (message "calendar-sync: Syncing %d calendar(s)..." (length calendar-sync-calendars)) (dolist (calendar calendar-sync-calendars) (calendar-sync--sync-calendar calendar)))) (defun calendar-sync--calendar-names () "Return list of configured calendar names." (mapcar (lambda (cal) (plist-get cal :name)) calendar-sync-calendars)) (defun calendar-sync--get-calendar-by-name (name) "Find calendar plist by NAME, or nil if not found." (cl-find-if (lambda (cal) (string= (plist-get cal :name) name)) calendar-sync-calendars)) ;;;###autoload (defun calendar-sync-now (&optional calendar-name) "Sync calendar(s) now asynchronously. When called interactively, prompts to select a specific calendar or all. When called non-interactively with CALENDAR-NAME, syncs that calendar. When called non-interactively with nil, syncs all calendars." (interactive (list (when calendar-sync-calendars (let ((choices (cons "all" (calendar-sync--calendar-names)))) (completing-read "Sync calendar: " choices nil t nil nil "all"))))) (cond ((null calendar-sync-calendars) (message "calendar-sync: No calendars configured (set calendar-sync-calendars)")) ((or (null calendar-name) (string= calendar-name "all")) (calendar-sync--sync-all-calendars)) (t (let ((calendar (calendar-sync--get-calendar-by-name calendar-name))) (if calendar (calendar-sync--sync-calendar calendar) (message "calendar-sync: Calendar '%s' not found" calendar-name)))))) ;;;###autoload (defun calendar-sync-status () "Display sync status for all configured calendars." (interactive) (if (null calendar-sync-calendars) (message "calendar-sync: No calendars configured") (let ((status-lines '())) (dolist (calendar calendar-sync-calendars) (let* ((name (plist-get calendar :name)) (file (plist-get calendar :file)) (state (calendar-sync--get-calendar-state name)) (status (or (plist-get state :status) 'never)) (last-sync (plist-get state :last-sync)) (last-error (plist-get state :last-error)) (status-str (pcase status ('ok (format "✓ %s" (if last-sync (format-time-string "%Y-%m-%d %H:%M" last-sync) "unknown"))) ('error (format "✗ %s" (or last-error "error"))) ('syncing "⟳ syncing...") ('never "— never synced")))) (push (format " %s: %s → %s" name status-str (abbreviate-file-name file)) status-lines))) (message "calendar-sync status:\n%s" (string-join (nreverse status-lines) "\n"))))) ;;; Timer management (defun calendar-sync--sync-timer-function () "Function called by sync timer. Checks for timezone changes and triggers re-sync if detected." (when (calendar-sync--timezone-changed-p) (let ((old-tz (calendar-sync--format-timezone-offset calendar-sync--last-timezone-offset)) (new-tz (calendar-sync--format-timezone-offset (calendar-sync--current-timezone-offset)))) (message "calendar-sync: Timezone change detected (%s → %s), re-syncing..." old-tz new-tz))) (calendar-sync--sync-all-calendars)) ;;;###autoload (defun calendar-sync-start () "Start automatic calendar syncing. Syncs all calendars immediately, then every `calendar-sync-interval-minutes'." (interactive) (when calendar-sync--timer (cancel-timer calendar-sync--timer)) (if (null calendar-sync-calendars) (message "calendar-sync: No calendars configured (set calendar-sync-calendars)") ;; Sync immediately (calendar-sync--sync-all-calendars) ;; Start timer for future syncs (convert minutes to seconds) (let ((interval-seconds (* calendar-sync-interval-minutes 60))) (setq calendar-sync--timer (run-at-time interval-seconds interval-seconds #'calendar-sync--sync-timer-function))) (message "calendar-sync: Auto-sync started (every %d minutes, %d calendars)" calendar-sync-interval-minutes (length calendar-sync-calendars)))) ;;;###autoload (defun calendar-sync-stop () "Stop automatic calendar syncing." (interactive) (when calendar-sync--timer (cancel-timer calendar-sync--timer) (setq calendar-sync--timer nil) (message "calendar-sync: Auto-sync stopped"))) ;;;###autoload (defun calendar-sync-toggle () "Toggle automatic calendar syncing on/off." (interactive) (if calendar-sync--timer (calendar-sync-stop) (calendar-sync-start))) ;;; Keybindings ;; Calendar sync prefix and keymap (defvar-keymap cj/calendar-map :doc "Keymap for calendar synchronization operations" "s" #'calendar-sync-now "i" #'calendar-sync-status "t" #'calendar-sync-toggle "S" #'calendar-sync-start "x" #'calendar-sync-stop) ;; Only set up keybindings if cj/custom-keymap exists (not in test environment) (when (boundp 'cj/custom-keymap) (keymap-set cj/custom-keymap "g" cj/calendar-map) (with-eval-after-load 'which-key (which-key-add-key-based-replacements "C-; g" "calendar sync menu" "C-; g s" "sync now" "C-; g i" "sync status" "C-; g t" "toggle auto-sync" "C-; g S" "start auto-sync" "C-; g x" "stop auto-sync"))) ;;; Initialization ;; Load saved state from previous session (calendar-sync--load-state) ;; Check for timezone change on startup (when (and calendar-sync-calendars (calendar-sync--timezone-changed-p)) (let ((old-tz (calendar-sync--format-timezone-offset calendar-sync--last-timezone-offset)) (new-tz (calendar-sync--format-timezone-offset (calendar-sync--current-timezone-offset)))) (message "calendar-sync: Timezone changed since last session (%s → %s)" old-tz new-tz) (message "calendar-sync: Will sync on next timer tick") ;; Note: We don't auto-sync here to avoid blocking Emacs startup ;; User can manually sync or it will happen on next timer tick if auto-sync is enabled )) ;; Start auto-sync if enabled and calendars are configured ;; Syncs immediately then every calendar-sync-interval-minutes (default: 60 minutes) (when (and calendar-sync-auto-start calendar-sync-calendars) (calendar-sync-start)) (provide 'calendar-sync) ;;; calendar-sync.el ends here