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 5     5   441708 use warnings;
  5         50  
  5         129  
3 5     5   21  
  5         8  
  5         191  
4             our $VERSION = '1.01';
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 5     5   2040 use File::Basename;
  5         16  
  5         214  
28 5     5   55 use Cwd qw(cwd abs_path);
  5         13  
  5         557  
29 5     5   32 use Carp qw(confess croak cluck);
  5         10  
  5         286  
30 5     5   32  
  5         9  
  5         22252  
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 working directory can be supplied as a relative (to L<Cwd::cwd>) or absolute path but is internally stored by C<set_wd()> as a full path. If C<work_dir> is ommitted, it is set based on the path of the I<$filename> supplied or the current working directory if the constructor is called with a string reference.
46              
47             =back
48              
49             =cut
50              
51             my ($proto, $first_arg) = (shift, shift);
52             my %opts = @_;
53 21     21 1 16596 my $o = {};
54 21         92 my $class = ref($proto) || $proto;
55 21         82 bless($o, $class);
56 21   33     206  
57 21         69 my $txt;
58             if (ref( $first_arg ) eq 'SCALAR') {
59 21         59 $txt = $$first_arg
60 21 100       131 } else {
61 5         16 croak 'first argument must be a filename' unless -f $first_arg;
62             $txt = do { local(@ARGV, $/) = $first_arg; <> };
63 16 50       440 $o->set_filename($first_arg)
64 16         64 }
  16         162  
  16         2755  
65 16         159  
66             $txt =~ s,\r,,g; # if it's a windows file
67             $txt =~ s,>\s+<,><,g;
68 21         156 $o->{tag_creator} = $1 if $txt =~ s/(<Creator.*<\/Creator>)//;
69 21         7279  
70 21 100       2382 # 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 21 50       289 $o->{tag_activities} = $1 if $txt =~ /(<Activities.*<\/Activities>)/;
76 21 50       2067 $o->{tag_activity} = $1 if $txt =~ /(<Activity.*<\/Activity>)/;
77 21 100       2261 $o->{tag_courses} = $1 if $txt =~ /(<Courses.*<\/Courses>)/;
78 21 100       2125 $o->{tag_course} = $1 if $txt =~ /(<Course(?!s).*<\/Course>)/;
79 21 100       933  
80 21 100       960 # Id seems only for Activities/Activity...
81             if ($o->{tag_activity}) {
82             $o->{tag_id} = $1 if $o->{tag_activity} =~ /<Activity.*<Id>(.*)<\/Id>/;
83 21 100       104 $o->{tag_activity_type} = $1 if $o->{tag_activity} =~ /<Activity Sport="([^"]+)"/;
84 12 50       10781 }
85 12 50       144  
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 21 100       107 $o->{tag_name} = $1 if $o->{tag_course} =~ /<Course.*<Name>(.*)<\/Name>/
90             }
91 9 50       1289  
92             $o->{tag_author} = $1 if $txt =~ /(<Author.*<\/Author>)/;
93             $o->_parse_author_tag if $o->{tag_author};
94 21 100       1392  
95 21 100       211 my @Lap;
96             if ( $o->{tag_activity} ) {
97 21         55 my $i = 0;
98 21 100       89 my $lap;
99 12         26 while ( $o->{tag_activity} =~ /(\<Lap StartTime=.*?\>.*?\<\/Lap\>)/g ) {
100 12         27 my ($lapstring, $last_point_previous_lap);
101 12         1504 $lapstring = $1;
102 41         94 $last_point_previous_lap = $lap->trackpoint(-1) if $i > 0;
103 41         1216 $lap = Geo::TCX::Lap->new($lapstring, ++$i, $last_point_previous_lap);
104 41 100       270 push @{ $o->{Laps} }, $lap
105 41         311 }
106 41         71 }
  41         3237  
107             if ( $o->{tag_course} ) {
108             # in Courses, data is structured as <Lap>...</Lap><Lap>...</Lap><Track>...</Track><Track>...</Track>
109 21 100       91 # 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 9         32 my (@lap_tags, @lap_endpoints, @track_tags);
113              
114 9         36 if ( $xml_str =~ m,(<Lap>.*</Lap>),s ) {
115             my $str = $1;
116 9 50       554 @lap_tags = split(/(?s)<\/Lap>\s*<Lap>/, $str );
117 9         31 if (@lap_tags == 0) { push @lap_tags, $str }
118 9         57 }
119 9 50       43  
  0         0  
120             for my $i (0 .. $#lap_tags) {
121             my ($end_pos, $end_pt);
122 9         50 if ( $lap_tags[$i] =~ m,<EndPosition>(.*)</EndPosition>,s ) {
123 9         103 $end_pt = Geo::TCX::Trackpoint->new( $1 );
124 9 50       77 push @lap_endpoints, $end_pt
125 9         102 }
126 9         32 # 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 9         75 $lap_tags[$i] =~ s,$,</Lap>,g
131 9         46 }
132 9         58 my $track_str;
133             if ( $xml_str =~ m,(<Track>.*</Track>),s ) {
134 9         18 $track_str = $1;
135 9 50       126 }
136 9         94  
137             my $t = Geo::TCX::Track->new( $track_str );
138             if (@lap_tags ==1) { $track_tags[0] = $track_str }
139 9         92 else {
140 9 50       57 my ($t1, $t2);
  9         31  
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 9         26 my ($lapstring, $last_point_previous_lap);
153 9         39 $lapstring = $lap_tags[$i] . $track_tags[$i];
154 9         26 $last_point_previous_lap = $lap->trackpoint(-1) if $i > 0;
155 9         302 $lap = Geo::TCX::Lap->new($lapstring, ++$i, $last_point_previous_lap);
156 9 50       47 push @{ $o->{Laps} }, $lap
157 9         99 }
158 9         23 }
  9         245  
159              
160             my $n = $o->laps;
161             die "cannot find any laps, must not be a *.tcx file or str" unless $n;
162 21         167 print "\nFound " . $n, ($n > 1 ? " Laps": " Lap"), "\n\n";
163 21 50       73 $o->{_txt} = $txt; # only for debugging
164 21 100       1511 $o->set_wd( $opts{work_dir} || $opts{wd} );
165 21         141 return $o
166 21   66     273 }
167 21         271  
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 14 my $wd = $opts{work_dir} || $opts{wd} || $clone->set_wd();
191 2         29  
192 2 50       19 my (@laps, $course);
193 2   0     11 @laps = $opts{lap} ? ($opts{lap}) : (1 .. $clone->laps);
194              
195 2         8 for my $lap_i (@laps) {
196 2 100       17 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         14 if ( defined $course ) {
199 4         34 push @{ $course->{Laps} }, $course_i->lap(1)
200 4         38 } else { $course = $course_i }
201 4 100       18 }
202 2         6 $course->set_filename( $opts{filename} );
  2         19  
203 2         9 return $course
204             }
205 2         16  
206 2         84 =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 1608 }
223 6         166  
224 6 50       550 =head2 Object Methods
225 6         114  
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 5665  
242 53 50 33     344 =over 4
243 53         167  
244 53         333 =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 95     95 1 2368 push @laps, $o->{Laps}[$lap_i-1]
259 95 100       243 }
  93         280  
260 2         18 return @laps
261 2         11 }
262 2         13  
263 4         23 =over 4
264 4         17  
265             =item merge_laps( #1, #2 )
266              
267 2         15 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 8  
283 1 50 33     14 splice @{ $o->{Laps}}, $i1 - 1, 2, $lap;
284 1 50       6 return 1
285 1         3 }
286 1         3  
287             =over 4
288 1         6  
289             =item split_lap( #, $trackpoint_no )
290 1         2  
  1         5  
291 1         3 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 594 =over 4
306 3 50 33     31  
307 3         13 =item split_lap_at_point_closest_to(#, $point or $trackpoint or $coord_str )
308 3         12  
309 3         10 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         16  
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 13  
325 1 50       5 =over 4
326 1         3  
327 1 50       36 =item time_add( @duration )
328 1         196  
329             =item time_subtract( @duration )
330 1         29  
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 642 my @duration = @_;
350 1         6 my @laps = @{$o->{Laps}};
351 1         2 for my $l (@laps) {
  1         6  
352 1         12 $l->time_subtract( @duration )
353 4         31 }
354             return 1
355 1         8 }
356              
357             =over 4
358              
359 1     1 1 3 =item delete_lap( # )
360 1         4  
361 1         4 =item keep_lap( # )
  1         5  
362 1         9  
363 4         31 delete or keep the specified lap I<#> form the object. Returns the list of laps removed in both cases.
364              
365 1         7 =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 21 }
382 5 50       20  
383 5         18 =over 4
384 5         15  
  5         29  
385             =item save_laps( \@laplist , key/values )
386 5         41  
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 9 An array reference can be provided to save only a a subset of lap numbers.
390 1         8  
391 1         9 I<key/values> are:
  1         16  
392 1         5  
  1         6  
393             Z<> C<course>: converts activity lap(s) as course files if true.
394 1         8 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 35  
422 9         27 # as mentioned in the pod, files will be saved in work_dir as they are new files
423 9 100       59 # use has expectation that that's where working files go
424 5         15 my ($name, $path, $ext) = fileparse( $fname, '\..*' );
425 5         21 my $wd = $o->set_wd();
426 5         28  
427             my ($tags_before_lap, $tags_after_lap) = $o->_prep_tags( %opts );
428 4         9  
  4         21  
429 9         64 # Id (for Activity) or Name (for Course) tag
430             my $tag_id_or_name = '';
431 9         28 if ($as_course) {
432 9 100 100     88 # a bit tricky to determine Name when saving as course, bear with us here
433 9         56 if (@laps_to_save == 1 ) {
434 9 50       64 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         531 else { $name = 'StartTimePlaceHolder' }
439 9         46 }
440             $tag_id_or_name .= '<Name>' . $name . '</Name>'
441 9         79 } 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       42 } else { $tag_id_or_name .= '<Id>' . 'StartTimePlaceHolder' . '</Id>' }
446              
447 6 50       27 # 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         24 # Yah ! And don't distance_net
449 6 100       23  
  4         13  
450             my $str;
451 2 50       11 for my $i (0 .. $#laps_to_save) {
  2         8  
452 0         0 my $l = $laps_to_save[$i]->clone;
453             $l->distance_net;
454 6         25  
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         28 my $fname_lap = $wd . $name . '-Lap-' . ($i+1) . $ext;
465 9         46 croak "$fname_lap already exists" if -f $fname_lap and !$opts{force};
466 15         240 open(my $fh, '>', $fname_lap) or die "can't open $fname_lap $!";
467 15         160 print $fh $str
468             }
469 15         66 }
470 15         48 return $str
471 15         1078 }
  9         146  
472              
473 15         182 =over 4
474 15         219  
475 15         87 =item save( key/values )
476              
477 15 100       177 saves the current instance.
478 10         75  
479 10 50 66     687 I<key/values> are:
480 10 50       1699  
481 10         1915 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         230  
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 703 my $str_lap = $lap->xml_string( indent => $opts{indent} );
507              
508 2         10 if ($lap->is_course) {
509 2 100       18 # for courses, the xml track tags are not nested within the lap
  1         19  
510 1         6 # tags but follow them instead. Yah, weird. So need to collect
511 2 50       19 # the strings seperately then assemble after the loop
512 2 50 33     108 if ( $str_lap =~ s,\s*(<Lap>.*</Lap>)\s*(<Track>.*</Track>)\s*,,s ) {
513             $str_course_laps .= $1;
514 2         32 $str_course_tracks .= $2
515 2         17 } else { croak "cannot find lap or track tags in Laps object" }
516 2 50       22 } else {
  2         15  
517 0         0 $str_activity_laps .= $str_lap
518             }
519 2         13 }
520 2         21  
521 4         46 # 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       43  
524             if ($o->is_course) { $str .= $str_course_laps . $str_course_tracks }
525             else { $str .= $str_activity_laps }
526              
527 4 50       193 $str .= $tags_after_lap;
528 4         37  
529 4         88 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       50 my ($newline, $tab, $as_course);
538             $newline = $opts{indent} ? "\n" : '';
539 2 50       23 $tab = $opts{indent} ? ' ' : '';
  2         52  
540 0         0 $as_course = 1 if $o->is_course or $opts{course};
541              
542 2         13 #
543             # Prepare the mark-up that appears *outside* the laps (therefore will be common to all saved laps)
544 2 50       530  
545 2         148 # These tag collection blocks could be shortened but it might not become more legible.
546 2         91 # 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   63 # 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         37 my ($tag_open_activity_or_course_singular, $tag_close_activity_or_course_singular);
554 11 100       80  
555 11 100       69 if ($o->{tag_trainingcenterdatabase} =~ /(<TrainingCenterDatabase[^>]*>)/) {
556 11 100 100     37 $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         67 $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       182 } else { croak 'can\'t find the expected <Activity Sport="..."> tag' }
572 11         80 ($tag_close_activity_or_course_singular, $tag_close_activity_or_course_plural) = ('</Activity>', '</Activities>')
573 11         34 }
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       63 ($tag_close_activity_or_course_singular, $tag_close_activity_or_course_plural) = ('</Course>', '</Courses>')
582 7 50       72 }
583 7         25 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       59 $tag_open_activity_or_course_singular = '<Course>';
586 7         29 ($tag_close_activity_or_course_singular, $tag_close_activity_or_course_plural) = ('</Course>', '</Courses>')
587 0         0 }
588 7         23  
589             # assembling the tags to get the mark-up that appears *before* the laps
590 11 100       52 my $tags_before_lap = '';
591 4 50       46 $tags_before_lap = $o->{tag_xml_version} . "\n";
592 4         15 $tags_before_lap .= $tag_open_trainctrdb;
593 0         0 $tags_before_lap .= $newline . $tab . $tag_open_activity_or_course_plural;
594 4 50       35 $tags_before_lap .= $newline . ($tab x 2) . $tag_open_activity_or_course_singular;
595 4         13 $tags_before_lap .= $newline . ($tab x 3);
596 0         0  
597 4         16 # assembling the tags to get the mark-up that appears *after* the laps
598             my ($tags_after_lap) = '';
599 11 100 100     104 $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         8 $tags_after_lap .= $newline . $tab . $tag_close_activity_or_course_plural;
602 4         11 $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         41 }
607 11         43  
608 11         34 =over 4
609 11         39  
610 11         56 =item set_filename( $filename )
611 11         32  
612             Sets/gets the filename. Returns the name of the file with the complete path.
613              
614 11         25 =back
615 11 100       121  
616 11         43 =cut
617 11         36  
618 11 100       63 my ($o, $fname) = (shift, shift);
619 11         29 return $o->{_fileABSOLUTENAME} unless $fname;
620             croak 'set_filename() takes only a single name as argument' if @_;
621 11         70 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 56     56 1 522 croak 'directory ' . $o->{_fileABSOLUTEPATH} . ' doesn\'t exist' unless -d $o->{_fileABSOLUTEPATH};
636 56 100       553 $o->{_fileNAME} = $name;
637 26 50       114 $o->{_filePATH} = $path;
638 26         88 $o->{_fileEXT} = $ext;
639 26 100       105 $o->{_filePARSEDNAME} = $fname;
  10         28  
640             # _file* keys only for debugging, should not be used anywhere else
641             return $o->{_fileABSOLUTENAME}
642 26         71 }
643 26         1379  
644 26 100       105 =over 4
645 10 100       44  
646             =item set_wd( $folder )
647 8         170  
648             Sets/gets the working directory and checks the validity of that path. Relative paths are supported for setting but only full paths are returned or internally stored.
649              
650 26         926 The previous working directory is also stored in memory; can call <set_wd('-')> to switch back and forth between two directories.
651 26         168  
652 26 50       368 Note that it does not call L<chdir>, it simply sets the path for the eventual saving of files.
653 26         304  
654 26         118 =back
655 26         60  
656 26         73 =cut
657              
658             my ($o, $dir) = (shift, shift);
659 26         139 croak 'set_wd() takes only a single folder as argument' if @_;
660             my $first_call = ! $o->_is_wd_defined; # ie if called for 1st time -- at construction by new()
661              
662             if (! $dir) {
663             return $o->{work_dir} unless $first_call;
664             my $fname = $o->set_filename;
665             if ($fname) {
666             my ($name, $path, $ext) = fileparse( $fname );
667             $o->set_wd( $path )
668             } else { $o->set_wd( cwd ) }
669             } else {
670             $dir =~ s/^\s+|\s+$//g; # some clean-up
671             $dir =~ s/~/$ENV{'HOME'}/ if $dir =~ /^~/;
672             $dir = $o->_set_wd_old if $dir eq '-';
673              
674             if ($dir =~ m,^[^/], ) { # convert rel path to full
675             $dir = $first_call ? cwd . '/' . $dir : $o->{work_dir} . $dir
676 79     79 1 322 }
677 79 50       263 $dir =~ s,/*$,/,; # some more cleaning
678 79         239 1 while ( $dir =~ s,/\./,/, ); # support '.'
679             1 while ( $dir =~ s,[^/]+/\.\./,, ); # and '..'
680 79 100       238 croak "$dir not a valid directory" unless -d $dir;
681 42 100       309  
682 14         75 if ($first_call) { $o->_set_wd_old( $dir ) }
683 14 100       42 else { $o->_set_wd_old( $o->{work_dir} ) }
684 13         462 $o->{work_dir} = $dir
685 13         76 }
686 1         11705 return $o->{work_dir}
687             }
688 37         278  
689 37 50       145 # if ($o->set_filename) { $o->set_wd() } # if we have a filename
690 37 100       132 # else { $o->set_wd( cwd ) } # if we don't
691              
692 37 100       166 my ($o, $dir) = @_;
693 8 100       23056 $o->{work_dir_old} = $dir if $dir;
694             return $o->{work_dir_old}
695 37         370 }
696 37         254  
697 37         153  
698 37 50       1206 =over 4
699              
700 37 100       148 =item is_activity()
  21         127  
701 16         74  
702 37         95 =item is_course()
703              
704             True if the C<Geo::TCX> instance is a of the type indicated by the method, false otherwise.
705 51         162  
706             =back
707              
708             =cut
709              
710              
711 42     42   128 =over 4
712 42 100       195  
713             =item activity( $string )
714 42         111  
715             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.
716 105     105   476  
717             =back
718              
719             =cut
720              
721             my ($o, $activity) = @_;
722             # should I check what activity types are allowed? Must they be single words?
723             if ($activity) {
724             $o->{tag_activity} =~ s,(\<Activity Sport=)"[^"]*",$1"$activity",;
725             return $o->activity
726             }
727             $activity = $1 if $o->{tag_activity} =~ /<Activity Sport="([^"]*)"/;
728             return $activity
729             }
730 2     2 1 20  
731 30     30 1 280 =over 4
732              
733             =item author( key/value )
734              
735             Gets/sets the fields of the Author tag. Supported keys are C<Name>, C<LangID>, C<PartNumber> and all excpect a string as value.
736              
737             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.
738              
739             Returns a hash reference of key/value pairs.
740              
741             This method is under development and behaviour could change in the future.
742              
743             =back
744 6     6 1 14  
745             =cut
746 6 100       14  
747 2         124 # 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.
748 2         16 # 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
749             # the Build entries contain integers but I am not supporting this at this point
750 4 50       29 #
751 4         18 # <Author xsi:type="Application_t"
752             # <Name>string</Name>
753             # <Build containis
754             # <Version>
755             # VersionMajor
756             # VersionMinor
757             # BuildMajor
758             # BuildMinor
759             # </Version>
760             # <Type>Release<Type>
761             # </Build>
762             # <LangID>EN</LangID>
763             # <PartNumber>digit and dash string</PartNumber>
764             # </Author>
765              
766             my %possible_author_keys;
767             my @author_keys = qw/ Name Build LangID PartNumber /;
768             $possible_author_keys{$_} = 1 for @author_keys;
769              
770             # NB: similar to the _file* keys, the _Author href can not exist at any time
771             my $o = shift;
772             $o->{_Author} = {};
773             my $href = $o->{_Author};
774             my $author_xml;
775             if ( $o->{tag_author} =~ m,<Author\s+([^=]+="[^"]+")>(.*)<\/Author>, ) {
776             $href->{string_inside_author_tag} = $1;
777             $author_xml = $2
778             }
779             for my $key (@author_keys) {
780             $href->{$key} = $1 if $author_xml =~ m,<$key>(.+)</$key>,
781             }
782             return $o->{tag_author}
783             }
784              
785             my $o = shift;
786             my $href = $o->{_Author};
787              
788             my $str = '<Author ' . $href->{string_inside_author_tag} . '>';
789             for my $key (@author_keys) {
790             $str .= "<$key>" . $href->{$key} . "</$key>" if defined $href->{key}
791             }
792             $str .= '</Author>';
793             $o->{tag_author} = $str;
794             return $o->{tag_author}
795 16     16   45 }
796 16         71  
797 16         54 my ($o, %keys_values) = @_;
798 16         36 my $href = $o->{_Author};
799 16 50       146 croak 'no author tag found in object' if (!%keys_values and ! $href);
800 16         83 if (%keys_values) {
801 16         87 for my $key (keys %keys_values) {
802             croak 'unsupported Author field' unless $possible_author_keys{$key};
803 16         64 $href->{$key} = $keys_values{$key}
804 64 100       1361 }
805             $o->_update_author_tag
806             }
807 16         142 return $href
808             }
809              
810 2     2   5 # returns the actual lap number if a negative index is passed to count from the end
811 2         4 my ($o, $lap_i, $n, %exists) = (shift, shift);
812             $n = $o->laps;
813 2         8 $lap_i += $n + 1 if $lap_i < 0;
814 2         9 $exists{$_} = 1 for (1 .. $n);
815             croak "Lap $lap_i does not exist" unless $exists{$lap_i};
816 8 50       25 return $lap_i
817 2         5 }
818 2         11  
819             =head1 EXAMPLES
820 2         6  
821             Coming soon.
822              
823 3     3 1 1179 =head1 BUGS
824 3         7  
825 3 50 66     21 Nothing to report yet.
826 3 100       10  
827 2         7 =head1 AUTHOR
828 2 50       8  
829 2         11 Patrick Joly
830              
831             =head1 VERSION
832 2         10  
833 3         9 1.01
834              
835             =head1 LICENCE AND COPYRIGHT
836              
837             Copyright (c) 2022, Patrick Joly C<< <patjol@cpan.org> >>. All rights reserved.
838 66     66   156  
839 66         168 This module is free software; you can redistribute it and/or modify it under the same terms as Perl itself. See L<perlartistic>.
840 66 100       188  
841 66         431 =head1 SEE ALSO
842 66 50       237  
843 66         192 L<Geo::Gpx>
844              
845             =head1 DISCLAIMER OF WARRANTY
846              
847             BECAUSE THIS SOFTWARE IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
848             FOR THE SOFTWARE, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
849             OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
850             PROVIDE THE SOFTWARE "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER
851             EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
852             WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE
853             ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE SOFTWARE IS WITH
854             YOU. SHOULD THE SOFTWARE PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL
855             NECESSARY SERVICING, REPAIR, OR CORRECTION.
856              
857             IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
858             WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
859             REDISTRIBUTE THE SOFTWARE AS PERMITTED BY THE ABOVE LICENCE, BE
860             LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL,
861             INCIDENTAL, OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR
862             INABILITY TO USE THE SOFTWARE (INCLUDING BUT NOT LIMITED TO LOSS OF
863             DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR
864             THIRD PARTIES OR A FAILURE OF THE SOFTWARE TO OPERATE WITH ANY OTHER
865             SOFTWARE), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
866             POSSIBILITY OF SUCH DAMAGES.
867              
868             =cut
869              
870             1;