postrunner 0.0.6 → 0.0.7

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,103 @@
1
+ #!/usr/bin/env ruby -w
2
+ # encoding: UTF-8
3
+ #
4
+ # = NavButtonRow.rb -- PostRunner - Manage the data from your Garmin sport devices.
5
+ #
6
+ # Copyright (c) 2015 by Chris Schlaeger <cs@taskjuggler.org>
7
+ #
8
+ # This program is free software; you can redistribute it and/or modify
9
+ # it under the terms of version 2 of the GNU General Public License as
10
+ # published by the Free Software Foundation.
11
+ #
12
+
13
+ require 'postrunner/HTMLBuilder'
14
+
15
+ module PostRunner
16
+
17
+ # Auxilliary class that stores the name of an icon file and a URL as a
18
+ # String. It is used to describe a NavButtonRow button.
19
+ class NavButtonDef < Struct.new(:icon, :url)
20
+ end
21
+
22
+ # A NavButtonRow is a row of buttons used to navigate between HTML pages.
23
+ class NavButtonRow
24
+
25
+ # A class to store the icon and URL of a button in the NavButtonRow
26
+ # objects.
27
+ class Button
28
+
29
+ # Create a Button object.
30
+ # @param icon [String] File name of the icon file
31
+ # @param url [String] URL of the page to change to
32
+ def initialize(icon, url = nil)
33
+ @icon = icon
34
+ @url = url
35
+ end
36
+
37
+ # Add the object as HTML Elements to the document.
38
+ # @param doc [HTMLBuilder] XML Document
39
+ def to_html(doc)
40
+ if @url
41
+ doc.a({ :href => @url }) {
42
+ doc.img({ :src => "icons/#{@icon}", :class => 'active_button' })
43
+ }
44
+ else
45
+ doc.img({ :src => "icons/#{@icon}", :class => 'inactive_button' })
46
+ end
47
+ end
48
+
49
+ end
50
+
51
+ # Create a new NavButtonRow object.
52
+ # @param float [String, Nil] specifies if the HTML representation should
53
+ # be a floating object that floats left or right.
54
+ def initialize(float = nil)
55
+ unless float.nil? || %w( left right ).include?(float)
56
+ raise ArgumentError "float argument must be nil, 'left' or 'right'"
57
+ end
58
+
59
+ @float = float
60
+ @buttons = []
61
+ end
62
+
63
+ # Add a new button to the NavButtonRow object.
64
+ # @param icon [String] File name of the icon file
65
+ # @param url [String] URL of the page to change to
66
+ def addButton(icon, url = nil)
67
+ @buttons << Button.new(icon, url)
68
+ end
69
+
70
+ # Add the object as HTML Elements to the document.
71
+ # @param doc [HTMLBuilder] XML Document
72
+ def to_html(doc)
73
+ doc.unique(:nav_button_row_style) {
74
+ doc.head { doc.style(style) }
75
+ }
76
+ doc.div({ :class => 'nav_button_row',
77
+ :style => "width: #{@buttons.length * (32 + 10)}px; " +
78
+ "#{@float ? "float: #{@float};" :
79
+ 'margin-left: auto; margin-right: auto'}"}) {
80
+ @buttons.each { |btn| btn.to_html(doc) }
81
+ }
82
+ end
83
+
84
+ private
85
+
86
+ def style
87
+ <<"EOT"
88
+ .nav_button_row {
89
+ padding: 3px 30px;
90
+ }
91
+ .active_button {
92
+ padding: 5px;
93
+ }
94
+ .inactive_button {
95
+ padding: 5px;
96
+ opacity: 0.4;
97
+ }
98
+ EOT
99
+ end
100
+
101
+ end
102
+
103
+ end
@@ -0,0 +1,77 @@
1
+ #!/usr/bin/env ruby -w
2
+ # encoding: UTF-8
3
+ #
4
+ # = PagingButtons.rb -- PostRunner - Manage the data from your Garmin sport devices.
5
+ #
6
+ # Copyright (c) 2015 by Chris Schlaeger <cs@taskjuggler.org>
7
+ #
8
+ # This program is free software; you can redistribute it and/or modify
9
+ # it under the terms of version 2 of the GNU General Public License as
10
+ # published by the Free Software Foundation.
11
+ #
12
+
13
+ require 'postrunner/NavButtonRow'
14
+
15
+ module PostRunner
16
+
17
+ # A class to generate a set of forward/backward buttons for an HTML page. It
18
+ # can also include jump to first/last buttons.
19
+ class PagingButtons
20
+
21
+ # Create a new PagingButtons object.
22
+ # @param page_urls [Array of String] Sorted list of all possible pages
23
+ # @param end_buttons [Boolean] If true jump to first/last buttons are
24
+ # included
25
+ def initialize(page_urls, end_buttons = true)
26
+ if page_urls.empty?
27
+ raise ArgumentError.new("'page_urls' must not be empty")
28
+ end
29
+ @pages = page_urls
30
+ @current_page_index = 0
31
+ @end_buttons = end_buttons
32
+ end
33
+
34
+ # Return the URL of the current page
35
+ def current_page
36
+ @pages[@current_page_index]
37
+ end
38
+
39
+ # Set the URL for the current page. It must be included in the URL set
40
+ # passed at creation time. The forward/backward links will be derived from
41
+ # the setting of the current page.
42
+ # @param page_url [String] URL of the page
43
+ def current_page=(page_url)
44
+ unless (@current_page_index = @pages.index(page_url))
45
+ raise ArgumentError.new("URL #{page_url} is not a known page URL")
46
+ end
47
+ end
48
+
49
+ # Iterate over all buttons. A NavButtonDef object is passed to the block
50
+ # that contains the icon and URL for the button. If no URL is set, the
51
+ # button is inactive.
52
+ def each
53
+ %w( first back forward last ).each do |button_name|
54
+ button = NavButtonDef.new
55
+ button.icon = button_name + '.png'
56
+ button.url =
57
+ case button_name
58
+ when 'first'
59
+ @current_page_index == 0 || !@end_buttons ? nil : @pages.first
60
+ when 'back'
61
+ @current_page_index == 0 ? nil :
62
+ @pages[@current_page_index - 1]
63
+ when 'forward'
64
+ @current_page_index == @pages.length - 1 ? nil :
65
+ @pages[@current_page_index + 1]
66
+ when 'last'
67
+ @current_page_index == @pages.length - 1 ||
68
+ !@end_buttons ? nil : @pages.last
69
+ end
70
+
71
+ yield(button)
72
+ end
73
+ end
74
+
75
+ end
76
+
77
+ end
@@ -3,7 +3,7 @@
3
3
  #
4
4
  # = PersonalRecords.rb -- PostRunner - Manage the data from your Garmin sport devices.
5
5
  #
6
- # Copyright (c) 2014 by Chris Schlaeger <cs@taskjuggler.org>
6
+ # Copyright (c) 2014, 2015 by Chris Schlaeger <cs@taskjuggler.org>
7
7
  #
8
8
  # This program is free software; you can redistribute it and/or modify
9
9
  # it under the terms of version 2 of the GNU General Public License as
@@ -14,115 +14,349 @@ require 'fileutils'
14
14
  require 'yaml'
15
15
 
16
16
  require 'fit4ruby'
17
+ require 'postrunner/BackedUpFile'
18
+ require 'postrunner/RecordListPageView'
19
+ require 'postrunner/ActivityLink'
17
20
 
18
21
  module PostRunner
19
22
 
20
23
  class PersonalRecords
21
24
 
25
+ include Fit4Ruby::Converters
26
+
27
+ SpeedRecordDistances = {
28
+ 'cycling' => {
29
+ 5000.0 => '5 km',
30
+ 8000.0 => '8 km',
31
+ 9000.0 => '9 km',
32
+ 10000.0 => '10 km',
33
+ 20000.0 => '20 km',
34
+ 40000.0 => '40 km',
35
+ 80000.0 => '80 km',
36
+ 90000.0 => '90 km',
37
+ 12000.0 => '120 km',
38
+ 18000.0 => '180 km',
39
+ },
40
+ 'running' => {
41
+ 1000.0 => '1 km',
42
+ 1609.0 => '1 mi',
43
+ 2000.0 => '2 km',
44
+ 3000.0 => '3 km',
45
+ 5000.0 => '5 km',
46
+ 10000.0 => '10 km',
47
+ 20000.0 => '20 km',
48
+ 30000.0 => '30 km',
49
+ 21097.5 => 'Half Marathon',
50
+ 42195.0 => 'Marathon'
51
+ },
52
+ 'swimming' => {
53
+ 100.0 => '100 m',
54
+ 300.0 => '300 m',
55
+ 400.0 => '400 m',
56
+ 750.0 => '750 m',
57
+ 1500.0 => '1.5 km',
58
+ 1930.0 => '1.2 mi',
59
+ 3000.0 => '3 km',
60
+ 4000.0 => '4 km',
61
+ 3860.0 => '2.4 mi'
62
+ },
63
+ 'walking' => {
64
+ 1000.0 => '1 km',
65
+ 1609.0 => '1 mi',
66
+ 5000.0 => '5 km',
67
+ 10000.0 => '10 km',
68
+ 21097.5 => 'Half Marathon',
69
+ 42195.0 => 'Marathon'
70
+ }
71
+ }
72
+
22
73
  class Record
23
74
 
24
- attr_accessor :distance, :duration, :start_time, :fit_file
75
+ include Fit4Ruby::Converters
76
+
77
+ attr_accessor :activity, :sport, :distance, :duration, :start_time
25
78
 
26
- def initialize(distance, duration, start_time, fit_file)
79
+ def initialize(activity, sport, distance, duration, start_time)
80
+ @activity = activity
81
+ @sport = sport
27
82
  @distance = distance
28
83
  @duration = duration
29
84
  @start_time = start_time
30
- @fit_file = fit_file
85
+ end
86
+
87
+ def to_table_row(t)
88
+ t.row((@duration.nil? ?
89
+ [ 'Longest Run', '%.1f m' % @distance, '-' ] :
90
+ [ PersonalRecords::SpeedRecordDistances[@sport][@distance],
91
+ secsToHMS(@duration),
92
+ speedToPace(@distance / @duration) ]) +
93
+ [ @activity.db.ref_by_fit_file(@activity.fit_file),
94
+ ActivityLink.new(@activity, false),
95
+ @start_time.strftime("%Y-%m-%d") ])
31
96
  end
32
97
 
33
98
  end
34
99
 
35
- include Fit4Ruby::Converters
100
+ class RecordSet
36
101
 
37
- def initialize(activities)
38
- @activities = activities
39
- @db_dir = activities.db_dir
40
- @records_file = File.join(@db_dir, 'records.yml')
41
- @records = []
102
+ include Fit4Ruby::Converters
42
103
 
43
- load_records
44
- end
104
+ attr_reader :year
45
105
 
46
- def register_result(distance, duration, start_time, fit_file)
47
- @records.each do |record|
48
- if record.duration > 0
49
- if duration > 0
50
- # This is a speed record for a popular distance.
51
- if distance == record.distance
52
- if duration < record.duration
53
- record.duration = duration
54
- record.start_time = start_time
55
- record.fit_file = fit_file
56
- Log.info "New record for #{distance} m in " +
57
- "#{secsToHMS(duration)}"
58
- return true
59
- else
60
- # No new record for this distance.
61
- return false
62
- end
63
- end
106
+ def initialize(sport, year)
107
+ @sport = sport
108
+ @year = year
109
+ @distance = nil
110
+ @speed_records = {}
111
+ PersonalRecords::SpeedRecordDistances[@sport].each_key do |dist|
112
+ @speed_records[dist] = nil
113
+ end
114
+ end
115
+
116
+ def register_result(result)
117
+ if result.duration
118
+ # We have a potential speed record for a known distance.
119
+ unless PersonalRecords::SpeedRecordDistances[@sport].
120
+ include?(result.distance)
121
+ Log.fatal "Unknown record distance #{result.distance}"
122
+ end
123
+
124
+ old_record = @speed_records[result.distance]
125
+ if old_record.nil? || old_record.duration > result.duration
126
+ @speed_records[result.distance] = result
127
+ Log.info "New #{@year ? @year.to_s : 'all-time'} " +
128
+ "#{result.sport} speed record for " +
129
+ "#{PersonalRecords::SpeedRecordDistances[@sport][
130
+ result.distance]}: " +
131
+ "#{secsToHMS(result.duration)}"
132
+ return true
64
133
  end
65
134
  else
66
- if distance > record.distance
67
- # This is a new distance record.
68
- record.distance = distance
69
- record.duration = 0
70
- record.start_time = start_time
71
- record.fit_file = fit_file
72
- Log.info "New distance record #{distance} m"
135
+ # We have a potential distance record.
136
+ if @distance.nil? || result.distance > @distance.distance
137
+ @distance = result
138
+ Log.info "New #{@year ? @year.to_s : 'all-time'} " +
139
+ "#{result.sport} distance record: #{result.distance} m"
73
140
  return true
74
- else
75
- # No new distance record.
76
- return false
77
141
  end
78
142
  end
143
+
144
+ false
145
+ end
146
+
147
+ def delete_activity(activity)
148
+ if @distance && @distance.activity == activity
149
+ @distance = nil
150
+ end
151
+ PersonalRecords::SpeedRecordDistances[@sport].each_key do |dist|
152
+ if @speed_records[dist] && @speed_records[dist].activity == activity
153
+ @speed_records[dist] = nil
154
+ end
155
+ end
156
+ end
157
+
158
+ # Return true if no Record is stored in this RecordSet object.
159
+ def empty?
160
+ return false if @distance
161
+ @speed_records.each_value { |r| return false if r }
162
+
163
+ true
164
+ end
165
+
166
+ # Iterator for all Record objects that are stored in this data structure.
167
+ def each(&block)
168
+ yield(@distance) if @distance
169
+ @speed_records.each_value do |record|
170
+ yield(record) if record
171
+ end
172
+ end
173
+
174
+ def to_s
175
+ return '' if empty?
176
+
177
+ generate_table.to_s + "\n"
178
+ end
179
+
180
+ def to_html(doc)
181
+ generate_table.to_html(doc)
182
+ end
183
+
184
+ private
185
+
186
+ def generate_table
187
+ t = FlexiTable.new
188
+ t.head
189
+ t.row([ 'Record', 'Time/Dist.', 'Avg. Pace', 'Ref.', 'Activity',
190
+ 'Date' ],
191
+ { :halign => :center })
192
+ t.set_column_attributes([
193
+ {},
194
+ { :halign => :right },
195
+ { :halign => :right },
196
+ { :halign => :right },
197
+ { :halign => :left },
198
+ { :halign => :left }
199
+ ])
200
+ t.body
201
+
202
+ records = @speed_records.values.delete_if { |r| r.nil? }
203
+ records << @distance if @distance
204
+
205
+ records.sort { |r1, r2| r1.distance <=> r2.distance }.each do |r|
206
+ r.to_table_row(t)
207
+ end
208
+
209
+ t
210
+ end
211
+
212
+
213
+ end
214
+
215
+ class SportRecords
216
+
217
+ attr_reader :sport, :all_time, :yearly
218
+
219
+ def initialize(sport)
220
+ @sport = sport
221
+ @all_time = RecordSet.new(@sport, nil)
222
+ @yearly = {}
223
+ end
224
+
225
+ def register_result(result)
226
+ year = result.start_time.year
227
+ unless @yearly[year]
228
+ @yearly[year] = RecordSet.new(@sport, year)
229
+ end
230
+
231
+ new_at = @all_time.register_result(result)
232
+ new_yr = @yearly[year].register_result(result)
233
+
234
+ new_at || new_yr
235
+ end
236
+
237
+ def delete_activity(activity)
238
+ ([ @all_time ] + @yearly.values).each do |r|
239
+ r.delete_activity(activity)
240
+ end
241
+ end
242
+
243
+ # Return true if no record is stored in this SportRecords object.
244
+ def empty?
245
+ return false unless @all_time.empty?
246
+ @yearly.each_value { |r| return false unless r.empty? }
247
+
248
+ true
249
+ end
250
+
251
+ # Iterator for all Record objects that are stored in this data structure.
252
+ def each(&block)
253
+ records = @yearly.values
254
+ records << @all_time if @all_time
255
+ records.each { |r| r.each(&block) }
256
+ end
257
+
258
+ def to_s
259
+ return '' if empty?
260
+
261
+ str = "All-time records:\n\n#{@all_time.to_s}" unless @all_time.empty?
262
+ @yearly.values.sort{ |r1, r2| r2.year <=> r1.year }.each do |record|
263
+ unless record.empty?
264
+ str += "Records of #{record.year}:\n\n#{record.to_s}"
265
+ end
266
+ end
267
+
268
+ str
269
+ end
270
+
271
+ def to_html(doc)
272
+ return nil if empty?
273
+
274
+ doc.div {
275
+ doc.h3('All-time records')
276
+ @all_time.to_html(doc)
277
+ @yearly.values.sort{ |r1, r2| r2.year <=> r1.year }.each do |record|
278
+ puts record.year
279
+ unless record.empty?
280
+ doc.h3("Records of #{record.year}")
281
+ record.to_html(doc)
282
+ end
283
+ end
284
+ }
79
285
  end
80
286
 
81
- # We have not found a record.
82
- @records << Record.new(distance, duration, start_time, fit_file)
83
- if duration == 0
84
- Log.info "New distance record #{distance} m"
85
- else
86
- Log.info "New record for #{distance}m in #{secsToHMS(duration)}"
287
+ end
288
+
289
+ def initialize(activities)
290
+ @activities = activities
291
+ @db_dir = activities.db_dir
292
+ @records_file = File.join(@db_dir, 'records.yml')
293
+ delete_all_records
294
+
295
+ load_records
296
+ end
297
+
298
+ def register_result(activity, sport, distance, duration, start_time)
299
+ unless @sport_records.include?(sport)
300
+ Log.info "Ignoring records for activity type '#{sport}' in " +
301
+ "#{activity.fit_file}"
302
+ return false
87
303
  end
88
304
 
89
- true
305
+ result = Record.new(activity, sport, distance, duration, start_time)
306
+ @sport_records[sport].register_result(result)
307
+ end
308
+
309
+ def delete_all_records
310
+ @sport_records = {}
311
+ SpeedRecordDistances.keys.each do |sport|
312
+ @sport_records[sport] = SportRecords.new(sport)
313
+ end
90
314
  end
91
315
 
92
- def delete_activity(fit_file)
93
- @records.delete_if { |r| r.fit_file == fit_file }
316
+ def delete_activity(activity)
317
+ @sport_records.each_value { |r| r.delete_activity(activity) }
94
318
  end
95
319
 
96
320
  def sync
97
321
  save_records
322
+
323
+ non_empty_records = @sport_records.select { |s, r| !r.empty? }
324
+ max = non_empty_records.length
325
+ i = 0
326
+ non_empty_records.each do |sport, record|
327
+ output_file = File.join(@activities.cfg[:html_dir],
328
+ "records-#{i}.html")
329
+ RecordListPageView.new(@activities, record, max, i).
330
+ write(output_file)
331
+ end
98
332
  end
99
333
 
100
334
  def to_s
101
- record_names = { 1000.0 => '1 km', 1609.0 => '1 mi', 5000.0 => '5 km',
102
- 21097.5 => '1/2 Marathon', 42195.0 => 'Marathon' }
103
- t = FlexiTable.new
104
- t.head
105
- t.row([ 'Record', 'Time/Dist.', 'Avg. Pace', 'Ref.', 'Activity', 'Date' ],
106
- { :halign => :center })
107
- t.set_column_attributes([
108
- {},
109
- { :halign => :right },
110
- { :halign => :right },
111
- { :halign => :right },
112
- { :halign => :right },
113
- { :halign => :left }
114
- ])
115
- t.body
116
- @records.sort { |r1, r2| r1.distance <=> r2.distance }.each do |r|
117
- activity = @activities.activity_by_fit_file(r.fit_file)
118
- t.row((r.duration == 0 ?
119
- [ 'Longest Run', '%.1f m' % r.distance, '-' ] :
120
- [ record_names[r.distance], secsToHMS(r.duration),
121
- speedToPace(r.distance / r.duration) ]) +
122
- [ @activities.ref_by_fit_file(r.fit_file),
123
- activity.name, r.start_time.strftime("%Y-%m-%d") ])
124
- end
125
- t.to_s
335
+ str = ''
336
+ @sport_records.each do |sport, record|
337
+ next if record.empty?
338
+ str += "Records for activity type #{sport}:\n\n#{record.to_s}"
339
+ end
340
+
341
+ str
342
+ end
343
+
344
+ # Iterator for all Record objects that are stored in this data structure.
345
+ def each(&block)
346
+ @sport_records.each_value { |r| r.each(&block) }
347
+ end
348
+
349
+ # Return an Array of all the records associated with the given Activity.
350
+ def activity_records(activity)
351
+ records = []
352
+ each do |record|
353
+ # puts record.activity
354
+ if record.activity.equal?(activity) && !records.include?(record)
355
+ records << record
356
+ end
357
+ end
358
+
359
+ records
126
360
  end
127
361
 
128
362
  private
@@ -130,25 +364,50 @@ module PostRunner
130
364
  def load_records
131
365
  begin
132
366
  if File.exists?(@records_file)
133
- @records = YAML.load_file(@records_file)
367
+ @sport_records = YAML.load_file(@records_file)
134
368
  else
135
369
  Log.info "No records file found at '#{@records_file}'"
136
370
  end
137
- rescue StandardError
371
+ rescue IOError
138
372
  Log.fatal "Cannot load records file '#{@records_file}': #{$!}"
139
373
  end
140
374
 
141
- unless @records.is_a?(Array)
375
+ unless @sport_records.is_a?(Hash)
142
376
  Log.fatal "The personal records file '#{@records_file}' is corrupted"
143
377
  end
378
+ fit_file_names_to_activity_refs
144
379
  end
145
380
 
146
381
  def save_records
382
+ activity_refs_to_fit_file_names
147
383
  begin
148
- File.open(@records_file, 'w') { |f| f.write(@records.to_yaml) }
149
- rescue StandardError
384
+ BackedUpFile.open(@records_file, 'w') do |f|
385
+ f.write(@sport_records.to_yaml)
386
+ end
387
+ rescue IOError
150
388
  Log.fatal "Cannot write records file '#{@records_file}': #{$!}"
151
389
  end
390
+ fit_file_names_to_activity_refs
391
+ end
392
+
393
+ # Convert FIT file names in all Record objects into Activity references.
394
+ def fit_file_names_to_activity_refs
395
+ each do |record|
396
+ # Record objects can be referenced multiple times.
397
+ if record.activity.is_a?(String)
398
+ record.activity = @activities.activity_by_fit_file(record.activity)
399
+ end
400
+ end
401
+ end
402
+
403
+ # Convert Activity references in all Record objects into FIT file names.
404
+ def activity_refs_to_fit_file_names
405
+ each do |record|
406
+ # Record objects can be referenced multiple times.
407
+ unless record.activity.is_a?(String)
408
+ record.activity = record.activity.fit_file
409
+ end
410
+ end
152
411
  end
153
412
 
154
413
  end