diff options
| author | Craig Jennings <c@cjennings.net> | 2025-11-16 18:09:17 -0600 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2025-11-16 18:09:17 -0600 |
| commit | da0bd6883a4032054aef4b59c338f60796a0fd99 (patch) | |
| tree | e95369646441b35058c89dfb9c31bad9410243fa /modules | |
| parent | 6280a13c87412a6ff50bbaa43e821c518bd2bd0e (diff) | |
feat(calendar-sync): Add automatic timezone detection and chronological sorting
Implemented calendar-sync.el as a complete replacement for org-gcal, featuring:
**Core Functionality:**
- One-way sync from Google Calendar to Org (via .ics URL)
- UTC to local timezone conversion for all event timestamps
- Chronological event sorting (past → present → future)
- Non-blocking sync using curl (works reliably in daemon mode)
**Automatic Timezone Detection:**
- Detects timezone changes when traveling between timezones
- Tracks timezone offset in seconds (-21600 for CST, -28800 for PST, etc.)
- Triggers automatic re-sync when timezone changes detected
- Shows informative messages: "Timezone change detected (UTC-6 → UTC-8)"
**State Persistence:**
- Saves sync state to ~/.emacs.d/data/calendar-sync-state.el
- Persists timezone and last sync time across Emacs sessions
- Enables detection even after closing Emacs before traveling
**User Features:**
- Interactive commands: calendar-sync-now, calendar-sync-start/stop
- Keybindings: C-; g s (sync), C-; g a (start auto-sync), C-; g x (stop)
- Optional auto-sync every 15 minutes (disabled by default)
- Clear status messages for all operations
**Code Quality:**
- Comprehensive test coverage: 51 ERT tests (100% passing)
- Refactored UTC conversion into separate function
- Clean separation of concerns (parsing, conversion, formatting, sorting)
- Well-documented with timezone behavior guide and changelog
**Migration:**
- Removed org-gcal-config.el (archived in modules/archived/)
- Updated init.el to use calendar-sync
- Moved gcal.org to .emacs.d/data/ for machine-independent syncing
- Removed org-gcal appointment capture template
Files modified: modules/calendar-sync.el:442, tests/test-calendar-sync.el:577
Files created: data/calendar-sync-state.el, tests/testutil-calendar-sync.el
Documentation: docs/calendar-sync-timezones.md, docs/calendar-sync-changelog.md
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
Diffstat (limited to 'modules')
| -rw-r--r-- | modules/archived/org-gcal-config.el (renamed from modules/org-gcal-config.el) | 26 | ||||
| -rw-r--r-- | modules/calendar-sync.el | 438 | ||||
| -rw-r--r-- | modules/host-environment.el | 2 | ||||
| -rw-r--r-- | modules/org-capture-config.el | 4 | ||||
| -rw-r--r-- | modules/org-roam-config.el | 2 | ||||
| -rw-r--r-- | modules/user-constants.el | 5 |
6 files changed, 456 insertions, 21 deletions
diff --git a/modules/org-gcal-config.el b/modules/archived/org-gcal-config.el index 4eca5e7e..9f43c1c8 100644 --- a/modules/org-gcal-config.el +++ b/modules/archived/org-gcal-config.el @@ -137,19 +137,7 @@ Useful after changing `cj/org-gcal-sync-interval-minutes'." :defer t ;; unless idle timer is set below :init - ;; Retrieve credentials from authinfo.gpg BEFORE package loads - ;; This is critical - org-gcal checks these variables at load time - (require 'auth-source) - (let ((credentials (car (auth-source-search :host "org-gcal" :require '(:user :secret))))) - (when credentials - (setq org-gcal-client-id (plist-get credentials :user)) - ;; The secret might be a function, so we need to handle that - (let ((secret (plist-get credentials :secret))) - (setq org-gcal-client-secret - (if (functionp secret) - (funcall secret) - secret))))) - + ;; Configure org-gcal settings (no authinfo.gpg decryption here - deferred to :config) ;; identify calendar to sync and it's destination (setq org-gcal-fetch-file-alist `(("craigmartinjennings@gmail.com" . ,gcal-file))) @@ -165,6 +153,18 @@ Useful after changing `cj/org-gcal-sync-interval-minutes'." (setq org-gcal-managed-update-existing-mode "gcal") ;; GCal wins on conflicts :config + ;; Retrieve credentials from authinfo.gpg when org-gcal is first loaded + ;; This happens on first use (e.g., C-; g s), not during daemon startup + (require 'auth-source) + (let ((credentials (car (auth-source-search :host "org-gcal" :require '(:user :secret))))) + (when credentials + (setq org-gcal-client-id (plist-get credentials :user)) + ;; The secret might be a function, so we need to handle that + (let ((secret (plist-get credentials :secret))) + (setq org-gcal-client-secret + (if (functionp secret) + (funcall secret) + secret))))) ;; Plstore caching is now configured globally in auth-config.el ;; to ensure it loads before org-gcal needs it diff --git a/modules/calendar-sync.el b/modules/calendar-sync.el new file mode 100644 index 00000000..8450b282 --- /dev/null +++ b/modules/calendar-sync.el @@ -0,0 +1,438 @@ +;;; calendar-sync.el --- Simple Google Calendar sync via .ics -*- lexical-binding: t; -*- + +;; Author: Craig Jennings <c@cjennings.net> +;; Created: 2025-11-16 + +;;; Commentary: + +;; Simple, reliable one-way sync from Google Calendar to Org mode. +;; Downloads .ics file from Google Calendar private URL and converts +;; to Org format. No OAuth, no API complexity, just file conversion. +;; +;; Features: +;; - Pure Emacs Lisp .ics parser (no external dependencies) +;; - Timer-based automatic sync (every 15 minutes, configurable) +;; - Self-contained in .emacs.d (no cron, portable across machines) +;; - Read-only (can't corrupt Google Calendar) +;; - Works with chime.el for event notifications +;; +;; Setup: +;; 1. Get your Google Calendar private .ics URL: +;; - Open Google Calendar → Settings → Your Calendar → Integrate calendar +;; - Copy the "Secret address in iCal format" URL +;; +;; 2. Configure in your init.el: +;; (setq calendar-sync-ics-url "https://calendar.google.com/calendar/ical/YOUR_PRIVATE_URL/basic.ics") +;; (require 'calendar-sync) +;; (calendar-sync-start) +;; +;; 3. Add to org-agenda (optional): +;; (add-to-list 'org-agenda-files calendar-sync-file) +;; +;; Usage: +;; - M-x calendar-sync-now ; Manual sync +;; - M-x calendar-sync-start ; Start auto-sync +;; - M-x calendar-sync-stop ; Stop auto-sync +;; - M-x calendar-sync-toggle ; Toggle auto-sync + +;;; Code: + +(require 'org) +(require 'user-constants) ; For gcal-file path + +;;; Configuration + +(defgroup calendar-sync nil + "Simple Google Calendar sync via .ics." + :group 'calendar + :prefix "calendar-sync-") + +(defcustom calendar-sync-ics-url nil + "Google Calendar private .ics URL. +Get this from Google Calendar Settings → Integrate calendar → Secret address in iCal format." + :type '(choice (const :tag "Not configured" nil) + (string :tag "iCal URL")) + :group 'calendar-sync) + +(defcustom calendar-sync-interval (* 15 60) + "Sync interval in seconds. +Default: 15 minutes (900 seconds)." + :type 'integer + :group 'calendar-sync) + +(defcustom calendar-sync-file + gcal-file + "Location of synced calendar file. +Defaults to gcal-file from user-constants." + :type 'file + :group 'calendar-sync) + +;;; Internal state + +(defvar calendar-sync--timer nil + "Timer object for automatic syncing.") + +(defvar calendar-sync--last-sync-time nil + "Time of last successful sync.") + +(defvar calendar-sync--last-error nil + "Last sync error message, if any.") + +(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 ((state `((timezone-offset . ,calendar-sync--last-timezone-offset) + (last-sync-time . ,calendar-sync--last-sync-time))) + (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)) + (setq calendar-sync--last-sync-time + (alist-get 'last-sync-time state)))) + (error + (message "calendar-sync: Error loading state: %s" (error-message-string err)))))) + +;;; .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. +Returns nil if property not found." + (when (string-match (format "^%s:\\(.*\\)$" property) event) + (match-string 1 event))) + +(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 ">"))) + +(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)." + (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." + (condition-case err + (let* ((events (calendar-sync--split-events ics-content)) + (parsed-events (delq nil (mapcar #'calendar-sync--parse-event events))) + (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 "# Google Calendar Events\n\n" + (string-join org-entries "\n\n") + "\n") + nil)) + (error + (setq calendar-sync--last-error (error-message-string err)) + (message "calendar-sync: Parse error: %s" calendar-sync--last-error) + nil))) + +;;; Sync functions + +(defun calendar-sync--fetch-ics (url) + "Fetch .ics file from URL using curl. +Returns .ics content as string, or nil on error. +Uses curl instead of url-retrieve-synchronously to avoid daemon mode hanging." + (condition-case err + (with-temp-buffer + (let ((exit-code (call-process "curl" nil t nil + "-s" ; Silent + "-L" ; Follow redirects + "-m" "10" ; Max 10 seconds + url))) + (if (= exit-code 0) + (buffer-string) + (setq calendar-sync--last-error (format "curl exited with code %d" exit-code)) + (message "calendar-sync: Fetch error: %s" calendar-sync--last-error) + nil))) + (error + (setq calendar-sync--last-error (error-message-string err)) + (message "calendar-sync: Fetch error: %s" calendar-sync--last-error) + nil))) + +(defun calendar-sync--write-file (content) + "Write CONTENT to `calendar-sync-file'. +Creates parent directories if needed." + (let ((dir (file-name-directory calendar-sync-file))) + (unless (file-directory-p dir) + (make-directory dir t))) + (with-temp-file calendar-sync-file + (insert content)) + (message "calendar-sync: Updated %s" calendar-sync-file)) + +;;;###autoload +(defun calendar-sync-now () + "Sync Google Calendar now. +Downloads .ics file and updates org file. +Tracks timezone for automatic re-sync on timezone changes." + (interactive) + (if (not calendar-sync-ics-url) + (message "calendar-sync: Please set calendar-sync-ics-url") + (message "calendar-sync: Syncing...") + (let* ((ics-content (calendar-sync--fetch-ics calendar-sync-ics-url)) + (org-content (and ics-content (calendar-sync--parse-ics ics-content)))) + (if org-content + (progn + (calendar-sync--write-file org-content) + (setq calendar-sync--last-sync-time (current-time)) + (setq calendar-sync--last-timezone-offset (calendar-sync--current-timezone-offset)) + (setq calendar-sync--last-error nil) + (calendar-sync--save-state) + (message "calendar-sync: Sync complete")) + (message "calendar-sync: Sync failed (see *Messages* for details)"))))) + +;;; 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-now)) + +;;;###autoload +(defun calendar-sync-start () + "Start automatic calendar syncing. +Syncs immediately, then every `calendar-sync-interval' seconds." + (interactive) + (when calendar-sync--timer + (cancel-timer calendar-sync--timer)) + (if (not calendar-sync-ics-url) + (message "calendar-sync: Please set calendar-sync-ics-url before starting") + ;; Sync immediately + (calendar-sync-now) + ;; Start timer for future syncs + (setq calendar-sync--timer + (run-at-time calendar-sync-interval + calendar-sync-interval + #'calendar-sync--sync-timer-function)) + (message "calendar-sync: Auto-sync started (every %d minutes)" + (/ calendar-sync-interval 60)))) + +;;;###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 + "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 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-ics-url + (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: Run `calendar-sync-now' or start auto-sync to update") + ;; 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 + )) + +(provide 'calendar-sync) +;;; calendar-sync.el ends here diff --git a/modules/host-environment.el b/modules/host-environment.el index 6900a8df..3cec5df1 100644 --- a/modules/host-environment.el +++ b/modules/host-environment.el @@ -112,7 +112,7 @@ Tries multiple methods in order of reliability: (when (string-match ".*/zoneinfo/\\(.+\\)" target) (match-string 1 target)))) - ;; Default to nil - lets org-gcal use its default + ;; Default to nil if detection fails nil)) (provide 'host-environment) diff --git a/modules/org-capture-config.el b/modules/org-capture-config.el index f41d0228..5d569002 100644 --- a/modules/org-capture-config.el +++ b/modules/org-capture-config.el @@ -84,10 +84,6 @@ Intended to be called within an org capture template." '(("t" "Task" entry (file+headline inbox-file "Inbox") "* TODO %?" :prepend t) - ("a" "Appointment" entry (file gcal-file) - "* %?\n:PROPERTIES:\n:calendar-id:craigmartinjennings@gmail.com\n:END:\n:org-gcal:\n%^T--%^T\n:END:\n\n" - :jump-to-captured t) - ("e" "Event" entry (file+headline schedule-file "Scheduled Events") "* %?%:description SCHEDULED: %^t%(cj/org-capture-event-content) diff --git a/modules/org-roam-config.el b/modules/org-roam-config.el index a33dd633..e8132776 100644 --- a/modules/org-roam-config.el +++ b/modules/org-roam-config.el @@ -87,7 +87,7 @@ (lambda () (when (and (member org-state org-done-keywords) (not (member org-last-state org-done-keywords)) - ;; Don't run for gcal.org - it's managed by org-gcal + ;; Don't run for gcal.org - it's synced from Google Calendar (not (string= (buffer-file-name) (expand-file-name gcal-file)))) (cj/org-roam-copy-todo-to-today))))) diff --git a/modules/user-constants.el b/modules/user-constants.el index eafd08e8..c4c7a106 100644 --- a/modules/user-constants.el +++ b/modules/user-constants.el @@ -127,8 +127,9 @@ Used by transcription module and other audio-related functionality.") (defvar schedule-file (expand-file-name "schedule.org" org-dir) "The location of the org file containing scheduled events.") -(defvar gcal-file (expand-file-name "gcal.org" org-dir) - "The location of the org file containing Google Calendar information.") +(defvar gcal-file (expand-file-name "data/gcal.org" user-emacs-directory) + "The location of the org file containing Google Calendar information. +Stored in .emacs.d/data/ so each machine syncs independently from Google Calendar.") (defvar reference-file (expand-file-name "reference.org" org-dir) "The location of the org file containing reference information.") |
