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