diff options
| author | Craig Jennings <c@cjennings.net> | 2026-05-20 11:58:40 -0400 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-05-20 11:58:40 -0400 |
| commit | 4a6201dd0117df55d164cee969f7c3c8123f6b28 (patch) | |
| tree | 8e71986b40f19c3fa598ed436a648795b58784d8 | |
| parent | d6a995b9090ca35190e59e765d5c14daf887e9d8 (diff) | |
| download | dotemacs-4a6201dd0117df55d164cee969f7c3c8123f6b28.tar.gz dotemacs-4a6201dd0117df55d164cee969f7c3c8123f6b28.zip | |
feat(calendar-sync): dispatch Google calendars through API helper
The Python helper from d6a995b could fetch and render on its own, but nothing in Emacs called it. This wires it in. Each entry in calendar-sync-calendars now takes a :fetcher key. 'api routes through the helper, and the default 'ics keeps the existing curl + Elisp parser path. Proton and any plain .ics feed work unchanged because the key defaults to 'ics.
The 'api path reads :account and :calendar-id off the calendar plist, builds the helper command (honoring the past/future window and the calendar-sync-skip-declined toggle), and runs it through make-process. The script writes the org file directly, so the sentinel only handles state bookkeeping and failure reporting, the same as the .ics worker.
I split the old --sync-calendar body into --sync-calendar-ics and turned --sync-calendar into a dispatcher. The command builder and script-path resolution are pure functions, tested directly. The dispatch routing is tested with both leaf syncers stubbed, so no process runs. I added 14 tests across the two new files, and the full suite is green.
Running the 'api path still needs the one-time OAuth bootstrap from docs/calendar-sync-api-setup.org.
| -rw-r--r-- | modules/calendar-sync.el | 116 | ||||
| -rw-r--r-- | tests/test-calendar-sync--api-command.el | 99 | ||||
| -rw-r--r-- | tests/test-calendar-sync--sync-dispatch.el | 81 |
3 files changed, 290 insertions, 6 deletions
diff --git a/modules/calendar-sync.el b/modules/calendar-sync.el index 2f2b8b4f..2fb1df03 100644 --- a/modules/calendar-sync.el +++ b/modules/calendar-sync.el @@ -87,15 +87,26 @@ (defvar calendar-sync-calendars nil "List of calendars to sync. -Each calendar is a plist with the following keys: - :name - Display name for the calendar (used in logs and prompts) - :url - URL to fetch .ics file from - :file - Output file path for org format +Each calendar is a plist. Common keys: + :name - Display name for the calendar (used in logs and prompts) + :file - Output file path for org format + :fetcher - Fetch path: \\='ics (default) or \\='api + +For the default \\='ics fetcher (Proton, plain .ics feeds): + :url - URL to fetch the .ics file from + +For the \\='api fetcher (Google Calendar, sees per-occurrence response +status so OOO auto-declines on recurring events can be filtered): + :account - OAuth account nickname (work, personal, ...) matching the + token file under ~/.config/calendar-sync/ + :calendar-id - Calendar ID (\"primary\" or a long calendar address) Example: (setq calendar-sync-calendars \\='((:name \"google\" - :url \"https://calendar.google.com/calendar/ical/.../basic.ics\" + :fetcher api + :account \"work\" + :calendar-id \"primary\" :file gcal-file) (:name \"proton\" :url \"https://calendar.proton.me/api/calendar/v1/url/.../calendar.ics\" @@ -141,6 +152,11 @@ Default: 3 months. This keeps recent history visible in org-agenda.") "Number of months in the future to include when expanding recurring events. Default: 12 months. This provides a full year of future events.") +(defvar calendar-sync-python-command "python3" + "Executable used to run the Google Calendar API helper script. +Only the API fetch path (a calendar with `:fetcher' \\='api) uses it; the +default .ics path shells out to curl instead.") + (defvar calendar-sync-fetch-timeout 120 "Maximum time in seconds for a calendar fetch to complete. This is the total time allowed for the entire transfer (connect + download). @@ -1388,14 +1404,102 @@ Checks `cj/debug-modules' for symbol `calendar-sync' or t (all)." (or (eq cj/debug-modules t) (memq 'calendar-sync cj/debug-modules)))) +;;; 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)))))) + ;;; Single Calendar Sync (defun calendar-sync--sync-calendar (calendar) "Sync a single CALENDAR asynchronously. -CALENDAR is a plist with :name, :url, and :file keys. +CALENDAR is a plist with :name, :file, and either :url (the default \\='ics +fetcher) or :account + :calendar-id (the \\='api fetcher). Dispatches on the +:fetcher key, defaulting to the .ics path. Updates calendar state and saves to disk on completion. The fetch and conversion run in external processes so parsing and writing large calendar files do not block the interactive Emacs thread." + (if (eq (plist-get calendar :fetcher) 'api) + (calendar-sync--sync-calendar-api calendar) + (calendar-sync--sync-calendar-ics calendar))) + +(defun calendar-sync--sync-calendar-ics (calendar) + "Sync a single CALENDAR from its :url .ics feed asynchronously. +CALENDAR is a plist with :name, :url, and :file keys." (let ((name (plist-get calendar :name)) (url (plist-get calendar :url)) (file (plist-get calendar :file)) diff --git a/tests/test-calendar-sync--api-command.el b/tests/test-calendar-sync--api-command.el new file mode 100644 index 00000000..21c09f51 --- /dev/null +++ b/tests/test-calendar-sync--api-command.el @@ -0,0 +1,99 @@ +;;; test-calendar-sync--api-command.el --- Tests for API command builder -*- lexical-binding: t; -*- + +;;; Commentary: +;; Unit tests for the Google Calendar API fetch path's pure helpers: +;; `calendar-sync--api-script' (resolves the helper script path) and +;; `calendar-sync--api-command' (builds the make-process argument list). +;; Covers Normal, Boundary, and Error cases. + +;;; Code: + +(require 'ert) +(require 'calendar-sync) + +;;; calendar-sync--api-script + +(ert-deftest test-calendar-sync--api-script-normal-resolves-to-helper () + "Normal: the script path ends with the helper filename and is absolute." + (let ((path (calendar-sync--api-script))) + (should (file-name-absolute-p path)) + (should (string-suffix-p "scripts/calendar_sync_api.py" path)))) + +(ert-deftest test-calendar-sync--api-script-normal-no-dotdot () + "Normal: the resolved path is collapsed (no literal ../ segment)." + (let ((path (calendar-sync--api-script))) + (should-not (string-match-p "\\.\\./" path)))) + +;;; calendar-sync--api-command — Normal + +(ert-deftest test-calendar-sync--api-command-normal-structure () + "Normal: command starts with the python binary + script, then the flags." + (let ((calendar-sync-python-command "python3") + (calendar-sync-past-months 3) + (calendar-sync-future-months 12) + (calendar-sync-skip-declined t)) + (let ((cmd (calendar-sync--api-command "work" "primary" "/tmp/out.org"))) + (should (equal (nth 0 cmd) "python3")) + (should (string-suffix-p "calendar_sync_api.py" (nth 1 cmd))) + (should (member "--account" cmd)) + (should (member "work" cmd)) + (should (member "--calendar-id" cmd)) + (should (member "primary" cmd)) + (should (member "--output" cmd)) + (should (member "/tmp/out.org" cmd))))) + +(ert-deftest test-calendar-sync--api-command-normal-flag-pairing () + "Normal: each value immediately follows its flag." + (let ((calendar-sync-python-command "python3") + (calendar-sync-skip-declined t)) + (let ((cmd (calendar-sync--api-command "personal" "abc123" "/tmp/gcal.org"))) + (should (equal "personal" (nth (1+ (cl-position "--account" cmd :test #'equal)) cmd))) + (should (equal "abc123" (nth (1+ (cl-position "--calendar-id" cmd :test #'equal)) cmd))) + (should (equal "/tmp/gcal.org" (nth (1+ (cl-position "--output" cmd :test #'equal)) cmd)))))) + +(ert-deftest test-calendar-sync--api-command-normal-window-from-defvars () + "Normal: past/future month flags reflect the configured defvars." + (let ((calendar-sync-python-command "python3") + (calendar-sync-past-months 6) + (calendar-sync-future-months 9) + (calendar-sync-skip-declined t)) + (let ((cmd (calendar-sync--api-command "work" "primary" "/tmp/out.org"))) + (should (equal "6" (nth (1+ (cl-position "--past-months" cmd :test #'equal)) cmd))) + (should (equal "9" (nth (1+ (cl-position "--future-months" cmd :test #'equal)) cmd)))))) + +(ert-deftest test-calendar-sync--api-command-normal-honors-python-command () + "Normal: a custom python command is used as argv[0]." + (let ((calendar-sync-python-command "/usr/bin/python3.14") + (calendar-sync-skip-declined t)) + (let ((cmd (calendar-sync--api-command "work" "primary" "/tmp/out.org"))) + (should (equal "/usr/bin/python3.14" (nth 0 cmd)))))) + +;;; calendar-sync--api-command — Boundary (declined toggle) + +(ert-deftest test-calendar-sync--api-command-boundary-skip-declined-omits-flag () + "Boundary: with skip-declined on (default), --keep-declined is absent. +The helper filters declines by default, matching the .ics path." + (let ((calendar-sync-python-command "python3") + (calendar-sync-skip-declined t)) + (let ((cmd (calendar-sync--api-command "work" "primary" "/tmp/out.org"))) + (should-not (member "--keep-declined" cmd))))) + +(ert-deftest test-calendar-sync--api-command-boundary-keep-declined-adds-flag () + "Boundary: with skip-declined nil, --keep-declined is passed through. +This keeps the API path honoring the same toggle as the parser path." + (let ((calendar-sync-python-command "python3") + (calendar-sync-skip-declined nil)) + (let ((cmd (calendar-sync--api-command "work" "primary" "/tmp/out.org"))) + (should (member "--keep-declined" cmd))))) + +;;; calendar-sync--api-command — Error + +(ert-deftest test-calendar-sync--api-command-error-returns-string-list () + "Error: every element of the command list is a string (make-process safe)." + (let ((calendar-sync-python-command "python3") + (calendar-sync-skip-declined nil)) + (let ((cmd (calendar-sync--api-command "work" "primary" "/tmp/out.org"))) + (should (cl-every #'stringp cmd))))) + +(provide 'test-calendar-sync--api-command) +;;; test-calendar-sync--api-command.el ends here diff --git a/tests/test-calendar-sync--sync-dispatch.el b/tests/test-calendar-sync--sync-dispatch.el new file mode 100644 index 00000000..22deeef0 --- /dev/null +++ b/tests/test-calendar-sync--sync-dispatch.el @@ -0,0 +1,81 @@ +;;; test-calendar-sync--sync-dispatch.el --- Tests for fetcher dispatch -*- lexical-binding: t; -*- + +;;; Commentary: +;; Unit tests for `calendar-sync--sync-calendar' dispatch. It routes a +;; calendar plist to the API helper when :fetcher is \\='api, and to the .ics +;; path otherwise (\\='ics, nil, or any other value). The two leaf syncers are +;; stubbed so no external process runs. +;; Covers Normal, Boundary, and Error cases. + +;;; Code: + +(require 'ert) +(require 'calendar-sync) + +(defmacro test-sync-dispatch--with-stubs (&rest body) + "Run BODY with both leaf syncers stubbed to record their calls. +Binds `api-calls' and `ics-calls' to lists of the calendars each received." + (declare (indent 0)) + `(let ((api-calls '()) + (ics-calls '())) + (cl-letf (((symbol-function 'calendar-sync--sync-calendar-api) + (lambda (cal) (push cal api-calls))) + ((symbol-function 'calendar-sync--sync-calendar-ics) + (lambda (cal) (push cal ics-calls)))) + ,@body))) + +;;; Normal + +(ert-deftest test-calendar-sync--sync-dispatch-normal-api-fetcher () + "Normal: :fetcher \\='api routes to the API syncer only." + (test-sync-dispatch--with-stubs + (let ((cal '(:name "google" :fetcher api :account "work" + :calendar-id "primary" :file "/tmp/gcal.org"))) + (calendar-sync--sync-calendar cal) + (should (equal (list cal) api-calls)) + (should (null ics-calls))))) + +(ert-deftest test-calendar-sync--sync-dispatch-normal-ics-fetcher () + "Normal: :fetcher \\='ics routes to the .ics syncer only." + (test-sync-dispatch--with-stubs + (let ((cal '(:name "proton" :fetcher ics :url "https://x/y.ics" + :file "/tmp/pcal.org"))) + (calendar-sync--sync-calendar cal) + (should (equal (list cal) ics-calls)) + (should (null api-calls))))) + +;;; Boundary + +(ert-deftest test-calendar-sync--sync-dispatch-boundary-missing-fetcher-defaults-ics () + "Boundary: a calendar with no :fetcher key defaults to the .ics path. +This is what keeps existing Proton/.ics config working unchanged." + (test-sync-dispatch--with-stubs + (let ((cal '(:name "legacy" :url "https://x/y.ics" :file "/tmp/c.org"))) + (calendar-sync--sync-calendar cal) + (should (equal (list cal) ics-calls)) + (should (null api-calls))))) + +(ert-deftest test-calendar-sync--sync-dispatch-boundary-nil-fetcher-defaults-ics () + "Boundary: an explicit :fetcher nil also defaults to the .ics path." + (test-sync-dispatch--with-stubs + (let ((cal '(:name "legacy" :fetcher nil :url "https://x/y.ics" + :file "/tmp/c.org"))) + (calendar-sync--sync-calendar cal) + (should (equal (list cal) ics-calls)) + (should (null api-calls))))) + +;;; Error + +(ert-deftest test-calendar-sync--sync-dispatch-error-unknown-fetcher-defaults-ics () + "Error: an unrecognized :fetcher value falls back to the .ics path. +Only \\='api is special-cased; anything else takes the safe default rather +than crashing." + (test-sync-dispatch--with-stubs + (let ((cal '(:name "weird" :fetcher carrier-pigeon :url "https://x/y.ics" + :file "/tmp/c.org"))) + (calendar-sync--sync-calendar cal) + (should (equal (list cal) ics-calls)) + (should (null api-calls))))) + +(provide 'test-calendar-sync--sync-dispatch) +;;; test-calendar-sync--sync-dispatch.el ends here |
