Pratt 1.6.2 → 1.6.4

Sign up to get free protection for your applications and to get access to all the features.
Files changed (50) hide show
  1. data/.gitignore +2 -0
  2. data/Pratt.gemspec +38 -12
  3. data/README.html +85 -0
  4. data/README.txt +6 -3
  5. data/TODO +6 -3
  6. data/VERSION +1 -1
  7. data/config.rb +3 -17
  8. data/db/zips.csv.zip +0 -0
  9. data/lib/models.rb +8 -0
  10. data/lib/pratt.rb +58 -293
  11. data/lib/pratt.rb.orig +626 -0
  12. data/lib/pratt/core_ext.rb +6 -0
  13. data/lib/pratt/{array.rb → core_ext/array.rb} +5 -0
  14. data/lib/pratt/core_ext/float.rb +28 -0
  15. data/lib/pratt/core_ext/nil.rb +5 -0
  16. data/lib/pratt/core_ext/numeric.rb +9 -0
  17. data/lib/pratt/{string.rb → core_ext/string.rb} +13 -0
  18. data/lib/pratt/core_ext/time.rb +20 -0
  19. data/lib/pratt/dialogs.rb +81 -0
  20. data/lib/pratt/formatting.rb +24 -0
  21. data/lib/pratt/project_actions.rb +25 -0
  22. data/lib/pratt/reports.rb +127 -0
  23. data/models/app.rb +1 -2
  24. data/models/customer.rb +22 -0
  25. data/models/invoice.rb +28 -0
  26. data/models/invoice_whence.rb +18 -0
  27. data/models/payment.rb +1 -1
  28. data/models/pratt.rb +0 -9
  29. data/models/project.rb +16 -22
  30. data/models/whence.rb +10 -1
  31. data/models/zip.rb +27 -0
  32. data/spec/fixtures/graph.expectation +0 -5
  33. data/spec/fixtures/proportions.expectation +4 -0
  34. data/spec/float_spec.rb +24 -0
  35. data/spec/numeric_spec.rb +30 -0
  36. data/spec/pratt_spec.rb +5 -4
  37. data/spec/project_spec.rb +8 -2
  38. data/spec/spec.opts +1 -1
  39. data/spec/spec_helper.rb +31 -1
  40. data/spec/string_ext_spec.rb +33 -0
  41. data/views/current.eruby +5 -0
  42. data/views/general-invoice.eruby +538 -0
  43. data/views/graph.eruby +2 -7
  44. data/views/invoice.eruby +3 -3
  45. data/views/main.rb +8 -6
  46. data/views/proportions.eruby +4 -0
  47. data/views/raw.eruby +1 -1
  48. metadata +86 -33
  49. data/lib/pratt/time.rb +0 -12
  50. data/views/env.rb +0 -22
@@ -0,0 +1,626 @@
1
+ # coding: utf-8
2
+ require 'fileutils'
3
+ require 'optparse'
4
+ require 'pathname'
5
+
6
+ require 'rubygems'
7
+ require 'colored'
8
+ require 'chronic'
9
+ require 'active_record'
10
+ require 'erubis'
11
+ require 'hoe'
12
+ require 'mocha'
13
+ require 'config'
14
+ require 'shifty_week'
15
+ require 'shifty_week/time'
16
+ require 'shifty_week/date'
17
+
18
+ require 'lib/pratt/array'
19
+ require 'lib/pratt/string'
20
+ require 'lib/pratt/time'
21
+
22
+ require 'models/app'
23
+ require 'models/customer'
24
+ require 'models/whence'
25
+ require 'models/project'
26
+ require 'models/payment'
27
+
28
+ class Pratt
29
+
30
+ NAME = 'Pratt'
31
+ URL = 'http://www.frogstarr78.com/projects/pratt'
32
+ AUTHORS = ['Scott Noel-Hemming']
33
+ SUMMARY = "Pro/Re-Active Time Tracker. Track time based on what you expect to be working on, with frequent prompts to ensure accuracy."
34
+ DESCRIPTION = %q|
35
+ Need a way to keep track of your time, but get caught up in work? Or constant interruptions?
36
+ Yeah you know who I'm talking about. Those people from the [abc] department always "NEEDING xyz FEATURE NOW!!!".
37
+ Seriously though. I'm usually just loose track of time. I wanted an app that I could start with a task I think
38
+ I'll be working on, but that get's in my face constantly to ensure I'm still working on it. And if I'm not any longer,
39
+ provides an easy way of changing to another task, or if I have changed tasks and not already updated the app, would
40
+ provide an easy way of changing the task of the previously recorded interval. That's what this is intended to do.
41
+
42
+ Time Tracking.
43
+ Proactively set what you expect to work on.
44
+ Reactively modify what you are no longer working on.
45
+ |
46
+ DEPENDENCIES = ["activerecord >=2.1.1", "sqlite3-ruby >=1.2.4", "rspec >=1.2.6", "mocha >=0.9.5"]
47
+ VERSION = File.open( File.join(Dir.pwd, 'VERSION') ).read.strip
48
+
49
+ PID_FILE='pratt.pid'
50
+ FMT = "%a %X %b %d %Y"
51
+ INVOICE_FMT = "%x"
52
+ @@color = true
53
+
54
+ attr_accessor :when_to, :scale, :color, :show_all, :env, :raw_conditions, :template, :week_day_start
55
+ attr_reader :project, :todo
56
+ def initialize proj = nil
57
+ @when_to = DateTime.now
58
+ @week = false
59
+ @day = false
60
+ @todo = []
61
+ @scale = nil
62
+ @show_all = false
63
+ @template = nil
64
+ @env = :development
65
+ @raw_conditions = ''
66
+ self.project = proj unless proj.nil?
67
+ end
68
+
69
+ # Set the project to something (Project/String)
70
+ # Conditionally creating a new project if the project
71
+ # named by the parameter isn't found
72
+ def project= proj
73
+ if proj.is_a?(Project)
74
+ @project = proj
75
+ else
76
+ @project = Project.find_or_create_by_name( { :name => proj } )
77
+ end
78
+ end
79
+
80
+ # We should act like an array
81
+ def << what
82
+ @todo << what
83
+ end
84
+
85
+ # Singleton Accessor for @app
86
+ def app
87
+ @app ||= App.last
88
+ @app
89
+ end
90
+
91
+ # TODO Rename
92
+ def graph
93
+ @primary = @off_total = @rest_total = 0.0
94
+ self.template = 'graph'
95
+
96
+ if project?
97
+ @projects = [project]
98
+
99
+ @primary = project.time_spent(scale, when_to)
100
+ @scaled_total = project.whences.time_spent(scale, when_to)
101
+ else
102
+ @projects = Project.all
103
+
104
+ @projects.each do |proj|
105
+ @primary = proj.time_spent(scale, when_to) if proj.name == Project.primary.name
106
+ @off_total = proj.time_spent(scale, when_to) if proj.name == Project.off.name
107
+ @rest_total += proj.time_spent(scale, when_to) if Project.rest.collect(&:name).include?(proj.name)
108
+ end
109
+ @scaled_total = Whence.time_spent(scale, when_to)-@off_total
110
+ end
111
+
112
+ if @primary + @off_total + @rest_total > 0.0
113
+ process_template!
114
+ else
115
+ "No data to report"
116
+ end
117
+ end
118
+
119
+ # Generate an invoice for a given time period
120
+ def invoice
121
+ self.template = 'invoice'
122
+
123
+ if project?
124
+ @projects = [project]
125
+
126
+ @total = project.time_spent(scale, when_to)
127
+ else
128
+ @projects = (Project.all - [Project.primary, Project.off])
129
+ @projects.select! {|proj| show_all or ( !show_all and proj.time_spent(scale, when_to) != 0.0 ) }
130
+
131
+ @total = @projects.inject 0.0 do |total, proj|
132
+ total += proj.time_spent(scale, when_to)
133
+ total
134
+ end
135
+ end
136
+
137
+ @projects.each do |project|
138
+ puts "#{project.name} #{project.payment.inspect}"
139
+ end
140
+ if @total > 0.0
141
+ puts process_template!
142
+ else
143
+ puts "No data to report"
144
+ end
145
+ end
146
+
147
+ def console options = []
148
+ options << %w(-r\ irb/completion -r\ lib/pratt --simple-prompt)
149
+ exec "irb #{options.join ' '}"
150
+ end
151
+
152
+ def current
153
+ project_names = ([Project.primary, Project.off] | Project.rest).collect(&:name)
154
+
155
+ if last_whence = Whence.last_unended || Whence.last
156
+ puts " projects: " << (
157
+ project_names.collect {|project_name| "'#{project_name.send(last_whence.end_at.nil? && last_whence.project.name == project_name ? :green : :magenta)}'" }
158
+ ) * ' '
159
+ if last_whence.end_at.nil?
160
+ puts " started: #{last_whence.start_at.strftime(FMT).send(:blue)}"
161
+ time_til = ( app.interval - ( Time.now - last_whence.start_at ) )
162
+ puts "next prompt: %s %s"% [Pratt.fmt_i( time_til / 60.0, 'min', :yellow ), Pratt.fmt_i( time_til % 60, 'sec', :yellow ) ], ''
163
+ end
164
+ else
165
+ puts " projects: " << (
166
+ project_names.collect do |project_name|
167
+ "'#{project_name.magenta}'"
168
+ end
169
+ ) * ' '
170
+ end
171
+ end
172
+
173
+ def begin
174
+ self.project.start! when_to
175
+ end
176
+ def restart
177
+ if project?
178
+ project.restart! when_to
179
+ else
180
+ Whence.last_unended.project.restart! when_to
181
+ end
182
+ end
183
+ def end
184
+ if project?
185
+ project.stop! when_to
186
+ else
187
+ Whence.last_unended.stop! when_to
188
+ end
189
+ end
190
+ def change
191
+ Whence.last.change! project.name
192
+ end
193
+ def destroy
194
+ project.destroy
195
+ end
196
+
197
+ def cpid
198
+ `pgrep -fl 'bin/pratt'`.chomp.split(' ').first
199
+ end
200
+
201
+ def pid
202
+ self.template = 'pid'
203
+ puts process_template!
204
+ end
205
+
206
+ def raw
207
+ self.template = 'raw'
208
+
209
+ if project?
210
+ @whences = project.whences.all
211
+ else
212
+ case raw_conditions
213
+ when 'all'
214
+ @whences = Whence.find raw_conditions.to_sym
215
+ when /^last$/, 'first'
216
+ @whences = [Whence.find raw_conditions.to_sym]
217
+ when /last[\(\s]?(\d+)[\)\s]?/
218
+ @whences = Whence.all.last($1.to_i)
219
+ else
220
+ @whences = Whence.all :conditions => ["start_at > ?", Chronic.parse("today 00:00")]
221
+ end
222
+ end
223
+ @whences.sort_by(&:id)
224
+ puts process_template!
225
+ end
226
+
227
+ def quit
228
+ project.stop! if project? and project.whences.last_unended
229
+ Whence.last_unended.stop! if Whence.last_unended
230
+ begin
231
+ Process.kill("KILL", app.pid.to_i)
232
+ rescue Errno::ESRCH
233
+ end
234
+ app.pid = ''
235
+ app.gui = ''
236
+ app.save!
237
+ end
238
+
239
+ def gui
240
+ if Whence.last_unended
241
+ pop
242
+ else
243
+ main
244
+ end
245
+ end
246
+
247
+ def show_env
248
+ defork { system("ruby views/env.rb ") }
249
+ end
250
+
251
+ def detect
252
+ if self.daemonized?
253
+ gui
254
+ else
255
+ daemonize!
256
+ end
257
+ end
258
+
259
+ <<<<<<< HEAD
260
+ def unlock
261
+ self.app.unlock
262
+ end
263
+ =======
264
+ def run
265
+ # Pratt.connect self.env
266
+
267
+ self.begin if i_should?(:begin)
268
+ self.change if i_should?(:change)
269
+ self.restart if i_should?(:restart)
270
+ self.end if i_should?(:end)
271
+
272
+ self.destroy if i_should?(:destroy)
273
+
274
+ self.pid if i_should?(:pid)
275
+ self.raw if i_should?(:raw)
276
+ self.current if i_should?(:current)
277
+ self.graph if i_should?(:graph)
278
+ self.gui if i_should?(:gui)
279
+ self.detect if i_should?(:detect)
280
+ self.tray_menu if i_should?(:tray_menu)
281
+ self.app.unlock if i_should?(:unlock)
282
+ >>>>>>> 08f31db4a4500d2f6950d31ca6646c6b08ac7432
283
+
284
+ def run
285
+ self.when_to.week_day_start = self.week_day_start
286
+ puts self.when_to.inspect, self.when_to.week_day_start
287
+ # must happen before any actions but after all cli argument parsing
288
+
289
+ self.begin if i_should? :begin
290
+ self.change if i_should? :change
291
+ self.restart if i_should? :restart
292
+ self.end if i_should? :end
293
+
294
+ self.destroy if i_should? :destroy
295
+
296
+ self.pid if i_should? :pid
297
+ self.raw if i_should? :raw
298
+ self.current if i_should? :current
299
+ puts self.graph if i_should? :graph
300
+ self.invoice if i_should? :invoice
301
+ self.console if i_should? :console
302
+ self.gui if i_should? :gui
303
+ self.show_env if i_should? :env
304
+ self.detect if i_should? :detect
305
+ self.unlock if i_should? :unlock
306
+
307
+ self.quit if i_should? :quit
308
+ self.daemonize! if i_should? :daemonize and not self.daemonized?
309
+ end
310
+
311
+ def daemonized?
312
+ !app.pid.blank? and ( cpid.to_i == app.pid )
313
+ end
314
+ def daemonize!
315
+ <<<<<<< HEAD
316
+ defork {
317
+ puts "pratt (#{Process.pid.to_s.yellow})"
318
+ app.pid = Process.pid
319
+ app.save!
320
+
321
+ gui
322
+ while(daemonized?)
323
+ sleep(app.interval)
324
+ gui
325
+ end
326
+ quit
327
+ }
328
+ =======
329
+ # self.class.connect :development
330
+ puts "pratt (#{Process.pid.to_s.yellow})"
331
+ app.pid = Process.pid
332
+ app.save!
333
+
334
+ tray_icon
335
+ gui
336
+ while(daemonized?)
337
+ sleep(app.interval)
338
+ gui
339
+ end
340
+ quit
341
+ end
342
+
343
+ def gui
344
+ if Whence.last_unended
345
+ pop
346
+ else
347
+ main
348
+ end
349
+ end
350
+
351
+ def tray_icon
352
+ Process.detach(
353
+ fork { system("ruby views/tray_icon.rb") }
354
+ )
355
+ >>>>>>> 08f31db4a4500d2f6950d31ca6646c6b08ac7432
356
+ end
357
+
358
+ def tray_menu
359
+ self.app.reload
360
+ return if self.app.gui?('tray_menu', true)
361
+ self.app.log('tray_menu')
362
+ Process.detach(
363
+ fork { system("ruby views/tray_menu.rb '#{Whence.last_unended.project.name}' 1197 2") }
364
+ )
365
+ end
366
+
367
+ private
368
+ def main
369
+ self.app.reload
370
+ return if self.app.gui?('main', true)
371
+ self.app.log('main')
372
+ projects = ([Project.primary, Project.off] | Project.rest).collect(&:name)
373
+ if Whence.count == 0
374
+ # first run
375
+ project = Whence.new(:project => Project.primary)
376
+ else
377
+ project = Whence.last_unended || Whence.last
378
+ end
379
+ defork { system("ruby views/main.rb --projects '#{projects*"','"}' --current '#{project.project.name}'") }
380
+ end
381
+ def pop
382
+ self.app.reload
383
+ return if self.app.gui?('pop', true)
384
+ self.app.log('pop')
385
+ self.project = Whence.last_unended.project
386
+ defork { system("ruby views/pop.rb --project '#{project.name}' --start '#{project.whences.last_unended.start_at}' --project_time '#{Pratt.totals(project.time_spent)}'") }
387
+ end
388
+
389
+ def i_should? what
390
+ @todo.include?(what)
391
+ end
392
+
393
+ def project?
394
+ !@project.nil? and @project.name?
395
+ end
396
+
397
+ def process_template!
398
+ input = File.open(Pratt.root("views", "#{template}.eruby").first).read
399
+ erubis = Erubis::Eruby.new(input)
400
+ erubis.evaluate(self)
401
+ end
402
+
403
+ def padded_to_max string
404
+ self.class.padded_to_max string
405
+ end
406
+
407
+ def defork &block
408
+ Process.detach(
409
+ fork &block
410
+ )
411
+ end
412
+
413
+ class << self
414
+
415
+
416
+ def max
417
+ # TODO Fix me
418
+ @max ||= Project.all.inject(0) {|x,p| x = p.name.length if p.name.length > x; x }
419
+ end
420
+
421
+ def color
422
+ @@color
423
+ end
424
+
425
+ def color= c
426
+ @@color = c
427
+ end
428
+
429
+ def color?
430
+ @@color == true
431
+ end
432
+
433
+
434
+ def parse args
435
+ me = Pratt.new
436
+
437
+ # There are aa few things we're parsing here
438
+ # Pratt config arguments (Ideally that should be all we do)
439
+ # Pratt actions. These may require ordering or not. They also may require an argument value.
440
+ #
441
+ # TODO: Redo the cli parsing...
442
+ # In some cases we require arguments to be run in a certain order, but we don't want some to be run concurrently w/ others.
443
+ # Sometimes it may be unexpected but helpful to allow that behavior.
444
+ opt = OptionParser.new do |opt|
445
+ Pratt.connect! ENV['PRATT_ENV'] || 'development' unless Pratt.connected?
446
+
447
+ # Actionable options
448
+ opt.on('-b', "--begin", String, "Begin project tracking.") do
449
+ me << :begin
450
+ end
451
+ opt.on('-r', "--restart", String, "Restart project log (stop last log and start a new one).
452
+ Applies to last un-ended project, unless a specific project is provided.") do
453
+ me << :restart
454
+ end
455
+ opt.on('-e', "--end", String, "Stop tracking interval for last project or supplied project if provided.") do
456
+ me << :end
457
+ end
458
+ opt.on('-c', "--change", String, "Change last time interval to this project.") do
459
+ me << :change
460
+ end
461
+ opt.on('-g', "--graph", String, "Display time spent on supplied project or all projects without argument value.") do
462
+ me << :graph
463
+ end
464
+ opt.on('-I', "--invoice", "Create an invoice.") do
465
+ me << :invoice
466
+ end
467
+ opt.on('-p', '--pid', "Process id display. (Is it still running)") do
468
+ me << :pid
469
+ end
470
+ opt.on('-R', '--raw [CONDITIONS]', "Dump logs (semi-)raw") do |conditions|
471
+ me << :raw
472
+ me.raw_conditions = conditions
473
+ end
474
+ opt.on('-C', "--current", "Show available projects and current project (if there is one)") do
475
+ me << :current
476
+ end
477
+ opt.on("-d", "--daemonize", "Start daemon.") do
478
+ me << :daemonize
479
+ end
480
+ opt.on('-D', '--detect', 'Detect appropriate behavior. (Daemonize or Graphical).') do
481
+ me << :detect
482
+ end
483
+ opt.on('-G', '--gui', 'Show "smart" gui.') do
484
+ me << :gui
485
+ end
486
+ opt.on('-q', "--quit", "Stop daemon.") do
487
+ me << :quit
488
+ end
489
+ opt.on('-U', '--unlock', "Manually unlock a gui that has died but left it's lock around.") do
490
+ me << :unlock
491
+ end
492
+ opt.on '-V', '--version' do
493
+ puts "Pro-Reactive Time Tracker [Pratt] (#{VERSION})"
494
+ end
495
+ opt.on('--destroy', String, "Remove a project.") do
496
+ me << :destroy
497
+ end
498
+
499
+ # Strictly configuration options
500
+ opt.on('-P', "--project PROJECT_NAME", String, "Set project.") do |proj|
501
+ me.project = proj
502
+ end
503
+
504
+ templates = []
505
+ Pratt.root("views", "*.eruby") {|view| templates << File.basename(view, '.eruby') }
506
+ opt.on('-t', "--template TEMPLATE", templates, "Template to use for displaying work done.
507
+ Available templates are #{templates.to_sentence('or')}.") do |template|
508
+ me.template = template
509
+ end
510
+ opt.on '-w', '--when_to TIME', String, 'When to do something.
511
+ (e.g. log time start|stop, or what time interval to graph)
512
+ If graphing, silently ignored w/out scale argument.' do |when_to|
513
+ me.when_to = Chronic.parse(when_to).to_datetime
514
+ end
515
+ scales = %w(day week month quarter year)
516
+ opt.on('-l', '--scale SCALE', scales, "Granularity of time argument
517
+ Available scales are #{scales.to_sentence('or')}.
518
+ Only applies to graphing.") do |scale|
519
+ me.scale = scale
520
+ end
521
+ opt.on('-L', '--log', "Redirect errors") do
522
+ FileUtils.mkdir 'log' unless File.exists? 'log'
523
+ $stderr.reopen('log/pratt.log', 'a')
524
+ $stderr.sync = true
525
+ end
526
+ opt.on('-N', '--no-color', "Display output without color or special characters.") do
527
+ Pratt.color = false
528
+ end
529
+ opt.on('-A', '--all', "Display all project regardless of other options.") do
530
+ me.show_all = true
531
+ end
532
+ opt.on '-s', '--start-day WEEK_DAY_START', String, "" do |wday_start|
533
+ me.week_day_start = wday_start
534
+ end
535
+ opt.on('-i', "--interval INTERVAL", Float, "Set the remind interval/min (Only applies to daemonized process).") do |interval|
536
+ me.app.interval = interval
537
+ me.app.save
538
+ end
539
+ <<<<<<< HEAD
540
+ =======
541
+ opt.on('-D', '--detect', 'Detect appropriate behavior. (Daemonize or Graphical).') do
542
+ me << :detect
543
+ end
544
+ opt.on('-G', '--gui', 'Show "smart" gui.') do
545
+ me << :gui
546
+ end
547
+ opt.on("-d", "--daemonize", "Start daemon.") do
548
+ me << :daemonize
549
+ end
550
+ opt.on('-q', "--quit", "Stop daemon.") do
551
+ me << :quit
552
+ end
553
+ opt.on('-G', '--gui', 'Show "smart" gui.') do
554
+ me << :gui
555
+ end
556
+ opt.on('-y', '--tray', 'Show tray') do
557
+ me << :tray_menu
558
+ end
559
+ opt.on('--last MODEL', %w(app project whence log), 'Show the last entry for supplied model') do |model|
560
+ puts send(model.classify, last).inspect
561
+ end
562
+ opt.on('-U', '--unlock', "Manually unlock a gui that has died but left it's lock around.") do
563
+ me.app.unlock
564
+ # me << :unlock
565
+ end
566
+ >>>>>>> 08f31db4a4500d2f6950d31ca6646c6b08ac7432
567
+
568
+ opt.parse!
569
+ end
570
+
571
+ me << :env if args.include? 'env'
572
+ me << :console if args.include? 'console'
573
+
574
+ me.run
575
+ end
576
+
577
+ # Calculate totals. I think this should be an instance method on Projects/?/Whences
578
+ def totals hr, fmt = false
579
+ "#{fmt_i(hr / 24, 'day', :cyan)} #{fmt_i(hr % 24, 'hour', :yellow)} #{fmt_i((60*(hr -= hr.to_i)), 'min', :green)}"
580
+ end
581
+
582
+ def percent label, off, total, color
583
+ percent = "%0.2f"% ((off/total)*100)
584
+ padded_to_max(label) << " #{percent}%".send(color)
585
+ end
586
+
587
+ # Where is Pratt installed.
588
+ # We've already chdir'd to it's base dir in the bin file
589
+ def root *globs, &block
590
+ root = Dir.pwd
591
+ if globs.empty?
592
+ subdir = root
593
+ else
594
+ subdir = File.join(root, *globs)
595
+ end
596
+
597
+ if block_given?
598
+ Pathname.glob(subdir) {|dir_files| yield dir_files }
599
+ else
600
+ Pathname.glob(subdir)
601
+ end
602
+ end
603
+
604
+ # Pad the output string to the maximum Project name
605
+ def padded_to_max string
606
+ "%#{max}.#{max}s"% string
607
+ end
608
+
609
+ # Migrate schema.
610
+ def migrate
611
+ Pratt.root( 'models', '*.rb' ) do |model_file|
612
+ klass = File.basename( model_file, '.rb' ).capitalize.constantize
613
+ begin
614
+ ActiveRecord::Base.connection.table_structure(model_file)
615
+ rescue ActiveRecord::StatementInvalid
616
+ klass.migrate :up if klass.superclass == ActiveRecord::Base
617
+ end
618
+ end
619
+ end
620
+
621
+ def fmt_i int, label, color
622
+ "%s #{label}"% [("%02i"% int).send(color), label]
623
+ end
624
+
625
+ end
626
+ end