Command-line and Emacs Calendar Client
1open Result 2 3let default_timezone = 4 ref (fun () -> 5 match Timedesc.Time_zone.local () with 6 | Some tz -> tz 7 | None -> Timedesc.Time_zone.utc) 8 9let timedesc_to_ptime dt = 10 match 11 Timedesc.to_timestamp_single dt |> Timedesc.Utils.ptime_of_timestamp 12 with 13 | Some t -> t 14 | None -> failwith "Invalid date conversion from Timedesc to Ptime" 15 16let ptime_to_timedesc ?(tz = !default_timezone ()) ptime = 17 let ts = Timedesc.Utils.timestamp_of_ptime ptime in 18 match Timedesc.of_timestamp ~tz_of_date_time:tz ts with 19 | Some dt -> dt 20 | None -> failwith "Invalid date conversion from Ptime to Timedesc" 21 22let get_today = 23 ref (fun ?(tz = !default_timezone ()) () -> 24 let now = Timedesc.now ~tz_of_date_time:tz () in 25 let date = Timedesc.date now in 26 let midnight = Timedesc.Time.make_exn ~hour:0 ~minute:0 ~second:0 () in 27 let dt = Timedesc.of_date_and_time_exn ~tz date midnight in 28 timedesc_to_ptime dt) 29 30(* Convert a midnight timestamp to end-of-day (23:59:59) *) 31let to_end_of_day date = 32 let dt = ptime_to_timedesc date in 33 let date = Timedesc.date dt in 34 let end_of_day_time = 35 Timedesc.Time.make_exn ~hour:23 ~minute:59 ~second:59 () 36 in 37 let end_of_day = Timedesc.of_date_and_time_exn date end_of_day_time in 38 timedesc_to_ptime end_of_day 39 40let add_days date days = 41 let dt = ptime_to_timedesc date in 42 let date = Timedesc.date dt in 43 let new_date = Timedesc.Date.add ~days date in 44 let time = Timedesc.time dt in 45 let new_dt = Timedesc.of_date_and_time_exn new_date time in 46 timedesc_to_ptime new_dt 47 48let add_weeks date weeks = add_days date (weeks * 7) 49 50let add_months date months = 51 let dt = ptime_to_timedesc date in 52 let old_ym = Timedesc.ym dt in 53 let year = Timedesc.Ym.year old_ym in 54 let month = Timedesc.Ym.month old_ym in 55 let day = Timedesc.day dt in 56 57 (* Calculate new year and month *) 58 let total_month = (year * 12) + month - 1 + months in 59 let new_year = total_month / 12 in 60 let new_month = (total_month mod 12) + 1 in 61 62 (* Try to create new date, handling end of month cases properly *) 63 let rec adjust_day d = 64 match Timedesc.Date.Ymd.make ~year:new_year ~month:new_month ~day:d with 65 | Ok new_date -> 66 let time = Timedesc.time dt in 67 let new_dt = Timedesc.of_date_and_time_exn new_date time in 68 timedesc_to_ptime new_dt 69 | Error _ -> 70 if d > 1 then adjust_day (d - 1) 71 else failwith "Invalid date after adding months" 72 in 73 adjust_day day 74 75let add_years date years = add_months date (years * 12) 76 77let get_start_of_week date = 78 let dt = ptime_to_timedesc date in 79 let day_of_week = Timedesc.weekday dt in 80 let days_to_subtract = 81 match day_of_week with 82 | `Mon -> 0 83 | `Tue -> 1 84 | `Wed -> 2 85 | `Thu -> 3 86 | `Fri -> 4 87 | `Sat -> 5 88 | `Sun -> 6 89 in 90 let monday_date = 91 Timedesc.Date.sub ~days:days_to_subtract (Timedesc.date dt) 92 in 93 let midnight = Timedesc.Time.make_exn ~hour:0 ~minute:0 ~second:0 () in 94 let monday_with_midnight = 95 Timedesc.of_date_and_time_exn monday_date midnight 96 in 97 timedesc_to_ptime monday_with_midnight 98 99let get_start_of_current_week ?(tz = !default_timezone ()) () = 100 get_start_of_week (!get_today ~tz ()) 101 102let get_start_of_next_week ?(tz = !default_timezone ()) () = 103 add_days (get_start_of_current_week ~tz ()) 7 104 105let get_end_of_week date = add_days (get_start_of_week date) 6 106 107let get_end_of_current_week ?(tz = !default_timezone ()) () = 108 get_end_of_week (!get_today ~tz ()) 109 110let get_end_of_next_week ?(tz = !default_timezone ()) () = 111 get_end_of_week (get_start_of_next_week ~tz ()) 112 113let get_start_of_month date = 114 let dt = ptime_to_timedesc date in 115 let year = Timedesc.year dt in 116 let month = Timedesc.month dt in 117 118 (* Create a date for the first of the month *) 119 match Timedesc.Date.Ymd.make ~year ~month ~day:1 with 120 | Ok first_day -> 121 let midnight = Timedesc.Time.make_exn ~hour:0 ~minute:0 ~second:0 () in 122 let first_of_month = Timedesc.of_date_and_time_exn first_day midnight in 123 timedesc_to_ptime first_of_month 124 | Error _ -> failwith "Invalid date for start of month" 125 126let get_start_of_current_month ?(tz = !default_timezone ()) () = 127 get_start_of_month (!get_today ~tz ()) 128 129let get_start_of_next_month ?(tz = !default_timezone ()) () = 130 add_months (get_start_of_current_month ~tz ()) 1 131 132let get_end_of_month date = 133 let dt = ptime_to_timedesc date in 134 let year = Timedesc.year dt in 135 let month = Timedesc.month dt in 136 137 (* Determine next month and year *) 138 let next_month_int = if month == 12 then 1 else month + 1 in 139 let next_month_year = if month == 12 then year + 1 else year in 140 141 (* Create a date for the first of next month *) 142 match 143 Timedesc.Date.Ymd.make ~year:next_month_year ~month:next_month_int ~day:1 144 with 145 | Ok first_of_next_month -> ( 146 (* Create the timestamp and subtract 1 second *) 147 let midnight = Timedesc.Time.make_exn ~hour:0 ~minute:0 ~second:0 () in 148 let first_of_next_month_dt = 149 Timedesc.of_date_and_time_exn first_of_next_month midnight 150 in 151 let one_second = Timedesc.Span.For_human.make_exn ~seconds:1 () in 152 let end_of_month_ts = 153 match Timedesc.to_timestamp first_of_next_month_dt with 154 | `Single ts -> Timedesc.Span.sub ts one_second 155 | `Ambiguous (ts, _) -> Timedesc.Span.sub ts one_second 156 in 157 match Timedesc.of_timestamp end_of_month_ts with 158 | Some end_of_month -> timedesc_to_ptime end_of_month 159 | None -> failwith "Invalid timestamp for end of month") 160 | Error _ -> failwith "Invalid date for end of month" 161 162let get_end_of_current_month ?(tz = !default_timezone ()) () = 163 get_end_of_month (!get_today ~tz ()) 164 165let get_end_of_next_month ?(tz = !default_timezone ()) () = 166 get_end_of_month (get_start_of_next_month ~tz ()) 167 168let convert_relative_date_formats ?(tz = !default_timezone ()) ~today ~tomorrow 169 ~week ~month () = 170 if today then 171 let today_date = !get_today ~tz () in 172 (* Set the end date to end-of-day to include all events on that day *) 173 let end_of_today = to_end_of_day today_date in 174 Some (today_date, end_of_today) 175 else if tomorrow then 176 let today = !get_today ~tz () in 177 let tomorrow_date = add_days today 1 in 178 (* Set the end date to end-of-day to include all events on that day *) 179 let end_of_tomorrow = to_end_of_day tomorrow_date in 180 Some (tomorrow_date, end_of_tomorrow) 181 else if week then 182 let week_start = get_start_of_current_week ~tz () in 183 let week_end_date = add_days week_start 6 in 184 (* Sunday is 6 days from Monday *) 185 (* Set the end date to end-of-day to include all events on Sunday *) 186 let end_of_week = to_end_of_day week_end_date in 187 Some (week_start, end_of_week) 188 else if month then 189 let month_start = get_start_of_current_month ~tz () in 190 let month_end = get_end_of_month month_start in 191 Some (month_start, month_end) 192 else None 193 194let ( let* ) = Result.bind 195 196(* Parse a date string that could be ISO format or a relative expression *) 197let parse_date ?(tz = !default_timezone ()) expr parameter = 198 let iso_date_regex = Re.Pcre.regexp "^(\\d{4})-(\\d{2})-(\\d{2})$" in 199 let relative_regex = Re.Pcre.regexp "^([+-])(\\d+)([dwm])$" in 200 match expr with 201 | "today" -> Ok (!get_today ~tz ()) 202 | "tomorrow" -> Ok (add_days (!get_today ~tz ()) 1) 203 | "yesterday" -> Ok (add_days (!get_today ~tz ()) (-1)) 204 | "this-week" -> ( 205 match parameter with 206 | `From -> Ok (get_start_of_current_week ~tz ()) 207 | `To -> Ok (get_end_of_current_week ~tz ())) 208 | "next-week" -> ( 209 match parameter with 210 | `From -> Ok (get_start_of_next_week ~tz ()) 211 | `To -> Ok (get_end_of_next_week ~tz ())) 212 | "this-month" -> ( 213 match parameter with 214 | `From -> Ok (get_start_of_current_month ~tz ()) 215 | `To -> Ok (get_end_of_current_month ~tz ())) 216 | "next-month" -> ( 217 match parameter with 218 | `From -> Ok (get_start_of_next_month ~tz ()) 219 | `To -> Ok (get_end_of_next_month ~tz ())) 220 | _ -> 221 (* Try to parse as ISO date *) 222 if Re.Pcre.pmatch ~rex:iso_date_regex expr then 223 let year = 224 int_of_string 225 (Re.Pcre.get_substring (Re.Pcre.exec ~rex:iso_date_regex expr) 1) 226 in 227 let month = 228 int_of_string 229 (Re.Pcre.get_substring (Re.Pcre.exec ~rex:iso_date_regex expr) 2) 230 in 231 let day = 232 int_of_string 233 (Re.Pcre.get_substring (Re.Pcre.exec ~rex:iso_date_regex expr) 3) 234 in 235 match Timedesc.Date.Ymd.make ~year ~month ~day with 236 | Ok date -> 237 let midnight = 238 Timedesc.Time.make_exn ~hour:0 ~minute:0 ~second:0 () 239 in 240 let dt = Timedesc.of_date_and_time_exn ~tz date midnight in 241 Ok (timedesc_to_ptime dt) 242 | Error _ -> Error (`Msg (Printf.sprintf "Invalid date: %s" expr)) 243 (* Try to parse as relative expression +Nd, -Nd, etc. *) 244 else if Re.Pcre.pmatch ~rex:relative_regex expr then 245 let sign = 246 Re.Pcre.get_substring (Re.Pcre.exec ~rex:relative_regex expr) 1 247 in 248 let num = 249 int_of_string 250 (Re.Pcre.get_substring (Re.Pcre.exec ~rex:relative_regex expr) 2) 251 in 252 let unit = 253 Re.Pcre.get_substring (Re.Pcre.exec ~rex:relative_regex expr) 3 254 in 255 let multiplier = if sign = "+" then 1 else -1 in 256 let value = num * multiplier in 257 let today = !get_today ~tz () in 258 match unit with 259 | "d" -> Ok (add_days today value) 260 | "w" -> ( 261 let date = add_weeks today value in 262 match parameter with 263 | `From -> Ok (get_start_of_week date) 264 | `To -> Ok (get_end_of_week date)) 265 | "m" -> ( 266 let date = add_months today value in 267 match parameter with 268 | `From -> Ok (get_start_of_month date) 269 | `To -> Ok (get_end_of_month date)) 270 | _ -> Error (`Msg (Printf.sprintf "Invalid date unit: %s" unit)) 271 else Error (`Msg (Printf.sprintf "Invalid date format: %s" expr)) 272 273let parse_time str = 274 try 275 let regex = 276 Re.Perl.compile_pat "^([0-9]{1,2}):([0-9]{1,2})(?::([0-9]{1,2}))?$" 277 in 278 match Re.exec_opt regex str with 279 | Some groups -> 280 let hour = int_of_string (Re.Group.get groups 1) in 281 let minute = int_of_string (Re.Group.get groups 2) in 282 let second = 283 try int_of_string (Re.Group.get groups 3) with Not_found -> 0 284 in 285 if hour < 0 || hour > 23 then 286 Error (`Msg (Printf.sprintf "Invalid hour: %d" hour)) 287 else if minute < 0 || minute > 59 then 288 Error (`Msg (Printf.sprintf "Invalid minute: %d" minute)) 289 else if second < 0 || second > 59 then 290 Error (`Msg (Printf.sprintf "Invalid second: %d" second)) 291 else Ok (hour, minute, second) 292 | None -> Error (`Msg "Invalid time format. Expected HH:MM or HH:MM:SS") 293 with e -> 294 Error 295 (`Msg (Printf.sprintf "Error parsing time: %s" (Printexc.to_string e))) 296 297let parse_date_time ?(tz = !default_timezone ()) ~date ~time parameter = 298 let* date_ptime = parse_date date parameter ~tz in 299 let* h, min, s = parse_time time in 300 301 let dt = ptime_to_timedesc ~tz date_ptime in 302 let date_part = Timedesc.date dt in 303 304 (* Create time *) 305 match Timedesc.Time.make ~hour:h ~minute:min ~second:s () with 306 | Ok time_part -> ( 307 (* Combine date and time *) 308 match Timedesc.of_date_and_time ~tz date_part time_part with 309 | Ok combined -> Ok (timedesc_to_ptime combined) 310 | Error _ -> Error (`Msg "Invalid date-time combination")) 311 | Error _ -> Error (`Msg "Invalid time for date-time combination") 312 313let ptime_of_ical = function 314 | `Datetime (`Utc t) -> t 315 | `Datetime (`Local t) -> 316 let system_tz = 317 match Timedesc.Time_zone.local () with 318 | Some tz -> tz 319 | None -> Timedesc.Time_zone.utc 320 in 321 let ts = Timedesc.Utils.timestamp_of_ptime t in 322 let dt = 323 match Timedesc.of_timestamp ~tz_of_date_time:system_tz ts with 324 | Some dt -> dt 325 | None -> failwith "Invalid local date conversion" 326 in 327 timedesc_to_ptime dt 328 | `Datetime (`With_tzid (t, (_, tzid))) -> 329 let tz = 330 match Timedesc.Time_zone.make tzid with 331 | Some tz -> tz 332 | None -> 333 failwith 334 (Printf.sprintf 335 "Warning: Unknown timezone %s, falling back to UTC\n" tzid) 336 in 337 let ts = Timedesc.Utils.timestamp_of_ptime t in 338 let dt = 339 match Timedesc.of_timestamp ~tz_of_date_time:tz ts with 340 | Some dt -> dt 341 | None -> failwith "Invalid timezone date conversion" 342 in 343 timedesc_to_ptime dt 344 | `Date date -> ( 345 let y, m, d = date in 346 match Timedesc.Date.Ymd.make ~year:y ~month:m ~day:d with 347 | Ok new_date -> 348 let midnight = Timedesc.Time.make_exn ~hour:0 ~minute:0 ~second:0 () in 349 let new_dt = Timedesc.of_date_and_time_exn new_date midnight in 350 timedesc_to_ptime new_dt 351 | Error _ -> 352 failwith (Printf.sprintf "Invalid date %d-%d-%d" y m d))