Command-line and Emacs Calendar Client

switch to Icalendar.recur_dates with recurrence_ids

Ryan Gibb b21bc78c f8696121

+1 -1
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)).
+
Currently, it operates on a [vdir](https://pimutils.org/specs/vdir/) directory of [`.ics`](https://datatracker.ietf.org/doc/html/rfc5545) files (as managed by tools like [vdirsyncer](https://github.com/pimutils/vdirsyncer)).
It has the `list`, `search`, `show`, `add`, `delete`, and `edit` subcommands, and supports timezones.
See [TODO](./TODO.org) for future plans.
+1
TODO.org
···
- [x] add/remove events
- [x] edit events
- [x] timezones
+
- [ ] allow editting recurrence-ids
- [ ] really stress test the timezone handling -- this is full of gotcha's
- [ ] don't load all calendars into memory to show only one event
- [ ] support specifying duration
+1 -1
bin/add_cmd.ml
···
let* recurrence =
match recur with
| Some r ->
-
let* p = Recur.parse_recurrence r in
+
let* p = parse_recurrence r in
Ok (Some p)
| None -> Ok None
in
+1 -1
bin/delete_cmd.ml
···
let run ~event_id ~fs calendar_dir =
let ( let* ) = Result.bind in
let filter = Query.with_id event_id in
-
let* results = Query.query_events ~fs calendar_dir ~filter () in
+
let* results = Query.query_without_recurrence ~fs calendar_dir ~filter () in
let* event =
match results with
| [ event ] -> Ok event
+2 -2
bin/edit_cmd.ml
···
~description ~recur ?timezone ?end_timezone ~fs calendar_dir =
let ( let* ) = Result.bind in
let filter = Query.with_id event_id in
-
let* results = Query.query_events ~fs calendar_dir ~filter () in
+
let* results = Query.query_without_recurrence ~fs calendar_dir ~filter () in
let* event =
match results with
| [ event ] -> Ok event
···
let* recurrence =
match recur with
| Some r ->
-
let* p = Recur.parse_recurrence r in
+
let* p = parse_recurrence r in
Ok (Some p)
| None -> Ok None
in
+133
bin/event_args.ml
···
(`Dtend
( Icalendar.Params.empty,
`Datetime (`With_tzid (datetime, (false, tzid))) )))))
+
+
let combine_results (results : ('a, 'b) result list) : ('a list, 'b) result =
+
let rec aux acc = function
+
| [] -> Ok (List.rev acc)
+
| Ok v :: rest -> aux (v :: acc) rest
+
| Error e :: _ -> Error e
+
in
+
aux [] results
+
+
let parse_recurrence recur =
+
let ( let* ) = Result.bind in
+
let parts = String.split_on_char ';' recur in
+
let freq = ref None in
+
let count = ref None in
+
let until = ref None in
+
let interval = ref None in
+
let by_parts = ref [] in
+
let results =
+
List.map
+
(fun part ->
+
let kv = String.split_on_char '=' part in
+
match kv with
+
| [ "FREQ"; value ] -> (
+
match String.uppercase_ascii value with
+
| "DAILY" ->
+
freq := Some `Daily;
+
Ok ()
+
| "WEEKLY" ->
+
freq := Some `Weekly;
+
Ok ()
+
| "MONTHLY" ->
+
freq := Some `Monthly;
+
Ok ()
+
| "YEARLY" ->
+
freq := Some `Yearly;
+
Ok ()
+
| _ -> Error (`Msg ("Unsupported frequency: " ^ value)))
+
| [ "COUNT"; value ] ->
+
if !until <> None then
+
Error (`Msg "Cannot use both COUNT and UNTIL in the same rule")
+
else (
+
count := Some (`Count (int_of_string value));
+
Ok ())
+
| [ "UNTIL"; value ] -> (
+
if !count <> None then
+
Error (`Msg "Cannot use both COUNT and UNTIL in the same rule")
+
else
+
let* v =
+
match Icalendar.parse_datetime value with
+
| Ok v -> Ok v
+
| Error e -> Error (`Msg e)
+
in
+
match v with
+
| `With_tzid _ -> Error (`Msg "Until can't be in a timezone")
+
| `Utc u ->
+
until := Some (`Until (`Utc u));
+
Ok ()
+
| `Local l ->
+
until := Some (`Until (`Local l));
+
Ok ())
+
| [ "INTERVAL"; value ] ->
+
interval := Some (int_of_string value);
+
Ok ()
+
| [ "BYDAY"; value ] ->
+
(* Parse day specifications like MO,WE,FR or 1MO,-1FR *)
+
let days = String.split_on_char ',' value in
+
let parse_day day =
+
(* Extract ordinal if present (like 1MO or -1FR) *)
+
let ordinal, day_code =
+
if
+
String.length day >= 3
+
&& (String.get day 0 = '+'
+
|| String.get day 0 = '-'
+
|| (String.get day 0 >= '0' && String.get day 0 <= '9'))
+
then (
+
let idx = ref 0 in
+
while
+
!idx < String.length day
+
&& (String.get day !idx = '+'
+
|| String.get day !idx = '-'
+
|| String.get day !idx >= '0'
+
&& String.get day !idx <= '9')
+
do
+
incr idx
+
done;
+
let ord_str = String.sub day 0 !idx in
+
let day_str =
+
String.sub day !idx (String.length day - !idx)
+
in
+
(int_of_string ord_str, day_str))
+
else (0, day)
+
in
+
let* weekday =
+
match day_code with
+
| "MO" -> Ok `Monday
+
| "TU" -> Ok `Tuesday
+
| "WE" -> Ok `Wednesday
+
| "TH" -> Ok `Thursday
+
| "FR" -> Ok `Friday
+
| "SA" -> Ok `Saturday
+
| "SU" -> Ok `Sunday
+
| _ -> Error (`Msg ("Invalid weekday: " ^ day_code))
+
in
+
Ok (ordinal, weekday)
+
in
+
let* day_specs = combine_results (List.map parse_day days) in
+
by_parts := `Byday day_specs :: !by_parts;
+
Ok ()
+
| [ "BYMONTHDAY"; value ] ->
+
let days = String.split_on_char ',' value in
+
let month_days = List.map int_of_string days in
+
by_parts := `Bymonthday month_days :: !by_parts;
+
Ok ()
+
| [ "BYMONTH"; value ] ->
+
let months = String.split_on_char ',' value in
+
let month_nums = List.map int_of_string months in
+
by_parts := `Bymonth month_nums :: !by_parts;
+
Ok ()
+
| _ -> Ok ())
+
parts
+
in
+
let* _ = combine_results results in
+
match !freq with
+
| Some f ->
+
let limit =
+
match (!count, !until) with
+
| Some c, None -> Some c
+
| None, Some u -> Some u
+
| _ -> None
+
in
+
let recurrence = (f, limit, !interval, !by_parts) in
+
Ok recurrence
+
| None -> Error (`Msg "FREQ is required in recurrence rule")
+2 -3
bin/list_cmd.ml
···
Some (Query.in_collections [ Collection.Col collection_id ])
| None -> None
in
-
let comparator = Query_args.create_instance_comparator sort in
+
let comparator = Query_args.create_event_comparator sort in
let* results =
Query.query ~fs calendar_dir ?filter ~from ~to_ ~comparator ?limit:count ()
in
if results = [] then print_endline "No events found."
else
-
print_endline
-
(Format.format_instances ~fs ~calendar_dir ~format ~tz results);
+
print_endline (Format.format_events ~fs ~calendar_dir ~format ~tz results);
Ok ()
let cmd ~fs calendar_dir =
+22 -24
bin/query_args.ml
···
& opt_all sort_converter [ default_sort ]
& info [ "sort"; "S" ] ~docv:"SORT" ~doc)
-
(* Convert sort specs to an instance comparator *)
-
let create_instance_comparator sort_specs =
+
(* Convert sort specs to an event comparator *)
+
let create_event_comparator sort_specs =
match sort_specs with
-
| [] -> Recur.Instance.by_start
+
| [] -> Event.by_start
| [ spec ] ->
let comp =
match spec.field with
-
| `Start -> Recur.Instance.by_start
-
| `End -> Recur.Instance.by_end
-
| `Summary -> Recur.Instance.by_event Event.by_summary
-
| `Location -> Recur.Instance.by_event Event.by_location
-
| `Calendar -> Recur.Instance.by_event Event.by_collection
+
| `Start -> Event.by_start
+
| `End -> Event.by_end
+
| `Summary -> Event.by_summary
+
| `Location -> Event.by_location
+
| `Calendar -> Event.by_collection
in
-
if spec.descending then Recur.Instance.descending comp else comp
+
if spec.descending then Event.descending comp else comp
| specs ->
(* Chain multiple sort specs together *)
List.fold_right
(fun spec acc ->
let comp =
match spec.field with
-
| `Start -> Recur.Instance.by_start
-
| `End -> Recur.Instance.by_end
-
| `Summary -> Recur.Instance.by_event Event.by_summary
-
| `Location -> Recur.Instance.by_event Event.by_location
-
| `Calendar -> Recur.Instance.by_event Event.by_collection
-
in
-
let comp =
-
if spec.descending then Recur.Instance.descending comp else comp
+
| `Start -> Event.by_start
+
| `End -> Event.by_end
+
| `Summary -> Event.by_summary
+
| `Location -> Event.by_location
+
| `Calendar -> Event.by_collection
in
-
Recur.Instance.chain comp acc)
+
let comp = if spec.descending then Event.descending comp else comp in
+
Event.chain comp acc)
(List.tl specs)
(let spec = List.hd specs in
let comp =
match spec.field with
-
| `Start -> Recur.Instance.by_start
-
| `End -> Recur.Instance.by_end
-
| `Summary -> Recur.Instance.by_event Event.by_summary
-
| `Location -> Recur.Instance.by_event Event.by_location
-
| `Calendar -> Recur.Instance.by_event Event.by_collection
+
| `Start -> Event.by_start
+
| `End -> Event.by_end
+
| `Summary -> Event.by_summary
+
| `Location -> Event.by_location
+
| `Calendar -> Event.by_collection
in
-
if spec.descending then Recur.Instance.descending comp else comp)
+
if spec.descending then Event.descending comp else comp)
let date_format_manpage_entries =
[
+2 -3
bin/search_cmd.ml
···
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 comparator = Query_args.create_instance_comparator sort in
+
let comparator = Query_args.create_event_comparator sort in
let* results =
Query.query ~fs calendar_dir ~filter ~from ~to_ ~comparator ?limit:count ()
in
if results = [] then print_endline "No events found."
else
-
print_endline
-
(Format.format_instances ~tz ~fs ~calendar_dir ~format results);
+
print_endline (Format.format_events ~tz ~fs ~calendar_dir ~format results);
Ok ()
let query_text_arg =
+1 -1
bin/show_cmd.ml
···
let run ~event_id ~format ~fs calendar_dir =
let ( let* ) = Result.bind in
let filter = Query.with_id event_id in
-
let* results = Query.query_events ~fs calendar_dir ~filter () in
+
let* results = Query.query_without_recurrence ~fs calendar_dir ~filter () in
if results = [] then print_endline "No events found."
else print_endline (Format.format_events ~fs ~calendar_dir ~format results);
Ok ()
+2 -2
caledonia.opam
···
"alcotest" {>= "1.8.0" & with-test}
]
pin-depends: [
-
# https://github.com/robur-coop/icalendar/pull/17
-
["icalendar.dev" "git+https://github.com/robur-coop/icalendar#70006afc8cf05963f5187966aae18d40e532d4b5"]
+
# https://github.com/robur-coop/icalendar/pull/13
+
["icalendar.dev" "git+https://github.com/robur-coop/icalendar#1ab8d3970295bc96dc651c53b25c7c9963a27f81"]
]
+11 -36
lib/calendar_dir.ml
···
let content = Eio.Path.load file in
match parse content with
| Ok calendar ->
-
let events =
-
List.filter_map
-
(function
-
| `Event e ->
-
Some (Event.of_icalendar collection ~file_name e)
-
| _ -> None)
-
(snd calendar)
-
in
-
events
+
Event.events_of_icalendar ~file_name collection calendar
| Error err ->
Printf.eprintf "Failed to parse %s: %s\n%!" file_path err;
[]
···
(`Msg
(Printf.sprintf "Exception processing directory %s: %s"
(snd collection_path) (Printexc.to_string e))))
+
+
let ( let* ) = Result.bind
-
let get_collections ~fs calendar_dir =
+
let get_events ~fs calendar_dir =
match list_collections ~fs calendar_dir with
| Error e -> Error e
| Ok ids -> (
···
| [] -> Ok (List.rev acc)
| id :: rest -> (
match get_collection ~fs calendar_dir id with
-
| Ok cal -> process_ids ((id, cal) :: acc) rest
+
| Ok cal -> process_ids (cal :: acc) rest
| Error `Not_found -> process_ids acc rest
| Error (`Msg e) -> Error (`Msg e))
in
-
process_ids [] ids
+
let* collections = process_ids [] ids in
+
Ok (List.flatten collections)
with exn ->
Error
(`Msg
(Printf.sprintf "Error getting collections: %s"
(Printexc.to_string exn))))
-
-
let default_prodid = `Prodid (Params.empty, "-//Freumh//Caledonia//EN")
let add_event ~fs calendar_dir event =
let collection = Event.get_collection event in
let file_path =
Event.get_file_path ~fs ~calendar_dir_path:calendar_dir.path event
in
-
let ical_event = Event.to_icalendar event in
let collection_path = get_collection_path ~fs calendar_dir collection in
-
let ( let* ) = Result.bind in
let* () = ensure_dir collection_path in
-
let calendar =
-
let props = [ default_prodid ] in
-
let components = [ `Event ical_event ] in
-
(props, components)
-
in
+
let calendar = Event.to_ical_calendar event in
let content = Icalendar.to_ics ~cr:true calendar in
let* _ =
try
···
calendar_dir.collections;
Ok ()
-
let load_calendar path =
-
try
-
let file_content = Eio.Path.load path in
-
match Icalendar.parse file_content with
-
| Ok calendar -> Ok calendar
-
| Error msg -> Error (`Msg msg)
-
with Eio.Exn.Io _ as exn ->
-
Error
-
(`Msg (Fmt.str "Failed to read file %s: %a\n%!" (snd path) Eio.Exn.pp exn))
-
let edit_event ~fs calendar_dir event =
let collection = Event.get_collection event in
let event_id = Event.get_id event in
let collection_path = get_collection_path ~fs calendar_dir collection in
-
let ( let* ) = Result.bind in
let* () = ensure_dir collection_path in
-
let ical_event = Event.to_icalendar event in
+
let ical_event = Event.to_ical_event event in
let file_path =
Event.get_file_path ~fs ~calendar_dir_path:calendar_dir.path event
in
-
let* existing_props, existing_components = load_calendar file_path in
+
let existing_props, existing_components = Event.to_ical_calendar event in
let calendar =
(* Replace the event with our updated version *)
let filtered_components =
···
let collection = Event.get_collection event in
let event_id = Event.get_id event in
let collection_path = get_collection_path ~fs calendar_dir collection in
-
let ( let* ) = Result.bind in
let* () = ensure_dir collection_path in
let file_path =
Event.get_file_path ~fs ~calendar_dir_path:calendar_dir.path event
in
-
let* existing_props, existing_components = load_calendar file_path in
+
let existing_props, existing_components = Event.to_ical_calendar event in
let other_events = ref false in
let calendar =
(* Replace the event with our updated version *)
+3 -3
lib/calendar_dir.mli
···
(** Get all calendar files in a Collection.t. If the collection doesn't exist in
the cache, it will be loaded from disk. *)
-
val get_collections :
+
val get_events :
fs:[> Eio.Fs.dir_ty ] Eio.Path.t ->
t ->
-
((Collection.t * Event.t list) list, [> `Msg of string ]) result
-
(** Get all Collection.ts with their calendar files. This will load any
+
(Event.t list, [> `Msg of string ]) result
+
(** Get all events in all collections. This will load any
Collection.ts that haven't been loaded yet. *)
val add_event :
+111 -44
lib/event.ml
···
open Icalendar
type event_id = string
-
type t = { collection : Collection.t; file_name : string; ical : event }
+
+
type t = {
+
collection : Collection.t;
+
file_name : string;
+
event : event;
+
calendar : calendar;
+
}
+
type date_error = [ `Msg of string ]
let generate_uuid () =
let uuid = Uuidm.v4_gen (Random.State.make_self_init ()) () in
Uuidm.to_string uuid
+
let default_prodid = `Prodid (Params.empty, "-//Freumh//Caledonia//EN")
+
let create ~summary ~start ?end_ ?location ?description ?recurrence collection =
let uuid = generate_uuid () in
let uid = (Params.empty, uuid) in
···
| Some desc -> `Description (Params.empty, desc) :: props
| None -> props
in
-
let ical =
+
let event =
{
dtstamp = (Params.empty, now);
uid;
···
alarms = [];
}
in
-
{ collection; file_name; ical }
+
let calendar =
+
let props = [ default_prodid ] in
+
let components = [ `Event event ] in
+
(props, components)
+
in
+
{ collection; file_name; event; calendar }
let edit ?summary ?start ?end_ ?location ?description ?recurrence t =
let now = Ptime_clock.now () in
-
let uid = t.ical.uid in
+
let uid = t.event.uid in
let dtstart =
-
match start with None -> t.ical.dtstart | Some s -> (Params.empty, s)
+
match start with None -> t.event.dtstart | Some s -> (Params.empty, s)
in
let dtend_or_duration =
-
match end_ with None -> t.ical.dtend_or_duration | Some _ -> end_
+
match end_ with None -> t.event.dtend_or_duration | Some _ -> end_
in
let rrule =
match recurrence with
-
| None -> t.ical.rrule
+
| None -> t.event.rrule
| Some r -> Some (Params.empty, r)
in
let props =
···
| `Description _ -> (
match description with None -> true | Some _ -> false)
| _ -> true)
-
t.ical.props
+
t.event.props
in
let props =
match summary with
···
| Some desc -> `Description (Params.empty, desc) :: props
| None -> props
in
-
let alarms = t.ical.alarms in
-
let ical =
+
let alarms = t.event.alarms in
+
let event =
{
dtstamp = (Params.empty, now);
uid;
···
in
let collection = t.collection in
let file_name = t.file_name in
-
{ collection; file_name; ical }
-
-
let of_icalendar collection ~file_name (ical : event) =
-
{ collection; file_name; ical }
+
let calendar = t.calendar in
+
{ collection; file_name; event; calendar }
-
let to_icalendar t =
-
let now = Ptime_clock.now () in
-
let uid = t.ical.uid in
-
let dtstart = t.ical.dtstart in
-
let dtend_or_duration = t.ical.dtend_or_duration in
-
let rrule = t.ical.rrule in
-
let props = t.ical.props in
-
let alarms = t.ical.alarms in
-
{
-
dtstamp = (Params.empty, now);
-
uid;
-
dtstart;
-
dtend_or_duration;
-
rrule;
-
props;
-
alarms;
-
}
+
let events_of_icalendar collection ~file_name calendar =
+
List.filter_map
+
(function
+
| `Event event -> Some { collection; file_name; event; calendar }
+
| _ -> None)
+
(snd calendar)
-
let get_id t = snd t.ical.uid
+
let to_ical_event t = t.event
+
let to_ical_calendar t = t.calendar
+
let get_id t = snd t.event.uid
let get_summary t =
match
List.filter_map
(function `Summary (_, s) when s <> "" -> Some s | _ -> None)
-
t.ical.props
+
t.event.props
with
| s :: _ -> Some s
| _ -> None
-
let get_start t = Date.ptime_of_ical (snd t.ical.dtstart)
+
let get_ical_start event =
+
Date.ptime_of_ical (snd event.dtstart)
+
+
let get_start t = get_ical_start t.event
-
let get_end t =
-
match t.ical.dtend_or_duration with
+
let get_ical_end event =
+
match event.dtend_or_duration with
| Some (`Dtend (_, d)) -> Some (Date.ptime_of_ical d)
| Some (`Duration (_, span)) -> (
-
let start = get_start t in
+
let start = get_ical_start event in
match Ptime.add_span start span with
| Some t -> Some t
| None ->
···
(Printf.sprintf "%.2fs" (Ptime.Span.to_float_s span))))
| None -> None
+
let get_end t = get_ical_end t.event
+
let get_start_timezone t =
-
match t.ical.dtstart with
+
match t.event.dtstart with
| _, `Datetime (`With_tzid (_, (_, tzid))) -> Some tzid
| _ -> None
let get_end_timezone t =
-
match t.ical.dtend_or_duration with
+
match t.event.dtend_or_duration with
| Some (`Dtend (_, `Datetime (`With_tzid (_, (_, tzid))))) -> Some tzid
| _ -> None
let get_duration t =
-
match t.ical.dtend_or_duration with
+
match t.event.dtend_or_duration with
| Some (`Duration (_, span)) -> Some span
| Some (`Dtend (_, e)) ->
let span = Ptime.diff (Date.ptime_of_ical e) (get_start t) in
···
| None -> None
let is_date t =
-
match (t.ical.dtstart, t.ical.dtend_or_duration) with
+
match (t.event.dtstart, t.event.dtend_or_duration) with
| (_, `Date _), _ -> true
| _, Some (`Dtend (_, `Date _)) -> true
| _ -> false
···
match
List.filter_map
(function `Location (_, s) when s <> "" -> Some s | _ -> None)
-
t.ical.props
+
t.event.props
with
| s :: _ -> Some s
| _ -> None
···
match
List.filter_map
(function `Description (_, s) when s <> "" -> Some s | _ -> None)
-
t.ical.props
+
t.event.props
with
| s :: _ -> Some s
| _ -> None
-
let get_recurrence t = Option.map (fun r -> snd r) t.ical.rrule
+
let get_recurrence t = Option.map (fun r -> snd r) t.event.rrule
let get_collection t = t.collection
let get_file_path ~fs ~calendar_dir_path t =
···
/ (match t.collection with Col s -> s)
/ t.file_name)
+
let get_recurrence_ids t =
+
let _, recurrence_ids =
+
match
+
List.partition (function `Event _ -> true | _ -> false) (snd t.calendar)
+
with
+
| `Event hd :: tl, _ ->
+
(hd, List.map (function `Event e -> e | _ -> assert false) tl)
+
| _ -> assert false
+
in
+
recurrence_ids
+
type comparator = t -> t -> int
let by_start e1 e2 =
···
let chain comp1 comp2 e1 e2 =
let result = comp1 e1 e2 in
if result <> 0 then result else comp2 e1 e2
+
+
let clone_with_event t event =
+
let collection = t.collection in
+
let file_name = t.file_name in
+
let calendar = t.calendar in
+
{ collection; file_name; event; calendar }
+
+
let expand_recurrences ~from ~to_ event =
+
let rule = get_recurrence event in
+
match rule with
+
(* If there's no recurrence we just return the original event. *)
+
| None ->
+
(* Include the original event instance only if it falls within the query range. *)
+
let start = get_start event in
+
let end_ = match get_end event with None -> start | Some e -> e in
+
if
+
Ptime.compare start to_ < 0
+
&&
+
(* end_ > f, meaning we don't include events that end at the exact start of our range.
+
This is handy to exclude date events that end at 00:00 the next day. *)
+
match from with Some f -> Ptime.compare end_ f > 0 | None -> true
+
then [ event ]
+
else []
+
| Some _ ->
+
let rec collect generator acc =
+
match generator () with
+
| None -> List.rev acc
+
| Some recur ->
+
let start = get_ical_start recur in
+
let end_ = match get_ical_end recur with None -> start | Some e -> e in
+
(* if start >= to then we're outside our (exclusive) date range and we terminate *)
+
if Ptime.compare start to_ >= 0 then List.rev acc
+
(* if end > from then, *)
+
else if
+
match from with
+
| Some f -> Ptime.compare end_ f > 0
+
| None -> true
+
(* we include the event *)
+
then collect generator (clone_with_event event recur :: acc)
+
(* otherwise we iterate till the event is in range *)
+
else collect generator acc
+
in
+
let generator =
+
let ical_event = to_ical_event event in
+
let recurrence_ids = get_recurrence_ids event in
+
recur_events ~recurrence_ids ical_event
+
in
+
collect generator []
+8 -5
lib/event.mli
···
t
(** Edit an existing event. *)
-
val of_icalendar : Collection.t -> file_name:string -> Icalendar.event -> t
-
(** Convert an Icalendar event to our event type. *)
+
val events_of_icalendar :
+
Collection.t -> file_name:string -> Icalendar.calendar -> t list
-
val to_icalendar : t -> Icalendar.event
-
(** Convert our event type to an Icalendar event *)
-
+
val to_ical_event : t -> Icalendar.event
+
val to_ical_calendar : t -> Icalendar.calendar
val get_id : t -> event_id
val get_summary : t -> string option
···
val get_file_path :
fs:'a Eio.Path.t -> calendar_dir_path:string -> t -> 'a Eio.Path.t
+
val get_recurrence_ids : t -> Icalendar.event list
+
type comparator = t -> t -> int
(** Event comparator function type *)
···
val chain : comparator -> comparator -> comparator
(** Chain two comparators together, using the second one as a tiebreaker when
the first one returns equality (0) *)
+
+
val expand_recurrences : from:Ptime.t option -> to_:Ptime.t -> t -> t list
+5 -43
lib/format.ml
···
recur_to_ics recur)
l
-
let format_alt ~fs ~calendar_dir ~format ~start ~end_ ?tz event =
+
let format_event ~fs ~calendar_dir ?(format = `Text) ?tz event =
let open Event in
+
let start = get_start event in
+
let end_ = Event.get_end event in
match format with
| `Text ->
let id = get_id event 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 ~cr:true (cal_props, [ `Event event_ical ])
+
let calendar = Event.to_ical_calendar event in
+
Icalendar.to_ics ~cr:true calendar
| `Sexp ->
let summary =
match get_summary event with Some summary -> summary | None -> ""
···
(String.escaped id) (String.escaped summary) start_date start_time
end_str location description calendar
-
let format_event ~fs ~calendar_dir ?(format = `Text) ?tz event =
-
format_alt ~fs ~calendar_dir ~format ~start:(Event.get_start event)
-
~end_:(Event.get_end event) ?tz event
-
-
let format_instance ~fs ~calendar_dir ?(format = `Text) ?tz instance =
-
let open Recur in
-
format_alt ~fs ~calendar_dir ~format ~start:instance.start ~end_:instance.end_
-
?tz instance.event
-
let format_events ~fs ~calendar_dir ?(format = `Text) ?tz events =
match format with
| `Json ->
···
(List.map
(fun e -> format_event ~fs ~calendar_dir ~format ?tz e)
events)
-
-
let format_instances ~fs ~calendar_dir ?(format = `Text) ?tz instances =
-
match format with
-
| `Json ->
-
let json_instances =
-
List.map
-
(fun e ->
-
Yojson.Safe.from_string
-
(format_instance ~fs ~calendar_dir ~format:`Json ?tz e))
-
instances
-
in
-
Yojson.Safe.to_string (`List json_instances)
-
| `Csv ->
-
"\"Summary\",\"Start\",\"End\",\"Location\",\"Calendar\"\n"
-
^ String.concat "\n"
-
(List.map
-
(format_instance ~fs ~calendar_dir ~format:`Csv ?tz)
-
instances)
-
| `Sexp ->
-
"("
-
^ String.concat "\n "
-
(List.map
-
(fun e -> format_instance ~fs ~calendar_dir ~format:`Sexp ?tz e)
-
instances)
-
^ ")"
-
| _ ->
-
String.concat "\n"
-
(List.map
-
(fun e -> format_instance ~fs ~calendar_dir ~format ?tz e)
-
instances)
-18
lib/format.mli
···
string
(** Format a single event, optionally using the specified timezone *)
-
val format_instance :
-
fs:'a Eio.Path.t ->
-
calendar_dir:Calendar_dir.t ->
-
?format:format ->
-
?tz:Timedesc.Time_zone.t ->
-
Recur.instance ->
-
string
-
(** Format a single event instance, optionally using the specified timezone *)
-
val format_events :
fs:'a Eio.Path.t ->
calendar_dir:Calendar_dir.t ->
···
Event.t list ->
string
(** Format a list of events, optionally using the specified timezone *)
-
-
val format_instances :
-
fs:'a Eio.Path.t ->
-
calendar_dir:Calendar_dir.t ->
-
?format:format ->
-
?tz:Timedesc.Time_zone.t ->
-
Recur.instance list ->
-
string
-
(** Format a list of event instances, optionally using the specified timezone *)
+28 -36
lib/query.ml
···
let not_filter filter event = not (filter event)
let matches_filter event filter = filter event
-
let query_events ~fs calendar_dir ?filter ?(comparator = Event.by_start) ?limit
-
() =
-
let ( let* ) = Result.bind in
-
let* collections = Calendar_dir.get_collections ~fs calendar_dir in
-
let events =
-
List.flatten (List.map (fun (_collection, events) -> events) collections)
+
let take n list =
+
let rec aux n lst acc =
+
match (lst, n) with
+
| _, 0 -> List.rev acc
+
| [], _ -> List.rev acc
+
| x :: xs, n -> aux (n - 1) xs (x :: acc)
in
+
aux n list []
+
+
let ( let* ) = Result.bind
+
+
let query_without_recurrence ~fs calendar_dir ?filter
+
?(comparator = Event.by_start) ?limit () =
+
let* events = Calendar_dir.get_events ~fs calendar_dir in
let filtered_events =
match filter with Some f -> List.filter f events | None -> events
in
let sorted_events = List.sort comparator filtered_events in
Ok
(match limit with
-
| Some n when n > 0 ->
-
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 []
+
| Some n when n > 0 -> take n sorted_events
| _ -> sorted_events)
-
let query ~fs calendar_dir ?filter ~from ~to_
-
?(comparator = Recur.Instance.by_start) ?limit () =
-
match query_events ~fs calendar_dir ?filter () with
-
| Ok events ->
-
let instances =
-
List.concat_map
-
(fun event -> Recur.expand_event event ~from ~to_)
-
events
-
in
-
let sorted_instances = List.sort comparator 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
+
let query ~fs calendar_dir ?filter ~from ~to_ ?(comparator = Event.by_start)
+
?limit () =
+
let* events = Calendar_dir.get_events ~fs calendar_dir in
+
let events =
+
match filter with Some f -> List.filter f events | None -> events
+
in
+
let events =
+
List.concat_map
+
(fun event -> Event.expand_recurrences event ~from ~to_)
+
events
+
in
+
let sorted_events = List.sort comparator events in
+
Ok
+
(match limit with Some n when n > 0 -> take n events | _ -> sorted_events)
+4 -4
lib/query.mli
···
val or_filter : filter list -> filter
val not_filter : filter -> filter
-
val query_events :
+
val query_without_recurrence :
fs:[> Eio.Fs.dir_ty ] Eio.Path.t ->
Calendar_dir.t ->
?filter:filter ->
···
?filter:filter ->
from:Ptime.t option ->
to_:Ptime.t ->
-
?comparator:Recur.Instance.comparator ->
+
?comparator:Event.comparator ->
?limit:int ->
unit ->
-
(Recur.instance list, [> `Msg of string ]) result
+
(Event.t list, [> `Msg of string ]) result
(** Find events with expansion of recurring events. Returns Ok with the list of
-
instances, or Error with a message. *)
+
events, or Error with a message. *)
(* Test-only helper functions *)
val matches_filter : Event.t -> filter -> bool
-200
lib/recur.ml
···
-
open Icalendar
-
-
type instance = { event : Event.t; start : Ptime.t; end_ : Ptime.t option }
-
-
module Instance = struct
-
type t = instance
-
type comparator = t -> t -> int
-
-
let by_start i1 i2 = Ptime.compare i1.start i2.start
-
-
let by_end i1 i2 =
-
match (i1.end_, i2.end_) with
-
| Some t1, Some t2 -> Ptime.compare t1 t2
-
| Some _, None -> 1
-
| None, Some _ -> -1
-
| None, None -> 0
-
-
let by_event event_comp i1 i2 = event_comp i1.event i2.event
-
let descending comp i1 i2 = -1 * comp i1 i2
-
-
let chain comp1 comp2 i1 i2 =
-
let result = comp1 i1 i2 in
-
if result <> 0 then result else comp2 i1 i2
-
end
-
-
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 ->
-
(* Include the original event instance only if it falls within the query range. *)
-
let start = Event.get_start event in
-
let end_ = match Event.get_end event with None -> start | Some e -> e in
-
if
-
Ptime.compare start to_ < 0
-
&&
-
(* end_ > f, meaning we don't include events that end at the exact start of our range.
-
This is handy to exclude date events that end at 00:00 the next day. *)
-
match from with Some f -> Ptime.compare end_ 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 []
-
-
let combine_results (results : ('a, 'b) result list) : ('a list, 'b) result =
-
let rec aux acc = function
-
| [] -> Ok (List.rev acc)
-
| Ok v :: rest -> aux (v :: acc) rest
-
| Error e :: _ -> Error e
-
in
-
aux [] results
-
-
let parse_recurrence recur =
-
let ( let* ) = Result.bind in
-
let parts = String.split_on_char ';' recur in
-
let freq = ref None in
-
let count = ref None in
-
let until = ref None in
-
let interval = ref None in
-
let by_parts = ref [] in
-
let results =
-
List.map
-
(fun part ->
-
let kv = String.split_on_char '=' part in
-
match kv with
-
| [ "FREQ"; value ] -> (
-
match String.uppercase_ascii value with
-
| "DAILY" ->
-
freq := Some `Daily;
-
Ok ()
-
| "WEEKLY" ->
-
freq := Some `Weekly;
-
Ok ()
-
| "MONTHLY" ->
-
freq := Some `Monthly;
-
Ok ()
-
| "YEARLY" ->
-
freq := Some `Yearly;
-
Ok ()
-
| _ -> Error (`Msg ("Unsupported frequency: " ^ value)))
-
| [ "COUNT"; value ] ->
-
if !until <> None then
-
Error (`Msg "Cannot use both COUNT and UNTIL in the same rule")
-
else (
-
count := Some (`Count (int_of_string value));
-
Ok ())
-
| [ "UNTIL"; value ] -> (
-
if !count <> None then
-
Error (`Msg "Cannot use both COUNT and UNTIL in the same rule")
-
else
-
let* v =
-
match parse_datetime value with
-
| Ok v -> Ok v
-
| Error e -> Error (`Msg e)
-
in
-
match v with
-
| `With_tzid _ -> Error (`Msg "Until can't be in a timezone")
-
| `Utc u ->
-
until := Some (`Until (`Utc u));
-
Ok ()
-
| `Local l ->
-
until := Some (`Until (`Local l));
-
Ok ())
-
| [ "INTERVAL"; value ] ->
-
interval := Some (int_of_string value);
-
Ok ()
-
| [ "BYDAY"; value ] ->
-
(* Parse day specifications like MO,WE,FR or 1MO,-1FR *)
-
let days = String.split_on_char ',' value in
-
let parse_day day =
-
(* Extract ordinal if present (like 1MO or -1FR) *)
-
let ordinal, day_code =
-
if
-
String.length day >= 3
-
&& (String.get day 0 = '+'
-
|| String.get day 0 = '-'
-
|| (String.get day 0 >= '0' && String.get day 0 <= '9'))
-
then (
-
let idx = ref 0 in
-
while
-
!idx < String.length day
-
&& (String.get day !idx = '+'
-
|| String.get day !idx = '-'
-
|| String.get day !idx >= '0'
-
&& String.get day !idx <= '9')
-
do
-
incr idx
-
done;
-
let ord_str = String.sub day 0 !idx in
-
let day_str =
-
String.sub day !idx (String.length day - !idx)
-
in
-
(int_of_string ord_str, day_str))
-
else (0, day)
-
in
-
let* weekday =
-
match day_code with
-
| "MO" -> Ok `Monday
-
| "TU" -> Ok `Tuesday
-
| "WE" -> Ok `Wednesday
-
| "TH" -> Ok `Thursday
-
| "FR" -> Ok `Friday
-
| "SA" -> Ok `Saturday
-
| "SU" -> Ok `Sunday
-
| _ -> Error (`Msg ("Invalid weekday: " ^ day_code))
-
in
-
Ok (ordinal, weekday)
-
in
-
let* day_specs = combine_results (List.map parse_day days) in
-
by_parts := `Byday day_specs :: !by_parts;
-
Ok ()
-
| [ "BYMONTHDAY"; value ] ->
-
let days = String.split_on_char ',' value in
-
let month_days = List.map int_of_string days in
-
by_parts := `Bymonthday month_days :: !by_parts;
-
Ok ()
-
| [ "BYMONTH"; value ] ->
-
let months = String.split_on_char ',' value in
-
let month_nums = List.map int_of_string months in
-
by_parts := `Bymonth month_nums :: !by_parts;
-
Ok ()
-
| _ -> Ok ())
-
parts
-
in
-
let* _ = combine_results results in
-
match !freq with
-
| Some f ->
-
let limit =
-
match (!count, !until) with
-
| Some c, None -> Some c
-
| None, Some u -> Some u
-
| _ -> None
-
in
-
let recurrence = (f, limit, !interval, !by_parts) in
-
Ok recurrence
-
| None -> Error (`Msg "FREQ is required in recurrence rule")
-35
lib/recur.mli
···
-
type instance = { event : Event.t; start : Ptime.t; end_ : Ptime.t option }
-
(** Instances of recurring events with adjusted start/end times *)
-
-
module Instance : sig
-
type t = instance
-
-
type comparator = t -> t -> int
-
(** Instance comparator function type *)
-
-
val by_start : comparator
-
(** Compare instances by start time, earlier times come first *)
-
-
val by_end : comparator
-
(** Compare instances by end time, earlier times come first. Instances with
-
end times come after those without *)
-
-
val by_event : Event.comparator -> comparator
-
(** Apply an event comparator to instances *)
-
-
val descending : comparator -> comparator
-
(** Reverse the order of a comparator *)
-
-
val chain : comparator -> comparator -> comparator
-
(** Chain two comparators together, using the second one as a tiebreaker when
-
the first one returns equality (0) *)
-
end
-
-
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. *)
-
-
val parse_recurrence :
-
string -> (Icalendar.recurrence, [ `Msg of string ]) result
+1 -1
test/dune
···
(tests
-
(names test_calendar_dir test_query test_recur)
+
(names test_calendar_dir test_query)
(libraries caledonia_lib alcotest str ptime)
(deps
(source_tree calendar)))
+6 -14
test/test_calendar_dir.ml
···
| 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 test_get_events ~fs () =
let calendar_dir =
match Calendar_dir.create ~fs calendar_dir_path with
| Ok dir -> dir
| Error (`Msg msg) ->
Alcotest.fail ("Calendar directory creation failed: " ^ msg)
in
-
match Calendar_dir.get_collections ~fs calendar_dir with
-
| Ok collections ->
+
match Calendar_dir.get_events ~fs calendar_dir with
+
| Ok events ->
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 = Collection.Col "example") collections);
-
Alcotest.(check bool)
-
"recurrence should be in the results" true
-
(List.exists
-
(fun (id, _) -> id = Collection.Col "recurrence")
-
collections);
+
"Should find two events" 32 (List.length events);
()
| Error e ->
let msg =
-
match e with `Msg m -> m | `Not_found -> "Collection not found"
+
match e with `Msg m -> m
in
Alcotest.fail ("Error getting collections: " ^ msg)
···
[
("list collections", `Quick, test_list_collections ~fs);
("get collection", `Quick, test_get_collection ~fs);
-
("get all collections", `Quick, test_get_collections ~fs);
+
("get all collections", `Quick, test_get_events ~fs);
]
let () =
+27 -32
test/test_query.ml
···
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);
+
| Ok events ->
+
Alcotest.(check int) "Should find events" 791 (List.length events);
let test_event =
List.find_opt
-
(fun instance ->
-
Option.get @@ Event.get_summary instance.Recur.event = "Test Event")
-
instances
+
(fun event -> Option.get @@ Event.get_summary event = "Test Event")
+
events
in
Alcotest.(check bool) "Should find Test Event" true (test_event <> None)
| Error _ -> Alcotest.fail "Error querying events"
···
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 =
+
| Ok events ->
+
let recurring_events =
List.filter
-
(fun instance ->
-
Option.get @@ Event.get_summary instance.Recur.event
-
= "Recurring Event")
-
instances
+
(fun event ->
+
Option.get @@ Event.get_summary event = "Recurring Event")
+
events
in
Alcotest.(check bool)
-
"Should find multiple recurring event instances" true
-
(List.length recurring_instances > 1)
+
"Should find multiple recurring event events" true
+
(List.length recurring_events > 1)
| Error _ -> Alcotest.fail "Error querying events"
let test_text_search ~fs () =
···
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 ->
+
| Ok events ->
Alcotest.(check int)
-
"Should find event with 'Test' in summary" 2 (List.length instances)
+
"Should find event with 'Test' in summary" 2 (List.length events)
| Error _ -> Alcotest.fail "Error querying events");
let filter = Query.location_contains "Weekly" in
(match Query.query ~fs calendar_dir ~from ~to_ ~filter () with
-
| Ok instances ->
+
| Ok events ->
Alcotest.(check int)
-
"Should find event with 'Weekly' in location" 10 (List.length instances)
+
"Should find event with 'Weekly' in location" 10 (List.length events)
| Error _ -> Alcotest.fail "Error querying events");
let filter =
Query.and_filter
[ Query.summary_contains "Test"; Query.description_contains "test" ]
in
(match Query.query ~fs calendar_dir ~from ~to_ ~filter () with
-
| Ok instances ->
+
| Ok events ->
Alcotest.(check int)
"Should find events matching combined and criteria" 2
-
(List.length instances)
+
(List.length events)
| Error _ -> Alcotest.fail "Error querying events");
let filter =
Query.or_filter
[ Query.summary_contains "Test"; Query.location_contains "Weekly" ]
in
(match Query.query ~fs calendar_dir ~from ~to_ ~filter () with
-
| Ok instances ->
+
| Ok events ->
Alcotest.(check int)
"Should find events matching combined or criteria" 12
-
(List.length instances)
+
(List.length events)
| Error _ -> Alcotest.fail "Error querying events");
()
···
let collection = Collection.Col "example" in
let filter = Query.in_collections [ collection ] in
(match Query.query ~fs calendar_dir ~from ~to_ ~filter () with
-
| Ok instances ->
+
| Ok events ->
let all_match_calendar =
List.for_all
-
(fun e ->
-
match Event.get_collection e.Recur.event with
-
| id -> id = collection)
-
instances
+
(fun e -> match Event.get_collection e with id -> id = collection)
+
events
in
Alcotest.(check bool)
(Printf.sprintf "All events should be from calendar '%s'"
(match collection with Col str -> str))
true all_match_calendar;
-
Alcotest.(check int) "Should find events" 2 (List.length instances)
+
Alcotest.(check int) "Should find events" 2 (List.length events)
| Error _ -> Alcotest.fail "Error querying events");
let collections = [ Collection.Col "example"; Collection.Col "recurrence" ] in
let filter = Query.in_collections collections in
(match Query.query ~fs calendar_dir ~from ~to_ ~filter () with
-
| Ok instances ->
-
Alcotest.(check int) "Should find events" 792 (List.length instances)
+
| Ok events ->
+
Alcotest.(check int) "Should find events" 791 (List.length events)
| Error _ -> Alcotest.fail "Error querying events");
let filter =
Query.in_collections [ Collection.Col "non-existent-calendar" ]
in
(match Query.query ~fs calendar_dir ~from ~to_ ~filter () with
-
| Ok instances ->
+
| Ok events ->
Alcotest.(check int)
-
"Should find 0 events for non-existent calendar" 0
-
(List.length instances)
+
"Should find 0 events for non-existent calendar" 0 (List.length events)
| Error _ -> Alcotest.fail "Error querying events");
()
-738
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:(`Datetime (`Utc event_start))
-
~recurrence (Collection.Col "test")
-
in
-
let test_date_range from_str to_str expected_count =
-
try
-
let from = Some (Result.get_ok @@ Date.parse_date from_str `From) in
-
let to_ = Result.get_ok @@ Date.parse_date 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_name = Option.get @@ Eio.Path.split 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 ->
-
Event.of_icalendar (Collection.Col "example") ~file_name ical_event)
-
-
(* 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 = Option.get @@ 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) ]