Command-line and Emacs Calendar Client

add emacs front end that communicates with a server mode via an sexp protocol

Ryan Gibb 188f2d89 a2787a0f

+4
CHANGELOG.md
···
+
+
### 0.4.0
+
+
- Emacs front end that communicates with a server mode via an S-expression protocol
### 0.3.1
+5 -3
README.md
···
# 📅 Caledonia 🏴󠁧󠁢󠁳󠁣󠁴󠁿
-
Caledonia is a command-line calendar client.
-
Currently, it operates on a [vdir](https://pimutils.org/specs/vdir/) directory of [`.ics`](https://datatracker.ietf.org/doc/html/rfc5545) files (as managed by tools like [vdirsyncer](https://github.com/pimutils/vdirsyncer)).
+
Caledonia is a calendar client with command-line and Emacs front-ends.
+
It operates on a [vdir](https://pimutils.org/specs/vdir/) directory of [`.ics`](https://datatracker.ietf.org/doc/html/rfc5545) files as managed by tools like [vdirsyncer](https://github.com/pimutils/vdirsyncer), which allows it to interact with CalDAV servers.
-
It has the `list`, `search`, `show`, `add`, `delete`, and `edit` subcommands, and has full timezone support.
+
The command-line has the `list`, `search`, `show`, `add`, `delete`, and `edit` subcommands, and has full timezone support.
An example `list` invocation is,
···
personal 2025-04-29 Tue 21:00 - 21:30 (UTC) Grandma call 8601c255-65fc-4bc9-baa9-465dd7b4cd7d
work 2025-04-30 Wed 15:00 - 16:00 (Europe/London) Weekly Meeting 4adcb98dfc1848601e38c2ea55edf71fab786c674d7b72d4c263053b23560a8d
```
+
+
The Emacs client is defined in [./emacs](./emacs) and communicates with `caled server` using a [S-expression](https://en.wikipedia.org/wiki/S-expression) based protocol.
See [TODO](./TODO.org) for future plans.
+13 -3
TODO.org
···
- [x] timezones
- [x] remove collection module
- ref [[https://github.com/ocaml-ppx/ppxlib/issues/481]] cc patrick
+
- [ ] add timezone to call date functions and make it required
- [ ] allow editing recurrence-ids
- [ ] don't load all calendars into memory to show only one event
- [ ] support specifying duration
···
- day of the week
- month names
- allow editting date or time without touching the other
+
- NB in emacs we use org-mode's datetime picker
- [x] diagnose events failing to parse [[https://github.com/robur-coop/icalendar/issues/14]]
- [x] [[https://github.com/robur-coop/icalendar/pull/13][handle RECURRENCE-ID]]
- [x] [[https://github.com/robur-coop/icalendar/issues/15][RRULE with local datetime]]
···
- [ ] support VALARMS
- [ ] support VOTODS
- [ ] support VCARDS
-
- [ ] server mode
-
- and maybe hold =Event='s in-memory instead of parsing them for every =Query=
+
- [x] server mode
+
- [x] hold =Event='s in-memory instead of parsing them for every =Query=
- [ ] implement TUI front end with something like [[https://github.com/leostera/minttea][minttea]]
- [ ] implement an emacs front end, like mu4e to mu
-
- [ ] parallel queries
+
- [x] listing, searching, querying
+
- [x] show details
+
- [x] show file
+
- [x] refresh
+
- [x] list possible calendars
+
- [x] add functions and bindings to change query parameters on the fly
+
- [ ] timezone support
+
- [ ] check the date module
+
- [ ] support adding, deleting, and editing events
+10 -3
bin/add_cmd.ml
···
| None -> Error (`Msg "Start date required")
in
let* end_ =
+
(* if we have an endtime and no end date default to start date *)
let end_date =
-
(* if we have an endtime and no end date default to start date *)
match (end_date, end_time) with
| None, Some _ -> start_date
+
| _ -> end_date
+
in
+
(* if we have a start date and no end date default to start date *)
+
let end_date =
+
match (start_date, end_date) with
+
| Some _, None -> start_date
| _ -> end_date
in
let end_timezone =
···
~calendar_dir_path:(Calendar_dir.get_path calendar_dir)
~summary ~start ?end_ ?location ?description ?recurrence calendar_name
in
-
let* _ = Calendar_dir.add_event ~fs calendar_dir event in
+
let* events = Calendar_dir.get_events ~fs calendar_dir in
+
let* _ = Calendar_dir.add_event ~fs calendar_dir events event in
Printf.printf "Event created with ID: %s\n" (Event.get_id event);
Ok ()
let cmd ~fs calendar_dir =
let run summary start_date start_time end_date end_time location description
-
recur calendar_name timezone end_timezone =
+
recur calendar_name timezone end_timezone () =
match
run ~summary ~start_date ~start_time ~end_date ~end_time ~location
~description ~recur ~calendar_name ?timezone ?end_timezone ~fs
+7 -6
bin/delete_cmd.ml
···
let run ~event_id ~fs calendar_dir =
let ( let* ) = Result.bind in
-
let filter = Query.with_id event_id in
-
let* results = Query.query_without_recurrence ~fs calendar_dir ~filter () in
+
let filter = Event.with_id event_id in
+
let* events = Calendar_dir.get_events ~fs calendar_dir in
+
let events = Event.query_without_recurrence events ~filter () in
let* event =
-
match results with
+
match events with
| [ event ] -> Ok event
| [] -> Error (`Msg ("No events found found for id " ^ event_id))
| _ -> Error (`Msg ("More than one found for id " ^ event_id))
in
-
let result = Calendar_dir.delete_event ~fs calendar_dir event in
+
let result = Calendar_dir.delete_event ~fs calendar_dir events event in
match result with
| Error (`Msg msg) -> Error (`Msg msg)
-
| Ok () ->
+
| Ok _ ->
Printf.printf "Event %s successfully deleted.\n" event_id;
Ok ()
···
Arg.(required & pos 0 (some string) None & info [] ~docv:"EVENT_ID" ~doc)
let cmd ~fs calendar_dir =
-
let run event_id =
+
let run event_id () =
match run ~event_id ~fs calendar_dir with
| Error (`Msg msg) ->
Printf.eprintf "Error: %s\n%!" msg;
+5 -2
bin/dune
···
ptime.clock.os
eio
eio_main
-
timere)
+
timere
+
sexplib
+
sexplib.unix)
(modules
main
query_args
···
show_cmd
add_cmd
delete_cmd
-
edit_cmd))
+
edit_cmd
+
server_cmd))
+5 -4
bin/edit_cmd.ml
···
let run ~event_id ~summary ~start_date ~start_time ~end_date ~end_time ~location
~description ~recur ?timezone ?end_timezone ~fs calendar_dir =
let ( let* ) = Result.bind in
-
let filter = Query.with_id event_id in
-
let* results = Query.query_without_recurrence ~fs calendar_dir ~filter () in
+
let filter = Event.with_id event_id in
+
let* events = Calendar_dir.get_events ~fs calendar_dir in
+
let results = Event.query_without_recurrence events ~filter () in
let* event =
match results with
| [ event ] -> Ok event
···
let* modifed_event =
Event.edit ?summary ?start ?end_ ?location ?description ?recurrence event
in
-
let* _ = Calendar_dir.edit_event ~fs calendar_dir modifed_event in
+
let* _ = Calendar_dir.edit_event ~fs calendar_dir events modifed_event in
Printf.printf "Event %s updated.\n" event_id;
Ok ()
···
let cmd ~fs calendar_dir =
let run event_id summary start_date start_time end_date end_time location
-
description recur timezone end_timezone =
+
description recur timezone end_timezone () =
match
run ~event_id ~summary ~start_date ~start_time ~end_date ~end_time
~location ~description ~recur ?timezone ?end_timezone ~fs calendar_dir
+8 -9
bin/list_cmd.ml
···
| Some f, None ->
let one_month_later = Date.add_months f 1 in
Ok (Some f, one_month_later)
-
| None, Some t ->
-
let today_date = !Date.get_today ~tz () in
-
Ok (Some today_date, Date.to_end_of_day t)
+
| None, Some t -> Ok (None, Date.to_end_of_day t)
| None, None ->
let today_date = !Date.get_today ~tz () in
let one_month_later = Date.add_months today_date 1 in
···
let filter =
match calendars with
| [] -> None
-
| calendar -> Some (Query.in_calendars calendar)
+
| calendar -> Some (Event.in_calendars calendar)
in
let comparator = Query_args.create_event_comparator sort in
-
let* results =
-
Query.query ~fs calendar_dir ?filter ~from ~to_ ~comparator ?limit:count ()
+
let* events = Calendar_dir.get_events ~fs calendar_dir in
+
let events =
+
Event.query events ?filter ~from ~to_ ~comparator ?limit:count ()
in
-
if results = [] then print_endline "No events found."
-
else print_endline (Event.format_events ~format ~tz results);
+
if events = [] then print_endline "No events found."
+
else print_endline (Event.format_events ~format ~tz events);
Ok ()
let cmd ~fs calendar_dir =
let run from_str to_str calendars count format today tomorrow week month
-
timezone sort =
+
timezone sort () =
match
run ?from_str ?to_str ~calendar:calendars ?count ~format ~today ~tomorrow
~week ~month ?timezone ~sort ~fs calendar_dir
+4 -3
bin/main.ml
···
-
(* Main entry point for the calendar CLI *)
-
open Cmdliner
let list_cmd = List_cmd.cmd
···
let add_cmd = Add_cmd.cmd
let delete_cmd = Delete_cmd.cmd
let edit_cmd = Edit_cmd.cmd
+
let server_cmd = Server_cmd.cmd
let doc = "Command-line calendar tool for managing local .ics files"
let version = "%%VERSION%%"
···
add_cmd ~fs calendar_dir;
edit_cmd ~fs calendar_dir;
delete_cmd ~fs calendar_dir;
+
server_cmd ~stdin:(Eio.Stdenv.stdin env)
+
~stdout:(Eio.Stdenv.stdout env) ~fs calendar_dir;
])
with
-
| Ok (`Ok n) -> n
+
| Ok (`Ok f) -> f ()
| Ok _ -> 0
| Error _ -> 1)
+18 -17
bin/search_cmd.ml
···
in
(match calendar with
| [] -> ()
-
| calendars -> filters := Query.in_calendars calendars :: !filters);
+
| calendars -> filters := Event.in_calendars calendars :: !filters);
(match query_text with
| Some text ->
-
if summary then filters := Query.summary_contains text :: !filters;
-
if description then filters := Query.description_contains text :: !filters;
-
if location then filters := Query.location_contains text :: !filters;
+
if summary then filters := Event.summary_contains text :: !filters;
+
if description then filters := Event.description_contains text :: !filters;
+
if location then filters := Event.location_contains text :: !filters;
if not (summary || description || location) then
filters :=
-
Query.or_filter
+
Event.or_filter
[
-
Query.summary_contains text;
-
Query.description_contains text;
-
Query.location_contains text;
+
Event.summary_contains text;
+
Event.description_contains text;
+
Event.location_contains text;
]
:: !filters
| None -> ());
-
if recurring then filters := Query.recurring_only () :: !filters;
-
if non_recurring then filters := Query.non_recurring_only () :: !filters;
+
if recurring then filters := Event.recurring_only () :: !filters;
+
if non_recurring then filters := Event.non_recurring_only () :: !filters;
(match id with
-
| Some id -> filters := Query.with_id id :: !filters
+
| Some id -> filters := Event.with_id id :: !filters
| None -> ());
-
let filter = Query.and_filter !filters in
+
let filter = Event.and_filter !filters in
let comparator = Query_args.create_event_comparator sort in
-
let* results =
-
Query.query ~fs calendar_dir ~filter ~from ~to_ ~comparator ?limit:count ()
+
let* events = Calendar_dir.get_events ~fs calendar_dir in
+
let events =
+
Event.query events ~filter ~from ~to_ ~comparator ?limit:count ()
in
-
if results = [] then print_endline "No events found."
-
else print_endline (Event.format_events ~tz ~format results);
+
if events = [] then print_endline "No events found."
+
else print_endline (Event.format_events ~tz ~format events);
Ok ()
let query_text_arg =
···
let cmd ~fs calendar_dir =
let run query_text from_str to_str calendars count format summary description
location id today tomorrow week month recurring non_recurring timezone
-
sort =
+
sort () =
match
run ?from_str ?to_str ~calendar:calendars ?count ?query_text ~summary
~description ~location ~id ~format ~today ~tomorrow ~week ~month
+85
bin/server_cmd.ml
···
+
open Eio
+
open Cmdliner
+
open Caledonia_lib
+
open Caledonia_lib.Sexp
+
+
let run ~stdin ~stdout ~fs calendar_dir () =
+
let reader = Buf_read.of_flow stdin ~max_size:1_000_000 in
+
let ( let* ) = Result.bind in
+
+
(* Initialize mutable events variable - will be updated on refresh *)
+
let mutable_events = ref (Calendar_dir.get_events ~fs calendar_dir) in
+
+
try
+
while true do
+
let line = Buf_read.line reader in
+
let response =
+
try
+
let sexp = Sexplib.Sexp.of_string line in
+
let request = Sexp.request_of_sexp sexp in
+
match request with
+
| ListCalendars ->
+
let* names = Calendar_dir.list_calendar_names ~fs calendar_dir in
+
Ok (sexp_of_response (Ok (Calendars names)))
+
| Refresh ->
+
(* Reload events from disk *)
+
mutable_events := Calendar_dir.get_events ~fs calendar_dir;
+
(* Return an empty response *)
+
Ok (sexp_of_response (Ok Empty))
+
| Query query_req ->
+
let* filter, from, to_, limit, _tz =
+
generate_query_params query_req
+
in
+
let* events = !mutable_events in
+
let events = Event.query events ~filter ~from ~to_ ?limit () in
+
Ok (sexp_of_response (Ok (Events events)))
+
with
+
| Sexplib.Conv.Of_sexp_error (_exn, bad_sexp) ->
+
let msg =
+
Printf.sprintf "Invalid request format for '%s': %s" line
+
(to_string bad_sexp)
+
in
+
Ok (sexp_of_response (Error msg))
+
| Failure msg ->
+
Ok (sexp_of_response (Error ("Processing failed: " ^ msg)))
+
| exn ->
+
let msg =
+
Printf.sprintf "Unexpected error: %s" (Printexc.to_string exn)
+
in
+
Ok (sexp_of_response (Error msg))
+
in
+
let response_line =
+
to_string
+
(match response with
+
| Ok r -> r
+
| Error (`Msg msg) -> Sexp.sexp_of_response (Error msg))
+
in
+
Flow.copy_string (response_line ^ "\n") stdout
+
done
+
with End_of_file -> ()
+
+
let cmd ~stdin ~stdout ~fs calendar_dir =
+
let run () =
+
let _ = run ~stdin ~stdout ~fs calendar_dir () in
+
0
+
in
+
let term = Term.(const run) in
+
+
let doc = "Process single-line S-expression requests from stdin to stdout." in
+
let man =
+
[
+
`S Manpage.s_description;
+
`P
+
"$(mname) $(tname) reads S-expression requests (one per line) from \
+
stdin, processes them, and writes S-expression responses (one per \
+
line) to stdout.";
+
`P "Example request: '(Query (()))'";
+
`P
+
"Example response: '(Ok (Events ((id ...) (summary ...) ...)))' or \
+
'(Error \"...\")'";
+
`S Manpage.s_examples;
+
`Pre "echo '(Query ((text \\\"meeting\\\")))' | $(mname) $(tname)";
+
]
+
in
+
let info = Cmd.info "server" ~doc ~man in
+
Cmd.v info term
+4 -3
bin/show_cmd.ml
···
let run ~event_id ~format ~fs calendar_dir =
let ( let* ) = Result.bind in
-
let filter = Query.with_id event_id in
-
let* results = Query.query_without_recurrence ~fs calendar_dir ~filter () in
+
let filter = Event.with_id event_id in
+
let* events = Calendar_dir.get_events ~fs calendar_dir in
+
let results = Event.query_without_recurrence events ~filter () in
if results = [] then print_endline "No events found."
else print_endline (Event.format_events ~format results);
Ok ()
···
& info [ "format"; "o" ] ~docv:"FORMAT" ~doc)
let cmd ~fs calendar_dir =
-
let run event_id format =
+
let run event_id format () =
match run ~event_id ~format ~fs calendar_dir with
| Error (`Msg msg) ->
Printf.eprintf "Error: %s\n%!" msg;
+1 -1
caledonia.opam
···
opam-version: "2.0"
-
version: "0.3.0"
+
version: "0.4.0"
maintainer: "Ryan Gibb <ryan@freumh.org"
authors: ["Ryan Gibb <ryan@freumh.org"]
homepage: "https://ryan.freumh.org/caledonia.html"
+2 -2
dune-project
···
(lang dune 3.4)
(name caledonia)
-
(version 0.3.0)
-
(using directory-targets 0.1)
+
(version 0.4.0)
+
(using directory-targets 0.1)
+822
emacs/caledonia.el
···
+
;;; caledonia.el --- Emacs integration for Caledonia -*- lexical-binding: t -*-
+
+
;; Copyright (C) 2025 Ryan Gibb
+
+
;; Author: Ryan Gibb <ryan@freumh.org>
+
;; Version: 0.4
+
;; Package-Requires: ((emacs "27.1"))
+
;; Keywords: calendar, caledonia
+
;; URL: https://ryan.freumh.org/caledonia.html
+
+
;; This file is not part of GNU Emacs.
+
+
;;; Commentary:
+
+
;; This package provides an Emacs interface to the Caledonia calendar CLI.
+
;; It communicates with Caledonia using S-expressions for data exchange.
+
+
;;; Code:
+
+
(require 'cl-lib)
+
(require 'calendar)
+
(require 'pulse nil t)
+
(require 'org)
+
+
(defgroup caledonia nil
+
"Interface to Caledonia calendar client."
+
:group 'calendar
+
:prefix "caledonia-")
+
+
(defcustom caledonia-executable "caled"
+
"Path to the Caledonia executable."
+
:type 'string
+
:group 'caledonia)
+
+
(defface caledonia-calendar-name-face
+
'((t :inherit font-lock-function-name-face))
+
"Face used for calendar names in the events view."
+
:group 'caledonia)
+
+
(defface caledonia-date-face
+
'((t :inherit font-lock-string-face))
+
"Face used for dates in the events view."
+
:group 'caledonia)
+
+
(defface caledonia-summary-face
+
'((t :inherit default))
+
"Face used for event summaries in the events view."
+
:group 'caledonia)
+
+
(defface caledonia-location-face
+
'((t :inherit font-lock-comment-face))
+
"Face used for event locations in the events view."
+
:group 'caledonia)
+
+
(defcustom caledonia-calendar-column-width 0
+
"Column width for the Calendar entry."
+
:type 'natnum)
+
+
(defcustom caledonia-start-column-width 0
+
"Column width for the Start entry."
+
:type 'natnum)
+
+
(defcustom caledonia-end-column-width 0
+
"Column width for the End entry."
+
:type 'natnum)
+
+
(defcustom caledonia-list-from-date "today"
+
"Default start date for calendar list view."
+
:type 'string
+
:group 'caledonia)
+
+
(defcustom caledonia-list-to-date "+3m"
+
"Default end date for calendar list view (3 months from today)."
+
:type 'string
+
:group 'caledonia)
+
+
(defcustom caledonia-search-from-date nil
+
"Default start date for calendar search; nil means no start date limit."
+
:type 'string
+
:group 'caledonia)
+
+
(defcustom caledonia-search-to-date "+75y"
+
"Default end date for calendar search (75 years from today)."
+
:type 'string
+
:group 'caledonia)
+
+
;; Define histories for input fields
+
+
(defvar caledonia-from-history nil "History for from date inputs.")
+
(defvar caledonia-to-history nil "History for to date inputs.")
+
(defvar caledonia-timezone-history nil "History for timezone inputs.")
+
(defvar caledonia-calendars-history nil "History for calendar inputs.")
+
(defvar caledonia-text-history nil "History for search text inputs.")
+
(defvar caledonia-search-fields-history nil "History for search fields inputs.")
+
(defvar caledonia-id-history nil "History for event ID inputs.")
+
(defvar caledonia-limit-history nil "History for limit inputs.")
+
(defvar caledonia-search-prompt-history nil "History for search prompt inputs.")
+
+
;; Internal variables
+
+
(defvar caledonia--events-buffer "*Caledonia Events*"
+
"Buffer name for displaying Caledonia events.")
+
(defvar caledonia--details-buffer "*Caledonia Event Details*"
+
"Buffer name for displaying Caledonia event details.")
+
(defvar caledonia--server-process nil
+
"The persistent Caledonia server process.")
+
(defvar caledonia--server-buffer-name "*caledonia-server-io*"
+
"Buffer for server process I/O.")
+
(defvar caledonia--response-line nil
+
"Last response line received.")
+
(defvar caledonia--response-flag nil
+
"Flag set when response is received.")
+
(defvar-local caledonia--current-query nil
+
"The current query parameters being displayed in this buffer.")
+
+
;; API functions
+
+
(defvar caledonia--server-line-buffer "")
+
+
(defun caledonia--server-filter (process output)
+
;; Append to the ongoing buffer for logging/debugging
+
(when (buffer-live-p (process-buffer process))
+
(with-current-buffer (process-buffer process)
+
(goto-char (point-max))
+
(insert output)))
+
;; Append new output to line buffer
+
(setq caledonia--server-line-buffer (concat caledonia--server-line-buffer output))
+
;; Extract full lines
+
(let ((lines (split-string caledonia--server-line-buffer "\n")))
+
;; Keep the last line (possibly incomplete) for next round
+
(setq caledonia--server-line-buffer (car (last lines)))
+
;; Process all complete lines
+
(dolist (line (butlast lines))
+
(when (and (not caledonia--response-flag)
+
(not (string-empty-p line)))
+
(setq caledonia--response-line line)
+
(setq caledonia--response-flag t)))))
+
+
(defun caledonia--server-sentinel (process event)
+
(message "Caledonia Server process event: %s (%s)" process event)
+
(setq caledonia--server-process nil))
+
+
(defun caledonia--ensure-server-running ()
+
(unless (and caledonia--server-process (process-live-p caledonia--server-process))
+
(message "Caledonia Starting server...")
+
(setq caledonia--server-process
+
(start-process "caledonia-server"
+
(get-buffer-create caledonia--server-buffer-name)
+
caledonia-executable
+
"server"))
+
(unless (and caledonia--server-process (process-live-p caledonia--server-process))
+
(error "Caledonia Failed to start server process."))
+
(set-process-filter caledonia--server-process #'caledonia--server-filter)
+
(set-process-sentinel caledonia--server-process #'caledonia--server-sentinel)
+
(message "Caledonia Server started.")))
+
+
(defun caledonia--send-request (request-str)
+
(caledonia--ensure-server-running)
+
(setq caledonia--response-line nil)
+
(setq caledonia--response-flag nil)
+
(process-send-string caledonia--server-process (concat request-str "\n"))
+
;; Wait for response
+
(let ((start-time (current-time)))
+
(while (and (not caledonia--response-flag)
+
(< (time-to-seconds (time-since start-time)) 5) ; 5 sec timeout
+
(process-live-p caledonia--server-process))
+
(accept-process-output caledonia--server-process 0 100000))) ; Wait 100ms
+
(unless caledonia--response-flag
+
(error "Caledonia Timeout or server died waiting for response."))
+
(condition-case err
+
(let ((response-sexp (read caledonia--response-line)))
+
(unless (and (listp response-sexp) (memq (car response-sexp) '(Ok Error)))
+
(error "Caledonia Invalid response format: %S" response-sexp))
+
(if (eq (car response-sexp) 'Error)
+
(error "Caledonia Server Error: %s" (cadr response-sexp))
+
;; Return the (Ok ...) payload
+
(cadr response-sexp)))
+
(error (error "Caledonia Failed to parse response line: %s"
+
caledonia--response-line (error-message-string err)))))
+
+
(defun caledonia--get-events (event-payload)
+
"Parse SEXP-STRING of structure (Events (events...))"
+
(if (and (listp event-payload) (eq (car event-payload) 'Events))
+
(let ((event-list (cadr event-payload)))
+
event-list)
+
(error
+
(message "Failed to parse Caledonia output: %s" (error-message-string err))
+
nil)))
+
+
;; UI functions
+
+
(defun caledonia--format-timestamp (iso-string &optional format)
+
"Format ISO-8601 time string to human-readable format.
+
FORMAT defaults to \"%Y-%m-%d %H:%M\" if not specified."
+
(let* ((parsed (parse-time-string iso-string))
+
(time (apply #'encode-time
+
(append (cl-subseq parsed 0 6) (list nil -1)))))
+
(format-time-string (or format "%Y-%m-%d %H:%M") time)))
+
+
(defun caledonia--get-key (key event)
+
"Get KEY from EVENT as a string."
+
(let ((value (cadr (assoc key event))))
+
(cond
+
((null value) nil)
+
((stringp value) value)
+
((symbolp value) (symbol-name value)))))
+
+
(defun caledonia--tabulated-list-entries (events)
+
"Convert EVENTS for a format suitable for showing via a tabulated-list-mode'."
+
(let ((max-calendar-width 0)
+
(max-start-width 0)
+
(max-end-width 0)
+
(tabulated-list-entries nil))
+
;; first pass: calculate maximum widths
+
(dolist (event events)
+
(let* ((calendar (caledonia--get-key 'calendar event))
+
(start (caledonia--get-key 'start event))
+
(end (caledonia--get-key 'end event))
+
(cal-str (if (not calendar) "unkown" calendar))
+
(start-str (caledonia--format-timestamp start))
+
(end-str (when end
+
(caledonia--format-timestamp (format "%s" end)))))
+
(setq max-calendar-width (max max-calendar-width (length cal-str)))
+
(setq max-start-width (max max-start-width (+ (length start-str) 2)))
+
(setq max-end-width (max max-end-width (length end-str)))))
+
(setq caledonia-calendar-column-width (max max-calendar-width (length "Calendar")))
+
(setq caledonia-start-column-width (max max-start-width (length "Start")))
+
(setq caledonia-end-column-width (max max-end-width (length "End")))
+
;; second pass: prepare tabulated-list entries with properties
+
(setq tabulated-list-entries
+
(mapcar (lambda (event)
+
(let* (
+
(id (caledonia--get-key 'id event))
+
(summary (caledonia--get-key 'summary event))
+
(start (caledonia--get-key 'start event))
+
(end (caledonia--get-key 'end event))
+
(location (caledonia--get-key 'location event))
+
(calendar (caledonia--get-key 'calendar event))
+
(start-str (caledonia--format-timestamp start))
+
(end-str (if end (caledonia--format-timestamp (format "%s" end)) ""))
+
(start-str (if end (format "%s -" start-str) start-str))
+
(location-str (if location (concat " @ " location) ""))
+
(cal-prop (propertize calendar 'face 'caledonia-calendar-name-face))
+
(start-prop (propertize start-str 'face 'caledonia-date-face))
+
(end-prop (propertize end-str 'face 'caledonia-date-face))
+
(summary-prop (propertize (concat summary location-str)
+
'face 'caledonia-summary-face))
+
;; Store the full event data as a text property for retrieval
+
(entry-id (propertize (format "%s" id) 'event-data event)))
+
(list entry-id (vector cal-prop start-prop end-prop summary-prop))))
+
events))
+
tabulated-list-entries))
+
+
(defun caledonia--sort-calendar (A B)
+
"Sort function for calendar column."
+
(let ((a (aref (cadr A) 0))
+
(b (aref (cadr B) 0)))
+
(string< a b)))
+
+
(defun caledonia--sort-start (A B)
+
"Sort function for date/time column."
+
(let ((a (aref (cadr A) 1))
+
(b (aref (cadr B) 1)))
+
(time-less-p (date-to-time a) (date-to-time b))))
+
+
(defun caledonia--sort-end (A B)
+
"Sort function for date/time column."
+
(let ((a (aref (cadr A) 2))
+
(b (aref (cadr B) 2)))
+
(time-less-p (date-to-time a) (date-to-time b))))
+
+
(defun caledonia--make-query (&optional query)
+
"Make a query with the QUERY S-expression.
+
If QUERY is nil, use the current query stored in `caledonia--current-query`."
+
(interactive)
+
(let* ((query-to-use (or query caledonia--current-query '())) ;; Use current query if available
+
;; Ensure to date is set if not present in query
+
(query-to-use (if (assq 'to query-to-use)
+
query-to-use
+
(cons `(to ,caledonia-list-to-date) query-to-use)))
+
(request-str (format "(Query %s)" (prin1-to-string query-to-use)))
+
(payload (caledonia--send-request request-str))
+
(events (caledonia--get-events payload))
+
(entries (caledonia--tabulated-list-entries events)))
+
;; Save this query for future refreshes if explicitly provided
+
(when query
+
(setq-local caledonia--current-query query-to-use))
+
(setq tabulated-list-entries entries))
+
(setq tabulated-list-format
+
`[("Calendar" ,caledonia-calendar-column-width caledonia--sort-calendar)
+
("Start" ,caledonia-start-column-width caledonia--sort-start)
+
("End" ,caledonia-end-column-width caledonia--sort-end)
+
("Summary" 0 t)])
+
(setq tabulated-list-sort-key (cons "Start" nil))
+
(tabulated-list-init-header)
+
(tabulated-list-print t))
+
+
(defun caledonia--find-and-highlight-event-in-file (file event-id)
+
"Find EVENT-ID in FILE, position cursor, and highlight the event.
+
Return non-nil if the event was found."
+
(when (and file event-id)
+
(let ((id-str (format "%s" event-id))
+
(found nil))
+
;; Try to find and highlight iCalendar VEVENT block
+
(goto-char (point-min))
+
(when (and (string-match-p "\\.ics$" file)
+
(search-forward (format "UID:%s" id-str) nil t))
+
;; Found the UID in an ICS file, try to highlight the VEVENT block
+
(let ((uid-pos (match-beginning 0))
+
(vevent-start nil)
+
(vevent-end nil))
+
;; Find start of the VEVENT block
+
(save-excursion
+
(goto-char uid-pos)
+
(if (search-backward "BEGIN:VEVENT" nil t)
+
(setq vevent-start (match-beginning 0))
+
(setq vevent-start uid-pos)))
+
;; Find end of the VEVENT block
+
(save-excursion
+
(goto-char uid-pos)
+
(if (search-forward "END:VEVENT" nil t)
+
(setq vevent-end (match-end 0))
+
(setq vevent-end (line-end-position))))
+
;; Highlight the whole VEVENT block if found
+
(when (and vevent-start vevent-end)
+
(goto-char vevent-start)
+
(caledonia--highlight-region vevent-start vevent-end)
+
(recenter)
+
(setq found t))))
+
(unless found
+
(message "Event ID not found in file"))
+
found)))
+
+
(defun caledonia--display-event-details (event)
+
"Display details for EVENT in a separate buffer."
+
(let ((buf (get-buffer-create caledonia--details-buffer)))
+
(with-current-buffer buf
+
(let ((inhibit-read-only t))
+
(erase-buffer)
+
(special-mode)
+
(let* ((id (caledonia--get-key 'id event))
+
(summary (caledonia--get-key 'summary event))
+
(description (caledonia--get-key 'description event))
+
(start (caledonia--get-key 'start event))
+
(end (caledonia--get-key 'end event))
+
(location (caledonia--get-key 'location event))
+
(calendar (caledonia--get-key 'calendar event))
+
(file (caledonia--get-key 'file event))
+
(start-str (when start (caledonia--format-timestamp start)))
+
(end-str (when end (caledonia--format-timestamp end))))
+
(when id
+
(insert (propertize "Summary: " 'face 'bold) summary "\n"))
+
(when id
+
(insert (propertize "ID: " 'face 'bold) id "\n"))
+
(when calendar
+
(insert (propertize "Calendar: " 'face 'bold) calendar "\n"))
+
(when start-str
+
(insert (propertize "Start: " 'face 'bold) start-str "\n"))
+
(when end-str
+
(insert (propertize "End: " 'face 'bold) end-str "\n"))
+
(when location
+
(insert (propertize "Location: " 'face 'bold) location "\n"))
+
(when file
+
(insert (propertize "File: " 'face 'bold)
+
(propertize file 'face 'link
+
'mouse-face 'highlight
+
'help-echo "Click to open file with highlighting"
+
'keymap (let ((map (make-sparse-keymap))
+
(event-copy event))
+
(define-key map [mouse-1]
+
(lambda ()
+
(interactive)
+
(let ((file-path file)
+
(id-val (caledonia--get-key 'id event-copy)))
+
(find-file file-path)
+
(caledonia--find-and-highlight-event-in-file
+
file-path id-val))))
+
(define-key map (kbd "RET")
+
(lambda ()
+
(interactive)
+
(let ((file-path file)
+
(id-val (caledonia--get-key 'id event-copy)))
+
(find-file file-path)
+
(caledonia--find-and-highlight-event-in-file
+
file-path id-val))))
+
map))
+
"\n"))
+
(when description
+
(insert "\n" (propertize "Description:" 'face 'bold) "\n"
+
(propertize "------------" 'face 'bold) "\n"
+
description "\n")))))
+
(switch-to-buffer-other-window buf)))
+
+
(defun caledonia--highlight-region (start end)
+
"Highlight the region between START and END."
+
(when (fboundp 'pulse-momentary-highlight-region)
+
(pulse-momentary-highlight-region start end))
+
;; Fallback for when pulse is not available
+
(unless (fboundp 'pulse-momentary-highlight-region)
+
(let ((overlay (make-overlay start end)))
+
(overlay-put overlay 'face 'highlight)
+
(run-with-timer 0.5 nil (lambda () (delete-overlay overlay))))))
+
+
(defun caledonia--read-date-range ()
+
"Read a date range from the user with org-mode date picker integration.
+
Returns a cons cell (from-date . to-date).
+
The from-date can be nil to indicate no start date constraint."
+
(let (from to)
+
(setq from
+
(if (y-or-n-p "Set a start date? ")
+
(org-read-date nil nil nil "From date: " nil nil t)
+
; empty string differentiates from nil for optional args later on
+
""))
+
;; Use org-mode's date picker for To date (must have a value)
+
(setq to (org-read-date nil nil nil "To date: " nil nil t))
+
(cons from to)))
+
+
;; Query parameter modification functions
+
+
(defun caledonia-query-date-range ()
+
"Set the date range for the current calendar view."
+
(interactive)
+
(when (eq major-mode 'caledonia-mode)
+
(let* ((dates (caledonia--read-date-range
+
"From date" "" 'caledonia-from-history
+
"To date" "" 'caledonia-to-history))
+
(from (car dates))
+
(to (cdr dates))
+
(current-query caledonia--current-query)
+
(new-query (copy-tree current-query)))
+
;; Update the query with the new date range
+
(setq new-query (assq-delete-all 'from new-query))
+
(setq new-query (assq-delete-all 'to new-query))
+
(when (and from (not (string-empty-p from)))
+
(push `(from ,from) new-query))
+
(when (and to (not (string-empty-p to)))
+
(push `(to ,to) new-query))
+
;; Execute the updated query
+
(caledonia--make-query new-query))))
+
+
(defun caledonia-query-calendars ()
+
"Set the calendars to filter by for the current calendar view.
+
Fetches available calendars from server to allow selection from a list."
+
(interactive)
+
(when (eq major-mode 'caledonia-mode)
+
(let* ((available-calendars
+
(caledonia--send-request "ListCalendars"))
+
(calendars-list
+
(if (and (listp available-calendars)
+
(eq (car available-calendars) 'Calendars))
+
(cadr available-calendars)
+
(progn
+
(message "Failed to get calendar list from server")
+
nil)))
+
;; Use completing-read-multiple to select from available calendars
+
(selected-calendars
+
(completing-read-multiple
+
"Select calendars (comma-separated, empty for all): "
+
;; Use empty list if no calendars found
+
(or calendars-list '())
+
nil nil
+
(let ((current-calendars (cdr (assq 'calendars caledonia--current-query))))
+
(when current-calendars
+
(mapconcat #'identity current-calendars ",")))
+
'caledonia-calendars-history))
+
(calendars (mapcar #'string-trim selected-calendars))
+
(current-query caledonia--current-query)
+
(new-query (copy-tree current-query)))
+
;; Update the query with the new calendars
+
(setq new-query (assq-delete-all 'calendars new-query))
+
(when (and calendars (not (null calendars)))
+
(push `(calendars ,calendars) new-query))
+
;; Execute the updated query
+
(caledonia--make-query new-query))))
+
+
(defun caledonia-query-text ()
+
"Set the search text for the current calendar view."
+
(interactive)
+
(when (eq major-mode 'caledonia-mode)
+
(let* ((text (read-string "Search text (leave empty for no text search): "
+
nil 'caledonia-text-history))
+
(search-in-str (when (and text (not (string-empty-p text)))
+
(read-string "Search in (summary,description,location - leave empty for all): "
+
nil 'caledonia-search-fields-history)))
+
(search-in (when (and search-in-str (not (string-empty-p search-in-str)))
+
(mapcar (lambda (field)
+
(intern (string-trim field)))
+
(split-string search-in-str "," t))))
+
(current-query caledonia--current-query)
+
(new-query (copy-tree current-query)))
+
;; Update the query with the new text search parameters
+
(setq new-query (assq-delete-all 'text new-query))
+
(setq new-query (assq-delete-all 'search_in new-query))
+
(when (and text (not (string-empty-p text)))
+
(push `(text ,text) new-query))
+
(when search-in
+
(push `(search_in ,search-in) new-query))
+
;; Execute the updated query
+
(caledonia--make-query new-query))))
+
+
(defun caledonia-query-id ()
+
"Set the event ID to filter by for the current calendar view."
+
(interactive)
+
(when (eq major-mode 'caledonia-mode)
+
(let* ((id (read-string "Event ID (leave empty for all events): "
+
nil 'caledonia-id-history))
+
(current-query caledonia--current-query)
+
(new-query (copy-tree current-query)))
+
;; Update the query with the new ID
+
(setq new-query (assq-delete-all 'id new-query))
+
(when (and id (not (string-empty-p id)))
+
(push `(id ,id) new-query))
+
;; Execute the updated query
+
(caledonia--make-query new-query))))
+
+
(defun caledonia-query-recurring ()
+
"Set whether to filter by recurring events for the current calendar view."
+
(interactive)
+
(when (eq major-mode 'caledonia-mode)
+
(let* ((recurring-str (completing-read "Recurring events (yes/no/all, leave empty for all): "
+
'("" "yes" "no") nil nil nil))
+
(recurring (cond ((string= recurring-str "yes") t)
+
((string= recurring-str "no") nil)
+
(t nil)))
+
(current-query caledonia--current-query)
+
(new-query (copy-tree current-query)))
+
;; Update the query with the recurring filter
+
(setq new-query (assq-delete-all 'recurring new-query))
+
(when (not (string-empty-p recurring-str))
+
(push `(recurring ,(if (string= recurring-str "yes") t nil)) new-query))
+
;; Execute the updated query
+
(caledonia--make-query new-query))))
+
+
(defun caledonia-query-limit ()
+
"Set the maximum number of events to show in the current calendar view."
+
(interactive)
+
(when (eq major-mode 'caledonia-mode)
+
(let* ((limit-str (read-string "Maximum events to show (leave empty for no limit): "
+
nil 'caledonia-limit-history))
+
(limit (when (and limit-str (not (string-empty-p limit-str)))
+
(string-to-number limit-str)))
+
(current-query caledonia--current-query)
+
(new-query (copy-tree current-query)))
+
;; Update the query with the new limit
+
(setq new-query (assq-delete-all 'limit new-query))
+
(when limit
+
(push `(limit ,limit) new-query))
+
;; Execute the updated query
+
(caledonia--make-query new-query))))
+
+
(defun caledonia-query-timezone ()
+
"Set the timezone for the current calendar view."
+
(interactive)
+
(when (eq major-mode 'caledonia-mode)
+
(let* ((timezone-str (read-string "Timezone (e.g. Europe/London, leave empty for default): "
+
nil 'caledonia-timezone-history))
+
(timezone (when (not (string-empty-p timezone-str)) timezone-str))
+
(current-query caledonia--current-query)
+
(new-query (copy-tree current-query)))
+
;; Update the query with the new timezone
+
(setq new-query (assq-delete-all 'timezone new-query))
+
(when timezone
+
(push `(timezone ,timezone) new-query))
+
;; Execute the updated query
+
(caledonia--make-query new-query))))
+
+
;; Buffer functions
+
+
(defun caledonia-show-event ()
+
"Show details for the event at point in a separate buffer."
+
(interactive)
+
(when (eq major-mode 'caledonia-mode)
+
(let* ((id (tabulated-list-get-id))
+
(event (when id (get-text-property 0 'event-data id))))
+
(if event
+
(caledonia--display-event-details event)
+
(message "No event at point")))))
+
+
(defun caledonia-open-event-file ()
+
"Open the file associated with the event at point.
+
If the file contains the event ID, the cursor will be positioned at that location."
+
(interactive)
+
(when (eq major-mode 'caledonia-mode)
+
(let* ((id (tabulated-list-get-id))
+
(event (when id (get-text-property 0 'event-data id)))
+
(file (when event (caledonia--get-key 'file event)))
+
(event-id (when event (caledonia--get-key 'id event))))
+
(cond
+
((not event)
+
(message "No event at point"))
+
((not file)
+
(message "No file associated with this event"))
+
((not (file-exists-p file))
+
(message "File does not exist: %s" file))
+
(t
+
(find-file file)
+
(caledonia--find-and-highlight-event-in-file file event-id))))))
+
+
(defun caledonia-refresh ()
+
"Refresh calendar data from disk and update the current view.
+
This is useful when calendar files have been modified outside Emacs
+
(for example, by a sync program or direct file edits)."
+
(interactive)
+
(when (eq major-mode 'caledonia-mode)
+
;; Send a refresh command to clear the server's cache
+
(caledonia--send-request "Refresh")
+
;; Re-apply the current query to update the view
+
(when (string= (buffer-name) caledonia--events-buffer)
+
;; Just use caledonia--make-query without args to use the stored query
+
(caledonia--make-query))))
+
+
;; Entry functions
+
+
(defun caledonia-query ()
+
"Query events with interactive prompts for all filter parameters.
+
Opens a series of prompts to build a complete query and then displays the results.
+
After the initial query is displayed, you can further refine the results
+
using the caledonia-query-* family of functions."
+
(interactive)
+
(let* (
+
(dates (caledonia--read-date-range))
+
(from (car dates))
+
(to (cdr dates))
+
(timezone-str (read-string "Timezone (e.g. Europe/London, leave empty for default): "
+
nil 'caledonia-timezone-history))
+
(timezone (when (not (string-empty-p timezone-str)) timezone-str))
+
(available-calendars
+
(caledonia--send-request "ListCalendars"))
+
(calendars-list
+
(if (and (listp available-calendars)
+
(eq (car available-calendars) 'Calendars))
+
(cadr available-calendars)
+
(progn
+
(message "Failed to get calendar list from server")
+
nil)))
+
(selected-calendars
+
(completing-read-multiple
+
"Select calendars (comma-separated, empty for all): "
+
(or calendars-list '()) nil nil nil 'caledonia-calendars-history))
+
(calendars (mapcar #'string-trim selected-calendars))
+
(text (read-string "Search text (leave empty for no text search): "
+
nil 'caledonia-text-history))
+
(search-in-str (when (and text (not (string-empty-p text)))
+
(read-string "Search in (summary,description,location - leave empty for all): "
+
nil 'caledonia-search-fields-history)))
+
(search-in (when (and search-in-str (not (string-empty-p search-in-str)))
+
(mapcar (lambda (field)
+
(intern (string-trim field)))
+
(split-string search-in-str "," t))))
+
(id (read-string "Event ID (leave empty for all events): "
+
nil 'caledonia-id-history))
+
(recurring (completing-read "Recurring events (yes/no/all, leave empty for all): "
+
'("" "yes" "no") nil nil nil))
+
(limit-str (read-string "Maximum events to show (leave empty for no limit): "
+
nil 'caledonia-limit-history))
+
(limit (when (and limit-str (not (string-empty-p limit-str)))
+
(string-to-number limit-str)))
+
(query nil))
+
;; Build query based on parameters
+
(when (and from (not (string-empty-p from)))
+
(push `(from ,from) query))
+
(when (and to (not (string-empty-p to)))
+
(push `(to ,to) query))
+
(when timezone
+
(push `(timezone ,timezone) query))
+
(when calendars
+
(push `(calendars ,calendars) query))
+
(when (and text (not (string-empty-p text)))
+
(push `(text ,text) query))
+
(when search-in
+
(push `(search_in ,search-in) query))
+
(when (and id (not (string-empty-p id)))
+
(push `(id ,id) query))
+
(when (not (string-empty-p recurring))
+
(push `(recurring ,(if (string= recurring "yes") t nil)) query))
+
(when limit
+
(push `(limit ,limit) query))
+
;; Create buffer and execute query
+
(let ((buffer (get-buffer-create caledonia--events-buffer)))
+
(with-current-buffer buffer
+
;; Clear the buffer and reset it
+
(let ((inhibit-read-only t))
+
(erase-buffer))
+
;; Activate our mode and make the query
+
(caledonia-mode)
+
(caledonia--make-query query)
+
(switch-to-buffer buffer)))))
+
+
(defun caledonia-list (&optional from-date to-date)
+
"List calendar in a new buffer within the default date range.
+
FROM-DATE and TO-DATE override the default date range if provided.
+
TO-DATE is required and will use a default if not specified.
+
With prefix arg (C-u), prompts for the date range with an interactive calendar."
+
(interactive
+
(when current-prefix-arg
+
(let* ((dates (caledonia--read-date-range)))
+
(list (car dates) (cdr dates)))))
+
(let ((buffer (get-buffer-create caledonia--events-buffer))
+
(from (or from-date caledonia-list-from-date))
+
;; Ensure to date is always provided
+
(to (or (and to-date (not (string-empty-p to-date)) to-date)
+
caledonia-list-to-date)))
+
(with-current-buffer buffer
+
;; Clear the buffer and reset it
+
(let ((inhibit-read-only t))
+
(erase-buffer))
+
;; Build the query
+
(let* ((query `((to ,to))))
+
;; Add from date only if specified
+
(when (and from (not (string-empty-p from)))
+
(setq query (append query `((from ,from)))))
+
;; Activate our mode and make the query
+
(caledonia-mode)
+
(caledonia--make-query query)
+
(switch-to-buffer buffer)))))
+
+
(defun caledonia-search (&optional expr from-date to-date)
+
"Search for query EXPR with optional FROM-DATE and TO-DATE.
+
This is an interactive function which asks user for EXPR if not passed as an argument.
+
With prefix arg (C-u), also prompts for date range with an interactive calendar.
+
Use this to find events matching specific text across all calendars.
+
TO-DATE is required; a default will be used if not provided."
+
(interactive
+
(let* ((search-text (read-string "Search for: " nil 'caledonia-search-prompt-history))
+
(dates (when current-prefix-arg (caledonia--read-date-range))))
+
(list search-text
+
(when current-prefix-arg (car dates))
+
(when current-prefix-arg (cdr dates)))))
+
(let ((buffer (get-buffer-create caledonia--events-buffer))
+
(from (or from-date caledonia-search-from-date))
+
(to (or to-date caledonia-search-to-date)))
+
(with-current-buffer buffer
+
;; Clear the buffer and reset it
+
(let ((inhibit-read-only t))
+
(erase-buffer))
+
;; Build the query
+
(let* ((query `((text ,expr)(to ,to))))
+
;; Add from date only if specified
+
(when (and from (not (string-empty-p from)))
+
(setq query (append query `((from ,from)))))
+
;; Activate our mode and make the query
+
(caledonia-mode)
+
(caledonia--make-query query)
+
(switch-to-buffer buffer)))))
+
+
;; Modes
+
;;;###autoload
+
+
;; Create a filter prefix map for query refinement
+
(defvar caledonia-filter-map
+
(let ((map (make-sparse-keymap)))
+
(define-key map (kbd "d") 'caledonia-query-date-range)
+
(define-key map (kbd "c") 'caledonia-query-calendars)
+
(define-key map (kbd "t") 'caledonia-query-text)
+
(define-key map (kbd "i") 'caledonia-query-id)
+
(define-key map (kbd "r") 'caledonia-query-recurring)
+
(define-key map (kbd "l") 'caledonia-query-limit)
+
(define-key map (kbd "z") 'caledonia-query-timezone)
+
map)
+
"Keymap for filter commands in Caledonia mode.")
+
+
(defvar caledonia-mode-map
+
(let ((map (make-sparse-keymap)))
+
(set-keymap-parent map tabulated-list-mode-map)
+
(define-key map (kbd "RET") 'caledonia-show-event)
+
(define-key map (kbd "M-RET") 'caledonia-open-event-file)
+
(define-key map (kbd "l") 'caledonia-list)
+
(define-key map (kbd "s") 'caledonia-search)
+
(define-key map (kbd "r") 'caledonia-refresh)
+
(define-key map (kbd "q") 'quit-window)
+
;; Individual filter command bindings
+
(define-key map (kbd "C-c d") 'caledonia-query-date-range)
+
(define-key map (kbd "C-c c") 'caledonia-query-calendars)
+
(define-key map (kbd "C-c t") 'caledonia-query-text)
+
(define-key map (kbd "C-c i") 'caledonia-query-id)
+
(define-key map (kbd "C-c r") 'caledonia-query-recurring)
+
(define-key map (kbd "C-c l") 'caledonia-query-limit)
+
(define-key map (kbd "C-c z") 'caledonia-query-timezone)
+
;; Use f prefix for filter commands
+
(define-key map (kbd "C-c f") caledonia-filter-map)
+
map)
+
"Keymap for Caledonia mode.")
+
+
(define-derived-mode caledonia-mode tabulated-list-mode "Caledonia"
+
"Major mode for displaying calendar entries in a tabular view.")
+
+
;; Define a prefix map specifically for Evil mode
+
(defvar caledonia-evil-filter-map
+
(let ((map (make-sparse-keymap)))
+
(define-key map "d" 'caledonia-query-date-range)
+
(define-key map "c" 'caledonia-query-calendars)
+
(define-key map "t" 'caledonia-query-text)
+
(define-key map "i" 'caledonia-query-id)
+
(define-key map "r" 'caledonia-query-recurring)
+
(define-key map "l" 'caledonia-query-limit)
+
(define-key map "z" 'caledonia-query-timezone)
+
map)
+
"Evil mode keymap for filter commands in Caledonia mode.")
+
+
(eval-after-load 'evil
+
'(progn
+
;; Basic navigation and commands
+
(evil-define-key 'normal caledonia-mode-map
+
(kbd "RET") 'caledonia-show-event
+
(kbd "M-RET") 'caledonia-open-event-file
+
"l" 'caledonia-list
+
"s" 'caledonia-search
+
"r" 'caledonia-refresh
+
"q" 'quit-window)
+
;; Set up a proper Evil prefix key
+
(evil-define-key 'normal caledonia-mode-map "f" caledonia-evil-filter-map)))
+
+
(defun caledonia--setup-evil-integration ()
+
"Set up Evil integration for Caledonia mode."
+
(when (bound-and-true-p evil-mode)
+
(evil-make-overriding-map caledonia-mode-map 'normal)
+
(evil-normalize-keymaps)))
+
+
(add-hook 'caledonia-mode-hook 'caledonia--setup-evil-integration)
+
+
(provide 'caledonia)
+
;;; caledonia.el ends here
+48 -84
lib/calendar_dir.ml
···
open Icalendar
-
module CalendarMap = Map.Make (struct
-
type t = string
-
-
let compare a b = String.compare a b
-
end)
-
-
type t = { path : string; mutable calendar_names : Event.t list CalendarMap.t }
+
type t = string
let get_calendar_path ~fs calendar_dir calendar_name_name =
-
Eio.Path.(fs / calendar_dir.path / calendar_name_name)
+
Eio.Path.(fs / calendar_dir / calendar_name_name)
let ensure_dir path =
try
···
let create ~fs path =
match ensure_dir Eio.Path.(fs / path) with
-
| Ok () -> Ok { path; calendar_names = CalendarMap.empty }
+
| Ok () -> Ok path
| Error e -> Error e
let list_calendar_names ~fs calendar_dir =
try
-
let dir = Eio.Path.(fs / calendar_dir.path) in
+
let dir = Eio.Path.(fs / calendar_dir) in
let calendar_names =
Eio.Path.read_dir dir
|> List.filter_map (fun file ->
-
if Eio.Path.is_directory Eio.Path.(dir / file) then Some file
+
if
+
String.length file > 0
+
&& file.[0] != '.'
+
&& Eio.Path.is_directory Eio.Path.(dir / file)
+
then Some file
else None)
|> List.sort (fun a b -> String.compare a b)
in
···
with Eio.Exn.Io _ as exn ->
Error
(`Msg
-
(Fmt.str "Failed to list calendar directory %s: %a" calendar_dir.path
+
(Fmt.str "Failed to list calendar directory %s: %a" calendar_dir
Eio.Exn.pp exn))
let rec load_events_recursive calendar_name dir_path =
···
[]
let get_calendar_events ~fs calendar_dir calendar_name =
-
match CalendarMap.find_opt calendar_name calendar_dir.calendar_names with
-
| Some events -> Ok events
-
| None -> (
-
let calendar_name_path =
-
get_calendar_path ~fs calendar_dir calendar_name
-
in
-
if not (Eio.Path.is_directory calendar_name_path) then Error `Not_found
-
else
-
try
-
let events = load_events_recursive calendar_name calendar_name_path in
-
calendar_dir.calendar_names <-
-
CalendarMap.add calendar_name events calendar_dir.calendar_names;
-
Ok events
-
with e ->
-
Error
-
(`Msg
-
(Printf.sprintf "Exception processing directory %s: %s"
-
(snd calendar_name_path) (Printexc.to_string e))))
+
let calendar_name_path =
+
get_calendar_path ~fs calendar_dir calendar_name
+
in
+
if not (Eio.Path.is_directory calendar_name_path) then Error `Not_found
+
else
+
try
+
let events = load_events_recursive calendar_name calendar_name_path in
+
Ok events
+
with e ->
+
Error
+
(`Msg
+
(Printf.sprintf "Exception processing directory %s: %s"
+
(snd calendar_name_path) (Printexc.to_string e)))
let ( let* ) = Result.bind
···
(Printf.sprintf "Error getting calendar_names: %s"
(Printexc.to_string exn))))
-
let add_event ~fs calendar_dir event =
+
let add_event ~fs calendar_dir events event =
let calendar_name = Event.get_calendar_name event in
let file = Event.get_file event in
let calendar_name_path = get_calendar_path ~fs calendar_dir calendar_name in
let* () = ensure_dir calendar_name_path in
let calendar = Event.to_ical_calendar event in
let content = Icalendar.to_ics ~cr:true calendar in
-
let* _ =
try
Eio.Path.save ~create:(`Or_truncate 0o644) file content;
-
Ok ()
+
Ok (event :: events)
with Eio.Exn.Io _ as exn ->
Error
(`Msg
(Fmt.str "Failed to write file %s: %a\n%!" (snd file) Eio.Exn.pp exn))
-
in
-
calendar_dir.calendar_names <-
-
CalendarMap.add calendar_name
-
(event
-
::
-
(match CalendarMap.find_opt calendar_name calendar_dir.calendar_names with
-
| Some lst -> lst
-
| None -> []))
-
calendar_dir.calendar_names;
-
Ok ()
-
let edit_event ~fs calendar_dir event =
+
let edit_event ~fs calendar_dir events event =
let calendar_name = Event.get_calendar_name event in
let event_id = Event.get_id event in
let calendar_name_path = get_calendar_path ~fs calendar_dir calendar_name in
···
(existing_props, `Event ical_event :: filtered_components)
in
let content = Icalendar.to_ics ~cr:true calendar in
-
let* _ =
-
try
-
Eio.Path.save ~create:(`Or_truncate 0o644) file content;
-
Ok ()
-
with Eio.Exn.Io _ as exn ->
-
Error
-
(`Msg
-
(Fmt.str "Failed to write file %s: %a\n%!" (snd file) Eio.Exn.pp exn))
-
in
-
calendar_dir.calendar_names <-
-
CalendarMap.add calendar_name
-
(event
-
::
-
(match CalendarMap.find_opt calendar_name calendar_dir.calendar_names with
-
(* filter old version *)
-
| Some lst -> List.filter (fun e -> Event.get_id e = event_id) lst
-
| None -> []))
-
calendar_dir.calendar_names;
-
Ok ()
+
try
+
Eio.Path.save ~create:(`Or_truncate 0o644) file content;
+
(* Filter out the old event and add the updated one *)
+
let filtered_events = List.filter (fun e -> Event.get_id e <> event_id) events in
+
Ok (event :: filtered_events)
+
with Eio.Exn.Io _ as exn ->
+
Error
+
(`Msg
+
(Fmt.str "Failed to write file %s: %a\n%!" (snd file) Eio.Exn.pp exn))
-
let delete_event ~fs calendar_dir event =
+
let delete_event ~fs calendar_dir events event =
let calendar_name = Event.get_calendar_name event in
let event_id = Event.get_id event in
let calendar_name_path = get_calendar_path ~fs calendar_dir calendar_name in
···
(existing_props, filtered_components)
in
let content = Icalendar.to_ics ~cr:true calendar in
-
let* _ =
-
try
-
(match !other_events with
-
| true -> Eio.Path.save ~create:(`Or_truncate 0o644) file content
-
| false -> Eio.Path.unlink file);
-
Ok ()
-
with Eio.Exn.Io _ as exn ->
-
Error
-
(`Msg
-
(Fmt.str "Failed to write file %s: %a\n%!" (snd file) Eio.Exn.pp exn))
-
in
-
calendar_dir.calendar_names <-
-
CalendarMap.add calendar_name
-
(match CalendarMap.find_opt calendar_name calendar_dir.calendar_names with
-
(* filter old version *)
-
| Some lst -> List.filter (fun e -> Event.get_id e = event_id) lst
-
| None -> [])
-
calendar_dir.calendar_names;
-
Ok ()
+
try
+
(match !other_events with
+
| true -> Eio.Path.save ~create:(`Or_truncate 0o644) file content
+
| false -> Eio.Path.unlink file);
+
(* Filter out the deleted event from the events list *)
+
let filtered_events = List.filter (fun e -> Event.get_id e <> event_id) events in
+
Ok filtered_events
+
with Eio.Exn.Io _ as exn ->
+
Error
+
(`Msg
+
(Fmt.str "Failed to write file %s: %a\n%!" (snd file) Eio.Exn.pp exn))
-
let get_path t = t.path
+
let get_path t = t
+12 -3
lib/calendar_dir.mli
···
val add_event :
fs:Eio.Fs.dir_ty Eio.Path.t ->
t ->
+
Event.t list ->
Event.t ->
-
(unit, [> `Msg of string ]) result
+
(Event.t list, [> `Msg of string ]) result
+
(** Add an event to the calendar directory. Takes the current events list and returns
+
an updated events list with the new event added. *)
val edit_event :
fs:Eio.Fs.dir_ty Eio.Path.t ->
t ->
+
Event.t list ->
Event.t ->
-
(unit, [> `Msg of string ]) result
+
(Event.t list, [> `Msg of string ]) result
+
(** Edit an event in the calendar directory. Takes the current events list and returns
+
an updated events list with the event updated. *)
val delete_event :
fs:Eio.Fs.dir_ty Eio.Path.t ->
t ->
+
Event.t list ->
Event.t ->
-
(unit, [> `Msg of string ]) result
+
(Event.t list, [> `Msg of string ]) result
+
(** Delete an event from the calendar directory. Takes the current events list and returns
+
an updated events list with the event removed. *)
val get_path : t -> string
+44 -1
lib/date.ml
···
let get_end_of_next_month ?(tz = !default_timezone ()) () =
get_end_of_month (get_start_of_next_month ~tz ())
+
let get_start_of_year date =
+
let dt = ptime_to_timedesc date in
+
let year = Timedesc.year dt in
+
+
(* Create a date for the first of January *)
+
match Timedesc.Date.Ymd.make ~year ~month:1 ~day:1 with
+
| Ok first_day ->
+
let midnight = Timedesc.Time.make_exn ~hour:0 ~minute:0 ~second:0 () in
+
let first_of_year = Timedesc.of_date_and_time_exn first_day midnight in
+
timedesc_to_ptime first_of_year
+
| Error _ -> failwith "Invalid date for start of year"
+
+
let get_start_of_current_year ?(tz = !default_timezone ()) () =
+
get_start_of_year (!get_today ~tz ())
+
+
let get_start_of_next_year ?(tz = !default_timezone ()) () =
+
add_years (get_start_of_current_year ~tz ()) 1
+
+
let get_end_of_year date =
+
let dt = ptime_to_timedesc date in
+
let year = Timedesc.year dt in
+
+
(* Create a date for the last day of the year (December 31) *)
+
match Timedesc.Date.Ymd.make ~year ~month:12 ~day:31 with
+
| Ok last_day ->
+
let end_of_day =
+
Timedesc.Time.make_exn ~hour:23 ~minute:59 ~second:59 ()
+
in
+
let end_of_year = Timedesc.of_date_and_time_exn last_day end_of_day in
+
timedesc_to_ptime end_of_year
+
| Error _ -> failwith "Invalid date for end of year"
+
+
let get_end_of_current_year ?(tz = !default_timezone ()) () =
+
get_end_of_year (!get_today ~tz ())
+
+
let get_end_of_next_year ?(tz = !default_timezone ()) () =
+
get_end_of_year (get_start_of_next_year ~tz ())
+
let convert_relative_date_formats ?(tz = !default_timezone ()) ~today ~tomorrow
~week ~month () =
if today then
···
else None
let parse_relative ~tz expr parameter =
-
let regex = Re.Pcre.regexp "^([+-])(\\d+)([dwm])$" in
+
let regex = Re.Pcre.regexp "^([+-])(\\d+)([dwmy])$" in
if Re.Pcre.pmatch ~rex:regex expr then
let match_result = Re.Pcre.exec ~rex:regex expr in
let sign = Re.Pcre.get_substring match_result 1 in
···
match parameter with
| `From -> Some (Ok (get_start_of_month date))
| `To -> Some (Ok (get_end_of_month date)))
+
| "y" -> (
+
let date = add_years today value in
+
match parameter with
+
| `From -> Some (Ok (get_start_of_year date))
+
| `To -> Some (Ok (get_end_of_year date)))
| _ -> Some (Error (`Msg (Printf.sprintf "Invalid date unit: %s" unit)))
else None
+30 -1
lib/date.mli
···
(** Get the end of the month for the given date. Raises an exception if the date
cannot be calculated. *)
+
val get_start_of_year : Ptime.t -> Ptime.t
+
(** Get the start of the year (Jan 1) for the given date. Raises an exception if
+
the date cannot be calculated. *)
+
+
val get_start_of_current_year : ?tz:Timedesc.Time_zone.t -> unit -> Ptime.t
+
(** Get the start of the current year in the specified timezone. If no timezone
+
is provided, uses the default_timezone. Raises an exception if the date
+
cannot be calculated. *)
+
+
val get_start_of_next_year : ?tz:Timedesc.Time_zone.t -> unit -> Ptime.t
+
(** Get the start of next year in the specified timezone. If no timezone is
+
provided, uses the default_timezone. Raises an exception if the date cannot
+
be calculated. *)
+
+
val get_end_of_year : Ptime.t -> Ptime.t
+
(** Get the end of the year (Dec 31, 23:59:59) for the given date. Raises an
+
exception if the date cannot be calculated. *)
+
+
val get_end_of_current_year : ?tz:Timedesc.Time_zone.t -> unit -> Ptime.t
+
(** Get the end of the current year in the specified timezone. If no timezone is
+
provided, uses the default_timezone. Raises an exception if the date cannot
+
be calculated. *)
+
+
val get_end_of_next_year : ?tz:Timedesc.Time_zone.t -> unit -> Ptime.t
+
(** Get the end of next year in the specified timezone. If no timezone is
+
provided, uses the default_timezone. Raises an exception if the date cannot
+
be calculated. *)
+
val convert_relative_date_formats :
?tz:Timedesc.Time_zone.t ->
today:bool ->
···
- "+Nd" - N days from today (e.g., "+7d" for a week from today)
- "-Nd" - N days before today (e.g., "-7d" for a week ago)
- "+Nw" - N weeks from today
-
- "+Nm" - N months from today *)
+
- "+Nm" - N months from today
+
- "+Ny" - N years from today *)
val parse_time : string -> (int * int * int, [> `Msg of string ]) result
(** Parse a time string in HH:MM or HH:MM:SS format. Returns Ok with (hour,
+4 -2
lib/dune
···
yojson
uuidm
eio
-
eio_main)
+
eio_main
+
cmdliner
+
sexplib)
(preprocess
-
(pps ppx_deriving.show ppx_deriving.eq)))
+
(pps ppx_deriving.show ppx_deriving.eq ppx_sexp_conv)))
+105 -28
lib/event.ml
···
let summary =
match get_summary event with Some summary -> summary | None -> ""
in
-
let start_date, start_time =
+
let start_str =
let dt = Date.ptime_to_timedesc ?tz start in
let y = Timedesc.year dt in
let m = Timedesc.month dt in
···
let h = Timedesc.hour dt in
let min = Timedesc.minute dt in
let s = Timedesc.second dt in
-
let dow =
-
match Timedesc.weekday dt with
-
| `Mon -> "monday"
-
| `Tue -> "tuesday"
-
| `Wed -> "wednesday"
-
| `Thu -> "thursday"
-
| `Fri -> "friday"
-
| `Sat -> "saturday"
-
| `Sun -> "sunday"
-
in
-
( Printf.sprintf "(%04d %02d %02d %s)" y m d dow,
-
Printf.sprintf "(%02d %02d %02d)" h min s )
+
(* Format as a single timestamp string that's easy for Emacs to parse *)
+
Printf.sprintf "\"%04d-%02d-%02dT%02d:%02d:%02d\"" y m d h min s
in
let end_str =
match end_ with
···
let h = Timedesc.hour dt in
let min = Timedesc.minute dt in
let s = Timedesc.second dt in
-
let dow =
-
match Timedesc.weekday dt with
-
| `Mon -> "monday"
-
| `Tue -> "tuesday"
-
| `Wed -> "wednesday"
-
| `Thu -> "thursday"
-
| `Fri -> "friday"
-
| `Sat -> "saturday"
-
| `Sun -> "sunday"
-
in
-
Printf.sprintf "((%04d %02d %02d %s) (%02d %02d %02d))" y m d dow h
-
min s
+
Printf.sprintf "\"%04d-%02d-%02dT%02d:%02d:%02d\"" y m d h min s
| None -> "nil"
in
let location =
···
in
let id = get_id event in
Printf.sprintf
-
"((:id \"%s\" :summary \"%s\" :start (%s %s) :end %s :location %s \
+
"((:id \"%s\" :summary \"%s\" :start %s :end %s :location %s \
:description %s :calendar %s))"
-
(String.escaped id) (String.escaped summary) start_date start_time
-
end_str location description calendar
+
(String.escaped id) (String.escaped summary) start_str end_str location
+
description calendar
let format_events_with_dynamic_columns ?tz events =
if events = [] then ""
···
recur_events ~recurrence_ids:other_events ical_event
in
collect generator []
+
+
let sexp_of_t event =
+
let open Sexplib.Sexp in
+
let start = get_start event in
+
let end_ = get_end event in
+
let format_ptime_iso p =
+
let dt = Date.ptime_to_timedesc p in
+
let y = Timedesc.year dt in
+
let m = Timedesc.month dt in
+
let d = Timedesc.day dt in
+
let h = Timedesc.hour dt in
+
let min = Timedesc.minute dt in
+
let s = Timedesc.second dt in
+
Printf.sprintf "%04d-%02d-%02dT%02d:%02d:%02d" y m d h min s
+
in
+
let entries =
+
[
+
Some (List [ Atom "id"; Atom (get_id event) ]);
+
(match get_summary event with
+
| Some s -> Some (List [ Atom "summary"; Atom s ])
+
| None -> None);
+
Some (List [ Atom "start"; Atom (format_ptime_iso start) ]);
+
(match end_ with
+
| Some e -> Some (List [ Atom "end"; Atom (format_ptime_iso e) ])
+
| None -> None);
+
(match get_location event with
+
| Some l -> Some (List [ Atom "location"; Atom l ])
+
| None -> None);
+
(match get_description event with
+
| Some d -> Some (List [ Atom "description"; Atom d ])
+
| None -> None);
+
Some (List [ Atom "file"; Atom (snd (get_file event)) ]);
+
Some (List [ Atom "calendar"; Atom (get_calendar_name event) ]);
+
]
+
in
+
let filtered_entries = List.filter_map Fun.id entries in
+
List filtered_entries
+
+
type filter = t -> bool
+
+
let text_matches pattern text =
+
let re = Re.Pcre.regexp ~flags:[ `CASELESS ] (Re.Pcre.quote pattern) in
+
Re.Pcre.pmatch ~rex:re text
+
+
let summary_contains text event =
+
match get_summary event with
+
| Some summary -> text_matches text summary
+
| None -> false
+
+
let description_contains text event =
+
match get_description event with
+
| Some desc -> text_matches text desc
+
| None -> false
+
+
let location_contains text event =
+
match get_location event with
+
| Some loc -> text_matches text loc
+
| None -> false
+
+
let in_calendars ids event =
+
let id = get_calendar_name event in
+
List.exists (fun col -> col = id) ids
+
+
let recurring_only () event = get_recurrence event <> None
+
let non_recurring_only () event = get_recurrence event = None
+
let with_id id event = get_id event = id
+
let and_filter filters event = List.for_all (fun filter -> filter event) filters
+
let or_filter filters event = List.exists (fun filter -> filter event) filters
+
let not_filter filter event = not (filter event)
+
let matches_filter event filter = filter event
+
+
let take n list =
+
let rec aux n lst acc =
+
match (lst, n) with
+
| _, 0 -> List.rev acc
+
| [], _ -> List.rev acc
+
| x :: xs, n -> aux (n - 1) xs (x :: acc)
+
in
+
aux n list []
+
+
let query_without_recurrence events ?filter ?(comparator = by_start) ?limit () =
+
let events =
+
match filter with Some f -> List.filter f events | None -> events
+
in
+
let events = List.sort comparator events in
+
match limit with Some n when n > 0 -> take n events | _ -> events
+
+
let query events ?filter ~from ~to_ ?comparator ?limit () =
+
let events =
+
match filter with Some f -> List.filter f events | None -> events
+
in
+
let events =
+
List.concat_map (fun event -> expand_recurrences event ~from ~to_) events
+
in
+
let events =
+
match comparator with None -> events | Some c -> List.sort c events
+
in
+
match limit with Some n when n > 0 -> take n events | _ -> events
+45
lib/event.mli
···
val format_events :
?format:format -> ?tz:Timedesc.Time_zone.t -> t list -> string
(** Format a list of events, optionally using the specified timezone *)
+
+
val sexp_of_t : t -> Sexplib0.Sexp.t
+
+
(** 3 Queries *)
+
+
(** Filter-based searching and querying of calendar events *)
+
+
type filter = t -> bool
+
+
val summary_contains : string -> filter
+
val description_contains : string -> filter
+
val location_contains : string -> filter
+
val in_calendars : string list -> filter
+
val recurring_only : unit -> filter
+
val non_recurring_only : unit -> filter
+
val with_id : event_id -> filter
+
val and_filter : filter list -> filter
+
val or_filter : filter list -> filter
+
val not_filter : filter -> filter
+
+
val query_without_recurrence :
+
t list ->
+
?filter:filter ->
+
?comparator:comparator ->
+
?limit:int ->
+
unit ->
+
t list
+
(** Find events without expansion of recurring events. Returns Ok with the list
+
of events, or Error with a message. *)
+
+
val query :
+
t list ->
+
?filter:filter ->
+
from:Ptime.t option ->
+
to_:Ptime.t ->
+
?comparator:comparator ->
+
?limit:int ->
+
unit ->
+
t list
+
(** Find events with expansion of recurring events. Returns Ok with the list of
+
events, or Error with a message. *)
+
+
(* Test-only helper functions *)
+
val matches_filter : t -> filter -> bool
+
(** Check if an event matches the given filter *)
-66
lib/query.ml
···
-
type filter = Event.t -> bool
-
-
let text_matches pattern text =
-
let re = Re.Pcre.regexp ~flags:[ `CASELESS ] (Re.Pcre.quote pattern) in
-
Re.Pcre.pmatch ~rex:re text
-
-
let summary_contains text event =
-
match Event.get_summary event with
-
| Some summary -> text_matches text summary
-
| None -> false
-
-
let description_contains text event =
-
match Event.get_description event with
-
| Some desc -> text_matches text desc
-
| None -> false
-
-
let location_contains text event =
-
match Event.get_location event with
-
| Some loc -> text_matches text loc
-
| None -> false
-
-
let in_calendars ids event =
-
let id = Event.get_calendar_name event in
-
List.exists (fun col -> col = id) ids
-
-
let recurring_only () event = Event.get_recurrence event <> None
-
let non_recurring_only () event = Event.get_recurrence event = None
-
let with_id id event = Event.get_id event = id
-
let and_filter filters event = List.for_all (fun filter -> filter event) filters
-
let or_filter filters event = List.exists (fun filter -> filter event) filters
-
let not_filter filter event = not (filter event)
-
let matches_filter event filter = filter event
-
-
let take n list =
-
let rec aux n lst acc =
-
match (lst, n) with
-
| _, 0 -> List.rev acc
-
| [], _ -> List.rev acc
-
| x :: xs, n -> aux (n - 1) xs (x :: acc)
-
in
-
aux n list []
-
-
let ( let* ) = Result.bind
-
-
let query_without_recurrence ~fs calendar_dir ?filter
-
?(comparator = Event.by_start) ?limit () =
-
let* events = Calendar_dir.get_events ~fs calendar_dir in
-
let events =
-
match filter with Some f -> List.filter f events | None -> events
-
in
-
let events = List.sort comparator events in
-
Ok (match limit with Some n when n > 0 -> take n events | _ -> events)
-
-
let query ~fs calendar_dir ?filter ~from ~to_ ?(comparator = Event.by_start)
-
?limit () =
-
let* events = Calendar_dir.get_events ~fs calendar_dir in
-
let events =
-
match filter with Some f -> List.filter f events | None -> events
-
in
-
let events =
-
List.concat_map
-
(fun event -> Event.expand_recurrences event ~from ~to_)
-
events
-
in
-
let events = List.sort comparator events in
-
Ok (match limit with Some n when n > 0 -> take n events | _ -> events)
-42
lib/query.mli
···
-
(** Filter-based searching and querying of calendar events *)
-
-
type filter = Event.t -> bool
-
-
val summary_contains : string -> filter
-
val description_contains : string -> filter
-
val location_contains : string -> filter
-
val in_calendars : string list -> filter
-
val recurring_only : unit -> filter
-
val non_recurring_only : unit -> filter
-
val with_id : Event.event_id -> filter
-
val and_filter : filter list -> filter
-
val or_filter : filter list -> filter
-
val not_filter : filter -> filter
-
-
val query_without_recurrence :
-
fs:Eio.Fs.dir_ty Eio.Path.t ->
-
Calendar_dir.t ->
-
?filter:filter ->
-
?comparator:Event.comparator ->
-
?limit:int ->
-
unit ->
-
(Event.t list, [> `Msg of string ]) result
-
(** Find events without expansion of recurring events. Returns Ok with the list
-
of events, or Error with a message. *)
-
-
val query :
-
fs:Eio.Fs.dir_ty Eio.Path.t ->
-
Calendar_dir.t ->
-
?filter:filter ->
-
from:Ptime.t option ->
-
to_:Ptime.t ->
-
?comparator:Event.comparator ->
-
?limit:int ->
-
unit ->
-
(Event.t list, [> `Msg of string ]) result
-
(** Find events with expansion of recurring events. Returns Ok with the list of
-
events, or Error with a message. *)
-
-
(* Test-only helper functions *)
-
val matches_filter : Event.t -> filter -> bool
-
(** Check if an event matches the given filter *)
+120
lib/sexp.ml
···
+
open Sexplib.Std
+
+
type search_field = Summary | Description | Location [@@deriving sexp]
+
+
type query_request = {
+
from : string option; [@sexp.option]
+
to_ : string; (* Required field, not optional *)
+
timezone : string option; [@sexp.option]
+
calendars : string list; [@default []]
+
text : string option; [@sexp.option]
+
search_in : search_field list; [@default []]
+
id : string option; [@sexp.option]
+
recurring : bool option; [@sexp.option]
+
limit : int option; [@sexp.option]
+
}
+
[@@deriving sexp]
+
+
(* workaround https://github.com/janestreet/ppx_sexp_conv/issues/18#issuecomment-2792574295 *)
+
let query_request_of_sexp sexp =
+
let open Sexplib.Sexp in
+
let sexp = match sexp with
+
| List ss ->
+
List (List.map (function List (Atom "to" :: v) -> List (Atom "to_" :: v) | v -> v) ss)
+
| v -> v
+
in
+
query_request_of_sexp sexp
+
+
let sexp_of_query_request q =
+
let open Sexplib.Sexp in
+
let sexp = sexp_of_query_request q in
+
let sexp = match sexp with
+
| List ss ->
+
List (List.map (function List (Atom "to_" :: v) -> List (Atom "to" :: v) | v -> v) ss)
+
| v -> v
+
in
+
sexp
+
+
type request = ListCalendars | Query of query_request | Refresh [@@deriving sexp]
+
+
type response_payload = Calendars of string list | Events of Event.t list | Empty
+
[@@deriving sexp_of]
+
+
type response = Ok of response_payload | Error of string [@@deriving sexp_of]
+
+
let filter_func_of_search_field text = function
+
| Summary -> Event.summary_contains text
+
| Description -> Event.description_contains text
+
| Location -> Event.location_contains text
+
+
let parse_timezone ~timezone =
+
match timezone with
+
| Some tzid -> (
+
match Timedesc.Time_zone.make tzid with
+
| Some tz -> tz
+
| None -> failwith ("Invalid timezone: " ^ tzid))
+
| None -> !Date.default_timezone ()
+
+
let generate_query_params (req : query_request) =
+
let ( let* ) = Result.bind in
+
let tz = parse_timezone ~timezone:req.timezone in
+
let* from =
+
match req.from with
+
| None -> Ok None
+
| Some s -> Result.map Option.some (Date.parse_date ~tz s `From)
+
in
+
let* to_ =
+
let* to_date = Date.parse_date ~tz req.to_ `To in
+
Ok (Date.to_end_of_day to_date)
+
in
+
let filters = ref [] in
+
(match req.calendars with
+
| [] -> ()
+
| cals -> filters := Event.in_calendars cals :: !filters);
+
(match req.text with
+
| Some text ->
+
let search_fields =
+
match req.search_in with
+
| [] -> [ Summary; Description; Location ]
+
| fields -> fields
+
in
+
let text_filters =
+
List.map (filter_func_of_search_field text) search_fields
+
in
+
filters := Event.or_filter text_filters :: !filters
+
| None -> ());
+
(match req.id with
+
| Some id -> filters := Event.with_id id :: !filters
+
| None -> ());
+
(match req.recurring with
+
| Some true -> filters := Event.recurring_only () :: !filters
+
| Some false -> filters := Event.non_recurring_only () :: !filters
+
| _ -> ());
+
let final_filter = Event.and_filter !filters in
+
let limit = req.limit in
+
Ok (final_filter, from, to_, limit, tz)
+
+
let is_whitespace = function ' ' | '\t' | '\n' | '\r' -> true | _ -> false
+
+
let needs_quotes s =
+
String.exists
+
(fun c -> is_whitespace c || c = '(' || c = ')' || c = '"' || c = '\'')
+
s
+
+
let escape s =
+
let buf = Buffer.create (String.length s) in
+
String.iter
+
(function
+
| '"' -> Buffer.add_string buf "\\\""
+
| '\\' -> Buffer.add_string buf "\\\\"
+
| '\n' -> Buffer.add_string buf "\\n"
+
| '\r' -> Buffer.add_string buf "\\r"
+
| '\t' -> Buffer.add_string buf "\\t"
+
| c -> Buffer.add_char buf c)
+
s;
+
"\"" ^ Buffer.contents buf ^ "\""
+
+
let rec to_string = function
+
| Sexplib.Sexp.Atom str -> if needs_quotes str then escape str else str
+
| Sexplib.Sexp.List lst ->
+
"(" ^ String.concat " " (List.map to_string lst) ^ ")"
+1 -1
test/dune
···
(tests
-
(names test_calendar_dir test_query)
+
(names test_calendar_dir test_date test_event)
(libraries caledonia_lib alcotest str ptime)
(deps
(source_tree calendar)))
+7 -7
test/test_date.ml
···
(* Test the Date module *)
+
open Caledonia_lib
+
(* Setup a fixed date for testing *)
let fixed_date = Option.get @@ Ptime.of_date_time ((2025, 3, 27), ((0, 0, 0), 0))
let setup_fixed_date () =
-
(Date.get_today := fun () -> fixed_date);
+
(Date.get_today := fun ?tz:_ () -> fixed_date);
fixed_date
let test_parse_date () =
let test_expr expr parameter expected =
try
-
let result = Query.parse_date expr parameter in
+
let result = Result.get_ok @@ Date.parse_date expr parameter in
let result_str =
let y, m, d = Ptime.to_date result in
Printf.sprintf "%04d-%02d-%02d" y m d
···
test_expr "2025-3-1" `From "2025-03-01";
test_expr "2025-3-1" `To "2025-03-01";
(try
-
let _ = Query.parse_date "invalid-format" `From in
+
let _ = Result.get_ok @@ Date.parse_date "invalid-format" `From in
Alcotest.fail "Should have raised an exception for invalid format"
with Failure msg ->
Alcotest.(check bool)
···
(String.length msg > 0));
()
-
let date_tests fs = [ ("date expression parsing", `Quick, test_parse_date) ]
+
let date_tests = [ ("date expression parsing", `Quick, test_parse_date) ]
let () =
-
Eio_main.run @@ fun env ->
-
let fs = Eio.Stdenv.fs env in
let _ = setup_fixed_date () in
-
Alcotest.run "Query Tests" [ ("query", date_tests fs) ]
+
Alcotest.run "Query Tests" [ ("query", date_tests) ]
+92 -109
test/test_query.ml test/test_event.ml
···
Some (Option.get @@ Ptime.of_date_time ((2025, 01, 01), ((0, 0, 0), 0)))
in
let to_ = Option.get @@ Ptime.of_date_time ((2026, 01, 01), ((0, 0, 0), 0)) in
-
match Query.query ~fs calendar_dir ~from ~to_ () with
-
| Ok events ->
-
Alcotest.(check int) "Should find events" 791 (List.length events);
-
let test_event =
-
List.find_opt
-
(fun event -> Option.get @@ Event.get_summary event = "Test Event")
-
events
-
in
-
Alcotest.(check bool) "Should find Test Event" true (test_event <> None)
-
| Error _ -> Alcotest.fail "Error querying events"
+
let events = Result.get_ok @@ Calendar_dir.get_events ~fs calendar_dir in
+
let events = Event.query events ~from ~to_ () in
+
Alcotest.(check int) "Should find events" 791 (List.length events);
+
let test_event =
+
List.find_opt
+
(fun event -> Option.get @@ Event.get_summary event = "Test Event")
+
events
+
in
+
Alcotest.(check bool) "Should find Test Event" true (test_event <> None)
let test_recurrence_expansion ~fs () =
let calendar_dir =
···
let to_ =
Option.get @@ Ptime.of_date_time ((2025, 5, 31), ((23, 59, 59), 0))
in
-
match Query.query ~fs calendar_dir ~from ~to_ () with
-
| Ok events ->
-
let recurring_events =
-
List.filter
-
(fun event ->
-
Option.get @@ Event.get_summary event = "Recurring Event")
-
events
-
in
-
Alcotest.(check bool)
-
"Should find multiple recurring event events" true
-
(List.length recurring_events > 1)
-
| Error _ -> Alcotest.fail "Error querying events"
+
let events = Result.get_ok @@ Calendar_dir.get_events ~fs calendar_dir in
+
let events = Event.query events ~from ~to_ () in
+
let recurring_events =
+
List.filter
+
(fun event -> Option.get @@ Event.get_summary event = "Recurring Event")
+
events
+
in
+
Alcotest.(check bool)
+
"Should find multiple recurring event events" true
+
(List.length recurring_events > 1)
let test_text_search ~fs () =
let calendar_dir =
Result.get_ok @@ Calendar_dir.create ~fs calendar_dir_path
in
-
let filter = Query.summary_contains "Test" in
+
let filter = Event.summary_contains "Test" in
let from =
Some (Option.get @@ Ptime.of_date_time ((2025, 01, 01), ((0, 0, 0), 0)))
in
let to_ = Option.get @@ Ptime.of_date_time ((2026, 01, 01), ((0, 0, 0), 0)) in
-
(match Query.query ~fs calendar_dir ~from ~to_ ~filter () with
-
| Ok events ->
-
Alcotest.(check int)
-
"Should find event with 'Test' in summary" 2 (List.length events)
-
| Error _ -> Alcotest.fail "Error querying events");
-
let filter = Query.location_contains "Weekly" in
-
(match Query.query ~fs calendar_dir ~from ~to_ ~filter () with
-
| Ok events ->
-
Alcotest.(check int)
-
"Should find event with 'Weekly' in location" 10 (List.length events)
-
| Error _ -> Alcotest.fail "Error querying events");
+
let events = Result.get_ok @@ Calendar_dir.get_events ~fs calendar_dir in
+
(let events = Event.query events ~from ~to_ ~filter () in
+
Alcotest.(check int)
+
"Should find event with 'Test' in summary" 2 (List.length events));
+
let filter = Event.location_contains "Weekly" in
+
(let events = Event.query events ~from ~to_ ~filter () in
+
Alcotest.(check int)
+
"Should find event with 'Weekly' in location" 10 (List.length events));
let filter =
-
Query.and_filter
-
[ Query.summary_contains "Test"; Query.description_contains "test" ]
+
Event.and_filter
+
[ Event.summary_contains "Test"; Event.description_contains "test" ]
in
-
(match Query.query ~fs calendar_dir ~from ~to_ ~filter () with
-
| Ok events ->
-
Alcotest.(check int)
-
"Should find events matching combined and criteria" 2
-
(List.length events)
-
| Error _ -> Alcotest.fail "Error querying events");
+
(let events = Event.query events ~from ~to_ ~filter () in
+
Alcotest.(check int)
+
"Should find events matching combined and criteria" 2 (List.length events));
let filter =
-
Query.or_filter
-
[ Query.summary_contains "Test"; Query.location_contains "Weekly" ]
+
Event.or_filter
+
[ Event.summary_contains "Test"; Event.location_contains "Weekly" ]
in
-
(match Query.query ~fs calendar_dir ~from ~to_ ~filter () with
-
| Ok events ->
-
Alcotest.(check int)
-
"Should find events matching combined or criteria" 12
-
(List.length events)
-
| Error _ -> Alcotest.fail "Error querying events");
+
(let events = Event.query events ~from ~to_ ~filter () in
+
Alcotest.(check int)
+
"Should find events matching combined or criteria" 12 (List.length events));
()
let test_calendar_filter ~fs () =
···
in
let to_ = Option.get @@ Ptime.of_date_time ((2026, 01, 01), ((0, 0, 0), 0)) in
let calendar_name = "example" in
-
let filter = Query.in_calendars [ calendar_name ] in
-
(match Query.query ~fs calendar_dir ~from ~to_ ~filter () with
-
| Ok events ->
-
let all_match_calendar =
-
List.for_all
-
(fun e ->
-
match Event.get_calendar_name e with id -> id = calendar_name)
-
events
-
in
-
Alcotest.(check bool)
-
(Printf.sprintf "All events should be from calendar '%s'" calendar_name)
-
true all_match_calendar;
-
Alcotest.(check int) "Should find events" 2 (List.length events)
-
| Error _ -> Alcotest.fail "Error querying events");
+
let filter = Event.in_calendars [ calendar_name ] in
+
let events = Result.get_ok @@ Calendar_dir.get_events ~fs calendar_dir in
+
(let events = Event.query events ~from ~to_ ~filter () in
+
let all_match_calendar =
+
List.for_all
+
(fun e ->
+
match Event.get_calendar_name e with id -> id = calendar_name)
+
events
+
in
+
Alcotest.(check bool)
+
(Printf.sprintf "All events should be from calendar '%s'" calendar_name)
+
true all_match_calendar;
+
Alcotest.(check int) "Should find events" 2 (List.length events));
let calendar_names = [ "example"; "recurrence" ] in
-
let filter = Query.in_calendars calendar_names in
-
(match Query.query ~fs calendar_dir ~from ~to_ ~filter () with
-
| Ok events ->
-
Alcotest.(check int) "Should find events" 791 (List.length events)
-
| Error _ -> Alcotest.fail "Error querying events");
-
let filter = Query.in_calendars [ "non-existent-calendar" ] in
-
(match Query.query ~fs calendar_dir ~from ~to_ ~filter () with
-
| Ok events ->
-
Alcotest.(check int)
-
"Should find 0 events for non-existent calendar" 0 (List.length events)
-
| Error _ -> Alcotest.fail "Error querying events");
+
let filter = Event.in_calendars calendar_names in
+
(let events = Event.query events ~from ~to_ ~filter () in
+
Alcotest.(check int) "Should find events" 791 (List.length events));
+
let filter = Event.in_calendars [ "non-existent-calendar" ] in
+
(let events = Event.query events ~from ~to_ ~filter () in
+
Alcotest.(check int)
+
"Should find 0 events for non-existent calendar" 0 (List.length events));
()
let test_events ~fs =
···
let test_case_insensitive_search ~fs () =
(* Test lowercase query for an uppercase word *)
-
let lowercase_filter = Query.summary_contains "important" in
+
let lowercase_filter = Event.summary_contains "important" in
let matches =
List.filter
-
(fun e -> Query.matches_filter e lowercase_filter)
+
(fun e -> Event.matches_filter e lowercase_filter)
(test_events ~fs)
in
Alcotest.(check bool)
"Lowercase query should match uppercase text in summary" true
(contains_summary matches "IMPORTANT Meeting");
(* Test uppercase query for a lowercase word *)
-
let uppercase_filter = Query.description_contains "WEEKLY" in
+
let uppercase_filter = Event.description_contains "WEEKLY" in
let matches =
List.filter
-
(fun e -> Query.matches_filter e uppercase_filter)
+
(fun e -> Event.matches_filter e uppercase_filter)
(test_events ~fs)
in
Alcotest.(check bool)
···
let test_partial_word_matching ~fs () =
(* Test searching for part of a word *)
-
let partial_filter = Query.summary_contains "Conf" in
+
let partial_filter = Event.summary_contains "Conf" in
(* Should match "Conference" *)
let matches =
List.filter
-
(fun e -> Query.matches_filter e partial_filter)
+
(fun e -> Event.matches_filter e partial_filter)
(test_events ~fs)
in
Alcotest.(check bool)
"Partial query should match full word in summary" true
(contains_summary matches "Conference Call");
(* Test another partial word in description *)
-
let partial_filter = Query.description_contains "nation" in
+
let partial_filter = Event.description_contains "nation" in
(* Should match "International" *)
let matches =
List.filter
-
(fun e -> Query.matches_filter e partial_filter)
+
(fun e -> Event.matches_filter e partial_filter)
(test_events ~fs)
in
Alcotest.(check bool)
···
let test_boolean_logic ~fs () =
(* Test AND filter *)
let and_filter =
-
Query.and_filter
-
[ Query.summary_contains "Meeting"; Query.description_contains "project" ]
+
Event.and_filter
+
[ Event.summary_contains "Meeting"; Event.description_contains "project" ]
in
let matches =
-
List.filter (fun e -> Query.matches_filter e and_filter) (test_events ~fs)
+
List.filter (fun e -> Event.matches_filter e and_filter) (test_events ~fs)
in
Alcotest.(check int)
"AND filter should match events with both terms" 2
···
(List.length matches);
(* Test OR filter *)
let or_filter =
-
Query.or_filter
-
[ Query.summary_contains "Workshop"; Query.summary_contains "Conference" ]
+
Event.or_filter
+
[ Event.summary_contains "Workshop"; Event.summary_contains "Conference" ]
in
let matches =
-
List.filter (fun e -> Query.matches_filter e or_filter) (test_events ~fs)
+
List.filter (fun e -> Event.matches_filter e or_filter) (test_events ~fs)
in
Alcotest.(check int)
"OR filter should match events with either term"
···
(List.length matches);
(* Test NOT filter *)
-
let not_filter = Query.not_filter (Query.summary_contains "Meeting") in
+
let not_filter = Event.not_filter (Event.summary_contains "Meeting") in
let matches =
-
List.filter (fun e -> Query.matches_filter e not_filter) (test_events ~fs)
+
List.filter (fun e -> Event.matches_filter e not_filter) (test_events ~fs)
in
Alcotest.(check int)
"NOT filter should match events without the term"
···
(List.length matches);
(* Test complex combination: (Meeting AND project) OR Workshop BUT NOT Conference *)
let complex_filter =
-
Query.and_filter
+
Event.and_filter
[
-
Query.or_filter
+
Event.or_filter
[
-
Query.and_filter
+
Event.and_filter
[
-
Query.summary_contains "Meeting";
-
Query.description_contains "project";
+
Event.summary_contains "Meeting";
+
Event.description_contains "project";
];
-
Query.summary_contains "Workshop";
+
Event.summary_contains "Workshop";
];
-
Query.not_filter (Query.summary_contains "Conference");
+
Event.not_filter (Event.summary_contains "Conference");
]
in
let matches =
List.filter
-
(fun e -> Query.matches_filter e complex_filter)
+
(fun e -> Event.matches_filter e complex_filter)
(test_events ~fs)
in
Alcotest.(check int)
···
let test_cross_field_search ~fs () =
(* Search for a term that appears in multiple fields across different events *)
let term_filter =
-
Query.or_filter
+
Event.or_filter
[
-
Query.summary_contains "meeting";
-
Query.description_contains "meeting";
-
Query.location_contains "meeting";
+
Event.summary_contains "meeting";
+
Event.description_contains "meeting";
+
Event.location_contains "meeting";
]
in
let matches =
-
List.filter (fun e -> Query.matches_filter e term_filter) (test_events ~fs)
+
List.filter (fun e -> Event.matches_filter e term_filter) (test_events ~fs)
in
Alcotest.(check int)
"Cross-field search should find all occurrences"
···
(List.length matches);
(* Another test with a different term *)
let term_filter =
-
Query.or_filter
+
Event.or_filter
[
-
Query.summary_contains "conference";
-
Query.description_contains "conference";
-
Query.location_contains "conference";
+
Event.summary_contains "conference";
+
Event.description_contains "conference";
+
Event.location_contains "conference";
]
in
let matches =
-
List.filter (fun e -> Query.matches_filter e term_filter) (test_events ~fs)
+
List.filter (fun e -> Event.matches_filter e term_filter) (test_events ~fs)
in
Alcotest.(check int)
"Cross-field search should find all occurrences of 'conference'"