File Coverage

blib/lib/Geo/TCX.pm
Criterion Covered Total %
statement 371 390 95.1
branch 145 198 73.2
condition 20 36 55.5
subroutine 32 32 100.0
pod 20 20 100.0
total 588 676 86.9


line stmt bran cond sub pod time code
1             use strict;
2 6     6   592154 use warnings;
  6         65  
  6         170  
3 6     6   32  
  6         10  
  6         284  
4             our $VERSION = '1.02';
5              
6             =encoding utf-8
7              
8             =head1 NAME
9              
10             Geo::TCX - Parse and edit and TCX activity and course files from GPS training devices
11              
12             =head1 SYNOPSIS
13              
14             use Geo::TCX;
15              
16             =head1 DESCRIPTION
17              
18             C<Geo::TCX> enables the parsing and editing of TCX activity and course 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.
19              
20             The module supports files containing a single Activity or Course. Database files consisting of multiple activities or courses are not supported.
21              
22             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).
23              
24             =cut
25              
26             use Geo::TCX::Lap;
27 6     6   2898 use File::Basename;
  6         24  
  6         231  
28 6     6   47 use Cwd qw(cwd abs_path);
  6         10  
  6         599  
29 6     6   44 use Carp qw(confess croak cluck);
  6         13  
  6         275  
30 6     6   33  
  6         18  
  6         29601  
31             =head2 Constructor Methods (class)
32              
33             =over 4
34              
35             =item new( $filename or $str_ref, work_dir => $working_directory )
36              
37             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.
38              
39             $o = Geo::TCX->new('2022-08-11-10-27-15.tcx');
40             or
41             $o = Geo::TCX->new( \'...');
42              
43             C<work_dir> or C<wd> for short can be set to specify where to save any working files (such as with the save_laps() method). The module never actually L<chdir>'s, it just keeps track of where the user wants to save files (and not have to type filenames with path each time), hence it is always defined.
44              
45             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).
46              
47             =back
48              
49             =cut
50              
51             my ($proto, $first_arg) = (shift, shift);
52             my %opts = @_;
53 23     23 1 14925 my $o = {};
54 23         105 my $class = ref($proto) || $proto;
55 23         75 bless($o, $class);
56 23   33     213  
57 23         92 my $txt;
58             if (ref( $first_arg ) eq 'SCALAR') {
59 23         66 $txt = $$first_arg
60 23 100       115 } else {
61 5         13 croak 'first argument must be a filename' unless -f $first_arg;
62             $txt = do { local(@ARGV, $/) = $first_arg; <> };
63 18 50       506 $o->set_filename($first_arg)
64 18         71 }
  18         164  
  18         3479  
65 18         169  
66             $txt =~ s,\r,,g; # if it's a windows file
67             $txt =~ s,>\s+<,><,g;
68 23         186 $o->{tag_creator} = $1 if $txt =~ s/(<Creator.*<\/Creator>)//;
69 23         9735  
70 23 100       3109 # Activities/Activity - are as recorded by an EDGE 705 device
71             # Courses/Course - are as converted by an EDGE 705 device from an Activity
72              
73             $o->{tag_xml_version} = $1 if $txt =~ /(<.xml version[^>]*>)/;
74             $o->{tag_trainingcenterdatabase} = $1 if $txt =~ /(<TrainingCenterDatabase.*<\/TrainingCenterDatabase>)/;
75 23 50       321 $o->{tag_activities} = $1 if $txt =~ /(<Activities.*<\/Activities>)/;
76 23 50       2822 $o->{tag_activity} = $1 if $txt =~ /(<Activity.*<\/Activity>)/;
77 23 100       3188 $o->{tag_courses} = $1 if $txt =~ /(<Courses.*<\/Courses>)/;
78 23 100       3058 $o->{tag_course} = $1 if $txt =~ /(<Course(?!s).*<\/Course>)/;
79 23 100       1197  
80 23 100       1295 # Id seems only for Activities/Activity...
81             if ($o->{tag_activity}) {
82             $o->{tag_id} = $1 if $o->{tag_activity} =~ /<Activity.*<Id>(.*)<\/Id>/;
83 23 100       107 $o->{tag_activity_type} = $1 if $o->{tag_activity} =~ /<Activity Sport="([^"]+)"/;
84 13 50       14690 }
85 13 50       151  
86             # ... and Name only for Courses/Course
87             if ($o->{tag_course}) {
88             # will pick up device name under Creator if we are not specific about the Course tag
89 23 100       124 $o->{tag_name} = $1 if $o->{tag_course} =~ /<Course.*<Name>(.*)<\/Name>/
90             }
91 10 50       1658  
92             $o->{tag_author} = $1 if $txt =~ /(<Author.*<\/Author>)/;
93             $o->_parse_author_tag if $o->{tag_author};
94 23 100       1820  
95 23 100       191 my @Lap;
96             if ( $o->{tag_activity} ) {
97 23         64 my $i = 0;
98 23 100       105 my $lap;
99 13         40 while ( $o->{tag_activity} =~ /(\<Lap StartTime=.*?\>.*?\<\/Lap\>)/g ) {
100 13         27 my ($lapstring, $last_point_previous_lap);
101 13         2088 $lapstring = $1;
102 45         134 $last_point_previous_lap = $lap->trackpoint(-1) if $i > 0;
103 45         1541 $lap = Geo::TCX::Lap->new($lapstring, ++$i, $last_point_previous_lap);
104 45 100       347 push @{ $o->{Laps} }, $lap
105 45         397 }
106 45         119 }
  45         4392  
107             if ( $o->{tag_course} ) {
108             # in Courses, data is structured as <Lap>...</Lap><Lap>...</Lap><Track>...</Track><Track>...</Track>
109 23 100       125 # actually, not sure just seem like it's one long ... track, not multiple ones, which complicates things
110             my $xml_str = $o->{tag_course};
111              
112 10         36 my (@lap_tags, @lap_endpoints, @track_tags);
113              
114 10         25 if ( $xml_str =~ m,(<Lap>.*</Lap>),s ) {
115             my $str = $1;
116 10 50       724 @lap_tags = split(/(?s)<\/Lap>\s*<Lap>/, $str );
117 10         34 if (@lap_tags == 0) { push @lap_tags, $str }
118 10         60 }
119 10 50       58  
  0         0  
120             for my $i (0 .. $#lap_tags) {
121             my ($end_pos, $end_pt);
122 10         60 if ( $lap_tags[$i] =~ m,<EndPosition>(.*)</EndPosition>,s ) {
123 10         46 $end_pt = Geo::TCX::Trackpoint->new( $1 );
124 10 50       88 push @lap_endpoints, $end_pt
125 10         180 }
126 10         35 # since split removed tags sometimes at ^ of string for other at $
127             # let's remove them all and add back
128             $lap_tags[$i] =~ s,</?Lap>,,g;
129             $lap_tags[$i] =~ s,^,<Lap>,g;
130 10         87 $lap_tags[$i] =~ s,$,</Lap>,g
131 10         60 }
132 10         76 my $track_str;
133             if ( $xml_str =~ m,(<Track>.*</Track>),s ) {
134 10         26 $track_str = $1;
135 10 50       177 }
136 10         106  
137             my $t = Geo::TCX::Track->new( $track_str );
138             if (@lap_tags ==1) { $track_tags[0] = $track_str }
139 10         81 else {
140 10 50       64 my ($t1, $t2);
  10         37  
141             for my $i (0 .. $#lap_tags ) {
142 0         0 if ($i < $#lap_tags) {
143 0         0 ($t1, $t2) = $t->split_at_point_closest_to( $lap_endpoints[$i] );
144 0 0       0 push @track_tags, $t1->xml_string;
145 0         0 $t = $t2
146 0         0 } else { push @track_tags, $t->xml_string } # ie don't split the last track portion
147 0         0 }
148 0         0 }
149              
150             my $lap;
151             for my $i (0 .. $#lap_tags) {
152 10         28 my ($lapstring, $last_point_previous_lap);
153 10         49 $lapstring = $lap_tags[$i] . $track_tags[$i];
154 10         28 $last_point_previous_lap = $lap->trackpoint(-1) if $i > 0;
155 10         172 $lap = Geo::TCX::Lap->new($lapstring, ++$i, $last_point_previous_lap);
156 10 50       47 push @{ $o->{Laps} }, $lap
157 10         104 }
158 10         27 }
  10         218  
159              
160             my $n = $o->laps;
161             die "cannot find any laps, must not be a *.tcx file or str" unless $n;
162 23         189 print "\nFound " . $n, ($n > 1 ? " Laps": " Lap"), "\n\n";
163 23 50       84 $o->{_txt} = $txt; # only for debugging
164 23 100       1460 $o->set_wd( $opts{work_dir} || $opts{wd} );
165 23         182 return $o
166 23   66     273 }
167 23         349  
168             =head2 Constructor Methods (object)
169              
170             =over 4
171              
172             =item activity_to_course( key/values )
173              
174             returns a new <Geo::TCX> instance as a course, based on the current activity.
175              
176             All I<key/values> are optional:
177              
178             Z<> C<< lap => I<#> >>: converts lap number I<#> to a course, dropping all other laps. All laps are converted if C<lap> is omitted.
179             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.
180             Z<> C<< filename => I<$filename> >>: will call C<set_filename()> with this value.
181             Z<> C<< work_dir => I<$work_dir> >>: if omitted, it will be set to the same as that of the current object.
182              
183             =back
184              
185             =cut
186              
187             my $clone = shift->clone;
188             my %opts = @_;
189             croak 'this instance is already a course' if $clone->is_course;
190 2     2 1 18 my $wd = $opts{work_dir} || $opts{wd} || $clone->set_wd();
191 2         16  
192 2 50       13 my (@laps, $course);
193 2   0     11 @laps = $opts{lap} ? ($opts{lap}) : (1 .. $clone->laps);
194              
195 2         6 for my $lap_i (@laps) {
196 2 100       63 my $str = $clone->save_laps( [ $lap_i ], nosave => 1, course => 1, course_name => $opts{course_name} );
197             my $course_i = Geo::TCX->new( \$str, work_dir => $wd );
198 2         12 if ( defined $course ) {
199 4         32 push @{ $course->{Laps} }, $course_i->lap(1)
200 4         27 } else { $course = $course_i }
201 4 100       22 }
202 2         7 $course->set_filename( $opts{filename} );
  2         14  
203 2         8 return $course
204             }
205 2         15  
206 2         40 =over 4
207              
208             =item clone()
209              
210             Returns a deep copy of a C<Geo::TCX> instance.
211              
212             $clone = $o->clone;
213              
214             =back
215              
216             =cut
217              
218             my $clone;
219             eval(Data::Dumper->Dump([ shift ], ['$clone']));
220             confess $@ if $@;
221             return $clone
222 6     6 1 1090 }
223 6         91  
224 6 50       534 =head2 Object Methods
225 6         69  
226             =over 4
227              
228             =item lap( # )
229              
230             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.
231              
232             =back
233              
234             =cut
235              
236             my ($o, $lap_i, %exists) = (shift, shift);
237             croak 'requires a single integer as argument' if ! $lap_i or @_;
238             $lap_i = $o->_lap_number($lap_i);
239             return $o->{Laps}[$lap_i-1]
240             }
241 53     53 1 6843  
242 53 50 33     323 =over 4
243 53         175  
244 53         375 =item laps( qw/ # # ... / )
245              
246             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).
247              
248             =back
249              
250             =cut
251              
252             my $o = shift;
253             return @{$o->{Laps}} unless @_;
254             my @numbers = @_;
255             my @laps;
256             for my $lap_i (@numbers) {
257             $lap_i = $o->_lap_number($lap_i);
258 97     97 1 2326 push @laps, $o->{Laps}[$lap_i-1]
259 97 100       246 }
  95         291  
260 2         8 return @laps
261 2         5 }
262 2         4  
263 4         9 =over 4
264 4         12  
265             =item merge_laps( #1, #2 )
266              
267 2         10 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.
268              
269             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>.
270              
271             =back
272              
273             =cut
274              
275             my ($o, $i1, $i2, %exists) = (shift, shift, shift);
276             croak 'merge_laps() requires two integers as argument' if ! $i2 or @_;
277             croak 'can only merge consecutive laps' unless ($i2 - $i1)==1;
278             my $l1 = $o->lap($i1);
279             my $l2 = $o->lap($i2);
280              
281             my $lap = $l1->merge($l2, as_is => 1);
282 1     1 1 10  
283 1 50 33     7 splice @{ $o->{Laps}}, $i1 - 1, 2, $lap;
284 1 50       5 return 1
285 1         3 }
286 1         3  
287             =over 4
288 1         8  
289             =item split_lap( #, $trackpoint_no )
290 1         2  
  1         6  
291 1         4 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.
292              
293             =back
294              
295             =cut
296              
297             my ($o, $lap_i, $pt_no, %exists) = (shift, shift, shift);
298             croak 'split_lap() requires two integers as argument' if ! $pt_no or @_;
299             $lap_i = $o->_lap_number($lap_i);
300             my ($lap_a, $lap_b) = $o->lap($lap_i)->split($pt_no);
301             splice @{ $o->{Laps}}, $lap_i -1, 1, ( $lap_a, $lap_b );
302             return 1
303             }
304              
305 3     3 1 625 =over 4
306 3 50 33     27  
307 3         14 =item split_lap_at_point_closest_to(#, $point or $trackpoint or $coord_str )
308 3         14  
309 3         8 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.
  3         20  
310 3         10  
311             =back
312              
313             =cut
314              
315             my ($o, $lap_i, $to_pt) = (shift, shift, shift);
316             croak 'split_lap_at_point_closest_to() expects two arguments' if @_;
317             $lap_i = $o->_lap_number($lap_i);
318             $to_pt = Geo::Gpx::Point->flex_coordinates( \$to_pt ) unless ref $to_pt;
319             my ($closest_pt, $min_dist, $pt_no) = $o->lap($lap_i)->point_closest_to( $to_pt );
320             # here we can print some info about the original track and where it will be split
321             $o->split_lap( $lap_i, $pt_no );
322             return 1
323             }
324 1     1 1 4  
325 1 50       5 =over 4
326 1         5  
327 1 50       18 =item time_add( @duration )
328 1         127  
329             =item time_subtract( @duration )
330 1         28  
331 1         14 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.
332              
333             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.
334              
335             =back
336              
337             =cut
338              
339             my $o = shift;
340             my @duration = @_;
341             my @laps = @{$o->{Laps}};
342             for my $l (@laps) {
343             $l->time_add( @duration )
344             }
345             return 1
346             }
347              
348             my $o = shift;
349 1     1 1 698 my @duration = @_;
350 1         6 my @laps = @{$o->{Laps}};
351 1         4 for my $l (@laps) {
  1         5  
352 1         3 $l->time_subtract( @duration )
353 4         23 }
354             return 1
355 1         9 }
356              
357             =over 4
358              
359 1     1 1 3 =item delete_lap( # )
360 1         4  
361 1         3 =item keep_lap( # )
  1         4  
362 1         4  
363 4         24 delete or keep the specified lap I<#> form the object. Returns the list of laps removed in both cases.
364              
365 1         8 =back
366              
367             =cut
368              
369             my ($o, $lap_i) = (shift, shift);
370             croak 'requires a single integer as argument' unless $lap_i;
371             $lap_i = $o->_lap_number( $lap_i );
372             my @removed = splice @{ $o->{Laps}}, $lap_i - 1, 1;
373             return @removed
374             }
375              
376             my ($o, $lap_i) = (shift, shift);
377             my @keep = $o->delete_lap($lap_i);
378             my @removed = @{ $o->{Laps}};
379             @{ $o->{Laps}} = @keep;
380             return @removed
381 5     5 1 13 }
382 5 50       14  
383 5         17 =over 4
384 5         8  
  5         16  
385             =item save_laps( \@laplist , key/values )
386 5         16  
387             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.
388              
389 1     1 1 5 An array reference can be provided to save only a a subset of lap numbers.
390 1         5  
391 1         3 I<key/values> are:
  1         3  
392 1         3  
  1         3  
393             Z<> C<course>: converts activity lap(s) as course files if true.
394 1         4 Z<> C<< course_name => $string >>: is only relevant with C<course> and will set the name of the course to I<$string>.
395             Z<> C<force>: overwrites existing files if true, otherwise it won't.
396             Z<> C<indent>: adds white space and indents the xml mark-up in the saved file if true.
397             Z<> C<nosave>: no files are actually saved if true. Useful if only interested in the xml string of the last lap processed.
398              
399             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.
400              
401             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.
402              
403             =back
404              
405             =cut
406              
407             my $o = shift;
408             my @laps_to_save;
409             if (ref ($_[0]) eq 'ARRAY') {
410             my $aref = shift;
411             for my $lap_i (@$aref) {
412             push @laps_to_save, $o->lap($lap_i)
413             }
414             } else { @laps_to_save = @{$o->{Laps}} }
415             my %opts = @_;
416              
417             my ($as_course, $fname);
418             $as_course = 1 if $o->is_course or $opts{course};
419             $fname = $o->set_filename;
420             croak 'no filename found, set_filename(<name>) before saving' unless $fname;
421 9     9 1 31  
422 9         24 # as mentioned in the pod, files will be saved in work_dir as they are new files
423 9 100       52 # use has expectation that that's where working files go
424 5         11 my ($name, $path, $ext) = fileparse( $fname, '\..*' );
425 5         27 my $wd = $o->set_wd();
426 5         27  
427             my ($tags_before_lap, $tags_after_lap) = $o->_prep_tags( %opts );
428 4         8  
  4         16  
429 9         75 # Id (for Activity) or Name (for Course) tag
430             my $tag_id_or_name = '';
431 9         24 if ($as_course) {
432 9 100 100     45 # a bit tricky to determine Name when saving as course, bear with us here
433 9         35 if (@laps_to_save == 1 ) {
434 9 50       56 my $name;
435             if ( defined $opts{course_name} ) { $name = $opts{course_name} }
436             else {
437             if ($o->is_course) { $name = $o->{tag_name} }
438 9         364 else { $name = 'StartTimePlaceHolder' }
439 9         46 }
440             $tag_id_or_name .= '<Name>' . $name . '</Name>'
441 9         96 } else {
442             $tag_id_or_name .= '<Name>' . 'StartTimePlaceHolder' . '</Name>';
443             # i.e. it's StartTime regardless if more than one lap
444 9         30 }
445 9 100       39 } else { $tag_id_or_name .= '<Id>' . 'StartTimePlaceHolder' . '</Id>' }
446              
447 6 50       25 # 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.
448 6         13 # Yah ! And don't distance_net
449 6 100       22  
  4         13  
450             my $str;
451 2 50       9 for my $i (0 .. $#laps_to_save) {
  2         6  
452 0         0 my $l = $laps_to_save[$i]->clone;
453             $l->distance_net;
454 6         29  
455             $str = $tags_before_lap;
456 0         0 $str .= $tag_id_or_name;
457             $str =~ s/StartTimePlaceHolder/$l->StartTime/e;
458              
459 3         9 my $xml_lap = $l->xml_string( course => $as_course, indent => $opts{indent} );
460             $str .= $xml_lap;
461             $str .= $tags_after_lap;
462              
463             unless ($opts{nosave}) {
464 9         29 my $fname_lap = $wd . $name . '-Lap-' . ($i+1) . $ext;
465 9         36 croak "$fname_lap already exists" if -f $fname_lap and !$opts{force};
466 15         168 open(my $fh, '>', $fname_lap) or die "can't open $fname_lap $!";
467 15         138 print $fh $str
468             }
469 15         45 }
470 15         48 return $str
471 15         357 }
  9         97  
472              
473 15         121 =over 4
474 15         144  
475 15         49 =item save( key/values )
476              
477 15 100       108 saves the current instance.
478 10         51  
479 10 50 66     514 I<key/values> are:
480 10 50       1376  
481 10         1743 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).
482             Z<> C<force>: overwrites existing files if true, otherwise it won't.
483             Z<> C<indent>: adds white space and indents the xml mark-up in the saved file if true.
484 9         193  
485             Returns a string containing the xml representation of the file.
486              
487             =back
488              
489             =cut
490              
491             my ($o, %opts) = @_;
492              
493             my $fname;
494             if ( $opts{filename} ) { $fname = $o->set_filename( $opts{filename} ) }
495             else { $fname = $o->set_filename() }
496             croak 'no filename found, provide one with set_filename() or use key \'filename\'' unless $fname;
497             croak "$fname already exists" if -f $fname and !$opts{force};
498              
499             my ($tags_before_lap, $tags_after_lap) = $o->_prep_tags( indent => $opts{indent} );
500             my $str = $tags_before_lap;
501             if ($o->is_course) { $str .= '<Name>' . $o->{tag_name} . '</Name>' }
502             else { $str .= '<Id>' . $o->{tag_id} . '</Id>' }
503              
504             my ($str_activity_laps, $str_course_laps, $str_course_tracks);
505             for my $lap ($o->laps) {
506 2     2 1 659 my $str_lap = $lap->xml_string( indent => $opts{indent} );
507              
508 2         12 if ($lap->is_course) {
509 2 100       14 # for courses, the xml track tags are not nested within the lap
  1         5  
510 1         3 # tags but follow them instead. Yah, weird. So need to collect
511 2 50       20 # the strings seperately then assemble after the loop
512 2 50 33     80 if ( $str_lap =~ s,\s*(<Lap>.*</Lap>)\s*(<Track>.*</Track>)\s*,,s ) {
513             $str_course_laps .= $1;
514 2         29 $str_course_tracks .= $2
515 2         12 } else { croak "cannot find lap or track tags in Laps object" }
516 2 50       21 } else {
  2         16  
517 0         0 $str_activity_laps .= $str_lap
518             }
519 2         14 }
520 2         22  
521 4         45 # Flatten the course tracks into a a single track
522             $str_course_tracks =~ s,</Track>\s*<Track>,,gs if $str_course_tracks;
523 4 50       30  
524             if ($o->is_course) { $str .= $str_course_laps . $str_course_tracks }
525             else { $str .= $str_activity_laps }
526              
527 4 50       199 $str .= $tags_after_lap;
528 4         36  
529 4         39 open(my $fh, '>', $fname) or die "can't open $fname $!";
530 0         0 print $fh $str;
531             return $str
532 0         0 }
533              
534             my ($o, %opts) = @_;
535              
536             # identical to save_laps()
537 2 50       61 my ($newline, $tab, $as_course);
538             $newline = $opts{indent} ? "\n" : '';
539 2 50       12 $tab = $opts{indent} ? ' ' : '';
  2         42  
540 0         0 $as_course = 1 if $o->is_course or $opts{course};
541              
542 2         29 #
543             # Prepare the mark-up that appears *outside* the laps (therefore will be common to all saved laps)
544 2 50       358  
545 2         166 # These tag collection blocks could be shortened but it might not become more legible.
546 2         90 # The many variables help for debugging as the resulting string can be an extremely
547             # long flat string
548              
549             # we first collect the tags we need, we assemble them later
550 11     11   62 # we need these 3 pairs of tags so declare in a block
551             my ($tag_open_trainctrdb, $tag_close_trainctrdb);
552             my ($tag_open_activity_or_course_plural, $tag_close_activity_or_course_plural );
553 11         26 my ($tag_open_activity_or_course_singular, $tag_close_activity_or_course_singular);
554 11 100       63  
555 11 100       61 if ($o->{tag_trainingcenterdatabase} =~ /(<TrainingCenterDatabase[^>]*>)/) {
556 11 100 100     46 $tag_open_trainctrdb = $1;
557             $tag_close_trainctrdb = '</TrainingCenterDatabase>'
558             } else { croak 'can\'t find the expected <TrainingCenterDatabase ...> tag' }
559              
560             # in history files (in mine at least), these tags ever appear only once, nesting all of the data within them
561             # <Activities><Activity Sport="Biking">
562             # <Courses><Course>
563             # Activity is nested within Activities and similarly Course is nested within Courses
564              
565             if ($o->{tag_activities}) {
566             if ( $o->{tag_activities} =~ /(<Activities[^>]*>)/) {
567 11         72 $tag_open_activity_or_course_plural = $1
568 11         0 } else { croak 'can\'t find the expected <Activities> tag' }
569 11         0 if ( $o->{tag_activity} =~ /(<Activity[^>]*>)/ ) {
570             $tag_open_activity_or_course_singular = $1
571 11 50       142 } else { croak 'can\'t find the expected <Activity Sport="..."> tag' }
572 11         58 ($tag_close_activity_or_course_singular, $tag_close_activity_or_course_plural) = ('</Activity>', '</Activities>')
573 11         26 }
574 0         0 if ($o->{tag_courses}) {
575             if ( $o->{tag_courses} =~ /(<Courses[^>]*>)/) {
576             $tag_open_activity_or_course_plural = $1
577             } else { croak 'can\'t find the expected <Courses> tag' }
578             if ( $o->{tag_course} =~ /(<Course(?!s)[^>]*>)/ ) {
579             $tag_open_activity_or_course_singular = $1
580             } else { croak 'can\'t find the expected <Course> tag' }
581 11 100       40 ($tag_close_activity_or_course_singular, $tag_close_activity_or_course_plural) = ('</Course>', '</Courses>')
582 7 50       51 }
583 7         21 if ($as_course and !$o->{tag_courses}) { # i.e. when saving an activity as a course
584 0         0 $tag_open_activity_or_course_plural = '<Courses>';
585 7 50       44 $tag_open_activity_or_course_singular = '<Course>';
586 7         21 ($tag_close_activity_or_course_singular, $tag_close_activity_or_course_plural) = ('</Course>', '</Courses>')
587 0         0 }
588 7         24  
589             # assembling the tags to get the mark-up that appears *before* the laps
590 11 100       47 my $tags_before_lap = '';
591 4 50       49 $tags_before_lap = $o->{tag_xml_version} . "\n";
592 4         20 $tags_before_lap .= $tag_open_trainctrdb;
593 0         0 $tags_before_lap .= $newline . $tab . $tag_open_activity_or_course_plural;
594 4 50       42 $tags_before_lap .= $newline . ($tab x 2) . $tag_open_activity_or_course_singular;
595 4         18 $tags_before_lap .= $newline . ($tab x 3);
596 0         0  
597 4         23 # assembling the tags to get the mark-up that appears *after* the laps
598             my ($tags_after_lap) = '';
599 11 100 100     90 $tags_after_lap = $newline . ($tab x 3) . $o->{tag_creator} if $o->{tag_creator};
600 4         12 $tags_after_lap .= $newline . ($tab x 2) . $tag_close_activity_or_course_singular;
601 4         9 $tags_after_lap .= $newline . $tab . $tag_close_activity_or_course_plural;
602 4         12 $tags_after_lap .= $newline . $tab . $o->{tag_author} if $o->{tag_author};
603             $tags_after_lap .= $newline . $tag_close_trainctrdb;
604              
605             return $tags_before_lap, $tags_after_lap
606 11         34 }
607 11         37  
608 11         34 =over 4
609 11         37  
610 11         47 =item set_filename( $filename )
611 11         40  
612             Sets/gets the filename. Returns the name of the file with the complete path.
613              
614 11         32 =back
615 11 100       59  
616 11         35 =cut
617 11         29  
618 11 100       52 my ($o, $fname) = (shift, shift);
619 11         33 return $o->{_fileABSOLUTENAME} unless $fname;
620             croak 'set_filename() takes only a single name as argument' if @_;
621 11         62 my $wd;
622             if ($o->_is_wd_defined) { $wd = $o->set_wd }
623             # set_filename gets called before set_wd by new() so can't access work_dir until initialized
624              
625             my ($name, $path, $ext);
626             ($name, $path, $ext) = fileparse( $fname, '\..*' );
627             if ($wd) {
628             if ( ! ($fname =~ /^\// ) ) {
629             # ie if fname is not an abolsute path, adjust $path to be relative to work_dir
630             ($name, $path, $ext) = fileparse( $wd . $fname, '\..*' )
631             }
632             }
633             $o->{_fileABSOLUTEPATH} = abs_path( $path ) . '/';
634             $o->{_fileABSOLUTENAME} = $o->{_fileABSOLUTEPATH} . $name . $ext;
635 60     60 1 2028 croak 'directory ' . $o->{_fileABSOLUTEPATH} . ' doesn\'t exist' unless -d $o->{_fileABSOLUTEPATH};
636 60 100       480 $o->{_fileNAME} = $name;
637 28 50       118 $o->{_filePATH} = $path;
638 28         60 $o->{_fileEXT} = $ext;
639 28 100       110 $o->{_filePARSEDNAME} = $fname;
  10         60  
640             # _file* keys only for debugging, should not be used anywhere else
641             return $o->{_fileABSOLUTENAME}
642 28         81 }
643 28         1430  
644 28 100       147 =over 4
645 10 100       42  
646             =item set_wd( $folder )
647 8         164  
648             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.
649              
650 28         964 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.
651 28         173  
652 28 50       392 =back
653 28         296  
654 28         127 =cut
655 28         78  
656 28         70 my ($o, $dir) = (shift, shift);
657             croak 'set_wd() takes only a single folder as argument' if @_;
658             my $first_call = ! $o->_is_wd_defined; # ie if called for 1st time -- at construction by new()
659 28         138  
660             if (! $dir) {
661             return $o->{work_dir} unless $first_call;
662             my $fname = $o->set_filename;
663             if ($fname) {
664             my ($name, $path, $ext) = fileparse( $fname );
665             $o->set_wd( $path )
666             } else { $o->set_wd( cwd ) }
667             } else {
668             $dir =~ s/^\s+|\s+$//g; # some clean-up
669             $dir =~ s/~/$ENV{'HOME'}/ if $dir =~ /^~/;
670             $dir = $o->_set_wd_old if $dir eq '-';
671              
672             if ($dir =~ m,^[^/], ) { # convert rel path to full
673             $dir = $first_call ? cwd . '/' . $dir : $o->{work_dir} . $dir
674 83     83 1 973 }
675 83 50       265 $dir =~ s,/*$,/,; # some more cleaning
676 83         252 1 while ( $dir =~ s,/\./,/, ); # support '.'
677             1 while ( $dir =~ s,[^/]+/\.\./,, ); # and '..'
678 83 100       244 croak "$dir not a valid directory" unless -d $dir;
679 44 100       289  
680 16         67 if ($first_call) { $o->_set_wd_old( $dir ) }
681 16 100       50 else { $o->_set_wd_old( $o->{work_dir} ) }
682 15         471 $o->{work_dir} = $dir
683 15         85 }
684 1         8893 return $o->{work_dir}
685             }
686 39         308  
687 39 50       146 # if ($o->set_filename) { $o->set_wd() } # if we have a filename
688 39 100       122 # else { $o->set_wd( cwd ) } # if we don't
689              
690 39 100       172 my ($o, $dir) = @_;
691 8 100       13527 $o->{work_dir_old} = $dir if $dir;
692             return $o->{work_dir_old}
693 39         466 }
694 39         180  
695 39         210  
696 39 50       1231 =over 4
697              
698 39 100       168 =item is_activity()
  23         139  
699 16         60  
700 39         117 =item is_course()
701              
702             True if the C<Geo::TCX> instance is a of the type indicated by the method, false otherwise.
703 55         215  
704             =back
705              
706             =cut
707              
708              
709 44     44   163 =over 4
710 44 100       184  
711             =item activity( $string )
712 44         104  
713             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.
714 111     111   454  
715             =back
716              
717             =cut
718              
719             my ($o, $activity) = @_;
720             # should I check what activity types are allowed? Must they be single words?
721             if ($activity) {
722             $o->{tag_activity} =~ s,(\<Activity Sport=)"[^"]*",$1"$activity",;
723             return $o->activity
724             }
725             $activity = $1 if $o->{tag_activity} =~ /<Activity Sport="([^"]*)"/;
726             return $activity
727             }
728 2     2 1 15  
729 30     30 1 252 =over 4
730              
731             =item author( key/value )
732              
733             Gets/sets the fields of the Author tag. Supported keys are C<Name>, C<LangID>, C<PartNumber> and all excpect a string as value.
734              
735             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.
736              
737             Returns a hash reference of key/value pairs.
738              
739             This method is under development and behaviour could change in the future.
740              
741             =back
742 6     6 1 13  
743             =cut
744 6 100       15  
745 2         93 # 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.
746 2         11 # 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
747             # the Build entries contain integers but I am not supporting this at this point
748 4 50       25 #
749 4         13 # <Author xsi:type="Application_t"
750             # <Name>string</Name>
751             # <Build containis
752             # <Version>
753             # VersionMajor
754             # VersionMinor
755             # BuildMajor
756             # BuildMinor
757             # </Version>
758             # <Type>Release<Type>
759             # </Build>
760             # <LangID>EN</LangID>
761             # <PartNumber>digit and dash string</PartNumber>
762             # </Author>
763              
764             my %possible_author_keys;
765             my @author_keys = qw/ Name Build LangID PartNumber /;
766             $possible_author_keys{$_} = 1 for @author_keys;
767              
768             # NB: similar to the _file* keys, the _Author href can not exist at any time
769             my $o = shift;
770             $o->{_Author} = {};
771             my $href = $o->{_Author};
772             my $author_xml;
773             if ( $o->{tag_author} =~ m,<Author\s+([^=]+="[^"]+")>(.*)<\/Author>, ) {
774             $href->{string_inside_author_tag} = $1;
775             $author_xml = $2
776             }
777             for my $key (@author_keys) {
778             $href->{$key} = $1 if $author_xml =~ m,<$key>(.+)</$key>,
779             }
780             return $o->{tag_author}
781             }
782              
783             my $o = shift;
784             my $href = $o->{_Author};
785              
786             my $str = '<Author ' . $href->{string_inside_author_tag} . '>';
787             for my $key (@author_keys) {
788             $str .= "<$key>" . $href->{$key} . "</$key>" if defined $href->{key}
789             }
790             $str .= '</Author>';
791             $o->{tag_author} = $str;
792             return $o->{tag_author}
793 17     17   47 }
794 17         69  
795 17         62 my ($o, %keys_values) = @_;
796 17         45 my $href = $o->{_Author};
797 17 50       156 croak 'no author tag found in object' if (!%keys_values and ! $href);
798 17         84 if (%keys_values) {
799 17         88 for my $key (keys %keys_values) {
800             croak 'unsupported Author field' unless $possible_author_keys{$key};
801 17         83 $href->{$key} = $keys_values{$key}
802 68 100       1585 }
803             $o->_update_author_tag
804             }
805 17         156 return $href
806             }
807              
808 2     2   5 # returns the actual lap number if a negative index is passed to count from the end
809 2         4 my ($o, $lap_i, $n, %exists) = (shift, shift);
810             $n = $o->laps;
811 2         8 $lap_i += $n + 1 if $lap_i < 0;
812 2         5 $exists{$_} = 1 for (1 .. $n);
813             croak "Lap $lap_i does not exist" unless $exists{$lap_i};
814 8 50       17 return $lap_i
815 2         6 }
816 2         5  
817             =head1 EXAMPLES
818 2         4  
819             Coming soon.
820              
821 3     3 1 1325 =head1 BUGS
822 3         8  
823 3 50 66     16 Nothing to report yet.
824 3 100       9  
825 2         8 =head1 AUTHOR
826 2 50       8  
827 2         6 Patrick Joly
828              
829             =head1 VERSION
830 2         7  
831 3         8 1.02
832              
833             =head1 LICENSE AND COPYRIGHT
834              
835             Copyright (c) 2022, Patrick Joly C<< <patjol@cpan.org> >>. All rights reserved.
836 66     66   154  
837 66         147 This module is free software; you can redistribute it and/or modify it under the same terms as Perl itself. See L<perlartistic>.
838 66 100       175  
839 66         385 =head1 SEE ALSO
840 66 50       181  
841 66         176 L<Geo::Gpx>
842              
843             =head1 DISCLAIMER OF WARRANTY
844              
845             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.
846              
847             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.
848              
849             =cut
850              
851             1;
852