doing 2.1.8 → 2.1.12

Sign up to get free protection for your applications and to get access to all the features.
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 +38 -0
  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 +88 -73
  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 +210 -105
  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]