From da0bd6883a4032054aef4b59c338f60796a0fd99 Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Sun, 16 Nov 2025 18:09:17 -0600 Subject: feat(calendar-sync): Add automatic timezone detection and chronological sorting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- modules/archived/org-gcal-config.el | 213 ++++++++++++++++++ modules/calendar-sync.el | 438 ++++++++++++++++++++++++++++++++++++ modules/host-environment.el | 2 +- modules/org-capture-config.el | 4 - modules/org-gcal-config.el | 213 ------------------ modules/org-roam-config.el | 2 +- modules/user-constants.el | 5 +- 7 files changed, 656 insertions(+), 221 deletions(-) create mode 100644 modules/archived/org-gcal-config.el create mode 100644 modules/calendar-sync.el delete mode 100644 modules/org-gcal-config.el (limited to 'modules') diff --git a/modules/archived/org-gcal-config.el b/modules/archived/org-gcal-config.el new file mode 100644 index 00000000..9f43c1c8 --- /dev/null +++ b/modules/archived/org-gcal-config.el @@ -0,0 +1,213 @@ +;;; org-gcal-config.el --- Google Calendar synchronization for Org-mode -*- lexical-binding: t; coding: utf-8; -*- +;; +;; Author: Craig Jennings +;; +;;; Commentary: +;; +;; Bidirectional synchronization between Google Calendar and Org-mode using org-gcal. +;; - Credential management via authinfo.gpg +;; - Automatic archival of past events +;; - Automatic removal of cancelled events, but with TODOs added for visibility +;; - System timezone configuration via functions in host-environment +;; - No notifications on syncing +;; - Events are managed by Org (changes in org file push back to Google Calendar) +;; This is controlled by org-gcal-managed-newly-fetched-mode and +;; org-gcal-managed-update-existing-mode set to "org" +;; - Automatic sync timer (configurable via cj/org-gcal-sync-interval-minutes) +;; Default: 30 minutes, set to nil to disable +;; See: https://github.com/kidd/org-gcal.el?tab=readme-ov-file#sync-automatically-at-regular-times +;; - Validates existing oath2-auto.plist file or creates it to avoid the issue mentioned here: +;; https://github.com/kidd/org-gcal.el?tab=readme-ov-file#note +;; +;; Prerequisites: +;; 1. Create OAuth 2.0 credentials in Google Cloud Console +;; See: https://github.com/kidd/org-gcal.el?tab=readme-ov-file#installation +;; 2. Store credentials in ~/.authinfo.gpg with this format: +;; machine org-gcal login YOUR_CLIENT_ID password YOUR_CLIENT_SECRET +;; 3. Define `gcal-file' in user-constants (location of org file to hold sync'd events). +;; +;; Usage: +;; - Manual sync: C-; g s (or M-x org-gcal-sync) +;; - Toggle auto-sync on/off: C-; g t +;; - Restart auto-sync (e.g., after changing interval): C-; g r +;; - Clear sync lock (if sync gets stuck): C-; g c +;; +;; Note: +;; This configuration creates oauth2-auto.plist on first run to prevent sync errors. +;; Passphrase caching is enabled. +;; +;;; Code: + +(require 'host-environment) +(require 'user-constants) + +;; Forward declare org-gcal internal variables and functions +(eval-when-compile + (defvar org-gcal--sync-lock)) +(declare-function org-gcal-reload-client-id-secret "org-gcal") + +;; User configurable sync interval +(defvar cj/org-gcal-sync-interval-minutes 30 + "Interval in minutes for automatic Google Calendar sync. +Set to nil to disable automatic syncing. +Changes take effect after calling `cj/org-gcal-restart-auto-sync'.") + +;; Internal timer object +(defvar cj/org-gcal-sync-timer nil + "Timer object for automatic org-gcal sync. +Use `cj/org-gcal-start-auto-sync' and `cj/org-gcal-stop-auto-sync' to control.") + +(defun cj/org-gcal-clear-sync-lock () + "Clear the org-gcal sync lock. +Useful when a sync fails and leaves the lock in place, preventing future syncs." + (interactive) + (setq org-gcal--sync-lock nil) + (message "org-gcal sync lock cleared")) + +(defun cj/org-gcal-convert-all-to-org-managed () + "Convert all org-gcal events in current buffer to Org-managed. + +Changes all events with org-gcal-managed property from `gcal' to `org', +enabling bidirectional sync so changes push back to Google Calendar." + (interactive) + (let ((count 0)) + (save-excursion + (goto-char (point-min)) + (while (re-search-forward "^:org-gcal-managed: gcal$" nil t) + (replace-match ":org-gcal-managed: org") + (setq count (1+ count)))) + (when (> count 0) + (save-buffer)) + (message "Converted %d event(s) to Org-managed" count))) + +(defun cj/org-gcal-start-auto-sync () + "Start automatic Google Calendar sync timer. +Uses the interval specified in `cj/org-gcal-sync-interval-minutes'. +Does nothing if interval is nil or timer is already running." + (interactive) + (when (and cj/org-gcal-sync-interval-minutes + (not (and cj/org-gcal-sync-timer + (memq cj/org-gcal-sync-timer timer-list)))) + (let ((interval-seconds (* cj/org-gcal-sync-interval-minutes 60))) + (setq cj/org-gcal-sync-timer + (run-with-timer + 120 ;; Initial delay: 2 minutes after startup + interval-seconds + (lambda () + (condition-case err + (org-gcal-sync) + (error (message "org-gcal: Auto-sync failed: %s" err)))))) + (message "org-gcal: Auto-sync started (every %d minutes)" + cj/org-gcal-sync-interval-minutes)))) + +(defun cj/org-gcal-stop-auto-sync () + "Stop automatic Google Calendar sync timer." + (interactive) + (when (and cj/org-gcal-sync-timer + (memq cj/org-gcal-sync-timer timer-list)) + (cancel-timer cj/org-gcal-sync-timer) + (setq cj/org-gcal-sync-timer nil) + (message "org-gcal: Auto-sync stopped"))) + +(defun cj/org-gcal-toggle-auto-sync () + "Toggle automatic Google Calendar sync timer on/off." + (interactive) + (if (and cj/org-gcal-sync-timer + (memq cj/org-gcal-sync-timer timer-list)) + (cj/org-gcal-stop-auto-sync) + (cj/org-gcal-start-auto-sync))) + +(defun cj/org-gcal-restart-auto-sync () + "Restart automatic Google Calendar sync timer. +Useful after changing `cj/org-gcal-sync-interval-minutes'." + (interactive) + (cj/org-gcal-stop-auto-sync) + (cj/org-gcal-start-auto-sync)) + +;; Deferred library required by org-gcal +(use-package deferred + :ensure t) + +;; OAuth2 authentication library required by org-gcal +(use-package oauth2-auto + :ensure t) + +(use-package org-gcal + :vc (:url "https://github.com/cjennings/org-gcal" :rev :newest) + :defer t ;; unless idle timer is set below + + :init + ;; 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))) + + (setq org-gcal-up-days 30) ;; Look 30 days back + (setq org-gcal-down-days 60) ;; Look 60 days forward + (setq org-gcal-auto-archive t) ;; auto-archive old events + (setq org-gcal-notify-p nil) ;; nil disables; t enables notifications + (setq org-gcal-remove-api-cancelled-events t) ;; auto-remove cancelled events + (setq org-gcal-update-cancelled-events-with-todo t) ;; todo cancelled events for visibility + + ;; Google Calendar is authoritative - avoids sync conflicts + (setq org-gcal-managed-newly-fetched-mode "gcal") ;; New events from GCal stay GCal-managed + (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 + + ;; set org-gcal timezone based on system timezone + (setq org-gcal-local-timezone (cj/detect-system-timezone)) + + ;; Reload client credentials (should already be loaded by org-gcal, but ensure it's set) + (org-gcal-reload-client-id-secret) + + ;; Auto-save gcal files after sync completes + (defun cj/org-gcal-save-files-after-sync (&rest _) + "Save all org-gcal files after sync completes." + (dolist (entry org-gcal-fetch-file-alist) + (let* ((file (cdr entry)) + (buffer (get-file-buffer file))) + (when (and buffer (buffer-modified-p buffer)) + (with-current-buffer buffer + (save-buffer) + (message "Saved %s after org-gcal sync" (file-name-nondirectory file))))))) + + ;; Advise org-gcal--sync-unlock which is called when sync completes + (advice-add 'org-gcal--sync-unlock :after #'cj/org-gcal-save-files-after-sync)) + +;; Start automatic sync timer based on user configuration +;; Set cj/org-gcal-sync-interval-minutes to nil to disable +;; (cj/org-gcal-start-auto-sync) + +;; Google Calendar keymap and keybindings +(defvar-keymap cj/gcal-map + :doc "Keymap for Google Calendar operations" + "s" #'org-gcal-sync + "t" #'cj/org-gcal-toggle-auto-sync + "r" #'cj/org-gcal-restart-auto-sync + "c" #'cj/org-gcal-clear-sync-lock) +(keymap-set cj/custom-keymap "g" cj/gcal-map) + +(with-eval-after-load 'which-key + (which-key-add-key-based-replacements + "C-; g" "gcal menu" + "C-; g s" "sync" + "C-; g t" "toggle auto-sync" + "C-; g r" "restart auto-sync" + "C-; g c" "clear sync lock")) + +(provide 'org-gcal-config) +;;; org-gcal-config.el ends here 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 +;; 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-gcal-config.el b/modules/org-gcal-config.el deleted file mode 100644 index 4eca5e7e..00000000 --- a/modules/org-gcal-config.el +++ /dev/null @@ -1,213 +0,0 @@ -;;; org-gcal-config.el --- Google Calendar synchronization for Org-mode -*- lexical-binding: t; coding: utf-8; -*- -;; -;; Author: Craig Jennings -;; -;;; Commentary: -;; -;; Bidirectional synchronization between Google Calendar and Org-mode using org-gcal. -;; - Credential management via authinfo.gpg -;; - Automatic archival of past events -;; - Automatic removal of cancelled events, but with TODOs added for visibility -;; - System timezone configuration via functions in host-environment -;; - No notifications on syncing -;; - Events are managed by Org (changes in org file push back to Google Calendar) -;; This is controlled by org-gcal-managed-newly-fetched-mode and -;; org-gcal-managed-update-existing-mode set to "org" -;; - Automatic sync timer (configurable via cj/org-gcal-sync-interval-minutes) -;; Default: 30 minutes, set to nil to disable -;; See: https://github.com/kidd/org-gcal.el?tab=readme-ov-file#sync-automatically-at-regular-times -;; - Validates existing oath2-auto.plist file or creates it to avoid the issue mentioned here: -;; https://github.com/kidd/org-gcal.el?tab=readme-ov-file#note -;; -;; Prerequisites: -;; 1. Create OAuth 2.0 credentials in Google Cloud Console -;; See: https://github.com/kidd/org-gcal.el?tab=readme-ov-file#installation -;; 2. Store credentials in ~/.authinfo.gpg with this format: -;; machine org-gcal login YOUR_CLIENT_ID password YOUR_CLIENT_SECRET -;; 3. Define `gcal-file' in user-constants (location of org file to hold sync'd events). -;; -;; Usage: -;; - Manual sync: C-; g s (or M-x org-gcal-sync) -;; - Toggle auto-sync on/off: C-; g t -;; - Restart auto-sync (e.g., after changing interval): C-; g r -;; - Clear sync lock (if sync gets stuck): C-; g c -;; -;; Note: -;; This configuration creates oauth2-auto.plist on first run to prevent sync errors. -;; Passphrase caching is enabled. -;; -;;; Code: - -(require 'host-environment) -(require 'user-constants) - -;; Forward declare org-gcal internal variables and functions -(eval-when-compile - (defvar org-gcal--sync-lock)) -(declare-function org-gcal-reload-client-id-secret "org-gcal") - -;; User configurable sync interval -(defvar cj/org-gcal-sync-interval-minutes 30 - "Interval in minutes for automatic Google Calendar sync. -Set to nil to disable automatic syncing. -Changes take effect after calling `cj/org-gcal-restart-auto-sync'.") - -;; Internal timer object -(defvar cj/org-gcal-sync-timer nil - "Timer object for automatic org-gcal sync. -Use `cj/org-gcal-start-auto-sync' and `cj/org-gcal-stop-auto-sync' to control.") - -(defun cj/org-gcal-clear-sync-lock () - "Clear the org-gcal sync lock. -Useful when a sync fails and leaves the lock in place, preventing future syncs." - (interactive) - (setq org-gcal--sync-lock nil) - (message "org-gcal sync lock cleared")) - -(defun cj/org-gcal-convert-all-to-org-managed () - "Convert all org-gcal events in current buffer to Org-managed. - -Changes all events with org-gcal-managed property from `gcal' to `org', -enabling bidirectional sync so changes push back to Google Calendar." - (interactive) - (let ((count 0)) - (save-excursion - (goto-char (point-min)) - (while (re-search-forward "^:org-gcal-managed: gcal$" nil t) - (replace-match ":org-gcal-managed: org") - (setq count (1+ count)))) - (when (> count 0) - (save-buffer)) - (message "Converted %d event(s) to Org-managed" count))) - -(defun cj/org-gcal-start-auto-sync () - "Start automatic Google Calendar sync timer. -Uses the interval specified in `cj/org-gcal-sync-interval-minutes'. -Does nothing if interval is nil or timer is already running." - (interactive) - (when (and cj/org-gcal-sync-interval-minutes - (not (and cj/org-gcal-sync-timer - (memq cj/org-gcal-sync-timer timer-list)))) - (let ((interval-seconds (* cj/org-gcal-sync-interval-minutes 60))) - (setq cj/org-gcal-sync-timer - (run-with-timer - 120 ;; Initial delay: 2 minutes after startup - interval-seconds - (lambda () - (condition-case err - (org-gcal-sync) - (error (message "org-gcal: Auto-sync failed: %s" err)))))) - (message "org-gcal: Auto-sync started (every %d minutes)" - cj/org-gcal-sync-interval-minutes)))) - -(defun cj/org-gcal-stop-auto-sync () - "Stop automatic Google Calendar sync timer." - (interactive) - (when (and cj/org-gcal-sync-timer - (memq cj/org-gcal-sync-timer timer-list)) - (cancel-timer cj/org-gcal-sync-timer) - (setq cj/org-gcal-sync-timer nil) - (message "org-gcal: Auto-sync stopped"))) - -(defun cj/org-gcal-toggle-auto-sync () - "Toggle automatic Google Calendar sync timer on/off." - (interactive) - (if (and cj/org-gcal-sync-timer - (memq cj/org-gcal-sync-timer timer-list)) - (cj/org-gcal-stop-auto-sync) - (cj/org-gcal-start-auto-sync))) - -(defun cj/org-gcal-restart-auto-sync () - "Restart automatic Google Calendar sync timer. -Useful after changing `cj/org-gcal-sync-interval-minutes'." - (interactive) - (cj/org-gcal-stop-auto-sync) - (cj/org-gcal-start-auto-sync)) - -;; Deferred library required by org-gcal -(use-package deferred - :ensure t) - -;; OAuth2 authentication library required by org-gcal -(use-package oauth2-auto - :ensure t) - -(use-package org-gcal - :vc (:url "https://github.com/cjennings/org-gcal" :rev :newest) - :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))))) - - ;; identify calendar to sync and it's destination - (setq org-gcal-fetch-file-alist `(("craigmartinjennings@gmail.com" . ,gcal-file))) - - (setq org-gcal-up-days 30) ;; Look 30 days back - (setq org-gcal-down-days 60) ;; Look 60 days forward - (setq org-gcal-auto-archive t) ;; auto-archive old events - (setq org-gcal-notify-p nil) ;; nil disables; t enables notifications - (setq org-gcal-remove-api-cancelled-events t) ;; auto-remove cancelled events - (setq org-gcal-update-cancelled-events-with-todo t) ;; todo cancelled events for visibility - - ;; Google Calendar is authoritative - avoids sync conflicts - (setq org-gcal-managed-newly-fetched-mode "gcal") ;; New events from GCal stay GCal-managed - (setq org-gcal-managed-update-existing-mode "gcal") ;; GCal wins on conflicts - - :config - ;; Plstore caching is now configured globally in auth-config.el - ;; to ensure it loads before org-gcal needs it - - ;; set org-gcal timezone based on system timezone - (setq org-gcal-local-timezone (cj/detect-system-timezone)) - - ;; Reload client credentials (should already be loaded by org-gcal, but ensure it's set) - (org-gcal-reload-client-id-secret) - - ;; Auto-save gcal files after sync completes - (defun cj/org-gcal-save-files-after-sync (&rest _) - "Save all org-gcal files after sync completes." - (dolist (entry org-gcal-fetch-file-alist) - (let* ((file (cdr entry)) - (buffer (get-file-buffer file))) - (when (and buffer (buffer-modified-p buffer)) - (with-current-buffer buffer - (save-buffer) - (message "Saved %s after org-gcal sync" (file-name-nondirectory file))))))) - - ;; Advise org-gcal--sync-unlock which is called when sync completes - (advice-add 'org-gcal--sync-unlock :after #'cj/org-gcal-save-files-after-sync)) - -;; Start automatic sync timer based on user configuration -;; Set cj/org-gcal-sync-interval-minutes to nil to disable -;; (cj/org-gcal-start-auto-sync) - -;; Google Calendar keymap and keybindings -(defvar-keymap cj/gcal-map - :doc "Keymap for Google Calendar operations" - "s" #'org-gcal-sync - "t" #'cj/org-gcal-toggle-auto-sync - "r" #'cj/org-gcal-restart-auto-sync - "c" #'cj/org-gcal-clear-sync-lock) -(keymap-set cj/custom-keymap "g" cj/gcal-map) - -(with-eval-after-load 'which-key - (which-key-add-key-based-replacements - "C-; g" "gcal menu" - "C-; g s" "sync" - "C-; g t" "toggle auto-sync" - "C-; g r" "restart auto-sync" - "C-; g c" "clear sync lock")) - -(provide 'org-gcal-config) -;;; org-gcal-config.el ends here 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.") -- cgit v1.2.3