doing 2.0.25 → 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 (81) 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 +28 -0
  6. data/Gemfile.lock +8 -1
  7. data/README.md +1 -1
  8. data/Rakefile +23 -4
  9. data/bin/doing +205 -127
  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 +40 -12
  53. data/example_plugin.rb +3 -3
  54. data/lib/completion/_doing.zsh +1 -1
  55. data/lib/completion/doing.bash +8 -8
  56. data/lib/completion/doing.fish +1 -1
  57. data/lib/doing/array.rb +36 -0
  58. data/lib/doing/colors.rb +70 -66
  59. data/lib/doing/completion.rb +6 -0
  60. data/lib/doing/configuration.rb +69 -28
  61. data/lib/doing/hash.rb +37 -0
  62. data/lib/doing/item.rb +77 -12
  63. data/lib/doing/items.rb +125 -0
  64. data/lib/doing/log_adapter.rb +55 -3
  65. data/lib/doing/note.rb +53 -1
  66. data/lib/doing/pager.rb +49 -38
  67. data/lib/doing/plugins/export/markdown_export.rb +4 -4
  68. data/lib/doing/plugins/export/template_export.rb +2 -2
  69. data/lib/doing/plugins/import/calendar_import.rb +4 -4
  70. data/lib/doing/plugins/import/doing_import.rb +5 -7
  71. data/lib/doing/plugins/import/timing_import.rb +3 -3
  72. data/lib/doing/prompt.rb +206 -0
  73. data/lib/doing/section.rb +30 -0
  74. data/lib/doing/string.rb +103 -27
  75. data/lib/doing/util.rb +14 -6
  76. data/lib/doing/version.rb +1 -1
  77. data/lib/doing/wwid.rb +306 -621
  78. data/lib/doing.rb +6 -2
  79. data/lib/examples/plugins/capture_thing_import.rb +162 -0
  80. metadata +73 -5
  81. 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,7 +25,7 @@ module Doing
24
25
  def initialize
25
26
  @timers = {}
26
27
  @recorded_items = []
27
- @content = {}
28
+ @content = Items.new
28
29
  @auto_tag = true
29
30
  end
30
31
 
@@ -53,52 +54,56 @@ module Doing
53
54
  create(@doing_file) unless File.exist?(@doing_file)
54
55
  input = IO.read(@doing_file)
55
56
  input = input.force_encoding('utf-8') if input.respond_to? :force_encoding
57
+ logger.debug('Read:', "read file #{@doing_file}")
56
58
  elsif File.exist?(File.expand_path(path)) && File.file?(File.expand_path(path)) && File.stat(File.expand_path(path)).size.positive?
57
59
  @doing_file = File.expand_path(path)
58
60
  input = IO.read(File.expand_path(path))
59
61
  input = input.force_encoding('utf-8') if input.respond_to? :force_encoding
62
+ logger.debug('Read:', "read file #{File.expand_path(path)}")
60
63
  elsif path.length < 256
61
64
  @doing_file = File.expand_path(path)
62
65
  create(path)
63
66
  input = IO.read(File.expand_path(path))
64
67
  input = input.force_encoding('utf-8') if input.respond_to? :force_encoding
68
+ logger.debug('Read:', "read file #{File.expand_path(path)}")
65
69
  end
66
70
 
67
71
  @other_content_top = []
68
72
  @other_content_bottom = []
69
73
 
70
- section = 'Uncategorized'
74
+ section = nil
71
75
  lines = input.split(/[\n\r]/)
72
- current = 0
73
76
 
74
77
  lines.each do |line|
75
78
  next if line =~ /^\s*$/
76
79
 
77
80
  if line =~ /^(\S[\S ]+):\s*(@\S+\s*)*$/
78
81
  section = Regexp.last_match(1)
79
- @content[section] = {}
80
- @content[section][:original] = line
81
- @content[section][:items] = []
82
- current = 0
82
+ @content.add_section(Section.new(section, original: line), log: false)
83
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
+
84
89
  date = Regexp.last_match(1).strip
85
90
  title = Regexp.last_match(2).strip
86
91
  item = Item.new(date, title, section)
87
- @content[section][:items].push(item)
88
- current += 1
89
- elsif current.zero?
90
- # if content[section][:items].length - 1 == current
92
+ @content.push(item)
93
+ elsif @content.count.zero?
94
+ # if content[section].items.length - 1 == current
91
95
  @other_content_top.push(line)
92
96
  elsif line =~ /^\S/
93
97
  @other_content_bottom.push(line)
94
98
  else
95
- prev_item = @content[section][:items][current - 1]
99
+ prev_item = @content.last
96
100
  prev_item.note = Note.new unless prev_item.note
97
101
 
98
102
  prev_item.note.add(line)
99
103
  # end
100
104
  end
101
105
  end
106
+
102
107
  Hooks.trigger :post_read, self
103
108
  end
104
109
 
@@ -119,7 +124,7 @@ module Doing
119
124
  ##
120
125
  ## @param input [String] Text input for editor
121
126
  ##
122
- def fork_editor(input = '')
127
+ def fork_editor(input = '', message: :default)
123
128
  # raise NonInteractive, 'Non-interactive terminal' unless $stdout.isatty || ENV['DOING_EDITOR_TEST']
124
129
 
125
130
  raise MissingEditor, 'No EDITOR variable defined in environment' if Util.default_editor.nil?
@@ -128,7 +133,9 @@ module Doing
128
133
 
129
134
  File.open(tmpfile.path, 'w+') do |f|
130
135
  f.puts input
131
- 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
132
139
  end
133
140
 
134
141
  pid = Process.fork { system("#{Util.editor_with_args} #{tmpfile.path}") }
@@ -174,13 +181,37 @@ module Doing
174
181
  title = input_lines[0]&.strip
175
182
  raise EmptyInput, 'No content in first line' if title.nil? || title.strip.empty?
176
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
+
177
208
  note = Note.new
178
209
  note.add(input_lines[1..-1]) if input_lines.length > 1
179
210
  # If title line ends in a parenthetical, use that as the note
180
211
  if note.empty? && title =~ /\s+\(.*?\)$/
181
- title.sub!(/\s+\((.*?)\)$/) do
212
+ title.sub!(/\s+\((?<note>.*?)\)$/) do
182
213
  m = Regexp.last_match
183
- note.add(m[1])
214
+ note.add(m['note'])
184
215
  ''
185
216
  end
186
217
  end
@@ -188,7 +219,7 @@ module Doing
188
219
  note.strip_lines!
189
220
  note.compress
190
221
 
191
- [title, note]
222
+ [date, title, note]
192
223
  end
193
224
 
194
225
  ##
@@ -262,21 +293,7 @@ module Doing
262
293
  ## @return [Array] section titles
263
294
  ##
264
295
  def sections
265
- @content.keys
266
- end
267
-
268
- ##
269
- ## Adds a section.
270
- ##
271
- ## @param title [String] The new section title
272
- ##
273
- def add_section(title)
274
- if @content.key?(title.cap_first)
275
- raise InvalidSection, %(section "#{title.cap_first}" already exists)
276
- end
277
-
278
- @content[title.cap_first] = { :original => "#{title}:", :items => [] }
279
- logger.info('New section:', %("#{title.cap_first}"))
296
+ @content.section_titles
280
297
  end
281
298
 
282
299
  ##
@@ -289,8 +306,9 @@ module Doing
289
306
  return 'All' if frag =~ /^all$/i
290
307
  frag ||= @config['current_section']
291
308
 
292
- sections.each { |sect| return sect.cap_first if frag.downcase == sect.downcase }
293
- section = false
309
+ return frag.cap_first if @content.section?(frag)
310
+
311
+ section = nil
294
312
  re = frag.split('').join('.*?')
295
313
  sections.each do |sect|
296
314
  next unless sect =~ /#{re}/i
@@ -305,79 +323,25 @@ module Doing
305
323
  unless section || guessed
306
324
  alt = guess_view(frag, guessed: true, suggest: true)
307
325
  if alt
308
- 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')
309
327
 
310
328
  raise WrongCommand.new("run again with #{"doing view #{alt}".boldwhite}", topic: 'Try again:') if meant_view
311
329
 
312
330
  end
313
331
 
314
- 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')
315
333
 
316
334
  if res
317
- add_section(frag.cap_first)
335
+ @content.add_section(frag.cap_first, log: true)
318
336
  write(@doing_file)
319
337
  return frag.cap_first
320
338
  end
321
339
 
322
- raise InvalidSection.new("unknown section #{frag.yellow}", topic: 'Missing:')
340
+ raise InvalidSection.new("unknown section #{frag.bold.white}", topic: 'Missing:')
323
341
  end
324
342
  section ? section.cap_first : guessed
325
343
  end
326
344
 
327
- ##
328
- ## Ask a yes or no question in the terminal
329
- ##
330
- ## @param question [String] The question
331
- ## to ask
332
- ## @param default_response (Bool) default
333
- ## response if no input
334
- ##
335
- ## @return (Bool) yes or no
336
- ##
337
- def yn(question, default_response: false)
338
- if default_response.is_a?(String)
339
- default = default_response =~ /y/i ? true : false
340
- else
341
- default = default_response
342
- end
343
-
344
- # if global --default is set, answer default
345
- return default if @default_option
346
-
347
- # if this isn't an interactive shell, answer default
348
- return default unless $stdout.isatty
349
-
350
- # clear the buffer
351
- if ARGV&.length
352
- ARGV.length.times do
353
- ARGV.shift
354
- end
355
- end
356
- system 'stty cbreak'
357
-
358
- cw = Color.white
359
- cbw = Color.boldwhite
360
- cbg = Color.boldgreen
361
- cd = Color.default
362
-
363
- options = unless default.nil?
364
- "#{cw}[#{default ? "#{cbg}Y#{cw}/#{cbw}n" : "#{cbw}y#{cw}/#{cbg}N"}#{cw}]#{cd}"
365
- else
366
- "#{cw}[#{cbw}y#{cw}/#{cbw}n#{cw}]#{cd}"
367
- end
368
- $stdout.syswrite "#{cbw}#{question.sub(/\?$/, '')} #{options}#{cbw}?#{cd} "
369
- res = $stdin.sysread 1
370
- puts
371
- system 'stty cooked'
372
-
373
- res.chomp!
374
- res.downcase!
375
-
376
- return default if res.empty?
377
-
378
- res =~ /y/i ? true : false
379
- end
380
-
381
345
  ##
382
346
  ## Attempt to match a string with an existing view
383
347
  ##
@@ -397,11 +361,14 @@ module Doing
397
361
  end
398
362
  unless view || guessed
399
363
  alt = guess_section(frag, guessed: true, suggest: true)
400
- 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')
401
368
 
402
369
  raise WrongCommand.new("run again with #{"doing show #{alt}".yellow}", topic: 'Try again:') if meant_view
403
370
 
404
- raise InvalidView.new(%(unkown view #{alt.yellow}), topic: 'Missing:')
371
+ raise InvalidView.new(%(unknown view #{alt.bold.white}), topic: 'Missing:')
405
372
  end
406
373
  view
407
374
  end
@@ -411,17 +378,22 @@ module Doing
411
378
  ##
412
379
  ## @param title [String] The entry title
413
380
  ## @param section [String] The section to add to
414
- ## @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
415
387
  ##
416
388
  def add_item(title, section = nil, opt = {})
417
389
  section ||= @config['current_section']
418
- add_section(section) unless @content.key?(section)
390
+ @content.add_section(section, log: false)
391
+ opt[:back] ||= opt[:date] ? opt[:date] : Time.now
419
392
  opt[:date] ||= Time.now
420
- opt[:note] ||= []
421
- opt[:back] ||= Time.now
393
+ note = Note.new
422
394
  opt[:timed] ||= false
423
395
 
424
- opt[:note] = opt[:note].lines if opt[:note].is_a?(String)
396
+ note.add(opt[:note]) if opt[:note]
425
397
 
426
398
  title = [title.strip.cap_first]
427
399
  title = title.join(' ')
@@ -431,10 +403,11 @@ module Doing
431
403
  title.add_tags!(@config['default_tags']) unless @config['default_tags'].empty?
432
404
  end
433
405
 
434
- title.gsub!(/ +/, ' ')
406
+ title.compress!
435
407
  entry = Item.new(opt[:back], title.strip, section)
436
- entry.note = opt[:note].map(&:chomp) unless opt[:note].join('').strip == ''
437
- items = @content[section][:items]
408
+ entry.note = note
409
+
410
+ items = @content.dup
438
411
  if opt[:timed]
439
412
  items.reverse!
440
413
  items.each_with_index do |i, x|
@@ -443,10 +416,9 @@ module Doing
443
416
  items[x].title = "#{i.title} @done(#{opt[:back].strftime('%F %R')})"
444
417
  break
445
418
  end
446
- items.reverse!
447
419
  end
448
420
 
449
- items.push(entry)
421
+ @content.push(entry)
450
422
  # logger.count(:added, level: :debug)
451
423
  logger.info('New entry:', %(added "#{entry.title}" to #{section}))
452
424
  end
@@ -457,16 +429,10 @@ module Doing
457
429
  ## @param items [Array] The items to deduplicate
458
430
  ## @param no_overlap [Boolean] Remove items with overlapping time spans
459
431
  ##
460
- def dedup(items, no_overlap = false)
461
-
462
- combined = []
463
- @content.each do |_k, v|
464
- combined += v[:items]
465
- end
466
-
432
+ def dedup(items, no_overlap: false)
467
433
  items.delete_if do |item|
468
434
  duped = false
469
- combined.each do |comp|
435
+ @content.each do |comp|
470
436
  duped = no_overlap ? item.overlapping_time?(comp) : item.same_time?(comp)
471
437
  break if duped
472
438
  end
@@ -516,17 +482,32 @@ module Doing
516
482
  "#{last_item.title}\n# EDIT BELOW THIS LINE ------------\n#{note}"
517
483
  end
518
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
+ #
519
491
  def reset_item(item, resume: false)
520
492
  item.date = Time.now
521
- if resume
522
- item.tag('done', remove: true)
523
- end
493
+ item.tag('done', remove: true) if resume
524
494
  logger.info('Reset:', %(Reset #{resume ? 'and resumed ' : ''} "#{item.title}" in #{item.section}))
525
495
  item
526
496
  end
527
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
+ #
528
510
  def repeat_item(item, opt = {})
529
- original = item.dup
530
511
  if item.should_finish?
531
512
  if item.should_time?
532
513
  item.title.tag!('done', value: Time.now.strftime('%F %R'))
@@ -543,10 +524,13 @@ module Doing
543
524
  note = opt[:note] || Note.new
544
525
 
545
526
  if opt[:editor]
546
- to_edit = title
547
- 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?
548
530
  new_item = fork_editor(to_edit)
549
- title, note = format_input(new_item)
531
+ date, title, note = format_input(new_item)
532
+
533
+ opt[:date] = date unless date.nil?
550
534
 
551
535
  if title.nil? || title.empty?
552
536
  logger.warn('Skipped:', 'No content provided')
@@ -554,9 +538,8 @@ module Doing
554
538
  end
555
539
  end
556
540
 
557
- update_item(original, item)
541
+ # @content.update_item(original, item)
558
542
  add_item(title, section, { note: note, back: opt[:date], timed: true })
559
- write(@doing_file)
560
543
  end
561
544
 
562
545
  ##
@@ -566,6 +549,7 @@ module Doing
566
549
  ##
567
550
  def repeat_last(opt = {})
568
551
  opt[:section] ||= 'all'
552
+ opt[:section] = guess_section(opt[:section])
569
553
  opt[:note] ||= []
570
554
  opt[:tag] ||= []
571
555
  opt[:tag_bool] ||= :and
@@ -577,6 +561,7 @@ module Doing
577
561
  end
578
562
 
579
563
  repeat_item(last, opt)
564
+ write(@doing_file)
580
565
  end
581
566
 
582
567
  ##
@@ -588,19 +573,19 @@ module Doing
588
573
  opt[:tag_bool] ||= :and
589
574
  opt[:section] ||= @config['current_section']
590
575
 
591
- items = filter_items([], opt: opt)
576
+ items = filter_items(Items.new, opt: opt)
592
577
 
593
578
  logger.debug('Filtered:', "Parameters matched #{items.count} entries")
594
579
 
595
580
  if opt[:interactive]
596
- last_entry = choose_from_items(items, {
581
+ last_entry = Prompt.choose_from_items(items, include_section: opt[:section] =~ /^all$/i,
597
582
  menu: true,
598
583
  header: '',
599
584
  prompt: 'Select an entry > ',
600
585
  multiple: false,
601
586
  sort: false,
602
587
  show_if_single: true
603
- }, include_section: opt[:section] =~ /^all$/i )
588
+ )
604
589
  else
605
590
  last_entry = items.max_by { |item| item.date }
606
591
  end
@@ -608,53 +593,6 @@ module Doing
608
593
  last_entry
609
594
  end
610
595
 
611
- def fzf
612
- @fzf ||= install_fzf
613
- end
614
-
615
- def install_fzf
616
- fzf_dir = File.join(File.dirname(__FILE__), '../helpers/fzf')
617
- FileUtils.mkdir_p(fzf_dir) unless File.directory?(fzf_dir)
618
- fzf_bin = File.join(fzf_dir, 'bin/fzf')
619
- return fzf_bin if File.exist?(fzf_bin)
620
-
621
- prev_level = Doing.logger.level
622
- Doing.logger.adjust_verbosity({ log_level: :info })
623
- Doing.logger.log_now(:warn, 'Compiling and installing fzf -- this will only happen once')
624
- Doing.logger.log_now(:warn, 'fzf is copyright Junegunn Choi, MIT License <https://github.com/junegunn/fzf/blob/master/LICENSE>')
625
-
626
- system("'#{fzf_dir}/install' --bin --no-key-bindings --no-completion --no-update-rc --no-bash --no-zsh --no-fish &> /dev/null")
627
- unless File.exist?(fzf_bin)
628
- Doing.logger.log_now(:warn, 'Error installing, trying again as root')
629
- system("sudo '#{fzf_dir}/install' --bin --no-key-bindings --no-completion --no-update-rc --no-bash --no-zsh --no-fish &> /dev/null")
630
- end
631
- raise RuntimeError.new('Error installing fzf, please report at https://github.com/ttscoff/doing/issues') unless File.exist?(fzf_bin)
632
-
633
- Doing.logger.info("fzf installed to #{fzf}")
634
- Doing.logger.adjust_verbosity({ log_level: prev_level })
635
- fzf_bin
636
- end
637
-
638
- ##
639
- ## Generate a menu of options and allow user selection
640
- ##
641
- ## @return [String] The selected option
642
- ##
643
- def choose_from(options, prompt: 'Make a selection: ', multiple: false, sorted: true, fzf_args: [])
644
- return nil unless $stdout.isatty
645
-
646
- # fzf_args << '-1' # User is expecting a menu, and even if only one it seves as confirmation
647
- fzf_args << %(--prompt "#{prompt}")
648
- fzf_args << '--multi' if multiple
649
- header = "esc: cancel,#{multiple ? ' tab: multi-select, ctrl-a: select all,' : ''} return: confirm"
650
- fzf_args << %(--header "#{header}")
651
- options.sort! if sorted
652
- res = `echo #{Shellwords.escape(options.join("\n"))}|#{fzf} #{fzf_args.join(' ')}`
653
- return false if res.strip.size.zero?
654
-
655
- res
656
- end
657
-
658
596
  def all_tags(items, opt: {})
659
597
  all_tags = []
660
598
  items.each { |item| all_tags.concat(item.tags).uniq! }
@@ -693,8 +631,8 @@ module Doing
693
631
  end
694
632
  # fzf_args << '-e' if opt[:exact]
695
633
  # puts fzf_args.join(' ')
696
- res = `echo #{Shellwords.escape(scannable)}|#{fzf} #{fzf_args.join(' ')}`
697
- selected = []
634
+ res = `echo #{Shellwords.escape(scannable)}|#{Prompt.fzf} #{fzf_args.join(' ')}`
635
+ selected = Items.new
698
636
  res.split(/\n/).each do |item|
699
637
  idx = item.match(/\|(\d+)$/)[1].to_i
700
638
  selected.push(items[idx])
@@ -722,15 +660,11 @@ module Doing
722
660
  ## @option opt [Number] :count (Number to return)
723
661
  ## @option opt [String] :age ('old' or 'new')
724
662
  ##
725
- def filter_items(items = [], opt: {})
663
+ def filter_items(items = Items.new, opt: {})
726
664
  if items.nil? || items.empty?
727
665
  section = opt[:section] ? guess_section(opt[:section]) : 'All'
728
666
 
729
- items = if section =~ /^all$/i
730
- @content.each_with_object([]) { |(_k, v), arr| arr.concat(v[:items].dup) }
731
- else
732
- @content[section][:items].dup
733
- end
667
+ items = section =~ /^all$/i ? @content.dup : @content.in_section(section)
734
668
  end
735
669
 
736
670
  items.sort_by! { |item| [item.date, item.title.downcase] }.reverse
@@ -805,14 +739,17 @@ module Doing
805
739
 
806
740
  keep
807
741
  end
808
- 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
809
745
 
810
746
  if opt[:age] =~ /^o/i
811
- filtered_items.slice(0, count).reverse
747
+ output.concat(filtered_items.slice(0, count).reverse)
812
748
  else
813
- filtered_items.reverse.slice(0, count)
749
+ output.concat(filtered_items.reverse.slice(0, count))
814
750
  end
815
751
 
752
+ output
816
753
  end
817
754
 
818
755
  ##
@@ -837,94 +774,15 @@ module Doing
837
774
  opt[:query] = "!#{opt[:query]}" if opt[:not]
838
775
  opt[:multiple] = true
839
776
  opt[:show_if_single] = true
840
- 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] })
841
778
 
842
- selection = choose_from_items(items, opt, include_section: section =~ /^all$/i)
779
+ selection = Prompt.choose_from_items(items, include_section: section =~ /^all$/i, **opt)
843
780
 
844
781
  raise NoResults, 'no items selected' if selection.nil? || selection.empty?
845
782
 
846
783
  act_on(selection, opt)
847
784
  end
848
785
 
849
- ##
850
- ## Create an interactive menu to select from a set of Items
851
- ##
852
- ## @param items [Array] list of items
853
- ## @param opt [Hash] options
854
- ## @param include_section [Boolean] include section
855
- ##
856
- ## @option opt [String] :header
857
- ## @option opt [String] :prompt
858
- ## @option opt [String] :query
859
- ## @option opt [Boolean] :show_if_single
860
- ## @option opt [Boolean] :menu
861
- ## @option opt [Boolean] :sort
862
- ## @option opt [Boolean] :multiple
863
- ## @option opt [Symbol] :case (:sensitive, :ignore, :smart)
864
- ##
865
- def choose_from_items(items, opt = {}, include_section: false)
866
- return items unless $stdout.isatty
867
-
868
- return nil unless items.count.positive?
869
-
870
- opt[:case] ||= :smart
871
- opt[:header] ||= "Arrows: navigate, tab: mark for selection, ctrl-a: select all, enter: commit"
872
- opt[:prompt] ||= "Select entries to act on > "
873
-
874
- pad = items.length.to_s.length
875
- options = items.map.with_index do |item, i|
876
- out = [
877
- format("%#{pad}d", i),
878
- ') ',
879
- format('%13s', item.date.relative_date),
880
- ' | ',
881
- item.title
882
- ]
883
- if include_section
884
- out.concat([
885
- ' (',
886
- item.section,
887
- ') '
888
- ])
889
- end
890
- out.join('')
891
- end
892
-
893
- fzf_args = [
894
- %(--header="#{opt[:header]}"),
895
- %(--prompt="#{opt[:prompt].sub(/ *$/, ' ')}"),
896
- opt[:multiple] ? '--multi' : '--no-multi',
897
- '-0',
898
- '--bind ctrl-a:select-all',
899
- %(-q "#{opt[:query]}"),
900
- '--info=inline'
901
- ]
902
- fzf_args.push('-1') unless opt[:show_if_single]
903
- fzf_args << case opt[:case].normalize_case
904
- when :sensitive
905
- '+i'
906
- when :ignore
907
- '-i'
908
- end
909
- fzf_args << '-e' if opt[:exact]
910
-
911
-
912
- unless opt[:menu]
913
- raise InvalidArgument, "Can't skip menu when no query is provided" unless opt[:query] && !opt[:query].empty?
914
-
915
- fzf_args.concat([%(--filter="#{opt[:query]}"), opt[:sort] ? '' : '--no-sort'])
916
- end
917
-
918
- res = `echo #{Shellwords.escape(options.join("\n"))}|#{fzf} #{fzf_args.join(' ')}`
919
- selected = []
920
- res.split(/\n/).each do |item|
921
- idx = item.match(/^ *(\d+)\)/)[1].to_i
922
- selected.push(items[idx])
923
- end
924
-
925
- opt[:multiple] ? selected : selected[0]
926
- end
927
-
928
786
  ##
929
787
  ## Perform actions on a set of entries. If
930
788
  ## no valid action is included in the opt
@@ -974,11 +832,11 @@ module Doing
974
832
 
975
833
  actions.concat(['resume/repeat', 'begin/reset']) if items.count == 1
976
834
 
977
- choice = choose_from(actions,
978
- prompt: 'What do you want to do with the selected items? > ',
979
- multiple: true,
980
- sorted: false,
981
- 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'])
982
840
  return unless choice
983
841
 
984
842
  to_do = choice.strip.split(/\n/)
@@ -992,7 +850,7 @@ module Doing
992
850
  type = action =~ /^add/ ? 'add' : 'remove'
993
851
  raise InvalidArgument, "'add tag' and 'remove tag' can not be used together" if opt[:tag]
994
852
 
995
- print "#{Color.yellow}Tag to #{type}: #{Color.reset}"
853
+ print "#{yellow("Tag to #{type}: ")}#{reset}"
996
854
  tag = $stdin.gets
997
855
  next if tag =~ /^ *$/
998
856
 
@@ -1000,17 +858,22 @@ module Doing
1000
858
  opt[:remove] = true if type == 'remove'
1001
859
  when /output formatted/
1002
860
  plugins = Plugins.available_plugins(type: :export).sort
1003
- output_format = choose_from(plugins,
1004
- prompt: 'Which output format? > ',
1005
- 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
+ ])
1006
869
  next if tag =~ /^ *$/
1007
870
 
1008
871
  raise UserCancelled unless output_format
1009
872
 
1010
873
  opt[:output] = output_format.strip
1011
- res = opt[:force] ? false : yn('Save to file?', default_response: 'n')
874
+ res = opt[:force] ? false : Prompt.yn('Save to file?', default_response: 'n')
1012
875
  if res
1013
- print "#{Color.yellow}File path/name: #{Color.reset}"
876
+ print "#{yellow('File path/name: ')}#{reset}"
1014
877
  filename = $stdin.gets.strip
1015
878
  next if filename.empty?
1016
879
 
@@ -1036,29 +899,28 @@ module Doing
1036
899
  end
1037
900
 
1038
901
  if opt[:resume] || opt[:reset]
1039
- if items.count > 1
1040
- raise InvalidArgument, 'resume and restart can only be used on a single entry'
1041
- else
1042
- item = items[0]
1043
- if opt[:resume] && !opt[:reset]
1044
- repeat_item(item, { editor: opt[:editor] })
1045
- elsif opt[:reset]
1046
- if item.tags?('done', :and) && !opt[:resume]
1047
- res = opt[:force] ? true : yn('Remove @done tag?', default_response: 'y')
1048
- else
1049
- res = opt[:resume]
1050
- end
1051
- update_item(item, reset_item(item, resume: res))
1052
- end
1053
- 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))
1054
914
  end
915
+ write(@doing_file)
916
+
1055
917
  return
1056
918
  end
1057
919
 
1058
920
  if opt[:delete]
1059
- 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')
1060
922
  if res
1061
- items.each { |item| delete_item(item, single: items.count == 1) }
923
+ items.each { |i| @content.delete_item(i, single: items.count == 1) }
1062
924
  write(@doing_file)
1063
925
  end
1064
926
  return
@@ -1066,31 +928,31 @@ module Doing
1066
928
 
1067
929
  if opt[:flag]
1068
930
  tag = @config['marker_tag'] || 'flagged'
1069
- items.map! do |item|
1070
- 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)
1071
933
  end
1072
934
  end
1073
935
 
1074
936
  if opt[:finish] || opt[:cancel]
1075
937
  tag = 'done'
1076
- items.map! do |item|
1077
- if item.should_finish?
1078
- should_date = !opt[:cancel] && item.should_time?
1079
- 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)
1080
942
  end
1081
943
  end
1082
944
  end
1083
945
 
1084
946
  if opt[:tag]
1085
947
  tag = opt[:tag]
1086
- items.map! do |item|
1087
- 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)
1088
950
  end
1089
951
  end
1090
952
 
1091
953
  if opt[:archive] || opt[:move]
1092
954
  section = opt[:archive] ? 'Archive' : guess_section(opt[:move])
1093
- items.map! {|item| move_item(item, section) }
955
+ items.map! { |i| i.move_to(section, label: true) }
1094
956
  end
1095
957
 
1096
958
  write(@doing_file)
@@ -1099,111 +961,88 @@ module Doing
1099
961
 
1100
962
  editable_items = []
1101
963
 
1102
- items.each do |item|
1103
- editable = "#{item.date} | #{item.title}"
1104
- 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
1105
967
  editable += "\n#{old_note}" unless old_note.nil?
1106
968
  editable_items << editable
1107
969
  end
1108
970
  divider = "\n-----------\n"
1109
- 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}"
1110
978
 
1111
979
  new_items = fork_editor(input).split(/#{divider}/)
1112
980
 
1113
981
  new_items.each_with_index do |new_item, i|
1114
-
1115
982
  input_lines = new_item.split(/[\n\r]+/).delete_if(&:ignore?)
1116
- title = input_lines[0]&.strip
983
+ first_line = input_lines[0]&.strip
1117
984
 
1118
- if title.nil? || title =~ /^#{divider.strip}$/ || title.strip.empty?
1119
- 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)
1120
988
  else
1121
- note = input_lines.length > 1 ? input_lines[1..-1] : []
989
+ date, title, note = format_input(new_item)
1122
990
 
1123
991
  note.map!(&:strip)
1124
992
  note.delete_if(&:ignore?)
1125
-
1126
- date = title.match(/^([\d\-: ]+) \| /)[1]
1127
- title.sub!(/^([\d\-: ]+) \| /, '')
1128
-
1129
993
  item = items[i]
994
+ old_item = item.dup
995
+ item.date = date || items[i].date
1130
996
  item.title = title
1131
997
  item.note = note
1132
- 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
1133
1003
  end
1134
1004
  end
1135
1005
 
1136
1006
  write(@doing_file)
1137
1007
  end
1138
1008
 
1139
- if opt[:output]
1140
- items.map! do |item|
1141
- item.title = "#{item.title} @project(#{item.section})"
1142
- item
1143
- end
1144
-
1145
- @content = { 'Export' => { :original => 'Export:', :items => items } }
1146
- options = { section: 'Export' }
1147
-
1148
-
1149
- if opt[:output] =~ /doing/
1150
- options[:output] = 'template'
1151
- options[:template] = '- %date | %title%note'
1152
- else
1153
- options[:output] = opt[:output]
1154
- options[:template] = opt[:template] || nil
1155
- end
1009
+ return unless opt[:output]
1156
1010
 
1157
- output = list_section(options)
1158
-
1159
- if opt[:save_to]
1160
- file = File.expand_path(opt[:save_to])
1161
- if File.exist?(file)
1162
- # Create a backup copy for the undo command
1163
- FileUtils.cp(file, "#{file}~")
1164
- end
1165
-
1166
- File.open(file, 'w+') do |f|
1167
- f.puts output
1168
- end
1169
-
1170
- logger.warn('File written:', file)
1171
- else
1172
- Doing::Pager.page output
1173
- end
1011
+ items.map! do |i|
1012
+ i.title = "#{i.title} @project(#{i.section})"
1013
+ i
1174
1014
  end
1175
- end
1176
1015
 
1177
- ##
1178
- ## Tag an item from the index
1179
- ##
1180
- ## @param item [Item] The item to tag
1181
- ## @param tags [String] The tag to apply
1182
- ## @param remove [Boolean] remove tags?
1183
- ## @param date [Boolean] Include timestamp?
1184
- ## @param single [Boolean] Log as a single change?
1185
- ##
1186
- ## @return [Item] updated item
1187
- ##
1188
- def tag_item(item, tags, remove: false, date: false, single: false)
1189
- added = []
1190
- removed = []
1016
+ @content = Items.new
1017
+ @content.concat(items)
1018
+ @content.add_section(Section.new('Export'), log: false)
1019
+ options = { section: 'Export' }
1191
1020
 
1192
- 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
1193
1028
 
1194
- done_date = Time.now
1029
+ output = list_section(options)
1195
1030
 
1196
- tags.each do |tag|
1197
- bool = remove ? :and : :not
1198
- if item.tags?(tag, bool)
1199
- item.tag(tag, remove: remove, value: date ? done_date.strftime('%F %R') : nil)
1200
- 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}~")
1201
1036
  end
1202
- end
1203
1037
 
1204
- 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
1205
1041
 
1206
- item
1042
+ logger.warn('File written:', file)
1043
+ else
1044
+ Doing::Pager.page output
1045
+ end
1207
1046
  end
1208
1047
 
1209
1048
  ##
@@ -1227,17 +1066,15 @@ module Doing
1227
1066
  opt[:unfinished] ||= false
1228
1067
  opt[:section] = opt[:section] ? guess_section(opt[:section]) : 'All'
1229
1068
 
1230
- items = filter_items([], opt: opt)
1069
+ items = filter_items(Items.new, opt: opt)
1231
1070
 
1232
1071
  if opt[:interactive]
1233
- items = choose_from_items(items, {
1234
- menu: true,
1072
+ items = Prompt.choose_from_items(items, include_section: opt[:section] =~ /^all$/i, menu: true,
1235
1073
  header: '',
1236
1074
  prompt: 'Select entries to tag > ',
1237
1075
  multiple: true,
1238
1076
  sort: true,
1239
- show_if_single: true
1240
- }, include_section: opt[:section] =~ /^all$/i)
1077
+ show_if_single: true)
1241
1078
 
1242
1079
  raise NoResults, 'no items selected' if items.empty?
1243
1080
 
@@ -1318,12 +1155,12 @@ module Doing
1318
1155
  end
1319
1156
  end
1320
1157
 
1321
- 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)
1322
1159
 
1323
1160
  item.note.add(opt[:note]) if opt[:note]
1324
1161
 
1325
1162
  if opt[:archive] && opt[:section] != 'Archive' && (opt[:count]).positive?
1326
- move_item(item, 'Archive', label: true)
1163
+ item.move_to('Archive', label: true)
1327
1164
  elsif opt[:archive] && opt[:count].zero?
1328
1165
  logger.warn('Skipped:', 'Archiving is skipped when operating on all entries')
1329
1166
  end
@@ -1332,29 +1169,6 @@ module Doing
1332
1169
  write(@doing_file)
1333
1170
  end
1334
1171
 
1335
- ##
1336
- ## Move item from current section to
1337
- ## destination section
1338
- ##
1339
- ## @param item [Item] The item to move
1340
- ## @param section [String] The destination section
1341
- ##
1342
- ## @return [Item] Updated item
1343
- ##
1344
- def move_item(item, section, label: true)
1345
- from = item.section
1346
- new_item = @content[item.section][:items].delete(item)
1347
- new_item.title.sub!(/(?:@from\(.*?\))?(.*)$/, "\\1 @from(#{from})") if label
1348
- new_item.section = section
1349
-
1350
- @content[section][:items].concat([new_item])
1351
-
1352
- logger.count(section == 'Archive' ? :archived : :moved)
1353
- logger.debug("#{section == 'Archive' ? 'Archived' : 'Moved'}:",
1354
- "#{new_item.title.truncate(60)} from #{from} to #{section}")
1355
- new_item
1356
- end
1357
-
1358
1172
  ##
1359
1173
  ## Get next item in the index
1360
1174
  ##
@@ -1365,49 +1179,13 @@ module Doing
1365
1179
  ## @return [Item] the next chronological item in the index
1366
1180
  ##
1367
1181
  def next_item(item, options = {})
1368
- items = filter_items([], opt: options)
1182
+ items = filter_items(Items.new, opt: options)
1369
1183
 
1370
1184
  idx = items.index(item)
1371
1185
 
1372
1186
  idx.positive? ? items[idx - 1] : nil
1373
1187
  end
1374
1188
 
1375
- ##
1376
- ## Delete an item from the index
1377
- ##
1378
- ## @param item The item
1379
- ##
1380
- def delete_item(item, single: false)
1381
- section = item.section
1382
-
1383
- section_items = @content[section][:items]
1384
- deleted = section_items.delete(item)
1385
- logger.count(:deleted)
1386
- logger.info('Entry deleted:', deleted.title) if single
1387
- end
1388
-
1389
- ##
1390
- ## Update an item in the index with a modified item
1391
- ##
1392
- ## @param old_item The old item
1393
- ## @param new_item The new item
1394
- ##
1395
- def update_item(old_item, new_item)
1396
- section = old_item.section
1397
-
1398
- section_items = @content[section][:items]
1399
- s_idx = section_items.index { |item| item.equal?(old_item) }
1400
-
1401
- raise ItemNotFound, 'Unable to find item in index, did it mutate?' unless s_idx
1402
-
1403
- return if section_items[s_idx].equal?(new_item)
1404
-
1405
- section_items[s_idx] = new_item
1406
- logger.count(:updated)
1407
- logger.info('Entry updated:', section_items[s_idx].title.truncate(60))
1408
- new_item
1409
- end
1410
-
1411
1189
  ##
1412
1190
  ## Edit the last entry
1413
1191
  ##
@@ -1423,16 +1201,18 @@ module Doing
1423
1201
  return
1424
1202
  end
1425
1203
 
1426
- content = [item.title.dup]
1427
- 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?
1428
1206
  new_item = fork_editor(content.join("\n"))
1429
- title, note = format_input(new_item)
1207
+ date, title, note = format_input(new_item)
1208
+ date ||= item.date
1430
1209
 
1431
1210
  if title.nil? || title.empty?
1432
1211
  logger.debug('Skipped:', 'No content provided')
1433
- elsif title == item.title && note.equal?(item.note)
1212
+ elsif title == item.title && note.equal?(item.note) && date.equal?(item.date)
1434
1213
  logger.debug('Skipped:', 'No change in content')
1435
1214
  else
1215
+ item.date = date unless date.nil?
1436
1216
  item.title = title
1437
1217
  item.note.add(note, replace: true)
1438
1218
  logger.info('Edited:', item.title)
@@ -1451,6 +1231,11 @@ module Doing
1451
1231
  ## @param target_tag [String] Tag to replace
1452
1232
  ## @param opt [Hash] Additional Options
1453
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
1454
1239
  def stop_start(target_tag, opt = {})
1455
1240
  tag = target_tag.dup
1456
1241
  opt[:section] ||= @config['current_section']
@@ -1465,7 +1250,9 @@ module Doing
1465
1250
 
1466
1251
  found_items = 0
1467
1252
 
1468
- @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
+
1469
1256
  next unless item.title =~ /@#{tag}/
1470
1257
 
1471
1258
  item.title.add_tags!([tag, 'done'], remove: true)
@@ -1475,7 +1262,7 @@ module Doing
1475
1262
 
1476
1263
  if opt[:archive] && opt[:section] != 'Archive'
1477
1264
  item.title = item.title.sub(/(?:@from\(.*?\))?(.*)$/, "\\1 @from(#{item.section})")
1478
- move_item(item, 'Archive', label: false)
1265
+ item.move_to('Archive', label: false, log: false)
1479
1266
  logger.count(:completed_archived)
1480
1267
  logger.info('Completed/archived:', item.title)
1481
1268
  else
@@ -1487,7 +1274,8 @@ module Doing
1487
1274
  logger.debug('Skipped:', "No active @#{tag} tasks found.") if found_items.zero?
1488
1275
 
1489
1276
  if opt[:new_item]
1490
- title, note = format_input(opt[:new_item])
1277
+ date, title, note = format_input(opt[:new_item])
1278
+ opt[:back] = date unless date.nil?
1491
1279
  note.add(opt[:note]) if opt[:note]
1492
1280
  title.tag!(tag)
1493
1281
  add_item(title.cap_first, opt[:section], { note: note, back: opt[:back] })
@@ -1504,7 +1292,6 @@ module Doing
1504
1292
  def write(file = nil, backup: true)
1505
1293
  Hooks.trigger :pre_write, self, file
1506
1294
  output = combined_content
1507
-
1508
1295
  if file.nil?
1509
1296
  $stdout.puts output
1510
1297
  else
@@ -1537,70 +1324,46 @@ module Doing
1537
1324
  bool = opt[:bool] || :and
1538
1325
  sect = opt[:section] !~ /^all$/i ? guess_section(opt[:section]) : 'all'
1539
1326
 
1540
- if sect =~ /^all$/i
1541
- all_sections = sections.dup
1542
- else
1543
- all_sections = [sect]
1544
- end
1545
-
1546
- counter = 0
1547
- new_content = {}
1548
-
1549
-
1550
- all_sections.each do |section|
1551
- items = @content[section][:items].dup
1552
- new_content[section] = {}
1553
- new_content[section][:original] = @content[section][:original]
1554
- new_content[section][:items] = []
1555
-
1556
- moved_items = []
1557
- if !tags.empty? || opt[:search] || opt[:before]
1558
- if opt[:before]
1559
- time_string = opt[:before]
1560
- cutoff = chronify(time_string, guess: :begin)
1561
- end
1327
+ section = guess_section(sect)
1562
1328
 
1563
- items.delete_if do |item|
1564
- if ((!tags.empty? && item.tags?(tags, bool)) || (opt[:search] && item.search(opt[:search].to_s)) || (opt[:before] && item.date < cutoff))
1565
- moved_items.push(item)
1566
- counter += 1
1567
- true
1568
- else
1569
- false
1570
- end
1571
- end
1572
- @content[section][:items] = items
1573
- new_content[section][:items] = moved_items
1574
- logger.warn('Rotated:', "#{moved_items.length} items from #{section}")
1575
- else
1576
- new_content[section][:items] = []
1577
- moved_items = []
1329
+ section_items = @content.in_section(section)
1330
+ max = section_items.count - keep.to_i
1578
1331
 
1579
- count = items.length < keep ? items.length : keep
1332
+ counter = 0
1333
+ new_content = Items.new
1580
1334
 
1581
- if items.count > count
1582
- moved_items.concat(items[count..-1])
1583
- else
1584
- moved_items.concat(items)
1585
- 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
1586
1341
 
1587
- @content[section][:items] = if count.zero?
1588
- []
1589
- else
1590
- items[0..count - 1]
1591
- end
1592
- 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?
1593
1345
 
1594
- 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
1595
1349
  end
1596
1350
  end
1597
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
+
1598
1361
  write(@doing_file)
1599
1362
 
1600
1363
  file = @doing_file.sub(/(\.\w+)$/, "_#{Time.now.strftime('%Y-%m-%d')}\\1")
1601
1364
  if File.exist?(file)
1602
1365
  init_doing_file(file)
1603
- @content.deep_merge(new_content)
1366
+ @content.concat(new_content).uniq!
1604
1367
  logger.warn('File update:', "added entries to existing file: #{file}")
1605
1368
  else
1606
1369
  @content = new_content
@@ -1616,7 +1379,7 @@ module Doing
1616
1379
  ## @return [String] The selected section name
1617
1380
  ##
1618
1381
  def choose_section
1619
- 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%'])
1620
1383
  choice ? choice.strip : choice
1621
1384
  end
1622
1385
 
@@ -1635,7 +1398,7 @@ module Doing
1635
1398
  ## @return [String] The selected view name
1636
1399
  ##
1637
1400
  def choose_view
1638
- 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%'])
1639
1402
  choice ? choice.strip : choice
1640
1403
  end
1641
1404
 
@@ -1693,7 +1456,7 @@ module Doing
1693
1456
  end
1694
1457
  end
1695
1458
 
1696
- items = filter_items([], opt: opt).reverse
1459
+ items = filter_items(Items.new, opt: opt).reverse
1697
1460
 
1698
1461
  items.reverse! if opt[:order] =~ /^d/i
1699
1462
 
@@ -1701,7 +1464,7 @@ module Doing
1701
1464
  opt[:menu] = !opt[:force]
1702
1465
  opt[:query] = '' # opt[:search]
1703
1466
  opt[:multiple] = true
1704
- 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)
1705
1468
 
1706
1469
  raise NoResults, 'no items selected' if selected.empty?
1707
1470
 
@@ -1709,11 +1472,8 @@ module Doing
1709
1472
  return
1710
1473
  end
1711
1474
 
1712
-
1713
1475
  opt[:output] ||= 'template'
1714
-
1715
1476
  opt[:wrap_width] ||= @config['templates']['default']['wrap_width'] || 0
1716
-
1717
1477
  output(items, title, is_single, opt)
1718
1478
  end
1719
1479
 
@@ -1734,11 +1494,12 @@ module Doing
1734
1494
  archive_all = section =~ /^all$/i # && !(tags.nil? || tags.empty?)
1735
1495
  section = guess_section(section) unless archive_all
1736
1496
 
1737
- 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')
1738
1499
 
1739
1500
  destination = guess_section(destination)
1740
1501
 
1741
- if sections.include?(destination) && (sections.include?(section) || archive_all)
1502
+ if @content.section?(destination) && (@content.section?(section) || archive_all)
1742
1503
  do_archive(section, destination, { count: count, tags: tags, bool: bool, search: options[:search], label: options[:label], before: options[:before] })
1743
1504
  write(doing_file)
1744
1505
  else
@@ -1978,7 +1739,6 @@ module Doing
1978
1739
  end
1979
1740
  end
1980
1741
 
1981
-
1982
1742
  logger.debug('Autotag:', "whitelisted tags: #{tagged[:whitelisted].log_tags}") unless tagged[:whitelisted].empty?
1983
1743
  logger.debug('Autotag:', "synonyms: #{tagged[:synonyms].log_tags}") unless tagged[:synonyms].empty?
1984
1744
  logger.debug('Autotag:', "transforms: #{tagged[:transformed].log_tags}") unless tagged[:transformed].empty?
@@ -1991,10 +1751,10 @@ module Doing
1991
1751
  text.add_tags!(tail_tags) unless tail_tags.empty?
1992
1752
 
1993
1753
  if text == original
1994
- logger.debug('Autotag:', "no change to \"#{text}\"")
1754
+ logger.debug('Autotag:', "no change to \"#{text.strip}\"")
1995
1755
  else
1996
1756
  new_tags = tagged[:whitelisted].concat(tail_tags).concat(tagged[:replaced])
1997
- logger.debug('Autotag:', "added #{new_tags.log_tags} to \"#{text}\"")
1757
+ logger.debug('Autotag:', "added #{new_tags.log_tags} to \"#{text.strip}\"")
1998
1758
  logger.count(:autotag, level: :info, count: 1, message: 'autotag updated %count %items')
1999
1759
  end
2000
1760
 
@@ -2171,7 +1931,7 @@ EOS
2171
1931
  def format_time(seconds, human: false)
2172
1932
  return [0, 0, 0] if seconds.nil?
2173
1933
 
2174
- if seconds.class == String && seconds =~ /(\d+):(\d+):(\d+)/
1934
+ if seconds.instance_of?(String) && seconds =~ /(\d+):(\d+):(\d+)/
2175
1935
  h = Regexp.last_match(1)
2176
1936
  m = Regexp.last_match(2)
2177
1937
  s = Regexp.last_match(3)
@@ -2200,13 +1960,13 @@ EOS
2200
1960
  ##
2201
1961
  def combined_content
2202
1962
  output = @other_content_top ? "#{@other_content_top.join("\n")}\n" : ''
2203
-
2204
- @content.each do |title, section|
2205
- output += "#{section[:original]}\n"
2206
- output += list_section({ section: title, template: "\t- %date | %title%t2note", highlight: false, wrap_width: 0, tags_color: false })
2207
- end
2208
-
1963
+ was_color = Color.coloring?
1964
+ Color.coloring = false
1965
+ output += @content.to_s
2209
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
+
2210
1970
  output.uncolor
2211
1971
  end
2212
1972
 
@@ -2261,99 +2021,50 @@ EOS
2261
2021
  ##
2262
2022
  ## Helper function, performs the actual archiving
2263
2023
  ##
2264
- ## @param sect [String] The source section
2024
+ ## @param section [String] The source section
2265
2025
  ## @param destination [String] The destination
2266
2026
  ## section
2267
2027
  ## @param opt [Hash] Additional Options
2268
2028
  ##
2269
- def do_archive(sect, destination, opt = {})
2029
+ def do_archive(section, destination, opt = {})
2270
2030
  count = opt[:count] || 0
2271
2031
  tags = opt[:tags] || []
2272
2032
  bool = opt[:bool] || :and
2273
2033
  label = opt[:label] || true
2274
2034
 
2275
- if sect =~ /^all$/i
2276
- all_sections = sections.dup
2277
- all_sections.delete(destination)
2278
- else
2279
- all_sections = [sect]
2280
- end
2281
-
2282
- counter = 0
2035
+ section = guess_section(section)
2036
+ destination = guess_section(destination)
2283
2037
 
2284
- all_sections.each do |section|
2285
- items = @content[section][:items].dup
2038
+ section_items = @content.in_section(section)
2039
+ max = section_items.count - count.to_i
2286
2040
 
2287
- moved_items = []
2288
- if !tags.empty? || opt[:search] || opt[:before]
2289
- if opt[:before]
2290
- time_string = opt[:before]
2291
- cutoff = chronify(time_string, guess: :begin)
2292
- end
2041
+ counter = 0
2293
2042
 
2294
- items.delete_if do |item|
2295
- if ((!tags.empty? && item.tags?(tags, bool)) || (opt[:search] && item.search(opt[:search].to_s)) || (opt[:before] && item.date < cutoff))
2296
- moved_items.push(item)
2297
- counter += 1
2298
- true
2299
- else
2300
- false
2301
- end
2302
- end
2303
- moved_items.each do |item|
2304
- if label
2305
- item.title = if section == @config['current_section']
2306
- item.title.sub(/(?: ?@from\(.*?\))?(.*)$/, '\1')
2307
- else
2308
- item.title.sub(/(?: ?@from\(.*?\))?(.*)$/, "\\1 @from(#{section})")
2309
- end
2310
- logger.debug('Moved:', "#{item.title} from #{section} to #{destination}")
2311
- end
2312
- 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
2313
2049
 
2314
- @content[section][:items] = items
2315
- @content[destination][:items].concat(moved_items)
2316
- if moved_items.length.positive?
2317
- logger.count(destination == 'Archive' ? :archived : :moved,
2318
- level: :info,
2319
- count: moved_items.length,
2320
- message: "%count %items from #{section} to #{destination}")
2321
- else
2322
- logger.info('Skipped:', 'No items were moved')
2323
- 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
2324
2054
  else
2325
- count = items.length if items.length < count
2326
-
2327
- items.map! do |item|
2328
- if label
2329
- item.title = if section == @config['current_section']
2330
- item.title.sub(/(?: ?@from\(.*?\))?(.*)$/, '\1')
2331
- else
2332
- item.title.sub(/(?: ?@from\(.*?\))?(.*)$/, "\\1 @from(#{section})")
2333
- end
2334
- logger.debug('Moved:', "#{item.title} from #{section} to #{destination}")
2335
- end
2336
- item
2337
- end
2338
-
2339
- if items.count > count
2340
- @content[destination][:items].concat(items[count..-1])
2341
- else
2342
- @content[destination][:items].concat(items)
2343
- end
2344
-
2345
- @content[section][:items] = if count.zero?
2346
- []
2347
- else
2348
- items[0..count - 1]
2349
- end
2350
-
2351
- logger.count(destination == 'Archive' ? :archived : :moved,
2352
- level: :info,
2353
- count: items.length - count,
2354
- message: "%count %items from #{section} to #{destination}")
2055
+ counter += 1
2056
+ item.move_to(destination, label: label, log: false)
2355
2057
  end
2356
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
2357
2068
  end
2358
2069
 
2359
2070
  def run_after
@@ -2365,31 +2076,5 @@ EOS
2365
2076
  logger.log_now(:error, 'Script error:', "Error running #{@config['run_after']}")
2366
2077
  logger.log_now(:error, 'STDERR output:', stderr)
2367
2078
  end
2368
-
2369
- def log_change(tags_added: [], tags_removed: [], count: 1, item: nil, single: false)
2370
- if tags_added.empty? && tags_removed.empty?
2371
- logger.count(:skipped, level: :debug, message: '%count %items with no change', count: count)
2372
- else
2373
- if tags_added.empty?
2374
- logger.count(:skipped, level: :debug, message: 'no tags added to %count %items')
2375
- else
2376
- if single && item
2377
- logger.info('Tagged:', %(added #{tags_added.count == 1 ? 'tag' : 'tags'} #{tags_added.map {|t| "@#{t}"}.join(', ')} to #{item.title}))
2378
- else
2379
- logger.count(:added_tags, level: :info, tag: tags_added, message: '%tags added to %count %items')
2380
- end
2381
- end
2382
-
2383
- if tags_removed.empty?
2384
- logger.count(:skipped, level: :debug, message: 'no tags removed from %count %items')
2385
- else
2386
- if single && item
2387
- logger.info('Untagged:', %(removed #{tags_removed.count == 1 ? 'tag' : 'tags'} #{tags_added.map {|t| "@#{t}"}.join(', ')} from #{item.title}))
2388
- else
2389
- logger.count(:removed_tags, level: :info, tag: tags_removed, message: '%tags removed from %count %items')
2390
- end
2391
- end
2392
- end
2393
- end
2394
2079
  end
2395
2080
  end