doing 2.1.13 → 2.1.17

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 (94) hide show
  1. checksums.yaml +4 -4
  2. data/.irbrc +1 -0
  3. data/.yardoc/checksums +14 -12
  4. data/.yardoc/object_types +0 -0
  5. data/.yardoc/objects/root.dat +0 -0
  6. data/CHANGELOG.md +76 -0
  7. data/Gemfile.lock +9 -2
  8. data/README.md +56 -19
  9. data/bin/doing +218 -68
  10. data/docs/doc/Array.html +117 -3
  11. data/docs/doc/BooleanTermParser/Clause.html +1 -1
  12. data/docs/doc/BooleanTermParser/Operator.html +1 -1
  13. data/docs/doc/BooleanTermParser/Query.html +1 -1
  14. data/docs/doc/BooleanTermParser/QueryParser.html +1 -1
  15. data/docs/doc/BooleanTermParser/QueryTransformer.html +1 -1
  16. data/docs/doc/BooleanTermParser.html +1 -1
  17. data/docs/doc/Doing/Color.html +6 -2
  18. data/docs/doc/Doing/Completion.html +1 -1
  19. data/docs/doc/Doing/Configuration.html +8 -4
  20. data/docs/doc/Doing/Errors/DoingNoTraceError.html +1 -1
  21. data/docs/doc/Doing/Errors/DoingRuntimeError.html +1 -1
  22. data/docs/doc/Doing/Errors/DoingStandardError.html +1 -1
  23. data/docs/doc/Doing/Errors/EmptyInput.html +1 -1
  24. data/docs/doc/Doing/Errors/NoResults.html +1 -1
  25. data/docs/doc/Doing/Errors/PluginException.html +1 -1
  26. data/docs/doc/Doing/Errors/UserCancelled.html +1 -1
  27. data/docs/doc/Doing/Errors/WrongCommand.html +1 -1
  28. data/docs/doc/Doing/Errors.html +1 -1
  29. data/docs/doc/Doing/Hooks.html +1 -1
  30. data/docs/doc/Doing/Item.html +340 -14
  31. data/docs/doc/Doing/Items.html +2 -2
  32. data/docs/doc/Doing/LogAdapter.html +1 -1
  33. data/docs/doc/Doing/Note.html +2 -2
  34. data/docs/doc/Doing/Pager.html +1 -1
  35. data/docs/doc/Doing/Plugins.html +1 -1
  36. data/docs/doc/Doing/Prompt.html +103 -1
  37. data/docs/doc/Doing/Section.html +1 -1
  38. data/docs/doc/Doing/TemplateString.html +2 -2
  39. data/docs/doc/Doing/Util/Backup.html +1 -1
  40. data/docs/doc/Doing/Util.html +1 -1
  41. data/docs/doc/Doing/WWID.html +77 -71
  42. data/docs/doc/Doing.html +3 -3
  43. data/docs/doc/GLI/Commands/MarkdownDocumentListener.html +1 -1
  44. data/docs/doc/GLI/Commands.html +1 -1
  45. data/docs/doc/GLI.html +1 -1
  46. data/docs/doc/Hash.html +1 -1
  47. data/docs/doc/Numeric.html +279 -0
  48. data/docs/doc/PhraseParser/Operator.html +1 -1
  49. data/docs/doc/PhraseParser/PhraseClause.html +1 -1
  50. data/docs/doc/PhraseParser/Query.html +1 -1
  51. data/docs/doc/PhraseParser/QueryParser.html +1 -1
  52. data/docs/doc/PhraseParser/QueryTransformer.html +1 -1
  53. data/docs/doc/PhraseParser/TermClause.html +1 -1
  54. data/docs/doc/PhraseParser.html +1 -1
  55. data/docs/doc/Status.html +1 -1
  56. data/docs/doc/String.html +997 -118
  57. data/docs/doc/Symbol.html +1 -1
  58. data/docs/doc/Time.html +1 -1
  59. data/docs/doc/_index.html +14 -9
  60. data/docs/doc/class_list.html +1 -1
  61. data/docs/doc/file.README.html +41 -15
  62. data/docs/doc/index.html +41 -15
  63. data/docs/doc/method_list.html +449 -305
  64. data/docs/doc/top-level-namespace.html +2 -2
  65. data/docs/index.md +56 -19
  66. data/doing.gemspec +2 -0
  67. data/doing.rdoc +76 -9
  68. data/example_plugin.rb +2 -4
  69. data/lib/completion/_doing.zsh +17 -17
  70. data/lib/completion/doing.bash +25 -25
  71. data/lib/completion/doing.fish +18 -6
  72. data/lib/doing/array_chronify.rb +57 -0
  73. data/lib/doing/colors.rb +4 -0
  74. data/lib/doing/configuration.rb +6 -2
  75. data/lib/doing/item.rb +108 -0
  76. data/lib/doing/log_adapter.rb +3 -3
  77. data/lib/doing/numeric_chronify.rb +40 -0
  78. data/lib/doing/plugins/export/dayone_export.rb +1 -1
  79. data/lib/doing/plugins/export/json_export.rb +2 -2
  80. data/lib/doing/plugins/export/template_export.rb +49 -90
  81. data/lib/doing/plugins/import/calendar_import.rb +13 -1
  82. data/lib/doing/plugins/import/doing_import.rb +12 -1
  83. data/lib/doing/plugins/import/timing_import.rb +13 -1
  84. data/lib/doing/prompt.rb +65 -1
  85. data/lib/doing/string.rb +137 -33
  86. data/lib/doing/string_chronify.rb +112 -14
  87. data/lib/doing/template_string.rb +1 -1
  88. data/lib/doing/time.rb +6 -6
  89. data/lib/doing/util_backup.rb +1 -1
  90. data/lib/doing/version.rb +1 -1
  91. data/lib/doing/wwid.rb +117 -106
  92. data/lib/doing.rb +36 -31
  93. data/lib/examples/plugins/say_export.rb +1 -4
  94. metadata +46 -2
@@ -64,22 +64,120 @@ module Doing
64
64
  when /^(\d+):(\d\d)$/
65
65
  minutes += Regexp.last_match(1).to_i * 60
66
66
  minutes += Regexp.last_match(2).to_i
67
- when /^(\d+(?:\.\d+)?)([hmd])?$/
68
- amt = Regexp.last_match(1)
69
- type = Regexp.last_match(2).nil? ? 'm' : Regexp.last_match(2)
70
-
71
- minutes = case type.downcase
72
- when 'm'
73
- amt.to_i
74
- when 'h'
75
- (amt.to_f * 60).round
76
- when 'd'
77
- (amt.to_f * 60 * 24).round
78
- else
79
- minutes
80
- end
67
+ when /^(\d+(?:\.\d+)?)([hmd])?/
68
+ scan(/(\d+(?:\.\d+)?)([hmd])?/).each do |m|
69
+ amt = m[0]
70
+ type = m[1].nil? ? 'm' : m[1]
71
+
72
+ minutes += case type.downcase
73
+ when 'm'
74
+ amt.to_i
75
+ when 'h'
76
+ (amt.to_f * 60).round
77
+ when 'd'
78
+ (amt.to_f * 60 * 24).round
79
+ else
80
+ 0
81
+ end
82
+ end
81
83
  end
82
84
  minutes * 60
83
85
  end
86
+
87
+ ##
88
+ ## Convert DD:HH:MM to seconds
89
+ ##
90
+ ## @return [Integer] rounded number of seconds
91
+ ##
92
+ def to_seconds
93
+ mtch = match(/(\d+):(\d+):(\d+)/)
94
+
95
+ raise Errors::DoingRuntimeError, "Invalid time string: #{self}" unless mtch
96
+
97
+ h = mtch[1]
98
+ m = mtch[2]
99
+ s = mtch[3]
100
+ (h.to_i * 60 * 60) + (m.to_i * 60) + s.to_i
101
+ end
102
+
103
+ ##
104
+ ## Convert DD:HH:MM to a natural language string
105
+ ##
106
+ ## @param format [Symbol] The format to output (:dhm, :hm, :m, :clock, :natural)
107
+ ##
108
+ def time_string(format: :dhm)
109
+ to_seconds.time_string(format: format)
110
+ end
111
+
112
+ ##
113
+ ## Convert (chronify) natural language dates
114
+ ## within configured date tags (tags whose value is
115
+ ## expected to be a date). Modifies string in place.
116
+ ##
117
+ ## @param additional_tags [Array] An array of
118
+ ## additional tags to
119
+ ## consider date_tags
120
+ ##
121
+ def expand_date_tags(additional_tags = nil)
122
+ iso_rx = /\d{4}-\d\d-\d\d \d\d:\d\d/
123
+
124
+ watch_tags = [
125
+ 'start(?:ed)?',
126
+ 'beg[ia]n',
127
+ 'done',
128
+ 'finished',
129
+ 'completed?',
130
+ 'waiting',
131
+ 'defer(?:red)?'
132
+ ]
133
+
134
+ if additional_tags
135
+ date_tags = additional_tags
136
+ date_tags = date_tags.split(/ *, */) if date_tags.is_a?(String)
137
+ date_tags.map! do |tag|
138
+ tag.sub(/^@/, '').gsub(/\((?!\?:)(.*?)\)/, '(?:\1)').strip
139
+ end
140
+ watch_tags.concat(date_tags).uniq!
141
+ end
142
+
143
+ done_rx = /(?<=^| )@(?<tag>#{watch_tags.join('|')})\((?<date>.*?)\)/i
144
+
145
+ gsub!(done_rx) do
146
+ m = Regexp.last_match
147
+ t = m['tag']
148
+ d = m['date']
149
+ future = t =~ /^(done|complete)/ ? false : true
150
+ parsed_date = d =~ iso_rx ? Time.parse(d) : d.chronify(guess: :begin, future: future)
151
+ parsed_date.nil? ? m[0] : "@#{t}(#{parsed_date.strftime('%F %R')})"
152
+ end
153
+ end
154
+
155
+ ##
156
+ ## Splits a range string and returns an array of
157
+ ## DateTime objects as [start, end]. If only one date is
158
+ ## given, end time is nil.
159
+ ##
160
+ ## @return [Array<DateTime>] Start and end dates as
161
+ ## array
162
+ ## @example Process a natural language date range
163
+ ## "mon 3pm to mon 5pm".split_date_range
164
+ ##
165
+ def split_date_range
166
+ date_string = dup
167
+ case date_string
168
+ when / (to|through|thru|(un)?til|-+) /
169
+ dates = date_string.split(/ (?:to|through|thru|(?:un)?til|-+) /)
170
+ start = dates[0].chronify(guess: :begin)
171
+ finish = dates[-1].chronify(guess: :end)
172
+ else
173
+ start = date_string.chronify(guess: :begin)
174
+ finish = nil
175
+ end
176
+
177
+ raise InvalidTimeExpression, 'Unrecognized date string' unless start
178
+
179
+ Doing.logger.debug('Parser:', "date range interpreted as #{start.strftime('%F %R')} -- #{finish ? finish.strftime('%F %R') : 'now'}")
180
+ [start, finish]
181
+ end
84
182
  end
85
183
  end
@@ -176,7 +176,7 @@ module Doing
176
176
  ' '
177
177
  else
178
178
  line = l.gsub(/%/, '\%').strip.wrap(width, pad: pad, indent: indent, offset: 0, prefix: prefix, color: last_color, after: after, reset: reset, pad_first: true)
179
- line.highlight_tags!(tags_color, last_color: last_color) unless tags_color.nil? || tags_color.empty?
179
+ line.highlight_tags!(tags_color, last_color: last_color) unless !tags_color || tags_color.nil? || tags_color.empty?
180
180
  "#{line} "
181
181
  end
182
182
  end.join("\n")
data/lib/doing/time.rb CHANGED
@@ -8,10 +8,10 @@ module Doing
8
8
  strftime('%_I:%M%P')
9
9
  elsif self > (Date.today - 6).to_time
10
10
  strftime('%a %_I:%M%P')
11
- elsif self.year == Date.today.year
11
+ elsif self.year == Date.today.year || (self.year + 1 == Date.today.year && self.month > Date.today.month)
12
12
  strftime('%m/%d %_I:%M%P')
13
13
  else
14
- strftime('%m/%d/%Y %_I:%M%P')
14
+ strftime('%m/%d/%y %_I:%M%P')
15
15
  end
16
16
  end
17
17
 
@@ -25,10 +25,10 @@ module Doing
25
25
  h = h % 24
26
26
 
27
27
  output = []
28
- output.push("#{d} #{'day'.pluralize(d)}") if d.positive?
29
- output.push("#{h} #{'hour'.pluralize(h)}") if h.positive?
30
- output.push("#{m} #{'minute'.pluralize(m)}") if m.positive?
31
- output.push("#{s} #{'second'.pluralize(s)}") if s.positive?
28
+ output.push("#{d} #{'day'.to_p(d)}") if d.positive?
29
+ output.push("#{h} #{'hour'.to_p(h)}") if h.positive?
30
+ output.push("#{m} #{'minute'.to_p(m)}") if m.positive?
31
+ output.push("#{s} #{'second'.to_p(s)}") if s.positive?
32
32
  output.join(', ')
33
33
  end
34
34
 
@@ -79,7 +79,7 @@ module Doing
79
79
  def redo_backup(filename = nil, count: 1)
80
80
  filename ||= Doing.config.settings['doing_file']
81
81
  # redo_file = File.join(backup_dir, "undone___#{File.basename(filename)}")
82
- undones = Dir.glob("undone*#{File.basename(filename)}", base: backup_dir).sort
82
+ undones = Dir.glob("undone*#{File.basename(filename)}", base: backup_dir).sort.reverse
83
83
  total = undones.count
84
84
  count = total if count > total
85
85
 
data/lib/doing/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Doing
2
- VERSION = '2.1.13'
2
+ VERSION = '2.1.17'
3
3
  end
data/lib/doing/wwid.rb CHANGED
@@ -185,34 +185,11 @@ module Doing
185
185
 
186
186
  date = nil
187
187
  iso_rx = /\d{4}-\d\d-\d\d \d\d:\d\d/
188
- watch_tags = [
189
- 'start(?:ed)?',
190
- 'beg[ia]n',
191
- 'done',
192
- 'finished',
193
- 'completed?',
194
- 'waiting',
195
- 'defer(?:red)?'
196
- ]
197
- if @config['date_tags']
198
- date_tags = @config['date_tags']
199
- date_tags = date_tags.split(/ *, */) if date_tags.is_a?(String)
200
- date_tags.map! do |tag|
201
- tag.sub(/^@/, '').gsub(/\((?!\?:)(.*?)\)/, '(?:\1)').strip
202
- end
203
- watch_tags.concat(date_tags).uniq!
204
- end
205
-
206
- done_rx = /(?<=^| )@(?<tag>#{watch_tags.join('|')})\((?<date>.*?)\)/i
207
188
  date_rx = /^(?:\s*- )?(?<date>.*?) \| (?=\S)/
208
189
 
209
- title.gsub!(done_rx) do
210
- m = Regexp.last_match
211
- t = m['tag']
212
- d = m['date']
213
- parsed_date = d =~ date_rx ? Time.parse(d) : d.chronify(guess: :begin)
214
- parsed_date.nil? ? m[0] : "@#{t}(#{parsed_date.strftime('%F %R')})"
215
- end
190
+ raise EmptyInput, 'No content' if title.sub(/^.*?\| */, '').strip.empty?
191
+
192
+ title.expand_date_tags(@config['date_tags'])
216
193
 
217
194
  if title =~ date_rx
218
195
  m = title.match(date_rx)
@@ -369,7 +346,8 @@ module Doing
369
346
  items.each_with_index do |i, x|
370
347
  next if i.title =~ / @done/
371
348
 
372
- items[x].title = "#{i.title} @done(#{opt[:back].strftime('%F %R')})"
349
+ finish_date = verify_duration(i.date, opt[:back], title: i.title)
350
+ items[x].tag('done', value: finish_date.strftime('%F %R'))
373
351
  break
374
352
  end
375
353
  end
@@ -384,10 +362,13 @@ module Doing
384
362
  end
385
363
 
386
364
  ##
387
- ## Remove items from a list that already exist in @content
365
+ ## Remove items from an array that already exist in
366
+ ## @content based on start and end times
388
367
  ##
389
- ## @param items [Array] The items to deduplicate
390
- ## @param no_overlap [Boolean] Remove items with overlapping time spans
368
+ ## @param items [Array] The items to
369
+ ## deduplicate
370
+ ## @param no_overlap [Boolean] Remove items with
371
+ ## overlapping time spans
391
372
  ##
392
373
  def dedup(items, no_overlap: false)
393
374
  items.delete_if do |item|
@@ -817,17 +798,18 @@ module Doing
817
798
  end
818
799
 
819
800
  def delete_items(items, force: false)
820
- res = force ? true : Prompt.yn("Delete #{items.size} #{items.size == 1 ? 'item' : 'items'}?", default_response: 'y')
821
- if res
822
- items.each do |i|
823
- deleted = @content.delete_item(i, single: items.count == 1)
824
- Hooks.trigger :post_entry_removed, self, deleted
825
- end
826
- write(@doing_file)
827
- end
801
+ items.slice(0, 5).each { |i| puts i.to_pretty } unless force
802
+ puts softpurple("+ #{items.size - 5} additional #{'item'.to_p(items.size - 5)}") if items.size > 5 && !force
803
+
804
+ res = force ? true : Prompt.yn("Delete #{items.size} #{'item'.to_p(items.size)}?", default_response: 'y')
805
+ return unless res
806
+
807
+ items.each { |i| Hooks.trigger :post_entry_removed, self, @content.delete_item(i, single: items.count == 1) }
808
+ write(@doing_file)
828
809
  end
829
810
 
830
811
  def edit_items(items)
812
+ items.sort_by! { |i| i.date }
831
813
  editable_items = []
832
814
 
833
815
  items.each do |i|
@@ -836,16 +818,16 @@ module Doing
836
818
  editable += "\n#{old_note}" unless old_note.nil?
837
819
  editable_items << editable
838
820
  end
839
- divider = "\n-----------\n"
821
+ divider = "-----------"
840
822
  notice =<<~EONOTICE
841
823
  # - You may delete entries, but leave all divider lines (---) in place.
842
824
  # - Start and @done dates replaced with a time string (yesterday 3pm) will
843
825
  # be parsed automatically. Do not delete the pipe (|) between start date
844
826
  # and entry title.
845
827
  EONOTICE
846
- input = "#{editable_items.map(&:strip).join(divider)}\n\n#{notice}"
828
+ input = "#{editable_items.map(&:strip).join("\n#{divider}\n")}\n\n#{notice}"
847
829
 
848
- new_items = fork_editor(input).split(/#{divider}/)
830
+ new_items = fork_editor(input).split(/^#{divider}/).map(&:strip)
849
831
 
850
832
  new_items.each_with_index do |new_item, i|
851
833
  input_lines = new_item.split(/[\n\r]+/).delete_if(&:ignore?)
@@ -854,7 +836,7 @@ module Doing
854
836
  if first_line.nil? || first_line =~ /^#{divider.strip}$/ || first_line.strip.empty?
855
837
  deleted = @content.delete_item(items[i], single: new_items.count == 1)
856
838
  Hooks.trigger :post_entry_removed, self, deleted
857
- Doing.logger.count(:deleted)
839
+ Doing.logger.info('Deleted:', deleted.title)
858
840
  else
859
841
  date, title, note = format_input(new_item)
860
842
 
@@ -894,8 +876,8 @@ module Doing
894
876
  opt[:search] = search
895
877
  end
896
878
 
897
- opt[:query] = opt[:search] if opt[:search] && !opt[:query]
898
- opt[:query] = "!#{opt[:query]}" if opt[:not]
879
+ # opt[:query] = opt[:search] if opt[:search] && !opt[:query]
880
+ opt[:query] = "!#{opt[:query]}" if opt[:query] && opt[:not]
899
881
  opt[:multiple] = true
900
882
  opt[:show_if_single] = true
901
883
  filter_options = %i[after before case date_filter from fuzzy not search section val].each_with_object({}) {
@@ -982,8 +964,14 @@ module Doing
982
964
  type = action =~ /^add/ ? 'add' : 'remove'
983
965
  raise InvalidArgument, "'add tag' and 'remove tag' can not be used together" if opt[:tag]
984
966
 
985
- print "#{yellow("Tag to #{type}: ")}#{reset}"
986
- tag = $stdin.gets
967
+ tags = type == 'add' ? all_tags(@content) : all_tags(items)
968
+
969
+ puts "#{yellow}Separate multiple tags with spaces, hit tab to complete known tags#{type == 'add' ? ', include values with tag(value)' : ''}"
970
+ puts "#{boldgreen}Available tags: #{boldwhite}#{tags.sort.map(&:add_at).join(', ')}" if type == 'remove'
971
+ tag = Prompt.read_line(prompt: "Tags to #{type}", completions: tags)
972
+
973
+ # print "#{yellow("Tag to #{type}: ")}#{reset}"
974
+ # tag = $stdin.gets
987
975
  next if tag =~ /^ *$/
988
976
 
989
977
  opt[:tag] = tag.strip.sub(/^@/, '')
@@ -998,15 +986,16 @@ module Doing
998
986
  '--no-sort',
999
987
  '--info=hidden'
1000
988
  ])
1001
- next if tag =~ /^ *$/
989
+ next if output_format =~ /^ *$/
1002
990
 
1003
991
  raise UserCancelled unless output_format
1004
992
 
1005
993
  opt[:output] = output_format.strip
1006
994
  res = opt[:force] ? false : Prompt.yn('Save to file?', default_response: 'n')
1007
995
  if res
1008
- print "#{yellow('File path/name: ')}#{reset}"
1009
- filename = $stdin.gets.strip
996
+ # print "#{yellow('File path/name: ')}#{reset}"
997
+ # filename = $stdin.gets.strip
998
+ filename = Prompt.read_line(prompt: 'File path/name')
1010
999
  next if filename.empty?
1011
1000
 
1012
1001
  opt[:save_to] = filename
@@ -1086,6 +1075,7 @@ module Doing
1086
1075
  tag = opt[:tag]
1087
1076
  items.map! do |i|
1088
1077
  i.tag(tag, date: false, remove: opt[:remove], single: single)
1078
+ i.expand_date_tags(@config['date_tags'])
1089
1079
  Hooks.trigger :post_entry_updated, self, i
1090
1080
  end
1091
1081
  end
@@ -1113,10 +1103,10 @@ module Doing
1113
1103
  i
1114
1104
  end
1115
1105
 
1116
- @content = Items.new
1117
- @content.concat(items)
1118
- @content.add_section(Section.new('Export'), log: false)
1119
- options = { section: 'Export' }
1106
+ export_items = Items.new
1107
+ export_items.concat(items)
1108
+ export_items.add_section(Section.new('Export'), log: false)
1109
+ options = { section: 'All' }
1120
1110
 
1121
1111
  if opt[:output] =~ /doing/
1122
1112
  options[:output] = 'template'
@@ -1126,7 +1116,7 @@ module Doing
1126
1116
  options[:template] = opt[:template] || nil
1127
1117
  end
1128
1118
 
1129
- output = list_section(options) # hooked
1119
+ output = list_section(options, items: export_items) # hooked
1130
1120
 
1131
1121
  if opt[:save_to]
1132
1122
  file = File.expand_path(opt[:save_to])
@@ -1145,6 +1135,26 @@ module Doing
1145
1135
  end
1146
1136
  end
1147
1137
 
1138
+ def verify_duration(date, finish_date, title: nil)
1139
+ max_elapsed = @config.dig('interaction', 'confirm_longer_than') || 0
1140
+ max_elapsed = max_elapsed.chronify_qty if max_elapsed.is_a?(String)
1141
+ elapsed = finish_date - date
1142
+
1143
+ if max_elapsed.positive? && (elapsed > max_elapsed)
1144
+ puts boldwhite(title) if title
1145
+ human = elapsed.time_string(format: :natural)
1146
+ res = Prompt.yn(yellow("Did this entry actually take #{human}"), default_response: true)
1147
+ unless res
1148
+ new_elapsed = Prompt.enter_text('How long did it take?').chronify_qty
1149
+ raise InvalidTimeExpression, 'Unrecognized time span entry' unless new_elapsed.positive?
1150
+
1151
+ finish_date = date + new_elapsed if new_elapsed
1152
+ end
1153
+ end
1154
+
1155
+ finish_date
1156
+ end
1157
+
1148
1158
  ##
1149
1159
  ## Tag the last entry or X entries
1150
1160
  ##
@@ -1183,6 +1193,19 @@ module Doing
1183
1193
 
1184
1194
  raise NoResults, 'no items matched your search' if items.empty?
1185
1195
 
1196
+ if opt[:tags].empty? && !opt[:autotag]
1197
+ completions = opt[:remove] ? all_tags(items) : all_tags(@content)
1198
+ if opt[:remove]
1199
+ puts "#{yellow}Available tags: #{boldwhite}#{completions.map(&:add_at).join(', ')}"
1200
+ else
1201
+ puts "#{yellow}Use tab to complete known tags"
1202
+ end
1203
+ opt[:tags] = Doing::Prompt.read_line(prompt: "Enter tag(s) to #{opt[:remove] ? 'remove' : 'add'}",
1204
+ completions: completions,
1205
+ default_response: '').to_tags
1206
+ raise UserCancelled, 'No tags provided' if opt[:tags].empty?
1207
+ end
1208
+
1186
1209
  items.each do |item|
1187
1210
  added = []
1188
1211
  removed = []
@@ -1206,21 +1229,8 @@ module Doing
1206
1229
  else
1207
1230
  next_entry.date - 60
1208
1231
  end
1209
- elsif opt[:took]
1210
- if item.date + opt[:took] > Time.now
1211
- item.date = Time.now - opt[:took]
1212
- done_date = Time.now
1213
- else
1214
- done_date = item.date + opt[:took]
1215
- end
1216
- elsif opt[:back]
1217
- done_date = if opt[:back].is_a? Integer
1218
- item.date + opt[:back]
1219
- else
1220
- item.date + (opt[:back] - item.date)
1221
- end
1222
1232
  else
1223
- done_date = Time.now
1233
+ done_date = item.calculate_end_date(opt)
1224
1234
  end
1225
1235
 
1226
1236
  opt[:tags].each do |tag|
@@ -1231,7 +1241,28 @@ module Doing
1231
1241
  next
1232
1242
  end
1233
1243
 
1244
+
1234
1245
  tag = tag.strip
1246
+
1247
+ if tag =~ /^done$/
1248
+ max_elapsed = @config.dig('interaction', 'confirm_longer_than') || 0
1249
+ max_elapsed = max_elapsed.chronify_qty if max_elapsed.is_a?(String)
1250
+ elapsed = done_date - item.date
1251
+
1252
+ if max_elapsed.positive? && (elapsed > max_elapsed) && !opt[:took]
1253
+ puts boldwhite(item.title)
1254
+ human = elapsed.time_string(format: :natural)
1255
+ res = Prompt.yn(yellow("Did this actually take #{human}"), default_response: true)
1256
+ unless res
1257
+ new_elapsed = Prompt.enter_text('How long did it take?').chronify_qty
1258
+ raise InvalidTimeExpression, 'Unrecognized time span entry' unless new_elapsed > 0
1259
+
1260
+ opt[:took] = new_elapsed
1261
+ done_date = item.calculate_end_date(opt) if opt[:took]
1262
+ end
1263
+ end
1264
+ end
1265
+
1235
1266
  if opt[:remove] || opt[:rename] || opt[:value]
1236
1267
  rename_to = nil
1237
1268
  if opt[:value]
@@ -1269,6 +1300,7 @@ module Doing
1269
1300
  logger.warn('Skipped:', 'Archiving is skipped when operating on all entries')
1270
1301
  end
1271
1302
 
1303
+ item.expand_date_tags(@config['date_tags'])
1272
1304
  Hooks.trigger :post_entry_updated, self, item
1273
1305
  end
1274
1306
 
@@ -1618,6 +1650,7 @@ module Doing
1618
1650
 
1619
1651
  opt[:output] ||= 'template'
1620
1652
  opt[:wrap_width] ||= @config['templates']['default']['wrap_width'] || 0
1653
+
1621
1654
  output(items, title, is_single, opt)
1622
1655
  end
1623
1656
 
@@ -2002,7 +2035,7 @@ module Doing
2002
2035
  EOS
2003
2036
  sorted_tags_data.reverse.each do |k, v|
2004
2037
  if v > 0
2005
- output += "<tr><td style='text-align:left;'>#{k}</td><td style='text-align:left;'>#{'%02d:%02d:%02d' % format_time(v)}</td></tr>\n"
2038
+ output += "<tr><td style='text-align:left;'>#{k}</td><td style='text-align:left;'>#{v.time_string(format: :clock)}</td></tr>\n"
2006
2039
  end
2007
2040
  end
2008
2041
  tail = <<EOS
@@ -2013,7 +2046,7 @@ EOS
2013
2046
  <tfoot>
2014
2047
  <tr>
2015
2048
  <td style="text-align:left;"><strong>Total</strong></td>
2016
- <td style="text-align:left;">#{'%02d:%02d:%02d' % format_time(total)}</td>
2049
+ <td style="text-align:left;">#{total.time_string(format: :clock)}</td>
2017
2050
  </tr>
2018
2051
  </tfoot>
2019
2052
  </table>
@@ -2028,7 +2061,7 @@ EOS
2028
2061
  EOS
2029
2062
  sorted_tags_data.reverse.each do |k, v|
2030
2063
  if v > 0
2031
- output += "| #{' ' * (pad - k.length)}#{k} | #{'%02d:%02d:%02d' % format_time(v)} |\n"
2064
+ output += "| #{' ' * (pad - k.length)}#{k} | #{v.time_string(format: :clock)} |\n"
2032
2065
  end
2033
2066
  end
2034
2067
  tail = "[Tag Totals]"
@@ -2036,11 +2069,10 @@ EOS
2036
2069
  when :json
2037
2070
  output = []
2038
2071
  sorted_tags_data.reverse.each do |k, v|
2039
- d, h, m = format_time(v)
2040
2072
  output << {
2041
2073
  'tag' => k,
2042
2074
  'seconds' => v,
2043
- 'formatted' => format('%<d>02d:%<h>02d:%<m>02d', d: d, h: h, m: m)
2075
+ 'formatted' => v.time_string(format: :clock)
2044
2076
  }
2045
2077
  end
2046
2078
  output
@@ -2051,8 +2083,7 @@ EOS
2051
2083
  (max - k.length).times do
2052
2084
  spacer += ' '
2053
2085
  end
2054
- _d, h, m = format_time(v, human: true)
2055
- output.push("┃ #{spacer}#{k}:#{format('%<h> 4dh %<m>02dm', h: h, m: m)} ┃")
2086
+ output.push("┃ #{spacer}#{k}:#{v.time_string(format: :hm)} ┃")
2056
2087
  end
2057
2088
 
2058
2089
  header = '┏━━ Tag Totals '
@@ -2065,14 +2096,14 @@ EOS
2065
2096
  (max + 12).times { divider += '━' }
2066
2097
  divider += '┫'
2067
2098
  output = output.empty? ? '' : "\n#{header}\n#{output.join("\n")}"
2068
- d, h, m = format_time(total, human: true)
2069
2099
  output += "\n#{divider}"
2070
2100
  spacer = ''
2071
2101
  (max - 6).times do
2072
2102
  spacer += ' '
2073
2103
  end
2104
+ total_time = total.time_string(format: :hm)
2074
2105
  total = "┃ #{spacer}total: "
2075
- total += format('%<h> 4dh %<m>02dm', h: h, m: m)
2106
+ total += total_time
2076
2107
  total += ' ┃'
2077
2108
  output += "\n#{total}"
2078
2109
  output += "\n#{footer}"
@@ -2084,13 +2115,11 @@ EOS
2084
2115
  (max - k.length).times do
2085
2116
  spacer += ' '
2086
2117
  end
2087
- d, h, m = format_time(v)
2088
- output.push("#{k}:#{spacer}#{format('%<d>02d:%<h>02d:%<m>02d', d: d, h: h, m: m)}")
2118
+ output.push("#{k}:#{spacer}#{v.time_string(format: :clock)}")
2089
2119
  end
2090
2120
 
2091
2121
  output = output.empty? ? '' : "\n--- Tag Totals ---\n#{output.join("\n")}"
2092
- d, h, m = format_time(total)
2093
- output += "\n\nTotal tracked: #{format('%<d>02d:%<h>02d:%<m>02d', d: d, h: h, m: m)}\n"
2122
+ output += "\n\nTotal tracked: #{total.time_string(format: :clock)}\n"
2094
2123
  output
2095
2124
  end
2096
2125
  end
@@ -2115,39 +2144,19 @@ EOS
2115
2144
  record_tag_times(item, seconds) if record
2116
2145
  return seconds.positive? ? seconds : false unless formatted
2117
2146
 
2118
- return seconds.positive? ? format('%02d:%02d:%02d', *format_time(seconds)) : false
2147
+ return seconds.positive? ? seconds.time_string(format: :clock) : false
2119
2148
  end
2120
2149
 
2121
2150
  false
2122
2151
  end
2123
2152
 
2124
2153
  ##
2125
- ## Format human readable time from seconds
2154
+ ## Load configuration files and updated the @config
2155
+ ## attribute with a Doing::Configuration object
2126
2156
  ##
2127
- ## @param seconds [Integer] Seconds
2157
+ ## @param filename [String] (optional) path to
2158
+ ## alternative config file
2128
2159
  ##
2129
- def format_time(seconds, human: false)
2130
- return [0, 0, 0] if seconds.nil?
2131
-
2132
- if seconds.instance_of?(String) && seconds =~ /(\d+):(\d+):(\d+)/
2133
- h = Regexp.last_match(1)
2134
- m = Regexp.last_match(2)
2135
- s = Regexp.last_match(3)
2136
- seconds = (h.to_i * 60 * 60) + (m.to_i * 60) + s.to_i
2137
- end
2138
- minutes = (seconds / 60).to_i
2139
- hours = (minutes / 60).to_i
2140
- if human
2141
- minutes = (minutes % 60).to_i
2142
- [0, hours, minutes]
2143
- else
2144
- days = (hours / 24).to_i
2145
- hours = (hours % 24).to_i
2146
- minutes = (minutes % 60).to_i
2147
- [days, hours, minutes]
2148
- end
2149
- end
2150
-
2151
2160
  def configure(filename = nil)
2152
2161
  if filename
2153
2162
  Doing.config_with(filename, { ignore_local: true })
@@ -2227,6 +2236,8 @@ EOS
2227
2236
  break
2228
2237
  end
2229
2238
 
2239
+ logger.debug('Output:', "#{items.count} #{items.count == 1 ? 'item' : 'items'} shown")
2240
+
2230
2241
  out
2231
2242
  end
2232
2243