doing 2.1.2pre → 2.1.6pre

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 (116) hide show
  1. checksums.yaml +4 -4
  2. data/.yardoc/checksums +19 -15
  3. data/.yardoc/object_types +0 -0
  4. data/.yardoc/objects/root.dat +0 -0
  5. data/.yardopts +1 -1
  6. data/CHANGELOG.md +62 -14
  7. data/Gemfile.lock +25 -1
  8. data/README.md +5 -1
  9. data/Rakefile +2 -0
  10. data/bin/doing +429 -142
  11. data/docs/_config.yml +1 -0
  12. data/{doc → docs/doc}/Array.html +63 -1
  13. data/docs/doc/BooleanTermParser/Clause.html +293 -0
  14. data/docs/doc/BooleanTermParser/Operator.html +172 -0
  15. data/docs/doc/BooleanTermParser/Query.html +417 -0
  16. data/docs/doc/BooleanTermParser/QueryParser.html +135 -0
  17. data/docs/doc/BooleanTermParser/QueryTransformer.html +124 -0
  18. data/docs/doc/BooleanTermParser.html +115 -0
  19. data/docs/doc/Doing/CLIFormat.html +131 -0
  20. data/{doc → docs/doc}/Doing/Color.html +2 -2
  21. data/{doc → docs/doc}/Doing/Completion.html +1 -1
  22. data/{doc → docs/doc}/Doing/Configuration.html +163 -69
  23. data/{doc → docs/doc}/Doing/Content.html +0 -0
  24. data/{doc → docs/doc}/Doing/Errors/DoingNoTraceError.html +1 -1
  25. data/{doc → docs/doc}/Doing/Errors/DoingRuntimeError.html +1 -1
  26. data/{doc → docs/doc}/Doing/Errors/DoingStandardError.html +1 -1
  27. data/{doc → docs/doc}/Doing/Errors/EmptyInput.html +1 -1
  28. data/{doc → docs/doc}/Doing/Errors/NoResults.html +1 -1
  29. data/{doc → docs/doc}/Doing/Errors/PluginException.html +1 -1
  30. data/{doc → docs/doc}/Doing/Errors/UserCancelled.html +1 -1
  31. data/{doc → docs/doc}/Doing/Errors/WrongCommand.html +1 -1
  32. data/{doc → docs/doc}/Doing/Errors.html +1 -1
  33. data/{doc → docs/doc}/Doing/Hooks.html +1 -1
  34. data/{doc → docs/doc}/Doing/Item.html +135 -89
  35. data/{doc → docs/doc}/Doing/Items.html +36 -2
  36. data/{doc → docs/doc}/Doing/LogAdapter.html +70 -1
  37. data/{doc → docs/doc}/Doing/Note.html +5 -134
  38. data/{doc → docs/doc}/Doing/Pager.html +1 -1
  39. data/{doc → docs/doc}/Doing/Plugins.html +431 -35
  40. data/{doc → docs/doc}/Doing/Prompt.html +70 -18
  41. data/{doc → docs/doc}/Doing/Section.html +1 -1
  42. data/docs/doc/Doing/TemplateString.html +713 -0
  43. data/docs/doc/Doing/Util/Backup.html +686 -0
  44. data/{doc → docs/doc}/Doing/Util.html +16 -4
  45. data/{doc → docs/doc}/Doing/WWID.html +133 -73
  46. data/{doc → docs/doc}/Doing/WWIDFile.html +0 -0
  47. data/{doc → docs/doc}/Doing.html +4 -4
  48. data/{doc → docs/doc}/GLI/Commands/MarkdownDocumentListener.html +1 -1
  49. data/{doc → docs/doc}/GLI/Commands.html +1 -1
  50. data/{doc → docs/doc}/GLI.html +1 -1
  51. data/{doc → docs/doc}/Hash.html +1 -1
  52. data/docs/doc/PhraseParser/Operator.html +172 -0
  53. data/docs/doc/PhraseParser/PhraseClause.html +303 -0
  54. data/docs/doc/PhraseParser/Query.html +495 -0
  55. data/docs/doc/PhraseParser/QueryParser.html +136 -0
  56. data/docs/doc/PhraseParser/QueryTransformer.html +124 -0
  57. data/docs/doc/PhraseParser/TermClause.html +293 -0
  58. data/docs/doc/PhraseParser.html +115 -0
  59. data/{doc → docs/doc}/Status.html +1 -1
  60. data/{doc → docs/doc}/String.html +319 -13
  61. data/{doc → docs/doc}/Symbol.html +35 -1
  62. data/{doc → docs/doc}/Time.html +70 -2
  63. data/{doc → docs/doc}/_index.html +132 -4
  64. data/docs/doc/class_list.html +51 -0
  65. data/{doc → docs/doc}/css/common.css +0 -0
  66. data/{doc → docs/doc}/css/full_list.css +0 -0
  67. data/{doc → docs/doc}/css/style.css +0 -0
  68. data/{doc → docs/doc}/file.README.html +6 -2
  69. data/{doc → docs/doc}/file_list.html +0 -0
  70. data/{doc → docs/doc}/frames.html +0 -0
  71. data/{doc → docs/doc}/index.html +6 -2
  72. data/{doc → docs/doc}/js/app.js +0 -0
  73. data/{doc → docs/doc}/js/full_list.js +0 -0
  74. data/{doc → docs/doc}/js/jquery.js +0 -0
  75. data/{doc → docs/doc}/method_list.html +684 -196
  76. data/{doc → docs/doc}/top-level-namespace.html +2 -2
  77. data/docs/index.md +60 -0
  78. data/doing.gemspec +3 -0
  79. data/doing.rdoc +222 -74
  80. data/example_plugin.rb +3 -1
  81. data/lib/completion/_doing.zsh +53 -41
  82. data/lib/completion/doing.bash +17 -6
  83. data/lib/completion/doing.fish +321 -2
  84. data/lib/doing/array.rb +9 -0
  85. data/lib/doing/boolean_term_parser.rb +86 -0
  86. data/lib/doing/completion/fish_completion.rb +46 -3
  87. data/lib/doing/completion/zsh_completion.rb +1 -1
  88. data/lib/doing/configuration.rb +48 -21
  89. data/lib/doing/item.rb +105 -10
  90. data/lib/doing/items.rb +6 -0
  91. data/lib/doing/log_adapter.rb +28 -0
  92. data/lib/doing/note.rb +31 -30
  93. data/lib/doing/phrase_parser.rb +124 -0
  94. data/lib/doing/plugin_manager.rb +84 -21
  95. data/lib/doing/plugins/export/dayone_export.rb +209 -0
  96. data/lib/doing/plugins/export/html_export.rb +2 -2
  97. data/lib/doing/plugins/export/json_export.rb +1 -0
  98. data/lib/doing/plugins/export/markdown_export.rb +1 -1
  99. data/lib/doing/plugins/export/template_export.rb +94 -86
  100. data/lib/doing/prompt.rb +26 -15
  101. data/lib/doing/string.rb +114 -29
  102. data/lib/doing/string_chronify.rb +5 -1
  103. data/lib/doing/symbol.rb +4 -0
  104. data/lib/doing/template_string.rb +197 -0
  105. data/lib/doing/time.rb +32 -0
  106. data/lib/doing/util.rb +6 -7
  107. data/lib/doing/util_backup.rb +287 -0
  108. data/lib/doing/version.rb +1 -1
  109. data/lib/doing/wwid.rb +105 -41
  110. data/lib/doing.rb +9 -0
  111. data/lib/examples/plugins/say_export.rb +1 -1
  112. data/lib/examples/plugins/wiki_export/wiki_export.rb +3 -3
  113. data/lib/templates/doing-dayone-entry.erb +6 -0
  114. data/lib/templates/doing-dayone.erb +5 -0
  115. metadata +136 -51
  116. data/doc/class_list.html +0 -51
@@ -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
 
@@ -25,11 +29,13 @@ module Doing
25
29
  'plugin_path' => File.join(Util.user_home, '.config', 'doing', 'plugins'),
26
30
  'command_path' => File.join(Util.user_home, '.config', 'doing', 'commands')
27
31
  },
28
- 'doing_file' => '~/what_was_i_doing.md',
32
+ 'doing_file' => '~/.local/share/doing/what_was_i_doing.md',
33
+ 'backup_dir' => '~/.local/share/doing/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',
35
41
  'interval_format' => 'text',
@@ -37,7 +43,8 @@ module Doing
37
43
  'templates' => {
38
44
  'default' => {
39
45
  'date_format' => '%Y-%m-%d %H:%M',
40
- 'template' => '%date | %title %interval%duration%note',
46
+ 'template' => '%reset%cyan%shortdate %boldwhite%80║ title %dark%boldmagenta[%boldwhite%-10section%boldmagenta]%reset
47
+ %yellow%interval%boldred%duration%dark%white%80_14┃ note',
41
48
  'wrap_width' => 0,
42
49
  'order' => 'asc'
43
50
  },
@@ -54,7 +61,8 @@ module Doing
54
61
  },
55
62
  'recent' => {
56
63
  'date_format' => '%_I:%M%P',
57
- 'template' => '%shortdate: %title (%section) %interval%duration%note',
64
+ 'template' => '%reset%cyan%shortdate %boldwhite%80║ title %dark%boldmagenta[%boldwhite%-10section%boldmagenta]%reset
65
+ %yellow%interval%boldred%duration%dark%white%80_14┃ note',
58
66
  'wrap_width' => 88,
59
67
  'count' => 10,
60
68
  'order' => 'asc'
@@ -66,7 +74,7 @@ module Doing
66
74
  'views' => {
67
75
  'done' => {
68
76
  'date_format' => '%_I:%M%P',
69
- 'template' => '%date | %title%note',
77
+ 'template' => '%date | %title (%section)% 18: note',
70
78
  'wrap_width' => 0,
71
79
  'section' => 'All',
72
80
  'count' => 0,
@@ -87,6 +95,11 @@ module Doing
87
95
  'marker_color' => 'red',
88
96
  'default_tags' => [],
89
97
  'tag_sort' => 'name',
98
+ 'search' => {
99
+ 'matching' => 'pattern', # fuzzy, pattern, exact
100
+ 'distance' => 3,
101
+ 'case' => 'smart' # sensitive, ignore, smart
102
+ },
90
103
  'include_notes' => true
91
104
  }
92
105
 
@@ -100,24 +113,32 @@ module Doing
100
113
  @config_file ||= default_config_file
101
114
  end
102
115
 
103
- def config_file=(file)
104
- @config_file = file
105
- end
106
-
107
116
  def config_dir
108
117
  @config_dir ||= File.join(Util.user_home, '.config', 'doing')
109
- # @config_dir ||= Util.user_home
118
+ end
119
+
120
+ ##
121
+ ## Check if configuration enforces exact string matching
122
+ ##
123
+ ## @return [Boolean] exact matching enabled
124
+ ##
125
+ def exact_match?
126
+ search_settings = @settings['search']
127
+ matching = search_settings.fetch('matching', 'pattern').normalize_matching
128
+ matching == :exact
110
129
  end
111
130
 
112
131
  def default_config_file
113
- raise DoingRuntimeError, "#{config_dir} exists but is not a directory" if File.exist?(config_dir) && !File.directory?(config_dir)
132
+ if File.exist?(config_dir) && !File.directory?(config_dir)
133
+ raise DoingRuntimeError, "#{config_dir} exists but is not a directory"
134
+
135
+ end
114
136
 
115
137
  unless File.exist?(config_dir)
116
138
  FileUtils.mkdir_p(config_dir)
117
139
  Doing.logger.log_now(:warn, "Config directory created at #{config_dir}")
118
140
  end
119
141
 
120
- # File.join(config_dir, 'config.yml')
121
142
  File.join(config_dir, 'config.yml')
122
143
  end
123
144
 
@@ -131,10 +152,13 @@ module Doing
131
152
  ## @return [String] file path
132
153
  ##
133
154
  def choose_config
155
+ return @config_file if @force_answer
156
+
134
157
  if @additional_configs.count.positive?
135
- choices = [@config_file]
136
- choices.concat(@additional_configs)
137
- res = Doing::Prompt.choose_from(choices.uniq.sort.reverse, sorted: false, prompt: 'Local configs found, select which to update > ')
158
+ choices = [@config_file].concat(@additional_configs)
159
+ res = Doing::Prompt.choose_from(choices.uniq.sort.reverse,
160
+ sorted: false,
161
+ prompt: 'Local configs found, select which to update > ')
138
162
 
139
163
  raise UserCancelled, 'Cancelled' unless res
140
164
 
@@ -214,11 +238,15 @@ module Doing
214
238
  cfg.nil? ? nil : { real_path[-1] => cfg }
215
239
  end
216
240
 
217
- # It takes the input, fills in the defaults where values do not exist.
241
+ # It takes the input, fills in the defaults where values
242
+ # do not exist.
243
+ #
244
+ # @param user_config a Hash or Configuration of
245
+ # overrides.
218
246
  #
219
- # user_config - a Hash or Configuration of overrides.
247
+ # @return [Hash] a Configuration filled with
248
+ # defaults.
220
249
  #
221
- # Returns a Configuration filled with defaults.
222
250
  def from(user_config)
223
251
  Util.deep_merge_hashes(DEFAULTS, Configuration[user_config].stringify_keys)
224
252
  end
@@ -233,14 +261,13 @@ module Doing
233
261
  old_file = File.join(Util.user_home, '.doingrc')
234
262
  return unless File.exist?(old_file)
235
263
 
236
- wwid = Doing::WWID.new
237
264
  Doing.logger.log_now(:warn, 'Deprecated:', "main config file location has changed to #{config_file}")
238
- res = wwid.yn("Move #{old_file} to new location, preserving settings?", default_response: true)
265
+ res = Prompt.yn("Move #{old_file} to new location, preserving settings?", default_response: true)
239
266
 
240
267
  return unless res
241
268
 
242
269
  if File.exist?(default_config_file)
243
- res = wwid.yn("#{default_config_file} already exists, overwrite it?", default_response: false)
270
+ res = Prompt.yn("#{default_config_file} already exists, overwrite it?", default_response: false)
244
271
 
245
272
  unless res
246
273
  @config_file = old_file
data/lib/doing/item.rb CHANGED
@@ -7,7 +7,7 @@ module Doing
7
7
  class Item
8
8
  attr_accessor :date, :title, :section, :note
9
9
 
10
- attr_reader :id
10
+ # attr_reader :id
11
11
 
12
12
  ##
13
13
  ## Initialize an item with date, title, section, and
@@ -116,7 +116,7 @@ module Doing
116
116
  ## Add (or remove) tags from the title of the item
117
117
  ##
118
118
  ## @param tags [Array] The tags to apply
119
- ## @param **options Additional options
119
+ ## @param options Additional options
120
120
  ##
121
121
  ## @option options :date [Boolean] Include timestamp?
122
122
  ## @option options :single [Boolean] Log as a single change?
@@ -162,6 +162,10 @@ module Doing
162
162
  @title.scan(/(?<= |\A)@([^\s(]+)/).map { |tag| tag[0] }.sort.uniq
163
163
  end
164
164
 
165
+ def tag_array
166
+ tags.tags_to_array
167
+ end
168
+
165
169
  ##
166
170
  ## Test if item contains tag(s)
167
171
  ##
@@ -172,6 +176,13 @@ module Doing
172
176
  ## @return [Boolean] true if tag/bool combination passes
173
177
  ##
174
178
  def tags?(tags, bool = :and, negate: false)
179
+ if bool == :pattern
180
+ tags = tags.join(' ') if tags.is_a?(Array)
181
+ matches = tag_pattern?(tags.gsub(/ *, */, ' '))
182
+
183
+ return negate ? !matches : matches
184
+ end
185
+
175
186
  tags = split_tags(tags)
176
187
  bool = bool.normalize_bool
177
188
 
@@ -186,6 +197,10 @@ module Doing
186
197
  negate ? !matches : matches
187
198
  end
188
199
 
200
+ def ignore_case(search, case_type)
201
+ (case_type == :smart && search !~ /[A-Z]/) || case_type == :ignore
202
+ end
203
+
189
204
  ##
190
205
  ## Test if item matches search string
191
206
  ##
@@ -197,9 +212,30 @@ module Doing
197
212
  ##
198
213
  ## @return [Boolean] matches search criteria
199
214
  ##
200
- def search(search, distance: 3, negate: false, case_type: :smart, fuzzy: false)
201
- text = @title + @note.to_s
202
- matches = text =~ search.to_rx(distance: distance, case_type: case_type)
215
+ def search(search, distance: nil, negate: false, case_type: nil)
216
+ prefs = Doing.config.settings['search'] || {}
217
+ matching = prefs.fetch('matching', 'pattern').normalize_matching
218
+ distance ||= prefs.fetch('distance', 3).to_i
219
+ case_type ||= prefs.fetch('case', 'smart').normalize_case
220
+
221
+ if search.is_rx? || matching == :fuzzy
222
+ matches = @title + @note.to_s =~ search.to_rx(distance: distance, case_type: case_type)
223
+ else
224
+ query = to_phrase_query(search.strip)
225
+
226
+ if query[:must].nil? && query[:must_not].nil?
227
+ query[:must] = query[:should]
228
+ query[:should] = []
229
+ end
230
+ matches = no_searches?(query[:must_not], case_type: case_type)
231
+ matches &&= all_searches?(query[:must], case_type: case_type)
232
+ matches &&= any_searches?(query[:should], case_type: case_type)
233
+ end
234
+ # if search =~ /(?<=\A| )[+-]\S/
235
+ # else
236
+ # text = @title + @note.to_s
237
+ # matches = text =~ search.to_rx(distance: distance, case_type: case_type)
238
+ # end
203
239
 
204
240
  # if search.is_rx? || !fuzzy
205
241
  # matches = text =~ search.to_rx(distance: distance, case_type: case_type)
@@ -286,33 +322,92 @@ module Doing
286
322
  start = @date
287
323
 
288
324
  t = (done - start).to_i
289
- t > 0 ? t : nil
325
+ t.positive? ? t : nil
326
+ end
327
+
328
+ def all_searches?(searches, case_type: :smart)
329
+ return true if searches.nil? || searches.empty?
330
+
331
+ text = @title + @note.to_s
332
+ searches.each do |s|
333
+ rx = Regexp.new(s.wildcard_to_rx, ignore_case(s, case_type))
334
+ return false unless text =~ rx
335
+ end
336
+ true
337
+ end
338
+
339
+ def no_searches?(searches, case_type: :smart)
340
+ return true if searches.nil? || searches.empty?
341
+
342
+ text = @title + @note.to_s
343
+ searches.each do |s|
344
+ rx = Regexp.new(s.wildcard_to_rx, ignore_case(s, case_type))
345
+ return false if text =~ rx
346
+ end
347
+ true
348
+ end
349
+
350
+ def any_searches?(searches, case_type: :smart)
351
+ return true if searches.nil? || searches.empty?
352
+
353
+ text = @title + @note.to_s
354
+ searches.each do |s|
355
+ rx = Regexp.new(s.wildcard_to_rx, ignore_case(s, case_type))
356
+ return true if text =~ rx
357
+ end
358
+ false
290
359
  end
291
360
 
292
361
  def all_tags?(tags)
362
+ return true if tags.nil? || tags.empty?
363
+
293
364
  tags.each do |tag|
294
- return false unless @title =~ /@#{tag}/
365
+ return false unless @title =~ /@#{tag.wildcard_to_rx}(?= |\(|\Z)/i
295
366
  end
296
367
  true
297
368
  end
298
369
 
299
370
  def no_tags?(tags)
371
+ return true if tags.nil? || tags.empty?
372
+
300
373
  tags.each do |tag|
301
- return false if @title =~ /@#{tag}/
374
+ return false if @title =~ /@#{tag.wildcard_to_rx}(?= |\(|\Z)/i
302
375
  end
303
376
  true
304
377
  end
305
378
 
306
379
  def any_tags?(tags)
380
+ return true if tags.nil? || tags.empty?
381
+
307
382
  tags.each do |tag|
308
- return true if @title =~ /@#{tag}/
383
+ return true if @title =~ /@#{tag.wildcard_to_rx}(?= |\(|\Z)/i
309
384
  end
310
385
  false
311
386
  end
312
387
 
388
+ def to_query(query)
389
+ parser = BooleanTermParser::QueryParser.new
390
+ transformer = BooleanTermParser::QueryTransformer.new
391
+ parse_tree = parser.parse(query)
392
+ transformer.apply(parse_tree).to_elasticsearch
393
+ end
394
+
395
+ def to_phrase_query(query)
396
+ parser = PhraseParser::QueryParser.new
397
+ transformer = PhraseParser::QueryTransformer.new
398
+ parse_tree = parser.parse(query)
399
+ transformer.apply(parse_tree).to_elasticsearch
400
+ end
401
+
402
+ def tag_pattern?(tags)
403
+ query = to_query(tags)
404
+
405
+ no_tags?(query[:must_not]) && all_tags?(query[:must]) && any_tags?(query[:should])
406
+ end
407
+
313
408
  def split_tags(tags)
314
409
  tags = tags.split(/ *, */) if tags.is_a? String
315
- tags.map { |t| t.strip.sub(/^@/, '') }
410
+ tags.map { |t| t.strip.add_at }
316
411
  end
317
412
  end
318
413
  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 = []
@@ -38,6 +38,7 @@ module Doing
38
38
  rotated
39
39
  skipped
40
40
  updated
41
+ exported
41
42
  ].freeze
42
43
 
43
44
  #
@@ -265,6 +266,31 @@ module Doing
265
266
  end
266
267
  end
267
268
 
269
+ def benchmark(key, state)
270
+ return unless ENV['DOING_BENCHMARK']
271
+
272
+ @benchmarks ||= {}
273
+ @benchmarks[key] ||= { start: nil, finish: nil }
274
+ @benchmarks[key][state] = Process.clock_gettime(Process::CLOCK_MONOTONIC)
275
+ end
276
+
277
+ def log_benchmarks
278
+ if ENV['DOING_BENCHMARK']
279
+ output = []
280
+ @benchmarks.each do |k, timers|
281
+ if timers[:finish] && timers[:start]
282
+ output << "#{k}: #{timers[:finish] - timers[:start]}"
283
+ else
284
+ output << "#{k}: error"
285
+ end
286
+ end
287
+ output.each do |msg|
288
+ $stdout.puts color_message(:debug, 'Benchmark:', msg)
289
+ end
290
+ end
291
+ end
292
+
293
+
268
294
  def log_change(tags_added: [], tags_removed: [], count: 1, item: nil, single: false)
269
295
  if tags_added.empty? && tags_removed.empty?
270
296
  count(:skipped, level: :debug, message: '%count %items with no change', count: count)
@@ -319,6 +345,8 @@ module Doing
319
345
  ['Archived:', data[:message] || 'completed and archived %count %items']
320
346
  when :skipped
321
347
  ['Skipped:', data[:message] || '%count %items were unchanged']
348
+ when :exported
349
+ ['Exported:', data[:message] || '%count %items were exported']
322
350
  end
323
351
  end
324
352
 
data/lib/doing/note.rb CHANGED
@@ -22,7 +22,7 @@ module Doing
22
22
  ## Add note contents, optionally replacing existing note
23
23
  ##
24
24
  ## @param note [Array] The note to add, can be
25
- ## string or array (Note)
25
+ ## String, Array, or Note
26
26
  ## @param replace [Boolean] replace existing
27
27
  ## content
28
28
  ##
@@ -36,32 +36,7 @@ module Doing
36
36
  end
37
37
 
38
38
  ##
39
- ## Append an array of strings to note
40
- ##
41
- ## @param lines [Array] Array of strings
42
- ##
43
- def append(lines)
44
- concat(lines)
45
- replace compress
46
- end
47
-
48
- ##
49
- ## Append a string to the note content
50
- ##
51
- ## @param input [String] The input string,
52
- ## newlines will be split
53
- ##
54
- def append_string(input)
55
- concat(input.split(/\n/).map(&:strip))
56
- replace compress
57
- end
58
-
59
- def compress!
60
- replace compress
61
- end
62
-
63
- ##
64
- ## Remove blank lines and comment lines (#)
39
+ ## Remove blank lines and comments (#)
65
40
  ##
66
41
  ## @return [Array] compressed array
67
42
  ##
@@ -69,8 +44,8 @@ module Doing
69
44
  delete_if { |l| l =~ /^\s*$/ || l =~ /^#/ }
70
45
  end
71
46
 
72
- def strip_lines!
73
- replace strip_lines
47
+ def compress!
48
+ replace compress
74
49
  end
75
50
 
76
51
  ##
@@ -83,6 +58,10 @@ module Doing
83
58
  map(&:strip)
84
59
  end
85
60
 
61
+ def strip_lines!
62
+ replace strip_lines
63
+ end
64
+
86
65
  ##
87
66
  ## Note as multi-line string
88
67
  def to_s
@@ -101,11 +80,33 @@ module Doing
101
80
  ## @param other [Note] The other Note
102
81
  ##
103
82
  ## @return [Boolean] true if equal
104
- ##
105
83
  def equal?(other)
106
84
  return false unless other.is_a?(Note)
107
85
 
108
86
  to_s == other.to_s
109
87
  end
88
+
89
+ private
90
+
91
+ ##
92
+ ## Append an array of strings to note
93
+ ##
94
+ ## @param lines [Array] Array of strings
95
+ ##
96
+ def append(lines)
97
+ concat(lines)
98
+ replace compress
99
+ end
100
+
101
+ ##
102
+ ## Append a string to the note content
103
+ ##
104
+ ## @param input [String] The input string,
105
+ ## newlines will be split
106
+ ##
107
+ def append_string(input)
108
+ concat(input.split(/\n/).map(&:strip))
109
+ replace compress
110
+ end
110
111
  end
111
112
  end
@@ -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