doing 2.0.22 → 2.1.0pre

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