doing 2.1.3 → 2.1.6

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 (115) 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 +48 -0
  7. data/Gemfile.lock +25 -1
  8. data/README.md +5 -1
  9. data/bin/doing +429 -142
  10. data/docs/_config.yml +1 -0
  11. data/{doc → docs/doc}/Array.html +63 -1
  12. data/docs/doc/BooleanTermParser/Clause.html +293 -0
  13. data/docs/doc/BooleanTermParser/Operator.html +172 -0
  14. data/docs/doc/BooleanTermParser/Query.html +417 -0
  15. data/docs/doc/BooleanTermParser/QueryParser.html +135 -0
  16. data/docs/doc/BooleanTermParser/QueryTransformer.html +124 -0
  17. data/docs/doc/BooleanTermParser.html +115 -0
  18. data/docs/doc/Doing/CLIFormat.html +131 -0
  19. data/{doc → docs/doc}/Doing/Color.html +2 -2
  20. data/{doc → docs/doc}/Doing/Completion.html +1 -1
  21. data/{doc → docs/doc}/Doing/Configuration.html +157 -11
  22. data/{doc → docs/doc}/Doing/Content.html +0 -0
  23. data/{doc → docs/doc}/Doing/Errors/DoingNoTraceError.html +1 -1
  24. data/{doc → docs/doc}/Doing/Errors/DoingRuntimeError.html +1 -1
  25. data/{doc → docs/doc}/Doing/Errors/DoingStandardError.html +1 -1
  26. data/{doc → docs/doc}/Doing/Errors/EmptyInput.html +1 -1
  27. data/{doc → docs/doc}/Doing/Errors/NoResults.html +1 -1
  28. data/{doc → docs/doc}/Doing/Errors/PluginException.html +1 -1
  29. data/{doc → docs/doc}/Doing/Errors/UserCancelled.html +1 -1
  30. data/{doc → docs/doc}/Doing/Errors/WrongCommand.html +1 -1
  31. data/{doc → docs/doc}/Doing/Errors.html +1 -1
  32. data/{doc → docs/doc}/Doing/Hooks.html +1 -1
  33. data/{doc → docs/doc}/Doing/Item.html +134 -73
  34. data/{doc → docs/doc}/Doing/Items.html +36 -2
  35. data/{doc → docs/doc}/Doing/LogAdapter.html +70 -1
  36. data/{doc → docs/doc}/Doing/Note.html +5 -134
  37. data/{doc → docs/doc}/Doing/Pager.html +1 -1
  38. data/{doc → docs/doc}/Doing/Plugins.html +431 -35
  39. data/{doc → docs/doc}/Doing/Prompt.html +35 -1
  40. data/{doc → docs/doc}/Doing/Section.html +1 -1
  41. data/docs/doc/Doing/TemplateString.html +713 -0
  42. data/docs/doc/Doing/Util/Backup.html +686 -0
  43. data/{doc → docs/doc}/Doing/Util.html +16 -4
  44. data/{doc → docs/doc}/Doing/WWID.html +133 -73
  45. data/{doc → docs/doc}/Doing/WWIDFile.html +0 -0
  46. data/{doc → docs/doc}/Doing.html +4 -4
  47. data/{doc → docs/doc}/GLI/Commands/MarkdownDocumentListener.html +1 -1
  48. data/{doc → docs/doc}/GLI/Commands.html +1 -1
  49. data/{doc → docs/doc}/GLI.html +1 -1
  50. data/{doc → docs/doc}/Hash.html +1 -1
  51. data/docs/doc/PhraseParser/Operator.html +172 -0
  52. data/docs/doc/PhraseParser/PhraseClause.html +303 -0
  53. data/docs/doc/PhraseParser/Query.html +495 -0
  54. data/docs/doc/PhraseParser/QueryParser.html +136 -0
  55. data/docs/doc/PhraseParser/QueryTransformer.html +124 -0
  56. data/docs/doc/PhraseParser/TermClause.html +293 -0
  57. data/docs/doc/PhraseParser.html +115 -0
  58. data/{doc → docs/doc}/Status.html +1 -1
  59. data/{doc → docs/doc}/String.html +285 -13
  60. data/{doc → docs/doc}/Symbol.html +35 -1
  61. data/{doc → docs/doc}/Time.html +70 -2
  62. data/{doc → docs/doc}/_index.html +132 -4
  63. data/docs/doc/class_list.html +51 -0
  64. data/{doc → docs/doc}/css/common.css +0 -0
  65. data/{doc → docs/doc}/css/full_list.css +0 -0
  66. data/{doc → docs/doc}/css/style.css +0 -0
  67. data/{doc → docs/doc}/file.README.html +6 -2
  68. data/{doc → docs/doc}/file_list.html +0 -0
  69. data/{doc → docs/doc}/frames.html +0 -0
  70. data/{doc → docs/doc}/index.html +6 -2
  71. data/{doc → docs/doc}/js/app.js +0 -0
  72. data/{doc → docs/doc}/js/full_list.js +0 -0
  73. data/{doc → docs/doc}/js/jquery.js +0 -0
  74. data/{doc → docs/doc}/method_list.html +624 -136
  75. data/{doc → docs/doc}/top-level-namespace.html +2 -2
  76. data/docs/index.md +60 -0
  77. data/doing.gemspec +3 -0
  78. data/doing.rdoc +222 -74
  79. data/example_plugin.rb +3 -1
  80. data/lib/completion/_doing.zsh +53 -41
  81. data/lib/completion/doing.bash +17 -6
  82. data/lib/completion/doing.fish +321 -2
  83. data/lib/doing/array.rb +9 -0
  84. data/lib/doing/boolean_term_parser.rb +86 -0
  85. data/lib/doing/completion/fish_completion.rb +46 -3
  86. data/lib/doing/completion/zsh_completion.rb +1 -1
  87. data/lib/doing/configuration.rb +45 -14
  88. data/lib/doing/item.rb +104 -9
  89. data/lib/doing/items.rb +6 -0
  90. data/lib/doing/log_adapter.rb +28 -0
  91. data/lib/doing/note.rb +31 -30
  92. data/lib/doing/phrase_parser.rb +124 -0
  93. data/lib/doing/plugin_manager.rb +84 -21
  94. data/lib/doing/plugins/export/dayone_export.rb +209 -0
  95. data/lib/doing/plugins/export/html_export.rb +2 -2
  96. data/lib/doing/plugins/export/json_export.rb +1 -0
  97. data/lib/doing/plugins/export/markdown_export.rb +1 -1
  98. data/lib/doing/plugins/export/template_export.rb +90 -85
  99. data/lib/doing/prompt.rb +17 -6
  100. data/lib/doing/string.rb +84 -29
  101. data/lib/doing/string_chronify.rb +5 -1
  102. data/lib/doing/symbol.rb +4 -0
  103. data/lib/doing/template_string.rb +197 -0
  104. data/lib/doing/time.rb +32 -0
  105. data/lib/doing/util.rb +6 -7
  106. data/lib/doing/util_backup.rb +287 -0
  107. data/lib/doing/version.rb +1 -1
  108. data/lib/doing/wwid.rb +105 -41
  109. data/lib/doing.rb +9 -0
  110. data/lib/examples/plugins/say_export.rb +1 -1
  111. data/lib/examples/plugins/wiki_export/wiki_export.rb +3 -3
  112. data/lib/templates/doing-dayone-entry.erb +6 -0
  113. data/lib/templates/doing-dayone.erb +5 -0
  114. metadata +136 -51
  115. 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, :config_file
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,14 @@ 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',
35
+ 'history_size' => 15,
30
36
  'paginate' => false,
31
37
  'never_time' => [],
32
38
  'never_finish' => [],
39
+ 'date_tags' => ['done', 'defer(?:red)?', 'waiting'],
33
40
 
34
41
  'timer_format' => 'text',
35
42
  'interval_format' => 'text',
@@ -37,7 +44,8 @@ module Doing
37
44
  'templates' => {
38
45
  'default' => {
39
46
  'date_format' => '%Y-%m-%d %H:%M',
40
- 'template' => '%date | %title %interval%duration%note',
47
+ 'template' => '%reset%cyan%shortdate %boldwhite%80║ title %dark%boldmagenta[%boldwhite%-10section%boldmagenta]%reset
48
+ %yellow%interval%boldred%duration%dark%white%80_14┃ note',
41
49
  'wrap_width' => 0,
42
50
  'order' => 'asc'
43
51
  },
@@ -54,7 +62,8 @@ module Doing
54
62
  },
55
63
  'recent' => {
56
64
  'date_format' => '%_I:%M%P',
57
- 'template' => '%shortdate: %title (%section) %interval%duration%note',
65
+ 'template' => '%reset%cyan%shortdate %boldwhite%80║ title %dark%boldmagenta[%boldwhite%-10section%boldmagenta]%reset
66
+ %yellow%interval%boldred%duration%dark%white%80_14┃ note',
58
67
  'wrap_width' => 88,
59
68
  'count' => 10,
60
69
  'order' => 'asc'
@@ -66,7 +75,7 @@ module Doing
66
75
  'views' => {
67
76
  'done' => {
68
77
  'date_format' => '%_I:%M%P',
69
- 'template' => '%date | %title%note',
78
+ 'template' => '%date | %title (%section)% 18: note',
70
79
  'wrap_width' => 0,
71
80
  'section' => 'All',
72
81
  'count' => 0,
@@ -87,6 +96,11 @@ module Doing
87
96
  'marker_color' => 'red',
88
97
  'default_tags' => [],
89
98
  'tag_sort' => 'name',
99
+ 'search' => {
100
+ 'matching' => 'pattern', # fuzzy, pattern, exact
101
+ 'distance' => 3,
102
+ 'case' => 'smart' # sensitive, ignore, smart
103
+ },
90
104
  'include_notes' => true
91
105
  }
92
106
 
@@ -104,6 +118,17 @@ module Doing
104
118
  @config_dir ||= File.join(Util.user_home, '.config', 'doing')
105
119
  end
106
120
 
121
+ ##
122
+ ## Check if configuration enforces exact string matching
123
+ ##
124
+ ## @return [Boolean] exact matching enabled
125
+ ##
126
+ def exact_match?
127
+ search_settings = @settings['search']
128
+ matching = search_settings.fetch('matching', 'pattern').normalize_matching
129
+ matching == :exact
130
+ end
131
+
107
132
  def default_config_file
108
133
  if File.exist?(config_dir) && !File.directory?(config_dir)
109
134
  raise DoingRuntimeError, "#{config_dir} exists but is not a directory"
@@ -128,10 +153,13 @@ module Doing
128
153
  ## @return [String] file path
129
154
  ##
130
155
  def choose_config
156
+ return @config_file if @force_answer
157
+
131
158
  if @additional_configs.count.positive?
132
- choices = [@config_file]
133
- choices.concat(@additional_configs)
134
- res = Doing::Prompt.choose_from(choices.uniq.sort.reverse, sorted: false, prompt: 'Local configs found, select which to update > ')
159
+ choices = [@config_file].concat(@additional_configs)
160
+ res = Doing::Prompt.choose_from(choices.uniq.sort.reverse,
161
+ sorted: false,
162
+ prompt: 'Local configs found, select which to update > ')
135
163
 
136
164
  raise UserCancelled, 'Cancelled' unless res
137
165
 
@@ -211,11 +239,15 @@ module Doing
211
239
  cfg.nil? ? nil : { real_path[-1] => cfg }
212
240
  end
213
241
 
214
- # It takes the input, fills in the defaults where values do not exist.
242
+ # It takes the input, fills in the defaults where values
243
+ # do not exist.
244
+ #
245
+ # @param user_config a Hash or Configuration of
246
+ # overrides.
215
247
  #
216
- # user_config - a Hash or Configuration of overrides.
248
+ # @return [Hash] a Configuration filled with
249
+ # defaults.
217
250
  #
218
- # Returns a Configuration filled with defaults.
219
251
  def from(user_config)
220
252
  Util.deep_merge_hashes(DEFAULTS, Configuration[user_config].stringify_keys)
221
253
  end
@@ -230,14 +262,13 @@ module Doing
230
262
  old_file = File.join(Util.user_home, '.doingrc')
231
263
  return unless File.exist?(old_file)
232
264
 
233
- wwid = Doing::WWID.new
234
265
  Doing.logger.log_now(:warn, 'Deprecated:', "main config file location has changed to #{config_file}")
235
- res = wwid.yn("Move #{old_file} to new location, preserving settings?", default_response: true)
266
+ res = Prompt.yn("Move #{old_file} to new location, preserving settings?", default_response: true)
236
267
 
237
268
  return unless res
238
269
 
239
270
  if File.exist?(default_config_file)
240
- res = wwid.yn("#{default_config_file} already exists, overwrite it?", default_response: false)
271
+ res = Prompt.yn("#{default_config_file} already exists, overwrite it?", default_response: false)
241
272
 
242
273
  unless res
243
274
  @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
@@ -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