postrunner 0.0.2 → 0.0.3

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.
@@ -0,0 +1,289 @@
1
+ require 'postrunner/HTMLBuilder'
2
+
3
+ module PostRunner
4
+
5
+ class FlexiTable
6
+
7
+ class Attributes
8
+
9
+ attr_accessor :min_terminal_width, :halign
10
+
11
+ def initialize(attrs = {})
12
+ @min_terminal_width = nil
13
+ @halign = nil
14
+
15
+ attrs.each do |name, value|
16
+ ivar_name = '@' + name.to_s
17
+ unless instance_variable_defined?(ivar_name)
18
+ Log.fatal "Unsupported attribute #{name}"
19
+ end
20
+ instance_variable_set(ivar_name, value)
21
+ end
22
+ end
23
+
24
+ def [](name)
25
+ ivar_name = '@' + name.to_s
26
+ return nil unless instance_variable_defined?(ivar_name)
27
+
28
+ instance_variable_get(ivar_name)
29
+ end
30
+
31
+ end
32
+
33
+ class Cell
34
+
35
+ def initialize(table, row, content, attributes)
36
+ @table = table
37
+ @row = row
38
+ @content = content
39
+ @attributes = attributes
40
+
41
+ @column_index = nil
42
+ @row_index = nil
43
+ end
44
+
45
+ def min_terminal_width
46
+ @content.to_s.length
47
+ end
48
+
49
+ def set_indicies(col_idx, row_idx)
50
+ @column_index = col_idx
51
+ @row_index = row_idx
52
+ end
53
+
54
+ def to_s
55
+ s = @content.to_s
56
+ width = get_attribute(:min_terminal_width)
57
+ case get_attribute(:halign)
58
+ when :left, nil
59
+ s + ' ' * (width - s.length)
60
+ when :right
61
+ ' ' * (width - s.length) + s
62
+ when :center
63
+ w = width - s.length
64
+ left_padding = w / 2
65
+ right_padding = w / 2 + w % 2
66
+ ' ' * left_padding + s + ' ' * right_padding
67
+ else
68
+ raise "Unknown alignment"
69
+ end
70
+ end
71
+
72
+ def to_html(doc)
73
+ doc.td(@content.respond_to?('to_html') ?
74
+ @content.to_html(doc) : @content.to_s)
75
+ end
76
+
77
+ private
78
+
79
+ def get_attribute(name)
80
+ @attributes[name] ||
81
+ @row.attributes[name] ||
82
+ @table.column_attributes[@column_index][name]
83
+ end
84
+
85
+ end
86
+
87
+ class Row < Array
88
+
89
+ attr_reader :attributes
90
+
91
+ def initialize(table)
92
+ @table = table
93
+ @attributes = Attributes.new
94
+ super()
95
+ end
96
+
97
+ def cell(content, attributes)
98
+ c = Cell.new(@table, self, content, attributes)
99
+ self << c
100
+ c
101
+ end
102
+
103
+ def set_indicies(col_idx, row_idx)
104
+ self[col_idx].set_indicies(col_idx, row_idx)
105
+ end
106
+
107
+ def set_row_attributes(attributes)
108
+ @attributes = Attributes.new(attributes)
109
+ end
110
+
111
+ def to_s
112
+ s = ''
113
+ frame = @table.frame
114
+
115
+ s << '|' if frame
116
+ s << join(frame ? '|' : ' ')
117
+ s << '|' if frame
118
+
119
+ s
120
+ end
121
+
122
+ def to_html(doc)
123
+ doc.tr {
124
+ each { |c| c.to_html(doc) }
125
+ }
126
+ end
127
+
128
+ end
129
+
130
+ attr_reader :frame, :column_attributes
131
+
132
+ def initialize(&block)
133
+ @head_rows = []
134
+ @body_rows = []
135
+ @foot_rows = []
136
+ @column_count = 0
137
+
138
+ @current_section = :body
139
+ @current_row = nil
140
+
141
+ @frame = true
142
+
143
+ @column_attributes = []
144
+
145
+ instance_eval(&block) if block_given?
146
+ end
147
+
148
+ def head
149
+ @current_section = :head
150
+ end
151
+
152
+ def body
153
+ @current_section = :body
154
+ end
155
+
156
+ def foot
157
+ @current_section = :foot
158
+ end
159
+
160
+ def new_row
161
+ @current_row = nil
162
+ end
163
+
164
+ def cell(content, attributes = {})
165
+ if @current_row.nil?
166
+ case @current_section
167
+ when :head
168
+ @head_rows
169
+ when :body
170
+ @body_rows
171
+ when :foot
172
+ @foot_rows
173
+ else
174
+ raise "Unknown section #{@current_section}"
175
+ end << (@current_row = Row.new(self))
176
+ end
177
+ @current_row.cell(content, attributes)
178
+ end
179
+
180
+ def row(cells, attributes = {})
181
+ cells.each { |c| cell(c) }
182
+ set_row_attributes(attributes)
183
+ new_row
184
+ end
185
+
186
+ def set_column_attributes(col_attributes)
187
+ col_attributes.each.with_index do |ca, idx|
188
+ @column_attributes[idx] = Attributes.new(ca)
189
+ end
190
+ end
191
+
192
+ def set_row_attributes(row_attributes)
193
+ unless @current_row
194
+ raise "No current row. Use after first cell definition but before " +
195
+ "new_row call."
196
+ end
197
+ @current_row.set_row_attributes(row_attributes)
198
+ end
199
+
200
+ def enable_frame(enabled)
201
+ @frame = enabled
202
+ end
203
+
204
+ def to_s
205
+ index_table
206
+ calc_terminal_columns
207
+
208
+ s = frame_line_to_s
209
+ s << rows_to_s(@head_rows)
210
+ s << frame_line_to_s unless @head_rows.empty?
211
+ s << rows_to_s(@body_rows)
212
+ s << frame_line_to_s unless @body_rows.empty?
213
+ s << rows_to_s(@foot_rows)
214
+ s << frame_line_to_s unless @foot_rows.empty?
215
+
216
+ s
217
+ end
218
+
219
+ def to_html(doc)
220
+ doc.table {
221
+ @head_rows.each { |r| r.to_html(doc) }
222
+ @body_rows.each { |r| r.to_html(doc) }
223
+ @foot_rows.each { |r| r.to_html(doc) }
224
+ }
225
+ end
226
+
227
+ private
228
+
229
+ def index_table
230
+ @column_count = (@head_rows[0] || @body_rows[0]).length
231
+
232
+ @column_count.times do |i|
233
+ index_table_rows(i, @head_rows)
234
+ index_table_rows(i, @body_rows)
235
+ index_table_rows(i, @foot_rows)
236
+ end
237
+ end
238
+
239
+ def index_table_rows(col_idx, rows)
240
+ rows.each.with_index do |r, row_idx|
241
+ r.set_indicies(col_idx, row_idx)
242
+ end
243
+ end
244
+
245
+ def calc_terminal_columns
246
+ @column_count.times do |i|
247
+ col_mtw = nil
248
+
249
+ col_mtw = calc_section_teminal_columns(i, col_mtw, @head_rows)
250
+ col_mtw = calc_section_teminal_columns(i, col_mtw, @body_rows)
251
+ col_mtw = calc_section_teminal_columns(i, col_mtw, @foot_rows)
252
+
253
+ @column_attributes[i] = Attributes.new unless @column_attributes[i]
254
+ @column_attributes[i].min_terminal_width = col_mtw
255
+ end
256
+ end
257
+
258
+ def calc_section_teminal_columns(col_idx, col_mtw, rows)
259
+ rows.each do |r|
260
+ if r[col_idx].nil?
261
+ raise ArgumentError, "Not all rows have same number of cells"
262
+ end
263
+
264
+ mtw = r[col_idx].min_terminal_width
265
+ if col_mtw.nil? || col_mtw < mtw
266
+ col_mtw = mtw
267
+ end
268
+ end
269
+
270
+ col_mtw
271
+ end
272
+
273
+ def rows_to_s(x_rows)
274
+ x_rows.empty? ? '' : (x_rows.map { |r| r.to_s}.join("\n") + "\n")
275
+ end
276
+
277
+ def frame_line_to_s
278
+ return '' unless @frame
279
+ s = '+'
280
+ @column_attributes.each do |c|
281
+ s += '-' * c.min_terminal_width + '+'
282
+ end
283
+ s + "\n"
284
+ end
285
+
286
+ end
287
+
288
+ end
289
+
@@ -0,0 +1,67 @@
1
+ require 'nokogiri'
2
+
3
+ module PostRunner
4
+
5
+ # Nokogiri is great, but I don't like the HTMLBuilder interface. This class
6
+ # is a wrapper around Nokogiri that provides a more Ruby-like interface.
7
+ class HTMLBuilder
8
+
9
+ # Create a new HTMLBuilder object.
10
+ def initialize
11
+ # This is the Nokogiri Document that will store all the data.
12
+ @doc = Nokogiri::HTML::Document.new
13
+ # We only need to keep a stack of the currently edited nodes so we know
14
+ # where we are in the node tree.
15
+ @node_stack = []
16
+ end
17
+
18
+ # Any call to an undefined method will create a HTML node of the same
19
+ # name.
20
+ def method_missing(method_name, *args)
21
+ node = Nokogiri::XML::Node.new(method_name.to_s, @doc)
22
+ if (parent = @node_stack.last)
23
+ parent.add_child(node)
24
+ else
25
+ @doc.add_child(node)
26
+ end
27
+ @node_stack.push(node)
28
+
29
+ args.each do |arg|
30
+ if arg.is_a?(String)
31
+ node.add_child(Nokogiri::XML::Text.new(arg, @doc))
32
+ elsif arg.is_a?(Hash)
33
+ # Hash arguments are attribute sets for the node. We just pass them
34
+ # directly to the node.
35
+ arg.each { |k, v| node[k] = v }
36
+ end
37
+ end
38
+
39
+ yield if block_given?
40
+ @node_stack.pop
41
+ end
42
+
43
+ # Only needed to comply with style guides. This all calls to unknown
44
+ # method will be handled properly. So, we always return true.
45
+ def respond_to?(method)
46
+ true
47
+ end
48
+
49
+ # Dump the HTML document as HTML formatted String.
50
+ def to_html
51
+ @doc.to_html
52
+ end
53
+
54
+ private
55
+
56
+ def add_child(parent, node)
57
+ if parent
58
+ parent.add_child(node)
59
+ else
60
+ @doc.add_child(node)
61
+ end
62
+ end
63
+
64
+ end
65
+
66
+ end
67
+
@@ -1,7 +1,8 @@
1
1
  require 'optparse'
2
2
  require 'logger'
3
3
  require 'fit4ruby'
4
- require 'postrunner/RuntimeConfig'
4
+
5
+ require 'postrunner/version'
5
6
  require 'postrunner/ActivitiesDB'
6
7
 
7
8
  module PostRunner
@@ -11,15 +12,19 @@ module PostRunner
11
12
  Log.formatter = proc { |severity, datetime, progname, msg|
12
13
  "#{severity == Logger::INFO ? '' : "#{severity}:"} #{msg}\n"
13
14
  }
15
+ Log.level = Logger::INFO
14
16
 
15
17
  class Main
16
18
 
17
19
  def initialize(args)
18
20
  @filter = nil
19
21
  @name = nil
20
- @activities = ActivitiesDB.new(File.join(ENV['HOME'], '.postrunner'))
22
+ @activities = nil
23
+ @db_dir = File.join(ENV['HOME'], '.postrunner')
24
+
25
+ return if (args = parse_options(args)).nil?
21
26
 
22
- execute_command(parse_options(args))
27
+ execute_command(args)
23
28
  end
24
29
 
25
30
  private
@@ -72,25 +77,30 @@ EOT
72
77
 
73
78
  opts.separator ""
74
79
  opts.separator "General options:"
80
+ opts.on('--dbdir dir', String,
81
+ 'Directory for the activity database and related files') do |d|
82
+ @db_dir = d
83
+ end
75
84
  opts.on('-v', '--verbose',
76
85
  'Show internal messages helpful for debugging problems') do
77
86
  Log.level = Logger::DEBUG
78
87
  end
79
88
  opts.on('-h', '--help', 'Show this message') do
80
89
  $stderr.puts opts
81
- exit
90
+ return nil
82
91
  end
83
92
  opts.on('--version', 'Show version number') do
84
93
  $stderr.puts VERSION
85
- exit
94
+ return nil
86
95
  end
87
96
 
88
97
  opts.separator <<"EOT"
89
98
 
90
99
  Commands:
91
100
 
92
- check <fit file> ...
93
- Check the provided FIT file(s) for structural errors.
101
+ check [ <fit file> | <ref> ... ]
102
+ Check the provided FIT file(s) for structural errors. If no file or
103
+ reference is provided, the complete archive is checked.
94
104
 
95
105
  dump <fit file> | <ref>
96
106
  Dump the content of the FIT file.
@@ -104,11 +114,17 @@ delete <ref>
104
114
  list
105
115
  List all FIT files stored in the data base.
106
116
 
117
+ records
118
+ List all personal records.
119
+
107
120
  rename <ref>
108
121
  Replace the FIT file name with a more meaningful name that describes
109
122
  the activity.
110
123
 
111
- summary <fit file> | <ref>
124
+ show <ref>
125
+ Show the FIT activity in a web browser.
126
+
127
+ summary <ref>
112
128
  Display the summary information for the FIT file.
113
129
  EOT
114
130
 
@@ -118,9 +134,15 @@ EOT
118
134
  end
119
135
 
120
136
  def execute_command(args)
137
+ @activities = ActivitiesDB.new(@db_dir)
138
+
121
139
  case (cmd = args.shift)
122
140
  when 'check'
123
- process_files_or_activities(args, :check)
141
+ if args.empty?
142
+ @activities.check
143
+ else
144
+ process_files_or_activities(args, :check)
145
+ end
124
146
  when 'delete'
125
147
  process_activities(args, :delete)
126
148
  when 'dump'
@@ -130,10 +152,14 @@ EOT
130
152
  process_files(args, :import)
131
153
  when 'list'
132
154
  @activities.list
155
+ when 'records'
156
+ @activities.show_records
133
157
  when 'rename'
134
158
  process_activities(args, :rename)
159
+ when 'show'
160
+ process_activities(args, :show)
135
161
  when 'summary'
136
- process_files_or_activities(args, :summary)
162
+ process_activities(args, :summary)
137
163
  when nil
138
164
  Log.fatal("No command provided. " +
139
165
  "See 'postrunner -h' for more information.")
@@ -146,30 +172,24 @@ EOT
146
172
  def process_files_or_activities(files_or_activities, command)
147
173
  files_or_activities.each do |foa|
148
174
  if foa[0] == ':'
149
- files = @activities.map_to_files(foa[1..-1])
150
- if files.empty?
151
- Log.warn "No matching activities found for '#{foa}'"
152
- return
153
- end
154
-
155
- process_files(files, command)
175
+ process_activities([ foa ], command)
156
176
  else
157
177
  process_files([ foa ], command)
158
178
  end
159
179
  end
160
180
  end
161
181
 
162
- def process_activities(activity_files, command)
163
- activity_files.each do |a|
164
- if a[0] == ':'
165
- files = @activities.map_to_files(a[1..-1])
166
- if files.empty?
167
- Log.warn "No matching activities found for '#{a}'"
182
+ def process_activities(activity_refs, command)
183
+ activity_refs.each do |a_ref|
184
+ if a_ref[0] == ':'
185
+ activities = @activities.find(a_ref[1..-1])
186
+ if activities.empty?
187
+ Log.warn "No matching activities found for '#{a_ref}'"
168
188
  return
169
189
  end
170
- process_files(files, command)
190
+ activities.each { |a| process_activity(a, command) }
171
191
  else
172
- Log.fatal "Activity references must start with ':': #{a}"
192
+ Log.fatal "Activity references must start with ':': #{a_ref}"
173
193
  end
174
194
  end
175
195
 
@@ -193,19 +213,39 @@ EOT
193
213
 
194
214
  def process_file(file, command)
195
215
  case command
196
- when :delete
197
- @activities.delete(file)
216
+ when :check, :dump
217
+ read_fit_file(file)
198
218
  when :import
199
219
  @activities.add(file)
220
+ else
221
+ Log.fatal("Unknown file command #{command}")
222
+ end
223
+ end
224
+
225
+ def process_activity(activity, command)
226
+ case command
227
+ when :check
228
+ activity.check
229
+ when :delete
230
+ @activities.delete(activity)
231
+ when :dump
232
+ activity.dump(@filter)
200
233
  when :rename
201
- @activities.rename(file, @name)
234
+ @activities.rename(activity, @name)
235
+ when :show
236
+ activity.show
237
+ when :summary
238
+ activity.summary
202
239
  else
203
- begin
204
- activity = Fit4Ruby::read(file, @filter)
205
- #rescue
206
- # Log.error("File '#{file}' is corrupted!: #{$!}")
207
- end
208
- puts activity.to_s if command == :summary
240
+ Log.fatal("Unknown activity command #{command}")
241
+ end
242
+ end
243
+
244
+ def read_fit_file(fit_file)
245
+ begin
246
+ return Fit4Ruby::read(fit_file, @filter)
247
+ rescue StandardError
248
+ Log.error("Cannot read FIT file '#{fit_file}': #{$!}")
209
249
  end
210
250
  end
211
251
 
@@ -0,0 +1,145 @@
1
+ require 'fileutils'
2
+ require 'yaml'
3
+
4
+ require 'fit4ruby'
5
+
6
+ module PostRunner
7
+
8
+ class PersonalRecords
9
+
10
+ class Record
11
+
12
+ attr_accessor :distance, :duration, :start_time, :fit_file
13
+
14
+ def initialize(distance, duration, start_time, fit_file)
15
+ @distance = distance
16
+ @duration = duration
17
+ @start_time = start_time
18
+ @fit_file = fit_file
19
+ end
20
+
21
+ end
22
+
23
+ include Fit4Ruby::Converters
24
+
25
+ def initialize(activities)
26
+ @activities = activities
27
+ @db_dir = activities.db_dir
28
+ @records_file = File.join(@db_dir, 'records.yml')
29
+ @records = []
30
+
31
+ load_records
32
+ end
33
+
34
+ def register_result(distance, duration, start_time, fit_file)
35
+ @records.each do |record|
36
+ if record.duration > 0
37
+ if duration > 0
38
+ # This is a speed record for a popular distance.
39
+ if distance == record.distance
40
+ if duration < record.duration
41
+ record.duration = duration
42
+ record.start_time = start_time
43
+ record.fit_file = fit_file
44
+ Log.info "New record for #{distance} m in " +
45
+ "#{secsToHMS(duration)}"
46
+ return true
47
+ else
48
+ # No new record for this distance.
49
+ return false
50
+ end
51
+ end
52
+ end
53
+ else
54
+ if distance > record.distance
55
+ # This is a new distance record.
56
+ record.distance = distance
57
+ record.duration = 0
58
+ record.start_time = start_time
59
+ record.fit_file = fit_file
60
+ Log.info "New distance record #{distance} m"
61
+ return true
62
+ else
63
+ # No new distance record.
64
+ return false
65
+ end
66
+ end
67
+ end
68
+
69
+ # We have not found a record.
70
+ @records << Record.new(distance, duration, start_time, fit_file)
71
+ if duration == 0
72
+ Log.info "New distance record #{distance} m"
73
+ else
74
+ Log.info "New record for #{distance}m in #{secsToHMS(duration)}"
75
+ end
76
+
77
+ true
78
+ end
79
+
80
+ def delete_activity(fit_file)
81
+ @records.delete_if { |r| r.fit_file == fit_file }
82
+ end
83
+
84
+ def sync
85
+ save_records
86
+ end
87
+
88
+ def to_s
89
+ record_names = { 1000.0 => '1 km', 1609.0 => '1 mi', 5000.0 => '5 km',
90
+ 21097.5 => '1/2 Marathon', 42195.0 => 'Marathon' }
91
+ t = FlexiTable.new
92
+ t.head
93
+ t.row([ 'Record', 'Time/Dist.', 'Avg. Pace', 'Ref.', 'Activity', 'Date' ],
94
+ { :halign => :center })
95
+ t.set_column_attributes([
96
+ {},
97
+ { :halign => :right },
98
+ { :halign => :right },
99
+ { :halign => :right },
100
+ { :halign => :right },
101
+ { :halign => :left }
102
+ ])
103
+ t.body
104
+ @records.sort { |r1, r2| r1.distance <=> r2.distance }.each do |r|
105
+ activity = @activities.activity_by_fit_file(r.fit_file)
106
+ t.row((r.duration == 0 ?
107
+ [ 'Longest Run', '%.1f m' % r.distance, '-' ] :
108
+ [ record_names[r.distance], secsToHMS(r.duration),
109
+ speedToPace(r.distance / r.duration) ]) +
110
+ [ @activities.ref_by_fit_file(r.fit_file),
111
+ activity.name, r.start_time.strftime("%Y-%m-%d") ])
112
+ end
113
+ t.to_s
114
+ end
115
+
116
+ private
117
+
118
+ def load_records
119
+ begin
120
+ if File.exists?(@records_file)
121
+ @records = YAML.load_file(@records_file)
122
+ else
123
+ Log.info "No records file found at '#{@records_file}'"
124
+ end
125
+ rescue StandardError
126
+ Log.fatal "Cannot load records file '#{@records_file}': #{$!}"
127
+ end
128
+
129
+ unless @records.is_a?(Array)
130
+ Log.fatal "The personal records file '#{@records_file}' is corrupted"
131
+ end
132
+ end
133
+
134
+ def save_records
135
+ begin
136
+ File.open(@records_file, 'w') { |f| f.write(@records.to_yaml) }
137
+ rescue StandardError
138
+ Log.fatal "Cannot write records file '#{@records_file}': #{$!}"
139
+ end
140
+ end
141
+
142
+ end
143
+
144
+ end
145
+