doing 2.1.7 → 2.1.11

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 (96) hide show
  1. checksums.yaml +4 -4
  2. data/.yardoc/checksums +9 -9
  3. data/.yardoc/object_types +0 -0
  4. data/.yardoc/objects/root.dat +0 -0
  5. data/CHANGELOG.md +39 -1
  6. data/Dockerfile +9 -0
  7. data/Dockerfile-2.6 +9 -0
  8. data/Dockerfile-2.7 +8 -0
  9. data/Dockerfile-3.0 +8 -0
  10. data/Gemfile.lock +1 -1
  11. data/README.md +1 -1
  12. data/Rakefile +51 -6
  13. data/bin/doing +2098 -1944
  14. data/docs/doc/Array.html +1 -1
  15. data/docs/doc/BooleanTermParser/Clause.html +1 -1
  16. data/docs/doc/BooleanTermParser/Operator.html +1 -1
  17. data/docs/doc/BooleanTermParser/Query.html +1 -1
  18. data/docs/doc/BooleanTermParser/QueryParser.html +1 -1
  19. data/docs/doc/BooleanTermParser/QueryTransformer.html +1 -1
  20. data/docs/doc/BooleanTermParser.html +1 -1
  21. data/docs/doc/Doing/Color.html +1 -1
  22. data/docs/doc/Doing/Completion.html +1 -1
  23. data/docs/doc/Doing/Configuration.html +2 -2
  24. data/docs/doc/Doing/Errors/DoingNoTraceError.html +1 -1
  25. data/docs/doc/Doing/Errors/DoingRuntimeError.html +1 -1
  26. data/docs/doc/Doing/Errors/DoingStandardError.html +1 -1
  27. data/docs/doc/Doing/Errors/EmptyInput.html +1 -1
  28. data/docs/doc/Doing/Errors/NoResults.html +1 -1
  29. data/docs/doc/Doing/Errors/PluginException.html +1 -1
  30. data/docs/doc/Doing/Errors/UserCancelled.html +1 -1
  31. data/docs/doc/Doing/Errors/WrongCommand.html +1 -1
  32. data/docs/doc/Doing/Errors.html +1 -1
  33. data/docs/doc/Doing/Hooks.html +1 -1
  34. data/docs/doc/Doing/Item.html +1 -1
  35. data/docs/doc/Doing/Items.html +1 -1
  36. data/docs/doc/Doing/LogAdapter.html +1 -1
  37. data/docs/doc/Doing/Note.html +1 -1
  38. data/docs/doc/Doing/Pager.html +1 -1
  39. data/docs/doc/Doing/Plugins.html +1 -1
  40. data/docs/doc/Doing/Prompt.html +132 -18
  41. data/docs/doc/Doing/Section.html +1 -1
  42. data/docs/doc/Doing/TemplateString.html +2 -2
  43. data/docs/doc/Doing/Util/Backup.html +79 -2
  44. data/docs/doc/Doing/Util.html +1 -1
  45. data/docs/doc/Doing/WWID.html +90 -77
  46. data/docs/doc/Doing.html +2 -2
  47. data/docs/doc/GLI/Commands/MarkdownDocumentListener.html +1 -1
  48. data/docs/doc/GLI/Commands.html +1 -1
  49. data/docs/doc/GLI.html +1 -1
  50. data/docs/doc/Hash.html +1 -1
  51. data/docs/doc/PhraseParser/Operator.html +1 -1
  52. data/docs/doc/PhraseParser/PhraseClause.html +1 -1
  53. data/docs/doc/PhraseParser/Query.html +1 -1
  54. data/docs/doc/PhraseParser/QueryParser.html +1 -1
  55. data/docs/doc/PhraseParser/QueryTransformer.html +1 -1
  56. data/docs/doc/PhraseParser/TermClause.html +1 -1
  57. data/docs/doc/PhraseParser.html +1 -1
  58. data/docs/doc/Status.html +1 -1
  59. data/docs/doc/String.html +97 -1
  60. data/docs/doc/Symbol.html +36 -2
  61. data/docs/doc/Time.html +1 -1
  62. data/docs/doc/_index.html +1 -1
  63. data/docs/doc/file.README.html +2 -2
  64. data/docs/doc/index.html +2 -2
  65. data/docs/doc/method_list.html +299 -235
  66. data/docs/doc/top-level-namespace.html +1 -1
  67. data/docs/index.md +1 -1
  68. data/doing.rdoc +9 -2
  69. data/generate_completions.sh +1 -3
  70. data/lib/completion/_doing.zsh +1 -1
  71. data/lib/completion/doing.bash +2 -2
  72. data/lib/completion/doing.fish +2 -1
  73. data/lib/doing/completion/bash_completion.rb +2 -2
  74. data/lib/doing/completion/fish_completion.rb +2 -2
  75. data/lib/doing/completion/zsh_completion.rb +2 -2
  76. data/lib/doing/completion.rb +12 -2
  77. data/lib/doing/configuration.rb +19 -9
  78. data/lib/doing/hooks.rb +10 -5
  79. data/lib/doing/items.rb +16 -1
  80. data/lib/doing/log_adapter.rb +1 -0
  81. data/lib/doing/pager.rb +2 -20
  82. data/lib/doing/plugins/import/calendar_import.rb +5 -0
  83. data/lib/doing/plugins/import/doing_import.rb +2 -0
  84. data/lib/doing/plugins/import/timing_import.rb +5 -0
  85. data/lib/doing/prompt.rb +47 -8
  86. data/lib/doing/string.rb +20 -0
  87. data/lib/doing/symbol.rb +4 -0
  88. data/lib/doing/util_backup.rb +38 -8
  89. data/lib/doing/version.rb +1 -1
  90. data/lib/doing/wwid.rb +211 -106
  91. data/lib/doing.rb +1 -0
  92. data/lib/examples/plugins/hooks.rb +31 -0
  93. data/scripts/generate_bash_completions.rb +2 -2
  94. data/scripts/sort_commands.rb +59 -0
  95. metadata +7 -3
  96. data/lib/helpers/fuzzyfilefinder +0 -0
data/bin/doing CHANGED
@@ -61,6 +61,7 @@ Doing.logger.benchmark(:configure, :start)
61
61
  config = Doing.config
62
62
  Doing.logger.benchmark(:configure, :finish)
63
63
 
64
+ config.settings['backup_dir'] = ENV['DOING_BACKUP_DIR'] if ENV['DOING_BACKUP_DIR']
64
65
  settings = config.settings
65
66
  wwid.config = settings
66
67
 
@@ -77,6 +78,8 @@ view your entries with myriad options, with a focus on a "natural" language synt
77
78
  default_command :recent
78
79
  # sort_help :manually
79
80
 
81
+ ## Global options
82
+
80
83
  desc 'Output notes if included in the template'
81
84
  switch [:notes], default_value: true, negatable: true
82
85
 
@@ -113,115 +116,32 @@ flag [:config_file], default_value: config.config_file
113
116
  desc 'Specify a different doing_file'
114
117
  flag %i[f doing_file]
115
118
 
116
- desc 'Add an entry'
117
- long_desc %(Record what you're starting now, or backdate the start time using natural language.
118
-
119
- A parenthetical at the end of the entry will be converted to a note.
120
-
121
- Run with no argument to create a new entry using #{Doing::Util.default_editor}.)
122
- arg_name 'ENTRY'
123
- command %i[now next] do |c|
124
- c.example 'doing now', desc: "Open #{Doing::Util.default_editor} to input an entry and optional note."
125
- c.example 'doing now working on a new project', desc: 'Add a new entry at the current time'
126
- c.example 'doing now debugging @project2', desc: 'Add an entry with a tag'
127
- c.example 'doing now adding an entry (with a note)', desc: 'Parenthetical at end is converted to note'
128
- c.example 'doing now --back 2pm A thing I started at 2:00 and am still doing...', desc: 'Backdate an entry'
129
-
130
- c.desc 'Section'
131
- c.arg_name 'NAME'
132
- c.flag %i[s section]
133
-
134
- c.desc "Edit entry with #{Doing::Util.default_editor}"
135
- c.switch %i[e editor], negatable: false, default_value: false
136
-
137
- c.desc 'Backdate start time [4pm|20m|2h|"yesterday noon"]'
138
- c.arg_name 'DATE_STRING'
139
- c.flag %i[b back started]
140
-
141
- c.desc 'Timed entry, marks last entry in section as @done'
142
- c.switch %i[f finish_last], negatable: false, default_value: false
143
-
144
- c.desc 'Include a note'
145
- c.arg_name 'TEXT'
146
- c.flag %i[n note]
147
-
148
- # c.desc "Edit entry with specified app"
149
- # c.arg_name 'editor_app'
150
- # # c.flag [:a, :app]
151
-
152
- c.action do |_global_options, options, args|
153
- if options[:back]
154
- date = options[:back].chronify(guess: :begin)
155
-
156
- raise InvalidTimeExpression.new('unable to parse date string', topic: 'Parser:') if date.nil?
157
- else
158
- date = Time.now
159
- end
160
-
161
- if options[:section]
162
- section = wwid.guess_section(options[:section]) || options[:section].cap_first
163
- else
164
- options[:section] = settings['current_section']
165
- end
166
-
167
- if options[:editor] || (args.empty? && $stdin.stat.size.zero?)
168
- raise MissingEditor, 'No EDITOR variable defined in environment' if Doing::Util.default_editor.nil?
169
-
170
- input = date.strftime('%F %R | ')
171
- input += args.join(' ') unless args.empty?
172
- input = wwid.fork_editor(input).strip
173
-
174
- raise EmptyInput, 'No content' if input.empty?
175
-
176
- date, title, note = wwid.format_input(input)
177
- note.add(options[:note]) if options[:note]
178
- wwid.add_item(title.cap_first, section, { note: note, back: date, timed: options[:finish_last] })
179
- wwid.write(wwid.doing_file)
180
- elsif args.length.positive?
181
- d, title, note = wwid.format_input(args.join(' '))
182
- date = d.nil? ? date : d
183
- note.add(options[:note]) if options[:note]
184
- wwid.add_item(title.cap_first, section, { note: note, back: date, timed: options[:finish_last] })
185
- wwid.write(wwid.doing_file)
186
- elsif $stdin.stat.size.positive?
187
- input = $stdin.read.strip
188
- d, title, note = wwid.format_input(input)
189
- unless d.nil?
190
- Doing.logger.debug('Parser:', 'Date detected in input, overriding command line values')
191
- date = d
192
- end
193
- note.add(options[:note]) if options[:note]
194
- wwid.add_item(title.cap_first, section, { note: note, back: date, timed: options[:finish_last] })
195
- wwid.write(wwid.doing_file)
196
- else
197
- raise EmptyInput, 'You must provide content when creating a new entry'
198
- end
199
- end
200
- end
119
+ ## Add/modify commands
201
120
 
202
- desc 'Reset the start time of an entry'
203
- long_desc 'Update the start time of the last entry or the last entry matching a tag/search filter.
204
- If no argument is provided, the start time will be reset to the current time.
205
- If a date string is provided as an argument, the start time will be set to the parsed result.'
206
- arg_name 'DATE_STRING'
207
- command %i[reset begin] do |c|
208
- c.example 'doing reset', desc: 'Reset the start time of the last entry to the current time'
209
- c.example 'doing reset --tag project1', desc: 'Reset the start time of the most recent entry tagged @project1 to the current time'
210
- c.example 'doing reset 3pm', desc: 'Reset the start time of the last entry to 3pm of the current day'
211
- c.example 'doing begin --tag todo --resume', desc: 'alias for reset. Updates the last @todo entry to the current time, removing @done tag.'
121
+ # @@again @@resume
122
+ desc 'Repeat last entry as new entry'
123
+ long_desc 'This command is designed to allow multiple time intervals to be created for an entry by duplicating it with a new start (and end, eventually) time.'
124
+ command %i[again resume] do |c|
125
+ c.example 'doing resume', desc: 'Duplicate the most recent entry with a new start time, removing any @done tag'
126
+ c.example 'doing again', desc: 'again is an alias for resume'
127
+ c.example 'doing resume --editor', desc: 'Repeat the last entry, opening the new entry in the default editor'
128
+ c.example 'doing resume --tag project1 --in Projects', desc: 'Repeat the last entry tagged @project1, creating the new entry in the Projects section'
129
+ c.example 'doing resume --interactive', desc: 'Select the entry to repeat from a menu'
212
130
 
213
- c.desc 'Limit search to section'
131
+ c.desc 'Get last entry from a specific section'
214
132
  c.arg_name 'NAME'
215
133
  c.flag %i[s section], default_value: 'All'
216
134
 
217
- c.desc 'Resume entry (remove @done)'
218
- c.switch %i[r resume], default_value: true
135
+ c.desc 'Add new entry to section (default: same section as repeated entry)'
136
+ c.arg_name 'SECTION_NAME'
137
+ c.flag [:in]
219
138
 
220
- c.desc 'Reset last entry matching tag. Wildcards allowed (*, ?).'
139
+ c.desc 'Repeat last entry matching tags. Combine multiple tags with a comma. Wildcards allowed (*, ?).'
221
140
  c.arg_name 'TAG'
222
141
  c.flag [:tag]
223
142
 
224
- c.desc 'Reset last entry matching search filter, surround with slashes for regex (e.g. "/query.*/"), start with single quote for exact match ("\'query")'
143
+ c.desc 'Repeat last entry matching search. Surround with
144
+ slashes for regex (e.g. "/query/"), start with a single quote for exact match ("\'query").'
225
145
  c.arg_name 'QUERY'
226
146
  c.flag [:search]
227
147
 
@@ -231,34 +151,30 @@ command %i[reset begin] do |c|
231
151
  c.desc 'Force exact search string matching (case sensitive)'
232
152
  c.switch %i[x exact], default_value: config.exact_match?, negatable: config.exact_match?
233
153
 
234
- c.desc 'Reset items that *don\'t* match search/tag filters'
154
+ c.desc 'Resume items that *don\'t* match search/tag filters'
235
155
  c.switch [:not], default_value: false, negatable: false
236
156
 
237
157
  c.desc 'Case sensitivity for search string matching [(c)ase-sensitive, (i)gnore, (s)mart]'
238
158
  c.arg_name 'TYPE'
239
159
  c.flag [:case], must_match: /^[csi]/, default_value: settings.dig('search', 'case')
240
160
 
241
- c.desc 'Boolean (AND|OR|NOT) with which to combine multiple tag filters'
161
+ c.desc 'Boolean used to combine multiple tags. Use PATTERN to parse + and - as booleans.'
242
162
  c.arg_name 'BOOLEAN'
243
163
  c.flag [:bool], must_match: REGEX_BOOL, default_value: 'PATTERN'
244
164
 
245
- c.desc 'Select from a menu of matching entries'
246
- c.switch %i[i interactive], negatable: false, default_value: false
165
+ c.desc "Edit duplicated entry with #{Doing::Util.default_editor} before adding"
166
+ c.switch %i[e editor], negatable: false, default_value: false
247
167
 
248
- c.action do |global_options, options, args|
249
- if args.count > 0
250
- reset_date = args.join(' ').chronify(guess: :begin)
251
- raise InvalidArgument, 'Invalid date string' unless reset_date
252
- else
253
- reset_date = Time.now
254
- end
168
+ c.desc 'Note'
169
+ c.arg_name 'TEXT'
170
+ c.flag %i[n note]
255
171
 
256
- options[:fuzzy] = false
257
- if options[:section]
258
- options[:section] = wwid.guess_section(options[:section]) || options[:section].cap_first
259
- end
172
+ c.desc 'Select item to resume from a menu of matching entries'
173
+ c.switch %i[i interactive], negatable: false, default_value: false
260
174
 
261
- options[:bool] = options[:bool].normalize_bool
175
+ c.action do |_global_options, options, _args|
176
+ options[:fuzzy] = false
177
+ tags = options[:tag].nil? ? [] : options[:tag].to_tags
262
178
 
263
179
  options[:case] = options[:case].normalize_case
264
180
 
@@ -268,65 +184,40 @@ command %i[reset begin] do |c|
268
184
  options[:search] = search
269
185
  end
270
186
 
187
+ opts = options.dup
271
188
 
272
- items = wwid.filter_items([], opt: options)
273
-
274
- if options[:interactive]
275
- last_entry = Doing::Prompt.choose_from_items(items, include_section: options[:section].nil?,
276
- menu: true,
277
- header: '',
278
- prompt: 'Select an entry to start/reset > ',
279
- multiple: false,
280
- sort: false,
281
- show_if_single: true)
282
- else
283
- last_entry = items.reverse.last
284
- end
285
-
286
- unless last_entry
287
- Doing.logger.warn('Not found:', 'No entry matching parameters was found.')
288
- return
289
- end
290
-
291
- wwid.reset_item(last_entry, date: reset_date, resume: options[:resume])
292
-
293
- # new_entry = Doing::Item.new(last_entry.date, last_entry.title, last_entry.section, new_note)
189
+ opts[:tag] = tags
190
+ opts[:tag_bool] = options[:bool].normalize_bool
191
+ opts[:interactive] = options[:interactive]
294
192
 
295
- wwid.write(wwid.doing_file)
193
+ wwid.repeat_last(opts)
296
194
  end
297
195
  end
298
196
 
197
+ # @@cancel
198
+ desc 'End last X entries with no time tracked'
199
+ long_desc 'Adds @done tag without datestamp so no elapsed time is recorded. Alias for `doing finish --no-date`.'
200
+ arg_name 'COUNT'
201
+ command :cancel do |c|
202
+ c.example 'doing cancel', desc: 'Cancel the last entry'
203
+ c.example 'doing cancel --tag project1 -u 5', desc: 'Cancel the last 5 unfinished entries containing @project1'
299
204
 
300
- desc 'Add a note to the last entry'
301
- long_desc %(
302
- If -r is provided with no other arguments, the last note is removed.
303
- If new content is specified through arguments or STDIN, any previous
304
- note will be replaced with the new one.
305
-
306
- Use -e to load the last entry in a text editor where you can append a note.
307
- )
308
- arg_name 'NOTE_TEXT'
309
- command :note do |c|
310
- c.example 'doing note', desc: 'Open the last entry in $EDITOR to append a note'
311
- c.example 'doing note "Just a quick annotation"', desc: 'Add a quick note to the last entry'
312
- c.example 'doing note --tag done "Keeping it real or something"', desc: 'Add a note to the last item tagged @done'
313
- c.example 'doing note --search "late night" -e', desc: 'Open $EDITOR to add a note to the last item containing "late night" (fuzzy matched)'
205
+ c.desc 'Archive entries'
206
+ c.switch %i[a archive], negatable: false, default_value: false
314
207
 
315
208
  c.desc 'Section'
316
209
  c.arg_name 'NAME'
317
- c.flag %i[s section], default_value: 'All'
318
-
319
- c.desc "Edit entry with #{Doing::Util.default_editor}"
320
- c.switch %i[e editor], negatable: false, default_value: false
321
-
322
- c.desc "Replace/Remove last entry's note (default append)"
323
- c.switch %i[r remove], negatable: false, default_value: false
210
+ c.flag %i[s section]
324
211
 
325
- c.desc 'Add/remove note from last entry matching tag. Wildcards allowed (*, ?).'
212
+ c.desc 'Cancel the last X entries containing TAG. Separate multiple tags with comma (--tag=tag1,tag2). Wildcards allowed (*, ?).'
326
213
  c.arg_name 'TAG'
327
214
  c.flag [:tag]
328
215
 
329
- c.desc 'Add/remove note from last entry matching search filter, surround with slashes for regex (e.g. "/query.*/"), start with single quote for exact match ("\'query")'
216
+ c.desc 'Boolean (AND|OR|NOT) with which to combine multiple tag filters. Use PATTERN to parse + and - as booleans.'
217
+ c.arg_name 'BOOLEAN'
218
+ c.flag [:bool], must_match: REGEX_BOOL, default_value: 'PATTERN'
219
+
220
+ c.desc 'Cancel the last X entries matching search filter, surround with slashes for regex (e.g. "/query.*/"), start with single quote for exact match ("\'query")'
330
221
  c.arg_name 'QUERY'
331
222
  c.flag [:search]
332
223
 
@@ -336,121 +227,154 @@ command :note do |c|
336
227
  c.desc 'Force exact search string matching (case sensitive)'
337
228
  c.switch %i[x exact], default_value: config.exact_match?, negatable: config.exact_match?
338
229
 
339
- c.desc 'Add note to item that *doesn\'t* match search/tag filters'
230
+ c.desc 'Finish items that *don\'t* match search/tag filters'
340
231
  c.switch [:not], default_value: false, negatable: false
341
232
 
342
233
  c.desc 'Case sensitivity for search string matching [(c)ase-sensitive, (i)gnore, (s)mart]'
343
234
  c.arg_name 'TYPE'
344
235
  c.flag [:case], must_match: /^[csi]/, default_value: settings.dig('search', 'case')
345
236
 
346
- c.desc 'Boolean (AND|OR|NOT) with which to combine multiple tag filters. Use PATTERN to parse + and - as booleans.'
347
- c.arg_name 'BOOLEAN'
348
- c.flag [:bool], must_match: REGEX_BOOL, default_value: 'PATTERN'
237
+ c.desc 'Cancel last entry (or entries) not already marked @done'
238
+ c.switch %i[u unfinished], negatable: false, default_value: false
349
239
 
350
- c.desc 'Select item for new note from a menu of matching entries'
240
+ c.desc 'Select item(s) to cancel from a menu of matching entries'
351
241
  c.switch %i[i interactive], negatable: false, default_value: false
352
242
 
353
243
  c.action do |_global_options, options, args|
354
244
  options[:fuzzy] = false
355
245
  if options[:section]
356
- options[:section] = wwid.guess_section(options[:section]) || options[:section].cap_first
246
+ section = wwid.guess_section(options[:section]) || options[:section].cap_first
247
+ else
248
+ section = settings['current_section']
357
249
  end
358
250
 
359
- options[:tag_bool] = options[:bool].normalize_bool
360
-
361
- options[:case] = options[:case].normalize_case
362
-
363
- if options[:search]
364
- search = options[:search]
365
- search.sub!(/^'?/, "'") if options[:exact]
366
- options[:search] = search
251
+ if options[:tag].nil?
252
+ tags = []
253
+ else
254
+ tags = options[:tag].to_tags
367
255
  end
368
256
 
257
+ raise InvalidArgument, 'Only one argument allowed' if args.length > 1
369
258
 
370
- last_entry = wwid.last_entry(options)
259
+ raise InvalidArgument, 'Invalid argument (specify number of recent items to mark @done)' unless args.empty? || args[0] =~ /\d+/
371
260
 
372
- unless last_entry
373
- Doing.logger.warn('Not found:', 'No entry matching parameters was found.')
374
- return
261
+ if options[:interactive]
262
+ count = 0
263
+ else
264
+ count = args[0] ? args[0].to_i : 1
375
265
  end
376
266
 
377
- last_note = last_entry.note || Doing::Note.new
378
- new_note = Doing::Note.new
379
-
380
- if options[:editor] || (args.empty? && $stdin.stat.size.zero? && !options[:remove])
381
- raise MissingEditor, 'No EDITOR variable defined in environment' if Doing::Util.default_editor.nil?
267
+ search = nil
382
268
 
383
- input = !args.empty? ? args.join(' ') : ''
269
+ if options[:search]
270
+ search = options[:search]
271
+ search.sub!(/^'?/, "'") if options[:exact]
272
+ end
384
273
 
385
- if options[:remove]
386
- prev_input = Doing::Note.new
387
- else
388
- prev_input = last_entry.note || Doing::Note.new
389
- end
390
-
391
- input = prev_input.add(input)
392
-
393
- input = wwid.fork_editor(prev_input.strip_lines.join("\n"), message: nil).strip
394
- note = input
395
- options[:remove] = true
396
- new_note.add(note)
397
- elsif !args.empty?
398
- new_note.add(args.join(' '))
399
- elsif $stdin.stat.size.positive?
400
- new_note.add($stdin.read.strip)
401
- else
402
- raise EmptyInput, 'You must provide content when adding a note' unless options[:remove]
403
- end
404
-
405
- if last_note.equal?(new_note)
406
- Doing.logger.debug('Skipped:', 'No note change')
407
- else
408
- last_note.add(new_note, replace: options[:remove])
409
- Doing.logger.info('Entry updated:', last_entry.title)
410
- end
411
- # new_entry = Doing::Item.new(last_entry.date, last_entry.title, last_entry.section, new_note)
274
+ opts = {
275
+ archive: options[:archive],
276
+ case: options[:case].normalize_case,
277
+ count: count,
278
+ date: false,
279
+ fuzzy: options[:fuzzy],
280
+ interactive: options[:interactive],
281
+ not: options[:not],
282
+ search: search,
283
+ section: section,
284
+ sequential: false,
285
+ tag: tags,
286
+ tag_bool: options[:bool].normalize_bool,
287
+ tags: ['done'],
288
+ unfinished: options[:unfinished]
289
+ }
412
290
 
413
- wwid.write(wwid.doing_file)
291
+ wwid.tag_last(opts)
414
292
  end
415
293
  end
416
294
 
417
- desc 'Finish any running @meanwhile tasks and optionally create a new one'
418
- long_desc 'The @meanwhile tag allows you to have long-running entries that encompass smaller entries.
419
- This command makes it easy to start and stop these overarching entries. Just run `doing meanwhile Starting work on this
420
- big project` to start a @meanwhile entry, add other entries as you work on the project, then use `doing meanwhile` by
421
- itself to mark the entry as @done.'
295
+ # @@done @@did
296
+ desc 'Add a completed item with @done(date). No argument finishes last entry.'
297
+ long_desc 'Use this command to add an entry after you\'ve already finished it. It will be immediately marked as @done.
298
+ You can modify the start and end times of the entry using the --back, --took, and --at flags, making it an easy
299
+ way to add entries in post and maintain accurate (albeit manual) time tracking.'
422
300
  arg_name 'ENTRY'
423
- command :meanwhile do |c|
424
- c.example 'doing meanwhile "Long task that will have others after it before it\'s done"', desc: 'Add a new long-running entry, completing any current @meanwhile entry'
425
- c.example 'doing meanwhile', desc: 'Finish any open @meanwhile entry'
426
- c.example 'doing meanwhile --archive', desc: 'Finish any open @meanwhile entry and archive it'
427
- c.example 'doing meanwhile --back 2h "Something I\'ve been working on for a while', desc: 'Add a @meanwhile entry with a start date 2 hours ago'
301
+ command %i[done did] do |c|
302
+ c.example 'doing done', desc: 'Tag the last entry @done'
303
+ c.example 'doing done I already finished this', desc: 'Add a new entry and immediately mark it @done'
304
+ c.example 'doing done --back 30m This took me half an hour', desc: 'Add an entry with a start date 30 minutes ago and a @done date of right now'
305
+ c.example 'doing done --at 3pm --took 1h Started and finished this afternoon', desc: 'Add an entry with a @done date of 3pm and a start date of 2pm (3pm - 1h)'
428
306
 
429
- c.desc 'Section'
430
- c.arg_name 'NAME'
431
- c.flag %i[s section]
307
+ c.desc 'Remove @done tag'
308
+ c.switch %i[r remove], negatable: false, default_value: false
432
309
 
433
- c.desc "Edit entry with #{Doing::Util.default_editor}"
434
- c.switch %i[e editor], negatable: false, default_value: false
310
+ c.desc 'Include date'
311
+ c.switch [:date], negatable: true, default_value: true
435
312
 
436
- c.desc 'Archive previous @meanwhile entry'
313
+ c.desc 'Immediately archive the entry'
437
314
  c.switch %i[a archive], negatable: false, default_value: false
438
315
 
439
- c.desc 'Backdate start date for new entry to date string [4pm|20m|2h|yesterday noon]'
316
+ c.desc %(Set finish date to specific date/time (natural langauge parsed, e.g. --at=1:30pm).
317
+ If used, ignores --back. Used with --took, backdates start date)
440
318
  c.arg_name 'DATE_STRING'
441
- c.flag %i[b back]
319
+ c.flag [:at]
442
320
 
443
- c.desc 'Note'
321
+ c.desc 'Backdate start date by interval or set to time [4pm|20m|2h|"yesterday noon"]'
322
+ c.arg_name 'DATE_STRING'
323
+ c.flag %i[b back started]
324
+
325
+ c.desc %(Set completion date to start date plus interval (XX[mhd] or HH:MM).
326
+ If used without the --back option, the start date will be moved back to allow
327
+ the completion date to be the current time.)
328
+ c.arg_name 'INTERVAL'
329
+ c.flag %i[t took]
330
+
331
+ c.desc 'Section'
332
+ c.arg_name 'NAME'
333
+ c.flag %i[s section]
334
+
335
+ c.desc "Edit entry with #{Doing::Util.default_editor} (with no arguments, edits the last entry)"
336
+ c.switch %i[e editor], negatable: false, default_value: false
337
+
338
+ c.desc 'Include a note'
444
339
  c.arg_name 'TEXT'
445
340
  c.flag %i[n note]
446
341
 
342
+ c.desc 'Finish last entry not already marked @done'
343
+ c.switch %i[u unfinished], negatable: false, default_value: false
344
+
345
+ # c.desc "Edit entry with specified app"
346
+ # c.arg_name 'editor_app'
347
+ # # c.flag [:a, :app]
348
+
447
349
  c.action do |_global_options, options, args|
350
+ took = 0
351
+ donedate = nil
352
+
353
+ if options[:took]
354
+ took = options[:took].chronify_qty
355
+ raise InvalidTimeExpression, 'Unable to parse date string for --took' if took.nil?
356
+ end
357
+
448
358
  if options[:back]
449
359
  date = options[:back].chronify(guess: :begin)
360
+ raise InvalidTimeExpression, 'Unable to parse date string for --back' if date.nil?
361
+ else
362
+ date = options[:took] ? Time.now - took : Time.now
363
+ end
450
364
 
451
- raise InvalidTimeExpression, 'Unable to parse date string' if date.nil?
365
+ if options[:at]
366
+ finish_date = options[:at].chronify(guess: :begin)
367
+ raise InvalidTimeExpression, 'Unable to parse date string for --at' if finish_date.nil?
368
+
369
+ date = options[:took] ? finish_date - took : finish_date
370
+ elsif options[:took]
371
+ finish_date = date + took
452
372
  else
453
- date = Time.now
373
+ finish_date = Time.now
374
+ end
375
+
376
+ if options[:date]
377
+ donedate = finish_date.strftime('%F %R')
454
378
  end
455
379
 
456
380
  if options[:section]
@@ -458,217 +382,273 @@ command :meanwhile do |c|
458
382
  else
459
383
  section = settings['current_section']
460
384
  end
461
- input = ''
385
+
386
+ note = Doing::Note.new
387
+ note.add(options[:note]) if options[:note]
462
388
 
463
389
  if options[:editor]
464
390
  raise MissingEditor, 'No EDITOR variable defined in environment' if Doing::Util.default_editor.nil?
465
- input += date.strftime('%F %R | ')
466
- input += args.join(' ') unless args.empty?
391
+ is_new = false
392
+
393
+ if args.empty?
394
+ last_entry = wwid.filter_items([], opt: { unfinished: options[:unfinished], section: section, count: 1, age: :newest }).max_by { |item| item.date }
395
+
396
+ unless last_entry
397
+ Doing.logger.debug('Skipped:', options[:unfinished] ? 'No unfinished entry' : 'Last entry already @done')
398
+ raise NoResults, 'No results'
399
+ end
400
+
401
+ old_entry = last_entry.dup
402
+ last_entry.note.add(note)
403
+ input = ["#{last_entry.date.strftime('%F %R | ')}#{last_entry.title}", last_entry.note.strip_lines.join("\n")].join("\n")
404
+ else
405
+ is_new = true
406
+ input = ["#{date.strftime('%F %R | ')}#{args.join(' ')}", note.strip_lines.join("\n")].join("\n")
407
+ end
408
+
467
409
  input = wwid.fork_editor(input).strip
410
+ raise EmptyInput, 'No content' unless input && !input.empty?
411
+
412
+ d, title, note = wwid.format_input(input)
413
+ date = d.nil? ? date : d
414
+ new_entry = Doing::Item.new(date, title, section, note)
415
+ if new_entry.should_finish?
416
+ if new_entry.should_time?
417
+ new_entry.tag('done', value: donedate)
418
+ else
419
+ new_entry.tag('done')
420
+ end
421
+ end
422
+
423
+ if (is_new)
424
+ Doing::Hooks.trigger :pre_entry_add, wwid, new_entry
425
+ wwid.content.push(new_entry)
426
+ Doing::Hooks.trigger :post_entry_added, wwid, new_entry.dup
427
+ else
428
+ wwid.content.update_item(old_entry, new_entry)
429
+ Doing::Hooks.trigger :post_entry_updated, wwid, new_entry unless options[:archive]
430
+ end
431
+
432
+ if options[:archive]
433
+ wwid.move_item(new_entry, 'Archive', label: true)
434
+ Doing::Hooks.trigger :post_entry_updated, wwid, new_entry
435
+ end
436
+
437
+ wwid.write(wwid.doing_file)
438
+ elsif args.empty? && $stdin.stat.size.zero?
439
+ if options[:remove]
440
+ wwid.tag_last({ tags: ['done'], count: 1, section: section, remove: true })
441
+ else
442
+ note = options[:note] ? Doing::Note.new(options[:note]) : nil
443
+ opt = {
444
+ archive: options[:archive],
445
+ back: finish_date,
446
+ count: 1,
447
+ date: options[:date],
448
+ note: note,
449
+ section: section,
450
+ tags: ['done'],
451
+ took: took == 0 ? nil : took,
452
+ unfinished: options[:unfinished]
453
+ }
454
+ wwid.tag_last(opt)
455
+ end
468
456
  elsif !args.empty?
469
- input = args.join(' ')
457
+ note = Doing::Note.new(options[:note])
458
+ d, title, new_note = wwid.format_input([args.join(' '), note.strip_lines.join("\n")].join("\n"))
459
+ date = d.nil? ? date : d
460
+ title.chomp!
461
+ section = 'Archive' if options[:archive]
462
+ new_entry = Doing::Item.new(date, title, section, new_note)
463
+ if new_entry.should_finish?
464
+ if new_entry.should_time?
465
+ new_entry.tag('done', value: donedate)
466
+ else
467
+ new_entry.tag('done')
468
+ end
469
+ end
470
+ Doing::Hooks.trigger :pre_entry_add, wwid, new_entry
471
+ wwid.content.push(new_entry)
472
+ Doing::Hooks.trigger :post_entry_added, wwid, new_entry.dup
473
+ wwid.write(wwid.doing_file)
474
+ Doing.logger.info('Entry Added:', new_entry.title)
470
475
  elsif $stdin.stat.size.positive?
471
- input = $stdin.read.strip
472
- end
473
-
474
- if input && !input.empty?
475
- d, input, note = wwid.format_input(input)
476
+ d, title, note = wwid.format_input($stdin.read.strip)
476
477
  unless d.nil?
477
478
  Doing.logger.debug('Parser:', 'Date detected in input, overriding command line values')
478
479
  date = d
479
480
  end
480
- else
481
- input = nil
482
- note = []
483
- end
481
+ note.add(options[:note]) if options[:note]
482
+ section = options[:archive] ? 'Archive' : section
483
+ new_entry = Doing::Item.new(date, title, section, note)
484
484
 
485
- if options[:note]
486
- note.push(options[:note])
487
- elsif note.empty?
488
- note = nil
489
- end
485
+ if new_entry.should_finish?
486
+ if new_entry.should_time?
487
+ new_entry.tag('done', value: donedate)
488
+ else
489
+ new_entry.tag('done')
490
+ end
491
+ end
490
492
 
491
- wwid.stop_start('meanwhile', { new_item: input, back: date, section: section, archive: options[:archive], note: note })
492
- wwid.write(wwid.doing_file)
493
+ Doing::Hooks.trigger :pre_entry_add, wwid, new_entry
494
+ wwid.content.push(new_entry)
495
+ Doing::Hooks.trigger :post_entry_added, wwid, new_entry.dup
496
+
497
+ wwid.write(wwid.doing_file)
498
+ Doing.logger.info('Entry Added:', new_entry.title)
499
+ else
500
+ raise EmptyInput, 'You must provide content when creating a new entry'
501
+ end
493
502
  end
494
503
  end
495
504
 
496
- desc 'Output HTML, CSS, and Markdown (ERB) templates for customization'
497
- long_desc %(
498
- Templates are printed to STDOUT for piping to a file.
499
- Save them and use them in the configuration file under export_templates.
500
- )
501
- arg_name 'TYPE', must_match: Doing::Plugins.template_regex
502
- command :template do |c|
503
- c.example 'doing template haml > ~/styles/my_doing.haml', desc: 'Output the haml template and save it to a file'
504
-
505
- c.desc 'List all available templates'
506
- c.switch %i[l list], negatable: false
507
-
508
- c.desc 'List in single column for completion'
509
- c.switch %i[c column]
510
-
511
- c.desc 'Save template to file instead of STDOUT'
512
- c.switch %i[s save], default_value: false, negatable: false
513
-
514
- c.desc 'Save template to alternate location'
515
- c.arg_name 'DIRECTORY'
516
- c.flag %i[p path], default_value: File.join(Doing::Util.user_home, '.config', 'doing', 'templates')
505
+ # @@finish
506
+ desc 'Mark last X entries as @done'
507
+ long_desc 'Marks the last X entries with a @done tag and current date. Does not alter already completed entries.'
508
+ arg_name 'COUNT'
509
+ command :finish do |c|
510
+ c.example 'doing finish', desc: 'Mark the last entry @done'
511
+ c.example 'doing finish --auto --section Later 10', desc: 'Add @done to any unfinished entries in the last 10 in Later, setting the finish time based on the start time of the task after it'
512
+ c.example 'doing finish --search "a specific entry" --at "yesterday 3pm"', desc: 'Search for an entry containing string and set its @done time to yesterday at 3pm'
517
513
 
518
- c.action do |_global_options, options, args|
519
- if options[:list] || options[:column]
520
- if options[:column]
521
- $stdout.print Doing::Plugins.plugin_templates.join("\n")
522
- else
523
- $stdout.puts "Available templates: #{Doing::Plugins.plugin_templates.join(', ')}"
524
- end
525
- return
526
- end
514
+ c.desc 'Include date'
515
+ c.switch [:date], negatable: true, default_value: true
527
516
 
528
- if args.empty?
529
- type = Doing::Prompt.choose_from(Doing::Plugins.plugin_templates, sorted: false, prompt: 'Select template type > ')
530
- type.sub!(/ \(.*?\)$/, '').strip!
531
- options[:save] = Doing::Prompt.yn("Save to #{options[:path]}? (No outputs to STDOUT)", default_response: false)
532
- else
533
- type = args[0]
534
- end
517
+ c.desc 'Backdate completed date to date string [4pm|20m|2h|yesterday noon]'
518
+ c.arg_name 'DATE_STRING'
519
+ c.flag %i[b back]
535
520
 
536
- raise InvalidPluginType, "No type specified, use `doing template [#{Doing::Plugins.plugin_templates.join('|')}]`" unless type
521
+ c.desc 'Set the completed date to the start date plus XX[hmd]'
522
+ c.arg_name 'INTERVAL'
523
+ c.flag %i[t took]
537
524
 
538
- if options[:save]
539
- Doing::Plugins.template_for_trigger(type, save_to: options[:path])
540
- else
541
- $stdout.puts Doing::Plugins.template_for_trigger(type, save_to: nil)
542
- end
525
+ c.desc %(Set finish date to specific date/time (natural langauge parsed, e.g. --at=1:30pm). If used, ignores --back.)
526
+ c.arg_name 'DATE_STRING'
527
+ c.flag [:at]
543
528
 
544
- # case args[0]
545
- # when /html|haml/i
546
- # $stdout.puts wwid.haml_template
547
- # when /css/i
548
- # $stdout.puts wwid.css_template
549
- # when /markdown|md|erb/i
550
- # $stdout.puts wwid.markdown_template
551
- # else
552
- # exit_now! 'Invalid type specified, must be HAML or CSS'
553
- # end
554
- end
555
- end
529
+ c.desc 'Finish the last X entries containing TAG.
530
+ Separate multiple tags with comma (--tag=tag1,tag2), combine with --bool. Wildcards allowed (*, ?).'
531
+ c.arg_name 'TAG'
532
+ c.flag [:tag]
556
533
 
557
- desc 'Display an interactive menu to perform operations'
558
- long_desc 'List all entries and select with typeahead fuzzy matching.
534
+ c.desc 'Finish the last X entries matching search filter, surround with slashes for regex (e.g. "/query.*/"), start with single quote for exact match ("\'query")'
535
+ c.arg_name 'QUERY'
536
+ c.flag [:search]
559
537
 
560
- Multiple selections are allowed, hit tab to add the highlighted entry to the
561
- selection, and use ctrl-a to select all visible items. Return processes the
562
- selected entries.
538
+ # c.desc '[DEPRECATED] Use alternative fuzzy matching for search string'
539
+ # c.switch [:fuzzy], default_value: false, negatable: false
563
540
 
564
- Search in the menu by typing:
541
+ c.desc 'Force exact search string matching (case sensitive)'
542
+ c.switch %i[x exact], default_value: config.exact_match?, negatable: config.exact_match?
565
543
 
566
- sbtrkt fuzzy-match Items that match sbtrkt
544
+ c.desc 'Finish items that *don\'t* match search/tag filters'
545
+ c.switch [:not], default_value: false, negatable: false
567
546
 
568
- \'wild exact-match (quoted) Items that include wild
547
+ c.desc 'Case sensitivity for search string matching [(c)ase-sensitive, (i)gnore, (s)mart]'
548
+ c.arg_name 'TYPE'
549
+ c.flag [:case], must_match: /^[csi]/, default_value: settings.dig('search', 'case')
569
550
 
570
- !fire inverse-exact-match Items that do not include fire'
571
- command :select do |c|
572
- c.example 'doing select', desc: 'Select from all entries. A menu of available actions will be presented after confirming the selection.'
573
- c.example 'doing select --editor', desc: 'Select entries from a menu and batch edit them in your default editor'
574
- c.example 'doing select --after "yesterday 12pm" --tag project1', desc: 'Display a menu of entries created after noon yesterday, add @project1 to selected entries'
575
- c.desc 'Select from a specific section'
576
- c.arg_name 'SECTION'
577
- c.flag %i[s section]
551
+ c.desc 'Boolean (AND|OR|NOT) with which to combine multiple tag filters. Use PATTERN to parse + and - as booleans.'
552
+ c.arg_name 'BOOLEAN'
553
+ c.flag [:bool], must_match: REGEX_BOOL, default_value: 'PATTERN'
578
554
 
579
- c.desc 'Tag selected entries'
580
- c.arg_name 'TAG'
581
- c.flag %i[t tag]
555
+ c.desc 'Remove done tag'
556
+ c.switch %i[r remove], negatable: false, default_value: false
582
557
 
583
- c.desc 'Reverse -c, -f, --flag, and -t (remove instead of adding)'
584
- c.switch %i[r remove], negatable: false
558
+ c.desc 'Finish last entry (or entries) not already marked @done'
559
+ c.switch %i[u unfinished], negatable: false, default_value: false
585
560
 
586
- # c.desc 'Add @done to selected item(s), using start time of next item as the finish time'
587
- # c.switch %i[a auto], negatable: false, default_value: false
561
+ c.desc %(Auto-generate finish dates from next entry's start time.
562
+ Automatically generate completion dates 1 minute before next item (in any section) began.
563
+ --auto overrides the --date and --back parameters.)
564
+ c.switch [:auto], negatable: false, default_value: false
588
565
 
589
- c.desc 'Archive selected items'
566
+ c.desc 'Archive entries'
590
567
  c.switch %i[a archive], negatable: false, default_value: false
591
568
 
592
- c.desc 'Move selected items to section'
593
- c.arg_name 'SECTION'
594
- c.flag %i[m move]
595
-
596
- c.desc 'Initial search query for filtering. Matching is fuzzy. For exact matching, start query with a single quote, e.g. `--query "\'search"'
597
- c.arg_name 'QUERY'
598
- c.flag %i[q query search]
599
-
600
- c.desc 'Select from entries older than date. If this is only a time (8am, 1:30pm, 15:00), all dates will be included, but entries will be filtered by time of day.'
601
- c.arg_name 'DATE_STRING'
602
- c.flag [:before]
603
-
604
- c.desc 'Select from entries newer than date. If this is only a time (8am, 1:30pm, 15:00), all dates will be included, but entries will be filtered by time of day.'
605
- c.arg_name 'DATE_STRING'
606
- c.flag [:after]
607
-
608
- c.desc %(
609
- Date range to show, or a single day to filter date on.
610
- Date range argument should be quoted. Date specifications can be natural language.
611
- To specify a range, use "to" or "through": `doing select --from "monday 8am to friday 5pm"`.
612
-
613
- If values are only time(s) (6am to noon) all dates will be included, but entries will be filtered
614
- by time of day.
615
- )
616
- c.arg_name 'DATE_OR_RANGE'
617
- c.flag [:from]
618
-
619
- c.desc 'Force exact search string matching (case sensitive)'
620
- c.switch %i[x exact], default_value: config.exact_match?, negatable: config.exact_match?
569
+ c.desc 'Section'
570
+ c.arg_name 'NAME'
571
+ c.flag %i[s section]
621
572
 
622
- c.desc 'Select items that *don\'t* match search/tag filters'
623
- c.switch [:not], default_value: false, negatable: false
573
+ c.desc 'Select item(s) to finish from a menu of matching entries'
574
+ c.switch %i[i interactive], negatable: false, default_value: false
624
575
 
625
- c.desc 'Case sensitivity for search string matching [(c)ase-sensitive, (i)gnore, (s)mart]'
626
- c.arg_name 'TYPE'
627
- c.flag [:case], must_match: /^[csi]/, default_value: settings.dig('search', 'case')
576
+ c.action do |_global_options, options, args|
577
+ options[:fuzzy] = false
578
+ unless options[:auto]
579
+ if options[:took]
580
+ took = options[:took].chronify_qty
581
+ raise InvalidTimeExpression, 'Unable to parse date string for --took' if took.nil?
582
+ end
628
583
 
629
- c.desc 'Use --no-menu to skip the interactive menu. Use with --query to filter items and act on results automatically. Test with `--output doing` to preview matches.'
630
- c.switch %i[menu], negatable: true, default_value: true
584
+ raise InvalidArgument, '--back and --took can not be used together' if options[:back] && options[:took]
631
585
 
632
- c.desc 'Cancel selected items (add @done without timestamp)'
633
- c.switch %i[c cancel], negatable: false, default_value: false
586
+ raise InvalidArgument, '--search and --tag can not be used together' if options[:search] && options[:tag]
634
587
 
635
- c.desc 'Delete selected items'
636
- c.switch %i[d delete], negatable: false, default_value: false
588
+ if options[:at]
589
+ finish_date = options[:at].chronify(guess: :begin)
590
+ raise InvalidTimeExpression, 'Unable to parse date string for --at' if finish_date.nil?
637
591
 
638
- c.desc 'Edit selected item(s)'
639
- c.switch %i[e editor], negatable: false, default_value: false
592
+ date = options[:took] ? finish_date - took : finish_date
593
+ elsif options[:back]
594
+ date = options[:back].chronify()
640
595
 
641
- c.desc 'Add @done with current time to selected item(s)'
642
- c.switch %i[f finish], negatable: false, default_value: false
596
+ raise InvalidTimeExpression, 'Unable to parse date string' if date.nil?
597
+ else
598
+ date = Time.now
599
+ end
600
+ end
643
601
 
644
- c.desc 'Add flag to selected item(s)'
645
- c.switch %i[flag], negatable: false, default_value: false
602
+ options[:took] = options[:took].chronify_qty if options[:took]
646
603
 
647
- c.desc 'Perform action without confirmation.'
648
- c.switch %i[force], negatable: false, default_value: false
604
+ if options[:tag].nil?
605
+ tags = []
606
+ else
607
+ tags = options[:tag].to_tags
608
+ end
649
609
 
650
- c.desc 'Save selected entries to file using --output format'
651
- c.arg_name 'FILE'
652
- c.flag %i[save_to]
610
+ raise InvalidArgument, 'Only one argument allowed' if args.length > 1
653
611
 
654
- c.desc "Output entries to format (#{Doing::Plugins.plugin_names(type: :export)})"
655
- c.arg_name 'FORMAT'
656
- c.flag %i[o output]
612
+ raise InvalidArgument, 'Invalid argument (specify number of recent items to mark @done)' unless args.length == 0 || args[0] =~ /\d+/
657
613
 
658
- c.desc "Copy selection as a new entry with current time and no @done tag. Only works with single selections. Can be combined with --editor."
659
- c.switch %i[again resume], negatable: false, default_value: false
614
+ if options[:interactive]
615
+ count = 0
616
+ else
617
+ count = args[0] ? args[0].to_i : 1
618
+ end
660
619
 
661
- c.action do |_global_options, options, args|
662
- raise DoingRuntimeError, %(Invalid output type "#{options[:output]}") if options[:output] && options[:output] !~ Doing::Plugins.plugin_regex(type: :export)
620
+ search = nil
663
621
 
664
- raise InvalidArgument, '--no-menu requires --query' if !options[:menu] && !options[:query]
622
+ if options[:search]
623
+ search = options[:search]
624
+ search.sub!(/^'?/, "'") if options[:exact]
625
+ end
665
626
 
666
- options[:case] = options[:case].normalize_case
627
+ opts = {
628
+ archive: options[:archive],
629
+ back: date,
630
+ case: options[:case].normalize_case,
631
+ count: count,
632
+ date: options[:date],
633
+ fuzzy: options[:fuzzy],
634
+ interactive: options[:interactive],
635
+ not: options[:not],
636
+ remove: options[:remove],
637
+ search: search,
638
+ section: options[:section],
639
+ sequential: options[:auto],
640
+ tag: tags,
641
+ tag_bool: options[:bool].normalize_bool,
642
+ tags: ['done'],
643
+ took: options[:took],
644
+ unfinished: options[:unfinished]
645
+ }
667
646
 
668
- wwid.interactive(options)
647
+ wwid.tag_last(opts)
669
648
  end
670
649
  end
671
650
 
651
+ # @@later
672
652
  desc 'Add an item to the Later section'
673
653
  arg_name 'ENTRY'
674
654
  command :later do |c|
@@ -728,229 +708,40 @@ command :later do |c|
728
708
  end
729
709
  end
730
710
 
731
- desc 'Add a completed item with @done(date). No argument finishes last entry.'
732
- long_desc 'Use this command to add an entry after you\'ve already finished it. It will be immediately marked as @done.
733
- You can modify the start and end times of the entry using the --back, --took, and --at flags, making it an easy
734
- way to add entries in post and maintain accurate (albeit manual) time tracking.'
735
- arg_name 'ENTRY'
736
- command %i[done did] do |c|
737
- c.example 'doing done', desc: 'Tag the last entry @done'
738
- c.example 'doing done I already finished this', desc: 'Add a new entry and immediately mark it @done'
739
- c.example 'doing done --back 30m This took me half an hour', desc: 'Add an entry with a start date 30 minutes ago and a @done date of right now'
740
- c.example 'doing done --at 3pm --took 1h Started and finished this afternoon', desc: 'Add an entry with a @done date of 3pm and a start date of 2pm (3pm - 1h)'
741
-
742
- c.desc 'Remove @done tag'
743
- c.switch %i[r remove], negatable: false, default_value: false
744
-
745
- c.desc 'Include date'
746
- c.switch [:date], negatable: true, default_value: true
747
-
748
- c.desc 'Immediately archive the entry'
749
- c.switch %i[a archive], negatable: false, default_value: false
750
-
751
- c.desc %(Set finish date to specific date/time (natural langauge parsed, e.g. --at=1:30pm).
752
- If used, ignores --back. Used with --took, backdates start date)
753
- c.arg_name 'DATE_STRING'
754
- c.flag [:at]
755
-
756
- c.desc 'Backdate start date by interval or set to time [4pm|20m|2h|"yesterday noon"]'
757
- c.arg_name 'DATE_STRING'
758
- c.flag %i[b back started]
759
-
760
- c.desc %(Set completion date to start date plus interval (XX[mhd] or HH:MM).
761
- If used without the --back option, the start date will be moved back to allow
762
- the completion date to be the current time.)
763
- c.arg_name 'INTERVAL'
764
- c.flag %i[t took]
711
+ # @@mark @@flag
712
+ desc 'Mark last entry as flagged'
713
+ command %i[mark flag] do |c|
714
+ c.example 'doing flag', desc: 'Add @flagged to the last entry created'
715
+ c.example 'doing mark', desc: 'mark is an alias for flag'
716
+ c.example 'doing flag --tag project1 --count 2', desc: 'Add @flagged to the last 2 entries tagged @project1'
717
+ c.example 'doing flag --interactive --search "/(develop|cod)ing/"', desc: 'Find entries matching regular expression and create a menu allowing multiple selections, selected items will be @flagged'
765
718
 
766
719
  c.desc 'Section'
767
- c.arg_name 'NAME'
768
- c.flag %i[s section]
769
-
770
- c.desc "Edit entry with #{Doing::Util.default_editor} (with no arguments, edits the last entry)"
771
- c.switch %i[e editor], negatable: false, default_value: false
772
-
773
- c.desc 'Include a note'
774
- c.arg_name 'TEXT'
775
- c.flag %i[n note]
776
-
777
- c.desc 'Finish last entry not already marked @done'
778
- c.switch %i[u unfinished], negatable: false, default_value: false
779
-
780
- # c.desc "Edit entry with specified app"
781
- # c.arg_name 'editor_app'
782
- # # c.flag [:a, :app]
783
-
784
- c.action do |_global_options, options, args|
785
- took = 0
786
- donedate = nil
787
-
788
- if options[:took]
789
- took = options[:took].chronify_qty
790
- raise InvalidTimeExpression, 'Unable to parse date string for --took' if took.nil?
791
- end
792
-
793
- if options[:back]
794
- date = options[:back].chronify(guess: :begin)
795
- raise InvalidTimeExpression, 'Unable to parse date string for --back' if date.nil?
796
- else
797
- date = options[:took] ? Time.now - took : Time.now
798
- end
799
-
800
- if options[:at]
801
- finish_date = options[:at].chronify(guess: :begin)
802
- raise InvalidTimeExpression, 'Unable to parse date string for --at' if finish_date.nil?
803
-
804
- date = options[:took] ? finish_date - took : finish_date
805
- elsif options[:took]
806
- finish_date = date + took
807
- else
808
- finish_date = Time.now
809
- end
810
-
811
- if options[:date]
812
- donedate = finish_date.strftime('%F %R')
813
- end
814
-
815
- if options[:section]
816
- section = wwid.guess_section(options[:section]) || options[:section].cap_first
817
- else
818
- section = settings['current_section']
819
- end
820
-
821
- note = Doing::Note.new
822
- note.add(options[:note]) if options[:note]
823
-
824
- if options[:editor]
825
- raise MissingEditor, 'No EDITOR variable defined in environment' if Doing::Util.default_editor.nil?
826
- is_new = false
827
-
828
- if args.empty?
829
- last_entry = wwid.filter_items([], opt: {unfinished: options[:unfinished], section: section, count: 1, age: 'new'}).max_by { |item| item.date }
830
-
831
- unless last_entry
832
- Doing.logger.debug('Skipped:', options[:unfinished] ? 'No unfinished entry' : 'Last entry already @done')
833
- raise NoResults, 'No results'
834
- end
835
-
836
- old_entry = last_entry.dup
837
- last_entry.note.add(note)
838
- input = ["#{last_entry.date.strftime('%F %R | ')}#{last_entry.title}", last_entry.note.strip_lines.join("\n")].join("\n")
839
- else
840
- is_new = true
841
- input = ["#{date.strftime('%F %R | ')}#{args.join(' ')}", note.strip_lines.join("\n")].join("\n")
842
- end
843
-
844
- input = wwid.fork_editor(input).strip
845
- raise EmptyInput, 'No content' unless input && !input.empty?
846
-
847
- d, title, note = wwid.format_input(input)
848
- date = d.nil? ? date : d
849
- new_entry = Doing::Item.new(date, title, section, note)
850
- if new_entry.should_finish?
851
- if new_entry.should_time?
852
- new_entry.tag('done', value: donedate)
853
- else
854
- new_entry.tag('done')
855
- end
856
- end
857
-
858
- if (is_new)
859
- wwid.content.push(new_entry)
860
- else
861
- wwid.content.update_item(old_entry, new_entry)
862
- end
863
-
864
- if options[:archive]
865
- wwid.move_item(new_entry, 'Archive', label: true)
866
- end
867
-
868
- wwid.write(wwid.doing_file)
869
- elsif args.empty? && $stdin.stat.size.zero?
870
- if options[:remove]
871
- wwid.tag_last({ tags: ['done'], count: 1, section: section, remove: true })
872
- else
873
- note = options[:note] ? Doing::Note.new(options[:note]) : nil
874
- opt = {
875
- archive: options[:archive],
876
- back: finish_date,
877
- count: 1,
878
- date: options[:date],
879
- note: note,
880
- section: section,
881
- tags: ['done'],
882
- took: took == 0 ? nil : took,
883
- unfinished: options[:unfinished]
884
- }
885
- wwid.tag_last(opt)
886
- end
887
- elsif !args.empty?
888
- note = Doing::Note.new(options[:note])
889
- d, title, new_note = wwid.format_input([args.join(' '), note.strip_lines.join("\n")].join("\n"))
890
- date = d.nil? ? date : d
891
- title.chomp!
892
- section = 'Archive' if options[:archive]
893
- new_entry = Doing::Item.new(date, title, section, new_note)
894
- if new_entry.should_finish?
895
- if new_entry.should_time?
896
- new_entry.tag('done', value: donedate)
897
- else
898
- new_entry.tag('done')
899
- end
900
- end
901
- wwid.content.push(new_entry)
902
- wwid.write(wwid.doing_file)
903
- Doing.logger.info('Entry Added:', new_entry.title)
904
- elsif $stdin.stat.size.positive?
905
- d, title, note = wwid.format_input($stdin.read.strip)
906
- unless d.nil?
907
- Doing.logger.debug('Parser:', 'Date detected in input, overriding command line values')
908
- date = d
909
- end
910
- note.add(options[:note]) if options[:note]
911
- section = options[:archive] ? 'Archive' : section
912
- new_entry = Doing::Item.new(date, title, section, note)
720
+ c.arg_name 'SECTION_NAME'
721
+ c.flag %i[s section], default_value: 'All'
913
722
 
914
- if new_entry.should_finish?
915
- if new_entry.should_time?
916
- new_entry.tag('done', value: donedate)
917
- else
918
- new_entry.tag('done')
919
- end
920
- end
723
+ c.desc 'How many recent entries to tag (0 for all)'
724
+ c.arg_name 'COUNT'
725
+ c.flag %i[c count], default_value: 1, must_match: /^\d+$/, type: Integer
921
726
 
922
- wwid.content.push(new_entry)
923
- wwid.write(wwid.doing_file)
924
- Doing.logger.info('Entry Added:', new_entry.title)
925
- else
926
- raise EmptyInput, 'You must provide content when creating a new entry'
927
- end
928
- end
929
- end
727
+ c.desc 'Don\'t ask permission to flag all entries when count is 0'
728
+ c.switch %i[force], negatable: false, default_value: false
930
729
 
931
- desc 'End last X entries with no time tracked'
932
- long_desc 'Adds @done tag without datestamp so no elapsed time is recorded. Alias for `doing finish --no-date`.'
933
- arg_name 'COUNT'
934
- command :cancel do |c|
935
- c.example 'doing cancel', desc: 'Cancel the last entry'
936
- c.example 'doing cancel --tag project1 -u 5', desc: 'Cancel the last 5 unfinished entries containing @project1'
730
+ c.desc 'Include current date/time with tag'
731
+ c.switch %i[d date], negatable: false, default_value: false
937
732
 
938
- c.desc 'Archive entries'
939
- c.switch %i[a archive], negatable: false, default_value: false
733
+ c.desc 'Remove flag'
734
+ c.switch %i[r remove], negatable: false, default_value: false
940
735
 
941
- c.desc 'Section'
942
- c.arg_name 'NAME'
943
- c.flag %i[s section]
736
+ c.desc 'Flag last entry (or entries) not marked @done'
737
+ c.switch %i[u unfinished], negatable: false, default_value: false
944
738
 
945
- c.desc 'Cancel the last X entries containing TAG. Separate multiple tags with comma (--tag=tag1,tag2). Wildcards allowed (*, ?).'
739
+ c.desc 'Flag the last entry containing TAG.
740
+ Separate multiple tags with comma (--tag=tag1,tag2), combine with --bool. Wildcards allowed (*, ?).'
946
741
  c.arg_name 'TAG'
947
742
  c.flag [:tag]
948
743
 
949
- c.desc 'Boolean (AND|OR|NOT) with which to combine multiple tag filters. Use PATTERN to parse + and - as booleans.'
950
- c.arg_name 'BOOLEAN'
951
- c.flag [:bool], must_match: REGEX_BOOL, default_value: 'PATTERN'
952
-
953
- c.desc 'Cancel the last X entries matching search filter, surround with slashes for regex (e.g. "/query.*/"), start with single quote for exact match ("\'query")'
744
+ c.desc 'Flag the last entry matching search filter, surround with slashes for regex (e.g. "/query.*/"), start with single quote for exact match ("\'query")'
954
745
  c.arg_name 'QUERY'
955
746
  c.flag [:search]
956
747
 
@@ -960,239 +751,197 @@ command :cancel do |c|
960
751
  c.desc 'Force exact search string matching (case sensitive)'
961
752
  c.switch %i[x exact], default_value: config.exact_match?, negatable: config.exact_match?
962
753
 
963
- c.desc 'Finish items that *don\'t* match search/tag filters'
754
+ c.desc 'Flag items that *don\'t* match search/tag/date filters'
964
755
  c.switch [:not], default_value: false, negatable: false
965
756
 
966
757
  c.desc 'Case sensitivity for search string matching [(c)ase-sensitive, (i)gnore, (s)mart]'
967
758
  c.arg_name 'TYPE'
968
759
  c.flag [:case], must_match: /^[csi]/, default_value: settings.dig('search', 'case')
969
760
 
970
- c.desc 'Cancel last entry (or entries) not already marked @done'
971
- c.switch %i[u unfinished], negatable: false, default_value: false
761
+ c.desc 'Boolean (AND|OR|NOT) with which to combine multiple tag filters. Use PATTERN to parse + and - as booleans.'
762
+ c.arg_name 'BOOLEAN'
763
+ c.flag [:bool], must_match: REGEX_BOOL, default_value: 'PATTERN'
972
764
 
973
- c.desc 'Select item(s) to cancel from a menu of matching entries'
765
+ c.desc 'Select item(s) to flag from a menu of matching entries'
974
766
  c.switch %i[i interactive], negatable: false, default_value: false
975
767
 
976
- c.action do |_global_options, options, args|
768
+ c.action do |_global_options, options, _args|
977
769
  options[:fuzzy] = false
770
+ mark = settings['marker_tag'] || 'flagged'
771
+
772
+ raise InvalidArgument, '--search and --tag can not be used together' if options[:search] && options[:tag]
773
+
774
+ section = 'All'
775
+
978
776
  if options[:section]
979
777
  section = wwid.guess_section(options[:section]) || options[:section].cap_first
980
- else
981
- section = settings['current_section']
982
778
  end
983
779
 
984
780
  if options[:tag].nil?
985
- tags = []
781
+ search_tags = []
986
782
  else
987
- tags = options[:tag].to_tags
783
+ search_tags = options[:tag].to_tags
988
784
  end
989
785
 
990
- raise InvalidArgument, 'Only one argument allowed' if args.length > 1
991
-
992
- raise InvalidArgument, 'Invalid argument (specify number of recent items to mark @done)' unless args.empty? || args[0] =~ /\d+/
993
-
994
786
  if options[:interactive]
995
787
  count = 0
788
+ options[:force] = true
996
789
  else
997
- count = args[0] ? args[0].to_i : 1
790
+ count = options[:count].to_i
998
791
  end
999
792
 
1000
- search = nil
793
+ options[:case] = options[:case].normalize_case
1001
794
 
1002
795
  if options[:search]
1003
796
  search = options[:search]
1004
797
  search.sub!(/^'?/, "'") if options[:exact]
798
+ options[:search] = search
1005
799
  end
1006
800
 
1007
- opts = {
1008
- archive: options[:archive],
1009
- case: options[:case].normalize_case,
1010
- count: count,
1011
- date: false,
1012
- fuzzy: options[:fuzzy],
1013
- interactive: options[:interactive],
1014
- not: options[:not],
1015
- search: search,
1016
- section: section,
1017
- sequential: false,
1018
- tag: tags,
1019
- tag_bool: options[:bool].normalize_bool,
1020
- tags: ['done'],
1021
- unfinished: options[:unfinished]
1022
- }
1023
-
1024
- wwid.tag_last(opts)
1025
- end
1026
- end
1027
-
1028
- desc 'Mark last X entries as @done'
1029
- long_desc 'Marks the last X entries with a @done tag and current date. Does not alter already completed entries.'
1030
- arg_name 'COUNT'
1031
- command :finish do |c|
1032
- c.example 'doing finish', desc: 'Mark the last entry @done'
1033
- c.example 'doing finish --auto --section Later 10', desc: 'Add @done to any unfinished entries in the last 10 in Later, setting the finish time based on the start time of the task after it'
1034
- c.example 'doing finish --search "a specific entry" --at "yesterday 3pm"', desc: 'Search for an entry containing string and set its @done time to yesterday at 3pm'
1035
-
1036
- c.desc 'Include date'
1037
- c.switch [:date], negatable: true, default_value: true
1038
-
1039
- c.desc 'Backdate completed date to date string [4pm|20m|2h|yesterday noon]'
1040
- c.arg_name 'DATE_STRING'
1041
- c.flag %i[b back]
1042
-
1043
- c.desc 'Set the completed date to the start date plus XX[hmd]'
1044
- c.arg_name 'INTERVAL'
1045
- c.flag %i[t took]
1046
-
1047
- c.desc %(Set finish date to specific date/time (natural langauge parsed, e.g. --at=1:30pm). If used, ignores --back.)
1048
- c.arg_name 'DATE_STRING'
1049
- c.flag [:at]
1050
-
1051
- c.desc 'Finish the last X entries containing TAG.
1052
- Separate multiple tags with comma (--tag=tag1,tag2), combine with --bool. Wildcards allowed (*, ?).'
1053
- c.arg_name 'TAG'
1054
- c.flag [:tag]
1055
-
1056
- c.desc 'Finish the last X entries matching search filter, surround with slashes for regex (e.g. "/query.*/"), start with single quote for exact match ("\'query")'
1057
- c.arg_name 'QUERY'
1058
- c.flag [:search]
1059
-
1060
- # c.desc '[DEPRECATED] Use alternative fuzzy matching for search string'
1061
- # c.switch [:fuzzy], default_value: false, negatable: false
1062
-
1063
- c.desc 'Force exact search string matching (case sensitive)'
1064
- c.switch %i[x exact], default_value: config.exact_match?, negatable: config.exact_match?
801
+ if count.zero? && !options[:force]
802
+ if options[:search]
803
+ section_q = ' matching your search terms'
804
+ elsif options[:tag]
805
+ section_q = ' matching your tag search'
806
+ elsif section == 'All'
807
+ section_q = ''
808
+ else
809
+ section_q = " in section #{section}"
810
+ end
1065
811
 
1066
- c.desc 'Finish items that *don\'t* match search/tag filters'
1067
- c.switch [:not], default_value: false, negatable: false
1068
812
 
1069
- c.desc 'Case sensitivity for search string matching [(c)ase-sensitive, (i)gnore, (s)mart]'
1070
- c.arg_name 'TYPE'
1071
- c.flag [:case], must_match: /^[csi]/, default_value: settings.dig('search', 'case')
813
+ question = if options[:remove]
814
+ "Are you sure you want to unflag all entries#{section_q}"
815
+ else
816
+ "Are you sure you want to flag all records#{section_q}"
817
+ end
1072
818
 
1073
- c.desc 'Boolean (AND|OR|NOT) with which to combine multiple tag filters. Use PATTERN to parse + and - as booleans.'
1074
- c.arg_name 'BOOLEAN'
1075
- c.flag [:bool], must_match: REGEX_BOOL, default_value: 'PATTERN'
819
+ res = Doing::Prompt.yn(question, default_response: false)
1076
820
 
1077
- c.desc 'Remove done tag'
1078
- c.switch %i[r remove], negatable: false, default_value: false
821
+ exit_now! 'Cancelled' unless res
822
+ end
1079
823
 
1080
- c.desc 'Finish last entry (or entries) not already marked @done'
1081
- c.switch %i[u unfinished], negatable: false, default_value: false
824
+ options[:count] = count
825
+ options[:section] = section
826
+ options[:tag] = search_tags
827
+ options[:tags] = [mark]
828
+ options[:tag_bool] = options[:bool].normalize_bool
1082
829
 
1083
- c.desc %(Auto-generate finish dates from next entry's start time.
1084
- Automatically generate completion dates 1 minute before next item (in any section) began.
1085
- --auto overrides the --date and --back parameters.)
1086
- c.switch [:auto], negatable: false, default_value: false
830
+ wwid.tag_last(options)
831
+ end
832
+ end
1087
833
 
1088
- c.desc 'Archive entries'
1089
- c.switch %i[a archive], negatable: false, default_value: false
834
+ # @@meanwhile
835
+ desc 'Finish any running @meanwhile tasks and optionally create a new one'
836
+ long_desc 'The @meanwhile tag allows you to have long-running entries that encompass smaller entries.
837
+ This command makes it easy to start and stop these overarching entries. Just run `doing meanwhile Starting work on this
838
+ big project` to start a @meanwhile entry, add other entries as you work on the project, then use `doing meanwhile` by
839
+ itself to mark the entry as @done.'
840
+ arg_name 'ENTRY'
841
+ command :meanwhile do |c|
842
+ c.example 'doing meanwhile "Long task that will have others after it before it\'s done"', desc: 'Add a new long-running entry, completing any current @meanwhile entry'
843
+ c.example 'doing meanwhile', desc: 'Finish any open @meanwhile entry'
844
+ c.example 'doing meanwhile --archive', desc: 'Finish any open @meanwhile entry and archive it'
845
+ c.example 'doing meanwhile --back 2h "Something I\'ve been working on for a while', desc: 'Add a @meanwhile entry with a start date 2 hours ago'
1090
846
 
1091
847
  c.desc 'Section'
1092
848
  c.arg_name 'NAME'
1093
849
  c.flag %i[s section]
1094
850
 
1095
- c.desc 'Select item(s) to finish from a menu of matching entries'
1096
- c.switch %i[i interactive], negatable: false, default_value: false
1097
-
1098
- c.action do |_global_options, options, args|
1099
- options[:fuzzy] = false
1100
- unless options[:auto]
1101
- if options[:took]
1102
- took = options[:took].chronify_qty
1103
- raise InvalidTimeExpression, 'Unable to parse date string for --took' if took.nil?
1104
- end
851
+ c.desc "Edit entry with #{Doing::Util.default_editor}"
852
+ c.switch %i[e editor], negatable: false, default_value: false
1105
853
 
1106
- raise InvalidArgument, '--back and --took can not be used together' if options[:back] && options[:took]
854
+ c.desc 'Archive previous @meanwhile entry'
855
+ c.switch %i[a archive], negatable: false, default_value: false
1107
856
 
1108
- raise InvalidArgument, '--search and --tag can not be used together' if options[:search] && options[:tag]
857
+ c.desc 'Backdate start date for new entry to date string [4pm|20m|2h|yesterday noon]'
858
+ c.arg_name 'DATE_STRING'
859
+ c.flag %i[b back]
1109
860
 
1110
- if options[:at]
1111
- finish_date = options[:at].chronify(guess: :begin)
1112
- raise InvalidTimeExpression, 'Unable to parse date string for --at' if finish_date.nil?
861
+ c.desc 'Note'
862
+ c.arg_name 'TEXT'
863
+ c.flag %i[n note]
1113
864
 
1114
- date = options[:took] ? finish_date - took : finish_date
1115
- elsif options[:back]
1116
- date = options[:back].chronify()
865
+ c.action do |_global_options, options, args|
866
+ if options[:back]
867
+ date = options[:back].chronify(guess: :begin)
1117
868
 
1118
- raise InvalidTimeExpression, 'Unable to parse date string' if date.nil?
1119
- else
1120
- date = Time.now
1121
- end
869
+ raise InvalidTimeExpression, 'Unable to parse date string' if date.nil?
870
+ else
871
+ date = Time.now
1122
872
  end
1123
873
 
1124
- options[:took] = options[:took].chronify_qty if options[:took]
1125
-
1126
- if options[:tag].nil?
1127
- tags = []
874
+ if options[:section]
875
+ section = wwid.guess_section(options[:section]) || options[:section].cap_first
1128
876
  else
1129
- tags = options[:tag].to_tags
877
+ section = settings['current_section']
1130
878
  end
879
+ input = ''
1131
880
 
1132
- raise InvalidArgument, 'Only one argument allowed' if args.length > 1
1133
-
1134
- raise InvalidArgument, 'Invalid argument (specify number of recent items to mark @done)' unless args.length == 0 || args[0] =~ /\d+/
881
+ if options[:editor]
882
+ raise MissingEditor, 'No EDITOR variable defined in environment' if Doing::Util.default_editor.nil?
883
+ input += date.strftime('%F %R | ')
884
+ input += args.join(' ') unless args.empty?
885
+ input = wwid.fork_editor(input).strip
886
+ elsif !args.empty?
887
+ input = args.join(' ')
888
+ elsif $stdin.stat.size.positive?
889
+ input = $stdin.read.strip
890
+ end
1135
891
 
1136
- if options[:interactive]
1137
- count = 0
892
+ if input && !input.empty?
893
+ d, input, note = wwid.format_input(input)
894
+ unless d.nil?
895
+ Doing.logger.debug('Parser:', 'Date detected in input, overriding command line values')
896
+ date = d
897
+ end
1138
898
  else
1139
- count = args[0] ? args[0].to_i : 1
899
+ input = nil
900
+ note = []
1140
901
  end
1141
902
 
1142
- search = nil
1143
-
1144
- if options[:search]
1145
- search = options[:search]
1146
- search.sub!(/^'?/, "'") if options[:exact]
903
+ if options[:note]
904
+ note.push(options[:note])
905
+ elsif note.empty?
906
+ note = nil
1147
907
  end
1148
908
 
1149
- opts = {
1150
- archive: options[:archive],
1151
- back: date,
1152
- case: options[:case].normalize_case,
1153
- count: count,
1154
- date: options[:date],
1155
- fuzzy: options[:fuzzy],
1156
- interactive: options[:interactive],
1157
- not: options[:not],
1158
- remove: options[:remove],
1159
- search: search,
1160
- section: options[:section],
1161
- sequential: options[:auto],
1162
- tag: tags,
1163
- tag_bool: options[:bool].normalize_bool,
1164
- tags: ['done'],
1165
- took: options[:took],
1166
- unfinished: options[:unfinished]
1167
- }
1168
-
1169
- wwid.tag_last(opts)
909
+ wwid.stop_start('meanwhile', { new_item: input, back: date, section: section, archive: options[:archive], note: note })
910
+ wwid.write(wwid.doing_file)
1170
911
  end
1171
912
  end
1172
913
 
1173
- desc 'Repeat last entry as new entry'
1174
- long_desc 'This command is designed to allow multiple time intervals to be created for an entry by duplicating it with a new start (and end, eventually) time.'
1175
- command %i[again resume] do |c|
1176
- c.example 'doing resume', desc: 'Duplicate the most recent entry with a new start time, removing any @done tag'
1177
- c.example 'doing again', desc: 'again is an alias for resume'
1178
- c.example 'doing resume --editor', desc: 'Repeat the last entry, opening the new entry in the default editor'
1179
- c.example 'doing resume --tag project1 --in Projects', desc: 'Repeat the last entry tagged @project1, creating the new entry in the Projects section'
1180
- c.example 'doing resume --interactive', desc: 'Select the entry to repeat from a menu'
914
+ # @@note
915
+ desc 'Add a note to the last entry'
916
+ long_desc %(
917
+ If -r is provided with no other arguments, the last note is removed.
918
+ If new content is specified through arguments or STDIN, any previous
919
+ note will be replaced with the new one.
1181
920
 
1182
- c.desc 'Get last entry from a specific section'
921
+ Use -e to load the last entry in a text editor where you can append a note.
922
+ )
923
+ arg_name 'NOTE_TEXT'
924
+ command :note do |c|
925
+ c.example 'doing note', desc: 'Open the last entry in $EDITOR to append a note'
926
+ c.example 'doing note "Just a quick annotation"', desc: 'Add a quick note to the last entry'
927
+ c.example 'doing note --tag done "Keeping it real or something"', desc: 'Add a note to the last item tagged @done'
928
+ c.example 'doing note --search "late night" -e', desc: 'Open $EDITOR to add a note to the last item containing "late night" (fuzzy matched)'
929
+
930
+ c.desc 'Section'
1183
931
  c.arg_name 'NAME'
1184
932
  c.flag %i[s section], default_value: 'All'
1185
933
 
1186
- c.desc 'Add new entry to section (default: same section as repeated entry)'
1187
- c.arg_name 'SECTION_NAME'
1188
- c.flag [:in]
934
+ c.desc "Edit entry with #{Doing::Util.default_editor}"
935
+ c.switch %i[e editor], negatable: false, default_value: false
1189
936
 
1190
- c.desc 'Repeat last entry matching tags. Combine multiple tags with a comma. Wildcards allowed (*, ?).'
937
+ c.desc "Replace/Remove last entry's note (default append)"
938
+ c.switch %i[r remove], negatable: false, default_value: false
939
+
940
+ c.desc 'Add/remove note from last entry matching tag. Wildcards allowed (*, ?).'
1191
941
  c.arg_name 'TAG'
1192
942
  c.flag [:tag]
1193
943
 
1194
- c.desc 'Repeat last entry matching search. Surround with
1195
- slashes for regex (e.g. "/query/"), start with a single quote for exact match ("\'query").'
944
+ c.desc 'Add/remove note from last entry matching search filter, surround with slashes for regex (e.g. "/query.*/"), start with single quote for exact match ("\'query")'
1196
945
  c.arg_name 'QUERY'
1197
946
  c.flag [:search]
1198
947
 
@@ -1202,30 +951,27 @@ command %i[again resume] do |c|
1202
951
  c.desc 'Force exact search string matching (case sensitive)'
1203
952
  c.switch %i[x exact], default_value: config.exact_match?, negatable: config.exact_match?
1204
953
 
1205
- c.desc 'Resume items that *don\'t* match search/tag filters'
954
+ c.desc 'Add note to item that *doesn\'t* match search/tag filters'
1206
955
  c.switch [:not], default_value: false, negatable: false
1207
956
 
1208
957
  c.desc 'Case sensitivity for search string matching [(c)ase-sensitive, (i)gnore, (s)mart]'
1209
958
  c.arg_name 'TYPE'
1210
959
  c.flag [:case], must_match: /^[csi]/, default_value: settings.dig('search', 'case')
1211
960
 
1212
- c.desc 'Boolean used to combine multiple tags. Use PATTERN to parse + and - as booleans.'
961
+ c.desc 'Boolean (AND|OR|NOT) with which to combine multiple tag filters. Use PATTERN to parse + and - as booleans.'
1213
962
  c.arg_name 'BOOLEAN'
1214
963
  c.flag [:bool], must_match: REGEX_BOOL, default_value: 'PATTERN'
1215
964
 
1216
- c.desc "Edit duplicated entry with #{Doing::Util.default_editor} before adding"
1217
- c.switch %i[e editor], negatable: false, default_value: false
965
+ c.desc 'Select item for new note from a menu of matching entries'
966
+ c.switch %i[i interactive], negatable: false, default_value: false
1218
967
 
1219
- c.desc 'Note'
1220
- c.arg_name 'TEXT'
1221
- c.flag %i[n note]
1222
-
1223
- c.desc 'Select item to resume from a menu of matching entries'
1224
- c.switch %i[i interactive], negatable: false, default_value: false
1225
-
1226
- c.action do |_global_options, options, _args|
968
+ c.action do |_global_options, options, args|
1227
969
  options[:fuzzy] = false
1228
- tags = options[:tag].nil? ? [] : options[:tag].to_tags
970
+ if options[:section]
971
+ options[:section] = wwid.guess_section(options[:section]) || options[:section].cap_first
972
+ end
973
+
974
+ options[:tag_bool] = options[:bool].normalize_bool
1229
975
 
1230
976
  options[:case] = options[:case].normalize_case
1231
977
 
@@ -1235,145 +981,165 @@ command %i[again resume] do |c|
1235
981
  options[:search] = search
1236
982
  end
1237
983
 
1238
- opts = options.dup
1239
984
 
1240
- opts[:tag] = tags
1241
- opts[:tag_bool] = options[:bool].normalize_bool
1242
- opts[:interactive] = options[:interactive]
985
+ last_entry = wwid.last_entry(options)
1243
986
 
1244
- wwid.repeat_last(opts)
1245
- end
1246
- end
987
+ unless last_entry
988
+ Doing.logger.warn('Not found:', 'No entry matching parameters was found.')
989
+ return
990
+ end
1247
991
 
1248
- desc 'List all tags in the current Doing file'
1249
- command :tags do |c|
1250
- c.desc 'Section'
1251
- c.arg_name 'SECTION_NAME'
1252
- c.flag %i[s section], default_value: 'All'
992
+ last_note = last_entry.note || Doing::Note.new
993
+ new_note = Doing::Note.new
1253
994
 
1254
- c.desc 'Show count of occurrences'
1255
- c.switch %i[c counts]
995
+ if options[:editor] || (args.empty? && $stdin.stat.size.zero? && !options[:remove])
996
+ raise MissingEditor, 'No EDITOR variable defined in environment' if Doing::Util.default_editor.nil?
1256
997
 
1257
- c.desc 'Sort by name or count'
1258
- c.arg_name 'SORT_ORDER'
1259
- c.flag %i[sort], default_value: 'name', must_match: /^(?:n(?:ame)?|c(?:ount)?)$/
998
+ input = !args.empty? ? args.join(' ') : ''
1260
999
 
1261
- c.desc 'Sort order (asc/desc)'
1262
- c.arg_name 'ORDER'
1263
- c.flag %i[o order], must_match: REGEX_SORT_ORDER, default_value: 'asc'
1000
+ if options[:remove]
1001
+ prev_input = Doing::Note.new
1002
+ else
1003
+ prev_input = last_entry.note || Doing::Note.new
1004
+ end
1264
1005
 
1265
- c.desc 'Get tags for entries matching tags. Combine multiple tags with a comma. Wildcards allowed (*, ?).'
1266
- c.arg_name 'TAG'
1267
- c.flag [:tag]
1006
+ input = prev_input.add(input)
1268
1007
 
1269
- c.desc 'Get tags for items matching search. Surround with
1270
- slashes for regex (e.g. "/query/"), start with a single quote for exact match ("\'query").'
1271
- c.arg_name 'QUERY'
1272
- c.flag [:search]
1008
+ input = wwid.fork_editor(prev_input.strip_lines.join("\n"), message: nil).strip
1009
+ note = input
1010
+ options[:remove] = true
1011
+ new_note.add(note)
1012
+ elsif !args.empty?
1013
+ new_note.add(args.join(' '))
1014
+ elsif $stdin.stat.size.positive?
1015
+ new_note.add($stdin.read.strip)
1016
+ else
1017
+ raise EmptyInput, 'You must provide content when adding a note' unless options[:remove]
1018
+ end
1273
1019
 
1274
- # c.desc '[DEPRECATED] Use alternative fuzzy matching for search string'
1275
- # c.switch [:fuzzy], default_value: false, negatable: false
1020
+ if last_note.equal?(new_note)
1021
+ Doing.logger.debug('Skipped:', 'No note change')
1022
+ else
1023
+ last_note.add(new_note, replace: options[:remove])
1024
+ Doing.logger.info('Entry updated:', last_entry.title)
1025
+ Doing::Hooks.trigger :post_entry_updated, wwid, last_entry
1026
+ end
1027
+ # new_entry = Doing::Item.new(last_entry.date, last_entry.title, last_entry.section, new_note)
1028
+ wwid.write(wwid.doing_file)
1029
+ end
1030
+ end
1276
1031
 
1277
- c.desc 'Force exact search string matching (case sensitive)'
1278
- c.switch %i[x exact], default_value: config.exact_match?, negatable: config.exact_match?
1032
+ # @@now @@next
1033
+ desc 'Add an entry'
1034
+ long_desc %(Record what you're starting now, or backdate the start time using natural language.
1279
1035
 
1280
- c.desc 'Get tags from items that *don\'t* match search/tag filters'
1281
- c.switch [:not], default_value: false, negatable: false
1036
+ A parenthetical at the end of the entry will be converted to a note.
1282
1037
 
1283
- c.desc 'Case sensitivity for search string matching [(c)ase-sensitive, (i)gnore, (s)mart]'
1284
- c.arg_name 'TYPE'
1285
- c.flag [:case], must_match: /^[csi]/, default_value: settings.dig('search', 'case')
1038
+ Run with no argument to create a new entry using #{Doing::Util.default_editor}.)
1039
+ arg_name 'ENTRY'
1040
+ command %i[now next] do |c|
1041
+ c.example 'doing now', desc: "Open #{Doing::Util.default_editor} to input an entry and optional note."
1042
+ c.example 'doing now working on a new project', desc: 'Add a new entry at the current time'
1043
+ c.example 'doing now debugging @project2', desc: 'Add an entry with a tag'
1044
+ c.example 'doing now adding an entry (with a note)', desc: 'Parenthetical at end is converted to note'
1045
+ c.example 'doing now --back 2pm A thing I started at 2:00 and am still doing...', desc: 'Backdate an entry'
1286
1046
 
1287
- c.desc 'Boolean used to combine multiple tags. Use PATTERN to parse + and - as booleans.'
1288
- c.arg_name 'BOOLEAN'
1289
- c.flag [:bool], must_match: REGEX_BOOL, default_value: 'PATTERN'
1047
+ c.desc 'Section'
1048
+ c.arg_name 'NAME'
1049
+ c.flag %i[s section]
1290
1050
 
1291
- c.desc 'Select items to scan from a menu of matching entries'
1292
- c.switch %i[i interactive], negatable: false, default_value: false
1051
+ c.desc "Edit entry with #{Doing::Util.default_editor}"
1052
+ c.switch %i[e editor], negatable: false, default_value: false
1293
1053
 
1294
- c.action do |_global, options, args|
1295
- section = wwid.guess_section(options[:section]) || options[:section].cap_first
1054
+ c.desc 'Backdate start time [4pm|20m|2h|"yesterday noon"]'
1055
+ c.arg_name 'DATE_STRING'
1056
+ c.flag %i[b back started]
1296
1057
 
1297
- items = wwid.filter_items([], opt: options)
1058
+ c.desc 'Timed entry, marks last entry in section as @done'
1059
+ c.switch %i[f finish_last], negatable: false, default_value: false
1298
1060
 
1299
- # items = wwid.content.in_section(section)
1300
- tags = wwid.all_tags(items, counts: true)
1061
+ c.desc 'Include a note'
1062
+ c.arg_name 'TEXT'
1063
+ c.flag %i[n note]
1301
1064
 
1302
- if options[:sort] =~ /^n/i
1303
- tags = tags.sort_by { |tag, count| tag }
1065
+ # c.desc "Edit entry with specified app"
1066
+ # c.arg_name 'editor_app'
1067
+ # # c.flag [:a, :app]
1068
+
1069
+ c.action do |_global_options, options, args|
1070
+ if options[:back]
1071
+ date = options[:back].chronify(guess: :begin)
1072
+
1073
+ raise InvalidTimeExpression.new('unable to parse date string', topic: 'Parser:') if date.nil?
1304
1074
  else
1305
- tags = tags.sort_by { |tag, count| count }
1075
+ date = Time.now
1306
1076
  end
1307
1077
 
1308
- tags.reverse! if options[:order].normalize_order == 'desc'
1309
-
1310
- if options[:counts]
1311
- tags.each { |t, c| puts "#{t} (#{c})" }
1078
+ if options[:section]
1079
+ section = wwid.guess_section(options[:section]) || options[:section].cap_first
1312
1080
  else
1313
- tags.each { |t, c| puts "#{t}" }
1081
+ options[:section] = settings['current_section']
1314
1082
  end
1315
- end
1316
- end
1317
1083
 
1084
+ if options[:editor] || (args.empty? && $stdin.stat.size.zero?)
1085
+ raise MissingEditor, 'No EDITOR variable defined in environment' if Doing::Util.default_editor.nil?
1318
1086
 
1319
- desc 'Add tag(s) to last entry'
1320
- long_desc 'Add (or remove) tags from the last entry, or from multiple entries
1321
- (with `--count`), entries matching a search (with `--search`), or entries
1322
- containing another tag (with `--tag`).
1087
+ input = date.strftime('%F %R | ')
1088
+ input += args.join(' ') unless args.empty?
1089
+ input = wwid.fork_editor(input).strip
1323
1090
 
1324
- When removing tags with `-r`, wildcards are allowed (`*` to match
1325
- multiple characters, `?` to match a single character). With `--regex`,
1326
- regular expressions will be interpreted instead of wildcards.
1091
+ raise EmptyInput, 'No content' if input.empty?
1327
1092
 
1328
- For all tag removals the match is case insensitive by default, but if
1329
- the tag search string contains any uppercase letters, the match will
1330
- become case sensitive automatically.
1093
+ date, title, note = wwid.format_input(input)
1094
+ note.add(options[:note]) if options[:note]
1095
+ wwid.add_item(title.cap_first, section, { note: note, back: date, timed: options[:finish_last] })
1096
+ wwid.write(wwid.doing_file)
1097
+ elsif args.length.positive?
1098
+ d, title, note = wwid.format_input(args.join(' '))
1099
+ date = d.nil? ? date : d
1100
+ note.add(options[:note]) if options[:note]
1101
+ wwid.add_item(title.cap_first, section, { note: note, back: date, timed: options[:finish_last] })
1102
+ wwid.write(wwid.doing_file)
1103
+ elsif $stdin.stat.size.positive?
1104
+ input = $stdin.read.strip
1105
+ d, title, note = wwid.format_input(input)
1106
+ unless d.nil?
1107
+ Doing.logger.debug('Parser:', 'Date detected in input, overriding command line values')
1108
+ date = d
1109
+ end
1110
+ note.add(options[:note]) if options[:note]
1111
+ wwid.add_item(title.cap_first, section, { note: note, back: date, timed: options[:finish_last] })
1112
+ wwid.write(wwid.doing_file)
1113
+ else
1114
+ raise EmptyInput, 'You must provide content when creating a new entry'
1115
+ end
1116
+ end
1117
+ end
1331
1118
 
1332
- Tag name arguments do not need to be prefixed with @.'
1333
- arg_name 'TAG', :multiple
1334
- command :tag do |c|
1335
- c.example 'doing tag mytag', desc: 'Add @mytag to the last entry created'
1336
- c.example 'doing tag --remove mytag', desc: 'Remove @mytag from the last entry created'
1337
- c.example 'doing tag --rename "other*" --count 10 newtag', desc: 'Rename tags beginning with "other" (wildcard) to @newtag on the last 10 entries'
1338
- c.example 'doing tag --search "developing" coding', desc: 'Add @coding to the last entry containing string "developing" (fuzzy matching)'
1339
- c.example 'doing tag --interactive --tag project1 coding', desc: 'Create an interactive menu from entries tagged @project1, selection(s) will be tagged with @coding'
1119
+ # @@reset @@begin
1120
+ desc 'Reset the start time of an entry'
1121
+ long_desc 'Update the start time of the last entry or the last entry matching a tag/search filter.
1122
+ If no argument is provided, the start time will be reset to the current time.
1123
+ If a date string is provided as an argument, the start time will be set to the parsed result.'
1124
+ arg_name 'DATE_STRING'
1125
+ command %i[reset begin] do |c|
1126
+ c.example 'doing reset', desc: 'Reset the start time of the last entry to the current time'
1127
+ c.example 'doing reset --tag project1', desc: 'Reset the start time of the most recent entry tagged @project1 to the current time'
1128
+ c.example 'doing reset 3pm', desc: 'Reset the start time of the last entry to 3pm of the current day'
1129
+ c.example 'doing begin --tag todo --resume', desc: 'alias for reset. Updates the last @todo entry to the current time, removing @done tag.'
1340
1130
 
1341
- c.desc 'Section'
1342
- c.arg_name 'SECTION_NAME'
1131
+ c.desc 'Limit search to section'
1132
+ c.arg_name 'NAME'
1343
1133
  c.flag %i[s section], default_value: 'All'
1344
1134
 
1345
- c.desc 'How many recent entries to tag (0 for all)'
1346
- c.arg_name 'COUNT'
1347
- c.flag %i[c count], default_value: 1, must_match: /^\d+$/, type: Integer
1348
-
1349
- c.desc 'Replace existing tag with tag argument, wildcards (*,?) allowed, or use with --regex'
1350
- c.arg_name 'ORIG_TAG'
1351
- c.flag %i[rename]
1352
-
1353
- c.desc 'Don\'t ask permission to tag all entries when count is 0'
1354
- c.switch %i[force], negatable: false, default_value: false
1355
-
1356
- c.desc 'Include current date/time with tag'
1357
- c.switch %i[d date], negatable: false, default_value: false
1358
-
1359
- c.desc 'Remove given tag(s)'
1360
- c.switch %i[r remove], negatable: false, default_value: false
1361
-
1362
- c.desc 'Interpret tag string as regular expression (with --remove)'
1363
- c.switch %i[regex], negatable: false, default_value: false
1364
-
1365
- c.desc 'Tag last entry (or entries) not marked @done'
1366
- c.switch %i[u unfinished], negatable: false, default_value: false
1367
-
1368
- c.desc 'Autotag entries based on autotag configuration in ~/.config/doing/config.yml'
1369
- c.switch %i[a autotag], negatable: false, default_value: false
1135
+ c.desc 'Resume entry (remove @done)'
1136
+ c.switch %i[r resume], default_value: true
1370
1137
 
1371
- c.desc 'Tag the last X entries containing TAG.
1372
- Separate multiple tags with comma (--tag=tag1,tag2), combine with --bool. Wildcards allowed (*, ?).'
1138
+ c.desc 'Reset last entry matching tag. Wildcards allowed (*, ?).'
1373
1139
  c.arg_name 'TAG'
1374
1140
  c.flag [:tag]
1375
1141
 
1376
- c.desc 'Tag entries matching search filter, surround with slashes for regex (e.g. "/query.*/"), start with single quote for exact match ("\'query")'
1142
+ c.desc 'Reset last entry matching search filter, surround with slashes for regex (e.g. "/query.*/"), start with single quote for exact match ("\'query")'
1377
1143
  c.arg_name 'QUERY'
1378
1144
  c.flag [:search]
1379
1145
 
@@ -1383,112 +1149,209 @@ command :tag do |c|
1383
1149
  c.desc 'Force exact search string matching (case sensitive)'
1384
1150
  c.switch %i[x exact], default_value: config.exact_match?, negatable: config.exact_match?
1385
1151
 
1386
- c.desc 'Tag items that *don\'t* match search/tag filters'
1152
+ c.desc 'Reset items that *don\'t* match search/tag filters'
1387
1153
  c.switch [:not], default_value: false, negatable: false
1388
1154
 
1389
1155
  c.desc 'Case sensitivity for search string matching [(c)ase-sensitive, (i)gnore, (s)mart]'
1390
1156
  c.arg_name 'TYPE'
1391
1157
  c.flag [:case], must_match: /^[csi]/, default_value: settings.dig('search', 'case')
1392
1158
 
1393
- c.desc 'Boolean (AND|OR|NOT) with which to combine multiple tag filters. Use PATTERN to parse + and - as booleans.'
1159
+ c.desc 'Boolean (AND|OR|NOT) with which to combine multiple tag filters'
1394
1160
  c.arg_name 'BOOLEAN'
1395
1161
  c.flag [:bool], must_match: REGEX_BOOL, default_value: 'PATTERN'
1396
1162
 
1397
- c.desc 'Select item(s) to tag from a menu of matching entries'
1163
+ c.desc 'Select from a menu of matching entries'
1398
1164
  c.switch %i[i interactive], negatable: false, default_value: false
1399
1165
 
1400
- c.action do |_global_options, options, args|
1401
- options[:fuzzy] = false
1402
- raise MissingArgument, 'You must specify at least one tag' if args.empty? && !options[:autotag]
1403
-
1404
- raise InvalidArgument, '--search and --tag can not be used together' if options[:search] && options[:tag]
1405
-
1406
- section = 'All'
1166
+ c.action do |global_options, options, args|
1167
+ if args.count > 0
1168
+ reset_date = args.join(' ').chronify(guess: :begin)
1169
+ raise InvalidArgument, 'Invalid date string' unless reset_date
1170
+ else
1171
+ reset_date = Time.now
1172
+ end
1407
1173
 
1174
+ options[:fuzzy] = false
1408
1175
  if options[:section]
1409
- section = wwid.guess_section(options[:section]) || options[:section].cap_first
1176
+ options[:section] = wwid.guess_section(options[:section]) || options[:section].cap_first
1410
1177
  end
1411
1178
 
1179
+ options[:bool] = options[:bool].normalize_bool
1412
1180
 
1413
- if options[:tag].nil?
1414
- search_tags = []
1415
- else
1416
- search_tags = options[:tag].to_tags
1181
+ options[:case] = options[:case].normalize_case
1182
+
1183
+ if options[:search]
1184
+ search = options[:search]
1185
+ search.sub!(/^'?/, "'") if options[:exact]
1186
+ options[:search] = search
1417
1187
  end
1418
1188
 
1419
- if options[:autotag]
1420
- tags = []
1421
- else
1422
- tags = if args.join('') =~ /,/
1423
- args.join('').split(/,/)
1424
- else
1425
- args.join(' ').split(' ') # in case tags are quoted as one arg
1426
- end
1427
1189
 
1428
- tags.map! { |tag| tag.sub(/^@/, '').strip }
1429
- end
1190
+ items = wwid.filter_items([], opt: options)
1430
1191
 
1431
1192
  if options[:interactive]
1432
- count = 0
1433
- options[:force] = true
1193
+ last_entry = Doing::Prompt.choose_from_items(items, include_section: options[:section].nil?,
1194
+ menu: true,
1195
+ header: '',
1196
+ prompt: 'Select an entry to start/reset > ',
1197
+ multiple: false,
1198
+ sort: false,
1199
+ show_if_single: true)
1434
1200
  else
1435
- count = options[:count].to_i
1201
+ last_entry = items.reverse.last
1436
1202
  end
1437
1203
 
1438
- options[:case] ||= :smart
1439
- options[:case] = options[:case].normalize_case
1440
-
1441
- if options[:search]
1442
- search = options[:search]
1443
- search.sub!(/^'?/, "'") if options[:exact]
1444
- options[:search] = search
1204
+ unless last_entry
1205
+ Doing.logger.warn('Not found:', 'No entry matching parameters was found.')
1206
+ return
1445
1207
  end
1446
1208
 
1447
- options[:count] = count
1448
- options[:section] = section
1449
- options[:tag] = search_tags
1450
- options[:tags] = tags
1451
- options[:tag_bool] = options[:bool].normalize_bool
1209
+ wwid.reset_item(last_entry, date: reset_date, resume: options[:resume])
1210
+ Doing::Hooks.trigger :post_entry_updated, wwid, last_entry
1211
+ # new_entry = Doing::Item.new(last_entry.date, last_entry.title, last_entry.section, new_note)
1452
1212
 
1453
- if count.zero? && !options[:force]
1454
- matches = wwid.filter_items([], opt: options).count
1213
+ wwid.write(wwid.doing_file)
1214
+ end
1215
+ end
1455
1216
 
1456
- if matches > 5
1457
- if options[:search]
1458
- section_q = ' matching your search terms'
1459
- elsif options[:tag]
1460
- section_q = ' matching your tag search'
1461
- elsif section == 'All'
1462
- section_q = ''
1463
- else
1464
- section_q = " in section #{section}"
1465
- end
1217
+ # @@select
1218
+ desc 'Display an interactive menu to perform operations'
1219
+ long_desc 'List all entries and select with typeahead fuzzy matching.
1466
1220
 
1221
+ Multiple selections are allowed, hit tab to add the highlighted entry to the
1222
+ selection, and use ctrl-a to select all visible items. Return processes the
1223
+ selected entries.
1467
1224
 
1468
- question = if options[:autotag]
1469
- "Are you sure you want to autotag #{matches} records#{section_q}"
1470
- elsif options[:remove]
1471
- "Are you sure you want to remove #{tags.join(' and ')} from #{matches} records#{section_q}"
1472
- else
1473
- "Are you sure you want to add #{tags.join(' and ')} to #{matches} records#{section_q}"
1474
- end
1225
+ Search in the menu by typing:
1475
1226
 
1476
- res = Doing::Prompt.yn(question, default_response: false)
1227
+ sbtrkt fuzzy-match Items that match s*b*t*r*k*t
1477
1228
 
1478
- raise UserCancelled unless res
1479
- end
1480
- end
1229
+ \'wild exact-match (quoted) Items that include wild
1481
1230
 
1482
- wwid.tag_last(options)
1231
+ !fire inverse-exact-match Items that do not include fire'
1232
+ command :select do |c|
1233
+ c.example 'doing select', desc: 'Select from all entries. A menu of available actions will be presented after confirming the selection.'
1234
+ c.example 'doing select --editor', desc: 'Select entries from a menu and batch edit them in your default editor'
1235
+ c.example 'doing select --after "yesterday 12pm" --tag project1', desc: 'Display a menu of entries created after noon yesterday, add @project1 to selected entries'
1236
+ c.desc 'Select from a specific section'
1237
+ c.arg_name 'SECTION'
1238
+ c.flag %i[s section]
1239
+
1240
+ c.desc 'Tag selected entries'
1241
+ c.arg_name 'TAG'
1242
+ c.flag %i[t tag]
1243
+
1244
+ c.desc 'Reverse -c, -f, --flag, and -t (remove instead of adding)'
1245
+ c.switch %i[r remove], negatable: false
1246
+
1247
+ # c.desc 'Add @done to selected item(s), using start time of next item as the finish time'
1248
+ # c.switch %i[a auto], negatable: false, default_value: false
1249
+
1250
+ c.desc 'Archive selected items'
1251
+ c.switch %i[a archive], negatable: false, default_value: false
1252
+
1253
+ c.desc 'Move selected items to section'
1254
+ c.arg_name 'SECTION'
1255
+ c.flag %i[m move]
1256
+
1257
+ c.desc 'Initial search query for filtering. Matching is fuzzy. For exact matching, start query with a single quote, e.g. `--query "\'search"'
1258
+ c.arg_name 'QUERY'
1259
+ c.flag %i[q query search]
1260
+
1261
+ c.desc 'Select from entries older than date. If this is only a time (8am, 1:30pm, 15:00), all dates will be included, but entries will be filtered by time of day.'
1262
+ c.arg_name 'DATE_STRING'
1263
+ c.flag [:before]
1264
+
1265
+ c.desc 'Select from entries newer than date. If this is only a time (8am, 1:30pm, 15:00), all dates will be included, but entries will be filtered by time of day.'
1266
+ c.arg_name 'DATE_STRING'
1267
+ c.flag [:after]
1268
+
1269
+ c.desc %(
1270
+ Date range to show, or a single day to filter date on.
1271
+ Date range argument should be quoted. Date specifications can be natural language.
1272
+ To specify a range, use "to" or "through": `doing select --from "monday 8am to friday 5pm"`.
1273
+
1274
+ If values are only time(s) (6am to noon) all dates will be included, but entries will be filtered
1275
+ by time of day.
1276
+ )
1277
+ c.arg_name 'DATE_OR_RANGE'
1278
+ c.flag [:from]
1279
+
1280
+ c.desc 'Force exact search string matching (case sensitive)'
1281
+ c.switch %i[x exact], default_value: config.exact_match?, negatable: config.exact_match?
1282
+
1283
+ c.desc 'Select items that *don\'t* match search/tag filters'
1284
+ c.switch [:not], default_value: false, negatable: false
1285
+
1286
+ c.desc 'Case sensitivity for search string matching [(c)ase-sensitive, (i)gnore, (s)mart]'
1287
+ c.arg_name 'TYPE'
1288
+ c.flag [:case], must_match: /^[csi]/, default_value: settings.dig('search', 'case')
1289
+
1290
+ c.desc 'Use --no-menu to skip the interactive menu. Use with --query to filter items and act on results automatically. Test with `--output doing` to preview matches.'
1291
+ c.switch %i[menu], negatable: true, default_value: true
1292
+
1293
+ c.desc 'Cancel selected items (add @done without timestamp)'
1294
+ c.switch %i[c cancel], negatable: false, default_value: false
1295
+
1296
+ c.desc 'Delete selected items'
1297
+ c.switch %i[d delete], negatable: false, default_value: false
1298
+
1299
+ c.desc 'Edit selected item(s)'
1300
+ c.switch %i[e editor], negatable: false, default_value: false
1301
+
1302
+ c.desc 'Add @done with current time to selected item(s)'
1303
+ c.switch %i[f finish], negatable: false, default_value: false
1304
+
1305
+ c.desc 'Add flag to selected item(s)'
1306
+ c.switch %i[flag], negatable: false, default_value: false
1307
+
1308
+ c.desc 'Perform action without confirmation.'
1309
+ c.switch %i[force], negatable: false, default_value: false
1310
+
1311
+ c.desc 'Save selected entries to file using --output format'
1312
+ c.arg_name 'FILE'
1313
+ c.flag %i[save_to]
1314
+
1315
+ c.desc "Output entries to format (#{Doing::Plugins.plugin_names(type: :export)})"
1316
+ c.arg_name 'FORMAT'
1317
+ c.flag %i[o output]
1318
+
1319
+ c.desc "Copy selection as a new entry with current time and no @done tag. Only works with single selections. Can be combined with --editor."
1320
+ c.switch %i[again resume], negatable: false, default_value: false
1321
+
1322
+ c.action do |_global_options, options, args|
1323
+ raise DoingRuntimeError, %(Invalid output type "#{options[:output]}") if options[:output] && options[:output] !~ Doing::Plugins.plugin_regex(type: :export)
1324
+
1325
+ raise InvalidArgument, '--no-menu requires --query' if !options[:menu] && !options[:query]
1326
+
1327
+ options[:case] = options[:case].normalize_case
1328
+
1329
+ wwid.interactive(options) # hooked
1483
1330
  end
1484
1331
  end
1485
1332
 
1486
- desc 'Mark last entry as flagged'
1487
- command %i[mark flag] do |c|
1488
- c.example 'doing flag', desc: 'Add @flagged to the last entry created'
1489
- c.example 'doing mark', desc: 'mark is an alias for flag'
1490
- c.example 'doing flag --tag project1 --count 2', desc: 'Add @flagged to the last 2 entries tagged @project1'
1491
- c.example 'doing flag --interactive --search "/(develop|cod)ing/"', desc: 'Find entries matching regular expression and create a menu allowing multiple selections, selected items will be @flagged'
1333
+ # @@tag
1334
+ desc 'Add tag(s) to last entry'
1335
+ long_desc 'Add (or remove) tags from the last entry, or from multiple entries
1336
+ (with `--count`), entries matching a search (with `--search`), or entries
1337
+ containing another tag (with `--tag`).
1338
+
1339
+ When removing tags with `-r`, wildcards are allowed (`*` to match
1340
+ multiple characters, `?` to match a single character). With `--regex`,
1341
+ regular expressions will be interpreted instead of wildcards.
1342
+
1343
+ For all tag removals the match is case insensitive by default, but if
1344
+ the tag search string contains any uppercase letters, the match will
1345
+ become case sensitive automatically.
1346
+
1347
+ Tag name arguments do not need to be prefixed with @.'
1348
+ arg_name 'TAG', :multiple
1349
+ command :tag do |c|
1350
+ c.example 'doing tag mytag', desc: 'Add @mytag to the last entry created'
1351
+ c.example 'doing tag --remove mytag', desc: 'Remove @mytag from the last entry created'
1352
+ c.example 'doing tag --rename "other*" --count 10 newtag', desc: 'Rename tags beginning with "other" (wildcard) to @newtag on the last 10 entries'
1353
+ c.example 'doing tag --search "developing" coding', desc: 'Add @coding to the last entry containing string "developing" (fuzzy matching)'
1354
+ c.example 'doing tag --interactive --tag project1 coding', desc: 'Create an interactive menu from entries tagged @project1, selection(s) will be tagged with @coding'
1492
1355
 
1493
1356
  c.desc 'Section'
1494
1357
  c.arg_name 'SECTION_NAME'
@@ -1498,24 +1361,34 @@ command %i[mark flag] do |c|
1498
1361
  c.arg_name 'COUNT'
1499
1362
  c.flag %i[c count], default_value: 1, must_match: /^\d+$/, type: Integer
1500
1363
 
1501
- c.desc 'Don\'t ask permission to flag all entries when count is 0'
1364
+ c.desc 'Replace existing tag with tag argument, wildcards (*,?) allowed, or use with --regex'
1365
+ c.arg_name 'ORIG_TAG'
1366
+ c.flag %i[rename]
1367
+
1368
+ c.desc 'Don\'t ask permission to tag all entries when count is 0'
1502
1369
  c.switch %i[force], negatable: false, default_value: false
1503
1370
 
1504
1371
  c.desc 'Include current date/time with tag'
1505
1372
  c.switch %i[d date], negatable: false, default_value: false
1506
1373
 
1507
- c.desc 'Remove flag'
1374
+ c.desc 'Remove given tag(s)'
1508
1375
  c.switch %i[r remove], negatable: false, default_value: false
1509
1376
 
1510
- c.desc 'Flag last entry (or entries) not marked @done'
1377
+ c.desc 'Interpret tag string as regular expression (with --remove)'
1378
+ c.switch %i[regex], negatable: false, default_value: false
1379
+
1380
+ c.desc 'Tag last entry (or entries) not marked @done'
1511
1381
  c.switch %i[u unfinished], negatable: false, default_value: false
1512
1382
 
1513
- c.desc 'Flag the last entry containing TAG.
1383
+ c.desc 'Autotag entries based on autotag configuration in ~/.config/doing/config.yml'
1384
+ c.switch %i[a autotag], negatable: false, default_value: false
1385
+
1386
+ c.desc 'Tag the last X entries containing TAG.
1514
1387
  Separate multiple tags with comma (--tag=tag1,tag2), combine with --bool. Wildcards allowed (*, ?).'
1515
1388
  c.arg_name 'TAG'
1516
1389
  c.flag [:tag]
1517
1390
 
1518
- c.desc 'Flag the last entry matching search filter, surround with slashes for regex (e.g. "/query.*/"), start with single quote for exact match ("\'query")'
1391
+ c.desc 'Tag entries matching search filter, surround with slashes for regex (e.g. "/query.*/"), start with single quote for exact match ("\'query")'
1519
1392
  c.arg_name 'QUERY'
1520
1393
  c.flag [:search]
1521
1394
 
@@ -1525,7 +1398,7 @@ command %i[mark flag] do |c|
1525
1398
  c.desc 'Force exact search string matching (case sensitive)'
1526
1399
  c.switch %i[x exact], default_value: config.exact_match?, negatable: config.exact_match?
1527
1400
 
1528
- c.desc 'Flag items that *don\'t* match search/tag/date filters'
1401
+ c.desc 'Tag items that *don\'t* match search/tag filters'
1529
1402
  c.switch [:not], default_value: false, negatable: false
1530
1403
 
1531
1404
  c.desc 'Case sensitivity for search string matching [(c)ase-sensitive, (i)gnore, (s)mart]'
@@ -1536,12 +1409,12 @@ command %i[mark flag] do |c|
1536
1409
  c.arg_name 'BOOLEAN'
1537
1410
  c.flag [:bool], must_match: REGEX_BOOL, default_value: 'PATTERN'
1538
1411
 
1539
- c.desc 'Select item(s) to flag from a menu of matching entries'
1412
+ c.desc 'Select item(s) to tag from a menu of matching entries'
1540
1413
  c.switch %i[i interactive], negatable: false, default_value: false
1541
1414
 
1542
- c.action do |_global_options, options, _args|
1415
+ c.action do |_global_options, options, args|
1543
1416
  options[:fuzzy] = false
1544
- mark = settings['marker_tag'] || 'flagged'
1417
+ raise MissingArgument, 'You must specify at least one tag' if args.empty? && !options[:autotag]
1545
1418
 
1546
1419
  raise InvalidArgument, '--search and --tag can not be used together' if options[:search] && options[:tag]
1547
1420
 
@@ -1551,12 +1424,25 @@ command %i[mark flag] do |c|
1551
1424
  section = wwid.guess_section(options[:section]) || options[:section].cap_first
1552
1425
  end
1553
1426
 
1427
+
1554
1428
  if options[:tag].nil?
1555
1429
  search_tags = []
1556
1430
  else
1557
1431
  search_tags = options[:tag].to_tags
1558
1432
  end
1559
1433
 
1434
+ if options[:autotag]
1435
+ tags = []
1436
+ else
1437
+ tags = if args.join('') =~ /,/
1438
+ args.join('').split(/,/)
1439
+ else
1440
+ args.join(' ').split(' ') # in case tags are quoted as one arg
1441
+ end
1442
+
1443
+ tags.map! { |tag| tag.sub(/^@/, '').strip }
1444
+ end
1445
+
1560
1446
  if options[:interactive]
1561
1447
  count = 0
1562
1448
  options[:force] = true
@@ -1564,6 +1450,7 @@ command %i[mark flag] do |c|
1564
1450
  count = options[:count].to_i
1565
1451
  end
1566
1452
 
1453
+ options[:case] ||= :smart
1567
1454
  options[:case] = options[:case].normalize_case
1568
1455
 
1569
1456
  if options[:search]
@@ -1572,114 +1459,97 @@ command %i[mark flag] do |c|
1572
1459
  options[:search] = search
1573
1460
  end
1574
1461
 
1462
+ options[:count] = count
1463
+ options[:section] = section
1464
+ options[:tag] = search_tags
1465
+ options[:tags] = tags
1466
+ options[:tag_bool] = options[:bool].normalize_bool
1467
+
1575
1468
  if count.zero? && !options[:force]
1576
- if options[:search]
1577
- section_q = ' matching your search terms'
1578
- elsif options[:tag]
1579
- section_q = ' matching your tag search'
1580
- elsif section == 'All'
1581
- section_q = ''
1582
- else
1583
- section_q = " in section #{section}"
1584
- end
1469
+ matches = wwid.filter_items([], opt: options).count
1585
1470
 
1471
+ if matches > 5
1472
+ if options[:search]
1473
+ section_q = ' matching your search terms'
1474
+ elsif options[:tag]
1475
+ section_q = ' matching your tag search'
1476
+ elsif section == 'All'
1477
+ section_q = ''
1478
+ else
1479
+ section_q = " in section #{section}"
1480
+ end
1586
1481
 
1587
- question = if options[:remove]
1588
- "Are you sure you want to unflag all entries#{section_q}"
1589
- else
1590
- "Are you sure you want to flag all records#{section_q}"
1591
- end
1592
1482
 
1593
- res = Doing::Prompt.yn(question, default_response: false)
1483
+ question = if options[:autotag]
1484
+ "Are you sure you want to autotag #{matches} records#{section_q}"
1485
+ elsif options[:remove]
1486
+ "Are you sure you want to remove #{tags.join(' and ')} from #{matches} records#{section_q}"
1487
+ else
1488
+ "Are you sure you want to add #{tags.join(' and ')} to #{matches} records#{section_q}"
1489
+ end
1594
1490
 
1595
- exit_now! 'Cancelled' unless res
1596
- end
1491
+ res = Doing::Prompt.yn(question, default_response: false)
1597
1492
 
1598
- options[:count] = count
1599
- options[:section] = section
1600
- options[:tag] = search_tags
1601
- options[:tags] = [mark]
1602
- options[:tag_bool] = options[:bool].normalize_bool
1493
+ raise UserCancelled unless res
1494
+ end
1495
+ end
1603
1496
 
1604
1497
  wwid.tag_last(options)
1605
1498
  end
1606
1499
  end
1607
1500
 
1608
- desc 'List all entries'
1609
- long_desc %(
1610
- The argument can be a section name, @tag(s) or both.
1611
- "pick" or "choose" as an argument will offer a section menu. Run with `--menu` to get a menu of available tags.
1501
+ ## View commands
1612
1502
 
1613
- Show tags by passing @tagname arguments. Multiple tags can be combined, and you can specify the boolean used to
1614
- combine them with `--bool (AND|OR|NOT)`. You can also use @+tagname to require a tag to match, or @-tagname to ignore
1615
- entries containing tagname. +/- operators require `--bool PATTERN` (which is the default).
1616
- )
1617
- arg_name '[SECTION|@TAGS]'
1618
- command :show do |c|
1619
- c.example 'doing show Currently', desc: 'Show entries in the Currently section'
1620
- c.example 'doing show @project1', desc: 'Show entries tagged @project1'
1621
- c.example 'doing show Later @doing', desc: 'Show entries from the Later section tagged @doing'
1622
- c.example 'doing show @oracle @writing --bool and', desc: 'Show entries tagged both @oracle and @writing'
1623
- c.example 'doing show Currently @devo --bool not', desc: 'Show entries in Currently NOT tagged @devo'
1624
- c.example 'doing show Ideas @doing --from "mon to fri"', desc: 'Show entries tagged @doing from the Ideas section added between monday and friday of the current week.'
1625
- c.example 'doing show --interactive Later @doing', desc: 'Create a menu from entries from the Later section tagged @doing to perform batch actions'
1503
+ # @@choose
1504
+ desc 'Select a section to display from a menu'
1505
+ command :choose do |c|
1506
+ c.action do |_global_options, _options, _args|
1507
+ section = wwid.choose_section
1626
1508
 
1627
- c.desc 'Tag filter, combine multiple tags with a comma. Use `--tag pick` for a menu of available tags. Wildcards allowed (*, ?). Added for compatibility with other commands.'
1628
- c.arg_name 'TAG'
1629
- c.flag [:tag]
1509
+ Doing::Pager.page wwid.list_section({ section: section.cap_first, count: 0 }) if section
1510
+ end
1511
+ end
1630
1512
 
1631
- c.desc 'Tag boolean (AND,OR,NOT). Use PATTERN to parse + and - as booleans.'
1632
- c.arg_name 'BOOLEAN'
1633
- c.flag %i[b bool], must_match: REGEX_BOOL, default_value: 'PATTERN'
1513
+ # @@grep @@search
1514
+ desc 'Search for entries'
1515
+ long_desc %(
1516
+ Search all sections (or limit to a single section) for entries matching text or regular expression. Normal strings are fuzzy matched.
1634
1517
 
1635
- c.desc 'Max count to show'
1636
- c.arg_name 'MAX'
1637
- c.flag %i[c count], default_value: 0, must_match: /^\d+$/, type: Integer
1518
+ To search with regular expressions, single quote the string and surround with slashes: `doing search '/\bm.*?x\b/'`
1519
+ )
1520
+ arg_name 'SEARCH_PATTERN'
1521
+ command %i[grep search] do |c|
1522
+ c.example 'doing grep "doing wiki"', desc: 'Find entries containing "doing wiki" using fuzzy matching'
1523
+ c.example 'doing search "\'search command"', desc: 'Find entries containing "search command" using exact matching (search is an alias for grep)'
1524
+ c.example 'doing grep "/do.*?wiki.*?@done/"', desc: 'Find entries matching regular expression'
1525
+ c.example 'doing search --before 12/21 "doing wiki"', desc: 'Find entries containing "doing wiki" with entry dates before 12/21 of the current year'
1638
1526
 
1639
- c.desc 'Age (oldest|newest)'
1640
- c.arg_name 'AGE'
1641
- c.flag %i[a age], default_value: 'newest'
1527
+ c.desc 'Section'
1528
+ c.arg_name 'NAME'
1529
+ c.flag %i[s section], default_value: 'All'
1642
1530
 
1643
- c.desc 'Show entries older than date. If this is only a time (8am, 1:30pm, 15:00), all dates will be included, but entries will be filtered by time of day.'
1531
+ c.desc 'Search entries older than date. If this is only a time (8am, 1:30pm, 15:00), all dates will be included, but entries will be filtered by time of day.'
1644
1532
  c.arg_name 'DATE_STRING'
1645
1533
  c.flag [:before]
1646
1534
 
1647
- c.desc 'Show entries newer than date. If this is only a time (8am, 1:30pm, 15:00), all dates will be included, but entries will be filtered by time of day.'
1535
+ c.desc 'Search entries newer than date. If this is only a time (8am, 1:30pm, 15:00), all dates will be included, but entries will be filtered by time of day.'
1648
1536
  c.arg_name 'DATE_STRING'
1649
1537
  c.flag [:after]
1650
1538
 
1651
1539
  c.desc %(
1652
1540
  Date range to show, or a single day to filter date on.
1653
1541
  Date range argument should be quoted. Date specifications can be natural language.
1654
- To specify a range, use "to" or "through": `doing show --from "monday 8am to friday 5pm"`.
1542
+ To specify a range, use "to" or "through": `doing search --from "monday 8am to friday 5pm"`.
1655
1543
 
1656
1544
  If values are only time(s) (6am to noon) all dates will be included, but entries will be filtered
1657
1545
  by time of day.
1658
1546
  )
1659
-
1660
1547
  c.arg_name 'DATE_OR_RANGE'
1661
1548
  c.flag [:from]
1662
1549
 
1663
- c.desc 'Search filter, surround with slashes for regex (/query/), start with single quote for exact match ("\'query")'
1664
- c.arg_name 'QUERY'
1665
- c.flag [:search]
1666
-
1667
- # c.desc '[DEPRECATED] Use alternative fuzzy matching for search string'
1668
- # c.switch [:fuzzy], default_value: false, negatable: false
1669
-
1670
- c.desc 'Force exact search string matching (case sensitive)'
1671
- c.switch %i[x exact], default_value: config.exact_match?, negatable: config.exact_match?
1672
-
1673
- c.desc 'Show items that *don\'t* match search/tag/date filters'
1674
- c.switch [:not], default_value: false, negatable: false
1675
-
1676
- c.desc 'Case sensitivity for search string matching [(c)ase-sensitive, (i)gnore, (s)mart]'
1677
- c.arg_name 'TYPE'
1678
- c.flag [:case], must_match: /^[csi]/, default_value: settings.dig('search', 'case')
1679
-
1680
- c.desc 'Sort order (asc/desc)'
1681
- c.arg_name 'ORDER'
1682
- c.flag %i[s sort], must_match: REGEX_SORT_ORDER, default_value: 'asc'
1550
+ c.desc "Output to export format (#{Doing::Plugins.plugin_names(type: :export)})"
1551
+ c.arg_name 'FORMAT'
1552
+ c.flag %i[o output]
1683
1553
 
1684
1554
  c.desc 'Show time intervals on @done tasks'
1685
1555
  c.switch %i[t times], default_value: true, negatable: true
@@ -1694,229 +1564,152 @@ command :show do |c|
1694
1564
  default = 'time'
1695
1565
  default = settings['tag_sort'] || 'name'
1696
1566
  c.arg_name 'KEY'
1697
- c.flag [:tag_sort], must_match: /^(?:name|time)/i, default_value: default
1698
-
1699
- c.desc 'Tag sort direction (asc|desc)'
1700
- c.arg_name 'DIRECTION'
1701
- c.flag [:tag_order], must_match: REGEX_SORT_ORDER, default_value: 'asc'
1567
+ c.flag [:tag_sort], must_match: /^(?:name|time)$/i, default_value: default
1702
1568
 
1703
1569
  c.desc 'Only show items with recorded time intervals'
1704
1570
  c.switch [:only_timed], default_value: false, negatable: false
1705
1571
 
1706
- c.desc 'Select section or tag to display from a menu'
1707
- c.switch %i[m menu], negatable: false, default_value: false
1708
-
1709
- c.desc 'Select from a menu of matching entries to perform additional operations'
1710
- c.switch %i[i interactive], negatable: false, default_value: false
1572
+ # c.desc '[DEPRECATED] Use alternative fuzzy matching for search string'
1573
+ # c.switch [:fuzzy], default_value: false, negatable: false
1711
1574
 
1712
- c.desc "Output to export format (#{Doing::Plugins.plugin_names(type: :export)})"
1713
- c.arg_name 'FORMAT'
1714
- c.flag %i[o output]
1715
- c.action do |global_options, options, args|
1716
- options[:fuzzy] = false
1717
- raise DoingRuntimeError, %(Invalid output type "#{options[:output]}") if options[:output] && options[:output] !~ Doing::Plugins.plugin_regex(type: :export)
1575
+ c.desc 'Force exact string matching (case sensitive)'
1576
+ c.switch %i[x exact], default_value: config.exact_match?, negatable: config.exact_match?
1718
1577
 
1719
- tag_filter = false
1720
- tags = []
1578
+ c.desc 'Show items that *don\'t* match search string'
1579
+ c.switch [:not], default_value: false, negatable: false
1721
1580
 
1722
- if args.length.positive?
1723
- case args[0]
1724
- when /^all$/i
1725
- section = 'All'
1726
- args.shift
1727
- when /^(choose|pick)$/i
1728
- section = wwid.choose_section(include_all: true)
1581
+ c.desc 'Case sensitivity for search string matching [(c)ase-sensitive, (i)gnore, (s)mart]'
1582
+ c.arg_name 'TYPE'
1583
+ c.flag [:case], must_match: /^[csi]/, default_value: settings.dig('search', 'case')
1729
1584
 
1730
- args.shift
1731
- when /^[@+-]/
1732
- section = 'All'
1733
- else
1734
- begin
1735
- section = wwid.guess_section(args[0])
1736
- rescue WrongCommand => exception
1737
- cmd = commands[:view]
1738
- action = cmd.send(:get_action, nil)
1739
- return action.call(global_options, options, args)
1740
- end
1585
+ c.desc "Edit matching entries with #{Doing::Util.default_editor}"
1586
+ c.switch %i[e editor], negatable: false, default_value: false
1741
1587
 
1742
- raise InvalidSection, "No such section: #{args[0]}" unless section
1588
+ c.desc "Delete matching entries"
1589
+ c.switch %i[d delete], negatable: false, default_value: false
1743
1590
 
1744
- args.shift
1745
- end
1746
- if args.length.positive?
1747
- args.each do |arg|
1748
- arg.split(/,/).each do |tag|
1749
- tags.push(tag.strip.sub(/^@/, ''))
1750
- end
1751
- end
1752
- end
1753
- else
1754
- section = options[:menu] ? wwid.choose_section(include_all: true) : settings['current_section']
1755
- section ||= 'All'
1756
- end
1591
+ c.desc 'Display an interactive menu of results to perform further operations'
1592
+ c.switch %i[i interactive], default_value: false, negatable: false
1757
1593
 
1758
- tags.concat(options[:tag].to_tags) if options[:tag]
1594
+ c.action do |_global_options, options, args|
1595
+ options[:fuzzy] = false
1596
+ raise DoingRuntimeError, %(Invalid output type "#{options[:output]}") if options[:output] && options[:output] !~ Doing::Plugins.plugin_regex(type: :export)
1759
1597
 
1760
- options[:times] = true if options[:totals]
1598
+ template = settings['templates']['default'].deep_merge(settings)
1599
+ tags_color = template.key?('tags_color') ? template['tags_color'] : nil
1761
1600
 
1762
- template = settings['templates']['default'].deep_merge({
1763
- 'wrap_width' => settings['wrap_width'] || 0,
1764
- 'date_format' => settings['default_date_format'],
1765
- 'order' => settings['order'] || 'asc',
1766
- 'tags_color' => settings['tags_color']
1767
- })
1601
+ section = wwid.guess_section(options[:section]) if options[:section]
1768
1602
 
1769
1603
  options[:case] = options[:case].normalize_case
1770
1604
 
1771
- if options[:search]
1772
- search = options[:search]
1773
- search.sub!(/^'?/, "'") if options[:exact]
1774
- options[:search] = search
1775
- end
1605
+ search = args.join(' ')
1606
+ search.sub!(/^'?/, "'") if options[:exact]
1776
1607
 
1608
+ options[:times] = true if options[:totals]
1609
+ options[:sort_tags] = options[:tag_sort] =~ /^n/i
1610
+ options[:highlight] = true
1611
+ options[:search] = search
1777
1612
  options[:section] = section
1613
+ options[:tags_color] = tags_color
1778
1614
 
1779
- unless tags.empty?
1780
- tag_filter = {
1781
- 'tags' => tags,
1782
- 'bool' => options[:bool].normalize_bool
1783
- }
1784
- end
1615
+ Doing::Pager.page wwid.list_section(options)
1616
+ end
1617
+ end
1785
1618
 
1786
- options[:tag_filter] = tag_filter
1787
- options[:tag] = nil
1619
+ # @@last
1620
+ desc 'Show the last entry, optionally edit'
1621
+ long_desc 'Shows the last entry. Using --search and --tag filters, you can view/edit the last entry matching a filter,
1622
+ allowing `doing last` to target historical entries.'
1623
+ command :last do |c|
1624
+ c.example 'doing last', desc: 'Show the most recent entry in all sections'
1625
+ c.example 'doing last -s Later', desc: 'Show the most recent entry in the Later section'
1626
+ c.example 'doing last --tag project1,work --bool AND', desc: 'Show most recent entry tagged @project1 and @work'
1627
+ c.example 'doing last --search "side hustle"', desc: 'Show most recent entry containing "side hustle" (fuzzy matching)'
1628
+ c.example 'doing last --search "\'side hustle"', desc: 'Show most recent entry containing "side hustle" (exact match)'
1629
+ c.example 'doing last --edit', desc: 'Open the most recent entry in an editor for modifications'
1630
+ c.example 'doing last --search "\'side hustle" --edit', desc: 'Open most recent entry containing "side hustle" (exact match) in editor'
1788
1631
 
1789
- items = wwid.filter_items([], opt: options)
1632
+ c.desc 'Specify a section'
1633
+ c.arg_name 'NAME'
1634
+ c.flag %i[s section], default_value: 'All'
1790
1635
 
1791
- if options[:menu]
1792
- tag = wwid.choose_tag(section, items: items, include_all: true)
1793
- raise UserCancelled unless tag
1636
+ c.desc "Edit entry with #{Doing::Util.default_editor}"
1637
+ c.switch %i[e editor], negatable: false, default_value: false
1794
1638
 
1795
- # options[:bool] = :and unless tags.empty?
1639
+ c.desc "Delete the last entry"
1640
+ c.switch %i[d delete], negatable: false, default_value: false
1796
1641
 
1797
- tags = tag.split(/ +/).map { |t| t.strip.sub(/^@?/, '') } if tag =~ /^@/
1798
- unless tags.empty?
1799
- tag_filter = {
1800
- 'tags' => tags,
1801
- 'bool' => options[:bool].normalize_bool
1802
- }
1803
- options[:tag_filter] = tag_filter
1804
- end
1805
- end
1806
-
1807
- opt = options.dup
1808
- opt[:sort_tags] = options[:tag_sort] =~ /^n/i
1809
- opt[:count] = options[:count].to_i
1810
- opt[:highlight] = true
1811
- opt[:order] = options[:sort].normalize_order
1812
- opt[:tag] = nil
1813
- opt[:tag_order] = options[:tag_order].normalize_order
1814
- opt[:tags_color] = template['tags_color']
1815
-
1816
- Doing::Pager.page wwid.list_section(opt, items: items)
1817
- end
1818
- end
1819
-
1820
- desc 'Search for entries'
1821
- long_desc %(
1822
- Search all sections (or limit to a single section) for entries matching text or regular expression. Normal strings are fuzzy matched.
1823
-
1824
- To search with regular expressions, single quote the string and surround with slashes: `doing search '/\bm.*?x\b/'`
1825
- )
1826
-
1827
- arg_name 'SEARCH_PATTERN'
1828
- command %i[grep search] do |c|
1829
- c.example 'doing grep "doing wiki"', desc: 'Find entries containing "doing wiki" using fuzzy matching'
1830
- c.example 'doing search "\'search command"', desc: 'Find entries containing "search command" using exact matching (search is an alias for grep)'
1831
- c.example 'doing grep "/do.*?wiki.*?@done/"', desc: 'Find entries matching regular expression'
1832
- c.example 'doing search --before 12/21 "doing wiki"', desc: 'Find entries containing "doing wiki" with entry dates before 12/21 of the current year'
1833
-
1834
- c.desc 'Section'
1835
- c.arg_name 'NAME'
1836
- c.flag %i[s section], default_value: 'All'
1837
-
1838
- c.desc 'Search entries older than date. If this is only a time (8am, 1:30pm, 15:00), all dates will be included, but entries will be filtered by time of day.'
1839
- c.arg_name 'DATE_STRING'
1840
- c.flag [:before]
1841
-
1842
- c.desc 'Search entries newer than date. If this is only a time (8am, 1:30pm, 15:00), all dates will be included, but entries will be filtered by time of day.'
1843
- c.arg_name 'DATE_STRING'
1844
- c.flag [:after]
1845
-
1846
- c.desc %(
1847
- Date range to show, or a single day to filter date on.
1848
- Date range argument should be quoted. Date specifications can be natural language.
1849
- To specify a range, use "to" or "through": `doing search --from "monday 8am to friday 5pm"`.
1850
-
1851
- If values are only time(s) (6am to noon) all dates will be included, but entries will be filtered
1852
- by time of day.
1853
- )
1854
- c.arg_name 'DATE_OR_RANGE'
1855
- c.flag [:from]
1642
+ c.desc 'Tag filter, combine multiple tags with a comma. Wildcards allowed (*, ?).'
1643
+ c.arg_name 'TAG'
1644
+ c.flag [:tag]
1856
1645
 
1857
- c.desc "Output to export format (#{Doing::Plugins.plugin_names(type: :export)})"
1858
- c.arg_name 'FORMAT'
1859
- c.flag %i[o output]
1646
+ c.desc 'Tag boolean (AND|OR|NOT). Use PATTERN to parse + and - as booleans.'
1647
+ c.arg_name 'BOOLEAN'
1648
+ c.flag [:bool], must_match: REGEX_BOOL, default_value: 'PATTERN'
1860
1649
 
1861
- c.desc 'Show time intervals on @done tasks'
1862
- c.switch %i[t times], default_value: true, negatable: true
1650
+ c.desc 'Search filter, surround with slashes for regex (/query/), start with single quote for exact match ("\'query")'
1651
+ c.arg_name 'QUERY'
1652
+ c.flag [:search]
1863
1653
 
1864
- c.desc 'Show elapsed time on entries without @done tag'
1654
+ c.desc 'Show elapsed time if entry is not tagged @done'
1865
1655
  c.switch [:duration]
1866
1656
 
1867
- c.desc 'Show intervals with totals at the end of output'
1868
- c.switch [:totals], default_value: false, negatable: false
1869
-
1870
- c.desc 'Sort tags by (name|time)'
1871
- default = 'time'
1872
- default = settings['tag_sort'] || 'name'
1873
- c.arg_name 'KEY'
1874
- c.flag [:tag_sort], must_match: /^(?:name|time)$/i, default_value: default
1875
-
1876
- c.desc 'Only show items with recorded time intervals'
1877
- c.switch [:only_timed], default_value: false, negatable: false
1878
-
1879
1657
  # c.desc '[DEPRECATED] Use alternative fuzzy matching for search string'
1880
1658
  # c.switch [:fuzzy], default_value: false, negatable: false
1881
1659
 
1882
- c.desc 'Force exact string matching (case sensitive)'
1660
+ c.desc 'Force exact search string matching (case sensitive)'
1883
1661
  c.switch %i[x exact], default_value: config.exact_match?, negatable: config.exact_match?
1884
1662
 
1885
- c.desc 'Show items that *don\'t* match search string'
1663
+ c.desc 'Show items that *don\'t* match search string or tag filter'
1886
1664
  c.switch [:not], default_value: false, negatable: false
1887
1665
 
1888
1666
  c.desc 'Case sensitivity for search string matching [(c)ase-sensitive, (i)gnore, (s)mart]'
1889
1667
  c.arg_name 'TYPE'
1890
1668
  c.flag [:case], must_match: /^[csi]/, default_value: settings.dig('search', 'case')
1891
1669
 
1892
- c.desc 'Display an interactive menu of results to perform further operations'
1893
- c.switch %i[i interactive], default_value: false, negatable: false
1894
-
1895
- c.action do |_global_options, options, args|
1670
+ c.action do |global_options, options, _args|
1896
1671
  options[:fuzzy] = false
1897
- raise DoingRuntimeError, %(Invalid output type "#{options[:output]}") if options[:output] && options[:output] !~ Doing::Plugins.plugin_regex(type: :export)
1898
-
1899
- template = settings['templates']['default'].deep_merge(settings)
1900
- tags_color = template.key?('tags_color') ? template['tags_color'] : nil
1672
+ raise InvalidArgument, '--tag and --search can not be used together' if options[:tag] && options[:search]
1901
1673
 
1902
- section = wwid.guess_section(options[:section]) if options[:section]
1674
+ if options[:tag].nil?
1675
+ options[:tag] = []
1676
+ else
1677
+ options[:tag] = options[:tag].to_tags
1678
+ options[:bool] = options[:bool].normalize_bool
1679
+ end
1903
1680
 
1904
1681
  options[:case] = options[:case].normalize_case
1905
1682
 
1906
- search = args.join(' ')
1907
- search.sub!(/^'?/, "'") if options[:exact]
1908
-
1909
- options[:times] = true if options[:totals]
1910
- options[:sort_tags] = options[:tag_sort] =~ /^n/i
1911
- options[:highlight] = true
1912
- options[:search] = search
1913
- options[:section] = section
1914
- options[:tags_color] = tags_color
1683
+ options[:search] = options[:search].sub(/^'?/, "'") if options[:search] && options[:exact]
1915
1684
 
1916
- Doing::Pager.page wwid.list_section(options)
1685
+ if options[:editor]
1686
+ wwid.edit_last(section: options[:section],
1687
+ options: {
1688
+ search: search,
1689
+ fuzzy: options[:fuzzy],
1690
+ case: options[:case],
1691
+ tag: tags,
1692
+ tag_bool: options[:bool],
1693
+ not: options[:not]
1694
+ })
1695
+ else
1696
+ last = wwid.last(times: true, section: options[:section],
1697
+ options: {
1698
+ duration: options[:duration],
1699
+ search: options[:search],
1700
+ fuzzy: options[:fuzzy],
1701
+ case: options[:case],
1702
+ negate: options[:not],
1703
+ tag: options[:tag],
1704
+ tag_bool: options[:bool],
1705
+ delete: options[:delete]
1706
+ })
1707
+ Doing::Pager::page last.strip if last
1708
+ end
1917
1709
  end
1918
1710
  end
1919
1711
 
1712
+ # @@recent
1920
1713
  desc 'List recent entries'
1921
1714
  default_value 10
1922
1715
  arg_name 'COUNT'
@@ -1985,76 +1778,82 @@ command :recent do |c|
1985
1778
  end
1986
1779
  end
1987
1780
 
1988
- desc 'List entries from today'
1989
- long_desc 'List entries from the current day. Use --before, --after, and
1990
- --from to specify time ranges.'
1991
- command :today do |c|
1992
- c.example 'doing today', desc: 'List all entries with start dates between 12am and 11:59PM for the current day'
1993
- c.example 'doing today --section Later', desc: 'List today\'s entries in the Later section'
1994
- c.example 'doing today --before 3pm --after 12pm', desc: 'List entries with start dates between 12pm and 3pm today'
1995
- c.example 'doing today --output json', desc: 'Output entries from today in JSON format'
1996
-
1997
- c.desc 'Specify a section'
1998
- c.arg_name 'NAME'
1999
- c.flag %i[s section], default_value: 'All'
1781
+ # @@show
1782
+ desc 'List all entries'
1783
+ long_desc %(
1784
+ The argument can be a section name, @tag(s) or both.
1785
+ "pick" or "choose" as an argument will offer a section menu. Run with `--menu` to get a menu of available tags.
2000
1786
 
2001
- c.desc 'Show time intervals on @done tasks'
2002
- c.switch %i[t times], default_value: true, negatable: true
1787
+ Show tags by passing @tagname arguments. Multiple tags can be combined, and you can specify the boolean used to
1788
+ combine them with `--bool (AND|OR|NOT)`. You can also use @+tagname to require a tag to match, or @-tagname to ignore
1789
+ entries containing tagname. +/- operators require `--bool PATTERN` (which is the default).
1790
+ )
1791
+ arg_name '[SECTION|@TAGS]'
1792
+ command :show do |c|
1793
+ c.example 'doing show Currently', desc: 'Show entries in the Currently section'
1794
+ c.example 'doing show @project1', desc: 'Show entries tagged @project1'
1795
+ c.example 'doing show Later @doing', desc: 'Show entries from the Later section tagged @doing'
1796
+ c.example 'doing show @oracle @writing --bool and', desc: 'Show entries tagged both @oracle and @writing'
1797
+ c.example 'doing show Currently @devo --bool not', desc: 'Show entries in Currently NOT tagged @devo'
1798
+ c.example 'doing show Ideas @doing --from "mon to fri"', desc: 'Show entries tagged @doing from the Ideas section added between monday and friday of the current week.'
1799
+ c.example 'doing show --interactive Later @doing', desc: 'Create a menu from entries from the Later section tagged @doing to perform batch actions'
2003
1800
 
2004
- c.desc 'Show elapsed time on entries without @done tag'
2005
- c.switch [:duration]
1801
+ c.desc 'Tag filter, combine multiple tags with a comma. Use `--tag pick` for a menu of available tags. Wildcards allowed (*, ?). Added for compatibility with other commands.'
1802
+ c.arg_name 'TAG'
1803
+ c.flag [:tag]
2006
1804
 
2007
- c.desc 'Show time totals at the end of output'
2008
- c.switch [:totals], default_value: false, negatable: false
1805
+ c.desc 'Tag boolean (AND,OR,NOT). Use PATTERN to parse + and - as booleans.'
1806
+ c.arg_name 'BOOLEAN'
1807
+ c.flag %i[b bool], must_match: REGEX_BOOL, default_value: 'PATTERN'
2009
1808
 
2010
- c.desc 'Sort tags by (name|time)'
2011
- default = 'time'
2012
- default = settings['tag_sort'] || 'name'
2013
- c.arg_name 'KEY'
2014
- c.flag [:tag_sort], must_match: /^(?:name|time)$/i, default_value: default
1809
+ c.desc 'Max count to show'
1810
+ c.arg_name 'MAX'
1811
+ c.flag %i[c count], default_value: 0, must_match: /^\d+$/, type: Integer
2015
1812
 
2016
- c.desc "Output to export format (#{Doing::Plugins.plugin_names(type: :export)})"
2017
- c.arg_name 'FORMAT'
2018
- c.flag %i[o output]
1813
+ c.desc 'Age (oldest|newest)'
1814
+ c.arg_name 'AGE'
1815
+ c.flag %i[a age], default_value: 'newest'
2019
1816
 
2020
- c.desc 'View entries before specified time (e.g. 8am, 12:30pm, 15:00)'
2021
- c.arg_name 'TIME_STRING'
1817
+ c.desc 'Show entries older than date. If this is only a time (8am, 1:30pm, 15:00), all dates will be included, but entries will be filtered by time of day.'
1818
+ c.arg_name 'DATE_STRING'
2022
1819
  c.flag [:before]
2023
1820
 
2024
- c.desc 'View entries after specified time (e.g. 8am, 12:30pm, 15:00)'
2025
- c.arg_name 'TIME_STRING'
1821
+ c.desc 'Show entries newer than date. If this is only a time (8am, 1:30pm, 15:00), all dates will be included, but entries will be filtered by time of day.'
1822
+ c.arg_name 'DATE_STRING'
2026
1823
  c.flag [:after]
2027
1824
 
2028
1825
  c.desc %(
2029
- Time range to show `doing today --from "12pm to 4pm"`
2030
- )
1826
+ Date range to show, or a single day to filter date on.
1827
+ Date range argument should be quoted. Date specifications can be natural language.
1828
+ To specify a range, use "to" or "through": `doing show --from "monday 8am to friday 5pm"`.
1829
+
1830
+ If values are only time(s) (6am to noon) all dates will be included, but entries will be filtered
1831
+ by time of day.
1832
+ )
1833
+
2031
1834
  c.arg_name 'DATE_OR_RANGE'
2032
1835
  c.flag [:from]
2033
1836
 
2034
- c.action do |_global_options, options, _args|
2035
- raise DoingRuntimeError, %(Invalid output type "#{options[:output]}") if options[:output] && options[:output] !~ Doing::Plugins.plugin_regex(type: :export)
1837
+ c.desc 'Search filter, surround with slashes for regex (/query/), start with single quote for exact match ("\'query")'
1838
+ c.arg_name 'QUERY'
1839
+ c.flag [:search]
2036
1840
 
2037
- options[:times] = true if options[:totals]
2038
- options[:sort_tags] = options[:tag_sort] =~ /^n/i
2039
- filter_options = %i[after before duration from section sort_tags totals].each_with_object({}) { |k, hsh| hsh[k] = options[k] }
1841
+ # c.desc '[DEPRECATED] Use alternative fuzzy matching for search string'
1842
+ # c.switch [:fuzzy], default_value: false, negatable: false
2040
1843
 
2041
- Doing::Pager.page wwid.today(options[:times], options[:output], filter_options).chomp
2042
- end
2043
- end
1844
+ c.desc 'Force exact search string matching (case sensitive)'
1845
+ c.switch %i[x exact], default_value: config.exact_match?, negatable: config.exact_match?
2044
1846
 
2045
- desc 'List entries for a date'
2046
- long_desc %(Date argument can be natural language. "thursday" would be interpreted as "last thursday,"
2047
- and "2d" would be interpreted as "two days ago." If you use "to" or "through" between two dates,
2048
- it will create a range.)
2049
- arg_name 'DATE_STRING'
2050
- command :on do |c|
2051
- c.example 'doing on friday', desc: 'List entries between 12am and 11:59PM last Friday'
2052
- c.example 'doing on 12/21/2020', desc: 'List entries from Dec 21, 2020'
2053
- c.example 'doing on "3d to 1d"', desc: 'List entries added between 3 days ago and 1 day ago'
1847
+ c.desc 'Show items that *don\'t* match search/tag/date filters'
1848
+ c.switch [:not], default_value: false, negatable: false
2054
1849
 
2055
- c.desc 'Section'
2056
- c.arg_name 'NAME'
2057
- c.flag %i[s section], default_value: 'All'
1850
+ c.desc 'Case sensitivity for search string matching [(c)ase-sensitive, (i)gnore, (s)mart]'
1851
+ c.arg_name 'TYPE'
1852
+ c.flag [:case], must_match: /^[csi]/, default_value: settings.dig('search', 'case')
1853
+
1854
+ c.desc 'Sort order (asc/desc)'
1855
+ c.arg_name 'ORDER'
1856
+ c.flag %i[s sort], must_match: REGEX_SORT_ORDER, default_value: 'asc'
2058
1857
 
2059
1858
  c.desc 'Show time intervals on @done tasks'
2060
1859
  c.switch %i[t times], default_value: true, negatable: true
@@ -2062,121 +1861,234 @@ command :on do |c|
2062
1861
  c.desc 'Show elapsed time on entries without @done tag'
2063
1862
  c.switch [:duration]
2064
1863
 
2065
- c.desc 'Show time totals at the end of output'
1864
+ c.desc 'Show intervals with totals at the end of output'
2066
1865
  c.switch [:totals], default_value: false, negatable: false
2067
1866
 
2068
1867
  c.desc 'Sort tags by (name|time)'
2069
1868
  default = 'time'
2070
1869
  default = settings['tag_sort'] || 'name'
2071
1870
  c.arg_name 'KEY'
2072
- c.flag [:tag_sort], must_match: /^(?:name|time)$/i, default_value: default
1871
+ c.flag [:tag_sort], must_match: /^(?:name|time)/i, default_value: default
1872
+
1873
+ c.desc 'Tag sort direction (asc|desc)'
1874
+ c.arg_name 'DIRECTION'
1875
+ c.flag [:tag_order], must_match: REGEX_SORT_ORDER, default_value: 'asc'
1876
+
1877
+ c.desc 'Only show items with recorded time intervals'
1878
+ c.switch [:only_timed], default_value: false, negatable: false
1879
+
1880
+ c.desc 'Select section or tag to display from a menu'
1881
+ c.switch %i[m menu], negatable: false, default_value: false
1882
+
1883
+ c.desc 'Select from a menu of matching entries to perform additional operations'
1884
+ c.switch %i[i interactive], negatable: false, default_value: false
2073
1885
 
2074
1886
  c.desc "Output to export format (#{Doing::Plugins.plugin_names(type: :export)})"
2075
1887
  c.arg_name 'FORMAT'
2076
1888
  c.flag %i[o output]
2077
-
2078
- c.action do |_global_options, options, args|
1889
+ c.action do |global_options, options, args|
1890
+ options[:fuzzy] = false
2079
1891
  raise DoingRuntimeError, %(Invalid output type "#{options[:output]}") if options[:output] && options[:output] !~ Doing::Plugins.plugin_regex(type: :export)
2080
1892
 
2081
- raise MissingArgument, 'Missing date argument' if args.empty?
1893
+ tag_filter = false
1894
+ tags = []
2082
1895
 
2083
- date_string = args.join(' ')
1896
+ if args.length.positive?
1897
+ case args[0]
1898
+ when /^all$/i
1899
+ section = 'All'
1900
+ args.shift
1901
+ when /^(choose|pick)$/i
1902
+ section = wwid.choose_section(include_all: true)
2084
1903
 
2085
- if date_string =~ / (to|through|thru) /
2086
- dates = date_string.split(/ (to|through|thru) /)
2087
- start = dates[0].chronify(guess: :begin)
2088
- finish = dates[2].chronify(guess: :end)
1904
+ args.shift
1905
+ when /^[@+-]/
1906
+ section = 'All'
1907
+ else
1908
+ begin
1909
+ section = wwid.guess_section(args[0])
1910
+ rescue WrongCommand => exception
1911
+ cmd = commands[:view]
1912
+ action = cmd.send(:get_action, nil)
1913
+ return action.call(global_options, options, args)
1914
+ end
1915
+
1916
+ raise InvalidSection, "No such section: #{args[0]}" unless section
1917
+
1918
+ args.shift
1919
+ end
1920
+ if args.length.positive?
1921
+ args.each do |arg|
1922
+ arg.split(/,/).each do |tag|
1923
+ tags.push(tag.strip.sub(/^@/, ''))
1924
+ end
1925
+ end
1926
+ end
2089
1927
  else
2090
- start = date_string.chronify(guess: :begin)
2091
- finish = false
1928
+ section = options[:menu] ? wwid.choose_section(include_all: true) : settings['current_section']
1929
+ section ||= 'All'
2092
1930
  end
2093
1931
 
2094
- raise InvalidTimeExpression, 'Unrecognized date string' unless start
2095
-
2096
- message = "date interpreted as #{start}"
2097
- message += " to #{finish}" if finish
2098
- Doing.logger.debug('Interpreter:', message)
1932
+ tags.concat(options[:tag].to_tags) if options[:tag]
2099
1933
 
2100
1934
  options[:times] = true if options[:totals]
2101
- options[:sort_tags] = options[:tag_sort] =~ /^n/i
2102
1935
 
2103
- Doing::Pager.page wwid.list_date([start, finish], options[:section], options[:times], options[:output],
2104
- { duration: options[:duration], totals: options[:totals], sort_tags: options[:sort_tags] }).chomp
1936
+ template = settings['templates']['default'].deep_merge({
1937
+ 'wrap_width' => settings['wrap_width'] || 0,
1938
+ 'date_format' => settings['default_date_format'],
1939
+ 'order' => settings['order'] || 'asc',
1940
+ 'tags_color' => settings['tags_color']
1941
+ })
1942
+
1943
+ options[:case] = options[:case].normalize_case
1944
+
1945
+ if options[:search]
1946
+ search = options[:search]
1947
+ search.sub!(/^'?/, "'") if options[:exact]
1948
+ options[:search] = search
1949
+ end
1950
+
1951
+ options[:section] = section
1952
+
1953
+ unless tags.empty?
1954
+ tag_filter = {
1955
+ 'tags' => tags,
1956
+ 'bool' => options[:bool].normalize_bool
1957
+ }
1958
+ end
1959
+
1960
+ options[:tag_filter] = tag_filter
1961
+ options[:tag] = nil
1962
+
1963
+ items = wwid.filter_items([], opt: options)
1964
+
1965
+ if options[:menu]
1966
+ tag = wwid.choose_tag(section, items: items, include_all: true)
1967
+ raise UserCancelled unless tag
1968
+
1969
+ # options[:bool] = :and unless tags.empty?
1970
+
1971
+ tags = tag.split(/ +/).map { |t| t.strip.sub(/^@?/, '') } if tag =~ /^@/
1972
+ unless tags.empty?
1973
+ tag_filter = {
1974
+ 'tags' => tags,
1975
+ 'bool' => options[:bool].normalize_bool
1976
+ }
1977
+ options[:tag_filter] = tag_filter
1978
+ end
1979
+ end
1980
+
1981
+ options[:age] ||= :newest
1982
+
1983
+ opt = options.dup
1984
+ opt[:age] = options[:age].normalize_age(:newest) if options[:age]
1985
+ opt[:sort_tags] = options[:tag_sort] =~ /^n/i
1986
+ opt[:count] = options[:count].to_i
1987
+ opt[:highlight] = true
1988
+ opt[:order] = options[:sort].normalize_order
1989
+ opt[:tag] = nil
1990
+ opt[:tag_order] = options[:tag_order].normalize_order
1991
+ opt[:tags_color] = template['tags_color']
1992
+
1993
+ Doing::Pager.page wwid.list_section(opt, items: items)
2105
1994
  end
2106
1995
  end
2107
1996
 
2108
- desc 'List entries since a date'
2109
- long_desc %(Date argument can be natural language and are always interpreted as being in the past. "thursday" would be interpreted as "last thursday,"
2110
- and "2d" would be interpreted as "two days ago.")
2111
- arg_name 'DATE_STRING'
2112
- command :since do |c|
2113
- c.example 'doing since 7/30', desc: 'List all entries created since 12am on 7/30 of the current year'
2114
- c.example 'doing since "monday 3pm" --output json', desc: 'Show entries since 3pm on Monday of the current week, output in JSON format'
2115
-
1997
+ # @@tags
1998
+ desc 'List all tags in the current Doing file'
1999
+ command :tags do |c|
2116
2000
  c.desc 'Section'
2117
- c.arg_name 'NAME'
2001
+ c.arg_name 'SECTION_NAME'
2118
2002
  c.flag %i[s section], default_value: 'All'
2119
2003
 
2120
- c.desc 'Show time intervals on @done tasks'
2121
- c.switch %i[t times], default_value: true, negatable: true
2004
+ c.desc 'Show count of occurrences'
2005
+ c.switch %i[c counts]
2122
2006
 
2123
- c.desc 'Show elapsed time on entries without @done tag'
2124
- c.switch [:duration]
2007
+ c.desc 'Sort by name or count'
2008
+ c.arg_name 'SORT_ORDER'
2009
+ c.flag %i[sort], default_value: 'name', must_match: /^(?:n(?:ame)?|c(?:ount)?)$/
2125
2010
 
2126
- c.desc 'Show time totals at the end of output'
2127
- c.switch [:totals], default_value: false, negatable: false
2011
+ c.desc 'Sort order (asc/desc)'
2012
+ c.arg_name 'ORDER'
2013
+ c.flag %i[o order], must_match: REGEX_SORT_ORDER, default_value: 'asc'
2128
2014
 
2129
- c.desc 'Sort tags by (name|time)'
2130
- default = 'time'
2131
- default = settings['tag_sort'] || 'name'
2132
- c.arg_name 'KEY'
2133
- c.flag [:tag_sort], must_match: /^(?:name|time)$/i, default_value: default
2015
+ c.desc 'Get tags for entries matching tags. Combine multiple tags with a comma. Wildcards allowed (*, ?).'
2016
+ c.arg_name 'TAG'
2017
+ c.flag [:tag]
2134
2018
 
2135
- c.desc "Output to export format (#{Doing::Plugins.plugin_names(type: :export)})"
2136
- c.arg_name 'FORMAT'
2137
- c.flag %i[o output]
2019
+ c.desc 'Get tags for items matching search. Surround with
2020
+ slashes for regex (e.g. "/query/"), start with a single quote for exact match ("\'query").'
2021
+ c.arg_name 'QUERY'
2022
+ c.flag [:search]
2138
2023
 
2139
- c.action do |_global_options, options, args|
2140
- raise DoingRuntimeError, %(Invalid output type "#{options[:output]}") if options[:output] && options[:output] !~ Doing::Plugins.plugin_regex(type: :export)
2024
+ # c.desc '[DEPRECATED] Use alternative fuzzy matching for search string'
2025
+ # c.switch [:fuzzy], default_value: false, negatable: false
2141
2026
 
2142
- raise MissingArgument, 'Missing date argument' if args.empty?
2027
+ c.desc 'Force exact search string matching (case sensitive)'
2028
+ c.switch %i[x exact], default_value: config.exact_match?, negatable: config.exact_match?
2143
2029
 
2144
- date_string = args.join(' ')
2030
+ c.desc 'Get tags from items that *don\'t* match search/tag filters'
2031
+ c.switch [:not], default_value: false, negatable: false
2145
2032
 
2146
- date_string.sub!(/(day) (\d)/, '\1 at \2')
2147
- date_string.sub!(/(\d+)d( ago)?/, '\1 days ago')
2033
+ c.desc 'Case sensitivity for search string matching [(c)ase-sensitive, (i)gnore, (s)mart]'
2034
+ c.arg_name 'TYPE'
2035
+ c.flag [:case], must_match: /^[csi]/, default_value: settings.dig('search', 'case')
2148
2036
 
2149
- start = date_string.chronify(guess: :begin)
2150
- finish = Time.now
2037
+ c.desc 'Boolean used to combine multiple tags. Use PATTERN to parse + and - as booleans.'
2038
+ c.arg_name 'BOOLEAN'
2039
+ c.flag [:bool], must_match: REGEX_BOOL, default_value: 'PATTERN'
2151
2040
 
2152
- raise InvalidTimeExpression, 'Unrecognized date string' unless start
2041
+ c.desc 'Select items to scan from a menu of matching entries'
2042
+ c.switch %i[i interactive], negatable: false, default_value: false
2153
2043
 
2154
- Doing.logger.debug('Interpreter:', "date interpreted as #{start} through the current time")
2044
+ c.action do |_global, options, args|
2045
+ section = wwid.guess_section(options[:section]) || options[:section].cap_first
2155
2046
 
2156
- options[:times] = true if options[:totals]
2157
- options[:sort_tags] = options[:tag_sort] =~ /^n/i
2047
+ items = wwid.filter_items([], opt: options)
2158
2048
 
2159
- Doing::Pager.page wwid.list_date([start, finish], options[:section], options[:times], options[:output],
2160
- { duration: options[:duration], totals: options[:totals], sort_tags: options[:sort_tags] }).chomp
2049
+ if options[:interactive]
2050
+ items = Doing::Prompt.choose_from_items(items, include_section: options[:section].nil?,
2051
+ menu: true,
2052
+ header: '',
2053
+ prompt: 'Select entries to scan > ',
2054
+ multiple: true,
2055
+ sort: true,
2056
+ show_if_single: true)
2057
+ end
2058
+
2059
+ # items = wwid.content.in_section(section)
2060
+ tags = wwid.all_tags(items, counts: true)
2061
+
2062
+ if options[:sort] =~ /^n/i
2063
+ tags = tags.sort_by { |tag, count| tag }
2064
+ else
2065
+ tags = tags.sort_by { |tag, count| count }
2066
+ end
2067
+
2068
+ tags.reverse! if options[:order].normalize_order == 'desc'
2069
+
2070
+ if options[:counts]
2071
+ tags.each { |t, c| puts "#{t} (#{c})" }
2072
+ else
2073
+ tags.each { |t, c| puts "#{t}" }
2074
+ end
2161
2075
  end
2162
2076
  end
2163
2077
 
2164
- desc 'List entries from yesterday'
2165
- long_desc 'Show only entries with start times within the previous 24 hour period. Use --before, --after, and --from to limit to
2166
- time spans within the day.'
2167
- command :yesterday do |c|
2168
- c.example 'doing yesterday', desc: 'List all entries from the previous day'
2169
- c.example 'doing yesterday --after 8am --before 5pm', desc: 'List entries from the previous day between 8am and 5pm'
2170
- c.example 'doing yesterday --totals', desc: 'List entries from previous day, including tag timers'
2078
+ # @@today
2079
+ desc 'List entries from today'
2080
+ long_desc 'List entries from the current day. Use --before, --after, and
2081
+ --from to specify time ranges.'
2082
+ command :today do |c|
2083
+ c.example 'doing today', desc: 'List all entries with start dates between 12am and 11:59PM for the current day'
2084
+ c.example 'doing today --section Later', desc: 'List today\'s entries in the Later section'
2085
+ c.example 'doing today --before 3pm --after 12pm', desc: 'List entries with start dates between 12pm and 3pm today'
2086
+ c.example 'doing today --output json', desc: 'Output entries from today in JSON format'
2171
2087
 
2172
2088
  c.desc 'Specify a section'
2173
2089
  c.arg_name 'NAME'
2174
2090
  c.flag %i[s section], default_value: 'All'
2175
2091
 
2176
- c.desc "Output to export format (#{Doing::Plugins.plugin_names(type: :export)})"
2177
- c.arg_name 'FORMAT'
2178
- c.flag %i[o output]
2179
-
2180
2092
  c.desc 'Show time intervals on @done tasks'
2181
2093
  c.switch %i[t times], default_value: true, negatable: true
2182
2094
 
@@ -2187,10 +2099,15 @@ command :yesterday do |c|
2187
2099
  c.switch [:totals], default_value: false, negatable: false
2188
2100
 
2189
2101
  c.desc 'Sort tags by (name|time)'
2102
+ default = 'time'
2190
2103
  default = settings['tag_sort'] || 'name'
2191
2104
  c.arg_name 'KEY'
2192
2105
  c.flag [:tag_sort], must_match: /^(?:name|time)$/i, default_value: default
2193
2106
 
2107
+ c.desc "Output to export format (#{Doing::Plugins.plugin_names(type: :export)})"
2108
+ c.arg_name 'FORMAT'
2109
+ c.flag %i[o output]
2110
+
2194
2111
  c.desc 'View entries before specified time (e.g. 8am, 12:30pm, 15:00)'
2195
2112
  c.arg_name 'TIME_STRING'
2196
2113
  c.flag [:before]
@@ -2200,223 +2117,144 @@ command :yesterday do |c|
2200
2117
  c.flag [:after]
2201
2118
 
2202
2119
  c.desc %(
2203
- Time range to show, e.g. `doing yesterday --from "1am to 8am"`
2204
- )
2205
- c.arg_name 'TIME_RANGE'
2120
+ Time range to show `doing today --from "12pm to 4pm"`
2121
+ )
2122
+ c.arg_name 'DATE_OR_RANGE'
2206
2123
  c.flag [:from]
2207
2124
 
2208
- c.desc 'Tag sort direction (asc|desc)'
2209
- c.arg_name 'DIRECTION'
2210
- c.flag [:tag_order], must_match: REGEX_SORT_ORDER, default_value: 'asc'
2211
-
2212
2125
  c.action do |_global_options, options, _args|
2213
2126
  raise DoingRuntimeError, %(Invalid output type "#{options[:output]}") if options[:output] && options[:output] !~ Doing::Plugins.plugin_regex(type: :export)
2214
2127
 
2128
+ options[:times] = true if options[:totals]
2215
2129
  options[:sort_tags] = options[:tag_sort] =~ /^n/i
2130
+ filter_options = %i[after before duration from section sort_tags totals].each_with_object({}) { |k, hsh| hsh[k] = options[k] }
2216
2131
 
2217
- if options[:from]
2218
- options[:from] = options[:from].split(/ (?:to|through|thru|(?:un)?til|-+) /).map do |time|
2219
- "yesterday #{time.sub(/(?mi)(^.*?(?=\d+)|(?<=[ap]m).*?$)/, '')}"
2220
- end.join(' to ')
2221
- end
2222
-
2223
- opt = {
2224
- after: options[:after],
2225
- before: options[:before],
2226
- duration: options[:duration],
2227
- from: options[:from],
2228
- sort_tags: options[:sort_tags],
2229
- tag_order: options[:tag_order].normalize_order,
2230
- totals: options[:totals],
2231
- order: settings.dig('templates', 'today', 'order')
2232
- }
2233
- Doing::Pager.page wwid.yesterday(options[:section], options[:times], options[:output], opt).chomp
2132
+ Doing::Pager.page wwid.today(options[:times], options[:output], filter_options).chomp
2234
2133
  end
2235
2134
  end
2236
2135
 
2237
- desc 'Show the last entry, optionally edit'
2238
- long_desc 'Shows the last entry. Using --search and --tag filters, you can view/edit the last entry matching a filter,
2239
- allowing `doing last` to target historical entries.'
2240
- command :last do |c|
2241
- c.example 'doing last', desc: 'Show the most recent entry in all sections'
2242
- c.example 'doing last -s Later', desc: 'Show the most recent entry in the Later section'
2243
- c.example 'doing last --tag project1,work --bool AND', desc: 'Show most recent entry tagged @project1 and @work'
2244
- c.example 'doing last --search "side hustle"', desc: 'Show most recent entry containing "side hustle" (fuzzy matching)'
2245
- c.example 'doing last --search "\'side hustle"', desc: 'Show most recent entry containing "side hustle" (exact match)'
2246
- c.example 'doing last --edit', desc: 'Open the most recent entry in an editor for modifications'
2247
- c.example 'doing last --search "\'side hustle" --edit', desc: 'Open most recent entry containing "side hustle" (exact match) in editor'
2136
+ # @on
2137
+ desc 'List entries for a date'
2138
+ long_desc %(Date argument can be natural language. "thursday" would be interpreted as "last thursday,"
2139
+ and "2d" would be interpreted as "two days ago." If you use "to" or "through" between two dates,
2140
+ it will create a range.)
2141
+ arg_name 'DATE_STRING'
2142
+ command :on do |c|
2143
+ c.example 'doing on friday', desc: 'List entries between 12am and 11:59PM last Friday'
2144
+ c.example 'doing on 12/21/2020', desc: 'List entries from Dec 21, 2020'
2145
+ c.example 'doing on "3d to 1d"', desc: 'List entries added between 3 days ago and 1 day ago'
2248
2146
 
2249
- c.desc 'Specify a section'
2147
+ c.desc 'Section'
2250
2148
  c.arg_name 'NAME'
2251
2149
  c.flag %i[s section], default_value: 'All'
2252
2150
 
2253
- c.desc "Edit entry with #{Doing::Util.default_editor}"
2254
- c.switch %i[e editor], negatable: false, default_value: false
2255
-
2256
- c.desc 'Tag filter, combine multiple tags with a comma. Wildcards allowed (*, ?).'
2257
- c.arg_name 'TAG'
2258
- c.flag [:tag]
2259
-
2260
- c.desc 'Tag boolean (AND|OR|NOT). Use PATTERN to parse + and - as booleans.'
2261
- c.arg_name 'BOOLEAN'
2262
- c.flag [:bool], must_match: REGEX_BOOL, default_value: 'PATTERN'
2263
-
2264
- c.desc 'Search filter, surround with slashes for regex (/query/), start with single quote for exact match ("\'query")'
2265
- c.arg_name 'QUERY'
2266
- c.flag [:search]
2151
+ c.desc 'Show time intervals on @done tasks'
2152
+ c.switch %i[t times], default_value: true, negatable: true
2267
2153
 
2268
- c.desc 'Show elapsed time if entry is not tagged @done'
2154
+ c.desc 'Show elapsed time on entries without @done tag'
2269
2155
  c.switch [:duration]
2270
2156
 
2271
- # c.desc '[DEPRECATED] Use alternative fuzzy matching for search string'
2272
- # c.switch [:fuzzy], default_value: false, negatable: false
2273
-
2274
- c.desc 'Force exact search string matching (case sensitive)'
2275
- c.switch %i[x exact], default_value: config.exact_match?, negatable: config.exact_match?
2276
-
2277
- c.desc 'Show items that *don\'t* match search string or tag filter'
2278
- c.switch [:not], default_value: false, negatable: false
2279
-
2280
- c.desc 'Case sensitivity for search string matching [(c)ase-sensitive, (i)gnore, (s)mart]'
2281
- c.arg_name 'TYPE'
2282
- c.flag [:case], must_match: /^[csi]/, default_value: settings.dig('search', 'case')
2157
+ c.desc 'Show time totals at the end of output'
2158
+ c.switch [:totals], default_value: false, negatable: false
2283
2159
 
2284
- c.action do |global_options, options, _args|
2285
- options[:fuzzy] = false
2286
- raise InvalidArgument, '--tag and --search can not be used together' if options[:tag] && options[:search]
2160
+ c.desc 'Sort tags by (name|time)'
2161
+ default = 'time'
2162
+ default = settings['tag_sort'] || 'name'
2163
+ c.arg_name 'KEY'
2164
+ c.flag [:tag_sort], must_match: /^(?:name|time)$/i, default_value: default
2287
2165
 
2288
- if options[:tag].nil?
2289
- tags = []
2290
- else
2291
- tags = options[:tag].to_tags
2292
- options[:bool] = options[:bool].normalize_bool
2293
- end
2166
+ c.desc "Output to export format (#{Doing::Plugins.plugin_names(type: :export)})"
2167
+ c.arg_name 'FORMAT'
2168
+ c.flag %i[o output]
2294
2169
 
2295
- options[:case] = options[:case].normalize_case
2170
+ c.action do |_global_options, options, args|
2171
+ raise DoingRuntimeError, %(Invalid output type "#{options[:output]}") if options[:output] && options[:output] !~ Doing::Plugins.plugin_regex(type: :export)
2296
2172
 
2297
- search = nil
2173
+ raise MissingArgument, 'Missing date argument' if args.empty?
2298
2174
 
2299
- if options[:search]
2300
- search = options[:search]
2301
- search.sub!(/^'?/, "'") if options[:exact]
2302
- end
2175
+ date_string = args.join(' ')
2303
2176
 
2304
- if options[:editor]
2305
- wwid.edit_last(section: options[:section], options: { search: search, fuzzy: options[:fuzzy], case: options[:case], tag: tags, tag_bool: options[:bool], not: options[:not] })
2177
+ if date_string =~ / (to|through|thru) /
2178
+ dates = date_string.split(/ (to|through|thru) /)
2179
+ start = dates[0].chronify(guess: :begin)
2180
+ finish = dates[2].chronify(guess: :end)
2306
2181
  else
2307
- Doing::Pager::page wwid.last(times: true, section: options[:section],
2308
- options: {
2309
- duration: options[:duration],
2310
- search: search,
2311
- fuzzy: options[:fuzzy],
2312
- case: options[:case],
2313
- negate: options[:not],
2314
- tag: tags,
2315
- tag_bool: options[:bool]
2316
- }).strip
2182
+ start = date_string.chronify(guess: :begin)
2183
+ finish = false
2317
2184
  end
2318
- end
2319
- end
2320
2185
 
2321
- desc 'List sections'
2322
- command :sections do |c|
2323
- c.desc 'List in single column'
2324
- c.switch %i[c column], negatable: false, default_value: false
2186
+ raise InvalidTimeExpression, 'Unrecognized date string' unless start
2325
2187
 
2326
- c.action do |_global_options, options, _args|
2327
- joiner = options[:column] ? "\n" : "\t"
2328
- print wwid.content.section_titles.join(joiner)
2329
- end
2330
- end
2188
+ message = "date interpreted as #{start}"
2189
+ message += " to #{finish}" if finish
2190
+ Doing.logger.debug('Interpreter:', message)
2331
2191
 
2332
- desc 'Select a section to display from a menu'
2333
- command :choose do |c|
2334
- c.action do |_global_options, _options, _args|
2335
- section = wwid.choose_section
2192
+ options[:times] = true if options[:totals]
2193
+ options[:sort_tags] = options[:tag_sort] =~ /^n/i
2336
2194
 
2337
- Doing::Pager.page wwid.list_section({ section: section.cap_first, count: 0 }) if section
2195
+ Doing::Pager.page wwid.list_date([start, finish], options[:section], options[:times], options[:output],
2196
+ { duration: options[:duration], totals: options[:totals], sort_tags: options[:sort_tags] }).chomp
2338
2197
  end
2339
2198
  end
2340
2199
 
2341
- desc 'Add a new section to the "doing" file'
2342
- arg_name 'SECTION_NAME'
2343
- command :add_section do |c|
2344
- c.example 'doing add_section Ideas', desc: 'Add a section called Ideas to the doing file'
2200
+ # @since
2201
+ desc 'List entries since a date'
2202
+ long_desc %(Date argument can be natural language and are always interpreted as being in the past. "thursday" would be interpreted as "last thursday,"
2203
+ and "2d" would be interpreted as "two days ago.")
2204
+ arg_name 'DATE_STRING'
2205
+ command :since do |c|
2206
+ c.example 'doing since 7/30', desc: 'List all entries created since 12am on 7/30 of the current year'
2207
+ c.example 'doing since "monday 3pm" --output json', desc: 'Show entries since 3pm on Monday of the current week, output in JSON format'
2345
2208
 
2346
- c.action do |_global_options, _options, args|
2347
- raise InvalidArgument, "Section #{args[0]} already exists" if wwid.sections.include?(args[0])
2209
+ c.desc 'Section'
2210
+ c.arg_name 'NAME'
2211
+ c.flag %i[s section], default_value: 'All'
2348
2212
 
2349
- wwid.content.add_section(args.join(' ').cap_first, log: true)
2350
- wwid.write(wwid.doing_file)
2351
- end
2352
- end
2213
+ c.desc 'Show time intervals on @done tasks'
2214
+ c.switch %i[t times], default_value: true, negatable: true
2353
2215
 
2354
- desc 'List available color variables for configuration templates and views'
2355
- command :colors do |c|
2356
- c.action do |_global_options, _options, _args|
2357
- bgs = []
2358
- fgs = []
2359
- colors::attributes.each do |color|
2360
- if color.to_s =~ /bg/
2361
- bgs.push("#{colors.send(color, " ")}#{colors.default} <-- #{color.to_s}")
2362
- else
2363
- fgs.push("#{colors.send(color, "XXXX")}#{colors.default} <-- #{color.to_s}")
2364
- end
2365
- end
2366
- out = []
2367
- out << fgs.join("\n")
2368
- out << bgs.join("\n")
2369
- Doing::Pager.page out.join("\n")
2370
- end
2371
- end
2216
+ c.desc 'Show elapsed time on entries without @done tag'
2217
+ c.switch [:duration]
2372
2218
 
2373
- desc 'List installed plugins'
2374
- long_desc %(Lists available plugins, including user-installed plugins.
2219
+ c.desc 'Show time totals at the end of output'
2220
+ c.switch [:totals], default_value: false, negatable: false
2375
2221
 
2376
- Export plugins are available with the `--output` flag on commands that support it.
2222
+ c.desc 'Sort tags by (name|time)'
2223
+ default = 'time'
2224
+ default = settings['tag_sort'] || 'name'
2225
+ c.arg_name 'KEY'
2226
+ c.flag [:tag_sort], must_match: /^(?:name|time)$/i, default_value: default
2377
2227
 
2378
- Import plugins are available using `doing import --type PLUGIN`.
2379
- )
2380
- command :plugins do |c|
2381
- c.example 'doing plugins', desc: 'List all plugins'
2382
- c.example 'doing plugins -t import', desc: 'List all import plugins'
2228
+ c.desc "Output to export format (#{Doing::Plugins.plugin_names(type: :export)})"
2229
+ c.arg_name 'FORMAT'
2230
+ c.flag %i[o output]
2383
2231
 
2384
- c.desc 'List plugins of type (import, export)'
2385
- c.arg_name 'TYPE'
2386
- c.flag %i[t type], must_match: /^(?:[iea].*)$/i, default_value: 'all'
2232
+ c.action do |_global_options, options, args|
2233
+ raise DoingRuntimeError, %(Invalid output type "#{options[:output]}") if options[:output] && options[:output] !~ Doing::Plugins.plugin_regex(type: :export)
2387
2234
 
2388
- c.desc 'List in single column for completion'
2389
- c.switch %i[c column], negatable: false, default_value: false
2235
+ raise MissingArgument, 'Missing date argument' if args.empty?
2390
2236
 
2391
- c.action do |_global_options, options, _args|
2392
- Doing::Plugins.list_plugins(options)
2393
- end
2394
- end
2237
+ date_string = args.join(' ')
2395
2238
 
2396
- desc 'Generate shell completion scripts'
2397
- long_desc 'Generates the necessary scripts to add command line completion to various shells, so typing \'doing\' and hitting
2398
- tab will offer completions of subcommands and their options.'
2399
- command :completion do |c|
2400
- c.example 'doing completion', desc: 'Output zsh (default) to STDOUT'
2401
- c.example 'doing completion --type zsh --file ~/.zsh-completions/_doing.zsh', desc: 'Output zsh completions to file'
2402
- c.example 'doing completion --type fish --file ~/.config/fish/completions/doing.fish', desc: 'Output fish completions to file'
2403
- c.example 'doing completion --type bash --file ~/.bash_it/completion/enabled/doing.bash', desc: 'Output bash completions to file'
2239
+ date_string.sub!(/(day) (\d)/, '\1 at \2')
2240
+ date_string.sub!(/(\d+)d( ago)?/, '\1 days ago')
2404
2241
 
2405
- c.desc 'Shell to generate for (bash, zsh, fish)'
2406
- c.arg_name 'SHELL'
2407
- c.flag %i[t type], must_match: /^[bzf](?:[ai]?sh)?$/i, default_value: 'zsh'
2242
+ start = date_string.chronify(guess: :begin)
2243
+ finish = Time.now
2408
2244
 
2409
- c.desc 'File to write output to'
2410
- c.arg_name 'PATH'
2411
- c.flag %i[f file], default_value: 'STDOUT'
2245
+ raise InvalidTimeExpression, 'Unrecognized date string' unless start
2412
2246
 
2413
- c.action do |_global_options, options, _args|
2414
- script_dir = File.join(File.dirname(__FILE__), '..', 'scripts')
2247
+ Doing.logger.debug('Interpreter:', "date interpreted as #{start} through the current time")
2415
2248
 
2416
- Doing::Completion.generate_completion(type: options[:type], file: options[:file])
2249
+ options[:times] = true if options[:totals]
2250
+ options[:sort_tags] = options[:tag_sort] =~ /^n/i
2251
+
2252
+ Doing::Pager.page wwid.list_date([start, finish], options[:section], options[:times], options[:output],
2253
+ { duration: options[:duration], totals: options[:totals], sort_tags: options[:sort_tags] }).chomp
2417
2254
  end
2418
2255
  end
2419
2256
 
2257
+ # @@view
2420
2258
  desc 'Display a user-created view'
2421
2259
  long_desc 'Views are defined in your configuration (use `doing config` to edit).
2422
2260
  Command line options override view configuration.'
@@ -2437,6 +2275,10 @@ command :view do |c|
2437
2275
  c.arg_name 'FORMAT'
2438
2276
  c.flag %i[o output]
2439
2277
 
2278
+ c.desc 'Age (oldest|newest)'
2279
+ c.arg_name 'AGE'
2280
+ c.flag %i[age], default_value: 'newest'
2281
+
2440
2282
  c.desc 'Show time intervals on @done tasks'
2441
2283
  c.switch %i[t times], default_value: true, negatable: true
2442
2284
 
@@ -2568,89 +2410,507 @@ command :view do |c|
2568
2410
  end
2569
2411
  end
2570
2412
 
2571
- # If the -o/--output flag was specified, override any default in the view template
2572
- options[:output] ||= view.key?('output_format') ? view['output_format'] : 'template'
2413
+ # If the -o/--output flag was specified, override any default in the view template
2414
+ options[:output] ||= view.key?('output_format') ? view['output_format'] : 'template'
2415
+
2416
+ count = options[:count] ? options[:count] : view.key?('count') ? view['count'] : 10
2417
+
2418
+ section = if options[:section]
2419
+ section
2420
+ else
2421
+ view.key?('section') ? view['section'] : settings['current_section']
2422
+ end
2423
+ order = view.key?('order') ? view['order'].normalize_order : 'asc'
2424
+
2425
+ totals = if options[:totals]
2426
+ true
2427
+ else
2428
+ view.key?('totals') ? view['totals'] : false
2429
+ end
2430
+ tag_order = if options[:tag_order]
2431
+ options[:tag_order].normalize_order
2432
+ else
2433
+ view.key?('tag_order') ? view['tag_order'].normalize_order : 'asc'
2434
+ end
2435
+
2436
+ options[:times] = true if totals
2437
+ output_format = options[:output]&.downcase || 'template'
2438
+
2439
+ options[:sort_tags] = if options[:tag_sort]
2440
+ options[:tag_sort] =~ /^n/i ? true : false
2441
+ elsif view.key?('tag_sort')
2442
+ view['tag_sort'] =~ /^n/i ? true : false
2443
+ else
2444
+ false
2445
+ end
2446
+
2447
+ %w[before after from duration].each { |k| options[k.to_sym] = view[k] if view.key?(k) && !options[k.to_sym] }
2448
+
2449
+ options[:case] = options[:case].normalize_case
2450
+
2451
+ search = nil
2452
+
2453
+ if options[:search]
2454
+ search = options[:search]
2455
+ search.sub!(/^'?/, "'") if options[:exact]
2456
+ end
2457
+
2458
+ options[:age] ||= :newest
2459
+
2460
+ opts = options.dup
2461
+ opts[:age] = options[:age].normalize_age(:newest)
2462
+ opts[:view_template] = title
2463
+ opts[:count] = count
2464
+ opts[:format] = date_format
2465
+ opts[:highlight] = options[:color]
2466
+ opts[:only_timed] = only_timed
2467
+ opts[:order] = order
2468
+ opts[:output] = options[:interactive] ? nil : options[:output]
2469
+ opts[:output] = output_format
2470
+ opts[:page_title] = page_title
2471
+ opts[:search] = search
2472
+ opts[:section] = section
2473
+ opts[:tag_filter] = tag_filter
2474
+ opts[:tag_order] = tag_order
2475
+ opts[:tags_color] = tags_color
2476
+ opts[:template] = template
2477
+ opts[:totals] = totals
2478
+
2479
+ Doing::Pager.page wwid.list_section(opts)
2480
+ elsif title.instance_of?(FalseClass)
2481
+ raise UserCancelled, 'Cancelled'
2482
+ else
2483
+ raise InvalidView, "View #{title} not found in config"
2484
+ end
2485
+ end
2486
+ end
2487
+
2488
+ # @@yesterday
2489
+ desc 'List entries from yesterday'
2490
+ long_desc 'Show only entries with start times within the previous 24 hour period. Use --before, --after, and --from to limit to
2491
+ time spans within the day.'
2492
+ command :yesterday do |c|
2493
+ c.example 'doing yesterday', desc: 'List all entries from the previous day'
2494
+ c.example 'doing yesterday --after 8am --before 5pm', desc: 'List entries from the previous day between 8am and 5pm'
2495
+ c.example 'doing yesterday --totals', desc: 'List entries from previous day, including tag timers'
2496
+
2497
+ c.desc 'Specify a section'
2498
+ c.arg_name 'NAME'
2499
+ c.flag %i[s section], default_value: 'All'
2500
+
2501
+ c.desc "Output to export format (#{Doing::Plugins.plugin_names(type: :export)})"
2502
+ c.arg_name 'FORMAT'
2503
+ c.flag %i[o output]
2504
+
2505
+ c.desc 'Show time intervals on @done tasks'
2506
+ c.switch %i[t times], default_value: true, negatable: true
2507
+
2508
+ c.desc 'Show elapsed time on entries without @done tag'
2509
+ c.switch [:duration]
2510
+
2511
+ c.desc 'Show time totals at the end of output'
2512
+ c.switch [:totals], default_value: false, negatable: false
2513
+
2514
+ c.desc 'Sort tags by (name|time)'
2515
+ default = settings['tag_sort'] || 'name'
2516
+ c.arg_name 'KEY'
2517
+ c.flag [:tag_sort], must_match: /^(?:name|time)$/i, default_value: default
2518
+
2519
+ c.desc 'View entries before specified time (e.g. 8am, 12:30pm, 15:00)'
2520
+ c.arg_name 'TIME_STRING'
2521
+ c.flag [:before]
2522
+
2523
+ c.desc 'View entries after specified time (e.g. 8am, 12:30pm, 15:00)'
2524
+ c.arg_name 'TIME_STRING'
2525
+ c.flag [:after]
2526
+
2527
+ c.desc %(
2528
+ Time range to show, e.g. `doing yesterday --from "1am to 8am"`
2529
+ )
2530
+ c.arg_name 'TIME_RANGE'
2531
+ c.flag [:from]
2532
+
2533
+ c.desc 'Tag sort direction (asc|desc)'
2534
+ c.arg_name 'DIRECTION'
2535
+ c.flag [:tag_order], must_match: REGEX_SORT_ORDER, default_value: 'asc'
2536
+
2537
+ c.action do |_global_options, options, _args|
2538
+ raise DoingRuntimeError, %(Invalid output type "#{options[:output]}") if options[:output] && options[:output] !~ Doing::Plugins.plugin_regex(type: :export)
2539
+
2540
+ options[:sort_tags] = options[:tag_sort] =~ /^n/i
2541
+
2542
+ if options[:from]
2543
+ options[:from] = options[:from].split(/ (?:to|through|thru|(?:un)?til|-+) /).map do |time|
2544
+ "yesterday #{time.sub(/(?mi)(^.*?(?=\d+)|(?<=[ap]m).*?$)/, '')}"
2545
+ end.join(' to ')
2546
+ end
2547
+
2548
+ opt = {
2549
+ after: options[:after],
2550
+ before: options[:before],
2551
+ duration: options[:duration],
2552
+ from: options[:from],
2553
+ sort_tags: options[:sort_tags],
2554
+ tag_order: options[:tag_order].normalize_order,
2555
+ totals: options[:totals],
2556
+ order: settings.dig('templates', 'today', 'order')
2557
+ }
2558
+ Doing::Pager.page wwid.yesterday(options[:section], options[:times], options[:output], opt).chomp
2559
+ end
2560
+ end
2561
+
2562
+ ## Utility commands
2563
+
2564
+ # @@add_section
2565
+ desc 'Add a new section to the "doing" file'
2566
+ arg_name 'SECTION_NAME'
2567
+ command :add_section do |c|
2568
+ c.example 'doing add_section Ideas', desc: 'Add a section called Ideas to the doing file'
2569
+
2570
+ c.action do |_global_options, _options, args|
2571
+ raise InvalidArgument, "Section #{args[0]} already exists" if wwid.sections.include?(args[0])
2572
+
2573
+ wwid.content.add_section(args.join(' ').cap_first, log: true)
2574
+ wwid.write(wwid.doing_file)
2575
+ end
2576
+ end
2577
+
2578
+ # @@config
2579
+ desc 'Edit the configuration file or output a value from it'
2580
+ long_desc %(Run without arguments, `doing config` opens your `config.yml` in an editor.
2581
+ If local configurations are found in the path between the current directory
2582
+ and the root (/), a menu will allow you to select which to open in the editor.
2583
+
2584
+ It will use the editor defined in `config_editor_app`, or one specified with `--editor`.
2585
+
2586
+ Use `doing config get` to output the configuration to the terminal, and
2587
+ provide a dot-separated key path to get a specific value. Shows the current value
2588
+ including keys/overrides set by local configs.)
2589
+ command :config do |c|
2590
+ c.example 'doing config', desc: "Open an active configuration in #{Doing::Util.find_default_editor('config')}"
2591
+ c.example 'doing config get doing_file', desc: 'Output the value of a config key as YAML'
2592
+ c.example 'doing config get plugins.plugin_path -o json', desc: 'Output the value of a key path as JSON'
2593
+ c.example 'doing config set plugins.say.say_voice Alex', desc: 'Set the value of a key path and update config file'
2594
+ c.example 'doing config set plug.say.voice Zarvox', desc: 'Key paths for get and set are fuzzy matched'
2595
+
2596
+ c.default_command :edit
2597
+
2598
+ c.desc 'DEPRECATED'
2599
+ c.switch %i[d dump]
2600
+
2601
+ c.desc 'DEPRECATED'
2602
+ c.switch %i[u update]
2603
+
2604
+ # @@config.list
2605
+ c.desc 'List configuration paths, including .doingrc files in the current and parent directories'
2606
+ c.long_desc 'Config files are listed in order of precedence (if there are multiple configs detected).
2607
+ Values defined in the top item in the list will override values in configutations below it.'
2608
+ c.command :list do |list|
2609
+ list.action do |global, options, args|
2610
+ puts config.additional_configs.join("\n")
2611
+ puts config.config_file
2612
+ end
2613
+ end
2614
+
2615
+ # @@config.edit
2616
+ c.desc 'Open config file in editor'
2617
+ c.command :edit do |edit|
2618
+ edit.example 'doing config edit', desc: 'Open a config file in the default editor'
2619
+ edit.example 'doing config edit --editor vim', desc: 'Open config in specific editor'
2620
+
2621
+ edit.desc 'Editor to use'
2622
+ edit.arg_name 'EDITOR'
2623
+ edit.flag %i[e editor], default_value: nil
2624
+
2625
+ if `uname` =~ /Darwin/
2626
+ edit.desc 'Application to use'
2627
+ edit.arg_name 'APP_NAME'
2628
+ edit.flag %i[a app]
2629
+
2630
+ edit.desc 'Application bundle id to use'
2631
+ edit.arg_name 'BUNDLE_ID'
2632
+ edit.flag %i[b bundle_id]
2633
+
2634
+ edit.desc "Use the config_editor_app defined in ~/.config/doing/config.yml (#{settings.key?('config_editor_app') ? settings['config_editor_app'] : 'config_editor_app not set'})"
2635
+ edit.switch %i[x default]
2636
+ end
2637
+
2638
+ edit.action do |global, options, args|
2639
+ if options[:update] || options[:dump]
2640
+ cmd = commands[:config]
2641
+ if options[:update]
2642
+ cmd = cmd.commands[:update]
2643
+ elsif options[:dump]
2644
+ cmd = cmd.commands[:get]
2645
+ end
2646
+ action = cmd.send(:get_action, nil)
2647
+ action.call(global, options, args)
2648
+ Doing.logger.warn('Deprecated:', '--dump and --update are deprecated,
2649
+ use `doing config get` and `doing config update`')
2650
+ Doing.logger.output_results
2651
+ return
2652
+ end
2653
+
2654
+ config_file = config.choose_config
2655
+
2656
+ if `uname` =~ /Darwin/
2657
+ if options[:default]
2658
+ editor = Doing::Util.find_default_editor('config')
2659
+ if editor
2660
+ if Doing::Util.exec_available(editor.split(/ /).first)
2661
+ system %(#{editor} "#{config_file}")
2662
+ else
2663
+ `open -a "#{editor}" "#{config_file}"`
2664
+ end
2665
+ else
2666
+ raise InvalidArgument, 'No viable editor found in config or environment.'
2667
+ end
2668
+ elsif options[:app] || options[:bundle_id]
2669
+ if options[:app]
2670
+ `open -a "#{options[:app]}" "#{config_file}"`
2671
+ elsif options[:bundle_id]
2672
+ `open -b #{options[:bundle_id]} "#{config_file}"`
2673
+ end
2674
+ else
2675
+ editor = options[:editor] || Doing::Util.find_default_editor('config')
2676
+
2677
+ raise MissingEditor, 'No viable editor defined in config or environment' unless editor
2678
+
2679
+ if Doing::Util.exec_available(editor.split(/ /).first)
2680
+ system %(#{editor} "#{config_file}")
2681
+ else
2682
+ `open -a "#{editor}" "#{config_file}"`
2683
+ end
2684
+ end
2685
+ else
2686
+ editor = options[:editor] || Doing::Util.default_editor
2687
+ raise MissingEditor, 'No EDITOR variable defined in environment' unless editor && Doing::Util.exec_available(editor.split(/ /).first)
2688
+
2689
+ system %(#{editor} "#{config_file}")
2690
+ end
2691
+ end
2692
+ end
2693
+
2694
+ # @@config.update @@config.refresh
2695
+ c.desc 'Update default config file, adding any missing keys'
2696
+ c.command %i[update refresh] do |update|
2697
+ update.action do |_global, options, args|
2698
+ config.configure({rewrite: true, ignore_local: true})
2699
+ Doing.logger.warn('Config:', 'config refreshed')
2700
+ end
2701
+ end
2702
+
2703
+ # @@config.undo
2704
+ c.desc 'Undo the last change to a config file'
2705
+ c.command :undo do |undo|
2706
+ undo.action do |_global, options, args|
2707
+ config_file = config.choose_config
2708
+ Doing::Util::Backup.restore_last_backup(config_file, count: 1)
2709
+ end
2710
+ end
2711
+
2712
+ # @@config.get @@config.dump
2713
+ c.desc 'Output a key\'s value'
2714
+ c.arg 'KEY_PATH'
2715
+ c.command %i[get dump] do |dump|
2716
+ dump.example 'doing config get', desc: 'Output the entire configuration'
2717
+ dump.example 'doing config get timer_format --output raw', desc: 'Output the value of timer_format as a plain string'
2718
+ dump.example 'doing config get doing_file', desc: 'Output the value of the doing_file setting, respecting local configurations'
2719
+ dump.example 'doing config get -o json plug.plugpath', desc: 'Key path is fuzzy matched: output the value of plugins->plugin_path as JSON'
2720
+
2721
+ dump.desc 'Format for output (json|yaml|raw)'
2722
+ dump.arg_name 'FORMAT'
2723
+ dump.flag %i[o output], default_value: 'yaml', must_match: /^(?:y(?:aml)?|j(?:son)?|r(?:aw)?)$/
2724
+
2725
+ dump.action do |_global, options, args|
2726
+
2727
+ keypath = args.join('.')
2728
+ cfg = config.value_for_key(keypath)
2729
+ real_path = config.resolve_key_path(keypath)
2730
+
2731
+ if cfg
2732
+ val = cfg.map {|k, v| v }[0]
2733
+ if real_path.count.positive?
2734
+ nested_cfg = {}
2735
+ nested_cfg.deep_set(real_path, val)
2736
+ else
2737
+ nested_cfg = val
2738
+ end
2739
+
2740
+ if options[:output] =~ /^r/
2741
+ if val.is_a?(Hash)
2742
+ $stdout.puts YAML.dump(val)
2743
+ elsif val.is_a?(Array)
2744
+ $stdout.puts val.join(', ')
2745
+ else
2746
+ $stdout.puts val.to_s
2747
+ end
2748
+ else
2749
+ $stdout.puts case options[:output]
2750
+ when /^j/
2751
+ JSON.pretty_generate(val)
2752
+ else
2753
+ YAML.dump(nested_cfg)
2754
+ end
2755
+ end
2756
+ else
2757
+ Doing.logger.log_now(:error, 'Config:', "Key #{keypath} not found")
2758
+ end
2759
+ Doing.logger.output_results
2760
+ return
2761
+ end
2762
+ end
2763
+
2764
+ # @@config.set
2765
+ c.desc 'Set a key\'s value in the config file'
2766
+ c.arg 'KEY VALUE'
2767
+ c.command :set do |set|
2768
+ set.example 'doing config set timer_format human', desc: 'Set the value of timer_format to "human"'
2769
+ set.example 'doing config set plug.plugpath ~/my_plugins', desc: 'Key path is fuzzy matched: set the value of plugins->plugin_path'
2770
+
2771
+ set.desc 'Delete specified key'
2772
+ set.switch %i[r remove], default_value: false, negatable: false
2773
+
2774
+ set.action do |_global, options, args|
2775
+ if args.count < 2 && !options[:remove]
2776
+ raise InvalidArgument, 'config set requires at least two arguments, key path and value'
2573
2777
 
2574
- count = options[:count] ? options[:count] : view.key?('count') ? view['count'] : 10
2778
+ end
2575
2779
 
2576
- section = if options[:section]
2577
- section
2578
- else
2579
- view.key?('section') ? view['section'] : settings['current_section']
2580
- end
2581
- order = view.key?('order') ? view['order'].normalize_order : 'asc'
2780
+ value = options[:remove] ? nil : args.pop
2781
+ keypath = args.join('.')
2782
+ real_path = config.resolve_key_path(keypath, create: true)
2582
2783
 
2583
- totals = if options[:totals]
2584
- true
2585
- else
2586
- view.key?('totals') ? view['totals'] : false
2587
- end
2588
- tag_order = if options[:tag_order]
2589
- options[:tag_order].normalize_order
2590
- else
2591
- view.key?('tag_order') ? view['tag_order'].normalize_order : 'asc'
2592
- end
2784
+ old_value = settings.dig(*real_path) || nil
2785
+ old_type = old_value&.class.to_s || nil
2593
2786
 
2594
- options[:times] = true if totals
2595
- output_format = options[:output]&.downcase || 'template'
2787
+ if old_value.is_a?(Hash) && !options[:remove]
2788
+ Doing.logger.log_now(:warn, 'Config:', "Config key must point to a single value, #{real_path.join('->').boldwhite} is a mapping")
2789
+ didyou = 'Did you mean:'
2790
+ old_value.keys.each do |k|
2791
+ Doing.logger.log_now(:warn, "#{didyou}", "#{keypath}.#{k}?")
2792
+ didyou = '..........or:'
2793
+ end
2794
+ raise InvalidArgument, 'Config value is a mapping, can not be set to a single value'
2596
2795
 
2597
- options[:sort_tags] = if options[:tag_sort]
2598
- options[:tag_sort] =~ /^n/i ? true : false
2599
- elsif view.key?('tag_sort')
2600
- view['tag_sort'] =~ /^n/i ? true : false
2601
- else
2602
- false
2603
- end
2796
+ end
2604
2797
 
2605
- %w[before after from duration].each { |k| options[k.to_sym] = view[k] if view.key?(k) && !options[k.to_sym] }
2798
+ config_file = config.choose_config(create: true)
2606
2799
 
2607
- options[:case] = options[:case].normalize_case
2800
+ cfg = YAML.safe_load_file(config_file) || {}
2608
2801
 
2609
- search = nil
2802
+ $stderr.puts "Updating #{config_file}".yellow
2610
2803
 
2611
- if options[:search]
2612
- search = options[:search]
2613
- search.sub!(/^'?/, "'") if options[:exact]
2804
+ if options[:remove]
2805
+ cfg.deep_set(real_path, nil)
2806
+ $stderr.puts "#{'Deleting key:'.yellow} #{real_path.join('->').boldwhite}"
2807
+ else
2808
+ current_value = cfg.dig(*real_path)
2809
+ cfg.deep_set(real_path, value.set_type(old_type))
2810
+
2811
+ $stderr.puts "#{' Key path:'.yellow} #{real_path.join('->').boldwhite}"
2812
+ $stderr.puts "#{'Inherited:'.yellow} #{(old_value ? old_value.to_s : 'empty').boldwhite}"
2813
+ $stderr.puts "#{' Current:'.yellow} #{ (current_value ? current_value.to_s : 'empty').boldwhite }"
2814
+ $stderr.puts "#{' New:'.yellow} #{value.set_type(old_type).to_s.boldwhite}"
2614
2815
  end
2615
2816
 
2616
- opts = options.dup
2617
- opts[:view_template] = title
2618
- opts[:count] = count
2619
- opts[:format] = date_format
2620
- opts[:highlight] = options[:color]
2621
- opts[:only_timed] = only_timed
2622
- opts[:order] = order
2623
- opts[:output] = options[:interactive] ? nil : options[:output]
2624
- opts[:output] = output_format
2625
- opts[:page_title] = page_title
2626
- opts[:search] = search
2627
- opts[:section] = section
2628
- opts[:tag_filter] = tag_filter
2629
- opts[:tag_order] = tag_order
2630
- opts[:tags_color] = tags_color
2631
- opts[:template] = template
2632
- opts[:totals] = totals
2817
+ res = Doing::Prompt.yn('Update selected config', default_response: true)
2633
2818
 
2634
- Doing::Pager.page wwid.list_section(opts)
2635
- elsif title.instance_of?(FalseClass)
2636
2819
  raise UserCancelled, 'Cancelled' unless res
2637
- else
2638
- raise InvalidView, "View #{title} not found in config"
2820
+
2821
+ Doing::Util.write_to_file(config_file, YAML.dump(cfg), backup: true)
2822
+ Doing.logger.warn('Config:', "#{config_file} updated")
2639
2823
  end
2640
2824
  end
2641
2825
  end
2642
2826
 
2643
- desc 'List available custom views'
2644
- command :views do |c|
2645
- c.desc 'List in single column'
2646
- c.switch %i[c column], default_value: false
2827
+ # @@open
2828
+ desc 'Open the "doing" file in an editor'
2829
+ long_desc "`doing open` defaults to using the editors->doing_file setting
2830
+ in #{config.config_file} (#{Doing::Util.find_default_editor('doing_file')})."
2831
+ command :open do |c|
2832
+ c.example 'doing open', desc: 'Open the doing file in the default editor'
2833
+ c.desc 'Open with editor command (e.g. vim, mate)'
2834
+ c.arg_name 'COMMAND'
2835
+ c.flag %i[e editor]
2836
+
2837
+ if `uname` =~ /Darwin/
2838
+ c.desc 'Open with app name'
2839
+ c.arg_name 'APP_NAME'
2840
+ c.flag %i[a app]
2841
+
2842
+ c.desc 'Open with app bundle id'
2843
+ c.arg_name 'BUNDLE_ID'
2844
+ c.flag %i[b bundle_id]
2845
+ end
2647
2846
 
2648
2847
  c.action do |_global_options, options, _args|
2649
- joiner = options[:column] ? "\n" : "\t"
2650
- print wwid.views.join(joiner)
2848
+ params = options.dup
2849
+ params.delete_if do |k, v|
2850
+ k.instance_of?(String) || v.nil? || v == false
2851
+ end
2852
+
2853
+ if options[:editor]
2854
+ raise MissingEditor, "Editor #{options[:editor]} not found" unless Doing::Util.exec_available(options[:editor].split(/ /).first)
2855
+
2856
+ editor = TTY::Which.which(options[:editor])
2857
+ system %(#{editor} "#{File.expand_path(wwid.doing_file)}")
2858
+ elsif `uname` =~ /Darwin/
2859
+ if options[:app]
2860
+ system %(open -a "#{options[:app]}" "#{File.expand_path(wwid.doing_file)}")
2861
+ elsif options[:bundle_id]
2862
+ system %(open -b "#{options[:bundle_id]}" "#{File.expand_path(wwid.doing_file)}")
2863
+ elsif Doing::Util.find_default_editor('doing_file')
2864
+ editor = Doing::Util.find_default_editor('doing_file')
2865
+ if Doing::Util.exec_available(editor.split(/ /).first)
2866
+ system %(#{editor} "#{File.expand_path(wwid.doing_file)}")
2867
+ else
2868
+ system %(open -a "#{editor}" "#{File.expand_path(wwid.doing_file)}")
2869
+ end
2870
+ else
2871
+ system %(open "#{File.expand_path(wwid.doing_file)}")
2872
+ end
2873
+ else
2874
+ raise MissingEditor, 'No EDITOR variable defined in environment' if Doing::Util.default_editor.nil?
2875
+
2876
+ system %(#{Doing::Util.default_editor} "#{File.expand_path(wwid.doing_file)}")
2877
+ end
2878
+ end
2879
+ end
2880
+
2881
+ # @@tag_dir
2882
+ desc 'Set the default tags for the current directory'
2883
+ long_desc 'Adds default_tags to a .doingrc file in the current directory. Any entry created in this directory or its
2884
+ subdirectories will be tagged with the default tags. You can modify these any time using the `config set` commnand or
2885
+ manually editing the .doingrc file.'
2886
+ arg_name 'TAG [TAG..]'
2887
+ command :tag_dir do |c|
2888
+ c.example 'doing tag_dir project1 project2', desc: 'Add @project1 and @project to to any entries in the current directory'
2889
+ c.example 'doing tag_dir --remove', desc: 'Clear the default tags for the directory'
2890
+
2891
+ c.desc 'Remove all default_tags from the local .doingrc'
2892
+ c.switch %i[r remove], negatable: false
2893
+
2894
+ c.action do |global, options, args|
2895
+ tags = args.join(' ').gsub(/ *, */, ' ').split(' ')
2896
+
2897
+ cfg_cmd = commands[:config]
2898
+ set_cmd = cfg_cmd.commands[:set]
2899
+ set_options = {}
2900
+ if options[:remove]
2901
+ set_args = ['default_tags']
2902
+ set_options[:remove] = true
2903
+ else
2904
+ set_args = ['default_tags', tags.join(',')]
2905
+ end
2906
+ action = set_cmd.send(:get_action, nil)
2907
+ return action.call(global, set_options, set_args)
2651
2908
  end
2652
2909
  end
2653
2910
 
2911
+ ## File handling/batch modification commands
2912
+
2913
+ # @@archive @@move
2654
2914
  desc 'Move entries between sections'
2655
2915
  long_desc %(Argument can be a section name to move all entries from a section,
2656
2916
  or start with an "@" to move entries matching a tag.
@@ -2720,29 +2980,131 @@ command %i[archive move] do |c|
2720
2980
  tags = args.length > 1 ? args[1..].map { |t| t.sub(/^@/, '').strip } : []
2721
2981
  end
2722
2982
 
2723
- raise InvalidArgument, '--keep and --count can not be used together' if options[:keep] && options[:count]
2724
-
2725
- tags.concat(options[:tag].to_tags) if options[:tag]
2726
-
2727
- search = nil
2728
-
2983
+ raise InvalidArgument, '--keep and --count can not be used together' if options[:keep] && options[:count]
2984
+
2985
+ tags.concat(options[:tag].to_tags) if options[:tag]
2986
+
2987
+ search = nil
2988
+
2989
+ options[:case] = options[:case].normalize_case
2990
+
2991
+ if options[:search]
2992
+ search = options[:search]
2993
+ search.sub!(/^'?/, "'") if options[:exact]
2994
+ end
2995
+
2996
+ opts = options.dup
2997
+ opts[:search] = search
2998
+ opts[:bool] = options[:bool].normalize_bool
2999
+ opts[:destination] = options[:to]
3000
+ opts[:tags] = tags
3001
+
3002
+ wwid.archive(section, opts)
3003
+ end
3004
+ end
3005
+
3006
+ # @@import
3007
+ desc 'Import entries from an external source'
3008
+ long_desc "Imports entries from other sources. Available plugins: #{Doing::Plugins.plugin_names(type: :import, separator: ', ')}"
3009
+ arg_name 'PATH'
3010
+ command :import do |c|
3011
+ c.example 'doing import --type timing "~/Desktop/All Activities.json"', desc: 'Import a Timing.app JSON report'
3012
+ c.example 'doing import --type doing --tag imported --no-autotag ~/doing_backup.md', desc: 'Import an Doing archive, tag all entries with @imported, skip autotagging'
3013
+ c.example 'doing import --type doing --from "10/1 to 10/15" ~/doing_backup.md', desc: 'Import a Doing archive, only importing entries between two dates'
3014
+
3015
+ c.desc "Import type (#{Doing::Plugins.plugin_names(type: :import)})"
3016
+ c.arg_name 'TYPE'
3017
+ c.flag :type, default_value: 'doing'
3018
+
3019
+ c.desc 'Only import items matching search. Surround with slashes for regex (/query/), start with single quote for exact match ("\'query")'
3020
+ c.arg_name 'QUERY'
3021
+ c.flag [:search]
3022
+
3023
+ # c.desc '[DEPRECATED] Use alternative fuzzy matching for search string'
3024
+ # c.switch [:fuzzy], default_value: false, negatable: false
3025
+
3026
+ c.desc 'Force exact search string matching (case sensitive)'
3027
+ c.switch %i[x exact], default_value: config.exact_match?, negatable: config.exact_match?
3028
+
3029
+ c.desc 'Import items that *don\'t* match search/tag/date filters'
3030
+ c.switch [:not], default_value: false, negatable: false
3031
+
3032
+ c.desc 'Case sensitivity for search string matching [(c)ase-sensitive, (i)gnore, (s)mart]'
3033
+ c.arg_name 'TYPE'
3034
+ c.flag [:case], must_match: /^[csi]/, default_value: settings.dig('search', 'case')
3035
+
3036
+ c.desc 'Only import items with recorded time intervals'
3037
+ c.switch [:only_timed], default_value: false, negatable: false
3038
+
3039
+ c.desc 'Target section'
3040
+ c.arg_name 'NAME'
3041
+ c.flag %i[s section]
3042
+
3043
+ c.desc 'Tag all imported entries'
3044
+ c.arg_name 'TAGS'
3045
+ c.flag :tag
3046
+
3047
+ c.desc 'Autotag entries'
3048
+ c.switch :autotag, negatable: true, default_value: true
3049
+
3050
+ c.desc 'Prefix entries with'
3051
+ c.arg_name 'PREFIX'
3052
+ c.flag :prefix
3053
+
3054
+ # TODO: Allow time range filtering
3055
+ c.desc 'Import entries older than date'
3056
+ c.arg_name 'DATE_STRING'
3057
+ c.flag [:before]
3058
+
3059
+ c.desc 'Import entries newer than date'
3060
+ c.arg_name 'DATE_STRING'
3061
+ c.flag [:after]
3062
+
3063
+ c.desc %(
3064
+ Date range to import. Date range argument should be quoted. Date specifications can be natural language.
3065
+ To specify a range, use "to" or "through": `--from "monday to friday"` or `--from 10/1 to 10/31`.
3066
+ Has no effect unless the import plugin has implemented date range filtering.
3067
+ )
3068
+ c.arg_name 'DATE_OR_RANGE'
3069
+ c.flag %i[f from]
3070
+
3071
+ c.desc 'Allow entries that overlap existing times'
3072
+ c.switch [:overlap], negatable: true
3073
+
3074
+ c.action do |_global_options, options, args|
3075
+ options[:fuzzy] = false
3076
+ if options[:section]
3077
+ options[:section] = wwid.guess_section(options[:section]) || options[:section].cap_first
3078
+ end
3079
+
3080
+ if options[:from]
3081
+ date_string = options[:from]
3082
+ if date_string =~ / (to|through|thru|(un)?til|-+) /
3083
+ dates = date_string.split(/ (to|through|thru|(un)?til|-+) /)
3084
+ start = dates[0].chronify(guess: :begin)
3085
+ finish = dates[2].chronify(guess: :end)
3086
+ else
3087
+ start = date_string.chronify(guess: :begin)
3088
+ finish = false
3089
+ end
3090
+ raise InvalidTimeExpression, 'Unrecognized date string' unless start
3091
+ dates = [start, finish]
3092
+ end
3093
+
2729
3094
  options[:case] = options[:case].normalize_case
2730
3095
 
2731
- if options[:search]
2732
- search = options[:search]
2733
- search.sub!(/^'?/, "'") if options[:exact]
3096
+ if options[:type] =~ Doing::Plugins.plugin_regex(type: :import)
3097
+ options[:no_overlap] = !options[:overlap]
3098
+ options[:date_filter] = dates
3099
+ wwid.import(args, options)
3100
+ wwid.write(wwid.doing_file)
3101
+ else
3102
+ raise InvalidPluginType, "Invalid import type: #{options[:type]}"
2734
3103
  end
2735
-
2736
- opts = options.dup
2737
- opts[:search] = search
2738
- opts[:bool] = options[:bool].normalize_bool
2739
- opts[:destination] = options[:to]
2740
- opts[:tags] = tags
2741
-
2742
- wwid.archive(section, opts)
2743
3104
  end
2744
3105
  end
2745
3106
 
3107
+ # @@rotate
2746
3108
  desc 'Move entries to archive file'
2747
3109
  long_desc 'As your doing file grows, commands can get slow. Given that your historical data (and your archive section)
2748
3110
  probably aren\'t providing any useful insights a year later, use this command to "rotate" old entries out to an archive
@@ -2812,298 +3174,166 @@ command :rotate do |c|
2812
3174
  end
2813
3175
  end
2814
3176
 
2815
- desc 'Open the "doing" file in an editor'
2816
- long_desc "`doing open` defaults to using the editors->doing_file setting
2817
- in #{config.config_file} (#{Doing::Util.find_default_editor('doing_file')})."
2818
- command :open do |c|
2819
- c.example 'doing open', desc: 'Open the doing file in the default editor'
2820
- c.desc 'Open with editor command (e.g. vim, mate)'
2821
- c.arg_name 'COMMAND'
2822
- c.flag %i[e editor]
2823
-
2824
- if `uname` =~ /Darwin/
2825
- c.desc 'Open with app name'
2826
- c.arg_name 'APP_NAME'
2827
- c.flag %i[a app]
2828
-
2829
- c.desc 'Open with app bundle id'
2830
- c.arg_name 'BUNDLE_ID'
2831
- c.flag %i[b bundle_id]
2832
- end
2833
-
2834
- c.action do |_global_options, options, _args|
2835
- params = options.dup
2836
- params.delete_if do |k, v|
2837
- k.instance_of?(String) || v.nil? || v == false
2838
- end
2839
-
2840
- if options[:editor]
2841
- raise MissingEditor, "Editor #{options[:editor]} not found" unless Doing::Util.exec_available(options[:editor].split(/ /).first)
2842
-
2843
- editor = TTY::Which.which(options[:editor])
2844
- system %(#{editor} "#{File.expand_path(wwid.doing_file)}")
2845
- elsif `uname` =~ /Darwin/
2846
- if options[:app]
2847
- system %(open -a "#{options[:app]}" "#{File.expand_path(wwid.doing_file)}")
2848
- elsif options[:bundle_id]
2849
- system %(open -b "#{options[:bundle_id]}" "#{File.expand_path(wwid.doing_file)}")
2850
- elsif Doing::Util.find_default_editor('doing_file')
2851
- editor = Doing::Util.find_default_editor('doing_file')
2852
- if Doing::Util.exec_available(editor.split(/ /).first)
2853
- system %(#{editor} "#{File.expand_path(wwid.doing_file)}")
2854
- else
2855
- system %(open -a "#{editor}" "#{File.expand_path(wwid.doing_file)}")
2856
- end
2857
- else
2858
- system %(open "#{File.expand_path(wwid.doing_file)}")
2859
- end
2860
- else
2861
- raise MissingEditor, 'No EDITOR variable defined in environment' if Doing::Util.default_editor.nil?
2862
-
2863
- system %(#{Doing::Util.default_editor} "#{File.expand_path(wwid.doing_file)}")
2864
- end
2865
- end
2866
- end
2867
-
2868
- desc 'Edit the configuration file or output a value from it'
2869
- long_desc %(Run without arguments, `doing config` opens your `config.yml` in an editor.
2870
- If local configurations are found in the path between the current directory
2871
- and the root (/), a menu will allow you to select which to open in the editor.
2872
-
2873
- It will use the editor defined in `config_editor_app`, or one specified with `--editor`.
2874
-
2875
- Use `doing config get` to output the configuration to the terminal, and
2876
- provide a dot-separated key path to get a specific value. Shows the current value
2877
- including keys/overrides set by local configs.)
2878
- command :config do |c|
2879
- c.example 'doing config', desc: "Open an active configuration in #{Doing::Util.find_default_editor('config')}"
2880
- c.example 'doing config get doing_file', desc: 'Output the value of a config key as YAML'
2881
- c.example 'doing config get plugins.plugin_path -o json', desc: 'Output the value of a key path as JSON'
2882
- c.example 'doing config set plugins.say.say_voice Alex', desc: 'Set the value of a key path and update config file'
2883
- c.example 'doing config set plug.say.voice Zarvox', desc: 'Key paths for get and set are fuzzy matched'
2884
-
2885
- c.default_command :edit
2886
-
2887
- c.desc 'DEPRECATED'
2888
- c.switch %i[d dump]
2889
-
2890
- c.desc 'DEPRECATED'
2891
- c.switch %i[u update]
2892
-
2893
- c.desc 'List configuration paths, including .doingrc files in the current and parent directories'
2894
- c.long_desc 'Config files are listed in order of precedence (if there are multiple configs detected).
2895
- Values defined in the top item in the list will override values in configutations below it.'
2896
- c.command :list do |list|
2897
- list.action do |global, options, args|
2898
- puts config.additional_configs.join("\n")
2899
- puts config.config_file
2900
- end
2901
- end
2902
-
2903
- c.desc 'Open config file in editor'
2904
- c.command :edit do |edit|
2905
- edit.example 'doing config edit', desc: 'Open a config file in the default editor'
2906
- edit.example 'doing config edit --editor vim', desc: 'Open config in specific editor'
2907
-
2908
- edit.desc 'Editor to use'
2909
- edit.arg_name 'EDITOR'
2910
- edit.flag %i[e editor], default_value: nil
2911
-
2912
- if `uname` =~ /Darwin/
2913
- edit.desc 'Application to use'
2914
- edit.arg_name 'APP_NAME'
2915
- edit.flag %i[a app]
2916
-
2917
- edit.desc 'Application bundle id to use'
2918
- edit.arg_name 'BUNDLE_ID'
2919
- edit.flag %i[b bundle_id]
2920
-
2921
- edit.desc "Use the config_editor_app defined in ~/.config/doing/config.yml (#{settings.key?('config_editor_app') ? settings['config_editor_app'] : 'config_editor_app not set'})"
2922
- edit.switch %i[x default]
2923
- end
2924
-
2925
- edit.action do |global, options, args|
2926
- if options[:update] || options[:dump]
2927
- cmd = commands[:config]
2928
- if options[:update]
2929
- cmd = cmd.commands[:update]
2930
- elsif options[:dump]
2931
- cmd = cmd.commands[:get]
2932
- end
2933
- action = cmd.send(:get_action, nil)
2934
- action.call(global, options, args)
2935
- Doing.logger.warn('Deprecated:', '--dump and --update are deprecated,
2936
- use `doing config get` and `doing config update`')
2937
- Doing.logger.output_results
2938
- return
2939
- end
2940
-
2941
- config_file = config.choose_config
2942
-
2943
- if `uname` =~ /Darwin/
2944
- if options[:default]
2945
- editor = Doing::Util.find_default_editor('config')
2946
- if editor
2947
- if Doing::Util.exec_available(editor.split(/ /).first)
2948
- system %(#{editor} "#{config_file}")
2949
- else
2950
- `open -a "#{editor}" "#{config_file}"`
2951
- end
2952
- else
2953
- raise InvalidArgument, 'No viable editor found in config or environment.'
2954
- end
2955
- elsif options[:app] || options[:bundle_id]
2956
- if options[:app]
2957
- `open -a "#{options[:app]}" "#{config_file}"`
2958
- elsif options[:bundle_id]
2959
- `open -b #{options[:bundle_id]} "#{config_file}"`
2960
- end
2961
- else
2962
- editor = options[:editor] || Doing::Util.find_default_editor('config')
2963
-
2964
- raise MissingEditor, 'No viable editor defined in config or environment' unless editor
2965
-
2966
- if Doing::Util.exec_available(editor.split(/ /).first)
2967
- system %(#{editor} "#{config_file}")
2968
- else
2969
- `open -a "#{editor}" "#{config_file}"`
2970
- end
2971
- end
2972
- else
2973
- editor = options[:editor] || Doing::Util.default_editor
2974
- raise MissingEditor, 'No EDITOR variable defined in environment' unless editor && Doing::Util.exec_available(editor.split(/ /).first)
2975
-
2976
- system %(#{editor} "#{config_file}")
2977
- end
2978
- end
2979
- end
2980
-
2981
- c.desc 'Update default config file, adding any missing keys'
2982
- c.command %i[update refresh] do |update|
2983
- update.action do |_global, options, args|
2984
- config.configure({rewrite: true, ignore_local: true})
2985
- Doing.logger.warn('Config:', 'config refreshed')
2986
- end
2987
- end
2988
-
2989
- c.desc 'Undo the last change to a config file'
2990
- c.command :undo do |undo|
2991
- undo.action do |_global, options, args|
2992
- config_file = config.choose_config
2993
- Doing::Util::Backup.restore_last_backup(config_file, count: 1)
2994
- end
2995
- end
2996
-
2997
- c.desc 'Output a key\'s value'
2998
- c.arg 'KEY_PATH'
2999
- c.command %i[get dump] do |dump|
3000
- dump.example 'doing config get', desc: 'Output the entire configuration'
3001
- dump.example 'doing config get timer_format --output raw', desc: 'Output the value of timer_format as a plain string'
3002
- dump.example 'doing config get doing_file', desc: 'Output the value of the doing_file setting, respecting local configurations'
3003
- dump.example 'doing config get -o json plug.plugpath', desc: 'Key path is fuzzy matched: output the value of plugins->plugin_path as JSON'
3004
-
3005
- dump.desc 'Format for output (json|yaml|raw)'
3006
- dump.arg_name 'FORMAT'
3007
- dump.flag %i[o output], default_value: 'yaml', must_match: /^(?:y(?:aml)?|j(?:son)?|r(?:aw)?)$/
3008
-
3009
- dump.action do |_global, options, args|
3010
-
3011
- keypath = args.join('.')
3012
- cfg = config.value_for_key(keypath)
3013
- real_path = config.resolve_key_path(keypath)
3014
-
3015
- if cfg
3016
- val = cfg.map {|k, v| v }[0]
3017
- if real_path.count.positive?
3018
- nested_cfg = {}
3019
- nested_cfg.deep_set(real_path, val)
3020
- else
3021
- nested_cfg = val
3022
- end
3177
+ ## Utility commands
3023
3178
 
3024
- if options[:output] =~ /^r/
3025
- if val.is_a?(Hash)
3026
- $stdout.puts YAML.dump(val)
3027
- elsif val.is_a?(Array)
3028
- $stdout.puts val.join(', ')
3029
- else
3030
- $stdout.puts val.to_s
3031
- end
3032
- else
3033
- $stdout.puts case options[:output]
3034
- when /^j/
3035
- JSON.pretty_generate(val)
3036
- else
3037
- YAML.dump(nested_cfg)
3038
- end
3039
- end
3179
+ # @@colors
3180
+ desc 'List available color variables for configuration templates and views'
3181
+ command :colors do |c|
3182
+ c.action do |_global_options, _options, _args|
3183
+ bgs = []
3184
+ fgs = []
3185
+ colors::attributes.each do |color|
3186
+ if color.to_s =~ /bg/
3187
+ bgs.push("#{colors.send(color, " ")}#{colors.default} <-- #{color.to_s}")
3040
3188
  else
3041
- Doing.logger.log_now(:error, 'Config:', "Key #{keypath} not found")
3189
+ fgs.push("#{colors.send(color, "XXXX")}#{colors.default} <-- #{color.to_s}")
3042
3190
  end
3043
- Doing.logger.output_results
3044
- return
3045
3191
  end
3192
+ out = []
3193
+ out << fgs.join("\n")
3194
+ out << bgs.join("\n")
3195
+ Doing::Pager.page out.join("\n")
3046
3196
  end
3197
+ end
3047
3198
 
3048
- c.desc 'Set a key\'s value in the config file'
3049
- c.arg 'KEY VALUE'
3050
- c.command :set do |set|
3051
- set.example 'doing config set timer_format human', desc: 'Set the value of timer_format to "human"'
3052
- set.example 'doing config set plug.plugpath ~/my_plugins', desc: 'Key path is fuzzy matched: set the value of plugins->plugin_path'
3199
+ # @@completion
3200
+ desc 'Generate shell completion scripts'
3201
+ long_desc 'Generates the necessary scripts to add command line completion to various shells, so typing \'doing\' and hitting
3202
+ tab will offer completions of subcommands and their options.'
3203
+ command :completion do |c|
3204
+ c.example 'doing completion', desc: 'Output zsh (default) to STDOUT'
3205
+ c.example 'doing completion --type zsh --file ~/.zsh-completions/_doing.zsh', desc: 'Output zsh completions to file'
3206
+ c.example 'doing completion --type fish --file ~/.config/fish/completions/doing.fish', desc: 'Output fish completions to file'
3207
+ c.example 'doing completion --type bash --file ~/.bash_it/completion/enabled/doing.bash', desc: 'Output bash completions to file'
3053
3208
 
3054
- set.desc 'Delete specified key'
3055
- set.switch %i[r remove], default_value: false, negatable: false
3209
+ c.desc 'Shell to generate for (bash, zsh, fish)'
3210
+ c.arg_name 'SHELL'
3211
+ c.flag %i[t type], must_match: /^(?:[bzf](?:[ai]?sh)?|all)$/i, default_value: 'zsh'
3056
3212
 
3057
- set.action do |_global, options, args|
3058
- if args.count < 2 && !options[:remove]
3059
- raise InvalidArgument, 'config set requires at least two arguments, key path and value'
3213
+ c.desc 'File to write output to'
3214
+ c.arg_name 'PATH'
3215
+ c.flag %i[f file], default_value: 'STDOUT'
3060
3216
 
3061
- end
3217
+ c.action do |_global_options, options, _args|
3218
+ script_dir = File.join(File.dirname(__FILE__), '..', 'scripts')
3062
3219
 
3063
- value = options[:remove] ? nil : args.pop
3064
- keypath = args.join('.')
3065
- real_path = config.resolve_key_path(keypath, create: true)
3220
+ Doing::Completion.generate_completion(type: options[:type], file: options[:file])
3221
+ end
3222
+ end
3066
3223
 
3067
- old_value = settings.dig(*real_path) || nil
3068
- old_type = old_value&.class.to_s || nil
3224
+ # @@plugins
3225
+ desc 'List installed plugins'
3226
+ long_desc %(Lists available plugins, including user-installed plugins.
3069
3227
 
3070
- if old_value.is_a?(Hash) && !options[:remove]
3071
- Doing.logger.log_now(:warn, 'Config:', "Config key must point to a single value, #{real_path.join('->').boldwhite} is a mapping")
3072
- didyou = 'Did you mean:'
3073
- old_value.keys.each do |k|
3074
- Doing.logger.log_now(:warn, "#{didyou}", "#{keypath}.#{k}?")
3075
- didyou = '..........or:'
3076
- end
3077
- raise InvalidArgument, 'Config value is a mapping, can not be set to a single value'
3228
+ Export plugins are available with the `--output` flag on commands that support it.
3078
3229
 
3079
- end
3230
+ Import plugins are available using `doing import --type PLUGIN`.
3231
+ )
3232
+ command :plugins do |c|
3233
+ c.example 'doing plugins', desc: 'List all plugins'
3234
+ c.example 'doing plugins -t import', desc: 'List all import plugins'
3080
3235
 
3081
- config_file = config.choose_config
3082
- cfg = YAML.safe_load_file(config_file) || {}
3236
+ c.desc 'List plugins of type (import, export)'
3237
+ c.arg_name 'TYPE'
3238
+ c.flag %i[t type], must_match: /^(?:[iea].*)$/i, default_value: 'all'
3083
3239
 
3084
- $stderr.puts "Updating #{config_file}".yellow
3240
+ c.desc 'List in single column for completion'
3241
+ c.switch %i[c column], negatable: false, default_value: false
3085
3242
 
3086
- if options[:remove]
3087
- cfg.deep_set(real_path, nil)
3088
- $stderr.puts "#{'Deleting key:'.yellow} #{real_path.join('->').boldwhite}"
3089
- else
3090
- cfg.deep_set(real_path, value.set_type(old_type))
3243
+ c.action do |_global_options, options, _args|
3244
+ Doing::Plugins.list_plugins(options)
3245
+ end
3246
+ end
3247
+
3248
+ # @@sections
3249
+ desc 'List sections'
3250
+ command :sections do |c|
3251
+ c.desc 'List in single column'
3252
+ c.switch %i[c column], negatable: false, default_value: false
3253
+
3254
+ c.action do |_global_options, options, _args|
3255
+ joiner = options[:column] ? "\n" : "\t"
3256
+ print wwid.content.section_titles.join(joiner)
3257
+ end
3258
+ end
3259
+
3260
+ # @@template
3261
+ desc 'Output HTML, CSS, and Markdown (ERB) templates for customization'
3262
+ long_desc %(
3263
+ Templates are printed to STDOUT for piping to a file.
3264
+ Save them and use them in the configuration file under export_templates.
3265
+ )
3266
+ arg_name 'TYPE', must_match: Doing::Plugins.template_regex
3267
+ command :template do |c|
3268
+ c.example 'doing template haml > ~/styles/my_doing.haml', desc: 'Output the haml template and save it to a file'
3269
+
3270
+ c.desc 'List all available templates'
3271
+ c.switch %i[l list], negatable: false
3272
+
3273
+ c.desc 'List in single column for completion'
3274
+ c.switch %i[c column]
3275
+
3276
+ c.desc 'Save template to file instead of STDOUT'
3277
+ c.switch %i[s save], default_value: false, negatable: false
3278
+
3279
+ c.desc 'Save template to alternate location'
3280
+ c.arg_name 'DIRECTORY'
3281
+ c.flag %i[p path], default_value: File.join(Doing::Util.user_home, '.config', 'doing', 'templates')
3091
3282
 
3092
- $stderr.puts "#{'Key path:'.yellow} #{real_path.join('->').boldwhite}"
3093
- $stderr.puts "#{'Previous:'.yellow} #{(old_value ? old_value.to_s : 'empty').boldwhite}"
3094
- $stderr.puts "#{' New:'.yellow} #{value.set_type(old_type).to_s.boldwhite}"
3283
+ c.action do |_global_options, options, args|
3284
+ if options[:list] || options[:column]
3285
+ if options[:column]
3286
+ $stdout.print Doing::Plugins.plugin_templates.join("\n")
3287
+ else
3288
+ $stdout.puts "Available templates: #{Doing::Plugins.plugin_templates.join(', ')}"
3095
3289
  end
3290
+ return
3291
+ end
3096
3292
 
3097
- res = Doing::Prompt.yn('Update selected config', default_response: true)
3293
+ if args.empty?
3294
+ type = Doing::Prompt.choose_from(Doing::Plugins.plugin_templates, sorted: false, prompt: 'Select template type > ')
3295
+ type.sub!(/ \(.*?\)$/, '').strip!
3296
+ options[:save] = Doing::Prompt.yn("Save to #{options[:path]}? (No outputs to STDOUT)", default_response: false)
3297
+ else
3298
+ type = args[0]
3299
+ end
3098
3300
 
3099
- raise UserCancelled, 'Cancelled' unless res
3301
+ raise InvalidPluginType, "No type specified, use `doing template [#{Doing::Plugins.plugin_templates.join('|')}]`" unless type
3100
3302
 
3101
- Doing::Util.write_to_file(config_file, YAML.dump(cfg), backup: true)
3102
- Doing.logger.warn('Config:', "#{config_file} updated")
3303
+ if options[:save]
3304
+ Doing::Plugins.template_for_trigger(type, save_to: options[:path])
3305
+ else
3306
+ $stdout.puts Doing::Plugins.template_for_trigger(type, save_to: nil)
3103
3307
  end
3308
+
3309
+ # case args[0]
3310
+ # when /html|haml/i
3311
+ # $stdout.puts wwid.haml_template
3312
+ # when /css/i
3313
+ # $stdout.puts wwid.css_template
3314
+ # when /markdown|md|erb/i
3315
+ # $stdout.puts wwid.markdown_template
3316
+ # else
3317
+ # exit_now! 'Invalid type specified, must be HAML or CSS'
3318
+ # end
3319
+ end
3320
+ end
3321
+
3322
+ # @@views
3323
+ desc 'List available custom views'
3324
+ command :views do |c|
3325
+ c.desc 'List in single column'
3326
+ c.switch %i[c column], default_value: false
3327
+
3328
+ c.action do |_global_options, options, _args|
3329
+ joiner = options[:column] ? "\n" : "\t"
3330
+ print wwid.views.join(joiner)
3104
3331
  end
3105
3332
  end
3106
3333
 
3334
+ ## History commands
3335
+
3336
+ # @@undo
3107
3337
  desc 'Undo the last X changes to the Doing file'
3108
3338
  long_desc 'Reverts the last X commands that altered the doing file.
3109
3339
  All changes performed by a single command are undone at once.
@@ -3153,6 +3383,7 @@ command :undo do |c|
3153
3383
  end
3154
3384
  end
3155
3385
 
3386
+ # @@redo
3156
3387
  long_desc 'Shortcut for `doing undo -r`, reverses the last undo command. You cannot undo a redo.'
3157
3388
  arg_name 'COUNT'
3158
3389
  command :redo do |c|
@@ -3175,6 +3406,7 @@ command :redo do |c|
3175
3406
  end
3176
3407
  end
3177
3408
 
3409
+ # @@changelog @@changes
3178
3410
  desc 'List recent changes in Doing'
3179
3411
  long_desc 'Display a formatted list of changes in recent versions, latest at the top'
3180
3412
  command %i[changelog changes] do |c|
@@ -3190,6 +3422,9 @@ command %i[changelog changes] do |c|
3190
3422
  end
3191
3423
  end
3192
3424
 
3425
+ ## Hidden commands
3426
+
3427
+ # @@commands_accepting
3193
3428
  arg_name 'OPTION'
3194
3429
  command :commands_accepting do |c|
3195
3430
  c.desc 'Output in single column for completion'
@@ -3215,107 +3450,26 @@ command :commands_accepting do |c|
3215
3450
  end
3216
3451
  end
3217
3452
 
3453
+ # @@install_fzf
3454
+ command :install_fzf do |c|
3455
+ c.desc 'Force reinstall'
3456
+ c.switch %i[r reinstall], default_value: false
3218
3457
 
3219
- desc 'Import entries from an external source'
3220
- long_desc "Imports entries from other sources. Available plugins: #{Doing::Plugins.plugin_names(type: :import, separator: ', ')}"
3221
- arg_name 'PATH'
3222
- command :import do |c|
3223
- c.example 'doing import --type timing "~/Desktop/All Activities.json"', desc: 'Import a Timing.app JSON report'
3224
- c.example 'doing import --type doing --tag imported --no-autotag ~/doing_backup.md', desc: 'Import an Doing archive, tag all entries with @imported, skip autotagging'
3225
- c.example 'doing import --type doing --from "10/1 to 10/15" ~/doing_backup.md', desc: 'Import a Doing archive, only importing entries between two dates'
3226
-
3227
- c.desc "Import type (#{Doing::Plugins.plugin_names(type: :import)})"
3228
- c.arg_name 'TYPE'
3229
- c.flag :type, default_value: 'doing'
3230
-
3231
- c.desc 'Only import items matching search. Surround with slashes for regex (/query/), start with single quote for exact match ("\'query")'
3232
- c.arg_name 'QUERY'
3233
- c.flag [:search]
3234
-
3235
- # c.desc '[DEPRECATED] Use alternative fuzzy matching for search string'
3236
- # c.switch [:fuzzy], default_value: false, negatable: false
3237
-
3238
- c.desc 'Force exact search string matching (case sensitive)'
3239
- c.switch %i[x exact], default_value: config.exact_match?, negatable: config.exact_match?
3240
-
3241
- c.desc 'Import items that *don\'t* match search/tag/date filters'
3242
- c.switch [:not], default_value: false, negatable: false
3243
-
3244
- c.desc 'Case sensitivity for search string matching [(c)ase-sensitive, (i)gnore, (s)mart]'
3245
- c.arg_name 'TYPE'
3246
- c.flag [:case], must_match: /^[csi]/, default_value: settings.dig('search', 'case')
3247
-
3248
- c.desc 'Only import items with recorded time intervals'
3249
- c.switch [:only_timed], default_value: false, negatable: false
3250
-
3251
- c.desc 'Target section'
3252
- c.arg_name 'NAME'
3253
- c.flag %i[s section]
3254
-
3255
- c.desc 'Tag all imported entries'
3256
- c.arg_name 'TAGS'
3257
- c.flag :tag
3258
-
3259
- c.desc 'Autotag entries'
3260
- c.switch :autotag, negatable: true, default_value: true
3261
-
3262
- c.desc 'Prefix entries with'
3263
- c.arg_name 'PREFIX'
3264
- c.flag :prefix
3265
-
3266
- # TODO: Allow time range filtering
3267
- c.desc 'Import entries older than date'
3268
- c.arg_name 'DATE_STRING'
3269
- c.flag [:before]
3270
-
3271
- c.desc 'Import entries newer than date'
3272
- c.arg_name 'DATE_STRING'
3273
- c.flag [:after]
3274
-
3275
- c.desc %(
3276
- Date range to import. Date range argument should be quoted. Date specifications can be natural language.
3277
- To specify a range, use "to" or "through": `--from "monday to friday"` or `--from 10/1 to 10/31`.
3278
- Has no effect unless the import plugin has implemented date range filtering.
3279
- )
3280
- c.arg_name 'DATE_OR_RANGE'
3281
- c.flag %i[f from]
3282
-
3283
- c.desc 'Allow entries that overlap existing times'
3284
- c.switch [:overlap], negatable: true
3285
-
3286
- c.action do |_global_options, options, args|
3287
- options[:fuzzy] = false
3288
- if options[:section]
3289
- options[:section] = wwid.guess_section(options[:section]) || options[:section].cap_first
3290
- end
3291
-
3292
- if options[:from]
3293
- date_string = options[:from]
3294
- if date_string =~ / (to|through|thru|(un)?til|-+) /
3295
- dates = date_string.split(/ (to|through|thru|(un)?til|-+) /)
3296
- start = dates[0].chronify(guess: :begin)
3297
- finish = dates[2].chronify(guess: :end)
3298
- else
3299
- start = date_string.chronify(guess: :begin)
3300
- finish = false
3301
- end
3302
- raise InvalidTimeExpression, 'Unrecognized date string' unless start
3303
- dates = [start, finish]
3304
- end
3305
-
3306
- options[:case] = options[:case].normalize_case
3458
+ c.desc 'Uninstall'
3459
+ c.switch %i[u uninstall], default_value: false, negatable: false
3307
3460
 
3308
- if options[:type] =~ Doing::Plugins.plugin_regex(type: :import)
3309
- options[:no_overlap] = !options[:overlap]
3310
- options[:date_filter] = dates
3311
- wwid.import(args, options)
3312
- wwid.write(wwid.doing_file)
3461
+ c.action do |g, o, a|
3462
+ if o[:uninstall]
3463
+ Doing::Prompt.uninstall_fzf
3313
3464
  else
3314
- raise InvalidPluginType, "Invalid import type: #{options[:type]}"
3465
+ Doing.logger.warn('fzf:', 'force reinstall') if o[:reinstall]
3466
+ res = Doing::Prompt.install_fzf(force: o[:reinstall])
3315
3467
  end
3316
3468
  end
3317
3469
  end
3318
3470
 
3471
+ ## Doing::Hooks
3472
+
3319
3473
  pre do |global, _command, _options, _args|
3320
3474
  # global[:pager] ||= settings['paginate']
3321
3475
  Doing::Pager.paginate = global[:pager]