doing 2.0.7.pre → 2.0.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. checksums.yaml +4 -4
  2. data/.yardoc/checksums +20 -0
  3. data/.yardoc/complete +0 -0
  4. data/.yardoc/object_types +0 -0
  5. data/.yardoc/objects/root.dat +0 -0
  6. data/.yardoc/proxy_types +0 -0
  7. data/.yardopts +1 -0
  8. data/CHANGELOG.md +13 -9
  9. data/Gemfile.lock +30 -10
  10. data/README.md +1 -1
  11. data/Rakefile +8 -1
  12. data/bin/doing +368 -21
  13. data/doc/Array.html +135 -0
  14. data/doc/Doing/Color.html +506 -0
  15. data/doc/Doing/Configuration.html +680 -0
  16. data/doc/Doing/Errors/DoingNoTraceError.html +186 -0
  17. data/doc/Doing/Errors/DoingRuntimeError.html +186 -0
  18. data/doc/Doing/Errors/DoingStandardError.html +186 -0
  19. data/doc/Doing/Errors/EmptyInput.html +186 -0
  20. data/doc/Doing/Errors/NoResults.html +186 -0
  21. data/doc/Doing/Errors/PluginException.html +248 -0
  22. data/doc/Doing/Errors/UserCancelled.html +186 -0
  23. data/doc/Doing/Errors/WrongCommand.html +186 -0
  24. data/doc/Doing/Errors.html +191 -0
  25. data/doc/Doing/Hooks.html +364 -0
  26. data/doc/Doing/Item.html +1385 -0
  27. data/doc/Doing/Items.html +393 -0
  28. data/doc/Doing/LogAdapter.html +1650 -0
  29. data/doc/Doing/Note.html +535 -0
  30. data/doc/Doing/Pager.html +268 -0
  31. data/doc/Doing/Plugins.html +849 -0
  32. data/doc/Doing/Util.html +870 -0
  33. data/doc/Doing/WWID.html +4827 -0
  34. data/doc/Doing.html +145 -0
  35. data/doc/GLI/Commands/MarkdownDocumentListener.html +763 -0
  36. data/doc/GLI/Commands.html +115 -0
  37. data/doc/GLI.html +115 -0
  38. data/doc/Hash.html +332 -0
  39. data/doc/Status.html +292 -0
  40. data/doc/String.html +1714 -0
  41. data/doc/Symbol.html +250 -0
  42. data/doc/Time.html +182 -0
  43. data/doc/_index.html +411 -0
  44. data/doc/class_list.html +51 -0
  45. data/doc/css/common.css +1 -0
  46. data/doc/css/full_list.css +58 -0
  47. data/doc/css/style.css +497 -0
  48. data/doc/file.README.html +123 -0
  49. data/doc/file_list.html +56 -0
  50. data/doc/frames.html +17 -0
  51. data/doc/index.html +123 -0
  52. data/doc/js/app.js +314 -0
  53. data/doc/js/full_list.js +216 -0
  54. data/doc/js/jquery.js +4 -0
  55. data/doc/method_list.html +1867 -0
  56. data/doc/top-level-namespace.html +112 -0
  57. data/doing.gemspec +5 -1
  58. data/doing.rdoc +354 -6
  59. data/example_plugin.rb +6 -6
  60. data/lib/doing/array.rb +15 -2
  61. data/lib/doing/configuration.rb +14 -12
  62. data/lib/doing/errors.rb +1 -1
  63. data/lib/doing/hash.rb +1 -1
  64. data/lib/doing/item.rb +113 -23
  65. data/lib/doing/log_adapter.rb +132 -119
  66. data/lib/doing/note.rb +1 -1
  67. data/lib/doing/plugin_manager.rb +5 -5
  68. data/lib/doing/plugins/export/csv_export.rb +1 -1
  69. data/lib/doing/plugins/export/template_export.rb +5 -7
  70. data/lib/doing/plugins/import/calendar_import.rb +8 -2
  71. data/lib/doing/plugins/import/doing_import.rb +10 -10
  72. data/lib/doing/plugins/import/timing_import.rb +12 -4
  73. data/lib/doing/string.rb +96 -21
  74. data/lib/doing/symbol.rb +9 -5
  75. data/lib/doing/time.rb +1 -1
  76. data/lib/doing/util.rb +18 -11
  77. data/lib/doing/version.rb +1 -1
  78. data/lib/doing/wwid.rb +510 -368
  79. data/lib/doing/wwidfile.rb +5 -5
  80. data/lib/doing.rb +2 -1
  81. data/lib/examples/plugins/say_export.rb +6 -6
  82. data/lib/examples/plugins/{templates → wiki_export/templates}/wiki.css +0 -0
  83. data/lib/examples/plugins/{templates → wiki_export/templates}/wiki.haml +0 -0
  84. data/lib/examples/plugins/{templates → wiki_export/templates}/wiki_index.haml +0 -0
  85. data/lib/examples/plugins/{wiki_export.rb → wiki_export/wiki_export.rb} +0 -0
  86. data/rdocfixer.rb +1 -1
  87. data/yard_templates/default/method_details/setup.rb +3 -0
  88. metadata +121 -8
data/lib/doing/wwid.rb CHANGED
@@ -9,7 +9,7 @@ require 'erb'
9
9
 
10
10
  module Doing
11
11
  ##
12
- ## @brief Main "What Was I Doing" methods
12
+ ## Main "What Was I Doing" methods
13
13
  ##
14
14
  class WWID
15
15
  attr_reader :additional_configs, :current_section, :doing_file, :content
@@ -19,7 +19,7 @@ module Doing
19
19
  # include Util
20
20
 
21
21
  ##
22
- ## @brief Initializes the object.
22
+ ## Initializes the object.
23
23
  ##
24
24
  def initialize
25
25
  @timers = {}
@@ -32,7 +32,7 @@ module Doing
32
32
  end
33
33
 
34
34
  ##
35
- ## @brief Logger
35
+ ## Logger
36
36
  ##
37
37
  ## Responds to :debug, :info, :warn, and :error
38
38
  ##
@@ -45,9 +45,9 @@ module Doing
45
45
  end
46
46
 
47
47
  ##
48
- ## @brief Initializes the doing file.
48
+ ## Initializes the doing file.
49
49
  ##
50
- ## @param path (String) Override path to a doing file, optional
50
+ ## @param path [String] Override path to a doing file, optional
51
51
  ##
52
52
  def init_doing_file(path = nil)
53
53
  @doing_file = File.expand_path(@config['doing_file'])
@@ -106,7 +106,7 @@ module Doing
106
106
  end
107
107
 
108
108
  ##
109
- ## @brief Create a new doing file
109
+ ## Create a new doing file
110
110
  ##
111
111
  def create(filename = nil)
112
112
  filename = @doing_file if filename.nil?
@@ -118,9 +118,9 @@ module Doing
118
118
  end
119
119
 
120
120
  ##
121
- ## @brief Create a process for an editor and wait for the file handle to return
121
+ ## Create a process for an editor and wait for the file handle to return
122
122
  ##
123
- ## @param input (String) Text input for editor
123
+ ## @param input [String] Text input for editor
124
124
  ##
125
125
  def fork_editor(input = '')
126
126
  # raise NonInteractive, 'Non-interactive terminal' unless $stdout.isatty || ENV['DOING_EDITOR_TEST']
@@ -164,13 +164,11 @@ module Doing
164
164
  end
165
165
 
166
166
  ##
167
- ## @brief Takes a multi-line string and formats it as an entry
167
+ ## Takes a multi-line string and formats it as an entry
168
168
  ##
169
- ## @return (Array) [(String)title, (Array)note]
169
+ ## @param input [String] The string to parse
170
170
  ##
171
- ## @param input (String) The string to parse
172
- ##
173
- ## @return (Array) [(String)title, (Note)note]
171
+ ## @return [Array] [[String]title, [Note]note]
174
172
  ##
175
173
  def format_input(input)
176
174
  raise EmptyInput, 'No content in entry' if input.nil? || input.strip.empty?
@@ -197,15 +195,15 @@ module Doing
197
195
  end
198
196
 
199
197
  ##
200
- ## @brief Converts input string into a Time object when input takes on the
198
+ ## Converts input string into a Time object when input takes on the
201
199
  ## following formats:
202
200
  ## - interval format e.g. '1d2h30m', '45m' etc.
203
201
  ## - a semantic phrase e.g. 'yesterday 5:30pm'
204
202
  ## - a strftime e.g. '2016-03-15 15:32:04 PDT'
205
203
  ##
206
- ## @param input (String) String to chronify
204
+ ## @param input [String] String to chronify
207
205
  ##
208
- ## @return (DateTime) result
206
+ ## @return [DateTime] result
209
207
  ##
210
208
  def chronify(input, future: false, guess: :begin)
211
209
  now = Time.now
@@ -229,13 +227,13 @@ module Doing
229
227
  end
230
228
 
231
229
  ##
232
- ## @brief Converts simple strings into seconds that can be added to a Time
230
+ ## Converts simple strings into seconds that can be added to a Time
233
231
  ## object
234
232
  ##
235
- ## @param qty (String) HH:MM or XX[dhm][[XXhm][XXm]] (1d2h30m, 45m,
233
+ ## @param qty [String] HH:MM or XX[dhm][[XXhm][XXm]] (1d2h30m, 45m,
236
234
  ## 1.5d, 1h20m, etc.)
237
235
  ##
238
- ## @return (Integer) seconds
236
+ ## @return [Integer] seconds
239
237
  ##
240
238
  def chronify_qty(qty)
241
239
  minutes = 0
@@ -262,18 +260,18 @@ module Doing
262
260
  end
263
261
 
264
262
  ##
265
- ## @brief List sections
263
+ ## List sections
266
264
  ##
267
- ## @return (Array) section titles
265
+ ## @return [Array] section titles
268
266
  ##
269
267
  def sections
270
268
  @content.keys
271
269
  end
272
270
 
273
271
  ##
274
- ## @brief Adds a section.
272
+ ## Adds a section.
275
273
  ##
276
- ## @param title (String) The new section title
274
+ ## @param title [String] The new section title
277
275
  ##
278
276
  def add_section(title)
279
277
  if @content.key?(title.cap_first)
@@ -285,10 +283,10 @@ module Doing
285
283
  end
286
284
 
287
285
  ##
288
- ## @brief Attempt to match a string with an existing section
286
+ ## Attempt to match a string with an existing section
289
287
  ##
290
- ## @param frag (String) The user-provided string
291
- ## @param guessed (Boolean) already guessed and failed
288
+ ## @param frag [String] The user-provided string
289
+ ## @param guessed [Boolean] already guessed and failed
292
290
  ##
293
291
  def guess_section(frag, guessed: false, suggest: false)
294
292
  return 'All' if frag =~ /^all$/i
@@ -330,10 +328,12 @@ module Doing
330
328
  end
331
329
 
332
330
  ##
333
- ## @brief Ask a yes or no question in the terminal
331
+ ## Ask a yes or no question in the terminal
334
332
  ##
335
- ## @param question (String) The question to ask
336
- ## @param default (Bool) default response if no input
333
+ ## @param question [String] The question
334
+ ## to ask
335
+ ## @param default_response (Bool) default
336
+ ## response if no input
337
337
  ##
338
338
  ## @return (Bool) yes or no
339
339
  ##
@@ -382,10 +382,10 @@ module Doing
382
382
  end
383
383
 
384
384
  ##
385
- ## @brief Attempt to match a string with an existing view
385
+ ## Attempt to match a string with an existing view
386
386
  ##
387
- ## @param frag (String) The user-provided string
388
- ## @param guessed (Boolean) already guessed
387
+ ## @param frag [String] The user-provided string
388
+ ## @param guessed [Boolean] already guessed
389
389
  ##
390
390
  def guess_view(frag, guessed: false, suggest: false)
391
391
  views.each { |view| return view if frag.downcase == view.downcase }
@@ -410,11 +410,11 @@ module Doing
410
410
  end
411
411
 
412
412
  ##
413
- ## @brief Adds an entry
413
+ ## Adds an entry
414
414
  ##
415
- ## @param title (String) The entry title
416
- ## @param section (String) The section to add to
417
- ## @param opt (Hash) Additional Options {:date, :note, :back, :timed}
415
+ ## @param title [String] The entry title
416
+ ## @param section [String] The section to add to
417
+ ## @param opt [Hash] Additional Options: :date, :note, :back, :timed
418
418
  ##
419
419
  def add_item(title, section = nil, opt = {})
420
420
  section ||= @config['current_section']
@@ -455,10 +455,10 @@ module Doing
455
455
  end
456
456
 
457
457
  ##
458
- ## @brief Remove items from a list that already exist in @content
458
+ ## Remove items from a list that already exist in @content
459
459
  ##
460
- ## @param items (Array) The items to deduplicate
461
- ## @param no_overlap (Boolean) Remove items with overlapping time spans
460
+ ## @param items [Array] The items to deduplicate
461
+ ## @param no_overlap [Boolean] Remove items with overlapping time spans
462
462
  ##
463
463
  def dedup(items, no_overlap = false)
464
464
 
@@ -473,17 +473,17 @@ module Doing
473
473
  duped = no_overlap ? item.overlapping_time?(comp) : item.same_time?(comp)
474
474
  break if duped
475
475
  end
476
- logger.count(:skipped, level: :debug, message: 'overlapping %item') if duped
476
+ logger.count(:skipped, level: :debug, message: '%count overlapping %items') if duped
477
477
  # logger.log_now(:debug, 'Skipped:', "overlapping entry: #{item.title}") if duped
478
478
  duped
479
479
  end
480
480
  end
481
481
 
482
482
  ##
483
- ## @brief Imports external entries
483
+ ## Imports external entries
484
484
  ##
485
- ## @param path (String) Path to JSON report file
486
- ## @param opt (Hash) Additional Options
485
+ ## @param paths [String] Path to JSON report file
486
+ ## @param opt [Hash] Additional Options
487
487
  ##
488
488
  def import(paths, opt = {})
489
489
  Plugins.plugins[:import].each do |_, options|
@@ -501,9 +501,9 @@ module Doing
501
501
  end
502
502
 
503
503
  ##
504
- ## @brief Return the content of the last note for a given section
504
+ ## Return the content of the last note for a given section
505
505
  ##
506
- ## @param section (String) The section to retrieve from, default
506
+ ## @param section [String] The section to retrieve from, default
507
507
  ## All
508
508
  ##
509
509
  def last_note(section = 'All')
@@ -563,9 +563,9 @@ module Doing
563
563
  end
564
564
 
565
565
  ##
566
- ## @brief Restart the last entry
566
+ ## Restart the last entry
567
567
  ##
568
- ## @param opt (Hash) Additional Options
568
+ ## @param opt [Hash] Additional Options
569
569
  ##
570
570
  def repeat_last(opt = {})
571
571
  opt[:section] ||= 'all'
@@ -583,9 +583,9 @@ module Doing
583
583
  end
584
584
 
585
585
  ##
586
- ## @brief Get the last entry
586
+ ## Get the last entry
587
587
  ##
588
- ## @param opt (Hash) Additional Options
588
+ ## @param opt [Hash] Additional Options
589
589
  ##
590
590
  def last_entry(opt = {})
591
591
  opt[:tag_bool] ||= :and
@@ -612,9 +612,9 @@ module Doing
612
612
  end
613
613
 
614
614
  ##
615
- ## @brief Generate a menu of options and allow user selection
615
+ ## Generate a menu of options and allow user selection
616
616
  ##
617
- ## @return (String) The selected option
617
+ ## @return [String] The selected option
618
618
  ##
619
619
  def choose_from(options, prompt: 'Make a selection: ', multiple: false, sorted: true, fzf_args: [])
620
620
  return nil unless $stdout.isatty
@@ -650,27 +650,56 @@ module Doing
650
650
  tag_groups
651
651
  end
652
652
 
653
+ def fuzzy_filter_items(items, opt: {})
654
+ scannable = items.map.with_index { |item, idx| "#{item.title} #{item.note.join(' ')}".gsub(/[|*?!]/, '') + "|#{idx}" }.join("\n")
655
+
656
+ fzf = File.join(File.dirname(__FILE__), '../helpers/fuzzyfilefinder')
657
+
658
+ fzf_args = [
659
+ '--multi',
660
+ %(--filter="#{opt[:search].sub(/^'?/, "'")}"),
661
+ '--no-sort',
662
+ '-d "\|"',
663
+ '--nth=1'
664
+ ]
665
+ if opt[:case]
666
+ fzf_args << case opt[:case].normalize_case
667
+ when :sensitive
668
+ '+i'
669
+ when :ignore
670
+ '-i'
671
+ end
672
+ end
673
+ # fzf_args << '-e' if opt[:exact]
674
+ # puts fzf_args.join(' ')
675
+ res = `echo #{Shellwords.escape(scannable)}|#{fzf} #{fzf_args.join(' ')}`
676
+ selected = []
677
+ res.split(/\n/).each do |item|
678
+ idx = item.match(/\|(\d+)$/)[1].to_i
679
+ selected.push(items[idx])
680
+ end
681
+ selected
682
+ end
683
+
653
684
  ##
654
- ## @brief Filter items based on search criteria
655
- ##
656
- ## @param items (Array) The items to filter (if empty, filters all items)
657
- ## @param opt (Hash) The filter parameters
685
+ ## Filter items based on search criteria
658
686
  ##
659
- ## Available filter options in opt object
687
+ ## @param items [Array] The items to filter (if empty, filters all items)
688
+ ## @param opt [Hash] The filter parameters
660
689
  ##
661
- ## - +:section+ (String)
662
- ## - +:unfinished+ (Boolean)
663
- ## - +:tag+ (Array or comma-separated string)
664
- ## - +:tag_bool+ (:and, :or, :not)
665
- ## - +:search+ (string, optional regex with //)
666
- ## - +:date_filter+ (Array[(Time)start, (Time)end])
667
- ## - +:only_timed+ (Boolean)
668
- ## - +:before+ (Date/Time string, unparsed)
669
- ## - +:after+ (Date/Time string, unparsed)
670
- ## - +:today+ (Boolean)
671
- ## - +:yesterday+ (Boolean)
672
- ## - +:count+ (Number to return)
673
- ## - +:age+ (String, 'old' or 'new')
690
+ ## @option opt [String] :section
691
+ ## @option opt [Boolean] :unfinished
692
+ ## @option opt [Array or String] :tag (Array or comma-separated string)
693
+ ## @option opt [Symbol] :tag_bool (:and, :or, :not)
694
+ ## @option opt [String] :search (string, optional regex with //)
695
+ ## @option opt [Array] :date_filter [[Time]start, [Time]end]
696
+ ## @option opt [Boolean] :only_timed
697
+ ## @option opt [String] :before (Date/Time string, unparsed)
698
+ ## @option opt [String] :after (Date/Time string, unparsed)
699
+ ## @option opt [Boolean] :today
700
+ ## @option opt [Boolean] :yesterday
701
+ ## @option opt [Number] :count (Number to return)
702
+ ## @option opt [String] :age ('old' or 'new')
674
703
  ##
675
704
  def filter_items(items = [], opt: {})
676
705
  if items.nil? || items.empty?
@@ -684,20 +713,31 @@ module Doing
684
713
  end
685
714
 
686
715
  items.sort_by! { |item| [item.date, item.title.downcase] }.reverse
716
+
687
717
  filtered_items = items.select do |item|
688
718
  keep = true
689
- finished = opt[:unfinished] && item.tags?('done', :and)
690
- keep = false if finished
719
+ if opt[:unfinished]
720
+ finished = item.tags?('done', :and)
721
+ finished = opt[:not] ? !finished : finished
722
+ keep = false if finished
723
+ end
691
724
 
692
725
  if keep && opt[:tag]
693
726
  opt[:tag_bool] ||= :and
694
727
  tag_match = opt[:tag].nil? || opt[:tag].empty? ? true : item.tags?(opt[:tag], opt[:tag_bool])
695
728
  keep = false unless tag_match
729
+ keep = opt[:not] ? !keep : keep
696
730
  end
697
731
 
698
732
  if keep && opt[:search]
699
- search_match = opt[:search].nil? || opt[:search].empty? ? true : item.search(opt[:search])
733
+ search_match = if opt[:search].nil? || opt[:search].empty?
734
+ true
735
+ else
736
+ item.search(opt[:search], case_type: opt[:case].normalize_case, fuzzy: opt[:fuzzy])
737
+ end
738
+
700
739
  keep = false unless search_match
740
+ keep = opt[:not] ? !keep : keep
701
741
  end
702
742
 
703
743
  if keep && opt[:date_filter]&.length == 2
@@ -710,30 +750,36 @@ module Doing
710
750
  item.date.strftime('%F') == start_date.strftime('%F')
711
751
  end
712
752
  keep = false unless in_date_range
753
+ keep = opt[:not] ? !keep : keep
713
754
  end
714
755
 
715
756
  keep = false if keep && opt[:only_timed] && !item.interval
716
757
 
717
758
  if keep && opt[:tag_filter] && !opt[:tag_filter]['tags'].empty?
718
759
  keep = item.tags?(opt[:tag_filter]['tags'], opt[:tag_filter]['bool'])
760
+ keep = opt[:not] ? !keep : keep
719
761
  end
720
762
 
721
763
  if keep && opt[:before]
722
764
  time_string = opt[:before]
723
765
  cutoff = chronify(time_string, guess: :begin)
724
766
  keep = cutoff && item.date <= cutoff
767
+ keep = opt[:not] ? !keep : keep
725
768
  end
726
769
 
727
770
  if keep && opt[:after]
728
771
  time_string = opt[:after]
729
772
  cutoff = chronify(time_string, guess: :end)
730
773
  keep = cutoff && item.date >= cutoff
774
+ keep = opt[:not] ? !keep : keep
731
775
  end
732
776
 
733
777
  if keep && opt[:today]
734
778
  keep = item.date >= Date.today.to_time && item.date < Date.today.next_day.to_time
779
+ keep = opt[:not] ? !keep : keep
735
780
  elsif keep && opt[:yesterday]
736
781
  keep = item.date >= Date.today.prev_day.to_time && item.date < Date.today.to_time
782
+ keep = opt[:not] ? !keep : keep
737
783
  end
738
784
 
739
785
  keep
@@ -749,28 +795,58 @@ module Doing
749
795
  end
750
796
 
751
797
  ##
752
- ## @brief Display an interactive menu of entries
798
+ ## Display an interactive menu of entries
799
+ ##
800
+ ## @param opt [Hash] Additional options
753
801
  ##
754
- ## @param opt (Hash) Additional options
802
+ ## Options hash is shared with #filter_items and #act_on
755
803
  ##
756
804
  def interactive(opt = {})
757
805
  section = opt[:section] ? guess_section(opt[:section]) : 'All'
806
+
807
+ search = nil
808
+
809
+ if opt[:search]
810
+ search = opt[:search]
811
+ search.sub!(/^'?/, "'") if opt[:exact]
812
+ opt[:search] = search
813
+ end
814
+
758
815
  opt[:query] = opt[:search] if opt[:search] && !opt[:query]
816
+ opt[:query] = "!#{opt[:query]}" if opt[:not]
759
817
  opt[:multiple] = true
760
- items = filter_items([], opt: { section: section, search: opt[:search] })
818
+ opt[:show_if_single] = true
819
+ items = filter_items([], opt: { section: section, search: opt[:search], fuzzy: opt[:fuzzy], case: opt[:case], not: opt[:not] })
761
820
 
762
821
  selection = choose_from_items(items, opt, include_section: section =~ /^all$/i)
763
822
 
764
- raise NoResults, 'no items selected' if selection.empty?
823
+ raise NoResults, 'no items selected' if selection.nil? || selection.empty?
765
824
 
766
825
  act_on(selection, opt)
767
826
  end
768
827
 
828
+ ##
829
+ ## Create an interactive menu to select from a set of Items
830
+ ##
831
+ ## @param items [Array] list of items
832
+ ## @param opt [Hash] options
833
+ ## @param include_section [Boolean] include section
834
+ ##
835
+ ## @option opt [String] :header
836
+ ## @option opt [String] :prompt
837
+ ## @option opt [String] :query
838
+ ## @option opt [Boolean] :show_if_single
839
+ ## @option opt [Boolean] :menu
840
+ ## @option opt [Boolean] :sort
841
+ ## @option opt [Boolean] :multiple
842
+ ## @option opt [Symbol] :case (:sensitive, :ignore, :smart)
843
+ ##
769
844
  def choose_from_items(items, opt = {}, include_section: false)
770
- return nil unless $stdout.isatty
845
+ return items unless $stdout.isatty
771
846
 
772
847
  return nil unless items.count.positive?
773
848
 
849
+ opt[:case] ||= :smart
774
850
  opt[:header] ||= "Arrows: navigate, tab: mark for selection, ctrl-a: select all, enter: commit"
775
851
  opt[:prompt] ||= "Select entries to act on > "
776
852
 
@@ -801,9 +877,18 @@ module Doing
801
877
  opt[:multiple] ? '--multi' : '--no-multi',
802
878
  '-0',
803
879
  '--bind ctrl-a:select-all',
804
- %(-q "#{opt[:query]}")
880
+ %(-q "#{opt[:query]}"),
881
+ '--info=inline'
805
882
  ]
806
883
  fzf_args.push('-1') unless opt[:show_if_single]
884
+ fzf_args << case opt[:case].normalize_case
885
+ when :sensitive
886
+ '+i'
887
+ when :ignore
888
+ '-i'
889
+ end
890
+ fzf_args << '-e' if opt[:exact]
891
+
807
892
 
808
893
  unless opt[:menu]
809
894
  raise InvalidArgument, "Can't skip menu when no query is provided" unless opt[:query] && !opt[:query].empty?
@@ -821,6 +906,27 @@ module Doing
821
906
  opt[:multiple] ? selected : selected[0]
822
907
  end
823
908
 
909
+ ##
910
+ ## Perform actions on a set of entries. If
911
+ ## no valid action is included in the opt
912
+ ## hash and the terminal is a TTY, a menu
913
+ ## will be presented
914
+ ##
915
+ ## @param items [Array] Array of Items to affect
916
+ ## @param opt [Hash] Options and actions to perform
917
+ ##
918
+ ## @option opt [Boolean] :editor
919
+ ## @option opt [Boolean] :delete
920
+ ## @option opt [String] :tag
921
+ ## @option opt [Boolean] :flag
922
+ ## @option opt [Boolean] :finish
923
+ ## @option opt [Boolean] :cancel
924
+ ## @option opt [Boolean] :archive
925
+ ## @option opt [String] :output
926
+ ## @option opt [String] :save_to
927
+ ## @option opt [Boolean] :again
928
+ ## @option opt [Boolean] :resume
929
+ ##
824
930
  def act_on(items, opt = {})
825
931
  actions = %i[editor delete tag flag finish cancel archive output save_to again resume]
826
932
  has_action = false
@@ -835,17 +941,17 @@ module Doing
835
941
 
836
942
  unless has_action
837
943
  actions = [
838
- 'add tag',
839
- 'remove tag',
840
- 'cancel',
841
- 'delete',
842
- 'finish',
843
- 'flag',
844
- 'archive',
845
- 'move',
846
- 'edit',
847
- 'output formatted'
848
- ]
944
+ 'add tag',
945
+ 'remove tag',
946
+ 'cancel',
947
+ 'delete',
948
+ 'finish',
949
+ 'flag',
950
+ 'archive',
951
+ 'move',
952
+ 'edit',
953
+ 'output formatted'
954
+ ]
849
955
 
850
956
  actions.concat(['resume/repeat', 'begin/reset']) if items.count == 1
851
957
 
@@ -853,7 +959,7 @@ module Doing
853
959
  prompt: 'What do you want to do with the selected items? > ',
854
960
  multiple: true,
855
961
  sorted: false,
856
- fzf_args: ['--height=60%', '--tac', '--no-sort'])
962
+ fzf_args: ["--height=#{actions.count + 3}", '--tac', '--no-sort', '--info=hidden'])
857
963
  return unless choice
858
964
 
859
965
  to_do = choice.strip.split(/\n/)
@@ -874,14 +980,13 @@ module Doing
874
980
  opt[:tag] = tag.strip.sub(/^@/, '')
875
981
  opt[:remove] = true if type == 'remove'
876
982
  when /output formatted/
877
- output_format = choose_from(Plugins.available_plugins(type: :export).sort,
983
+ plugins = Plugins.available_plugins(type: :export).sort
984
+ output_format = choose_from(plugins,
878
985
  prompt: 'Which output format? > ',
879
- fzf_args: ['--height=60%', '--tac', '--no-sort'])
986
+ fzf_args: ["--height=#{plugins.count + 3}", '--tac', '--no-sort', '--info=hidden'])
880
987
  next if tag =~ /^ *$/
881
988
 
882
- unless output_format
883
- raise UserCancelled, 'Cancelled'
884
- end
989
+ raise UserCancelled unless output_format
885
990
 
886
991
  opt[:output] = output_format.strip
887
992
  res = opt[:force] ? false : yn('Save to file?', default_response: 'n')
@@ -934,7 +1039,7 @@ module Doing
934
1039
  if opt[:delete]
935
1040
  res = opt[:force] ? true : yn("Delete #{items.size} items?", default_response: 'y')
936
1041
  if res
937
- items.each { |item| delete_item(item) }
1042
+ items.each { |item| delete_item(item, single: items.count == 1) }
938
1043
  write(@doing_file)
939
1044
  end
940
1045
  return
@@ -992,7 +1097,7 @@ module Doing
992
1097
  title = input_lines[0]&.strip
993
1098
 
994
1099
  if title.nil? || title =~ /^#{divider.strip}$/ || title.strip.empty?
995
- delete_item(items[i])
1100
+ delete_item(items[i], single: new_items.count == 1)
996
1101
  else
997
1102
  note = input_lines.length > 1 ? input_lines[1..-1] : []
998
1103
 
@@ -1051,12 +1156,15 @@ module Doing
1051
1156
  end
1052
1157
 
1053
1158
  ##
1054
- ## @brief Tag an item from the index
1159
+ ## Tag an item from the index
1055
1160
  ##
1056
- ## @param item (Item) The item to tag
1057
- ## @param tags (string) The tag to apply
1058
- ## @param remove (Boolean) remove tags
1059
- ## @param date (Boolean) Include timestamp?
1161
+ ## @param item [Item] The item to tag
1162
+ ## @param tags [String] The tag to apply
1163
+ ## @param remove [Boolean] remove tags?
1164
+ ## @param date [Boolean] Include timestamp?
1165
+ ## @param single [Boolean] Log as a single change?
1166
+ ##
1167
+ ## @return [Item] updated item
1060
1168
  ##
1061
1169
  def tag_item(item, tags, remove: false, date: false, single: false)
1062
1170
  added = []
@@ -1080,9 +1188,13 @@ module Doing
1080
1188
  end
1081
1189
 
1082
1190
  ##
1083
- ## @brief Tag the last entry or X entries
1191
+ ## Tag the last entry or X entries
1192
+ ##
1193
+ ## @param opt [Hash] Additional Options (see
1194
+ ## #filter_items for filtering
1195
+ ## options)
1084
1196
  ##
1085
- ## @param opt (Hash) Additional Options
1197
+ ## @see #filter_items
1086
1198
  ##
1087
1199
  def tag_last(opt = {})
1088
1200
  opt[:count] ||= 1
@@ -1202,13 +1314,13 @@ module Doing
1202
1314
  end
1203
1315
 
1204
1316
  ##
1205
- ## @brief Move item from current section to
1317
+ ## Move item from current section to
1206
1318
  ## destination section
1207
1319
  ##
1208
- ## @param item The item
1209
- ## @param section The destination section
1320
+ ## @param item [Item] The item to move
1321
+ ## @param section [String] The destination section
1210
1322
  ##
1211
- ## @return Updated item
1323
+ ## @return [Item] Updated item
1212
1324
  ##
1213
1325
  def move_item(item, section, label: true)
1214
1326
  from = item.section
@@ -1225,9 +1337,13 @@ module Doing
1225
1337
  end
1226
1338
 
1227
1339
  ##
1228
- ## @brief Get next item in the index
1340
+ ## Get next item in the index
1341
+ ##
1342
+ ## @param item [Item] target item
1343
+ ## @param options [Hash] additional options
1344
+ ## @see #filter_items
1229
1345
  ##
1230
- ## @param item
1346
+ ## @return [Item] the next chronological item in the index
1231
1347
  ##
1232
1348
  def next_item(item, options = {})
1233
1349
  items = filter_items([], opt: options)
@@ -1238,21 +1354,21 @@ module Doing
1238
1354
  end
1239
1355
 
1240
1356
  ##
1241
- ## @brief Delete an item from the index
1357
+ ## Delete an item from the index
1242
1358
  ##
1243
1359
  ## @param item The item
1244
1360
  ##
1245
- def delete_item(item)
1361
+ def delete_item(item, single: false)
1246
1362
  section = item.section
1247
1363
 
1248
1364
  section_items = @content[section][:items]
1249
1365
  deleted = section_items.delete(item)
1250
1366
  logger.count(:deleted)
1251
- logger.info('Entry deleted:', deleted.title)
1367
+ logger.info('Entry deleted:', deleted.title) if single
1252
1368
  end
1253
1369
 
1254
1370
  ##
1255
- ## @brief Update an item in the index with a modified item
1371
+ ## Update an item in the index with a modified item
1256
1372
  ##
1257
1373
  ## @param old_item The old item
1258
1374
  ## @param new_item The new item
@@ -1274,9 +1390,9 @@ module Doing
1274
1390
  end
1275
1391
 
1276
1392
  ##
1277
- ## @brief Edit the last entry
1393
+ ## Edit the last entry
1278
1394
  ##
1279
- ## @param section (String) The section, default "All"
1395
+ ## @param section [String] The section, default "All"
1280
1396
  ##
1281
1397
  def edit_last(section: 'All', options: {})
1282
1398
  options[:section] = guess_section(section)
@@ -1307,14 +1423,14 @@ module Doing
1307
1423
  end
1308
1424
 
1309
1425
  ##
1310
- ## @brief Accepts one tag and the raw text of a new item if the passed tag
1311
- ## is on any item, it's replaced with @done. if new_item is not
1312
- ## nil, it's tagged with the passed tag and inserted. This is for
1313
- ## use where only one instance of a given tag should exist
1314
- ## (@meanwhile)
1426
+ ## Accepts one tag and the raw text of a new item if the
1427
+ ## passed tag is on any item, it's replaced with @done.
1428
+ ## if new_item is not nil, it's tagged with the passed
1429
+ ## tag and inserted. This is for use where only one
1430
+ ## instance of a given tag should exist (@meanwhile)
1315
1431
  ##
1316
- ## @param tag (String) Tag to replace
1317
- ## @param opt (Hash) Additional Options
1432
+ ## @param target_tag [String] Tag to replace
1433
+ ## @param opt [Hash] Additional Options
1318
1434
  ##
1319
1435
  def stop_start(target_tag, opt = {})
1320
1436
  tag = target_tag.dup
@@ -1362,13 +1478,13 @@ module Doing
1362
1478
  end
1363
1479
 
1364
1480
  ##
1365
- ## @brief Write content to file or STDOUT
1481
+ ## Write content to file or STDOUT
1366
1482
  ##
1367
- ## @param file (String) The filepath to write to
1483
+ ## @param file [String] The filepath to write to
1368
1484
  ##
1369
1485
  def write(file = nil, backup: true)
1370
1486
  Hooks.trigger :pre_write, self, file
1371
- output = wrapped_content
1487
+ output = combined_content
1372
1488
 
1373
1489
  if file.nil?
1374
1490
  $stdout.puts output
@@ -1378,21 +1494,10 @@ module Doing
1378
1494
  end
1379
1495
  end
1380
1496
 
1381
- def wrapped_content
1382
- output = @other_content_top ? "#{@other_content_top.join("\n")}\n" : ''
1383
-
1384
- @content.each do |title, section|
1385
- output += "#{section[:original]}\n"
1386
- output += list_section({ section: title, template: "\t- %date | %title%t2note", highlight: false, wrap_width: 0 })
1387
- end
1388
-
1389
- output + @other_content_bottom.join("\n") unless @other_content_bottom.nil?
1390
- end
1391
-
1392
1497
  ##
1393
- ## @brief Restore a backed up version of a file
1498
+ ## Restore a backed up version of a file
1394
1499
  ##
1395
- ## @param file (String) The filepath to restore
1500
+ ## @param file [String] The filepath to restore
1396
1501
  ##
1397
1502
  def restore_backup(file)
1398
1503
  if File.exist?("#{file}~")
@@ -1404,7 +1509,7 @@ module Doing
1404
1509
  end
1405
1510
 
1406
1511
  ##
1407
- ## @brief Rename doing file with date and start fresh one
1512
+ ## Rename doing file with date and start fresh one
1408
1513
  ##
1409
1514
  def rotate(opt = {})
1410
1515
  keep = opt[:keep] || 0
@@ -1487,9 +1592,9 @@ module Doing
1487
1592
  end
1488
1593
 
1489
1594
  ##
1490
- ## @brief Generate a menu of sections and allow user selection
1595
+ ## Generate a menu of sections and allow user selection
1491
1596
  ##
1492
- ## @return (String) The selected section name
1597
+ ## @return [String] The selected section name
1493
1598
  ##
1494
1599
  def choose_section
1495
1600
  choice = choose_from(sections.sort, prompt: 'Choose a section > ', fzf_args: ['--height=60%'])
@@ -1497,18 +1602,18 @@ module Doing
1497
1602
  end
1498
1603
 
1499
1604
  ##
1500
- ## @brief List available views
1605
+ ## List available views
1501
1606
  ##
1502
- ## @return (Array) View names
1607
+ ## @return [Array] View names
1503
1608
  ##
1504
1609
  def views
1505
1610
  @config.has_key?('views') ? @config['views'].keys : []
1506
1611
  end
1507
1612
 
1508
1613
  ##
1509
- ## @brief Generate a menu of views and allow user selection
1614
+ ## Generate a menu of views and allow user selection
1510
1615
  ##
1511
- ## @return (String) The selected view name
1616
+ ## @return [String] The selected view name
1512
1617
  ##
1513
1618
  def choose_view
1514
1619
  choice = choose_from(views.sort, prompt: 'Choose a view > ', fzf_args: ['--height=60%'])
@@ -1516,9 +1621,9 @@ module Doing
1516
1621
  end
1517
1622
 
1518
1623
  ##
1519
- ## @brief Gets a view from configuration
1624
+ ## Gets a view from configuration
1520
1625
  ##
1521
- ## @param title (String) The title of the view to retrieve
1626
+ ## @param title [String] The title of the view to retrieve
1522
1627
  ##
1523
1628
  def get_view(title)
1524
1629
  return @config['views'][title] if @config['views'].has_key?(title)
@@ -1527,9 +1632,9 @@ module Doing
1527
1632
  end
1528
1633
 
1529
1634
  ##
1530
- ## @brief Display contents of a section based on options
1635
+ ## Display contents of a section based on options
1531
1636
  ##
1532
- ## @param opt (Hash) Additional Options
1637
+ ## @param opt [Hash] Additional Options
1533
1638
  ##
1534
1639
  def list_section(opt = {})
1535
1640
  opt[:count] ||= 0
@@ -1564,7 +1669,6 @@ module Doing
1564
1669
 
1565
1670
  items.reverse! if opt[:order] =~ /^d/i
1566
1671
 
1567
-
1568
1672
  if opt[:interactive]
1569
1673
  opt[:menu] = !opt[:force]
1570
1674
  opt[:query] = '' # opt[:search]
@@ -1585,44 +1689,12 @@ module Doing
1585
1689
  output(items, title, is_single, opt)
1586
1690
  end
1587
1691
 
1588
- def output(items, title, is_single, opt = {})
1589
- out = nil
1590
-
1591
- raise InvalidArgument, 'Unknown output format' unless opt[:output] =~ Plugins.plugin_regex(type: :export)
1592
-
1593
- export_options = { page_title: title, is_single: is_single, options: opt }
1594
-
1595
- Plugins.plugins[:export].each do |_, options|
1596
- next unless opt[:output] =~ /^(#{options[:trigger].normalize_trigger})$/i
1597
-
1598
- out = options[:class].render(self, items, variables: export_options)
1599
- break
1600
- end
1601
-
1602
- out
1603
- end
1604
-
1605
- def load_plugins
1606
- if @config.key?('plugins') && @config['plugins']['plugin_path']
1607
- add_dir = @config['plugins']['plugin_path']
1608
- else
1609
- add_dir = File.join(@user_home, '.config', 'doing', 'plugins')
1610
- begin
1611
- FileUtils.mkdir_p(add_dir) if add_dir
1612
- rescue
1613
- nil
1614
- end
1615
- end
1616
-
1617
- Plugins.load_plugins(add_dir)
1618
- end
1619
-
1620
1692
  ##
1621
- ## @brief Move entries from a section to Archive or other specified
1693
+ ## Move entries from a section to Archive or other specified
1622
1694
  ## section
1623
1695
  ##
1624
- ## @param section (String) The source section
1625
- ## @param options (Hash) Options
1696
+ ## @param section [String] The source section
1697
+ ## @param options [Hash] Options
1626
1698
  ##
1627
1699
  def archive(section = @config['current_section'], options = {})
1628
1700
  count = options[:keep] || 0
@@ -1647,108 +1719,11 @@ module Doing
1647
1719
  end
1648
1720
 
1649
1721
  ##
1650
- ## @brief Helper function, performs the actual archiving
1722
+ ## Show all entries from the current day
1651
1723
  ##
1652
- ## @param section (String) The source section
1653
- ## @param destination (String) The destination section
1654
- ## @param opt (Hash) Additional Options
1655
- ##
1656
- def do_archive(sect, destination, opt = {})
1657
- count = opt[:count] || 0
1658
- tags = opt[:tags] || []
1659
- bool = opt[:bool] || :and
1660
- label = opt[:label] || true
1661
-
1662
- if sect =~ /^all$/i
1663
- all_sections = sections.dup
1664
- all_sections.delete(destination)
1665
- else
1666
- all_sections = [sect]
1667
- end
1668
-
1669
- counter = 0
1670
-
1671
- all_sections.each do |section|
1672
- items = @content[section][:items].dup
1673
-
1674
- moved_items = []
1675
- if !tags.empty? || opt[:search] || opt[:before]
1676
- if opt[:before]
1677
- time_string = opt[:before]
1678
- cutoff = chronify(time_string, guess: :begin)
1679
- end
1680
-
1681
- items.delete_if do |item|
1682
- if ((!tags.empty? && item.tags?(tags, bool)) || (opt[:search] && item.search(opt[:search].to_s)) || (opt[:before] && item.date < cutoff))
1683
- moved_items.push(item)
1684
- counter += 1
1685
- true
1686
- else
1687
- false
1688
- end
1689
- end
1690
- moved_items.each do |item|
1691
- if label
1692
- item.title = if section == @config['current_section']
1693
- item.title.sub(/(?: ?@from\(.*?\))?(.*)$/, '\1')
1694
- else
1695
- item.title.sub(/(?: ?@from\(.*?\))?(.*)$/, "\\1 @from(#{section})")
1696
- end
1697
- logger.debug('Moved:', "#{item.title} from #{section} to #{destination}")
1698
- end
1699
- end
1700
-
1701
- @content[section][:items] = items
1702
- @content[destination][:items].concat(moved_items)
1703
- if moved_items.length.positive?
1704
- logger.count(destination == 'Archive' ? :archived : :moved,
1705
- level: :info,
1706
- count: moved_items.length,
1707
- message: "%count %items from #{section} to #{destination}")
1708
- else
1709
- logger.info('Skipped:', 'No items were moved')
1710
- end
1711
- else
1712
- count = items.length if items.length < count
1713
-
1714
- items.map! do |item|
1715
- if label
1716
- item.title = if section == @config['current_section']
1717
- item.title.sub(/(?: ?@from\(.*?\))?(.*)$/, '\1')
1718
- else
1719
- item.title.sub(/(?: ?@from\(.*?\))?(.*)$/, "\\1 @from(#{section})")
1720
- end
1721
- logger.debug('Moved:', "#{item.title} from #{section} to #{destination}")
1722
- end
1723
- item
1724
- end
1725
-
1726
- if items.count > count
1727
- @content[destination][:items].concat(items[count..-1])
1728
- else
1729
- @content[destination][:items].concat(items)
1730
- end
1731
-
1732
- @content[section][:items] = if count.zero?
1733
- []
1734
- else
1735
- items[0..count - 1]
1736
- end
1737
-
1738
- logger.count(destination == 'Archive' ? :archived : :moved,
1739
- level: :info,
1740
- count: items.length - count,
1741
- message: "%count %items from #{section} to #{destination}")
1742
- end
1743
- end
1744
- end
1745
-
1746
- ##
1747
- ## @brief Show all entries from the current day
1748
- ##
1749
- ## @param times (Boolean) show times
1750
- ## @param output (String) output format
1751
- ## @param opt (Hash) Options
1724
+ ## @param times [Boolean] show times
1725
+ ## @param output [String] output format
1726
+ ## @param opt [Hash] Options
1752
1727
  ##
1753
1728
  def today(times = true, output = nil, opt = {})
1754
1729
  opt[:totals] ||= false
@@ -1774,13 +1749,13 @@ module Doing
1774
1749
  end
1775
1750
 
1776
1751
  ##
1777
- ## @brief Display entries within a date range
1752
+ ## Display entries within a date range
1778
1753
  ##
1779
- ## @param dates (Array) [start, end]
1780
- ## @param section (String) The section
1754
+ ## @param dates [Array] [start, end]
1755
+ ## @param section [String] The section
1781
1756
  ## @param times (Bool) Show times
1782
- ## @param output (String) Output format
1783
- ## @param opt (Hash) Additional Options
1757
+ ## @param output [String] Output format
1758
+ ## @param opt [Hash] Additional Options
1784
1759
  ##
1785
1760
  def list_date(dates, section, times = nil, output = nil, opt = {})
1786
1761
  opt[:totals] ||= false
@@ -1794,12 +1769,12 @@ module Doing
1794
1769
  end
1795
1770
 
1796
1771
  ##
1797
- ## @brief Show entries from the previous day
1772
+ ## Show entries from the previous day
1798
1773
  ##
1799
- ## @param section (String) The section
1774
+ ## @param section [String] The section
1800
1775
  ## @param times (Bool) Show times
1801
- ## @param output (String) Output format
1802
- ## @param opt (Hash) Additional Options
1776
+ ## @param output [String] Output format
1777
+ ## @param opt [Hash] Additional Options
1803
1778
  ##
1804
1779
  def yesterday(section, times = nil, output = nil, opt = {})
1805
1780
  opt[:totals] ||= false
@@ -1827,11 +1802,11 @@ module Doing
1827
1802
  end
1828
1803
 
1829
1804
  ##
1830
- ## @brief Show recent entries
1805
+ ## Show recent entries
1831
1806
  ##
1832
- ## @param count (Integer) The number to show
1833
- ## @param section (String) The section to show from, default Currently
1834
- ## @param opt (Hash) Additional Options
1807
+ ## @param count [Integer] The number to show
1808
+ ## @param section [String] The section to show from, default Currently
1809
+ ## @param opt [Hash] Additional Options
1835
1810
  ##
1836
1811
  def recent(count = 10, section = nil, opt = {})
1837
1812
  times = opt[:t] || true
@@ -1849,10 +1824,10 @@ module Doing
1849
1824
  end
1850
1825
 
1851
1826
  ##
1852
- ## @brief Show the last entry
1827
+ ## Show the last entry
1853
1828
  ##
1854
1829
  ## @param times (Bool) Show times
1855
- ## @param section (String) Section to pull from, default Currently
1830
+ ## @param section [String] Section to pull from, default Currently
1856
1831
  ##
1857
1832
  def last(times: true, section: nil, options: {})
1858
1833
  section = section.nil? || section =~ /all/i ? 'All' : guess_section(section)
@@ -1875,16 +1850,17 @@ module Doing
1875
1850
  end
1876
1851
 
1877
1852
  opts[:search] = options[:search] if options[:search]
1878
-
1853
+ opts[:case] = options[:case]
1854
+ opts[:not] = options[:negate]
1879
1855
  list_section(opts)
1880
1856
  end
1881
1857
 
1882
1858
  ##
1883
- ## @brief Uses 'autotag' configuration to turn keywords into tags for time tracking.
1859
+ ## Uses 'autotag' configuration to turn keywords into tags for time tracking.
1884
1860
  ## Does not repeat tags in a title, and only converts the first instance of an
1885
1861
  ## untagged keyword
1886
1862
  ##
1887
- ## @param text (String) The text to tag
1863
+ ## @param text [String] The text to tag
1888
1864
  ##
1889
1865
  def autotag(text)
1890
1866
  return unless text
@@ -1892,78 +1868,100 @@ module Doing
1892
1868
 
1893
1869
  original = text.dup
1894
1870
 
1895
- current_tags = text.scan(/@\w+/)
1896
- whitelisted = []
1871
+ current_tags = text.scan(/@\w+/).map { |t| t.sub(/^@/, '') }
1872
+ tagged = {
1873
+ whitelisted: [],
1874
+ synonyms: [],
1875
+ transformed: [],
1876
+ replaced: []
1877
+ }
1878
+
1897
1879
  @config['autotag']['whitelist'].each do |tag|
1898
1880
  next if text =~ /@#{tag}\b/i
1899
1881
 
1900
- text.sub!(/(?<!@)\b(#{tag.strip})\b/i) do |m|
1901
- m.downcase! if tag =~ /[a-z]/
1902
- whitelisted.push("@#{m}")
1882
+ text.sub!(/(?<= |\A)(#{tag.strip})(?= |\Z)/i) do |m|
1883
+ m.downcase! unless tag =~ /[A-Z]/
1884
+ tagged[:whitelisted].push(m)
1903
1885
  "@#{m}"
1904
1886
  end
1905
1887
  end
1906
- tail_tags = []
1888
+
1907
1889
  @config['autotag']['synonyms'].each do |tag, v|
1908
1890
  v.each do |word|
1909
1891
  next unless text =~ /\b#{word}\b/i
1910
1892
 
1911
- tail_tags.push(tag) unless current_tags.include?("@#{tag}") || whitelisted.include?("@#{tag}")
1893
+ unless current_tags.include?(tag) || tagged[:whitelisted].include?(tag)
1894
+ tagged[:synonyms].push(tag)
1895
+ tagged[:synonyms] = tagged[:synonyms].uniq
1896
+ end
1912
1897
  end
1913
1898
  end
1899
+
1914
1900
  if @config['autotag'].key? 'transform'
1915
1901
  @config['autotag']['transform'].each do |tag|
1916
1902
  next unless tag =~ /\S+:\S+/
1917
1903
 
1918
1904
  rx, r = tag.split(/:/)
1905
+ flag_rx = %r{/([r]+)$}
1906
+ if r =~ flag_rx
1907
+ flags = r.match(flag_rx)[1].split(//)
1908
+ r.sub!(flag_rx, '')
1909
+ end
1919
1910
  r.gsub!(/\$/, '\\')
1920
- rx.sub!(/^@/, '')
1921
- regex = Regexp.new('@' + rx + '\b')
1922
-
1923
- matches = text.scan(regex)
1924
- next unless matches
1911
+ rx.sub!(/^@?/, '@')
1912
+ regex = Regexp.new("(?<= |\\A)#{rx}(?= |\\Z)")
1925
1913
 
1926
- matches.each do |m|
1914
+ text.sub!(regex) do
1915
+ m = Regexp.last_match
1927
1916
  new_tag = r
1928
- if m.is_a?(Array)
1929
- index = 1
1930
- m.each do |v|
1931
- new_tag.gsub!('\\' + index.to_s, v)
1932
- index += 1
1933
- end
1917
+
1918
+ m.to_a.slice(1, m.length - 1).each_with_index do |v, idx|
1919
+ new_tag.gsub!("\\#{idx + 1}", v)
1920
+ end
1921
+ # Replace original tag if /r
1922
+ if flags&.include?('r')
1923
+ tagged[:replaced].concat(new_tag.split(/ /).map { |t| t.sub(/^@/, '') })
1924
+ new_tag.split(/ /).map { |t| t.sub(/^@?/, '@') }.join(' ')
1925
+ else
1926
+ tagged[:transformed].concat(new_tag.split(/ /).map { |t| t.sub(/^@/, '') })
1927
+ tagged[:transformed] = tagged[:transformed].uniq
1928
+ m[0]
1934
1929
  end
1935
- tail_tags.push(new_tag)
1936
1930
  end
1937
1931
  end
1938
1932
  end
1939
1933
 
1940
- logger.debug('Autotag:', "whitelisted tags: #{whitelisted.join(', ')}") unless whitelisted.empty?
1941
- new_tags = whitelisted
1942
- unless tail_tags.empty?
1943
- tags = tail_tags.uniq.map { |t| "@#{t}".cyan }.join(' ')
1944
- logger.debug('Autotag:', "synonym tags: #{tags}")
1945
- tags_a = tail_tags.map { |t| "@#{t}" }
1946
- text.add_tags!(tags_a.join(' '))
1947
- new_tags.concat(tags_a)
1948
- end
1949
1934
 
1950
- unless text == original
1951
- logger.info('Autotag:', "added #{new_tags.join(', ')} to \"#{text}\"")
1935
+ logger.debug('Autotag:', "whitelisted tags: #{tagged[:whitelisted].log_tags}") unless tagged[:whitelisted].empty?
1936
+ logger.debug('Autotag:', "synonyms: #{tagged[:synonyms].log_tags}") unless tagged[:synonyms].empty?
1937
+ logger.debug('Autotag:', "transforms: #{tagged[:transformed].log_tags}") unless tagged[:transformed].empty?
1938
+ logger.debug('Autotag:', "transform replaced: #{tagged[:replaced].log_tags}") unless tagged[:replaced].empty?
1939
+
1940
+ tail_tags = tagged[:synonyms].concat(tagged[:transformed])
1941
+ tail_tags.sort!
1942
+ tail_tags.uniq!
1943
+
1944
+ text.add_tags!(tail_tags) unless tail_tags.empty?
1945
+
1946
+ if text == original
1947
+ logger.debug('Autotag:', "no change to \"#{text}\"")
1952
1948
  else
1953
- logger.debug('Skipped:', "no change to \"#{text}\"")
1949
+ new_tags = tagged[:whitelisted].concat(tail_tags).concat(tagged[:replaced])
1950
+ logger.debug('Autotag:', "added #{new_tags.log_tags} to \"#{text}\"")
1951
+ logger.count(:autotag, level: :info, count: 1, message: 'autotag updated %count %items')
1954
1952
  end
1955
1953
 
1956
- text
1954
+ text.dedup_tags
1957
1955
  end
1958
1956
 
1959
1957
  ##
1960
- ## @brief Get total elapsed time for all tags in
1958
+ ## Get total elapsed time for all tags in
1961
1959
  ## selection
1962
1960
  ##
1963
- ## @param format (String) return format (html,
1961
+ ## @param format [String] return format (html,
1964
1962
  ## json, or text)
1965
- ## @param sort_by_name (Boolean) Sort by name if true, otherwise by time
1966
- ## @param sort_order (String) The sort order (asc or desc)
1963
+ ## @param sort_by_name [Boolean] Sort by name if true, otherwise by time
1964
+ ## @param sort_order [String] The sort order (asc or desc)
1967
1965
  ##
1968
1966
  def tag_times(format: :text, sort_by_name: false, sort_order: 'asc')
1969
1967
  return '' if @timers.empty?
@@ -2048,7 +2046,7 @@ EOS
2048
2046
  (max - k.length).times do
2049
2047
  spacer += ' '
2050
2048
  end
2051
- d, h, m = format_time(v, human: true)
2049
+ _d, h, m = format_time(v, human: true)
2052
2050
  output.push("┃ #{spacer}#{k}:#{format('%<h> 4dh %<m>02dm', h: h, m: m)} ┃")
2053
2051
  end
2054
2052
 
@@ -2093,13 +2091,13 @@ EOS
2093
2091
  end
2094
2092
 
2095
2093
  ##
2096
- ## @brief Gets the interval between entry's start
2094
+ ## Gets the interval between entry's start
2097
2095
  ## date and @done date
2098
2096
  ##
2099
- ## @param item (Hash) The entry
2100
- ## @param formatted (Bool) Return human readable
2097
+ ## @param item [Item] The entry
2098
+ ## @param formatted [Boolean] Return human readable
2101
2099
  ## time (default seconds)
2102
- ## @param record (Bool) Add the interval to the
2100
+ ## @param record [Boolean] Add the interval to the
2103
2101
  ## total for each tag
2104
2102
  ##
2105
2103
  ## @return Interval in seconds, or [d, h, m] array if
@@ -2119,28 +2117,9 @@ EOS
2119
2117
  end
2120
2118
 
2121
2119
  ##
2122
- ## @brief Record times for item tags
2120
+ ## Format human readable time from seconds
2123
2121
  ##
2124
- ## @param item The item
2125
- ##
2126
- def record_tag_times(item, seconds)
2127
- item_hash = "#{item.date.strftime('%s')}#{item.title}#{item.section}"
2128
- return if @recorded_items.include?(item_hash)
2129
- item.title.scan(/(?mi)@(\S+?)(\(.*\))?(?=\s|$)/).each do |m|
2130
- k = m[0] == 'done' ? 'All' : m[0].downcase
2131
- if @timers.key?(k)
2132
- @timers[k] += seconds
2133
- else
2134
- @timers[k] = seconds
2135
- end
2136
- @recorded_items.push(item_hash)
2137
- end
2138
- end
2139
-
2140
- ##
2141
- ## @brief Format human readable time from seconds
2142
- ##
2143
- ## @param seconds The seconds
2122
+ ## @param seconds [Integer] Seconds
2144
2123
  ##
2145
2124
  def format_time(seconds, human: false)
2146
2125
  return [0, 0, 0] if seconds.nil?
@@ -2166,6 +2145,169 @@ EOS
2166
2145
 
2167
2146
  private
2168
2147
 
2148
+ ##
2149
+ ## Wraps doing file content with additional
2150
+ ## header/footer content
2151
+ ##
2152
+ ## @return [String] concatenated content
2153
+ ##
2154
+ def combined_content
2155
+ output = @other_content_top ? "#{@other_content_top.join("\n")}\n" : ''
2156
+
2157
+ @content.each do |title, section|
2158
+ output += "#{section[:original]}\n"
2159
+ output += list_section({ section: title, template: "\t- %date | %title%t2note", highlight: false, wrap_width: 0 })
2160
+ end
2161
+
2162
+ output + @other_content_bottom.join("\n") unless @other_content_bottom.nil?
2163
+ end
2164
+
2165
+ ##
2166
+ ## Generate output using available export plugins
2167
+ ##
2168
+ ## @param items [Array] The items
2169
+ ## @param title [String] Page title
2170
+ ## @param is_single [Boolean] Indicates if single
2171
+ ## section
2172
+ ## @param opt [Hash] Additional options
2173
+ ##
2174
+ ## @return [String] formatted output based on opt[:output]
2175
+ ## template trigger
2176
+ ##
2177
+ def output(items, title, is_single, opt = {})
2178
+ out = nil
2179
+
2180
+ raise InvalidArgument, 'Unknown output format' unless opt[:output] =~ Plugins.plugin_regex(type: :export)
2181
+
2182
+ export_options = { page_title: title, is_single: is_single, options: opt }
2183
+
2184
+ Plugins.plugins[:export].each do |_, options|
2185
+ next unless opt[:output] =~ /^(#{options[:trigger].normalize_trigger})$/i
2186
+
2187
+ out = options[:class].render(self, items, variables: export_options)
2188
+ break
2189
+ end
2190
+
2191
+ out
2192
+ end
2193
+
2194
+ ##
2195
+ ## Record times for item tags
2196
+ ##
2197
+ ## @param item [Item] The item to record
2198
+ ##
2199
+ def record_tag_times(item, seconds)
2200
+ item_hash = "#{item.date.strftime('%s')}#{item.title}#{item.section}"
2201
+ return if @recorded_items.include?(item_hash)
2202
+ item.title.scan(/(?mi)@(\S+?)(\(.*\))?(?=\s|$)/).each do |m|
2203
+ k = m[0] == 'done' ? 'All' : m[0].downcase
2204
+ if @timers.key?(k)
2205
+ @timers[k] += seconds
2206
+ else
2207
+ @timers[k] = seconds
2208
+ end
2209
+ @recorded_items.push(item_hash)
2210
+ end
2211
+ end
2212
+
2213
+ ##
2214
+ ## Helper function, performs the actual archiving
2215
+ ##
2216
+ ## @param sect [String] The source section
2217
+ ## @param destination [String] The destination
2218
+ ## section
2219
+ ## @param opt [Hash] Additional Options
2220
+ ##
2221
+ def do_archive(sect, destination, opt = {})
2222
+ count = opt[:count] || 0
2223
+ tags = opt[:tags] || []
2224
+ bool = opt[:bool] || :and
2225
+ label = opt[:label] || true
2226
+
2227
+ if sect =~ /^all$/i
2228
+ all_sections = sections.dup
2229
+ all_sections.delete(destination)
2230
+ else
2231
+ all_sections = [sect]
2232
+ end
2233
+
2234
+ counter = 0
2235
+
2236
+ all_sections.each do |section|
2237
+ items = @content[section][:items].dup
2238
+
2239
+ moved_items = []
2240
+ if !tags.empty? || opt[:search] || opt[:before]
2241
+ if opt[:before]
2242
+ time_string = opt[:before]
2243
+ cutoff = chronify(time_string, guess: :begin)
2244
+ end
2245
+
2246
+ items.delete_if do |item|
2247
+ if ((!tags.empty? && item.tags?(tags, bool)) || (opt[:search] && item.search(opt[:search].to_s)) || (opt[:before] && item.date < cutoff))
2248
+ moved_items.push(item)
2249
+ counter += 1
2250
+ true
2251
+ else
2252
+ false
2253
+ end
2254
+ end
2255
+ moved_items.each do |item|
2256
+ if label
2257
+ item.title = if section == @config['current_section']
2258
+ item.title.sub(/(?: ?@from\(.*?\))?(.*)$/, '\1')
2259
+ else
2260
+ item.title.sub(/(?: ?@from\(.*?\))?(.*)$/, "\\1 @from(#{section})")
2261
+ end
2262
+ logger.debug('Moved:', "#{item.title} from #{section} to #{destination}")
2263
+ end
2264
+ end
2265
+
2266
+ @content[section][:items] = items
2267
+ @content[destination][:items].concat(moved_items)
2268
+ if moved_items.length.positive?
2269
+ logger.count(destination == 'Archive' ? :archived : :moved,
2270
+ level: :info,
2271
+ count: moved_items.length,
2272
+ message: "%count %items from #{section} to #{destination}")
2273
+ else
2274
+ logger.info('Skipped:', 'No items were moved')
2275
+ end
2276
+ else
2277
+ count = items.length if items.length < count
2278
+
2279
+ items.map! do |item|
2280
+ if label
2281
+ item.title = if section == @config['current_section']
2282
+ item.title.sub(/(?: ?@from\(.*?\))?(.*)$/, '\1')
2283
+ else
2284
+ item.title.sub(/(?: ?@from\(.*?\))?(.*)$/, "\\1 @from(#{section})")
2285
+ end
2286
+ logger.debug('Moved:', "#{item.title} from #{section} to #{destination}")
2287
+ end
2288
+ item
2289
+ end
2290
+
2291
+ if items.count > count
2292
+ @content[destination][:items].concat(items[count..-1])
2293
+ else
2294
+ @content[destination][:items].concat(items)
2295
+ end
2296
+
2297
+ @content[section][:items] = if count.zero?
2298
+ []
2299
+ else
2300
+ items[0..count - 1]
2301
+ end
2302
+
2303
+ logger.count(destination == 'Archive' ? :archived : :moved,
2304
+ level: :info,
2305
+ count: items.length - count,
2306
+ message: "%count %items from #{section} to #{destination}")
2307
+ end
2308
+ end
2309
+ end
2310
+
2169
2311
  def run_after
2170
2312
  return unless @config.key?('run_after')
2171
2313