File Coverage

blib/lib/Geo/TCX.pm
Criterion Covered Total %
statement 380 412 92.2
branch 146 204 71.5
condition 20 36 55.5
subroutine 34 35 97.1
pod 20 20 100.0
total 600 707 84.8


line stmt bran cond sub pod time code
1             package Geo::TCX;
2 7     7   718055 use strict;
  7         87  
  7         211  
3 7     7   39 use warnings;
  7         14  
  7         330  
4              
5             our $VERSION = '1.03';
6              
7             =encoding utf-8
8              
9             =head1 NAME
10              
11             Geo::TCX - Parse and edit and TCX activity and course files from GPS training devices
12              
13             =head1 SYNOPSIS
14              
15             use Geo::TCX;
16              
17             =head1 DESCRIPTION
18              
19             C<Geo::TCX> enables the parsing and editing of TCX activity and course files, including those from FIT files. TCX files follow an XML schema developed by Garmin and common to its GPS sports devices. Among other methods, the module enables laps from an activity to be saved as individual *.tcx files, split into separate laps based on a given point, merged, or converted to courses to plan a future activity.
20              
21             FIT activity and course files are supported provided that L<Geo::FIT> is installed and that the C<fit2tcx.pl> script it provides appears on the user's path.
22              
23             The module supports files containing a single Activity or Course. Database files consisting of multiple activities or courses are not supported.
24              
25             The documentation regarding TCX files in general uses the terms history and activity quite interchangeably, including in the user guides such as the one for the Garmin Edge device the author of this module is using. In C<Geo::TCX>, the terms Activity/Activities are used to refer to tracks recorded by a device (consistently with the XML mark-up) and Course/Courses refer to planned tracks meant to be followed during an activity (i.e. the term history is seldomly used).
26              
27             =cut
28              
29 7     7   3502 use Geo::TCX::Lap;
  7         25  
  7         252  
30 7     7   59 use File::Basename;
  7         15  
  7         674  
31 7     7   5365 use File::Temp qw/ tempfile /;
  7         81730  
  7         478  
32 7     7   3854 use IPC::System::Simple qw(system);
  7         33432  
  7         465  
33 7     7   59 use Cwd qw(cwd abs_path);
  7         17  
  7         310  
34 7     7   40 use Carp qw(confess croak cluck);
  7         16  
  7         37167  
35              
36             =head2 Constructor Methods (class)
37              
38             =over 4
39              
40             =item new( $filename or $str_ref, work_dir => $working_directory )
41              
42             loads and returns a new Geo::TCX instance using the I<$filename> supplied as first argument or a string reference equivalent to the xml tags of a *.tcx file.
43              
44             $o = Geo::TCX->new('2022-08-11-10-27-15.tcx');
45             or
46             $o = Geo::TCX->new( \'...');
47              
48             The optional C<work_dir> (or C<wd> for short) specifies where to save any working files, such as with the save_laps() method. It can be supplied as a relative path or as an absolute path. If C<work_dir> is omitted, it is set based on the path of the I<$filename> supplied or the current working directory if the constructor is called with an XML string reference (see C<< set_wd() >> for more info).
49              
50             =back
51              
52             =cut
53              
54             sub new {
55 23     23 1 12594 my ($proto, $first_arg) = (shift, shift);
56 23         122 my %opts = @_;
57 23         76 my $o = {};
58 23   33     224 my $class = ref($proto) || $proto;
59 23         93 bless($o, $class);
60              
61 23         59 my $txt;
62 23 100       138 if (ref( $first_arg ) eq 'SCALAR') {
63 5         16 $txt = $$first_arg
64             } else {
65 18         62 my $fname = $first_arg;
66 18         64 my $file_to_read = $first_arg;
67 18 50       421 croak 'first argument must be a filename' unless -f $fname;
68              
69 18 50       166 if ($fname =~ /(?i:\.fit$)/) {
70 0         0 my ($fh, $tmp_fname) = tempfile();
71 0         0 _convert_fit_to_tcx( $fname, $tmp_fname );
72 0         0 $file_to_read = $tmp_fname;
73 0         0 $fname =~ s/(?i:.fit)$/.tcx/
74             }
75 18         44 $txt = do { local(@ARGV, $/) = $file_to_read; <> };
  18         171  
  18         3340  
76 18         147 $o->set_filename($fname)
77             }
78              
79 23         188 $txt =~ s,\r,,g; # if it's a windows file
80 23         9603 $txt =~ s,>\s+<,><,g;
81 23 100       3174 $o->{tag_creator} = $1 if $txt =~ s/(<Creator.*<\/Creator>)//;
82              
83             # Activities/Activity - are as recorded by an EDGE 705 device
84             # Courses/Course - are as converted by an EDGE 705 device from an Activity
85              
86 23 50       316 $o->{tag_xml_version} = $1 if $txt =~ /(<.xml version[^>]*>)/;
87 23 50       2764 $o->{tag_trainingcenterdatabase} = $1 if $txt =~ /(<TrainingCenterDatabase.*<\/TrainingCenterDatabase>)/;
88 23 100       3050 $o->{tag_activities} = $1 if $txt =~ /(<Activities.*<\/Activities>)/;
89 23 100       3736 $o->{tag_activity} = $1 if $txt =~ /(<Activity.*<\/Activity>)/;
90 23 100       1263 $o->{tag_courses} = $1 if $txt =~ /(<Courses.*<\/Courses>)/;
91 23 100       1367 $o->{tag_course} = $1 if $txt =~ /(<Course(?!s).*<\/Course>)/;
92              
93             # Id seems only for Activities/Activity...
94 23 100       134 if ($o->{tag_activity}) {
95 13 50       14870 $o->{tag_id} = $1 if $o->{tag_activity} =~ /<Activity.*<Id>(.*)<\/Id>/;
96 13 50       179 $o->{tag_activity_type} = $1 if $o->{tag_activity} =~ /<Activity Sport="([^"]+)"/;
97             }
98              
99             # ... and Name only for Courses/Course
100 23 100       126 if ($o->{tag_course}) {
101             # will pick up device name under Creator if we are not specific about the Course tag
102 10 50       1755 $o->{tag_name} = $1 if $o->{tag_course} =~ /<Course.*<Name>(.*)<\/Name>/
103             }
104              
105 23 100       1807 $o->{tag_author} = $1 if $txt =~ /(<Author.*<\/Author>)/;
106 23 100       301 $o->_parse_author_tag if $o->{tag_author};
107              
108 23         105 my @Lap;
109 23 100       92 if ( $o->{tag_activity} ) {
110 13         43 my $i = 0;
111 13         26 my $lap;
112 13         2106 while ( $o->{tag_activity} =~ /(\<Lap StartTime=.*?\>.*?\<\/Lap\>)/g ) {
113 45         149 my ($lapstring, $last_point_previous_lap);
114 45         1519 $lapstring = $1;
115 45 100       317 $last_point_previous_lap = $lap->trackpoint(-1) if $i > 0;
116 45         375 $lap = Geo::TCX::Lap->new($lapstring, ++$i, $last_point_previous_lap);
117 45         105 push @{ $o->{Laps} }, $lap
  45         4593  
118             }
119             }
120 23 100       98 if ( $o->{tag_course} ) {
121             # in Courses, data is structured as <Lap>...</Lap><Lap>...</Lap><Track>...</Track><Track>...</Track>
122             # actually, not sure just seem like it's one long ... track, not multiple ones, which complicates things
123 10         27 my $xml_str = $o->{tag_course};
124              
125 10         24 my (@lap_tags, @lap_endpoints, @track_tags);
126              
127 10 50       751 if ( $xml_str =~ m,(<Lap>.*</Lap>),s ) {
128 10         35 my $str = $1;
129 10         65 @lap_tags = split(/(?s)<\/Lap>\s*<Lap>/, $str );
130 10 50       48 if (@lap_tags == 0) { push @lap_tags, $str }
  0         0  
131             }
132              
133 10         50 for my $i (0 .. $#lap_tags) {
134 10         26 my ($end_pos, $end_pt);
135 10 50       93 if ( $lap_tags[$i] =~ m,<EndPosition>(.*)</EndPosition>,s ) {
136 10         79 $end_pt = Geo::TCX::Trackpoint->new( $1 );
137 10         29 push @lap_endpoints, $end_pt
138             }
139             # since split removed tags sometimes at ^ of string for other at $
140             # let's remove them all and add back
141 10         86 $lap_tags[$i] =~ s,</?Lap>,,g;
142 10         62 $lap_tags[$i] =~ s,^,<Lap>,g;
143 10         73 $lap_tags[$i] =~ s,$,</Lap>,g
144             }
145 10         23 my $track_str;
146 10 50       172 if ( $xml_str =~ m,(<Track>.*</Track>),s ) {
147 10         114 $track_str = $1;
148             }
149              
150 10         82 my $t = Geo::TCX::Track->new( $track_str );
151 10 50       68 if (@lap_tags ==1) { $track_tags[0] = $track_str }
  10         32  
152             else {
153 0         0 my ($t1, $t2);
154 0         0 for my $i (0 .. $#lap_tags ) {
155 0 0       0 if ($i < $#lap_tags) {
156 0         0 ($t1, $t2) = $t->split_at_point_closest_to( $lap_endpoints[$i] );
157 0         0 push @track_tags, $t1->xml_string;
158 0         0 $t = $t2
159 0         0 } else { push @track_tags, $t->xml_string } # ie don't split the last track portion
160             }
161             }
162              
163 10         26 my $lap;
164 10         43 for my $i (0 .. $#lap_tags) {
165 10         24 my ($lapstring, $last_point_previous_lap);
166 10         197 $lapstring = $lap_tags[$i] . $track_tags[$i];
167 10 50       68 $last_point_previous_lap = $lap->trackpoint(-1) if $i > 0;
168 10         107 $lap = Geo::TCX::Lap->new($lapstring, ++$i, $last_point_previous_lap);
169 10         32 push @{ $o->{Laps} }, $lap
  10         207  
170             }
171             }
172              
173 23         181 my $n = $o->laps;
174 23 50       83 die "cannot find any laps, must not be a *.tcx file or str" unless $n;
175 23 100       2823 print "\nFound " . $n, ($n > 1 ? " Laps": " Lap"), "\n\n";
176 23         173 $o->{_txt} = $txt; # only for debugging
177 23   66     264 $o->set_wd( $opts{work_dir} || $opts{wd} );
178 23         434 return $o
179             }
180              
181             =head2 Constructor Methods (object)
182              
183             =over 4
184              
185             =item activity_to_course( key/values )
186              
187             returns a new <Geo::TCX> instance as a course, based on the current activity.
188              
189             All I<key/values> are optional:
190              
191             Z<> C<< lap => I<#> >>: converts lap number I<#> to a course, dropping all other laps. All laps are converted if C<lap> is omitted.
192             Z<> C<< course_name => I<$string> >>: the name for the course. The name will be the lap's C<StartTime> if a value is not specified.
193             Z<> C<< filename => I<$filename> >>: will call C<set_filename()> with this value.
194             Z<> C<< work_dir => I<$work_dir> >>: if omitted, it will be set to the same as that of the current object.
195              
196             =back
197              
198             =cut
199              
200             sub activity_to_course {
201 2     2 1 18 my $clone = shift->clone;
202 2         16 my %opts = @_;
203 2 50       13 croak 'this instance is already a course' if $clone->is_course;
204 2   0     23 my $wd = $opts{work_dir} || $opts{wd} || $clone->set_wd();
205              
206 2         5 my (@laps, $course);
207 2 100       61 @laps = $opts{lap} ? ($opts{lap}) : (1 .. $clone->laps);
208              
209 2         8 for my $lap_i (@laps) {
210 4         32 my $str = $clone->save_laps( [ $lap_i ], nosave => 1, course => 1, course_name => $opts{course_name} );
211 4         31 my $course_i = Geo::TCX->new( \$str, work_dir => $wd );
212 4 100       21 if ( defined $course ) {
213 2         6 push @{ $course->{Laps} }, $course_i->lap(1)
  2         11  
214 2         8 } else { $course = $course_i }
215             }
216 2         12 $course->set_filename( $opts{filename} );
217 2         46 return $course
218             }
219              
220             =over 4
221              
222             =item clone()
223              
224             Returns a deep copy of a C<Geo::TCX> instance.
225              
226             $clone = $o->clone;
227              
228             =back
229              
230             =cut
231              
232             sub clone {
233 6     6 1 1072 my $clone;
234 6         64 eval(Data::Dumper->Dump([ shift ], ['$clone']));
235 6 50       384 confess $@ if $@;
236 6         157 return $clone
237             }
238              
239             =head2 Object Methods
240              
241             =over 4
242              
243             =item lap( # )
244              
245             Returns the lap object corresponding to the lap number I<#> specified. I<#> is one-indexed but negative numbers can be used to count from the end, e.g C<-1> to get the last lap.
246              
247             =back
248              
249             =cut
250              
251             sub lap {
252 53     53 1 7747 my ($o, $lap_i, %exists) = (shift, shift);
253 53 50 33     310 croak 'requires a single integer as argument' if ! $lap_i or @_;
254 53         153 $lap_i = $o->_lap_number($lap_i);
255 53         332 return $o->{Laps}[$lap_i-1]
256             }
257              
258             =over 4
259              
260             =item laps( qw/ # # ... / )
261              
262             Returns a list of L<Geo::TCX::Lap> objects corresponding to the lap number(s) specified, or all laps if called without arguments. This method is useful as an access for the number of laps (i.e. without arguments in scalar context).
263              
264             =back
265              
266             =cut
267              
268             sub laps {
269 97     97 1 2608 my $o = shift;
270 97 100       259 return @{$o->{Laps}} unless @_;
  95         275  
271 2         6 my @numbers = @_;
272 2         5 my @laps;
273 2         5 for my $lap_i (@numbers) {
274 4         9 $lap_i = $o->_lap_number($lap_i);
275 4         10 push @laps, $o->{Laps}[$lap_i-1]
276             }
277             return @laps
278 2         8 }
279              
280             =over 4
281              
282             =item merge_laps( #1, #2 )
283              
284             Merges lap I<#1> with lap I<#2> and returns true. Both laps must be consecutive laps and the number of laps in the object decreases by one.
285              
286             The C<TotalTimeSeconds> and C<DistanceMeters> aggregates of the merged lap are adjusted. For Activity laps, performance metrics are also adjusted. For Course laps, C<EndPosition> is also adjusted. See L<Geo::TCX::Lap>.
287              
288             =back
289              
290             =cut
291              
292             sub merge_laps {
293 1     1 1 10 my ($o, $i1, $i2, %exists) = (shift, shift, shift);
294 1 50 33     10 croak 'merge_laps() requires two integers as argument' if ! $i2 or @_;
295 1 50       5 croak 'can only merge consecutive laps' unless ($i2 - $i1)==1;
296 1         4 my $l1 = $o->lap($i1);
297 1         5 my $l2 = $o->lap($i2);
298              
299 1         7 my $lap = $l1->merge($l2, as_is => 1);
300              
301 1         2 splice @{ $o->{Laps}}, $i1 - 1, 2, $lap;
  1         5  
302 1         3 return 1
303             }
304              
305             =over 4
306              
307             =item split_lap( #, $trackpoint_no )
308              
309             Splits lap number I<#> at the specified I<$trackpoint_no> into two laps and returns true. The number of laps in the object increases by one.
310              
311             =back
312              
313             =cut
314              
315             sub split_lap {
316 3     3 1 662 my ($o, $lap_i, $pt_no, %exists) = (shift, shift, shift);
317 3 50 33     24 croak 'split_lap() requires two integers as argument' if ! $pt_no or @_;
318 3         14 $lap_i = $o->_lap_number($lap_i);
319 3         30 my ($lap_a, $lap_b) = $o->lap($lap_i)->split($pt_no);
320 3         8 splice @{ $o->{Laps}}, $lap_i -1, 1, ( $lap_a, $lap_b );
  3         19  
321 3         12 return 1
322             }
323              
324             =over 4
325              
326             =item split_lap_at_point_closest_to(#, $point or $trackpoint or $coord_str )
327              
328             Equivalent to C<split_lap()> but splits the specified lap I<#> at the trackpoint that lies closest to a given L<Geo::Gpx::Point>, L<Geo::TCX::Trackpoint>, or a string that can be interpreted as coordinates by C<< Geo::Gpx::Point->flex_coordinates >>. Returns true.
329              
330             =back
331              
332             =cut
333              
334             sub split_lap_at_point_closest_to {
335 1     1 1 4 my ($o, $lap_i, $to_pt) = (shift, shift, shift);
336 1 50       4 croak 'split_lap_at_point_closest_to() expects two arguments' if @_;
337 1         5 $lap_i = $o->_lap_number($lap_i);
338 1 50       16 $to_pt = Geo::Gpx::Point->flex_coordinates( \$to_pt ) unless ref $to_pt;
339 1         166 my ($closest_pt, $min_dist, $pt_no) = $o->lap($lap_i)->point_closest_to( $to_pt );
340             # here we can print some info about the original track and where it will be split
341 1         25 $o->split_lap( $lap_i, $pt_no );
342 1         22 return 1
343             }
344              
345             =over 4
346              
347             =item time_add( @duration )
348              
349             =item time_subtract( @duration )
350              
351             Perform L<DateTime> math on the timestamps of each trackpoint in the track by adding the specified time as per the syntax of L<DateTime>'s C<add()> and C<subtract()> methods. Returns true.
352              
353             Perform L<Date::Time> math on the timestamps of each lap's starttime and trackpoint by adding the specified time as per the syntax of L<Date::Time>'s C<add()> method. Returns true.
354              
355             =back
356              
357             =cut
358              
359             sub time_add {
360 1     1 1 655 my $o = shift;
361 1         5 my @duration = @_;
362 1         3 my @laps = @{$o->{Laps}};
  1         4  
363 1         3 for my $l (@laps) {
364 4         21 $l->time_add( @duration )
365             }
366 1         7 return 1
367             }
368              
369             sub time_subtract {
370 1     1 1 3 my $o = shift;
371 1         5 my @duration = @_;
372 1         3 my @laps = @{$o->{Laps}};
  1         5  
373 1         3 for my $l (@laps) {
374 4         20 $l->time_subtract( @duration )
375             }
376 1         7 return 1
377             }
378              
379             =over 4
380              
381             =item delete_lap( # )
382              
383             =item keep_lap( # )
384              
385             delete or keep the specified lap I<#> form the object. Returns the list of laps removed in both cases.
386              
387             =back
388              
389             =cut
390              
391             sub delete_lap {
392 5     5 1 15 my ($o, $lap_i) = (shift, shift);
393 5 50       12 croak 'requires a single integer as argument' unless $lap_i;
394 5         13 $lap_i = $o->_lap_number( $lap_i );
395 5         10 my @removed = splice @{ $o->{Laps}}, $lap_i - 1, 1;
  5         17  
396             return @removed
397 5         16 }
398              
399             sub keep_lap {
400 1     1 1 5 my ($o, $lap_i) = (shift, shift);
401 1         4 my @keep = $o->delete_lap($lap_i);
402 1         2 my @removed = @{ $o->{Laps}};
  1         6  
403 1         3 @{ $o->{Laps}} = @keep;
  1         4  
404             return @removed
405 1         5 }
406              
407             =over 4
408              
409             =item save_laps( \@laplist , key/values )
410              
411             saves each lap as a separate *.tcx file in the working directory as per <set_wd()>. The filenames will consist of the original source file's name, suffixed by the respective lap number.
412              
413             An array reference can be provided to save only a a subset of lap numbers.
414              
415             I<key/values> are:
416              
417             Z<> C<course>: converts activity lap(s) as course files if true.
418             Z<> C<< course_name => $string >>: is only relevant with C<course> and will set the name of the course to I<$string>.
419             Z<> C<force>: overwrites existing files if true, otherwise it won't.
420             Z<> C<indent>: adds white space and indents the xml mark-up in the saved file if true.
421             Z<> C<nosave>: no files are actually saved if true. Useful if only interested in the xml string of the last lap processed.
422              
423             C<course_name> will be ignored if there is more than one lap and the lap's C<StartTime> will be used instead. This is to avoid having multiple files with the same name given that devices use this tag when listing available courses. Acttvity files have an C<Id> tag instead of C<Name> and the laps's C<StartTime> is used at all times. It is easy to edit any of these tags manually in a text editor; just look for the C<< <Name>...</Name> >> tag or C<< <Id>...</Id> >> tags near the top of the files.
424              
425             Returns a string containing the xml of the last lap processed which can subsequently be passed directly to C<< Geo::TCX->new() >> to construct a new instsance.
426              
427             =back
428              
429             =cut
430              
431             sub save_laps {
432 9     9 1 34 my $o = shift;
433 9         25 my @laps_to_save;
434 9 100       51 if (ref ($_[0]) eq 'ARRAY') {
435 5         14 my $aref = shift;
436 5         23 for my $lap_i (@$aref) {
437 5         25 push @laps_to_save, $o->lap($lap_i)
438             }
439 4         11 } else { @laps_to_save = @{$o->{Laps}} }
  4         19  
440 9         49 my %opts = @_;
441              
442 9         26 my ($as_course, $fname);
443 9 100 100     39 $as_course = 1 if $o->is_course or $opts{course};
444 9         43 $fname = $o->set_filename;
445 9 50       44 croak 'no filename found, set_filename(<name>) before saving' unless $fname;
446              
447             # as mentioned in the pod, files will be saved in work_dir as they are new files
448             # use has expectation that that's where working files go
449 9         394 my ($name, $path, $ext) = fileparse( $fname, '\..*' );
450 9         52 my $wd = $o->set_wd();
451              
452 9         64 my ($tags_before_lap, $tags_after_lap) = $o->_prep_tags( %opts );
453              
454             # Id (for Activity) or Name (for Course) tag
455 9         33 my $tag_id_or_name = '';
456 9 100       48 if ($as_course) {
457             # a bit tricky to determine Name when saving as course, bear with us here
458 6 50       33 if (@laps_to_save == 1 ) {
459 6         15 my $name;
460 6 100       20 if ( defined $opts{course_name} ) { $name = $opts{course_name} }
  4         11  
461             else {
462 2 50       9 if ($o->is_course) { $name = $o->{tag_name} }
  2         5  
463 0         0 else { $name = 'StartTimePlaceHolder' }
464             }
465 6         27 $tag_id_or_name .= '<Name>' . $name . '</Name>'
466             } else {
467 0         0 $tag_id_or_name .= '<Name>' . 'StartTimePlaceHolder' . '</Name>';
468             # i.e. it's StartTime regardless if more than one lap
469             }
470 3         9 } else { $tag_id_or_name .= '<Id>' . 'StartTimePlaceHolder' . '</Id>' }
471              
472             # Now from what is left below, we can create a save() method to save a file with multilaps. Simply move the $tags_before_lap and $tags_after_lap outside of the loop, continue to add to the $str (i.e. it gets appended to at all times) and we put the saving block outside of the loop at the very end.
473             # Yah ! And don't distance_net
474              
475 9         19 my $str;
476 9         35 for my $i (0 .. $#laps_to_save) {
477 15         175 my $l = $laps_to_save[$i]->clone;
478 15         121 $l->distance_net;
479              
480 15         44 $str = $tags_before_lap;
481 15         47 $str .= $tag_id_or_name;
482 15         322 $str =~ s/StartTimePlaceHolder/$l->StartTime/e;
  9         81  
483              
484 15         123 my $xml_lap = $l->xml_string( course => $as_course, indent => $opts{indent} );
485 15         165 $str .= $xml_lap;
486 15         74 $str .= $tags_after_lap;
487              
488 15 100       103 unless ($opts{nosave}) {
489 10         49 my $fname_lap = $wd . $name . '-Lap-' . ($i+1) . $ext;
490 10 50 66     394 croak "$fname_lap already exists" if -f $fname_lap and !$opts{force};
491 10 50       1032 open(my $fh, '>', $fname_lap) or die "can't open $fname_lap $!";
492 10         64061 print $fh $str
493             }
494             }
495 9         198 return $str
496             }
497              
498             =over 4
499              
500             =item save( key/values )
501              
502             saves the current instance.
503              
504             I<key/values> are:
505              
506             Z<> C<filename>: the name of the file to be saved. Has the effect calling C<set_filename()> and changes the name of the file in the current instance (e.g. akin to "save as" in many applications).
507             Z<> C<force>: overwrites existing files if true, otherwise it won't.
508             Z<> C<indent>: adds white space and indents the xml mark-up in the saved file if true.
509              
510             Returns a string containing the xml representation of the file.
511              
512             =back
513              
514             =cut
515              
516             sub save {
517 2     2 1 673 my ($o, %opts) = @_;
518              
519 2         10 my $fname;
520 2 100       18 if ( $opts{filename} ) { $fname = $o->set_filename( $opts{filename} ) }
  1         15  
521 1         4 else { $fname = $o->set_filename() }
522 2 50       17 croak 'no filename found, provide one with set_filename() or use key \'filename\'' unless $fname;
523 2 50 33     67 croak "$fname already exists" if -f $fname and !$opts{force};
524              
525 2         27 my ($tags_before_lap, $tags_after_lap) = $o->_prep_tags( indent => $opts{indent} );
526 2         7 my $str = $tags_before_lap;
527 2 50       12 if ($o->is_course) { $str .= '<Name>' . $o->{tag_name} . '</Name>' }
  2         16  
528 0         0 else { $str .= '<Id>' . $o->{tag_id} . '</Id>' }
529              
530 2         11 my ($str_activity_laps, $str_course_laps, $str_course_tracks);
531 2         14 for my $lap ($o->laps) {
532 4         37 my $str_lap = $lap->xml_string( indent => $opts{indent} );
533              
534 4 50       20 if ($lap->is_course) {
535             # for courses, the xml track tags are not nested within the lap
536             # tags but follow them instead. Yah, weird. So need to collect
537             # the strings seperately then assemble after the loop
538 4 50       195 if ( $str_lap =~ s,\s*(<Lap>.*</Lap>)\s*(<Track>.*</Track>)\s*,,s ) {
539 4         29 $str_course_laps .= $1;
540 4         40 $str_course_tracks .= $2
541 0         0 } else { croak "cannot find lap or track tags in Laps object" }
542             } else {
543 0         0 $str_activity_laps .= $str_lap
544             }
545             }
546              
547             # Flatten the course tracks into a a single track
548 2 50       42 $str_course_tracks =~ s,</Track>\s*<Track>,,gs if $str_course_tracks;
549              
550 2 50       12 if ($o->is_course) { $str .= $str_course_laps . $str_course_tracks }
  2         31  
551 0         0 else { $str .= $str_activity_laps }
552              
553 2         10 $str .= $tags_after_lap;
554              
555 2 50       305 open(my $fh, '>', $fname) or die "can't open $fname $!";
556 2         115 print $fh $str;
557 2         78 return $str
558             }
559              
560             sub _prep_tags {
561 11     11   56 my ($o, %opts) = @_;
562              
563             # identical to save_laps()
564 11         33 my ($newline, $tab, $as_course);
565 11 100       58 $newline = $opts{indent} ? "\n" : '';
566 11 100       58 $tab = $opts{indent} ? ' ' : '';
567 11 100 100     38 $as_course = 1 if $o->is_course or $opts{course};
568              
569             #
570             # Prepare the mark-up that appears *outside* the laps (therefore will be common to all saved laps)
571              
572             # These tag collection blocks could be shortened but it might not become more legible.
573             # The many variables help for debugging as the resulting string can be an extremely
574             # long flat string
575              
576             # we first collect the tags we need, we assemble them later
577             # we need these 3 pairs of tags so declare in a block
578 11         63 my ($tag_open_trainctrdb, $tag_close_trainctrdb);
579 11         0 my ($tag_open_activity_or_course_plural, $tag_close_activity_or_course_plural );
580 11         0 my ($tag_open_activity_or_course_singular, $tag_close_activity_or_course_singular);
581              
582 11 50       131 if ($o->{tag_trainingcenterdatabase} =~ /(<TrainingCenterDatabase[^>]*>)/) {
583 11         64 $tag_open_trainctrdb = $1;
584 11         36 $tag_close_trainctrdb = '</TrainingCenterDatabase>'
585 0         0 } else { croak 'can\'t find the expected <TrainingCenterDatabase ...> tag' }
586              
587             # in history files (in mine at least), these tags ever appear only once, nesting all of the data within them
588             # <Activities><Activity Sport="Biking">
589             # <Courses><Course>
590             # Activity is nested within Activities and similarly Course is nested within Courses
591              
592 11 100       45 if ($o->{tag_activities}) {
593 7 50       55 if ( $o->{tag_activities} =~ /(<Activities[^>]*>)/) {
594 7         24 $tag_open_activity_or_course_plural = $1
595 0         0 } else { croak 'can\'t find the expected <Activities> tag' }
596 7 50       55 if ( $o->{tag_activity} =~ /(<Activity[^>]*>)/ ) {
597 7         72 $tag_open_activity_or_course_singular = $1
598 0         0 } else { croak 'can\'t find the expected <Activity Sport="..."> tag' }
599 7         24 ($tag_close_activity_or_course_singular, $tag_close_activity_or_course_plural) = ('</Activity>', '</Activities>')
600             }
601 11 100       44 if ($o->{tag_courses}) {
602 4 50       37 if ( $o->{tag_courses} =~ /(<Courses[^>]*>)/) {
603 4         17 $tag_open_activity_or_course_plural = $1
604 0         0 } else { croak 'can\'t find the expected <Courses> tag' }
605 4 50       49 if ( $o->{tag_course} =~ /(<Course(?!s)[^>]*>)/ ) {
606 4         12 $tag_open_activity_or_course_singular = $1
607 0         0 } else { croak 'can\'t find the expected <Course> tag' }
608 4         18 ($tag_close_activity_or_course_singular, $tag_close_activity_or_course_plural) = ('</Course>', '</Courses>')
609             }
610 11 100 100     87 if ($as_course and !$o->{tag_courses}) { # i.e. when saving an activity as a course
611 4         11 $tag_open_activity_or_course_plural = '<Courses>';
612 4         8 $tag_open_activity_or_course_singular = '<Course>';
613 4         12 ($tag_close_activity_or_course_singular, $tag_close_activity_or_course_plural) = ('</Course>', '</Courses>')
614             }
615              
616             # assembling the tags to get the mark-up that appears *before* the laps
617 11         29 my $tags_before_lap = '';
618 11         38 $tags_before_lap = $o->{tag_xml_version} . "\n";
619 11         29 $tags_before_lap .= $tag_open_trainctrdb;
620 11         31 $tags_before_lap .= $newline . $tab . $tag_open_activity_or_course_plural;
621 11         45 $tags_before_lap .= $newline . ($tab x 2) . $tag_open_activity_or_course_singular;
622 11         37 $tags_before_lap .= $newline . ($tab x 3);
623              
624             # assembling the tags to get the mark-up that appears *after* the laps
625 11         28 my ($tags_after_lap) = '';
626 11 100       61 $tags_after_lap = $newline . ($tab x 3) . $o->{tag_creator} if $o->{tag_creator};
627 11         32 $tags_after_lap .= $newline . ($tab x 2) . $tag_close_activity_or_course_singular;
628 11         29 $tags_after_lap .= $newline . $tab . $tag_close_activity_or_course_plural;
629 11 100       50 $tags_after_lap .= $newline . $tab . $o->{tag_author} if $o->{tag_author};
630 11         26 $tags_after_lap .= $newline . $tag_close_trainctrdb;
631              
632 11         76 return $tags_before_lap, $tags_after_lap
633             }
634              
635             =over 4
636              
637             =item set_filename( $filename )
638              
639             Sets/gets the filename. Returns the name of the file with the complete path.
640              
641             If the instance was created from a FIT file, the filename is set to the same name but with a C<.tcx> extension by default.
642              
643             =back
644              
645             =cut
646              
647             sub set_filename {
648 60     60 1 1936 my ($o, $fname) = (shift, shift);
649 60 100       485 return $o->{_fileABSOLUTENAME} unless $fname;
650 28 50       298 croak 'set_filename() takes only a single name as argument' if @_;
651 28         112 my $wd;
652 28 100       103 if ($o->_is_wd_defined) { $wd = $o->set_wd }
  10         22  
653             # set_filename gets called before set_wd by new() so can't access work_dir until initialized
654              
655 28         81 my ($name, $path, $ext);
656 28         1444 ($name, $path, $ext) = fileparse( $fname, '\..*' );
657 28 100       165 if ($wd) {
658 10 100       37 if ( ! ($fname =~ /^\// ) ) {
659             # ie if fname is not an abolsute path, adjust $path to be relative to work_dir
660 8         132 ($name, $path, $ext) = fileparse( $wd . $fname, '\..*' )
661             }
662             }
663 28         996 $o->{_fileABSOLUTEPATH} = abs_path( $path ) . '/';
664 28         173 $o->{_fileABSOLUTENAME} = $o->{_fileABSOLUTEPATH} . $name . $ext;
665 28 50       411 croak 'directory ' . $o->{_fileABSOLUTEPATH} . ' doesn\'t exist' unless -d $o->{_fileABSOLUTEPATH};
666 28         142 $o->{_fileNAME} = $name;
667 28         97 $o->{_filePATH} = $path;
668 28         84 $o->{_fileEXT} = $ext;
669 28         111 $o->{_filePARSEDNAME} = $fname;
670             # _file* keys only for debugging, should not be used anywhere else
671             return $o->{_fileABSOLUTENAME}
672 28         134 }
673              
674             =over 4
675              
676             =item set_wd( $folder )
677              
678             Sets/gets the working directory for any eventual saving of the *.tcx file and checks the validity of that path. It can be set as a relative path (i.e. relative to the actual L<Cwd>) or as an absolute path, but is always returned as a full path.
679              
680             This working directory is always defined. The previous one is also stored in memory, such that C<set_wd('-')> switches back and forth between two directories. The module never actually C<chdir>'s, it just keeps track of where the user wishes to save files.
681              
682             =back
683              
684             =cut
685              
686             sub set_wd {
687 83     83 1 910 my ($o, $dir) = (shift, shift);
688 83 50       275 croak 'set_wd() takes only a single folder as argument' if @_;
689 83         307 my $first_call = ! $o->_is_wd_defined; # ie if called for 1st time -- at construction by new()
690              
691 83 100       238 if (! $dir) {
692 44 100       277 return $o->{work_dir} unless $first_call;
693 16         74 my $fname = $o->set_filename;
694 16 100       56 if ($fname) {
695 15         519 my ($name, $path, $ext) = fileparse( $fname );
696 15         88 $o->set_wd( $path )
697 1         7593 } else { $o->set_wd( cwd ) }
698             } else {
699 39         280 $dir =~ s/^\s+|\s+$//g; # some clean-up
700 39 50       158 $dir =~ s/~/$ENV{'HOME'}/ if $dir =~ /^~/;
701 39 100       142 $dir = $o->_set_wd_old if $dir eq '-';
702              
703 39 100       153 if ($dir =~ m,^[^/], ) { # convert rel path to full
704 8 100       13897 $dir = $first_call ? cwd . '/' . $dir : $o->{work_dir} . $dir
705             }
706 39         415 $dir =~ s,/*$,/,; # some more cleaning
707 39         173 1 while ( $dir =~ s,/\./,/, ); # support '.'
708 39         186 1 while ( $dir =~ s,[^/]+/\.\./,, ); # and '..'
709 39 50       986 croak "$dir not a valid directory" unless -d $dir;
710              
711 39 100       142 if ($first_call) { $o->_set_wd_old( $dir ) }
  23         146  
712 16         58 else { $o->_set_wd_old( $o->{work_dir} ) }
713 39         136 $o->{work_dir} = $dir
714             }
715             return $o->{work_dir}
716 55         234 }
717              
718             # if ($o->set_filename) { $o->set_wd() } # if we have a filename
719             # else { $o->set_wd( cwd ) } # if we don't
720              
721             sub _set_wd_old {
722 44     44   144 my ($o, $dir) = @_;
723 44 100       202 $o->{work_dir_old} = $dir if $dir;
724             return $o->{work_dir_old}
725 44         120 }
726              
727 111     111   458 sub _is_wd_defined { return defined shift->{work_dir} }
728              
729             =over 4
730              
731             =item is_activity()
732              
733             =item is_course()
734              
735             True if the C<Geo::TCX> instance is a of the type indicated by the method, false otherwise.
736              
737             =back
738              
739             =cut
740              
741 2     2 1 16 sub is_activity { return defined shift->{tag_activity} }
742 30     30 1 250 sub is_course { return defined shift->{tag_course} }
743              
744             =over 4
745              
746             =item activity( $string )
747              
748             Gets/sets the Activity type as detected from C<\<Activity Sport="*"\>>, sets it to I<$string> if provided. Garmin devices (at least the Edge) record activities as being of types 'Running', 'Biking', 'MultiSport', etc.
749              
750             =back
751              
752             =cut
753              
754             sub activity {
755 6     6 1 15 my ($o, $activity) = @_;
756             # should I check what activity types are allowed? Must they be single words?
757 6 100       14 if ($activity) {
758 2         96 $o->{tag_activity} =~ s,(\<Activity Sport=)"[^"]*",$1"$activity",;
759 2         12 return $o->activity
760             }
761 4 50       26 $activity = $1 if $o->{tag_activity} =~ /<Activity Sport="([^"]*)"/;
762 4         19 return $activity
763             }
764              
765             =over 4
766              
767             =item author( key/value )
768              
769             Gets/sets the fields of the Author tag. Supported keys are C<Name>, C<LangID>, C<PartNumber> and all excpect a string as value.
770              
771             The C<Build> field can also be accesses but the intent is to set it, the string supplied should be in the form of an xml string in the way this tag appears in a *.tcx file (e.g. Version, VersionMajor, VersionMinor, Type, …). Simply access that key of the returned hash ref to see what is should look like.
772              
773             Returns a hash reference of key/value pairs.
774              
775             This method is under development and behaviour could change in the future.
776              
777             =back
778              
779             =cut
780              
781             # the only purpose I have for this at this stage is to set the name mostly, we'll see if I have a need for other, but will take the Build key as is and not set up sub-keys yet.
782             # I mostly want to use this method so that I can set it if I want for any *.tcx I generate (Courses, save laps), with the version number of my module as well
783             # the Build entries contain integers but I am not supporting this at this point
784             #
785             # <Author xsi:type="Application_t"
786             # <Name>string</Name>
787             # <Build containis
788             # <Version>
789             # VersionMajor
790             # VersionMinor
791             # BuildMajor
792             # BuildMinor
793             # </Version>
794             # <Type>Release<Type>
795             # </Build>
796             # <LangID>EN</LangID>
797             # <PartNumber>digit and dash string</PartNumber>
798             # </Author>
799              
800             my %possible_author_keys;
801             my @author_keys = qw/ Name Build LangID PartNumber /;
802             $possible_author_keys{$_} = 1 for @author_keys;
803              
804             # NB: similar to the _file* keys, the _Author href can not exist at any time
805             sub _parse_author_tag {
806 17     17   57 my $o = shift;
807 17         78 $o->{_Author} = {};
808 17         56 my $href = $o->{_Author};
809 17         34 my $author_xml;
810 17 50       186 if ( $o->{tag_author} =~ m,<Author\s+([^=]+="[^"]+")>(.*)<\/Author>, ) {
811 17         88 $href->{string_inside_author_tag} = $1;
812 17         75 $author_xml = $2
813             }
814 17         75 for my $key (@author_keys) {
815 68 100       1522 $href->{$key} = $1 if $author_xml =~ m,<$key>(.+)</$key>,
816             }
817             return $o->{tag_author}
818 17         56 }
819              
820             sub _update_author_tag {
821 2     2   5 my $o = shift;
822 2         4 my $href = $o->{_Author};
823              
824 2         8 my $str = '<Author ' . $href->{string_inside_author_tag} . '>';
825 2         5 for my $key (@author_keys) {
826             $str .= "<$key>" . $href->{$key} . "</$key>" if defined $href->{key}
827 8 50       18 }
828 2         5 $str .= '</Author>';
829 2         6 $o->{tag_author} = $str;
830             return $o->{tag_author}
831 2         4 }
832              
833             sub author {
834 3     3 1 1388 my ($o, %keys_values) = @_;
835 3         7 my $href = $o->{_Author};
836 3 50 66     14 croak 'no author tag found in object' if (!%keys_values and ! $href);
837 3 100       10 if (%keys_values) {
838 2         9 for my $key (keys %keys_values) {
839 2 50       7 croak 'unsupported Author field' unless $possible_author_keys{$key};
840 2         6 $href->{$key} = $keys_values{$key}
841             }
842             $o->_update_author_tag
843 2         6 }
844 3         8 return $href
845             }
846              
847             # returns the actual lap number if a negative index is passed to count from the end
848             sub _lap_number {
849 66     66   154 my ($o, $lap_i, $n, %exists) = (shift, shift);
850 66         156 $n = $o->laps;
851 66 100       180 $lap_i += $n + 1 if $lap_i < 0;
852 66         393 $exists{$_} = 1 for (1 .. $n);
853 66 50       177 croak "Lap $lap_i does not exist" unless $exists{$lap_i};
854 66         181 return $lap_i
855             }
856              
857             our $FitConvertPl;
858             sub _convert_fit_to_tcx {
859 0     0     require Geo::FIT;
860 0           my ( $fname, $tmp_fname ) = @_;
861 0 0         if (!defined $FitConvertPl) {
862 0           for (split /:/, $ENV{PATH} ) {
863 0           $FitConvertPl = $_ . '/fit2tcx.pl';
864 0 0         last if -f $FitConvertPl
865             }
866             }
867 0           my @args = ($fname, $tmp_fname);
868 0           system($^X, $FitConvertPl, @args);
869 0           return 1
870             }
871              
872             =head1 EXAMPLES
873              
874             Coming soon.
875              
876             =head1 BUGS
877              
878             Nothing to report yet.
879              
880             =head1 AUTHOR
881              
882             Patrick Joly
883              
884             =head1 VERSION
885              
886             1.03
887              
888             =head1 LICENSE AND COPYRIGHT
889              
890             Copyright (c) 2022, Patrick Joly C<< <patjol@cpan.org> >>. All rights reserved.
891              
892             This module is free software; you can redistribute it and/or modify it under the same terms as Perl itself. See L<perlartistic>.
893              
894             =head1 SEE ALSO
895              
896             L<Geo::Gpx>, L<Geo::FIT>.
897              
898             =head1 DISCLAIMER OF WARRANTY
899              
900             BECAUSE THIS SOFTWARE IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY FOR THE SOFTWARE, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE SOFTWARE "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE SOFTWARE IS WITH YOU. SHOULD THE SOFTWARE PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR, OR CORRECTION.
901              
902             IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR REDISTRIBUTE THE SOFTWARE AS PERMITTED BY THE ABOVE LICENSE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE SOFTWARE (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE SOFTWARE TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
903              
904             =cut
905              
906             1;
907