1(** mlgpx Command Line Interface with pretty ANSI output *)
2
3open Cmdliner
4open Gpx
5
6let ( / ) = Eio.Path.( / )
7
8(* Terminal and formatting setup *)
9let setup_fmt style_renderer =
10 Fmt_tty.setup_std_outputs ?style_renderer ();
11 ()
12
13(* Color formatters *)
14let info_style = Fmt.(styled (`Fg `Green))
15let warn_style = Fmt.(styled (`Fg `Yellow))
16let error_style = Fmt.(styled (`Fg `Red))
17let success_style = Fmt.(styled (`Fg `Green))
18let bold_style = Fmt.(styled `Bold)
19
20(* Logging functions *)
21let log_info fmt =
22 Fmt.pf Format.err_formatter "[%a] " (info_style Fmt.string) "INFO";
23 Format.kfprintf (fun fmt -> Format.pp_print_newline fmt (); Format.pp_print_flush fmt ()) Format.err_formatter fmt
24
25
26let log_error fmt =
27 Fmt.pf Format.err_formatter "[%a] " (error_style Fmt.string) "ERROR";
28 Format.kfprintf (fun fmt -> Format.pp_print_newline fmt (); Format.pp_print_flush fmt ()) Format.err_formatter fmt
29
30let log_success fmt =
31 Format.kfprintf (fun fmt -> Format.pp_print_newline fmt (); Format.pp_print_flush fmt ()) Format.std_formatter fmt
32
33(* Utility functions *)
34let waypoints_to_track_segments waypoints =
35 if waypoints = [] then
36 []
37 else
38 Track.Segment.make waypoints :: []
39
40let sort_waypoints sort_by_time sort_by_name waypoints =
41 if sort_by_time then
42 List.sort (fun wpt1 wpt2 ->
43 match Waypoint.time wpt1, Waypoint.time wpt2 with
44 | Some t1, Some t2 -> Ptime.compare t1 t2
45 | Some _, None -> -1
46 | None, Some _ -> 1
47 | None, None -> 0
48 ) waypoints
49 else if sort_by_name then
50 List.sort (fun wpt1 wpt2 ->
51 match Waypoint.name wpt1, Waypoint.name wpt2 with
52 | Some n1, Some n2 -> String.compare n1 n2
53 | Some _, None -> -1
54 | None, Some _ -> 1
55 | None, None -> 0
56 ) waypoints
57 else
58 waypoints
59
60(* Main conversion command *)
61let convert_waypoints_to_trackset input_file output_file track_name track_desc
62 sort_by_time sort_by_name preserve_waypoints verbose style_renderer =
63 setup_fmt style_renderer;
64 let run env =
65 try
66 let fs = Eio.Stdenv.fs env in
67
68 if verbose then
69 log_info "Reading GPX file: %a" (bold_style Fmt.string) input_file;
70
71 (* Read input GPX *)
72 let gpx = Gpx_eio.read (fs / input_file) in
73
74 if verbose then
75 log_info "Found %d waypoints and %d existing tracks"
76 (Doc.waypoint_count gpx)
77 (Doc.track_count gpx);
78
79 (* Check if we have waypoints to convert *)
80 if Doc.waypoints gpx = [] then (
81 log_error "Input file contains no waypoints - nothing to convert";
82 exit 1
83 );
84
85 (* Sort waypoints if requested *)
86 let sorted_waypoints = sort_waypoints sort_by_time sort_by_name (Doc.waypoints gpx) in
87
88 if verbose && (sort_by_time || sort_by_name) then
89 log_info "Sorted %d waypoints" (List.length sorted_waypoints);
90
91 (* Convert waypoints to track segments *)
92 let track_segments = waypoints_to_track_segments sorted_waypoints in
93
94 (* Create the new track *)
95 let new_track = Track.make ~name:track_name in
96 let new_track = { new_track with
97 cmt = Some "Generated from waypoints by mlgpx CLI";
98 desc = track_desc;
99 src = Some "mlgpx";
100 type_ = Some "converted";
101 trksegs = track_segments;
102 } in
103
104 if verbose then (
105 let total_points = List.fold_left (fun acc seg -> acc + Track.Segment.point_count seg) 0 track_segments in
106 log_info "Created track %a with %d segments containing %d points"
107 (bold_style Fmt.string) track_name
108 (List.length track_segments) total_points
109 );
110
111 (* Build output GPX *)
112 let output_gpx =
113 if preserve_waypoints then
114 Doc.add_track gpx new_track
115 else
116 Doc.add_track (Doc.clear_waypoints gpx) new_track in
117 let output_gpx =
118 match Doc.metadata gpx with
119 | Some meta ->
120 let desc = match Metadata.description meta with
121 | Some existing -> existing ^ " (waypoints converted to track)"
122 | None -> "Waypoints converted to track" in
123 Doc.with_metadata output_gpx { meta with desc = Some desc }
124 | None ->
125 let meta = Metadata.make ~name:"Converted" in
126 let meta = { meta with desc = Some "Waypoints converted to track" } in
127 Doc.with_metadata output_gpx meta in
128
129 (* Validate output *)
130 let validation = Gpx.validate_gpx output_gpx in
131 if not validation.is_valid then (
132 log_error "Generated GPX failed validation:";
133 List.iter (fun (issue : Gpx.validation_issue) ->
134 let level_str = match issue.level with `Error -> "ERROR" | `Warning -> "WARNING" in
135 let level_color = match issue.level with `Error -> error_style | `Warning -> warn_style in
136 Fmt.pf Format.err_formatter " %a: %s\n" (level_color Fmt.string) level_str issue.message
137 ) validation.issues;
138 exit 1
139 );
140
141 if verbose then
142 log_info "Writing output to: %a" (bold_style Fmt.string) output_file;
143
144 (* Write output GPX *)
145 Gpx_eio.write (fs / output_file) output_gpx;
146
147 if verbose then (
148 Fmt.pf Format.std_formatter "%a\n" (success_style Fmt.string) "Conversion completed successfully!";
149 log_info "Output contains:";
150 Fmt.pf Format.err_formatter " - %d waypoints%s\n"
151 (Doc.waypoint_count output_gpx)
152 (if preserve_waypoints then " (preserved)" else " (removed)");
153 Fmt.pf Format.err_formatter " - %d tracks (%a + %d existing)\n"
154 (Doc.track_count output_gpx)
155 (success_style Fmt.string) "1 new"
156 (Doc.track_count gpx)
157 ) else (
158 log_success "Converted %d waypoints to track: %a → %a"
159 (List.length sorted_waypoints)
160 (bold_style Fmt.string) input_file
161 (bold_style Fmt.string) output_file
162 )
163
164 with
165 | Gpx.Gpx_error err ->
166 log_error "GPX Error: %s" (Error.to_string err);
167 exit 2
168 | Sys_error msg ->
169 log_error "System error: %s" msg;
170 exit 2
171 | exn ->
172 log_error "Unexpected error: %s" (Printexc.to_string exn);
173 exit 2
174 in
175 Eio_main.run run
176
177(* Helper function to collect all timestamps from GPX *)
178let collect_all_timestamps gpx =
179 let times = ref [] in
180
181 (* Collect from waypoints *)
182 List.iter (fun wpt ->
183 match Waypoint.time wpt with
184 | Some t -> times := t :: !times
185 | None -> ()
186 ) (Doc.waypoints gpx);
187
188 (* Collect from routes *)
189 List.iter (fun route ->
190 List.iter (fun rtept ->
191 match Waypoint.time rtept with
192 | Some t -> times := t :: !times
193 | None -> ()
194 ) (Route.points route)
195 ) (Doc.routes gpx);
196
197 (* Collect from tracks *)
198 List.iter (fun track ->
199 List.iter (fun seg ->
200 List.iter (fun trkpt ->
201 match Waypoint.time trkpt with
202 | Some t -> times := t :: !times
203 | None -> ()
204 ) (Track.Segment.points seg)
205 ) (Track.segments track)
206 ) (Doc.tracks gpx);
207
208 !times
209
210(* Info command *)
211let info_command input_file verbose style_renderer =
212 setup_fmt style_renderer;
213 let run env =
214 try
215 let fs = Eio.Stdenv.fs env in
216
217 if verbose then
218 log_info "Analyzing GPX file: %a" (bold_style Fmt.string) input_file;
219
220 let gpx = Gpx_eio.read (fs / input_file) in
221
222 (* Header *)
223 Fmt.pf Format.std_formatter "%a\n" (bold_style Fmt.string) "GPX File Information";
224
225 (* Basic info *)
226 Printf.printf " Version: %s\n" (Doc.version gpx);
227 Printf.printf " Creator: %s\n" (Doc.creator gpx);
228
229 (match Doc.metadata gpx with
230 | Some meta ->
231 Printf.printf " Name: %s\n" (Option.value (Metadata.name meta) ~default:"<unnamed>");
232 Printf.printf " Description: %s\n" (Option.value (Metadata.description meta) ~default:"<none>");
233 (match Metadata.time meta with
234 | Some time -> Printf.printf " Created: %s\n" (Ptime.to_rfc3339 time)
235 | None -> ())
236 | None ->
237 Printf.printf " No metadata\n");
238
239 (* Content summary *)
240 Fmt.pf Format.std_formatter "\n%a\n" (bold_style Fmt.string) "Content Summary";
241 Printf.printf " Waypoints: %d\n" (Doc.waypoint_count gpx);
242 Printf.printf " Routes: %d\n" (Doc.route_count gpx);
243 Printf.printf " Tracks: %d\n" (Doc.track_count gpx);
244
245 (* Time range *)
246 let all_times = collect_all_timestamps gpx in
247 if all_times <> [] then (
248 let sorted_times = List.sort Ptime.compare all_times in
249 let start_time = List.hd sorted_times in
250 let stop_time = List.hd (List.rev sorted_times) in
251
252 Fmt.pf Format.std_formatter "\n%a\n" (bold_style Fmt.string) "Time Range";
253 Fmt.pf Format.std_formatter " Start: %a\n" (info_style Fmt.string) (Ptime.to_rfc3339 start_time);
254 Fmt.pf Format.std_formatter " Stop: %a\n" (info_style Fmt.string) (Ptime.to_rfc3339 stop_time);
255
256 (* Calculate duration *)
257 let duration_span = Ptime.diff stop_time start_time in
258 match Ptime.Span.to_int_s duration_span with
259 | Some seconds ->
260 let ( / ) = Int.div in
261 let days = seconds / 86400 in
262 let hours = (seconds mod 86400) / 3600 in
263 let minutes = (seconds mod 3600) / 60 in
264
265 if days > 0 then
266 Fmt.pf Format.std_formatter " Duration: %a\n" (bold_style Fmt.string)
267 (Printf.sprintf "%d days, %d hours, %d minutes" days hours minutes)
268 else if hours > 0 then
269 Fmt.pf Format.std_formatter " Duration: %a\n" (bold_style Fmt.string)
270 (Printf.sprintf "%d hours, %d minutes" hours minutes)
271 else
272 Fmt.pf Format.std_formatter " Duration: %a\n" (bold_style Fmt.string)
273 (Printf.sprintf "%d minutes" minutes)
274 | None ->
275 (* Duration too large to represent as int *)
276 Fmt.pf Format.std_formatter " Duration: %a\n" (bold_style Fmt.string)
277 (Printf.sprintf "%.1f days" (Ptime.Span.to_float_s duration_span /. 86400.));
278
279 Printf.printf " Total points with timestamps: %d\n" (List.length all_times)
280 );
281
282 (* Detailed waypoint info *)
283 if Doc.waypoints gpx <> [] then (
284 Fmt.pf Format.std_formatter "\n%a\n" (bold_style Fmt.string) "Waypoints";
285 let waypoints_with_time = List.filter (fun wpt -> Waypoint.time wpt <> None) (Doc.waypoints gpx) in
286 let waypoints_with_elevation = List.filter (fun wpt -> Waypoint.elevation wpt <> None) (Doc.waypoints gpx) in
287 Printf.printf " - %d with timestamps\n" (List.length waypoints_with_time);
288 Printf.printf " - %d with elevation data\n" (List.length waypoints_with_elevation);
289
290 if verbose && List.length (Doc.waypoints gpx) <= 10 then (
291 Printf.printf " Details:\n";
292 List.iteri (fun i wpt ->
293 let lat, lon = Waypoint.to_floats wpt in
294 Fmt.pf Format.std_formatter " %a %s (%.6f, %.6f)%s%s\n"
295 (info_style Fmt.string) (Printf.sprintf "%d." (i + 1))
296 (Option.value (Waypoint.name wpt) ~default:"<unnamed>")
297 lat lon
298 (match Waypoint.elevation wpt with Some e -> Printf.sprintf " elev=%.1fm" e | None -> "")
299 (match Waypoint.time wpt with Some t -> " @" ^ Ptime.to_rfc3339 t | None -> "")
300 ) (Doc.waypoints gpx)
301 )
302 );
303
304 (* Track info *)
305 if Doc.tracks gpx <> [] then (
306 Fmt.pf Format.std_formatter "\n%a\n" (bold_style Fmt.string) "Tracks";
307 List.iteri (fun i track ->
308 let total_points = Track.point_count track in
309 Fmt.pf Format.std_formatter " %a %s (%d segments, %d points)\n"
310 (info_style Fmt.string) (Printf.sprintf "%d." (i + 1))
311 (Option.value (Track.name track) ~default:"<unnamed>")
312 (Track.segment_count track) total_points
313 ) (Doc.tracks gpx)
314 );
315
316 (* Validation *)
317 let validation = Gpx.validate_gpx gpx in
318 Printf.printf "\n";
319 if validation.is_valid then
320 Fmt.pf Format.std_formatter "Validation: %a\n" (success_style Fmt.string) "PASSED"
321 else (
322 Fmt.pf Format.std_formatter "Validation: %a\n" (error_style Fmt.string) "FAILED";
323 List.iter (fun (issue : Gpx.validation_issue) ->
324 let level_str = match issue.level with `Error -> "ERROR" | `Warning -> "WARNING" in
325 let level_color = match issue.level with `Error -> error_style | `Warning -> warn_style in
326 Fmt.pf Format.std_formatter " %a: %s\n" (level_color Fmt.string) level_str issue.message
327 ) validation.issues
328 )
329
330 with
331 | Gpx.Gpx_error err ->
332 log_error "GPX Error: %s" (Error.to_string err);
333 exit 2
334 | Sys_error msg ->
335 log_error "System error: %s" msg;
336 exit 2
337 | exn ->
338 log_error "Unexpected error: %s" (Printexc.to_string exn);
339 exit 2
340 in
341 Eio_main.run run
342
343(* CLI argument definitions *)
344let input_file_arg =
345 let doc = "Input GPX file path" in
346 Arg.(required & pos 0 (some non_dir_file) None & info [] ~docv:"INPUT" ~doc)
347
348let output_file_arg =
349 let doc = "Output GPX file path" in
350 Arg.(required & pos 1 (some string) None & info [] ~docv:"OUTPUT" ~doc)
351
352let track_name_opt =
353 let doc = "Name for the generated track (default: \"Converted from waypoints\")" in
354 Arg.(value & opt string "Converted from waypoints" & info ["n"; "name"] ~docv:"NAME" ~doc)
355
356let track_description_opt =
357 let doc = "Description for the generated track" in
358 Arg.(value & opt (some string) None & info ["d"; "desc"] ~docv:"DESC" ~doc)
359
360let sort_by_time_flag =
361 let doc = "Sort waypoints by timestamp before conversion" in
362 Arg.(value & flag & info ["t"; "sort-time"] ~doc)
363
364let sort_by_name_flag =
365 let doc = "Sort waypoints by name before conversion" in
366 Arg.(value & flag & info ["sort-name"] ~doc)
367
368let preserve_waypoints_flag =
369 let doc = "Keep original waypoints in addition to generated track" in
370 Arg.(value & flag & info ["p"; "preserve"] ~doc)
371
372let verbose_flag =
373 let doc = "Enable verbose output" in
374 Arg.(value & flag & info ["v"; "verbose"] ~doc)
375
376(* Command definitions *)
377let convert_cmd =
378 let doc = "Convert waypoints to trackset" in
379 let man = [
380 `S Manpage.s_description;
381 `P "Convert all waypoints in a GPX file to a single track. This is useful for \
382 converting a collection of waypoints into a navigable route or for \
383 consolidating GPS data.";
384 `P "The conversion preserves all waypoint data (coordinates, elevation, \
385 timestamps, etc.) in the track points. By default, waypoints are removed \
386 from the output file unless --preserve is used.";
387 `S Manpage.s_examples;
388 `P "Convert waypoints to track:";
389 `Pre " mlgpx convert waypoints.gpx track.gpx";
390 `P "Convert with custom track name and preserve original waypoints:";
391 `Pre " mlgpx convert -n \"My Route\" --preserve waypoints.gpx route.gpx";
392 `P "Sort waypoints by timestamp before conversion:";
393 `Pre " mlgpx convert --sort-time waypoints.gpx sorted_track.gpx";
394 ] in
395 let term = Term.(const convert_waypoints_to_trackset $ input_file_arg $ output_file_arg
396 $ track_name_opt $ track_description_opt $ sort_by_time_flag
397 $ sort_by_name_flag $ preserve_waypoints_flag $ verbose_flag
398 $ Fmt_cli.style_renderer ()) in
399 Cmd.v (Cmd.info "convert" ~doc ~man) term
400
401let info_cmd =
402 let doc = "Display information about a GPX file" in
403 let man = [
404 `S Manpage.s_description;
405 `P "Analyze and display detailed information about a GPX file including \
406 statistics, content summary, and validation results.";
407 `P "This command is useful for understanding the structure and content \
408 of GPX files before processing them.";
409 `S Manpage.s_examples;
410 `P "Show basic information:";
411 `Pre " mlgpx info file.gpx";
412 `P "Show detailed information with waypoint details:";
413 `Pre " mlgpx info -v file.gpx";
414 ] in
415 let input_arg =
416 let doc = "GPX file to analyze" in
417 Arg.(required & pos 0 (some non_dir_file) None & info [] ~docv:"FILE" ~doc) in
418 let term = Term.(const info_command $ input_arg $ verbose_flag
419 $ Fmt_cli.style_renderer ()) in
420 Cmd.v (Cmd.info "info" ~doc ~man) term
421
422(* Main CLI *)
423let main_cmd =
424 let doc = "mlgpx - GPX file manipulation toolkit" in
425 let man = [
426 `S Manpage.s_description;
427 `P "mlgpx is a command-line toolkit for working with GPX (GPS Exchange Format) \
428 files. It provides tools for converting, analyzing, and manipulating GPS data.";
429 `S Manpage.s_commands;
430 `P "Available commands:";
431 `P "$(b,convert) - Convert waypoints to trackset";
432 `P "$(b,info) - Display GPX file information";
433 `S Manpage.s_common_options;
434 `P "$(b,--verbose), $(b,-v) - Enable verbose output";
435 `P "$(b,--color)={auto|always|never} - Control ANSI color output";
436 `P "$(b,--help) - Show command help";
437 `S Manpage.s_examples;
438 `P "Convert waypoints to track:";
439 `Pre " mlgpx convert waypoints.gpx track.gpx";
440 `P "Analyze a GPX file with colors:";
441 `Pre " mlgpx info --verbose --color=always file.gpx";
442 `P "Convert without colors for scripts:";
443 `Pre " mlgpx convert --color=never waypoints.gpx track.gpx";
444 `S Manpage.s_bugs;
445 `P "Report bugs at https://github.com/avsm/mlgpx/issues";
446 ] in
447 let default_term = Term.(ret (const (`Help (`Pager, None)))) in
448 Cmd.group (Cmd.info "mlgpx" ~version:"0.1.0" ~doc ~man) ~default:default_term
449 [convert_cmd; info_cmd]
450
451let () =
452 Printexc.record_backtrace true;
453 exit (Cmd.eval main_cmd)