Command-line and Emacs Calendar Client
1open Icalendar
2
3type event_id = string
4
5type t = {
6 collection : Collection.t;
7 file : Eio.Fs.dir_ty Eio.Path.t;
8 event : event;
9 calendar : calendar;
10}
11
12type date_error = [ `Msg of string ]
13
14let generate_uuid () =
15 let uuid = Uuidm.v4_gen (Random.State.make_self_init ()) () in
16 Uuidm.to_string uuid
17
18let default_prodid = `Prodid (Params.empty, "-//Freumh//Caledonia//EN")
19
20let create ~(fs : Eio.Fs.dir_ty Eio.Path.t) ~calendar_dir_path ~summary ~start
21 ?end_ ?location ?description ?recurrence collection =
22 let uuid = generate_uuid () in
23 let uid = (Params.empty, uuid) in
24 let file_name = uuid ^ ".ics" in
25 let file =
26 Eio.Path.(
27 fs / calendar_dir_path
28 / (match collection with Collection.Col s -> s)
29 / file_name)
30 in
31 let dtstart = (Params.empty, start) in
32 let dtend_or_duration = end_ in
33 let rrule = Option.map (fun r -> (Params.empty, r)) recurrence in
34 let now = Ptime_clock.now () in
35 let props = [ `Summary (Params.empty, summary) ] in
36 let props =
37 match location with
38 | Some loc -> `Location (Params.empty, loc) :: props
39 | None -> props
40 in
41 let props =
42 match description with
43 | Some desc -> `Description (Params.empty, desc) :: props
44 | None -> props
45 in
46 let event =
47 {
48 dtstamp = (Params.empty, now);
49 uid;
50 dtstart;
51 dtend_or_duration;
52 rrule;
53 props;
54 alarms = [];
55 }
56 in
57 let calendar =
58 let props = [ default_prodid ] in
59 let components = [ `Event event ] in
60 (props, components)
61 in
62 { collection; file; event; calendar }
63
64let edit ?summary ?start ?end_ ?location ?description ?recurrence t =
65 let now = Ptime_clock.now () in
66 let uid = t.event.uid in
67 let dtstart =
68 match start with None -> t.event.dtstart | Some s -> (Params.empty, s)
69 in
70 let dtend_or_duration =
71 match end_ with None -> t.event.dtend_or_duration | Some _ -> end_
72 in
73 let rrule =
74 match recurrence with
75 | None -> t.event.rrule
76 | Some r -> Some (Params.empty, r)
77 in
78 let props =
79 List.filter
80 (function
81 | `Summary _ -> ( match summary with None -> true | Some _ -> false)
82 | `Location _ -> ( match location with None -> true | Some _ -> false)
83 | `Description _ -> (
84 match description with None -> true | Some _ -> false)
85 | _ -> true)
86 t.event.props
87 in
88 let props =
89 match summary with
90 | Some summary -> `Summary (Params.empty, summary) :: props
91 | None -> props
92 in
93 let props =
94 match location with
95 | Some loc -> `Location (Params.empty, loc) :: props
96 | None -> props
97 in
98 let props =
99 match description with
100 | Some desc -> `Description (Params.empty, desc) :: props
101 | None -> props
102 in
103 let alarms = t.event.alarms in
104 let event =
105 {
106 dtstamp = (Params.empty, now);
107 uid;
108 dtstart;
109 dtend_or_duration;
110 rrule;
111 props;
112 alarms;
113 }
114 in
115 let collection = t.collection in
116 let file = t.file in
117 let calendar = t.calendar in
118 { collection; file; event; calendar }
119
120let events_of_icalendar collection ~file calendar =
121 let remove_dup_ids lst =
122 let rec aux acc = function
123 | [] -> acc
124 | x :: xs ->
125 if List.exists (fun r -> r.uid = x.uid) acc then aux acc xs
126 else aux (x :: acc) xs
127 in
128 aux [] lst
129 in
130 let events =
131 List.filter_map
132 (function `Event event -> Some event | _ -> None)
133 (snd calendar)
134 in
135 let events = remove_dup_ids events in
136 List.map (function event -> { collection; file; event; calendar }) events
137
138let to_ical_event t = t.event
139let to_ical_calendar t = t.calendar
140let get_id t = snd t.event.uid
141
142let get_summary t =
143 match
144 List.filter_map
145 (function `Summary (_, s) when s <> "" -> Some s | _ -> None)
146 t.event.props
147 with
148 | s :: _ -> Some s
149 | _ -> None
150
151let get_ical_start event = Date.ptime_of_ical (snd event.dtstart)
152let get_start t = get_ical_start t.event
153
154let get_ical_end event =
155 match event.dtend_or_duration with
156 | Some (`Dtend (_, d)) -> Some (Date.ptime_of_ical d)
157 | Some (`Duration (_, span)) -> (
158 let start = get_ical_start event in
159 match Ptime.add_span start span with
160 | Some t -> Some t
161 | None ->
162 failwith
163 (Printf.sprintf "Invalid duration calculation: %s + %s"
164 (Ptime.to_rfc3339 start)
165 (Printf.sprintf "%.2fs" (Ptime.Span.to_float_s span))))
166 | None -> None
167
168let get_end t = get_ical_end t.event
169
170let get_start_timezone t =
171 match t.event.dtstart with
172 | _, `Datetime (`With_tzid (_, (_, tzid))) -> Some tzid
173 | _ -> None
174
175let get_end_timezone t =
176 match t.event.dtend_or_duration with
177 | Some (`Dtend (_, `Datetime (`With_tzid (_, (_, tzid))))) -> Some tzid
178 | _ -> None
179
180let get_duration t =
181 match t.event.dtend_or_duration with
182 | Some (`Duration (_, span)) -> Some span
183 | Some (`Dtend (_, e)) ->
184 let span = Ptime.diff (Date.ptime_of_ical e) (get_start t) in
185 Some span
186 | None -> None
187
188let is_date t =
189 match (t.event.dtstart, t.event.dtend_or_duration) with
190 | (_, `Date _), _ -> true
191 | _, Some (`Dtend (_, `Date _)) -> true
192 | _ -> false
193
194let get_location t =
195 match
196 List.filter_map
197 (function `Location (_, s) when s <> "" -> Some s | _ -> None)
198 t.event.props
199 with
200 | s :: _ -> Some s
201 | _ -> None
202
203let get_description t =
204 match
205 List.filter_map
206 (function `Description (_, s) when s <> "" -> Some s | _ -> None)
207 t.event.props
208 with
209 | s :: _ -> Some s
210 | _ -> None
211
212let get_recurrence t = Option.map (fun r -> snd r) t.event.rrule
213let get_collection t = t.collection
214let get_file t = t.file
215
216type comparator = t -> t -> int
217
218let by_start e1 e2 =
219 let t1 = get_start e1 in
220 let t2 = get_start e2 in
221 Ptime.compare t1 t2
222
223let by_end e1 e2 =
224 match (get_end e1, get_end e2) with
225 | Some t1, Some t2 -> Ptime.compare t1 t2
226 | Some _, None -> 1
227 | None, Some _ -> -1
228 | None, None -> 0
229
230let by_summary e1 e2 =
231 match (get_summary e1, get_summary e2) with
232 | Some s1, Some s2 -> String.compare s1 s2
233 | Some _, None -> 1
234 | None, Some _ -> -1
235 | None, None -> 0
236
237let by_location e1 e2 =
238 match (get_location e1, get_location e2) with
239 | Some l1, Some l2 -> String.compare l1 l2
240 | Some _, None -> 1
241 | None, Some _ -> -1
242 | None, None -> 0
243
244let by_collection e1 e2 =
245 match (get_collection e1, get_collection e2) with
246 | Collection.Col c1, Collection.Col c2 -> String.compare c1 c2
247
248let descending comp e1 e2 = -1 * comp e1 e2
249
250let chain comp1 comp2 e1 e2 =
251 let result = comp1 e1 e2 in
252 if result <> 0 then result else comp2 e1 e2
253
254let clone_with_event t event =
255 let collection = t.collection in
256 let file = t.file in
257 let calendar = t.calendar in
258 { collection; file; event; calendar }
259
260type format = [ `Text | `Entries | `Json | `Csv | `Ics | `Sexp ]
261
262let format_date ?tz date =
263 let dt = Date.ptime_to_timedesc ?tz date in
264 let y = Timedesc.year dt in
265 let m = Timedesc.month dt in
266 let d = Timedesc.day dt in
267 let weekday =
268 match Timedesc.weekday dt with
269 | `Mon -> "Mon"
270 | `Tue -> "Tue"
271 | `Wed -> "Wed"
272 | `Thu -> "Thu"
273 | `Fri -> "Fri"
274 | `Sat -> "Sat"
275 | `Sun -> "Sun"
276 in
277 Printf.sprintf "%04d-%02d-%02d %s" y m d weekday
278
279let format_time ?tz date =
280 let dt = Date.ptime_to_timedesc ?tz date in
281 let h = Timedesc.hour dt in
282 let m = Timedesc.minute dt in
283 Printf.sprintf "%02d:%02d" h m
284
285let format_datetime ?tz date =
286 let tz_str =
287 match tz with
288 | Some tz -> Printf.sprintf "(%s)" (Timedesc.Time_zone.name tz)
289 | None -> ""
290 in
291 Printf.sprintf "%s %s%s" (format_date ?tz date) (format_time ?tz date) tz_str
292
293let same_day day other =
294 let y1, m1, d1 = Ptime.to_date day in
295 let y2, m2, d2 = Ptime.to_date other in
296 y1 == y2 && m1 == m2 && d1 == d2
297
298let next_day day ~next =
299 let y1, m1, d1 = Ptime.to_date day in
300 let y2, m2, d2 = Ptime.to_date next in
301 y1 == y2 && m1 == m2 && d1 == d2 - 1
302
303(* exosed from icalendar *)
304
305let weekday_strings =
306 [
307 (`Monday, "MO");
308 (`Tuesday, "TU");
309 (`Wednesday, "WE");
310 (`Thursday, "TH");
311 (`Friday, "FR");
312 (`Saturday, "SA");
313 (`Sunday, "SU");
314 ]
315
316let freq_strings =
317 [
318 (`Daily, "DAILY");
319 (`Hourly, "HOURLY");
320 (`Minutely, "MINUTELY");
321 (`Monthly, "MONTHLY");
322 (`Secondly, "SECONDLY");
323 (`Weekly, "WEEKLY");
324 (`Yearly, "YEARLY");
325 ]
326
327let date_to_str (y, m, d) = Printf.sprintf "%04d%02d%02d" y m d
328
329let datetime_to_str ptime utc =
330 let date, ((hh, mm, ss), _) = Ptime.to_date_time ptime in
331 Printf.sprintf "%sT%02d%02d%02d%s" (date_to_str date) hh mm ss
332 (if utc then "Z" else "")
333
334let timestamp_to_ics ts buf =
335 Buffer.add_string buf
336 @@
337 match ts with
338 | `Utc ts -> datetime_to_str ts true
339 | `Local ts -> datetime_to_str ts false
340 | `With_tzid (ts, _str) -> (* TODO *) datetime_to_str ts false
341
342let recurs_to_ics (freq, count_or_until, interval, l) buf =
343 let write_rulepart key value =
344 Buffer.add_string buf key;
345 Buffer.add_char buf '=';
346 Buffer.add_string buf value
347 in
348 let int_list l = String.concat "," @@ List.map string_of_int l in
349 let recur_to_ics = function
350 | `Byminute byminlist -> write_rulepart "BYMINUTE" (int_list byminlist)
351 | `Byday bywdaylist ->
352 let wday (weeknumber, weekday) =
353 (if weeknumber = 0 then "" else string_of_int weeknumber)
354 ^ List.assoc weekday weekday_strings
355 in
356 write_rulepart "BYDAY" (String.concat "," @@ List.map wday bywdaylist)
357 | `Byhour byhrlist -> write_rulepart "BYHOUR" (int_list byhrlist)
358 | `Bymonth bymolist -> write_rulepart "BYMONTH" (int_list bymolist)
359 | `Bymonthday bymodaylist ->
360 write_rulepart "BYMONTHDAY" (int_list bymodaylist)
361 | `Bysecond byseclist -> write_rulepart "BYSECOND" (int_list byseclist)
362 | `Bysetposday bysplist -> write_rulepart "BYSETPOS" (int_list bysplist)
363 | `Byweek bywknolist -> write_rulepart "BYWEEKNO" (int_list bywknolist)
364 | `Byyearday byyrdaylist ->
365 write_rulepart "BYYEARDAY" (int_list byyrdaylist)
366 | `Weekday weekday ->
367 write_rulepart "WKST" (List.assoc weekday weekday_strings)
368 in
369 write_rulepart "FREQ" (List.assoc freq freq_strings);
370 (match count_or_until with
371 | None -> ()
372 | Some x -> (
373 Buffer.add_char buf ';';
374 match x with
375 | `Count c -> write_rulepart "COUNT" (string_of_int c)
376 | `Until enddate ->
377 (* TODO cleanup *)
378 Buffer.add_string buf "UNTIL=";
379 timestamp_to_ics enddate buf));
380 (match interval with
381 | None -> ()
382 | Some i ->
383 Buffer.add_char buf ';';
384 write_rulepart "INTERVAL" (string_of_int i));
385 List.iter
386 (fun recur ->
387 Buffer.add_char buf ';';
388 recur_to_ics recur)
389 l
390
391let format_event ?(format = `Text) ?tz event =
392 let start = get_start event in
393 let end_ = get_end event in
394 match format with
395 | `Text ->
396 let id = get_id event in
397 let start_date = " " ^ format_date ?tz start in
398 let start_time =
399 match is_date event with
400 | true -> ""
401 | false -> " " ^ format_time ?tz start
402 in
403 let end_date, end_time =
404 match end_ with
405 | None -> ("", "")
406 | Some end_ -> (
407 match is_date event with
408 | true -> (
409 match next_day start ~next:end_ with
410 | true -> ("", "")
411 | false -> (" - " ^ format_date ?tz end_, ""))
412 | false -> (
413 match same_day start end_ with
414 | true -> ("", " - " ^ format_time ?tz end_)
415 | false ->
416 (" - " ^ format_date ?tz end_, " " ^ format_time ?tz end_)))
417 in
418 let summary =
419 match get_summary event with
420 | Some summary when summary <> "" -> " " ^ summary
421 | _ -> ""
422 in
423 let location =
424 match get_location event with
425 | Some loc when loc <> "" -> " @" ^ loc
426 | _ -> ""
427 in
428 let collection = match get_collection event with Col s -> s in
429 Printf.sprintf "%-45s\t%s\t%s%s%s%s%s%s" id collection start_date
430 start_time end_date end_time summary location
431 | `Entries ->
432 let format_opt label f opt =
433 Option.map (fun x -> Printf.sprintf "%s: %s\n" label (f x)) opt
434 |> Option.value ~default:""
435 in
436 let format timezone datetime =
437 match is_date event with
438 | true -> format_date ?tz datetime
439 | false -> (
440 format_datetime ?tz datetime
441 ^ match timezone with None -> "" | Some t -> " (" ^ t ^ ")")
442 in
443 let start_str =
444 format_opt "Start" (format (get_start_timezone event)) (Some start)
445 in
446 let end_str = format_opt "End" (format (get_end_timezone event)) end_ in
447 let location_str = format_opt "Location" Fun.id (get_location event) in
448 let description_str =
449 format_opt "Description" Fun.id (get_description event)
450 in
451 let rrule_str =
452 Option.map
453 (fun r ->
454 let buf = Buffer.create 128 in
455 recurs_to_ics r buf;
456 Printf.sprintf "%s: %s\n" "Reccurence" (Buffer.contents buf))
457 (get_recurrence event)
458 |> Option.value ~default:""
459 in
460 let summary_str = format_opt "Summary" Fun.id (get_summary event) in
461 let file_str = format_opt "File" Fun.id (Some (snd (get_file event))) in
462 Printf.sprintf "%s%s%s%s%s%s%s" summary_str start_str end_str location_str
463 description_str rrule_str file_str
464 | `Json ->
465 let open Yojson.Safe in
466 let json =
467 `Assoc
468 [
469 ("id", `String (get_id event));
470 ( "summary",
471 match get_summary event with
472 | Some summary -> `String summary
473 | None -> `Null );
474 ("start", `String (format_datetime ?tz start));
475 ( "end",
476 match end_ with
477 | Some e -> `String (format_datetime ?tz e)
478 | None -> `Null );
479 ( "location",
480 match get_location event with
481 | Some loc -> `String loc
482 | None -> `Null );
483 ( "description",
484 match get_description event with
485 | Some desc -> `String desc
486 | None -> `Null );
487 ( "calendar",
488 match get_collection event with
489 | Collection.Col cal -> `String cal );
490 ]
491 in
492 to_string json
493 | `Csv ->
494 let summary =
495 match get_summary event with Some summary -> summary | None -> ""
496 in
497 let start = format_datetime ?tz start in
498 let end_str =
499 match end_ with Some e -> format_datetime ?tz e | None -> ""
500 in
501 let location =
502 match get_location event with Some loc -> loc | None -> ""
503 in
504 let cal_id =
505 match get_collection event with Collection.Col cal -> cal
506 in
507 Printf.sprintf "\"%s\",\"%s\",\"%s\",\"%s\",\"%s\"" summary start end_str
508 location cal_id
509 | `Ics ->
510 let calendar = to_ical_calendar event in
511 Icalendar.to_ics ~cr:true calendar
512 | `Sexp ->
513 let summary =
514 match get_summary event with Some summary -> summary | None -> ""
515 in
516 let start_date, start_time =
517 let dt = Date.ptime_to_timedesc ?tz start in
518 let y = Timedesc.year dt in
519 let m = Timedesc.month dt in
520 let d = Timedesc.day dt in
521 let h = Timedesc.hour dt in
522 let min = Timedesc.minute dt in
523 let s = Timedesc.second dt in
524 let dow =
525 match Timedesc.weekday dt with
526 | `Mon -> "monday"
527 | `Tue -> "tuesday"
528 | `Wed -> "wednesday"
529 | `Thu -> "thursday"
530 | `Fri -> "friday"
531 | `Sat -> "saturday"
532 | `Sun -> "sunday"
533 in
534 ( Printf.sprintf "(%04d %02d %02d %s)" y m d dow,
535 Printf.sprintf "(%02d %02d %02d)" h min s )
536 in
537 let end_str =
538 match end_ with
539 | Some end_date ->
540 let dt = Date.ptime_to_timedesc ?tz end_date in
541 let y = Timedesc.year dt in
542 let m = Timedesc.month dt in
543 let d = Timedesc.day dt in
544 let h = Timedesc.hour dt in
545 let min = Timedesc.minute dt in
546 let s = Timedesc.second dt in
547 let dow =
548 match Timedesc.weekday dt with
549 | `Mon -> "monday"
550 | `Tue -> "tuesday"
551 | `Wed -> "wednesday"
552 | `Thu -> "thursday"
553 | `Fri -> "friday"
554 | `Sat -> "saturday"
555 | `Sun -> "sunday"
556 in
557 Printf.sprintf "((%04d %02d %02d %s) (%02d %02d %02d))" y m d dow h
558 min s
559 | None -> "nil"
560 in
561 let location =
562 match get_location event with
563 | Some loc -> Printf.sprintf "\"%s\"" (String.escaped loc)
564 | None -> "nil"
565 in
566 let description =
567 match get_description event with
568 | Some desc -> Printf.sprintf "\"%s\"" (String.escaped desc)
569 | None -> "nil"
570 in
571 let calendar =
572 match get_collection event with
573 | Collection.Col cal -> Printf.sprintf "\"%s\"" (String.escaped cal)
574 in
575 let id = get_id event in
576 Printf.sprintf
577 "((:id \"%s\" :summary \"%s\" :start (%s %s) :end %s :location %s \
578 :description %s :calendar %s))"
579 (String.escaped id) (String.escaped summary) start_date start_time
580 end_str location description calendar
581
582let format_events ?(format = `Text) ?tz events =
583 match format with
584 | `Json ->
585 let json_events =
586 List.map
587 (fun e -> Yojson.Safe.from_string (format_event ~format:`Json ?tz e))
588 events
589 in
590 Yojson.Safe.to_string (`List json_events)
591 | `Csv ->
592 "\"Summary\",\"Start\",\"End\",\"Location\",\"Calendar\"\n"
593 ^ String.concat "\n" (List.map (format_event ~format:`Csv ?tz) events)
594 | `Sexp ->
595 "("
596 ^ String.concat "\n "
597 (List.map (fun e -> format_event ~format:`Sexp ?tz e) events)
598 ^ ")"
599 | _ ->
600 String.concat "\n" (List.map (fun e -> format_event ~format ?tz e) events)
601
602let expand_recurrences ~from ~to_ event =
603 let rule = get_recurrence event in
604 match rule with
605 (* If there's no recurrence we just return the original event. *)
606 | None ->
607 (* Include the original event instance only if it falls within the query range. *)
608 let start = get_start event in
609 let end_ = match get_end event with None -> start | Some e -> e in
610 if
611 Ptime.compare start to_ < 0
612 &&
613 (* end_ > f, meaning we don't include events that end at the exact start of our range.
614 This is handy to exclude date events that end at 00:00 the next day. *)
615 match from with Some f -> Ptime.compare end_ f > 0 | None -> true
616 then [ event ]
617 else []
618 | Some _ ->
619 let rec collect generator acc =
620 match generator () with
621 | None -> List.rev acc
622 | Some recur ->
623 let start = get_ical_start recur in
624 let end_ =
625 match get_ical_end recur with None -> start | Some e -> e
626 in
627 (* if start >= to then we're outside our (exclusive) date range and we terminate *)
628 if Ptime.compare start to_ >= 0 then List.rev acc
629 (* if end > from then, *)
630 else if
631 match from with
632 | Some f -> Ptime.compare end_ f > 0
633 | None -> true
634 (* we include the event *)
635 then collect generator (clone_with_event event recur :: acc)
636 (* otherwise we iterate till the event is in range *)
637 else collect generator acc
638 in
639 let generator =
640 let ical_event = to_ical_event event in
641 (* The first event is the non recurrence-id one *)
642 let _, other_events =
643 match
644 List.partition
645 (function `Event _ -> true | _ -> false)
646 (snd event.calendar)
647 with
648 | `Event hd :: tl, _ ->
649 (hd, List.map (function `Event e -> e | _ -> assert false) tl)
650 | _ -> assert false
651 in
652 recur_events ~recurrence_ids:other_events ical_event
653 in
654 collect generator []