doing 2.1.37 → 2.1.40

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 (121) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +61 -0
  3. data/Gemfile.lock +1 -1
  4. data/README.md +1 -1
  5. data/Rakefile +7 -1
  6. data/bin/commands/config.rb +43 -34
  7. data/bin/commands/done.rb +1 -18
  8. data/bin/commands/finish.rb +30 -25
  9. data/bin/commands/grep.rb +3 -14
  10. data/bin/commands/last.rb +2 -8
  11. data/bin/commands/meanwhile.rb +13 -6
  12. data/bin/commands/now.rb +2 -4
  13. data/bin/commands/on.rb +4 -15
  14. data/bin/commands/recent.rb +2 -8
  15. data/bin/commands/reset.rb +24 -1
  16. data/bin/commands/select.rb +1 -1
  17. data/bin/commands/show.rb +8 -16
  18. data/bin/commands/since.rb +1 -12
  19. data/bin/commands/today.rb +2 -13
  20. data/bin/commands/view.rb +1 -1
  21. data/bin/commands/yesterday.rb +2 -13
  22. data/bin/doing +41 -36
  23. data/docs/doc/Array.html +1 -1
  24. data/docs/doc/BooleanTermParser/Clause.html +1 -1
  25. data/docs/doc/BooleanTermParser/Operator.html +1 -1
  26. data/docs/doc/BooleanTermParser/Query.html +1 -1
  27. data/docs/doc/BooleanTermParser/QueryParser.html +1 -1
  28. data/docs/doc/BooleanTermParser/QueryTransformer.html +1 -1
  29. data/docs/doc/BooleanTermParser.html +1 -1
  30. data/docs/doc/Doing/Color.html +166 -20
  31. data/docs/doc/Doing/Completion.html +1 -1
  32. data/docs/doc/Doing/Configuration.html +1 -1
  33. data/docs/doc/Doing/Errors/DoingNoTraceError.html +7 -3
  34. data/docs/doc/Doing/Errors/DoingRuntimeError.html +7 -3
  35. data/docs/doc/Doing/Errors/DoingStandardError.html +1 -1
  36. data/docs/doc/Doing/Errors/EmptyInput.html +10 -2
  37. data/docs/doc/Doing/Errors/HistoryLimitError.html +194 -0
  38. data/docs/doc/Doing/Errors/InvalidPlugin.html +194 -0
  39. data/docs/doc/Doing/Errors/MissingBackupFile.html +194 -0
  40. data/docs/doc/Doing/Errors/NoResults.html +10 -2
  41. data/docs/doc/Doing/Errors/PluginException.html +1 -1
  42. data/docs/doc/Doing/Errors/UserCancelled.html +10 -2
  43. data/docs/doc/Doing/Errors/WrongCommand.html +10 -2
  44. data/docs/doc/Doing/Errors.html +9 -9
  45. data/docs/doc/Doing/Hooks.html +1 -1
  46. data/docs/doc/Doing/Item.html +114 -1576
  47. data/docs/doc/Doing/Items.html +121 -5
  48. data/docs/doc/Doing/Logger.html +1 -1
  49. data/docs/doc/Doing/Note.html +1 -1
  50. data/docs/doc/Doing/Pager.html +1 -1
  51. data/docs/doc/Doing/Plugins.html +1 -1
  52. data/docs/doc/Doing/Prompt.html +2 -2
  53. data/docs/doc/Doing/Section.html +1 -1
  54. data/docs/doc/Doing/TemplateString.html +2 -2
  55. data/docs/doc/Doing/Types.html +1 -1
  56. data/docs/doc/Doing/Util/Backup.html +5 -5
  57. data/docs/doc/Doing/Util.html +1 -1
  58. data/docs/doc/Doing/WWID.html +197 -4033
  59. data/docs/doc/Doing.html +2 -2
  60. data/docs/doc/FalseClass.html +1 -1
  61. data/docs/doc/GLI/Commands/Help.html +1 -1
  62. data/docs/doc/GLI/Commands/MarkdownDocumentListener.html +1 -1
  63. data/docs/doc/GLI/Commands.html +1 -1
  64. data/docs/doc/GLI.html +1 -1
  65. data/docs/doc/Hash.html +1 -1
  66. data/docs/doc/Object.html +1 -1
  67. data/docs/doc/PhraseParser/Operator.html +1 -1
  68. data/docs/doc/PhraseParser/PhraseClause.html +1 -1
  69. data/docs/doc/PhraseParser/Query.html +1 -1
  70. data/docs/doc/PhraseParser/QueryParser.html +1 -1
  71. data/docs/doc/PhraseParser/QueryTransformer.html +1 -1
  72. data/docs/doc/PhraseParser/TermClause.html +1 -1
  73. data/docs/doc/PhraseParser.html +1 -1
  74. data/docs/doc/Status.html +1 -1
  75. data/docs/doc/String.html +1 -1
  76. data/docs/doc/Symbol.html +1 -1
  77. data/docs/doc/Time.html +1 -1
  78. data/docs/doc/TrueClass.html +1 -1
  79. data/docs/doc/_index.html +26 -5
  80. data/docs/doc/class_list.html +1 -1
  81. data/docs/doc/file.README.html +2 -2
  82. data/docs/doc/index.html +2 -2
  83. data/docs/doc/method_list.html +237 -709
  84. data/docs/doc/top-level-namespace.html +3 -3
  85. data/docs/index.md +1 -1
  86. data/doing.rdoc +54 -7
  87. data/lib/completion/_doing.zsh +6 -6
  88. data/lib/completion/doing.bash +10 -10
  89. data/lib/completion/doing.fish +8 -2
  90. data/lib/doing/add_options.rb +31 -1
  91. data/lib/doing/chronify/array.rb +68 -18
  92. data/lib/doing/chronify/string.rb +3 -1
  93. data/lib/doing/colors.rb +77 -30
  94. data/lib/doing/completion.rb +4 -5
  95. data/lib/doing/errors.rb +51 -35
  96. data/lib/doing/hooks.rb +3 -3
  97. data/lib/doing/item/dates.rb +112 -0
  98. data/lib/doing/item/query.rb +433 -0
  99. data/lib/doing/item/state.rb +59 -0
  100. data/lib/doing/item/tags.rb +87 -0
  101. data/lib/doing/item.rb +6 -537
  102. data/lib/doing/items.rb +39 -14
  103. data/lib/doing/plugin_manager.rb +3 -3
  104. data/lib/doing/plugins/export/template_export.rb +4 -4
  105. data/lib/doing/plugins/import/cal_to_json.scpt +0 -0
  106. data/lib/doing/prompt.rb +6 -8
  107. data/lib/doing/string/tags.rb +8 -2
  108. data/lib/doing/util_backup.rb +6 -8
  109. data/lib/doing/version.rb +1 -1
  110. data/lib/doing/wwid/display.rb +399 -0
  111. data/lib/doing/wwid/editor.rb +214 -0
  112. data/lib/doing/wwid/filetools.rb +186 -0
  113. data/lib/doing/wwid/filter.rb +218 -0
  114. data/lib/doing/wwid/guess.rb +87 -0
  115. data/lib/doing/wwid/interactive.rb +385 -0
  116. data/lib/doing/wwid/modify.rb +618 -0
  117. data/lib/doing/wwid/tags.rb +54 -0
  118. data/lib/doing/wwid/timers.rb +345 -0
  119. data/lib/doing/wwid/wwidutil.rb +104 -0
  120. data/lib/doing/wwid.rb +31 -2308
  121. metadata +19 -2
@@ -0,0 +1,214 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Doing
4
+ class WWID
5
+ # Editor methods for WWID class
6
+ module Editor
7
+ ##
8
+ ## Create a process for an editor and wait for the file handle to return
9
+ ##
10
+ ## @param input [String] Text input for editor
11
+ ##
12
+ def fork_editor(input = '', message: :default)
13
+ # raise NonInteractive, 'Non-interactive terminal' unless $stdout.isatty || ENV['DOING_EDITOR_TEST']
14
+
15
+ raise MissingEditor, 'No EDITOR variable defined in environment' if Util.default_editor.nil?
16
+
17
+ tmpfile = Tempfile.new(['doing', '.md'])
18
+
19
+ File.open(tmpfile.path, 'w+') do |f|
20
+ f.puts input
21
+ unless message.nil?
22
+ f.puts message == :default ? "# The first line is the entry title, any lines after that are added as a note" : message
23
+ end
24
+ end
25
+
26
+ pid = Process.fork { system("#{Util.editor_with_args} #{tmpfile.path}") }
27
+
28
+ trap('INT') do
29
+ begin
30
+ Process.kill(9, pid)
31
+ rescue StandardError
32
+ Errno::ESRCH
33
+ end
34
+ tmpfile.unlink
35
+ tmpfile.close!
36
+ exit 0
37
+ end
38
+
39
+ Process.wait(pid)
40
+
41
+ begin
42
+ if $?.exitstatus == 0
43
+ input = IO.read(tmpfile.path)
44
+ else
45
+ exit_now! 'Cancelled'
46
+ end
47
+ ensure
48
+ tmpfile.close
49
+ tmpfile.unlink
50
+ end
51
+
52
+ input.split(/\n/).delete_if(&:ignore?).join("\n")
53
+ end
54
+
55
+ ##
56
+ ## Takes a multi-line string and formats it as an entry
57
+ ##
58
+ ## @param input [String] The string to parse
59
+ ##
60
+ ## @return [Array] [[String]title, [Note]note]
61
+ ##
62
+ def format_input(input)
63
+ raise EmptyInput, 'No content in entry' if input.nil? || input.strip.empty?
64
+
65
+ input_lines = input.split(/[\n\r]+/).delete_if(&:ignore?)
66
+ title = input_lines[0]&.strip
67
+ raise EmptyInput, 'No content in first line' if title.nil? || title.strip.empty?
68
+
69
+ date = nil
70
+ iso_rx = /\d{4}-\d\d-\d\d \d\d:\d\d/
71
+ date_rx = /^(?:\s*- )?(?<date>.*?) \| (?=\S)/
72
+
73
+ raise EmptyInput, 'No content' if title.sub(/^.*?\| */, '').strip.empty?
74
+
75
+ title.expand_date_tags(Doing.setting('date_tags'))
76
+
77
+ if title =~ date_rx
78
+ m = title.match(date_rx)
79
+ d = m['date']
80
+ date = if d =~ iso_rx
81
+ Time.parse(d)
82
+ else
83
+ d.chronify(guess: :begin)
84
+ end
85
+ title.sub!(date_rx, '').strip!
86
+ end
87
+
88
+ note = Note.new
89
+ note.add(input_lines[1..-1]) if input_lines.length > 1
90
+ # If title line ends in a parenthetical, use that as the note
91
+ if note.empty? && title =~ /\s+\(.*?\)$/
92
+ title.sub!(/\s+\((?<note>.*?)\)$/) do
93
+ m = Regexp.last_match
94
+ note.add(m['note'])
95
+ ''
96
+ end
97
+ end
98
+
99
+ note.strip_lines!
100
+ note.compress
101
+
102
+ [date, title, note]
103
+ end
104
+
105
+ def add_with_editor(**options)
106
+ raise MissingEditor, 'No EDITOR variable defined in environment' if Util.default_editor.nil?
107
+
108
+ input = options[:date].strftime('%F %R | ')
109
+ input += options[:title]
110
+ input += "\n#{options[:note]}" if options[:note]
111
+ input = fork_editor(input).strip
112
+
113
+ d, title, note = format_input(input)
114
+ raise EmptyInput, 'No content' if title.empty?
115
+
116
+ if options[:ask]
117
+ ask_note = Doing::Prompt.read_lines(prompt: 'Add a note')
118
+ note.add(ask_note) unless ask_note.empty?
119
+ end
120
+
121
+ date = d.nil? ? options[:date] : d
122
+ finish = options[:finish_last] || false
123
+ add_item(title.cap_first, options[:section], { note: note, back: date, timed: finish })
124
+ write(@doing_file)
125
+ end
126
+
127
+ def edit_items(items)
128
+ items.sort_by! { |i| i.date }
129
+ editable_items = []
130
+
131
+ items.each do |i|
132
+ editable = "#{i.date.strftime('%F %R')} | #{i.title}"
133
+ old_note = i.note ? i.note.strip_lines.join("\n") : nil
134
+ editable += "\n#{old_note}" unless old_note.nil?
135
+ editable_items << editable
136
+ end
137
+ divider = "-----------"
138
+ notice =<<~EONOTICE
139
+ # - You may delete entries, but leave all divider lines (---) in place.
140
+ # - Start and @done dates replaced with a time string (yesterday 3pm) will
141
+ # be parsed automatically. Do not delete the pipe (|) between start date
142
+ # and entry title.
143
+ EONOTICE
144
+ input = "#{editable_items.map(&:strip).join("\n#{divider}\n")}\n\n#{notice}"
145
+
146
+ new_items = fork_editor(input).split(/^#{divider}/).map(&:strip)
147
+
148
+ new_items.each_with_index do |new_item, i|
149
+ input_lines = new_item.split(/[\n\r]+/).delete_if(&:ignore?)
150
+ first_line = input_lines[0]&.strip
151
+
152
+ if first_line.nil? || first_line =~ /^#{divider.strip}$/ || first_line.strip.empty?
153
+ deleted = @content.delete_item(items[i], single: new_items.count == 1)
154
+ Hooks.trigger :post_entry_removed, self, deleted
155
+ Doing.logger.info('Deleted:', deleted.title)
156
+ else
157
+ date, title, note = format_input(new_item)
158
+
159
+ note.map!(&:strip)
160
+ note.delete_if(&:ignore?)
161
+ item = items[i]
162
+ old_item = item.clone
163
+ item.date = date || items[i].date
164
+ item.title = title
165
+ item.note = note
166
+ if (item.equal?(old_item))
167
+ Doing.logger.count(:skipped, level: :debug)
168
+ else
169
+ Doing.logger.count(:updated)
170
+ Hooks.trigger :post_entry_updated, self, item, old_item
171
+ end
172
+ end
173
+ end
174
+ end
175
+
176
+ ##
177
+ ## Edit the last entry
178
+ ##
179
+ ## @param section [String] The section, default "All"
180
+ ##
181
+ def edit_last(section: 'All', options: {})
182
+ options[:section] = guess_section(section)
183
+
184
+ item = last_entry(options)
185
+
186
+ if item.nil?
187
+ logger.debug('Skipped:', 'No entries found')
188
+ return
189
+ end
190
+
191
+ old_item = item.clone
192
+ content = ["#{item.date.strftime('%F %R')} | #{item.title.dup}"]
193
+ content << item.note.strip_lines.join("\n") unless item.note.empty?
194
+ new_item = fork_editor(content.join("\n"))
195
+ date, title, note = format_input(new_item)
196
+ date ||= item.date
197
+
198
+ if title.nil? || title.empty?
199
+ logger.debug('Skipped:', 'No content provided')
200
+ elsif title == item.title && note.equal?(item.note) && date.equal?(item.date)
201
+ logger.debug('Skipped:', 'No change in content')
202
+ else
203
+ item.date = date unless date.nil?
204
+ item.title = title
205
+ item.note.add(note, replace: true)
206
+ logger.info('Edited:', item.title)
207
+ Hooks.trigger :post_entry_updated, self, item, old_item
208
+
209
+ write(@doing_file)
210
+ end
211
+ end
212
+ end
213
+ end
214
+ end
@@ -0,0 +1,186 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Doing
4
+ # File methods for WWID class
5
+ class WWID
6
+ module FileTools
7
+ ##
8
+ ## Initializes the doing file.
9
+ ##
10
+ ## @param path [String] Override path to a doing file, optional
11
+ ##
12
+ def init_doing_file(path = nil)
13
+ @doing_file = File.expand_path(Doing.setting('doing_file'))
14
+
15
+ if path.nil?
16
+ create(@doing_file) unless File.exist?(@doing_file)
17
+ input = IO.read(@doing_file)
18
+ input = input.force_encoding('utf-8') if input.respond_to? :force_encoding
19
+ logger.debug('Read:', "read file #{@doing_file}")
20
+ elsif File.exist?(File.expand_path(path)) && File.file?(File.expand_path(path)) && File.stat(File.expand_path(path)).size.positive?
21
+ @doing_file = File.expand_path(path)
22
+ input = IO.read(File.expand_path(path))
23
+ input = input.force_encoding('utf-8') if input.respond_to? :force_encoding
24
+ logger.debug('Read:', "read file #{File.expand_path(path)}")
25
+ elsif path.length < 256
26
+ @doing_file = File.expand_path(path)
27
+ create(path)
28
+ input = IO.read(File.expand_path(path))
29
+ input = input.force_encoding('utf-8') if input.respond_to? :force_encoding
30
+ logger.debug('Read:', "read file #{File.expand_path(path)}")
31
+ end
32
+
33
+ @other_content_top = []
34
+ @other_content_bottom = []
35
+
36
+ section = nil
37
+ lines = input.split(/[\n\r]/)
38
+
39
+ lines.each do |line|
40
+ next if line =~ /^\s*$/
41
+
42
+ if line =~ /^(\S[\S ]+):\s*(@\S+\s*)*$/
43
+ section = Regexp.last_match(1)
44
+ @content.add_section(Section.new(section, original: line), log: false)
45
+ elsif line =~ /^\s*- (\d{4}-\d\d-\d\d \d\d:\d\d) \| (.*)/
46
+ if section.nil?
47
+ section = 'Uncategorized'
48
+ @content.add_section(Section.new(section, original: 'Uncategorized:'), log: false)
49
+ end
50
+
51
+ date = Regexp.last_match(1).strip
52
+ title = Regexp.last_match(2).strip
53
+ item = Item.new(date, title, section)
54
+ @content.push(item)
55
+ elsif @content.count.zero?
56
+ # if content[section].items.length - 1 == current
57
+ @other_content_top.push(line)
58
+ elsif line =~ /^\S/
59
+ @other_content_bottom.push(line)
60
+ else
61
+ prev_item = @content.last
62
+ prev_item.note = Note.new unless prev_item.note
63
+
64
+ prev_item.note.add(line)
65
+ # end
66
+ end
67
+ end
68
+
69
+ Hooks.trigger :post_read, self
70
+ @initial_content = @content.clone
71
+ end
72
+
73
+ ##
74
+ ## Create a new doing file
75
+ ##
76
+ def create(filename = nil)
77
+ filename = @doing_file if filename.nil?
78
+ return if File.exist?(filename) && File.stat(filename).size.positive?
79
+
80
+ FileUtils.mkdir_p(File.dirname(filename)) unless File.directory?(File.dirname(filename))
81
+
82
+ File.open(filename, 'w+') do |f|
83
+ f.puts "#{Doing.setting('current_section')}:"
84
+ end
85
+ end
86
+
87
+ ##
88
+ ## Write content to file or STDOUT
89
+ ##
90
+ ## @param file [String] The filepath to write to
91
+ ##
92
+ def write(file = nil, backup: true)
93
+ Hooks.trigger :pre_write, self, file
94
+ output = combined_content
95
+ if file.nil?
96
+ $stdout.puts output
97
+ else
98
+ Util.write_to_file(file, output, backup: backup)
99
+ run_after if Doing.setting('run_after')
100
+ end
101
+ end
102
+
103
+ ##
104
+ ## Rename doing file with date and start fresh one
105
+ ##
106
+ def rotate(opt)
107
+ opt ||= {}
108
+ keep = opt[:keep] || 0
109
+ tags = []
110
+ tags.concat(opt[:tag].split(/ *, */).map { |t| t.sub(/^@/, '').strip }) if opt[:tag]
111
+ bool = opt[:bool] || :and
112
+ sect = opt[:section] !~ /^all$/i ? guess_section(opt[:section]) : 'all'
113
+
114
+ section = guess_section(sect)
115
+
116
+ section_items = @content.in_section(section)
117
+ max = section_items.count - keep.to_i
118
+
119
+ counter = 0
120
+ new_content = Items.new
121
+
122
+ section_items.each do |item|
123
+ break if counter >= max
124
+ if opt[:before]
125
+ time_string = opt[:before]
126
+ cutoff = time_string.chronify(guess: :begin)
127
+ end
128
+
129
+ unless ((!tags.empty? && !item.tags?(tags, bool)) || (opt[:search] && !item.search(opt[:search].to_s)) || (opt[:before] && item.date >= cutoff))
130
+ new_item = @content.delete(item)
131
+ Hooks.trigger :post_entry_removed, self, item.clone
132
+ raise DoingRuntimeError, "Error deleting item: #{item}" if new_item.nil?
133
+
134
+ new_content.add_section(new_item.section, log: false)
135
+ new_content.push(new_item)
136
+ counter += 1
137
+ end
138
+ end
139
+
140
+ if counter.positive?
141
+ logger.count(:rotated,
142
+ level: :info,
143
+ count: counter,
144
+ message: "Rotated %count %items")
145
+ else
146
+ logger.info('Skipped:', 'No items were rotated')
147
+ end
148
+
149
+ write(@doing_file)
150
+
151
+ file = @doing_file.sub(/(\.\w+)$/, "_#{Time.now.strftime('%Y-%m-%d')}\\1")
152
+ if File.exist?(file)
153
+ init_doing_file(file)
154
+ @content.concat(new_content).uniq!
155
+ logger.warn('File update:', "added entries to existing file: #{file}")
156
+ else
157
+ @content = new_content
158
+ logger.warn('File update:', "created new file: #{file}")
159
+ end
160
+
161
+ write(file, backup: false)
162
+ end
163
+
164
+ private
165
+
166
+ ##
167
+ ## Wraps doing file content with additional
168
+ ## header/footer content
169
+ ##
170
+ ## @return [String] concatenated content
171
+ ## @api private
172
+ def combined_content
173
+ output = @other_content_top ? "#{@other_content_top.join("\n")}\n" : ''
174
+ was_color = Color.coloring?
175
+ Color.coloring = false
176
+ @content.dedup!(match_section: true)
177
+ output += @content.to_s
178
+ output += @other_content_bottom.join("\n") unless @other_content_bottom.nil?
179
+ # Just strip all ANSI colors from the content before writing to doing file
180
+ Color.coloring = was_color
181
+
182
+ output.uncolor
183
+ end
184
+ end
185
+ end
186
+ end
@@ -0,0 +1,218 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Doing
4
+ class WWID
5
+ # Filter methods for WWID class
6
+ module Filter
7
+ def fuzzy_filter_items(items, opt: {})
8
+ scannable = items.map.with_index { |item, idx| "#{item.title} #{item.note.join(' ')}".gsub(/[|*?!]/, '') + "|#{idx}" }.join("\n")
9
+
10
+ fzf_args = [
11
+ '--multi',
12
+ %(--filter="#{opt[:search].sub(/^'?/, "'")}"),
13
+ '--no-sort',
14
+ '-d "\|"',
15
+ '--nth=1'
16
+ ]
17
+ if opt[:case]
18
+ fzf_args << case opt[:case].normalize_case
19
+ when :sensitive
20
+ '+i'
21
+ when :ignore
22
+ '-i'
23
+ end
24
+ end
25
+ # fzf_args << '-e' if opt[:exact]
26
+ # puts fzf_args.join(' ')
27
+ res = `echo #{Shellwords.escape(scannable)}|#{Prompt.fzf} #{fzf_args.join(' ')}`
28
+ selected = Items.new
29
+ res.split(/\n/).each do |item|
30
+ idx = item.match(/\|(\d+)$/)[1].to_i
31
+ selected.push(items[idx])
32
+ end
33
+ selected
34
+ end
35
+
36
+ ##
37
+ ## Filter items based on search criteria
38
+ ##
39
+ ## @param items [Array] The items to filter (if empty, filters all items)
40
+ ## @param opt [Hash] The filter parameters
41
+ ##
42
+ ## @option opt [String] :section ('all')
43
+ ## @option opt [Boolean] :unfinished (false)
44
+ ## @option opt [Array or String] :tag ([]) Array or comma-separated string
45
+ ## @option opt [Symbol] :tag_bool (:and) :and, :or, :not
46
+ ## @option opt [String] :search ('') string, optional regex with `/string/`
47
+ ## @option opt [Array] :date_filter (nil) [[Time]start, [Time]end]
48
+ ## @option opt [Boolean] :only_timed (false)
49
+ ## @option opt [String] :before (nil) Date/Time string, unparsed
50
+ ## @option opt [String] :after (nil) Date/Time string, unparsed
51
+ ## @option opt [Boolean] :today (false) limit to entries from today
52
+ ## @option opt [Boolean] :yesterday (false) limit to entries from yesterday
53
+ ## @option opt [Number] :count (0) max entries to return
54
+ ## @option opt [String] :age (new) 'old' or 'new'
55
+ ## @option opt [Array] :val (nil) Array of tag value queries
56
+ ##
57
+ def filter_items(items = Items.new, opt: {})
58
+ logger.benchmark(:filter_items, :start)
59
+ time_rx = /^(\d{1,2}+(:\d{1,2}+)?( *(am|pm))?|midnight|noon)$/i
60
+
61
+ if items.nil? || items.empty?
62
+ section = opt[:section] ? guess_section(opt[:section]) : 'All'
63
+ items = section =~ /^all$/i ? @content.clone : @content.in_section(section)
64
+ end
65
+
66
+ if !opt[:time_filter]
67
+ opt[:time_filter] = [nil, nil]
68
+ if opt[:from] && !opt[:date_filter]
69
+ if opt[:from][0].is_a?(String) && opt[:from][0] =~ time_rx
70
+ opt[:time_filter] = opt[:from]
71
+ elsif opt[:from][0].is_a?(Time)
72
+ opt[:date_filter] = opt[:from]
73
+ end
74
+ end
75
+ end
76
+
77
+ if opt[:before].is_a?(String) && opt[:before] =~ time_rx
78
+ opt[:time_filter][1] = opt[:before]
79
+ opt[:before] = nil
80
+ end
81
+
82
+ if opt[:after].is_a?(String) && opt[:after] =~ time_rx
83
+ opt[:time_filter][0] = opt[:after]
84
+ opt[:after] = nil
85
+ end
86
+
87
+ items.sort_by! { |item| [item.date, item.title.downcase] }.reverse
88
+
89
+ filtered_items = items.select do |item|
90
+ keep = true
91
+ if opt[:unfinished]
92
+ finished = item.tags?('done', :and)
93
+ finished = opt[:not] ? !finished : finished
94
+ keep = false if finished
95
+ end
96
+
97
+ if keep && opt[:val]&.count&.positive?
98
+ bool = opt[:bool].normalize_bool if opt[:bool]
99
+ bool ||= :and
100
+ bool = :and if bool == :pattern
101
+
102
+ val_match = opt[:val].nil? || opt[:val].empty? ? true : item.tag_values?(opt[:val], bool)
103
+ keep = false unless val_match
104
+ keep = opt[:not] ? !keep : keep
105
+ end
106
+
107
+ if keep && opt[:tag]
108
+ opt[:tag_bool] = opt[:bool].normalize_bool if opt[:bool]
109
+ opt[:tag_bool] ||= :and
110
+ tag_match = opt[:tag].nil? || opt[:tag].empty? ? true : item.tags?(opt[:tag], opt[:tag_bool])
111
+ keep = false unless tag_match
112
+ keep = opt[:not] ? !keep : keep
113
+ end
114
+
115
+ if keep && opt[:search]
116
+ search_match = if opt[:search].nil? || opt[:search].empty?
117
+ true
118
+ else
119
+ item.search(opt[:search], case_type: opt[:case].normalize_case)
120
+ end
121
+
122
+ keep = false unless search_match
123
+ keep = opt[:not] ? !keep : keep
124
+ end
125
+
126
+ if keep && opt[:date_filter]&.length == 2
127
+ start_date = opt[:date_filter][0]
128
+ end_date = opt[:date_filter][1]
129
+
130
+ in_date_range = if end_date
131
+ item.date >= start_date && item.date <= end_date
132
+ else
133
+ item.date.strftime('%F') == start_date.strftime('%F')
134
+ end
135
+ keep = false unless in_date_range
136
+ keep = opt[:not] ? !keep : keep
137
+ end
138
+
139
+ if keep && opt[:time_filter][0] || opt[:time_filter][1]
140
+ start_string = if opt[:time_filter][0].nil?
141
+ "#{item.date.strftime('%Y-%m-%d')} 12am"
142
+ else
143
+ "#{item.date.strftime('%Y-%m-%d')} #{opt[:time_filter][0]}"
144
+ end
145
+ start_time = start_string.chronify(guess: :begin)
146
+
147
+ end_string = if opt[:time_filter][1].nil?
148
+ "#{item.date.to_datetime.next_day.strftime('%Y-%m-%d')} 12am"
149
+ else
150
+ "#{item.date.strftime('%Y-%m-%d')} #{opt[:time_filter][1]}"
151
+ end
152
+ end_time = end_string.chronify(guess: :end)
153
+
154
+ in_time_range = item.date >= start_time && item.date <= end_time
155
+ keep = false unless in_time_range
156
+ keep = opt[:not] ? !keep : keep
157
+ end
158
+
159
+ keep = false if keep && opt[:only_timed] && !item.interval
160
+
161
+ if keep && opt[:tag_filter]
162
+ keep = item.tags?(opt[:tag_filter]['tags'], opt[:tag_filter]['bool'])
163
+ keep = opt[:not] ? !keep : keep
164
+ end
165
+
166
+ if keep && opt[:before]
167
+ before = opt[:before]
168
+ cutoff = if before =~ time_rx
169
+ "#{item.date.strftime('%Y-%m-%d')} #{before}".chronify(guess: :begin)
170
+ elsif before.is_a?(String)
171
+ before.chronify(guess: :begin)
172
+ else
173
+ before
174
+ end
175
+ keep = cutoff && item.date <= cutoff
176
+ keep = opt[:not] ? !keep : keep
177
+ end
178
+
179
+ if keep && opt[:after]
180
+ after = opt[:after]
181
+ cutoff = if after =~ time_rx
182
+ "#{item.date.strftime('%Y-%m-%d')} #{after}".chronify(guess: :end)
183
+ elsif after.is_a?(String)
184
+ after.chronify(guess: :end)
185
+ else
186
+ after
187
+ end
188
+ keep = cutoff && item.date >= cutoff
189
+ keep = opt[:not] ? !keep : keep
190
+ end
191
+
192
+ if keep && opt[:today]
193
+ keep = item.date >= Date.today.to_time && item.date < Date.today.next_day.to_time
194
+ keep = opt[:not] ? !keep : keep
195
+ elsif keep && opt[:yesterday]
196
+ keep = item.date >= Date.today.prev_day.to_time && item.date < Date.today.to_time
197
+ keep = opt[:not] ? !keep : keep
198
+ end
199
+
200
+ keep
201
+ end
202
+ count = opt[:count].to_i&.positive? ? opt[:count].to_i : filtered_items.count
203
+
204
+ output = Items.new
205
+
206
+ if opt[:age] && opt[:age].normalize_age == :oldest
207
+ output.concat(filtered_items.slice(0, count).reverse)
208
+ else
209
+ output.concat(filtered_items.reverse.slice(0, count))
210
+ end
211
+
212
+ logger.benchmark(:filter_items, :finish)
213
+
214
+ output
215
+ end
216
+ end
217
+ end
218
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Doing
4
+ class WWID
5
+ # Section and view guessing methods for WWID class
6
+ module Guess
7
+ ##
8
+ ## Attempt to match a string with an existing section
9
+ ##
10
+ ## @param frag [String] The user-provided string
11
+ ## @param guessed [Boolean] already guessed and failed
12
+ ##
13
+ def guess_section(frag, guessed: false, suggest: false)
14
+ return 'All' if frag =~ /^all$/i
15
+ frag ||= Doing.setting('current_section')
16
+
17
+ return frag.cap_first if @content.section?(frag)
18
+
19
+ found = @content.guess_section(frag, distance: 2)
20
+
21
+ section = found ? found.title : nil
22
+
23
+ return section if suggest
24
+
25
+ unless section || guessed
26
+ alt = guess_view(frag, guessed: true, suggest: true)
27
+ if alt
28
+ prompt = Color.template("{bw}Did you mean `{xy}doing {by}view {xy}#{alt}`{bw}?{x}")
29
+ meant_view = Prompt.yn(prompt, default_response: 'n')
30
+
31
+ msg = format('%<y>srun with `%<w>sdoing view %<alt>s%<y>s`', w: boldwhite, y: yellow, alt: alt)
32
+ raise Errors::WrongCommand.new(msg, topic: 'Try again:') if meant_view
33
+
34
+ end
35
+
36
+ res = Prompt.yn("#{boldwhite}Section #{frag.yellow}#{boldwhite} not found, create it", default_response: 'n')
37
+
38
+ if res
39
+ @content.add_section(frag.cap_first, log: true)
40
+ write(@doing_file)
41
+ return frag.cap_first
42
+ end
43
+
44
+ raise Errors::InvalidSection.new("unknown section #{frag.bold.white}", topic: 'Missing:')
45
+ end
46
+ section ? section.cap_first : guessed
47
+ end
48
+
49
+ ##
50
+ ## Attempt to match a string with an existing view
51
+ ##
52
+ ## @param frag [String] The user-provided string
53
+ ## @param guessed [Boolean] already guessed
54
+ ##
55
+ def guess_view(frag, guessed: false, suggest: false)
56
+ views.each { |view| return view if frag.downcase == view.downcase }
57
+ view = false
58
+ re = frag.to_rx(distance: 2, case_type: :ignore)
59
+ views.each do |v|
60
+ next unless v =~ /#{re}/i
61
+
62
+ logger.debug('Match:', %(Assuming "#{v}" from "#{frag}"))
63
+ view = v
64
+ break
65
+ end
66
+ unless view || guessed
67
+ alt = guess_section(frag, guessed: true, suggest: true)
68
+
69
+ raise Errors::InvalidView.new(%(unknown view #{frag.bold.white}), topic: 'Missing:') unless alt
70
+
71
+ prompt = Color.template("{bw}Did you mean `{xy}doing {by}show {xy}#{alt}`{bw}?{x}")
72
+ meant_view = Prompt.yn(prompt, default_response: 'n')
73
+
74
+ if meant_view
75
+ msg = format('%<y>srun with `%<w>sdoing show %<alt>s%<y>s`', w: boldwhite, y: yellow, alt: alt)
76
+ raise Errors::WrongCommand.new(msg, topic: 'Try again:')
77
+
78
+ end
79
+
80
+ raise Errors::InvalidView.new(%(unknown view #{alt.bold.white}), topic: 'Missing:')
81
+
82
+ end
83
+ view
84
+ end
85
+ end
86
+ end
87
+ end