doing 2.1.10 → 2.1.11

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