Command-line and Emacs Calendar Client
1open Cmdliner
2open Caledonia_lib
3
4let from_arg =
5 let doc =
6 "Start date in YYYY-MM-DD format, partial date format (YYYY-MM or YYYY), \
7 or a relative expression (today, tomorrow, this-week, next-week, \
8 this-month, next-month, +Nd, -Nd, +Nw, +Nm). See DATE FORMATS for more."
9 in
10 let i = Arg.info [ "from"; "f" ] ~docv:"DATE" ~doc in
11 Arg.(value @@ opt (some string) None i)
12
13let to_arg =
14 let doc =
15 "End date in YYYY-MM-DD format, partial date format (YYYY-MM or YYYY), or \
16 a relative expression (today, tomorrow, this-week, next-week, this-month, \
17 next-month, +Nd, -Nd, +Nw, +Nm). See DATE FORMATS for more."
18 in
19 let i = Arg.info [ "to"; "t" ] ~docv:"DATE" ~doc in
20 Arg.(value @@ opt (some string) None i)
21
22let calendar_arg =
23 let doc = "Filter by calendar" in
24 Arg.(
25 value & opt_all string [] & info [ "calendar"; "c" ] ~docv:"CALENDAR" ~doc)
26
27let format_enum =
28 [
29 ("text", `Text);
30 ("entries", `Entries);
31 ("json", `Json);
32 ("csv", `Csv);
33 ("ics", `Ics);
34 ("sexp", `Sexp);
35 ]
36
37let format_arg =
38 let doc =
39 "Output format (text, entries, json, csv, ics, sexp). Note that dates are \
40 localised to the TIMEZONE option but the timezone they're defined in is \
41 displayed."
42 in
43 Arg.(
44 value
45 & opt (enum format_enum) `Text
46 & info [ "format"; "o" ] ~docv:"FORMAT" ~doc)
47
48let count_arg =
49 let doc = "Maximum number of events to display" in
50 Arg.(value & opt (some int) None & info [ "count"; "n" ] ~docv:"COUNT" ~doc)
51
52let today_arg =
53 let doc = "Show events for today only" in
54 Arg.(value & flag & info [ "today"; "d" ] ~doc)
55
56let tomorrow_arg =
57 let doc = "Show events for tomorrow only" in
58 Arg.(value & flag & info [ "tomorrow" ] ~doc)
59
60let week_arg =
61 let doc = "Show events for the current week" in
62 Arg.(value & flag & info [ "week"; "w" ] ~doc)
63
64let month_arg =
65 let doc = "Show events for the current month" in
66 Arg.(value & flag & info [ "month"; "m" ] ~doc)
67
68let timezone_arg =
69 let doc =
70 "Timezone to use for date calculations (e.g., 'America/New_York', 'UTC', \
71 'Europe/London') defaulting to the system timezone"
72 in
73 Arg.(
74 value
75 & opt (some string) None
76 & info [ "timezone"; "z" ] ~docv:"TIMEZONE" ~doc)
77
78let sort_field_enum =
79 [
80 ("start", `Start);
81 ("end", `End);
82 ("summary", `Summary);
83 ("location", `Location);
84 ("calendar", `Calendar);
85 ]
86
87type sort_spec = {
88 field : [ `Start | `End | `Summary | `Location | `Calendar ];
89 descending : bool;
90}
91
92let parse_sort_spec str =
93 let ( let* ) = Result.bind in
94 let parts = String.split_on_char ':' str in
95 match parts with
96 | [] -> Error (`Msg "Empty sort specification")
97 | field_str :: order_opt -> (
98 let* descending =
99 match order_opt with
100 | [ "desc" ] | [ "descending" ] -> Ok true
101 | [ "asc" ] | [ "ascending" ] -> Ok false
102 | [] -> Ok false (* Default to ascending *)
103 | _ -> Error (`Msg ("Invalid sort order in: " ^ str))
104 in
105 match List.assoc_opt field_str sort_field_enum with
106 | Some field -> Ok { field; descending }
107 | None ->
108 Error
109 (`Msg
110 (Printf.sprintf "Invalid sort field '%s'. Valid options are: %s"
111 field_str
112 (String.concat ", " (List.map fst sort_field_enum)))))
113
114let sort_converter =
115 let parse s = parse_sort_spec s in
116 let print ppf spec =
117 let field_str =
118 List.find_map
119 (fun (name, field) -> if field = spec.field then Some name else None)
120 sort_field_enum
121 in
122 let order_str = if spec.descending then ":desc" else "" in
123 Fmt.pf ppf "%s%s" (Option.value field_str ~default:"unknown") order_str
124 in
125 Arg.conv (parse, print)
126
127let default_sort = { field = `Start; descending = false }
128
129let sort_arg =
130 let doc =
131 "Sorting specifications in the format 'field[:order]' where field is one \
132 of 'start', 'end', 'summary', 'location', 'calendar' and order is one of \
133 'asc'/'ascending' or 'desc'/'descending' (default: asc). Multiple sort \
134 specs can be provided for multi-level sorting. When no sort is specified, \
135 defaults to sorting by start time ascending."
136 in
137 Arg.(
138 value
139 & opt_all sort_converter [ default_sort ]
140 & info [ "sort"; "S" ] ~docv:"SORT" ~doc)
141
142(* Convert sort specs to an event comparator *)
143let create_event_comparator sort_specs =
144 match sort_specs with
145 | [] -> Event.by_start
146 | [ spec ] ->
147 let comp =
148 match spec.field with
149 | `Start -> Event.by_start
150 | `End -> Event.by_end
151 | `Summary -> Event.by_summary
152 | `Location -> Event.by_location
153 | `Calendar -> Event.by_calendar_name
154 in
155 if spec.descending then Event.descending comp else comp
156 | specs ->
157 (* Chain multiple sort specs together *)
158 List.fold_right
159 (fun spec acc ->
160 let comp =
161 match spec.field with
162 | `Start -> Event.by_start
163 | `End -> Event.by_end
164 | `Summary -> Event.by_summary
165 | `Location -> Event.by_location
166 | `Calendar -> Event.by_calendar_name
167 in
168 let comp = if spec.descending then Event.descending comp else comp in
169 Event.chain comp acc)
170 (List.tl specs)
171 (let spec = List.hd specs in
172 let comp =
173 match spec.field with
174 | `Start -> Event.by_start
175 | `End -> Event.by_end
176 | `Summary -> Event.by_summary
177 | `Location -> Event.by_location
178 | `Calendar -> Event.by_calendar_name
179 in
180 if spec.descending then Event.descending comp else comp)
181
182let parse_timezone ~timezone =
183 match timezone with
184 | Some tzid -> (
185 match Timedesc.Time_zone.make tzid with
186 | Some tz -> tz
187 | None -> failwith ("Invalid timezone: " ^ tzid))
188 | None -> !Date.default_timezone ()
189
190let date_format_manpage_entries =
191 [
192 `S "DATE FORMATS";
193 `P
194 "The following are the possible date formats for the --from and --to \
195 command line parameters. Note the value is dependent on --from / --to, \
196 so --from 2025 --to 2025 will include all the events in the year 2025";
197 `I ("YYYY-MM-DD", "Specific date (e.g., 2025-3-27, zero-padding optional)");
198 `I ("YYYY-MM", "Start/end of specific month (e.g., 2025-3 for March 2025)");
199 `I ("YYYY", "Start/end of specific year (e.g., 2025)");
200 `I ("today", "Current day");
201 `I ("tomorrow", "Next day");
202 `I ("yesterday", "Previous day");
203 `I ("this-week", "Start/end of current week");
204 `I ("next-week", "Start/end of next week");
205 `I ("this-month", "Start/end of current month");
206 `I ("next-month", "Start/end of next month");
207 `I ("+Nd", "N days from today (e.g., +7d for a week from today)");
208 `I ("-Nd", "N days before today (e.g., -7d for a week ago)");
209 `I ("+Nw", "N weeks from today (e.g., +4w for 4 weeks from today)");
210 `I ("+Nm", "N months from today (e.g., +2m for 2 months from today)");
211 ]