Command-line and Emacs Calendar Client

Compare changes

Choose any two refs to compare.

+8
CHANGELOG.md
···
### 0.3.0
···
+
+
### 0.4.0
+
+
- Emacs front end that communicates with a server mode via an S-expression protocol
+
+
### 0.3.1
+
+
- various bugfixes and tweaks
### 0.3.0
+35 -19
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)).
-
It has the `list`, `search`, `show`, `add`, `delete`, and `edit` subcommands, and has full timezone support.
An example `list` invocation is,
```
$ caled list
-
054bb346-b24f-49f4-80ab-fcb6040c19a7 personal 2025-04-04 Fri 13:00 - 14:00 (America/New_York) New York 8am meeting
-
3B84B125-6EFC-4E1C-B35A-97EFCA61110E family 2025-04-06 Sun 21:00 - 22:00 (UTC) Family chat @Video call
-
4adcb98dfc1848601e38c2ea55edf71fab786c674d7b72d4c263053b23560a8d work 2025-04-09 Wed 15:00 - 16:00 (Europe/London) Weekly Meeting
-
ccef66cd4d1e87ae7319097f027f8322de67f758 personal 2025-04-10 Thu 11:00 - 12:00 (UTC) Dentist
-
3B84B125-6EFC-4E1C-B35A-97EFCA61110E family 2025-04-13 Sun 21:00 - 22:00 (UTC) Family chat @Video call
-
33cf18ec-90d3-40f8-8335-f338fbdb395b personal 2025-04-15 Tue - 2025-04-17 Thu John Doe in town
-
8601c255-65fc-4bc9-baa9-465dd7b4cd7d personal 2025-04-15 Tue 21:00 - 21:30 (UTC) Grandma call
-
4adcb98dfc1848601e38c2ea55edf71fab786c674d7b72d4c263053b23560a8d work 2025-04-16 Wed 15:00 - 16:00 (Europe/London) Weekly Meeting
-
7hm4laoadevr1ene8o876f2576@google.com personal 2025-04-19 Sat Jane Doe's birthday
-
3B84B125-6EFC-4E1C-B35A-97EFCA61110E family 2025-04-20 Sun 21:00 - 22:00 (UTC) Family chat @Video call
-
8601c255-65fc-4bc9-baa9-465dd7b4cd7d personal 2025-04-22 Tue 21:00 - 21:30 (UTC) Grandma call
-
4adcb98dfc1848601e38c2ea55edf71fab786c674d7b72d4c263053b23560a8d work 2025-04-23 Wed 15:00 - 16:00 (Europe/London) Weekly Meeting
-
3B84B125-6EFC-4E1C-B35A-97EFCA61110E family 2025-04-27 Sun 21:00 - 22:00 (UTC) Family chat @Video call
-
8601c255-65fc-4bc9-baa9-465dd7b4cd7d personal 2025-04-29 Tue 21:00 - 21:30 (UTC) Grandma call
-
4adcb98dfc1848601e38c2ea55edf71fab786c674d7b72d4c263053b23560a8d work 2025-04-30 Wed 15:00 - 16:00 (Europe/London) Weekly Meeting
```
See [TODO](./TODO.org) for future plans.
## Configuration
···
+
# Caledonia
+
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.
+
The command-line has the `list`, `search`, `show`, `add`, `delete`, and `edit` subcommands, and has full timezone support.
An example `list` invocation is,
```
$ caled list
+
personal 2025-04-04 Fri 13:00 - 14:00 (America/New_York) New York 8am meeting 054bb346-b24f-49f4-80ab-fcb6040c19a7
+
family 2025-04-06 Sun 21:00 - 22:00 (UTC) Family chat @Video call 3B84B125-6EFC-4E1C-B35A-97EFCA61110E
+
work 2025-04-09 Wed 15:00 - 16:00 (Europe/London) Weekly Meeting 4adcb98dfc1848601e38c2ea55edf71fab786c674d7b72d4c263053b23560a8d
+
personal 2025-04-10 Thu 11:00 - 12:00 (UTC) Dentist ccef66cd4d1e87ae7319097f027f8322de67f758
+
family 2025-04-13 Sun 21:00 - 22:00 (UTC) Family chat @Video call 3B84B125-6EFC-4E1C-B35A-97EFCA61110E
+
personal 2025-04-15 Tue - 2025-04-17 Thu John Doe in town 33cf18ec-90d3-40f8-8335-f338fbdb395b
+
personal 2025-04-15 Tue 21:00 - 21:30 (UTC) Grandma call 8601c255-65fc-4bc9-baa9-465dd7b4cd7d
+
work 2025-04-16 Wed 15:00 - 16:00 (Europe/London) Weekly Meeting 4adcb98dfc1848601e38c2ea55edf71fab786c674d7b72d4c263053b23560a8d
+
personal 2025-04-19 Sat Jane Doe's birthday 7hm4laoadevr1ene8o876f2576@google.com
+
family 2025-04-20 Sun 21:00 - 22:00 (UTC) Family chat @Video call 3B84B125-6EFC-4E1C-B35A-97EFCA61110E
+
personal 2025-04-22 Tue 21:00 - 21:30 (UTC) Grandma call 8601c255-65fc-4bc9-baa9-465dd7b4cd7d
+
work 2025-04-23 Wed 15:00 - 16:00 (Europe/London) Weekly Meeting 4adcb98dfc1848601e38c2ea55edf71fab786c674d7b72d4c263053b23560a8d
+
family 2025-04-27 Sun 21:00 - 22:00 (UTC) Family chat @Video call 3B84B125-6EFC-4E1C-B35A-97EFCA61110E
+
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.
+
+
## Installation
+
+
With [opam](https://opam.ocaml.org/),
+
+
```
+
$ opam install .
+
```
+
+
With [Nix](https://nixos.org/),
+
+
```
+
$ nix shell 'git+https://tangled.sh/@ryan.freumh.org/caledonia?ref=main'
+
```
## Configuration
+33 -28
TODO.org
···
-
- [x] list/search events
-
- [x] add/remove events
-
- [x] edit events
-
- [x] timezones
-
- [x] remove collection module
-
- ref [[https://github.com/ocaml-ppx/ppxlib/issues/481]] cc patrick
-
- [ ] allow editting recurrence-ids
-
- [ ] really stress test the timezone handling -- this is full of gotcha's
-
- [ ] don't load all calendars into memory to show only one event
-
- [ ] support specifying duration
-
- [ ] diagnose events failing to parse
-
- [[https://github.com/robur-coop/icalendar/issues/14]]
-
- [ ] [[https://github.com/robur-coop/icalendar/pull/13][handle RECURRENCE-ID]]
-
- [ ] [[https://github.com/robur-coop/icalendar/issues/15][RRULE with local datetime]]
-
- [ ] CalDAV syncing
-
- Currently, you can use [[https://github.com/pimutils/vdirsyncer][vdirsyncer]]
-
- [ ] support querying times as well as dates
-
- [ ] custom date/time formatting
-
- [ ] support querying regex
-
- [ ] support VTIMEZONE
-
- [ ] support VALARMS
-
- [ ] support VOTODS
-
- [ ] support VCARDS
-
- [ ] server mode
-
- and maybe 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 client like mu4e
-
- [ ] parallel queries
···
+
* DONE list/search events
+
* DONE add/remove events
+
* DONE edit events
+
* DONE timezones
+
* DONE diagnose events failing to parse [[https://github.com/robur-coop/icalendar/issues/14]]
+
* DONE [[https://github.com/robur-coop/icalendar/pull/13][handle RECURRENCE-ID]]
+
* DONE [[https://github.com/robur-coop/icalendar/issues/15][RRULE with local datetime]]
+
* DONE server mode
+
* DONE hold =Event='s in-memory instead of parsing them for every =Query=
+
* TODO support vdir metadata
+
* TODO add timezone all date functions
+
* TODO allow editing recurrence-ids
+
* TODO support specifying duration
+
* TODO CalDAV syncing
+
* TODO support complex datetime queries
+
* TODO custom date/time formatting
+
* TODO support querying text with regex
+
* TODO support VTIMEZONE
+
* TODO support VALARMS
+
* TODO support VOTODS
+
* TODO support VCARDS
+
* TODO support VJOURNAL
+
* TODO implement TUI front end with something like [[https://github.com/leostera/minttea][minttea]]
+
* TODO implement an emacs front end, like mu4e to mu
+
** DONE listing, searching, querying
+
** DONE show details
+
** DONE show file
+
** DONE refresh
+
** DONE list possible calendars
+
** DONE add functions and bindings to change query parameters on the fly
+
** TODO timezone support
+
** TODO support adding, deleting, and editing events
+
** TODO add a org-agenda style view for caledonia-list
+21 -5
bin/add_cmd.ml
···
| None -> Error (`Msg "Start date required")
in
let* end_ =
let end_date =
-
match end_date with None -> start_date | Some e -> Some e
in
-
parse_end ~end_date ~end_time ~timezone ~end_timezone
in
let* recurrence =
match recur with
···
| None -> Ok None
in
let calendar_name = calendar_name in
-
let event =
Event.create ~fs
~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
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 =
match
run ~summary ~start_date ~start_time ~end_date ~end_time ~location
~description ~recur ~calendar_name ?timezone ?end_timezone ~fs
···
| 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 =
+
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 =
+
(* if we specify and end date and time without a end timezone, default to the start timezone *)
+
match (end_date, end_time, end_timezone) with
+
| Some _, Some _, None -> timezone
+
| _ -> end_timezone
+
in
+
parse_end ~end_date ~end_time ~end_timezone
in
let* recurrence =
match recur with
···
| None -> Ok None
in
let calendar_name = calendar_name in
+
let* event =
Event.create ~fs
~calendar_dir_path:(Calendar_dir.get_path calendar_dir)
~summary ~start ?end_ ?location ?description ?recurrence calendar_name
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 () =
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* event =
-
match results 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
match result with
| Error (`Msg msg) -> Error (`Msg msg)
-
| 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 =
match run ~event_id ~fs calendar_dir with
| Error (`Msg msg) ->
Printf.eprintf "Error: %s\n%!" msg;
···
let run ~event_id ~fs calendar_dir =
let ( let* ) = Result.bind 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 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 events event in
match result with
| Error (`Msg msg) -> Error (`Msg msg)
+
| 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 () =
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)
(modules
main
query_args
···
show_cmd
add_cmd
delete_cmd
-
edit_cmd))
···
ptime.clock.os
eio
eio_main
+
timere
+
sexplib
+
sexplib.unix)
(modules
main
query_args
···
show_cmd
add_cmd
delete_cmd
+
edit_cmd
+
server_cmd))
+17 -7
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* event =
match results with
| [ event ] -> Ok event
···
let* start = parse_start ~start_date ~start_time ~timezone in
let* end_ =
let end_date =
-
match end_date with None -> start_date | Some e -> Some e
in
-
parse_end ~end_date ~end_time ~timezone ~end_timezone
in
let* recurrence =
match recur with
···
Ok (Some p)
| None -> Ok None
in
-
let modifed_event =
Event.edit ?summary ?start ?end_ ?location ?description ?recurrence event
in
-
let* _ = Calendar_dir.edit_event ~fs calendar_dir 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 =
match
run ~event_id ~summary ~start_date ~start_time ~end_date ~end_time
~location ~description ~recur ?timezone ?end_timezone ~fs calendar_dir
···
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 = 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* start = parse_start ~start_date ~start_time ~timezone in
let* end_ =
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
+
let end_timezone =
+
(* if we specify and end date and time without a end timezone, default to the start timezone *)
+
match (end_date, end_time, end_timezone) with
+
| Some _, Some _, None -> timezone
+
| _ -> end_timezone
+
in
+
parse_end ~end_date ~end_time ~end_timezone
in
let* recurrence =
match recur with
···
Ok (Some p)
| None -> Ok None
in
+
let* modifed_event =
Event.edit ?summary ?start ?end_ ?location ?description ?recurrence 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 () =
match
run ~event_id ~summary ~start_date ~start_time ~end_date ~end_time
~location ~description ~recur ?timezone ?end_timezone ~fs calendar_dir
+28 -28
bin/event_args.ml
···
match start_time with
| None -> Ok ()
| Some _ ->
-
Error (`Msg "Can't specify an start time without an end date")
in
let* _ =
match timezone with
···
Date.parse_date ~tz:Timedesc.Time_zone.utc start_date `From
in
let date = Ptime.to_date ptime in
-
Ok (Some (`Date date))
| Some start_time -> (
match timezone with
| None ->
···
Date.parse_date_time ~tz:Timedesc.Time_zone.utc ~date:start_date
~time:start_time `From
in
-
Ok (Some (`Datetime (`With_tzid (datetime, (false, tzid)))))
| Some "FLOATING" ->
let* datetime =
Date.parse_date_time ~tz:Timedesc.Time_zone.utc ~date:start_date
~time:start_time `From
in
-
Ok (Some (`Datetime (`Local datetime)))
| Some "UTC" ->
let* datetime =
Date.parse_date_time ~tz:Timedesc.Time_zone.utc ~date:start_date
~time:start_time `From
in
-
Ok (Some (`Datetime (`Utc datetime)))
| Some tzid ->
let* datetime =
Date.parse_date_time ~tz:Timedesc.Time_zone.utc ~date:start_date
~time:start_time `From
in
-
Ok (Some (`Datetime (`With_tzid (datetime, (false, tzid)))))))
-
let parse_end ~end_date ~end_time ~timezone ~end_timezone =
let ( let* ) = Result.bind in
let* _ =
-
match timezone with
-
| None -> Ok ()
-
| Some tzid -> (
-
match Timedesc.Time_zone.make tzid with
-
| Some _ -> Ok ()
-
| None ->
-
Error (`Msg (Printf.sprintf "Warning: Unknown timezone %s" tzid)))
-
in
-
let* _ =
match end_timezone with
| None -> Ok ()
| Some tzid -> (
···
let* _ =
match end_timezone with
| None -> Ok ()
-
| Some _ -> Error (`Msg "Can't specify a timezone without a end time")
in
Ok None
| Some end_date -> (
match end_time with
| None ->
let* _ =
-
match (timezone, end_timezone) with
-
| None, None -> Ok ()
-
| Some _, None ->
-
Error (`Msg "Can't specify a timezone without a end time")
-
| _ ->
Error (`Msg "Can't specify an end timezone without a end time")
in
let* ptime =
Date.parse_date end_date ~tz:Timedesc.Time_zone.utc `From
in
let date = Ptime.to_date ptime in
-
Ok (Some (`Dtend (Icalendar.Params.empty, `Date date)))
| Some end_time -> (
-
match (timezone, end_timezone) with
-
| None, None ->
let* tzid =
match Timedesc.Time_zone.local () with
| Some tz -> Ok (Timedesc.Time_zone.name tz)
···
(`Dtend
( Icalendar.Params.empty,
`Datetime (`With_tzid (datetime, (false, tzid))) )))
-
| _, Some "FLOATING" | Some "FLOATING", None ->
let* datetime =
Date.parse_date_time ~tz:Timedesc.Time_zone.utc ~date:end_date
~time:end_time `From
···
Ok
(Some
(`Dtend (Icalendar.Params.empty, `Datetime (`Local datetime))))
-
| _, Some "UTC" | Some "UTC", None ->
let* datetime =
Date.parse_date_time ~tz:Timedesc.Time_zone.utc ~date:end_date
~time:end_time `From
···
Ok
(Some
(`Dtend (Icalendar.Params.empty, `Datetime (`Utc datetime))))
-
| _, Some tzid | Some tzid, _ ->
let* datetime =
Date.parse_date_time ~tz:Timedesc.Time_zone.utc ~date:end_date
~time:end_time `From
···
match start_time with
| None -> Ok ()
| Some _ ->
+
Error (`Msg "Can't specify an start time without a start date")
in
let* _ =
match timezone with
···
Date.parse_date ~tz:Timedesc.Time_zone.utc start_date `From
in
let date = Ptime.to_date ptime in
+
Ok (Some (Icalendar.Params.singleton Valuetype `Date, `Date date))
| Some start_time -> (
match timezone with
| None ->
···
Date.parse_date_time ~tz:Timedesc.Time_zone.utc ~date:start_date
~time:start_time `From
in
+
Ok
+
(Some
+
( Icalendar.Params.empty,
+
`Datetime (`With_tzid (datetime, (false, tzid))) ))
| Some "FLOATING" ->
let* datetime =
Date.parse_date_time ~tz:Timedesc.Time_zone.utc ~date:start_date
~time:start_time `From
in
+
Ok (Some (Icalendar.Params.empty, `Datetime (`Local datetime)))
| Some "UTC" ->
let* datetime =
Date.parse_date_time ~tz:Timedesc.Time_zone.utc ~date:start_date
~time:start_time `From
in
+
Ok (Some (Icalendar.Params.empty, `Datetime (`Utc datetime)))
| Some tzid ->
let* datetime =
Date.parse_date_time ~tz:Timedesc.Time_zone.utc ~date:start_date
~time:start_time `From
in
+
Ok
+
(Some
+
( Icalendar.Params.empty,
+
`Datetime (`With_tzid (datetime, (false, tzid))) ))))
+
let parse_end ~end_date ~end_time ~end_timezone =
let ( let* ) = Result.bind in
let* _ =
match end_timezone with
| None -> Ok ()
| Some tzid -> (
···
let* _ =
match end_timezone with
| None -> Ok ()
+
| Some _ ->
+
Error (`Msg "Can't specify an end timezone without an end date")
in
Ok None
| Some end_date -> (
match end_time with
| None ->
let* _ =
+
match end_timezone with
+
| Some _ ->
Error (`Msg "Can't specify an end timezone without a end time")
+
| _ -> Ok ()
in
let* ptime =
Date.parse_date end_date ~tz:Timedesc.Time_zone.utc `From
in
+
(* DTEND;VALUE=DATE the event ends at the start of the specified date *)
+
let ptime = Date.add_days ptime 1 in
let date = Ptime.to_date ptime in
+
Ok
+
(Some
+
(`Dtend (Icalendar.Params.singleton Valuetype `Date, `Date date)))
| Some end_time -> (
+
match end_timezone with
+
| None ->
let* tzid =
match Timedesc.Time_zone.local () with
| Some tz -> Ok (Timedesc.Time_zone.name tz)
···
(`Dtend
( Icalendar.Params.empty,
`Datetime (`With_tzid (datetime, (false, tzid))) )))
+
| Some "FLOATING" ->
let* datetime =
Date.parse_date_time ~tz:Timedesc.Time_zone.utc ~date:end_date
~time:end_time `From
···
Ok
(Some
(`Dtend (Icalendar.Params.empty, `Datetime (`Local datetime))))
+
| Some "UTC" ->
let* datetime =
Date.parse_date_time ~tz:Timedesc.Time_zone.utc ~date:end_date
~time:end_time `From
···
Ok
(Some
(`Dtend (Icalendar.Params.empty, `Datetime (`Utc datetime))))
+
| Some tzid ->
let* datetime =
Date.parse_date_time ~tz:Timedesc.Time_zone.utc ~date:end_date
~time:end_time `From
+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, 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)
in
let comparator = Query_args.create_event_comparator sort in
-
let* results =
-
Query.query ~fs calendar_dir ?filter ~from ~to_ ~comparator ?limit:count ()
in
-
if results = [] then print_endline "No events found."
-
else print_endline (Event.format_events ~format ~tz results);
Ok ()
let cmd ~fs calendar_dir =
let run from_str to_str calendars count format today tomorrow week month
-
timezone sort =
match
run ?from_str ?to_str ~calendar:calendars ?count ~format ~today ~tomorrow
~week ~month ?timezone ~sort ~fs calendar_dir
···
| Some f, None ->
let one_month_later = Date.add_months f 1 in
Ok (Some f, one_month_later)
+
| 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 (Event.in_calendars calendar)
in
let comparator = Query_args.create_event_comparator sort in
+
let* events = Calendar_dir.get_events ~fs calendar_dir in
+
let events =
+
Event.query events ?filter ~from ~to_ ~comparator ?limit:count ()
in
+
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 () =
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 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;
])
with
-
| Ok (`Ok n) -> n
| Ok _ -> 0
| Error _ -> 1)
···
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 f) -> f ()
| Ok _ -> 0
| Error _ -> 1)
+18 -17
bin/search_cmd.ml
···
in
(match calendar with
| [] -> ()
-
| calendars -> filters := Query.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 not (summary || description || location) then
filters :=
-
Query.or_filter
[
-
Query.summary_contains text;
-
Query.description_contains text;
-
Query.location_contains text;
]
:: !filters
| None -> ());
-
if recurring then filters := Query.recurring_only () :: !filters;
-
if non_recurring then filters := Query.non_recurring_only () :: !filters;
(match id with
-
| Some id -> filters := Query.with_id id :: !filters
| None -> ());
-
let filter = Query.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 ()
in
-
if results = [] then print_endline "No events found."
-
else print_endline (Event.format_events ~tz ~format results);
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 =
match
run ?from_str ?to_str ~calendar:calendars ?count ?query_text ~summary
~description ~location ~id ~format ~today ~tomorrow ~week ~month
···
in
(match calendar with
| [] -> ()
+
| calendars -> filters := Event.in_calendars calendars :: !filters);
(match query_text with
| Some text ->
+
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 :=
+
Event.or_filter
[
+
Event.summary_contains text;
+
Event.description_contains text;
+
Event.location_contains text;
]
:: !filters
| None -> ());
+
if recurring then filters := Event.recurring_only () :: !filters;
+
if non_recurring then filters := Event.non_recurring_only () :: !filters;
(match id with
+
| Some id -> filters := Event.with_id id :: !filters
| None -> ());
+
let filter = Event.and_filter !filters in
let comparator = Query_args.create_event_comparator sort in
+
let* events = Calendar_dir.get_events ~fs calendar_dir in
+
let events =
+
Event.query events ~filter ~from ~to_ ~comparator ?limit:count ()
in
+
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 () =
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
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 =
match run ~event_id ~format ~fs calendar_dir with
| Error (`Msg msg) ->
Printf.eprintf "Error: %s\n%!" msg;
···
let run ~event_id ~format ~fs calendar_dir =
let ( let* ) = Result.bind 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 () =
match run ~event_id ~format ~fs calendar_dir with
| Error (`Msg msg) ->
Printf.eprintf "Error: %s\n%!" msg;
+2 -1
caledonia.opam
···
opam-version: "2.0"
-
version: "0.3.0"
maintainer: "Ryan Gibb <ryan@freumh.org"
authors: ["Ryan Gibb <ryan@freumh.org"]
homepage: "https://ryan.freumh.org/caledonia.html"
···
"eio_main" {>= "0.12"}
"timere" {>= "0.8.0"}
"timedesc" {>= "0.8.0"}
"alcotest" {>= "1.8.0" & with-test}
]
pin-depends: [
···
opam-version: "2.0"
+
version: "0.4.0"
maintainer: "Ryan Gibb <ryan@freumh.org"
authors: ["Ryan Gibb <ryan@freumh.org"]
homepage: "https://ryan.freumh.org/caledonia.html"
···
"eio_main" {>= "0.12"}
"timere" {>= "0.8.0"}
"timedesc" {>= "0.8.0"}
+
"ppx_sexp_conv" {>= "0.15.1"}
"alcotest" {>= "1.8.0" & with-test}
]
pin-depends: [
+2 -2
dune-project
···
(lang dune 3.4)
(name caledonia)
-
(version 0.3.0)
-
(using directory-targets 0.1)
···
(lang dune 3.4)
(name caledonia)
+
(version 0.4.0)
+
(using directory-targets 0.1)
+56
emacs/caledonia-evil.el
···
···
+
;;; caledonia-evil.el --- Evil bindings for Caledonia -*- lexical-binding: t; -*-
+
;;
+
;; Copyright (C) 2025 Ryan Gibb
+
;;
+
;; Author: Ryan Gibb <ryan@freumh.org>
+
;; Maintainer: Ryan Gibb <ryan@freumh.org>
+
;; Version: 0.4.0
+
;; Keywords: calendar
+
;; Package-Requires: ((emacs "24.3"))
+
;; URL: https://ryan.freumh.org/caledonia.html
+
;;
+
;; This file is not part of GNU Emacs.
+
;;
+
;;; Commentary:
+
;;
+
;; This package provides Evil bindings for Caledonia.
+
;;
+
;;; Code:
+
+
(require 'evil)
+
(require 'caledonia)
+
+
(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.")
+
+
(defun caledonia-evil--setup-evil-bindings ()
+
"Set up Evil keybindings for `caledonia-mode`."
+
(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
+
"f" caledonia-evil-filter-map))
+
+
(defun caledonia-evil--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)
+
(caledonia-evil--setup-evil-bindings)))
+
+
(add-hook 'caledonia-mode-hook 'caledonia-evil--setup-evil-integration)
+
+
(provide 'caledonia-evil)
+
;;; caledonia-evil.el ends here
+788
emacs/caledonia.el
···
···
+
;;; caledonia.el --- Emacs integration for Caledonia -*- lexical-binding: t -*-
+
+
;; Copyright (C) 2025 Ryan Gibb
+
+
;; Author: Ryan Gibb <ryan@freumh.org>
+
;; Maintainer: Ryan Gibb <ryan@freumh.org>
+
;; Version: 0.4.0
+
;; Keywords: calendar
+
;; Package-Requires: ((emacs "24.4"))
+
;; 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 (executable-find "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
+
"Non-nil means a responce has been recieved.")
+
(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)
+
"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)
+
"Listen on PROCESS for an EVENT."
+
(message "Caledonia Server process event: %s (%s)" process event)
+
(setq caledonia--server-process nil))
+
+
(defun caledonia--ensure-server-running ()
+
"Run the caledonia binary in server mode."
+
(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)
+
"Send REQUEST-STR and get responce back."
+
(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 "Caledonia Failed to parse response line: %s"
+
caledonia--response-line (error-message-string err))))
+
+
(defun caledonia--get-events (event-payload)
+
"Parse EVENT-PAYLOAD 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 ISO-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 between A and B."
+
(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 between A and B."
+
(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 between A and B."
+
(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 (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 (completing-read "Recurring events (yes/no/all, leave empty for all): "
+
'("" "yes" "no") nil nil 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))
+
(push `(recurring ,(if (string= recurring "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."
+
(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, 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, 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.")
+
+
(provide 'caledonia)
+
;;; caledonia.el ends here
+175
flake.lock
···
···
+
{
+
"nodes": {
+
"flake-compat": {
+
"flake": false,
+
"locked": {
+
"lastModified": 1696426674,
+
"narHash": "sha256-kvjfFW7WAETZlt09AgDn1MrtKzP7t90Vf7vypd3OL1U=",
+
"owner": "edolstra",
+
"repo": "flake-compat",
+
"rev": "0f9255e01c2351cc7d116c072cb317785dd33b33",
+
"type": "github"
+
},
+
"original": {
+
"owner": "edolstra",
+
"repo": "flake-compat",
+
"type": "github"
+
}
+
},
+
"flake-utils": {
+
"inputs": {
+
"systems": "systems"
+
},
+
"locked": {
+
"lastModified": 1731533236,
+
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
+
"owner": "numtide",
+
"repo": "flake-utils",
+
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
+
"type": "github"
+
},
+
"original": {
+
"owner": "numtide",
+
"repo": "flake-utils",
+
"type": "github"
+
}
+
},
+
"mirage-opam-overlays": {
+
"flake": false,
+
"locked": {
+
"lastModified": 1710922379,
+
"narHash": "sha256-j4QREQDUf8oHOX7qg6wAOupgsNQoYlufxoPrgagD+pY=",
+
"owner": "dune-universe",
+
"repo": "mirage-opam-overlays",
+
"rev": "797cb363df3ff763c43c8fbec5cd44de2878757e",
+
"type": "github"
+
},
+
"original": {
+
"owner": "dune-universe",
+
"repo": "mirage-opam-overlays",
+
"type": "github"
+
}
+
},
+
"nixpkgs": {
+
"locked": {
+
"lastModified": 1743761221,
+
"narHash": "sha256-g79y3RpbyfOlwm3W4yuMXLmmNI7iDgIDqg10nq8pz5U=",
+
"owner": "nixos",
+
"repo": "nixpkgs",
+
"rev": "e1964791d66993a8aa5b9f19c1c6dd5827851355",
+
"type": "github"
+
},
+
"original": {
+
"owner": "nixos",
+
"repo": "nixpkgs",
+
"type": "github"
+
}
+
},
+
"opam-nix": {
+
"inputs": {
+
"flake-compat": "flake-compat",
+
"flake-utils": [
+
"flake-utils"
+
],
+
"mirage-opam-overlays": "mirage-opam-overlays",
+
"nixpkgs": [
+
"nixpkgs"
+
],
+
"opam-overlays": "opam-overlays",
+
"opam-repository": "opam-repository",
+
"opam2json": "opam2json"
+
},
+
"locked": {
+
"lastModified": 1744376747,
+
"narHash": "sha256-CKZLqLgZtMrUyZKlroISYp6Z4aoN1N9xeyk0dPNxGvc=",
+
"owner": "RyanGibb",
+
"repo": "opam-nix",
+
"rev": "787eeba962582ff0142f445a74f1edb667d7e941",
+
"type": "github"
+
},
+
"original": {
+
"owner": "RyanGibb",
+
"ref": "timere",
+
"repo": "opam-nix",
+
"type": "github"
+
}
+
},
+
"opam-overlays": {
+
"flake": false,
+
"locked": {
+
"lastModified": 1726822209,
+
"narHash": "sha256-bwM18ydNT9fYq91xfn4gmS21q322NYrKwfq0ldG9GYw=",
+
"owner": "dune-universe",
+
"repo": "opam-overlays",
+
"rev": "f2bec38beca4aea9e481f2fd3ee319c519124649",
+
"type": "github"
+
},
+
"original": {
+
"owner": "dune-universe",
+
"repo": "opam-overlays",
+
"type": "github"
+
}
+
},
+
"opam-repository": {
+
"flake": false,
+
"locked": {
+
"lastModified": 1740730647,
+
"narHash": "sha256-6veU2WjUGcWDAzLDjoAI1L6GWZd0KIUq19sHcbJS+u8=",
+
"owner": "ocaml",
+
"repo": "opam-repository",
+
"rev": "f1f75fef5fbf1e8bd1cc9544e50b89ba59f625e2",
+
"type": "github"
+
},
+
"original": {
+
"owner": "ocaml",
+
"repo": "opam-repository",
+
"type": "github"
+
}
+
},
+
"opam2json": {
+
"inputs": {
+
"nixpkgs": [
+
"opam-nix",
+
"nixpkgs"
+
]
+
},
+
"locked": {
+
"lastModified": 1671540003,
+
"narHash": "sha256-5pXfbUfpVABtKbii6aaI2EdAZTjHJ2QntEf0QD2O5AM=",
+
"owner": "tweag",
+
"repo": "opam2json",
+
"rev": "819d291ea95e271b0e6027679de6abb4d4f7f680",
+
"type": "github"
+
},
+
"original": {
+
"owner": "tweag",
+
"repo": "opam2json",
+
"type": "github"
+
}
+
},
+
"root": {
+
"inputs": {
+
"flake-utils": "flake-utils",
+
"nixpkgs": "nixpkgs",
+
"opam-nix": "opam-nix"
+
}
+
},
+
"systems": {
+
"locked": {
+
"lastModified": 1681028828,
+
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
+
"owner": "nix-systems",
+
"repo": "default",
+
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
+
"type": "github"
+
},
+
"original": {
+
"owner": "nix-systems",
+
"repo": "default",
+
"type": "github"
+
}
+
}
+
},
+
"root": "root",
+
"version": 7
+
}
+39
flake.nix
···
···
+
{
+
inputs = {
+
nixpkgs.url = "github:nixos/nixpkgs";
+
opam-nix.url = "github:tweag/opam-nix";
+
flake-utils.url = "github:numtide/flake-utils";
+
# we pin opam-nix's nixpkgs to follow the flakes, avoiding using two different instances
+
opam-nix.inputs.nixpkgs.follows = "nixpkgs";
+
# deduplicate flakes
+
opam-nix.inputs.flake-utils.follows = "flake-utils";
+
};
+
outputs = { self, nixpkgs, flake-utils, opam-nix, ... }@inputs:
+
flake-utils.lib.eachDefaultSystem (system:
+
let
+
package = "caledonia";
+
pkgs = nixpkgs.legacyPackages.${system};
+
opam-nix-lib = opam-nix.lib.${system};
+
devPackagesQuery = {
+
ocaml-lsp-server = "*";
+
ocamlformat = "*";
+
utop = "*";
+
};
+
query = {
+
ocaml-base-compiler = "*";
+
};
+
scope =
+
opam-nix-lib.buildOpamProject' { } ./. (query // devPackagesQuery);
+
in {
+
packages.default = scope.${package};
+
defaultPackage = scope.${package};
+
+
devShells.default = let
+
devPackages = builtins.attrValues
+
(pkgs.lib.getAttrs (builtins.attrNames devPackagesQuery) scope);
+
in pkgs.mkShell {
+
inputsFrom = [ scope.${package} ];
+
buildInputs = devPackages;
+
};
+
});
+
}
+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 }
let get_calendar_path ~fs calendar_dir calendar_name_name =
-
Eio.Path.(fs / calendar_dir.path / 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 }
| Error e -> Error e
let list_calendar_names ~fs calendar_dir =
try
-
let dir = Eio.Path.(fs / calendar_dir.path) 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
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
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 ( let* ) = Result.bind
···
(Printf.sprintf "Error getting calendar_names: %s"
(Printexc.to_string exn))))
-
let add_event ~fs calendar_dir 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 ()
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 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 ()
-
let delete_event ~fs calendar_dir 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 ()
-
let get_path t = t.path
···
open Icalendar
+
type t = string
let get_calendar_path ~fs calendar_dir 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
| Error e -> Error e
let list_calendar_names ~fs calendar_dir =
try
+
let dir = Eio.Path.(fs / calendar_dir) in
let calendar_names =
Eio.Path.read_dir dir
|> List.filter_map (fun 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
Eio.Exn.pp exn))
let rec load_events_recursive calendar_name dir_path =
···
[]
let get_calendar_events ~fs calendar_dir calendar_name =
+
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 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
try
Eio.Path.save ~create:(`Or_truncate 0o644) file content;
+
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))
+
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
+
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 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
+
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
+12 -3
lib/calendar_dir.mli
···
val add_event :
fs:Eio.Fs.dir_ty Eio.Path.t ->
t ->
Event.t ->
-
(unit, [> `Msg of string ]) result
val edit_event :
fs:Eio.Fs.dir_ty Eio.Path.t ->
t ->
Event.t ->
-
(unit, [> `Msg of string ]) result
val delete_event :
fs:Eio.Fs.dir_ty Eio.Path.t ->
t ->
Event.t ->
-
(unit, [> `Msg of string ]) result
val get_path : t -> string
···
val add_event :
fs:Eio.Fs.dir_ty Eio.Path.t ->
t ->
+
Event.t list ->
Event.t ->
+
(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 ->
+
(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 ->
+
(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
-1
lib/collection.ml
···
-
type t = Col of string
···
-3
lib/collection.mli
···
-
(** A calendar_name is a directory of `ics` files *)
-
-
type t = Col of string (** The name of the calendar_name. *)
···
+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 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
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)))
| _ -> Some (Error (`Msg (Printf.sprintf "Invalid date unit: %s" unit)))
else None
···
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+)([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 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 *)
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,
···
(** 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
+
- "+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)
(preprocess
-
(pps ppx_deriving.show ppx_deriving.eq)))
···
yojson
uuidm
eio
+
eio_main
+
cmdliner
+
sexplib)
(preprocess
+
(pps ppx_deriving.show ppx_deriving.eq ppx_sexp_conv)))
+147 -42
lib/event.ml
···
Uuidm.to_string uuid
let default_prodid = `Prodid (Params.empty, "-//Freumh//Caledonia//EN")
let create ~(fs : Eio.Fs.dir_ty Eio.Path.t) ~calendar_dir_path ~summary ~start
?end_ ?location ?description ?recurrence calendar_name =
···
Eio.Path.(
fs / calendar_dir_path / (match calendar_name with s -> s) / file_name)
in
-
let dtstart = (Params.empty, start) in
let dtend_or_duration = end_ in
let rrule = Option.map (fun r -> (Params.empty, r)) recurrence in
let now = Ptime_clock.now () in
let props = [ `Summary (Params.empty, summary) ] in
···
let components = [ `Event event ] in
(props, components)
in
-
{ calendar_name; file; event; calendar }
let edit ?summary ?start ?end_ ?location ?description ?recurrence t =
let now = Ptime_clock.now () in
let uid = t.event.uid in
-
let dtstart =
-
match start with None -> t.event.dtstart | Some s -> (Params.empty, s)
-
in
let dtend_or_duration =
match end_ with None -> t.event.dtend_or_duration | Some _ -> end_
in
let rrule =
match recurrence with
| None -> t.event.rrule
···
let calendar_name = t.calendar_name in
let file = t.file in
let calendar = t.calendar in
-
{ calendar_name; file; event; calendar }
let events_of_icalendar calendar_name ~file calendar =
let remove_dup_ids lst =
···
match t.event.dtstart with
| _, `Datetime (`With_tzid (_, (_, tzid))) -> Some tzid
| _, `Datetime (`Utc _) -> Some "UTC"
-
| _, `Datetime (`Local _) -> Some "FLOATING"
| _ -> None
let get_end_timezone t =
match t.event.dtend_or_duration with
| Some (`Dtend (_, `Datetime (`With_tzid (_, (_, tzid))))) -> Some tzid
| Some (`Dtend (_, `Datetime (`Utc _))) -> Some "UTC"
-
| Some (`Dtend (_, `Datetime (`Local _))) -> Some "FLOATING"
| _ -> None
let get_duration t =
···
let id, calendar_name, date_time, summary, location =
text_event_data ?tz event
in
-
Printf.sprintf "%s\t%s\t%s\t%s\t%s" id calendar_name date_time summary
-
location
| `Entries ->
let format_opt label f opt =
Option.map (fun x -> Printf.sprintf "%s: %s\n" label (f x)) opt
···
let summary =
match get_summary event with Some summary -> summary | None -> ""
in
-
let start_date, start_time =
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 )
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
| 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 \
:description %s :calendar %s))"
-
(String.escaped id) (String.escaped summary) start_date start_time
-
end_str location description calendar
let format_events_with_dynamic_columns ?tz events =
if events = [] then ""
···
(fun acc (_, _, date, _, _) -> max acc (String.length date))
0 event_data
in
-
(* Format each event with calculated widths *)
let formatted_events =
List.map
(fun (id, cal, date, summary, location) ->
-
Printf.sprintf "%-*s %-*s %-*s %s%s" max_id_width id max_cal_width
-
cal max_date_width date summary
-
(if location <> "" then " " ^ location else ""))
event_data
in
String.concat "\n" formatted_events
···
recur_events ~recurrence_ids:other_events ical_event
in
collect generator []
···
Uuidm.to_string uuid
let default_prodid = `Prodid (Params.empty, "-//Freumh//Caledonia//EN")
+
let ( let* ) = Result.bind
let create ~(fs : Eio.Fs.dir_ty Eio.Path.t) ~calendar_dir_path ~summary ~start
?end_ ?location ?description ?recurrence calendar_name =
···
Eio.Path.(
fs / calendar_dir_path / (match calendar_name with s -> s) / file_name)
in
+
let dtstart = start in
let dtend_or_duration = end_ in
+
let* _ =
+
match (dtstart, dtend_or_duration) with
+
| (_, `Date _), Some (`Dtend (_, `Datetime _)) ->
+
Error (`Msg "If the start is a date the end must also be a date.")
+
| (_, `Datetime _), Some (`Dtend (_, `Date _)) ->
+
Error
+
(`Msg "If the start is a datetime the end must also be a datetime.")
+
| _ -> Ok ()
+
in
let rrule = Option.map (fun r -> (Params.empty, r)) recurrence in
let now = Ptime_clock.now () in
let props = [ `Summary (Params.empty, summary) ] in
···
let components = [ `Event event ] in
(props, components)
in
+
Ok { calendar_name; file; event; calendar }
let edit ?summary ?start ?end_ ?location ?description ?recurrence t =
let now = Ptime_clock.now () in
let uid = t.event.uid in
+
let dtstart = match start with None -> t.event.dtstart | Some s -> s in
let dtend_or_duration =
match end_ with None -> t.event.dtend_or_duration | Some _ -> end_
in
+
let* _ =
+
match (dtstart, dtend_or_duration) with
+
| (_, `Date _), Some (`Dtend (_, `Datetime _)) ->
+
Error (`Msg "If the start is a date the end must also be a date.")
+
| (_, `Datetime _), Some (`Dtend (_, `Date _)) ->
+
Error
+
(`Msg "If the start is a datetime the end must also be a datetime.")
+
| _ -> Ok ()
+
in
let rrule =
match recurrence with
| None -> t.event.rrule
···
let calendar_name = t.calendar_name in
let file = t.file in
let calendar = t.calendar in
+
Ok { calendar_name; file; event; calendar }
let events_of_icalendar calendar_name ~file calendar =
let remove_dup_ids lst =
···
match t.event.dtstart with
| _, `Datetime (`With_tzid (_, (_, tzid))) -> Some tzid
| _, `Datetime (`Utc _) -> Some "UTC"
| _ -> None
let get_end_timezone t =
match t.event.dtend_or_duration with
| Some (`Dtend (_, `Datetime (`With_tzid (_, (_, tzid))))) -> Some tzid
| Some (`Dtend (_, `Datetime (`Utc _))) -> Some "UTC"
| _ -> None
let get_duration t =
···
let id, calendar_name, date_time, summary, location =
text_event_data ?tz event
in
+
Printf.sprintf "%s\t%s\t%s\t%s\t%s" calendar_name date_time summary
+
location id
| `Entries ->
let format_opt label f opt =
Option.map (fun x -> Printf.sprintf "%s: %s\n" label (f x)) opt
···
let summary =
match get_summary event with Some summary -> summary | None -> ""
in
+
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
+
(* 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
+
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 :end %s :location %s \
:description %s :calendar %s))"
+
(String.escaped id) (String.escaped summary) start_str end_str location
+
description calendar
let format_events_with_dynamic_columns ?tz events =
if events = [] then ""
···
(fun acc (_, _, date, _, _) -> max acc (String.length date))
0 event_data
in
+
(* Calculate max width for summary+location *)
+
let max_summary_loc_width =
+
List.fold_left
+
(fun acc (_, _, _, summary, location) ->
+
let full_length =
+
String.length summary
+
+ if location <> "" then String.length location + 1 else 0
+
in
+
max acc full_length)
+
0 event_data
+
in
(* Format each event with calculated widths *)
let formatted_events =
List.map
(fun (id, cal, date, summary, location) ->
+
let summary_loc =
+
summary ^ if location <> "" then " " ^ location else ""
+
in
+
Printf.sprintf "%-*s %-*s %-*s %-*s" max_cal_width cal
+
max_date_width date max_summary_loc_width summary_loc max_id_width
+
id)
event_data
in
String.concat "\n" formatted_events
···
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
+54 -7
lib/event.mli
···
type date_error = [ `Msg of string ]
val create :
fs:Eio.Fs.dir_ty Eio.Path.t ->
calendar_dir_path:string ->
summary:string ->
-
start:Icalendar.date_or_datetime ->
?end_:
[ `Duration of Icalendar.params * Ptime.Span.t
| `Dtend of Icalendar.params * Icalendar.date_or_datetime ] ->
···
?description:string ->
?recurrence:Icalendar.recurrence ->
string ->
-
t
(** Create a new event with required properties.
The start and end times can be specified as Icalendar.timestamp values,
···
val edit :
?summary:string ->
-
?start:Icalendar.date_or_datetime ->
?end_:
[ `Duration of Icalendar.params * Ptime.Span.t
| `Dtend of Icalendar.params * Icalendar.date_or_datetime ] ->
···
?description:string ->
?recurrence:Icalendar.recurrence ->
t ->
-
t
(** Edit an existing event. *)
val events_of_icalendar :
···
val get_recurrence : t -> Icalendar.recurrence option
val get_calendar_name : t -> string
val get_file : t -> Eio.Fs.dir_ty Eio.Path.t
type comparator = t -> t -> int
(** Event comparator function type *)
···
(** Chain two comparators together, using the second one as a tiebreaker when
the first one returns equality (0) *)
-
(** Functions for formatting various data structures as strings *)
type format = [ `Text | `Entries | `Json | `Csv | `Ics | `Sexp ]
(** Format type for output *)
-
(** Functions for formatting specific event types *)
val format_event : ?format:format -> ?tz:Timedesc.Time_zone.t -> t -> string
(** Format a single event, optionally using the specified timezone *)
···
?format:format -> ?tz:Timedesc.Time_zone.t -> t list -> string
(** Format a list of events, optionally using the specified timezone *)
-
val expand_recurrences : from:Ptime.t option -> to_:Ptime.t -> t -> t list
···
type date_error = [ `Msg of string ]
+
(** {2 Events} *)
+
val create :
fs:Eio.Fs.dir_ty Eio.Path.t ->
calendar_dir_path:string ->
summary:string ->
+
start:Icalendar.params * Icalendar.date_or_datetime ->
?end_:
[ `Duration of Icalendar.params * Ptime.Span.t
| `Dtend of Icalendar.params * Icalendar.date_or_datetime ] ->
···
?description:string ->
?recurrence:Icalendar.recurrence ->
string ->
+
(t, [> `Msg of string ]) result
(** Create a new event with required properties.
The start and end times can be specified as Icalendar.timestamp values,
···
val edit :
?summary:string ->
+
?start:Icalendar.params * Icalendar.date_or_datetime ->
?end_:
[ `Duration of Icalendar.params * Ptime.Span.t
| `Dtend of Icalendar.params * Icalendar.date_or_datetime ] ->
···
?description:string ->
?recurrence:Icalendar.recurrence ->
t ->
+
(t, [> `Msg of string ]) result
(** Edit an existing event. *)
val events_of_icalendar :
···
val get_recurrence : t -> Icalendar.recurrence option
val get_calendar_name : t -> string
val get_file : t -> Eio.Fs.dir_ty Eio.Path.t
+
val expand_recurrences : from:Ptime.t option -> to_:Ptime.t -> t -> t list
+
+
(** {2 Comparators} *)
type comparator = t -> t -> int
(** Event comparator function type *)
···
(** Chain two comparators together, using the second one as a tiebreaker when
the first one returns equality (0) *)
+
(** 2 Formatting *)
type format = [ `Text | `Entries | `Json | `Csv | `Ics | `Sexp ]
(** Format type for output *)
val format_event : ?format:format -> ?tz:Timedesc.Time_zone.t -> t -> string
(** Format a single event, optionally using the specified timezone *)
···
?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)
(libraries caledonia_lib alcotest str ptime)
(deps
(source_tree calendar)))
···
(tests
+
(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 *)
(* 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);
fixed_date
let test_parse_date () =
let test_expr expr parameter expected =
try
-
let result = Query.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
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 () =
-
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) ]
···
(* 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 ?tz:_ () -> fixed_date);
fixed_date
let test_parse_date () =
let test_expr expr parameter expected =
try
+
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 _ = 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 = [ ("date expression parsing", `Quick, test_parse_date) ]
let () =
let _ = setup_fixed_date () in
+
Alcotest.run "Query Tests" [ ("query", date_tests) ]
+322
test/test_event.ml
···
···
+
(* Test the Query 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 ?tz:_ () -> fixed_date);
+
fixed_date
+
+
let calendar_dir_path = Filename.concat (Sys.getcwd ()) "calendar"
+
+
let test_query_all ~fs () =
+
let calendar_dir =
+
Result.get_ok @@ Calendar_dir.create ~fs calendar_dir_path
+
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
+
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 =
+
Result.get_ok @@ Calendar_dir.create ~fs calendar_dir_path
+
in
+
let from =
+
Some (Option.get @@ Ptime.of_date_time ((2025, 3, 1), ((0, 0, 0), 0)))
+
in
+
let to_ =
+
Option.get @@ Ptime.of_date_time ((2025, 5, 31), ((23, 59, 59), 0))
+
in
+
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 = 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
+
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 =
+
Event.and_filter
+
[ Event.summary_contains "Test"; Event.description_contains "test" ]
+
in
+
(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 =
+
Event.or_filter
+
[ Event.summary_contains "Test"; Event.location_contains "Weekly" ]
+
in
+
(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 () =
+
let calendar_dir =
+
Result.get_ok @@ Calendar_dir.create ~fs calendar_dir_path
+
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
+
let calendar_name = "example" in
+
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 = 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 =
+
(* Create a test event with specific text in all fields *)
+
let create_test_event ~calendar_name ~summary ~description ~location ~start =
+
Event.create ~fs ~calendar_dir_path ~summary ~start
+
?description:(if description = "" then None else Some description)
+
?location:(if location = "" then None else Some location)
+
calendar_name
+
in
+
[
+
(* Event with text in all fields *)
+
Result.get_ok
+
@@ create_test_event ~calendar_name:"search_test" ~summary:"Project Meeting"
+
~description:"Weekly project status meeting with team"
+
~location:"Conference Room A"
+
~start:(Icalendar.Params.empty, `Datetime (`Utc fixed_date));
+
(* Event with mixed case to test case insensitivity *)
+
Result.get_ok
+
@@ create_test_event ~calendar_name:"search_test"
+
~summary:"IMPORTANT Meeting"
+
~description:"Critical project review with stakeholders"
+
~location:"Executive Suite"
+
~start:(Icalendar.Params.empty, `Datetime (`Utc fixed_date));
+
(* Event with word fragments *)
+
Result.get_ok
+
@@ create_test_event ~calendar_name:"search_test" ~summary:"Conference Call"
+
~description:"International conference preparation"
+
~location:"Remote Meeting Room"
+
~start:(Icalendar.Params.empty, `Datetime (`Utc fixed_date));
+
(* Event with unique text in each field *)
+
Result.get_ok
+
@@ create_test_event ~calendar_name:"search_test"
+
~summary:"Workshop on Testing"
+
~description:"Quality Assurance techniques and practices"
+
~location:"Training Center"
+
~start:(Icalendar.Params.empty, `Datetime (`Utc fixed_date));
+
]
+
+
(* Test helper to verify if a list of events contains an event with a given summary *)
+
let contains_summary events summary =
+
List.exists
+
(fun e -> String.equal (Option.get @@ Event.get_summary e) summary)
+
events
+
+
let test_case_insensitive_search ~fs () =
+
(* Test lowercase query for an uppercase word *)
+
let lowercase_filter = Event.summary_contains "important" in
+
let matches =
+
List.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 = Event.description_contains "WEEKLY" in
+
let matches =
+
List.filter
+
(fun e -> Event.matches_filter e uppercase_filter)
+
(test_events ~fs)
+
in
+
Alcotest.(check bool)
+
"Uppercase query should match lowercase text in description" true
+
(contains_summary matches "Project Meeting")
+
+
let test_partial_word_matching ~fs () =
+
(* Test searching for part of a word *)
+
let partial_filter = Event.summary_contains "Conf" in
+
(* Should match "Conference" *)
+
let matches =
+
List.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 = Event.description_contains "nation" in
+
(* Should match "International" *)
+
let matches =
+
List.filter
+
(fun e -> Event.matches_filter e partial_filter)
+
(test_events ~fs)
+
in
+
Alcotest.(check bool)
+
"Partial query should match within word in description" true
+
(contains_summary matches "Conference Call");
+
+
Alcotest.(check bool)
+
"Partial query should match within word in description" true
+
(contains_summary matches "Conference Call")
+
+
let test_boolean_logic ~fs () =
+
(* Test AND filter *)
+
let and_filter =
+
Event.and_filter
+
[ Event.summary_contains "Meeting"; Event.description_contains "project" ]
+
in
+
let matches =
+
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
+
(* Two events have both "Meeting" in summary and "project" in description *)
+
(List.length matches);
+
(* Test OR filter *)
+
let or_filter =
+
Event.or_filter
+
[ Event.summary_contains "Workshop"; Event.summary_contains "Conference" ]
+
in
+
let matches =
+
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"
+
2 (* One event has "Workshop", one has "Conference" *)
+
(List.length matches);
+
+
(* Test NOT filter *)
+
let not_filter = Event.not_filter (Event.summary_contains "Meeting") in
+
let matches =
+
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"
+
2 (* Two events don't have "Meeting" in the summary *)
+
(List.length matches);
+
(* Test complex combination: (Meeting AND project) OR Workshop BUT NOT Conference *)
+
let complex_filter =
+
Event.and_filter
+
[
+
Event.or_filter
+
[
+
Event.and_filter
+
[
+
Event.summary_contains "Meeting";
+
Event.description_contains "project";
+
];
+
Event.summary_contains "Workshop";
+
];
+
Event.not_filter (Event.summary_contains "Conference");
+
]
+
in
+
let matches =
+
List.filter
+
(fun e -> Event.matches_filter e complex_filter)
+
(test_events ~fs)
+
in
+
Alcotest.(check int)
+
"Complex filter should match correctly"
+
3 (* Three events should match the complex criteria *)
+
(List.length matches)
+
+
let test_cross_field_search ~fs () =
+
(* Search for a term that appears in multiple fields across different events *)
+
let term_filter =
+
Event.or_filter
+
[
+
Event.summary_contains "meeting";
+
Event.description_contains "meeting";
+
Event.location_contains "meeting";
+
]
+
in
+
let matches =
+
List.filter (fun e -> Event.matches_filter e term_filter) (test_events ~fs)
+
in
+
Alcotest.(check int)
+
"Cross-field search should find all occurrences"
+
3 (* "meeting" appears in 3 events across different fields *)
+
(List.length matches);
+
(* Another test with a different term *)
+
let term_filter =
+
Event.or_filter
+
[
+
Event.summary_contains "conference";
+
Event.description_contains "conference";
+
Event.location_contains "conference";
+
]
+
in
+
let matches =
+
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'"
+
2 (* "conference" appears in 2 events across different fields *)
+
(List.length matches)
+
+
let query_tests fs =
+
[
+
("query all events", `Quick, test_query_all ~fs);
+
("recurrence expansion", `Quick, test_recurrence_expansion ~fs);
+
("text search", `Quick, test_text_search ~fs);
+
("calendar filter", `Quick, test_calendar_filter ~fs);
+
("case insensitive search", `Quick, test_case_insensitive_search ~fs);
+
("partial word matching", `Quick, test_partial_word_matching ~fs);
+
("boolean logic filters", `Quick, test_boolean_logic ~fs);
+
("cross-field searching", `Quick, test_cross_field_search ~fs);
+
]
+
+
let () =
+
Eio_main.run @@ fun env ->
+
let fs = Eio.Stdenv.fs env in
+
let _ = setup_fixed_date () in
+
Alcotest.run "Query Tests" [ ("query", query_tests fs) ]
-334
test/test_query.ml
···
-
(* Test the Query 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 ?tz:_ () -> fixed_date);
-
fixed_date
-
-
let calendar_dir_path = Filename.concat (Sys.getcwd ()) "calendar"
-
-
let test_query_all ~fs () =
-
let calendar_dir =
-
Result.get_ok @@ Calendar_dir.create ~fs calendar_dir_path
-
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_ () 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 test_recurrence_expansion ~fs () =
-
let calendar_dir =
-
Result.get_ok @@ Calendar_dir.create ~fs calendar_dir_path
-
in
-
let from =
-
Some (Option.get @@ Ptime.of_date_time ((2025, 3, 1), ((0, 0, 0), 0)))
-
in
-
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 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 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 filter =
-
Query.and_filter
-
[ Query.summary_contains "Test"; Query.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 filter =
-
Query.or_filter
-
[ Query.summary_contains "Test"; Query.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 test_calendar_filter ~fs () =
-
let calendar_dir =
-
Result.get_ok @@ Calendar_dir.create ~fs calendar_dir_path
-
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
-
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 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 test_events ~fs =
-
(* Create a test event with specific text in all fields *)
-
let create_test_event ~calendar_name ~summary ~description ~location ~start =
-
Event.create ~fs ~calendar_dir_path ~summary ~start
-
?description:(if description = "" then None else Some description)
-
?location:(if location = "" then None else Some location)
-
calendar_name
-
in
-
[
-
(* Event with text in all fields *)
-
create_test_event ~calendar_name:"search_test" ~summary:"Project Meeting"
-
~description:"Weekly project status meeting with team"
-
~location:"Conference Room A"
-
~start:(`Datetime (`Utc fixed_date));
-
(* Event with mixed case to test case insensitivity *)
-
create_test_event ~calendar_name:"search_test" ~summary:"IMPORTANT Meeting"
-
~description:"Critical project review with stakeholders"
-
~location:"Executive Suite"
-
~start:(`Datetime (`Utc fixed_date));
-
(* Event with word fragments *)
-
create_test_event ~calendar_name:"search_test" ~summary:"Conference Call"
-
~description:"International conference preparation"
-
~location:"Remote Meeting Room"
-
~start:(`Datetime (`Utc fixed_date));
-
(* Event with unique text in each field *)
-
create_test_event ~calendar_name:"search_test"
-
~summary:"Workshop on Testing"
-
~description:"Quality Assurance techniques and practices"
-
~location:"Training Center"
-
~start:(`Datetime (`Utc fixed_date));
-
]
-
-
(* Test helper to verify if a list of events contains an event with a given summary *)
-
let contains_summary events summary =
-
List.exists
-
(fun e -> String.equal (Option.get @@ Event.get_summary e) summary)
-
events
-
-
let test_case_insensitive_search ~fs () =
-
(* Test lowercase query for an uppercase word *)
-
let lowercase_filter = Query.summary_contains "important" in
-
let matches =
-
List.filter
-
(fun e -> Query.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 matches =
-
List.filter
-
(fun e -> Query.matches_filter e uppercase_filter)
-
(test_events ~fs)
-
in
-
Alcotest.(check bool)
-
"Uppercase query should match lowercase text in description" true
-
(contains_summary matches "Project Meeting")
-
-
let test_partial_word_matching ~fs () =
-
(* Test searching for part of a word *)
-
let partial_filter = Query.summary_contains "Conf" in
-
(* Should match "Conference" *)
-
let matches =
-
List.filter
-
(fun e -> Query.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
-
(* Should match "International" *)
-
let matches =
-
List.filter
-
(fun e -> Query.matches_filter e partial_filter)
-
(test_events ~fs)
-
in
-
Alcotest.(check bool)
-
"Partial query should match within word in description" true
-
(contains_summary matches "Conference Call");
-
-
Alcotest.(check bool)
-
"Partial query should match within word in description" true
-
(contains_summary matches "Conference Call")
-
-
let test_boolean_logic ~fs () =
-
(* Test AND filter *)
-
let and_filter =
-
Query.and_filter
-
[ Query.summary_contains "Meeting"; Query.description_contains "project" ]
-
in
-
let matches =
-
List.filter (fun e -> Query.matches_filter e and_filter) (test_events ~fs)
-
in
-
Alcotest.(check int)
-
"AND filter should match events with both terms" 2
-
(* Two events have both "Meeting" in summary and "project" in description *)
-
(List.length matches);
-
(* Test OR filter *)
-
let or_filter =
-
Query.or_filter
-
[ Query.summary_contains "Workshop"; Query.summary_contains "Conference" ]
-
in
-
let matches =
-
List.filter (fun e -> Query.matches_filter e or_filter) (test_events ~fs)
-
in
-
Alcotest.(check int)
-
"OR filter should match events with either term"
-
2 (* One event has "Workshop", one has "Conference" *)
-
(List.length matches);
-
-
(* Test NOT filter *)
-
let not_filter = Query.not_filter (Query.summary_contains "Meeting") in
-
let matches =
-
List.filter (fun e -> Query.matches_filter e not_filter) (test_events ~fs)
-
in
-
Alcotest.(check int)
-
"NOT filter should match events without the term"
-
2 (* Two events don't have "Meeting" in the summary *)
-
(List.length matches);
-
(* Test complex combination: (Meeting AND project) OR Workshop BUT NOT Conference *)
-
let complex_filter =
-
Query.and_filter
-
[
-
Query.or_filter
-
[
-
Query.and_filter
-
[
-
Query.summary_contains "Meeting";
-
Query.description_contains "project";
-
];
-
Query.summary_contains "Workshop";
-
];
-
Query.not_filter (Query.summary_contains "Conference");
-
]
-
in
-
let matches =
-
List.filter
-
(fun e -> Query.matches_filter e complex_filter)
-
(test_events ~fs)
-
in
-
Alcotest.(check int)
-
"Complex filter should match correctly"
-
3 (* Three events should match the complex criteria *)
-
(List.length matches)
-
-
let test_cross_field_search ~fs () =
-
(* Search for a term that appears in multiple fields across different events *)
-
let term_filter =
-
Query.or_filter
-
[
-
Query.summary_contains "meeting";
-
Query.description_contains "meeting";
-
Query.location_contains "meeting";
-
]
-
in
-
let matches =
-
List.filter (fun e -> Query.matches_filter e term_filter) (test_events ~fs)
-
in
-
Alcotest.(check int)
-
"Cross-field search should find all occurrences"
-
3 (* "meeting" appears in 3 events across different fields *)
-
(List.length matches);
-
(* Another test with a different term *)
-
let term_filter =
-
Query.or_filter
-
[
-
Query.summary_contains "conference";
-
Query.description_contains "conference";
-
Query.location_contains "conference";
-
]
-
in
-
let matches =
-
List.filter (fun e -> Query.matches_filter e term_filter) (test_events ~fs)
-
in
-
Alcotest.(check int)
-
"Cross-field search should find all occurrences of 'conference'"
-
2 (* "conference" appears in 2 events across different fields *)
-
(List.length matches)
-
-
let query_tests fs =
-
[
-
("query all events", `Quick, test_query_all ~fs);
-
("recurrence expansion", `Quick, test_recurrence_expansion ~fs);
-
("text search", `Quick, test_text_search ~fs);
-
("calendar filter", `Quick, test_calendar_filter ~fs);
-
("case insensitive search", `Quick, test_case_insensitive_search ~fs);
-
("partial word matching", `Quick, test_partial_word_matching ~fs);
-
("boolean logic filters", `Quick, test_boolean_logic ~fs);
-
("cross-field searching", `Quick, test_cross_field_search ~fs);
-
]
-
-
let () =
-
Eio_main.run @@ fun env ->
-
let fs = Eio.Stdenv.fs env in
-
let _ = setup_fixed_date () in
-
Alcotest.run "Query Tests" [ ("query", query_tests fs) ]
···