doing 2.1.39 → 2.1.40

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.
Files changed (116) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +23 -0
  3. data/Gemfile.lock +1 -1
  4. data/README.md +1 -1
  5. data/bin/commands/config.rb +43 -34
  6. data/bin/commands/done.rb +1 -18
  7. data/bin/commands/finish.rb +30 -25
  8. data/bin/commands/grep.rb +3 -14
  9. data/bin/commands/last.rb +2 -8
  10. data/bin/commands/meanwhile.rb +13 -6
  11. data/bin/commands/on.rb +3 -16
  12. data/bin/commands/recent.rb +2 -8
  13. data/bin/commands/reset.rb +24 -1
  14. data/bin/commands/select.rb +1 -1
  15. data/bin/commands/show.rb +6 -17
  16. data/bin/commands/since.rb +1 -12
  17. data/bin/commands/today.rb +2 -13
  18. data/bin/commands/view.rb +1 -1
  19. data/bin/commands/yesterday.rb +2 -13
  20. data/bin/doing +15 -8
  21. data/docs/doc/Array.html +1 -1
  22. data/docs/doc/BooleanTermParser/Clause.html +1 -1
  23. data/docs/doc/BooleanTermParser/Operator.html +1 -1
  24. data/docs/doc/BooleanTermParser/Query.html +1 -1
  25. data/docs/doc/BooleanTermParser/QueryParser.html +1 -1
  26. data/docs/doc/BooleanTermParser/QueryTransformer.html +1 -1
  27. data/docs/doc/BooleanTermParser.html +1 -1
  28. data/docs/doc/Doing/Color.html +166 -20
  29. data/docs/doc/Doing/Completion.html +1 -1
  30. data/docs/doc/Doing/Configuration.html +1 -1
  31. data/docs/doc/Doing/Errors/DoingNoTraceError.html +7 -3
  32. data/docs/doc/Doing/Errors/DoingRuntimeError.html +7 -3
  33. data/docs/doc/Doing/Errors/DoingStandardError.html +1 -1
  34. data/docs/doc/Doing/Errors/EmptyInput.html +10 -2
  35. data/docs/doc/Doing/Errors/HistoryLimitError.html +194 -0
  36. data/docs/doc/Doing/Errors/InvalidPlugin.html +194 -0
  37. data/docs/doc/Doing/Errors/MissingBackupFile.html +194 -0
  38. data/docs/doc/Doing/Errors/NoResults.html +10 -2
  39. data/docs/doc/Doing/Errors/PluginException.html +1 -1
  40. data/docs/doc/Doing/Errors/UserCancelled.html +10 -2
  41. data/docs/doc/Doing/Errors/WrongCommand.html +10 -2
  42. data/docs/doc/Doing/Errors.html +9 -9
  43. data/docs/doc/Doing/Hooks.html +1 -1
  44. data/docs/doc/Doing/Item.html +90 -1615
  45. data/docs/doc/Doing/Items.html +121 -5
  46. data/docs/doc/Doing/Logger.html +1 -1
  47. data/docs/doc/Doing/Note.html +1 -1
  48. data/docs/doc/Doing/Pager.html +1 -1
  49. data/docs/doc/Doing/Plugins.html +1 -1
  50. data/docs/doc/Doing/Prompt.html +2 -2
  51. data/docs/doc/Doing/Section.html +1 -1
  52. data/docs/doc/Doing/TemplateString.html +2 -2
  53. data/docs/doc/Doing/Types.html +1 -1
  54. data/docs/doc/Doing/Util/Backup.html +5 -5
  55. data/docs/doc/Doing/Util.html +1 -1
  56. data/docs/doc/Doing/WWID.html +197 -4033
  57. data/docs/doc/Doing.html +2 -2
  58. data/docs/doc/FalseClass.html +1 -1
  59. data/docs/doc/GLI/Commands/Help.html +1 -1
  60. data/docs/doc/GLI/Commands/MarkdownDocumentListener.html +1 -1
  61. data/docs/doc/GLI/Commands.html +1 -1
  62. data/docs/doc/GLI.html +1 -1
  63. data/docs/doc/Hash.html +1 -1
  64. data/docs/doc/Object.html +1 -1
  65. data/docs/doc/PhraseParser/Operator.html +1 -1
  66. data/docs/doc/PhraseParser/PhraseClause.html +1 -1
  67. data/docs/doc/PhraseParser/Query.html +1 -1
  68. data/docs/doc/PhraseParser/QueryParser.html +1 -1
  69. data/docs/doc/PhraseParser/QueryTransformer.html +1 -1
  70. data/docs/doc/PhraseParser/TermClause.html +1 -1
  71. data/docs/doc/PhraseParser.html +1 -1
  72. data/docs/doc/Status.html +1 -1
  73. data/docs/doc/String.html +1 -1
  74. data/docs/doc/Symbol.html +1 -1
  75. data/docs/doc/Time.html +1 -1
  76. data/docs/doc/TrueClass.html +1 -1
  77. data/docs/doc/_index.html +26 -5
  78. data/docs/doc/class_list.html +1 -1
  79. data/docs/doc/file.README.html +2 -2
  80. data/docs/doc/index.html +2 -2
  81. data/docs/doc/method_list.html +293 -773
  82. data/docs/doc/top-level-namespace.html +3 -3
  83. data/docs/index.md +1 -1
  84. data/doing.rdoc +49 -7
  85. data/lib/completion/_doing.zsh +5 -5
  86. data/lib/completion/doing.bash +8 -8
  87. data/lib/completion/doing.fish +7 -2
  88. data/lib/doing/add_options.rb +31 -1
  89. data/lib/doing/chronify/array.rb +64 -22
  90. data/lib/doing/colors.rb +77 -30
  91. data/lib/doing/completion.rb +4 -5
  92. data/lib/doing/errors.rb +51 -35
  93. data/lib/doing/hooks.rb +3 -3
  94. data/lib/doing/item/dates.rb +112 -0
  95. data/lib/doing/item/query.rb +433 -0
  96. data/lib/doing/item/state.rb +59 -0
  97. data/lib/doing/item/tags.rb +87 -0
  98. data/lib/doing/item.rb +6 -667
  99. data/lib/doing/items.rb +38 -13
  100. data/lib/doing/plugin_manager.rb +3 -3
  101. data/lib/doing/plugins/export/template_export.rb +4 -4
  102. data/lib/doing/plugins/import/cal_to_json.scpt +0 -0
  103. data/lib/doing/util_backup.rb +6 -8
  104. data/lib/doing/version.rb +1 -1
  105. data/lib/doing/wwid/display.rb +399 -0
  106. data/lib/doing/wwid/editor.rb +214 -0
  107. data/lib/doing/wwid/filetools.rb +186 -0
  108. data/lib/doing/wwid/filter.rb +218 -0
  109. data/lib/doing/wwid/guess.rb +87 -0
  110. data/lib/doing/wwid/interactive.rb +385 -0
  111. data/lib/doing/wwid/modify.rb +618 -0
  112. data/lib/doing/wwid/tags.rb +54 -0
  113. data/lib/doing/wwid/timers.rb +345 -0
  114. data/lib/doing/wwid/wwidutil.rb +104 -0
  115. data/lib/doing/wwid.rb +31 -2317
  116. metadata +19 -2
data/lib/doing/colors.rb CHANGED
@@ -2,9 +2,18 @@
2
2
 
3
3
  # Cribbed from <https://github.com/flori/term-ansicolor>
4
4
  module Doing
5
- # Terminal color functions
5
+ # Terminal output color functions.
6
6
  module Color
7
- # :stopdoc:
7
+ # All available color names. Available as methods and string extensions.
8
+ #
9
+ # @example Use a color as a method. Color reset will be added to end of string.
10
+ # Color.yellow('This text is yellow') => "\e[33mThis text is yellow\e[0m"
11
+ #
12
+ # @example Use a color as a string extension. Color reset added automatically.
13
+ # 'This text is green'.green => "\e[1;32mThis text is green\e[0m"
14
+ #
15
+ # @example Send a text string as a color
16
+ # Color.send('red') => "\e[31m"
8
17
  ATTRIBUTES = [
9
18
  [:clear, 0], # String#clear is already used to empty string in Ruby 1.9
10
19
  [:reset, 0], # synonym for :clear
@@ -66,7 +75,7 @@ module Doing
66
75
  [:redacted, '0;30;40'],
67
76
  [:alert, '1;31;43'],
68
77
  [:error, '1;37;41'],
69
- [:default, '0;39']
78
+ [:default, '0;39']
70
79
  ].map(&:freeze).freeze
71
80
 
72
81
  ATTRIBUTE_NAMES = ATTRIBUTES.transpose.first
@@ -119,24 +128,60 @@ module Doing
119
128
  end
120
129
 
121
130
  class << self
122
- # Returns true, if the coloring function of this module
131
+ # Returns true if the coloring function of this module
123
132
  # is switched on, false otherwise.
124
133
  def coloring?
125
134
  @coloring
126
135
  end
127
136
 
128
- # Turns the coloring on or off globally, so you can easily do
129
- # this for example:
130
- # Doing::Color::coloring = STDOUT.isatty
131
137
  attr_writer :coloring
132
138
 
139
+ ##
140
+ ## Enables colored output
141
+ ##
142
+ ## @example Turn color on or off based on TTY
143
+ ## Doing::Color.coloring = STDOUT.isatty
133
144
  def coloring
134
145
  @coloring ||= true
135
146
  end
147
+
148
+ ##
149
+ ## Convert a template string to a colored string.
150
+ ## Colors are specified with single letters inside
151
+ ## curly braces. Uppercase changes background color.
152
+ ##
153
+ ## w: white, k: black, g: green, l: blue, y: yellow, c: cyan,
154
+ ## m: magenta, r: red, b: bold, u: underline, i: italic,
155
+ ## x: reset (remove background, color, emphasis)
156
+ ##
157
+ ## @example Convert a templated string
158
+ ## Color.template('{Rwb}Warning:{x} {w}you look a little {g}ill{x}')
159
+ ##
160
+ ## @param input [String, Array] The template
161
+ ## string. If this is an array, the
162
+ ## elements will be joined with a
163
+ ## space.
164
+ ##
165
+ ## @return [String] Colorized string
166
+ ##
167
+ def template(input)
168
+ input = input.join(' ') if input.is_a? Array
169
+ fmt = input.gsub(/\{(\w+)\}/) do
170
+ Regexp.last_match(1).split('').map { |c| "%<#{c}>s" }.join('')
171
+ end
172
+
173
+ colors = { w: white, k: black, g: green, l: blue,
174
+ y: yellow, c: cyan, m: magenta, r: red,
175
+ W: bgwhite, K: bgblack, G: bggreen, L: bgblue,
176
+ Y: bgyellow, C: bgcyan, M: bgmagenta, R: bgred,
177
+ b: bold, u: underline, i: italic, x: reset }
178
+
179
+ format(fmt, colors)
180
+ end
136
181
  end
137
182
 
138
183
  ATTRIBUTES.each do |c, v|
139
- eval <<-EOT
184
+ new_method = <<-EOSCRIPT
140
185
  def #{c}(string = nil)
141
186
  result = ''
142
187
  result << "\e[#{v}m" if Doing::Color.coloring?
@@ -152,33 +197,37 @@ module Doing
152
197
  result << "\e[0m" if Doing::Color.coloring?
153
198
  result
154
199
  end
155
- EOT
200
+ EOSCRIPT
201
+
202
+ module_eval(new_method)
203
+
204
+ next unless c =~ /bold/
156
205
 
157
206
  # Accept brightwhite in addition to boldwhite
158
- if c =~ /bold/
159
- eval <<-EOT
160
- def #{c.to_s.sub(/bold/, 'bright')}(string = nil)
161
- result = ''
162
- result << "\e[#{v}m" if Doing::Color.coloring?
163
- if block_given?
164
- result << yield
165
- elsif string.respond_to?(:to_str)
166
- result << string.to_str
167
- elsif respond_to?(:to_str)
168
- result << to_str
169
- else
170
- return result #only switch on
171
- end
172
- result << "\e[0m" if Doing::Color.coloring?
173
- result
207
+ new_method = <<-EOSCRIPT
208
+ def #{c.to_s.sub(/bold/, 'bright')}(string = nil)
209
+ result = ''
210
+ result << "\e[#{v}m" if Doing::Color.coloring?
211
+ if block_given?
212
+ result << yield
213
+ elsif string.respond_to?(:to_str)
214
+ result << string.to_str
215
+ elsif respond_to?(:to_str)
216
+ result << to_str
217
+ else
218
+ return result #only switch on
174
219
  end
175
- EOT
176
- end
220
+ result << "\e[0m" if Doing::Color.coloring?
221
+ result
222
+ end
223
+ EOSCRIPT
224
+
225
+ module_eval(new_method)
177
226
  end
178
227
 
179
228
  # Regular expression that is used to scan for ANSI-sequences while
180
229
  # uncoloring strings.
181
- COLORED_REGEXP = /\e\[(?:(?:[349]|10)[0-7]|[0-9])?m/
230
+ COLORED_REGEXP = /\e\[(?:(?:[349]|10)[0-7]|[0-9])?m/.freeze
182
231
 
183
232
  # Returns an uncolored version of the string, that is all
184
233
  # ANSI-sequences are stripped from the string.
@@ -194,8 +243,6 @@ module Doing
194
243
  end
195
244
  end
196
245
 
197
- module_function
198
-
199
246
  # Returns an array of all Doing::Color attributes as symbols.
200
247
  def attributes
201
248
  ATTRIBUTE_NAMES
@@ -2,11 +2,10 @@
2
2
 
3
3
  require 'tty-progressbar'
4
4
 
5
- $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), 'completion'))
6
- require 'string'
7
- require 'fish_completion'
8
- require 'zsh_completion'
9
- require 'bash_completion'
5
+ require_relative 'completion/string'
6
+ require_relative 'completion/fish_completion'
7
+ require_relative 'completion/zsh_completion'
8
+ require_relative 'completion/bash_completion'
10
9
 
11
10
  module Doing
12
11
  # Completion script generator
data/lib/doing/errors.rb CHANGED
@@ -2,19 +2,27 @@
2
2
 
3
3
  module Doing
4
4
  module Errors
5
- class UserCancelled < ::StandardError
6
- def initialize(msg = 'Cancelled', topic = 'Exited:')
5
+ class DoingNoTraceError < ::StandardError
6
+ def initialize(msg = nil, level: nil, topic: 'Error:', exit_code: 1)
7
+ level ||= :error
7
8
  Doing.logger.output_results
8
- Doing.logger.log_now(:warn, topic, msg)
9
- Process.exit 1
9
+ if msg
10
+ Doing.logger.log_now(level, topic, msg)
11
+ end
12
+
13
+ Process.exit exit_code
10
14
  end
11
15
  end
12
16
 
13
- class EmptyInput < ::StandardError
17
+ class UserCancelled < DoingNoTraceError
18
+ def initialize(msg = 'Cancelled', topic = 'Exited:')
19
+ super(msg, level: :warn, topic: topic, exit_code: 1)
20
+ end
21
+ end
22
+
23
+ class EmptyInput < DoingNoTraceError
14
24
  def initialize(msg = 'No input', topic = 'Exited:')
15
- Doing.logger.output_results
16
- Doing.logger.log_now(:warn, topic, msg)
17
- Process.exit 1
25
+ super(msg, level: :warn, topic: topic, exit_code: 6)
18
26
  end
19
27
  end
20
28
 
@@ -22,44 +30,47 @@ module Doing
22
30
  def initialize(msg = '')
23
31
  Doing.logger.output_results
24
32
 
25
- super
33
+ super(msg)
26
34
  end
27
35
  end
28
36
 
29
- class WrongCommand < ::StandardError
37
+ class WrongCommand < DoingNoTraceError
30
38
  def initialize(msg = 'wrong command', topic: 'Error:')
31
- Doing.logger.warn(topic, msg)
32
-
33
- super(msg)
39
+ super(msg, level: :warn, topic: topic, exit_code: 2)
34
40
  end
35
41
  end
36
42
 
37
43
  class DoingRuntimeError < ::RuntimeError
38
- def initialize(msg = 'Runtime Error', topic: 'Error:')
44
+ def initialize(msg = 'Runtime Error', exit_code = nil, topic: 'Error:')
39
45
  Doing.logger.output_results
40
46
  Doing.logger.log_now(:error, topic, msg)
41
- Process.exit 1
47
+
48
+ Process.exit exit_code if exit_code
42
49
  end
43
50
  end
44
51
 
45
- class NoResults < ::StandardError
52
+ class NoResults < DoingNoTraceError
46
53
  def initialize(msg = 'No results', topic = 'Exited:')
47
- Doing.logger.output_results
48
- Doing.logger.log_now(:warn, topic, msg)
49
- Process.exit 0
54
+ super(msg, level: :warn, topic: topic, exit_code: 0)
50
55
 
51
56
  end
52
57
  end
53
58
 
54
- class DoingNoTraceError < ::StandardError
55
- def initialize(msg = nil, level = nil, topic = nil)
56
- level ||= :error
57
- Doing.logger.output_results
58
- if msg
59
- Doing.logger.log_now(level, topic, msg)
60
- end
59
+ class HistoryLimitError < DoingNoTraceError
60
+ def initialize(msg, exit_code = 24)
61
+ super(msg, level: :error, topic: 'History:', exit_code: exit_code)
62
+ end
63
+ end
61
64
 
62
- Process.exit 1
65
+ class MissingBackupFile < DoingNoTraceError
66
+ def initialize(msg, exit_code = 26)
67
+ super(msg, level: :error, topic: 'History:', exit_code: exit_code)
68
+ end
69
+ end
70
+
71
+ class InvalidPlugin < DoingRuntimeError
72
+ def initialize(kind = 'output', msg = nil)
73
+ super(%(Invalid #{kind} type (#{msg})), 128, topic: 'Invalid:')
63
74
  end
64
75
  end
65
76
 
@@ -75,6 +86,10 @@ module Doing
75
86
  'Import plugin'
76
87
  when /^e/
77
88
  'Export plugin'
89
+ when /^h/
90
+ 'Hook'
91
+ when /^u/
92
+ 'Unrecognized'
78
93
  else
79
94
  type.to_s
80
95
  end
@@ -82,7 +97,8 @@ module Doing
82
97
  msg = "(#{@type}: #{@plugin}) #{msg}"
83
98
 
84
99
  Doing.logger.log_now(:error, 'Plugin:', msg)
85
- Process.exit 1
100
+
101
+ super(msg)
86
102
  end
87
103
  end
88
104
 
@@ -90,17 +106,17 @@ module Doing
90
106
  InvalidPluginType = Class.new(PluginException)
91
107
  PluginUncallable = Class.new(PluginException)
92
108
 
93
- InvalidArgument = Class.new(DoingRuntimeError)
94
- MissingArgument = Class.new(DoingRuntimeError)
95
- MissingFile = Class.new(DoingRuntimeError)
96
- MissingEditor = Class.new(DoingRuntimeError)
109
+ InvalidArgument = Class.new(DoingNoTraceError)
110
+ MissingArgument = Class.new(DoingNoTraceError)
111
+ MissingFile = Class.new(DoingNoTraceError)
112
+ MissingEditor = Class.new(DoingNoTraceError)
97
113
  NonInteractive = Class.new(StandardError)
98
114
 
99
- NoEntryError = Class.new(DoingRuntimeError)
115
+ NoEntryError = Class.new(DoingNoTraceError)
100
116
 
101
117
  InvalidTimeExpression = Class.new(DoingRuntimeError)
102
- InvalidSection = Class.new(DoingRuntimeError)
103
- InvalidView = Class.new(DoingRuntimeError)
118
+ InvalidSection = Class.new(DoingNoTraceError)
119
+ InvalidView = Class.new(DoingNoTraceError)
104
120
 
105
121
  ItemNotFound = Class.new(DoingRuntimeError)
106
122
  # FatalException = Class.new(::RuntimeError)
data/lib/doing/hooks.rb CHANGED
@@ -15,7 +15,7 @@ module Doing
15
15
  post_entry_removed: [], # wwid, entry.dup
16
16
  pre_export: [], # wwid, format, entries
17
17
  pre_write: [], # wwid, file
18
- post_write: [] # wwid, file
18
+ post_write: [] # file
19
19
  }
20
20
 
21
21
  # map of all hooks and their priorities
@@ -40,10 +40,10 @@ module Doing
40
40
  # register a single hook to be called later, internal API
41
41
  def self.register_one(event, priority, &block)
42
42
  unless @registry[event]
43
- raise Doing::Errors::HookUnavailable, "Invalid hook. Doing only supports #{@registry.keys.inspect}"
43
+ raise Doing::Errors::HookUnavailable.new("Invalid hook. Doing only supports #{@registry.keys.inspect}", 'hook', event)
44
44
  end
45
45
 
46
- raise Doing::Errors::PluginUncallable, 'Hooks must respond to :call' unless block.respond_to? :call
46
+ raise Doing::Errors::PluginUncallable.new('Hooks must respond to :call', 'hook', event) unless block.respond_to? :call
47
47
 
48
48
  Doing.logger.debug('Hook Manager:', "Registered #{event} hook") if ENV['DOING_PLUGIN_DEBUG']
49
49
 
@@ -0,0 +1,112 @@
1
+ module Doing
2
+ class Item
3
+ # def date=(new_date)
4
+ # @date = new_date.is_a?(Time) ? new_date : Time.parse(new_date)
5
+ # end
6
+
7
+ ## If the entry doesn't have a @done date, return the elapsed time
8
+ def duration
9
+ return nil unless should_time? && should_finish?
10
+
11
+ return nil if @title =~ /(?<=^| )@done\b/
12
+
13
+ return Time.now - @date
14
+ end
15
+
16
+ ##
17
+ ## Get the difference between the item's start date and
18
+ ## the value of its @done tag (if present)
19
+ ##
20
+ ## @return Interval in seconds
21
+ ##
22
+ def interval
23
+ @interval ||= calc_interval
24
+ end
25
+
26
+ ##
27
+ ## Get the value of the item's @done tag
28
+ ##
29
+ ## @return [Time] @done value
30
+ ##
31
+ def end_date
32
+ @end_date ||= Time.parse(Regexp.last_match(1)) if @title =~ /@done\((\d{4}-\d\d-\d\d \d\d:\d\d.*?)\)/
33
+ end
34
+
35
+ def calculate_end_date(opt)
36
+ if opt[:took]
37
+ if @date + opt[:took] > Time.now
38
+ @date = Time.now - opt[:took]
39
+ Time.now
40
+ else
41
+ @date + opt[:took]
42
+ end
43
+ elsif opt[:back]
44
+ if opt[:back].is_a? Integer
45
+ @date + opt[:back]
46
+ else
47
+ @date + (opt[:back] - @date)
48
+ end
49
+ else
50
+ Time.now
51
+ end
52
+ end
53
+
54
+ ##
55
+ ## Test if two items occur at the same time (same start date and equal duration)
56
+ ##
57
+ ## @param item_b [Item] The item to compare
58
+ ##
59
+ ## @return [Boolean] is equal?
60
+ ##
61
+ def same_time?(item_b)
62
+ date == item_b.date ? interval == item_b.interval : false
63
+ end
64
+
65
+ ##
66
+ ## Test if the interval between start date and @done
67
+ ## value overlaps with another item's
68
+ ##
69
+ ## @param item_b [Item] The item to compare
70
+ ##
71
+ ## @return [Boolean] overlaps?
72
+ ##
73
+ def overlapping_time?(item_b)
74
+ return true if same_time?(item_b)
75
+
76
+ start_a = date
77
+ a_interval = interval
78
+ end_a = a_interval ? start_a + a_interval.to_i : start_a
79
+ start_b = item_b.date
80
+ b_interval = item_b.interval
81
+ end_b = b_interval ? start_b + b_interval.to_i : start_b
82
+ (start_a >= start_b && start_a <= end_b) || (end_a >= start_b && end_a <= end_b) || (start_a < start_b && end_a > end_b)
83
+ end
84
+
85
+ ##
86
+ ## Updates the title of the Item by expanding natural
87
+ ## language dates within configured date tags (tags
88
+ ## whose value is expected to be a date)
89
+ ##
90
+ ## @param additional_tags An array of additional
91
+ ## tag names to consider
92
+ ## dates
93
+ ##
94
+ def expand_date_tags(additional_tags = nil)
95
+ @title.expand_date_tags(additional_tags)
96
+ end
97
+
98
+ private
99
+
100
+ def calc_interval
101
+ return nil unless should_time? && should_finish?
102
+
103
+ done = end_date
104
+ return nil if done.nil?
105
+
106
+ start = @date
107
+
108
+ t = (done - start).to_i
109
+ t.positive? ? t : nil
110
+ end
111
+ end
112
+ end