doing 2.1.12 → 2.1.16

Sign up to get free protection for your applications and to get access to all the features.
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