querylane 0.1.0 → 0.2.0

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 (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +73 -1
  3. data/CONTRIBUTING.md +1 -1
  4. data/README.md +2 -0
  5. data/app/assets/stylesheets/querylane.css +12 -1
  6. data/app/javascript/querylane/controllers/box_controller.js +1 -1
  7. data/app/javascript/querylane/controllers/results_controller.js +8 -1
  8. data/app/views/querylane/_chips.html.erb +2 -1
  9. data/app/views/querylane/_denied.html.erb +2 -1
  10. data/app/views/querylane/_empty.html.erb +10 -4
  11. data/app/views/querylane/_facets.html.erb +3 -2
  12. data/app/views/querylane/_first_load.html.erb +2 -1
  13. data/app/views/querylane/_form.html.erb +19 -9
  14. data/app/views/querylane/_group.html.erb +2 -1
  15. data/app/views/querylane/_help.html.erb +3 -2
  16. data/app/views/querylane/_options.html.erb +5 -3
  17. data/app/views/querylane/_options_row.html.erb +18 -7
  18. data/app/views/querylane/_outcome.html.erb +5 -3
  19. data/app/views/querylane/_pager.html.erb +3 -2
  20. data/app/views/querylane/_preview.html.erb +2 -1
  21. data/app/views/querylane/_record.html.erb +2 -1
  22. data/app/views/querylane/_related.html.erb +2 -1
  23. data/app/views/querylane/_results.html.erb +2 -2
  24. data/app/views/querylane/_rows.html.erb +2 -1
  25. data/app/views/querylane/_saved.html.erb +2 -1
  26. data/app/views/querylane/_suggest_box.html.erb +10 -7
  27. data/app/views/querylane/_suggestions.html.erb +2 -2
  28. data/app/views/querylane/_toolbar.html.erb +2 -1
  29. data/app/views/querylane/_unavailable.html.erb +2 -1
  30. data/app/views/querylane/_writes.html.erb +4 -3
  31. data/config/locales/querylane.en.yml +27 -0
  32. data/lib/querylane/chips.rb +24 -7
  33. data/lib/querylane/configuration.rb +9 -0
  34. data/lib/querylane/copy.rb +28 -0
  35. data/lib/querylane/definition.rb +44 -10
  36. data/lib/querylane/parser.rb +16 -13
  37. data/lib/querylane/presenter.rb +19 -7
  38. data/lib/querylane/query.rb +59 -17
  39. data/lib/querylane/request.rb +9 -6
  40. data/lib/querylane/rewriter.rb +44 -2
  41. data/lib/querylane/search.rb +2 -0
  42. data/lib/querylane/searchers/meilisearch.rb +19 -2
  43. data/lib/querylane/version.rb +1 -1
  44. data/lib/querylane.rb +1 -0
  45. metadata +3 -2
@@ -50,6 +50,40 @@ module Querylane
50
50
  text.strip
51
51
  end
52
52
 
53
+ # The query with one filter chip's term replaced by its typed value as
54
+ # plain words ("smith last:smiht" → "smith smiht"), for a chip that offers
55
+ # it (Chip#as_words); otherwise the query unchanged.
56
+ #
57
+ # @param query [Querylane::Query] valid
58
+ # @param chip [Querylane::Chips::Chip, nil]
59
+ # @return [String]
60
+ def as_words(query, chip)
61
+ return query.source unless chip&.as_words
62
+
63
+ words_in_place(query, chip.ranges.first, chip.as_words)
64
+ end
65
+
66
+ # The query text with `range` replaced by `typed` as plain words.
67
+ #
68
+ # @return [String]
69
+ def words_in_place(query, range, typed)
70
+ words = typed.split.map { |word| bare_word(query.definition, word) }.join(" ")
71
+ # Keep the words apart from a neighbour typed without a space (`last:"x"is:active`).
72
+ before = (range.begin.positive? && !query.source[range.begin - 1].match?(/\s/)) ? " " : ""
73
+ after = (range.end < query.source.length && !query.source[range.end].match?(/\s/)) ? " " : ""
74
+ replace(query.source, range, "#{before}#{words}#{after}")
75
+ end
76
+
77
+ # A word that reads back as a plain word: anything the parser would take
78
+ # as syntax (an operator key, a leading "-", OR, NOT, brackets, quotes) or
79
+ # the interpreter would read as an operator ("1234" as a last four) is
80
+ # quoted, since quoted words are never interpreted.
81
+ def bare_word(definition, word)
82
+ plain = word.match?(/\A[^\s()"-][^\s()"]*\z/) && !word.match?(/\A#{Parser::KEY}/o) && !%w[OR NOT].include?(word) &&
83
+ definition.interpret(word).nil?
84
+ plain ? word : quoted(word)
85
+ end
86
+
53
87
  # The options panel's view of a query: each field's value (keyed by the
54
88
  # compose field name), the parts no field can show (kept as typed), and,
55
89
  # per masked field, the masked terms it leaves in the box (masked values
@@ -139,7 +173,7 @@ module Querylane
139
173
  written = found.map { |token| token.quoted ? quoted(token.text) : quote(token.text) }
140
174
  operator.default ? written.join(" ") : written.map { |text| "#{operator.key}:#{text}" }.join(" ")
141
175
  else
142
- found = found.map { |token| operator.normalize ? token.with(text: operator.normalize.call(token.text)) : token }
176
+ found = found.map { |token| (operator.normalize && !typed?(operator)) ? token.with(text: operator.normalize.call(token.text)) : token }
143
177
  join_or(found.uniq.map { |token| "#{operator.key}:#{token.quoted ? quoted(token.text) : quote(token.text)}" })
144
178
  end
145
179
  end
@@ -169,7 +203,15 @@ module Querylane
169
203
  end
170
204
  end
171
205
 
172
- def display(term) = term.quoted ? quoted(term.value.to_s) : term.value.to_s
206
+ # A field's text for a term: quoted as typed; a `display_value: :typed` filter
207
+ # shows the value as typed, others (and interpreted terms, whose typed
208
+ # token would read differently once prefixed) the normalized value.
209
+ def display(term)
210
+ value = (typed?(term.operator) && !term.interpreted) ? term.raw.to_s : term.value.to_s
211
+ term.quoted ? quoted(value) : value
212
+ end
213
+
214
+ def typed?(operator) = operator.is_a?(Definition::FilterOperator) && operator.display_value == :typed
173
215
 
174
216
  # Always quoted, with " and \ escaped.
175
217
  def quoted(value) = %("#{value.to_s.gsub(/["\\]/) { |char| "\\#{char}" }}")
@@ -211,6 +211,8 @@ module Querylane
211
211
  chip = Chips.for(query).find { |candidate| candidate.index == chip_index }
212
212
  alternative = chip&.alternatives&.find { |candidate| candidate.index == alternative_index }
213
213
  alternative ? Rewriter.replace(query.source, chip.ranges.first, alternative.text) : query.source
214
+ elsif @request.as_words
215
+ Rewriter.as_words(query, Chips.for(query).find { |candidate| candidate.index == @request.as_words })
214
216
  else
215
217
  chip = Chips.for(query).find { |candidate| candidate.index == @request.remove }
216
218
  chip ? Rewriter.remove(query.source, chip.ranges) : query.source
@@ -67,10 +67,27 @@ module Querylane
67
67
 
68
68
  def client = @client.respond_to?(:call) ? @client.call : @client
69
69
 
70
+ # Turns a backend failure into SearchUnavailable and logs why. An API
71
+ # error is logged by code, type and status only: its message can echo
72
+ # the filter, and filters carry what the user searched for.
70
73
  def call
71
74
  yield
72
- rescue ::Meilisearch::ApiError, ::Meilisearch::CommunicationError, ::Meilisearch::TimeoutError => error
73
- raise SearchUnavailable, "Meilisearch #{error.class.name.demodulize}"
75
+ rescue ::Meilisearch::ApiError => error
76
+ unavailable(error, "code=#{error.code} type=#{error.type} http=#{error.http_code}", error.code)
77
+ rescue ::Meilisearch::CommunicationError, ::Meilisearch::TimeoutError => error
78
+ unavailable(error, without_url_secrets(error.message))
79
+ end
80
+
81
+ # A connection error can quote the client URL; keep its host and port,
82
+ # drop any user:password@ and query string.
83
+ def without_url_secrets(message)
84
+ message.gsub(%r{(\w+://)[^\s/@]+@}, '\1').gsub(/\?[^\s)]*/, "")
85
+ end
86
+
87
+ def unavailable(error, detail, code = nil)
88
+ name = "Meilisearch #{error.class.name.demodulize}"
89
+ Querylane.configuration.logger.warn("[querylane] search unavailable: #{name} #{detail}")
90
+ raise SearchUnavailable, [name, code].compact.join(" ")
74
91
  end
75
92
 
76
93
  # Indexes whose scope and query can match something; the others are
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Querylane
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
data/lib/querylane.rb CHANGED
@@ -14,6 +14,7 @@ require "strscan"
14
14
 
15
15
  require "querylane/version"
16
16
  require "querylane/errors"
17
+ require "querylane/copy"
17
18
  require "querylane/configuration"
18
19
  require "querylane/filter"
19
20
  require "querylane/scope"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: querylane
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ajaya Agrawalla
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-10-20 00:00:00.000000000 Z
11
+ date: 2026-11-10 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -79,6 +79,7 @@ files:
79
79
  - lib/querylane/configuration.rb
80
80
  - lib/querylane/context.rb
81
81
  - lib/querylane/controller.rb
82
+ - lib/querylane/copy.rb
82
83
  - lib/querylane/definition.rb
83
84
  - lib/querylane/doctor.rb
84
85
  - lib/querylane/engine.rb