Command-line and Emacs Calendar Client

support timezones

Ryan Gibb 2607c460 174d4688

+4
CHANGELOG.md
···
+
+
### 0.3.0
+
+
- timezone support
### 0.2.0
+1 -1
README.md
···
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`, `search`, `show`, `add`, `delete`, and `edit` subcommands.
+
It has the `list`, `search`, `show`, `add`, `delete`, and `edit` subcommands, and supports timezones.
See [TODO](./TODO.org) for future plans.
## Configuration
+22 -16
TODO.org
···
-
* DONE list/search events
-
* DONE add/remove events
-
** TODO support more options in creating events, e.g. event duration
-
* DONE edit events
-
* TODO diagnose events failing to parse
-
* TODO timezones
-
timerer
-
* 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=
+
- [x] list/search events
+
- [x] add/remove events
+
- [x] edit events
+
- [x] timezones
+
- [ ] 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
+
- [ ] make query filter a lambda
+
- [ ] diagnose events failing to parse
+
- [ ] [[https://github.com/robur-coop/icalendar/pull/13][handle RECURRENCE-ID]]
+
- [ ] CalDAV syncing
+
- Currently, you can use [[https://github.com/pimutils/vdirsyncer][vdirsyncer]]
+
- [ ] support querying times as well as dates
+
- [ ] custom date/time formatting
+
- [ ] support querying regex
+
- [ ] support VALARMS
+
- [ ] support VOTODS
+
- [ ] support VCARDS
+
- [ ] server mode
+
- and maybe hold =Event='s in-memory in a =Collection= instead of parsing them for every =Query=
+
- [ ] implement TUI front end with something like [[https://github.com/leostera/minttea][minttea]]
+
- [ ] implement an emacs client like mu4e
+20 -99
bin/add_cmd.ml
···
open Cmdliner
open Caledonia_lib
-
open Query_args
+
open Event_args
let run ~summary ~start_date ~start_time ~end_date ~end_time ~location
-
~description ~recur ~collection ~today ~tomorrow ~week ~month ~fs
-
calendar_dir =
+
~description ~recur ~collection ?timezone ?end_timezone ~fs calendar_dir =
let ( let* ) = Result.bind in
-
let* start_date =
-
match
-
( Query_args.convert_relative_date ~today ~tomorrow ~week ~month,
-
start_date )
-
with
-
| Some date, _ -> Ok date
-
| None, Some date -> Ok date
-
| None, None -> Error (`Msg "Start date is required")
-
in
+
let* start = parse_start ~start_date ~start_time ~timezone in
let* start =
-
Date.parse_date_time_opt ~date:start_date ?time:start_time `From
-
in
-
let end_date =
-
match
-
(Query_args.convert_relative_date ~today ~tomorrow ~week ~month, end_date)
-
with
-
| Some date, _ -> Some date
-
| None, Some date -> Some date
-
| None, None -> None
-
in
-
let* end_ =
-
match end_date with
-
| None -> Ok None
-
| Some end_date ->
-
let* end_dt =
-
Date.parse_date_time_opt ~date:end_date ?time:end_time `To
-
in
-
Ok (Some end_dt)
+
match start with
+
| Some s -> Ok s
+
| None -> Error (`Msg "Start date required")
in
+
let* end_ = parse_end ~end_date ~end_time ~timezone ~end_timezone in
let* recurrence =
match recur with
| Some r ->
···
Printf.printf "Event created with ID: %s\n" (Event.get_id event);
Ok ()
-
let collection_arg =
-
let doc = "Calendar to add the event to" in
-
Arg.(
-
required
-
& opt (some string) None
-
& info [ "calendar"; "c" ] ~docv:"CALENDAR" ~doc)
-
-
let summary_arg =
-
let doc = "Event summary/title" in
-
Arg.(required & pos 0 (some string) None & info [] ~docv:"SUMMARY" ~doc)
-
-
let start_date_arg =
-
let doc = "Event start date (YYYY-MM-DD)" in
-
Arg.(value & opt (some string) None & info [ "date" ] ~docv:"DATE" ~doc)
-
-
let start_time_arg =
-
let doc = "Event start time (HH:MM)" in
-
Arg.(value & opt (some string) None & info [ "time"; "t" ] ~docv:"TIME" ~doc)
-
-
let end_date_arg =
-
let doc = "Event end date (YYYY-MM-DD)" in
-
Arg.(
-
value
-
& opt (some string) None
-
& info [ "end-date"; "e" ] ~docv:"END_DATE" ~doc)
-
-
let end_time_arg =
-
let doc = "Event end time (HH:MM)" in
-
Arg.(
-
value & opt (some string) None & info [ "end-time" ] ~docv:"END_TIME" ~doc)
-
-
let location_arg =
-
let doc = "Event location" in
-
Arg.(
-
value
-
& opt (some string) None
-
& info [ "location"; "l" ] ~docv:"LOCATION" ~doc)
-
-
let description_arg =
-
let doc = "Event description" in
-
Arg.(
-
value
-
& opt (some string) None
-
& info [ "description"; "D" ] ~docv:"DESCRIPTION" ~doc)
-
-
let recur_arg =
-
let doc = "See RECURRENCE section" in
-
Arg.(
-
value & opt (some string) None & info [ "recur"; "r" ] ~docv:"RECUR" ~doc)
-
let cmd ~fs calendar_dir =
let run summary start_date start_time end_date end_time location description
-
recur collection today tomorrow week month =
+
recur collection timezone end_timezone =
match
run ~summary ~start_date ~start_time ~end_date ~end_time ~location
-
~description ~recur ~collection ~today ~tomorrow ~week ~month ~fs
-
calendar_dir
+
~description ~recur ~collection ?timezone ?end_timezone ~fs calendar_dir
with
| Error (`Msg msg) ->
Printf.eprintf "Error: %s\n%!" msg;
···
in
let term =
Term.(
-
const run $ summary_arg $ start_date_arg $ start_time_arg $ end_date_arg
-
$ end_time_arg $ location_arg $ description_arg $ recur_arg
-
$ collection_arg $ today_arg $ tomorrow_arg $ week_arg $ month_arg)
+
const run $ required_summary_arg $ start_date_arg $ start_time_arg
+
$ end_date_arg $ end_time_arg $ location_arg $ description_arg $ recur_arg
+
$ collection_arg $ timezone_arg $ end_time_arg)
in
let doc = "Add a new calendar event" in
let man =
···
`P
"Specify the event summary (title) as the first argument, and use \
options to set other details.";
-
`P "Note all times are in Coordinated Universal Time (UTC).";
`S Manpage.s_options;
]
@ date_format_manpage_entries
@ [
`S Manpage.s_examples;
-
`I
-
( "Add a simple event for today:",
-
"caled add \"Team Meeting\" --today --time 14:00" );
+
`I ("Add a event for today:", "caled add \"Meeting\" -d today -t 14:00");
`I
( "Add an event with a specific date and time:",
-
"caled add \"Dentist Appointment\" --date 2025-04-15 --time 10:30"
-
);
+
"caled add \"Dentist Appointment\" -d 2025-04-15 -t 10:30" );
`I
( "Add an event with an end time:",
-
"caled add \"Conference\" --date 2025-05-20 --time 09:00 \
-
--end-date 2025-05-22 --end-time 17:00" );
+
"caled add \"Conference\" -d 2025-05-20 -t 09:00 -e 2025-05-22 -T \
+
17:00" );
`I
( "Add an event with location and description:",
-
"caled add \"Lunch with Bob\" --date 2025-04-02 --time 12:30 \
-
--location \"Pasta Restaurant\" --description \"Discuss project \
-
plans\"" );
+
"caled add \"Lunch with Bob\" -d 2025-04-02 -t 12:30 -l \"Pasta \
+
Restaurant\" -D \"Discuss project plans\"" );
`I
( "Add an event to a specific calendar:",
-
"caled add \"Work Meeting\" --date 2025-04-03 --time 15:00 \
-
--calendar work" );
+
"caled add \"Work Meeting\" -d 2025-04-03 -t 15:00 --calendar work"
+
);
`S "RECURRENCE";
`P
"Recurrence rule in iCalendar RFC5545 format. The FREQ part is \
+6 -4
bin/dune
···
ptime
ptime.clock.os
eio
-
eio_main)
+
eio_main
+
timere)
(modules
main
+
query_args
+
event_args
list_cmd
search_cmd
-
query_args
+
show_cmd
add_cmd
-
edit_cmd
delete_cmd
-
show_cmd))
+
edit_cmd))
+9 -112
bin/edit_cmd.ml
···
open Cmdliner
open Caledonia_lib
-
open Query_args
+
open Event_args
let run ~event_id ~summary ~start_date ~start_time ~end_date ~end_time ~location
-
~description ~recur ~today ~tomorrow ~week ~month ~fs calendar_dir =
+
~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
···
| [] -> Error (`Msg ("No events found found for id " ^ event_id))
| _ -> Error (`Msg ("More than one found for id " ^ event_id))
in
-
let start_date =
-
match
-
( Query_args.convert_relative_date ~today ~tomorrow ~week ~month,
-
start_date )
-
with
-
| Some date, _ -> Some date
-
| None, Some date -> Some date
-
| None, None -> None
-
in
-
let* start =
-
match start_date with
-
| Some date ->
-
let* d = Date.parse_date_time_opt ~date ?time:start_time `From in
-
Ok (Some d)
-
| None -> Ok None
-
in
-
let end_date =
-
match
-
(Query_args.convert_relative_date ~today ~tomorrow ~week ~month, end_date)
-
with
-
| Some date, _ -> Some date
-
| None, Some date -> Some date
-
| None, None -> None
-
in
-
let* end_ =
-
match end_date with
-
| None -> Ok None
-
| Some end_date ->
-
let* end_dt =
-
Date.parse_date_time_opt ~date:end_date ?time:end_time `To
-
in
-
Ok (Some end_dt)
-
in
+
let* start = parse_start ~start_date ~start_time ~timezone in
+
let* end_ = parse_end ~end_date ~end_time ~timezone ~end_timezone in
let* recurrence =
match recur with
| Some r ->
···
let doc = "ID of the event to edit" in
Arg.(required & pos 0 (some string) None & info [] ~docv:"EVENT_ID" ~doc)
-
let summary_arg =
-
let doc = "New event summary/title" in
-
Arg.(
-
value
-
& opt (some string) None
-
& info [ "summary"; "s" ] ~docv:"SUMMARY" ~doc)
-
-
let start_date_arg =
-
let doc = "New event start date (YYYY-MM-DD)" in
-
Arg.(value & opt (some string) None & info [ "date" ] ~docv:"DATE" ~doc)
-
-
let start_time_arg =
-
let doc = "New event start time (HH:MM)" in
-
Arg.(value & opt (some string) None & info [ "time"; "t" ] ~docv:"TIME" ~doc)
-
-
let end_date_arg =
-
let doc = "New event end date (YYYY-MM-DD)" in
-
Arg.(
-
value
-
& opt (some string) None
-
& info [ "end-date"; "e" ] ~docv:"END_DATE" ~doc)
-
-
let end_time_arg =
-
let doc = "New event end time (HH:MM)" in
-
Arg.(
-
value & opt (some string) None & info [ "end-time" ] ~docv:"END_TIME" ~doc)
-
-
let location_arg =
-
let doc = "New event location" in
-
Arg.(
-
value
-
& opt (some string) None
-
& info [ "location"; "l" ] ~docv:"LOCATION" ~doc)
-
-
let description_arg =
-
let doc = "New event description" in
-
Arg.(
-
value
-
& opt (some string) None
-
& info [ "description"; "D" ] ~docv:"DESCRIPTION" ~doc)
-
-
let recur_arg =
-
let doc =
-
"Recurrence rule in iCalendar RFC5545 format. The FREQ part is required.\n\
-
\ Use empty string to remove recurrence.\n\
-
\ \n\
-
\ FREQ=<frequency>: DAILY, WEEKLY, MONTHLY, or YEARLY (required)\n\
-
\ COUNT=<number>: Limit to this many occurrences (optional, cannot be \
-
used with UNTIL)\n\
-
\ UNTIL=<date>: Repeat until this date (optional, cannot be used with \
-
COUNT)\n\
-
\ INTERVAL=<number>: Interval between occurrences, e.g., 2 for every \
-
other (optional)\n\
-
\ BYDAY=<dayspec>: Specific days, e.g., MO,WE,FR or 1MO (first Monday) \
-
(optional)\n\
-
\ BYMONTHDAY=<daynum>: Day of month, e.g., 1,15 or -1 (last day) \
-
(optional)\n\
-
\ BYMONTH=<monthnum>: Month number, e.g., 1,6,12 for Jan,Jun,Dec \
-
(optional)\n\
-
\ \n\
-
\ Examples:\n\
-
\ FREQ=DAILY;COUNT=5 - Daily for 5 occurrences\n\
-
\ FREQ=WEEKLY;INTERVAL=2 - Every other week indefinitely\n\
-
\ FREQ=WEEKLY;BYDAY=MO,WE,FR - Every Monday, Wednesday, Friday\n\
-
\ FREQ=MONTHLY;BYDAY=1MO - First Monday of every month\n\
-
\ FREQ=YEARLY;BYMONTH=1;BYMONTHDAY=1 - Every January 1st (New Year's Day)\n\
-
\ FREQ=MONTHLY;BYMONTHDAY=-1 - Last day of every month"
-
in
-
Arg.(
-
value & opt (some string) None & info [ "recur"; "r" ] ~docv:"RECUR" ~doc)
-
let cmd ~fs calendar_dir =
let run event_id summary start_date start_time end_date end_time location
-
description recur today tomorrow week month =
+
description recur timezone end_timezone =
match
run ~event_id ~summary ~start_date ~start_time ~end_date ~end_time
-
~location ~description ~recur ~today ~tomorrow ~week ~month ~fs
-
calendar_dir
+
~location ~description ~recur ?timezone ?end_timezone ~fs calendar_dir
with
| Error (`Msg msg) ->
Printf.eprintf "Error: %s\n%!" msg;
···
in
let term =
Term.(
-
const run $ event_id_arg $ summary_arg $ start_date_arg $ start_time_arg
-
$ end_date_arg $ end_time_arg $ location_arg $ description_arg $ recur_arg
-
$ today_arg $ tomorrow_arg $ week_arg $ month_arg)
+
const run $ event_id_arg $ optional_summary_arg $ start_date_arg
+
$ start_time_arg $ end_date_arg $ end_time_arg $ location_arg
+
$ description_arg $ recur_arg $ timezone_arg $ end_timezone_arg)
in
let doc = "Edit an existing calendar event" in
let man =
+214
bin/event_args.ml
···
+
open Cmdliner
+
open Caledonia_lib
+
+
let collection_arg =
+
let doc = "Calendar to add the event to" in
+
Arg.(
+
required
+
& opt (some string) None
+
& info [ "calendar"; "c" ] ~docv:"CALENDAR" ~doc)
+
+
let required_summary_arg =
+
let doc = "Event summary/title" in
+
Arg.(required & pos 0 (some string) None & info [] ~docv:"SUMMARY" ~doc)
+
+
let optional_summary_arg =
+
let doc = "Event summary/title" in
+
Arg.(
+
value
+
& opt (some string) None
+
& info [ "summary"; "s" ] ~docv:"SUMMARY" ~doc)
+
+
let start_date_arg =
+
let doc = "Event start date (YYYY-MM-DD)" in
+
Arg.(value & opt (some string) None & info [ "date"; "d" ] ~docv:"DATE" ~doc)
+
+
let start_time_arg =
+
let doc = "Event start time (HH:MM)" in
+
Arg.(value & opt (some string) None & info [ "time"; "t" ] ~docv:"TIME" ~doc)
+
+
let end_date_arg =
+
let doc = "Event end date (YYYY-MM-DD)" in
+
Arg.(
+
value
+
& opt (some string) None
+
& info [ "end-date"; "e" ] ~docv:"END_DATE" ~doc)
+
+
let end_time_arg =
+
let doc = "Event end time (HH:MM)" in
+
Arg.(
+
value
+
& opt (some string) None
+
& info [ "end-time"; "T" ] ~docv:"END_TIME" ~doc)
+
+
let timezone_arg =
+
let doc =
+
"Timezone to add events to (e.g., 'America/New_York', 'UTC', \
+
'Europe/London'). If not specified, will use the local timezone."
+
in
+
Arg.(
+
value
+
& opt (some string) None
+
& info [ "timezone"; "z" ] ~docv:"TIMEZONE" ~doc)
+
+
let end_timezone_arg =
+
let doc = "The timezone of the end of the event. Defaults to TIMEZONE." in
+
Arg.(
+
value
+
& opt (some string) None
+
& info [ "end-timezone"; "Z" ] ~docv:"END_TIMEZONE" ~doc)
+
+
let location_arg =
+
let doc = "Event location" in
+
Arg.(
+
value
+
& opt (some string) None
+
& info [ "location"; "l" ] ~docv:"LOCATION" ~doc)
+
+
let description_arg =
+
let doc = "Event description" in
+
Arg.(
+
value
+
& opt (some string) None
+
& info [ "description"; "D" ] ~docv:"DESCRIPTION" ~doc)
+
+
let recur_arg =
+
let doc = "See RECURRENCE section" in
+
Arg.(
+
value & opt (some string) None & info [ "recur"; "r" ] ~docv:"RECUR" ~doc)
+
+
let date_format_manpage_entries =
+
[
+
`S "DATE FORMATS";
+
`P "Relative date formats for --date / -d and --end-date / -e:";
+
`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)");
+
]
+
+
let parse_start ~start_date ~start_time ~timezone =
+
let ( let* ) = Result.bind in
+
match start_date with
+
| None ->
+
let* _ =
+
match start_time with
+
| None -> Ok ()
+
| Some _ ->
+
Error (`Msg "Can't specify an start time without an end date")
+
in
+
let* _ =
+
match timezone with
+
| None -> Ok ()
+
| _ -> Error (`Msg "Can't specify a timezone without a start date")
+
in
+
Ok None
+
| Some start_date -> (
+
match start_time with
+
| None ->
+
let* _ =
+
match timezone with
+
| None -> Ok ()
+
| _ -> Error (`Msg "Can't specify a timezone without a start time")
+
in
+
let* ptime =
+
Date.parse_date ~tz:Timedesc.Time_zone.utc start_date `From
+
in
+
let date = Ptime.to_date ptime in
+
Ok (Some (`Date date))
+
| Some start_time -> (
+
match timezone with
+
| None ->
+
let* datetime =
+
Date.parse_date_time ~tz:Timedesc.Time_zone.utc ~date:start_date
+
~time:start_time `From
+
in
+
Ok (Some (`Datetime (`Local datetime)))
+
| Some "UTC" ->
+
let* datetime =
+
Date.parse_date_time ~tz:Timedesc.Time_zone.utc ~date:start_date
+
~time:start_time `From
+
in
+
Ok (Some (`Datetime (`Utc datetime)))
+
| Some tzid ->
+
let* tz =
+
match Timedesc.Time_zone.make tzid with
+
| Some tz_obj -> Ok tz_obj
+
| None -> Error (`Msg ("Invalid timezone: " ^ tzid))
+
in
+
let* datetime =
+
Date.parse_date_time ~tz ~date:start_date ~time:start_time `From
+
in
+
Ok (Some (`Datetime (`With_tzid (datetime, (false, tzid)))))))
+
+
let parse_end ~end_date ~end_time ~timezone ~end_timezone =
+
let ( let* ) = Result.bind in
+
match end_date with
+
| None ->
+
let* _ =
+
match end_time with
+
| None -> Ok ()
+
| Some _ -> Error (`Msg "Can't specify an end time without an end date")
+
in
+
let* _ =
+
match end_timezone with
+
| None -> Ok ()
+
| Some _ -> Error (`Msg "Can't specify a timezone without a end time")
+
in
+
Ok None
+
| Some end_date -> (
+
match end_time with
+
| None ->
+
let* _ =
+
match (timezone, end_timezone) with
+
| None, None -> Ok ()
+
| Some _, None ->
+
Error (`Msg "Can't specify a timezone without a end time")
+
| _ ->
+
Error (`Msg "Can't specify an end timezone without a end time")
+
in
+
let* ptime =
+
Date.parse_date end_date ~tz:Timedesc.Time_zone.utc `From
+
in
+
let date = Ptime.to_date ptime in
+
Ok (Some (`Dtend (Icalendar.Params.empty, `Date date)))
+
| Some end_time -> (
+
match (timezone, end_timezone) with
+
| None, None ->
+
let* datetime =
+
Date.parse_date_time ~tz:Timedesc.Time_zone.utc ~date:end_date
+
~time:end_time `From
+
in
+
Ok
+
(Some
+
(`Dtend (Icalendar.Params.empty, `Datetime (`Local datetime))))
+
| _, Some "UTC" | Some "UTC", None ->
+
let* datetime =
+
Date.parse_date_time ~tz:Timedesc.Time_zone.utc ~date:end_date
+
~time:end_time `From
+
in
+
Ok
+
(Some
+
(`Dtend (Icalendar.Params.empty, `Datetime (`Utc datetime))))
+
| _, Some tzid | Some tzid, _ ->
+
let* tz =
+
match Timedesc.Time_zone.make tzid with
+
| Some tz_obj -> Ok tz_obj
+
| None -> Error (`Msg ("Invalid timezone: " ^ tzid))
+
in
+
let* datetime =
+
Date.parse_date_time ~tz ~date:end_date ~time:end_time `From
+
in
+
+
Ok
+
(Some
+
(`Dtend
+
( Icalendar.Params.empty,
+
`Datetime (`With_tzid (datetime, (false, tzid))) )))))
+44 -16
bin/list_cmd.ml
···
open Caledonia_lib
open Query_args
-
let run ?from ?to_ ?calendar ?count ~format ~today ~tomorrow ~week ~month ~fs
-
calendar_dir =
+
let run ?from_str ?to_str ?calendar ?count ~format ~today ~tomorrow ~week ~month
+
?timezone ~fs calendar_dir =
let ( let* ) = Result.bind in
-
let from, to_ =
-
match Date.convert_relative_date_formats ~today ~tomorrow ~week ~month with
-
| Some (from, to_) -> (Some from, to_)
+
let tz = Query_args.parse_timezone ~timezone in
+
let* from, to_ =
+
match
+
Date.convert_relative_date_formats ~tz ~today ~tomorrow ~week ~month ()
+
with
+
| Some (from, to_) ->
+
let* _ =
+
match (from_str, to_str) with
+
| None, None -> Ok ()
+
| _ ->
+
Error
+
(`Msg
+
"Can't specify --from / --to when using --today, --week, \
+
--month")
+
in
+
Ok (Some from, to_)
| None -> (
+
let* from =
+
match from_str with
+
| None -> Ok None
+
| Some s ->
+
let* d = Date.parse_date ~tz s `From in
+
Ok (Some d)
+
in
+
let* to_ =
+
match to_str with
+
| None -> Ok None
+
| Some s ->
+
let* d = Date.parse_date ~tz s `From in
+
Ok (Some d)
+
in
match (from, to_) with
-
| Some f, Some t -> (Some f, Date.to_end_of_day t)
+
| Some f, Some t -> Ok (Some f, Date.to_end_of_day t)
| Some f, None ->
let one_month_later = Date.add_months f 1 in
-
(Some f, one_month_later)
+
Ok (Some f, one_month_later)
| None, Some t ->
-
let today_date = !Date.get_today () in
-
(Some today_date, Date.to_end_of_day t)
+
let today_date = !Date.get_today ~tz () in
+
Ok (Some today_date, Date.to_end_of_day t)
| None, None ->
-
let today_date = !Date.get_today () in
+
let today_date = !Date.get_today ~tz () in
let one_month_later = Date.add_months today_date 1 in
-
(Some today_date, one_month_later))
+
Ok (Some today_date, one_month_later))
in
let filter =
match calendar with
···
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);
+
else print_endline (Format.format_instances ~format ~tz results);
Ok ()
let cmd ~fs calendar_dir =
-
let run from to_ calendar count format today tomorrow week month =
+
let run from_str to_str calendar count format today tomorrow week month
+
timezone =
match
-
run ?from ?to_ ?calendar ?count ~format ~today ~tomorrow ~week ~month ~fs
-
calendar_dir
+
run ?from_str ?to_str ?calendar ?count ~format ~today ~tomorrow ~week
+
~month ?timezone ~fs calendar_dir
with
| Error (`Msg msg) ->
Printf.eprintf "Error: %s\n%!" msg;
···
let term =
Term.(
const run $ from_arg $ to_arg $ calendar_arg $ count_arg $ format_arg
-
$ today_arg $ tomorrow_arg $ week_arg $ month_arg)
+
$ today_arg $ tomorrow_arg $ week_arg $ month_arg $ timezone_arg)
in
let doc = "List calendar events" in
let man =
+25 -12
bin/query_args.ml
···
+Nm)"
in
let i = Arg.info [ "from"; "f" ] ~docv:"DATE" ~doc in
-
let parse_date s = Date.parse_date s `From in
-
Arg.(value @@ opt (some (Cmdliner.Arg.conv (parse_date, Ptime.pp))) None i)
+
Arg.(value @@ opt (some string) None i)
let to_arg =
let doc =
···
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 = Date.parse_date s `From in
-
Arg.(value @@ opt (some (Cmdliner.Arg.conv (parse_date, Ptime.pp))) None i)
+
Arg.(value @@ opt (some string) None i)
let calendar_arg =
let doc = "Calendar to filter by" in
···
let format_enum =
[
("text", `Text);
+
("entries", `Entries);
("json", `Json);
("csv", `Csv);
("ics", `Ics);
-
("records", `Entries);
("sexp", `Sexp);
]
let format_arg =
-
let doc = "Output format (text, json, csv, ics, table, sexp)" in
+
let doc = "Output format (text, entries, json, csv, ics, sexp)" in
Arg.(
value
& opt (enum format_enum) `Text
···
let doc = "Show events for the current month" in
Arg.(value & flag & info [ "month"; "m" ] ~doc)
+
let timezone_arg =
+
let doc =
+
"Timezone to use for date calculations (e.g., 'America/New_York', 'UTC', \
+
'Europe/London') defaulting to the system timezone"
+
in
+
Arg.(
+
value
+
& opt (some string) None
+
& info [ "timezone"; "z" ] ~docv:"TIMEZONE" ~doc)
+
let date_format_manpage_entries =
[
`S "DATE FORMATS";
···
`I ("--tomorrow", "Show events for tomorrow only");
`I ("--week, -w", "Show events for the current week");
`I ("--month, -m", "Show events for the current month");
+
`I
+
( "--timezone, -z",
+
"Timezone to use for date calculations (e.g., 'America/New_York', \
+
'UTC')" );
`P "Relative date formats for --from and --to:";
`I ("today", "Current day");
`I ("tomorrow", "Next day");
···
`I ("+Nm", "N months from today (e.g., +2m for 2 months from today)");
]
-
let convert_relative_date ~today ~tomorrow ~week ~month =
-
if today then Some "today"
-
else if tomorrow then Some "tomorrow"
-
else if week then Some "this-week"
-
else if month then Some "this-month"
-
else None
+
let parse_timezone ~timezone =
+
match timezone with
+
| Some tzid -> (
+
match Timedesc.Time_zone.make tzid with
+
| Some tz -> tz
+
| None -> failwith ("Invalid timezone: " ^ tzid))
+
| None -> !Date.default_timezone ()
+43 -15
bin/search_cmd.ml
···
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 run ?from_str ?to_str ?calendar ?count ?query_text ~summary ~description
+
~location ~format ~today ~tomorrow ~week ~month ~recurring ~non_recurring
+
?timezone ~fs calendar_dir =
let ( let* ) = Result.bind in
let filters = ref [] in
-
let from, to_ =
-
match Date.convert_relative_date_formats ~today ~tomorrow ~week ~month with
-
| Some (from, to_) -> (Some from, to_)
+
let tz = Query_args.parse_timezone ~timezone in
+
let* from, to_ =
+
match
+
Date.convert_relative_date_formats ~tz ~today ~tomorrow ~week ~month ()
+
with
+
| Some (from, to_) ->
+
let* _ =
+
match (from_str, to_str) with
+
| None, None -> Ok ()
+
| _ ->
+
Error
+
(`Msg
+
"Can't specify --from / --to when using --today, --week, \
+
--month")
+
in
+
Ok (Some from, to_)
| None -> (
+
let* from =
+
match from_str with
+
| None -> Ok None
+
| Some s ->
+
let* d = Date.parse_date s `From in
+
Ok (Some d)
+
in
+
let* to_ =
+
match to_str with
+
| None -> Ok None
+
| Some s ->
+
let* d = Date.parse_date s `From in
+
Ok (Some d)
+
in
let max_date = Date.add_years (!Date.get_today ()) 75 in
match (from, to_) with
-
| Some f, Some t -> (Some f, Date.to_end_of_day t)
-
| Some f, None -> (Some f, Date.to_end_of_day max_date)
-
| None, Some t -> (None, Date.to_end_of_day t)
-
| None, None -> (None, Date.to_end_of_day max_date))
+
| Some f, Some t -> Ok (Some f, Date.to_end_of_day t)
+
| Some f, None -> Ok (Some f, Date.to_end_of_day max_date)
+
| None, Some t -> Ok (None, Date.to_end_of_day t)
+
| None, None -> Ok (None, Date.to_end_of_day max_date))
in
(match calendar with
| Some collection_id ->
···
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 =
+
let run query_text from_str to_str calendar count format summary description
+
location today tomorrow week month recurring non_recurring timezone =
match
-
run ?from ?to_ ?calendar ?count ?query_text ~summary ~description
+
run ?from_str ?to_str ?calendar ?count ?query_text ~summary ~description
~location ~format ~today ~tomorrow ~week ~month ~recurring
-
~non_recurring ~fs calendar_dir
+
~non_recurring ?timezone ~fs calendar_dir
with
| Error (`Msg msg) ->
Printf.eprintf "Error: %s\n%!" msg;
···
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)
+
$ tomorrow_arg $ week_arg $ month_arg $ recurring_arg $ non_recurring_arg
+
$ timezone_arg)
in
let doc = "Search calendar events for specific text" in
let man =
+1 -1
caledonia.opam
···
opam-version: "2.0"
-
version: "0.1.0"
+
version: "0.3.0"
maintainer: "Ryan Gibb <ryan@freumh.org"
authors: ["Ryan Gibb <ryan@freumh.org"]
homepage: "https://ryan.freumh.org/caledonia.html"
+1 -1
dune-project
···
(lang dune 3.4)
(name caledonia)
-
(version 0.1.0)
+
(version 0.3.0)
(using directory-targets 0.1)
+1 -3
lib/calendar_dir.ml
···
let file = Eio.Path.(collection_path / file_name) in
let _, file_path = file in
match Filename.check_suffix file_name ".ics" with
-
| false ->
-
Printf.eprintf "Skipping non ics file %s\n%!" file_path;
-
[]
+
| false -> []
| true -> (
try
let content = Eio.Path.load file in
+134 -65
lib/date.ml
···
open Result
+
let default_timezone =
+
ref (fun () ->
+
match Timedesc.Time_zone.local () with
+
| Some tz -> tz
+
| None -> Timedesc.Time_zone.utc)
+
let timedesc_to_ptime dt =
-
match Timedesc.to_timestamp_single dt |> Timedesc.Utils.ptime_of_timestamp with
+
match
+
Timedesc.to_timestamp_single dt |> Timedesc.Utils.ptime_of_timestamp
+
with
| Some t -> t
| None -> failwith "Invalid date conversion from Timedesc to Ptime"
-
let ptime_to_timedesc ptime =
+
let ptime_to_timedesc ?(tz = !default_timezone ()) ptime =
let ts = Timedesc.Utils.timestamp_of_ptime ptime in
-
match Timedesc.of_timestamp ts with
+
match Timedesc.of_timestamp ~tz_of_date_time:tz ts with
| Some dt -> dt
| None -> failwith "Invalid date conversion from Ptime to Timedesc"
let get_today =
-
ref (fun () ->
-
let now = Timedesc.now () in
+
ref (fun ?(tz = !default_timezone ()) () ->
+
let now = Timedesc.now ~tz_of_date_time:tz () in
let date = Timedesc.date now in
let midnight = Timedesc.Time.make_exn ~hour:0 ~minute:0 ~second:0 () in
-
let dt = Timedesc.of_date_and_time_exn date midnight in
+
let dt = Timedesc.of_date_and_time_exn ~tz date midnight in
timedesc_to_ptime dt)
(* Convert a midnight timestamp to end-of-day (23:59:59) *)
let to_end_of_day date =
let dt = ptime_to_timedesc date in
let date = Timedesc.date dt in
-
let end_of_day_time = Timedesc.Time.make_exn ~hour:23 ~minute:59 ~second:59 () in
+
let end_of_day_time =
+
Timedesc.Time.make_exn ~hour:23 ~minute:59 ~second:59 ()
+
in
let end_of_day = Timedesc.of_date_and_time_exn date end_of_day_time in
timedesc_to_ptime end_of_day
···
let new_dt = Timedesc.of_date_and_time_exn new_date time in
timedesc_to_ptime new_dt
-
let add_weeks date weeks =
-
add_days date (weeks * 7)
+
let add_weeks date weeks = add_days date (weeks * 7)
let add_months date months =
let dt = ptime_to_timedesc date in
···
let year = Timedesc.Ym.year old_ym in
let month = Timedesc.Ym.month old_ym in
let day = Timedesc.day dt in
-
+
(* Calculate new year and month *)
let total_month = (year * 12) + month - 1 + months in
let new_year = total_month / 12 in
let new_month = (total_month mod 12) + 1 in
-
+
(* Try to create new date, handling end of month cases properly *)
let rec adjust_day d =
match Timedesc.Date.Ymd.make ~year:new_year ~month:new_month ~day:d with
-
| Ok new_date ->
+
| Ok new_date ->
let time = Timedesc.time dt in
let new_dt = Timedesc.of_date_and_time_exn new_date time in
timedesc_to_ptime new_dt
-
| Error _ ->
+
| Error _ ->
if d > 1 then adjust_day (d - 1)
else failwith "Invalid date after adding months"
in
adjust_day day
-
let add_years date years =
-
add_months date (years * 12)
+
let add_years date years = add_months date (years * 12)
let get_start_of_week date =
let dt = ptime_to_timedesc date in
···
| `Sat -> 5
| `Sun -> 6
in
-
let monday_date = Timedesc.Date.sub ~days:days_to_subtract (Timedesc.date dt) in
+
let monday_date =
+
Timedesc.Date.sub ~days:days_to_subtract (Timedesc.date dt)
+
in
let midnight = Timedesc.Time.make_exn ~hour:0 ~minute:0 ~second:0 () in
-
let monday_with_midnight = Timedesc.of_date_and_time_exn monday_date midnight in
+
let monday_with_midnight =
+
Timedesc.of_date_and_time_exn monday_date midnight
+
in
timedesc_to_ptime monday_with_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_start_of_current_week ?(tz = !default_timezone ()) () =
+
get_start_of_week (!get_today ~tz ())
+
+
let get_start_of_next_week ?(tz = !default_timezone ()) () =
+
add_days (get_start_of_current_week ~tz ()) 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_end_of_current_week ?(tz = !default_timezone ()) () =
+
get_end_of_week (!get_today ~tz ())
+
+
let get_end_of_next_week ?(tz = !default_timezone ()) () =
+
get_end_of_week (get_start_of_next_week ~tz ())
let get_start_of_month date =
let dt = ptime_to_timedesc date in
let year = Timedesc.year dt in
let month = Timedesc.month dt in
-
+
(* Create a date for the first of the month *)
match Timedesc.Date.Ymd.make ~year ~month ~day:1 with
| Ok first_day ->
···
timedesc_to_ptime first_of_month
| Error _ -> failwith "Invalid date for start 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_start_of_current_month ?(tz = !default_timezone ()) () =
+
get_start_of_month (!get_today ~tz ())
+
+
let get_start_of_next_month ?(tz = !default_timezone ()) () =
+
add_months (get_start_of_current_month ~tz ()) 1
let get_end_of_month date =
let dt = ptime_to_timedesc date in
let year = Timedesc.year dt in
let month = Timedesc.month dt in
-
+
(* Determine next month and year *)
let next_month_int = if month == 12 then 1 else month + 1 in
let next_month_year = if month == 12 then year + 1 else year in
-
+
(* Create a date for the first of next month *)
-
match Timedesc.Date.Ymd.make ~year:next_month_year ~month:next_month_int ~day:1 with
-
| Ok first_of_next_month ->
+
match
+
Timedesc.Date.Ymd.make ~year:next_month_year ~month:next_month_int ~day:1
+
with
+
| Ok first_of_next_month -> (
(* Create the timestamp and subtract 1 second *)
let midnight = Timedesc.Time.make_exn ~hour:0 ~minute:0 ~second:0 () in
-
let first_of_next_month_dt = Timedesc.of_date_and_time_exn first_of_next_month midnight in
+
let first_of_next_month_dt =
+
Timedesc.of_date_and_time_exn first_of_next_month midnight
+
in
let one_second = Timedesc.Span.For_human.make_exn ~seconds:1 () in
-
let end_of_month_ts =
+
let end_of_month_ts =
match Timedesc.to_timestamp first_of_next_month_dt with
| `Single ts -> Timedesc.Span.sub ts one_second
| `Ambiguous (ts, _) -> Timedesc.Span.sub ts one_second
in
-
(match Timedesc.of_timestamp end_of_month_ts with
+
match Timedesc.of_timestamp end_of_month_ts with
| Some end_of_month -> timedesc_to_ptime end_of_month
| None -> failwith "Invalid timestamp for end of month")
| Error _ -> failwith "Invalid date for end 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 ())
+
let get_end_of_current_month ?(tz = !default_timezone ()) () =
+
get_end_of_month (!get_today ~tz ())
-
let convert_relative_date_formats ~today ~tomorrow ~week ~month =
+
let get_end_of_next_month ?(tz = !default_timezone ()) () =
+
get_end_of_month (get_start_of_next_month ~tz ())
+
+
let convert_relative_date_formats ?(tz = !default_timezone ()) ~today ~tomorrow
+
~week ~month () =
if today then
-
let today_date = !get_today () in
+
let today_date = !get_today ~tz () 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 today = !get_today ~tz () 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_start = get_start_of_current_week ~tz () 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_start = get_start_of_current_month ~tz () in
let month_end = get_end_of_month month_start in
Some (month_start, month_end)
else None
···
let ( let* ) = Result.bind
(* Parse a date string that could be ISO format or a relative expression *)
-
let parse_date expr parameter =
+
let parse_date ?(tz = !default_timezone ()) expr parameter =
let iso_date_regex = Re.Pcre.regexp "^(\\d{4})-(\\d{2})-(\\d{2})$" in
let relative_regex = Re.Pcre.regexp "^([+-])(\\d+)([dwm])$" in
match expr with
-
| "today" -> Ok (!get_today ())
-
| "tomorrow" -> Ok (add_days (!get_today ()) 1)
-
| "yesterday" -> Ok (add_days (!get_today ()) (-1))
+
| "today" -> Ok (!get_today ~tz ())
+
| "tomorrow" -> Ok (add_days (!get_today ~tz ()) 1)
+
| "yesterday" -> Ok (add_days (!get_today ~tz ()) (-1))
| "this-week" -> (
match parameter with
-
| `From -> Ok (get_start_of_current_week ())
-
| `To -> Ok (get_end_of_current_week ()))
+
| `From -> Ok (get_start_of_current_week ~tz ())
+
| `To -> Ok (get_end_of_current_week ~tz ()))
| "next-week" -> (
match parameter with
-
| `From -> Ok (get_start_of_next_week ())
-
| `To -> Ok (get_end_of_next_week ()))
+
| `From -> Ok (get_start_of_next_week ~tz ())
+
| `To -> Ok (get_end_of_next_week ~tz ()))
| "this-month" -> (
match parameter with
-
| `From -> Ok (get_start_of_current_month ())
-
| `To -> Ok (get_end_of_current_month ()))
+
| `From -> Ok (get_start_of_current_month ~tz ())
+
| `To -> Ok (get_end_of_current_month ~tz ()))
| "next-month" -> (
match parameter with
-
| `From -> Ok (get_start_of_next_month ())
-
| `To -> Ok (get_end_of_next_month ()))
+
| `From -> Ok (get_start_of_next_month ~tz ())
+
| `To -> Ok (get_end_of_next_month ~tz ()))
| _ ->
(* Try to parse as ISO date *)
if Re.Pcre.pmatch ~rex:iso_date_regex expr then
···
in
match Timedesc.Date.Ymd.make ~year ~month ~day with
| Ok date ->
-
let midnight = Timedesc.Time.make_exn ~hour:0 ~minute:0 ~second:0 () in
-
let dt = Timedesc.of_date_and_time_exn date midnight in
+
let midnight =
+
Timedesc.Time.make_exn ~hour:0 ~minute:0 ~second:0 ()
+
in
+
let dt = Timedesc.of_date_and_time_exn ~tz date midnight in
Ok (timedesc_to_ptime dt)
| Error _ -> Error (`Msg (Printf.sprintf "Invalid date: %s" expr))
(* Try to parse as relative expression +Nd, -Nd, etc. *)
···
in
let multiplier = if sign = "+" then 1 else -1 in
let value = num * multiplier in
-
let today = !get_today () in
+
let today = !get_today ~tz () in
match unit with
| "d" -> Ok (add_days today value)
| "w" -> (
···
Error
(`Msg (Printf.sprintf "Error parsing time: %s" (Printexc.to_string e)))
-
let parse_date_time ~date ~time parameter =
-
let* date_ptime = parse_date date parameter in
-
let* (h, min, s) = parse_time time in
-
-
let dt = ptime_to_timedesc date_ptime in
+
let parse_date_time ?(tz = !default_timezone ()) ~date ~time parameter =
+
let* date_ptime = parse_date date parameter ~tz in
+
let* h, min, s = parse_time time in
+
+
let dt = ptime_to_timedesc ~tz date_ptime in
let date_part = Timedesc.date dt in
-
+
(* Create time *)
match Timedesc.Time.make ~hour:h ~minute:min ~second:s () with
-
| Ok time_part ->
+
| Ok time_part -> (
(* Combine date and time *)
-
(match Timedesc.of_date_and_time date_part time_part with
+
match Timedesc.of_date_and_time ~tz date_part time_part with
| Ok combined -> Ok (timedesc_to_ptime combined)
| Error _ -> Error (`Msg "Invalid date-time combination"))
| Error _ -> Error (`Msg "Invalid time for date-time combination")
-
let parse_date_time_opt ~date ?time parameter =
-
match time with
-
| None -> parse_date date parameter
-
| Some time -> parse_date_time ~date ~time parameter
+
let ptime_of_ical = function
+
| `Datetime (`Utc t) -> t
+
| `Datetime (`Local t) ->
+
let system_tz =
+
match Timedesc.Time_zone.local () with
+
| Some tz -> tz
+
| None -> Timedesc.Time_zone.utc
+
in
+
let ts = Timedesc.Utils.timestamp_of_ptime t in
+
let dt =
+
match Timedesc.of_timestamp ~tz_of_date_time:system_tz ts with
+
| Some dt -> dt
+
| None -> failwith "Invalid local date conversion"
+
in
+
timedesc_to_ptime dt
+
| `Datetime (`With_tzid (t, (_, tzid))) ->
+
let tz =
+
match Timedesc.Time_zone.make tzid with
+
| Some tz -> tz
+
| None ->
+
failwith
+
(Printf.sprintf
+
"Warning: Unknown timezone %s, falling back to UTC\n" tzid)
+
in
+
let ts = Timedesc.Utils.timestamp_of_ptime t in
+
let dt =
+
match Timedesc.of_timestamp ~tz_of_date_time:tz ts with
+
| Some dt -> dt
+
| None -> failwith "Invalid timezone date conversion"
+
in
+
timedesc_to_ptime dt
+
| `Date date -> (
+
let y, m, d = date in
+
match Timedesc.Date.Ymd.make ~year:y ~month:m ~day:d with
+
| Ok new_date ->
+
let midnight = Timedesc.Time.make_exn ~hour:0 ~minute:0 ~second:0 () in
+
let new_dt = Timedesc.of_date_and_time_exn new_date midnight in
+
timedesc_to_ptime new_dt
+
| Error _ ->
+
failwith (Printf.sprintf "Invalid date %d-%d-%d" y m d))
+65 -37
lib/date.mli
···
-
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 default_timezone : (unit -> Timedesc.Time_zone.t) ref
+
(** Default timezone to use for date operations. Defaults to the local timezone
+
of the system, falling back to UTC if local timezone cannot be determined.
+
*)
+
+
val timedesc_to_ptime : Timedesc.t -> Ptime.t
+
(** Convert a Timedesc.t to a Ptime.t. *)
+
+
val ptime_to_timedesc : ?tz:Timedesc.Time_zone.t -> Ptime.t -> Timedesc.t
+
(** Convert a Ptime.t to a Timedesc.t with the specified timezone. If no
+
timezone is provided, uses the default_timezone. *)
+
+
val get_today : (?tz:Timedesc.Time_zone.t -> unit -> Ptime.t) ref
+
(** Get the current date at midnight in the specified timezone. If no timezone
+
is provided, uses the default_timezone. 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
···
(** 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_current_week : ?tz:Timedesc.Time_zone.t -> unit -> Ptime.t
+
(** Get the start of the current week in the specified timezone. If no timezone
+
is provided, uses the default_timezone. Raises an exception if the date
+
cannot be calculated. *)
-
val get_start_of_next_week : unit -> Ptime.t
-
(** Get the start of next week. Raises an exception if the date cannot be
-
calculated. *)
+
val get_start_of_next_week : ?tz:Timedesc.Time_zone.t -> unit -> Ptime.t
+
(** Get the start of next week in the specified timezone. If no timezone is
+
provided, uses the default_timezone. Raises an exception if the date cannot
+
be calculated. *)
val get_end_of_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_current_week : ?tz:Timedesc.Time_zone.t -> unit -> Ptime.t
+
(** Get the end of the current week in the specified timezone. If no timezone is
+
provided, uses the default_timezone. Raises an exception if the date cannot
+
be calculated. *)
-
val get_end_of_next_week : unit -> Ptime.t
-
(** Get the end of next week. Raises an exception if the date cannot be
-
calculated. *)
+
val get_end_of_next_week : ?tz:Timedesc.Time_zone.t -> unit -> Ptime.t
+
(** Get the end of next week in the specified timezone. If no timezone is
+
provided, uses the default_timezone. Raises an exception if the date cannot
+
be calculated. *)
val get_start_of_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_current_month : ?tz:Timedesc.Time_zone.t -> unit -> Ptime.t
+
(** Get the start of the current month in the specified timezone. If no timezone
+
is provided, uses the default_timezone. Raises an exception if the date
+
cannot be calculated. *)
-
val get_start_of_next_month : unit -> Ptime.t
-
(** Get the start of next month. Raises an exception if the date cannot be
-
calculated. *)
+
val get_start_of_next_month : ?tz:Timedesc.Time_zone.t -> unit -> Ptime.t
+
(** Get the start of next month in the specified timezone. If no timezone is
+
provided, uses the default_timezone. Raises an exception if the date cannot
+
be calculated. *)
-
val get_end_of_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_current_month : ?tz:Timedesc.Time_zone.t -> unit -> Ptime.t
+
(** Get the end of the current month in the specified timezone. If no timezone
+
is provided, uses the default_timezone. Raises an exception if the date
+
cannot be calculated. *)
-
val get_end_of_next_month : unit -> Ptime.t
-
(** Get the end of next month. Raises an exception if the date cannot be
-
calculated. *)
+
val get_end_of_next_month : ?tz:Timedesc.Time_zone.t -> unit -> Ptime.t
+
(** Get the end of next month in the specified timezone. If no timezone is
+
provided, uses the default_timezone. Raises an exception if the date cannot
+
be calculated. *)
val get_end_of_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 convert_relative_date_formats :
+
?tz:Timedesc.Time_zone.t ->
today:bool ->
tomorrow:bool ->
week:bool ->
month:bool ->
+
unit ->
(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. **)
+
(** Converts relative date formats to determine from/to dates in the specified
+
timezone. If no timezone is provided, uses the default_timezone. Returns a
+
tuple of (start_date, end_date) or raises an exception if the dates could
+
not be determined. **)
val parse_date :
-
string -> [ `To | `From ] -> (Ptime.t, [> `Msg of string ]) result
+
?tz:Timedesc.Time_zone.t ->
+
string ->
+
[ `To | `From ] ->
+
(Ptime.t, [> `Msg of string ]) result
(** Parse a date string that could be ISO format (YYYY-MM-DD) or a relative
-
expression.
+
expression in the specified timezone. If no timezone is provided, uses the
+
default_timezone.
Supported formats:
- ISO format: "YYYY-MM-DD"
···
minute, second) or Error with a message. **)
val parse_date_time :
+
?tz:Timedesc.Time_zone.t ->
date:string ->
time:string ->
[ `To | `From ] ->
(Ptime.t, [> `Msg of string ]) result
+
(** Parse a date and time string in the specified timezone. If no timezone is
+
provided, uses the default_timezone. *)
-
val parse_date_time_opt :
-
date:string ->
-
?time:string ->
-
[ `To | `From ] ->
-
(Ptime.t, [> `Msg of string ]) result
+
val ptime_of_ical : Icalendar.date_or_datetime -> Ptime.t
+18 -26
lib/event.ml
···
type t = { collection : Collection.t; file_name : string; ical : event }
type date_error = [ `Msg of string ]
-
(* TODO handle timezones *)
-
let ptime_of_datetime = function
-
| `Datetime (`Utc t) -> t
-
| `Datetime (`Local t) -> t
-
| `Datetime (`With_tzid (t, _)) -> t
-
| `Date date -> (
-
match Ptime.of_date date with
-
| Some t -> t
-
| None ->
-
let year, month, day = date in
-
failwith (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 uuid = generate_uuid () in
let uid = (Params.empty, uuid) in
let file_name = uuid ^ ".ics" in
-
let dtstart = (Params.empty, `Datetime (`Utc start)) in
-
let dtend_or_duration =
-
Option.map (fun d -> `Dtend (Params.empty, `Datetime (`Utc d))) end_
-
in
+
let dtstart = (Params.empty, start) in
+
let dtend_or_duration = end_ in
let rrule = Option.map (fun r -> (Params.empty, r)) recurrence in
let now = Ptime_clock.now () in
let props = [ `Summary (Params.empty, summary) ] in
···
let now = Ptime_clock.now () in
let uid = t.ical.uid in
let dtstart =
-
match start with
-
| None -> t.ical.dtstart
-
| Some s -> (Params.empty, `Datetime (`Utc s))
+
match start with None -> t.ical.dtstart | Some s -> (Params.empty, s)
in
let dtend_or_duration =
-
match end_ with
-
| None -> t.ical.dtend_or_duration
-
| Some d -> Some (`Dtend (Params.empty, `Datetime (`Utc d)))
+
match end_ with None -> t.ical.dtend_or_duration | Some _ -> end_
in
let rrule =
match recurrence with
···
| s :: _ -> Some s
| _ -> None
-
let get_start t = ptime_of_datetime (snd t.ical.dtstart)
+
let get_start t = Date.ptime_of_ical (snd t.ical.dtstart)
let get_end t =
match t.ical.dtend_or_duration with
-
| Some (`Dtend (_, d)) -> Some (ptime_of_datetime d)
+
| Some (`Dtend (_, d)) -> Some (Date.ptime_of_ical d)
| Some (`Duration (_, span)) -> (
let start = get_start t in
match Ptime.add_span start span with
···
(Printf.sprintf "%.2fs" (Ptime.Span.to_float_s span))))
| None -> None
+
let get_start_timezone t =
+
match t.ical.dtstart with
+
| _, `Datetime (`With_tzid (_, (_, tzid))) -> Some tzid
+
| _ -> None
+
+
let get_end_timezone t =
+
match t.ical.dtend_or_duration with
+
| Some (`Dtend (_, `Datetime (`With_tzid (_, (_, tzid))))) -> Some tzid
+
| _ -> None
+
let get_duration t =
match t.ical.dtend_or_duration with
| Some (`Duration (_, span)) -> Some span
| Some (`Dtend (_, e)) ->
-
let span = Ptime.diff (ptime_of_datetime e) (get_start t) in
+
let span = Ptime.diff (Date.ptime_of_ical e) (get_start t) in
Some span
| None -> None
-
let get_day_event t =
+
let is_date t =
match (t.ical.dtstart, t.ical.dtend_or_duration) with
| (_, `Date _), _ -> true
| _, Some (`Dtend (_, `Date _)) -> true
+31 -8
lib/event.mli
···
(** Event type representing a calendar event *)
type date_error = [ `Msg of string ]
-
(** Type for date-related errors *)
val create :
summary:string ->
-
start:Ptime.t ->
-
?end_:Ptime.t ->
+
start:Icalendar.date_or_datetime ->
+
?end_:
+
[ `Duration of Icalendar.params * Ptime.Span.t
+
| `Dtend of Icalendar.params * Icalendar.date_or_datetime ] ->
?location:string ->
?description:string ->
?recurrence:Icalendar.recurrence ->
Collection.t ->
t
-
(** Create a new event with required properties *)
+
(** Create a new event with required properties.
+
+
The start and end times can be specified as Icalendar.timestamp values,
+
which allows for directly using any of the three RFC5545 time formats:
+
- `Utc time: Fixed to absolute UTC time
+
- `Local time: Floating local time (follows user's timezone)
+
- `With_tzid (time, timezone): Local time with timezone reference *)
val edit :
?summary:string ->
-
?start:Ptime.t ->
-
?end_:Ptime.t ->
+
?start:Icalendar.date_or_datetime ->
+
?end_:
+
[ `Duration of Icalendar.params * Ptime.Span.t
+
| `Dtend of Icalendar.params * Icalendar.date_or_datetime ] ->
?location:string ->
?description:string ->
?recurrence:Icalendar.recurrence ->
t ->
t
-
(** Edit an existing event *)
+
(** Edit an existing event. *)
val of_icalendar : Collection.t -> file_name:string -> Icalendar.event -> t
(** Convert an Icalendar event to our event type. *)
···
val get_id : t -> event_id
val get_summary : t -> string option
+
val get_start : t -> Ptime.t
+
(** Get the start time of an event. Note that local times are converted to UTC
+
based on their timezone information. If no timezone is specified, the system
+
timezone is used. *)
+
val get_end : t -> Ptime.t option
+
(** Get the end time of an event. Like get_start, times are converted to UTC
+
based on timezone information. Returns None if the event doesn't have an end
+
time. *)
+
+
val is_date : t -> bool
+
(** Returns true if either the start or end timestamp is specified as a date
+
instead of a datetime. *)
+
+
val get_start_timezone : t -> string option
+
val get_end_timezone : t -> string option
val get_duration : t -> Ptime.span option
-
val get_day_event : t -> bool
val get_location : t -> string option
val get_description : t -> string option
val get_recurrence : t -> Icalendar.recurrence option
+79 -66
lib/format.ml
···
-
type format = [ `Text | `Json | `Csv | `Ics | `Entries | `Sexp ]
-
-
let ptime_to_timedesc ptime =
-
let ts = Timedesc.Utils.timestamp_of_ptime ptime in
-
match Timedesc.of_timestamp ts with
-
| Some dt -> dt
-
| None -> failwith "Invalid date conversion from Ptime to Timedesc"
+
type format = [ `Text | `Entries | `Json | `Csv | `Ics | `Sexp ]
-
let format_date date =
-
let dt = ptime_to_timedesc date in
+
let format_date ?tz date =
+
let dt = Date.ptime_to_timedesc ?tz date in
let y = Timedesc.year dt in
let m = Timedesc.month dt in
let d = Timedesc.day dt in
···
in
Printf.sprintf "%04d-%02d-%02d %s" y m d weekday
-
let format_time date =
-
let _, ((h, m, _), _) = Ptime.to_date_time date in
+
let format_time ?tz date =
+
let dt = Date.ptime_to_timedesc ?tz date in
+
let h = Timedesc.hour dt in
+
let m = Timedesc.minute dt in
Printf.sprintf "%02d:%02d" h m
-
let format_datetime date =
-
Printf.sprintf "%s %s" (format_date date) (format_time date)
+
let format_datetime ?tz date =
+
let tz_str =
+
match tz with
+
| Some tz -> Printf.sprintf "(%s)" (Timedesc.Time_zone.name tz)
+
| None -> ""
+
in
+
Printf.sprintf "%s %s%s" (format_date ?tz date) (format_time ?tz date) tz_str
let next_day day ~next =
let y1, m1, d1 = Ptime.to_date day in
···
recur_to_ics recur)
l
-
let format_alt ~format ~start ~end_ event =
+
let format_alt ~format ~start ~end_ ?tz event =
let open Event in
match format with
| `Text ->
let id = get_id event in
-
let start_date = " " ^ format_date start in
+
let start_date = " " ^ format_date ?tz start in
let start_time =
-
match get_day_event event with
+
match is_date event with
| true -> ""
-
| false -> " " ^ format_time start
+
| false -> " " ^ format_time ?tz start
in
let end_date, end_time =
match end_ with
| None -> ("", "")
| Some end_ -> (
-
match (get_day_event event, next_day start ~next:end_) with
+
match (is_date event, next_day start ~next:end_) with
| true, true -> ("", "")
-
| true, _ -> (" - " ^ format_date end_, "")
-
| false, true -> ("", " - " ^ format_time end_)
-
| false, _ -> (" - " ^ format_date end_, " " ^ format_time end_))
+
| true, _ -> (" - " ^ format_date ?tz end_, "")
+
| false, true -> ("", " - " ^ format_time ?tz end_)
+
| false, _ ->
+
(" - " ^ format_date ?tz end_, " " ^ format_time ?tz end_))
in
let summary =
match get_summary event with
···
in
Printf.sprintf "%-45s%s%s%s%s%s%s" id start_date start_time end_date
end_time summary location
+
| `Entries ->
+
let format_opt label f opt =
+
Option.map (fun x -> Printf.sprintf "%s: %s\n" label (f x)) opt
+
|> Option.value ~default:""
+
in
+
let format timezone datetime =
+
match is_date event with
+
| true -> format_date ?tz datetime
+
| false -> (
+
format_datetime ?tz datetime
+
^ match timezone with None -> "" | Some t -> " (" ^ t ^ ")")
+
in
+
let start_str =
+
format_opt "Start" (format (get_start_timezone event)) (Some start)
+
in
+
let end_str = format_opt "End" (format (get_end_timezone event)) end_ in
+
let location_str = format_opt "Location" Fun.id (get_location event) in
+
let description_str =
+
format_opt "Description" Fun.id (get_description event)
+
in
+
let rrule_str =
+
Option.map
+
(fun r ->
+
let buf = Buffer.create 128 in
+
recurs_to_ics r buf;
+
Printf.sprintf "%s: %s\n" "Reccurence" (Buffer.contents buf))
+
(get_recurrence event)
+
|> Option.value ~default:""
+
in
+
let summary_str = format_opt "Summary" Fun.id (get_summary event) in
+
Printf.sprintf "%s%s%s%s%s%s" summary_str start_str end_str location_str
+
description_str rrule_str
| `Json ->
let open Yojson.Safe in
let json =
···
match get_summary event with
| Some summary -> `String summary
| None -> `Null );
-
("start", `String (format_datetime start));
+
("start", `String (format_datetime ?tz start));
( "end",
match end_ with
-
| Some e -> `String (format_datetime e)
+
| Some e -> `String (format_datetime ?tz e)
| None -> `Null );
( "location",
match get_location event with
···
let summary =
match get_summary event with Some summary -> summary | None -> ""
in
-
let start = format_datetime start in
+
let start = format_datetime ?tz start in
let end_str =
-
match end_ with Some e -> format_datetime e | None -> ""
+
match end_ with Some e -> format_datetime ?tz e | None -> ""
in
let location =
match get_location event with Some loc -> loc | None -> ""
···
let cal_props = [] in
let event_ical = Event.to_icalendar event in
Icalendar.to_ics ~cr:true (cal_props, [ `Event event_ical ])
-
| `Entries ->
-
let summary =
-
match get_summary event with Some summary -> summary | None -> ""
-
in
-
let start = format_datetime start in
-
let end_str =
-
match end_ with Some e -> format_datetime e | None -> ""
-
in
-
let location =
-
match get_location event with Some loc -> loc | None -> ""
-
in
-
let description =
-
match get_description event with Some desc -> desc | None -> ""
-
in
-
let rrule =
-
match get_recurrence event with
-
| Some r ->
-
let buf = Buffer.create 128 in
-
recurs_to_ics r buf;
-
Buffer.contents buf
-
| None -> ""
-
in
-
Printf.sprintf "%s: %s\n%s: %s\n%s: %s\n%s: %s\n%s: %s\n%s: %s" "Summary"
-
summary "Start" start "End" end_str "Location" location "Description"
-
description "Reccurence" rrule
| `Sexp ->
let summary =
match get_summary event with Some summary -> summary | None -> ""
in
let start_date, start_time =
-
let dt = ptime_to_timedesc start in
+
let dt = Date.ptime_to_timedesc ?tz start in
let y = Timedesc.year dt in
let m = Timedesc.month dt in
let d = Timedesc.day dt in
···
let end_str =
match end_ with
| Some end_date ->
-
let dt = ptime_to_timedesc end_date in
+
let dt = Date.ptime_to_timedesc ?tz end_date in
let y = Timedesc.year dt in
let m = Timedesc.month dt in
let d = Timedesc.day dt in
···
(String.escaped id) (String.escaped summary) start_date start_time
end_str location description calendar
-
let format_event ?(format = `Text) event =
+
let format_event ?(format = `Text) ?tz event =
format_alt ~format ~start:(Event.get_start event) ~end_:(Event.get_end event)
-
event
+
?tz event
-
let format_instance ?(format = `Text) instance =
+
let format_instance ?(format = `Text) ?tz instance =
let open Recur in
-
format_alt ~format ~start:instance.start ~end_:instance.end_ instance.event
+
format_alt ~format ~start:instance.start ~end_:instance.end_ ?tz
+
instance.event
-
let format_events ?(format = `Text) events =
+
let format_events ?(format = `Text) ?tz events =
match format with
| `Json ->
let json_events =
List.map
-
(fun e -> Yojson.Safe.from_string (format_event ~format:`Json e))
+
(fun e -> Yojson.Safe.from_string (format_event ~format:`Json ?tz e))
events
in
Yojson.Safe.to_string (`List json_events)
| `Csv ->
"\"Summary\",\"Start\",\"End\",\"Location\",\"Calendar\"\n"
-
^ String.concat "\n" (List.map (format_event ~format:`Csv) events)
+
^ String.concat "\n" (List.map (format_event ~format:`Csv ?tz) events)
| `Sexp ->
"("
^ String.concat "\n "
-
(List.map (fun e -> format_event ~format:`Sexp e) events)
+
(List.map (fun e -> format_event ~format:`Sexp ?tz e) events)
^ ")"
-
| _ -> String.concat "\n" (List.map (fun e -> format_event ~format e) events)
+
| _ ->
+
String.concat "\n" (List.map (fun e -> format_event ~format ?tz e) events)
-
let format_instances ?(format = `Text) instances =
+
let format_instances ?(format = `Text) ?tz instances =
match format with
| `Json ->
let json_instances =
List.map
-
(fun e -> Yojson.Safe.from_string (format_instance ~format:`Json e))
+
(fun e ->
+
Yojson.Safe.from_string (format_instance ~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 ~format:`Csv) instances)
+
^ String.concat "\n"
+
(List.map (format_instance ~format:`Csv ?tz) instances)
| `Sexp ->
"("
^ String.concat "\n "
-
(List.map (fun e -> format_instance ~format:`Sexp e) instances)
+
(List.map (fun e -> format_instance ~format:`Sexp ?tz e) instances)
^ ")"
| _ ->
String.concat "\n"
-
(List.map (fun e -> format_instance ~format e) instances)
+
(List.map (fun e -> format_instance ~format ?tz e) instances)
+13 -9
lib/format.mli
···
(** Functions for formatting various data structures as strings *)
-
type format = [ `Text | `Json | `Csv | `Ics | `Entries | `Sexp ]
+
type format = [ `Text | `Entries | `Json | `Csv | `Ics | `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_event :
+
?format:format -> ?tz:Timedesc.Time_zone.t -> Event.t -> string
+
(** Format a single event, optionally using the specified timezone *)
-
val format_instance : ?format:format -> Recur.instance -> string
-
(** Format a single event instance *)
+
val format_instance :
+
?format:format -> ?tz:Timedesc.Time_zone.t -> Recur.instance -> string
+
(** Format a single event instance, optionally using the specified timezone *)
-
val format_events : ?format:format -> Event.t list -> string
-
(** Format a list of events *)
+
val format_events :
+
?format:format -> ?tz:Timedesc.Time_zone.t -> Event.t list -> string
+
(** Format a list of events, optionally using the specified timezone *)
-
val format_instances : ?format:format -> Recur.instance list -> string
-
(** Format a list of event instances *)
+
val format_instances :
+
?format:format -> ?tz:Timedesc.Time_zone.t -> Recur.instance list -> string
+
(** Format a list of event instances, optionally using the specified timezone *)
+1
lib/query.ml
···
| _ -> sorted_events)
let query ~fs calendar_dir ?filter ~from ~to_ ?sort_by ?order ?limit () =
+
Fmt.epr "Querying from %a to %a\n%!" Ptime.pp (Option.get from) Ptime.pp to_;
match query_events ~fs calendar_dir ?filter ?sort_by ?order () with
| Ok events ->
let instances =
+5 -3
lib/recur.ml
···
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 *)
+
(* 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.is_earlier start ~than:to_
+
Ptime.compare start to_ < 0
&&
-
match from with Some f -> Ptime.is_later end_ ~than:f | None -> true
+
(* 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
+9 -5
test/test_query.ml
···
let fixed_date = Option.get @@ Ptime.of_date_time ((2025, 3, 27), ((0, 0, 0), 0))
let setup_fixed_date () =
-
(Date.get_today := fun () -> fixed_date);
+
(Date.get_today := fun ?tz:_ () -> fixed_date);
fixed_date
let calendar_dir_path = Filename.concat (Sys.getcwd ()) "calendar"
···
(* 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;
+
~location:"Conference Room A"
+
~start:(`Datetime (`Utc fixed_date));
(* Event with mixed case to test case insensitivity *)
create_test_event ~collection:"search_test" ~summary:"IMPORTANT Meeting"
~description:"Critical project review with stakeholders"
-
~location:"Executive Suite" ~start:fixed_date;
+
~location:"Executive Suite"
+
~start:(`Datetime (`Utc fixed_date));
(* Event with word fragments *)
create_test_event ~collection:"search_test" ~summary:"Conference Call"
~description:"International conference preparation"
-
~location:"Remote Meeting Room" ~start:fixed_date;
+
~location:"Remote Meeting Room"
+
~start:(`Datetime (`Utc fixed_date));
(* Event with unique text in each field *)
create_test_event ~collection:"search_test" ~summary:"Workshop on Testing"
~description:"Quality Assurance techniques and practices"
-
~location:"Training Center" ~start:fixed_date;
+
~location:"Training Center"
+
~start:(`Datetime (`Utc fixed_date));
]
(* Test helper to verify if a list of events contains an event with a given summary *)
+2 -1
test/test_recur.ml
···
let recurrence = (`Weekly, None, Some 1, []) in
(* Weekly recurrence *)
let recurring_event =
-
Event.create ~summary:"Weekly Recurring Event" ~start:event_start
+
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 =