aboutsummaryrefslogtreecommitdiff
path: root/modules/calendar-sync.el
blob: 1079a72becd66757f82fb9227099f2d6d94cda32 (plain)
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
;;; calendar-sync.el --- Multi-calendar sync via .ics  -*- lexical-binding: t; -*-

;; Author: Craig Jennings <c@cjennings.net>
;; Created: 2025-11-16

;;; Commentary:
;;
;; Layer: 3 (Domain Workflow).
;; Category: D/S.
;; Load shape: eager only when calendar-sync.local.el configures calendars.
;; Eager reason: daily agenda workflow; timers and network fetches are guarded.
;; Top-level side effects: defines C-; g map; starts sync only when configured.
;; Runtime requires: cl-lib, subr-x, system-lib, cj-org-text-lib, keybindings,
;;   calendar-sync-ics, calendar-sync-recurrence, calendar-sync-org,
;;   calendar-sync-source.
;; Direct test load: yes.
;;
;; One-way calendar synchronization from configured .ics/API sources into Org
;; files. Feed URLs may be inline or resolved from auth-source via :secret-host.
;;
;; This is the public face of the module: it owns configuration, the parse
;; pipeline orchestrator, the sync dispatch, the user commands, the timer, and
;; the C-; g keymap.  The parsing, recurrence expansion, Org rendering, and
;; fetch/worker code live in the calendar-sync-ics / -recurrence / -org /
;; -source layers, which this module requires.  Every public name is unchanged
;; so existing (require 'calendar-sync) callers and tests keep working.

;;; Code:

(require 'cl-lib)
(require 'subr-x)
(require 'system-lib)  ;; provides cj/auth-source-secret-value (leaf; no ai-config dep)
(require 'cj-org-text-lib)
(require 'keybindings)  ;; provides cj/custom-keymap
(require 'calendar-sync-ics)
(require 'calendar-sync-recurrence)
(require 'calendar-sync-org)
(require 'calendar-sync-source)

;;; Configuration

(defgroup calendar-sync nil
  "One-way calendar synchronization to Org files."
  :group 'calendar)

(defvar calendar-sync-calendars nil
  "List of calendars to sync.
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), give the feed
URL one of two ways:
  :url         - the feed URL inline (plaintext in this file)
  :secret-host - an auth-source host whose secret holds the feed URL,
                 looked up in ~/.authinfo.gpg (encrypted at rest).  Prefer
                 this: the .ics URL is itself a secret token.  If both are
                 set, :url wins.

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\"
           :fetcher api
           :account \"work\"
           :calendar-id \"primary\"
           :file gcal-file)
          (:name \"proton\"
           :url \"https://calendar.proton.me/api/calendar/v1/url/.../calendar.ics\"
           :file pcal-file)))")

(defcustom calendar-sync-private-config-file
  (expand-file-name "calendar-sync.local.el" user-emacs-directory)
  "Private calendar-sync config file loaded when readable.
This file is the intended place to set `calendar-sync-calendars' with private
calendar feed URLs."
  :type 'file
  :group 'calendar-sync)

(defvar calendar-sync-interval-minutes 60
  "Sync interval in minutes.
Default: 60 minutes (1 hour).")

(defvar calendar-sync-auto-start t
  "Whether to automatically start calendar sync when module loads.
If non-nil, sync starts automatically when calendar-sync is loaded.
If nil, user must manually call `calendar-sync-start'.")

(defvar calendar-sync-user-emails
  '("craigmartinjennings@gmail.com" "craig.jennings@deepsat.com" "c@cjennings.net")
  "List of user email addresses for determining acceptance status.
Used by `calendar-sync--find-user-status' to look up the user's
PARTSTAT in event attendee lists.")

(defvar calendar-sync-skip-declined t
  "When non-nil, drop events whose PARTSTAT for the user is \"declined\".
Declined events still arrive in the ICS feed, but they shouldn't show
up on the agenda. Set to nil to keep them (each entry then carries a
:STATUS: declined property drawer).
Note: the ICS feed and the Google Calendar API can disagree — auto-
declines via OOO sometimes write only on the API side, so a few
declined events may still slip through.")

(defvar calendar-sync-past-months 3
  "Number of months in the past to include when expanding recurring events.
Default: 3 months. This keeps recent history visible in org-agenda.")

(defvar calendar-sync-future-months 12
  "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).
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
  "Timer object for automatic syncing.")

;;; Parsing orchestration

(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.
Recurring events are expanded into individual occurrences.
RECURRENCE-ID exceptions are applied to override specific occurrences."
  (condition-case err
      (let* ((range (calendar-sync--get-date-range))
             (events (calendar-sync--split-events ics-content))
             ;; First pass: collect all RECURRENCE-ID exceptions
             (exceptions (calendar-sync--collect-recurrence-exceptions ics-content))
             (parsed-events '())
             (max-events 5000)  ; Safety limit to prevent Emacs from hanging
             (events-generated 0))
        ;; Process each event
        (dolist (event-str events)
          (when (< events-generated max-events)
            (let ((expanded (calendar-sync--expand-recurring-event event-str range)))
              (if expanded
                  ;; Recurring event - add all occurrences with exceptions applied
                  (let ((with-exceptions (calendar-sync--apply-recurrence-exceptions
                                          expanded exceptions)))
                    (setq parsed-events (append parsed-events with-exceptions))
                    (setq events-generated (+ events-generated (length with-exceptions))))
                ;; Non-recurring event - parse normally
                (let ((parsed (calendar-sync--parse-event event-str)))
                  (when (and parsed
                             (calendar-sync--date-in-range-p (plist-get parsed :start) range))
                    (push parsed parsed-events)
                    (setq events-generated (1+ events-generated))))))))
        (when (>= events-generated max-events)
          (calendar-sync--log-silently "calendar-sync: WARNING: Hit max events limit (%d), some events may be missing" max-events))
        (setq parsed-events (calendar-sync--filter-declined parsed-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)
                                      (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)))
          ;; Distinguish a healthy zero-event calendar from garbage: a real
          ;; iCalendar (carries BEGIN:VCALENDAR) with no in-window events
          ;; returns the header alone, so the caller writes an empty calendar
          ;; and reports success.  Non-iCalendar content (an HTML error page, a
          ;; truncated download) has no VCALENDAR and returns nil -- a failure.
          (cond
           (org-entries
            (concat "# Calendar Events\n\n"
                    (string-join org-entries "\n\n")
                    "\n"))
           ((string-match-p "BEGIN:VCALENDAR" ics-content)
            "# Calendar Events\n\n")
           (t nil))))
    (error
     (calendar-sync--log-silently "calendar-sync: Parse error: %s" (error-message-string err))
     nil)))

;;; Sync dispatch

(defun calendar-sync--sync-calendar (calendar)
  "Sync a single CALENDAR asynchronously.
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--require-calendars ()
  "Return non-nil if calendars are configured, else warn and return nil."
  (or calendar-sync-calendars
      (progn (message "calendar-sync: No calendars configured (set calendar-sync-calendars)")
             nil)))

(defun calendar-sync--sync-all-calendars ()
  "Sync all configured calendars asynchronously.
Each calendar syncs in parallel."
  (when (calendar-sync--require-calendars)
    (message "calendar-sync: Syncing %d calendar(s)..." (length calendar-sync-calendars))
    (dolist (calendar calendar-sync-calendars)
      (calendar-sync--sync-calendar calendar))))

(defun calendar-sync--calendar-names ()
  "Return list of configured calendar names."
  (mapcar (lambda (cal) (plist-get cal :name)) calendar-sync-calendars))

(defun calendar-sync--get-calendar-by-name (name)
  "Find calendar plist by NAME, or nil if not found."
  (cl-find-if (lambda (cal) (string= (plist-get cal :name) name))
              calendar-sync-calendars))

;;; Commands

;;;###autoload
(defun calendar-sync-now (&optional calendar-name)
  "Sync calendar(s) now asynchronously.
When called interactively, prompts to select a specific calendar or all.
When called non-interactively with CALENDAR-NAME, syncs that calendar.
When called non-interactively with nil, syncs all calendars."
  (interactive
   (list (when calendar-sync-calendars
           (let ((choices (cons "all" (calendar-sync--calendar-names))))
             (completing-read "Sync calendar: " choices nil t nil nil "all")))))
  (cond
   ((not (calendar-sync--require-calendars)) nil)
   ((or (null calendar-name) (string= calendar-name "all"))
    (calendar-sync--sync-all-calendars))
   (t
    (let ((calendar (calendar-sync--get-calendar-by-name calendar-name)))
      (if calendar
          (calendar-sync--sync-calendar calendar)
        (message "calendar-sync: Calendar '%s' not found" calendar-name))))))

;;;###autoload
(defun calendar-sync-status ()
  "Display sync status for all configured calendars."
  (interactive)
  (when (calendar-sync--require-calendars)
    (let ((status-lines '()))
      (dolist (calendar calendar-sync-calendars)
        (let* ((name (plist-get calendar :name))
               (file (plist-get calendar :file))
               (state (calendar-sync--get-calendar-state name))
               (status (or (plist-get state :status) 'never))
               (last-sync (plist-get state :last-sync))
               (last-error (plist-get state :last-error))
               (status-str
                (pcase status
                  ('ok (format "✓ %s" (if last-sync
                                          (format-time-string "%Y-%m-%d %H:%M" last-sync)
                                        "unknown")))
                  ('error (format "✗ %s" (or last-error "error")))
                  ('syncing "⟳ syncing...")
                  ('never "— never synced"))))
          (push (format "  %s: %s → %s" name status-str (abbreviate-file-name file))
                status-lines)))
      (message "calendar-sync status:\n%s"
               (string-join (nreverse status-lines) "\n")))))

;;; 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--sync-all-calendars))

;;;###autoload
(defun calendar-sync-start ()
  "Start automatic calendar syncing.
Syncs all calendars immediately, then every `calendar-sync-interval-minutes'."
  (interactive)
  (when calendar-sync--timer
    (cancel-timer calendar-sync--timer))
  (when (calendar-sync--require-calendars)
    ;; Sync immediately
    (calendar-sync--sync-all-calendars)
    ;; Start timer for future syncs (convert minutes to seconds)
    (let ((interval-seconds (* calendar-sync-interval-minutes 60)))
      (setq calendar-sync--timer
            (run-at-time interval-seconds
                         interval-seconds
                         #'calendar-sync--sync-timer-function)))
    (message "calendar-sync: Auto-sync started (every %d minutes, %d calendars)"
             calendar-sync-interval-minutes
             (length calendar-sync-calendars))))

;;;###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
  "i" #'calendar-sync-status
  "t" #'calendar-sync-toggle
  "S" #'calendar-sync-start
  "x" #'calendar-sync-stop)

(cj/register-prefix-map "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 i" "sync status"
    "C-; g t" "toggle auto-sync"
    "C-; g S" "start auto-sync"
    "C-; g x" "stop auto-sync"))

;;; Initialization

(calendar-sync--load-private-config)

;; Load saved state from previous session
(calendar-sync--load-state)

;; Check for timezone change on startup
(when (and calendar-sync-calendars
           (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: Will sync on next timer tick")
    ;; 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
    ))

;; Start auto-sync if enabled and calendars are configured
;; Syncs immediately then every calendar-sync-interval-minutes (default: 60 minutes)
(when (and calendar-sync-auto-start
           calendar-sync-calendars
           (not noninteractive))
  (calendar-sync-start))


(provide 'calendar-sync)
;;; calendar-sync.el ends here