aboutsummaryrefslogtreecommitdiff
path: root/modules/calendar-sync-source.el
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-06-29 05:23:56 -0400
committerCraig Jennings <c@cjennings.net>2026-06-29 05:23:56 -0400
commit54250d958f2829ff0f44a223a1481b6ec55a6d91 (patch)
treea3e86f5aebc39e934fee12b75e81fc56b016e5da /modules/calendar-sync-source.el
parent3ca9a9f17daa3d6258aa9f32a1a56a9f9e19642c (diff)
downloaddotemacs-54250d958f2829ff0f44a223a1481b6ec55a6d91.tar.gz
dotemacs-54250d958f2829ff0f44a223a1481b6ec55a6d91.zip
refactor: split calendar-sync.el into layered modules
Break the 1724-line calendar-sync.el into a thin public face plus four layered libraries, moving every function verbatim so behavior and public names are unchanged: - calendar-sync-ics.el — base parsing: RFC 5545 text cleaning, VEVENT property extraction, attendee/organizer/URL parsing, timezone and timestamp conversion, date arithmetic, single-event parsing. Depends on neither of the other new modules. - calendar-sync-recurrence.el — RRULE/EXDATE/RECURRENCE-ID expansion. - calendar-sync-org.el — Org rendering and atomic file output. - calendar-sync-source.el — sync state and persistence, async .ics fetch, the batch conversion worker, and the Google Calendar API path. calendar-sync.el keeps configuration, the parse orchestrator, sync dispatch, the user commands, the timer, and the C-; g keymap, and requires the four layers. Each layer forward-declares the config defvars it reads, so no layer requires the top module back. The batch worker loads the whole graph, so source forward-declares the two functions it calls there. Every public name is preserved, so all 574 existing calendar-sync tests pass unchanged through the require chain. The four new modules carry the load-graph and package headers and join the header-contract allowlist. Claude-Session: https://claude.ai/code/session_014fyKMTTqLrZpL3rDF3dYc3
Diffstat (limited to 'modules/calendar-sync-source.el')
-rw-r--r--modules/calendar-sync-source.el426
1 files changed, 426 insertions, 0 deletions
diff --git a/modules/calendar-sync-source.el b/modules/calendar-sync-source.el
new file mode 100644
index 000000000..d9efc885b
--- /dev/null
+++ b/modules/calendar-sync-source.el
@@ -0,0 +1,426 @@
+;;; calendar-sync-source.el --- Feed fetch, state, and conversion workers -*- coding: utf-8; lexical-binding: t; -*-
+
+;; Author: Craig Jennings <c@cjennings.net>
+;; Created: 2025-11-16
+
+;;; Commentary:
+;;
+;; Layer: 3 (Domain Workflow).
+;; Category: D/S.
+;; Load shape: library.
+;; Top-level side effects: none (defuns plus internal state defvars).
+;; Runtime requires: subr-x, system-lib, calendar-sync-ics.
+;; Direct test load: yes (requires calendar-sync-ics explicitly).
+;;
+;; Source layer of calendar-sync: per-calendar sync state and its on-disk
+;; persistence, asynchronous .ics fetching via curl, the batch Emacs
+;; conversion worker, and the Google Calendar API fetch path. Drives a
+;; single calendar from either its .ics feed or the API helper.
+;;
+;; The batch worker loads the top calendar-sync module (whose path is held
+;; in `calendar-sync--module-file') and there calls `calendar-sync--parse-ics'
+;; and `calendar-sync--write-file'. Those live in the top and org modules
+;; respectively, which require this one, so they are forward-declared here
+;; rather than required (the worker has the full graph loaded, and these
+;; functions are only ever invoked inside it).
+
+;;; Code:
+
+(require 'subr-x)
+(require 'system-lib) ;; provides cj/auth-source-secret-value
+(require 'calendar-sync-ics)
+
+;; Owned by calendar-sync.el (config) / calendar-sync-org.el (output);
+;; forward-declared so this module compiles and reads them without a cycle.
+(defvar calendar-sync-calendars)
+(defvar calendar-sync-fetch-timeout)
+(defvar calendar-sync-python-command)
+(defvar calendar-sync-past-months)
+(defvar calendar-sync-future-months)
+(defvar calendar-sync-user-emails)
+(defvar calendar-sync-skip-declined)
+(defvar calendar-sync-private-config-file)
+(defvar calendar-sync--module-file)
+(declare-function calendar-sync--parse-ics "calendar-sync" (ics-content))
+(declare-function calendar-sync--write-file "calendar-sync-org" (content file))
+
+;;; Internal state
+
+(defvar calendar-sync--calendar-states (make-hash-table :test 'equal)
+ "Per-calendar sync state.
+Hash table mapping calendar name (string) to state plist with:
+ :last-sync - Time of last successful sync
+ :status - Symbol: ok, error, or syncing
+ :last-error - Error message string, or nil")
+
+(defvar calendar-sync--state-file
+ (expand-file-name "persist/calendar-sync-state.el" user-emacs-directory)
+ "File to persist sync state across Emacs sessions.")
+
+;;; State Persistence
+
+(defun calendar-sync--save-state ()
+ "Save sync state to disk for persistence across sessions."
+ (let* ((calendar-states-alist
+ (let ((result '()))
+ (maphash (lambda (name state)
+ (push (cons name state) result))
+ calendar-sync--calendar-states)
+ result))
+ (state `((timezone-offset . ,calendar-sync--last-timezone-offset)
+ (calendar-states . ,calendar-states-alist)))
+ (dir (file-name-directory calendar-sync--state-file)))
+ (unless (file-directory-p dir)
+ (make-directory dir t))
+ (let ((tmp (make-temp-file (expand-file-name ".calendar-sync-state-" dir))))
+ (with-temp-file tmp
+ (prin1 state (current-buffer)))
+ (rename-file tmp calendar-sync--state-file t))))
+
+(defun calendar-sync--load-state ()
+ "Load sync state from disk."
+ (when (file-exists-p calendar-sync--state-file)
+ (condition-case err
+ (with-temp-buffer
+ (insert-file-contents calendar-sync--state-file)
+ (let ((state (read (current-buffer))))
+ (setq calendar-sync--last-timezone-offset
+ (alist-get 'timezone-offset state))
+ ;; Load per-calendar states
+ (let ((cal-states (alist-get 'calendar-states state)))
+ (clrhash calendar-sync--calendar-states)
+ (dolist (entry cal-states)
+ (puthash (car entry) (cdr entry) calendar-sync--calendar-states)))))
+ (error
+ (calendar-sync--log-silently "calendar-sync: Error loading state: %s" (error-message-string err))))))
+
+(defun calendar-sync--get-calendar-state (calendar-name)
+ "Get state plist for CALENDAR-NAME, or nil if not found."
+ (gethash calendar-name calendar-sync--calendar-states))
+
+(defun calendar-sync--set-calendar-state (calendar-name state)
+ "Set STATE plist for CALENDAR-NAME."
+ (puthash calendar-name state calendar-sync--calendar-states))
+
+;;; Debug Logging
+
+(defun calendar-sync--debug-p ()
+ "Return non-nil if calendar-sync debug logging is enabled.
+Checks `cj/debug-modules' for symbol `calendar-sync' or t (all)."
+ (and (boundp 'cj/debug-modules)
+ (or (eq cj/debug-modules t)
+ (memq 'calendar-sync cj/debug-modules))))
+
+;;; Private Config
+
+(defun calendar-sync--load-private-config ()
+ "Load private calendar-sync configuration when available."
+ (when (file-readable-p calendar-sync-private-config-file)
+ (condition-case err
+ (load calendar-sync-private-config-file nil t)
+ (error
+ (message "calendar-sync: Failed to load private config %s: %s"
+ (abbreviate-file-name calendar-sync-private-config-file)
+ (error-message-string err))))))
+
+;;; .ics Fetch
+
+(defun calendar-sync--fetch-ics (url callback)
+ "Fetch .ics file from URL asynchronously using curl.
+Calls CALLBACK with the .ics content as string (normalized to Unix line endings)
+or nil on error. CALLBACK signature: (lambda (content) ...).
+
+The fetch happens asynchronously and doesn't block Emacs. The callback is
+invoked when the fetch completes, either successfully or with an error."
+ (condition-case err
+ (let ((buffer (generate-new-buffer " *calendar-sync-curl*")))
+ (make-process
+ :name "calendar-sync-curl"
+ :buffer buffer
+ :command (list "curl" "-s" "-L" "--fail"
+ "--connect-timeout" "10"
+ "--max-time" (number-to-string calendar-sync-fetch-timeout)
+ url)
+ :sentinel
+ (lambda (process event)
+ (when (memq (process-status process) '(exit signal))
+ (let ((buf (process-buffer process)))
+ (when (buffer-live-p buf)
+ (let ((content
+ (with-current-buffer buf
+ (if (and (eq (process-status process) 'exit)
+ (= (process-exit-status process) 0))
+ (calendar-sync--normalize-line-endings (buffer-string))
+ (calendar-sync--log-silently "calendar-sync: Fetch error: curl failed: %s" (string-trim event))
+ nil))))
+ (kill-buffer buf)
+ (funcall callback content))))))))
+ (error
+ (calendar-sync--log-silently "calendar-sync: Fetch error: %s" (error-message-string err))
+ (funcall callback nil))))
+
+(defun calendar-sync--fetch-ics-file (url callback)
+ "Fetch .ics from URL to a temp file asynchronously.
+Calls CALLBACK with the temp file path on success, or nil on error. The caller
+owns deleting the temp file after a successful callback."
+ (condition-case err
+ (let ((buffer (generate-new-buffer " *calendar-sync-curl*"))
+ (temp-file (make-temp-file "calendar-sync-" nil ".ics")))
+ (make-process
+ :name "calendar-sync-curl"
+ :buffer buffer
+ :command (list "curl" "-s" "-L" "--fail"
+ "--connect-timeout" "10"
+ "--max-time" (number-to-string calendar-sync-fetch-timeout)
+ "-o" temp-file
+ url)
+ :sentinel
+ (lambda (process event)
+ (when (memq (process-status process) '(exit signal))
+ (let ((buf (process-buffer process))
+ (success (and (eq (process-status process) 'exit)
+ (= (process-exit-status process) 0))))
+ (when (buffer-live-p buf)
+ (unless success
+ (calendar-sync--log-silently "calendar-sync: Fetch error: curl failed: %s"
+ (string-trim event)))
+ (kill-buffer buf))
+ (if success
+ (funcall callback temp-file)
+ (when (file-exists-p temp-file)
+ (delete-file temp-file))
+ (funcall callback nil)))))))
+ (error
+ (calendar-sync--log-silently "calendar-sync: Fetch error: %s" (error-message-string err))
+ (funcall callback nil))))
+
+;;; Batch Conversion Worker
+
+(defun calendar-sync--emacs-binary ()
+ "Return the Emacs executable to use for calendar conversion workers."
+ (let ((candidate (expand-file-name invocation-name invocation-directory)))
+ (if (file-executable-p candidate)
+ candidate
+ invocation-name)))
+
+(defun calendar-sync--batch-convert-file (ics-file output-file past-months future-months user-emails)
+ "Convert ICS-FILE to Org format and write OUTPUT-FILE.
+PAST-MONTHS, FUTURE-MONTHS, and USER-EMAILS mirror the interactive session's
+calendar conversion settings. This is intended for noninteractive worker
+processes, not direct interactive use."
+ (setq calendar-sync-past-months past-months
+ calendar-sync-future-months future-months
+ calendar-sync-user-emails user-emails)
+ (let* ((ics-content
+ (with-temp-buffer
+ (insert-file-contents ics-file)
+ (calendar-sync--normalize-line-endings (buffer-string))))
+ (org-content (calendar-sync--parse-ics ics-content)))
+ (unless org-content
+ (error "calendar-sync: parse failed"))
+ (calendar-sync--write-file org-content output-file)))
+
+(defun calendar-sync--worker-command (ics-file output-file)
+ "Build the batch Emacs command that converts ICS-FILE to OUTPUT-FILE."
+ (let ((module-dir (file-name-directory calendar-sync--module-file))
+ (private-config-file
+ (make-temp-name (expand-file-name "calendar-sync-worker-config-"
+ temporary-file-directory)))
+ (state-file
+ (make-temp-name (expand-file-name "calendar-sync-worker-state-"
+ temporary-file-directory))))
+ (list (calendar-sync--emacs-binary)
+ "--batch"
+ "--no-site-file"
+ "--no-site-lisp"
+ "--eval" (format "(setq load-prefer-newer t calendar-sync-auto-start nil calendar-sync-private-config-file %S calendar-sync--state-file %S)"
+ private-config-file state-file)
+ "-L" module-dir
+ "-l" calendar-sync--module-file
+ "--eval" (format "(calendar-sync--batch-convert-file %S %S %S %S '%S)"
+ ics-file
+ output-file
+ calendar-sync-past-months
+ calendar-sync-future-months
+ calendar-sync-user-emails))))
+
+(defun calendar-sync--convert-ics-file-async (ics-file output-file callback)
+ "Convert ICS-FILE to OUTPUT-FILE in a batch Emacs worker.
+Calls CALLBACK as (CALLBACK SUCCESS ERROR-MESSAGE). Deletes ICS-FILE after the
+worker exits."
+ (condition-case err
+ (let ((buffer (generate-new-buffer " *calendar-sync-worker*")))
+ (make-process
+ :name "calendar-sync-worker"
+ :buffer buffer
+ :command (calendar-sync--worker-command ics-file output-file)
+ :sentinel
+ (lambda (process _event)
+ (when (memq (process-status process) '(exit signal))
+ (let* ((buf (process-buffer process))
+ (success (and (eq (process-status process) 'exit)
+ (= (process-exit-status process) 0)))
+ (error-message
+ (when (buffer-live-p buf)
+ (with-current-buffer buf
+ (string-trim (buffer-string))))))
+ (when (file-exists-p ics-file)
+ (delete-file ics-file))
+ (when (buffer-live-p buf)
+ (kill-buffer buf))
+ (funcall callback success error-message))))))
+ (error
+ (when (file-exists-p ics-file)
+ (delete-file ics-file))
+ (funcall callback nil (error-message-string err)))))
+
+(defun calendar-sync--mark-sync-failed (name reason)
+ "Record failed sync state for calendar NAME with REASON."
+ (calendar-sync--set-calendar-state
+ name
+ (list :status 'error
+ :last-sync (plist-get (calendar-sync--get-calendar-state name) :last-sync)
+ :last-error reason))
+ (calendar-sync--save-state)
+ (message "calendar-sync: [%s] Sync failed (see *Messages*)" name))
+
+;;; Google Calendar API Fetch Path
+
+(defun calendar-sync--api-script ()
+ "Return the absolute path to the Google Calendar API helper script.
+Resolved relative to this module so batch workers and tests don't depend
+on `user-emacs-directory'."
+ (let ((module-dir (file-name-directory calendar-sync--module-file)))
+ (expand-file-name "calendar_sync_api.py"
+ (expand-file-name "scripts"
+ (file-name-parent-directory module-dir)))))
+
+(defun calendar-sync--api-command (account calendar-id output-file)
+ "Build the command list that runs the API helper.
+ACCOUNT and CALENDAR-ID select the OAuth account and calendar; OUTPUT-FILE
+is where the helper writes rendered org content. The past/future window
+mirrors the .ics path's `calendar-sync-past-months' /
+`calendar-sync-future-months'. When `calendar-sync-skip-declined' is nil,
+passes --keep-declined so the API path honors the same toggle."
+ (append
+ (list calendar-sync-python-command
+ (calendar-sync--api-script)
+ "--account" account
+ "--calendar-id" calendar-id
+ "--output" output-file
+ "--past-months" (number-to-string calendar-sync-past-months)
+ "--future-months" (number-to-string calendar-sync-future-months))
+ (unless calendar-sync-skip-declined
+ (list "--keep-declined"))))
+
+(defun calendar-sync--sync-calendar-api (calendar)
+ "Sync a single Google CALENDAR via the API helper script.
+CALENDAR is a plist with :name, :account, :calendar-id, and :file keys.
+The helper fetches, filters, and renders org in one pass and writes :file
+directly, so it runs in a single external process off the interactive thread."
+ (let* ((name (plist-get calendar :name))
+ (account (plist-get calendar :account))
+ (calendar-id (plist-get calendar :calendar-id))
+ (file (plist-get calendar :file))
+ (fetch-start (float-time)))
+ (calendar-sync--set-calendar-state name '(:status syncing))
+ (calendar-sync--log-silently "calendar-sync: [%s] Syncing (API)..." name)
+ (condition-case err
+ (let ((buffer (generate-new-buffer " *calendar-sync-api*")))
+ (make-process
+ :name "calendar-sync-api"
+ :buffer buffer
+ :command (calendar-sync--api-command account calendar-id file)
+ :sentinel
+ (lambda (process _event)
+ (when (memq (process-status process) '(exit signal))
+ (let* ((buf (process-buffer process))
+ (success (and (eq (process-status process) 'exit)
+ (= (process-exit-status process) 0)))
+ (output (when (buffer-live-p buf)
+ (with-current-buffer buf
+ (string-trim (buffer-string))))))
+ (when (buffer-live-p buf)
+ (kill-buffer buf))
+ (if (not success)
+ (calendar-sync--mark-sync-failed
+ name (if (or (null output) (string-empty-p output))
+ "API helper failed"
+ output))
+ (calendar-sync--set-calendar-state
+ name
+ (list :status 'ok
+ :last-sync (current-time)
+ :last-error nil))
+ (setq calendar-sync--last-timezone-offset
+ (calendar-sync--current-timezone-offset))
+ (calendar-sync--save-state)
+ (let ((total-elapsed (- (float-time) fetch-start)))
+ (message "calendar-sync: [%s] Sync complete (%.1fs total) → %s"
+ name total-elapsed file))))))))
+ (error
+ (calendar-sync--log-silently "calendar-sync: [%s] API helper error: %s"
+ name (error-message-string err))
+ (calendar-sync--mark-sync-failed name (error-message-string err))))))
+
+;;; .ics Sync Path
+
+(defun calendar-sync--calendar-url (calendar)
+ "Return the .ics feed URL for CALENDAR, or nil if none is configured.
+An explicit :url wins. Otherwise :secret-host names an auth-source host
+whose stored secret is the URL (kept in auth-source because the .ics URL
+is itself a token)."
+ (or (plist-get calendar :url)
+ (when-let* ((host (plist-get calendar :secret-host)))
+ (cj/auth-source-secret-value host))))
+
+(defun calendar-sync--sync-calendar-ics (calendar)
+ "Sync a single CALENDAR from its .ics feed asynchronously.
+CALENDAR is a plist with :name, :file, and a feed URL resolved by
+`calendar-sync--calendar-url' (an explicit :url, or a :secret-host
+looked up in auth-source)."
+ (let ((name (plist-get calendar :name))
+ (url (calendar-sync--calendar-url calendar))
+ (file (plist-get calendar :file))
+ (fetch-start (float-time)))
+ (calendar-sync--set-calendar-state name '(:status syncing))
+ (calendar-sync--log-silently "calendar-sync: [%s] Syncing..." name)
+ (calendar-sync--fetch-ics-file
+ url
+ (lambda (ics-file)
+ (let ((fetch-elapsed (- (float-time) fetch-start)))
+ (if (null ics-file)
+ (progn
+ (calendar-sync--log-silently "calendar-sync: [%s] Fetch failed" name)
+ (calendar-sync--mark-sync-failed name "Fetch failed"))
+ (when (calendar-sync--debug-p)
+ (calendar-sync--log-silently "calendar-sync: [%s] Fetched in %.1fs"
+ name fetch-elapsed))
+ (calendar-sync--convert-ics-file-async
+ ics-file
+ file
+ (lambda (success error-message)
+ (if (not success)
+ (progn
+ (calendar-sync--log-silently "calendar-sync: [%s] Conversion failed: %s"
+ name error-message)
+ (calendar-sync--mark-sync-failed
+ name
+ (if (or (null error-message)
+ (string-empty-p error-message))
+ "Conversion failed"
+ error-message)))
+ (calendar-sync--set-calendar-state
+ name
+ (list :status 'ok
+ :last-sync (current-time)
+ :last-error nil))
+ (setq calendar-sync--last-timezone-offset
+ (calendar-sync--current-timezone-offset))
+ (calendar-sync--save-state)
+ (let ((total-elapsed (- (float-time) fetch-start)))
+ (message "calendar-sync: [%s] Sync complete (%.1fs total) → %s"
+ name total-elapsed file)))))))))))
+
+(provide 'calendar-sync-source)
+;;; calendar-sync-source.el ends here