Command-line and Emacs Calendar Client

initial

Ryan Gibb fcb62e22

+2
.gitignore
···
+
_build/
+
_opam/
.ocamlformat

This is a binary file and will not be displayed.

+19
README.md
···
+
# 📅 Caledonia 🏴󠁧󠁢󠁳󠁣󠁴󠁿
+
+
Caledonia is a command-line calendar client.
+
Currently, it operates on directories of [`.ics`](https://datatracker.ietf.org/doc/html/rfc5545) files (as managed by tools like [vdirsyncer](https://github.com/pimutils/vdirsyncer)).
+
It supports the `list` and `query` commands.
+
See [TODO](./TODO.org) for future plans.
+
+
## Configuration
+
+
Caledonia looks for calendars in the directory specified by the `CALENDAR_DIR` environment variable or in `~/.calendars/` by default.
+
+
## Tests
+
+
The project includes a test suite that can be ran with `dune runtest`.
+
+
## Thanks
+
+
To [Patrick](https://patrick.sirref.org/) for suggesting the name, and all the developers of the dependencies used, especially [icalendar](https://github.com/robur-coop/icalendar) and [calendar](https://github.com/ocaml-community/calendar).
+
+15
TODO.org
···
+
* DONE list/search events
+
* TODO diagnose events failing to parse
+
* TODO timezones and daylight saving
+
* TODO add/remove events
+
* TODO edit events
+
will need to modify the event to ICS code to retain fields we don't use
+
* TODO [[https://github.com/robur-coop/icalendar/pull/13][handle RECURRENCE-ID]]
+
* TODO CalDAV syncing
+
Currently, you can use [[https://github.com/pimutils/vdirsyncer][vdirsyncer]]
+
* TODO support a DSL of date queries (e.g. query for every event on a Monday)
+
* TODO support querying times as well as dates
+
* TODO custom date/time formatting
+
* TODO support querying regex
+
* TODO server mode
+
and maybe hold =Event='s in-memory in a =Collection= instead of parsing them for every =Query=
+17
bin/dune
···
+
(executable
+
(name main)
+
(public_name caled)
+
(package caledonia)
+
(libraries
+
caledonia_lib
+
cmdliner
+
fmt
+
logs
+
logs.fmt
+
logs.cli
+
re
+
ptime
+
ptime.clock.os
+
eio
+
eio_main)
+
(modules main list_cmd search_cmd query_args))
+91
bin/list_cmd.ml
···
+
open Cmdliner
+
open Caledonia_lib
+
open Query_args
+
+
let run ?from ?to_ ?calendar ?count ~format ~today ~tomorrow ~week ~month ~fs
+
calendar_dir =
+
let ( let* ) = Result.bind in
+
let from, to_ =
+
match Query.convert_relative_date_formats ~today ~tomorrow ~week ~month with
+
| Some (from, to_) -> (Some from, to_)
+
| None -> (
+
match (from, to_) with
+
| Some f, Some t -> (Some f, Query.to_end_of_day t)
+
| Some f, None ->
+
let one_month_later = Query.add_months f 1 in
+
(Some f, one_month_later)
+
| None, Some t ->
+
let today_date = !Query.get_today () in
+
(Some today_date, Query.to_end_of_day t)
+
| None, None ->
+
let today_date = !Query.get_today () in
+
let one_month_later = Query.add_months today_date 1 in
+
(Some today_date, one_month_later))
+
in
+
let filter =
+
match calendar with
+
| Some collection_id ->
+
Some (Query.in_collections [ Calendar_dir.Collection collection_id ])
+
| None -> None
+
in
+
let* results =
+
Query.query ~fs calendar_dir ?filter ~from ~to_ ?limit:count ()
+
in
+
if results = [] then print_endline "No events found."
+
else print_endline (Format.format_instances ~format results);
+
Ok ()
+
+
let cmd ~fs calendar_dir =
+
let run from to_ calendar count format today tomorrow week month =
+
match
+
run ?from ?to_ ?calendar ?count ~format ~today ~tomorrow ~week ~month ~fs
+
calendar_dir
+
with
+
| Error (`Msg msg) ->
+
Printf.eprintf "Error: %s\n%!" msg;
+
1
+
| Ok () -> 0
+
in
+
let term =
+
Term.(
+
const run $ from_arg $ to_arg $ calendar_arg $ count_arg $ format_arg
+
$ today_arg $ tomorrow_arg $ week_arg $ month_arg)
+
in
+
let doc = "List calendar events" in
+
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.";
+
`S Manpage.s_options;
+
`S "DATE FORMATS";
+
]
+
@ date_format_manpage_entries
+
@ [
+
`S "EXAMPLES";
+
`P "List all events for today:";
+
`P " caled list --today";
+
`P "List all events for tomorrow:";
+
`P " caled list --tomorrow";
+
`P "List all events for the current week:";
+
`P " caled list --week";
+
`P "List all events for the current month:";
+
`P " caled list --month";
+
`P "List events within a specific date range:";
+
`P " caled list --from 2025-03-27 --to 2025-04-01";
+
`P "List events from a specific calendar:";
+
`P " caled list --calendar work";
+
`P "List events in JSON format:";
+
`P " caled list --format json";
+
`P "Limit the number of events shown:";
+
`P " caled list --count 5";
+
]
+
in
+
let exit_info =
+
[ Cmd.Exit.info ~doc:"on success." 0; Cmd.Exit.info ~doc:"on error." 1 ]
+
in
+
+
let info = Cmd.info "list" ~doc ~man ~exits:exit_info in
+
Cmd.v info term
+45
bin/main.ml
···
+
(* Main entry point for the calendar CLI *)
+
+
open Cmdliner
+
+
let list_cmd = List_cmd.cmd
+
let search_cmd = Search_cmd.cmd
+
let doc = "Command-line calendar tool for managing local .ics files"
+
let version = "%%VERSION%%"
+
+
let main env =
+
let exit_info =
+
[
+
Cmd.Exit.info ~doc:"on success." 0;
+
Cmd.Exit.info
+
~doc:
+
"on error (including invalid date format, file access issues, or \
+
other errors)."
+
1;
+
]
+
in
+
let info = Cmd.info "caled" ~version ~doc ~exits:exit_info in
+
let default =
+
Term.(ret (const (fun () -> `Help (`Pager, None)) $ const ()))
+
in
+
let calendar_dir_path =
+
match Sys.getenv_opt "CALENDAR_DIR" with
+
| Some dir -> dir
+
| None -> Filename.concat (Sys.getenv "HOME") ".calendar"
+
in
+
let fs = Eio.Stdenv.fs env in
+
match Caledonia_lib.Calendar_dir.create ~fs calendar_dir_path with
+
| Error (`Msg e) ->
+
Printf.eprintf "%s" e;
+
1
+
| Ok calendar_dir -> (
+
match
+
Cmd.eval_value
+
(Cmd.group info ~default
+
[ list_cmd ~fs calendar_dir; search_cmd ~fs calendar_dir ])
+
with
+
| Ok (`Ok n) -> n
+
| Ok _ -> 0
+
| Error _ -> 1)
+
+
let () = Eio_main.run @@ fun env -> exit (main env)
+92
bin/query_args.ml
···
+
open Cmdliner
+
open Caledonia_lib
+
+
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)"
+
in
+
let i = Arg.info [ "from"; "f" ] ~docv:"DATE" ~doc in
+
let parse_date s =
+
try Ok (Query.parse_date_expression s `From)
+
with Failure msg -> Error (`Msg msg)
+
in
+
Arg.(value @@ opt (some (Cmdliner.Arg.conv (parse_date, Ptime.pp))) 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)"
+
in
+
let i = Arg.info [ "to"; "t" ] ~docv:"DATE" ~doc in
+
let parse_date s =
+
try Ok (Query.parse_date_expression s `To)
+
with Failure msg -> Error (`Msg msg)
+
in
+
Arg.(value @@ opt (some (Cmdliner.Arg.conv (parse_date, Ptime.pp))) None i)
+
+
let calendar_arg =
+
let doc = "Calendar to filter by" in
+
Arg.(
+
value
+
& opt (some string) None
+
& info [ "calendar"; "c" ] ~docv:"CALENDAR" ~doc)
+
+
let format_enum =
+
[
+
("text", `Text);
+
("json", `Json);
+
("csv", `Csv);
+
("ics", `Ics);
+
("table", `Table);
+
("sexp", `Sexp);
+
]
+
+
let format_arg =
+
let doc = "Output format (text, json, csv, ics, table, sexp)" in
+
Arg.(
+
value
+
& opt (enum format_enum) `Text
+
& info [ "format"; "o" ] ~docv:"FORMAT" ~doc)
+
+
let count_arg =
+
let doc = "Maximum number of events to display" in
+
Arg.(value & opt (some int) None & info [ "count"; "n" ] ~docv:"COUNT" ~doc)
+
+
let today_arg =
+
let doc = "Show events for today only" in
+
Arg.(value & flag & info [ "today"; "d" ] ~doc)
+
+
let tomorrow_arg =
+
let doc = "Show events for tomorrow only" in
+
Arg.(value & flag & info [ "tomorrow" ] ~doc)
+
+
let week_arg =
+
let doc = "Show events for the current week" in
+
Arg.(value & flag & info [ "week"; "w" ] ~doc)
+
+
let month_arg =
+
let doc = "Show events for the current month" in
+
Arg.(value & flag & info [ "month"; "m" ] ~doc)
+
+
let date_format_manpage_entries =
+
[
+
`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");
+
`P "Relative date formats for --from and --to:";
+
`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 ("+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)");
+
]
+153
bin/search_cmd.ml
···
+
open Cmdliner
+
open Caledonia_lib
+
open Query_args
+
+
let run ?from ?to_ ?calendar ?count ?query_text ~summary ~description ~location
+
~format ~today ~tomorrow ~week ~month ~recurring ~non_recurring ~fs
+
calendar_dir =
+
let ( let* ) = Result.bind in
+
let filters = ref [] in
+
let from, to_ =
+
match Query.convert_relative_date_formats ~today ~tomorrow ~week ~month with
+
| Some (from, to_) -> (Some from, to_)
+
| None -> (
+
let max_date = Query.add_years (!Query.get_today ()) 75 in
+
match (from, to_) with
+
| Some f, Some t -> (Some f, Query.to_end_of_day t)
+
| Some f, None -> (Some f, Query.to_end_of_day max_date)
+
| None, Some t -> (None, Query.to_end_of_day t)
+
| None, None -> (None, Query.to_end_of_day max_date))
+
in
+
(match calendar with
+
| Some collection_id ->
+
filters :=
+
Query.in_collections [ Calendar_dir.Collection collection_id ]
+
:: !filters
+
| None -> ());
+
(match query_text with
+
| Some text ->
+
if summary then filters := Query.summary_contains text :: !filters;
+
if description then filters := Query.description_contains text :: !filters;
+
if location then filters := Query.location_contains text :: !filters;
+
if not (summary || description || location) then
+
filters :=
+
Query.or_filter
+
[
+
Query.summary_contains text;
+
Query.description_contains text;
+
Query.location_contains text;
+
]
+
:: !filters
+
| None -> ());
+
if recurring then filters := Query.recurring_only () :: !filters;
+
if non_recurring then filters := Query.non_recurring_only () :: !filters;
+
let filter = Query.and_filter !filters in
+
let* results =
+
Query.query ~fs calendar_dir ~filter ~from ~to_ ?limit:count ()
+
in
+
if results = [] then print_endline "No events found."
+
else print_endline (Format.format_instances ~format results);
+
Ok ()
+
+
let query_text_arg =
+
let doc = "Text to search for in events (summary, description, location)" in
+
Arg.(value & pos 0 (some string) None & info [] ~docv:"TEXT" ~doc)
+
+
let summary_arg =
+
let doc = "Search in event summaries only" in
+
Arg.(value & flag & info [ "summary"; "s" ] ~doc)
+
+
let description_arg =
+
let doc = "Search in event descriptions only" in
+
Arg.(value & flag & info [ "description"; "D" ] ~doc)
+
+
let location_arg =
+
let doc = "Search in event locations only" in
+
Arg.(value & flag & info [ "location"; "l" ] ~doc)
+
+
let recurring_arg =
+
let doc = "Search for recurring events only" in
+
Arg.(value & flag & info [ "recurring"; "r" ] ~doc)
+
+
let non_recurring_arg =
+
let doc = "Search for non-recurring events only" in
+
Arg.(value & flag & info [ "non-recurring"; "R" ] ~doc)
+
+
let cmd ~fs calendar_dir =
+
let run query_text from to_ calendar count format summary description location
+
today tomorrow week month recurring non_recurring =
+
match
+
run ?from ?to_ ?calendar ?count ?query_text ~summary ~description
+
~location ~format ~today ~tomorrow ~week ~month ~recurring
+
~non_recurring ~fs calendar_dir
+
with
+
| Error (`Msg msg) ->
+
Printf.eprintf "Error: %s\n%!" msg;
+
1
+
| Ok () -> 0
+
in
+
let term =
+
Term.(
+
const run $ query_text_arg $ from_arg $ to_arg $ calendar_arg $ count_arg
+
$ format_arg $ summary_arg $ description_arg $ location_arg $ today_arg
+
$ tomorrow_arg $ week_arg $ month_arg $ recurring_arg $ non_recurring_arg)
+
in
+
let doc = "Search calendar events for specific text" in
+
let man =
+
[
+
`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
+
"The search text is optional if you're using other filters. For \
+
example, you can find all recurring events without specifying any \
+
search text.";
+
`S Manpage.s_options;
+
`S "DATE FORMATS";
+
]
+
@ date_format_manpage_entries
+
@ [
+
`S "EXAMPLES";
+
`P "Search for 'meeting' in all events:";
+
`P " caled search meeting";
+
`P "Search for 'interview' in event summaries only:";
+
`P " caled search --summary interview";
+
`P "Search for 'conference' in a specific calendar:";
+
`P " caled search --calendar work conference";
+
`P "Search for 'workshop' in event descriptions for today only:";
+
`P " caled search --description --today workshop";
+
`P "Search for 'project' in events this month:";
+
`P " caled search --month project";
+
`P "Search for 'workshop' in event descriptions within a date range:";
+
`P
+
" caled search --description --from 2025-03-27 --to 2025-04-01 \
+
workshop";
+
`P "Search for recurring events only:";
+
`P " caled search --recurring meeting";
+
`P "Search for non-recurring events only:";
+
`P " caled search --non-recurring appointment";
+
`P "Find all recurring events:";
+
`P " caled search --recurring";
+
`P "Find all events in a specific calendar:";
+
`P " caled search --calendar work";
+
]
+
in
+
let exit_info =
+
[ Cmd.Exit.info ~doc:"on success." 0; Cmd.Exit.info ~doc:"on error." 1 ]
+
in
+
+
let info = Cmd.info "search" ~doc ~man ~exits:exit_info in
+
Cmd.v info term
+29
caledonia.opam
···
+
opam-version: "2.0"
+
version: "0.1.0"
+
maintainer: "Ryan Gibb <ryan@freumh.org"
+
authors: ["Ryan Gibb <ryan@freumh.org"]
+
homepage: "https://ryan.freumh.org/caledonia.html"
+
bug-reports: "https://github.com/RyanGibb/caledonia/issues"
+
dev-repo: "git+https://github.com/RyanGibb/caledonia.git"
+
license: "MIT"
+
synopsis: "Command-line calendar tool"
+
build: [
+
["dune" "subst"] {pinned}
+
["dune" "build" "-p" name "-j" jobs]
+
]
+
depends: [
+
"ocaml" {>= "4.08.0"}
+
"dune" {>= "3.4"}
+
"icalendar" {>= "0.1.10"}
+
"cmdliner" {>= "1.3.0"}
+
"ptime" {>= "1.2"}
+
"fmt" {>= "0.9.0"}
+
"logs" {>= "0.7.0"}
+
"re" {>= "1.12.0"}
+
"yojson" {>= "2.2.2"}
+
"uuidm" {>= "0.9.9"}
+
"eio" {>= "0.12"}
+
"eio_main" {>= "0.12"}
+
"calendar" {>= "3.0.0"}
+
"alcotest" {>= "1.8.0" & with-test}
+
]
+4
dune-project
···
+
(lang dune 3.4)
+
(name caledonia)
+
(version 0.1.0)
+
(using directory-targets 0.1)
+112
lib/calendar_dir.ml
···
+
open Icalendar
+
+
type collection = Collection of string
+
type calendar_file = { calendar : calendar; file_path : string }
+
+
module CollectionMap = Map.Make (struct
+
type t = collection
+
+
let compare (Collection a) (Collection b) = String.compare a b
+
end)
+
+
type calendar_dir = {
+
path : string;
+
mutable collections : calendar_file list CollectionMap.t;
+
}
+
+
let collection_path calendar_dir (Collection collection_name) =
+
Filename.concat calendar_dir.path collection_name
+
+
let ensure_dir ~fs path =
+
try
+
if not (Sys.file_exists path) then (
+
Eio.Path.(mkdir ~perm:0o755 (fs / path));
+
Ok ())
+
else if not (Sys.is_directory path) then
+
Error (`Msg (Printf.sprintf "%s exists but is not a directory" path))
+
else Ok ()
+
with Eio.Exn.Io _ as exn ->
+
Error
+
(`Msg (Fmt.str "Failed to create directory %s: %a" path Eio.Exn.pp exn))
+
+
let create ~fs path =
+
match ensure_dir ~fs path with
+
| Ok () -> Ok { path; collections = CollectionMap.empty }
+
| Error e -> Error e
+
+
let list_collections ~fs calendar_dir =
+
try
+
let dir_path = Eio.Path.(fs / calendar_dir.path) in
+
let collections =
+
Eio.Path.read_dir dir_path
+
|> List.filter_map (fun file ->
+
let path = Filename.concat calendar_dir.path file in
+
if Sys.is_directory path then Some (Collection file) else None)
+
|> List.sort (fun (Collection a) (Collection b) -> String.compare a b)
+
in
+
Ok collections
+
with Eio.Exn.Io _ as exn ->
+
Error
+
(`Msg
+
(Fmt.str "Failed to list calendar directory %s: %a" calendar_dir.path
+
Eio.Exn.pp exn))
+
+
let get_collection ~fs calendar_dir collection =
+
match CollectionMap.find_opt collection calendar_dir.collections with
+
| Some calendars -> Ok calendars
+
| None -> (
+
let collection_path = collection_path calendar_dir collection in
+
if not (Sys.is_directory collection_path) then Error `Not_found
+
else
+
try
+
let files = Sys.readdir collection_path in
+
let calendar_files =
+
List.filter_map
+
(fun filename ->
+
match Filename.check_suffix filename ".ics" with
+
| false -> None
+
| true -> (
+
let file = Eio.Path.(fs / collection_path / filename) in
+
let _, file_path = file in
+
try
+
let content = Eio.Path.load file in
+
match parse content with
+
| Ok calendar -> Some { calendar; file_path }
+
| Error err ->
+
Printf.eprintf "Failed to parse %s: %s\n%!" file_path
+
err;
+
None
+
with Eio.Exn.Io _ as exn ->
+
Fmt.epr "Failed to read file %s: %a\n%!" file_path
+
Eio.Exn.pp exn;
+
None))
+
(Array.to_list files)
+
in
+
calendar_dir.collections <-
+
CollectionMap.add collection calendar_files calendar_dir.collections;
+
Ok calendar_files
+
with e ->
+
Error
+
(`Msg
+
(Printf.sprintf "Exception processing directory %s: %s"
+
collection_path (Printexc.to_string e))))
+
+
let get_collections ~fs calendar_dir =
+
match list_collections ~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
+
| Ok cal -> process_ids ((id, cal) :: acc) rest
+
| Error `Not_found -> process_ids acc rest
+
| Error (`Msg e) -> Error (`Msg e))
+
in
+
process_ids [] ids
+
with exn ->
+
Error
+
(`Msg
+
(Printf.sprintf "Error getting collections: %s"
+
(Printexc.to_string exn))))
+45
lib/calendar_dir.mli
···
+
(** Functions for managing calendar directories with collections of .ics files
+
*)
+
+
type collection = Collection of string (** The name of the collection. *)
+
+
module CollectionMap : Map.S with type key = collection
+
(** Module for mapping collection names to their calendar files *)
+
+
type calendar_file = { calendar : Icalendar.calendar; file_path : string }
+
(** Record representing a calendar file with its metadata *)
+
+
type calendar_dir
+
(** A directory of collections, where each collection is a subdirectory
+
containing .ics files *)
+
+
val create :
+
fs:[> Eio.Fs.dir_ty ] Eio.Path.t ->
+
string ->
+
(calendar_dir, [> `Msg of string ]) result
+
(** Create a calendar_dir from a directory path. Returns Ok with the
+
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 ->
+
calendar_dir ->
+
(collection list, [> `Msg of string ]) result
+
(** List available collections in the calendar_dir. Returns Ok with the list of
+
collection names if successful, or Error with a message if the directory
+
cannot be read. *)
+
+
val get_collection :
+
fs:[> Eio.Fs.dir_ty ] Eio.Path.t ->
+
calendar_dir ->
+
collection ->
+
(calendar_file list, [> `Msg of string | `Not_found ]) result
+
(** Get all calendar files in a collection. If the collection doesn't exist in
+
the cache, it will be loaded from disk. *)
+
+
val get_collections :
+
fs:[> Eio.Fs.dir_ty ] Eio.Path.t ->
+
calendar_dir ->
+
((collection * calendar_file list) list, [> `Msg of string ]) result
+
(** Get all collections with their calendar files. This will load any
+
collections that haven't been loaded yet. *)
+17
lib/dune
···
+
(library
+
(name caledonia_lib)
+
(public_name caledonia.lib)
+
(libraries
+
icalendar
+
ptime
+
ptime.clock.os
+
calendar
+
fmt
+
logs
+
re
+
yojson
+
uuidm
+
eio
+
eio_main)
+
(preprocess
+
(pps ppx_deriving.show ppx_deriving.eq)))
+140
lib/event.ml
···
+
open Icalendar
+
+
type event_id = string
+
+
type t = {
+
id : event_id;
+
summary : string;
+
start : Ptime.t;
+
end_ : Ptime.t option;
+
location : string option;
+
description : string option;
+
recurrence : recurrence option;
+
collection : Calendar_dir.collection option;
+
}
+
+
type date_error = [ `Msg of string ]
+
+
let ptime_of_datetime = function
+
| `Datetime (`Utc t) -> Ok t
+
| `Datetime (`Local t) -> Ok t
+
(* TODO handle timezones *)
+
| `Datetime (`With_tzid (t, _)) -> Ok t
+
| `Date date -> (
+
match Ptime.of_date_time (date, ((0, 0, 0), 0)) with
+
| Some t -> Ok t
+
| None ->
+
let year, month, day = date in
+
Error (`Msg (Printf.sprintf "Invalid date %d-%d-%d" year month day)))
+
+
let generate_uuid () =
+
let uuid = Uuidm.v4_gen (Random.State.make_self_init ()) () in
+
Uuidm.to_string uuid
+
+
let create ?collection ~summary ~start ?end_ ?location ?description ?recurrence
+
() =
+
{
+
id = generate_uuid ();
+
summary;
+
start;
+
end_;
+
location;
+
description;
+
recurrence;
+
collection;
+
}
+
+
let of_icalendar collection ical_event =
+
let ( let* ) = Result.bind in
+
let* start = ptime_of_datetime (snd ical_event.dtstart) in
+
let* end_ =
+
match ical_event.dtend_or_duration with
+
| Some (`Dtend (_, dt)) -> (
+
match ptime_of_datetime dt with
+
| Ok t -> Ok (Some t)
+
| Error msg -> Error msg)
+
| Some (`Duration (_, span)) -> (
+
match Ptime.add_span start span with
+
| Some t -> Ok (Some t)
+
| None ->
+
Error
+
(`Msg
+
(Printf.sprintf "Invalid duration calculation: %s + %s"
+
(Ptime.to_rfc3339 start)
+
(Printf.sprintf "%.2fs" (Ptime.Span.to_float_s span)))))
+
| None -> Ok None
+
in
+
+
let recurrence = Option.map snd ical_event.rrule in
+
let uid_ref = ref None in
+
let summary_ref = ref None in
+
let location_ref = ref None in
+
let description_ref = ref None in
+
(* TODO we might want to consider using more properties *)
+
List.iter
+
(function
+
| `Summary (_, s) when s <> "" -> summary_ref := Some s
+
| `Location (_, s) -> location_ref := Some s
+
| `Description (_, s) -> description_ref := Some s
+
| `Uid (_, s) when s <> "" -> uid_ref := Some s
+
| _ -> ())
+
ical_event.props;
+
(* TODO these should probably be required *)
+
let summary =
+
match !summary_ref with Some s -> s | None -> "Untitled Event"
+
in
+
let id = match !uid_ref with Some s -> s | None -> generate_uuid () in
+
Ok
+
{
+
id;
+
summary;
+
start;
+
end_;
+
location = !location_ref;
+
description = !description_ref;
+
recurrence;
+
collection = Some collection;
+
}
+
+
(* TODO retain unused parameters *)
+
let to_icalendar t =
+
let now = Ptime_clock.now () in
+
let props = [] in
+
let props = `Summary (Params.empty, t.summary) :: props in
+
let props =
+
match t.location with
+
| Some loc -> `Location (Params.empty, loc) :: props
+
| None -> props
+
in
+
let props =
+
match t.description with
+
| Some desc -> `Description (Params.empty, desc) :: props
+
| None -> props
+
in
+
{
+
dtstamp = (Params.empty, now);
+
uid = (Params.empty, t.id);
+
dtstart = (Params.empty, `Datetime (`Utc t.start));
+
dtend_or_duration =
+
Option.map (fun e -> `Dtend (Params.empty, `Datetime (`Utc e))) t.end_;
+
rrule = Option.map (fun r -> (Params.empty, r)) t.recurrence;
+
props;
+
alarms = [];
+
}
+
+
let get_id t = t.id
+
let get_summary t = t.summary
+
let get_start t = t.start
+
let get_end t = t.end_
+
+
let get_duration t =
+
match t.end_ with
+
| Some e ->
+
let span = Ptime.diff e t.start in
+
Some span
+
| None -> None
+
+
let get_location t = t.location
+
let get_description t = t.description
+
let get_recurrence t = t.recurrence
+
let get_collection t = t.collection
+40
lib/event.mli
···
+
(** Core event functionality and data access *)
+
+
type event_id = string
+
(** Event ID type *)
+
+
type t
+
(** Event type representing a calendar event *)
+
+
type date_error = [ `Msg of string ]
+
(** Type for date-related errors *)
+
+
val create :
+
?collection:Calendar_dir.collection ->
+
summary:string ->
+
start:Ptime.t ->
+
?end_:Ptime.t ->
+
?location:string ->
+
?description:string ->
+
?recurrence:Icalendar.recurrence ->
+
unit ->
+
t
+
(** Create a new event with required properties *)
+
+
val of_icalendar :
+
Calendar_dir.collection -> Icalendar.event -> (t, date_error) result
+
(** Convert an Icalendar event to our event type. Returns Ok with the event or
+
Error with a message. *)
+
+
val to_icalendar : t -> Icalendar.event
+
(** Convert our event type to an Icalendar event *)
+
+
val get_id : t -> event_id
+
val get_summary : t -> string
+
val get_start : t -> Ptime.t
+
val get_end : t -> Ptime.t option
+
val get_duration : t -> Ptime.span option
+
val get_location : t -> string option
+
val get_description : t -> string option
+
val get_recurrence : t -> Icalendar.recurrence option
+
val get_collection : t -> Calendar_dir.collection option
+353
lib/format.ml
···
+
type format = [ `Text | `Json | `Csv | `Ics | `Table | `Sexp ]
+
+
let format_date date =
+
let y, m, d = Ptime.to_date date in
+
let cal_date = CalendarLib.Date.make y m d in
+
let weekday =
+
match CalendarLib.Date.day_of_week cal_date with
+
| CalendarLib.Date.Mon -> "Mon"
+
| CalendarLib.Date.Tue -> "Tue"
+
| CalendarLib.Date.Wed -> "Wed"
+
| CalendarLib.Date.Thu -> "Thu"
+
| CalendarLib.Date.Fri -> "Fri"
+
| CalendarLib.Date.Sat -> "Sat"
+
| CalendarLib.Date.Sun -> "Sun"
+
in
+
Printf.sprintf "%04d-%02d-%02d %s" y m d weekday
+
+
let format_time date =
+
let _, ((h, m, _), _) = Ptime.to_date_time date in
+
Printf.sprintf "%02d:%02d" h m
+
+
let format_datetime date =
+
Printf.sprintf "%s %s" (format_date date) (format_time date)
+
+
let format_event ?(format = `Text) event =
+
let open Event in
+
match format with
+
| `Text ->
+
let summary = get_summary event in
+
let date = format_date (get_start event) in
+
let time = format_time (get_start event) in
+
let end_time_str =
+
match get_end event with
+
| Some e -> Printf.sprintf "-%s" (format_time e)
+
| None -> ""
+
in
+
let location_str =
+
match get_location event with
+
| Some loc when loc <> "" -> Printf.sprintf " @ %s" loc
+
| _ -> ""
+
in
+
let recur_str =
+
match get_recurrence event with
+
| Some _ -> Printf.sprintf " (recurring)"
+
| None -> ""
+
in
+
Printf.sprintf "%s %s%s %s%s%s" date time end_time_str summary
+
location_str recur_str
+
| `Json ->
+
let open Yojson.Safe in
+
let json =
+
`Assoc
+
[
+
("id", `String (get_id event));
+
("summary", `String (get_summary event));
+
("start", `String (format_datetime (get_start event)));
+
( "end",
+
match get_end event with
+
| Some e -> `String (format_datetime e)
+
| None -> `Null );
+
( "location",
+
match get_location event with
+
| Some loc -> `String loc
+
| None -> `Null );
+
( "description",
+
match get_description event with
+
| Some desc -> `String desc
+
| None -> `Null );
+
( "calendar",
+
match get_collection event with
+
| Some (Calendar_dir.Collection cal) -> `String cal
+
| None -> `Null );
+
]
+
in
+
to_string json
+
| `Csv ->
+
let summary = get_summary event in
+
let start = format_datetime (get_start event) in
+
let end_str =
+
match get_end event with Some e -> format_datetime e | None -> ""
+
in
+
let location =
+
match get_location event with Some loc -> loc | None -> ""
+
in
+
let cal_id =
+
match get_collection event with
+
| Some (Calendar_dir.Collection cal) -> cal
+
| None -> ""
+
in
+
Printf.sprintf "\"%s\",\"%s\",\"%s\",\"%s\",\"%s\"" summary start end_str
+
location cal_id
+
| `Ics ->
+
let cal_props = [] in
+
let event_ical = Event.to_icalendar event in
+
Icalendar.to_ics (cal_props, [ `Event event_ical ])
+
| `Table ->
+
let width = 80 in
+
let hr = String.make width '-' in
+
let summary = get_summary event in
+
let start = format_datetime (get_start event) in
+
let end_str =
+
match get_end event with Some e -> format_datetime e | None -> ""
+
in
+
let location =
+
match get_location event with Some loc -> loc | None -> ""
+
in
+
Printf.sprintf
+
"%s\n\
+
| %-20s | %-30s |\n\
+
| %-20s | %-30s |\n\
+
| %-20s | %-30s |\n\
+
| %-20s | %-30s |\n\
+
%s"
+
hr "Summary" summary "Start" start "End" end_str "Location" location hr
+
| `Sexp ->
+
let summary = get_summary event in
+
let start_date, start_time =
+
let date = get_start event in
+
let y, m, d = Ptime.to_date date in
+
let _, ((h, min, s), _) = Ptime.to_date_time date in
+
let cal_date = CalendarLib.Date.make y m d in
+
let dow =
+
match CalendarLib.Date.day_of_week cal_date with
+
| CalendarLib.Date.Mon -> "monday"
+
| CalendarLib.Date.Tue -> "tuesday"
+
| CalendarLib.Date.Wed -> "wednesday"
+
| CalendarLib.Date.Thu -> "thursday"
+
| CalendarLib.Date.Fri -> "friday"
+
| CalendarLib.Date.Sat -> "saturday"
+
| CalendarLib.Date.Sun -> "sunday"
+
in
+
( Printf.sprintf "(%04d %02d %02d %s)" y m d dow,
+
Printf.sprintf "(%02d %02d %02d)" h min s )
+
in
+
let end_str =
+
match get_end event with
+
| Some end_date ->
+
let y, m, d = Ptime.to_date end_date in
+
let _, ((h, min, s), _) = Ptime.to_date_time end_date in
+
let cal_date = CalendarLib.Date.make y m d in
+
let dow =
+
match CalendarLib.Date.day_of_week cal_date with
+
| CalendarLib.Date.Mon -> "monday"
+
| CalendarLib.Date.Tue -> "tuesday"
+
| CalendarLib.Date.Wed -> "wednesday"
+
| CalendarLib.Date.Thu -> "thursday"
+
| CalendarLib.Date.Fri -> "friday"
+
| CalendarLib.Date.Sat -> "saturday"
+
| CalendarLib.Date.Sun -> "sunday"
+
in
+
Printf.sprintf "((%04d %02d %02d %s) (%02d %02d %02d))" y m d dow h
+
min s
+
| None -> "nil"
+
in
+
let location =
+
match get_location event with
+
| Some loc -> Printf.sprintf "\"%s\"" (String.escaped loc)
+
| None -> "nil"
+
in
+
let description =
+
match get_description event with
+
| Some desc -> Printf.sprintf "\"%s\"" (String.escaped desc)
+
| None -> "nil"
+
in
+
let calendar =
+
match get_collection event with
+
| Some (Calendar_dir.Collection cal) ->
+
Printf.sprintf "\"%s\"" (String.escaped cal)
+
| None -> "nil"
+
in
+
let id = get_id event in
+
Printf.sprintf
+
"((:id \"%s\" :summary \"%s\" :start (%s %s) :end %s :location %s \
+
:description %s :calendar %s))"
+
(String.escaped id) (String.escaped summary) start_date start_time
+
end_str location description calendar
+
+
let format_instance ?(format = `Text) instance =
+
match format with
+
| `Text ->
+
let summary = Event.get_summary instance.Recur.event in
+
let date = format_date instance.Recur.start in
+
let time = format_time instance.Recur.start in
+
let end_time_str =
+
match instance.Recur.end_ with
+
| Some e -> Printf.sprintf "-%s" (format_time e)
+
| None -> ""
+
in
+
let location_str =
+
match Event.get_location instance.Recur.event with
+
| Some loc when loc <> "" -> Printf.sprintf " @ %s" loc
+
| _ -> ""
+
in
+
Printf.sprintf "%s %s%s %s%s" date time end_time_str summary location_str
+
| format -> format_event ~format instance.Recur.event
+
+
let format_events ?(format = `Text) events =
+
match format with
+
| `Json ->
+
let json_events =
+
List.map
+
(fun e -> Yojson.Safe.from_string (format_event ~format:`Json e))
+
events
+
in
+
Yojson.Safe.to_string (`List json_events)
+
| `Csv ->
+
"\"Summary\",\"Start\",\"End\",\"Location\",\"Calendar\"\n"
+
^ String.concat "\n"
+
(List.map
+
(fun e ->
+
let summary = Event.get_summary e in
+
let start = format_datetime (Event.get_start e) in
+
let end_str =
+
match Event.get_end e with
+
| Some end_ -> format_datetime end_
+
| None -> ""
+
in
+
let location =
+
match Event.get_location e with Some loc -> loc | None -> ""
+
in
+
let cal_id =
+
match Event.get_collection e with
+
| Some (Calendar_dir.Collection cal) -> cal
+
| None -> ""
+
in
+
Printf.sprintf "\"%s\",\"%s\",\"%s\",\"%s\",\"%s\"" summary start
+
end_str location cal_id)
+
events)
+
| `Sexp ->
+
(* For S-expressions, we want a list of event S-expressions *)
+
"("
+
^ String.concat "\n "
+
(List.map (fun e -> format_event ~format:`Sexp e) events)
+
^ ")"
+
| _ -> String.concat "\n" (List.map (fun e -> format_event ~format e) events)
+
+
let format_instances ?(format = `Text) instances =
+
match format with
+
| `Json ->
+
let json_instances =
+
List.map
+
(fun i ->
+
let e = i.Recur.event in
+
let json = Yojson.Safe.from_string (format_event ~format:`Json e) in
+
match json with
+
| `Assoc fields ->
+
`Assoc
+
(("start", `String (format_datetime i.Recur.start))
+
:: ( "end",
+
match i.Recur.end_ with
+
| Some e -> `String (format_datetime e)
+
| None -> `Null )
+
:: List.filter
+
(fun (k, _) -> k <> "start" && k <> "end")
+
fields)
+
| _ -> json)
+
instances
+
in
+
Yojson.Safe.to_string (`List json_instances)
+
| `Csv ->
+
"\"Summary\",\"Start\",\"End\",\"Location\",\"Calendar\"\n"
+
^ String.concat "\n"
+
(List.map
+
(fun i ->
+
let e = i.Recur.event in
+
let summary = Event.get_summary e in
+
let start = format_datetime i.Recur.start in
+
let end_str =
+
match i.Recur.end_ with
+
| Some end_ -> format_datetime end_
+
| None -> ""
+
in
+
let location =
+
match Event.get_location e with Some loc -> loc | None -> ""
+
in
+
let cal_id =
+
match Event.get_collection e with
+
| Some (Calendar_dir.Collection cal) -> cal
+
| None -> ""
+
in
+
Printf.sprintf "\"%s\",\"%s\",\"%s\",\"%s\",\"%s\"" summary start
+
end_str location cal_id)
+
instances)
+
| `Sexp ->
+
(* Create a list of instance S-expressions *)
+
let format_instance_sexp i =
+
let e = i.Recur.event in
+
let summary = Event.get_summary e in
+
let start_date, start_time =
+
let date = i.Recur.start in
+
let y, m, d = Ptime.to_date date in
+
let _, ((h, min, s), _) = Ptime.to_date_time date in
+
let cal_date = CalendarLib.Date.make y m d in
+
let dow =
+
match CalendarLib.Date.day_of_week cal_date with
+
| CalendarLib.Date.Mon -> "monday"
+
| CalendarLib.Date.Tue -> "tuesday"
+
| CalendarLib.Date.Wed -> "wednesday"
+
| CalendarLib.Date.Thu -> "thursday"
+
| CalendarLib.Date.Fri -> "friday"
+
| CalendarLib.Date.Sat -> "saturday"
+
| CalendarLib.Date.Sun -> "sunday"
+
in
+
( Printf.sprintf "(%04d %02d %02d %s)" y m d dow,
+
Printf.sprintf "(%02d %02d %02d)" h min s )
+
in
+
let end_str =
+
match i.Recur.end_ with
+
| Some end_date ->
+
let y, m, d = Ptime.to_date end_date in
+
let _, ((h, min, s), _) = Ptime.to_date_time end_date in
+
let cal_date = CalendarLib.Date.make y m d in
+
let dow =
+
match CalendarLib.Date.day_of_week cal_date with
+
| CalendarLib.Date.Mon -> "monday"
+
| CalendarLib.Date.Tue -> "tuesday"
+
| CalendarLib.Date.Wed -> "wednesday"
+
| CalendarLib.Date.Thu -> "thursday"
+
| CalendarLib.Date.Fri -> "friday"
+
| CalendarLib.Date.Sat -> "saturday"
+
| CalendarLib.Date.Sun -> "sunday"
+
in
+
Printf.sprintf "((%04d %02d %02d %s) (%02d %02d %02d))" y m d dow
+
h min s
+
| None -> "nil"
+
in
+
let location =
+
match Event.get_location e with
+
| Some loc -> Printf.sprintf "\"%s\"" (String.escaped loc)
+
| None -> "nil"
+
in
+
let description =
+
match Event.get_description e with
+
| Some desc -> Printf.sprintf "\"%s\"" (String.escaped desc)
+
| None -> "nil"
+
in
+
let calendar =
+
match Event.get_collection e with
+
| Some (Calendar_dir.Collection cal) ->
+
Printf.sprintf "\"%s\"" (String.escaped cal)
+
| None -> "nil"
+
in
+
let id = Event.get_id e in
+
Printf.sprintf
+
"((:id \"%s\" :summary \"%s\" :start (%s %s) :end %s :location %s \
+
:description %s :calendar %s))"
+
(String.escaped id) (String.escaped summary) start_date start_time
+
end_str location description calendar
+
in
+
"(" ^ String.concat "\n " (List.map format_instance_sexp instances) ^ ")"
+
| _ ->
+
String.concat "\n"
+
(List.map (fun i -> format_instance ~format i) instances)
+17
lib/format.mli
···
+
(** Functions for formatting various data structures as strings *)
+
+
type format = [ `Text | `Json | `Csv | `Ics | `Table | `Sexp ]
+
(** Format type for output *)
+
+
(** Functions for formatting specific event types *)
+
val format_event : ?format:format -> Event.t -> string
+
(** Format a single event *)
+
+
val format_instance : ?format:format -> Recur.instance -> string
+
(** Format a single event instance *)
+
+
val format_events : ?format:format -> Event.t list -> string
+
(** Format a list of events *)
+
+
val format_instances : ?format:format -> Recur.instance list -> string
+
(** Format a list of event instances *)
+424
lib/query.ml
···
+
type filter =
+
| SummaryContains of string
+
| DescriptionContains of string
+
| LocationContains of string
+
| InCollections of Calendar_dir.collection list
+
| RecurringOnly
+
| NonRecurringOnly
+
| WithId of Event.event_id
+
| And of filter list
+
| Or of filter list
+
| Not of filter
+
+
type sort_order = [ `Ascending | `Descending ]
+
type sort_by = [ `Start | `End | `Summary | `Location | `Calendar ]
+
+
let calendar_to_ptime date =
+
let open CalendarLib in
+
let year = Calendar.year date in
+
let month = Date.int_of_month (Calendar.month date) in
+
let day = Calendar.day_of_month date in
+
let time = Calendar.to_time date in
+
let hour = Time.hour time in
+
let minute = Time.minute time in
+
let second = Time.second time in
+
match
+
Ptime.of_date_time ((year, month, day), ((hour, minute, second), 0))
+
with
+
| Some t -> t
+
| None -> failwith "Invalid date conversion from Calendar to Ptime"
+
+
let ptime_to_calendar ptime =
+
let (year, month, day), ((hour, minute, second), _) =
+
Ptime.to_date_time ptime
+
in
+
let open CalendarLib in
+
let date = Date.make year month day in
+
let time = Time.make hour minute second in
+
Calendar.create date time
+
+
let get_today =
+
ref (fun () ->
+
let today_date = CalendarLib.Date.today () in
+
let midnight = CalendarLib.Time.make 0 0 0 in
+
let today_with_midnight =
+
CalendarLib.Calendar.create today_date midnight
+
in
+
calendar_to_ptime today_with_midnight)
+
+
(* Convert a midnight timestamp to end-of-day (23:59:59) *)
+
let to_end_of_day date =
+
let cal_date = ptime_to_calendar date in
+
let date_only = CalendarLib.Calendar.to_date cal_date in
+
let end_of_day_time = CalendarLib.Time.make 23 59 59 in
+
let end_of_day = CalendarLib.Calendar.create date_only end_of_day_time in
+
calendar_to_ptime end_of_day
+
+
let add_days date days =
+
let cal_date = ptime_to_calendar date in
+
let period = CalendarLib.Calendar.Period.day days in
+
let new_date = CalendarLib.Calendar.add cal_date period in
+
calendar_to_ptime new_date
+
+
let add_weeks date weeks =
+
let cal_date = ptime_to_calendar date in
+
let period = CalendarLib.Calendar.Period.week weeks in
+
let new_date = CalendarLib.Calendar.add cal_date period in
+
calendar_to_ptime new_date
+
+
let add_months date months =
+
let cal_date = ptime_to_calendar date in
+
let period = CalendarLib.Calendar.Period.month months in
+
let new_date = CalendarLib.Calendar.add cal_date period in
+
calendar_to_ptime new_date
+
+
let add_years date years =
+
let cal_date = ptime_to_calendar date in
+
let period = CalendarLib.Calendar.Period.year years in
+
let new_date = CalendarLib.Calendar.add cal_date period in
+
calendar_to_ptime new_date
+
+
let get_start_of_week date =
+
let cal_date = ptime_to_calendar date in
+
let day_of_week = CalendarLib.Calendar.day_of_week cal_date in
+
let days_to_subtract =
+
match day_of_week with
+
| CalendarLib.Date.Mon -> 0
+
| CalendarLib.Date.Tue -> 1
+
| CalendarLib.Date.Wed -> 2
+
| CalendarLib.Date.Thu -> 3
+
| CalendarLib.Date.Fri -> 4
+
| CalendarLib.Date.Sat -> 5
+
| CalendarLib.Date.Sun -> 6
+
in
+
let monday =
+
CalendarLib.Calendar.add cal_date
+
(CalendarLib.Calendar.Period.day (-days_to_subtract))
+
in
+
(* Extract the date part and create a new calendar with midnight time *)
+
let monday_date = CalendarLib.Calendar.to_date monday in
+
let midnight = CalendarLib.Time.make 0 0 0 in
+
let monday_at_midnight = CalendarLib.Calendar.create monday_date midnight in
+
calendar_to_ptime monday_at_midnight
+
+
let get_start_of_current_week () = get_start_of_week (!get_today ())
+
let get_start_of_next_week () = add_days (get_start_of_current_week ()) 7
+
let get_end_of_week date = add_days (get_start_of_week date) 6
+
let get_end_of_current_week () = get_end_of_week (!get_today ())
+
let get_end_of_next_week () = get_end_of_week (get_start_of_next_week ())
+
+
let get_start_of_month date =
+
let cal_date = ptime_to_calendar date in
+
(* Extract year and month from calendar date *)
+
let year = CalendarLib.Calendar.year cal_date in
+
let month = CalendarLib.Calendar.month cal_date in
+
(* Create a date for the first of the month *)
+
let month_int = CalendarLib.Date.int_of_month month in
+
let first_day = CalendarLib.Date.make year month_int 1 in
+
let midnight = CalendarLib.Time.make 0 0 0 in
+
let first_of_month = CalendarLib.Calendar.create first_day midnight in
+
calendar_to_ptime first_of_month
+
+
let get_start_of_current_month () = get_start_of_month (!get_today ())
+
let get_start_of_next_month () = add_months (get_start_of_current_month ()) 1
+
+
let get_end_of_month date =
+
let cal_date = ptime_to_calendar date in
+
let year = CalendarLib.Calendar.year cal_date in
+
let month = CalendarLib.Calendar.month cal_date in
+
let month_int = CalendarLib.Date.int_of_month month in
+
(* Create a calendar for the first of next month *)
+
let next_month_int = if month_int == 12 then 1 else month_int + 1 in
+
let next_month_year = if month_int == 12 then year + 1 else year in
+
let first_of_next_month =
+
CalendarLib.Date.make next_month_year next_month_int 1
+
in
+
let midnight = CalendarLib.Time.make 0 0 0 in
+
let first_of_next_month_cal =
+
CalendarLib.Calendar.create first_of_next_month midnight
+
in
+
(* Subtract one second to get the end of the current month *)
+
let period = CalendarLib.Calendar.Period.second (-1) in
+
let last_of_month = CalendarLib.Calendar.add first_of_next_month_cal period in
+
calendar_to_ptime last_of_month
+
+
let get_end_of_current_month () = get_end_of_month (!get_today ())
+
let get_end_of_next_month () = get_end_of_month (get_start_of_next_month ())
+
+
(* Parse a date string that could be ISO format or a relative expression *)
+
let parse_date_expression 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" -> !get_today ()
+
| "tomorrow" -> add_days (!get_today ()) 1
+
| "yesterday" -> add_days (!get_today ()) (-1)
+
| "this-week" -> (
+
match parameter with
+
| `From -> get_start_of_current_week ()
+
| `To -> get_end_of_current_week ())
+
| "next-week" -> (
+
match parameter with
+
| `From -> get_start_of_next_week ()
+
| `To -> get_end_of_next_week ())
+
| "this-month" -> (
+
match parameter with
+
| `From -> get_start_of_current_month ()
+
| `To -> get_end_of_current_month ())
+
| "next-month" -> (
+
match parameter with
+
| `From -> get_start_of_next_month ()
+
| `To -> get_end_of_next_month ())
+
| _ ->
+
(* Try to parse as ISO date *)
+
if Re.Pcre.pmatch ~rex:iso_date_regex expr then
+
try
+
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 Ptime.of_date_time ((year, month, day), ((0, 0, 0), 0)) with
+
| Some date -> date
+
| None -> failwith (Printf.sprintf "Invalid date: %s" expr)
+
with e ->
+
failwith
+
(Printf.sprintf "Failed to parse ISO date '%s': %s" expr
+
(Printexc.to_string e))
+
(* Try to parse as relative expression +Nd, -Nd, etc. *)
+
else if Re.Pcre.pmatch ~rex:relative_regex expr then
+
try
+
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 () in
+
match unit with
+
| "d" -> add_days today value
+
| "w" -> (
+
let date = add_weeks today value in
+
match parameter with
+
| `From -> get_start_of_week date
+
| `To -> get_end_of_week date)
+
| "m" -> (
+
let date = add_months today value in
+
match parameter with
+
| `From -> get_start_of_month date
+
| `To -> get_end_of_month date)
+
| _ -> failwith (Printf.sprintf "Invalid date unit: %s" unit)
+
with e ->
+
failwith
+
(Printf.sprintf "Failed to parse relative date '%s': %s" expr
+
(Printexc.to_string e))
+
else failwith (Printf.sprintf "Invalid date format: %s" expr)
+
+
let convert_relative_date_formats ~today ~tomorrow ~week ~month =
+
if today then
+
let today_date = !get_today () in
+
(* Set the end date to end-of-day to include all events on that day *)
+
let end_of_today = to_end_of_day today_date in
+
Some (today_date, end_of_today)
+
else if tomorrow then
+
let today = !get_today () in
+
let tomorrow_date = add_days today 1 in
+
(* Set the end date to end-of-day to include all events on that day *)
+
let end_of_tomorrow = to_end_of_day tomorrow_date in
+
Some (tomorrow_date, end_of_tomorrow)
+
else if week then
+
let week_start = get_start_of_current_week () in
+
let week_end_date = add_days week_start 6 in
+
(* Sunday is 6 days from Monday *)
+
(* Set the end date to end-of-day to include all events on Sunday *)
+
let end_of_week = to_end_of_day week_end_date in
+
Some (week_start, end_of_week)
+
else if month then
+
let month_start = get_start_of_current_month () in
+
let month_end = get_end_of_month month_start in
+
Some (month_start, month_end)
+
else None
+
+
let summary_contains text = SummaryContains text
+
let description_contains text = DescriptionContains text
+
let location_contains text = LocationContains text
+
let in_collections ids = InCollections ids
+
let recurring_only () = RecurringOnly
+
let non_recurring_only () = NonRecurringOnly
+
let with_id id = WithId id
+
let and_filter filters = And filters
+
let or_filter filters = Or filters
+
let not_filter filter = Not filter
+
+
let rec matches_filter event = function
+
| SummaryContains text ->
+
let summary = Event.get_summary event in
+
let re = Re.Pcre.regexp ~flags:[ `CASELESS ] (Re.Pcre.quote text) in
+
Re.Pcre.pmatch ~rex:re summary
+
| DescriptionContains text -> (
+
match Event.get_description event with
+
| Some desc ->
+
let re = Re.Pcre.regexp ~flags:[ `CASELESS ] (Re.Pcre.quote text) in
+
Re.Pcre.pmatch ~rex:re desc
+
| None -> false)
+
| LocationContains text -> (
+
match Event.get_location event with
+
| Some loc ->
+
let re = Re.Pcre.regexp ~flags:[ `CASELESS ] (Re.Pcre.quote text) in
+
Re.Pcre.pmatch ~rex:re loc
+
| None -> false)
+
| InCollections ids -> (
+
match Event.get_collection event with
+
| Some id -> List.exists (fun col -> col = id) ids
+
| None -> false)
+
| RecurringOnly -> Event.get_recurrence event <> None
+
| NonRecurringOnly -> Event.get_recurrence event = None
+
| WithId id -> Event.get_id event = id
+
| And filters -> List.for_all (matches_filter event) filters
+
| Or filters -> List.exists (matches_filter event) filters
+
| Not filter -> not (matches_filter event filter)
+
+
let compare_events sort_by order e1 e2 =
+
let compare =
+
match sort_by with
+
| `Start ->
+
let t1 = Event.get_start e1 in
+
let t2 = Event.get_start e2 in
+
Ptime.compare t1 t2
+
| `End -> (
+
match (Event.get_end e1, Event.get_end e2) with
+
| Some t1, Some t2 -> Ptime.compare t1 t2
+
| Some _, None -> 1
+
| None, Some _ -> -1
+
| None, None -> 0)
+
| `Summary -> String.compare (Event.get_summary e1) (Event.get_summary e2)
+
| `Location -> (
+
match (Event.get_location e1, Event.get_location e2) with
+
| Some l1, Some l2 -> String.compare l1 l2
+
| Some _, None -> 1
+
| None, Some _ -> -1
+
| None, None -> 0)
+
| `Calendar -> (
+
match (Event.get_collection e1, Event.get_collection e2) with
+
| Some (Calendar_dir.Collection c1), Some (Calendar_dir.Collection c2)
+
->
+
String.compare c1 c2
+
| Some _, None -> 1
+
| None, Some _ -> -1
+
| None, None -> 0)
+
in
+
match order with `Ascending -> compare | `Descending -> -compare
+
+
let get_all_events ~fs calendar_dir =
+
match Calendar_dir.get_collections ~fs calendar_dir with
+
| Ok collections ->
+
let events =
+
List.concat_map
+
(fun (collection, calendar_files) ->
+
List.concat_map
+
(fun calendar_file ->
+
List.filter_map
+
(function
+
| `Event e -> (
+
match Event.of_icalendar collection e with
+
| Ok event -> Some event
+
| Error (`Msg msg) ->
+
Printf.eprintf "Error parsing event from %s: %s\n%!"
+
calendar_file.Calendar_dir.file_path msg;
+
None)
+
| _ -> None)
+
(snd calendar_file.Calendar_dir.calendar))
+
calendar_files)
+
collections
+
in
+
Ok events
+
| Error e -> Error e
+
+
let query_events ~fs calendar_dir ?filter ?sort_by ?order ?limit () =
+
match get_all_events ~fs calendar_dir with
+
| Ok events ->
+
let filtered_events =
+
match filter with
+
| Some f -> List.filter (fun event -> matches_filter event f) events
+
| None -> events
+
in
+
let sorted_events =
+
match (sort_by, order) with
+
| Some criteria, Some ord ->
+
List.sort (compare_events criteria ord) filtered_events
+
| Some criteria, None ->
+
List.sort (compare_events criteria `Ascending) filtered_events
+
| None, _ ->
+
List.sort (compare_events `Start `Ascending) filtered_events
+
in
+
Ok
+
(match limit with
+
| Some n when n > 0 ->
+
let rec take n lst acc =
+
match (lst, n) with
+
| _, 0 -> List.rev acc
+
| [], _ -> List.rev acc
+
| x :: xs, n -> take (n - 1) xs (x :: acc)
+
in
+
take n sorted_events []
+
| _ -> sorted_events)
+
| Error e -> Error e
+
+
let query ~fs calendar_dir ?filter ~from ~to_ ?sort_by ?order ?limit () =
+
match query_events ~fs calendar_dir ?filter ?sort_by ?order () with
+
| Ok events ->
+
let instances =
+
List.concat_map
+
(fun event -> Recur.expand_event event ~from ~to_)
+
events
+
in
+
let compare_instances criteria ord i1 i2 =
+
match criteria with
+
| `Start ->
+
let c = Ptime.compare i1.Recur.start i2.Recur.start in
+
if ord = `Ascending then c else -c
+
| `End -> (
+
match (i1.Recur.end_, i2.Recur.end_) with
+
| Some t1, Some t2 ->
+
let c = Ptime.compare t1 t2 in
+
if ord = `Ascending then c else -c
+
| Some _, None -> if ord = `Ascending then 1 else -1
+
| None, Some _ -> if ord = `Ascending then -1 else 1
+
| None, None -> 0)
+
| other ->
+
let c = compare_events other ord i1.Recur.event i2.Recur.event in
+
if ord = `Ascending then c else -c
+
in
+
let sorted_instances =
+
match (sort_by, order) with
+
| Some criteria, Some ord ->
+
List.sort (compare_instances criteria ord) instances
+
| Some criteria, None ->
+
List.sort (compare_instances criteria `Ascending) instances
+
| None, _ -> List.sort (compare_instances `Start `Ascending) instances
+
in
+
Ok
+
(match limit with
+
| Some n when n > 0 ->
+
let rec take n lst acc =
+
match (lst, n) with
+
| _, 0 -> List.rev acc
+
| [], _ -> List.rev acc
+
| x :: xs, n -> take (n - 1) xs (x :: acc)
+
in
+
take n sorted_instances []
+
| _ -> sorted_instances)
+
| Error e -> Error e
+152
lib/query.mli
···
+
(** Filter-based searching and querying of calendar events *)
+
+
type filter
+
(** Type representing a query filter *)
+
+
type sort_order = [ `Ascending | `Descending ]
+
(** Type representing the sort order *)
+
+
type sort_by = [ `Start | `End | `Summary | `Location | `Calendar ]
+
(** Type representing sort criteria *)
+
+
(** {1 Date helper functions} *)
+
+
val get_today : (unit -> Ptime.t) ref
+
(** Get the current date at midnight. This is a reference to support testing.
+
Returns the date or raises an exception if the date cannot be determined. *)
+
+
val to_end_of_day : Ptime.t -> Ptime.t
+
(** Converts a date with midnight time (00:00:00) to the same date with
+
end-of-day time (23:59:59). This is particularly useful for making "to"
+
dates in a range inclusive, addressing the common UX expectation that
+
specifying "--to 2025-04-01" would include events occurring on April 1st. *)
+
+
val add_days : Ptime.t -> int -> Ptime.t
+
(** Add specified number of days to a date. Raises an exception if the date
+
cannot be calculated. *)
+
+
val add_weeks : Ptime.t -> int -> Ptime.t
+
(** Add specified number of weeks to a date. Raises an exception if the date
+
cannot be calculated. *)
+
+
val add_months : Ptime.t -> int -> Ptime.t
+
(** Add specified number of months to a date. Raises an exception if the date
+
cannot be calculated. *)
+
+
val add_years : Ptime.t -> int -> Ptime.t
+
(** Add specified number of months to a date. Raises an exception if the date
+
cannot be calculated. *)
+
+
val get_start_of_week : Ptime.t -> Ptime.t
+
(** Get the start of the week (Monday) for the given date. Raises an exception
+
if the date cannot be calculated. *)
+
+
val get_start_of_current_week : unit -> Ptime.t
+
(** Get the start of the current week. Raises an exception if the date cannot be
+
calculated. *)
+
+
val get_start_of_next_week : unit -> Ptime.t
+
(** Get the start of next week. Raises an exception if the date cannot be
+
calculated. *)
+
+
val get_end_of_week : Ptime.t -> Ptime.t
+
(** Get the end of the week (Monday) for the given date. Raises an exception if
+
the date cannot be calculated. *)
+
+
val get_end_of_current_week : unit -> Ptime.t
+
(** Get the end of the current week. Raises an exception if the date cannot be
+
calculated. *)
+
+
val get_end_of_next_week : unit -> Ptime.t
+
(** Get the end of next week. Raises an exception if the date cannot be
+
calculated. *)
+
+
val get_start_of_month : Ptime.t -> Ptime.t
+
(** Get the start of the month for the given date. Raises an exception if the
+
date cannot be calculated. *)
+
+
val get_start_of_current_month : unit -> Ptime.t
+
(** Get the start of the current month. Raises an exception if the date cannot
+
be calculated. *)
+
+
val get_start_of_next_month : unit -> Ptime.t
+
(** Get the start of next month. Raises an exception if the date cannot be
+
calculated. *)
+
+
val get_end_of_current_month : unit -> Ptime.t
+
(** Get the end of the current month. Raises an exception if the date cannot be
+
calculated. *)
+
+
val get_end_of_next_month : unit -> Ptime.t
+
(** Get the end of next month. Raises an exception if the date cannot be
+
calculated. *)
+
+
val get_end_of_month : Ptime.t -> Ptime.t
+
(** Get the end of the month for the given date. Raises an exception if the date
+
cannot be calculated. *)
+
+
val parse_date_expression : string -> [ `To | `From ] -> Ptime.t
+
(** Parse a date string that could be ISO format (YYYY-MM-DD) or a relative
+
expression. Raises an exception if the date cannot be parsed.
+
+
Supported formats:
+
- ISO format: "YYYY-MM-DD"
+
- Relative expressions:
+
- "today" - Current day
+
- "tomorrow" - Next day
+
- "yesterday" - Previous day
+
- "this-week" - Start of current week
+
- "next-week" - Start of next week
+
- "this-month" - Start of current month
+
- "next-month" - Start of next month
+
- "+Nd" - N days from today (e.g., "+7d" for a week from today)
+
- "-Nd" - N days before today (e.g., "-7d" for a week ago)
+
- "+Nw" - N weeks from today
+
- "+Nm" - N months from today *)
+
+
val convert_relative_date_formats :
+
today:bool ->
+
tomorrow:bool ->
+
week:bool ->
+
month:bool ->
+
(Ptime.t * Ptime.t) option
+
(** Converts relative date formats to determine from/to_ dates. Returns a tuple
+
of (start_date, end_date) or raises an exception if the dates could not be
+
determined. **)
+
+
(** {1 Filter creation} *)
+
+
val summary_contains : string -> filter
+
val description_contains : string -> filter
+
val location_contains : string -> filter
+
val in_collections : Calendar_dir.collection 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
+
(** Filter composition *)
+
+
val or_filter : filter list -> filter
+
val not_filter : filter -> filter
+
+
val query :
+
fs:[> Eio.Fs.dir_ty ] Eio.Path.t ->
+
Calendar_dir.calendar_dir ->
+
?filter:filter ->
+
from:Ptime.t option ->
+
to_:Ptime.t ->
+
?sort_by:sort_by ->
+
?order:sort_order ->
+
?limit:int ->
+
unit ->
+
(Recur.instance list, [> `Msg of string ]) result
+
(** Find events with expansion of recurring events. Returns Ok with the list of
+
instances, or Error with a message. *)
+
+
(* Test-only helper functions *)
+
val matches_filter : Event.t -> filter -> bool
+
(** Check if an event matches the given filter *)
+
+
val compare_events : sort_by -> sort_order -> Event.t -> Event.t -> int
+
(** Compare two events based on the sort criteria and order *)
+42
lib/recur.ml
···
+
open Icalendar
+
+
type instance = { event : Event.t; start : Ptime.t; end_ : Ptime.t option }
+
+
let clone_with_time original start =
+
let duration = Event.get_duration original in
+
let end_ =
+
match duration with Some span -> Ptime.add_span start span | None -> None
+
in
+
{ event = original; start; end_ }
+
+
let expand_event event ~from ~to_ =
+
let rule = Event.get_recurrence event in
+
match rule with
+
(* If there's no recurrence we just return the original event. *)
+
| None ->
+
let start = Event.get_start event in
+
(* Include the original event instance only if it falls within the query range *)
+
if
+
Ptime.compare start to_ <= 0
+
&& match from with Some f -> Ptime.compare start f >= 0 | None -> true
+
then [ clone_with_time event start ]
+
else []
+
(* We return all instances within the range, regardless of whether the original
+
event instance was included. This ensures recurring events that start before
+
the query range but have instances within it are properly included. *)
+
| Some rule ->
+
let rec collect generator acc =
+
match generator () with
+
| None -> List.rev acc
+
| Some date ->
+
if Ptime.compare date to_ > 0 then List.rev acc
+
else if
+
match from with
+
| Some f -> Ptime.compare date f < 0
+
| None -> false
+
then collect generator acc
+
else collect generator (clone_with_time event date :: acc)
+
in
+
let start_date = Event.get_start event in
+
let generator = recur_dates start_date rule in
+
collect generator []
+8
lib/recur.mli
···
+
type instance = { event : Event.t; start : Ptime.t; end_ : Ptime.t option }
+
(** Instances of recurring events with adjusted start/end times *)
+
+
val expand_event :
+
Event.t -> from:Ptime.t option -> to_:Ptime.t -> instance list
+
(** Generates all instances of an event within a date range, including the
+
original and recurrences. If the event result is an Error, returns an empty
+
list. *)
+13
test/calendar/example/event.ics
···
+
BEGIN:VCALENDAR
+
VERSION:2.0
+
PRODID:-//Caledonia Test Suite//EN
+
BEGIN:VEVENT
+
UID:test-event@caledonia.test
+
DTSTAMP:20250329T000000Z
+
DTSTART:20250327T090000Z
+
DTEND:20250327T100000Z
+
SUMMARY:Test Event
+
LOCATION:Test Location
+
DESCRIPTION:This is a test event for testing the Caledonia calendar library.
+
END:VEVENT
+
END:VCALENDAR
+13
test/calendar/example/other-event.ics
···
+
BEGIN:VCALENDAR
+
VERSION:2.0
+
PRODID:-//Caledonia Test Suite//EN
+
BEGIN:VEVENT
+
UID:other-test-event@caledonia.test
+
DTSTAMP:20250329T000000Z
+
DTSTART:20250329T090000Z
+
DTEND:20250329T100000Z
+
SUMMARY:Other Test Event
+
LOCATION:Test Location
+
DESCRIPTION:This is a test event for testing the Caledonia calendar library.
+
END:VEVENT
+
END:VCALENDAR
+14
test/calendar/recurrence/10_weekly_count10.ics
···
+
BEGIN:VCALENDAR
+
VERSION:2.0
+
PRODID:-//Caledonia Test Suite//EN
+
BEGIN:VEVENT
+
UID:weekly-count10-recur@caledonia.test
+
DTSTAMP:20250329T000000Z
+
DTSTART:20250317T120000Z
+
DTEND:20250317T130000Z
+
SUMMARY:Recurring Event
+
LOCATION:Weekly Meeting Room
+
DESCRIPTION:Weekly event that repeats exactly 10 times
+
RRULE:FREQ=WEEKLY;COUNT=10
+
END:VEVENT
+
END:VCALENDAR
+13
test/calendar/recurrence/11_daily_until.ics
···
+
BEGIN:VCALENDAR
+
VERSION:2.0
+
PRODID:-//Caledonia Test Suite//EN
+
BEGIN:VEVENT
+
UID:daily-until-recur@caledonia.test
+
DTSTAMP:20250329T000000Z
+
DTSTART:20250501T100000Z
+
DTEND:20250501T110000Z
+
SUMMARY:Daily Until May 15
+
DESCRIPTION:Daily event that repeats until a specific date
+
RRULE:FREQ=DAILY;UNTIL=20250515T000000Z
+
END:VEVENT
+
END:VCALENDAR
+13
test/calendar/recurrence/12_weekly_until.ics
···
+
BEGIN:VCALENDAR
+
VERSION:2.0
+
PRODID:-//Caledonia Test Suite//EN
+
BEGIN:VEVENT
+
UID:weekly-until-recur@caledonia.test
+
DTSTAMP:20250329T000000Z
+
DTSTART:20250501T120000Z
+
DTEND:20250501T130000Z
+
SUMMARY:Weekly Until June 30
+
DESCRIPTION:Weekly event that repeats until a specific date
+
RRULE:FREQ=WEEKLY;UNTIL=20250630T000000Z
+
END:VEVENT
+
END:VCALENDAR
+13
test/calendar/recurrence/13_weekly_monday_wednesday.ics
···
+
BEGIN:VCALENDAR
+
VERSION:2.0
+
PRODID:-//Caledonia Test Suite//EN
+
BEGIN:VEVENT
+
UID:weekly-mon-wed-recur@caledonia.test
+
DTSTAMP:20250329T000000Z
+
DTSTART:20250324T140000Z
+
DTEND:20250324T150000Z
+
SUMMARY:Weekly on Monday and Wednesday
+
DESCRIPTION:Weekly event that occurs on Monday and Wednesday
+
RRULE:FREQ=WEEKLY;BYDAY=MO,WE
+
END:VEVENT
+
END:VCALENDAR
+13
test/calendar/recurrence/14_weekly_weekends.ics
···
+
BEGIN:VCALENDAR
+
VERSION:2.0
+
PRODID:-//Caledonia Test Suite//EN
+
BEGIN:VEVENT
+
UID:weekly-weekends-recur@caledonia.test
+
DTSTAMP:20250329T000000Z
+
DTSTART:20250501T160000Z
+
DTEND:20250501T170000Z
+
SUMMARY:Weekly on Weekends
+
DESCRIPTION:Weekly event that occurs on Saturday and Sunday
+
RRULE:FREQ=WEEKLY;BYDAY=SA,SU
+
END:VEVENT
+
END:VCALENDAR
+13
test/calendar/recurrence/15_monthly_specific_day.ics
···
+
BEGIN:VCALENDAR
+
VERSION:2.0
+
PRODID:-//Caledonia Test Suite//EN
+
BEGIN:VEVENT
+
UID:monthly-15th-recur@caledonia.test
+
DTSTAMP:20250329T000000Z
+
DTSTART:20250515T100000Z
+
DTEND:20250515T110000Z
+
SUMMARY:Monthly on the 15th
+
DESCRIPTION:Monthly event occurring on the 15th of each month
+
RRULE:FREQ=MONTHLY;BYMONTHDAY=15
+
END:VEVENT
+
END:VCALENDAR
+13
test/calendar/recurrence/16_monthly_second_monday.ics
···
+
BEGIN:VCALENDAR
+
VERSION:2.0
+
PRODID:-//Caledonia Test Suite//EN
+
BEGIN:VEVENT
+
UID:monthly-2nd-monday-recur@caledonia.test
+
DTSTAMP:20250329T000000Z
+
DTSTART:20250513T120000Z
+
DTEND:20250513T130000Z
+
SUMMARY:Monthly on Second Monday
+
DESCRIPTION:Monthly event occurring on the second Monday of each month
+
RRULE:FREQ=MONTHLY;BYDAY=2MO
+
END:VEVENT
+
END:VCALENDAR
+13
test/calendar/recurrence/17_monthly_last_day.ics
···
+
BEGIN:VCALENDAR
+
VERSION:2.0
+
PRODID:-//Caledonia Test Suite//EN
+
BEGIN:VEVENT
+
UID:monthly-last-day-recur@caledonia.test
+
DTSTAMP:20250329T000000Z
+
DTSTART:20250531T140000Z
+
DTEND:20250531T150000Z
+
SUMMARY:Monthly on Last Day
+
DESCRIPTION:Monthly event occurring on the last day of each month
+
RRULE:FREQ=MONTHLY;BYMONTHDAY=-1
+
END:VEVENT
+
END:VCALENDAR
+13
test/calendar/recurrence/18_yearly_specific_date.ics
···
+
BEGIN:VCALENDAR
+
VERSION:2.0
+
PRODID:-//Caledonia Test Suite//EN
+
BEGIN:VEVENT
+
UID:yearly-april15-recur@caledonia.test
+
DTSTAMP:20250329T000000Z
+
DTSTART:20250415T160000Z
+
DTEND:20250415T170000Z
+
SUMMARY:Yearly on April 15
+
DESCRIPTION:Yearly event occurring on April 15th each year
+
RRULE:FREQ=YEARLY;BYMONTH=4;BYMONTHDAY=15
+
END:VEVENT
+
END:VCALENDAR
+13
test/calendar/recurrence/19_yearly_mothers_day.ics
···
+
BEGIN:VCALENDAR
+
VERSION:2.0
+
PRODID:-//Caledonia Test Suite//EN
+
BEGIN:VEVENT
+
UID:yearly-mothers-day-recur@caledonia.test
+
DTSTAMP:20250329T000000Z
+
DTSTART:20250512T100000Z
+
DTEND:20250512T110000Z
+
SUMMARY:Mother's Day (2nd Sunday in May)
+
DESCRIPTION:Yearly event on the second Sunday in May (Mother's Day in the US)
+
RRULE:FREQ=YEARLY;BYMONTH=5;BYDAY=2SU
+
END:VEVENT
+
END:VCALENDAR
+13
test/calendar/recurrence/1_daily.ics
···
+
BEGIN:VCALENDAR
+
VERSION:2.0
+
PRODID:-//Caledonia Test Suite//EN
+
BEGIN:VEVENT
+
UID:daily-recur@caledonia.test
+
DTSTAMP:20250329T000000Z
+
DTSTART:20250327T100000Z
+
DTEND:20250327T110000Z
+
SUMMARY:Daily Recurring Event
+
DESCRIPTION:Basic daily recurring event
+
RRULE:FREQ=DAILY
+
END:VEVENT
+
END:VCALENDAR
+13
test/calendar/recurrence/20_complex_weekdays_months.ics
···
+
BEGIN:VCALENDAR
+
VERSION:2.0
+
PRODID:-//Caledonia Test Suite//EN
+
BEGIN:VEVENT
+
UID:complex-weekdays-months-recur@caledonia.test
+
DTSTAMP:20250329T000000Z
+
DTSTART:20250507T120000Z
+
DTEND:20250507T130000Z
+
SUMMARY:Tuesday/Thursday in Summer
+
DESCRIPTION:Weekly on Tuesday and Thursday, but only in Jun, Jul, Aug
+
RRULE:FREQ=WEEKLY;BYDAY=TU,TH;BYMONTH=6,7,8
+
END:VEVENT
+
END:VCALENDAR
+13
test/calendar/recurrence/21_complex_multiple_monthdays.ics
···
+
BEGIN:VCALENDAR
+
VERSION:2.0
+
PRODID:-//Caledonia Test Suite//EN
+
BEGIN:VEVENT
+
UID:complex-monthdays-recur@caledonia.test
+
DTSTAMP:20250329T000000Z
+
DTSTART:20250501T140000Z
+
DTEND:20250501T150000Z
+
SUMMARY:Monthly on 1st, 15th, Last Day
+
DESCRIPTION:Monthly event occurring on 1st, 15th, and last day of each month
+
RRULE:FREQ=MONTHLY;BYMONTHDAY=1,15,-1
+
END:VEVENT
+
END:VCALENDAR
+15
test/calendar/recurrence/22_with_exdate.ics
···
+
BEGIN:VCALENDAR
+
VERSION:2.0
+
PRODID:-//Caledonia Test Suite//EN
+
BEGIN:VEVENT
+
UID:with-exdate-recur@caledonia.test
+
DTSTAMP:20250329T000000Z
+
DTSTART:20250501T160000Z
+
DTEND:20250501T170000Z
+
SUMMARY:Weekly with Exclusions
+
DESCRIPTION:Weekly event with specific dates excluded
+
RRULE:FREQ=WEEKLY
+
EXDATE:20250515T160000Z
+
EXDATE:20250529T160000Z
+
END:VEVENT
+
END:VCALENDAR
+13
test/calendar/recurrence/23_dst_transition.ics
···
+
BEGIN:VCALENDAR
+
VERSION:2.0
+
PRODID:-//Caledonia Test Suite//EN
+
BEGIN:VEVENT
+
UID:dst-transition-recur@caledonia.test
+
DTSTAMP:20250329T000000Z
+
DTSTART:20251027T010000Z
+
DTEND:20251027T020000Z
+
SUMMARY:Weekly over DST Transition
+
DESCRIPTION:Weekly event that spans DST transition date
+
RRULE:FREQ=WEEKLY;COUNT=4
+
END:VEVENT
+
END:VCALENDAR
+13
test/calendar/recurrence/24_long_interval.ics
···
+
BEGIN:VCALENDAR
+
VERSION:2.0
+
PRODID:-//Caledonia Test Suite//EN
+
BEGIN:VEVENT
+
UID:long-interval-recur@caledonia.test
+
DTSTAMP:20250329T000000Z
+
DTSTART:20250501T080000Z
+
DTEND:20250501T090000Z
+
SUMMARY:Every 100 Days
+
DESCRIPTION:Event that recurs every 100 days
+
RRULE:FREQ=DAILY;INTERVAL=100
+
END:VEVENT
+
END:VCALENDAR
+13
test/calendar/recurrence/25_leap_day.ics
···
+
BEGIN:VCALENDAR
+
VERSION:2.0
+
PRODID:-//Caledonia Test Suite//EN
+
BEGIN:VEVENT
+
UID:leap-day-recur@caledonia.test
+
DTSTAMP:20250329T000000Z
+
DTSTART:20280229T090000Z
+
DTEND:20280229T100000Z
+
SUMMARY:Yearly on February 29
+
DESCRIPTION:Yearly event on leap day (February 29)
+
RRULE:FREQ=YEARLY;BYMONTH=2;BYMONTHDAY=29
+
END:VEVENT
+
END:VCALENDAR
+13
test/calendar/recurrence/26_weekly_wkst.ics
···
+
BEGIN:VCALENDAR
+
VERSION:2.0
+
PRODID:-//Caledonia Test Suite//EN
+
BEGIN:VEVENT
+
UID:weekly-wkst-recur@caledonia.test
+
DTSTAMP:20250329T000000Z
+
DTSTART:20250501T080000Z
+
DTEND:20250501T090000Z
+
SUMMARY:Weekly with Monday Week Start
+
DESCRIPTION:Weekly event with explicit week start day (Monday)
+
RRULE:FREQ=WEEKLY;WKST=MO;COUNT=4
+
END:VEVENT
+
END:VCALENDAR
+13
test/calendar/recurrence/27_monthly_nth_weekday.ics
···
+
BEGIN:VCALENDAR
+
VERSION:2.0
+
PRODID:-//Caledonia Test Suite//EN
+
BEGIN:VEVENT
+
UID:monthly-third-sunday-recur@caledonia.test
+
DTSTAMP:20250329T000000Z
+
DTSTART:20250519T090000Z
+
DTEND:20250519T100000Z
+
SUMMARY:Monthly on Third Sunday
+
DESCRIPTION:Monthly event occurring on the third Sunday of each month
+
RRULE:FREQ=MONTHLY;BYDAY=3SU
+
END:VEVENT
+
END:VCALENDAR
+13
test/calendar/recurrence/28_yearly_historical.ics
···
+
BEGIN:VCALENDAR
+
VERSION:2.0
+
PRODID:-//Caledonia Test Suite//EN
+
BEGIN:VEVENT
+
UID:yearly-historical-recur@caledonia.test
+
DTSTAMP:20250329T000000Z
+
DTSTART:20000101T100000Z
+
DTEND:20000101T110000Z
+
SUMMARY:Historical Yearly Series
+
DESCRIPTION:Yearly events in the past, ending in 2010
+
RRULE:FREQ=YEARLY;UNTIL=20101231T235959Z
+
END:VEVENT
+
END:VCALENDAR
+13
test/calendar/recurrence/29_monthly_bymonth.ics
···
+
BEGIN:VCALENDAR
+
VERSION:2.0
+
PRODID:-//Caledonia Test Suite//EN
+
BEGIN:VEVENT
+
UID:monthly-specific-months-recur@caledonia.test
+
DTSTAMP:20250329T000000Z
+
DTSTART:20250301T110000Z
+
DTEND:20250301T120000Z
+
SUMMARY:Monthly in Mar, Jun, Sep, Dec
+
DESCRIPTION:Monthly event occurring only in specific months of the year
+
RRULE:FREQ=MONTHLY;BYMONTH=3,6,9,12
+
END:VEVENT
+
END:VCALENDAR
+13
test/calendar/recurrence/2_weekly.ics
···
+
BEGIN:VCALENDAR
+
VERSION:2.0
+
PRODID:-//Caledonia Test Suite//EN
+
BEGIN:VEVENT
+
UID:weekly-recur@caledonia.test
+
DTSTAMP:20250329T000000Z
+
DTSTART:20250327T120000Z
+
DTEND:20250327T130000Z
+
SUMMARY:Weekly Recurring Event
+
DESCRIPTION:Basic weekly recurring event
+
RRULE:FREQ=WEEKLY
+
END:VEVENT
+
END:VCALENDAR
+13
test/calendar/recurrence/30_fourth_weekday.ics
···
+
BEGIN:VCALENDAR
+
VERSION:2.0
+
PRODID:-//Caledonia Test Suite//EN
+
BEGIN:VEVENT
+
UID:yearly-fourth-sunday-recur@caledonia.test
+
DTSTAMP:20250329T000000Z
+
DTSTART:20251027T120000Z
+
DTEND:20251027T130000Z
+
SUMMARY:Yearly on Fourth Sunday in October
+
DESCRIPTION:Yearly event on the fourth Sunday in October
+
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=4SU
+
END:VEVENT
+
END:VCALENDAR
+13
test/calendar/recurrence/3_monthly.ics
···
+
BEGIN:VCALENDAR
+
VERSION:2.0
+
PRODID:-//Caledonia Test Suite//EN
+
BEGIN:VEVENT
+
UID:monthly-recur@caledonia.test
+
DTSTAMP:20250329T000000Z
+
DTSTART:20250501T140000Z
+
DTEND:20250501T150000Z
+
SUMMARY:Monthly Recurring Event
+
DESCRIPTION:Basic monthly recurring event
+
RRULE:FREQ=MONTHLY
+
END:VEVENT
+
END:VCALENDAR
+13
test/calendar/recurrence/4_yearly.ics
···
+
BEGIN:VCALENDAR
+
VERSION:2.0
+
PRODID:-//Caledonia Test Suite//EN
+
BEGIN:VEVENT
+
UID:yearly-recur@caledonia.test
+
DTSTAMP:20250329T000000Z
+
DTSTART:20250501T160000Z
+
DTEND:20250501T170000Z
+
SUMMARY:Yearly Recurring Event
+
DESCRIPTION:Basic yearly recurring event
+
RRULE:FREQ=YEARLY
+
END:VEVENT
+
END:VCALENDAR
+13
test/calendar/recurrence/5_every_2_days.ics
···
+
BEGIN:VCALENDAR
+
VERSION:2.0
+
PRODID:-//Caledonia Test Suite//EN
+
BEGIN:VEVENT
+
UID:every2days-recur@caledonia.test
+
DTSTAMP:20250329T000000Z
+
DTSTART:20250501T100000Z
+
DTEND:20250501T110000Z
+
SUMMARY:Every 2 Days
+
DESCRIPTION:Event recurring every 2 days with interval
+
RRULE:FREQ=DAILY;INTERVAL=2
+
END:VEVENT
+
END:VCALENDAR
+13
test/calendar/recurrence/6_every_3_weeks.ics
···
+
BEGIN:VCALENDAR
+
VERSION:2.0
+
PRODID:-//Caledonia Test Suite//EN
+
BEGIN:VEVENT
+
UID:every3weeks-recur@caledonia.test
+
DTSTAMP:20250329T000000Z
+
DTSTART:20250501T120000Z
+
DTEND:20250501T130000Z
+
SUMMARY:Every 3 Weeks
+
DESCRIPTION:Event recurring every 3 weeks with interval
+
RRULE:FREQ=WEEKLY;INTERVAL=3
+
END:VEVENT
+
END:VCALENDAR
+13
test/calendar/recurrence/7_bimonthly.ics
···
+
BEGIN:VCALENDAR
+
VERSION:2.0
+
PRODID:-//Caledonia Test Suite//EN
+
BEGIN:VEVENT
+
UID:bimonthly-recur@caledonia.test
+
DTSTAMP:20250329T000000Z
+
DTSTART:20250501T140000Z
+
DTEND:20250501T150000Z
+
SUMMARY:Every Other Month
+
DESCRIPTION:Event recurring every other month with interval
+
RRULE:FREQ=MONTHLY;INTERVAL=2
+
END:VEVENT
+
END:VCALENDAR
+13
test/calendar/recurrence/8_biennial.ics
···
+
BEGIN:VCALENDAR
+
VERSION:2.0
+
PRODID:-//Caledonia Test Suite//EN
+
BEGIN:VEVENT
+
UID:biennial-recur@caledonia.test
+
DTSTAMP:20250329T000000Z
+
DTSTART:20250501T160000Z
+
DTEND:20250501T170000Z
+
SUMMARY:Every 2 Years
+
DESCRIPTION:Event recurring every 2 years with interval
+
RRULE:FREQ=YEARLY;INTERVAL=2
+
END:VEVENT
+
END:VCALENDAR
+13
test/calendar/recurrence/9_daily_count5.ics
···
+
BEGIN:VCALENDAR
+
VERSION:2.0
+
PRODID:-//Caledonia Test Suite//EN
+
BEGIN:VEVENT
+
UID:daily-count5-recur@caledonia.test
+
DTSTAMP:20250329T000000Z
+
DTSTART:20250501T100000Z
+
DTEND:20250501T110000Z
+
SUMMARY:Daily 5 Times
+
DESCRIPTION:Daily event that repeats exactly 5 times
+
RRULE:FREQ=DAILY;COUNT=5
+
END:VEVENT
+
END:VCALENDAR
+5
test/dune
···
+
(tests
+
(names test_calendar_dir test_query test_recur)
+
(libraries caledonia_lib alcotest str ptime)
+
(deps
+
(source_tree calendar)))
+85
test/test_calendar_dir.ml
···
+
(* Test the Calendar_dir.module *)
+
+
open Caledonia_lib
+
+
let calendar_dir_path = Filename.concat (Sys.getcwd ()) "calendar"
+
+
let test_list_collections ~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 ->
+
Alcotest.(check int)
+
"Should find two collections" 2 (List.length collections);
+
Alcotest.(check bool)
+
"example should be in the list" true
+
(List.exists
+
(fun c -> c = Calendar_dir.Collection "example")
+
collections);
+
Alcotest.(check bool)
+
"recurrence should be in the list" true
+
(List.exists
+
(fun c -> c = Calendar_dir.Collection "recurrence")
+
collections);
+
()
+
+
let test_get_collection ~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
+
(Calendar_dir.Collection "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)
+
+
let test_get_collections ~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.get_collections ~fs calendar_dir with
+
| Ok collections ->
+
Alcotest.(check int)
+
"Should find two collections" 2 (List.length collections);
+
Alcotest.(check bool)
+
"example should be in the results" true
+
(List.exists
+
(fun (id, _) -> id = Calendar_dir.Collection "example")
+
collections);
+
Alcotest.(check bool)
+
"recurrence should be in the results" true
+
(List.exists
+
(fun (id, _) -> id = Calendar_dir.Collection "recurrence")
+
collections);
+
()
+
| Error e ->
+
let msg =
+
match e with `Msg m -> m | `Not_found -> "Collection not found"
+
in
+
Alcotest.fail ("Error getting collections: " ^ 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_collections ~fs);
+
]
+
+
let () =
+
Eio_main.run @@ fun env ->
+
let fs = Eio.Stdenv.fs env in
+
Alcotest.run "Calendar_dir.Tests" [ ("calendar", calendar_tests fs) ]
+379
test/test_query.ml
···
+
(* Test the Query module *)
+
+
open Caledonia_lib
+
+
(* Test for date expression functionality *)
+
+
(* 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 () =
+
(Query.get_today := fun () -> fixed_date);
+
fixed_date
+
+
let test_parse_date_expression () =
+
let test_expr expr parameter expected =
+
try
+
let result = Query.parse_date_expression expr parameter in
+
let result_str =
+
let y, m, d = Ptime.to_date result in
+
Printf.sprintf "%04d-%02d-%02d" y m d
+
in
+
Alcotest.(check string)
+
(Printf.sprintf "'%s' %s should parse to '%s'" expr
+
(match parameter with `From -> "from" | `To -> "to")
+
expected)
+
expected result_str
+
with Failure msg ->
+
Alcotest.fail (Printf.sprintf "Failed to parse '%s': %s" expr msg)
+
in
+
test_expr "today" `From "2025-03-27";
+
test_expr "today" `To "2025-03-27";
+
test_expr "tomorrow" `From "2025-03-28";
+
test_expr "tomorrow" `To "2025-03-28";
+
test_expr "yesterday" `From "2025-03-26";
+
test_expr "yesterday" `To "2025-03-26";
+
test_expr "this-week" `From "2025-03-24";
+
test_expr "this-week" `To "2025-03-30";
+
test_expr "next-week" `From "2025-03-31";
+
test_expr "next-week" `To "2025-04-06";
+
test_expr "this-month" `From "2025-03-01";
+
test_expr "this-month" `To "2025-03-31";
+
test_expr "next-month" `From "2025-04-01";
+
test_expr "next-month" `To "2025-04-30";
+
test_expr "+7d" `From "2025-04-03";
+
test_expr "+7d" `To "2025-04-03";
+
test_expr "-7d" `From "2025-03-20";
+
test_expr "-7d" `To "2025-03-20";
+
test_expr "+2w" `From "2025-04-07";
+
test_expr "+2w" `To "2025-04-13";
+
test_expr "+1m" `From "2025-04-01";
+
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";
+
(try
+
let _ = Query.parse_date_expression "invalid-format" `From in
+
Alcotest.fail "Should have raised an exception for invalid format"
+
with Failure msg ->
+
Alcotest.(check bool)
+
"Invalid format should raise exception with appropriate message" true
+
(String.length msg > 0));
+
()
+
+
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 instances ->
+
Alcotest.(check int) "Should find events" 792 (List.length instances);
+
let test_event =
+
List.find_opt
+
(fun instance ->
+
Event.get_summary instance.Recur.event = "Test Event")
+
instances
+
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 instances ->
+
let recurring_instances =
+
List.filter
+
(fun instance ->
+
Event.get_summary instance.Recur.event = "Recurring Event")
+
instances
+
in
+
Alcotest.(check bool)
+
"Should find multiple recurring event instances" true
+
(List.length recurring_instances > 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 instances ->
+
Alcotest.(check int)
+
"Should find event with 'Test' in summary" 2 (List.length instances)
+
| Error _ -> Alcotest.fail "Error querying events");
+
let filter = Query.location_contains "Weekly" in
+
(match Query.query ~fs calendar_dir ~from ~to_ ~filter () with
+
| Ok instances ->
+
Alcotest.(check int)
+
"Should find event with 'Weekly' in location" 10 (List.length instances)
+
| 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 instances ->
+
Alcotest.(check int)
+
"Should find events matching combined and criteria" 2
+
(List.length instances)
+
| 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 instances ->
+
Alcotest.(check int)
+
"Should find events matching combined or criteria" 12
+
(List.length instances)
+
| 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 = Calendar_dir.Collection "example" in
+
let filter = Query.in_collections [ collection ] in
+
(match Query.query ~fs calendar_dir ~from ~to_ ~filter () with
+
| Ok instances ->
+
let all_match_calendar =
+
List.for_all
+
(fun e ->
+
match Event.get_collection e.Recur.event with
+
| Some id -> id = collection
+
| None -> false)
+
instances
+
in
+
Alcotest.(check bool)
+
(Printf.sprintf "All events should be from calendar '%s'"
+
(match collection with Collection str -> str))
+
true all_match_calendar;
+
Alcotest.(check int) "Should find events" 2 (List.length instances)
+
| Error _ -> Alcotest.fail "Error querying events");
+
let collections =
+
[ Calendar_dir.Collection "example"; Calendar_dir.Collection "recurrence" ]
+
in
+
let filter = Query.in_collections collections in
+
(match Query.query ~fs calendar_dir ~from ~to_ ~filter () with
+
| Ok instances ->
+
Alcotest.(check int) "Should find events" 792 (List.length instances)
+
| Error _ -> Alcotest.fail "Error querying events");
+
let filter =
+
Query.in_collections [ Calendar_dir.Collection "non-existent-calendar" ]
+
in
+
(match Query.query ~fs calendar_dir ~from ~to_ ~filter () with
+
| Ok instances ->
+
Alcotest.(check int)
+
"Should find 0 events for non-existent calendar" 0
+
(List.length instances)
+
| Error _ -> Alcotest.fail "Error querying events");
+
()
+
+
let test_events =
+
(* Create a test event with specific text in all fields *)
+
let create_test_event ~collection ~summary ~description ~location ~start =
+
Event.create ~collection:(Calendar_dir.Collection collection) ~summary
+
~start
+
?description:(if description = "" then None else Some description)
+
?location:(if location = "" then None else Some location)
+
()
+
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: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:fixed_date;
+
(* Event with word fragments *)
+
create_test_event ~collection:"search_test" ~summary:"Conference Call"
+
~description:"International conference preparation"
+
~location:"Remote Meeting Room" ~start: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: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 (Event.get_summary e) summary) events
+
+
let test_case_insensitive_search () =
+
(* 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
+
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
+
in
+
Alcotest.(check bool)
+
"Uppercase query should match lowercase text in description" true
+
(contains_summary matches "Project Meeting")
+
+
let test_partial_word_matching () =
+
(* 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
+
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
+
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 () =
+
(* 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
+
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
+
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
+
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
+
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 () =
+
(* 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
+
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
+
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 =
+
[
+
("date expression parsing", `Quick, test_parse_date_expression);
+
("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);
+
("partial word matching", `Quick, test_partial_word_matching);
+
("boolean logic filters", `Quick, test_boolean_logic);
+
("cross-field searching", `Quick, test_cross_field_search);
+
]
+
+
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) ]
+744
test/test_recur.ml
···
+
(* Test recurrence expansion for specific event file.
+
More tests can be found in the icalendar library at test/test_recur.ml *)
+
+
open Caledonia_lib
+
+
let test_recurring_events_in_date_range () =
+
(* Create a recurring event that starts BEFORE our test range *)
+
let event_start =
+
Option.get @@ Ptime.of_date_time ((2025, 2, 1), ((14, 0, 0), 0))
+
in
+
let recurrence = (`Weekly, None, Some 1, []) in
+
(* Weekly recurrence *)
+
let recurring_event =
+
Event.create ~summary:"Weekly Recurring Event" ~start:event_start
+
~recurrence ~collection:(Calendar_dir.Collection "test") ()
+
in
+
let test_date_range from_str to_str expected_count =
+
try
+
let from = Some (Query.parse_date_expression from_str `From) in
+
let to_ = Query.parse_date_expression to_str `To in
+
let instances = Recur.expand_event recurring_event ~from ~to_ in
+
Printf.printf "Testing date range: %s to %s\n" from_str to_str;
+
Printf.printf "Found %d instances:\n" (List.length instances);
+
List.iter
+
(fun i ->
+
let date_str =
+
let y, m, d = Ptime.to_date i.Recur.start in
+
Printf.sprintf "%04d-%02d-%02d" y m d
+
in
+
let time_str =
+
let _, ((h, m, s), _) = Ptime.to_date_time i.Recur.start in
+
Printf.sprintf "%02d:%02d:%02d" h m s
+
in
+
Printf.printf " - %s %s\n" date_str time_str)
+
instances;
+
(* Check the count matches what we expect *)
+
Alcotest.(check int)
+
(Printf.sprintf "Date range %s to %s should have %d occurrences"
+
from_str to_str expected_count)
+
expected_count (List.length instances)
+
with Failure msg ->
+
Alcotest.fail
+
(Printf.sprintf "Failed to parse date range '%s' to '%s': %s" from_str
+
to_str msg)
+
in
+
test_date_range "2025-01-25" "2025-01-31" 0;
+
test_date_range "2025-02-01" "2025-02-02" 1;
+
test_date_range "2025-02-01" "2025-02-14" 2;
+
test_date_range "2025-02-01" "2025-02-15" 2;
+
(* Event started in February, but query range is only in March *)
+
test_date_range "2025-03-01" "2025-03-31" 5;
+
(* Specific test for the March 8 instance *)
+
test_date_range "2025-03-08" "2025-03-09" 1;
+
(* Test a range that spans original event date and later dates *)
+
test_date_range "2025-01-15" "2025-03-15" 6;
+
()
+
+
let test_dir = Filename.concat (Sys.getcwd ()) "calendar/recurrence/"
+
let day_seconds = 86400
+
+
(* Parse date string in YYYY-MM-DD format *)
+
let parse_date s =
+
try
+
let year = int_of_string (String.sub s 0 4) in
+
let month = int_of_string (String.sub s 5 2) in
+
let day = int_of_string (String.sub s 8 2) in
+
+
match Ptime.of_date_time ((year, month, day), ((0, 0, 0), 0)) with
+
| Some t -> t
+
| None -> failwith "Invalid date"
+
with _ ->
+
failwith (Printf.sprintf "Invalid date format: %s (expected YYYY-MM-DD)" s)
+
+
let load_event_from_file ~fs event_file =
+
let file = Eio.Path.(fs / test_dir / event_file) in
+
let _, file_path = file in
+
let content = Eio.Path.load file in
+
match Icalendar.parse content with
+
| Error err -> failwith (Printf.sprintf "Error parsing calendar: %s" err)
+
| Ok (_, components) -> (
+
match
+
(* load a single event *)
+
List.find_map (function `Event e -> Some e | _ -> None) components
+
with
+
| None -> failwith "No event found in file"
+
| Some ical_event -> (
+
match
+
Event.of_icalendar (Calendar_dir.Collection "example") ical_event
+
with
+
| Ok event -> event
+
| Error (`Msg msg) ->
+
failwith
+
(Printf.sprintf "Error parsing event from %s: %s\n%!" file_path
+
msg)))
+
+
(* Format RRULE for display *)
+
let format_rrule_str rule =
+
let freq, until_count, interval, _ = rule in
+
let freq_str =
+
match freq with
+
| `Daily -> "DAILY"
+
| `Weekly -> "WEEKLY"
+
| `Monthly -> "MONTHLY"
+
| `Yearly -> "YEARLY"
+
| _ -> "OTHER"
+
in
+
let count_until_str =
+
match until_count with
+
| Some (`Count n) -> Printf.sprintf "COUNT=%d" n
+
| Some (`Until _) -> "UNTIL=..."
+
| None -> ""
+
in
+
let interval_str =
+
match interval with Some n -> Printf.sprintf "INTERVAL=%d" n | None -> ""
+
in
+
let parts =
+
List.filter (fun s -> s <> "") [ freq_str; count_until_str; interval_str ]
+
in
+
"RRULE:" ^ String.concat ";" parts
+
+
(* Common recurrence test logic *)
+
let test_recurrence_expansion ~fs event_file start_str end_str expected_count =
+
let event = load_event_from_file ~fs event_file in
+
let start_date = parse_date start_str in
+
let end_date = parse_date end_str in
+
let instances =
+
Recur.expand_event event ~from:(Some start_date) ~to_:end_date
+
in
+
(* Format readable info for test failure messages *)
+
let rrule_str =
+
match Event.get_recurrence event with
+
| Some rule -> format_rrule_str rule
+
| None -> "No recurrence rule"
+
in
+
let summary = Event.get_summary event in
+
let msg =
+
Printf.sprintf
+
"Expected %d instances for '%s' (%s) between %s and %s, but got %d"
+
expected_count summary rrule_str start_str end_str (List.length instances)
+
in
+
Alcotest.(check int) msg expected_count (List.length instances);
+
instances
+
+
(* Helper to verify instances are properly spaced *)
+
let verify_instance_spacing instances expected_interval =
+
if List.length instances < 2 then
+
Alcotest.(check bool) "Should have at least 2 unique instances" true false
+
else
+
let errors = ref [] in
+
ignore
+
(List.fold_left
+
(fun prev curr ->
+
let span = Ptime.diff curr.Recur.start prev.Recur.start in
+
let days_diff =
+
Ptime.Span.to_float_s span /. float_of_int day_seconds
+
|> Float.round |> int_of_float
+
in
+
if days_diff <> expected_interval then
+
errors := (prev, curr, days_diff) :: !errors;
+
curr)
+
(List.hd instances) (List.tl instances));
+
match !errors with
+
| [] -> ()
+
| (prev, curr, actual) :: _ ->
+
let msg =
+
Printf.sprintf
+
"Expected interval of %d days, but found %d days between %s and %s"
+
expected_interval actual
+
(Ptime.to_rfc3339 ~space:true prev.Recur.start)
+
(Ptime.to_rfc3339 ~space:true curr.Recur.start)
+
in
+
if actual = 0 then
+
(* Ignore zero interval errors for now - this means we have duplicates *)
+
()
+
else Alcotest.(check int) msg expected_interval actual
+
+
let test_daily ~fs () =
+
let instances =
+
test_recurrence_expansion ~fs "1_daily.ics" "2025-03-27" "2025-04-10" 14
+
in
+
verify_instance_spacing instances 1
+
+
let test_weekly ~fs () =
+
let instances =
+
test_recurrence_expansion ~fs "2_weekly.ics" "2025-03-27" "2025-06-27" 14
+
in
+
verify_instance_spacing instances 7
+
+
let test_monthly ~fs () =
+
let instances =
+
test_recurrence_expansion ~fs "3_monthly.ics" "2025-01-01" "2025-12-31" 8
+
in
+
(* Just verify the results without checking the interval, since months have different lengths *)
+
let first_instance = List.hd instances in
+
let (_, _, first_day), _ = Ptime.to_date_time first_instance.Recur.start in
+
(* Check that they all fall on the same day of the month *)
+
let all_same_day =
+
List.for_all
+
(fun instance ->
+
let (_, _, day), _ = Ptime.to_date_time instance.Recur.start in
+
day = first_day)
+
instances
+
in
+
Alcotest.(check bool)
+
(Printf.sprintf "All instances should be on day %d of the month" first_day)
+
true all_same_day
+
+
let test_yearly ~fs () =
+
let instances =
+
test_recurrence_expansion ~fs "4_yearly.ics" "2025-01-01" "2035-12-31" 11
+
in
+
(* Verify instances are one year apart by checking the date *)
+
let all_same_month_day =
+
if List.length instances < 2 then true
+
else
+
let first = List.hd instances in
+
let (_, first_month, first_day), _ =
+
Ptime.to_date_time first.Recur.start
+
in
+
List.for_all
+
(fun instance ->
+
let (_, month, day), _ = Ptime.to_date_time instance.Recur.start in
+
month = first_month && day = first_day)
+
instances
+
in
+
Alcotest.(check bool)
+
"All instances should be on the same day/month" true all_same_month_day
+
+
let test_every_2_days ~fs () =
+
let instances =
+
test_recurrence_expansion ~fs "5_every_2_days.ics" "2025-05-01" "2025-05-31"
+
15
+
in
+
verify_instance_spacing instances 2
+
+
let test_every_3_weeks ~fs () =
+
let instances =
+
test_recurrence_expansion ~fs "6_every_3_weeks.ics" "2025-05-01"
+
"2025-08-31" 6
+
in
+
verify_instance_spacing instances 21 (* 3 weeks = 21 days *)
+
+
let test_bimonthly ~fs () =
+
let instances =
+
test_recurrence_expansion ~fs "7_bimonthly.ics" "2025-05-01" "2026-05-31" 7
+
in
+
(* Verify all instances are on the same day of month *)
+
let first_instance = List.hd instances in
+
let (_, _, first_day), _ = Ptime.to_date_time first_instance.Recur.start in
+
let all_same_day =
+
List.for_all
+
(fun instance ->
+
let (_, _, day), _ = Ptime.to_date_time instance.Recur.start in
+
day = first_day)
+
instances
+
in
+
Alcotest.(check bool)
+
(Printf.sprintf "All instances should be on day %d of the month" first_day)
+
true all_same_day
+
+
let test_biennial ~fs () =
+
let instances =
+
test_recurrence_expansion ~fs "8_biennial.ics" "2025-01-01" "2035-12-31" 6
+
in
+
(* Verify all instances are on the same month and day, every two years *)
+
let first_instance = List.hd instances in
+
let (first_year, first_month, first_day), _ =
+
Ptime.to_date_time first_instance.Recur.start
+
in
+
let all_same_date_alternate_years =
+
List.for_all
+
(fun instance ->
+
let (year, month, day), _ = Ptime.to_date_time instance.Recur.start in
+
month = first_month && day = first_day && (year - first_year) mod 2 = 0)
+
instances
+
in
+
Alcotest.(check bool)
+
"All instances should be on same day/month every two years" true
+
all_same_date_alternate_years
+
+
let test_daily_count5 ~fs () =
+
let instances =
+
test_recurrence_expansion ~fs "9_daily_count5.ics" "2025-05-01" "2025-05-31"
+
5
+
in
+
Alcotest.(check int)
+
"Should have exactly 5 instances" 5 (List.length instances);
+
verify_instance_spacing instances 1
+
+
let test_weekly_count10 ~fs () =
+
let instances =
+
test_recurrence_expansion ~fs "10_weekly_count10.ics" "2025-03-17"
+
"2025-06-30" 10
+
in
+
verify_instance_spacing instances 7
+
+
let test_daily_until ~fs () =
+
let instances =
+
test_recurrence_expansion ~fs "11_daily_until.ics" "2025-05-01" "2025-05-31"
+
14
+
in
+
(* Verify instances only occur until May 15 *)
+
let last_instance =
+
List.fold_left
+
(fun latest curr ->
+
if Ptime.compare curr.Recur.start latest.Recur.start > 0 then curr
+
else latest)
+
(List.hd instances) instances
+
in
+
let (_, month, day), _ = Ptime.to_date_time last_instance.Recur.start in
+
Alcotest.(check int) "Last instance should be in May" 5 month;
+
Alcotest.(check bool)
+
"Last instance should be on or before May 15" true (day <= 15);
+
verify_instance_spacing instances 1
+
+
let test_weekly_until ~fs () =
+
let instances =
+
test_recurrence_expansion ~fs "12_weekly_until.ics" "2025-05-01"
+
"2025-07-31" 9
+
in
+
(* Verify instances only occur until June 30 *)
+
let last_instance =
+
List.fold_left
+
(fun latest curr ->
+
if Ptime.compare curr.Recur.start latest.Recur.start > 0 then curr
+
else latest)
+
(List.hd instances) instances
+
in
+
let (_, month, day), _ = Ptime.to_date_time last_instance.Recur.start in
+
Alcotest.(check int) "Last instance should be in June" 6 month;
+
Alcotest.(check bool)
+
"Last instance should be on or before June 30" true (day <= 30);
+
verify_instance_spacing instances 7
+
+
let test_weekly_monday_wednesday ~fs () =
+
let instances =
+
test_recurrence_expansion ~fs "13_weekly_monday_wednesday.ics" "2025-03-24"
+
"2025-04-30" 11
+
in
+
let check_day_of_week instance day_list =
+
let date = instance.Recur.start in
+
let weekday =
+
match Ptime.weekday ~tz_offset_s:0 date with
+
| `Mon -> 1
+
| `Tue -> 2
+
| `Wed -> 3
+
| `Thu -> 4
+
| `Fri -> 5
+
| `Sat -> 6
+
| `Sun -> 0
+
in
+
List.mem weekday day_list
+
in
+
let all_on_mon_wed =
+
List.for_all
+
(fun instance ->
+
check_day_of_week instance [ 1; 3 ] (* Monday = 1, Wednesday = 3 *))
+
instances
+
in
+
Alcotest.(check bool)
+
"All instances should be on Monday or Wednesday" true all_on_mon_wed
+
+
let test_weekly_weekends ~fs () =
+
let instances =
+
test_recurrence_expansion ~fs "14_weekly_weekends.ics" "2025-05-01"
+
"2025-06-30" 18
+
in
+
let check_day_of_week instance day_list =
+
let date = instance.Recur.start in
+
let weekday =
+
match Ptime.weekday ~tz_offset_s:0 date with
+
| `Mon -> 1
+
| `Tue -> 2
+
| `Wed -> 3
+
| `Thu -> 4
+
| `Fri -> 5
+
| `Sat -> 6
+
| `Sun -> 0
+
in
+
List.mem weekday day_list
+
in
+
let all_on_weekends =
+
List.for_all
+
(fun instance ->
+
check_day_of_week instance [ 6; 0 ] (* Saturday = 6, Sunday = 0 *))
+
instances
+
in
+
Alcotest.(check bool)
+
"All instances should be on Saturday or Sunday" true all_on_weekends
+
+
let test_monthly_specific_day ~fs () =
+
let instances =
+
test_recurrence_expansion ~fs "15_monthly_specific_day.ics" "2025-01-01"
+
"2025-12-31" 8
+
in
+
(* Verify all instances are on the same day of month *)
+
let first_instance = List.hd instances in
+
let (_, _, first_day), _ = Ptime.to_date_time first_instance.Recur.start in
+
let all_same_day =
+
List.for_all
+
(fun instance ->
+
let (_, _, day), _ = Ptime.to_date_time instance.Recur.start in
+
day = first_day)
+
instances
+
in
+
Alcotest.(check bool)
+
(Printf.sprintf "All instances should be on day %d of the month" first_day)
+
true all_same_day
+
+
let test_monthly_second_monday ~fs () =
+
let instances =
+
test_recurrence_expansion ~fs "16_monthly_second_monday.ics" "2025-05-01"
+
"2026-04-30" 11
+
in
+
let check_day_of_week instance =
+
let date = instance.Recur.start in
+
let weekday =
+
match Ptime.weekday ~tz_offset_s:0 date with `Mon -> true | _ -> false
+
in
+
let (_, _, day), _ = Ptime.to_date_time date in
+
weekday && day >= 8 && day <= 14 (* Second Monday is between 8th and 14th *)
+
in
+
let all_second_mondays = List.for_all check_day_of_week instances in
+
Alcotest.(check bool)
+
"All instances should be on the second Monday of each month" true
+
all_second_mondays
+
+
let test_monthly_last_day ~fs () =
+
let instances =
+
test_recurrence_expansion ~fs "17_monthly_last_day.ics" "2025-05-01"
+
"2026-04-30" 11
+
in
+
let is_last_day_of_month instance =
+
let date = instance.Recur.start in
+
let (year, month, day), _ = Ptime.to_date_time date in
+
let last_day =
+
match month with
+
| 2 ->
+
if (year mod 4 = 0 && year mod 100 <> 0) || year mod 400 = 0 then 29
+
else 28
+
| 4 | 6 | 9 | 11 -> 30
+
| _ -> 31
+
in
+
day = last_day
+
in
+
let all_last_days = List.for_all is_last_day_of_month instances in
+
Alcotest.(check bool)
+
"All instances should be on the last day of each month" true all_last_days
+
+
let test_yearly_specific_date ~fs () =
+
let instances =
+
test_recurrence_expansion ~fs "18_yearly_specific_date.ics" "2025-01-01"
+
"2035-12-31" 11
+
in
+
(* Verify all instances are on the same month and day *)
+
let first_instance = List.hd instances in
+
let (_, first_month, first_day), _ =
+
Ptime.to_date_time first_instance.Recur.start
+
in
+
let all_same_date =
+
List.for_all
+
(fun instance ->
+
let (_, month, day), _ = Ptime.to_date_time instance.Recur.start in
+
month = first_month && day = first_day)
+
instances
+
in
+
Alcotest.(check bool)
+
(Printf.sprintf "All instances should be on %d/%d" first_month first_day)
+
true all_same_date
+
+
let test_yearly_mothers_day ~fs () =
+
let instances =
+
test_recurrence_expansion ~fs "19_yearly_mothers_day.ics" "2025-01-01"
+
"2035-12-31" 10
+
in
+
let is_second_sunday_in_may instance =
+
let date = instance.Recur.start in
+
let (_, month, day), _ = Ptime.to_date_time date in
+
let is_sunday =
+
match Ptime.weekday ~tz_offset_s:0 date with `Sun -> true | _ -> false
+
in
+
month = 5 && is_sunday && day >= 8
+
&& day <= 14 (* Second Sunday is between 8th and 14th *)
+
in
+
let all_mothers_days = List.for_all is_second_sunday_in_may instances in
+
Alcotest.(check bool)
+
"All instances should be on the second Sunday in May" true all_mothers_days
+
+
let test_complex_weekdays_months ~fs () =
+
let instances =
+
test_recurrence_expansion ~fs "20_complex_weekdays_months.ics" "2025-05-01"
+
"2025-09-30" 26
+
in
+
let is_tue_thu_in_summer instance =
+
let date = instance.Recur.start in
+
let (_, month, _), _ = Ptime.to_date_time date in
+
let is_tue_thu =
+
match Ptime.weekday ~tz_offset_s:0 date with
+
| `Tue | `Thu -> true
+
| _ -> false
+
in
+
is_tue_thu && (month = 6 || month = 7 || month = 8)
+
(* Jun, Jul, Aug *)
+
in
+
let all_valid = List.for_all is_tue_thu_in_summer instances in
+
Alcotest.(check bool)
+
"All instances should be on Tuesday/Thursday in summer months" true
+
all_valid
+
+
let test_complex_multiple_monthdays ~fs () =
+
let instances =
+
test_recurrence_expansion ~fs "21_complex_multiple_monthdays.ics"
+
"2025-05-01" "2026-05-31" 38
+
in
+
(* Verify instances fall on the specified days (1st, 15th, or last day of month) *)
+
let valid_day =
+
fun day month ->
+
day = 1 || day = 15
+
|| (month = 1 && day = 31)
+
(* not a leap year *)
+
|| (month = 2 && day = 28)
+
|| (month = 3 && day = 31)
+
|| (month = 4 && day = 30)
+
|| (month = 5 && day = 31)
+
|| (month = 6 && day = 30)
+
|| (month = 7 && day = 31)
+
|| (month = 8 && day = 31)
+
|| (month = 9 && day = 30)
+
|| (month = 10 && day = 31)
+
|| (month = 11 && day = 30)
+
|| (month = 12 && day = 31)
+
in
+
let all_valid_days =
+
List.for_all
+
(fun instance ->
+
let (_, month, day), _ = Ptime.to_date_time instance.Recur.start in
+
valid_day day month)
+
instances
+
in
+
Alcotest.(check bool)
+
"All instances should be on 1st, 15th, or last day of month" true
+
all_valid_days
+
+
let test_with_exdate ~fs () =
+
let instances =
+
test_recurrence_expansion ~fs "22_with_exdate.ics" "2025-05-01" "2025-06-30"
+
9
+
in
+
(* Verify that excluded dates (May 15 and May 29) are not in the instances *)
+
let excluded_dates = [ parse_date "2025-05-15"; parse_date "2025-05-29" ] in
+
let no_excluded_dates =
+
List.for_all
+
(fun instance ->
+
let check_not_excluded excluded =
+
Ptime.compare instance.Recur.start excluded <> 0
+
in
+
List.for_all check_not_excluded excluded_dates)
+
instances
+
in
+
Alcotest.(check bool)
+
"No instances should be on excluded dates" true no_excluded_dates;
+
(* Weekly recurrence should have 9 instances in this period *)
+
Alcotest.(check int) "Should have 9 instances" 9 (List.length instances)
+
+
let test_dst_transition ~fs () =
+
(* This event occurs from 2025-10-27 to 2025-11-17 *)
+
let instances =
+
test_recurrence_expansion ~fs "23_dst_transition.ics" "2025-10-01"
+
"2025-11-30" 4
+
in
+
Alcotest.(check int)
+
"Should have 4 instances across DST transition" 4 (List.length instances);
+
(* Check each consecutive pair of dates *)
+
ignore
+
(List.fold_left
+
(fun prev curr ->
+
let span = Ptime.diff curr.Recur.start prev.Recur.start in
+
let days_diff =
+
Ptime.Span.to_float_s span /. float_of_int day_seconds
+
|> Float.round |> int_of_float
+
in
+
Alcotest.(check int)
+
(Printf.sprintf "Days diff should be 7 days")
+
7 days_diff;
+
curr)
+
(List.hd instances) (List.tl instances))
+
+
let test_long_interval ~fs () =
+
let instances =
+
test_recurrence_expansion ~fs "24_long_interval.ics" "2025-05-01"
+
"2026-05-31" 4
+
in
+
verify_instance_spacing instances 100
+
+
let test_leap_day ~fs () =
+
let instances =
+
test_recurrence_expansion ~fs "25_leap_day.ics" "2028-01-01" "2036-12-31" 3
+
in
+
Alcotest.(check int)
+
"Should have 3 leap day instances" 3 (List.length instances);
+
let all_leap_days =
+
List.for_all
+
(fun instance ->
+
let (_, month, day), _ = Ptime.to_date_time instance.Recur.start in
+
month = 2 && day = 29)
+
instances
+
in
+
Alcotest.(check bool)
+
"All instances should be on February 29" true all_leap_days;
+
let years =
+
List.map
+
(fun instance ->
+
let (year, _, _), _ = Ptime.to_date_time instance.Recur.start in
+
year)
+
instances
+
|> List.sort_uniq compare
+
in
+
let expected_years = [ 2028; 2032; 2036 ] in
+
Alcotest.(check (list int))
+
"Should have instances in leap years 2028, 2032, and 2036" expected_years
+
years
+
+
let test_weekly_wkst ~fs () =
+
let instances =
+
test_recurrence_expansion ~fs "26_weekly_wkst.ics" "2025-05-01" "2025-05-31"
+
4
+
in
+
(* Verify we get exactly 4 instances (COUNT=4) *)
+
Alcotest.(check int)
+
"Should have exactly 4 instances" 4 (List.length instances);
+
verify_instance_spacing instances 7
+
+
let test_monthly_nth_weekday ~fs () =
+
let instances =
+
test_recurrence_expansion ~fs "27_monthly_nth_weekday.ics" "2025-05-01"
+
"2026-04-30" 11
+
in
+
let is_third_sunday instance =
+
let date = instance.Recur.start in
+
let is_sunday =
+
match Ptime.weekday ~tz_offset_s:0 date with `Sun -> true | _ -> false
+
in
+
let (_, _, day), _ = Ptime.to_date_time date in
+
is_sunday && day >= 15
+
&& day <= 21 (* Third Sunday is between 15th and 21st *)
+
in
+
let all_third_sundays = List.for_all is_third_sunday instances in
+
Alcotest.(check bool)
+
"All instances should be on the third Sunday of each month" true
+
all_third_sundays
+
+
let test_yearly_historical ~fs () =
+
let instances =
+
test_recurrence_expansion ~fs "28_yearly_historical.ics" "2000-01-01"
+
"2011-01-01" 11
+
in
+
(* Verify instances are yearly and end by 2010-12-31 *)
+
let all_before_2011 =
+
List.for_all
+
(fun instance ->
+
let (year, _, _), _ = Ptime.to_date_time instance.Recur.start in
+
year <= 2010)
+
instances
+
in
+
Alcotest.(check bool)
+
"All instances should be before 2011" true all_before_2011;
+
()
+
+
let test_monthly_bymonth ~fs () =
+
let instances =
+
test_recurrence_expansion ~fs "29_monthly_bymonth.ics" "2025-01-01"
+
"2026-12-31" 8
+
in
+
let is_specified_month instance =
+
let date = instance.Recur.start in
+
let (_, month, _), _ = Ptime.to_date_time date in
+
month = 3 || month = 6 || month = 9 || month = 12 (* Mar, Jun, Sep, Dec *)
+
in
+
let all_specified_months = List.for_all is_specified_month instances in
+
Alcotest.(check bool)
+
"All instances should be in Mar, Jun, Sep, or Dec" true all_specified_months;
+
()
+
+
let test_fourth_weekday ~fs () =
+
let instances =
+
test_recurrence_expansion ~fs "30_fourth_weekday.ics" "2025-01-01"
+
"2035-12-31" 10
+
in
+
let is_fourth_sunday_in_october instance =
+
let date = instance.Recur.start in
+
let (_, month, day), _ = Ptime.to_date_time date in
+
let is_sunday =
+
match Ptime.weekday ~tz_offset_s:0 date with `Sun -> true | _ -> false
+
in
+
month = 10 && is_sunday && day >= 22
+
&& day <= 28 (* Fourth Sunday is between 22nd and 28th *)
+
in
+
let all_fourth_sundays = List.for_all is_fourth_sunday_in_october instances in
+
Alcotest.(check bool)
+
"All instances should be on the fourth Sunday in October" true
+
all_fourth_sundays
+
+
let recur_expand_tests ~fs =
+
[
+
( "recurring events in date range",
+
`Quick,
+
test_recurring_events_in_date_range );
+
("daily recurrence", `Quick, test_daily ~fs);
+
("weekly recurrence", `Quick, test_weekly ~fs);
+
("monthly recurrence", `Quick, test_monthly ~fs);
+
("yearly recurrence", `Quick, test_yearly ~fs);
+
("every 2 days", `Quick, test_every_2_days ~fs);
+
("every 3 weeks", `Quick, test_every_3_weeks ~fs);
+
("bimonthly", `Quick, test_bimonthly ~fs);
+
("biennial", `Quick, test_biennial ~fs);
+
("daily with count=5", `Quick, test_daily_count5 ~fs);
+
("weekly with count=10", `Quick, test_weekly_count10 ~fs);
+
("daily until", `Quick, test_daily_until ~fs);
+
("weekly until", `Quick, test_weekly_until ~fs);
+
("weekly on Monday/Wednesday", `Quick, test_weekly_monday_wednesday ~fs);
+
("weekly on weekends", `Quick, test_weekly_weekends ~fs);
+
("monthly on specific day", `Quick, test_monthly_specific_day ~fs);
+
("monthly on second Monday", `Quick, test_monthly_second_monday ~fs);
+
("monthly on last day", `Quick, test_monthly_last_day ~fs);
+
("yearly on specific date", `Quick, test_yearly_specific_date ~fs);
+
("yearly on Mother's Day", `Quick, test_yearly_mothers_day ~fs);
+
("complex weekdays and months", `Quick, test_complex_weekdays_months ~fs);
+
("complex multiple month days", `Quick, test_complex_multiple_monthdays ~fs);
+
("with excluded dates", `Quick, test_with_exdate ~fs);
+
("DST transition handling", `Quick, test_dst_transition ~fs);
+
("long interval", `Quick, test_long_interval ~fs);
+
("leap day handling", `Quick, test_leap_day ~fs);
+
("weekly with week start", `Quick, test_weekly_wkst ~fs);
+
("monthly on nth weekday", `Quick, test_monthly_nth_weekday ~fs);
+
("yearly historical", `Quick, test_yearly_historical ~fs);
+
("monthly by month", `Quick, test_monthly_bymonth ~fs);
+
("fourth weekday", `Quick, test_fourth_weekday ~fs);
+
]
+
+
let () =
+
Eio_main.run @@ fun env ->
+
let fs = Eio.Stdenv.fs env in
+
Alcotest.run "Recur Expansion Tests"
+
[ ("recur_expand", recur_expand_tests ~fs) ]