Command-line and Emacs Calendar Client
1;;; caledonia.el --- Emacs integration for Caledonia -*- lexical-binding: t -*-
2
3;; Copyright (C) 2025 Ryan Gibb
4
5;; Author: Ryan Gibb <ryan@freumh.org>
6;; Maintainer: Ryan Gibb <ryan@freumh.org>
7;; Version: 0.4.0
8;; Keywords: calendar
9;; Package-Requires: ((emacs "24.4"))
10;; URL: https://ryan.freumh.org/caledonia.html
11
12;; This file is not part of GNU Emacs.
13
14;;; Commentary:
15
16;; This package provides an Emacs interface to the Caledonia calendar CLI.
17;; It communicates with Caledonia using S-expressions for data exchange.
18
19;;; Code:
20
21(require 'cl-lib)
22(require 'calendar)
23(require 'pulse nil t)
24(require 'org)
25
26(defgroup caledonia nil
27 "Interface to Caledonia calendar client."
28 :group 'calendar
29 :prefix "caledonia-")
30
31(defcustom caledonia-executable (executable-find "caled")
32 "Path to the Caledonia executable."
33 :type 'string
34 :group 'caledonia)
35
36(defface caledonia-calendar-name-face
37 '((t :inherit font-lock-function-name-face))
38 "Face used for calendar names in the events view."
39 :group 'caledonia)
40
41(defface caledonia-date-face
42 '((t :inherit font-lock-string-face))
43 "Face used for dates in the events view."
44 :group 'caledonia)
45
46(defface caledonia-summary-face
47 '((t :inherit default))
48 "Face used for event summaries in the events view."
49 :group 'caledonia)
50
51(defface caledonia-location-face
52 '((t :inherit font-lock-comment-face))
53 "Face used for event locations in the events view."
54 :group 'caledonia)
55
56(defcustom caledonia-calendar-column-width 0
57 "Column width for the Calendar entry."
58 :type 'natnum)
59
60(defcustom caledonia-start-column-width 0
61 "Column width for the Start entry."
62 :type 'natnum)
63
64(defcustom caledonia-end-column-width 0
65 "Column width for the End entry."
66 :type 'natnum)
67
68(defcustom caledonia-list-from-date "today"
69 "Default start date for calendar list view."
70 :type 'string
71 :group 'caledonia)
72
73(defcustom caledonia-list-to-date "+3m"
74 "Default end date for calendar list view (3 months from today)."
75 :type 'string
76 :group 'caledonia)
77
78(defcustom caledonia-search-from-date nil
79 "Default start date for calendar search; nil means no start date limit."
80 :type 'string
81 :group 'caledonia)
82
83(defcustom caledonia-search-to-date "+75y"
84 "Default end date for calendar search (75 years from today)."
85 :type 'string
86 :group 'caledonia)
87
88;; Define histories for input fields
89
90(defvar caledonia-from-history nil "History for from date inputs.")
91(defvar caledonia-to-history nil "History for to date inputs.")
92(defvar caledonia-timezone-history nil "History for timezone inputs.")
93(defvar caledonia-calendars-history nil "History for calendar inputs.")
94(defvar caledonia-text-history nil "History for search text inputs.")
95(defvar caledonia-search-fields-history nil "History for search fields inputs.")
96(defvar caledonia-id-history nil "History for event ID inputs.")
97(defvar caledonia-limit-history nil "History for limit inputs.")
98(defvar caledonia-search-prompt-history nil "History for search prompt inputs.")
99
100;; Internal variables
101
102(defvar caledonia--events-buffer "*Caledonia Events*"
103 "Buffer name for displaying Caledonia events.")
104(defvar caledonia--details-buffer "*Caledonia Event Details*"
105 "Buffer name for displaying Caledonia event details.")
106(defvar caledonia--server-process nil
107 "The persistent Caledonia server process.")
108(defvar caledonia--server-buffer-name "*caledonia-server-io*"
109 "Buffer for server process I/O.")
110(defvar caledonia--response-line nil
111 "Last response line received.")
112(defvar caledonia--response-flag nil
113 "Non-nil means a responce has been recieved.")
114(defvar-local caledonia--current-query nil
115 "The current query parameters being displayed in this buffer.")
116
117;; API functions
118
119(defvar caledonia--server-line-buffer "")
120
121(defun caledonia--server-filter (process output)
122 "Filter PROCESS OUTPUT."
123 ;; Append to the ongoing buffer for logging/debugging
124 (when (buffer-live-p (process-buffer process))
125 (with-current-buffer (process-buffer process)
126 (goto-char (point-max))
127 (insert output)))
128 ;; Append new output to line buffer
129 (setq caledonia--server-line-buffer (concat caledonia--server-line-buffer output))
130 ;; Extract full lines
131 (let ((lines (split-string caledonia--server-line-buffer "\n")))
132 ;; Keep the last line (possibly incomplete) for next round
133 (setq caledonia--server-line-buffer (car (last lines)))
134 ;; Process all complete lines
135 (dolist (line (butlast lines))
136 (when (and (not caledonia--response-flag)
137 (not (string-empty-p line)))
138 (setq caledonia--response-line line)
139 (setq caledonia--response-flag t)))))
140
141(defun caledonia--server-sentinel (process event)
142 "Listen on PROCESS for an EVENT."
143 (message "Caledonia Server process event: %s (%s)" process event)
144 (setq caledonia--server-process nil))
145
146(defun caledonia--ensure-server-running ()
147 "Run the caledonia binary in server mode."
148 (unless (and caledonia--server-process (process-live-p caledonia--server-process))
149 (message "Caledonia Starting server...")
150 (setq caledonia--server-process
151 (start-process "caledonia-server"
152 (get-buffer-create caledonia--server-buffer-name)
153 caledonia-executable
154 "server"))
155 (unless (and caledonia--server-process (process-live-p caledonia--server-process))
156 (error "Caledonia Failed to start server process"))
157 (set-process-filter caledonia--server-process #'caledonia--server-filter)
158 (set-process-sentinel caledonia--server-process #'caledonia--server-sentinel)
159 (message "Caledonia Server started.")))
160
161(defun caledonia--send-request (request-str)
162 "Send REQUEST-STR and get responce back."
163 (caledonia--ensure-server-running)
164 (setq caledonia--response-line nil)
165 (setq caledonia--response-flag nil)
166 (process-send-string caledonia--server-process (concat request-str "\n"))
167 ;; Wait for response
168 (let ((start-time (current-time)))
169 (while (and (not caledonia--response-flag)
170 (< (time-to-seconds (time-since start-time)) 5) ; 5 sec timeout
171 (process-live-p caledonia--server-process))
172 (accept-process-output caledonia--server-process 0 100000))) ; Wait 100ms
173 (unless caledonia--response-flag
174 (error "Caledonia Timeout or server died waiting for response"))
175 (condition-case err
176 (let ((response-sexp (read caledonia--response-line)))
177 (unless (and (listp response-sexp) (memq (car response-sexp) '(Ok Error)))
178 (error "Caledonia Invalid response format: %S" response-sexp))
179 (if (eq (car response-sexp) 'Error)
180 (error "Caledonia Server Error: %s" (cadr response-sexp))
181 ;; Return the (Ok ...) payload
182 (cadr response-sexp)))
183 (error "Caledonia Failed to parse response line: %s"
184 caledonia--response-line (error-message-string err))))
185
186(defun caledonia--get-events (event-payload)
187 "Parse EVENT-PAYLOAD of structure (Events (events...))."
188 (if (and (listp event-payload) (eq (car event-payload) 'Events))
189 (let ((event-list (cadr event-payload)))
190 event-list)
191 (error
192 (message "Failed to parse Caledonia output: %s" (error-message-string err))
193 nil)))
194
195;; UI functions
196
197(defun caledonia--format-timestamp (iso-string &optional format)
198 "Format ISO-8601 time string ISO-STRING to human-readable format.
199FORMAT defaults to \"%Y-%m-%d %H:%M\" if not specified."
200 (let* ((parsed (parse-time-string iso-string))
201 (time (apply #'encode-time
202 (append (cl-subseq parsed 0 6) (list nil -1)))))
203 (format-time-string (or format "%Y-%m-%d %H:%M") time)))
204
205(defun caledonia--get-key (key event)
206 "Get KEY from EVENT as a string."
207 (let ((value (cadr (assoc key event))))
208 (cond
209 ((null value) nil)
210 ((stringp value) value)
211 ((symbolp value) (symbol-name value)))))
212
213(defun caledonia--tabulated-list-entries (events)
214 "Convert EVENTS for a format suitable for showing via a tabulated-list-mode'."
215 (let ((max-calendar-width 0)
216 (max-start-width 0)
217 (max-end-width 0)
218 (tabulated-list-entries nil))
219 ;; first pass: calculate maximum widths
220 (dolist (event events)
221 (let* ((calendar (caledonia--get-key 'calendar event))
222 (start (caledonia--get-key 'start event))
223 (end (caledonia--get-key 'end event))
224 (cal-str (if (not calendar) "unkown" calendar))
225 (start-str (caledonia--format-timestamp start))
226 (end-str (when end
227 (caledonia--format-timestamp (format "%s" end)))))
228 (setq max-calendar-width (max max-calendar-width (length cal-str)))
229 (setq max-start-width (max max-start-width (+ (length start-str) 2)))
230 (setq max-end-width (max max-end-width (length end-str)))))
231 (setq caledonia-calendar-column-width (max max-calendar-width (length "Calendar")))
232 (setq caledonia-start-column-width (max max-start-width (length "Start")))
233 (setq caledonia-end-column-width (max max-end-width (length "End")))
234 ;; second pass: prepare tabulated-list entries with properties
235 (setq tabulated-list-entries
236 (mapcar (lambda (event)
237 (let* (
238 (id (caledonia--get-key 'id event))
239 (summary (caledonia--get-key 'summary event))
240 (start (caledonia--get-key 'start event))
241 (end (caledonia--get-key 'end event))
242 (location (caledonia--get-key 'location event))
243 (calendar (caledonia--get-key 'calendar event))
244 (start-str (caledonia--format-timestamp start))
245 (end-str (if end (caledonia--format-timestamp (format "%s" end)) ""))
246 (start-str (if end (format "%s -" start-str) start-str))
247 (location-str (if location (concat " @ " location) ""))
248 (cal-prop (propertize calendar 'face 'caledonia-calendar-name-face))
249 (start-prop (propertize start-str 'face 'caledonia-date-face))
250 (end-prop (propertize end-str 'face 'caledonia-date-face))
251 (summary-prop (propertize (concat summary location-str)
252 'face 'caledonia-summary-face))
253 ;; Store the full event data as a text property for retrieval
254 (entry-id (propertize (format "%s" id) 'event-data event)))
255 (list entry-id (vector cal-prop start-prop end-prop summary-prop))))
256 events))
257 tabulated-list-entries))
258
259(defun caledonia--sort-calendar (A B)
260 "Sort function for calendar column between A and B."
261 (let ((a (aref (cadr A) 0))
262 (b (aref (cadr B) 0)))
263 (string< a b)))
264
265(defun caledonia--sort-start (A B)
266 "Sort function for date/time column between A and B."
267 (let ((a (aref (cadr A) 1))
268 (b (aref (cadr B) 1)))
269 (time-less-p (date-to-time a) (date-to-time b))))
270
271(defun caledonia--sort-end (A B)
272 "Sort function for date/time column between A and B."
273 (let ((a (aref (cadr A) 2))
274 (b (aref (cadr B) 2)))
275 (time-less-p (date-to-time a) (date-to-time b))))
276
277(defun caledonia--make-query (&optional query)
278 "Make a query with the QUERY S-expression.
279If QUERY is nil, use the current query stored in `caledonia--current-query`."
280 (interactive)
281 (let* ((query-to-use (or query caledonia--current-query '())) ;; Use current query if available
282 ;; Ensure to date is set if not present in query
283 (query-to-use (if (assq 'to query-to-use)
284 query-to-use
285 (cons `(to ,caledonia-list-to-date) query-to-use)))
286 (request-str (format "(Query %s)" (prin1-to-string query-to-use)))
287 (payload (caledonia--send-request request-str))
288 (events (caledonia--get-events payload))
289 (entries (caledonia--tabulated-list-entries events)))
290 ;; Save this query for future refreshes if explicitly provided
291 (when query
292 (setq-local caledonia--current-query query-to-use))
293 (setq tabulated-list-entries entries))
294 (setq tabulated-list-format
295 `[("Calendar" ,caledonia-calendar-column-width caledonia--sort-calendar)
296 ("Start" ,caledonia-start-column-width caledonia--sort-start)
297 ("End" ,caledonia-end-column-width caledonia--sort-end)
298 ("Summary" 0 t)])
299 (setq tabulated-list-sort-key (cons "Start" nil))
300 (tabulated-list-init-header)
301 (tabulated-list-print t))
302
303(defun caledonia--find-and-highlight-event-in-file (file event-id)
304 "Find EVENT-ID in FILE, position cursor, and highlight the event.
305Return non-nil if the event was found."
306 (when (and file event-id)
307 (let ((id-str (format "%s" event-id))
308 (found nil))
309 ;; Try to find and highlight iCalendar VEVENT block
310 (goto-char (point-min))
311 (when (and (string-match-p "\\.ics$" file)
312 (search-forward (format "UID:%s" id-str) nil t))
313 ;; Found the UID in an ICS file, try to highlight the VEVENT block
314 (let ((uid-pos (match-beginning 0))
315 (vevent-start nil)
316 (vevent-end nil))
317 ;; Find start of the VEVENT block
318 (save-excursion
319 (goto-char uid-pos)
320 (if (search-backward "BEGIN:VEVENT" nil t)
321 (setq vevent-start (match-beginning 0))
322 (setq vevent-start uid-pos)))
323 ;; Find end of the VEVENT block
324 (save-excursion
325 (goto-char uid-pos)
326 (if (search-forward "END:VEVENT" nil t)
327 (setq vevent-end (match-end 0))
328 (setq vevent-end (line-end-position))))
329 ;; Highlight the whole VEVENT block if found
330 (when (and vevent-start vevent-end)
331 (goto-char vevent-start)
332 (caledonia--highlight-region vevent-start vevent-end)
333 (recenter)
334 (setq found t))))
335 (unless found
336 (message "Event ID not found in file"))
337 found)))
338
339(defun caledonia--display-event-details (event)
340 "Display details for EVENT in a separate buffer."
341 (let ((buf (get-buffer-create caledonia--details-buffer)))
342 (with-current-buffer buf
343 (let ((inhibit-read-only t))
344 (erase-buffer)
345 (special-mode)
346 (let* ((id (caledonia--get-key 'id event))
347 (summary (caledonia--get-key 'summary event))
348 (description (caledonia--get-key 'description event))
349 (start (caledonia--get-key 'start event))
350 (end (caledonia--get-key 'end event))
351 (location (caledonia--get-key 'location event))
352 (calendar (caledonia--get-key 'calendar event))
353 (file (caledonia--get-key 'file event))
354 (start-str (when start (caledonia--format-timestamp start)))
355 (end-str (when end (caledonia--format-timestamp end))))
356 (when id
357 (insert (propertize "Summary: " 'face 'bold) summary "\n"))
358 (when id
359 (insert (propertize "ID: " 'face 'bold) id "\n"))
360 (when calendar
361 (insert (propertize "Calendar: " 'face 'bold) calendar "\n"))
362 (when start-str
363 (insert (propertize "Start: " 'face 'bold) start-str "\n"))
364 (when end-str
365 (insert (propertize "End: " 'face 'bold) end-str "\n"))
366 (when location
367 (insert (propertize "Location: " 'face 'bold) location "\n"))
368 (when file
369 (insert (propertize "File: " 'face 'bold)
370 (propertize file 'face 'link
371 'mouse-face 'highlight
372 'help-echo "Click to open file with highlighting"
373 'keymap (let ((map (make-sparse-keymap))
374 (event-copy event))
375 (define-key map [mouse-1]
376 (lambda ()
377 (interactive)
378 (let ((file-path file)
379 (id-val (caledonia--get-key 'id event-copy)))
380 (find-file file-path)
381 (caledonia--find-and-highlight-event-in-file
382 file-path id-val))))
383 (define-key map (kbd "RET")
384 (lambda ()
385 (interactive)
386 (let ((file-path file)
387 (id-val (caledonia--get-key 'id event-copy)))
388 (find-file file-path)
389 (caledonia--find-and-highlight-event-in-file
390 file-path id-val))))
391 map))
392 "\n"))
393 (when description
394 (insert "\n" (propertize "Description:" 'face 'bold) "\n"
395 (propertize "------------" 'face 'bold) "\n"
396 description "\n")))))
397 (switch-to-buffer-other-window buf)))
398
399(defun caledonia--highlight-region (start end)
400 "Highlight the region between START and END."
401 (when (fboundp 'pulse-momentary-highlight-region)
402 (pulse-momentary-highlight-region start end))
403 ;; Fallback for when pulse is not available
404 (unless (fboundp 'pulse-momentary-highlight-region)
405 (let ((overlay (make-overlay start end)))
406 (overlay-put overlay 'face 'highlight)
407 (run-with-timer 0.5 nil (lambda () (delete-overlay overlay))))))
408
409(defun caledonia--read-date-range ()
410 "Read a date range from the user with `org-mode' date picker integration.
411Returns a cons cell (from-date . to-date).
412The from-date can be nil to indicate no start date constraint."
413 (let (from to)
414 (setq from
415 (if (y-or-n-p "Set a start date? ")
416 (org-read-date nil nil nil "From date: " nil nil t)
417 ; empty string differentiates from nil for optional args later on
418 ""))
419 ;; Use org-mode's date picker for To date (must have a value)
420 (setq to (org-read-date nil nil nil "To date: " nil nil t))
421 (cons from to)))
422
423;; Query parameter modification functions
424
425(defun caledonia-query-date-range ()
426 "Set the date range for the current calendar view."
427 (interactive)
428 (when (eq major-mode 'caledonia-mode)
429 (let* ((dates (caledonia--read-date-range))
430 (from (car dates))
431 (to (cdr dates))
432 (current-query caledonia--current-query)
433 (new-query (copy-tree current-query)))
434 ;; Update the query with the new date range
435 (setq new-query (assq-delete-all 'from new-query))
436 (setq new-query (assq-delete-all 'to new-query))
437 (when (and from (not (string-empty-p from)))
438 (push `(from ,from) new-query))
439 (when (and to (not (string-empty-p to)))
440 (push `(to ,to) new-query))
441 ;; Execute the updated query
442 (caledonia--make-query new-query))))
443
444(defun caledonia-query-calendars ()
445 "Set the calendars to filter by for the current calendar view.
446Fetches available calendars from server to allow selection from a list."
447 (interactive)
448 (when (eq major-mode 'caledonia-mode)
449 (let* ((available-calendars
450 (caledonia--send-request "ListCalendars"))
451 (calendars-list
452 (if (and (listp available-calendars)
453 (eq (car available-calendars) 'Calendars))
454 (cadr available-calendars)
455 (progn
456 (message "Failed to get calendar list from server")
457 nil)))
458 ;; Use completing-read-multiple to select from available calendars
459 (selected-calendars
460 (completing-read-multiple
461 "Select calendars (comma-separated, empty for all): "
462 ;; Use empty list if no calendars found
463 (or calendars-list '())
464 nil nil
465 (let ((current-calendars (cdr (assq 'calendars caledonia--current-query))))
466 (when current-calendars
467 (mapconcat #'identity current-calendars ",")))
468 'caledonia-calendars-history))
469 (calendars (mapcar #'string-trim selected-calendars))
470 (current-query caledonia--current-query)
471 (new-query (copy-tree current-query)))
472 ;; Update the query with the new calendars
473 (setq new-query (assq-delete-all 'calendars new-query))
474 (when (and calendars (not (null calendars)))
475 (push `(calendars ,calendars) new-query))
476 ;; Execute the updated query
477 (caledonia--make-query new-query))))
478
479(defun caledonia-query-text ()
480 "Set the search text for the current calendar view."
481 (interactive)
482 (when (eq major-mode 'caledonia-mode)
483 (let* ((text (read-string "Search text (leave empty for no text search): "
484 nil 'caledonia-text-history))
485 (search-in-str (when (and text (not (string-empty-p text)))
486 (read-string "Search in (summary,description,location - leave empty for all): "
487 nil 'caledonia-search-fields-history)))
488 (search-in (when (and search-in-str (not (string-empty-p search-in-str)))
489 (mapcar (lambda (field)
490 (intern (string-trim field)))
491 (split-string search-in-str "," t))))
492 (current-query caledonia--current-query)
493 (new-query (copy-tree current-query)))
494 ;; Update the query with the new text search parameters
495 (setq new-query (assq-delete-all 'text new-query))
496 (setq new-query (assq-delete-all 'search_in new-query))
497 (when (and text (not (string-empty-p text)))
498 (push `(text ,text) new-query))
499 (when search-in
500 (push `(search_in ,search-in) new-query))
501 ;; Execute the updated query
502 (caledonia--make-query new-query))))
503
504(defun caledonia-query-id ()
505 "Set the event ID to filter by for the current calendar view."
506 (interactive)
507 (when (eq major-mode 'caledonia-mode)
508 (let* ((id (read-string "Event ID (leave empty for all events): "
509 nil 'caledonia-id-history))
510 (current-query caledonia--current-query)
511 (new-query (copy-tree current-query)))
512 ;; Update the query with the new ID
513 (setq new-query (assq-delete-all 'id new-query))
514 (when (and id (not (string-empty-p id)))
515 (push `(id ,id) new-query))
516 ;; Execute the updated query
517 (caledonia--make-query new-query))))
518
519(defun caledonia-query-recurring ()
520 "Set whether to filter by recurring events for the current calendar view."
521 (interactive)
522 (when (eq major-mode 'caledonia-mode)
523 (let* ((recurring (completing-read "Recurring events (yes/no/all, leave empty for all): "
524 '("" "yes" "no") nil nil nil))
525 (current-query caledonia--current-query)
526 (new-query (copy-tree current-query)))
527 ;; Update the query with the recurring filter
528 (setq new-query (assq-delete-all 'recurring new-query))
529 (when (not (string-empty-p recurring))
530 (push `(recurring ,(if (string= recurring "yes") t nil)) new-query))
531 ;; Execute the updated query
532 (caledonia--make-query new-query))))
533
534(defun caledonia-query-limit ()
535 "Set the maximum number of events to show in the current calendar view."
536 (interactive)
537 (when (eq major-mode 'caledonia-mode)
538 (let* ((limit-str (read-string "Maximum events to show (leave empty for no limit): "
539 nil 'caledonia-limit-history))
540 (limit (when (and limit-str (not (string-empty-p limit-str)))
541 (string-to-number limit-str)))
542 (current-query caledonia--current-query)
543 (new-query (copy-tree current-query)))
544 ;; Update the query with the new limit
545 (setq new-query (assq-delete-all 'limit new-query))
546 (when limit
547 (push `(limit ,limit) new-query))
548 ;; Execute the updated query
549 (caledonia--make-query new-query))))
550
551(defun caledonia-query-timezone ()
552 "Set the timezone for the current calendar view."
553 (interactive)
554 (when (eq major-mode 'caledonia-mode)
555 (let* ((timezone-str (read-string "Timezone (e.g. Europe/London, leave empty for default): "
556 nil 'caledonia-timezone-history))
557 (timezone (when (not (string-empty-p timezone-str)) timezone-str))
558 (current-query caledonia--current-query)
559 (new-query (copy-tree current-query)))
560 ;; Update the query with the new timezone
561 (setq new-query (assq-delete-all 'timezone new-query))
562 (when timezone
563 (push `(timezone ,timezone) new-query))
564 ;; Execute the updated query
565 (caledonia--make-query new-query))))
566
567;; Buffer functions
568
569(defun caledonia-show-event ()
570 "Show details for the event at point in a separate buffer."
571 (interactive)
572 (when (eq major-mode 'caledonia-mode)
573 (let* ((id (tabulated-list-get-id))
574 (event (when id (get-text-property 0 'event-data id))))
575 (if event
576 (caledonia--display-event-details event)
577 (message "No event at point")))))
578
579(defun caledonia-open-event-file ()
580 "Open the file associated with the event at point.
581If the file contains the event ID, the cursor will be positioned at that
582location."
583 (interactive)
584 (when (eq major-mode 'caledonia-mode)
585 (let* ((id (tabulated-list-get-id))
586 (event (when id (get-text-property 0 'event-data id)))
587 (file (when event (caledonia--get-key 'file event)))
588 (event-id (when event (caledonia--get-key 'id event))))
589 (cond
590 ((not event)
591 (message "No event at point"))
592 ((not file)
593 (message "No file associated with this event"))
594 ((not (file-exists-p file))
595 (message "File does not exist: %s" file))
596 (t
597 (find-file file)
598 (caledonia--find-and-highlight-event-in-file file event-id))))))
599
600(defun caledonia-refresh ()
601 "Refresh calendar data from disk and update the current view.
602This is useful when calendar files have been modified outside Emacs."
603 (interactive)
604 (when (eq major-mode 'caledonia-mode)
605 ;; Send a refresh command to clear the server's cache
606 (caledonia--send-request "Refresh")
607 ;; Re-apply the current query to update the view
608 (when (string= (buffer-name) caledonia--events-buffer)
609 ;; Just use caledonia--make-query without args to use the stored query
610 (caledonia--make-query))))
611
612;; Entry functions
613
614(defun caledonia-query ()
615 "Query events with interactive prompts for all filter parameters.
616Opens a series of prompts to build a complete query and then displays the
617results. After the initial query is displayed, you can further refine the
618results using the caledonia-query-* family of functions."
619 (interactive)
620 (let* (
621 (dates (caledonia--read-date-range))
622 (from (car dates))
623 (to (cdr dates))
624 (timezone-str (read-string "Timezone (e.g. Europe/London, leave empty for default): "
625 nil 'caledonia-timezone-history))
626 (timezone (when (not (string-empty-p timezone-str)) timezone-str))
627 (available-calendars
628 (caledonia--send-request "ListCalendars"))
629 (calendars-list
630 (if (and (listp available-calendars)
631 (eq (car available-calendars) 'Calendars))
632 (cadr available-calendars)
633 (progn
634 (message "Failed to get calendar list from server")
635 nil)))
636 (selected-calendars
637 (completing-read-multiple
638 "Select calendars (comma-separated, empty for all): "
639 (or calendars-list '()) nil nil nil 'caledonia-calendars-history))
640 (calendars (mapcar #'string-trim selected-calendars))
641 (text (read-string "Search text (leave empty for no text search): "
642 nil 'caledonia-text-history))
643 (search-in-str (when (and text (not (string-empty-p text)))
644 (read-string "Search in (summary,description,location - leave empty for all): "
645 nil 'caledonia-search-fields-history)))
646 (search-in (when (and search-in-str (not (string-empty-p search-in-str)))
647 (mapcar (lambda (field)
648 (intern (string-trim field)))
649 (split-string search-in-str "," t))))
650 (id (read-string "Event ID (leave empty for all events): "
651 nil 'caledonia-id-history))
652 (recurring (completing-read "Recurring events (yes/no/all, leave empty for all): "
653 '("" "yes" "no") nil nil nil))
654 (limit-str (read-string "Maximum events to show (leave empty for no limit): "
655 nil 'caledonia-limit-history))
656 (limit (when (and limit-str (not (string-empty-p limit-str)))
657 (string-to-number limit-str)))
658 (query nil))
659 ;; Build query based on parameters
660 (when (and from (not (string-empty-p from)))
661 (push `(from ,from) query))
662 (when (and to (not (string-empty-p to)))
663 (push `(to ,to) query))
664 (when timezone
665 (push `(timezone ,timezone) query))
666 (when calendars
667 (push `(calendars ,calendars) query))
668 (when (and text (not (string-empty-p text)))
669 (push `(text ,text) query))
670 (when search-in
671 (push `(search_in ,search-in) query))
672 (when (and id (not (string-empty-p id)))
673 (push `(id ,id) query))
674 (when (not (string-empty-p recurring))
675 (push `(recurring ,(if (string= recurring "yes") t nil)) query))
676 (when limit
677 (push `(limit ,limit) query))
678 ;; Create buffer and execute query
679 (let ((buffer (get-buffer-create caledonia--events-buffer)))
680 (with-current-buffer buffer
681 ;; Clear the buffer and reset it
682 (let ((inhibit-read-only t))
683 (erase-buffer))
684 ;; Activate our mode and make the query
685 (caledonia-mode)
686 (caledonia--make-query query)
687 (switch-to-buffer buffer)))))
688
689(defun caledonia-list (&optional from-date to-date)
690 "List calendar in a new buffer within the default date range.
691FROM-DATE and TO-DATE override the default date range if provided. TO-DATE is
692required and will use a default if not specified. With prefix arg, prompts for
693the date range with an interactive calendar."
694 (interactive
695 (when current-prefix-arg
696 (let* ((dates (caledonia--read-date-range)))
697 (list (car dates) (cdr dates)))))
698 (let ((buffer (get-buffer-create caledonia--events-buffer))
699 (from (or from-date caledonia-list-from-date))
700 ;; Ensure to date is always provided
701 (to (or (and to-date (not (string-empty-p to-date)) to-date)
702 caledonia-list-to-date)))
703 (with-current-buffer buffer
704 ;; Clear the buffer and reset it
705 (let ((inhibit-read-only t))
706 (erase-buffer))
707 ;; Build the query
708 (let* ((query `((to ,to))))
709 ;; Add from date only if specified
710 (when (and from (not (string-empty-p from)))
711 (setq query (append query `((from ,from)))))
712 ;; Activate our mode and make the query
713 (caledonia-mode)
714 (caledonia--make-query query)
715 (switch-to-buffer buffer)))))
716
717(defun caledonia-search (&optional expr from-date to-date)
718 "Search for query EXPR with optional FROM-DATE and TO-DATE.
719This is an interactive function which asks user for EXPR if not passed as an
720argument. With prefix arg, also prompts for date range with an interactive
721calendar. Use this to find events matching specific text across all calendars.
722TO-DATE is required; a default will be used if not provided."
723 (interactive
724 (let* ((search-text (read-string "Search for: " nil 'caledonia-search-prompt-history))
725 (dates (when current-prefix-arg (caledonia--read-date-range))))
726 (list search-text
727 (when current-prefix-arg (car dates))
728 (when current-prefix-arg (cdr dates)))))
729 (let ((buffer (get-buffer-create caledonia--events-buffer))
730 (from (or from-date caledonia-search-from-date))
731 (to (or to-date caledonia-search-to-date)))
732 (with-current-buffer buffer
733 ;; Clear the buffer and reset it
734 (let ((inhibit-read-only t))
735 (erase-buffer))
736 ;; Build the query
737 (let* ((query `((text ,expr)(to ,to))))
738 ;; Add from date only if specified
739 (when (and from (not (string-empty-p from)))
740 (setq query (append query `((from ,from)))))
741 ;; Activate our mode and make the query
742 (caledonia-mode)
743 (caledonia--make-query query)
744 (switch-to-buffer buffer)))))
745
746;; Modes
747;;;###autoload
748
749;; Create a filter prefix map for query refinement
750(defvar caledonia-filter-map
751 (let ((map (make-sparse-keymap)))
752 (define-key map (kbd "d") 'caledonia-query-date-range)
753 (define-key map (kbd "c") 'caledonia-query-calendars)
754 (define-key map (kbd "t") 'caledonia-query-text)
755 (define-key map (kbd "i") 'caledonia-query-id)
756 (define-key map (kbd "r") 'caledonia-query-recurring)
757 (define-key map (kbd "l") 'caledonia-query-limit)
758 (define-key map (kbd "z") 'caledonia-query-timezone)
759 map)
760 "Keymap for filter commands in Caledonia mode.")
761
762(defvar caledonia-mode-map
763 (let ((map (make-sparse-keymap)))
764 (set-keymap-parent map tabulated-list-mode-map)
765 (define-key map (kbd "RET") 'caledonia-show-event)
766 (define-key map (kbd "M-RET") 'caledonia-open-event-file)
767 (define-key map (kbd "l") 'caledonia-list)
768 (define-key map (kbd "s") 'caledonia-search)
769 (define-key map (kbd "r") 'caledonia-refresh)
770 (define-key map (kbd "q") 'quit-window)
771 ;; Individual filter command bindings
772 (define-key map (kbd "C-c d") 'caledonia-query-date-range)
773 (define-key map (kbd "C-c c") 'caledonia-query-calendars)
774 (define-key map (kbd "C-c t") 'caledonia-query-text)
775 (define-key map (kbd "C-c i") 'caledonia-query-id)
776 (define-key map (kbd "C-c r") 'caledonia-query-recurring)
777 (define-key map (kbd "C-c l") 'caledonia-query-limit)
778 (define-key map (kbd "C-c z") 'caledonia-query-timezone)
779 ;; Use f prefix for filter commands
780 (define-key map (kbd "C-c f") caledonia-filter-map)
781 map)
782 "Keymap for Caledonia mode.")
783
784(define-derived-mode caledonia-mode tabulated-list-mode "Caledonia"
785 "Major mode for displaying calendar entries in a tabular view.")
786
787(provide 'caledonia)
788;;; caledonia.el ends here