doing 2.1.0pre → 2.1.4pre

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