doing 2.1.0pre → 2.1.4pre

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 (67) hide show
  1. checksums.yaml +4 -4
  2. data/.yardoc/checksums +13 -9
  3. data/.yardoc/object_types +0 -0
  4. data/.yardoc/objects/root.dat +0 -0
  5. data/CHANGELOG.md +42 -10
  6. data/Gemfile.lock +23 -1
  7. data/README.md +1 -1
  8. data/Rakefile +2 -0
  9. data/bin/doing +421 -156
  10. data/doc/Array.html +1 -1
  11. data/doc/Doing/Color.html +1 -1
  12. data/doc/Doing/Completion.html +1 -1
  13. data/doc/Doing/Configuration.html +81 -90
  14. data/doc/Doing/Errors/DoingNoTraceError.html +1 -1
  15. data/doc/Doing/Errors/DoingRuntimeError.html +1 -1
  16. data/doc/Doing/Errors/DoingStandardError.html +1 -1
  17. data/doc/Doing/Errors/EmptyInput.html +1 -1
  18. data/doc/Doing/Errors/NoResults.html +1 -1
  19. data/doc/Doing/Errors/PluginException.html +1 -1
  20. data/doc/Doing/Errors/UserCancelled.html +1 -1
  21. data/doc/Doing/Errors/WrongCommand.html +1 -1
  22. data/doc/Doing/Errors.html +1 -1
  23. data/doc/Doing/Hooks.html +1 -1
  24. data/doc/Doing/Item.html +84 -20
  25. data/doc/Doing/Items.html +35 -1
  26. data/doc/Doing/LogAdapter.html +1 -1
  27. data/doc/Doing/Note.html +1 -1
  28. data/doc/Doing/Pager.html +1 -1
  29. data/doc/Doing/Plugins.html +1 -1
  30. data/doc/Doing/Prompt.html +70 -18
  31. data/doc/Doing/Section.html +1 -1
  32. data/doc/Doing/Util.html +16 -4
  33. data/doc/Doing/WWID.html +27 -147
  34. data/doc/Doing.html +3 -3
  35. data/doc/GLI/Commands/MarkdownDocumentListener.html +1 -1
  36. data/doc/GLI/Commands.html +1 -1
  37. data/doc/GLI.html +1 -1
  38. data/doc/Hash.html +1 -1
  39. data/doc/Status.html +1 -1
  40. data/doc/String.html +344 -4
  41. data/doc/Symbol.html +1 -1
  42. data/doc/Time.html +70 -2
  43. data/doc/_index.html +125 -4
  44. data/doc/class_list.html +1 -1
  45. data/doc/file.README.html +2 -2
  46. data/doc/index.html +2 -2
  47. data/doc/method_list.html +537 -193
  48. data/doc/top-level-namespace.html +2 -2
  49. data/doing.gemspec +2 -0
  50. data/doing.rdoc +276 -75
  51. data/lib/completion/doing.bash +20 -20
  52. data/lib/doing/boolean_term_parser.rb +86 -0
  53. data/lib/doing/configuration.rb +36 -19
  54. data/lib/doing/item.rb +102 -9
  55. data/lib/doing/items.rb +6 -0
  56. data/lib/doing/phrase_parser.rb +124 -0
  57. data/lib/doing/plugins/export/template_export.rb +29 -2
  58. data/lib/doing/prompt.rb +21 -11
  59. data/lib/doing/string.rb +47 -3
  60. data/lib/doing/string_chronify.rb +85 -0
  61. data/lib/doing/time.rb +32 -0
  62. data/lib/doing/util.rb +2 -5
  63. data/lib/doing/util_backup.rb +235 -0
  64. data/lib/doing/version.rb +1 -1
  65. data/lib/doing/wwid.rb +224 -124
  66. data/lib/doing.rb +7 -0
  67. metadata +46 -2
@@ -101,9 +101,9 @@ _doing_finish() {
101
101
  _doing_grep() {
102
102
 
103
103
  if [[ "$token" == --* ]]; then
104
- COMPREPLY=( $( compgen -W '--after --before --case --interactive --not --output --only_timed --section --times --tag_sort --totals --exact' -- $token ) )
104
+ COMPREPLY=( $( compgen -W '--after --before --case --duration --from --interactive --not --output --only_timed --section --times --tag_sort --totals --exact' -- $token ) )
105
105
  elif [[ "$token" == -* ]]; then
106
- COMPREPLY=( $( compgen -W '-i -o -s -t -x --after --before --case --interactive --not --output --only_timed --section --times --tag_sort --totals --exact' -- $token ) )
106
+ COMPREPLY=( $( compgen -W '-i -o -s -t -x --after --before --case --duration --from --interactive --not --output --only_timed --section --times --tag_sort --totals --exact' -- $token ) )
107
107
 
108
108
  fi
109
109
  }
@@ -131,9 +131,9 @@ _doing_import() {
131
131
  _doing_last() {
132
132
 
133
133
  if [[ "$token" == --* ]]; then
134
- COMPREPLY=( $( compgen -W '--bool --case --editor --not --section --search --tag --exact' -- $token ) )
134
+ COMPREPLY=( $( compgen -W '--bool --case --duration --editor --not --section --search --tag --exact' -- $token ) )
135
135
  elif [[ "$token" == -* ]]; then
136
- COMPREPLY=( $( compgen -W '-e -s -x --bool --case --editor --not --section --search --tag --exact' -- $token ) )
136
+ COMPREPLY=( $( compgen -W '-e -s -x --bool --case --duration --editor --not --section --search --tag --exact' -- $token ) )
137
137
 
138
138
  fi
139
139
  }
@@ -191,9 +191,9 @@ _doing_now() {
191
191
  _doing_on() {
192
192
 
193
193
  if [[ "$token" == --* ]]; then
194
- COMPREPLY=( $( compgen -W '--output --section --times --tag_sort --totals' -- $token ) )
194
+ COMPREPLY=( $( compgen -W '--duration --output --section --times --tag_sort --totals' -- $token ) )
195
195
  elif [[ "$token" == -* ]]; then
196
- COMPREPLY=( $( compgen -W '-o -s -t --output --section --times --tag_sort --totals' -- $token ) )
196
+ COMPREPLY=( $( compgen -W '-o -s -t --duration --output --section --times --tag_sort --totals' -- $token ) )
197
197
 
198
198
  fi
199
199
  }
@@ -221,9 +221,9 @@ _doing_plugins() {
221
221
  _doing_recent() {
222
222
 
223
223
  if [[ "$token" == --* ]]; then
224
- COMPREPLY=( $( compgen -W '--interactive --section --times --tag_sort --totals' -- $token ) )
224
+ COMPREPLY=( $( compgen -W '--duration --interactive --section --times --tag_sort --totals' -- $token ) )
225
225
  elif [[ "$token" == -* ]]; then
226
- COMPREPLY=( $( compgen -W '-i -s -t --interactive --section --times --tag_sort --totals' -- $token ) )
226
+ COMPREPLY=( $( compgen -W '-i -s -t --duration --interactive --section --times --tag_sort --totals' -- $token ) )
227
227
 
228
228
  fi
229
229
  }
@@ -261,9 +261,9 @@ _doing_sections() {
261
261
  _doing_select() {
262
262
 
263
263
  if [[ "$token" == --* ]]; then
264
- COMPREPLY=( $( compgen -W '--archive --resume --cancel --case --delete --editor --finish --flag --force --move --menu --not --output --search --remove --section --save_to --tag --exact' -- $token ) )
264
+ COMPREPLY=( $( compgen -W '--archive --after --resume --before --cancel --case --delete --editor --finish --flag --force --from --move --menu --not --output --search --remove --section --save_to --tag --exact' -- $token ) )
265
265
  elif [[ "$token" == -* ]]; then
266
- COMPREPLY=( $( compgen -W '-a -c -d -e -f -m -o -r -s -t -x --archive --resume --cancel --case --delete --editor --finish --flag --force --move --menu --not --output --search --remove --section --save_to --tag --exact' -- $token ) )
266
+ COMPREPLY=( $( compgen -W '-a -c -d -e -f -m -o -r -s -t -x --archive --after --resume --before --cancel --case --delete --editor --finish --flag --force --from --move --menu --not --output --search --remove --section --save_to --tag --exact' -- $token ) )
267
267
 
268
268
  fi
269
269
  }
@@ -276,9 +276,9 @@ local words=$(doing sections)
276
276
  IFS="$OLD_IFS"
277
277
 
278
278
  if [[ "$token" == --* ]]; then
279
- COMPREPLY=( $( compgen -W '--age --after --bool --before --count --case --from --interactive --not --output --only_timed --sort --search --times --tag --tag_order --tag_sort --totals --exact' -- $token ) )
279
+ COMPREPLY=( $( compgen -W '--age --after --bool --before --count --case --duration --from --interactive --not --output --only_timed --sort --search --times --tag --tag_order --tag_sort --totals --exact' -- $token ) )
280
280
  elif [[ "$token" == -* ]]; then
281
- COMPREPLY=( $( compgen -W '-a -b -c -f -i -o -s -t -x --age --after --bool --before --count --case --from --interactive --not --output --only_timed --sort --search --times --tag --tag_order --tag_sort --totals --exact' -- $token ) )
281
+ COMPREPLY=( $( compgen -W '-a -b -c -i -o -s -t -x --age --after --bool --before --count --case --duration --from --interactive --not --output --only_timed --sort --search --times --tag --tag_order --tag_sort --totals --exact' -- $token ) )
282
282
  else
283
283
  local nocasematchWasOff=0
284
284
  shopt nocasematch >/dev/null || nocasematchWasOff=1
@@ -301,9 +301,9 @@ IFS="$OLD_IFS"
301
301
  _doing_since() {
302
302
 
303
303
  if [[ "$token" == --* ]]; then
304
- COMPREPLY=( $( compgen -W '--output --section --times --tag_sort --totals' -- $token ) )
304
+ COMPREPLY=( $( compgen -W '--duration --output --section --times --tag_sort --totals' -- $token ) )
305
305
  elif [[ "$token" == -* ]]; then
306
- COMPREPLY=( $( compgen -W '-o -s -t --output --section --times --tag_sort --totals' -- $token ) )
306
+ COMPREPLY=( $( compgen -W '-o -s -t --duration --output --section --times --tag_sort --totals' -- $token ) )
307
307
 
308
308
  fi
309
309
  }
@@ -331,9 +331,9 @@ _doing_template() {
331
331
  _doing_today() {
332
332
 
333
333
  if [[ "$token" == --* ]]; then
334
- COMPREPLY=( $( compgen -W '--after --before --output --section --times --tag_sort --totals' -- $token ) )
334
+ COMPREPLY=( $( compgen -W '--after --before --duration --from --output --section --times --tag_sort --totals' -- $token ) )
335
335
  elif [[ "$token" == -* ]]; then
336
- COMPREPLY=( $( compgen -W '-o -s -t --after --before --output --section --times --tag_sort --totals' -- $token ) )
336
+ COMPREPLY=( $( compgen -W '-o -s -t --after --before --duration --from --output --section --times --tag_sort --totals' -- $token ) )
337
337
 
338
338
  fi
339
339
  }
@@ -356,9 +356,9 @@ local words=$(doing views)
356
356
  IFS="$OLD_IFS"
357
357
 
358
358
  if [[ "$token" == --* ]]; then
359
- COMPREPLY=( $( compgen -W '--after --bool --before --count --case --color --interactive --not --output --only_timed --section --search --times --tag --tag_order --tag_sort --totals --exact' -- $token ) )
359
+ COMPREPLY=( $( compgen -W '--after --bool --before --count --case --color --duration --from --interactive --not --output --only_timed --section --search --times --tag --tag_order --tag_sort --totals --exact' -- $token ) )
360
360
  elif [[ "$token" == -* ]]; then
361
- COMPREPLY=( $( compgen -W '-b -c -i -o -s -t -x --after --bool --before --count --case --color --interactive --not --output --only_timed --section --search --times --tag --tag_order --tag_sort --totals --exact' -- $token ) )
361
+ COMPREPLY=( $( compgen -W '-b -c -i -o -s -t -x --after --bool --before --count --case --color --duration --from --interactive --not --output --only_timed --section --search --times --tag --tag_order --tag_sort --totals --exact' -- $token ) )
362
362
  else
363
363
  local nocasematchWasOff=0
364
364
  shopt nocasematch >/dev/null || nocasematchWasOff=1
@@ -401,9 +401,9 @@ _doing_wiki() {
401
401
  _doing_yesterday() {
402
402
 
403
403
  if [[ "$token" == --* ]]; then
404
- COMPREPLY=( $( compgen -W '--after --before --output --section --times --tag_order --tag_sort --totals' -- $token ) )
404
+ COMPREPLY=( $( compgen -W '--after --before --duration --from --output --section --times --tag_order --tag_sort --totals' -- $token ) )
405
405
  elif [[ "$token" == -* ]]; then
406
- COMPREPLY=( $( compgen -W '-o -s -t --after --before --output --section --times --tag_order --tag_sort --totals' -- $token ) )
406
+ COMPREPLY=( $( compgen -W '-o -s -t --after --before --duration --from --output --section --times --tag_order --tag_sort --totals' -- $token ) )
407
407
 
408
408
  fi
409
409
  }
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'parslet'
4
+
5
+ module BooleanTermParser
6
+ # This query parser adds an optional operator ("+" or "-") to the simple term
7
+ # parser. In order to do that, a new "clause" node is added to the parse tree.
8
+ class QueryParser < Parslet::Parser
9
+ rule(:term) { match('[^\s]').repeat(1).as(:term) }
10
+ rule(:operator) { (str('+') | str('-')).as(:operator) }
11
+ rule(:clause) { (operator.maybe >> term).as(:clause) }
12
+ rule(:space) { match('\s').repeat(1) }
13
+ rule(:query) { (clause >> space.maybe).repeat.as(:query) }
14
+ root(:query)
15
+ end
16
+
17
+ class QueryTransformer < Parslet::Transform
18
+ rule(:clause => subtree(:clause)) do
19
+ Clause.new(clause[:operator]&.to_s, clause[:term].to_s)
20
+ end
21
+ rule(:query => sequence(:clauses)) { Query.new(clauses) }
22
+ end
23
+
24
+ class Operator
25
+ def self.symbol(str)
26
+ case str
27
+ when '+'
28
+ :must
29
+ when '-'
30
+ :must_not
31
+ when nil
32
+ :should
33
+ else
34
+ raise "Unknown operator: #{str}"
35
+ end
36
+ end
37
+ end
38
+
39
+ class Clause
40
+ attr_accessor :operator, :term
41
+
42
+ def initialize(operator, term)
43
+ self.operator = Operator.symbol(operator)
44
+ self.term = term
45
+ end
46
+ end
47
+
48
+ class Query
49
+ attr_accessor :should_terms, :must_not_terms, :must_terms
50
+
51
+ def initialize(clauses)
52
+ grouped = clauses.chunk { |c| c.operator }.to_h
53
+ self.should_terms = grouped.fetch(:should, []).map(&:term)
54
+ self.must_not_terms = grouped.fetch(:must_not, []).map(&:term)
55
+ self.must_terms = grouped.fetch(:must, []).map(&:term)
56
+ end
57
+
58
+ def to_elasticsearch
59
+ query = {}
60
+
61
+ if should_terms.any?
62
+ query[:should] = should_terms.map do |term|
63
+ match(term)
64
+ end
65
+ end
66
+
67
+ if must_terms.any?
68
+ query[:must] = must_terms.map do |term|
69
+ match(term)
70
+ end
71
+ end
72
+
73
+ if must_not_terms.any?
74
+ query[:must_not] = must_not_terms.map do |term|
75
+ match(term)
76
+ end
77
+ end
78
+
79
+ query
80
+ end
81
+
82
+ def match(term)
83
+ term
84
+ end
85
+ end
86
+ end
@@ -7,7 +7,11 @@ module Doing
7
7
  class Configuration
8
8
  attr_reader :settings
9
9
 
10
- attr_writer :ignore_local
10
+ attr_writer :ignore_local, :config_file, :force_answer
11
+
12
+ def force_answer
13
+ @force_answer ||= false
14
+ end
11
15
 
12
16
  MissingConfigFile = Class.new(RuntimeError)
13
17
 
@@ -26,34 +30,37 @@ module Doing
26
30
  'command_path' => File.join(Util.user_home, '.config', 'doing', 'commands')
27
31
  },
28
32
  'doing_file' => '~/what_was_i_doing.md',
33
+ 'backup_dir' => '~/.doing_backup',
29
34
  'current_section' => 'Currently',
30
35
  'paginate' => false,
31
36
  'never_time' => [],
32
37
  'never_finish' => [],
38
+ 'date_tags' => ['done', 'defer(?:red)?', 'waiting'],
33
39
 
34
40
  'timer_format' => 'text',
41
+ 'interval_format' => 'text',
35
42
 
36
43
  'templates' => {
37
44
  'default' => {
38
45
  'date_format' => '%Y-%m-%d %H:%M',
39
- 'template' => '%date | %title%note',
46
+ 'template' => '%date | %title %interval%duration%note',
40
47
  'wrap_width' => 0,
41
48
  'order' => 'asc'
42
49
  },
43
50
  'today' => {
44
51
  'date_format' => '%_I:%M%P',
45
- 'template' => '%date: %title %interval%note',
52
+ 'template' => '%date: %title %interval%duration%note',
46
53
  'wrap_width' => 0,
47
54
  'order' => 'asc'
48
55
  },
49
56
  'last' => {
50
57
  'date_format' => '%-I:%M%P on %a',
51
- 'template' => '%title (at %date)%odnote',
58
+ 'template' => '%title (at %date) %interval%duration%odnote',
52
59
  'wrap_width' => 88
53
60
  },
54
61
  'recent' => {
55
62
  'date_format' => '%_I:%M%P',
56
- 'template' => '%shortdate: %title (%section)',
63
+ 'template' => '%shortdate: %title (%section) %interval%duration%note',
57
64
  'wrap_width' => 88,
58
65
  'count' => 10,
59
66
  'order' => 'asc'
@@ -99,24 +106,21 @@ module Doing
99
106
  @config_file ||= default_config_file
100
107
  end
101
108
 
102
- def config_file=(file)
103
- @config_file = file
104
- end
105
-
106
109
  def config_dir
107
110
  @config_dir ||= File.join(Util.user_home, '.config', 'doing')
108
- # @config_dir ||= Util.user_home
109
111
  end
110
112
 
111
113
  def default_config_file
112
- raise DoingRuntimeError, "#{config_dir} exists but is not a directory" if File.exist?(config_dir) && !File.directory?(config_dir)
114
+ if File.exist?(config_dir) && !File.directory?(config_dir)
115
+ raise DoingRuntimeError, "#{config_dir} exists but is not a directory"
116
+
117
+ end
113
118
 
114
119
  unless File.exist?(config_dir)
115
120
  FileUtils.mkdir_p(config_dir)
116
121
  Doing.logger.log_now(:warn, "Config directory created at #{config_dir}")
117
122
  end
118
123
 
119
- # File.join(config_dir, 'config.yml')
120
124
  File.join(config_dir, 'config.yml')
121
125
  end
122
126
 
@@ -130,10 +134,13 @@ module Doing
130
134
  ## @return [String] file path
131
135
  ##
132
136
  def choose_config
137
+ return @config_file if @force_answer
138
+
133
139
  if @additional_configs.count.positive?
134
- choices = [@config_file]
135
- choices.concat(@additional_configs)
136
- res = Doing::Prompt.choose_from(choices.uniq.sort.reverse, sorted: false, prompt: 'Local configs found, select which to update > ')
140
+ choices = [@config_file].concat(@additional_configs)
141
+ res = Doing::Prompt.choose_from(choices.uniq.sort.reverse,
142
+ sorted: false,
143
+ prompt: 'Local configs found, select which to update > ')
137
144
 
138
145
  raise UserCancelled, 'Cancelled' unless res
139
146
 
@@ -153,7 +160,7 @@ module Doing
153
160
  ## matched, first match wins)
154
161
  ## @return [Array] ordered array of resolved keys
155
162
  ##
156
- def resolve_key_path(keypath)
163
+ def resolve_key_path(keypath, create: false)
157
164
  cfg = @settings
158
165
  real_path = []
159
166
  unless keypath =~ /^[.*]?$/
@@ -170,8 +177,18 @@ module Doing
170
177
  end
171
178
 
172
179
  if new_cfg.nil?
173
- Doing.logger.error("Key match not found: #{path}")
174
- break
180
+ return nil unless create
181
+
182
+ resolved = real_path.count.positive? ? "Resolved #{real_path.join('->')}, but " : ''
183
+ Doing.logger.log_now(:warn, "#{resolved}#{path} is unknown")
184
+ new_path = [*real_path, path, *paths].join('->')
185
+ Doing.logger.log_now(:warn, "Continuing will create the path #{new_path}")
186
+ res = Prompt.yn('Key path not found, create it?', default_response: true)
187
+ raise InvalidArgument, 'Invalid key path' unless res
188
+
189
+ real_path.push(path).concat(paths)
190
+ Doing.logger.debug('Config:', "translated key path #{keypath} to #{real_path.join('.')}")
191
+ return real_path
175
192
  end
176
193
  cfg = new_cfg
177
194
  end
@@ -194,7 +211,7 @@ module Doing
194
211
  cfg = @settings
195
212
  real_path = ['config']
196
213
  unless keypath =~ /^[.*]?$/
197
- real_path = resolve_key_path(keypath)
214
+ real_path = resolve_key_path(keypath, create: false)
198
215
  return nil unless real_path&.count&.positive?
199
216
 
200
217
  cfg = cfg.dig(*real_path)
data/lib/doing/item.rb CHANGED
@@ -31,6 +31,13 @@ module Doing
31
31
  # @date = new_date.is_a?(Time) ? new_date : Time.parse(new_date)
32
32
  # end
33
33
 
34
+ ## If the entry doesn't have a @done date, return the elapsed time
35
+ def duration
36
+ return nil if @title =~ /(?<=^| )@done\b/
37
+
38
+ return Time.now - @date
39
+ end
40
+
34
41
  ##
35
42
  ## Get the difference between the item's start date and
36
43
  ## the value of its @done tag (if present)
@@ -109,7 +116,7 @@ module Doing
109
116
  ## Add (or remove) tags from the title of the item
110
117
  ##
111
118
  ## @param tags [Array] The tags to apply
112
- ## @param **options Additional options
119
+ ## @param options Additional options
113
120
  ##
114
121
  ## @option options :date [Boolean] Include timestamp?
115
122
  ## @option options :single [Boolean] Log as a single change?
@@ -165,6 +172,13 @@ module Doing
165
172
  ## @return [Boolean] true if tag/bool combination passes
166
173
  ##
167
174
  def tags?(tags, bool = :and, negate: false)
175
+ if bool == :pattern
176
+ tags = tags.join(' ') if tags.is_a?(Array)
177
+ matches = tag_pattern?(tags.gsub(/ *, */, ' '))
178
+
179
+ return negate ? !matches : matches
180
+ end
181
+
168
182
  tags = split_tags(tags)
169
183
  bool = bool.normalize_bool
170
184
 
@@ -179,6 +193,10 @@ module Doing
179
193
  negate ? !matches : matches
180
194
  end
181
195
 
196
+ def ignore_case(search, case_type)
197
+ (case_type == :smart && search !~ /[A-Z]/) || case_type == :ignore
198
+ end
199
+
182
200
  ##
183
201
  ## Test if item matches search string
184
202
  ##
@@ -190,9 +208,25 @@ module Doing
190
208
  ##
191
209
  ## @return [Boolean] matches search criteria
192
210
  ##
193
- def search(search, distance: 3, negate: false, case_type: :smart, fuzzy: false)
194
- text = @title + @note.to_s
195
- matches = text =~ search.to_rx(distance: distance, case_type: case_type)
211
+ def search(search, distance: 3, negate: false, case_type: :smart)
212
+ if search.is_rx?
213
+ matches = @title + @note.to_s =~ search.to_rx(distance: distance, case_type: case_type)
214
+ else
215
+ query = to_phrase_query(search.strip)
216
+
217
+ if query[:must].nil? && query[:must_not].nil?
218
+ query[:must] = query[:should]
219
+ query[:should] = []
220
+ end
221
+ matches = no_searches?(query[:must_not], case_type: case_type)
222
+ matches &&= all_searches?(query[:must], case_type: case_type)
223
+ matches &&= any_searches?(query[:should], case_type: case_type)
224
+ end
225
+ # if search =~ /(?<=\A| )[+-]\S/
226
+ # else
227
+ # text = @title + @note.to_s
228
+ # matches = text =~ search.to_rx(distance: distance, case_type: case_type)
229
+ # end
196
230
 
197
231
  # if search.is_rx? || !fuzzy
198
232
  # matches = text =~ search.to_rx(distance: distance, case_type: case_type)
@@ -279,33 +313,92 @@ module Doing
279
313
  start = @date
280
314
 
281
315
  t = (done - start).to_i
282
- t > 0 ? t : nil
316
+ t.positive? ? t : nil
317
+ end
318
+
319
+ def all_searches?(searches, case_type: :smart)
320
+ return true if searches.nil? || searches.empty?
321
+
322
+ text = @title + @note.to_s
323
+ searches.each do |s|
324
+ rx = Regexp.new(s.wildcard_to_rx, ignore_case(s, case_type))
325
+ return false unless text =~ rx
326
+ end
327
+ true
328
+ end
329
+
330
+ def no_searches?(searches, case_type: :smart)
331
+ return true if searches.nil? || searches.empty?
332
+
333
+ text = @title + @note.to_s
334
+ searches.each do |s|
335
+ rx = Regexp.new(s.wildcard_to_rx, ignore_case(s, case_type))
336
+ return false if text =~ rx
337
+ end
338
+ true
339
+ end
340
+
341
+ def any_searches?(searches, case_type: :smart)
342
+ return true if searches.nil? || searches.empty?
343
+
344
+ text = @title + @note.to_s
345
+ searches.each do |s|
346
+ rx = Regexp.new(s.wildcard_to_rx, ignore_case(s, case_type))
347
+ return true if text =~ rx
348
+ end
349
+ false
283
350
  end
284
351
 
285
352
  def all_tags?(tags)
353
+ return true if tags.nil? || tags.empty?
354
+
286
355
  tags.each do |tag|
287
- return false unless @title =~ /@#{tag}/
356
+ return false unless @title =~ /@#{tag.wildcard_to_rx}(?= |\(|\Z)/i
288
357
  end
289
358
  true
290
359
  end
291
360
 
292
361
  def no_tags?(tags)
362
+ return true if tags.nil? || tags.empty?
363
+
293
364
  tags.each do |tag|
294
- return false if @title =~ /@#{tag}/
365
+ return false if @title =~ /@#{tag.wildcard_to_rx}(?= |\(|\Z)/i
295
366
  end
296
367
  true
297
368
  end
298
369
 
299
370
  def any_tags?(tags)
371
+ return true if tags.nil? || tags.empty?
372
+
300
373
  tags.each do |tag|
301
- return true if @title =~ /@#{tag}/
374
+ return true if @title =~ /@#{tag.wildcard_to_rx}(?= |\(|\Z)/i
302
375
  end
303
376
  false
304
377
  end
305
378
 
379
+ def to_query(query)
380
+ parser = BooleanTermParser::QueryParser.new
381
+ transformer = BooleanTermParser::QueryTransformer.new
382
+ parse_tree = parser.parse(query)
383
+ transformer.apply(parse_tree).to_elasticsearch
384
+ end
385
+
386
+ def to_phrase_query(query)
387
+ parser = PhraseParser::QueryParser.new
388
+ transformer = PhraseParser::QueryTransformer.new
389
+ parse_tree = parser.parse(query)
390
+ transformer.apply(parse_tree).to_elasticsearch
391
+ end
392
+
393
+ def tag_pattern?(tags)
394
+ query = to_query(tags)
395
+
396
+ no_tags?(query[:must_not]) && all_tags?(query[:must]) && any_tags?(query[:should])
397
+ end
398
+
306
399
  def split_tags(tags)
307
400
  tags = tags.split(/ *, */) if tags.is_a? String
308
- tags.map { |t| t.strip.sub(/^@/, '') }
401
+ tags.map { |t| t.strip.add_at }
309
402
  end
310
403
  end
311
404
  end
data/lib/doing/items.rb CHANGED
@@ -105,6 +105,12 @@ module Doing
105
105
  new_item
106
106
  end
107
107
 
108
+ def all_tags
109
+ each_with_object([]) do |entry, tags|
110
+ tags.concat(entry.tags).sort!.uniq!
111
+ end
112
+ end
113
+
108
114
  # Output sections and items in Doing file format
109
115
  def to_s
110
116
  out = []
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'parslet'
4
+
5
+ module PhraseParser
6
+ # This parser adds quoted phrases (using matched double quotes) in addition to
7
+ # terms. This is done creating multiple types of clauses instead of just one.
8
+ # A phrase clause generates an Elasticsearch match_phrase query.
9
+ class QueryParser < Parslet::Parser
10
+ rule(:term) { match('[^\s"]').repeat(1).as(:term) }
11
+ rule(:quote) { str('"') }
12
+ rule(:operator) { (str('+') | str('-')).as(:operator) }
13
+ rule(:phrase) do
14
+ (quote >> (term >> space.maybe).repeat >> quote).as(:phrase)
15
+ end
16
+ rule(:clause) { (operator.maybe >> (phrase | term)).as(:clause) }
17
+ rule(:space) { match('\s').repeat(1) }
18
+ rule(:query) { (clause >> space.maybe).repeat.as(:query) }
19
+ root(:query)
20
+ end
21
+
22
+ class QueryTransformer < Parslet::Transform
23
+ rule(:clause => subtree(:clause)) do
24
+ if clause[:term]
25
+ TermClause.new(clause[:operator]&.to_s, clause[:term].to_s)
26
+ elsif clause[:phrase]
27
+ phrase = clause[:phrase].map { |p| p[:term].to_s }.join(' ')
28
+ PhraseClause.new(clause[:operator]&.to_s, phrase)
29
+ else
30
+ raise "Unexpected clause type: '#{clause}'"
31
+ end
32
+ end
33
+ rule(query: sequence(:clauses)) { Query.new(clauses) }
34
+ end
35
+
36
+ class Operator
37
+ def self.symbol(str)
38
+ case str
39
+ when '+'
40
+ :must
41
+ when '-'
42
+ :must_not
43
+ when nil
44
+ :should
45
+ else
46
+ raise "Unknown operator: #{str}"
47
+ end
48
+ end
49
+ end
50
+
51
+ class TermClause
52
+ attr_accessor :operator, :term
53
+
54
+ def initialize(operator, term)
55
+ self.operator = Operator.symbol(operator)
56
+ self.term = term
57
+ end
58
+ end
59
+
60
+ # Phrase
61
+ class PhraseClause
62
+ attr_accessor :operator, :phrase
63
+
64
+ def initialize(operator, phrase)
65
+ self.operator = Operator.symbol(operator)
66
+ self.phrase = phrase
67
+ end
68
+ end
69
+
70
+ ## Query object
71
+ class Query
72
+ attr_accessor :should_clauses, :must_not_clauses, :must_clauses
73
+
74
+ def initialize(clauses)
75
+ grouped = clauses.chunk(&:operator).to_h
76
+ self.should_clauses = grouped.fetch(:should, [])
77
+ self.must_not_clauses = grouped.fetch(:must_not, [])
78
+ self.must_clauses = grouped.fetch(:must, [])
79
+ end
80
+
81
+ def to_elasticsearch
82
+ query = {}
83
+
84
+ if should_clauses.any?
85
+ query[:should] = should_clauses.map do |clause|
86
+ clause_to_query(clause)
87
+ end
88
+ end
89
+
90
+ if must_clauses.any?
91
+ query[:must] = must_clauses.map do |clause|
92
+ clause_to_query(clause)
93
+ end
94
+ end
95
+
96
+ if must_not_clauses.any?
97
+ query[:must_not] = must_not_clauses.map do |clause|
98
+ clause_to_query(clause)
99
+ end
100
+ end
101
+
102
+ query
103
+ end
104
+
105
+ def clause_to_query(clause)
106
+ case clause
107
+ when TermClause
108
+ match(clause.term)
109
+ when PhraseClause
110
+ match_phrase(clause.phrase)
111
+ else
112
+ raise "Unknown clause type: #{clause}"
113
+ end
114
+ end
115
+
116
+ def match(term)
117
+ term
118
+ end
119
+
120
+ def match_phrase(phrase)
121
+ phrase
122
+ end
123
+ end
124
+ end