doing 2.0.22 → 2.1.0pre

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. checksums.yaml +4 -4
  2. data/.yardoc/checksums +18 -15
  3. data/.yardoc/object_types +0 -0
  4. data/.yardoc/objects/root.dat +0 -0
  5. data/CHANGELOG.md +36 -1
  6. data/Gemfile.lock +8 -1
  7. data/README.md +7 -1
  8. data/Rakefile +23 -4
  9. data/bin/doing +323 -173
  10. data/doc/Array.html +354 -1
  11. data/doc/Doing/Color.html +104 -92
  12. data/doc/Doing/Completion.html +216 -0
  13. data/doc/Doing/Configuration.html +340 -5
  14. data/doc/Doing/Content.html +229 -0
  15. data/doc/Doing/Errors/DoingNoTraceError.html +1 -1
  16. data/doc/Doing/Errors/DoingRuntimeError.html +1 -1
  17. data/doc/Doing/Errors/DoingStandardError.html +1 -1
  18. data/doc/Doing/Errors/EmptyInput.html +1 -1
  19. data/doc/Doing/Errors/NoResults.html +1 -1
  20. data/doc/Doing/Errors/PluginException.html +1 -1
  21. data/doc/Doing/Errors/UserCancelled.html +1 -1
  22. data/doc/Doing/Errors/WrongCommand.html +1 -1
  23. data/doc/Doing/Errors.html +1 -1
  24. data/doc/Doing/Hooks.html +1 -1
  25. data/doc/Doing/Item.html +337 -49
  26. data/doc/Doing/Items.html +444 -35
  27. data/doc/Doing/LogAdapter.html +139 -51
  28. data/doc/Doing/Note.html +253 -22
  29. data/doc/Doing/Pager.html +74 -36
  30. data/doc/Doing/Plugins.html +1 -1
  31. data/doc/Doing/Prompt.html +674 -0
  32. data/doc/Doing/Section.html +354 -0
  33. data/doc/Doing/Util.html +57 -1
  34. data/doc/Doing/WWID.html +477 -670
  35. data/doc/Doing/WWIDFile.html +398 -0
  36. data/doc/Doing.html +5 -5
  37. data/doc/GLI/Commands/MarkdownDocumentListener.html +1 -1
  38. data/doc/GLI/Commands.html +1 -1
  39. data/doc/GLI.html +1 -1
  40. data/doc/Hash.html +97 -1
  41. data/doc/Status.html +37 -3
  42. data/doc/String.html +599 -23
  43. data/doc/Symbol.html +3 -3
  44. data/doc/Time.html +1 -1
  45. data/doc/_index.html +22 -1
  46. data/doc/class_list.html +1 -1
  47. data/doc/file.README.html +8 -2
  48. data/doc/index.html +8 -2
  49. data/doc/method_list.html +453 -173
  50. data/doc/top-level-namespace.html +1 -1
  51. data/doing.gemspec +3 -0
  52. data/doing.rdoc +79 -27
  53. data/example_plugin.rb +5 -5
  54. data/lib/completion/_doing.zsh +42 -42
  55. data/lib/completion/doing.bash +10 -10
  56. data/lib/completion/doing.fish +1 -280
  57. data/lib/doing/array.rb +36 -0
  58. data/lib/doing/colors.rb +70 -66
  59. data/lib/doing/completion/bash_completion.rb +1 -2
  60. data/lib/doing/completion/fish_completion.rb +1 -1
  61. data/lib/doing/completion/zsh_completion.rb +1 -1
  62. data/lib/doing/completion.rb +6 -0
  63. data/lib/doing/configuration.rb +134 -23
  64. data/lib/doing/hash.rb +37 -0
  65. data/lib/doing/item.rb +77 -12
  66. data/lib/doing/items.rb +125 -0
  67. data/lib/doing/log_adapter.rb +58 -4
  68. data/lib/doing/note.rb +53 -1
  69. data/lib/doing/pager.rb +49 -38
  70. data/lib/doing/plugins/export/markdown_export.rb +4 -4
  71. data/lib/doing/plugins/export/template_export.rb +2 -2
  72. data/lib/doing/plugins/import/calendar_import.rb +4 -4
  73. data/lib/doing/plugins/import/doing_import.rb +5 -7
  74. data/lib/doing/plugins/import/timing_import.rb +3 -3
  75. data/lib/doing/prompt.rb +206 -0
  76. data/lib/doing/section.rb +30 -0
  77. data/lib/doing/string.rb +123 -35
  78. data/lib/doing/util.rb +14 -6
  79. data/lib/doing/version.rb +1 -1
  80. data/lib/doing/wwid.rb +307 -614
  81. data/lib/doing.rb +6 -2
  82. data/lib/examples/plugins/capture_thing_import.rb +162 -0
  83. data/rdoc_to_mmd.rb +14 -8
  84. data/scripts/generate_bash_completions.rb +1 -1
  85. data/scripts/generate_fish_completions.rb +1 -1
  86. data/scripts/generate_zsh_completions.rb +1 -1
  87. metadata +73 -5
  88. data/lib/doing/wwidfile.rb +0 -117
data/lib/doing/wwid.rb CHANGED
@@ -16,6 +16,7 @@ module Doing
16
16
 
17
17
  attr_accessor :config, :config_file, :auto_tag, :default_option
18
18
 
19
+ include Color
19
20
  # include Util
20
21
 
21
22
  ##
@@ -24,11 +25,8 @@ module Doing
24
25
  def initialize
25
26
  @timers = {}
26
27
  @recorded_items = []
27
- @content = {}
28
- @doingrc_needs_update = false
29
- @default_config_file = '.doingrc'
28
+ @content = Items.new
30
29
  @auto_tag = true
31
- @user_home = Util.user_home
32
30
  end
33
31
 
34
32
  ##
@@ -56,52 +54,56 @@ module Doing
56
54
  create(@doing_file) unless File.exist?(@doing_file)
57
55
  input = IO.read(@doing_file)
58
56
  input = input.force_encoding('utf-8') if input.respond_to? :force_encoding
57
+ logger.debug('Read:', "read file #{@doing_file}")
59
58
  elsif File.exist?(File.expand_path(path)) && File.file?(File.expand_path(path)) && File.stat(File.expand_path(path)).size.positive?
60
59
  @doing_file = File.expand_path(path)
61
60
  input = IO.read(File.expand_path(path))
62
61
  input = input.force_encoding('utf-8') if input.respond_to? :force_encoding
62
+ logger.debug('Read:', "read file #{File.expand_path(path)}")
63
63
  elsif path.length < 256
64
64
  @doing_file = File.expand_path(path)
65
65
  create(path)
66
66
  input = IO.read(File.expand_path(path))
67
67
  input = input.force_encoding('utf-8') if input.respond_to? :force_encoding
68
+ logger.debug('Read:', "read file #{File.expand_path(path)}")
68
69
  end
69
70
 
70
71
  @other_content_top = []
71
72
  @other_content_bottom = []
72
73
 
73
- section = 'Uncategorized'
74
+ section = nil
74
75
  lines = input.split(/[\n\r]/)
75
- current = 0
76
76
 
77
77
  lines.each do |line|
78
78
  next if line =~ /^\s*$/
79
79
 
80
80
  if line =~ /^(\S[\S ]+):\s*(@\S+\s*)*$/
81
81
  section = Regexp.last_match(1)
82
- @content[section] = {}
83
- @content[section][:original] = line
84
- @content[section][:items] = []
85
- current = 0
82
+ @content.add_section(Section.new(section, original: line), log: false)
86
83
  elsif line =~ /^\s*- (\d{4}-\d\d-\d\d \d\d:\d\d) \| (.*)/
84
+ if section.nil?
85
+ section = 'Uncategorized'
86
+ @content.add_section(Section.new(section, original: 'Uncategorized:'), log: false)
87
+ end
88
+
87
89
  date = Regexp.last_match(1).strip
88
90
  title = Regexp.last_match(2).strip
89
91
  item = Item.new(date, title, section)
90
- @content[section][:items].push(item)
91
- current += 1
92
- elsif current.zero?
93
- # if content[section][:items].length - 1 == current
92
+ @content.push(item)
93
+ elsif @content.count.zero?
94
+ # if content[section].items.length - 1 == current
94
95
  @other_content_top.push(line)
95
96
  elsif line =~ /^\S/
96
97
  @other_content_bottom.push(line)
97
98
  else
98
- prev_item = @content[section][:items][current - 1]
99
+ prev_item = @content.last
99
100
  prev_item.note = Note.new unless prev_item.note
100
101
 
101
102
  prev_item.note.add(line)
102
103
  # end
103
104
  end
104
105
  end
106
+
105
107
  Hooks.trigger :post_read, self
106
108
  end
107
109
 
@@ -122,7 +124,7 @@ module Doing
122
124
  ##
123
125
  ## @param input [String] Text input for editor
124
126
  ##
125
- def fork_editor(input = '')
127
+ def fork_editor(input = '', message: :default)
126
128
  # raise NonInteractive, 'Non-interactive terminal' unless $stdout.isatty || ENV['DOING_EDITOR_TEST']
127
129
 
128
130
  raise MissingEditor, 'No EDITOR variable defined in environment' if Util.default_editor.nil?
@@ -131,7 +133,9 @@ module Doing
131
133
 
132
134
  File.open(tmpfile.path, 'w+') do |f|
133
135
  f.puts input
134
- f.puts "\n# The first line is the entry title, any lines after that are added as a note"
136
+ unless message.nil?
137
+ f.puts message == :default ? "# The first line is the entry title, any lines after that are added as a note" : message
138
+ end
135
139
  end
136
140
 
137
141
  pid = Process.fork { system("#{Util.editor_with_args} #{tmpfile.path}") }
@@ -177,13 +181,37 @@ module Doing
177
181
  title = input_lines[0]&.strip
178
182
  raise EmptyInput, 'No content in first line' if title.nil? || title.strip.empty?
179
183
 
184
+ date = nil
185
+ iso_rx = /\d{4}-\d\d-\d\d \d\d:\d\d/
186
+ done_rx = /(?<=^| )@(?<tag>done|finished|completed?)\((?<date>.*?)\)/i
187
+ date_rx = /^(?:\s*- )?(?<date>.*?) \| (?=\S)/
188
+
189
+ title.gsub!(done_rx) do
190
+ m = Regexp.last_match
191
+ t = m['tag']
192
+ d = m['date']
193
+ parsed_date = d =~ date_rx ? Time.parse(d) : chronify(d, guess: :begin)
194
+ parsed_date.nil? ? m[0] : "@#{t}(#{parsed_date.strftime('%F %R')})"
195
+ end
196
+
197
+ if title =~ date_rx
198
+ m = title.match(date_rx)
199
+ d = m['date']
200
+ date = if d =~ iso_rx
201
+ Time.parse(d)
202
+ else
203
+ chronify(d, guess: :begin)
204
+ end
205
+ title.sub!(date_rx, '').strip!
206
+ end
207
+
180
208
  note = Note.new
181
209
  note.add(input_lines[1..-1]) if input_lines.length > 1
182
210
  # If title line ends in a parenthetical, use that as the note
183
211
  if note.empty? && title =~ /\s+\(.*?\)$/
184
- title.sub!(/\s+\((.*?)\)$/) do
212
+ title.sub!(/\s+\((?<note>.*?)\)$/) do
185
213
  m = Regexp.last_match
186
- note.add(m[1])
214
+ note.add(m['note'])
187
215
  ''
188
216
  end
189
217
  end
@@ -191,7 +219,7 @@ module Doing
191
219
  note.strip_lines!
192
220
  note.compress
193
221
 
194
- [title, note]
222
+ [date, title, note]
195
223
  end
196
224
 
197
225
  ##
@@ -265,21 +293,7 @@ module Doing
265
293
  ## @return [Array] section titles
266
294
  ##
267
295
  def sections
268
- @content.keys
269
- end
270
-
271
- ##
272
- ## Adds a section.
273
- ##
274
- ## @param title [String] The new section title
275
- ##
276
- def add_section(title)
277
- if @content.key?(title.cap_first)
278
- raise InvalidSection, %(section "#{title.cap_first}" already exists)
279
- end
280
-
281
- @content[title.cap_first] = { :original => "#{title}:", :items => [] }
282
- logger.info('New section:', %("#{title.cap_first}"))
296
+ @content.section_titles
283
297
  end
284
298
 
285
299
  ##
@@ -292,8 +306,9 @@ module Doing
292
306
  return 'All' if frag =~ /^all$/i
293
307
  frag ||= @config['current_section']
294
308
 
295
- sections.each { |sect| return sect.cap_first if frag.downcase == sect.downcase }
296
- section = false
309
+ return frag.cap_first if @content.section?(frag)
310
+
311
+ section = nil
297
312
  re = frag.split('').join('.*?')
298
313
  sections.each do |sect|
299
314
  next unless sect =~ /#{re}/i
@@ -308,79 +323,25 @@ module Doing
308
323
  unless section || guessed
309
324
  alt = guess_view(frag, guessed: true, suggest: true)
310
325
  if alt
311
- meant_view = yn("#{Color.boldwhite}Did you mean `#{Color.yellow}doing view #{alt}#{Color.boldwhite}`?", default_response: 'n')
326
+ meant_view = Prompt.yn("#{boldwhite("Did you mean")} `#{yellow("doing view #{alt}")}#{boldwhite}`?", default_response: 'n')
312
327
 
313
328
  raise WrongCommand.new("run again with #{"doing view #{alt}".boldwhite}", topic: 'Try again:') if meant_view
314
329
 
315
330
  end
316
331
 
317
- res = yn("#{Color.boldwhite}Section #{frag.yellow}#{Color.boldwhite} not found, create it", default_response: 'n')
332
+ res = Prompt.yn("#{boldwhite}Section #{frag.yellow}#{boldwhite} not found, create it", default_response: 'n')
318
333
 
319
334
  if res
320
- add_section(frag.cap_first)
335
+ @content.add_section(frag.cap_first, log: true)
321
336
  write(@doing_file)
322
337
  return frag.cap_first
323
338
  end
324
339
 
325
- raise InvalidSection.new("unknown section #{frag.yellow}", topic: 'Missing:')
340
+ raise InvalidSection.new("unknown section #{frag.bold.white}", topic: 'Missing:')
326
341
  end
327
342
  section ? section.cap_first : guessed
328
343
  end
329
344
 
330
- ##
331
- ## Ask a yes or no question in the terminal
332
- ##
333
- ## @param question [String] The question
334
- ## to ask
335
- ## @param default_response (Bool) default
336
- ## response if no input
337
- ##
338
- ## @return (Bool) yes or no
339
- ##
340
- def yn(question, default_response: false)
341
- if default_response.is_a?(String)
342
- default = default_response =~ /y/i ? true : false
343
- else
344
- default = default_response
345
- end
346
-
347
- # if global --default is set, answer default
348
- return default if @default_option
349
-
350
- # if this isn't an interactive shell, answer default
351
- return default unless $stdout.isatty
352
-
353
- # clear the buffer
354
- if ARGV&.length
355
- ARGV.length.times do
356
- ARGV.shift
357
- end
358
- end
359
- system 'stty cbreak'
360
-
361
- cw = Color.white
362
- cbw = Color.boldwhite
363
- cbg = Color.boldgreen
364
- cd = Color.default
365
-
366
- options = unless default.nil?
367
- "#{cw}[#{default ? "#{cbg}Y#{cw}/#{cbw}n" : "#{cbw}y#{cw}/#{cbg}N"}#{cw}]#{cd}"
368
- else
369
- "#{cw}[#{cbw}y#{cw}/#{cbw}n#{cw}]#{cd}"
370
- end
371
- $stdout.syswrite "#{cbw}#{question.sub(/\?$/, '')} #{options}#{cbw}?#{cd} "
372
- res = $stdin.sysread 1
373
- puts
374
- system 'stty cooked'
375
-
376
- res.chomp!
377
- res.downcase!
378
-
379
- return default if res.empty?
380
-
381
- res =~ /y/i ? true : false
382
- end
383
-
384
345
  ##
385
346
  ## Attempt to match a string with an existing view
386
347
  ##
@@ -400,11 +361,14 @@ module Doing
400
361
  end
401
362
  unless view || guessed
402
363
  alt = guess_section(frag, guessed: true, suggest: true)
403
- meant_view = yn("Did you mean `doing show #{alt}`?", default_response: 'n')
364
+
365
+ raise InvalidView.new(%(unknown view #{frag.bold.white}), topic: 'Missing:') unless alt
366
+
367
+ meant_view = Prompt.yn("Did you mean `doing show #{alt}`?", default_response: 'n')
404
368
 
405
369
  raise WrongCommand.new("run again with #{"doing show #{alt}".yellow}", topic: 'Try again:') if meant_view
406
370
 
407
- raise InvalidView.new(%(unkown view #{alt.yellow}), topic: 'Missing:')
371
+ raise InvalidView.new(%(unknown view #{alt.bold.white}), topic: 'Missing:')
408
372
  end
409
373
  view
410
374
  end
@@ -414,17 +378,22 @@ module Doing
414
378
  ##
415
379
  ## @param title [String] The entry title
416
380
  ## @param section [String] The section to add to
417
- ## @param opt [Hash] Additional Options: :date, :note, :back, :timed
381
+ ## @param opt [Hash] Additional Options
382
+ ##
383
+ ## @option opt :date [Date] item start date
384
+ ## @option opt :note [Array] item note (will be converted if value is String)
385
+ ## @option opt :back [Date] backdate
386
+ ## @option opt :timed [Boolean] new item is timed entry, marks previous entry as @done
418
387
  ##
419
388
  def add_item(title, section = nil, opt = {})
420
389
  section ||= @config['current_section']
421
- add_section(section) unless @content.key?(section)
390
+ @content.add_section(section, log: false)
391
+ opt[:back] ||= opt[:date] ? opt[:date] : Time.now
422
392
  opt[:date] ||= Time.now
423
- opt[:note] ||= []
424
- opt[:back] ||= Time.now
393
+ note = Note.new
425
394
  opt[:timed] ||= false
426
395
 
427
- opt[:note] = opt[:note].lines if opt[:note].is_a?(String)
396
+ note.add(opt[:note]) if opt[:note]
428
397
 
429
398
  title = [title.strip.cap_first]
430
399
  title = title.join(' ')
@@ -434,10 +403,11 @@ module Doing
434
403
  title.add_tags!(@config['default_tags']) unless @config['default_tags'].empty?
435
404
  end
436
405
 
437
- title.gsub!(/ +/, ' ')
406
+ title.compress!
438
407
  entry = Item.new(opt[:back], title.strip, section)
439
- entry.note = opt[:note].map(&:chomp) unless opt[:note].join('').strip == ''
440
- items = @content[section][:items]
408
+ entry.note = note
409
+
410
+ items = @content.dup
441
411
  if opt[:timed]
442
412
  items.reverse!
443
413
  items.each_with_index do |i, x|
@@ -446,10 +416,9 @@ module Doing
446
416
  items[x].title = "#{i.title} @done(#{opt[:back].strftime('%F %R')})"
447
417
  break
448
418
  end
449
- items.reverse!
450
419
  end
451
420
 
452
- items.push(entry)
421
+ @content.push(entry)
453
422
  # logger.count(:added, level: :debug)
454
423
  logger.info('New entry:', %(added "#{entry.title}" to #{section}))
455
424
  end
@@ -460,16 +429,10 @@ module Doing
460
429
  ## @param items [Array] The items to deduplicate
461
430
  ## @param no_overlap [Boolean] Remove items with overlapping time spans
462
431
  ##
463
- def dedup(items, no_overlap = false)
464
-
465
- combined = []
466
- @content.each do |_k, v|
467
- combined += v[:items]
468
- end
469
-
432
+ def dedup(items, no_overlap: false)
470
433
  items.delete_if do |item|
471
434
  duped = false
472
- combined.each do |comp|
435
+ @content.each do |comp|
473
436
  duped = no_overlap ? item.overlapping_time?(comp) : item.same_time?(comp)
474
437
  break if duped
475
438
  end
@@ -519,17 +482,32 @@ module Doing
519
482
  "#{last_item.title}\n# EDIT BELOW THIS LINE ------------\n#{note}"
520
483
  end
521
484
 
485
+ # Reset start date to current time, optionally remove
486
+ # done tag (resume)
487
+ #
488
+ # @param item [Item] the item to reset/resume
489
+ # @param resume [Boolean] removing @done tag if true
490
+ #
522
491
  def reset_item(item, resume: false)
523
492
  item.date = Time.now
524
- if resume
525
- item.tag('done', remove: true)
526
- end
493
+ item.tag('done', remove: true) if resume
527
494
  logger.info('Reset:', %(Reset #{resume ? 'and resumed ' : ''} "#{item.title}" in #{item.section}))
528
495
  item
529
496
  end
530
497
 
498
+ # Duplicate an item and add it as a new item
499
+ #
500
+ # @param item [Item] the item to duplicate
501
+ # @param opt [Hash] additional options
502
+ #
503
+ # @option opt :editor [Boolean] open new item in editor
504
+ # @option opt :date [String] set start date
505
+ # @option opt :in [String] add new item to section :in
506
+ # @option opt :note [Note] add note to new item
507
+ #
508
+ # @return nothing
509
+ #
531
510
  def repeat_item(item, opt = {})
532
- original = item.dup
533
511
  if item.should_finish?
534
512
  if item.should_time?
535
513
  item.title.tag!('done', value: Time.now.strftime('%F %R'))
@@ -546,10 +524,13 @@ module Doing
546
524
  note = opt[:note] || Note.new
547
525
 
548
526
  if opt[:editor]
549
- to_edit = title
550
- to_edit += "\n#{note.to_s}" unless note.empty?
527
+ start = opt[:date] ? opt[:date] : Time.now
528
+ to_edit = "#{start.strftime('%F %R')} | #{title}"
529
+ to_edit += "\n#{note.strip_lines.join("\n")}" unless note.empty?
551
530
  new_item = fork_editor(to_edit)
552
- title, note = format_input(new_item)
531
+ date, title, note = format_input(new_item)
532
+
533
+ opt[:date] = date unless date.nil?
553
534
 
554
535
  if title.nil? || title.empty?
555
536
  logger.warn('Skipped:', 'No content provided')
@@ -557,9 +538,8 @@ module Doing
557
538
  end
558
539
  end
559
540
 
560
- update_item(original, item)
541
+ # @content.update_item(original, item)
561
542
  add_item(title, section, { note: note, back: opt[:date], timed: true })
562
- write(@doing_file)
563
543
  end
564
544
 
565
545
  ##
@@ -569,6 +549,7 @@ module Doing
569
549
  ##
570
550
  def repeat_last(opt = {})
571
551
  opt[:section] ||= 'all'
552
+ opt[:section] = guess_section(opt[:section])
572
553
  opt[:note] ||= []
573
554
  opt[:tag] ||= []
574
555
  opt[:tag_bool] ||= :and
@@ -580,6 +561,7 @@ module Doing
580
561
  end
581
562
 
582
563
  repeat_item(last, opt)
564
+ write(@doing_file)
583
565
  end
584
566
 
585
567
  ##
@@ -591,19 +573,19 @@ module Doing
591
573
  opt[:tag_bool] ||= :and
592
574
  opt[:section] ||= @config['current_section']
593
575
 
594
- items = filter_items([], opt: opt)
576
+ items = filter_items(Items.new, opt: opt)
595
577
 
596
578
  logger.debug('Filtered:', "Parameters matched #{items.count} entries")
597
579
 
598
580
  if opt[:interactive]
599
- last_entry = choose_from_items(items, {
581
+ last_entry = Prompt.choose_from_items(items, include_section: opt[:section] =~ /^all$/i,
600
582
  menu: true,
601
583
  header: '',
602
584
  prompt: 'Select an entry > ',
603
585
  multiple: false,
604
586
  sort: false,
605
587
  show_if_single: true
606
- }, include_section: opt[:section] =~ /^all$/i )
588
+ )
607
589
  else
608
590
  last_entry = items.max_by { |item| item.date }
609
591
  end
@@ -611,42 +593,6 @@ module Doing
611
593
  last_entry
612
594
  end
613
595
 
614
- def fzf
615
- fzf_dir = File.join(File.dirname(__FILE__), '../helpers/fzf')
616
- FileUtils.mkdir_p(fzf_dir) unless File.directory?(fzf_dir)
617
- fzf = File.join(fzf_dir, 'bin/fzf')
618
- return fzf if File.exist?(fzf)
619
-
620
- Doing.logger.log_now(:warn, 'Compiling and installing FZF. This will only happen once')
621
- Doing.logger.log_now(:warn, 'fzf is copyright Junegunn Choi <https://github.com/junegunn/fzf/blob/master/LICENSE>')
622
-
623
- res = `#{fzf_dir}/install --bin --no-key-bindings --no-completion --no-update-rc --no-bash --no-zsh --no-fish`
624
-
625
- raise DoingRuntimeError unless File.exist?(fzf)
626
-
627
- fzf
628
- end
629
-
630
- ##
631
- ## Generate a menu of options and allow user selection
632
- ##
633
- ## @return [String] The selected option
634
- ##
635
- def choose_from(options, prompt: 'Make a selection: ', multiple: false, sorted: true, fzf_args: [])
636
- return nil unless $stdout.isatty
637
-
638
- # fzf_args << '-1' # User is expecting a menu, and even if only one it seves as confirmation
639
- fzf_args << %(--prompt "#{prompt}")
640
- fzf_args << '--multi' if multiple
641
- header = "esc: cancel,#{multiple ? ' tab: multi-select, ctrl-a: select all,' : ''} return: confirm"
642
- fzf_args << %(--header "#{header}")
643
- options.sort! if sorted
644
- res = `echo #{Shellwords.escape(options.join("\n"))}|#{fzf} #{fzf_args.join(' ')}`
645
- return false if res.strip.size.zero?
646
-
647
- res
648
- end
649
-
650
596
  def all_tags(items, opt: {})
651
597
  all_tags = []
652
598
  items.each { |item| all_tags.concat(item.tags).uniq! }
@@ -685,8 +631,8 @@ module Doing
685
631
  end
686
632
  # fzf_args << '-e' if opt[:exact]
687
633
  # puts fzf_args.join(' ')
688
- res = `echo #{Shellwords.escape(scannable)}|#{fzf} #{fzf_args.join(' ')}`
689
- selected = []
634
+ res = `echo #{Shellwords.escape(scannable)}|#{Prompt.fzf} #{fzf_args.join(' ')}`
635
+ selected = Items.new
690
636
  res.split(/\n/).each do |item|
691
637
  idx = item.match(/\|(\d+)$/)[1].to_i
692
638
  selected.push(items[idx])
@@ -714,15 +660,11 @@ module Doing
714
660
  ## @option opt [Number] :count (Number to return)
715
661
  ## @option opt [String] :age ('old' or 'new')
716
662
  ##
717
- def filter_items(items = [], opt: {})
663
+ def filter_items(items = Items.new, opt: {})
718
664
  if items.nil? || items.empty?
719
665
  section = opt[:section] ? guess_section(opt[:section]) : 'All'
720
666
 
721
- items = if section =~ /^all$/i
722
- @content.each_with_object([]) { |(_k, v), arr| arr.concat(v[:items].dup) }
723
- else
724
- @content[section][:items].dup
725
- end
667
+ items = section =~ /^all$/i ? @content.dup : @content.in_section(section)
726
668
  end
727
669
 
728
670
  items.sort_by! { |item| [item.date, item.title.downcase] }.reverse
@@ -797,14 +739,17 @@ module Doing
797
739
 
798
740
  keep
799
741
  end
800
- count = opt[:count] && opt[:count].positive? ? opt[:count] : filtered_items.length
742
+ count = opt[:count]&.positive? ? opt[:count] : filtered_items.length
743
+
744
+ output = Items.new
801
745
 
802
746
  if opt[:age] =~ /^o/i
803
- filtered_items.slice(0, count).reverse
747
+ output.concat(filtered_items.slice(0, count).reverse)
804
748
  else
805
- filtered_items.reverse.slice(0, count)
749
+ output.concat(filtered_items.reverse.slice(0, count))
806
750
  end
807
751
 
752
+ output
808
753
  end
809
754
 
810
755
  ##
@@ -829,94 +774,15 @@ module Doing
829
774
  opt[:query] = "!#{opt[:query]}" if opt[:not]
830
775
  opt[:multiple] = true
831
776
  opt[:show_if_single] = true
832
- items = filter_items([], opt: { section: section, search: opt[:search], fuzzy: opt[:fuzzy], case: opt[:case], not: opt[:not] })
777
+ items = filter_items(Items.new, opt: { section: section, search: opt[:search], fuzzy: opt[:fuzzy], case: opt[:case], not: opt[:not] })
833
778
 
834
- selection = choose_from_items(items, opt, include_section: section =~ /^all$/i)
779
+ selection = Prompt.choose_from_items(items, include_section: section =~ /^all$/i, **opt)
835
780
 
836
781
  raise NoResults, 'no items selected' if selection.nil? || selection.empty?
837
782
 
838
783
  act_on(selection, opt)
839
784
  end
840
785
 
841
- ##
842
- ## Create an interactive menu to select from a set of Items
843
- ##
844
- ## @param items [Array] list of items
845
- ## @param opt [Hash] options
846
- ## @param include_section [Boolean] include section
847
- ##
848
- ## @option opt [String] :header
849
- ## @option opt [String] :prompt
850
- ## @option opt [String] :query
851
- ## @option opt [Boolean] :show_if_single
852
- ## @option opt [Boolean] :menu
853
- ## @option opt [Boolean] :sort
854
- ## @option opt [Boolean] :multiple
855
- ## @option opt [Symbol] :case (:sensitive, :ignore, :smart)
856
- ##
857
- def choose_from_items(items, opt = {}, include_section: false)
858
- return items unless $stdout.isatty
859
-
860
- return nil unless items.count.positive?
861
-
862
- opt[:case] ||= :smart
863
- opt[:header] ||= "Arrows: navigate, tab: mark for selection, ctrl-a: select all, enter: commit"
864
- opt[:prompt] ||= "Select entries to act on > "
865
-
866
- pad = items.length.to_s.length
867
- options = items.map.with_index do |item, i|
868
- out = [
869
- format("%#{pad}d", i),
870
- ') ',
871
- format('%13s', item.date.relative_date),
872
- ' | ',
873
- item.title
874
- ]
875
- if include_section
876
- out.concat([
877
- ' (',
878
- item.section,
879
- ') '
880
- ])
881
- end
882
- out.join('')
883
- end
884
-
885
- fzf_args = [
886
- %(--header="#{opt[:header]}"),
887
- %(--prompt="#{opt[:prompt].sub(/ *$/, ' ')}"),
888
- opt[:multiple] ? '--multi' : '--no-multi',
889
- '-0',
890
- '--bind ctrl-a:select-all',
891
- %(-q "#{opt[:query]}"),
892
- '--info=inline'
893
- ]
894
- fzf_args.push('-1') unless opt[:show_if_single]
895
- fzf_args << case opt[:case].normalize_case
896
- when :sensitive
897
- '+i'
898
- when :ignore
899
- '-i'
900
- end
901
- fzf_args << '-e' if opt[:exact]
902
-
903
-
904
- unless opt[:menu]
905
- raise InvalidArgument, "Can't skip menu when no query is provided" unless opt[:query] && !opt[:query].empty?
906
-
907
- fzf_args.concat([%(--filter="#{opt[:query]}"), opt[:sort] ? '' : '--no-sort'])
908
- end
909
-
910
- res = `echo #{Shellwords.escape(options.join("\n"))}|#{fzf} #{fzf_args.join(' ')}`
911
- selected = []
912
- res.split(/\n/).each do |item|
913
- idx = item.match(/^ *(\d+)\)/)[1].to_i
914
- selected.push(items[idx])
915
- end
916
-
917
- opt[:multiple] ? selected : selected[0]
918
- end
919
-
920
786
  ##
921
787
  ## Perform actions on a set of entries. If
922
788
  ## no valid action is included in the opt
@@ -966,11 +832,11 @@ module Doing
966
832
 
967
833
  actions.concat(['resume/repeat', 'begin/reset']) if items.count == 1
968
834
 
969
- choice = choose_from(actions,
970
- prompt: 'What do you want to do with the selected items? > ',
971
- multiple: true,
972
- sorted: false,
973
- fzf_args: ["--height=#{actions.count + 3}", '--tac', '--no-sort', '--info=hidden'])
835
+ choice = Prompt.choose_from(actions,
836
+ prompt: 'What do you want to do with the selected items? > ',
837
+ multiple: true,
838
+ sorted: false,
839
+ fzf_args: ["--height=#{actions.count + 3}", '--tac', '--no-sort', '--info=hidden'])
974
840
  return unless choice
975
841
 
976
842
  to_do = choice.strip.split(/\n/)
@@ -984,7 +850,7 @@ module Doing
984
850
  type = action =~ /^add/ ? 'add' : 'remove'
985
851
  raise InvalidArgument, "'add tag' and 'remove tag' can not be used together" if opt[:tag]
986
852
 
987
- print "#{Color.yellow}Tag to #{type}: #{Color.reset}"
853
+ print "#{yellow("Tag to #{type}: ")}#{reset}"
988
854
  tag = $stdin.gets
989
855
  next if tag =~ /^ *$/
990
856
 
@@ -992,17 +858,22 @@ module Doing
992
858
  opt[:remove] = true if type == 'remove'
993
859
  when /output formatted/
994
860
  plugins = Plugins.available_plugins(type: :export).sort
995
- output_format = choose_from(plugins,
996
- prompt: 'Which output format? > ',
997
- fzf_args: ["--height=#{plugins.count + 3}", '--tac', '--no-sort', '--info=hidden'])
861
+ output_format = Prompt.choose_from(plugins,
862
+ prompt: 'Which output format? > ',
863
+ fzf_args: [
864
+ "--height=#{plugins.count + 3}",
865
+ '--tac',
866
+ '--no-sort',
867
+ '--info=hidden'
868
+ ])
998
869
  next if tag =~ /^ *$/
999
870
 
1000
871
  raise UserCancelled unless output_format
1001
872
 
1002
873
  opt[:output] = output_format.strip
1003
- res = opt[:force] ? false : yn('Save to file?', default_response: 'n')
874
+ res = opt[:force] ? false : Prompt.yn('Save to file?', default_response: 'n')
1004
875
  if res
1005
- print "#{Color.yellow}File path/name: #{Color.reset}"
876
+ print "#{yellow('File path/name: ')}#{reset}"
1006
877
  filename = $stdin.gets.strip
1007
878
  next if filename.empty?
1008
879
 
@@ -1028,29 +899,28 @@ module Doing
1028
899
  end
1029
900
 
1030
901
  if opt[:resume] || opt[:reset]
1031
- if items.count > 1
1032
- raise InvalidArgument, 'resume and restart can only be used on a single entry'
1033
- else
1034
- item = items[0]
1035
- if opt[:resume] && !opt[:reset]
1036
- repeat_item(item, { editor: opt[:editor] })
1037
- elsif opt[:reset]
1038
- if item.tags?('done', :and) && !opt[:resume]
1039
- res = opt[:force] ? true : yn('Remove @done tag?', default_response: 'y')
1040
- else
1041
- res = opt[:resume]
1042
- end
1043
- update_item(item, reset_item(item, resume: res))
1044
- end
1045
- write(@doing_file)
902
+ raise InvalidArgument, 'resume and restart can only be used on a single entry' if items.count > 1
903
+
904
+ item = items[0]
905
+ if opt[:resume] && !opt[:reset]
906
+ repeat_item(item, { editor: opt[:editor] })
907
+ elsif opt[:reset]
908
+ res = if item.tags?('done', :and) && !opt[:resume]
909
+ opt[:force] ? true : Prompt.yn('Remove @done tag?', default_response: 'y')
910
+ else
911
+ opt[:resume]
912
+ end
913
+ @content.update_item(item, reset_item(item, resume: res))
1046
914
  end
915
+ write(@doing_file)
916
+
1047
917
  return
1048
918
  end
1049
919
 
1050
920
  if opt[:delete]
1051
- res = opt[:force] ? true : yn("Delete #{items.size} items?", default_response: 'y')
921
+ res = opt[:force] ? true : Prompt.yn("Delete #{items.size} items?", default_response: 'y')
1052
922
  if res
1053
- items.each { |item| delete_item(item, single: items.count == 1) }
923
+ items.each { |i| @content.delete_item(i, single: items.count == 1) }
1054
924
  write(@doing_file)
1055
925
  end
1056
926
  return
@@ -1058,31 +928,31 @@ module Doing
1058
928
 
1059
929
  if opt[:flag]
1060
930
  tag = @config['marker_tag'] || 'flagged'
1061
- items.map! do |item|
1062
- tag_item(item, tag, date: false, remove: opt[:remove], single: single)
931
+ items.map! do |i|
932
+ i.tag(tag, date: false, remove: opt[:remove], single: single)
1063
933
  end
1064
934
  end
1065
935
 
1066
936
  if opt[:finish] || opt[:cancel]
1067
937
  tag = 'done'
1068
- items.map! do |item|
1069
- if item.should_finish?
1070
- should_date = !opt[:cancel] && item.should_time?
1071
- tag_item(item, tag, date: should_date, remove: opt[:remove], single: single)
938
+ items.map! do |i|
939
+ if i.should_finish?
940
+ should_date = !opt[:cancel] && i.should_time?
941
+ i.tag(tag, date: should_date, remove: opt[:remove], single: single)
1072
942
  end
1073
943
  end
1074
944
  end
1075
945
 
1076
946
  if opt[:tag]
1077
947
  tag = opt[:tag]
1078
- items.map! do |item|
1079
- tag_item(item, tag, date: false, remove: opt[:remove], single: single)
948
+ items.map! do |i|
949
+ i.tag(tag, date: false, remove: opt[:remove], single: single)
1080
950
  end
1081
951
  end
1082
952
 
1083
953
  if opt[:archive] || opt[:move]
1084
954
  section = opt[:archive] ? 'Archive' : guess_section(opt[:move])
1085
- items.map! {|item| move_item(item, section) }
955
+ items.map! { |i| i.move_to(section, label: true) }
1086
956
  end
1087
957
 
1088
958
  write(@doing_file)
@@ -1091,111 +961,88 @@ module Doing
1091
961
 
1092
962
  editable_items = []
1093
963
 
1094
- items.each do |item|
1095
- editable = "#{item.date} | #{item.title}"
1096
- old_note = item.note ? item.note.to_s : nil
964
+ items.each do |i|
965
+ editable = "#{i.date.strftime('%F %R')} | #{i.title}"
966
+ old_note = i.note ? i.note.strip_lines.join("\n") : nil
1097
967
  editable += "\n#{old_note}" unless old_note.nil?
1098
968
  editable_items << editable
1099
969
  end
1100
970
  divider = "\n-----------\n"
1101
- input = editable_items.map(&:strip).join(divider) + "\n\n# You may delete entries, but leave all divider lines in place"
971
+ notice =<<~EONOTICE
972
+ # - You may delete entries, but leave all divider lines (---) in place.
973
+ # - Start and @done dates replaced with a time string (yesterday 3pm) will
974
+ # be parsed automatically. Do not delete the pipe (|) between start date
975
+ # and entry title.
976
+ EONOTICE
977
+ input = "#{editable_items.map(&:strip).join(divider)}\n\n#{notice}"
1102
978
 
1103
979
  new_items = fork_editor(input).split(/#{divider}/)
1104
980
 
1105
981
  new_items.each_with_index do |new_item, i|
1106
-
1107
982
  input_lines = new_item.split(/[\n\r]+/).delete_if(&:ignore?)
1108
- title = input_lines[0]&.strip
983
+ first_line = input_lines[0]&.strip
1109
984
 
1110
- if title.nil? || title =~ /^#{divider.strip}$/ || title.strip.empty?
1111
- delete_item(items[i], single: new_items.count == 1)
985
+ if first_line.nil? || first_line =~ /^#{divider.strip}$/ || first_line.strip.empty?
986
+ @content.delete_item(items[i], single: new_items.count == 1)
987
+ Doing.logger.count(:deleted)
1112
988
  else
1113
- note = input_lines.length > 1 ? input_lines[1..-1] : []
989
+ date, title, note = format_input(new_item)
1114
990
 
1115
991
  note.map!(&:strip)
1116
992
  note.delete_if(&:ignore?)
1117
-
1118
- date = title.match(/^([\d\-: ]+) \| /)[1]
1119
- title.sub!(/^([\d\-: ]+) \| /, '')
1120
-
1121
993
  item = items[i]
994
+ old_item = item.dup
995
+ item.date = date || items[i].date
1122
996
  item.title = title
1123
997
  item.note = note
1124
- item.date = Time.parse(date) || items[i].date
998
+ if (item.equal?(old_item))
999
+ Doing.logger.count(:skipped, level: :debug)
1000
+ else
1001
+ Doing.logger.count(:updated)
1002
+ end
1125
1003
  end
1126
1004
  end
1127
1005
 
1128
1006
  write(@doing_file)
1129
1007
  end
1130
1008
 
1131
- if opt[:output]
1132
- items.map! do |item|
1133
- item.title = "#{item.title} @project(#{item.section})"
1134
- item
1135
- end
1136
-
1137
- @content = { 'Export' => { :original => 'Export:', :items => items } }
1138
- options = { section: 'Export' }
1139
-
1140
-
1141
- if opt[:output] =~ /doing/
1142
- options[:output] = 'template'
1143
- options[:template] = '- %date | %title%note'
1144
- else
1145
- options[:output] = opt[:output]
1146
- options[:template] = opt[:template] || nil
1147
- end
1148
-
1149
- output = list_section(options)
1009
+ return unless opt[:output]
1150
1010
 
1151
- if opt[:save_to]
1152
- file = File.expand_path(opt[:save_to])
1153
- if File.exist?(file)
1154
- # Create a backup copy for the undo command
1155
- FileUtils.cp(file, "#{file}~")
1156
- end
1157
-
1158
- File.open(file, 'w+') do |f|
1159
- f.puts output
1160
- end
1161
-
1162
- logger.warn('File written:', file)
1163
- else
1164
- Doing::Pager.page output
1165
- end
1011
+ items.map! do |i|
1012
+ i.title = "#{i.title} @project(#{i.section})"
1013
+ i
1166
1014
  end
1167
- end
1168
1015
 
1169
- ##
1170
- ## Tag an item from the index
1171
- ##
1172
- ## @param item [Item] The item to tag
1173
- ## @param tags [String] The tag to apply
1174
- ## @param remove [Boolean] remove tags?
1175
- ## @param date [Boolean] Include timestamp?
1176
- ## @param single [Boolean] Log as a single change?
1177
- ##
1178
- ## @return [Item] updated item
1179
- ##
1180
- def tag_item(item, tags, remove: false, date: false, single: false)
1181
- added = []
1182
- removed = []
1016
+ @content = Items.new
1017
+ @content.concat(items)
1018
+ @content.add_section(Section.new('Export'), log: false)
1019
+ options = { section: 'Export' }
1183
1020
 
1184
- tags = tags.to_tags if tags.is_a? ::String
1021
+ if opt[:output] =~ /doing/
1022
+ options[:output] = 'template'
1023
+ options[:template] = '- %date | %title%note'
1024
+ else
1025
+ options[:output] = opt[:output]
1026
+ options[:template] = opt[:template] || nil
1027
+ end
1185
1028
 
1186
- done_date = Time.now
1029
+ output = list_section(options)
1187
1030
 
1188
- tags.each do |tag|
1189
- bool = remove ? :and : :not
1190
- if item.tags?(tag, bool)
1191
- item.tag(tag, remove: remove, value: date ? done_date.strftime('%F %R') : nil)
1192
- remove ? removed.push(tag) : added.push(tag)
1031
+ if opt[:save_to]
1032
+ file = File.expand_path(opt[:save_to])
1033
+ if File.exist?(file)
1034
+ # Create a backup copy for the undo command
1035
+ FileUtils.cp(file, "#{file}~")
1193
1036
  end
1194
- end
1195
1037
 
1196
- log_change(tags_added: added, tags_removed: removed, count: 1, item: item, single: single)
1038
+ File.open(file, 'w+') do |f|
1039
+ f.puts output
1040
+ end
1197
1041
 
1198
- item
1042
+ logger.warn('File written:', file)
1043
+ else
1044
+ Doing::Pager.page output
1045
+ end
1199
1046
  end
1200
1047
 
1201
1048
  ##
@@ -1219,17 +1066,15 @@ module Doing
1219
1066
  opt[:unfinished] ||= false
1220
1067
  opt[:section] = opt[:section] ? guess_section(opt[:section]) : 'All'
1221
1068
 
1222
- items = filter_items([], opt: opt)
1069
+ items = filter_items(Items.new, opt: opt)
1223
1070
 
1224
1071
  if opt[:interactive]
1225
- items = choose_from_items(items, {
1226
- menu: true,
1072
+ items = Prompt.choose_from_items(items, include_section: opt[:section] =~ /^all$/i, menu: true,
1227
1073
  header: '',
1228
1074
  prompt: 'Select entries to tag > ',
1229
1075
  multiple: true,
1230
1076
  sort: true,
1231
- show_if_single: true
1232
- }, include_section: opt[:section] =~ /^all$/i)
1077
+ show_if_single: true)
1233
1078
 
1234
1079
  raise NoResults, 'no items selected' if items.empty?
1235
1080
 
@@ -1310,12 +1155,12 @@ module Doing
1310
1155
  end
1311
1156
  end
1312
1157
 
1313
- log_change(tags_added: added, tags_removed: removed, item: item, single: items.count == 1)
1158
+ logger.log_change(tags_added: added, tags_removed: removed, item: item, single: items.count == 1)
1314
1159
 
1315
1160
  item.note.add(opt[:note]) if opt[:note]
1316
1161
 
1317
1162
  if opt[:archive] && opt[:section] != 'Archive' && (opt[:count]).positive?
1318
- move_item(item, 'Archive', label: true)
1163
+ item.move_to('Archive', label: true)
1319
1164
  elsif opt[:archive] && opt[:count].zero?
1320
1165
  logger.warn('Skipped:', 'Archiving is skipped when operating on all entries')
1321
1166
  end
@@ -1324,29 +1169,6 @@ module Doing
1324
1169
  write(@doing_file)
1325
1170
  end
1326
1171
 
1327
- ##
1328
- ## Move item from current section to
1329
- ## destination section
1330
- ##
1331
- ## @param item [Item] The item to move
1332
- ## @param section [String] The destination section
1333
- ##
1334
- ## @return [Item] Updated item
1335
- ##
1336
- def move_item(item, section, label: true)
1337
- from = item.section
1338
- new_item = @content[item.section][:items].delete(item)
1339
- new_item.title.sub!(/(?:@from\(.*?\))?(.*)$/, "\\1 @from(#{from})") if label
1340
- new_item.section = section
1341
-
1342
- @content[section][:items].concat([new_item])
1343
-
1344
- logger.count(section == 'Archive' ? :archived : :moved)
1345
- logger.debug("#{section == 'Archive' ? 'Archived' : 'Moved'}:",
1346
- "#{new_item.title.truncate(60)} from #{from} to #{section}")
1347
- new_item
1348
- end
1349
-
1350
1172
  ##
1351
1173
  ## Get next item in the index
1352
1174
  ##
@@ -1357,49 +1179,13 @@ module Doing
1357
1179
  ## @return [Item] the next chronological item in the index
1358
1180
  ##
1359
1181
  def next_item(item, options = {})
1360
- items = filter_items([], opt: options)
1182
+ items = filter_items(Items.new, opt: options)
1361
1183
 
1362
1184
  idx = items.index(item)
1363
1185
 
1364
1186
  idx.positive? ? items[idx - 1] : nil
1365
1187
  end
1366
1188
 
1367
- ##
1368
- ## Delete an item from the index
1369
- ##
1370
- ## @param item The item
1371
- ##
1372
- def delete_item(item, single: false)
1373
- section = item.section
1374
-
1375
- section_items = @content[section][:items]
1376
- deleted = section_items.delete(item)
1377
- logger.count(:deleted)
1378
- logger.info('Entry deleted:', deleted.title) if single
1379
- end
1380
-
1381
- ##
1382
- ## Update an item in the index with a modified item
1383
- ##
1384
- ## @param old_item The old item
1385
- ## @param new_item The new item
1386
- ##
1387
- def update_item(old_item, new_item)
1388
- section = old_item.section
1389
-
1390
- section_items = @content[section][:items]
1391
- s_idx = section_items.index { |item| item.equal?(old_item) }
1392
-
1393
- raise ItemNotFound, 'Unable to find item in index, did it mutate?' unless s_idx
1394
-
1395
- return if section_items[s_idx].equal?(new_item)
1396
-
1397
- section_items[s_idx] = new_item
1398
- logger.count(:updated)
1399
- logger.info('Entry updated:', section_items[s_idx].title.truncate(60))
1400
- new_item
1401
- end
1402
-
1403
1189
  ##
1404
1190
  ## Edit the last entry
1405
1191
  ##
@@ -1415,16 +1201,18 @@ module Doing
1415
1201
  return
1416
1202
  end
1417
1203
 
1418
- content = [item.title.dup]
1419
- content << item.note.to_s unless item.note.empty?
1204
+ content = ["#{item.date.strftime('%F %R')} | #{item.title.dup}"]
1205
+ content << item.note.strip_lines.join("\n") unless item.note.empty?
1420
1206
  new_item = fork_editor(content.join("\n"))
1421
- title, note = format_input(new_item)
1207
+ date, title, note = format_input(new_item)
1208
+ date ||= item.date
1422
1209
 
1423
1210
  if title.nil? || title.empty?
1424
1211
  logger.debug('Skipped:', 'No content provided')
1425
- elsif title == item.title && note.equal?(item.note)
1212
+ elsif title == item.title && note.equal?(item.note) && date.equal?(item.date)
1426
1213
  logger.debug('Skipped:', 'No change in content')
1427
1214
  else
1215
+ item.date = date unless date.nil?
1428
1216
  item.title = title
1429
1217
  item.note.add(note, replace: true)
1430
1218
  logger.info('Edited:', item.title)
@@ -1443,6 +1231,11 @@ module Doing
1443
1231
  ## @param target_tag [String] Tag to replace
1444
1232
  ## @param opt [Hash] Additional Options
1445
1233
  ##
1234
+ ## @option opt :section [String] target section
1235
+ ## @option opt :archive [Boolean] archive old item
1236
+ ## @option opt :back [Date] backdate new item
1237
+ ## @option opt :new_item [String] content to use for new item
1238
+ ## @option opt :note [Array] note content for new item
1446
1239
  def stop_start(target_tag, opt = {})
1447
1240
  tag = target_tag.dup
1448
1241
  opt[:section] ||= @config['current_section']
@@ -1457,7 +1250,9 @@ module Doing
1457
1250
 
1458
1251
  found_items = 0
1459
1252
 
1460
- @content[opt[:section]][:items].each_with_index do |item, i|
1253
+ @content.each_with_index do |item, i|
1254
+ next unless item.section == opt[:section] || opt[:section] =~ /all/i
1255
+
1461
1256
  next unless item.title =~ /@#{tag}/
1462
1257
 
1463
1258
  item.title.add_tags!([tag, 'done'], remove: true)
@@ -1467,7 +1262,7 @@ module Doing
1467
1262
 
1468
1263
  if opt[:archive] && opt[:section] != 'Archive'
1469
1264
  item.title = item.title.sub(/(?:@from\(.*?\))?(.*)$/, "\\1 @from(#{item.section})")
1470
- move_item(item, 'Archive', label: false)
1265
+ item.move_to('Archive', label: false, log: false)
1471
1266
  logger.count(:completed_archived)
1472
1267
  logger.info('Completed/archived:', item.title)
1473
1268
  else
@@ -1479,7 +1274,8 @@ module Doing
1479
1274
  logger.debug('Skipped:', "No active @#{tag} tasks found.") if found_items.zero?
1480
1275
 
1481
1276
  if opt[:new_item]
1482
- title, note = format_input(opt[:new_item])
1277
+ date, title, note = format_input(opt[:new_item])
1278
+ opt[:back] = date unless date.nil?
1483
1279
  note.add(opt[:note]) if opt[:note]
1484
1280
  title.tag!(tag)
1485
1281
  add_item(title.cap_first, opt[:section], { note: note, back: opt[:back] })
@@ -1496,7 +1292,6 @@ module Doing
1496
1292
  def write(file = nil, backup: true)
1497
1293
  Hooks.trigger :pre_write, self, file
1498
1294
  output = combined_content
1499
-
1500
1295
  if file.nil?
1501
1296
  $stdout.puts output
1502
1297
  else
@@ -1513,7 +1308,7 @@ module Doing
1513
1308
  def restore_backup(file)
1514
1309
  if File.exist?("#{file}~")
1515
1310
  FileUtils.cp("#{file}~", file)
1516
- logger.warn('File update:', "Restored #{file.sub(/^#{@user_home}/, '~')}")
1311
+ logger.warn('File update:', "Restored #{file.sub(/^#{Util.user_home}/, '~')}")
1517
1312
  else
1518
1313
  logger.error('Restore error:', 'No backup file found')
1519
1314
  end
@@ -1529,70 +1324,46 @@ module Doing
1529
1324
  bool = opt[:bool] || :and
1530
1325
  sect = opt[:section] !~ /^all$/i ? guess_section(opt[:section]) : 'all'
1531
1326
 
1532
- if sect =~ /^all$/i
1533
- all_sections = sections.dup
1534
- else
1535
- all_sections = [sect]
1536
- end
1537
-
1538
- counter = 0
1539
- new_content = {}
1540
-
1541
-
1542
- all_sections.each do |section|
1543
- items = @content[section][:items].dup
1544
- new_content[section] = {}
1545
- new_content[section][:original] = @content[section][:original]
1546
- new_content[section][:items] = []
1547
-
1548
- moved_items = []
1549
- if !tags.empty? || opt[:search] || opt[:before]
1550
- if opt[:before]
1551
- time_string = opt[:before]
1552
- cutoff = chronify(time_string, guess: :begin)
1553
- end
1327
+ section = guess_section(sect)
1554
1328
 
1555
- items.delete_if do |item|
1556
- if ((!tags.empty? && item.tags?(tags, bool)) || (opt[:search] && item.search(opt[:search].to_s)) || (opt[:before] && item.date < cutoff))
1557
- moved_items.push(item)
1558
- counter += 1
1559
- true
1560
- else
1561
- false
1562
- end
1563
- end
1564
- @content[section][:items] = items
1565
- new_content[section][:items] = moved_items
1566
- logger.warn('Rotated:', "#{moved_items.length} items from #{section}")
1567
- else
1568
- new_content[section][:items] = []
1569
- moved_items = []
1329
+ section_items = @content.in_section(section)
1330
+ max = section_items.count - keep.to_i
1570
1331
 
1571
- count = items.length < keep ? items.length : keep
1332
+ counter = 0
1333
+ new_content = Items.new
1572
1334
 
1573
- if items.count > count
1574
- moved_items.concat(items[count..-1])
1575
- else
1576
- moved_items.concat(items)
1577
- end
1335
+ @content.each do |item|
1336
+ break if counter >= max
1337
+ if opt[:before]
1338
+ time_string = opt[:before]
1339
+ cutoff = chronify(time_string, guess: :begin)
1340
+ end
1578
1341
 
1579
- @content[section][:items] = if count.zero?
1580
- []
1581
- else
1582
- items[0..count - 1]
1583
- end
1584
- new_content[section][:items] = moved_items
1342
+ unless ((!tags.empty? && !item.tags?(tags, bool)) || (opt[:search] && !item.search(opt[:search].to_s)) || (opt[:before] && item.date >= cutoff))
1343
+ new_item = @content.delete(item)
1344
+ raise DoingRuntimeError, "Error deleting item: #{item}" if new_item.nil?
1585
1345
 
1586
- logger.warn('Rotated:', "#{items.length - count} items from #{section}")
1346
+ new_content.add_section(new_item.section, log: false)
1347
+ new_content.push(new_item)
1348
+ counter += 1
1587
1349
  end
1588
1350
  end
1589
1351
 
1352
+ if counter.positive?
1353
+ logger.count(:rotated,
1354
+ level: :info,
1355
+ count: counter,
1356
+ message: "Rotated %count %items")
1357
+ else
1358
+ logger.info('Skipped:', 'No items were rotated')
1359
+ end
1360
+
1590
1361
  write(@doing_file)
1591
1362
 
1592
1363
  file = @doing_file.sub(/(\.\w+)$/, "_#{Time.now.strftime('%Y-%m-%d')}\\1")
1593
1364
  if File.exist?(file)
1594
1365
  init_doing_file(file)
1595
- @content.deep_merge(new_content)
1366
+ @content.concat(new_content).uniq!
1596
1367
  logger.warn('File update:', "added entries to existing file: #{file}")
1597
1368
  else
1598
1369
  @content = new_content
@@ -1608,7 +1379,7 @@ module Doing
1608
1379
  ## @return [String] The selected section name
1609
1380
  ##
1610
1381
  def choose_section
1611
- choice = choose_from(sections.sort, prompt: 'Choose a section > ', fzf_args: ['--height=60%'])
1382
+ choice = Prompt.choose_from(@content.section_titles.sort, prompt: 'Choose a section > ', fzf_args: ['--height=60%'])
1612
1383
  choice ? choice.strip : choice
1613
1384
  end
1614
1385
 
@@ -1627,7 +1398,7 @@ module Doing
1627
1398
  ## @return [String] The selected view name
1628
1399
  ##
1629
1400
  def choose_view
1630
- choice = choose_from(views.sort, prompt: 'Choose a view > ', fzf_args: ['--height=60%'])
1401
+ choice = Prompt.choose_from(views.sort, prompt: 'Choose a view > ', fzf_args: ['--height=60%'])
1631
1402
  choice ? choice.strip : choice
1632
1403
  end
1633
1404
 
@@ -1685,7 +1456,7 @@ module Doing
1685
1456
  end
1686
1457
  end
1687
1458
 
1688
- items = filter_items([], opt: opt).reverse
1459
+ items = filter_items(Items.new, opt: opt).reverse
1689
1460
 
1690
1461
  items.reverse! if opt[:order] =~ /^d/i
1691
1462
 
@@ -1693,7 +1464,7 @@ module Doing
1693
1464
  opt[:menu] = !opt[:force]
1694
1465
  opt[:query] = '' # opt[:search]
1695
1466
  opt[:multiple] = true
1696
- selected = choose_from_items(items, opt, include_section: opt[:section] =~ /^all$/i )
1467
+ selected = Prompt.choose_from_items(items, include_section: opt[:section] =~ /^all$/i, **opt)
1697
1468
 
1698
1469
  raise NoResults, 'no items selected' if selected.empty?
1699
1470
 
@@ -1701,11 +1472,8 @@ module Doing
1701
1472
  return
1702
1473
  end
1703
1474
 
1704
-
1705
1475
  opt[:output] ||= 'template'
1706
-
1707
1476
  opt[:wrap_width] ||= @config['templates']['default']['wrap_width'] || 0
1708
-
1709
1477
  output(items, title, is_single, opt)
1710
1478
  end
1711
1479
 
@@ -1726,11 +1494,12 @@ module Doing
1726
1494
  archive_all = section =~ /^all$/i # && !(tags.nil? || tags.empty?)
1727
1495
  section = guess_section(section) unless archive_all
1728
1496
 
1729
- add_section('Archive') if destination =~ /^archive$/i && !sections.include?('Archive')
1497
+ @content.add_section(destination, log: true)
1498
+ # add_section(Section.new('Archive')) if destination =~ /^archive$/i && !@content.section?('Archive')
1730
1499
 
1731
1500
  destination = guess_section(destination)
1732
1501
 
1733
- if sections.include?(destination) && (sections.include?(section) || archive_all)
1502
+ if @content.section?(destination) && (@content.section?(section) || archive_all)
1734
1503
  do_archive(section, destination, { count: count, tags: tags, bool: bool, search: options[:search], label: options[:label], before: options[:before] })
1735
1504
  write(doing_file)
1736
1505
  else
@@ -1970,7 +1739,6 @@ module Doing
1970
1739
  end
1971
1740
  end
1972
1741
 
1973
-
1974
1742
  logger.debug('Autotag:', "whitelisted tags: #{tagged[:whitelisted].log_tags}") unless tagged[:whitelisted].empty?
1975
1743
  logger.debug('Autotag:', "synonyms: #{tagged[:synonyms].log_tags}") unless tagged[:synonyms].empty?
1976
1744
  logger.debug('Autotag:', "transforms: #{tagged[:transformed].log_tags}") unless tagged[:transformed].empty?
@@ -1983,10 +1751,10 @@ module Doing
1983
1751
  text.add_tags!(tail_tags) unless tail_tags.empty?
1984
1752
 
1985
1753
  if text == original
1986
- logger.debug('Autotag:', "no change to \"#{text}\"")
1754
+ logger.debug('Autotag:', "no change to \"#{text.strip}\"")
1987
1755
  else
1988
1756
  new_tags = tagged[:whitelisted].concat(tail_tags).concat(tagged[:replaced])
1989
- logger.debug('Autotag:', "added #{new_tags.log_tags} to \"#{text}\"")
1757
+ logger.debug('Autotag:', "added #{new_tags.log_tags} to \"#{text.strip}\"")
1990
1758
  logger.count(:autotag, level: :info, count: 1, message: 'autotag updated %count %items')
1991
1759
  end
1992
1760
 
@@ -2163,7 +1931,7 @@ EOS
2163
1931
  def format_time(seconds, human: false)
2164
1932
  return [0, 0, 0] if seconds.nil?
2165
1933
 
2166
- if seconds.class == String && seconds =~ /(\d+):(\d+):(\d+)/
1934
+ if seconds.instance_of?(String) && seconds =~ /(\d+):(\d+):(\d+)/
2167
1935
  h = Regexp.last_match(1)
2168
1936
  m = Regexp.last_match(2)
2169
1937
  s = Regexp.last_match(3)
@@ -2192,13 +1960,13 @@ EOS
2192
1960
  ##
2193
1961
  def combined_content
2194
1962
  output = @other_content_top ? "#{@other_content_top.join("\n")}\n" : ''
2195
-
2196
- @content.each do |title, section|
2197
- output += "#{section[:original]}\n"
2198
- output += list_section({ section: title, template: "\t- %date | %title%t2note", highlight: false, wrap_width: 0, tags_color: false })
2199
- end
2200
-
1963
+ was_color = Color.coloring?
1964
+ Color.coloring = false
1965
+ output += @content.to_s
2201
1966
  output += @other_content_bottom.join("\n") unless @other_content_bottom.nil?
1967
+ # Just strip all ANSI colors from the content before writing to doing file
1968
+ Color.coloring = was_color
1969
+
2202
1970
  output.uncolor
2203
1971
  end
2204
1972
 
@@ -2253,99 +2021,50 @@ EOS
2253
2021
  ##
2254
2022
  ## Helper function, performs the actual archiving
2255
2023
  ##
2256
- ## @param sect [String] The source section
2024
+ ## @param section [String] The source section
2257
2025
  ## @param destination [String] The destination
2258
2026
  ## section
2259
2027
  ## @param opt [Hash] Additional Options
2260
2028
  ##
2261
- def do_archive(sect, destination, opt = {})
2029
+ def do_archive(section, destination, opt = {})
2262
2030
  count = opt[:count] || 0
2263
2031
  tags = opt[:tags] || []
2264
2032
  bool = opt[:bool] || :and
2265
2033
  label = opt[:label] || true
2266
2034
 
2267
- if sect =~ /^all$/i
2268
- all_sections = sections.dup
2269
- all_sections.delete(destination)
2270
- else
2271
- all_sections = [sect]
2272
- end
2273
-
2274
- counter = 0
2035
+ section = guess_section(section)
2036
+ destination = guess_section(destination)
2275
2037
 
2276
- all_sections.each do |section|
2277
- items = @content[section][:items].dup
2038
+ section_items = @content.in_section(section)
2039
+ max = section_items.count - count.to_i
2278
2040
 
2279
- moved_items = []
2280
- if !tags.empty? || opt[:search] || opt[:before]
2281
- if opt[:before]
2282
- time_string = opt[:before]
2283
- cutoff = chronify(time_string, guess: :begin)
2284
- end
2041
+ counter = 0
2285
2042
 
2286
- items.delete_if do |item|
2287
- if ((!tags.empty? && item.tags?(tags, bool)) || (opt[:search] && item.search(opt[:search].to_s)) || (opt[:before] && item.date < cutoff))
2288
- moved_items.push(item)
2289
- counter += 1
2290
- true
2291
- else
2292
- false
2293
- end
2294
- end
2295
- moved_items.each do |item|
2296
- if label
2297
- item.title = if section == @config['current_section']
2298
- item.title.sub(/(?: ?@from\(.*?\))?(.*)$/, '\1')
2299
- else
2300
- item.title.sub(/(?: ?@from\(.*?\))?(.*)$/, "\\1 @from(#{section})")
2301
- end
2302
- logger.debug('Moved:', "#{item.title} from #{section} to #{destination}")
2303
- end
2304
- end
2043
+ @content.map! do |item|
2044
+ break if counter >= max
2045
+ if opt[:before]
2046
+ time_string = opt[:before]
2047
+ cutoff = chronify(time_string, guess: :begin)
2048
+ end
2305
2049
 
2306
- @content[section][:items] = items
2307
- @content[destination][:items].concat(moved_items)
2308
- if moved_items.length.positive?
2309
- logger.count(destination == 'Archive' ? :archived : :moved,
2310
- level: :info,
2311
- count: moved_items.length,
2312
- message: "%count %items from #{section} to #{destination}")
2313
- else
2314
- logger.info('Skipped:', 'No items were moved')
2315
- end
2050
+ if (item.section.downcase != section.downcase && section != /^all$/i) || item.section.downcase == destination.downcase
2051
+ item
2052
+ elsif ((!tags.empty? && !item.tags?(tags, bool)) || (opt[:search] && !item.search(opt[:search].to_s)) || (opt[:before] && item.date >= cutoff))
2053
+ item
2316
2054
  else
2317
- count = items.length if items.length < count
2318
-
2319
- items.map! do |item|
2320
- if label
2321
- item.title = if section == @config['current_section']
2322
- item.title.sub(/(?: ?@from\(.*?\))?(.*)$/, '\1')
2323
- else
2324
- item.title.sub(/(?: ?@from\(.*?\))?(.*)$/, "\\1 @from(#{section})")
2325
- end
2326
- logger.debug('Moved:', "#{item.title} from #{section} to #{destination}")
2327
- end
2328
- item
2329
- end
2330
-
2331
- if items.count > count
2332
- @content[destination][:items].concat(items[count..-1])
2333
- else
2334
- @content[destination][:items].concat(items)
2335
- end
2336
-
2337
- @content[section][:items] = if count.zero?
2338
- []
2339
- else
2340
- items[0..count - 1]
2341
- end
2342
-
2343
- logger.count(destination == 'Archive' ? :archived : :moved,
2344
- level: :info,
2345
- count: items.length - count,
2346
- message: "%count %items from #{section} to #{destination}")
2055
+ counter += 1
2056
+ item.move_to(destination, label: label, log: false)
2347
2057
  end
2348
2058
  end
2059
+
2060
+ if counter.positive?
2061
+ logger.count(destination == 'Archive' ? :archived : :moved,
2062
+ level: :info,
2063
+ count: counter,
2064
+ message: "%count %items from #{section} to #{destination}")
2065
+ else
2066
+ logger.info('Skipped:', 'No items were moved')
2067
+ end
2349
2068
  end
2350
2069
 
2351
2070
  def run_after
@@ -2357,31 +2076,5 @@ EOS
2357
2076
  logger.log_now(:error, 'Script error:', "Error running #{@config['run_after']}")
2358
2077
  logger.log_now(:error, 'STDERR output:', stderr)
2359
2078
  end
2360
-
2361
- def log_change(tags_added: [], tags_removed: [], count: 1, item: nil, single: false)
2362
- if tags_added.empty? && tags_removed.empty?
2363
- logger.count(:skipped, level: :debug, message: '%count %items with no change', count: count)
2364
- else
2365
- if tags_added.empty?
2366
- logger.count(:skipped, level: :debug, message: 'no tags added to %count %items')
2367
- else
2368
- if single && item
2369
- logger.info('Tagged:', %(added #{tags_added.count == 1 ? 'tag' : 'tags'} #{tags_added.map {|t| "@#{t}"}.join(', ')} to #{item.title}))
2370
- else
2371
- logger.count(:added_tags, level: :info, tag: tags_added, message: '%tags added to %count %items')
2372
- end
2373
- end
2374
-
2375
- if tags_removed.empty?
2376
- logger.count(:skipped, level: :debug, message: 'no tags removed from %count %items')
2377
- else
2378
- if single && item
2379
- logger.info('Untagged:', %(removed #{tags_removed.count == 1 ? 'tag' : 'tags'} #{tags_added.map {|t| "@#{t}"}.join(', ')} from #{item.title}))
2380
- else
2381
- logger.count(:removed_tags, level: :info, tag: tags_removed, message: '%tags removed from %count %items')
2382
- end
2383
- end
2384
- end
2385
- end
2386
2079
  end
2387
2080
  end