postrunner 0.0.4 → 0.0.5

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: f5442c8e0d9d50397dc5fae363f102b851cffca2
4
- data.tar.gz: f774c976a32016ef7815dfba94ee154555adaffb
3
+ metadata.gz: 10eb940a7f897be53510322b131e9307a844b3d3
4
+ data.tar.gz: 85b3a20f60cd7b20bede30072eae6d51af9bf93c
5
5
  SHA512:
6
- metadata.gz: 69c25003d3de0d97c30f5714b37084332536ac2949a0eda529333424bbe533361c62df559e4de5cdd80534b6dce1256e0df9af966eb942bd4591829956c57765
7
- data.tar.gz: 929fcc906deede1c84d728b0ecba0208a761f5d5310f2a98e873e6c61a28b25e5fc949bbcbcc95a19f9a111d57ced4b773f978768bb9ff0cb6bccb99d39efac0
6
+ metadata.gz: 1c59ca7d3109f211b7719bf1f5b526702c664d38b71766777784bfafef0edd72782efa580b79d9bfadfee5c9aacf79d1be62c9e7aaf9901275d2d04cec5cbe6d
7
+ data.tar.gz: 3b1d77b712513e49a922bc94a1a44f8e88c9681d36c62df7c797bda14207b60721efb3cee5bf7393452fd8c1993a8f2dd597f1bdecef074113587fd7ce20c7ab
data/README.md CHANGED
@@ -6,44 +6,107 @@ PostRunner is an application to manage FIT files such as those produced by Garmi
6
6
 
7
7
  PostRunner is a Ruby application. You need to have a Ruby 2.0 or later runtime environment installed.
8
8
 
9
- $ gem install postrunner
9
+ ```
10
+ $ gem install postrunner
11
+ ```
10
12
 
11
13
  ## Usage
12
14
 
15
+ ### Importing FIT files
16
+
13
17
  To get started you need to connect your device to your computer and mount it as a drive. Only devices that expose their data as FAT file system are supported. Older devices use proprietary drivers and are not supported by postrunner. Once the device is mounted find out the full path to the directory that contains your FIT files. You can then import all files on the device.
14
18
 
15
- $ postrunner import /var/run/media/user/GARMIN/GARMIN/ACTIVITY/
19
+ ```
20
+ $ postrunner import /var/run/media/user/GARMIN/GARMIN/ACTIVITY/
21
+ ```
16
22
 
17
23
  The above command assumes that your device is mounted as /var/run/media/user. Please replace this with the path to your device. Files that have been imported previously will not be imported again.
18
24
 
25
+ ### Viewing FIT file data on the console
26
+
19
27
  Now you can list all the FIT files in your data base.
20
28
 
21
- $ postrunner list
29
+ ```
30
+ $ postrunner list
31
+ ```
22
32
 
23
- The first column is the index you can use to reference FIT files. To get a summary of the most recent activity use the following command.
33
+ The first column is the index you can use to reference FIT files. To
34
+ get a summary of the most recent activity use the following command.
35
+ References to already imported activities start with a colon followed
36
+ by the index number.
37
+
38
+ ```
39
+ $ postrunner summary :1
40
+ ```
24
41
 
25
- $ postrunner summary :1
26
-
27
42
  To get a summary of the oldest activity you can use
28
43
 
29
- $ postrunner summary :-1
30
-
44
+ ```
45
+ $ postrunner summary :-1
46
+ ```
47
+
48
+ To select multiple activities you can use a range.
49
+
50
+ ```
51
+ $ postrunner summary :1-3
52
+ ```
53
+
31
54
  You can also get a full dump of the content of a FIT file.
32
55
 
33
- $ postrunner dump 1234568.FIT
56
+ ```
57
+ $ postrunner dump 1234568.FIT
58
+ ```
34
59
 
35
60
  If the file is already in the data base you can also use the reference notation.
36
61
 
37
- $ postrunner dump :1
62
+ ```
63
+ $ postrunner dump :1
64
+ ```
38
65
 
39
66
  This will provide you with a lot more information contained in the FIT files that is not available through Garmin Connect or most other tools.
40
67
 
68
+ ### Viewing FIT file data in your web browser
69
+
70
+ You can also view the full details of your activity in your browser.
71
+ This view includes a map (internet connection for map data required)
72
+ and charts for speed, pace, heart rate, cadence and the like.
73
+
74
+ ```
75
+ $ postrunner show
76
+ ```
77
+
78
+ This will open an overview of the most recent activities in your web
79
+ browser. It will use Firefox by default. You can overwrite this by
80
+ setting the BROWSER environment variable.
81
+
82
+ To view a specific run directly, you can use similar specifications
83
+ like those explained above.
84
+
85
+ ```
86
+ $ postrunner show :1
87
+ ```
88
+
41
89
  ## Contributing
42
90
 
43
- PostRunner is currently work in progress. It does some things I want with files from my Garmin FR620. It's certainly possible to do more things and support more devices. Patches are welcome!
91
+ PostRunner is currently work in progress. It does some things I want
92
+ with files from my Garmin FR620. It's certainly possible to do more
93
+ things and support more devices. Patches are welcome!
44
94
 
45
95
  1. Fork it ( https://github.com/scrapper/postrunner/fork )
46
96
  2. Create your feature branch (`git checkout -b my-new-feature`)
47
97
  3. Commit your changes (`git commit -am 'Add some feature'`)
48
98
  4. Push to the branch (`git push origin my-new-feature`)
49
99
  5. Create a new Pull Request
100
+
101
+ ## License
102
+
103
+ PostRunner is licensed under the GNU GPL version 2.
104
+
105
+ The distribution includes third party components that are licensed
106
+ under different OSI compatible terms.
107
+
108
+ * flot: MIT License
109
+ * jquery: MIT License
110
+ * openlayers: 2 clause BSD license
111
+ * Oxygen Icons: GNU LGPLv3 (https://techbase.kde.org/Projects/Oxygen/Licensing)
112
+
data/Rakefile CHANGED
@@ -1,6 +1,16 @@
1
1
  require "bundler/gem_tasks"
2
2
  require "rspec/core/rake_task"
3
3
 
4
+ # Add the include path for the fit4ruby library. We assume it is located in
5
+ # the same directory as the postrunner directory.
6
+ fit4ruby = File.realpath(File.join(File.dirname(__FILE__), '..',
7
+ 'fit4ruby', 'lib'))
8
+ if ENV['RUBYLIB']
9
+ ENV['RUBYLIB'] += ":#{fit4ruby}"
10
+ else
11
+ ENV['RUBYLIB'] = fit4ruby
12
+ end
13
+
4
14
  RSpec::Core::RakeTask.new
5
15
 
6
16
  task :default => :spec
@@ -1,22 +1,34 @@
1
+ #!/usr/bin/env ruby -w
2
+ # encoding: UTF-8
3
+ #
4
+ # = ActivitiesDB.rb -- PostRunner - Manage the data from your Garmin sport devices.
5
+ #
6
+ # Copyright (c) 2014 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
+
1
13
  require 'fileutils'
2
14
  require 'yaml'
3
15
 
4
16
  require 'fit4ruby'
5
17
  require 'postrunner/Activity'
6
18
  require 'postrunner/PersonalRecords'
7
- require 'postrunner/FlexiTable'
19
+ require 'postrunner/ActivityListView'
8
20
 
9
21
  module PostRunner
10
22
 
11
23
  class ActivitiesDB
12
24
 
13
- include Fit4Ruby::Converters
14
-
15
- attr_reader :db_dir, :fit_dir
25
+ attr_reader :db_dir, :cfg, :fit_dir, :html_dir, :activities
16
26
 
17
- def initialize(db_dir)
27
+ def initialize(db_dir, cfg)
18
28
  @db_dir = db_dir
29
+ @cfg = cfg
19
30
  @fit_dir = File.join(@db_dir, 'fit')
31
+ @html_dir = File.join(@db_dir, 'html')
20
32
  @archive_file = File.join(@db_dir, 'archive.yml')
21
33
 
22
34
  create_directories
@@ -34,16 +46,20 @@ module PostRunner
34
46
  Log.fatal "The archive file '#{@archive_file}' is corrupted"
35
47
  end
36
48
 
37
- # The reference to this object is needed inside Activity object but is
38
- # not stored in the archive file. We have to retrofit the Activity
39
- # instances with this data.
49
+ # Not all instance variables of Activity are stored in the file. The
50
+ # normal constructor is not run during YAML::load_file. We have to
51
+ # initialize those instance variables in a secondary step.
40
52
  @activities.each do |a|
41
- a.db = self
53
+ a.late_init(self)
42
54
  end
43
55
 
44
56
  @records = PersonalRecords.new(self)
45
57
  end
46
58
 
59
+ # Add a new FIT file to the database.
60
+ # @param fit_file [String] Name of the FIT file.
61
+ # @return [TrueClass or FalseClass] True if the file could be added. False
62
+ # otherwise.
47
63
  def add(fit_file)
48
64
  base_fit_file = File.basename(fit_file)
49
65
  if @activities.find { |a| a.fit_file == base_fit_file }
@@ -77,6 +93,16 @@ module PostRunner
77
93
 
78
94
  activity.register_records(@records)
79
95
 
96
+ # The HTML activity views contain links to their predecessors and
97
+ # successors. After inserting a new activity, we need to re-generate
98
+ # these views as well.
99
+ if (pred = predecessor(activity))
100
+ pred.generate_html_view
101
+ end
102
+ if (succ = successor(activity))
103
+ succ.generate_html_view
104
+ end
105
+
80
106
  sync
81
107
  Log.info "#{fit_file} successfully added to archive"
82
108
 
@@ -84,7 +110,17 @@ module PostRunner
84
110
  end
85
111
 
86
112
  def delete(activity)
113
+ pred = predecessor(activities)
114
+ succ = successor(activities)
115
+
87
116
  @activities.delete(activity)
117
+
118
+ # The HTML activity views contain links to their predecessors and
119
+ # successors. After deleting an activity, we need to re-generate these
120
+ # views as well.
121
+ pred.generate_html_view if pred
122
+ succ.generate_html_view if succ
123
+
88
124
  sync
89
125
  end
90
126
 
@@ -139,6 +175,22 @@ module PostRunner
139
175
  []
140
176
  end
141
177
 
178
+ # Return the next Activity after the provided activity. Note that this has
179
+ # a lower index. If none is found, return nil.
180
+ def successor(activity)
181
+ idx = @activities.index(activity)
182
+ return nil if idx.nil? || idx == 0
183
+ @activities[idx - 1]
184
+ end
185
+
186
+ # Return the previous Activity before the provided activity. Note that
187
+ # this has a higher index. If none is found, return nil.
188
+ def predecessor(activity)
189
+ idx = @activities.index(activity)
190
+ return nil if idx.nil? || idx >= @activities.length - 2
191
+ @activities[idx + 1]
192
+ end
193
+
142
194
  def map_to_files(query)
143
195
  case query
144
196
  when /\A-?\d+$\z/
@@ -171,36 +223,43 @@ module PostRunner
171
223
  []
172
224
  end
173
225
 
226
+ # Show the activity list in a web browser.
227
+ def show_list_in_browser
228
+ ActivityListView.new(self).update_html_index
229
+ show_in_browser(File.join(@html_dir, 'index.html'))
230
+ end
231
+
174
232
  def list
175
- i = 0
176
- t = FlexiTable.new
177
- t.head
178
- t.row(%w( Ref. Activity Start Distance Duration Pace ),
179
- { :halign => :left })
180
- t.set_column_attributes([
181
- { :halign => :right },
182
- {}, {},
183
- { :halign => :right },
184
- { :halign => :right },
185
- { :halign => :right }
186
- ])
187
- t.body
188
- @activities.each do |a|
189
- t.row([
190
- i += 1,
191
- a.name[0..19],
192
- a.timestamp.strftime("%a, %Y %b %d %H:%M"),
193
- "%.2f" % (a.total_distance / 1000),
194
- secsToHMS(a.total_timer_time),
195
- speedToPace(a.avg_speed) ])
196
- end
197
- puts t.to_s
233
+ puts ActivityListView.new(self).to_s
198
234
  end
199
235
 
200
236
  def show_records
201
237
  puts @records.to_s
202
238
  end
203
239
 
240
+ # Launch a web browser and show an HTML file.
241
+ # @param html_file [String] file name of the HTML file to show
242
+ def show_in_browser(html_file)
243
+ cmd = "#{ENV['BROWSER'] || 'firefox'} \"#{html_file}\" &"
244
+
245
+ unless system(cmd)
246
+ Log.fatal "Failed to execute the following shell command: #{$cmd}\n" +
247
+ "#{$!}"
248
+ end
249
+ end
250
+
251
+ # This method can be called to re-generate all HTML reports and all HTML
252
+ # index files.
253
+ def generate_all_html_reports
254
+ Log.info "Re-generating all HTML report files..."
255
+ # Generate HTML views for all activities in the DB.
256
+ @activities.each { |a| a.generate_html_view }
257
+ Log.info "All HTML report files have been re-generated."
258
+ # (Re-)generate index files.
259
+ ActivityListView.new(self).update_html_index
260
+ Log.info "HTML index files have been updated."
261
+ end
262
+
204
263
  private
205
264
 
206
265
  def sync
@@ -211,11 +270,18 @@ module PostRunner
211
270
  end
212
271
 
213
272
  @records.sync
273
+ ActivityListView.new(self).update_html_index
214
274
  end
215
275
 
216
276
  def create_directories
217
277
  create_directory(@db_dir, 'data')
218
278
  create_directory(@fit_dir, 'fit')
279
+ create_directory(@html_dir, 'html')
280
+
281
+ create_symlink('icons')
282
+ create_symlink('jquery')
283
+ create_symlink('flot')
284
+ create_symlink('openlayers')
219
285
  end
220
286
 
221
287
  def create_directory(dir, name)
@@ -229,6 +295,28 @@ module PostRunner
229
295
  end
230
296
  end
231
297
 
298
+ def create_symlink(dir)
299
+ # This file should be in lib/postrunner. The 'misc' directory should be
300
+ # found in '../../misc'.
301
+ misc_dir = File.realpath(File.join(File.dirname(__FILE__),
302
+ '..', '..', 'misc'))
303
+ unless Dir.exists?(misc_dir)
304
+ Log.fatal "Cannot find 'misc' directory under '#{misc_dir}': #{$!}"
305
+ end
306
+ src_dir = File.join(misc_dir, dir)
307
+ unless Dir.exists?(src_dir)
308
+ Log.fatal "Cannot find '#{src_dir}': #{$!}"
309
+ end
310
+ dst_dir = File.join(@html_dir, dir)
311
+ unless File.exists?(dst_dir)
312
+ begin
313
+ FileUtils.ln_s(src_dir, dst_dir)
314
+ rescue IOError
315
+ Log.fatal "Cannot create symbolic link to '#{dst_dir}': #{$!}"
316
+ end
317
+ end
318
+ end
319
+
232
320
  end
233
321
 
234
322
  end
@@ -1,14 +1,25 @@
1
+ #!/usr/bin/env ruby -w
2
+ # encoding: UTF-8
3
+ #
4
+ # = Activity.rb -- PostRunner - Manage the data from your Garmin sport devices.
5
+ #
6
+ # Copyright (c) 2014 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
+
1
13
  require 'fit4ruby'
2
14
 
3
- require 'postrunner/ActivityReport'
15
+ require 'postrunner/ActivitySummary'
4
16
  require 'postrunner/ActivityView'
5
17
 
6
18
  module PostRunner
7
19
 
8
20
  class Activity
9
21
 
10
- attr_reader :fit_file, :name, :fit_activity
11
- attr_accessor :db
22
+ attr_reader :db, :fit_file, :name, :fit_activity, :html_dir, :html_file
12
23
 
13
24
  # This is a list of variables that provide data from the fit file. To
14
25
  # speed up access to it, we cache the data in the activity database.
@@ -16,16 +27,24 @@ module PostRunner
16
27
  avg_speed )
17
28
 
18
29
  def initialize(db, fit_file, fit_activity, name = nil)
19
- @db = db
20
30
  @fit_file = fit_file
21
31
  @fit_activity = fit_activity
22
32
  @name = name || fit_file
33
+ late_init(db)
23
34
 
24
35
  @@CachedVariables.each do |v|
25
36
  v_str = "@#{v}"
26
37
  instance_variable_set(v_str, fit_activity.send(v))
27
38
  self.class.send(:attr_reader, v.to_sym)
28
39
  end
40
+ # Generate HTML file for this activity.
41
+ generate_html_view
42
+ end
43
+
44
+ def late_init(db)
45
+ @db = db
46
+ @html_dir = File.join(@db.db_dir, 'html')
47
+ @html_file = File.join(@html_dir, "#{@fit_file[0..-5]}.html")
29
48
  end
30
49
 
31
50
  def check
@@ -61,21 +80,19 @@ module PostRunner
61
80
  end
62
81
 
63
82
  def show
64
- @fit_activity = load_fit_file unless @fit_activity
65
- view = ActivityView.new(self, File.join(@db.db_dir, 'html'))
66
- #view = TrackView.new(self, '../../html')
67
- #view.generate_html
68
- #chart = ChartView.new(self, '../../html')
69
- #chart.generate_html
83
+ generate_html_view #unless File.exists?(@html_file)
84
+
85
+ @db.show_in_browser(@html_file)
70
86
  end
71
87
 
72
88
  def summary
73
89
  @fit_activity = load_fit_file unless @fit_activity
74
- puts ActivityReport.new(@fit_activity).to_s
90
+ puts ActivitySummary.new(@fit_activity, name, @db.cfg[:unit_system]).to_s
75
91
  end
76
92
 
77
93
  def rename(name)
78
94
  @name = name
95
+ generate_html_view
79
96
  end
80
97
 
81
98
  def register_records(db)
@@ -90,6 +107,12 @@ module PostRunner
90
107
  end
91
108
  end
92
109
 
110
+ def generate_html_view
111
+ @fit_activity = load_fit_file unless @fit_activity
112
+ ActivityView.new(self, @db.cfg[:unit_system], @db.predecessor(self),
113
+ @db.successor(self))
114
+ end
115
+
93
116
  private
94
117
 
95
118
  def load_fit_file(filter = nil)
@@ -0,0 +1,195 @@
1
+ #!/usr/bin/env ruby -w
2
+ # encoding: UTF-8
3
+ #
4
+ # = ActivitListView.rb -- PostRunner - Manage the data from your Garmin sport devices.
5
+ #
6
+ # Copyright (c) 2014 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 'fit4ruby'
14
+
15
+ require 'postrunner/FlexiTable'
16
+ require 'postrunner/HTMLBuilder'
17
+ require 'postrunner/ViewWidgets'
18
+
19
+ module PostRunner
20
+
21
+ class ActivityListView
22
+
23
+ class ActivityLink
24
+
25
+ def initialize(activity)
26
+ @activity = activity
27
+ end
28
+
29
+ def to_html(doc)
30
+ doc.a(@activity.name, { :class => 'activity_link',
31
+ :href => @activity.fit_file[0..-5] + '.html' })
32
+ end
33
+
34
+ def to_s
35
+ @activity.name[0..19]
36
+ end
37
+
38
+ end
39
+
40
+ include Fit4Ruby::Converters
41
+ include ViewWidgets
42
+
43
+ def initialize(db)
44
+ @db = db
45
+ @unit_system = @db.cfg[:unit_system]
46
+ @page_size = 20
47
+ @page_no = -1
48
+ @last_page = (@db.activities.length - 1) / @page_size
49
+ end
50
+
51
+ def update_html_index
52
+ 0.upto(@last_page) do |page_no|
53
+ @page_no = page_no
54
+ generate_html_index_page
55
+ end
56
+ end
57
+
58
+ def to_html(doc)
59
+ generate_table.to_html(doc)
60
+ end
61
+
62
+ def to_s
63
+ generate_table.to_s
64
+ end
65
+
66
+ private
67
+
68
+ def generate_html_index_page
69
+ doc = HTMLBuilder.new
70
+
71
+ doc.html {
72
+ head(doc)
73
+ body(doc)
74
+ }
75
+
76
+ write_file(doc)
77
+ end
78
+
79
+ def head(doc)
80
+ doc.head {
81
+ doc.meta({ 'http-equiv' => 'Content-Type',
82
+ 'content' => 'text/html; charset=utf-8' })
83
+ doc.title("PostRunner Activities")
84
+ style(doc)
85
+ }
86
+ end
87
+
88
+ def style(doc)
89
+ view_widgets_style(doc)
90
+ doc.style(<<EOT
91
+ body {
92
+ font-family: verdana,arial,sans-serif;
93
+ margin: 0px;
94
+ }
95
+ .main {
96
+ text-align: center;
97
+ }
98
+ .widget_frame {
99
+ width: 900px;
100
+ }
101
+ .activity_link {
102
+ padding: 0px 3px 0px 3px;
103
+ }
104
+ .ft_cell {
105
+ height: 30px
106
+ }
107
+ EOT
108
+ )
109
+ end
110
+
111
+ def body(doc)
112
+ doc.body {
113
+ first_page = @page_no == 0 ? nil: 'index.html'
114
+ prev_page = @page_no == 0 ? nil :
115
+ @page_no == 1 ? 'index.html' :
116
+ "index#{@page_no - 1}.html"
117
+ prev_page = @page_no == 0 ? nil :
118
+ @page_no == 1 ? 'index.html' :
119
+ "index#{@page_no - 1}.html"
120
+ next_page = @page_no < @last_page ? "index#{@page_no + 1}.html" : nil
121
+ last_page = @page_no == @last_page ? nil : "index#{@last_page}.html"
122
+ titlebar(doc, first_page, prev_page, nil, next_page, last_page)
123
+
124
+ doc.div({ :class => 'main' }) {
125
+ frame(doc, 'Activities') {
126
+ generate_table.to_html(doc)
127
+ }
128
+ }
129
+ footer(doc)
130
+ }
131
+ end
132
+
133
+ def generate_table
134
+ i = @page_no < 0 ? 0 : @page_no * @page_size
135
+ t = FlexiTable.new
136
+ t.head
137
+ t.row(%w( Ref. Activity Start Distance Duration Pace ),
138
+ { :halign => :left })
139
+ t.set_column_attributes([
140
+ { :halign => :right },
141
+ {}, {},
142
+ { :halign => :right },
143
+ { :halign => :right },
144
+ { :halign => :right }
145
+ ])
146
+ t.body
147
+ activities = @page_no == -1 ? @db.activities :
148
+ @db.activities[(@page_no * @page_size)..
149
+ ((@page_no + 1) * @page_size - 1)]
150
+ activities.each do |a|
151
+ t.row([
152
+ i += 1,
153
+ ActivityLink.new(a),
154
+ a.timestamp.strftime("%a, %Y %b %d %H:%M"),
155
+ local_value(a.total_distance, 'm', '%.2f',
156
+ { :metric => 'km', :statute => 'mi' }),
157
+ secsToHMS(a.total_timer_time),
158
+ pace(a.avg_speed) ])
159
+ end
160
+
161
+ t
162
+ end
163
+
164
+ def write_file(doc)
165
+ output_file = File.join(@db.html_dir,
166
+ "index#{@page_no == 0 ? '' : @page_no}.html")
167
+ begin
168
+ File.write(output_file, doc.to_html)
169
+ rescue IOError
170
+ Log.fatal "Cannot write activity index file '#{output_file}: #{$!}"
171
+ end
172
+ end
173
+
174
+ def local_value(value, from_unit, format, units)
175
+ to_unit = units[@unit_system]
176
+ return '-' unless value
177
+ value *= conversion_factor(from_unit, to_unit)
178
+ "#{format % [value, to_unit]}"
179
+ end
180
+
181
+ def pace(speed)
182
+ case @unit_system
183
+ when :metric
184
+ "#{speedToPace(speed)}"
185
+ when :statute
186
+ "#{speedToPace(speed, 1609.34)}"
187
+ else
188
+ Log.fatal "Unknown unit system #{@unit_system}"
189
+ end
190
+ end
191
+
192
+ end
193
+
194
+ end
195
+