Command-line and Emacs Calendar Client
1open Cmdliner 2open Caledonia_lib 3 4let calendar_name_arg = 5 let doc = "Calendar to add the event to" in 6 Arg.( 7 required 8 & opt (some string) None 9 & info [ "calendar"; "c" ] ~docv:"CALENDAR" ~doc) 10 11let required_summary_arg = 12 let doc = "Event summary/title" in 13 Arg.(required & pos 0 (some string) None & info [] ~docv:"SUMMARY" ~doc) 14 15let optional_summary_arg = 16 let doc = "Event summary/title" in 17 Arg.( 18 value 19 & opt (some string) None 20 & info [ "summary"; "s" ] ~docv:"SUMMARY" ~doc) 21 22let start_date_arg = 23 let doc = "Event start date (YYYY-MM-DD)" in 24 Arg.(value & opt (some string) None & info [ "date"; "d" ] ~docv:"DATE" ~doc) 25 26let start_time_arg = 27 let doc = "Event start time (HH:MM)" in 28 Arg.(value & opt (some string) None & info [ "time"; "t" ] ~docv:"TIME" ~doc) 29 30let end_date_arg = 31 let doc = "Event end date (YYYY-MM-DD). Defaults to DATE." in 32 Arg.( 33 value 34 & opt (some string) None 35 & info [ "end-date"; "e" ] ~docv:"END_DATE" ~doc) 36 37let end_time_arg = 38 let doc = "Event end time (HH:MM)" in 39 Arg.( 40 value 41 & opt (some string) None 42 & info [ "end-time"; "T" ] ~docv:"END_TIME" ~doc) 43 44let timezone_arg = 45 let doc = 46 "Timezone to add events to (e.g., 'America/New_York', 'UTC', \ 47 'Europe/London'). If not specified, will use the local timezone. For a \ 48 floating time (always at whatever the sytem time is), use 'FLOATING'." 49 in 50 Arg.( 51 value 52 & opt (some string) None 53 & info [ "timezone"; "z" ] ~docv:"TIMEZONE" ~doc) 54 55let end_timezone_arg = 56 let doc = "The timezone of the end of the event. Defaults to TIMEZONE." in 57 Arg.( 58 value 59 & opt (some string) None 60 & info [ "end-timezone"; "Z" ] ~docv:"END_TIMEZONE" ~doc) 61 62let location_arg = 63 let doc = "Event location" in 64 Arg.( 65 value 66 & opt (some string) None 67 & info [ "location"; "l" ] ~docv:"LOCATION" ~doc) 68 69let description_arg = 70 let doc = "Event description" in 71 Arg.( 72 value 73 & opt (some string) None 74 & info [ "description"; "D" ] ~docv:"DESCRIPTION" ~doc) 75 76let recur_arg = 77 let doc = "See RECURRENCE section" in 78 Arg.( 79 value & opt (some string) None & info [ "recur"; "r" ] ~docv:"RECUR" ~doc) 80 81let date_format_manpage_entries = 82 [ 83 `S "DATE FORMATS"; 84 `P 85 "The following are the possible date formats for the --date and \ 86 --end-date command line parameters. Note the value is dependent on \ 87 --date / --end-date, so --date 2025-03 --end-date 2025-03 will span the \ 88 month of March."; 89 `I ("YYYY-MM-DD", "Specific date (e.g., 2025-3-27, zero-padding optional)"); 90 `I ("YYYY-MM", "Start/end of specific month (e.g., 2025-3 for March 2025)"); 91 `I ("YYYY", "Start/end of specific year (e.g., 2025)"); 92 `I ("today", "Current day"); 93 `I ("tomorrow", "Next day"); 94 `I ("yesterday", "Previous day"); 95 `I ("this-week", "Start/end of current week"); 96 `I ("next-week", "Start/end of next week"); 97 `I ("this-month", "Start/end of current month"); 98 `I ("next-month", "Start/end of next month"); 99 `I ("+Nd", "N days from today (e.g., +7d for a week from today)"); 100 `I ("-Nd", "N days before today (e.g., -7d for a week ago)"); 101 `I ("+Nw", "N weeks from today (e.g., +4w for 4 weeks from today)"); 102 `I ("+Nm", "N months from today (e.g., +2m for 2 months from today)"); 103 ] 104 105let parse_start ~start_date ~start_time ~timezone = 106 let ( let* ) = Result.bind in 107 let* _ = 108 match timezone with 109 | None -> Ok () 110 | Some tzid -> ( 111 match Timedesc.Time_zone.make tzid with 112 | Some _ -> Ok () 113 | None -> 114 Error (`Msg (Printf.sprintf "Warning: Unknown timezone %s" tzid))) 115 in 116 match start_date with 117 | None -> 118 let* _ = 119 match start_time with 120 | None -> Ok () 121 | Some _ -> 122 Error (`Msg "Can't specify an start time without a start date") 123 in 124 let* _ = 125 match timezone with 126 | None -> Ok () 127 | _ -> Error (`Msg "Can't specify a timezone without a start date") 128 in 129 Ok None 130 | Some start_date -> ( 131 match start_time with 132 | None -> 133 let* _ = 134 match timezone with 135 | None -> Ok () 136 | _ -> Error (`Msg "Can't specify a timezone without a start time") 137 in 138 let* ptime = 139 Date.parse_date ~tz:Timedesc.Time_zone.utc start_date `From 140 in 141 let date = Ptime.to_date ptime in 142 Ok (Some (Icalendar.Params.singleton Valuetype `Date, `Date date)) 143 | Some start_time -> ( 144 match timezone with 145 | None -> 146 let* tzid = 147 match Timedesc.Time_zone.local () with 148 | Some tz -> Ok (Timedesc.Time_zone.name tz) 149 | None -> Error (`Msg "Failed to get system timezone") 150 in 151 let* datetime = 152 Date.parse_date_time ~tz:Timedesc.Time_zone.utc ~date:start_date 153 ~time:start_time `From 154 in 155 Ok 156 (Some 157 ( Icalendar.Params.empty, 158 `Datetime (`With_tzid (datetime, (false, tzid))) )) 159 | Some "FLOATING" -> 160 let* datetime = 161 Date.parse_date_time ~tz:Timedesc.Time_zone.utc ~date:start_date 162 ~time:start_time `From 163 in 164 Ok (Some (Icalendar.Params.empty, `Datetime (`Local datetime))) 165 | Some "UTC" -> 166 let* datetime = 167 Date.parse_date_time ~tz:Timedesc.Time_zone.utc ~date:start_date 168 ~time:start_time `From 169 in 170 Ok (Some (Icalendar.Params.empty, `Datetime (`Utc datetime))) 171 | Some tzid -> 172 let* datetime = 173 Date.parse_date_time ~tz:Timedesc.Time_zone.utc ~date:start_date 174 ~time:start_time `From 175 in 176 Ok 177 (Some 178 ( Icalendar.Params.empty, 179 `Datetime (`With_tzid (datetime, (false, tzid))) )))) 180 181let parse_end ~end_date ~end_time ~end_timezone = 182 let ( let* ) = Result.bind in 183 let* _ = 184 match end_timezone with 185 | None -> Ok () 186 | Some tzid -> ( 187 match Timedesc.Time_zone.make tzid with 188 | Some _ -> Ok () 189 | None -> 190 Error (`Msg (Printf.sprintf "Warning: Unknown timezone %s" tzid))) 191 in 192 match end_date with 193 | None -> 194 let* _ = 195 match end_time with 196 | None -> Ok () 197 | Some _ -> Error (`Msg "Can't specify an end time without an end date") 198 in 199 let* _ = 200 match end_timezone with 201 | None -> Ok () 202 | Some _ -> 203 Error (`Msg "Can't specify an end timezone without an end date") 204 in 205 Ok None 206 | Some end_date -> ( 207 match end_time with 208 | None -> 209 let* _ = 210 match end_timezone with 211 | Some _ -> 212 Error (`Msg "Can't specify an end timezone without a end time") 213 | _ -> Ok () 214 in 215 let* ptime = 216 Date.parse_date end_date ~tz:Timedesc.Time_zone.utc `From 217 in 218 (* DTEND;VALUE=DATE the event ends at the start of the specified date *) 219 let ptime = Date.add_days ptime 1 in 220 let date = Ptime.to_date ptime in 221 Ok 222 (Some 223 (`Dtend (Icalendar.Params.singleton Valuetype `Date, `Date date))) 224 | Some end_time -> ( 225 match end_timezone with 226 | None -> 227 let* tzid = 228 match Timedesc.Time_zone.local () with 229 | Some tz -> Ok (Timedesc.Time_zone.name tz) 230 | None -> Error (`Msg "Failed to get system timezone") 231 in 232 let* datetime = 233 Date.parse_date_time ~tz:Timedesc.Time_zone.utc ~date:end_date 234 ~time:end_time `From 235 in 236 Ok 237 (Some 238 (`Dtend 239 ( Icalendar.Params.empty, 240 `Datetime (`With_tzid (datetime, (false, tzid))) ))) 241 | Some "FLOATING" -> 242 let* datetime = 243 Date.parse_date_time ~tz:Timedesc.Time_zone.utc ~date:end_date 244 ~time:end_time `From 245 in 246 Ok 247 (Some 248 (`Dtend (Icalendar.Params.empty, `Datetime (`Local datetime)))) 249 | Some "UTC" -> 250 let* datetime = 251 Date.parse_date_time ~tz:Timedesc.Time_zone.utc ~date:end_date 252 ~time:end_time `From 253 in 254 Ok 255 (Some 256 (`Dtend (Icalendar.Params.empty, `Datetime (`Utc datetime)))) 257 | Some tzid -> 258 let* datetime = 259 Date.parse_date_time ~tz:Timedesc.Time_zone.utc ~date:end_date 260 ~time:end_time `From 261 in 262 Ok 263 (Some 264 (`Dtend 265 ( Icalendar.Params.empty, 266 `Datetime (`With_tzid (datetime, (false, tzid))) ))))) 267 268let combine_results (results : ('a, 'b) result list) : ('a list, 'b) result = 269 let rec aux acc = function 270 | [] -> Ok (List.rev acc) 271 | Ok v :: rest -> aux (v :: acc) rest 272 | Error e :: _ -> Error e 273 in 274 aux [] results 275 276let parse_recurrence recur = 277 let ( let* ) = Result.bind in 278 let parts = String.split_on_char ';' recur in 279 let freq = ref None in 280 let count = ref None in 281 let until = ref None in 282 let interval = ref None in 283 let by_parts = ref [] in 284 let results = 285 List.map 286 (fun part -> 287 let kv = String.split_on_char '=' part in 288 match kv with 289 | [ "FREQ"; value ] -> ( 290 match String.uppercase_ascii value with 291 | "DAILY" -> 292 freq := Some `Daily; 293 Ok () 294 | "WEEKLY" -> 295 freq := Some `Weekly; 296 Ok () 297 | "MONTHLY" -> 298 freq := Some `Monthly; 299 Ok () 300 | "YEARLY" -> 301 freq := Some `Yearly; 302 Ok () 303 | _ -> Error (`Msg ("Unsupported frequency: " ^ value))) 304 | [ "COUNT"; value ] -> 305 if !until <> None then 306 Error (`Msg "Cannot use both COUNT and UNTIL in the same rule") 307 else ( 308 count := Some (`Count (int_of_string value)); 309 Ok ()) 310 | [ "UNTIL"; value ] -> ( 311 if !count <> None then 312 Error (`Msg "Cannot use both COUNT and UNTIL in the same rule") 313 else 314 let* v = 315 match Icalendar.parse_datetime value with 316 | Ok v -> Ok v 317 | Error e -> Error (`Msg e) 318 in 319 match v with 320 | `With_tzid _ -> Error (`Msg "Until can't be in a timezone") 321 | `Utc u -> 322 until := Some (`Until (`Utc u)); 323 Ok () 324 | `Local l -> 325 until := Some (`Until (`Local l)); 326 Ok ()) 327 | [ "INTERVAL"; value ] -> 328 interval := Some (int_of_string value); 329 Ok () 330 | [ "BYDAY"; value ] -> 331 (* Parse day specifications like MO,WE,FR or 1MO,-1FR *) 332 let days = String.split_on_char ',' value in 333 let parse_day day = 334 (* Extract ordinal if present (like 1MO or -1FR) *) 335 let ordinal, day_code = 336 if 337 String.length day >= 3 338 && (String.get day 0 = '+' 339 || String.get day 0 = '-' 340 || (String.get day 0 >= '0' && String.get day 0 <= '9')) 341 then ( 342 let idx = ref 0 in 343 while 344 !idx < String.length day 345 && (String.get day !idx = '+' 346 || String.get day !idx = '-' 347 || String.get day !idx >= '0' 348 && String.get day !idx <= '9') 349 do 350 incr idx 351 done; 352 let ord_str = String.sub day 0 !idx in 353 let day_str = 354 String.sub day !idx (String.length day - !idx) 355 in 356 (int_of_string ord_str, day_str)) 357 else (0, day) 358 in 359 let* weekday = 360 match day_code with 361 | "MO" -> Ok `Monday 362 | "TU" -> Ok `Tuesday 363 | "WE" -> Ok `Wednesday 364 | "TH" -> Ok `Thursday 365 | "FR" -> Ok `Friday 366 | "SA" -> Ok `Saturday 367 | "SU" -> Ok `Sunday 368 | _ -> Error (`Msg ("Invalid weekday: " ^ day_code)) 369 in 370 Ok (ordinal, weekday) 371 in 372 let* day_specs = combine_results (List.map parse_day days) in 373 by_parts := `Byday day_specs :: !by_parts; 374 Ok () 375 | [ "BYMONTHDAY"; value ] -> 376 let days = String.split_on_char ',' value in 377 let month_days = List.map int_of_string days in 378 by_parts := `Bymonthday month_days :: !by_parts; 379 Ok () 380 | [ "BYMONTH"; value ] -> 381 let months = String.split_on_char ',' value in 382 let month_nums = List.map int_of_string months in 383 by_parts := `Bymonth month_nums :: !by_parts; 384 Ok () 385 | _ -> Ok ()) 386 parts 387 in 388 let* _ = combine_results results in 389 match !freq with 390 | Some f -> 391 let limit = 392 match (!count, !until) with 393 | Some c, None -> Some c 394 | None, Some u -> Some u 395 | _ -> None 396 in 397 let recurrence = (f, limit, !interval, !by_parts) in 398 Ok recurrence 399 | None -> Error (`Msg "FREQ is required in recurrence rule") 400 401let recurrence_format_manpage_entries = 402 [ 403 `S "RECURRENCE"; 404 `P "Recurrence rule in iCalendar RFC5545 format. The FREQ part is required."; 405 `I ("FREQ=<frequency>", "DAILY, WEEKLY, MONTHLY, or YEARLY (required)"); 406 `I 407 ( "COUNT=<number>", 408 "Limit to this many occurrences (optional, cannot be used with UNTIL)" 409 ); 410 `I 411 ( "UNTIL=<date>", 412 "Repeat until this date (optional, cannot be used with COUNT)" ); 413 `I 414 ( "INTERVAL=<number>", 415 "Interval between occurrences, e.g., 2 for every other (optional)" ); 416 `I 417 ( "BYDAY=<dayspec>", 418 "Specific days, e.g., MO,WE,FR or 1MO (first Monday) (optional)" ); 419 `I 420 ( "BYMONTHDAY=<daynum>", 421 "Day of month, e.g., 1,15 or -1 (last day) (optional)" ); 422 `I 423 ( "BYMONTH=<monthnum>", 424 "Month number, e.g., 1,6,12 for Jan,Jun,Dec (optional)" ); 425 `P "Examples:"; 426 `I ("FREQ=DAILY;COUNT=5", "Daily for 5 occurrences"); 427 `I ("FREQ=WEEKLY;INTERVAL=2", "Every other week indefinitely"); 428 `I ("FREQ=WEEKLY;BYDAY=MO,WE,FR", "Every Monday, Wednesday, Friday"); 429 `I ("FREQ=MONTHLY;BYDAY=1MO", "First Monday of every month"); 430 `I 431 ( "FREQ=YEARLY;BYMONTH=1;BYMONTHDAY=1", 432 "Every January 1st (New Year's Day)" ); 433 `I ("FREQ=MONTHLY;BYMONTHDAY=-1", "Last day of every month"); 434 ]