doing 2.1.12 → 2.1.16

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 (93) hide show
  1. checksums.yaml +4 -4
  2. data/.irbrc +1 -0
  3. data/.yardoc/checksums +16 -14
  4. data/.yardoc/object_types +0 -0
  5. data/.yardoc/objects/root.dat +0 -0
  6. data/CHANGELOG.md +67 -0
  7. data/Gemfile.lock +9 -2
  8. data/README.md +56 -19
  9. data/bin/doing +317 -113
  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 +1 -1
  18. data/docs/doc/Doing/Completion.html +1 -1
  19. data/docs/doc/Doing/Configuration.html +7 -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 +337 -14
  31. data/docs/doc/Doing/Items.html +66 -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 +84 -1
  40. data/docs/doc/Doing/Util.html +1 -1
  41. data/docs/doc/Doing/WWID.html +214 -35
  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 +881 -138
  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 +408 -256
  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 +257 -48
  68. data/example_plugin.rb +2 -4
  69. data/lib/completion/_doing.zsh +31 -27
  70. data/lib/completion/doing.bash +50 -39
  71. data/lib/completion/doing.fish +37 -7
  72. data/lib/doing/array_chronify.rb +57 -0
  73. data/lib/doing/configuration.rb +4 -1
  74. data/lib/doing/item.rb +176 -0
  75. data/lib/doing/log_adapter.rb +1 -1
  76. data/lib/doing/numeric_chronify.rb +40 -0
  77. data/lib/doing/plugins/export/dayone_export.rb +1 -1
  78. data/lib/doing/plugins/export/json_export.rb +2 -2
  79. data/lib/doing/plugins/export/template_export.rb +47 -90
  80. data/lib/doing/plugins/import/calendar_import.rb +13 -1
  81. data/lib/doing/plugins/import/doing_import.rb +12 -1
  82. data/lib/doing/plugins/import/timing_import.rb +13 -1
  83. data/lib/doing/prompt.rb +54 -1
  84. data/lib/doing/string.rb +97 -33
  85. data/lib/doing/string_chronify.rb +112 -14
  86. data/lib/doing/template_string.rb +1 -1
  87. data/lib/doing/time.rb +6 -6
  88. data/lib/doing/util_backup.rb +1 -1
  89. data/lib/doing/version.rb +1 -1
  90. data/lib/doing/wwid.rb +128 -103
  91. data/lib/doing.rb +36 -31
  92. data/lib/examples/plugins/say_export.rb +1 -4
  93. 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.12'
2
+ VERSION = '2.1.16'
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|
@@ -640,6 +621,7 @@ module Doing
640
621
  ## @option opt [Boolean] :yesterday (false) limit to entries from yesterday
641
622
  ## @option opt [Number] :count (0) max entries to return
642
623
  ## @option opt [String] :age (new) 'old' or 'new'
624
+ ## @option opt [Array] :val (nil) Array of tag value queries
643
625
  ##
644
626
  def filter_items(items = Items.new, opt: {})
645
627
  time_rx = /^(\d{1,2}+(:\d{1,2}+)?( *(am|pm))?|midnight|noon)$/
@@ -701,6 +683,16 @@ module Doing
701
683
  keep = false if finished
702
684
  end
703
685
 
686
+ if keep && opt[:val]&.count&.positive?
687
+ bool = opt[:bool].normalize_bool if opt[:bool]
688
+ bool ||= :and
689
+ bool = :and if bool == :pattern
690
+
691
+ val_match = opt[:val].nil? || opt[:val].empty? ? true : item.tag_values?(opt[:val], bool)
692
+ keep = false unless val_match
693
+ keep = opt[:not] ? !keep : keep
694
+ end
695
+
704
696
  if keep && opt[:tag]
705
697
  opt[:tag_bool] = opt[:bool].normalize_bool if opt[:bool]
706
698
  opt[:tag_bool] ||= :and
@@ -817,6 +809,7 @@ module Doing
817
809
  end
818
810
 
819
811
  def edit_items(items)
812
+ items.sort_by! { |i| i.date }
820
813
  editable_items = []
821
814
 
822
815
  items.each do |i|
@@ -825,16 +818,16 @@ module Doing
825
818
  editable += "\n#{old_note}" unless old_note.nil?
826
819
  editable_items << editable
827
820
  end
828
- divider = "\n-----------\n"
821
+ divider = "-----------"
829
822
  notice =<<~EONOTICE
830
823
  # - You may delete entries, but leave all divider lines (---) in place.
831
824
  # - Start and @done dates replaced with a time string (yesterday 3pm) will
832
825
  # be parsed automatically. Do not delete the pipe (|) between start date
833
826
  # and entry title.
834
827
  EONOTICE
835
- input = "#{editable_items.map(&:strip).join(divider)}\n\n#{notice}"
828
+ input = "#{editable_items.map(&:strip).join("\n#{divider}\n")}\n\n#{notice}"
836
829
 
837
- new_items = fork_editor(input).split(/#{divider}/)
830
+ new_items = fork_editor(input).split(/^#{divider}/).map(&:strip)
838
831
 
839
832
  new_items.each_with_index do |new_item, i|
840
833
  input_lines = new_item.split(/[\n\r]+/).delete_if(&:ignore?)
@@ -843,7 +836,7 @@ module Doing
843
836
  if first_line.nil? || first_line =~ /^#{divider.strip}$/ || first_line.strip.empty?
844
837
  deleted = @content.delete_item(items[i], single: new_items.count == 1)
845
838
  Hooks.trigger :post_entry_removed, self, deleted
846
- Doing.logger.count(:deleted)
839
+ Doing.logger.info('Deleted:', deleted.title)
847
840
  else
848
841
  date, title, note = format_input(new_item)
849
842
 
@@ -883,11 +876,11 @@ module Doing
883
876
  opt[:search] = search
884
877
  end
885
878
 
886
- opt[:query] = opt[:search] if opt[:search] && !opt[:query]
887
- 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]
888
881
  opt[:multiple] = true
889
882
  opt[:show_if_single] = true
890
- filter_options = %i[after before case date_filter from fuzzy not search section].each_with_object({}) {
883
+ filter_options = %i[after before case date_filter from fuzzy not search section val].each_with_object({}) {
891
884
  |k, hsh| hsh[k] = opt[k]
892
885
  }
893
886
  items = filter_items(Items.new, opt: filter_options)
@@ -971,8 +964,14 @@ module Doing
971
964
  type = action =~ /^add/ ? 'add' : 'remove'
972
965
  raise InvalidArgument, "'add tag' and 'remove tag' can not be used together" if opt[:tag]
973
966
 
974
- print "#{yellow("Tag to #{type}: ")}#{reset}"
975
- 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
976
975
  next if tag =~ /^ *$/
977
976
 
978
977
  opt[:tag] = tag.strip.sub(/^@/, '')
@@ -987,15 +986,16 @@ module Doing
987
986
  '--no-sort',
988
987
  '--info=hidden'
989
988
  ])
990
- next if tag =~ /^ *$/
989
+ next if output_format =~ /^ *$/
991
990
 
992
991
  raise UserCancelled unless output_format
993
992
 
994
993
  opt[:output] = output_format.strip
995
994
  res = opt[:force] ? false : Prompt.yn('Save to file?', default_response: 'n')
996
995
  if res
997
- print "#{yellow('File path/name: ')}#{reset}"
998
- 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')
999
999
  next if filename.empty?
1000
1000
 
1001
1001
  opt[:save_to] = filename
@@ -1075,6 +1075,7 @@ module Doing
1075
1075
  tag = opt[:tag]
1076
1076
  items.map! do |i|
1077
1077
  i.tag(tag, date: false, remove: opt[:remove], single: single)
1078
+ i.expand_date_tags(@config['date_tags'])
1078
1079
  Hooks.trigger :post_entry_updated, self, i
1079
1080
  end
1080
1081
  end
@@ -1102,10 +1103,10 @@ module Doing
1102
1103
  i
1103
1104
  end
1104
1105
 
1105
- @content = Items.new
1106
- @content.concat(items)
1107
- @content.add_section(Section.new('Export'), log: false)
1108
- 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' }
1109
1110
 
1110
1111
  if opt[:output] =~ /doing/
1111
1112
  options[:output] = 'template'
@@ -1115,7 +1116,7 @@ module Doing
1115
1116
  options[:template] = opt[:template] || nil
1116
1117
  end
1117
1118
 
1118
- output = list_section(options) # hooked
1119
+ output = list_section(options, items: export_items) # hooked
1119
1120
 
1120
1121
  if opt[:save_to]
1121
1122
  file = File.expand_path(opt[:save_to])
@@ -1134,6 +1135,26 @@ module Doing
1134
1135
  end
1135
1136
  end
1136
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
+
1137
1158
  ##
1138
1159
  ## Tag the last entry or X entries
1139
1160
  ##
@@ -1172,6 +1193,19 @@ module Doing
1172
1193
 
1173
1194
  raise NoResults, 'no items matched your search' if items.empty?
1174
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
+
1175
1209
  items.each do |item|
1176
1210
  added = []
1177
1211
  removed = []
@@ -1195,21 +1229,8 @@ module Doing
1195
1229
  else
1196
1230
  next_entry.date - 60
1197
1231
  end
1198
- elsif opt[:took]
1199
- if item.date + opt[:took] > Time.now
1200
- item.date = Time.now - opt[:took]
1201
- done_date = Time.now
1202
- else
1203
- done_date = item.date + opt[:took]
1204
- end
1205
- elsif opt[:back]
1206
- done_date = if opt[:back].is_a? Integer
1207
- item.date + opt[:back]
1208
- else
1209
- item.date + (opt[:back] - item.date)
1210
- end
1211
1232
  else
1212
- done_date = Time.now
1233
+ done_date = item.calculate_end_date(opt)
1213
1234
  end
1214
1235
 
1215
1236
  opt[:tags].each do |tag|
@@ -1220,15 +1241,39 @@ module Doing
1220
1241
  next
1221
1242
  end
1222
1243
 
1244
+
1223
1245
  tag = tag.strip
1224
- if opt[:remove] || opt[:rename]
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
+
1266
+ if opt[:remove] || opt[:rename] || opt[:value]
1225
1267
  rename_to = nil
1226
- if opt[:rename]
1268
+ if opt[:value]
1269
+ rename_to = tag
1270
+ elsif opt[:rename]
1227
1271
  rename_to = tag
1228
1272
  tag = opt[:rename]
1229
1273
  end
1230
1274
  old_title = item.title.dup
1231
- item.title.tag!(tag, remove: opt[:remove], rename_to: rename_to, regex: opt[:regex])
1275
+ force = opt[:value].nil? ? false : true
1276
+ item.title.tag!(tag, remove: opt[:remove], rename_to: rename_to, regex: opt[:regex], value: opt[:value], force: force)
1232
1277
  if old_title != item.title
1233
1278
  removed << tag
1234
1279
  added << rename_to if rename_to
@@ -1255,6 +1300,7 @@ module Doing
1255
1300
  logger.warn('Skipped:', 'Archiving is skipped when operating on all entries')
1256
1301
  end
1257
1302
 
1303
+ item.expand_date_tags(@config['date_tags'])
1258
1304
  Hooks.trigger :post_entry_updated, self, item
1259
1305
  end
1260
1306
 
@@ -1823,7 +1869,8 @@ module Doing
1823
1869
  case: options[:case],
1824
1870
  not: options[:negate],
1825
1871
  config_template: 'last',
1826
- delete: options[:delete]
1872
+ delete: options[:delete],
1873
+ val: options[:val]
1827
1874
  }
1828
1875
 
1829
1876
  if options[:tag]
@@ -1987,7 +2034,7 @@ module Doing
1987
2034
  EOS
1988
2035
  sorted_tags_data.reverse.each do |k, v|
1989
2036
  if v > 0
1990
- output += "<tr><td style='text-align:left;'>#{k}</td><td style='text-align:left;'>#{'%02d:%02d:%02d' % format_time(v)}</td></tr>\n"
2037
+ output += "<tr><td style='text-align:left;'>#{k}</td><td style='text-align:left;'>#{v.time_string(format: :clock)}</td></tr>\n"
1991
2038
  end
1992
2039
  end
1993
2040
  tail = <<EOS
@@ -1998,7 +2045,7 @@ EOS
1998
2045
  <tfoot>
1999
2046
  <tr>
2000
2047
  <td style="text-align:left;"><strong>Total</strong></td>
2001
- <td style="text-align:left;">#{'%02d:%02d:%02d' % format_time(total)}</td>
2048
+ <td style="text-align:left;">#{total.time_string(format: :clock)}</td>
2002
2049
  </tr>
2003
2050
  </tfoot>
2004
2051
  </table>
@@ -2013,7 +2060,7 @@ EOS
2013
2060
  EOS
2014
2061
  sorted_tags_data.reverse.each do |k, v|
2015
2062
  if v > 0
2016
- output += "| #{' ' * (pad - k.length)}#{k} | #{'%02d:%02d:%02d' % format_time(v)} |\n"
2063
+ output += "| #{' ' * (pad - k.length)}#{k} | #{v.time_string(format: :clock)} |\n"
2017
2064
  end
2018
2065
  end
2019
2066
  tail = "[Tag Totals]"
@@ -2021,11 +2068,10 @@ EOS
2021
2068
  when :json
2022
2069
  output = []
2023
2070
  sorted_tags_data.reverse.each do |k, v|
2024
- d, h, m = format_time(v)
2025
2071
  output << {
2026
2072
  'tag' => k,
2027
2073
  'seconds' => v,
2028
- 'formatted' => format('%<d>02d:%<h>02d:%<m>02d', d: d, h: h, m: m)
2074
+ 'formatted' => v.time_string(format: :clock)
2029
2075
  }
2030
2076
  end
2031
2077
  output
@@ -2036,8 +2082,7 @@ EOS
2036
2082
  (max - k.length).times do
2037
2083
  spacer += ' '
2038
2084
  end
2039
- _d, h, m = format_time(v, human: true)
2040
- output.push("┃ #{spacer}#{k}:#{format('%<h> 4dh %<m>02dm', h: h, m: m)} ┃")
2085
+ output.push("┃ #{spacer}#{k}:#{v.time_string(format: :hm)} ┃")
2041
2086
  end
2042
2087
 
2043
2088
  header = '┏━━ Tag Totals '
@@ -2050,14 +2095,14 @@ EOS
2050
2095
  (max + 12).times { divider += '━' }
2051
2096
  divider += '┫'
2052
2097
  output = output.empty? ? '' : "\n#{header}\n#{output.join("\n")}"
2053
- d, h, m = format_time(total, human: true)
2054
2098
  output += "\n#{divider}"
2055
2099
  spacer = ''
2056
2100
  (max - 6).times do
2057
2101
  spacer += ' '
2058
2102
  end
2103
+ total_time = total.time_string(format: :hm)
2059
2104
  total = "┃ #{spacer}total: "
2060
- total += format('%<h> 4dh %<m>02dm', h: h, m: m)
2105
+ total += total_time
2061
2106
  total += ' ┃'
2062
2107
  output += "\n#{total}"
2063
2108
  output += "\n#{footer}"
@@ -2069,13 +2114,11 @@ EOS
2069
2114
  (max - k.length).times do
2070
2115
  spacer += ' '
2071
2116
  end
2072
- d, h, m = format_time(v)
2073
- output.push("#{k}:#{spacer}#{format('%<d>02d:%<h>02d:%<m>02d', d: d, h: h, m: m)}")
2117
+ output.push("#{k}:#{spacer}#{v.time_string(format: :clock)}")
2074
2118
  end
2075
2119
 
2076
2120
  output = output.empty? ? '' : "\n--- Tag Totals ---\n#{output.join("\n")}"
2077
- d, h, m = format_time(total)
2078
- output += "\n\nTotal tracked: #{format('%<d>02d:%<h>02d:%<m>02d', d: d, h: h, m: m)}\n"
2121
+ output += "\n\nTotal tracked: #{total.time_string(format: :clock)}\n"
2079
2122
  output
2080
2123
  end
2081
2124
  end
@@ -2100,39 +2143,19 @@ EOS
2100
2143
  record_tag_times(item, seconds) if record
2101
2144
  return seconds.positive? ? seconds : false unless formatted
2102
2145
 
2103
- return seconds.positive? ? format('%02d:%02d:%02d', *format_time(seconds)) : false
2146
+ return seconds.positive? ? seconds.time_string(format: :clock) : false
2104
2147
  end
2105
2148
 
2106
2149
  false
2107
2150
  end
2108
2151
 
2109
2152
  ##
2110
- ## Format human readable time from seconds
2153
+ ## Load configuration files and updated the @config
2154
+ ## attribute with a Doing::Configuration object
2111
2155
  ##
2112
- ## @param seconds [Integer] Seconds
2156
+ ## @param filename [String] (optional) path to
2157
+ ## alternative config file
2113
2158
  ##
2114
- def format_time(seconds, human: false)
2115
- return [0, 0, 0] if seconds.nil?
2116
-
2117
- if seconds.instance_of?(String) && seconds =~ /(\d+):(\d+):(\d+)/
2118
- h = Regexp.last_match(1)
2119
- m = Regexp.last_match(2)
2120
- s = Regexp.last_match(3)
2121
- seconds = (h.to_i * 60 * 60) + (m.to_i * 60) + s.to_i
2122
- end
2123
- minutes = (seconds / 60).to_i
2124
- hours = (minutes / 60).to_i
2125
- if human
2126
- minutes = (minutes % 60).to_i
2127
- [0, hours, minutes]
2128
- else
2129
- days = (hours / 24).to_i
2130
- hours = (hours % 24).to_i
2131
- minutes = (minutes % 60).to_i
2132
- [days, hours, minutes]
2133
- end
2134
- end
2135
-
2136
2159
  def configure(filename = nil)
2137
2160
  if filename
2138
2161
  Doing.config_with(filename, { ignore_local: true })
@@ -2212,6 +2235,8 @@ EOS
2212
2235
  break
2213
2236
  end
2214
2237
 
2238
+ logger.debug('Output:', "#{items.count} #{items.count == 1 ? 'item' : 'items'} shown")
2239
+
2215
2240
  out
2216
2241
  end
2217
2242