#!/usr/bin/perl # Questions? Contact Phil Yastrow at # or Adam Schneider at # NOTE: if the perl module 'Archive::Zip' is installed, you can add # '.zip' to the output directory name to create a zip file. if (!@ARGV) { die "Usage: $0 filename [output_directory] [include_trackless_runs]\n"; } my ($Infile,$Output_Dir,$Include_Trackless,$Verbose) = @ARGV; ($Model,%Files) = &ParseLogbook($Infile,$Include_Trackless,$Verbose); &SaveLogbookFiles($Model,$Output_Dir,$Verbose,%Files); ################################################################################ sub ParseLogbook { # Parse a Garmin Logbook XML file, and split into separate files for each track # Algorithm: # 1. read xml file # 2. every time a 'run boundary' is crossed, write it to a file with the syntax: # date-time[-note].{xml|hst} my ($infile,$include_trackless,$verbose) = @_; my (@forerunner_file,$run,$file_header,$inHeader,$filename,$notes,$filename_note,$gpx_trackname,%output); my $note_chars = ($^O =~ /^Mac/) ? 11 : 40; my $model = '201'; my %file_footer; $file_footer{'201'} = "\n"; $file_footer{'301'} = "\n"; $file_footer{'305'} = " \n\n"; $file_footer{'2007'} = "\n"; $file_footer{'gpx'} = "\n"; if ($infile =~ /\.gz$/i) { eval { require Compress::Zlib }; if ($@) { die "Cannot open .gz files; Compress::Zlib is not installed\n"; } use Compress::Zlib; my $gz = gzopen($infile, "rb") or die "Cannot open $infile: $gzerrno\n"; while ($gz->gzreadline($_) > 0) { $_ =~ s/(?<=\>)(<\w[^>]*>)/\n$1/g; # put tags on separate lines $_ =~ s/(<\/\w[^>]*>)(<\/\w[^>]*>)/$1\n$2/g; # put closing tags on separate lines $_ =~ s/(<\/\w[^>]*>)(<\/\w[^>]*>)/$1\n$2/g; # put closing tags on separate lines (once more for good measure) push (@forerunner_file,split(/\r\n|\r|\n/,$_)); } if ($gzerrno != Z_STREAM_END) { die "Error reading from $infile: $gzerrno\n"; } } elsif ($infile =~ /\.zip$/i) { eval { require Archive::Zip }; if ($@) { die "Cannot open .zip files; Archive::Zip is not installed\n"; } use Archive::Zip; my $zip = Archive::Zip->new($infile); my @files_in_zip = $zip->members(); if (!@files_in_zip) { die "Error reading from $infile\n"; } my $first_file = $zip->contents($files_in_zip[0]); $first_file =~ s/(?<=\>)(<\w[^>]*>)/\n$1/g; # put tags on separate lines $first_file =~ s/(<\/\w[^>]*>)(<\/\w[^>]*>)/$1\n$2/g; # put closing tags on separate lines $first_file =~ s/(<\/\w[^>]*>)(<\/\w[^>]*>)/$1\n$2/g; # put closing tags on separate lines (once more for good measure) push (@forerunner_file,split(/\r\n|\r|\n/,$first_file)); } else { open(MASTERXML,$infile) || die "Cannot open $infile\n"; # open master file for reading foreach () { $_ =~ s/(?<=\>)(<\w[^>]*>)/\n$1/g; # put tags on separate lines $_ =~ s/(<\/\w[^>]*>)(<\/\w[^>]*>)/$1\n$2/g; # put closing tags on separate lines $_ =~ s/(<\/\w[^>]*>)(<\/\w[^>]*>)/$1\n$2/g; # put closing tags on separate lines (once more for good measure) push (@forerunner_file,split(/[\r\n]+/,$_)); # fix some line break issues } close (MASTERXML); } foreach (@forerunner_file[0..4]) { if (//i) { $inIndex = 0; $full_index .= $line; } elsif ($inIndex) { $full_index .= $line; } elsif ($line =~ //i) { $inWorkouts = 0; } elsif ($inWorkouts) { } # don't store 'em, just ignore 'em elsif ($line =~ /<(Activities|Courses)\b/i) { $container_start = $line; # save it for later ($container_end = $container_start) =~ s/(Activities|Courses)[^>]*/\/$1/; } elsif ($line =~ /<(MultiSportSession)\b/i || (!$inMultisport && $line =~ /<(Activity|Course)\b/i)) { if ($line =~ /<(MultiSportSession)\b/i) { $inMultisport = 1; } $inHeader = 0; # we're no longer in the header $run = $line; # start storing a run } elsif ($inHeader == 1) { # if we are in the header, save it to be printed with each run $file_header .= $line; } elsif ($line =~ /]*>([^<]*)/) { $filename_note = $1; $filename_note =~ s/&/&/g; # convert ampersands $filename_note =~ s/'/'/g; # convert apostrophes $filename_note =~ s/"/"/g; # convert quotes $filename_note = substr($filename_note,0,$note_chars); # Save notes field to append to filename $filename_note =~ s/\s+/_/g; # Change spaces to underscores $filename_note =~ s/_+$//g; # Eliminate terminal underscores $run .= $line; # don't forget to add the Notes line to the current run } elsif (!$id && $line =~ /<(Name|Id)>(.*?)<\/\1>/) { $id = $2; $run .= $line; # don't forget to add the "Id" line to the current run } elsif ($line =~ /<\/(MultiSportSession)\b/i || (!$inMultisport && $line =~ /<\/(Activity|Course)\b/i)) { # this is the end of the run, so output everything to the file if (!$include_trackless && $run !~ /(?!$id).*<\/Id>\s*//gi; # delete non-matching IDs $cropped_index =~ s/<(CourseRef|CourseNameRef|ActivityRef)><\/\1>//gsi; # delete blank Ref tags $cropped_index =~ s/\n\s+\n/\n/gsi; # delete blank lines left by the blank tags $cropped_index =~ s/<(\w+) Name="([^"]*)">[\s\n]+?<\/\1>/<$1 Name="$2"\/>/gsi; # collapse empty sports $run .= $line; ($filename = $id) =~ s/(\d\d\d\d)-(\d\d)-(\d\d)T(\d\d):(\d\d):(\d\d).*/$1$2$3-$4$5$6/g; # remove dashes and colons from the filename $filename .= "-$filename_note" if ($filename_note); $filename =~ s/[:;\/\(\)]/-/g; # change slashes, colons, and parentheses to hyphens $index_content{$filename} = $cropped_index; $run_content{$filename} = $run; $container_start{$filename} = $container_start; $container_end{$filename} = $container_end; print "$id: track found\n" if ($verbose); $file_number++; } $inMultisport = 0; $run = undef; $id = undef; $filename_note = undef; $filename = undef; # clear $filename -- this is important } else { $run .= $line; # append this line to the current run } } foreach (keys %run_content) { $output{$_} = $file_header."\n".$index_content{$_}."\n".$container_start{$_}.$run_content{$_}.$container_end{$_}."\n".$author_info."\n".$file_footer{$model}; # store header, run data, and close info } print "\n" if ($verbose); } else { my $inHeader = 1; # start by reading the header of the file my $inMultiSport = 0; my $header_30x,$footer_30x,$sport_30x,$name_30x; my %gpx_names; my $file_number = 1; foreach my $line (@forerunner_file) { $line =~ s/^\xEF[\xBA-\xBF]*//; # remove Unicode marker, if present $line .= "\n"; if ($line =~ /^\s*<\/?Folder/i) { next; # Training Center apparently allows "Folders"; they can be ignored. } if ($line =~ /( *)<(Running|Biking|Other|MultiSport)\s+Name="(.*?)"\s*>/) { # store this info but don't change $inHeader (my $spaces,$sport_30x,$name_30x) = ($1,$2,$3); $name_30x =~ s/\xC3([\x81-\xBF])/chr(ord($1)+64)/ge; # fix 2-byte accented letters $name_30x =~ s/([\x80-\xFF])/'&#'.ord($1).';'/ge; # fix accented letters $header_30x = "$spaces<$sport_30x Name=\"$name_30x\">\n"; $footer_30x = "$spaces\n"; if ($sport_30x eq 'MultiSport') { $inMultiSport = 1; } } elsif ($line =~ /(|)/i && !$inMultiSport) { $inHeader = 0; # we're no longer in the header $run = $line; # start storing a run } elsif ($line =~ /()/i && $inMultiSport) { $inHeader = 0; # we're no longer in the header $run = $line; # start storing a run } elsif ($inHeader == 1) { # if we are in the header, save it to be printed with each run if ($model eq 'gpx' && $line =~ /(<(bounds|time) [^>]*\/>|<(bounds|time) [^>]*>[^<]*<\/(bounds|time)>)\s*$/) { # skip the "bounds" or "time" line in GPX files, they're no longer valid } else { $file_header .= $line; } } elsif ($line =~ /(<(?:Notes|desc)>)([^<]*)/) { $filename_note = $2; $filename_note =~ s/&/&/g; # convert ampersands $filename_note =~ s/'/'/g; # convert apostrophes $filename_note =~ s/"/"/g; # convert quotes $filename_note = substr($filename_note,0,$note_chars); # Save notes field to append to filename $filename_note =~ s/\s+/_/g; # Change spaces to underscores $filename_note =~ s/_+$//g; # Eliminate terminal underscores if ($model ne 'gpx') { $line =~ s/([\x80-\xFF])/'&#'.ord($1).';'/ge; # NOW fix the accented characters $line =~ s/>/\>/g; $line =~ s//gi; # whoops! change the tags back! } $run .= $line; # don't forget to add the Notes line to the current run } elsif ($line =~ /()([^<]*)/ && !$gpx_trackname) { # second condition ensures that named trackpoints don't overwrite the track name $gpx_trackname = $2; $gpx_names{$2} += 1; $run .= $line; # don't forget to add the track name line to the current run } elsif ($line =~ /(\bStartTime(?:>|="))([A-Z0-9\-\:]*)/i) { # save this StartTime as the filename for this run $starttime ||= $2; $run .= $line; # don't forget to add the StartTime line to the current run } elsif ((!$inMultiSport && $line =~ /<\/(Run|trk)>/) || ($inMultiSport && $line =~ /<\/MultiSportSession>/)) { # this is the end of the run, so output everything to the file if (!$include_trackless && $run !~ / 1) { $filename .= " #".$gpx_names{$filename}; } } else { ($filename = $starttime) =~ s/[Z:-]//g; # remove dashes and colons from the filename $filename ||= "Forerunner $file_number"; $filename =~ s/T/-/g; # change T to a hyphen in the filename $filename .= "-$filename_note" if ($filename_note); $filename =~ s/[:;\/\(\)]/-/g; # change slashes, colons, and parentheses to hyphens if ($sport_30x) { $filename = "$sport_30x-$filename"; } } $output{$filename} = $file_header.$header_30x.$run.$footer_30x.$file_footer{$model}; # store header, run data, and close info print "$filename: track found\n" if ($verbose); $file_number++; } $notes = ''; $starttime = ''; $gpx_trackname = ''; $filename_note = ''; $filename = ''; # clear $filename -- this is important } else { $run .= $line; # append this line to the current run } } print "\n" if ($verbose); } return ($model,%output); } ################################################################################ sub SaveLogbookFiles { my ($model,$output_dir,$verbose,%files) = @_; my (%path,$zip,$zipfile,$z,$zipstatus,@zippedfiles); my %suffix = ('201'=>'xml','301'=>'hst','305'=>'hst','2007'=>'tcx','gpx'=>'gpx'); # Mac, Windows, and UNIX handle directory names differently: if ($^O =~ /Mac/) { $sep1 = ':'; $sep2 = ':'; } elsif ($^O =~ /Win/) { $sep1 = ''; $sep2 = '\\'; } else { $sep1 = ''; $sep2 = '/'; } if ($output_dir =~ /^(.*\/)?(.*\.)?zip/) { eval { require Archive::Zip }; if ($@) { die "Archive::Zip is not installed\n"; } use Archive::Zip; $zip = 1; $output_dir = (-e $1) ? $1 : ''; # if output_dir starts with xxx/, xxx is a directory $zipfile = ($2) ? $output_dir.$2.'zip' : "logbook.zip"; } elsif ($output_dir) { if (!-e $output_dir) { mkdir ($output_dir); } if (!-d $output_dir) { die "The output directory '$output_dir' does not exist\n"; } $output_dir = $sep1.$output_dir.$sep2; } if ($zip) { $z = Archive::Zip->new(); } foreach my $filename (sort keys %files) { (my $output_filename = $filename) =~ s/[\/:]/-/g; $path{$filename} = "$output_dir$output_filename.$suffix{$model}"; open (RUNFILE,">$path{$filename}") or warn("Can't create $path{$filename}!\n"); print RUNFILE $files{$filename}; # print header, run data, and close info close (RUNFILE); # close the file if ($zip) { $z->addFile($path{$filename},"$output_filename.$suffix{$model}") or warn("Can't add file $path{$filename}!\n"); push (@zippedfiles,"$output_filename.$suffix{$model}"); print "$path{$filename}: zipped\n" if ($verbose); } else { print "$path{$filename}: saved\n" if ($verbose); } } if ($zip) { $zipstatus = $z->writeToFileNamed($zipfile); foreach my $f (sort keys %files) { unlink ($path{$f}) or warn "Can't delete $path{$f}\n"; } } $zipfile ||= 1; return ($zipfile,@zippedfiles); }