postrunner 0.0.8 → 0.0.9
Sign up to get free protection for your applications and to get access to all the features.
- 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
|