Command-line and Emacs Calendar Client
1open Icalendar
2
3type instance = { event : Event.t; start : Ptime.t; end_ : Ptime.t option }
4
5module Instance = struct
6 type t = instance
7 type comparator = t -> t -> int
8
9 let by_start i1 i2 = Ptime.compare i1.start i2.start
10
11 let by_end i1 i2 =
12 match (i1.end_, i2.end_) with
13 | Some t1, Some t2 -> Ptime.compare t1 t2
14 | Some _, None -> 1
15 | None, Some _ -> -1
16 | None, None -> 0
17
18 let by_event event_comp i1 i2 = event_comp i1.event i2.event
19 let descending comp i1 i2 = -1 * comp i1 i2
20
21 let chain comp1 comp2 i1 i2 =
22 let result = comp1 i1 i2 in
23 if result <> 0 then result else comp2 i1 i2
24end
25
26let clone_with_time original start =
27 let duration = Event.get_duration original in
28 let end_ =
29 match duration with Some span -> Ptime.add_span start span | None -> None
30 in
31 { event = original; start; end_ }
32
33let expand_event event ~from ~to_ =
34 let rule = Event.get_recurrence event in
35 match rule with
36 (* If there's no recurrence we just return the original event. *)
37 | None ->
38 (* Include the original event instance only if it falls within the query range. *)
39 let start = Event.get_start event in
40 let end_ = match Event.get_end event with None -> start | Some e -> e in
41 if
42 Ptime.compare start to_ < 0
43 &&
44 (* end_ > f, meaning we don't include events that end at the exact start of our range.
45 This is handy to exclude date events that end at 00:00 the next day. *)
46 match from with Some f -> Ptime.compare end_ f > 0 | None -> true
47 then [ clone_with_time event start ]
48 else []
49 (* We return all instances within the range, regardless of whether the original
50 event instance was included. This ensures recurring events that start before
51 the query range but have instances within it are properly included. *)
52 | Some rule ->
53 let rec collect generator acc =
54 match generator () with
55 | None -> List.rev acc
56 | Some date ->
57 if Ptime.compare date to_ > 0 then List.rev acc
58 else if
59 match from with
60 | Some f -> Ptime.compare date f < 0
61 | None -> false
62 then collect generator acc
63 else collect generator (clone_with_time event date :: acc)
64 in
65 let start_date = Event.get_start event in
66 let generator = recur_dates start_date rule in
67 collect generator []
68
69let combine_results (results : ('a, 'b) result list) : ('a list, 'b) result =
70 let rec aux acc = function
71 | [] -> Ok (List.rev acc)
72 | Ok v :: rest -> aux (v :: acc) rest
73 | Error e :: _ -> Error e
74 in
75 aux [] results
76
77let parse_recurrence recur =
78 let ( let* ) = Result.bind in
79 let parts = String.split_on_char ';' recur in
80 let freq = ref None in
81 let count = ref None in
82 let until = ref None in
83 let interval = ref None in
84 let by_parts = ref [] in
85 let results =
86 List.map
87 (fun part ->
88 let kv = String.split_on_char '=' part in
89 match kv with
90 | [ "FREQ"; value ] -> (
91 match String.uppercase_ascii value with
92 | "DAILY" ->
93 freq := Some `Daily;
94 Ok ()
95 | "WEEKLY" ->
96 freq := Some `Weekly;
97 Ok ()
98 | "MONTHLY" ->
99 freq := Some `Monthly;
100 Ok ()
101 | "YEARLY" ->
102 freq := Some `Yearly;
103 Ok ()
104 | _ -> Error (`Msg ("Unsupported frequency: " ^ value)))
105 | [ "COUNT"; value ] ->
106 if !until <> None then
107 Error (`Msg "Cannot use both COUNT and UNTIL in the same rule")
108 else (
109 count := Some (`Count (int_of_string value));
110 Ok ())
111 | [ "UNTIL"; value ] -> (
112 if !count <> None then
113 Error (`Msg "Cannot use both COUNT and UNTIL in the same rule")
114 else
115 let* v =
116 match parse_datetime value with
117 | Ok v -> Ok v
118 | Error e -> Error (`Msg e)
119 in
120 match v with
121 | `With_tzid _ -> Error (`Msg "Until can't be in a timezone")
122 | `Utc u ->
123 until := Some (`Until (`Utc u));
124 Ok ()
125 | `Local l ->
126 until := Some (`Until (`Local l));
127 Ok ())
128 | [ "INTERVAL"; value ] ->
129 interval := Some (int_of_string value);
130 Ok ()
131 | [ "BYDAY"; value ] ->
132 (* Parse day specifications like MO,WE,FR or 1MO,-1FR *)
133 let days = String.split_on_char ',' value in
134 let parse_day day =
135 (* Extract ordinal if present (like 1MO or -1FR) *)
136 let ordinal, day_code =
137 if
138 String.length day >= 3
139 && (String.get day 0 = '+'
140 || String.get day 0 = '-'
141 || (String.get day 0 >= '0' && String.get day 0 <= '9'))
142 then (
143 let idx = ref 0 in
144 while
145 !idx < String.length day
146 && (String.get day !idx = '+'
147 || String.get day !idx = '-'
148 || String.get day !idx >= '0'
149 && String.get day !idx <= '9')
150 do
151 incr idx
152 done;
153 let ord_str = String.sub day 0 !idx in
154 let day_str =
155 String.sub day !idx (String.length day - !idx)
156 in
157 (int_of_string ord_str, day_str))
158 else (0, day)
159 in
160 let* weekday =
161 match day_code with
162 | "MO" -> Ok `Monday
163 | "TU" -> Ok `Tuesday
164 | "WE" -> Ok `Wednesday
165 | "TH" -> Ok `Thursday
166 | "FR" -> Ok `Friday
167 | "SA" -> Ok `Saturday
168 | "SU" -> Ok `Sunday
169 | _ -> Error (`Msg ("Invalid weekday: " ^ day_code))
170 in
171 Ok (ordinal, weekday)
172 in
173 let* day_specs = combine_results (List.map parse_day days) in
174 by_parts := `Byday day_specs :: !by_parts;
175 Ok ()
176 | [ "BYMONTHDAY"; value ] ->
177 let days = String.split_on_char ',' value in
178 let month_days = List.map int_of_string days in
179 by_parts := `Bymonthday month_days :: !by_parts;
180 Ok ()
181 | [ "BYMONTH"; value ] ->
182 let months = String.split_on_char ',' value in
183 let month_nums = List.map int_of_string months in
184 by_parts := `Bymonth month_nums :: !by_parts;
185 Ok ()
186 | _ -> Ok ())
187 parts
188 in
189 let* _ = combine_results results in
190 match !freq with
191 | Some f ->
192 let limit =
193 match (!count, !until) with
194 | Some c, None -> Some c
195 | None, Some u -> Some u
196 | _ -> None
197 in
198 let recurrence = (f, limit, !interval, !by_parts) in
199 Ok recurrence
200 | None -> Error (`Msg "FREQ is required in recurrence rule")