diff options
| author | Craig Jennings <c@cjennings.net> | 2026-05-10 03:09:37 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-05-10 03:09:37 -0500 |
| commit | f89b6f22409318ac3124138f7d230c829e6d73c5 (patch) | |
| tree | 85bef5f0016fa66b082e524e0f152e844a60a8c3 /modules/calendar-sync.el | |
| parent | 1d24227c1335b8b154d0bd14a34b6db5e8069f02 (diff) | |
| download | dotemacs-f89b6f22409318ac3124138f7d230c829e6d73c5.tar.gz dotemacs-f89b6f22409318ac3124138f7d230c829e6d73c5.zip | |
Keep calendar sync off the UI thread
Move calendar feed conversion into an isolated batch Emacs worker so large parse/write cycles do not freeze interactive editing. Cover the worker command, isolated logging, quoted settings, and sync success/failure paths with focused ERTs.
Diffstat (limited to 'modules/calendar-sync.el')
| -rw-r--r-- | modules/calendar-sync.el | 242 |
1 files changed, 185 insertions, 57 deletions
diff --git a/modules/calendar-sync.el b/modules/calendar-sync.el index b232567a..f87d0192 100644 --- a/modules/calendar-sync.el +++ b/modules/calendar-sync.el @@ -69,6 +69,15 @@ ;;; Code: +(require 'cl-lib) +(require 'subr-x) + +(defun calendar-sync--log-silently (format-string &rest args) + "Log FORMAT-STRING with ARGS without requiring the full config." + (if (fboundp 'cj/log-silently) + (apply #'cj/log-silently format-string args) + (apply #'message format-string args))) + ;;; Configuration (defgroup calendar-sync nil @@ -129,6 +138,17 @@ Large calendars (thousands of events) may need more time on slow connections. A separate 10-second connect timeout ensures fast failure when a host is unreachable.") +(defvar calendar-sync--module-file + (let* ((loaded-file (or load-file-name buffer-file-name)) + (source-file (when loaded-file + (concat (file-name-sans-extension loaded-file) ".el")))) + (if (and source-file (file-readable-p source-file)) + source-file + loaded-file)) + "Absolute path to this module file. +Used by the batch conversion worker so it can load the same parser code +without loading the user's init file.") + ;;; Internal state (defvar calendar-sync--timer nil @@ -209,7 +229,7 @@ Example: -21600 → 'UTC-6' or 'UTC-6:00'." (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)))))) + (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." @@ -773,7 +793,7 @@ TZ database as the `date' command." (nth 2 local) ; hour (nth 1 local))) ; minute (error - (cj/log-silently "calendar-sync: Error converting timezone %s: %s" + (calendar-sync--log-silently "calendar-sync: Error converting timezone %s: %s" source-tz (error-message-string err)) nil)))) @@ -978,7 +998,7 @@ BASE-EVENT is the event plist, RRULE is parsed rrule, RANGE is date range." ;; 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)) + (calendar-sync--log-silently "calendar-sync: WARNING: Hit max iterations (%d) expanding weekly event" max-iterations)) (nreverse occurrences))) (defun calendar-sync--expand-monthly (base-event rrule range) @@ -1011,7 +1031,7 @@ Filters out dates excluded via EXDATE properties." ('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) + (_ (calendar-sync--log-silently "calendar-sync: Unsupported RRULE frequency: %s" freq) nil)))) ;; Filter out EXDATE occurrences (if exdates @@ -1163,8 +1183,8 @@ RECURRENCE-ID exceptions are applied to override specific occurrences." (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)) + (calendar-sync--log-silently "calendar-sync: WARNING: Hit max events limit (%d), some events may be missing" max-events)) + (calendar-sync--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) @@ -1177,7 +1197,7 @@ RECURRENCE-ID exceptions are applied to override specific occurrences." "\n") nil))) (error - (cj/log-silently "calendar-sync: Parse error: %s" (error-message-string err)) + (calendar-sync--log-silently "calendar-sync: Parse error: %s" (error-message-string err)) nil))) ;;; Sync functions @@ -1208,12 +1228,47 @@ invoked when the fetch completes, either successfully or with an error." (if (and (eq (process-status process) 'exit) (= (process-exit-status process) 0)) (calendar-sync--normalize-line-endings (buffer-string)) - (cj/log-silently "calendar-sync: Fetch error: curl failed: %s" (string-trim event)) + (calendar-sync--log-silently "calendar-sync: Fetch error: curl failed: %s" (string-trim event)) nil)))) (kill-buffer buf) (funcall callback content)))))))) (error - (cj/log-silently "calendar-sync: Fetch error: %s" (error-message-string err)) + (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" + "--connect-timeout" "10" + "--max-time" (number-to-string calendar-sync-fetch-timeout) + "-o" temp-file + url) + :sentinel + (lambda (process event) + (when (memq (process-status process) '(exit signal)) + (let ((buf (process-buffer process)) + (success (and (eq (process-status process) 'exit) + (= (process-exit-status process) 0)))) + (when (buffer-live-p buf) + (unless success + (calendar-sync--log-silently "calendar-sync: Fetch error: curl failed: %s" + (string-trim event))) + (kill-buffer buf)) + (if success + (funcall callback temp-file) + (when (file-exists-p temp-file) + (delete-file temp-file)) + (funcall callback nil))))))) + (error + (calendar-sync--log-silently "calendar-sync: Fetch error: %s" (error-message-string err)) (funcall callback nil)))) (defun calendar-sync--write-file (content file) @@ -1225,6 +1280,92 @@ Creates parent directories if needed." (with-temp-file file (insert content))) +(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 ((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" calendar-sync--module-file + "--eval" (format "(calendar-sync--batch-convert-file %S %S %S %S '%S)" + ics-file + output-file + calendar-sync-past-months + calendar-sync-future-months + calendar-sync-user-emails)))) + +(defun calendar-sync--convert-ics-file-async (ics-file output-file callback) + "Convert ICS-FILE to OUTPUT-FILE in a batch Emacs worker. +Calls CALLBACK as (CALLBACK SUCCESS ERROR-MESSAGE). Deletes ICS-FILE after the +worker exits." + (condition-case err + (let ((buffer (generate-new-buffer " *calendar-sync-worker*"))) + (make-process + :name "calendar-sync-worker" + :buffer buffer + :command (calendar-sync--worker-command ics-file output-file) + :sentinel + (lambda (process _event) + (when (memq (process-status process) '(exit signal)) + (let* ((buf (process-buffer process)) + (success (and (eq (process-status process) 'exit) + (= (process-exit-status process) 0))) + (error-message + (when (buffer-live-p buf) + (with-current-buffer buf + (string-trim (buffer-string)))))) + (when (file-exists-p ics-file) + (delete-file ics-file)) + (when (buffer-live-p buf) + (kill-buffer buf)) + (funcall callback success error-message)))))) + (error + (when (file-exists-p ics-file) + (delete-file ics-file)) + (funcall callback nil (error-message-string err))))) + +(defun calendar-sync--mark-sync-failed (name reason) + "Record failed sync state for calendar NAME with REASON." + (calendar-sync--set-calendar-state + name + (list :status 'error + :last-sync (plist-get (calendar-sync--get-calendar-state name) :last-sync) + :last-error reason)) + (calendar-sync--save-state) + (message "calendar-sync: [%s] Sync failed (see *Messages*)" name)) + ;;; Debug Logging (defun calendar-sync--load-private-config () @@ -1250,63 +1391,50 @@ Checks `cj/debug-modules' for symbol `calendar-sync' or t (all)." "Sync a single CALENDAR asynchronously. CALENDAR is a plist with :name, :url, and :file keys. Updates calendar state and saves to disk on completion. -Logs timing for each phase to *Messages* for performance diagnosis." +The fetch and conversion run in external processes so parsing and writing large +calendar files do not block the interactive Emacs thread." (let ((name (plist-get calendar :name)) (url (plist-get calendar :url)) (file (plist-get calendar :file)) (fetch-start (float-time))) - ;; Mark as syncing (calendar-sync--set-calendar-state name '(:status syncing)) - (cj/log-silently "calendar-sync: [%s] Syncing..." name) - (calendar-sync--fetch-ics + (calendar-sync--log-silently "calendar-sync: [%s] Syncing..." name) + (calendar-sync--fetch-ics-file url - (lambda (ics-content) + (lambda (ics-file) (let ((fetch-elapsed (- (float-time) fetch-start))) - (if (null ics-content) + (if (null ics-file) (progn - (cj/log-silently "calendar-sync: [%s] Fetch failed" name) - (calendar-sync--set-calendar-state - name - (list :status 'error - :last-sync (plist-get (calendar-sync--get-calendar-state name) :last-sync) - :last-error "Fetch failed")) - (calendar-sync--save-state) - (message "calendar-sync: [%s] Sync failed (see *Messages*)" name)) + (calendar-sync--log-silently "calendar-sync: [%s] Fetch failed" name) + (calendar-sync--mark-sync-failed name "Fetch failed")) (when (calendar-sync--debug-p) - (cj/log-silently "calendar-sync: [%s] Fetched %dKB in %.1fs" - name (/ (length ics-content) 1024) fetch-elapsed)) - (let* ((parse-start (float-time)) - (org-content (calendar-sync--parse-ics ics-content)) - (parse-elapsed (- (float-time) parse-start))) - (if (null org-content) - (progn - (cj/log-silently "calendar-sync: [%s] Parse failed (%.1fs)" name parse-elapsed) - (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)) - (when (calendar-sync--debug-p) - (cj/log-silently "calendar-sync: [%s] Parsed in %.1fs" name parse-elapsed)) - (let ((write-start (float-time))) - (calendar-sync--write-file org-content file) - (when (calendar-sync--debug-p) - (cj/log-silently "calendar-sync: [%s] Wrote %s in %.2fs" - name (file-name-nondirectory file) - (- (float-time) write-start)))) - (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)))))))))) + (calendar-sync--log-silently "calendar-sync: [%s] Fetched in %.1fs" + name fetch-elapsed)) + (calendar-sync--convert-ics-file-async + ics-file + file + (lambda (success error-message) + (if (not success) + (progn + (calendar-sync--log-silently "calendar-sync: [%s] Conversion failed: %s" + name error-message) + (calendar-sync--mark-sync-failed + name + (if (or (null error-message) + (string-empty-p error-message)) + "Conversion failed" + error-message))) + (calendar-sync--set-calendar-state + name + (list :status 'ok + :last-sync (current-time) + :last-error nil)) + (setq calendar-sync--last-timezone-offset + (calendar-sync--current-timezone-offset)) + (calendar-sync--save-state) + (let ((total-elapsed (- (float-time) fetch-start))) + (message "calendar-sync: [%s] Sync complete (%.1fs total) → %s" + name total-elapsed file))))))))))) (defun calendar-sync--require-calendars () "Return non-nil if calendars are configured, else warn and return nil." |
