Command-line and Emacs Calendar Client

Compare changes

Choose any two refs to compare.

+8
CHANGELOG.md
···
+
+
### 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
+43 -4
README.md
···
-
# ๐Ÿ“… Caledonia ๐Ÿด๓ ง๓ ข๓ ณ๓ ฃ๓ ด๓ ฟ
+
# 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
+
```
-
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 supports timezones.
+
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
-
- [ ] 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 in a =Collection= 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
-
- [ ] remove collection module
-
- ref [[https://github.com/ocaml-ppx/ppxlib/issues/481]] cc patrick
+
* 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
+58 -68
bin/add_cmd.ml
···
open Event_args
let run ~summary ~start_date ~start_time ~end_date ~end_time ~location
-
~description ~recur ~collection ?timezone ?end_timezone ~fs calendar_dir =
+
~description ~recur ~calendar_name ?timezone ?end_timezone ~fs calendar_dir
+
=
let ( let* ) = Result.bind in
let* start = parse_start ~start_date ~start_time ~timezone in
let* start =
···
| Some s -> Ok s
| None -> Error (`Msg "Start date required")
in
-
let* end_ = parse_end ~end_date ~end_time ~timezone ~end_timezone 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
| Some r ->
···
Ok (Some p)
| None -> Ok None
in
-
let collection = Collection.Col collection in
-
let event =
+
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 collection
+
~summary ~start ?end_ ?location ?description ?recurrence calendar_name
in
-
let* _ = Calendar_dir.add_event ~fs calendar_dir event in
+
let* events = Calendar_dir.get_events ~fs calendar_dir in
+
let* _ = Calendar_dir.add_event ~fs calendar_dir events event in
Printf.printf "Event created with ID: %s\n" (Event.get_id event);
Ok ()
let cmd ~fs calendar_dir =
let run summary start_date start_time end_date end_time location description
-
recur collection timezone end_timezone =
+
recur calendar_name timezone end_timezone () =
match
run ~summary ~start_date ~start_time ~end_date ~end_time ~location
-
~description ~recur ~collection ?timezone ?end_timezone ~fs calendar_dir
+
~description ~recur ~calendar_name ?timezone ?end_timezone ~fs
+
calendar_dir
with
| Error (`Msg msg) ->
Printf.eprintf "Error: %s\n%!" msg;
···
Term.(
const run $ required_summary_arg $ start_date_arg $ start_time_arg
$ end_date_arg $ end_time_arg $ location_arg $ description_arg $ recur_arg
-
$ collection_arg $ timezone_arg $ end_time_arg)
+
$ calendar_name_arg $ timezone_arg $ end_timezone_arg)
in
let doc = "Add a new calendar event" in
let man =
···
`P
"Specify the event summary (title) as the first argument, and use \
options to set other details.";
+
`S Manpage.s_examples;
+
`I
+
( "Add a event for today:",
+
"caled add \"Meeting\" --date today --time 14:00" );
+
`I
+
( "Add an event with a specific date and time:",
+
"caled add \"Dentist Appointment\" --date 2025-04-15 --time 10:30" );
+
`I
+
( "Add an event with an end time:",
+
"caled add \"Conference\" --date 2025-05-20 --time 09:00 --end-date \
+
2025-05-22 --end-time 17:00" );
+
`I
+
( "Add an event with location and description:",
+
"caled add \"Lunch with Bob\" --date 2025-04-02 --time 12:30 \
+
--location \"Pasta Restaurant\" --description \"Discuss project \
+
plans\"" );
+
`I
+
( "Add an event to a specific calendar:",
+
"caled add \"Work Meeting\" --date 2025-04-03 --time 15:00 \
+
--calendar work" );
`S Manpage.s_options;
]
-
@ date_format_manpage_entries
-
@ [
-
`S Manpage.s_examples;
-
`I
-
( "Add a event for today:",
-
"caled add \"Meeting\" --date today --time 14:00" );
-
`I
-
( "Add an event with a specific date and time:",
-
"caled add \"Dentist Appointment\" --date 2025-04-15 --time 10:30"
-
);
-
`I
-
( "Add an event with an end time:",
-
"caled add \"Conference\" --date 2025-05-20 --time 09:00 \
-
--end-date 2025-05-22 --end-time 17:00" );
-
`I
-
( "Add an event with location and description:",
-
"caled add \"Lunch with Bob\" --date 2025-04-02 --time 12:30 \
-
--location \"Pasta Restaurant\" --description \"Discuss project \
-
plans\"" );
-
`I
-
( "Add an event to a specific calendar:",
-
"caled add \"Work Meeting\" --date 2025-04-03 --time 15:00 \
-
--calendar work" );
-
`S "RECURRENCE";
-
`P
-
"Recurrence rule in iCalendar RFC5545 format. The FREQ part is \
-
required.";
-
`I ("FREQ=<frequency>", "DAILY, WEEKLY, MONTHLY, or YEARLY (required)");
-
`I
-
( "COUNT=<number>",
-
"Limit to this many occurrences (optional, cannot be used with \
-
UNTIL)" );
-
`I
-
( "UNTIL=<date>",
-
"Repeat until this date (optional, cannot be used with COUNT)" );
-
`I
-
( "INTERVAL=<number>",
-
"Interval between occurrences, e.g., 2 for every other (optional)"
-
);
-
`I
-
( "BYDAY=<dayspec>",
-
"Specific days, e.g., MO,WE,FR or 1MO (first Monday) (optional)" );
-
`I
-
( "BYMONTHDAY=<daynum>",
-
"Day of month, e.g., 1,15 or -1 (last day) (optional)" );
-
`I
-
( "BYMONTH=<monthnum>",
-
"Month number, e.g., 1,6,12 for Jan,Jun,Dec (optional)" );
-
`P "Examples:";
-
`I ("FREQ=DAILY;COUNT=5", "Daily for 5 occurrences");
-
`I ("FREQ=WEEKLY;INTERVAL=2", "Every other week indefinitely");
-
`I ("FREQ=WEEKLY;BYDAY=MO,WE,FR", "Every Monday, Wednesday, Friday");
-
`I ("FREQ=MONTHLY;BYDAY=1MO", "First Monday of every month");
-
`I
-
( "FREQ=YEARLY;BYMONTH=1;BYMONTHDAY=1",
-
"Every January 1st (New Year's Day)" );
-
`I ("FREQ=MONTHLY;BYMONTHDAY=-1", "Last day of every month");
-
]
+
@ date_format_manpage_entries @ recurrence_format_manpage_entries
+
@ [ `S Manpage.s_see_also ]
+
in
+
let exit_info =
+
[ Cmd.Exit.info ~doc:"on success." 0; Cmd.Exit.info ~doc:"on error." 1 ]
in
-
let info = Cmd.info "add" ~doc ~man in
+
let info = Cmd.info "add" ~doc ~man ~exits:exit_info in
Cmd.v info term
+13 -7
bin/delete_cmd.ml
···
let run ~event_id ~fs calendar_dir =
let ( let* ) = Result.bind in
-
let filter = Query.with_id event_id in
-
let* results = Query.query_without_recurrence ~fs calendar_dir ~filter () in
+
let filter = Event.with_id event_id in
+
let* events = Calendar_dir.get_events ~fs calendar_dir in
+
let events = Event.query_without_recurrence events ~filter () in
let* event =
-
match results with
+
match events with
| [ event ] -> Ok event
| [] -> Error (`Msg ("No events found found for id " ^ event_id))
| _ -> Error (`Msg ("More than one found for id " ^ event_id))
in
-
let result = Calendar_dir.delete_event ~fs calendar_dir event in
+
let result = Calendar_dir.delete_event ~fs calendar_dir events event in
match result with
| Error (`Msg msg) -> Error (`Msg msg)
-
| Ok () ->
+
| Ok _ ->
Printf.printf "Event %s successfully deleted.\n" event_id;
Ok ()
···
Arg.(required & pos 0 (some string) None & info [] ~docv:"EVENT_ID" ~doc)
let cmd ~fs calendar_dir =
-
let run event_id =
+
let run event_id () =
match run ~event_id ~fs calendar_dir with
| Error (`Msg msg) ->
Printf.eprintf "Error: %s\n%!" msg;
···
`S Manpage.s_examples;
`P "Delete an event:";
`P " caled delete 12345678-1234-5678-1234-567812345678";
+
`S Manpage.s_options;
]
+
@ [ `S Manpage.s_see_also ]
in
-
let info = Cmd.info "delete" ~doc ~man in
+
let exit_info =
+
[ Cmd.Exit.info ~doc:"on success." 0; Cmd.Exit.info ~doc:"on error." 1 ]
+
in
+
let info = Cmd.info "delete" ~doc ~man ~exits:exit_info in
Cmd.v info term
+5 -2
bin/dune
···
ptime.clock.os
eio
eio_main
-
timere)
+
timere
+
sexplib
+
sexplib.unix)
(modules
main
query_args
···
show_cmd
add_cmd
delete_cmd
-
edit_cmd))
+
edit_cmd
+
server_cmd))
+44 -27
bin/edit_cmd.ml
···
let run ~event_id ~summary ~start_date ~start_time ~end_date ~end_time ~location
~description ~recur ?timezone ?end_timezone ~fs calendar_dir =
let ( let* ) = Result.bind in
-
let filter = Query.with_id event_id in
-
let* results = Query.query_without_recurrence ~fs calendar_dir ~filter () in
+
let filter = Event.with_id event_id in
+
let* events = Calendar_dir.get_events ~fs calendar_dir in
+
let results = Event.query_without_recurrence events ~filter () in
let* event =
match results with
| [ event ] -> Ok event
···
| _ -> Error (`Msg ("More than one found for id " ^ event_id))
in
let* start = parse_start ~start_date ~start_time ~timezone in
-
let* end_ = parse_end ~end_date ~end_time ~timezone ~end_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
| Some r ->
···
Ok (Some p)
| None -> Ok None
in
-
let modifed_event =
+
let* modifed_event =
Event.edit ?summary ?start ?end_ ?location ?description ?recurrence event
in
-
let* _ = Calendar_dir.edit_event ~fs calendar_dir modifed_event in
+
let* _ = Calendar_dir.edit_event ~fs calendar_dir events modifed_event in
Printf.printf "Event %s updated.\n" event_id;
Ok ()
···
let cmd ~fs calendar_dir =
let run event_id summary start_date start_time end_date end_time location
-
description recur timezone end_timezone =
+
description recur timezone end_timezone () =
match
run ~event_id ~summary ~start_date ~start_time ~end_date ~end_time
~location ~description ~recur ?timezone ?end_timezone ~fs calendar_dir
···
`P
"Specify the event ID as the first argument, and use options to change \
event details.";
+
`S Manpage.s_examples;
+
`I
+
( "Change the summary of an event:",
+
"caled edit 12345678-1234-5678-1234-567812345678 --summary \"New \
+
Title\"" );
+
`I
+
( "Change the date and time:",
+
"caled edit 12345678-1234-5678-1234-567812345678 --date 2025-05-01 \
+
--time 15:30" );
+
`I
+
( "Update the location:",
+
"caled edit 12345678-1234-5678-1234-567812345678 --location \
+
\"Conference Room B\"" );
+
`I
+
( "Change the description:",
+
"caled edit 12345678-1234-5678-1234-567812345678 --description \
+
\"Updated agenda for the meeting\"" );
`S Manpage.s_options;
]
-
@ date_format_manpage_entries
-
@ [
-
`S Manpage.s_examples;
-
`I
-
( "Change the summary of an event:",
-
"caled edit 12345678-1234-5678-1234-567812345678 --summary \"New \
-
Title\"" );
-
`I
-
( "Change the date and time:",
-
"caled edit 12345678-1234-5678-1234-567812345678 --date 2025-05-01 \
-
--time 15:30" );
-
`I
-
( "Update the location:",
-
"caled edit 12345678-1234-5678-1234-567812345678 --location \
-
\"Conference Room B\"" );
-
`I
-
( "Change the description:",
-
"caled edit 12345678-1234-5678-1234-567812345678 --description \
-
\"Updated agenda for the meeting\"" );
-
]
+
@ date_format_manpage_entries @ recurrence_format_manpage_entries
+
@ [ `S Manpage.s_see_also ]
+
in
+
let exit_info =
+
[ Cmd.Exit.info ~doc:"on success." 0; Cmd.Exit.info ~doc:"on error." 1 ]
in
-
let info = Cmd.info "edit" ~doc ~man in
+
let info = Cmd.info "edit" ~doc ~man ~exits:exit_info in
Cmd.v info term
+125 -38
bin/event_args.ml
···
open Cmdliner
open Caledonia_lib
-
let collection_arg =
+
let calendar_name_arg =
let doc = "Calendar to add the event to" in
Arg.(
required
···
Arg.(value & opt (some string) None & info [ "time"; "t" ] ~docv:"TIME" ~doc)
let end_date_arg =
-
let doc = "Event end date (YYYY-MM-DD)" in
+
let doc = "Event end date (YYYY-MM-DD). Defaults to DATE." in
Arg.(
value
& opt (some string) None
···
let timezone_arg =
let doc =
"Timezone to add events to (e.g., 'America/New_York', 'UTC', \
-
'Europe/London'). If not specified, will use the local timezone."
+
'Europe/London'). If not specified, will use the local timezone. For a \
+
floating time (always at whatever the sytem time is), use 'FLOATING'."
in
Arg.(
value
···
let date_format_manpage_entries =
[
`S "DATE FORMATS";
-
`P "Relative date formats for --date / -d and --end-date / -e:";
+
`P
+
"The following are the possible date formats for the --date and \
+
--end-date command line parameters. Note the value is dependent on \
+
--date / --end-date, so --date 2025-03 --end-date 2025-03 will span the \
+
month of March.";
+
`I ("YYYY-MM-DD", "Specific date (e.g., 2025-3-27, zero-padding optional)");
+
`I ("YYYY-MM", "Start/end of specific month (e.g., 2025-3 for March 2025)");
+
`I ("YYYY", "Start/end of specific year (e.g., 2025)");
`I ("today", "Current day");
`I ("tomorrow", "Next day");
`I ("yesterday", "Previous day");
-
`I ("this-week", "Start of current week");
-
`I ("next-week", "Start of next week");
-
`I ("this-month", "Start of current month");
-
`I ("next-month", "Start of next month");
+
`I ("this-week", "Start/end of current week");
+
`I ("next-week", "Start/end of next week");
+
`I ("this-month", "Start/end of current month");
+
`I ("next-month", "Start/end of next month");
`I ("+Nd", "N days from today (e.g., +7d for a week from today)");
`I ("-Nd", "N days before today (e.g., -7d for a week ago)");
`I ("+Nw", "N weeks from today (e.g., +4w for 4 weeks from today)");
···
let parse_start ~start_date ~start_time ~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
match start_date with
| None ->
let* _ =
match start_time with
| None -> Ok ()
| Some _ ->
-
Error (`Msg "Can't specify an start time without an end date")
+
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 (`Date date))
+
Ok (Some (Icalendar.Params.singleton Valuetype `Date, `Date date))
| Some start_time -> (
match timezone with
| None ->
+
let* tzid =
+
match Timedesc.Time_zone.local () with
+
| Some tz -> Ok (Timedesc.Time_zone.name tz)
+
| None -> Error (`Msg "Failed to get system timezone")
+
in
let* datetime =
Date.parse_date_time ~tz:Timedesc.Time_zone.utc ~date:start_date
~time:start_time `From
in
-
Ok (Some (`Datetime (`Local datetime)))
+
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 (`Datetime (`Utc datetime)))
+
Ok (Some (Icalendar.Params.empty, `Datetime (`Utc datetime)))
| Some tzid ->
-
let* tz =
-
match Timedesc.Time_zone.make tzid with
-
| Some tz_obj -> Ok tz_obj
-
| None -> Error (`Msg ("Invalid timezone: " ^ tzid))
-
in
let* datetime =
-
Date.parse_date_time ~tz ~date:start_date ~time:start_time `From
+
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)))))))
+
Ok
+
(Some
+
( Icalendar.Params.empty,
+
`Datetime (`With_tzid (datetime, (false, tzid))) ))))
-
let parse_end ~end_date ~end_time ~timezone ~end_timezone =
+
let parse_end ~end_date ~end_time ~end_timezone =
let ( let* ) = Result.bind in
+
let* _ =
+
match end_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
match end_date with
| None ->
let* _ =
···
let* _ =
match end_timezone with
| None -> Ok ()
-
| Some _ -> Error (`Msg "Can't specify a timezone without a end time")
+
| 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 (timezone, end_timezone) with
-
| None, None -> Ok ()
-
| Some _, None ->
-
Error (`Msg "Can't specify a timezone without a end time")
-
| _ ->
+
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.empty, `Date date)))
+
Ok
+
(Some
+
(`Dtend (Icalendar.Params.singleton Valuetype `Date, `Date date)))
| Some end_time -> (
-
match (timezone, end_timezone) with
-
| None, None ->
+
match end_timezone with
+
| None ->
+
let* tzid =
+
match Timedesc.Time_zone.local () with
+
| Some tz -> Ok (Timedesc.Time_zone.name tz)
+
| None -> Error (`Msg "Failed to get system timezone")
+
in
+
let* datetime =
+
Date.parse_date_time ~tz:Timedesc.Time_zone.utc ~date:end_date
+
~time:end_time `From
+
in
+
Ok
+
(Some
+
(`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" | Some "UTC", None ->
+
| 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 | Some tzid, _ ->
-
let* tz =
-
match Timedesc.Time_zone.make tzid with
-
| Some tz_obj -> Ok tz_obj
-
| None -> Error (`Msg ("Invalid timezone: " ^ tzid))
-
in
+
| Some tzid ->
let* datetime =
-
Date.parse_date_time ~tz ~date:end_date ~time:end_time `From
+
Date.parse_date_time ~tz:Timedesc.Time_zone.utc ~date:end_date
+
~time:end_time `From
in
-
Ok
(Some
(`Dtend
···
let recurrence = (f, limit, !interval, !by_parts) in
Ok recurrence
| None -> Error (`Msg "FREQ is required in recurrence rule")
+
+
let recurrence_format_manpage_entries =
+
[
+
`S "RECURRENCE";
+
`P "Recurrence rule in iCalendar RFC5545 format. The FREQ part is required.";
+
`I ("FREQ=<frequency>", "DAILY, WEEKLY, MONTHLY, or YEARLY (required)");
+
`I
+
( "COUNT=<number>",
+
"Limit to this many occurrences (optional, cannot be used with UNTIL)"
+
);
+
`I
+
( "UNTIL=<date>",
+
"Repeat until this date (optional, cannot be used with COUNT)" );
+
`I
+
( "INTERVAL=<number>",
+
"Interval between occurrences, e.g., 2 for every other (optional)" );
+
`I
+
( "BYDAY=<dayspec>",
+
"Specific days, e.g., MO,WE,FR or 1MO (first Monday) (optional)" );
+
`I
+
( "BYMONTHDAY=<daynum>",
+
"Day of month, e.g., 1,15 or -1 (last day) (optional)" );
+
`I
+
( "BYMONTH=<monthnum>",
+
"Month number, e.g., 1,6,12 for Jan,Jun,Dec (optional)" );
+
`P "Examples:";
+
`I ("FREQ=DAILY;COUNT=5", "Daily for 5 occurrences");
+
`I ("FREQ=WEEKLY;INTERVAL=2", "Every other week indefinitely");
+
`I ("FREQ=WEEKLY;BYDAY=MO,WE,FR", "Every Monday, Wednesday, Friday");
+
`I ("FREQ=MONTHLY;BYDAY=1MO", "First Monday of every month");
+
`I
+
( "FREQ=YEARLY;BYMONTH=1;BYMONTHDAY=1",
+
"Every January 1st (New Year's Day)" );
+
`I ("FREQ=MONTHLY;BYMONTHDAY=-1", "Last day of every month");
+
]
+39 -43
bin/list_cmd.ml
···
open Caledonia_lib
open Query_args
-
let run ?from_str ?to_str ?calendar ?count ~format ~today ~tomorrow ~week ~month
-
?timezone ~sort ~fs calendar_dir =
+
let run ?from_str ?to_str ~calendar:calendars ?count ~format ~today ~tomorrow
+
~week ~month ?timezone ~sort ~fs calendar_dir =
let ( let* ) = Result.bind in
let tz = Query_args.parse_timezone ~timezone in
let* from, to_ =
···
match to_str with
| None -> Ok None
| Some s ->
-
let* d = Date.parse_date ~tz s `From in
+
let* d = Date.parse_date ~tz s `To in
Ok (Some d)
in
match (from, to_) with
···
| Some f, None ->
let one_month_later = Date.add_months f 1 in
Ok (Some f, one_month_later)
-
| None, Some t ->
-
let today_date = !Date.get_today ~tz () in
-
Ok (Some today_date, Date.to_end_of_day t)
+
| None, Some t -> Ok (None, Date.to_end_of_day t)
| None, None ->
let today_date = !Date.get_today ~tz () in
let one_month_later = Date.add_months today_date 1 in
Ok (Some today_date, one_month_later))
in
let filter =
-
match calendar with
-
| Some collection_id ->
-
Some (Query.in_collections [ Collection.Col collection_id ])
-
| None -> None
+
match calendars with
+
| [] -> None
+
| calendar -> Some (Event.in_calendars calendar)
in
let comparator = Query_args.create_event_comparator sort in
-
let* results =
-
Query.query ~fs calendar_dir ?filter ~from ~to_ ~comparator ?limit:count ()
+
let* events = Calendar_dir.get_events ~fs calendar_dir in
+
let events =
+
Event.query events ?filter ~from ~to_ ~comparator ?limit:count ()
in
-
if results = [] then print_endline "No events found."
-
else print_endline (Event.format_events ~format ~tz results);
+
if events = [] then print_endline "No events found."
+
else print_endline (Event.format_events ~format ~tz events);
Ok ()
let cmd ~fs calendar_dir =
-
let run from_str to_str calendar count format today tomorrow week month
-
timezone sort =
+
let run from_str to_str calendars count format today tomorrow week month
+
timezone sort () =
match
-
run ?from_str ?to_str ?calendar ?count ~format ~today ~tomorrow ~week
-
~month ?timezone ~sort ~fs calendar_dir
+
run ?from_str ?to_str ~calendar:calendars ?count ~format ~today ~tomorrow
+
~week ~month ?timezone ~sort ~fs calendar_dir
with
| Error (`Msg msg) ->
Printf.eprintf "Error: %s\n%!" msg;
···
let man =
[
`S Manpage.s_description;
-
`P "List calendar events within a specified date range.";
-
`P "By default, events from today to one month from today are shown.";
-
`P "You can use date flags to show events for a specific time period.";
-
`P "You can also filter events by calendar using the --calendar flag.";
-
`P "Use the --sort option to control the sorting of results.";
+
`P
+
"List calendar events within a specified date range. By default, \
+
events from today to one month from today are shown. You can use date \
+
flags to show events for a specific time period, and filter events \
+
with the --sort option.";
+
`S Manpage.s_examples;
+
`I ("List all events for today:", "caled list --today");
+
`I ("List all events for tomorrow:", "caled list --tomorrow");
+
`I ("List all events for the current week:", "caled list --week");
+
`I ("List all events for the current month:", "caled list --month");
+
`I
+
( "List events within a specific date range:",
+
"caled list --from 2025-03-27 --to 2025-04-01" );
+
`I ("List events from a specific calendar:", "caled list --calendar work");
+
`I ("List events in JSON format:", "caled list --format json");
+
`I ("Limit the number of events shown:", "caled list --count 5");
+
`I
+
( "Sort by multiple fields (start time and summary):",
+
"caled list --sort start --sort summary" );
+
`I
+
( "Sort by calendar name in descending order:",
+
"caled list --sort calendar:desc" );
`S Manpage.s_options;
]
@ date_format_manpage_entries
-
@ [
-
`S Manpage.s_examples;
-
`I ("List all events for today:", "caled list --today");
-
`I ("List all events for tomorrow:", "caled list --tomorrow");
-
`I ("List all events for the current week:", "caled list --week");
-
`I ("List all events for the current month:", "caled list --month");
-
`I
-
( "List events within a specific date range:",
-
"caled list --from 2025-03-27 --to 2025-04-01" );
-
`I
-
("List events from a specific calendar:", "caled list --calendar work");
-
`I ("List events in JSON format:", "caled list --format json");
-
`I ("Limit the number of events shown:", "caled list --count 5");
-
`I
-
( "Sort by multiple fields (start time and summary):",
-
"caled list --sort start --sort summary" );
-
`I
-
( "Sort by calendar name in descending order:",
-
"caled list --sort calendar:desc" );
-
]
+
@ [ `S Manpage.s_see_also ]
in
let exit_info =
[ Cmd.Exit.info ~doc:"on success." 0; Cmd.Exit.info ~doc:"on error." 1 ]
+4 -3
bin/main.ml
···
-
(* Main entry point for the calendar CLI *)
-
open Cmdliner
let list_cmd = List_cmd.cmd
···
let add_cmd = Add_cmd.cmd
let delete_cmd = Delete_cmd.cmd
let edit_cmd = Edit_cmd.cmd
+
let server_cmd = Server_cmd.cmd
let doc = "Command-line calendar tool for managing local .ics files"
let version = "%%VERSION%%"
···
add_cmd ~fs calendar_dir;
edit_cmd ~fs calendar_dir;
delete_cmd ~fs calendar_dir;
+
server_cmd ~stdin:(Eio.Stdenv.stdin env)
+
~stdout:(Eio.Stdenv.stdout env) ~fs calendar_dir;
])
with
-
| Ok (`Ok n) -> n
+
| Ok (`Ok f) -> f ()
| Ok _ -> 0
| Error _ -> 1)
+35 -35
bin/query_args.ml
···
let from_arg =
let doc =
-
"Start date in YYYY-MM-DD format or a relative expression (today, \
-
tomorrow, this-week, next-week, this-month, next-month, +Nd, -Nd, +Nw, \
-
+Nm)"
+
"Start date in YYYY-MM-DD format, partial date format (YYYY-MM or YYYY), \
+
or a relative expression (today, tomorrow, this-week, next-week, \
+
this-month, next-month, +Nd, -Nd, +Nw, +Nm). See DATE FORMATS for more."
in
let i = Arg.info [ "from"; "f" ] ~docv:"DATE" ~doc in
Arg.(value @@ opt (some string) None i)
let to_arg =
let doc =
-
"End date in YYYY-MM-DD format or a relative expression (today, tomorrow, \
-
this-week, next-week, this-month, next-month, +Nd, -Nd, +Nw, +Nm)"
+
"End date in YYYY-MM-DD format, partial date format (YYYY-MM or YYYY), or \
+
a relative expression (today, tomorrow, this-week, next-week, this-month, \
+
next-month, +Nd, -Nd, +Nw, +Nm). See DATE FORMATS for more."
in
let i = Arg.info [ "to"; "t" ] ~docv:"DATE" ~doc in
Arg.(value @@ opt (some string) None i)
let calendar_arg =
-
let doc = "Calendar to filter by" in
+
let doc = "Filter by calendar" in
Arg.(
-
value
-
& opt (some string) None
-
& info [ "calendar"; "c" ] ~docv:"CALENDAR" ~doc)
+
value & opt_all string [] & info [ "calendar"; "c" ] ~docv:"CALENDAR" ~doc)
let format_enum =
[
···
]
let format_arg =
-
let doc = "Output format (text, entries, json, csv, ics, sexp)" in
+
let doc =
+
"Output format (text, entries, json, csv, ics, sexp). Note that dates are \
+
localised to the TIMEZONE option but the timezone they're defined in is \
+
displayed."
+
in
Arg.(
value
& opt (enum format_enum) `Text
···
| `End -> Event.by_end
| `Summary -> Event.by_summary
| `Location -> Event.by_location
-
| `Calendar -> Event.by_collection
+
| `Calendar -> Event.by_calendar_name
in
if spec.descending then Event.descending comp else comp
| specs ->
···
| `End -> Event.by_end
| `Summary -> Event.by_summary
| `Location -> Event.by_location
-
| `Calendar -> Event.by_collection
+
| `Calendar -> Event.by_calendar_name
in
let comp = if spec.descending then Event.descending comp else comp in
Event.chain comp acc)
···
| `End -> Event.by_end
| `Summary -> Event.by_summary
| `Location -> Event.by_location
-
| `Calendar -> Event.by_collection
+
| `Calendar -> Event.by_calendar_name
in
if spec.descending then Event.descending comp else comp)
+
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 date_format_manpage_entries =
[
`S "DATE FORMATS";
-
`P "Date format flags:";
-
`I ("--today, -d", "Show events for today only");
-
`I ("--tomorrow", "Show events for tomorrow only");
-
`I ("--week, -w", "Show events for the current week");
-
`I ("--month, -m", "Show events for the current month");
-
`I
-
( "--timezone, -z",
-
"Timezone to use for date calculations (e.g., 'America/New_York', \
-
'UTC')" );
-
`P "Relative date formats for --from and --to:";
+
`P
+
"The following are the possible date formats for the --from and --to \
+
command line parameters. Note the value is dependent on --from / --to, \
+
so --from 2025 --to 2025 will include all the events in the year 2025";
+
`I ("YYYY-MM-DD", "Specific date (e.g., 2025-3-27, zero-padding optional)");
+
`I ("YYYY-MM", "Start/end of specific month (e.g., 2025-3 for March 2025)");
+
`I ("YYYY", "Start/end of specific year (e.g., 2025)");
`I ("today", "Current day");
`I ("tomorrow", "Next day");
`I ("yesterday", "Previous day");
-
`I ("this-week", "Start of current week");
-
`I ("next-week", "Start of next week");
-
`I ("this-month", "Start of current month");
-
`I ("next-month", "Start of next month");
+
`I ("this-week", "Start/end of current week");
+
`I ("next-week", "Start/end of next week");
+
`I ("this-month", "Start/end of current month");
+
`I ("next-month", "Start/end of next month");
`I ("+Nd", "N days from today (e.g., +7d for a week from today)");
`I ("-Nd", "N days before today (e.g., -7d for a week ago)");
`I ("+Nw", "N weeks from today (e.g., +4w for 4 weeks from today)");
`I ("+Nm", "N months from today (e.g., +2m for 2 months from today)");
]
-
-
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 ()
+69 -81
bin/search_cmd.ml
···
open Caledonia_lib
open Query_args
-
let run ?from_str ?to_str ?calendar ?count ?query_text ~summary ~description
+
let run ?from_str ?to_str ~calendar ?count ?query_text ~summary ~description
~location ~id ~format ~today ~tomorrow ~week ~month ~recurring
~non_recurring ?timezone ~sort ~fs calendar_dir =
let ( let* ) = Result.bind in
···
match to_str with
| None -> Ok None
| Some s ->
-
let* d = Date.parse_date s `From in
+
let* d = Date.parse_date s `To in
Ok (Some d)
in
let max_date = Date.add_years (!Date.get_today ()) 75 in
···
| None, None -> Ok (None, Date.to_end_of_day max_date))
in
(match calendar with
-
| Some collection_id ->
-
filters :=
-
Query.in_collections [ Collection.Col collection_id ] :: !filters
-
| None -> ());
+
| [] -> ()
+
| calendars -> filters := Event.in_calendars calendars :: !filters);
(match query_text with
| Some text ->
-
if summary then filters := Query.summary_contains text :: !filters;
-
if description then filters := Query.description_contains text :: !filters;
-
if location then filters := Query.location_contains text :: !filters;
+
if summary then filters := Event.summary_contains text :: !filters;
+
if description then filters := Event.description_contains text :: !filters;
+
if location then filters := Event.location_contains text :: !filters;
if not (summary || description || location) then
filters :=
-
Query.or_filter
+
Event.or_filter
[
-
Query.summary_contains text;
-
Query.description_contains text;
-
Query.location_contains text;
+
Event.summary_contains text;
+
Event.description_contains text;
+
Event.location_contains text;
]
:: !filters
| None -> ());
-
if recurring then filters := Query.recurring_only () :: !filters;
-
if non_recurring then filters := Query.non_recurring_only () :: !filters;
+
if recurring then filters := Event.recurring_only () :: !filters;
+
if non_recurring then filters := Event.non_recurring_only () :: !filters;
(match id with
-
| Some id -> filters := Query.with_id id :: !filters
+
| Some id -> filters := Event.with_id id :: !filters
| None -> ());
-
let filter = Query.and_filter !filters in
+
let filter = Event.and_filter !filters in
let comparator = Query_args.create_event_comparator sort in
-
let* results =
-
Query.query ~fs calendar_dir ~filter ~from ~to_ ~comparator ?limit:count ()
+
let* events = Calendar_dir.get_events ~fs calendar_dir in
+
let events =
+
Event.query events ~filter ~from ~to_ ~comparator ?limit:count ()
in
-
if results = [] then print_endline "No events found."
-
else print_endline (Event.format_events ~tz ~format results);
+
if events = [] then print_endline "No events found."
+
else print_endline (Event.format_events ~tz ~format events);
Ok ()
let query_text_arg =
-
let doc = "Text to search for in events (summary, description, location)" in
+
let doc =
+
"Text to search for in the fields summary, description, and/or location."
+
in
Arg.(value & pos 0 (some string) None & info [] ~docv:"TEXT" ~doc)
let summary_arg =
···
Arg.(value & opt (some string) None & info [ "id"; "i" ] ~docv:"ID" ~doc)
let cmd ~fs calendar_dir =
-
let run query_text from_str to_str calendar count format summary description
+
let run query_text from_str to_str calendars count format summary description
location id today tomorrow week month recurring non_recurring timezone
-
sort =
+
sort () =
match
-
run ?from_str ?to_str ?calendar ?count ?query_text ~summary ~description
-
~location ~id ~format ~today ~tomorrow ~week ~month ~recurring
-
~non_recurring ?timezone ~sort ~fs calendar_dir
+
run ?from_str ?to_str ~calendar:calendars ?count ?query_text ~summary
+
~description ~location ~id ~format ~today ~tomorrow ~week ~month
+
~recurring ~non_recurring ?timezone ~sort ~fs calendar_dir
with
| Error (`Msg msg) ->
Printf.eprintf "Error: %s\n%!" msg;
···
`S Manpage.s_description;
`P
"Search calendar events for text in summary, description, or location \
-
fields.";
-
`P
-
"By default, the search looks across all text fields in all events \
-
regardless of date.";
-
`P
-
"You can narrow the search to a specific date range with date flags or \
-
--from and --to.";
-
`P
-
"You can specify specific fields to search in using the --summary, \
-
--description, or --location flags.";
-
`P
-
"You can limit results to only recurring or non-recurring events using \
-
the --recurring or --non-recurring flags.";
-
`P "Use the --sort option to control the sorting of results.";
-
`P
-
"The search text is optional if you're using other filters. For \
-
example, you can find all recurring events without specifying any \
-
search text.";
+
fields. By default, the search looks across all text fields in all \
+
events regardless of date. You can specify specific fields to search \
+
in using the --summary, --description, or --location flags. You can \
+
use date flags to show events for a specific time period, and filter \
+
events with the --sort option.\n\
+
\ The search text is optional, so you search events according \
+
to other query criteria.";
+
`S Manpage.s_examples;
+
`I ("Search for 'meeting' in all events:", "caled search meeting");
+
`I
+
( "Search for 'interview' in event summaries only:",
+
"caled search --summary interview" );
+
`I
+
( "Search for 'conference' in a specific calendar:",
+
"caled search --calendar work conference" );
+
`I
+
( "Search for 'workshop' in event descriptions for today only:",
+
"caled search --description --today workshop" );
+
`I
+
( "Search for 'project' in events this month:",
+
"caled search --month project" );
+
`I
+
( "Search for 'workshop' in event descriptions within a date range:",
+
"caled search --description --from 2025-03-27 --to 2025-04-01 \
+
workshop" );
+
`I
+
("Search for recurring events only:", "caled search --recurring meeting");
+
`I
+
( "Search for non-recurring events only:",
+
"caled search --non-recurring appointment" );
+
`I ("Find all recurring events:", "caled search --recurring");
+
`I
+
( "Find all events in a specific calendar:",
+
"caled search --calendar work" );
+
`I
+
( "Sort results by location and then summary:",
+
"caled search --sort location --sort summary" );
+
`I
+
( "Sort results by end time in descending order:",
+
"caled search --sort end:desc" );
`S Manpage.s_options;
]
@ date_format_manpage_entries
-
@ [
-
`S Manpage.s_examples;
-
`I ("Search for 'meeting' in all events:", "caled search meeting");
-
`I
-
( "Search for 'interview' in event summaries only:",
-
"caled search --summary interview" );
-
`I
-
( "Search for 'conference' in a specific calendar:",
-
"caled search --calendar work conference" );
-
`I
-
( "Search for 'workshop' in event descriptions for today only:",
-
"caled search --description --today workshop" );
-
`I
-
( "Search for 'project' in events this month:",
-
"caled search --month project" );
-
`I
-
( "Search for 'workshop' in event descriptions within a date range:",
-
"caled search --description --from 2025-03-27 --to 2025-04-01 \
-
workshop" );
-
`I
-
( "Search for recurring events only:",
-
"caled search --recurring meeting" );
-
`I
-
( "Search for non-recurring events only:",
-
"caled search --non-recurring appointment" );
-
`I ("Find all recurring events:", "caled search --recurring");
-
`I
-
( "Find all events in a specific calendar:",
-
"caled search --calendar work" );
-
`I
-
( "Sort results by location and then summary:",
-
"caled search --sort location --sort summary" );
-
`I
-
( "Sort results by end time in descending order:",
-
"caled search --sort end:desc" );
-
]
+
@ [ `S Manpage.s_see_also ]
in
let exit_info =
[ Cmd.Exit.info ~doc:"on success." 0; Cmd.Exit.info ~doc:"on error." 1 ]
+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
+10 -8
bin/show_cmd.ml
···
let run ~event_id ~format ~fs calendar_dir =
let ( let* ) = Result.bind in
-
let filter = Query.with_id event_id in
-
let* results = Query.query_without_recurrence ~fs calendar_dir ~filter () in
+
let filter = Event.with_id event_id in
+
let* events = Calendar_dir.get_events ~fs calendar_dir in
+
let results = Event.query_without_recurrence events ~filter () in
if results = [] then print_endline "No events found."
else print_endline (Event.format_events ~format results);
Ok ()
···
& info [ "format"; "o" ] ~docv:"FORMAT" ~doc)
let cmd ~fs calendar_dir =
-
let run event_id format =
+
let run event_id format () =
match run ~event_id ~format ~fs calendar_dir with
| Error (`Msg msg) ->
Printf.eprintf "Error: %s\n%!" msg;
···
[
`S Manpage.s_description;
`P "Show detailed information about a specific event by its ID.";
-
`P
-
"You can find event IDs by using the `list` or `search` commands with \
-
the `id` output using `-o id`.";
-
`S Manpage.s_options;
+
`P "You can find event IDs by using the `list` or `search` commands.";
`S Manpage.s_examples;
`P "Show event details:";
`P " caled show 12345678-1234-5678-1234-567812345678";
`P "Show event details in JSON format:";
`P " caled show 12345678-1234-5678-1234-567812345678 --format json";
+
`S Manpage.s_options;
]
in
-
let info = Cmd.info "show" ~doc ~man in
+
let exit_info =
+
[ Cmd.Exit.info ~doc:"on success." 0; Cmd.Exit.info ~doc:"on error." 1 ]
+
in
+
let info = Cmd.info "show" ~doc ~man ~exits:exit_info in
Cmd.v info term
+2 -1
caledonia.opam
···
opam-version: "2.0"
-
version: "0.3.0"
+
version: "0.4.0"
maintainer: "Ryan Gibb <ryan@freumh.org"
authors: ["Ryan Gibb <ryan@freumh.org"]
homepage: "https://ryan.freumh.org/caledonia.html"
···
"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)
+
(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;
+
};
+
});
+
}
+95 -124
lib/calendar_dir.ml
···
open Icalendar
-
module CollectionMap = Map.Make (struct
-
type t = Collection.t
-
-
let compare (Collection.Col a) (Collection.Col b) = String.compare a b
-
end)
-
-
type t = { path : string; mutable collections : Event.t list CollectionMap.t }
+
type t = string
-
let get_collection_path ~fs calendar_dir (Collection.Col collection_name) =
-
Eio.Path.(fs / calendar_dir.path / collection_name)
+
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; collections = CollectionMap.empty }
+
| Ok () -> Ok path
| Error e -> Error e
-
let list_collections ~fs calendar_dir =
+
let list_calendar_names ~fs calendar_dir =
try
-
let dir = Eio.Path.(fs / calendar_dir.path) in
-
let collections =
+
let dir = Eio.Path.(fs / calendar_dir) in
+
let calendar_names =
Eio.Path.read_dir dir
|> List.filter_map (fun file ->
-
if Eio.Path.is_directory Eio.Path.(dir / file) then
-
Some (Collection.Col file)
+
if
+
String.length file > 0
+
&& file.[0] != '.'
+
&& Eio.Path.is_directory Eio.Path.(dir / file)
+
then Some file
else None)
-
|> List.sort (fun (Collection.Col a) (Collection.Col b) ->
-
String.compare a b)
+
|> List.sort (fun a b -> String.compare a b)
in
-
Ok collections
+
Ok calendar_names
with Eio.Exn.Io _ as exn ->
Error
(`Msg
-
(Fmt.str "Failed to list calendar directory %s: %a" calendar_dir.path
+
(Fmt.str "Failed to list calendar directory %s: %a" calendar_dir
Eio.Exn.pp exn))
-
let load_events collection collection_path file_name =
-
let file = Eio.Path.(collection_path / file_name) in
-
let _, file_path = file in
-
match Filename.check_suffix file_name ".ics" with
-
| false -> []
-
| true -> (
-
try
-
let content = Eio.Path.load file in
-
match parse content with
-
| Ok calendar -> Event.events_of_icalendar ~file collection calendar
-
| Error err ->
-
Printf.eprintf "Failed to parse %s: %s\n%!" file_path err;
-
[]
-
with Eio.Exn.Io _ as exn ->
-
Fmt.epr "Failed to read file %s: %a\n%!" file_path Eio.Exn.pp exn;
-
[])
+
let rec load_events_recursive calendar_name dir_path =
+
try
+
Eio.Path.read_dir dir_path
+
|> List.fold_left
+
(fun acc name ->
+
let path = Eio.Path.(dir_path / name) in
+
if Eio.Path.is_directory path then
+
acc @ load_events_recursive calendar_name path
+
else if Filename.check_suffix name ".ics" then (
+
try
+
let content = Eio.Path.load path in
+
match parse content with
+
| Ok calendar ->
+
acc
+
@ Event.events_of_icalendar ~file:path calendar_name calendar
+
| Error err ->
+
Printf.eprintf "Failed to parse %s: %s\n%!" (snd path) err;
+
acc
+
with Eio.Exn.Io _ as exn ->
+
Fmt.epr "Failed to read file %s: %a\n%!" (snd path) Eio.Exn.pp
+
exn;
+
acc)
+
else acc)
+
[]
+
with Eio.Exn.Io _ as exn ->
+
Fmt.epr "Failed to read directory %s: %a\n%!" (snd dir_path) Eio.Exn.pp exn;
+
[]
-
let get_collection ~fs calendar_dir collection =
-
match CollectionMap.find_opt collection calendar_dir.collections with
-
| Some events -> Ok events
-
| None -> (
-
let collection_path = get_collection_path ~fs calendar_dir collection in
-
if not (Eio.Path.is_directory collection_path) then Error `Not_found
-
else
-
try
-
let files = Eio.Path.read_dir collection_path in
-
let events =
-
List.flatten
-
@@ List.map (load_events collection collection_path) files
-
in
-
calendar_dir.collections <-
-
CollectionMap.add collection events calendar_dir.collections;
-
Ok events
-
with e ->
-
Error
-
(`Msg
-
(Printf.sprintf "Exception processing directory %s: %s"
-
(snd collection_path) (Printexc.to_string e))))
+
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
let get_events ~fs calendar_dir =
-
match list_collections ~fs calendar_dir with
+
match list_calendar_names ~fs calendar_dir with
| Error e -> Error e
| Ok ids -> (
try
let rec process_ids acc = function
| [] -> Ok (List.rev acc)
| id :: rest -> (
-
match get_collection ~fs calendar_dir id with
+
match get_calendar_events ~fs calendar_dir id with
| Ok cal -> process_ids (cal :: acc) rest
| Error `Not_found -> process_ids acc rest
| Error (`Msg e) -> Error (`Msg e))
in
-
let* collections = process_ids [] ids in
-
Ok (List.flatten collections)
+
let* calendar_names = process_ids [] ids in
+
Ok (List.flatten calendar_names)
with exn ->
Error
(`Msg
-
(Printf.sprintf "Error getting collections: %s"
+
(Printf.sprintf "Error getting calendar_names: %s"
(Printexc.to_string exn))))
-
let add_event ~fs calendar_dir event =
-
let collection = Event.get_collection event in
+
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 collection_path = get_collection_path ~fs calendar_dir collection in
-
let* () = ensure_dir collection_path in
+
let calendar_name_path = get_calendar_path ~fs calendar_dir calendar_name in
+
let* () = ensure_dir calendar_name_path in
let calendar = Event.to_ical_calendar event in
let content = Icalendar.to_ics ~cr:true calendar in
-
let* _ =
try
Eio.Path.save ~create:(`Or_truncate 0o644) file content;
-
Ok ()
+
Ok (event :: events)
with Eio.Exn.Io _ as exn ->
Error
(`Msg
(Fmt.str "Failed to write file %s: %a\n%!" (snd file) Eio.Exn.pp exn))
-
in
-
calendar_dir.collections <-
-
CollectionMap.add collection
-
(event
-
::
-
(match CollectionMap.find_opt collection calendar_dir.collections with
-
| Some lst -> lst
-
| None -> []))
-
calendar_dir.collections;
-
Ok ()
-
let edit_event ~fs calendar_dir event =
-
let collection = Event.get_collection event in
+
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 collection_path = get_collection_path ~fs calendar_dir collection in
-
let* () = ensure_dir collection_path in
+
let calendar_name_path = get_calendar_path ~fs calendar_dir calendar_name in
+
let* () = ensure_dir calendar_name_path in
let ical_event = Event.to_ical_event event in
let file = Event.get_file event in
let existing_props, existing_components = Event.to_ical_calendar event 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.collections <-
-
CollectionMap.add collection
-
(event
-
::
-
(match CollectionMap.find_opt collection calendar_dir.collections with
-
(* filter old version *)
-
| Some lst -> List.filter (fun e -> Event.get_id e = event_id) lst
-
| None -> []))
-
calendar_dir.collections;
-
Ok ()
+
try
+
Eio.Path.save ~create:(`Or_truncate 0o644) file content;
+
(* Filter out the old event and add the updated one *)
+
let filtered_events = List.filter (fun e -> Event.get_id e <> event_id) events in
+
Ok (event :: filtered_events)
+
with Eio.Exn.Io _ as exn ->
+
Error
+
(`Msg
+
(Fmt.str "Failed to write file %s: %a\n%!" (snd file) Eio.Exn.pp exn))
-
let delete_event ~fs calendar_dir event =
-
let collection = Event.get_collection event in
+
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 collection_path = get_collection_path ~fs calendar_dir collection in
-
let* () = ensure_dir collection_path in
+
let calendar_name_path = get_calendar_path ~fs calendar_dir calendar_name in
+
let* () = ensure_dir calendar_name_path in
let file = Event.get_file event in
let existing_props, existing_components = Event.to_ical_calendar event in
let other_events = ref false 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.collections <-
-
CollectionMap.add collection
-
(match CollectionMap.find_opt collection calendar_dir.collections with
-
(* filter old version *)
-
| Some lst -> List.filter (fun e -> Event.get_id e = event_id) lst
-
| None -> [])
-
calendar_dir.collections;
-
Ok ()
+
try
+
(match !other_events with
+
| true -> Eio.Path.save ~create:(`Or_truncate 0o644) file content
+
| false -> Eio.Path.unlink file);
+
(* Filter out the deleted event from the events list *)
+
let filtered_events = List.filter (fun e -> Event.get_id e <> event_id) events in
+
Ok filtered_events
+
with Eio.Exn.Io _ as exn ->
+
Error
+
(`Msg
+
(Fmt.str "Failed to write file %s: %a\n%!" (snd file) Eio.Exn.pp exn))
-
let get_path t = t.path
+
let get_path t = t
+23 -17
lib/calendar_dir.mli
···
-
(** Functions for managing calendar directories with Collection.ts of .ics files
-
*)
+
(** Functions for managing calendar directories with strings of .ics files *)
type t
-
(** A directory of Collection.ts, where each collection is a subdirectory
+
(** A directory of strings, where each calendar_name is a subdirectory
containing .ics files *)
val create :
···
calendar_dir if successful, or Error with a message if the directory cannot
be created or accessed. *)
-
val list_collections :
-
fs:Eio.Fs.dir_ty Eio.Path.t ->
-
t ->
-
(Collection.t list, [> `Msg of string ]) result
-
(** List available Collection.ts in the calendar_dir. Returns Ok with the list
-
of Collection.t names if successful, or Error with a message if the
-
directory cannot be read. *)
+
val list_calendar_names :
+
fs:Eio.Fs.dir_ty Eio.Path.t -> t -> (string list, [> `Msg of string ]) result
+
(** List available strings in the calendar_dir. Returns Ok with the list of
+
string names if successful, or Error with a message if the directory cannot
+
be read. *)
-
val get_collection :
+
val get_calendar_events :
fs:Eio.Fs.dir_ty Eio.Path.t ->
t ->
-
Collection.t ->
+
string ->
(Event.t list, [> `Msg of string | `Not_found ]) result
-
(** Get all calendar files in a Collection.t. If the collection doesn't exist in
+
(** Get all calendar files in a string. If the calendar_name doesn't exist in
the cache, it will be loaded from disk. *)
val get_events :
fs:Eio.Fs.dir_ty Eio.Path.t -> t -> (Event.t list, [> `Msg of string ]) result
-
(** Get all events in all collections. This will load any Collection.ts that
+
(** Get all events in all calendar_names. This will load any strings that
haven't been loaded yet. *)
val add_event :
fs:Eio.Fs.dir_ty Eio.Path.t ->
t ->
+
Event.t list ->
Event.t ->
-
(unit, [> `Msg of string ]) result
+
(Event.t list, [> `Msg of string ]) result
+
(** Add an event to the calendar directory. Takes the current events list and returns
+
an updated events list with the new event added. *)
val edit_event :
fs:Eio.Fs.dir_ty Eio.Path.t ->
t ->
+
Event.t list ->
Event.t ->
-
(unit, [> `Msg of string ]) result
+
(Event.t list, [> `Msg of string ]) result
+
(** Edit an event in the calendar directory. Takes the current events list and returns
+
an updated events list with the event updated. *)
val delete_event :
fs:Eio.Fs.dir_ty Eio.Path.t ->
t ->
+
Event.t list ->
Event.t ->
-
(unit, [> `Msg of string ]) result
+
(Event.t list, [> `Msg of string ]) result
+
(** Delete an event from the calendar directory. Takes the current events list and returns
+
an updated events list with the event removed. *)
val get_path : t -> string
-1
lib/collection.ml
···
-
type t = Col of string
-3
lib/collection.mli
···
-
(** A collection is a directory of `ics` files *)
-
-
type t = Col of string (** The name of the collection. *)
+167 -70
lib/date.ml
···
let get_end_of_next_month ?(tz = !default_timezone ()) () =
get_end_of_month (get_start_of_next_month ~tz ())
+
let get_start_of_year date =
+
let dt = ptime_to_timedesc date in
+
let year = Timedesc.year dt in
+
+
(* Create a date for the first of January *)
+
match Timedesc.Date.Ymd.make ~year ~month:1 ~day:1 with
+
| Ok first_day ->
+
let midnight = Timedesc.Time.make_exn ~hour:0 ~minute:0 ~second:0 () in
+
let first_of_year = Timedesc.of_date_and_time_exn first_day midnight in
+
timedesc_to_ptime first_of_year
+
| Error _ -> failwith "Invalid date for start of year"
+
+
let get_start_of_current_year ?(tz = !default_timezone ()) () =
+
get_start_of_year (!get_today ~tz ())
+
+
let get_start_of_next_year ?(tz = !default_timezone ()) () =
+
add_years (get_start_of_current_year ~tz ()) 1
+
+
let get_end_of_year date =
+
let dt = ptime_to_timedesc date in
+
let year = Timedesc.year dt in
+
+
(* Create a date for the last day of the year (December 31) *)
+
match Timedesc.Date.Ymd.make ~year ~month:12 ~day:31 with
+
| Ok last_day ->
+
let end_of_day =
+
Timedesc.Time.make_exn ~hour:23 ~minute:59 ~second:59 ()
+
in
+
let end_of_year = Timedesc.of_date_and_time_exn last_day end_of_day in
+
timedesc_to_ptime end_of_year
+
| Error _ -> failwith "Invalid date for end of year"
+
+
let get_end_of_current_year ?(tz = !default_timezone ()) () =
+
get_end_of_year (!get_today ~tz ())
+
+
let get_end_of_next_year ?(tz = !default_timezone ()) () =
+
get_end_of_year (get_start_of_next_year ~tz ())
+
let convert_relative_date_formats ?(tz = !default_timezone ()) ~today ~tomorrow
~week ~month () =
if today then
···
let ( let* ) = Result.bind
-
(* Parse a date string that could be ISO format or a relative expression *)
+
let parse_full_iso_datet ~tz expr =
+
let regex = Re.Pcre.regexp "^(\\d{4})-(\\d{1,2})-(\\d{1,2})$" in
+
if Re.Pcre.pmatch ~rex:regex expr then
+
let match_result = Re.Pcre.exec ~rex:regex expr in
+
let year = int_of_string (Re.Pcre.get_substring match_result 1) in
+
let month = int_of_string (Re.Pcre.get_substring match_result 2) in
+
let day = int_of_string (Re.Pcre.get_substring match_result 3) in
+
match Timedesc.Date.Ymd.make ~year ~month ~day with
+
| Ok date ->
+
let midnight = Timedesc.Time.make_exn ~hour:0 ~minute:0 ~second:0 () in
+
let dt = Timedesc.of_date_and_time_exn ~tz date midnight in
+
Some (Ok (timedesc_to_ptime dt))
+
| Error _ -> Some (Error (`Msg (Printf.sprintf "Invalid date: %s" expr)))
+
else None
+
+
let parse_year_only ~tz expr parameter =
+
let regex = Re.Pcre.regexp "^(\\d{4})$" in
+
if Re.Pcre.pmatch ~rex:regex expr then
+
let match_result = Re.Pcre.exec ~rex:regex expr in
+
let year = int_of_string (Re.Pcre.get_substring match_result 1) in
+
match parameter with
+
| `From -> (
+
match Timedesc.Date.Ymd.make ~year ~month:1 ~day:1 with
+
| Ok date ->
+
let time = Timedesc.Time.make_exn ~hour:0 ~minute:0 ~second:0 () in
+
let dt = Timedesc.of_date_and_time_exn ~tz date time in
+
Some (Ok (timedesc_to_ptime dt))
+
| Error _ ->
+
Some (Error (`Msg (Printf.sprintf "Invalid year: %s" expr))))
+
| `To -> (
+
match Timedesc.Date.Ymd.make ~year ~month:12 ~day:31 with
+
| Ok date ->
+
let time =
+
Timedesc.Time.make_exn ~hour:23 ~minute:59 ~second:59 ()
+
in
+
let dt = Timedesc.of_date_and_time_exn ~tz date time in
+
Some (Ok (timedesc_to_ptime dt))
+
| Error _ ->
+
Some (Error (`Msg (Printf.sprintf "Invalid year: %s" expr))))
+
else None
+
+
let parse_year_month ~tz expr parameter =
+
let regex = Re.Pcre.regexp "^(\\d{4})-(\\d{1,2})$" in
+
if Re.Pcre.pmatch ~rex:regex expr then
+
let match_result = Re.Pcre.exec ~rex:regex expr in
+
let year = int_of_string (Re.Pcre.get_substring match_result 1) in
+
let month = int_of_string (Re.Pcre.get_substring match_result 2) in
+
match parameter with
+
| `From -> (
+
match Timedesc.Date.Ymd.make ~year ~month ~day:1 with
+
| Ok date ->
+
let time = Timedesc.Time.make_exn ~hour:0 ~minute:0 ~second:0 () in
+
let dt = Timedesc.of_date_and_time_exn ~tz date time in
+
Some (Ok (timedesc_to_ptime dt))
+
| Error _ ->
+
Some (Error (`Msg (Printf.sprintf "Invalid year-month: %s" expr))))
+
| `To -> (
+
let next_month = if month = 12 then 1 else month + 1 in
+
let next_month_year = if month = 12 then year + 1 else year in
+
match
+
Timedesc.Date.Ymd.make ~year:next_month_year ~month:next_month ~day:1
+
with
+
| Ok next_month_date ->
+
let last_day_of_month = Timedesc.Date.sub ~days:1 next_month_date in
+
let end_of_day =
+
Timedesc.Time.make_exn ~hour:23 ~minute:59 ~second:59 ()
+
in
+
let dt =
+
Timedesc.of_date_and_time_exn ~tz last_day_of_month end_of_day
+
in
+
Some (Ok (timedesc_to_ptime dt))
+
| Error _ ->
+
Some (Error (`Msg (Printf.sprintf "Invalid year-month: %s" expr))))
+
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
+
let num = int_of_string (Re.Pcre.get_substring match_result 2) in
+
let unit = Re.Pcre.get_substring match_result 3 in
+
let multiplier = if sign = "+" then 1 else -1 in
+
let value = num * multiplier in
+
let today = !get_today ~tz () in
+
match unit with
+
| "d" -> Some (Ok (add_days today value))
+
| "w" -> (
+
let date = add_weeks today value in
+
match parameter with
+
| `From -> Some (Ok (get_start_of_week date))
+
| `To -> Some (Ok (get_end_of_week date)))
+
| "m" -> (
+
let date = add_months today value 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
+
let parse_date ?(tz = !default_timezone ()) expr parameter =
-
let iso_date_regex = Re.Pcre.regexp "^(\\d{4})-(\\d{2})-(\\d{2})$" in
-
let relative_regex = Re.Pcre.regexp "^([+-])(\\d+)([dwm])$" in
match expr with
| "today" -> Ok (!get_today ~tz ())
| "tomorrow" -> Ok (add_days (!get_today ~tz ()) 1)
···
match parameter with
| `From -> Ok (get_start_of_next_month ~tz ())
| `To -> Ok (get_end_of_next_month ~tz ()))
-
| _ ->
-
(* Try to parse as ISO date *)
-
if Re.Pcre.pmatch ~rex:iso_date_regex expr then
-
let year =
-
int_of_string
-
(Re.Pcre.get_substring (Re.Pcre.exec ~rex:iso_date_regex expr) 1)
-
in
-
let month =
-
int_of_string
-
(Re.Pcre.get_substring (Re.Pcre.exec ~rex:iso_date_regex expr) 2)
-
in
-
let day =
-
int_of_string
-
(Re.Pcre.get_substring (Re.Pcre.exec ~rex:iso_date_regex expr) 3)
-
in
-
match Timedesc.Date.Ymd.make ~year ~month ~day with
-
| Ok date ->
-
let midnight =
-
Timedesc.Time.make_exn ~hour:0 ~minute:0 ~second:0 ()
-
in
-
let dt = Timedesc.of_date_and_time_exn ~tz date midnight in
-
Ok (timedesc_to_ptime dt)
-
| Error _ -> Error (`Msg (Printf.sprintf "Invalid date: %s" expr))
-
(* Try to parse as relative expression +Nd, -Nd, etc. *)
-
else if Re.Pcre.pmatch ~rex:relative_regex expr then
-
let sign =
-
Re.Pcre.get_substring (Re.Pcre.exec ~rex:relative_regex expr) 1
-
in
-
let num =
-
int_of_string
-
(Re.Pcre.get_substring (Re.Pcre.exec ~rex:relative_regex expr) 2)
-
in
-
let unit =
-
Re.Pcre.get_substring (Re.Pcre.exec ~rex:relative_regex expr) 3
-
in
-
let multiplier = if sign = "+" then 1 else -1 in
-
let value = num * multiplier in
-
let today = !get_today ~tz () in
-
match unit with
-
| "d" -> Ok (add_days today value)
-
| "w" -> (
-
let date = add_weeks today value in
-
match parameter with
-
| `From -> Ok (get_start_of_week date)
-
| `To -> Ok (get_end_of_week date))
-
| "m" -> (
-
let date = add_months today value in
-
match parameter with
-
| `From -> Ok (get_start_of_month date)
-
| `To -> Ok (get_end_of_month date))
-
| _ -> Error (`Msg (Printf.sprintf "Invalid date unit: %s" unit))
-
else Error (`Msg (Printf.sprintf "Invalid date format: %s" expr))
+
| _ -> (
+
(* Option alternative operator *)
+
let ( |>? ) opt f = match opt with None -> f () | Some x -> Some x in
+
( ( ( parse_full_iso_datet ~tz expr |>? fun () ->
+
parse_year_only ~tz expr parameter )
+
|>? fun () -> parse_year_month ~tz expr parameter )
+
|>? fun () -> parse_relative ~tz expr parameter )
+
|> function
+
| Some result -> result
+
| None -> Error (`Msg (Printf.sprintf "Invalid date format: %s" expr)))
let parse_time str =
try
···
let ptime_of_ical = function
| `Datetime (`Utc t) -> t
| `Datetime (`Local t) ->
-
let system_tz =
-
match Timedesc.Time_zone.local () with
-
| Some tz -> tz
-
| None -> Timedesc.Time_zone.utc
-
in
+
let tz = Timedesc.Time_zone.local_exn () in
let ts = Timedesc.Utils.timestamp_of_ptime t in
+
(* Icalendar gives us the Ptime in UTC, which we parse to a Timedesc *)
let dt =
-
match Timedesc.of_timestamp ~tz_of_date_time:system_tz ts with
-
| Some dt -> dt
-
| None -> failwith "Invalid local date conversion"
+
Timedesc.of_timestamp_exn ~tz_of_date_time:Timedesc.Time_zone.utc ts
in
+
(* We extract the datetime, and reinterpret it in the appropriate timezone *)
+
let date = Timedesc.date dt in
+
let time = Timedesc.time dt in
+
let dt = Timedesc.of_date_and_time_exn ~tz date time in
timedesc_to_ptime dt
| `Datetime (`With_tzid (t, (_, tzid))) ->
let tz =
match Timedesc.Time_zone.make tzid with
| Some tz -> tz
-
| None ->
-
failwith
-
(Printf.sprintf
-
"Warning: Unknown timezone %s, falling back to UTC\n" tzid)
+
| None -> failwith (Printf.sprintf "Warning: Unknown timezone %s" tzid)
in
+
(* Icalendar gives us the Ptime in UTC, which we parse to a Timedesc *)
let ts = Timedesc.Utils.timestamp_of_ptime t in
let dt =
-
match Timedesc.of_timestamp ~tz_of_date_time:tz ts with
-
| Some dt -> dt
-
| None -> failwith "Invalid timezone date conversion"
+
Timedesc.of_timestamp_exn ~tz_of_date_time:Timedesc.Time_zone.utc ts
in
+
(* We extract the datetime, and reinterpret it in the appropriate timezone *)
+
let date = Timedesc.date dt in
+
let time = Timedesc.time dt in
+
let dt = Timedesc.of_date_and_time_exn ~tz date time in
timedesc_to_ptime dt
| `Date date -> (
let y, m, d = date in
+38 -2
lib/date.mli
···
(** Get the end of the month for the given date. Raises an exception if the date
cannot be calculated. *)
+
val get_start_of_year : Ptime.t -> Ptime.t
+
(** Get the start of the year (Jan 1) for the given date. Raises an exception if
+
the date cannot be calculated. *)
+
+
val get_start_of_current_year : ?tz:Timedesc.Time_zone.t -> unit -> Ptime.t
+
(** Get the start of the current year in the specified timezone. If no timezone
+
is provided, uses the default_timezone. Raises an exception if the date
+
cannot be calculated. *)
+
+
val get_start_of_next_year : ?tz:Timedesc.Time_zone.t -> unit -> Ptime.t
+
(** Get the start of next year in the specified timezone. If no timezone is
+
provided, uses the default_timezone. Raises an exception if the date cannot
+
be calculated. *)
+
+
val get_end_of_year : Ptime.t -> Ptime.t
+
(** Get the end of the year (Dec 31, 23:59:59) for the given date. Raises an
+
exception if the date cannot be calculated. *)
+
+
val get_end_of_current_year : ?tz:Timedesc.Time_zone.t -> unit -> Ptime.t
+
(** Get the end of the current year in the specified timezone. If no timezone is
+
provided, uses the default_timezone. Raises an exception if the date cannot
+
be calculated. *)
+
+
val get_end_of_next_year : ?tz:Timedesc.Time_zone.t -> unit -> Ptime.t
+
(** Get the end of next year in the specified timezone. If no timezone is
+
provided, uses the default_timezone. Raises an exception if the date cannot
+
be calculated. *)
+
val convert_relative_date_formats :
?tz:Timedesc.Time_zone.t ->
today:bool ->
···
default_timezone.
Supported formats:
-
- ISO format: "YYYY-MM-DD"
+
- ISO format:
+
- "YYYY-MM-DD" (full date)
+
- "YYYY-MM" (partial date)
+
- For --from: defaults to first day of month
+
- For --to: defaults to last day of month
+
- "YYYY" (partial date)
+
- For --from: defaults to January 1st of year
+
- For --to: defaults to December 31st of year
- Relative expressions:
- "today" - Current day
- "tomorrow" - Next day
···
- "+Nd" - N days from today (e.g., "+7d" for a week from today)
- "-Nd" - N days before today (e.g., "-7d" for a week ago)
- "+Nw" - N weeks from today
-
- "+Nm" - N months from today *)
+
- "+Nm" - N months from today
+
- "+Ny" - N years from today *)
val parse_time : string -> (int * int * int, [> `Msg of string ]) result
(** Parse a time string in HH:MM or HH:MM:SS format. Returns Ok with (hour,
+4 -2
lib/dune
···
yojson
uuidm
eio
-
eio_main)
+
eio_main
+
cmdliner
+
sexplib)
(preprocess
-
(pps ppx_deriving.show ppx_deriving.eq)))
+
(pps ppx_deriving.show ppx_deriving.eq ppx_sexp_conv)))
+292 -105
lib/event.ml
···
type event_id = string
type t = {
-
collection : Collection.t;
+
calendar_name : string;
file : Eio.Fs.dir_ty Eio.Path.t;
event : event;
calendar : calendar;
···
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 collection =
+
?end_ ?location ?description ?recurrence calendar_name =
let uuid = generate_uuid () in
let uid = (Params.empty, uuid) in
let file_name = uuid ^ ".ics" in
let file =
Eio.Path.(
-
fs / calendar_dir_path
-
/ (match collection with Collection.Col s -> s)
-
/ file_name)
+
fs / calendar_dir_path / (match calendar_name with s -> s) / file_name)
in
-
let dtstart = (Params.empty, start) 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
-
{ collection; file; event; calendar }
+
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 -> (Params.empty, s)
-
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
···
alarms;
}
in
-
let collection = t.collection in
+
let calendar_name = t.calendar_name in
let file = t.file in
let calendar = t.calendar in
-
{ collection; file; event; calendar }
+
Ok { calendar_name; file; event; calendar }
-
let events_of_icalendar collection ~file calendar =
+
let events_of_icalendar calendar_name ~file calendar =
let remove_dup_ids lst =
let rec aux acc = function
| [] -> acc
···
(snd calendar)
in
let events = remove_dup_ids events in
-
List.map (function event -> { collection; file; event; calendar }) events
+
List.map (function event -> { calendar_name; file; event; calendar }) events
let to_ical_event t = t.event
let to_ical_calendar t = t.calendar
···
let get_start_timezone t =
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 =
···
| _ -> None
let get_recurrence t = Option.map (fun r -> snd r) t.event.rrule
-
let get_collection t = t.collection
+
let get_calendar_name t = t.calendar_name
let get_file t = t.file
type comparator = t -> t -> int
···
| None, Some _ -> -1
| None, None -> 0
-
let by_collection e1 e2 =
-
match (get_collection e1, get_collection e2) with
-
| Collection.Col c1, Collection.Col c2 -> String.compare c1 c2
+
let by_calendar_name e1 e2 =
+
match (get_calendar_name e1, get_calendar_name e2) with
+
| c1, c2 -> String.compare c1 c2
let descending comp e1 e2 = -1 * comp e1 e2
···
if result <> 0 then result else comp2 e1 e2
let clone_with_event t event =
-
let collection = t.collection in
+
let calendar_name = t.calendar_name in
let file = t.file in
let calendar = t.calendar in
-
{ collection; file; event; calendar }
+
{ calendar_name; file; event; calendar }
type format = [ `Text | `Entries | `Json | `Csv | `Ics | `Sexp ]
···
in
Printf.sprintf "%s %s%s" (format_date ?tz date) (format_time ?tz date) tz_str
-
let same_day day other =
-
let y1, m1, d1 = Ptime.to_date day in
-
let y2, m2, d2 = Ptime.to_date other in
-
y1 == y2 && m1 == m2 && d1 == d2
-
-
let next_day day ~next =
-
let y1, m1, d1 = Ptime.to_date day in
-
let y2, m2, d2 = Ptime.to_date next in
-
y1 == y2 && m1 == m2 && d1 == d2 - 1
+
let day_diff day ~next =
+
let span = Ptime.diff next day in
+
let d, _ = Ptime.Span.to_d_ps span in
+
d
(* exosed from icalendar *)
···
recur_to_ics recur)
l
+
let text_event_data ?tz event =
+
let id = get_id event in
+
let start = get_start event in
+
let end_ = get_end event in
+
let start_date = format_date ?tz start in
+
let start_timezone = get_start_timezone event in
+
let end_timezone = get_end_timezone event in
+
let same_timezone =
+
match (start_timezone, end_timezone) with
+
| Some tz1, Some tz2 when tz1 = tz2 -> true
+
| _ -> false
+
in
+
let start_time =
+
match is_date event with
+
| true -> ""
+
| false ->
+
let tz_str =
+
if same_timezone then " " ^ format_time ?tz start
+
else
+
match start_timezone with
+
| Some tzid -> " " ^ format_time ?tz start ^ " (" ^ tzid ^ ")"
+
| None -> " " ^ format_time ?tz start
+
in
+
tz_str
+
in
+
let end_date, end_time =
+
match end_ with
+
| None -> ("", "")
+
| Some end_ -> (
+
match is_date event with
+
| true -> (
+
match day_diff start ~next:end_ <= 1 with
+
| true -> ("", "")
+
| false -> (" - " ^ format_date ?tz end_, ""))
+
| false -> (
+
match day_diff start ~next:end_ == 0 with
+
| true ->
+
let tz_str =
+
match end_timezone with
+
| Some tzid when same_timezone ->
+
("", " - " ^ format_time ?tz end_ ^ " (" ^ tzid ^ ")")
+
| Some tzid ->
+
("", " - " ^ format_time ?tz end_ ^ " (" ^ tzid ^ ")")
+
| None -> ("", " - " ^ format_time ?tz end_)
+
in
+
tz_str
+
| false ->
+
let tz_str =
+
match end_timezone with
+
| Some tzid when same_timezone ->
+
( " - " ^ format_date ?tz end_,
+
" " ^ format_time ?tz end_ ^ " (" ^ tzid ^ ")" )
+
| Some tzid ->
+
( " - " ^ format_date ?tz end_,
+
" " ^ format_time ?tz end_ ^ " (" ^ tzid ^ ")" )
+
| None ->
+
(" - " ^ format_date ?tz end_, " " ^ format_time ?tz end_)
+
in
+
tz_str))
+
in
+
let summary =
+
match get_summary event with
+
| Some summary when summary <> "" -> summary
+
| _ -> ""
+
in
+
let location =
+
match get_location event with
+
| Some loc when loc <> "" -> "@" ^ loc
+
| _ -> ""
+
in
+
let calendar_name = get_calendar_name event in
+
let date_time = start_date ^ start_time ^ end_date ^ end_time in
+
(id, calendar_name, date_time, summary, location)
+
let format_event ?(format = `Text) ?tz event =
let start = get_start event in
let end_ = get_end event in
match format with
| `Text ->
-
let id = get_id event in
-
let start_date = " " ^ format_date ?tz start in
-
let start_time =
-
match is_date event with
-
| true -> ""
-
| false -> " " ^ format_time ?tz start
+
let id, calendar_name, date_time, summary, location =
+
text_event_data ?tz event
in
-
let end_date, end_time =
-
match end_ with
-
| None -> ("", "")
-
| Some end_ -> (
-
match is_date event with
-
| true -> (
-
match next_day start ~next:end_ with
-
| true -> ("", "")
-
| false -> (" - " ^ format_date ?tz end_, ""))
-
| false -> (
-
match same_day start end_ with
-
| true -> ("", " - " ^ format_time ?tz end_)
-
| false ->
-
(" - " ^ format_date ?tz end_, " " ^ format_time ?tz end_)))
-
in
-
let summary =
-
match get_summary event with
-
| Some summary when summary <> "" -> " " ^ summary
-
| _ -> ""
-
in
-
let location =
-
match get_location event with
-
| Some loc when loc <> "" -> " @" ^ loc
-
| _ -> ""
-
in
-
let collection = match get_collection event with Col s -> s in
-
Printf.sprintf "%-45s\t%s\t%s%s%s%s%s%s" id collection start_date
-
start_time end_date end_time summary location
+
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
|> Option.value ~default:""
in
-
let format timezone datetime =
+
let start_timezone = get_start_timezone event in
+
let end_timezone = get_end_timezone event in
+
let same_timezone =
+
match (start_timezone, end_timezone) with
+
| Some tz1, Some tz2 when tz1 = tz2 -> true
+
| _ -> false
+
in
+
let format timezone datetime is_end =
match is_date event with
| true -> format_date ?tz datetime
-
| false -> (
-
format_datetime ?tz datetime
-
^ match timezone with None -> "" | Some t -> " (" ^ t ^ ")")
+
| false ->
+
let tz_suffix =
+
if (not is_end) && same_timezone then ""
+
else match timezone with None -> "" | Some t -> " (" ^ t ^ ")"
+
in
+
format_datetime ?tz datetime ^ tz_suffix
in
let start_str =
-
format_opt "Start" (format (get_start_timezone event)) (Some start)
+
format_opt "Start" (fun d -> format start_timezone d false) (Some start)
+
in
+
let end_str =
+
format_opt "End" (fun d -> format end_timezone d true) end_
in
-
let end_str = format_opt "End" (format (get_end_timezone event)) end_ in
let location_str = format_opt "Location" Fun.id (get_location event) in
let description_str =
format_opt "Description" Fun.id (get_description event)
···
match get_description event with
| Some desc -> `String desc
| None -> `Null );
-
( "calendar",
-
match get_collection event with
-
| Collection.Col cal -> `String cal );
+
("calendar", match get_calendar_name event with cal -> `String cal);
]
in
to_string json
···
let location =
match get_location event with Some loc -> loc | None -> ""
in
-
let cal_id =
-
match get_collection event with Collection.Col cal -> cal
-
in
+
let cal_id = match get_calendar_name event with cal -> cal in
Printf.sprintf "\"%s\",\"%s\",\"%s\",\"%s\",\"%s\"" summary start end_str
location cal_id
| `Ics ->
···
let summary =
match get_summary event with Some summary -> summary | None -> ""
in
-
let start_date, start_time =
+
let start_str =
let dt = Date.ptime_to_timedesc ?tz start in
let y = Timedesc.year dt in
let m = Timedesc.month dt in
···
let h = Timedesc.hour dt in
let min = Timedesc.minute dt in
let s = Timedesc.second dt in
-
let dow =
-
match Timedesc.weekday dt with
-
| `Mon -> "monday"
-
| `Tue -> "tuesday"
-
| `Wed -> "wednesday"
-
| `Thu -> "thursday"
-
| `Fri -> "friday"
-
| `Sat -> "saturday"
-
| `Sun -> "sunday"
-
in
-
( Printf.sprintf "(%04d %02d %02d %s)" y m d dow,
-
Printf.sprintf "(%02d %02d %02d)" h min s )
+
(* Format as a single timestamp string that's easy for Emacs to parse *)
+
Printf.sprintf "\"%04d-%02d-%02dT%02d:%02d:%02d\"" y m d h min s
in
let end_str =
match end_ with
···
let h = Timedesc.hour dt in
let min = Timedesc.minute dt in
let s = Timedesc.second dt in
-
let dow =
-
match Timedesc.weekday dt with
-
| `Mon -> "monday"
-
| `Tue -> "tuesday"
-
| `Wed -> "wednesday"
-
| `Thu -> "thursday"
-
| `Fri -> "friday"
-
| `Sat -> "saturday"
-
| `Sun -> "sunday"
-
in
-
Printf.sprintf "((%04d %02d %02d %s) (%02d %02d %02d))" y m d dow h
-
min s
+
Printf.sprintf "\"%04d-%02d-%02dT%02d:%02d:%02d\"" y m d h min s
| None -> "nil"
in
let location =
···
| None -> "nil"
in
let calendar =
-
match get_collection event with
-
| Collection.Col cal -> Printf.sprintf "\"%s\"" (String.escaped cal)
+
match get_calendar_name event with
+
| cal -> Printf.sprintf "\"%s\"" (String.escaped cal)
in
let id = get_id event in
Printf.sprintf
-
"((:id \"%s\" :summary \"%s\" :start (%s %s) :end %s :location %s \
+
"((:id \"%s\" :summary \"%s\" :start %s :end %s :location %s \
:description %s :calendar %s))"
-
(String.escaped id) (String.escaped summary) start_date start_time
-
end_str location description calendar
+
(String.escaped id) (String.escaped summary) start_str end_str location
+
description calendar
+
+
let format_events_with_dynamic_columns ?tz events =
+
if events = [] then ""
+
else
+
let event_data = List.map (text_event_data ?tz) events in
+
(* Calculate max width for each column *)
+
let max_id_width =
+
List.fold_left
+
(fun acc (id, _, _, _, _) -> max acc (String.length id))
+
0 event_data
+
in
+
let max_cal_width =
+
List.fold_left
+
(fun acc (_, cal, _, _, _) -> max acc (String.length cal))
+
0 event_data
+
in
+
let max_date_width =
+
List.fold_left
+
(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
let format_events ?(format = `Text) ?tz events =
match format with
···
^ String.concat "\n "
(List.map (fun e -> format_event ~format:`Sexp ?tz e) events)
^ ")"
+
| `Text -> format_events_with_dynamic_columns ?tz events
| _ ->
String.concat "\n" (List.map (fun e -> format_event ~format ?tz e) 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
+59 -12
lib/event.mli
···
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.date_or_datetime ->
+
start:Icalendar.params * Icalendar.date_or_datetime ->
?end_:
[ `Duration of Icalendar.params * Ptime.Span.t
| `Dtend of Icalendar.params * Icalendar.date_or_datetime ] ->
?location:string ->
?description:string ->
?recurrence:Icalendar.recurrence ->
-
Collection.t ->
-
t
+
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.date_or_datetime ->
+
?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
+
(t, [> `Msg of string ]) result
(** Edit an existing event. *)
val events_of_icalendar :
-
Collection.t -> file:Eio.Fs.dir_ty Eio.Path.t -> Icalendar.calendar -> t list
+
string -> file:Eio.Fs.dir_ty Eio.Path.t -> Icalendar.calendar -> t list
val to_ical_event : t -> Icalendar.event
val to_ical_calendar : t -> Icalendar.calendar
···
val get_location : t -> string option
val get_description : t -> string option
val get_recurrence : t -> Icalendar.recurrence option
-
val get_collection : t -> Collection.t
+
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 *)
···
(** Compare events by location alphabetically. Events with locations come before
those without *)
-
val by_collection : comparator
-
(** Compare events by collection name alphabetically *)
+
val by_calendar_name : comparator
+
(** Compare events by calendar_name name alphabetically *)
val descending : comparator -> comparator
(** Reverse the order of a comparator *)
···
(** 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 *)
+
(** 2 Formatting *)
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
+
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 *)
-70
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_collections ids event =
-
let id = Event.get_collection 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 filtered_events =
-
match filter with Some f -> List.filter f events | None -> events
-
in
-
let sorted_events = List.sort comparator filtered_events in
-
Ok
-
(match limit with
-
| Some n when n > 0 -> take n sorted_events
-
| _ -> sorted_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 sorted_events = List.sort comparator events in
-
Ok
-
(match limit with Some n when n > 0 -> take n events | _ -> sorted_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_collections : Collection.t list -> filter
-
val recurring_only : unit -> filter
-
val non_recurring_only : unit -> filter
-
val with_id : Event.event_id -> filter
-
val and_filter : filter list -> filter
-
val or_filter : filter list -> filter
-
val not_filter : filter -> filter
-
-
val query_without_recurrence :
-
fs:Eio.Fs.dir_ty Eio.Path.t ->
-
Calendar_dir.t ->
-
?filter:filter ->
-
?comparator:Event.comparator ->
-
?limit:int ->
-
unit ->
-
(Event.t list, [> `Msg of string ]) result
-
(** Find events without expansion of recurring events. Returns Ok with the list
-
of events, or Error with a message. *)
-
-
val query :
-
fs:Eio.Fs.dir_ty Eio.Path.t ->
-
Calendar_dir.t ->
-
?filter:filter ->
-
from:Ptime.t option ->
-
to_:Ptime.t ->
-
?comparator:Event.comparator ->
-
?limit:int ->
-
unit ->
-
(Event.t list, [> `Msg of string ]) result
-
(** Find events with expansion of recurring events. Returns Ok with the list of
-
events, or Error with a message. *)
-
-
(* Test-only helper functions *)
-
val matches_filter : Event.t -> filter -> bool
-
(** Check if an event matches the given filter *)
+120
lib/sexp.ml
···
+
open Sexplib.Std
+
+
type search_field = Summary | Description | Location [@@deriving sexp]
+
+
type query_request = {
+
from : string option; [@sexp.option]
+
to_ : string; (* Required field, not optional *)
+
timezone : string option; [@sexp.option]
+
calendars : string list; [@default []]
+
text : string option; [@sexp.option]
+
search_in : search_field list; [@default []]
+
id : string option; [@sexp.option]
+
recurring : bool option; [@sexp.option]
+
limit : int option; [@sexp.option]
+
}
+
[@@deriving sexp]
+
+
(* workaround https://github.com/janestreet/ppx_sexp_conv/issues/18#issuecomment-2792574295 *)
+
let query_request_of_sexp sexp =
+
let open Sexplib.Sexp in
+
let sexp = match sexp with
+
| List ss ->
+
List (List.map (function List (Atom "to" :: v) -> List (Atom "to_" :: v) | v -> v) ss)
+
| v -> v
+
in
+
query_request_of_sexp sexp
+
+
let sexp_of_query_request q =
+
let open Sexplib.Sexp in
+
let sexp = sexp_of_query_request q in
+
let sexp = match sexp with
+
| List ss ->
+
List (List.map (function List (Atom "to_" :: v) -> List (Atom "to" :: v) | v -> v) ss)
+
| v -> v
+
in
+
sexp
+
+
type request = ListCalendars | Query of query_request | Refresh [@@deriving sexp]
+
+
type response_payload = Calendars of string list | Events of Event.t list | Empty
+
[@@deriving sexp_of]
+
+
type response = Ok of response_payload | Error of string [@@deriving sexp_of]
+
+
let filter_func_of_search_field text = function
+
| Summary -> Event.summary_contains text
+
| Description -> Event.description_contains text
+
| Location -> Event.location_contains text
+
+
let parse_timezone ~timezone =
+
match timezone with
+
| Some tzid -> (
+
match Timedesc.Time_zone.make tzid with
+
| Some tz -> tz
+
| None -> failwith ("Invalid timezone: " ^ tzid))
+
| None -> !Date.default_timezone ()
+
+
let generate_query_params (req : query_request) =
+
let ( let* ) = Result.bind in
+
let tz = parse_timezone ~timezone:req.timezone in
+
let* from =
+
match req.from with
+
| None -> Ok None
+
| Some s -> Result.map Option.some (Date.parse_date ~tz s `From)
+
in
+
let* to_ =
+
let* to_date = Date.parse_date ~tz req.to_ `To in
+
Ok (Date.to_end_of_day to_date)
+
in
+
let filters = ref [] in
+
(match req.calendars with
+
| [] -> ()
+
| cals -> filters := Event.in_calendars cals :: !filters);
+
(match req.text with
+
| Some text ->
+
let search_fields =
+
match req.search_in with
+
| [] -> [ Summary; Description; Location ]
+
| fields -> fields
+
in
+
let text_filters =
+
List.map (filter_func_of_search_field text) search_fields
+
in
+
filters := Event.or_filter text_filters :: !filters
+
| None -> ());
+
(match req.id with
+
| Some id -> filters := Event.with_id id :: !filters
+
| None -> ());
+
(match req.recurring with
+
| Some true -> filters := Event.recurring_only () :: !filters
+
| Some false -> filters := Event.non_recurring_only () :: !filters
+
| _ -> ());
+
let final_filter = Event.and_filter !filters in
+
let limit = req.limit in
+
Ok (final_filter, from, to_, limit, tz)
+
+
let is_whitespace = function ' ' | '\t' | '\n' | '\r' -> true | _ -> false
+
+
let needs_quotes s =
+
String.exists
+
(fun c -> is_whitespace c || c = '(' || c = ')' || c = '"' || c = '\'')
+
s
+
+
let escape s =
+
let buf = Buffer.create (String.length s) in
+
String.iter
+
(function
+
| '"' -> Buffer.add_string buf "\\\""
+
| '\\' -> Buffer.add_string buf "\\\\"
+
| '\n' -> Buffer.add_string buf "\\n"
+
| '\r' -> Buffer.add_string buf "\\r"
+
| '\t' -> Buffer.add_string buf "\\t"
+
| c -> Buffer.add_char buf c)
+
s;
+
"\"" ^ Buffer.contents buf ^ "\""
+
+
let rec to_string = function
+
| Sexplib.Sexp.Atom str -> if needs_quotes str then escape str else str
+
| Sexplib.Sexp.List lst ->
+
"(" ^ String.concat " " (List.map to_string lst) ^ ")"
+1 -1
test/dune
···
(tests
-
(names test_calendar_dir test_query)
+
(names test_calendar_dir test_date test_event)
(libraries caledonia_lib alcotest str ptime)
(deps
(source_tree calendar)))
+17 -18
test/test_calendar_dir.ml
···
let calendar_dir_path = Filename.concat (Sys.getcwd ()) "calendar"
-
let test_list_collections ~fs () =
+
let test_list_calendar_names ~fs () =
let calendar_dir =
match Calendar_dir.create ~fs calendar_dir_path with
| Ok dir -> dir
| Error (`Msg msg) ->
Alcotest.fail ("Calendar directory creation failed: " ^ msg)
in
-
match Calendar_dir.list_collections ~fs calendar_dir with
-
| Error (`Msg msg) -> Alcotest.fail ("List collections failed: " ^ msg)
-
| Ok collections ->
+
match Calendar_dir.list_calendar_names ~fs calendar_dir with
+
| Error (`Msg msg) -> Alcotest.fail ("List calendar_names failed: " ^ msg)
+
| Ok calendar_names ->
Alcotest.(check int)
-
"Should find two collections" 2 (List.length collections);
+
"Should find two calendar_names" 2
+
(List.length calendar_names);
Alcotest.(check bool)
"example should be in the list" true
-
(List.exists (fun c -> c = Collection.Col "example") collections);
+
(List.exists (fun c -> c = "example") calendar_names);
Alcotest.(check bool)
"recurrence should be in the list" true
-
(List.exists (fun c -> c = Collection.Col "recurrence") collections);
+
(List.exists (fun c -> c = "recurrence") calendar_names);
()
-
let test_get_collection ~fs () =
+
let test_get_calendar_events ~fs () =
let calendar_dir =
match Calendar_dir.create ~fs calendar_dir_path with
| Ok dir -> dir
| Error (`Msg msg) ->
Alcotest.fail ("Calendar directory creation failed: " ^ msg)
in
-
let result =
-
Calendar_dir.get_collection ~fs calendar_dir (Collection.Col "example")
-
in
+
let result = Calendar_dir.get_calendar_events ~fs calendar_dir "example" in
match result with
-
| Ok _ -> Alcotest.(check pass) "Should find example collection" () ()
-
| Error `Not_found -> Alcotest.fail "Failed to find example collection"
-
| Error (`Msg msg) -> Alcotest.fail ("Error getting collection: " ^ msg)
+
| Ok _ -> Alcotest.(check pass) "Should find example calendar_name" () ()
+
| Error `Not_found -> Alcotest.fail "Failed to find example calendar_name"
+
| Error (`Msg msg) -> Alcotest.fail ("Error getting calendar_name: " ^ msg)
let test_get_events ~fs () =
let calendar_dir =
···
()
| Error e ->
let msg = match e with `Msg m -> m in
-
Alcotest.fail ("Error getting collections: " ^ msg)
+
Alcotest.fail ("Error getting calendar_names: " ^ msg)
let calendar_tests fs =
[
-
("list collections", `Quick, test_list_collections ~fs);
-
("get collection", `Quick, test_get_collection ~fs);
-
("get all collections", `Quick, test_get_events ~fs);
+
("list calendar_names", `Quick, test_list_calendar_names ~fs);
+
("get calendar_name", `Quick, test_get_calendar_events ~fs);
+
("get all calendar_names", `Quick, test_get_events ~fs);
]
let () =
+13 -7
test/test_date.ml
···
(* Test the Date module *)
+
open Caledonia_lib
+
(* Setup a fixed date for testing *)
let fixed_date = Option.get @@ Ptime.of_date_time ((2025, 3, 27), ((0, 0, 0), 0))
let setup_fixed_date () =
-
(Date.get_today := fun () -> fixed_date);
+
(Date.get_today := fun ?tz:_ () -> fixed_date);
fixed_date
let test_parse_date () =
let test_expr expr parameter expected =
try
-
let result = Query.parse_date expr parameter in
+
let result = Result.get_ok @@ Date.parse_date expr parameter in
let result_str =
let y, m, d = Ptime.to_date result in
Printf.sprintf "%04d-%02d-%02d" y m d
···
test_expr "+1m" `To "2025-04-30";
test_expr "2025-01-01" `From "2025-01-01";
test_expr "2025-01-01" `To "2025-01-01";
+
test_expr "2025-01" `From "2025-01-01";
+
test_expr "2025-01" `To "2025-01-01";
+
test_expr "2025" `From "2025-01-01";
+
test_expr "2025" `To "2025-01-01";
+
test_expr "2025-3-1" `From "2025-03-01";
+
test_expr "2025-3-1" `To "2025-03-01";
(try
-
let _ = Query.parse_date "invalid-format" `From in
+
let _ = Result.get_ok @@ Date.parse_date "invalid-format" `From in
Alcotest.fail "Should have raised an exception for invalid format"
with Failure msg ->
Alcotest.(check bool)
···
(String.length msg > 0));
()
-
let date_tests fs = [ ("date expression parsing", `Quick, test_parse_date) ]
+
let date_tests = [ ("date expression parsing", `Quick, test_parse_date) ]
let () =
-
Eio_main.run @@ fun env ->
-
let fs = Eio.Stdenv.fs env in
let _ = setup_fixed_date () in
-
Alcotest.run "Query Tests" [ ("query", date_tests fs) ]
+
Alcotest.run "Query Tests" [ ("query", date_tests) ]
+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) ]
-335
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 collection = Collection.Col "example" in
-
let filter = Query.in_collections [ collection ] 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_collection e with id -> id = collection)
-
events
-
in
-
Alcotest.(check bool)
-
(Printf.sprintf "All events should be from calendar '%s'"
-
(match collection with Col str -> str))
-
true all_match_calendar;
-
Alcotest.(check int) "Should find events" 2 (List.length events)
-
| Error _ -> Alcotest.fail "Error querying events");
-
let collections = [ Collection.Col "example"; Collection.Col "recurrence" ] in
-
let filter = Query.in_collections collections 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_collections [ Collection.Col "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 ~collection ~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)
-
(Collection.Col collection)
-
in
-
[
-
(* Event with text in all fields *)
-
create_test_event ~collection:"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 ~collection:"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 ~collection:"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 ~collection:"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) ]