postrunner 0.0.8 → 0.0.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +18 -1
- data/lib/postrunner/ActivitiesDB.rb +9 -14
- data/lib/postrunner/Activity.rb +48 -3
- data/lib/postrunner/ActivityListView.rb +2 -2
- data/lib/postrunner/ActivitySummary.rb +13 -6
- data/lib/postrunner/ActivityView.rb +1 -1
- data/lib/postrunner/ChartView.rb +12 -5
- data/lib/postrunner/DeviceList.rb +16 -5
- data/lib/postrunner/EPO_Downloader.rb +153 -0
- data/lib/postrunner/Main.rb +62 -22
- data/lib/postrunner/PersonalRecords.rb +21 -9
- data/lib/postrunner/QueryResult.rb +36 -0
- data/lib/postrunner/RecordListPageView.rb +2 -1
- data/lib/postrunner/RuntimeConfig.rb +12 -0
- data/lib/postrunner/Schema.rb +62 -0
- data/lib/postrunner/version.rb +1 -1
- data/postrunner.gemspec +1 -1
- data/spec/ActivitySummary_spec.rb +6 -2
- metadata +7 -4
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 19aaff0f7f7586b68eb837bd1679ce7e31b9cfe7
|
4
|
+
data.tar.gz: a25b3ceb7a05e129edc6560d73ccb65a1ebe0e16
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: e0abfc8cef81ebf908bbcedb7443eb2481537c0f4156502aa65e6730c2b58607117a0c0d58886907b042c049e38ec8043bc3c632e4fc2b3e2a965409a81e7962
|
7
|
+
data.tar.gz: 773ba376b663e6b8aaddbbbb3e84e968b9b57d1d5c5cb0b39d5e809725268afdcf9a667b5647f1e325bdce399198f58d575d1884725c71bac4f27bc240a6e57f
|
data/README.md
CHANGED
@@ -2,7 +2,8 @@
|
|
2
2
|
|
3
3
|
PostRunner is an application to manage FIT files such as those
|
4
4
|
produced by Garmin products like the Forerunner 620 (FR620). It allows you to
|
5
|
-
import the files from the device and inspect them.
|
5
|
+
import the files from the device and inspect them. It can also update
|
6
|
+
satellite orbit prediction data on the device to speed-up fix times.
|
6
7
|
|
7
8
|
## Installation
|
8
9
|
|
@@ -80,6 +81,22 @@ This will provide you with a lot more information contained in the FIT
|
|
80
81
|
files that is not available through Garmin Connect or most other
|
81
82
|
tools.
|
82
83
|
|
84
|
+
When you upload your FIT data to the Garmin Connect site using WiFi or
|
85
|
+
Garmin Software, your device will be updated with 7 days worth of
|
86
|
+
Extended Prediction Orbit (EPO) data. The GPS receiver in your device
|
87
|
+
can use this data to acquire GPS locks much faster during the next 7
|
88
|
+
days. To fetch the current set of EPO data, just use the following
|
89
|
+
command while you have your device mounted via USB.
|
90
|
+
|
91
|
+
```
|
92
|
+
$ postrunner update-gps
|
93
|
+
```
|
94
|
+
|
95
|
+
This was tested on the FR620 and will probably also work on the FR220.
|
96
|
+
Other devices may work, but you use this at your own risk. This
|
97
|
+
feature will download a file called EPO.BIN and copy it to
|
98
|
+
GARMIN/REMOTESW/EPO.BIN.
|
99
|
+
|
83
100
|
### Viewing FIT file data in your web browser
|
84
101
|
|
85
102
|
You can also view the full details of your activity in your browser.
|
@@ -76,9 +76,9 @@ module PostRunner
|
|
76
76
|
# files. This method is idempotent and can be called even when directories
|
77
77
|
# exist already.
|
78
78
|
def create_directories
|
79
|
-
create_directory(@db_dir, 'data')
|
80
|
-
create_directory(@fit_dir, 'fit')
|
81
|
-
create_directory(@cfg[:html_dir], 'html')
|
79
|
+
@cfg.create_directory(@db_dir, 'data')
|
80
|
+
@cfg.create_directory(@fit_dir, 'fit')
|
81
|
+
@cfg.create_directory(@cfg[:html_dir], 'html')
|
82
82
|
|
83
83
|
@auxilliary_dirs.each do |dir|
|
84
84
|
create_auxdir(dir)
|
@@ -168,6 +168,12 @@ module PostRunner
|
|
168
168
|
|
169
169
|
def set(activity, attribute, value)
|
170
170
|
activity.set(attribute, value)
|
171
|
+
if %w( norecord type ).include?(attribute)
|
172
|
+
# If we have changed a norecord setting or an activity type, we need
|
173
|
+
# to regenerate all reports and re-collect the record list since we
|
174
|
+
# don't know which Activity needs to replace the changed one.
|
175
|
+
check
|
176
|
+
end
|
171
177
|
sync
|
172
178
|
end
|
173
179
|
|
@@ -344,17 +350,6 @@ module PostRunner
|
|
344
350
|
ActivityListView.new(self).update_index_pages
|
345
351
|
end
|
346
352
|
|
347
|
-
def create_directory(dir, name)
|
348
|
-
return if Dir.exists?(dir)
|
349
|
-
|
350
|
-
Log.info "Creating #{name} directory #{dir}"
|
351
|
-
begin
|
352
|
-
Dir.mkdir(dir)
|
353
|
-
rescue StandardError
|
354
|
-
Log.fatal "Cannot create #{name} directory #{dir}: #{$!}"
|
355
|
-
end
|
356
|
-
end
|
357
|
-
|
358
353
|
def create_auxdir(dir)
|
359
354
|
# This file should be in lib/postrunner. The 'misc' directory should be
|
360
355
|
# found in '../../misc'.
|
data/lib/postrunner/Activity.rb
CHANGED
@@ -14,6 +14,8 @@ require 'fit4ruby'
|
|
14
14
|
|
15
15
|
require 'postrunner/ActivitySummary'
|
16
16
|
require 'postrunner/ActivityView'
|
17
|
+
require 'postrunner/Schema'
|
18
|
+
require 'postrunner/QueryResult'
|
17
19
|
|
18
20
|
module PostRunner
|
19
21
|
|
@@ -26,7 +28,20 @@ module PostRunner
|
|
26
28
|
@@CachedActivityValues = %w( sport sub_sport timestamp total_distance
|
27
29
|
total_timer_time avg_speed )
|
28
30
|
# We also store some additional information in the archive index.
|
29
|
-
@@CachedAttributes = @@CachedActivityValues + %w( fit_file name )
|
31
|
+
@@CachedAttributes = @@CachedActivityValues + %w( fit_file name norecord )
|
32
|
+
|
33
|
+
@@Schemata = {
|
34
|
+
'long_date' => Schema.new('long_date', 'Date',
|
35
|
+
{ :func => 'timestamp',
|
36
|
+
:column_alignment => :left,
|
37
|
+
:format => 'date_with_weekday' }),
|
38
|
+
'sub_type' => Schema.new('sub_type', 'Subtype',
|
39
|
+
{ :func => 'activity_sub_type',
|
40
|
+
:column_alignment => :left }),
|
41
|
+
'type' => Schema.new('type', 'Type',
|
42
|
+
{ :func => 'activity_type',
|
43
|
+
:column_alignment => :left })
|
44
|
+
}
|
30
45
|
|
31
46
|
ActivityTypes = {
|
32
47
|
'generic' => 'Generic',
|
@@ -138,6 +153,8 @@ module PostRunner
|
|
138
153
|
else
|
139
154
|
if @@CachedActivityValues.include?(name_without_at)
|
140
155
|
@unset_variables << name_without_at
|
156
|
+
elsif name_without_at == 'norecord'
|
157
|
+
@norecord = false
|
141
158
|
else
|
142
159
|
Log.fatal "Don't know how to initialize the instance variable " +
|
143
160
|
"#{name_without_at}."
|
@@ -159,6 +176,25 @@ module PostRunner
|
|
159
176
|
end
|
160
177
|
end
|
161
178
|
|
179
|
+
def query(key)
|
180
|
+
unless @@Schemata.include?(key)
|
181
|
+
raise ArgumentError, "Unknown key '#{key}' requested in query"
|
182
|
+
end
|
183
|
+
|
184
|
+
schema = @@Schemata[key]
|
185
|
+
|
186
|
+
if schema.func
|
187
|
+
value = send(schema.func)
|
188
|
+
else
|
189
|
+
unless instance_variable_defined?(key)
|
190
|
+
raise ArgumentError, "Don't know how to query '#{key}'"
|
191
|
+
end
|
192
|
+
value = instance_variable_get(key)
|
193
|
+
end
|
194
|
+
|
195
|
+
QueryResult.new(value, schema)
|
196
|
+
end
|
197
|
+
|
162
198
|
def show
|
163
199
|
generate_html_view #unless File.exists?(@html_file)
|
164
200
|
|
@@ -167,7 +203,7 @@ module PostRunner
|
|
167
203
|
|
168
204
|
def summary
|
169
205
|
@fit_activity = load_fit_file unless @fit_activity
|
170
|
-
puts ActivitySummary.new(
|
206
|
+
puts ActivitySummary.new(self, @db.cfg[:unit_system],
|
171
207
|
{ :name => @name,
|
172
208
|
:type => activity_type,
|
173
209
|
:sub_type => activity_sub_type }).to_s
|
@@ -199,6 +235,11 @@ module PostRunner
|
|
199
235
|
ActivitySubTypes.values.join(', ')
|
200
236
|
end
|
201
237
|
@sub_sport = ActivitySubTypes.invert[value]
|
238
|
+
when 'norecord'
|
239
|
+
unless %w( true false).include?(value)
|
240
|
+
Log.fatal "norecord must either be 'true' or 'false'"
|
241
|
+
end
|
242
|
+
@norecord = value == 'true'
|
202
243
|
else
|
203
244
|
Log.fatal "Unknown activity attribute '#{attribute}'. Must be one of " +
|
204
245
|
'name, type or subtype'
|
@@ -207,6 +248,10 @@ module PostRunner
|
|
207
248
|
end
|
208
249
|
|
209
250
|
def register_records
|
251
|
+
# If we have the @norecord flag set, we ignore this Activity for the
|
252
|
+
# record collection.
|
253
|
+
return if @norecord
|
254
|
+
|
210
255
|
distance_record = 0.0
|
211
256
|
distance_record_sport = nil
|
212
257
|
# Array with popular distances (in meters) in ascending order.
|
@@ -295,7 +340,7 @@ module PostRunner
|
|
295
340
|
|
296
341
|
# Store the found records
|
297
342
|
start_time = @fit_activity.sessions[0].timestamp
|
298
|
-
if
|
343
|
+
if distance_record_sport
|
299
344
|
@db.records.register_result(self, distance_record_sport,
|
300
345
|
distance_record, nil, start_time)
|
301
346
|
end
|
@@ -92,8 +92,8 @@ module PostRunner
|
|
92
92
|
t.row([
|
93
93
|
i += 1,
|
94
94
|
ActivityLink.new(a, true),
|
95
|
-
a.
|
96
|
-
a.
|
95
|
+
a.query('type'),
|
96
|
+
a.query('long_date'),
|
97
97
|
local_value(a.total_distance, 'm', '%.2f',
|
98
98
|
{ :metric => 'km', :statute => 'mi' }),
|
99
99
|
secsToHMS(a.total_timer_time),
|
@@ -21,8 +21,9 @@ module PostRunner
|
|
21
21
|
|
22
22
|
include Fit4Ruby::Converters
|
23
23
|
|
24
|
-
def initialize(
|
25
|
-
@
|
24
|
+
def initialize(activity, unit_system, custom_fields)
|
25
|
+
@activity = activity
|
26
|
+
@fit_activity = activity.fit_activity
|
26
27
|
@name = custom_fields[:name]
|
27
28
|
@type = custom_fields[:type]
|
28
29
|
@sub_type = custom_fields[:sub_type]
|
@@ -54,9 +55,10 @@ module PostRunner
|
|
54
55
|
local_value(session, 'total_distance', '%.2f %s',
|
55
56
|
{ :metric => 'km', :statute => 'mi'}) ])
|
56
57
|
t.row([ 'Time:', secsToHMS(session.total_timer_time) ])
|
57
|
-
if
|
58
|
+
if @activity.sport == 'running' || @activity.sport == 'multisport'
|
58
59
|
t.row([ 'Avg. Pace:', pace(session, 'avg_speed') ])
|
59
|
-
|
60
|
+
end
|
61
|
+
if @activity.sport != 'running'
|
60
62
|
t.row([ 'Avg. Speed:',
|
61
63
|
local_value(session, 'avg_speed', '%.1f %s',
|
62
64
|
{ :metric => 'km/h', :statute => 'mph' }) ])
|
@@ -86,6 +88,11 @@ module PostRunner
|
|
86
88
|
t.row([ 'Avg. Stride Length:',
|
87
89
|
local_value(session, 'avg_stride_length', '%.2f %s',
|
88
90
|
{ :metric => 'm', :statute => 'ft' }) ])
|
91
|
+
rec_hr = @fit_activity.recovery_hr
|
92
|
+
end_hr = @fit_activity.ending_hr
|
93
|
+
t.row([ 'Recovery HR:',
|
94
|
+
rec_hr && end_hr ?
|
95
|
+
"#{rec_hr} bpm [#{end_hr - rec_hr} bpm]" : '-' ])
|
89
96
|
rec_time = @fit_activity.recovery_time
|
90
97
|
t.row([ 'Recovery Time:', rec_time ? secsToHMS(rec_time * 60) : '-' ])
|
91
98
|
vo2max = @fit_activity.vo2max
|
@@ -100,7 +107,7 @@ module PostRunner
|
|
100
107
|
t = FlexiTable.new
|
101
108
|
t.head
|
102
109
|
t.row([ 'Lap', 'Duration', 'Distance',
|
103
|
-
|
110
|
+
@activity.sport == 'running' ? 'Avg. Pace' : 'Avg. Speed',
|
104
111
|
'Stride', 'Cadence', 'Avg. HR', 'Max. HR' ])
|
105
112
|
t.set_column_attributes(Array.new(8, { :halign => :right }))
|
106
113
|
t.body
|
@@ -109,7 +116,7 @@ module PostRunner
|
|
109
116
|
t.cell(secsToHMS(lap.total_timer_time))
|
110
117
|
t.cell(local_value(lap, 'total_distance', '%.2f',
|
111
118
|
{ :metric => 'km', :statute => 'mi' }))
|
112
|
-
if
|
119
|
+
if @activity.sport == 'running'
|
113
120
|
t.cell(pace(lap, 'avg_speed', false))
|
114
121
|
else
|
115
122
|
t.cell(local_value(lap, 'avg_speed', '%.1f',
|
@@ -61,7 +61,7 @@ module PostRunner
|
|
61
61
|
# The main area with the 2 column layout.
|
62
62
|
doc.div({ :class => 'main' }) {
|
63
63
|
doc.div({ :class => 'left_col' }) {
|
64
|
-
ActivitySummary.new(@activity
|
64
|
+
ActivitySummary.new(@activity, @unit_system,
|
65
65
|
{ :name => @activity.name,
|
66
66
|
:type => @activity.activity_type,
|
67
67
|
:sub_type => @activity.activity_sub_type
|
data/lib/postrunner/ChartView.rb
CHANGED
@@ -15,7 +15,7 @@ module PostRunner
|
|
15
15
|
|
16
16
|
def initialize(activity, unit_system)
|
17
17
|
@activity = activity
|
18
|
-
@sport = activity.
|
18
|
+
@sport = activity.sport
|
19
19
|
@unit_system = unit_system
|
20
20
|
@empty_charts = {}
|
21
21
|
end
|
@@ -33,9 +33,10 @@ module PostRunner
|
|
33
33
|
}
|
34
34
|
|
35
35
|
doc.script(java_script)
|
36
|
-
if @sport == 'running'
|
36
|
+
if @sport == 'running' || @sport == 'multisport'
|
37
37
|
chart_div(doc, 'pace', "Pace (#{select_unit('min/km')})")
|
38
|
-
|
38
|
+
end
|
39
|
+
if @sport != 'running'
|
39
40
|
chart_div(doc, 'speed', "Speed (#{select_unit('km/h')})")
|
40
41
|
end
|
41
42
|
chart_div(doc, 'altitude', "Elevation (#{select_unit('m')})")
|
@@ -44,6 +45,7 @@ module PostRunner
|
|
44
45
|
chart_div(doc, 'vertical_oscillation',
|
45
46
|
"Vertical Oscillation (#{select_unit('cm')})")
|
46
47
|
chart_div(doc, 'stance_time', 'Ground Contact Time (ms)')
|
48
|
+
chart_div(doc, 'temperature', 'Temperature (°C)')
|
47
49
|
end
|
48
50
|
|
49
51
|
private
|
@@ -94,9 +96,10 @@ EOT
|
|
94
96
|
s = "$(function() {\n"
|
95
97
|
|
96
98
|
s << tooltip_div
|
97
|
-
if @sport == 'running'
|
99
|
+
if @sport == 'running' || @sport == 'multisport'
|
98
100
|
s << line_graph('pace', 'Pace', 'min/km', '#0A7BEE' )
|
99
|
-
|
101
|
+
end
|
102
|
+
if @sport != 'running'
|
100
103
|
s << line_graph('speed', 'Speed', 'km/h', '#0A7BEE' )
|
101
104
|
end
|
102
105
|
s << line_graph('altitude', 'Elevation', 'm', '#5AAA44')
|
@@ -119,6 +122,7 @@ EOT
|
|
119
122
|
[ '#A0D488', 273 ],
|
120
123
|
[ '#F79666', 305 ],
|
121
124
|
[ '#EE3F2D', nil ] ])
|
125
|
+
s << line_graph('temperature', 'Temperature', 'C', '#444444')
|
122
126
|
|
123
127
|
s << "\n});\n"
|
124
128
|
|
@@ -161,6 +165,9 @@ EOT
|
|
161
165
|
min_value = nil
|
162
166
|
@activity.fit_activity.records.each do |r|
|
163
167
|
value = r.get_as(field, select_unit(unit))
|
168
|
+
|
169
|
+
next unless value
|
170
|
+
|
164
171
|
if field == 'pace'
|
165
172
|
# Slow speeds lead to very large pace values that make the graph
|
166
173
|
# hard to read. We cap the pace at 20.0 min/km to keep it readable.
|
@@ -18,6 +18,8 @@ module PostRunner
|
|
18
18
|
|
19
19
|
class DeviceList
|
20
20
|
|
21
|
+
include Fit4Ruby::Converters
|
22
|
+
|
21
23
|
def initialize(fit_activity)
|
22
24
|
@fit_activity = fit_activity
|
23
25
|
end
|
@@ -46,15 +48,19 @@ module PostRunner
|
|
46
48
|
t.body
|
47
49
|
|
48
50
|
t.cell('Manufacturer:', { :width => '40%' })
|
49
|
-
t.cell(device.manufacturer, { :width => '60%' })
|
51
|
+
t.cell(device.manufacturer.upcase, { :width => '60%' })
|
50
52
|
t.new_row
|
51
53
|
|
52
|
-
if (product =
|
54
|
+
if (product = %w( garmin dynastream dynastream_oem ).include?(
|
55
|
+
device.manufacturer) ?
|
56
|
+
device.garmin_product : device.product)
|
57
|
+
# For unknown products the numerical ID will be returned.
|
58
|
+
product = product.to_s unless product.is_a?(String)
|
53
59
|
t.cell('Product:')
|
54
|
-
|
55
|
-
|
60
|
+
# Beautify some product names. The others will just be upcased.
|
61
|
+
rename = { 'hrm_run_single_byte_product_id' => 'HRM Run',
|
56
62
|
'hrm_run' => 'HRM Run' }
|
57
|
-
product = rename[product]
|
63
|
+
product = rename.include?(product) ? rename[product] : product.upcase
|
58
64
|
t.cell(product)
|
59
65
|
t.new_row
|
60
66
|
end
|
@@ -87,6 +93,11 @@ module PostRunner
|
|
87
93
|
t.cell(device.battery_status)
|
88
94
|
t.new_row
|
89
95
|
end
|
96
|
+
if device.cum_operating_time
|
97
|
+
t.cell('Cumulated Operating Time:')
|
98
|
+
t.cell(secsToDHMS(device.cum_operating_time))
|
99
|
+
t.new_row
|
100
|
+
end
|
90
101
|
|
91
102
|
seen_indexes << device.device_index
|
92
103
|
end
|
@@ -0,0 +1,153 @@
|
|
1
|
+
#!/usr/bin/env ruby -w
|
2
|
+
# encoding: UTF-8
|
3
|
+
#
|
4
|
+
# = EPO_Downloader.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 'uri'
|
14
|
+
require 'net/http'
|
15
|
+
|
16
|
+
module PostRunner
|
17
|
+
|
18
|
+
# This class can download the current set of Extended Prediction Orbit data
|
19
|
+
# for GPS satellites and store them in the EPO.BIN file format. Some Garmin
|
20
|
+
# devices pick up this file under GARMIN/GARMIN/REMOTESW/EPO.BIN.
|
21
|
+
class EPO_Downloader
|
22
|
+
|
23
|
+
@@URI = URI('http://omt.garmin.com/Rce/ProtobufApi/EphemerisService/GetEphemerisData')
|
24
|
+
# This is the payload of the POST request. It was taken from
|
25
|
+
# http://www.kluenter.de/garmin-ephemeris-files-and-linux/. It may contain
|
26
|
+
# a product ID or serial number.
|
27
|
+
@@POST_DATA = "\n-\n\aexpress\u0012\u0005de_DE\u001A\aWindows\"\u0012601 Service Pack 1\u0012\n\b\x8C\xB4\x93\xB8\u000E\u0012\u0000\u0018\u0000\u0018\u001C\"\u0000"
|
28
|
+
@@HEADER = {
|
29
|
+
'Garmin-Client-Name' => 'CoreService',
|
30
|
+
'Content-Type' => 'application/octet-stream',
|
31
|
+
'Content-Length' => '63'
|
32
|
+
}
|
33
|
+
|
34
|
+
# Create an EPO_Downloader object.
|
35
|
+
def initialize
|
36
|
+
@http = Net::HTTP.new(@@URI.host, @@URI.port)
|
37
|
+
@request = Net::HTTP::Post.new(@@URI.path, initheader = @@HEADER)
|
38
|
+
@request.body = @@POST_DATA
|
39
|
+
end
|
40
|
+
|
41
|
+
# Download the current EPO data from the Garmin server and write it into
|
42
|
+
# the specified output file.
|
43
|
+
# @param output_file [String] The name of the output file. Usually this is
|
44
|
+
# 'EPO.BIN'.
|
45
|
+
def download(output_file)
|
46
|
+
return false unless (epo = get_epo_from_server)
|
47
|
+
return false unless (epo = fix(epo))
|
48
|
+
return false unless check_epo_data(epo)
|
49
|
+
write_file(output_file, epo)
|
50
|
+
Log.info "Extended Prediction Orbit (EPO) data has been downloaded " +
|
51
|
+
"from Garmin site."
|
52
|
+
|
53
|
+
true
|
54
|
+
end
|
55
|
+
|
56
|
+
private
|
57
|
+
|
58
|
+
def get_epo_from_server
|
59
|
+
begin
|
60
|
+
res = @http.request(@request)
|
61
|
+
rescue => e
|
62
|
+
Log.error "Extended Prediction Orbit (EPO) data download error: " +
|
63
|
+
e.message
|
64
|
+
return nil
|
65
|
+
end
|
66
|
+
if res.code.to_i != 200
|
67
|
+
Log.error "Extended Prediction Orbit (EPO) data download failed: #{res}"
|
68
|
+
return nil
|
69
|
+
end
|
70
|
+
res.body
|
71
|
+
end
|
72
|
+
|
73
|
+
# The downloaded data contains Extended Prediction Orbit data for 6 hour
|
74
|
+
# windows for 7 days. Each EPO set is 2307 bytes long, but the first 3
|
75
|
+
# bytes must be removed for the FR620 to understand it.
|
76
|
+
# https://forums.garmin.com/showthread.php?79555-when-will-garmin-express-mac-be-able-to-sync-GPS-EPO-bin-file-on-fenix-2&p=277398#post277398
|
77
|
+
# The 2304 bytes consist of 32 sets of 72 byte GPS satellite data.
|
78
|
+
# http://www.vis-plus.ee/pdf/SIM28_SIM68R_SIM68V_EPO-II_Protocol_V1.00.pdf
|
79
|
+
def fix(epo)
|
80
|
+
unless epo.length == 28 * 2307
|
81
|
+
Log.error "GPS data has unexpected length of #{epo.length} bytes"
|
82
|
+
return nil
|
83
|
+
end
|
84
|
+
|
85
|
+
epo_fixed = ''
|
86
|
+
0.upto(27) do |i|
|
87
|
+
offset = i * 2307
|
88
|
+
# The fill bytes always seem to be 0. Let's issue a warning in case
|
89
|
+
# this ever changes.
|
90
|
+
unless epo[offset].to_i == 0 &&
|
91
|
+
epo[offset + 1].to_i == 0 &&
|
92
|
+
epo[offset + 2].to_i == 0
|
93
|
+
Log.warning "EPO fill bytes are not 0 bytes"
|
94
|
+
end
|
95
|
+
epo_fixed += epo[offset + 3, 2304]
|
96
|
+
end
|
97
|
+
|
98
|
+
epo_fixed
|
99
|
+
end
|
100
|
+
|
101
|
+
def check_epo_data(epo)
|
102
|
+
# Convert EPO string into Array of bytes.
|
103
|
+
epo = epo.bytes.to_a
|
104
|
+
unless epo.length == 28 * 72 * 32
|
105
|
+
Log.error "EPO file has wrong length (#{epo.length})"
|
106
|
+
return false
|
107
|
+
end
|
108
|
+
date_1980_01_06 = Time.parse("1980-01-06T00:00:00+00:00")
|
109
|
+
now = Time.now
|
110
|
+
end_date = start_date = nil
|
111
|
+
# Split the EPO data into Arrays of 32 * 72 bytes.
|
112
|
+
epo.each_slice(32 * 72).to_a.each do |epo_set|
|
113
|
+
# For each of the 32 satellites we have 72 bytes of data.
|
114
|
+
epo_set.each_slice(72).to_a.each do |sat|
|
115
|
+
# The last byte is an XOR checksum of the first 71 bytes.
|
116
|
+
xor = 0
|
117
|
+
0.upto(70) { |i| xor ^= sat[i] }
|
118
|
+
unless xor == sat[71]
|
119
|
+
Log.error "Checksum error in EPO file"
|
120
|
+
return false
|
121
|
+
end
|
122
|
+
# The first 3 bytes of every satellite record look like a timestamp.
|
123
|
+
# I assume they are hours after January 6th, 1980 UTC. They probably
|
124
|
+
# indicate the start of the 6 hour window that the data is for.
|
125
|
+
hours_after_1980_01_06 = sat[0] | (sat[1] << 8) | (sat[2] << 16)
|
126
|
+
date = date_1980_01_06 + hours_after_1980_01_06 * 60 * 60
|
127
|
+
if date > now + 8 * 24 * 60 * 60
|
128
|
+
Log.warn "EPO timestamp (#{date}) is too far in the future"
|
129
|
+
elsif date < now - 24 * 60 * 60
|
130
|
+
Log.warn "EPO timestamp (#{date}) is too old"
|
131
|
+
end
|
132
|
+
start_date = date if start_date.nil? || date < start_date
|
133
|
+
end_date = date if end_date.nil? || date > end_date
|
134
|
+
end
|
135
|
+
end
|
136
|
+
Log.info "EPO data is valid from #{start_date} to " +
|
137
|
+
"#{end_date + 6 * 60 * 60}."
|
138
|
+
|
139
|
+
true
|
140
|
+
end
|
141
|
+
|
142
|
+
def write_file(output_file, data)
|
143
|
+
begin
|
144
|
+
File.write(output_file, data)
|
145
|
+
rescue IOError
|
146
|
+
Log.fatal "Cannot write EPO file '#{output_file}': #{$!}"
|
147
|
+
end
|
148
|
+
end
|
149
|
+
|
150
|
+
end
|
151
|
+
|
152
|
+
end
|
153
|
+
|
data/lib/postrunner/Main.rb
CHANGED
@@ -17,6 +17,7 @@ require 'fit4ruby'
|
|
17
17
|
require 'postrunner/version'
|
18
18
|
require 'postrunner/RuntimeConfig'
|
19
19
|
require 'postrunner/ActivitiesDB'
|
20
|
+
require 'postrunner/EPO_Downloader'
|
20
21
|
|
21
22
|
module PostRunner
|
22
23
|
|
@@ -115,51 +116,56 @@ EOT
|
|
115
116
|
Commands:
|
116
117
|
|
117
118
|
check [ <fit file> | <ref> ... ]
|
118
|
-
|
119
|
-
|
119
|
+
Check the provided FIT file(s) for structural errors. If no file or
|
120
|
+
reference is provided, the complete archive is checked.
|
120
121
|
|
121
122
|
dump <fit file> | <ref>
|
122
|
-
|
123
|
+
Dump the content of the FIT file.
|
123
124
|
|
124
125
|
import [ <fit file> | <directory> ]
|
125
|
-
|
126
|
-
|
127
|
-
|
126
|
+
Import the provided FIT file(s) into the postrunner database. If no
|
127
|
+
file or directory is provided, the directory that was used for the
|
128
|
+
previous import is being used.
|
128
129
|
|
129
130
|
delete <ref>
|
130
|
-
|
131
|
+
Delete the activity from the archive.
|
131
132
|
|
132
133
|
list
|
133
|
-
|
134
|
+
List all FIT files stored in the data base.
|
134
135
|
|
135
136
|
records
|
136
|
-
|
137
|
+
List all personal records.
|
137
138
|
|
138
139
|
rename <new name> <ref>
|
139
|
-
|
140
|
-
|
141
|
-
|
140
|
+
For the specified activities replace current activity name with a
|
141
|
+
new name that describes the activity. By default the activity name
|
142
|
+
matches the FIT file name.
|
142
143
|
|
143
144
|
set <attribute> <value> <ref>
|
144
|
-
|
145
|
-
|
145
|
+
For the specified activies set the attribute to the given value. The
|
146
|
+
following attributes are supported:
|
146
147
|
|
147
|
-
|
148
|
-
|
149
|
-
|
148
|
+
name: The activity name (defaults to FIT file name)
|
149
|
+
norecord: Ignore all records from this activity (value must true
|
150
|
+
or false)
|
151
|
+
type: The type of the activity
|
152
|
+
subtype: The subtype of the activity
|
150
153
|
|
151
154
|
show [ <ref> ]
|
152
|
-
|
153
|
-
|
155
|
+
Show the referenced FIT activity in a web browser. If no reference
|
156
|
+
is provided show the list of activities in the database.
|
154
157
|
|
155
158
|
summary <ref>
|
156
|
-
|
159
|
+
Display the summary information for the FIT file.
|
157
160
|
|
158
161
|
units <metric | statute>
|
159
|
-
|
162
|
+
Change the unit system.
|
160
163
|
|
161
164
|
htmldir <directory>
|
162
|
-
|
165
|
+
Change the output directory for the generated HTML files
|
166
|
+
|
167
|
+
update-gps Download the current set of GPS Extended Prediction Orbit (EPO)
|
168
|
+
data and store them on the device.
|
163
169
|
|
164
170
|
|
165
171
|
<fit file> An absolute or relative name of a .FIT file.
|
@@ -239,6 +245,8 @@ EOT
|
|
239
245
|
change_unit_system(args)
|
240
246
|
when 'htmldir'
|
241
247
|
change_html_dir(args)
|
248
|
+
when 'update-gps'
|
249
|
+
update_gps_data
|
242
250
|
when nil
|
243
251
|
Log.fatal("No command provided. " +
|
244
252
|
"See 'postrunner -h' for more information.")
|
@@ -352,6 +360,38 @@ EOT
|
|
352
360
|
end
|
353
361
|
end
|
354
362
|
|
363
|
+
def update_gps_data
|
364
|
+
epo_dir = File.join(@db_dir, 'epo')
|
365
|
+
@cfg.create_directory(epo_dir, 'GPS Data Cache')
|
366
|
+
epo_file = File.join(epo_dir, 'EPO.BIN')
|
367
|
+
|
368
|
+
if !File.exists?(epo_file) ||
|
369
|
+
(File.mtime(epo_file) < Time.now - (6 * 60 * 60))
|
370
|
+
# The EPO file only changes every 6 hours. No need to download it more
|
371
|
+
# frequently if it already exists.
|
372
|
+
if EPO_Downloader.new.download(epo_file)
|
373
|
+
unless (remotesw_dir = @cfg[:import_dir])
|
374
|
+
Log.error "No device directory set. Please import an activity " +
|
375
|
+
"from your device first."
|
376
|
+
return
|
377
|
+
end
|
378
|
+
remotesw_dir = File.join(remotesw_dir, '..', 'REMOTESW')
|
379
|
+
unless Dir.exists?(remotesw_dir)
|
380
|
+
Log.error "Cannot find '#{remotesw_dir}'. Please connect and " +
|
381
|
+
"mount your Garmin device."
|
382
|
+
return
|
383
|
+
end
|
384
|
+
begin
|
385
|
+
FileUtils.cp(epo_file, remotesw_dir)
|
386
|
+
rescue
|
387
|
+
Log.error "Cannot copy EPO.BIN file to your device at " +
|
388
|
+
"'#{remotesw_dir}'."
|
389
|
+
return
|
390
|
+
end
|
391
|
+
end
|
392
|
+
end
|
393
|
+
end
|
394
|
+
|
355
395
|
def handle_version_update
|
356
396
|
if @cfg.get_option(:version) != VERSION
|
357
397
|
Log.warn "PostRunner version upgrade detected."
|
@@ -20,12 +20,16 @@ require 'postrunner/ActivityLink'
|
|
20
20
|
|
21
21
|
module PostRunner
|
22
22
|
|
23
|
+
# The PersonalRecords class stores the various records. Records are grouped
|
24
|
+
# by specific year or all-time records.
|
23
25
|
class PersonalRecords
|
24
26
|
|
25
27
|
include Fit4Ruby::Converters
|
26
28
|
|
29
|
+
# List of popular distances for each sport.
|
27
30
|
SpeedRecordDistances = {
|
28
31
|
'cycling' => {
|
32
|
+
1000.0 => '1 km',
|
29
33
|
5000.0 => '5 km',
|
30
34
|
8000.0 => '8 km',
|
31
35
|
9000.0 => '9 km',
|
@@ -38,6 +42,9 @@ module PostRunner
|
|
38
42
|
18000.0 => '180 km',
|
39
43
|
},
|
40
44
|
'running' => {
|
45
|
+
400.0 => '400 m',
|
46
|
+
500.0 => '500 m',
|
47
|
+
800.0 => '800 m',
|
41
48
|
1000.0 => '1 km',
|
42
49
|
1609.0 => '1 mi',
|
43
50
|
2000.0 => '2 km',
|
@@ -61,6 +68,7 @@ module PostRunner
|
|
61
68
|
3860.0 => '2.4 mi'
|
62
69
|
},
|
63
70
|
'walking' => {
|
71
|
+
500.0 => '500 m',
|
64
72
|
1000.0 => '1 km',
|
65
73
|
1609.0 => '1 mi',
|
66
74
|
5000.0 => '5 km',
|
@@ -70,6 +78,8 @@ module PostRunner
|
|
70
78
|
}
|
71
79
|
}
|
72
80
|
|
81
|
+
# The Record class stores a single speed or longest distance record. It
|
82
|
+
# also stores a reference to the Activity that contains the record.
|
73
83
|
class Record
|
74
84
|
|
75
85
|
include Fit4Ruby::Converters
|
@@ -86,7 +96,7 @@ module PostRunner
|
|
86
96
|
|
87
97
|
def to_table_row(t)
|
88
98
|
t.row((@duration.nil? ?
|
89
|
-
[ 'Longest
|
99
|
+
[ 'Longest Distance', '%.3f km' % (@distance / 1000.0), '-' ] :
|
90
100
|
[ PersonalRecords::SpeedRecordDistances[@sport][@distance],
|
91
101
|
secsToHMS(@duration),
|
92
102
|
speedToPace(@distance / @duration) ]) +
|
@@ -106,7 +116,7 @@ module PostRunner
|
|
106
116
|
def initialize(sport, year)
|
107
117
|
@sport = sport
|
108
118
|
@year = year
|
109
|
-
@
|
119
|
+
@distance_record = nil
|
110
120
|
@speed_records = {}
|
111
121
|
PersonalRecords::SpeedRecordDistances[@sport].each_key do |dist|
|
112
122
|
@speed_records[dist] = nil
|
@@ -133,8 +143,9 @@ module PostRunner
|
|
133
143
|
end
|
134
144
|
else
|
135
145
|
# We have a potential distance record.
|
136
|
-
if @
|
137
|
-
|
146
|
+
if @distance_record.nil? ||
|
147
|
+
@distance_record.distance < result.distance
|
148
|
+
@distance_record = result
|
138
149
|
Log.info "New #{@year ? @year.to_s : 'all-time'} " +
|
139
150
|
"#{result.sport} distance record: #{result.distance} m"
|
140
151
|
return true
|
@@ -145,8 +156,8 @@ module PostRunner
|
|
145
156
|
end
|
146
157
|
|
147
158
|
def delete_activity(activity)
|
148
|
-
if @
|
149
|
-
@
|
159
|
+
if @distance_record && @distance_record.activity == activity
|
160
|
+
@distance_record = nil
|
150
161
|
end
|
151
162
|
PersonalRecords::SpeedRecordDistances[@sport].each_key do |dist|
|
152
163
|
if @speed_records[dist] && @speed_records[dist].activity == activity
|
@@ -157,7 +168,7 @@ module PostRunner
|
|
157
168
|
|
158
169
|
# Return true if no Record is stored in this RecordSet object.
|
159
170
|
def empty?
|
160
|
-
return false if @
|
171
|
+
return false if @distance_record
|
161
172
|
@speed_records.each_value { |r| return false if r }
|
162
173
|
|
163
174
|
true
|
@@ -165,7 +176,7 @@ module PostRunner
|
|
165
176
|
|
166
177
|
# Iterator for all Record objects that are stored in this data structure.
|
167
178
|
def each(&block)
|
168
|
-
yield(@
|
179
|
+
yield(@distance_record) if @distance_record
|
169
180
|
@speed_records.each_value do |record|
|
170
181
|
yield(record) if record
|
171
182
|
end
|
@@ -200,7 +211,7 @@ module PostRunner
|
|
200
211
|
t.body
|
201
212
|
|
202
213
|
records = @speed_records.values.delete_if { |r| r.nil? }
|
203
|
-
records << @
|
214
|
+
records << @distance_record if @distance_record
|
204
215
|
|
205
216
|
records.sort { |r1, r2| r1.distance <=> r2.distance }.each do |r|
|
206
217
|
r.to_table_row(t)
|
@@ -328,6 +339,7 @@ module PostRunner
|
|
328
339
|
"records-#{i}.html")
|
329
340
|
RecordListPageView.new(@activities, record, max, i).
|
330
341
|
write(output_file)
|
342
|
+
i += 1
|
331
343
|
end
|
332
344
|
end
|
333
345
|
|
@@ -0,0 +1,36 @@
|
|
1
|
+
#!/usr/bin/env ruby -w
|
2
|
+
# encoding: UTF-8
|
3
|
+
#
|
4
|
+
# = QueryResult.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
|
+
module PostRunner
|
14
|
+
|
15
|
+
# Queries provide an abstract interface to retrieve individual values from
|
16
|
+
# Activities, Laps and so on. The result of a query is returned as a
|
17
|
+
# QueryResult object.
|
18
|
+
class QueryResult
|
19
|
+
|
20
|
+
# Create a QueryResult object.
|
21
|
+
# @param value [any] Result of the query
|
22
|
+
# @param schema [Schema] A reference to the Schema of the queried
|
23
|
+
# attribute.
|
24
|
+
def initialize(value, schema)
|
25
|
+
@value = value
|
26
|
+
@schema = schema
|
27
|
+
end
|
28
|
+
|
29
|
+
# Conver the result into a text String.
|
30
|
+
def to_s
|
31
|
+
@schema.to_s(@value)
|
32
|
+
end
|
33
|
+
|
34
|
+
end
|
35
|
+
|
36
|
+
end
|
@@ -55,7 +55,8 @@ module PostRunner
|
|
55
55
|
ViewFrame.new("All-time #{@sport_name} Records",
|
56
56
|
frame_width, @records.all_time).to_html(@doc)
|
57
57
|
|
58
|
-
@records.yearly.
|
58
|
+
@records.yearly.sort{ |y1, y2| y2[0] <=> y1[0] }.
|
59
|
+
each do |year, record|
|
59
60
|
next if record.empty?
|
60
61
|
ViewFrame.new("#{year} #{@sport_name} Records",
|
61
62
|
frame_width, record).to_html(@doc)
|
@@ -56,6 +56,18 @@ module PostRunner
|
|
56
56
|
save_options
|
57
57
|
end
|
58
58
|
|
59
|
+
# Ensure that the requested directory exists.
|
60
|
+
def create_directory(dir, name)
|
61
|
+
return if Dir.exists?(dir)
|
62
|
+
|
63
|
+
Log.info "Creating #{name} directory #{dir}"
|
64
|
+
begin
|
65
|
+
Dir.mkdir(dir)
|
66
|
+
rescue StandardError
|
67
|
+
Log.fatal "Cannot create #{name} directory #{dir}: #{$!}"
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
59
71
|
private
|
60
72
|
|
61
73
|
def load_options
|
@@ -0,0 +1,62 @@
|
|
1
|
+
#!/usr/bin/env ruby -w
|
2
|
+
# encoding: UTF-8
|
3
|
+
#
|
4
|
+
# = Schema.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
|
+
module PostRunner
|
14
|
+
|
15
|
+
# A Schema provides a unified way to query and process diverse data types.
|
16
|
+
class Schema
|
17
|
+
|
18
|
+
attr_reader :key, :name,
|
19
|
+
:func, :column_alignment, :metric_unit, :imperial_unit
|
20
|
+
|
21
|
+
# Create a Schema object.
|
22
|
+
# @param key [Symbol] The globally unique identifier for the object
|
23
|
+
# @param name [String] A human readable name to describe the object
|
24
|
+
# @param opts [Hash] A Hash with values to overwrite the default values
|
25
|
+
# of some instance variables.
|
26
|
+
def initialize(key, name, opts = {})
|
27
|
+
@key = key
|
28
|
+
@name = name
|
29
|
+
|
30
|
+
# Default values for optional variables
|
31
|
+
@func = nil
|
32
|
+
@format = nil
|
33
|
+
@column_alignment = :right
|
34
|
+
@metric_unit = nil
|
35
|
+
@imperial_unit = nil
|
36
|
+
|
37
|
+
# Overwrite the default value for optional variables that have values
|
38
|
+
# provided in opts.
|
39
|
+
opts.each do |on, ov|
|
40
|
+
if instance_variable_defined?('@' + on.to_s)
|
41
|
+
instance_variable_set('@' + on.to_s, ov)
|
42
|
+
else
|
43
|
+
raise ArgumentError, "Unknown instance variable '#{on}'"
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
def to_s(value)
|
49
|
+
value = send(@format, value) if @format
|
50
|
+
value.to_s
|
51
|
+
end
|
52
|
+
|
53
|
+
private
|
54
|
+
|
55
|
+
def date_with_weekday(timestamp)
|
56
|
+
timestamp.strftime('%a, %Y %b %d %H:%M')
|
57
|
+
end
|
58
|
+
|
59
|
+
end
|
60
|
+
|
61
|
+
end
|
62
|
+
|
data/lib/postrunner/version.rb
CHANGED
data/postrunner.gemspec
CHANGED
@@ -19,7 +19,7 @@ Gem::Specification.new do |spec|
|
|
19
19
|
spec.require_paths = ["lib"]
|
20
20
|
spec.required_ruby_version = '>=2.0'
|
21
21
|
|
22
|
-
spec.add_dependency 'fit4ruby', '~> 0.0.
|
22
|
+
spec.add_dependency 'fit4ruby', '~> 0.0.6'
|
23
23
|
spec.add_dependency 'nokogiri', '~> 1.6'
|
24
24
|
|
25
25
|
spec.add_development_dependency 'bundler', '~> 1.6'
|
@@ -13,11 +13,15 @@
|
|
13
13
|
require 'postrunner/ActivitySummary'
|
14
14
|
require 'spec_helper'
|
15
15
|
|
16
|
+
class Activity < Struct.new(:fit_activity, :sport)
|
17
|
+
end
|
18
|
+
|
16
19
|
describe PostRunner::ActivitySummary do
|
17
20
|
|
18
21
|
before(:each) do
|
19
|
-
|
20
|
-
|
22
|
+
fa = create_fit_activity('2014-08-26-19:00', 30)
|
23
|
+
a = Activity.new(fa, 'running')
|
24
|
+
@as = PostRunner::ActivitySummary.new(a, :metric,
|
21
25
|
{ :name => 'test', :type => 'Running',
|
22
26
|
:sub_type => 'Street' })
|
23
27
|
end
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: postrunner
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.0.
|
4
|
+
version: 0.0.9
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Chris Schlaeger
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2015-
|
11
|
+
date: 2015-09-11 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: fit4ruby
|
@@ -16,14 +16,14 @@ dependencies:
|
|
16
16
|
requirements:
|
17
17
|
- - ~>
|
18
18
|
- !ruby/object:Gem::Version
|
19
|
-
version: 0.0.
|
19
|
+
version: 0.0.6
|
20
20
|
type: :runtime
|
21
21
|
prerelease: false
|
22
22
|
version_requirements: !ruby/object:Gem::Requirement
|
23
23
|
requirements:
|
24
24
|
- - ~>
|
25
25
|
- !ruby/object:Gem::Version
|
26
|
-
version: 0.0.
|
26
|
+
version: 0.0.6
|
27
27
|
- !ruby/object:Gem::Dependency
|
28
28
|
name: nokogiri
|
29
29
|
requirement: !ruby/object:Gem::Requirement
|
@@ -121,14 +121,17 @@ files:
|
|
121
121
|
- lib/postrunner/BackedUpFile.rb
|
122
122
|
- lib/postrunner/ChartView.rb
|
123
123
|
- lib/postrunner/DeviceList.rb
|
124
|
+
- lib/postrunner/EPO_Downloader.rb
|
124
125
|
- lib/postrunner/FlexiTable.rb
|
125
126
|
- lib/postrunner/HTMLBuilder.rb
|
126
127
|
- lib/postrunner/Main.rb
|
127
128
|
- lib/postrunner/NavButtonRow.rb
|
128
129
|
- lib/postrunner/PagingButtons.rb
|
129
130
|
- lib/postrunner/PersonalRecords.rb
|
131
|
+
- lib/postrunner/QueryResult.rb
|
130
132
|
- lib/postrunner/RecordListPageView.rb
|
131
133
|
- lib/postrunner/RuntimeConfig.rb
|
134
|
+
- lib/postrunner/Schema.rb
|
132
135
|
- lib/postrunner/TrackView.rb
|
133
136
|
- lib/postrunner/UserProfileView.rb
|
134
137
|
- lib/postrunner/View.rb
|