Command-line and Emacs Calendar Client

exposed sort functionality to query commands

Ryan Gibb 9e5d6377 d97295ec

+12 -8
bin/add_cmd.ml
···
@ date_format_manpage_entries
@ [
`S Manpage.s_examples;
-
`I ("Add a event for today:", "caled add \"Meeting\" -d today -t 14:00");
+
`I
+
( "Add a event for today:",
+
"caled add \"Meeting\" --date today --time 14:00" );
`I
( "Add an event with a specific date and time:",
-
"caled add \"Dentist Appointment\" -d 2025-04-15 -t 10:30" );
+
"caled add \"Dentist Appointment\" --date 2025-04-15 --time 10:30"
+
);
`I
( "Add an event with an end time:",
-
"caled add \"Conference\" -d 2025-05-20 -t 09:00 -e 2025-05-22 -T \
-
17:00" );
+
"caled add \"Conference\" --date 2025-05-20 --time 09:00 \
+
--end-date 2025-05-22 --end-time 17:00" );
`I
( "Add an event with location and description:",
-
"caled add \"Lunch with Bob\" -d 2025-04-02 -t 12:30 -l \"Pasta \
-
Restaurant\" -D \"Discuss project plans\"" );
+
"caled add \"Lunch with Bob\" --date 2025-04-02 --time 12:30 \
+
--location \"Pasta Restaurant\" --description \"Discuss project \
+
plans\"" );
`I
( "Add an event to a specific calendar:",
-
"caled add \"Work Meeting\" -d 2025-04-03 -t 15:00 --calendar work"
-
);
+
"caled add \"Work Meeting\" --date 2025-04-03 --time 15:00 \
+
--calendar work" );
`S "RECURRENCE";
`P
"Recurrence rule in iCalendar RFC5545 format. The FREQ part is \
+14 -5
bin/list_cmd.ml
···
open Query_args
let run ?from_str ?to_str ?calendar ?count ~format ~today ~tomorrow ~week ~month
-
?timezone ~fs calendar_dir =
+
?timezone ~sort ~fs calendar_dir =
let ( let* ) = Result.bind in
let tz = Query_args.parse_timezone ~timezone in
let* from, to_ =
···
Some (Query.in_collections [ Collection.Col collection_id ])
| None -> None
in
+
let comparator = Query_args.create_instance_comparator sort in
let* results =
-
Query.query ~fs calendar_dir ?filter ~from ~to_ ?limit:count ()
+
Query.query ~fs calendar_dir ?filter ~from ~to_ ~comparator ?limit:count ()
in
if results = [] then print_endline "No events found."
else
···
let cmd ~fs calendar_dir =
let run from_str to_str calendar count format today tomorrow week month
-
timezone =
+
timezone sort =
match
run ?from_str ?to_str ?calendar ?count ~format ~today ~tomorrow ~week
-
~month ?timezone ~fs calendar_dir
+
~month ?timezone ~sort ~fs calendar_dir
with
| Error (`Msg msg) ->
Printf.eprintf "Error: %s\n%!" msg;
···
let term =
Term.(
const run $ from_arg $ to_arg $ calendar_arg $ count_arg $ format_arg
-
$ today_arg $ tomorrow_arg $ week_arg $ month_arg $ timezone_arg)
+
$ today_arg $ tomorrow_arg $ week_arg $ month_arg $ timezone_arg
+
$ sort_arg)
in
let doc = "List calendar events" in
let man =
···
`P "By default, events from today to one month from today are shown.";
`P "You can use date flags to show events for a specific time period.";
`P "You can also filter events by calendar using the --calendar flag.";
+
`P "Use the --sort option to control the sorting of results.";
`S Manpage.s_options;
]
@ date_format_manpage_entries
···
("List events from a specific calendar:", "caled list --calendar work");
`I ("List events in JSON format:", "caled list --format json");
`I ("Limit the number of events shown:", "caled list --count 5");
+
`I
+
( "Sort by multiple fields (start time and summary):",
+
"caled list --sort start --sort summary" );
+
`I
+
( "Sort by calendar name in descending order:",
+
"caled list --sort calendar:desc" );
]
in
let exit_info =
+106
bin/query_args.ml
···
& opt (some string) None
& info [ "timezone"; "z" ] ~docv:"TIMEZONE" ~doc)
+
let sort_field_enum =
+
[
+
("start", `Start);
+
("end", `End);
+
("summary", `Summary);
+
("location", `Location);
+
("calendar", `Calendar);
+
]
+
+
type sort_spec = {
+
field : [ `Start | `End | `Summary | `Location | `Calendar ];
+
descending : bool;
+
}
+
+
let parse_sort_spec str =
+
let ( let* ) = Result.bind in
+
let parts = String.split_on_char ':' str in
+
match parts with
+
| [] -> Error (`Msg "Empty sort specification")
+
| field_str :: order_opt -> (
+
let* descending =
+
match order_opt with
+
| [ "desc" ] | [ "descending" ] -> Ok true
+
| [ "asc" ] | [ "ascending" ] -> Ok false
+
| [] -> Ok false (* Default to ascending *)
+
| _ -> Error (`Msg ("Invalid sort order in: " ^ str))
+
in
+
match List.assoc_opt field_str sort_field_enum with
+
| Some field -> Ok { field; descending }
+
| None ->
+
Error
+
(`Msg
+
(Printf.sprintf "Invalid sort field '%s'. Valid options are: %s"
+
field_str
+
(String.concat ", " (List.map fst sort_field_enum)))))
+
+
let sort_converter =
+
let parse s = parse_sort_spec s in
+
let print ppf spec =
+
let field_str =
+
List.find_map
+
(fun (name, field) -> if field = spec.field then Some name else None)
+
sort_field_enum
+
in
+
let order_str = if spec.descending then ":desc" else "" in
+
Fmt.pf ppf "%s%s" (Option.value field_str ~default:"unknown") order_str
+
in
+
Arg.conv (parse, print)
+
+
let default_sort = { field = `Start; descending = false }
+
+
let sort_arg =
+
let doc =
+
"Sorting specifications in the format 'field[:order]' where field is one \
+
of 'start', 'end', 'summary', 'location', 'calendar' and order is one of \
+
'asc'/'ascending' or 'desc'/'descending' (default: asc). Multiple sort \
+
specs can be provided for multi-level sorting. When no sort is specified, \
+
defaults to sorting by start time ascending."
+
in
+
Arg.(
+
value
+
& opt_all sort_converter [ default_sort ]
+
& info [ "sort"; "s" ] ~docv:"SORT" ~doc)
+
+
(* Convert sort specs to an instance comparator *)
+
let create_instance_comparator sort_specs =
+
match sort_specs with
+
| [] -> Recur.Instance.by_start
+
| [ spec ] ->
+
let comp =
+
match spec.field with
+
| `Start -> Recur.Instance.by_start
+
| `End -> Recur.Instance.by_end
+
| `Summary -> Recur.Instance.by_event Event.by_summary
+
| `Location -> Recur.Instance.by_event Event.by_location
+
| `Calendar -> Recur.Instance.by_event Event.by_collection
+
in
+
if spec.descending then Recur.Instance.descending comp else comp
+
| specs ->
+
(* Chain multiple sort specs together *)
+
List.fold_right
+
(fun spec acc ->
+
let comp =
+
match spec.field with
+
| `Start -> Recur.Instance.by_start
+
| `End -> Recur.Instance.by_end
+
| `Summary -> Recur.Instance.by_event Event.by_summary
+
| `Location -> Recur.Instance.by_event Event.by_location
+
| `Calendar -> Recur.Instance.by_event Event.by_collection
+
in
+
let comp =
+
if spec.descending then Recur.Instance.descending comp else comp
+
in
+
Recur.Instance.chain comp acc)
+
(List.tl specs)
+
(let spec = List.hd specs in
+
let comp =
+
match spec.field with
+
| `Start -> Recur.Instance.by_start
+
| `End -> Recur.Instance.by_end
+
| `Summary -> Recur.Instance.by_event Event.by_summary
+
| `Location -> Recur.Instance.by_event Event.by_location
+
| `Calendar -> Recur.Instance.by_event Event.by_collection
+
in
+
if spec.descending then Recur.Instance.descending comp else comp)
+
let date_format_manpage_entries =
[
`S "DATE FORMATS";
+16 -6
bin/search_cmd.ml
···
let run ?from_str ?to_str ?calendar ?count ?query_text ~summary ~description
~location ~format ~today ~tomorrow ~week ~month ~recurring ~non_recurring
-
?timezone ~fs calendar_dir =
+
?timezone ~sort ~fs calendar_dir =
let ( let* ) = Result.bind in
let filters = ref [] in
let tz = Query_args.parse_timezone ~timezone in
···
if recurring then filters := Query.recurring_only () :: !filters;
if non_recurring then filters := Query.non_recurring_only () :: !filters;
let filter = Query.and_filter !filters in
+
let comparator = Query_args.create_instance_comparator sort in
let* results =
-
Query.query ~fs calendar_dir ~filter ~from ~to_ ?limit:count ()
+
Query.query ~fs calendar_dir ~filter ~from ~to_ ~comparator ?limit:count ()
in
if results = [] then print_endline "No events found."
-
else print_endline (Format.format_instances ~fs ~calendar_dir ~format results);
+
else
+
print_endline
+
(Format.format_instances ~tz ~fs ~calendar_dir ~format results);
Ok ()
let query_text_arg =
···
let cmd ~fs calendar_dir =
let run query_text from_str to_str calendar count format summary description
-
location today tomorrow week month recurring non_recurring timezone =
+
location today tomorrow week month recurring non_recurring timezone sort =
match
run ?from_str ?to_str ?calendar ?count ?query_text ~summary ~description
~location ~format ~today ~tomorrow ~week ~month ~recurring
-
~non_recurring ?timezone ~fs calendar_dir
+
~non_recurring ?timezone ~sort ~fs calendar_dir
with
| Error (`Msg msg) ->
Printf.eprintf "Error: %s\n%!" msg;
···
const run $ query_text_arg $ from_arg $ to_arg $ calendar_arg $ count_arg
$ format_arg $ summary_arg $ description_arg $ location_arg $ today_arg
$ tomorrow_arg $ week_arg $ month_arg $ recurring_arg $ non_recurring_arg
-
$ timezone_arg)
+
$ timezone_arg $ sort_arg)
in
let doc = "Search calendar events for specific text" in
let man =
···
`P
"You can limit results to only recurring or non-recurring events using \
the --recurring or --non-recurring flags.";
+
`P "Use the --sort option to control the sorting of results.";
`P
"The search text is optional if you're using other filters. For \
example, you can find all recurring events without specifying any \
···
`I
( "Find all events in a specific calendar:",
"caled search --calendar work" );
+
`I
+
( "Sort results by location and then summary:",
+
"caled search --sort location --sort summary" );
+
`I
+
( "Sort results by end time in descending order:",
+
"caled search --sort end:desc" );
]
in
let exit_info =
+4
lib/event.ml
···
| Collection.Col c1, Collection.Col c2 -> String.compare c1 c2
let descending comp e1 e2 = -1 * comp e1 e2
+
+
let chain comp1 comp2 e1 e2 =
+
let result = comp1 e1 e2 in
+
if result <> 0 then result else comp2 e1 e2
+4
lib/event.mli
···
val descending : comparator -> comparator
(** Reverse the order of a comparator *)
+
+
val chain : comparator -> comparator -> comparator
+
(** Chain two comparators together, using the second one as a tiebreaker when
+
the first one returns equality (0) *)
+4
lib/recur.ml
···
let by_event event_comp i1 i2 = event_comp i1.event i2.event
let descending comp i1 i2 = -1 * comp i1 i2
+
+
let chain comp1 comp2 i1 i2 =
+
let result = comp1 i1 i2 in
+
if result <> 0 then result else comp2 i1 i2
end
let clone_with_time original start =
+6 -2
lib/recur.mli
···
(** Compare instances by start time, earlier times come first *)
val by_end : comparator
-
(** Compare instances by end time, earlier times come first.
-
Instances with end times come after those without *)
+
(** Compare instances by end time, earlier times come first. Instances with
+
end times come after those without *)
val by_event : Event.comparator -> comparator
(** Apply an event comparator to instances *)
val descending : comparator -> comparator
(** Reverse the order of a comparator *)
+
+
val chain : comparator -> comparator -> comparator
+
(** Chain two comparators together, using the second one as a tiebreaker when
+
the first one returns equality (0) *)
end
val expand_event :