1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
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
|