doing 2.1.37 → 2.1.40

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