doing 2.0.25 → 2.1.0pre

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