postrunner 0.0.2 → 0.0.3

Sign up to get free protection for your applications and to get access to all the features.
@@ -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
+