(** mlgpx Command Line Interface with pretty ANSI output *) open Cmdliner open Gpx (* Terminal and formatting setup *) let setup_fmt style_renderer = Fmt_tty.setup_std_outputs ?style_renderer (); () (* Color formatters *) let info_style = Fmt.(styled (`Fg `Green)) let warn_style = Fmt.(styled (`Fg `Yellow)) let error_style = Fmt.(styled (`Fg `Red)) let success_style = Fmt.(styled (`Fg `Green)) let bold_style = Fmt.(styled `Bold) (* Logging functions *) let log_info fmt = Fmt.pf Format.err_formatter "[%a] " (info_style Fmt.string) "INFO"; Format.kfprintf (fun fmt -> Format.pp_print_newline fmt (); Format.pp_print_flush fmt ()) Format.err_formatter fmt let log_error fmt = Fmt.pf Format.err_formatter "[%a] " (error_style Fmt.string) "ERROR"; Format.kfprintf (fun fmt -> Format.pp_print_newline fmt (); Format.pp_print_flush fmt ()) Format.err_formatter fmt let log_success fmt = Format.kfprintf (fun fmt -> Format.pp_print_newline fmt (); Format.pp_print_flush fmt ()) Format.std_formatter fmt (* Utility functions *) let waypoints_to_track_segments waypoints = if waypoints = [] then [] else Track.Segment.make waypoints :: [] let sort_waypoints sort_by_time sort_by_name waypoints = if sort_by_time then List.sort (fun wpt1 wpt2 -> match Waypoint.time wpt1, Waypoint.time wpt2 with | Some t1, Some t2 -> Ptime.compare t1 t2 | Some _, None -> -1 | None, Some _ -> 1 | None, None -> 0 ) waypoints else if sort_by_name then List.sort (fun wpt1 wpt2 -> match Waypoint.name wpt1, Waypoint.name wpt2 with | Some n1, Some n2 -> String.compare n1 n2 | Some _, None -> -1 | None, Some _ -> 1 | None, None -> 0 ) waypoints else waypoints (* Main conversion command *) let convert_waypoints_to_trackset input_file output_file track_name track_desc sort_by_time sort_by_name preserve_waypoints verbose style_renderer = setup_fmt style_renderer; let run env = try let fs = Eio.Stdenv.fs env in if verbose then log_info "Reading GPX file: %a" (bold_style Fmt.string) input_file; (* Read input GPX *) let gpx = Gpx_eio.read ~fs input_file in if verbose then log_info "Found %d waypoints and %d existing tracks" (Doc.waypoint_count gpx) (Doc.track_count gpx); (* Check if we have waypoints to convert *) if Doc.waypoints gpx = [] then ( log_error "Input file contains no waypoints - nothing to convert"; exit 1 ); (* Sort waypoints if requested *) let sorted_waypoints = sort_waypoints sort_by_time sort_by_name (Doc.waypoints gpx) in if verbose && (sort_by_time || sort_by_name) then log_info "Sorted %d waypoints" (List.length sorted_waypoints); (* Convert waypoints to track segments *) let track_segments = waypoints_to_track_segments sorted_waypoints in (* Create the new track *) let new_track = Track.make ~name:track_name in let new_track = { new_track with cmt = Some "Generated from waypoints by mlgpx CLI"; desc = track_desc; src = Some "mlgpx"; type_ = Some "converted"; trksegs = track_segments; } in if verbose then ( let total_points = List.fold_left (fun acc seg -> acc + Track.Segment.point_count seg) 0 track_segments in log_info "Created track %a with %d segments containing %d points" (bold_style Fmt.string) track_name (List.length track_segments) total_points ); (* Build output GPX *) let output_gpx = if preserve_waypoints then Doc.add_track gpx new_track else Doc.add_track (Doc.clear_waypoints gpx) new_track in let output_gpx = match Doc.metadata gpx with | Some meta -> let desc = match Metadata.description meta with | Some existing -> existing ^ " (waypoints converted to track)" | None -> "Waypoints converted to track" in Doc.with_metadata output_gpx { meta with desc = Some desc } | None -> let meta = Metadata.make ~name:"Converted" in let meta = { meta with desc = Some "Waypoints converted to track" } in Doc.with_metadata output_gpx meta in (* Validate output *) let validation = Gpx.validate_gpx output_gpx in if not validation.is_valid then ( log_error "Generated GPX failed validation:"; List.iter (fun (issue : Gpx.validation_issue) -> let level_str = match issue.level with `Error -> "ERROR" | `Warning -> "WARNING" in let level_color = match issue.level with `Error -> error_style | `Warning -> warn_style in Fmt.pf Format.err_formatter " %a: %s\n" (level_color Fmt.string) level_str issue.message ) validation.issues; exit 1 ); if verbose then log_info "Writing output to: %a" (bold_style Fmt.string) output_file; (* Write output GPX *) Gpx_eio.write ~fs output_file output_gpx; if verbose then ( Fmt.pf Format.std_formatter "%a\n" (success_style Fmt.string) "Conversion completed successfully!"; log_info "Output contains:"; Fmt.pf Format.err_formatter " - %d waypoints%s\n" (Doc.waypoint_count output_gpx) (if preserve_waypoints then " (preserved)" else " (removed)"); Fmt.pf Format.err_formatter " - %d tracks (%a + %d existing)\n" (Doc.track_count output_gpx) (success_style Fmt.string) "1 new" (Doc.track_count gpx) ) else ( log_success "Converted %d waypoints to track: %a → %a" (List.length sorted_waypoints) (bold_style Fmt.string) input_file (bold_style Fmt.string) output_file ) with | Gpx.Gpx_error err -> log_error "GPX Error: %s" (Error.to_string err); exit 2 | Sys_error msg -> log_error "System error: %s" msg; exit 2 | exn -> log_error "Unexpected error: %s" (Printexc.to_string exn); exit 2 in Eio_main.run run (* Helper function to collect all timestamps from GPX *) let collect_all_timestamps gpx = let times = ref [] in (* Collect from waypoints *) List.iter (fun wpt -> match Waypoint.time wpt with | Some t -> times := t :: !times | None -> () ) (Doc.waypoints gpx); (* Collect from routes *) List.iter (fun route -> List.iter (fun rtept -> match Waypoint.time rtept with | Some t -> times := t :: !times | None -> () ) (Route.points route) ) (Doc.routes gpx); (* Collect from tracks *) List.iter (fun track -> List.iter (fun seg -> List.iter (fun trkpt -> match Waypoint.time trkpt with | Some t -> times := t :: !times | None -> () ) (Track.Segment.points seg) ) (Track.segments track) ) (Doc.tracks gpx); !times (* Info command *) let info_command input_file verbose style_renderer = setup_fmt style_renderer; let run env = try let fs = Eio.Stdenv.fs env in if verbose then log_info "Analyzing GPX file: %a" (bold_style Fmt.string) input_file; let gpx = Gpx_eio.read ~fs input_file in (* Header *) Fmt.pf Format.std_formatter "%a\n" (bold_style Fmt.string) "GPX File Information"; (* Basic info *) Printf.printf " Version: %s\n" (Doc.version gpx); Printf.printf " Creator: %s\n" (Doc.creator gpx); (match Doc.metadata gpx with | Some meta -> Printf.printf " Name: %s\n" (Option.value (Metadata.name meta) ~default:""); Printf.printf " Description: %s\n" (Option.value (Metadata.description meta) ~default:""); (match Metadata.time meta with | Some time -> Printf.printf " Created: %s\n" (Ptime.to_rfc3339 time) | None -> ()) | None -> Printf.printf " No metadata\n"); (* Content summary *) Fmt.pf Format.std_formatter "\n%a\n" (bold_style Fmt.string) "Content Summary"; Printf.printf " Waypoints: %d\n" (Doc.waypoint_count gpx); Printf.printf " Routes: %d\n" (Doc.route_count gpx); Printf.printf " Tracks: %d\n" (Doc.track_count gpx); (* Time range *) let all_times = collect_all_timestamps gpx in if all_times <> [] then ( let sorted_times = List.sort Ptime.compare all_times in let start_time = List.hd sorted_times in let stop_time = List.hd (List.rev sorted_times) in Fmt.pf Format.std_formatter "\n%a\n" (bold_style Fmt.string) "Time Range"; Fmt.pf Format.std_formatter " Start: %a\n" (info_style Fmt.string) (Ptime.to_rfc3339 start_time); Fmt.pf Format.std_formatter " Stop: %a\n" (info_style Fmt.string) (Ptime.to_rfc3339 stop_time); (* Calculate duration *) let duration_span = Ptime.diff stop_time start_time in match Ptime.Span.to_int_s duration_span with | Some seconds -> let days = seconds / 86400 in let hours = (seconds mod 86400) / 3600 in let minutes = (seconds mod 3600) / 60 in if days > 0 then Fmt.pf Format.std_formatter " Duration: %a\n" (bold_style Fmt.string) (Printf.sprintf "%d days, %d hours, %d minutes" days hours minutes) else if hours > 0 then Fmt.pf Format.std_formatter " Duration: %a\n" (bold_style Fmt.string) (Printf.sprintf "%d hours, %d minutes" hours minutes) else Fmt.pf Format.std_formatter " Duration: %a\n" (bold_style Fmt.string) (Printf.sprintf "%d minutes" minutes) | None -> (* Duration too large to represent as int *) Fmt.pf Format.std_formatter " Duration: %a\n" (bold_style Fmt.string) (Printf.sprintf "%.1f days" (Ptime.Span.to_float_s duration_span /. 86400.)); Printf.printf " Total points with timestamps: %d\n" (List.length all_times) ); (* Detailed waypoint info *) if Doc.waypoints gpx <> [] then ( Fmt.pf Format.std_formatter "\n%a\n" (bold_style Fmt.string) "Waypoints"; let waypoints_with_time = List.filter (fun wpt -> Waypoint.time wpt <> None) (Doc.waypoints gpx) in let waypoints_with_elevation = List.filter (fun wpt -> Waypoint.elevation wpt <> None) (Doc.waypoints gpx) in Printf.printf " - %d with timestamps\n" (List.length waypoints_with_time); Printf.printf " - %d with elevation data\n" (List.length waypoints_with_elevation); if verbose && List.length (Doc.waypoints gpx) <= 10 then ( Printf.printf " Details:\n"; List.iteri (fun i wpt -> let lat, lon = Waypoint.to_floats wpt in Fmt.pf Format.std_formatter " %a %s (%.6f, %.6f)%s%s\n" (info_style Fmt.string) (Printf.sprintf "%d." (i + 1)) (Option.value (Waypoint.name wpt) ~default:"") lat lon (match Waypoint.elevation wpt with Some e -> Printf.sprintf " elev=%.1fm" e | None -> "") (match Waypoint.time wpt with Some t -> " @" ^ Ptime.to_rfc3339 t | None -> "") ) (Doc.waypoints gpx) ) ); (* Track info *) if Doc.tracks gpx <> [] then ( Fmt.pf Format.std_formatter "\n%a\n" (bold_style Fmt.string) "Tracks"; List.iteri (fun i track -> let total_points = Track.point_count track in Fmt.pf Format.std_formatter " %a %s (%d segments, %d points)\n" (info_style Fmt.string) (Printf.sprintf "%d." (i + 1)) (Option.value (Track.name track) ~default:"") (Track.segment_count track) total_points ) (Doc.tracks gpx) ); (* Validation *) let validation = Gpx.validate_gpx gpx in Printf.printf "\n"; if validation.is_valid then Fmt.pf Format.std_formatter "Validation: %a\n" (success_style Fmt.string) "PASSED" else ( Fmt.pf Format.std_formatter "Validation: %a\n" (error_style Fmt.string) "FAILED"; List.iter (fun (issue : Gpx.validation_issue) -> let level_str = match issue.level with `Error -> "ERROR" | `Warning -> "WARNING" in let level_color = match issue.level with `Error -> error_style | `Warning -> warn_style in Fmt.pf Format.std_formatter " %a: %s\n" (level_color Fmt.string) level_str issue.message ) validation.issues ) with | Gpx.Gpx_error err -> log_error "GPX Error: %s" (Error.to_string err); exit 2 | Sys_error msg -> log_error "System error: %s" msg; exit 2 | exn -> log_error "Unexpected error: %s" (Printexc.to_string exn); exit 2 in Eio_main.run run (* CLI argument definitions *) let input_file_arg = let doc = "Input GPX file path" in Arg.(required & pos 0 (some non_dir_file) None & info [] ~docv:"INPUT" ~doc) let output_file_arg = let doc = "Output GPX file path" in Arg.(required & pos 1 (some string) None & info [] ~docv:"OUTPUT" ~doc) let track_name_opt = let doc = "Name for the generated track (default: \"Converted from waypoints\")" in Arg.(value & opt string "Converted from waypoints" & info ["n"; "name"] ~docv:"NAME" ~doc) let track_description_opt = let doc = "Description for the generated track" in Arg.(value & opt (some string) None & info ["d"; "desc"] ~docv:"DESC" ~doc) let sort_by_time_flag = let doc = "Sort waypoints by timestamp before conversion" in Arg.(value & flag & info ["t"; "sort-time"] ~doc) let sort_by_name_flag = let doc = "Sort waypoints by name before conversion" in Arg.(value & flag & info ["sort-name"] ~doc) let preserve_waypoints_flag = let doc = "Keep original waypoints in addition to generated track" in Arg.(value & flag & info ["p"; "preserve"] ~doc) let verbose_flag = let doc = "Enable verbose output" in Arg.(value & flag & info ["v"; "verbose"] ~doc) (* Command definitions *) let convert_cmd = let doc = "Convert waypoints to trackset" in let man = [ `S Manpage.s_description; `P "Convert all waypoints in a GPX file to a single track. This is useful for \ converting a collection of waypoints into a navigable route or for \ consolidating GPS data."; `P "The conversion preserves all waypoint data (coordinates, elevation, \ timestamps, etc.) in the track points. By default, waypoints are removed \ from the output file unless --preserve is used."; `S Manpage.s_examples; `P "Convert waypoints to track:"; `Pre " mlgpx convert waypoints.gpx track.gpx"; `P "Convert with custom track name and preserve original waypoints:"; `Pre " mlgpx convert -n \"My Route\" --preserve waypoints.gpx route.gpx"; `P "Sort waypoints by timestamp before conversion:"; `Pre " mlgpx convert --sort-time waypoints.gpx sorted_track.gpx"; ] in let term = Term.(const convert_waypoints_to_trackset $ input_file_arg $ output_file_arg $ track_name_opt $ track_description_opt $ sort_by_time_flag $ sort_by_name_flag $ preserve_waypoints_flag $ verbose_flag $ Fmt_cli.style_renderer ()) in Cmd.v (Cmd.info "convert" ~doc ~man) term let info_cmd = let doc = "Display information about a GPX file" in let man = [ `S Manpage.s_description; `P "Analyze and display detailed information about a GPX file including \ statistics, content summary, and validation results."; `P "This command is useful for understanding the structure and content \ of GPX files before processing them."; `S Manpage.s_examples; `P "Show basic information:"; `Pre " mlgpx info file.gpx"; `P "Show detailed information with waypoint details:"; `Pre " mlgpx info -v file.gpx"; ] in let input_arg = let doc = "GPX file to analyze" in Arg.(required & pos 0 (some non_dir_file) None & info [] ~docv:"FILE" ~doc) in let term = Term.(const info_command $ input_arg $ verbose_flag $ Fmt_cli.style_renderer ()) in Cmd.v (Cmd.info "info" ~doc ~man) term (* Main CLI *) let main_cmd = let doc = "mlgpx - GPX file manipulation toolkit" in let man = [ `S Manpage.s_description; `P "mlgpx is a command-line toolkit for working with GPX (GPS Exchange Format) \ files. It provides tools for converting, analyzing, and manipulating GPS data."; `S Manpage.s_commands; `P "Available commands:"; `P "$(b,convert) - Convert waypoints to trackset"; `P "$(b,info) - Display GPX file information"; `S Manpage.s_common_options; `P "$(b,--verbose), $(b,-v) - Enable verbose output"; `P "$(b,--color)={auto|always|never} - Control ANSI color output"; `P "$(b,--help) - Show command help"; `S Manpage.s_examples; `P "Convert waypoints to track:"; `Pre " mlgpx convert waypoints.gpx track.gpx"; `P "Analyze a GPX file with colors:"; `Pre " mlgpx info --verbose --color=always file.gpx"; `P "Convert without colors for scripts:"; `Pre " mlgpx convert --color=never waypoints.gpx track.gpx"; `S Manpage.s_bugs; `P "Report bugs at https://github.com/avsm/mlgpx/issues"; ] in let default_term = Term.(ret (const (`Help (`Pager, None)))) in Cmd.group (Cmd.info "mlgpx" ~version:"0.1.0" ~doc ~man) ~default:default_term [convert_cmd; info_cmd] let () = Printexc.record_backtrace true; exit (Cmd.eval main_cmd)