doing 2.1.10 → 2.1.11

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