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
@@ -59,8 +59,33 @@ en:
59
59
  more:
60
60
  one: "+1 more"
61
61
  other: "+%{count} more"
62
+ # Query errors (Querylane::Query::MESSAGES holds the same English as defaults).
63
+ # Values a user typed are interpolated; a sensitive value never is.
62
64
  errors:
63
65
  parked: "%{key}: is not available yet."
66
+ unknown_operator: "%{key}: is not an operator."
67
+ did_you_mean: "%{key}: is not an operator. Did you mean %{suggestion}:?"
68
+ filter_value: "\"%{value}\" is not a valid %{key}: value."
69
+ filter_value_hidden: "That value is not a valid %{key}: value."
70
+ flag_value: "%{value} is not a value of %{key}:. Try %{values}."
71
+ text_in_or: "OR joins operators, not names. Search one name at a time."
72
+ text_in_not: "- and NOT apply to operators, not names."
73
+ text_in_group: "Parentheses group operators, not names."
74
+ one_text_operator: "Search %{operators} one at a time, or combine with %{filters}."
75
+ one_text_operator_alone: "Search %{operators} one at a time."
76
+ # Joins the operator and filter lists of the two messages above.
77
+ list_or: " or "
78
+ too_long: "Search is too long (%{max} characters at most)."
79
+ close_quote: "Close the quote."
80
+ needs_value: "%{key}: needs a value."
81
+ unexpected: "Unexpected %{text}."
82
+ or_needs_terms: "OR needs a term on both sides."
83
+ needs_term: "%{operator} needs a term."
84
+ ended_early: "Search ended early."
85
+ too_deep: "Too many nested parentheses (%{max} at most)."
86
+ close_parenthesis: "Add the closing )."
87
+ empty_parentheses: "Empty parentheses."
88
+ too_many_terms: "Too many terms (%{max} at most)."
64
89
  group:
65
90
  more:
66
91
  one: "1 more record for this person"
@@ -107,6 +132,7 @@ en:
107
132
  facets:
108
133
  find: "Find a %{facet}…"
109
134
  find_label: "Find a %{facet} value"
135
+ label: "Filters"
110
136
  note: "Counts are records. Ticking more boxes in one group widens the search (OR); the counts beside unticked boxes show what they would add. Different groups narrow (AND)."
111
137
  unavailable: "Counts unavailable"
112
138
  help:
@@ -124,6 +150,7 @@ en:
124
150
  widen: "Search all %{facet}"
125
151
  drop: "Drop %{label}"
126
152
  relax: "Allow fewer name matches"
153
+ as_words: "Search \"%{value}\" as part of a name"
127
154
  empty_note: "Each button changes the search box, so the new search is visible."
128
155
  empty: "No matching records found"
129
156
  unavailable_title: "Search is not answering right now."
@@ -6,9 +6,11 @@ module Querylane
6
6
  # sensitive value in clear.
7
7
  module Chips
8
8
  # alternatives: other readings of an interpreted term ("Division 5432 instead");
9
- # words: the chip of the query's words (names), not a filter.
10
- Chip = Data.define(:index, :label, :ranges, :alternatives, :words) do
11
- def initialize(index:, label:, ranges:, alternatives: [], words: false)
9
+ # words: the chip of the query's words (names), not a filter;
10
+ # as_words: for a filter whose operator opts in (`as_words: true`), the
11
+ # value as typed, which an empty search offers to search as plain words.
12
+ Chip = Data.define(:index, :label, :ranges, :alternatives, :words, :as_words) do
13
+ def initialize(index:, label:, ranges:, alternatives: [], words: false, as_words: nil)
12
14
  super
13
15
  end
14
16
  end
@@ -29,13 +31,25 @@ module Querylane
29
31
  query.nodes.each do |node|
30
32
  next if node.is_a?(Query::Term) && node.kind == :text
31
33
 
32
- chips << [label(node), [node.range], alternatives(query, node)]
34
+ chips << [label(node), [node.range], alternatives(query, node), false, as_words(query, node)]
33
35
  end
34
- chips.each_with_index.map do |(text, ranges, others, words), index|
35
- Chip.new(index: index, label: text, ranges: ranges, alternatives: others || [], words: words || false)
36
+ chips.each_with_index.map do |(text, ranges, others, words, typed), index|
37
+ Chip.new(index: index, label: text, ranges: ranges, alternatives: others || [], words: words || false, as_words: typed)
36
38
  end
37
39
  end
38
40
 
41
+ # The typed value of a top-level filter term whose operator offers it as
42
+ # plain words (never a sensitive one: the definition refuses that), or nil.
43
+ # Not offered while another text operator holds the words (one per query).
44
+ def as_words(query, node)
45
+ return unless node.is_a?(Query::Term) && node.kind == :filter && node.operator.as_words && !node.interpreted
46
+ return if query.text_operator && !query.text_operator.equal?(query.definition.default_text)
47
+
48
+ typed = node.raw.to_s.strip.presence
49
+ # Offered only when the rewritten query still runs (length and term limits).
50
+ typed if typed && Query.analyze(query.definition, Rewriter.words_in_place(query, node.range, typed), config: query.config).valid?
51
+ end
52
+
39
53
  # Other readings of an interpreted term, each with the explicit text it
40
54
  # would become (e.g. "div:5432").
41
55
  def alternatives(query, node)
@@ -63,7 +77,7 @@ module Querylane
63
77
 
64
78
  def term_label(term)
65
79
  case term.kind
66
- when :filter then format(term.operator, mask(term.value, term.operator.mask))
80
+ when :filter then format(term.operator, mask(filter_value(term), term.operator.mask))
67
81
  when :flag then "#{term.operator.label} #{term.operator.values.fetch(term.value)}"
68
82
  when :text then format(term.operator, term.value)
69
83
  end
@@ -71,6 +85,9 @@ module Querylane
71
85
 
72
86
  def format(operator, value) = operator.label.gsub("%{value}", value.to_s)
73
87
 
88
+ # The value a filter chip shows: normalized, or as typed (`display_value: :typed`).
89
+ def filter_value(term) = (term.operator.display_value == :typed && !term.interpreted) ? term.raw.to_s : term.value.to_s
90
+
74
91
  # @param value [String]
75
92
  # @param mask [Symbol] :none, :all, :last4, :count
76
93
  def mask(value, mask)
@@ -5,6 +5,7 @@ module Querylane
5
5
  #
6
6
  # Querylane.configure do |config|
7
7
  # config.searcher = -> { Querylane::Searchers::Meilisearch.new(client: MeilisearchService.client) }
8
+ # config.logger = Rails.logger
8
9
  # end
9
10
  class Configuration
10
11
  # Name of the query parameter (filtered from logs by the engine).
@@ -18,6 +19,10 @@ module Querylane
18
19
 
19
20
  attr_writer :searcher
20
21
 
22
+ # Where querylane writes why a search could not run. A host passes its
23
+ # own (Rails.logger); without one querylane logs to standard output.
24
+ attr_writer :logger
25
+
21
26
  # How long a group token ("more records", preview) stays valid.
22
27
  attr_accessor :group_token_ttl
23
28
 
@@ -35,8 +40,12 @@ module Querylane
35
40
  @debounce_ms = 300
36
41
  @allow_get_query = false
37
42
  @searcher = nil
43
+ @logger = ActiveSupport::Logger.new($stdout)
38
44
  end
39
45
 
46
+ # @return [Logger]
47
+ attr_reader :logger
48
+
40
49
  # The searcher for this request. A callable is resolved on every call, so
41
50
  # it can follow code reloading and per-request clients.
42
51
  #
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Querylane
4
+ # Host-supplied copy that may be a locale key or plain text: a panel row's
5
+ # `hint:` and `placeholder:`, a filter's `invalid:` sentence. Resolved when
6
+ # shown (never when the definition is built), so a memoized definition still
7
+ # follows the current locale.
8
+ #
9
+ # Copy.text(:"search.hints.name") # => I18n.t("search.hints.name")
10
+ # Copy.text("search.hints.name") # => the same (a dotted key)
11
+ # Copy.text("Exact last name, any case") # => as given
12
+ module Copy
13
+ # A dotted locale key: no spaces, at least one dot between word parts.
14
+ KEY = /\A[A-Za-z0-9_-]+(\.[A-Za-z0-9_-]+)+\z/
15
+
16
+ module_function
17
+
18
+ # @param value [String, Symbol, nil] a locale key or text
19
+ # @return [String, nil] the translation for a key, the text otherwise
20
+ def text(value)
21
+ case value
22
+ when nil then nil
23
+ when Symbol then I18n.t(value)
24
+ else value.to_s.match?(KEY) ? I18n.t(value.to_s) : value.to_s
25
+ end
26
+ end
27
+ end
28
+ end
@@ -17,7 +17,16 @@ module Querylane
17
17
  class Definition
18
18
  Index = Data.define(:key, :name)
19
19
  Text = Data.define(:key, :attributes, :sensitive, :default, :label, :mask)
20
- FilterOperator = Data.define(:key, :attribute, :sensitive, :normalize, :format, :label, :mask)
20
+ # invalid: a second sentence for the bad-value error (locale key or text);
21
+ # as_words: an empty search offers to search the value as plain words;
22
+ # display_value: :normalized (chips and the panel show the normalized value) or
23
+ # :typed (they show the value as typed).
24
+ FilterOperator = Data.define(:key, :attribute, :sensitive, :normalize, :format, :label, :mask, :invalid, :as_words, :display_value) do
25
+ # The newer members default, so code built for the earlier members still works.
26
+ def initialize(key:, attribute:, sensitive:, normalize:, format:, label:, mask:, invalid: nil, as_words: false, display_value: :normalized)
27
+ super
28
+ end
29
+ end
21
30
  Flags = Data.define(:key, :aliases, :attribute, :values, :rules, :label)
22
31
  # values: for a flag facet, the flag values it shows (nil: all of them).
23
32
  # top: filter-facet values loaded; show: how many show before "Find…";
@@ -44,7 +53,14 @@ module Querylane
44
53
  # values: for a :flags row, the flag values it shows (nil: all of them).
45
54
  # masked: an operator in the row masks its values (mask other than :none):
46
55
  # the field starts empty and hides what is typed.
47
- PanelRow = Data.define(:label, :keys, :kind, :bare, :masked, :values) do
56
+ # hint: a line under the row; placeholder: the empty inputs' placeholder
57
+ # (each a locale key or text, see Querylane::Copy).
58
+ PanelRow = Data.define(:label, :keys, :kind, :bare, :masked, :values, :hint, :placeholder) do
59
+ # hint and placeholder default, so code built for the earlier members still works.
60
+ def initialize(label:, keys:, kind:, bare:, masked:, values:, hint: nil, placeholder: nil)
61
+ super
62
+ end
63
+
48
64
  # @return [String] the compose field name (unique per row, also when two
49
65
  # rows show different values of one flag family)
50
66
  def name
@@ -63,6 +79,7 @@ module Querylane
63
79
  strip: ->(value) { value.strip }
64
80
  }.freeze
65
81
  MASKS = %i[none all last4 count].freeze
82
+ DISPLAYS = %i[normalized typed].freeze
66
83
 
67
84
  attr_reader :name, :indexes, :texts, :filters, :flags, :facets, :sorts, :per_options, :retrieve, :highlight, :hooks, :group,
68
85
  :saved_store, :min_text_length, :parked
@@ -228,11 +245,14 @@ module Querylane
228
245
  [nil, rows]
229
246
  end
230
247
 
231
- # [label, [[row_label, keys, {bare:}], ...]] → PanelGroup, checked against the operators
248
+ # [label, [[row_label, keys, bare, values, hint, placeholder], ...]] → PanelGroup,
249
+ # checked against the operators
232
250
  def build_group((label, rows))
233
- PanelGroup.new(label, rows.map do |row_label, keys, bare, values|
251
+ PanelGroup.new(label, rows.map do |row_label, keys, bare, values, hint, placeholder|
234
252
  if keys.all? { |key| option(key) }
235
- PanelRow.new(row_label, keys, :options, false, false, nil)
253
+ raise ConfigurationError, "#{@name}: panel row #{row_label} takes placeholder: only for inputs" if placeholder
254
+
255
+ PanelRow.new(row_label, keys, :options, false, false, nil, hint, nil)
236
256
  else
237
257
  operators = keys.map { |key| operator(key) || raise(ConfigurationError, "#{@name}: panel field #{key} is not an operator or option") }
238
258
  kind = operators.all?(Flags) ? :flags : :inputs
@@ -242,8 +262,10 @@ module Querylane
242
262
  raise ConfigurationError, "#{@name}: panel row #{row_label} takes values: only for flags" if values && kind != :flags
243
263
  unknown = Array(values) - ((kind == :flags) ? operators.first.values.keys : [])
244
264
  raise ConfigurationError, "#{@name}: panel row #{row_label} has unknown values #{unknown.join(", ")}" if unknown.any?
265
+ raise ConfigurationError, "#{@name}: panel row #{row_label} takes placeholder: only for inputs" if placeholder && kind != :inputs
245
266
 
246
- PanelRow.new(row_label, keys, kind, bare, operators.any? { |operator| operator.respond_to?(:mask) && operator.mask != :none }, values)
267
+ masked = operators.any? { |operator| operator.respond_to?(:mask) && operator.mask != :none }
268
+ PanelRow.new(row_label, keys, kind, bare, masked, values, hint, placeholder)
247
269
  end
248
270
  end.freeze)
249
271
  end
@@ -394,12 +416,22 @@ module Querylane
394
416
 
395
417
  # An exact-match operator. `normalize` runs before `format` is checked.
396
418
  # `mask` controls how chips show sensitive values: :none, :all, :last4, :count.
397
- def filter(key, attribute:, sensitive: false, normalize: nil, format: nil, label: nil, mask: nil)
419
+ # `invalid` (locale key or text) follows "… is not a valid key: value."
420
+ # as a second sentence. `as_words: true` lets an empty search offer the
421
+ # value as plain words. `display_value: :typed` shows the value as typed in
422
+ # chips and the options panel (the search still uses the normalized value).
423
+ def filter(key, attribute:, sensitive: false, normalize: nil, format: nil, label: nil, mask: nil, invalid: nil, as_words: false,
424
+ display_value: :normalized)
398
425
  normalizer = normalize.is_a?(Symbol) ? NORMALIZERS.fetch(normalize) : normalize
399
426
  mask ||= sensitive ? :all : :none
400
427
  raise ConfigurationError, "Unknown mask #{mask}" unless MASKS.include?(mask)
428
+ raise ConfigurationError, "Unknown display_value #{display_value}" unless DISPLAYS.include?(display_value)
429
+ raise ConfigurationError, "filter #{key}: display_value: :typed needs mask: :none" if display_value == :typed && mask != :none
430
+ # The link would repeat the value in clear.
431
+ raise ConfigurationError, "filter #{key}: as_words cannot be sensitive" if as_words && sensitive
401
432
 
402
- @filters << FilterOperator.new(key.to_sym, attribute.to_s, sensitive, normalizer, format, label || "#{key.to_s.humanize} %{value}", mask)
433
+ @filters << FilterOperator.new(key.to_sym, attribute.to_s, sensitive, normalizer, format, label || "#{key.to_s.humanize} %{value}", mask,
434
+ invalid, as_words ? true : false, display_value)
403
435
  end
404
436
 
405
437
  # A family of named values (`is:active`). Either `attribute:` (value
@@ -439,8 +471,10 @@ module Querylane
439
471
  # A labelled row of operators (or option keys). `bare: true` gives the
440
472
  # operators one shared input written without a prefix.
441
473
  # `values:` limits a flags row to some of its values (the rest stay in the box).
442
- def row(label, *keys, bare: false, values: nil)
443
- @rows << [label, keys.map(&:to_sym), bare, values&.map(&:to_sym)]
474
+ # `hint:` is a line under the row; `placeholder:` sits in the row's empty
475
+ # inputs (inputs rows only). Each is a locale key or text (Querylane::Copy).
476
+ def row(label, *keys, bare: false, values: nil, hint: nil, placeholder: nil)
477
+ @rows << [label, keys.map(&:to_sym), bare, values&.map(&:to_sym), hint, placeholder]
444
478
  end
445
479
  end
446
480
  end
@@ -40,7 +40,7 @@ module Querylane
40
40
 
41
41
  def parse
42
42
  if @source.length > @config.max_length
43
- raise Error.new("Search is too long (#{@config.max_length} characters at most).", 0...@source.length)
43
+ raise Error.new(message(:too_long, max: @config.max_length), 0...@source.length)
44
44
  end
45
45
 
46
46
  @tokens = tokenize
@@ -73,7 +73,7 @@ module Querylane
73
73
  elsif scanner.scan(/-(?=[^\s)])/) then token(:minus, scanner, start)
74
74
  elsif scanner.scan(KEY) then key_token(scanner, start, scanner[1])
75
75
  elsif scanner.scan(QUOTED) then token(:term, scanner, start, value: unescape(scanner[1]), quoted: true)
76
- elsif scanner.scan('"') then raise Error.new("Close the quote.", start...@source.length)
76
+ elsif scanner.scan('"') then raise Error.new(message(:close_quote), start...@source.length)
77
77
  else
78
78
  scanner.scan(BARE)
79
79
  token(:term, scanner, start, value: scanner.matched)
@@ -86,7 +86,7 @@ module Querylane
86
86
  elsif scanner.scan(BARE)
87
87
  token(:term, scanner, start, key: key, value: scanner.matched)
88
88
  else
89
- raise Error.new("#{key}: needs a value.", start...scanner.charpos)
89
+ raise Error.new(message(:needs_value, key: key), start...scanner.charpos)
90
90
  end
91
91
  end
92
92
 
@@ -99,7 +99,7 @@ module Querylane
99
99
  def parse_sequence(closing:)
100
100
  nodes = []
101
101
  until done? || (closing && peek?(:rparen))
102
- raise Error.new("Unexpected ).", current.range) if peek?(:rparen)
102
+ raise Error.new(message(:unexpected, text: ")"), current.range) if peek?(:rparen)
103
103
 
104
104
  nodes << parse_or
105
105
  end
@@ -110,7 +110,7 @@ module Querylane
110
110
  parts = [parse_unary]
111
111
  while peek?(:or)
112
112
  operator = advance
113
- raise Error.new("OR needs a term on both sides.", operator.range) if done? || peek?(:rparen) || peek?(:or)
113
+ raise Error.new(message(:or_needs_terms), operator.range) if done? || peek?(:rparen) || peek?(:or)
114
114
 
115
115
  parts << parse_unary
116
116
  end
@@ -122,7 +122,7 @@ module Querylane
122
122
  def parse_unary
123
123
  if peek?(:minus) || peek?(:not)
124
124
  operator = advance
125
- raise Error.new("#{(operator.type == :not) ? "NOT" : "-"} needs a term.", operator.range) if done?
125
+ raise Error.new(message(:needs_term, operator: (operator.type == :not) ? "NOT" : "-"), operator.range) if done?
126
126
 
127
127
  node = parse_atom
128
128
  return Ast::Not.new(node, operator.range.begin...node.range.end)
@@ -131,24 +131,24 @@ module Querylane
131
131
  end
132
132
 
133
133
  def parse_atom
134
- raise Error.new("Search ended early.", @source.length...@source.length) if done?
134
+ raise Error.new(message(:ended_early), @source.length...@source.length) if done?
135
135
 
136
136
  case current.type
137
137
  when :lparen then parse_group
138
- when :or then raise Error.new("OR needs a term on both sides.", current.range)
138
+ when :or then raise Error.new(message(:or_needs_terms), current.range)
139
139
  when :term then parse_term
140
- else raise Error.new("Unexpected #{@source[current.range]}.", current.range)
140
+ else raise Error.new(message(:unexpected, text: @source[current.range]), current.range)
141
141
  end
142
142
  end
143
143
 
144
144
  def parse_group
145
145
  open = advance
146
146
  @depth += 1
147
- raise Error.new("Too many nested parentheses (#{@config.max_depth} at most).", open.range) if @depth > @config.max_depth
147
+ raise Error.new(message(:too_deep, max: @config.max_depth), open.range) if @depth > @config.max_depth
148
148
 
149
149
  nodes = parse_sequence(closing: true)
150
- raise Error.new("Add the closing ).", open.range.begin...@source.length) unless peek?(:rparen)
151
- raise Error.new("Empty parentheses.", open.range) if nodes.empty?
150
+ raise Error.new(message(:close_parenthesis), open.range.begin...@source.length) unless peek?(:rparen)
151
+ raise Error.new(message(:empty_parentheses), open.range) if nodes.empty?
152
152
 
153
153
  close = advance
154
154
  @depth -= 1
@@ -158,11 +158,14 @@ module Querylane
158
158
  def parse_term
159
159
  term = advance
160
160
  @terms += 1
161
- raise Error.new("Too many terms (#{@config.max_terms} at most).", term.range) if @terms > @config.max_terms
161
+ raise Error.new(message(:too_many_terms, max: @config.max_terms), term.range) if @terms > @config.max_terms
162
162
 
163
163
  Ast::Term.new(term.key&.downcase, term.value, term.quoted, term.range)
164
164
  end
165
165
 
166
+ # Syntax errors share Query's localized messages (`querylane.errors.*`).
167
+ def message(name, **values) = Query.message(name, **values)
168
+
166
169
  def current = @tokens[@position]
167
170
 
168
171
  def advance
@@ -30,7 +30,13 @@ module Querylane
30
30
  super
31
31
  end
32
32
  end
33
- Group = Data.define(:key, :hits)
33
+ # lead: the hit that placed the group on the page (the first of its hits
34
+ # to come back, in the page's sort); hits may be reordered after it.
35
+ Group = Data.define(:key, :hits, :lead) do
36
+ def initialize(key:, hits:, lead: hits.first)
37
+ super
38
+ end
39
+ end
34
40
  # A group in suggestions: a short detail, its status, and what choosing it
35
41
  # does (an Action opens it; nil runs the full search).
36
42
  Suggestion = Data.define(:detail, :badge, :action)
@@ -87,7 +93,8 @@ module Querylane
87
93
  # @return [String] the id of the group's "more records" frame
88
94
  def related_frame_id(group) = "#{id}-related-#{record_key(group.hits.first)}"
89
95
 
90
- # @return [Array<Group>] in hit order, the best-ranked hit leading each group
96
+ # @return [Array<Group>] in hit order: each group sits where its first hit
97
+ # came back (best-ranked, or first in the chosen sort), which leads it
91
98
  def groups
92
99
  order = []
93
100
  grouped = {}
@@ -96,12 +103,15 @@ module Querylane
96
103
  order << key unless grouped.key?(key)
97
104
  (grouped[key] ||= []) << hit
98
105
  end
99
- order.map { |key| Group.new(key, order_records(grouped.fetch(key))) }
106
+ order.map do |key|
107
+ hits = grouped.fetch(key)
108
+ Group.new(key: key, hits: order_records(hits), lead: hits.first)
109
+ end
100
110
  end
101
111
 
102
112
  # A group's records in the definition's group order (the order "more
103
- # records" and the preview use); unchanged without one. The best-ranked
104
- # record still decides where the group sits on the page.
113
+ # records" and the preview use); unchanged without one. The lead record
114
+ # still decides where the group sits on the page and what it is called.
105
115
  def order_records(hits)
106
116
  order = result.definition.group&.order
107
117
  return hits if order.nil? || hits.one?
@@ -120,10 +130,12 @@ module Querylane
120
130
  0
121
131
  end
122
132
 
123
- # Group heading (HTML-safe); default: the first highlighted text attribute.
133
+ # Group heading (HTML-safe); default: the lead record's first highlighted
134
+ # text attribute, so headings follow the page's order (Name A–Z reads in
135
+ # name order even when one person's records carry different names).
124
136
  def title(group)
125
137
  attribute = result.definition.highlight.first
126
- view.querylane_highlight(group.hits.first, attribute)
138
+ view.querylane_highlight(group.lead, attribute)
127
139
  end
128
140
 
129
141
  # Secondary heading next to the title (HTML-safe or plain), or nil.
@@ -14,6 +14,9 @@ module Querylane
14
14
 
15
15
  attr_reader :definition, :source, :nodes, :error
16
16
 
17
+ # @return [Querylane::Configuration] the limits the query was read with
18
+ attr_reader :config
19
+
17
20
  # @param definition [Querylane::Definition]
18
21
  # @param source [String]
19
22
  # @return [Querylane::Query]
@@ -21,6 +24,41 @@ module Querylane
21
24
  new(definition, source.to_s, config)
22
25
  end
23
26
 
27
+ # English defaults of `querylane.errors.*` (the locale file repeats them).
28
+ MESSAGES = {
29
+ parked: "%{key}: is not available yet.",
30
+ unknown_operator: "%{key}: is not an operator.",
31
+ did_you_mean: "%{key}: is not an operator. Did you mean %{suggestion}:?",
32
+ filter_value: "\"%{value}\" is not a valid %{key}: value.",
33
+ filter_value_hidden: "That value is not a valid %{key}: value.",
34
+ flag_value: "%{value} is not a value of %{key}:. Try %{values}.",
35
+ text_in_or: "OR joins operators, not names. Search one name at a time.",
36
+ text_in_not: "- and NOT apply to operators, not names.",
37
+ text_in_group: "Parentheses group operators, not names.",
38
+ one_text_operator: "Search %{operators} one at a time, or combine with %{filters}.",
39
+ one_text_operator_alone: "Search %{operators} one at a time.",
40
+ list_or: " or ",
41
+ too_long: "Search is too long (%{max} characters at most).",
42
+ close_quote: "Close the quote.",
43
+ needs_value: "%{key}: needs a value.",
44
+ unexpected: "Unexpected %{text}.",
45
+ or_needs_terms: "OR needs a term on both sides.",
46
+ needs_term: "%{operator} needs a term.",
47
+ ended_early: "Search ended early.",
48
+ too_deep: "Too many nested parentheses (%{max} at most).",
49
+ close_parenthesis: "Add the closing ).",
50
+ empty_parentheses: "Empty parentheses.",
51
+ too_many_terms: "Too many terms (%{max} at most)."
52
+ }.freeze
53
+
54
+ # A query error message in the current locale.
55
+ #
56
+ # @param name [Symbol] a key of MESSAGES (`querylane.errors.<name>`)
57
+ # @return [String]
58
+ def self.message(name, **values)
59
+ I18n.t("querylane.errors.#{name}", **values, default: MESSAGES.fetch(name))
60
+ end
61
+
24
62
  def valid? = error.nil?
25
63
 
26
64
  def blank? = source.strip.empty?
@@ -67,6 +105,7 @@ module Querylane
67
105
  def initialize(definition, source, config)
68
106
  @definition = definition
69
107
  @source = source
108
+ @config = config
70
109
  @nodes = []
71
110
  @error = nil
72
111
  root = Parser.parse(source, config: config)
@@ -106,7 +145,7 @@ module Querylane
106
145
  end
107
146
  return text_term(definition.default_text, value, term, context, interpreted) if key.nil?
108
147
 
109
- raise Invalid.new(I18n.t("querylane.errors.parked", key: key, default: "%{key}: is not available yet."), term.range) if definition.parked.include?(key.to_s.downcase)
148
+ raise Invalid.new(message(:parked, key: key), term.range) if definition.parked.include?(key.to_s.downcase)
110
149
 
111
150
  operator = definition.operator(key) || raise(unknown_key(key, term))
112
151
  resolve_operator(operator, value, term, context, interpreted)
@@ -121,22 +160,18 @@ module Querylane
121
160
  end
122
161
 
123
162
  def text_term(operator, value, term, context, interpreted)
124
- if context
125
- message = {
126
- or: "OR joins operators, not names. Search one name at a time.",
127
- not: "- and NOT apply to operators, not names.",
128
- group: "Parentheses group operators, not names."
129
- }.fetch(context)
130
- raise Invalid.new(message, term.range)
131
- end
163
+ raise Invalid.new(message(:"text_in_#{context}"), term.range) if context
164
+
132
165
  Term.new(:text, operator, value, term.quoted, term.range, interpreted, term.value)
133
166
  end
134
167
 
135
168
  def filter_term(operator, value, term, interpreted)
136
- normalized = operator.normalize ? operator.normalize.call(value) : value
169
+ # A copy: a normalizer that edits its argument must not change the typed value.
170
+ normalized = operator.normalize ? operator.normalize.call(value.dup) : value
137
171
  if operator.format && !operator.format.match?(normalized)
138
- shown = operator.sensitive ? "That value" : "\"#{value}\""
139
- raise Invalid.new("#{shown} is not a valid #{operator.key}: value.", term.range)
172
+ # A sensitive value is never repeated, not even to the host's copy.
173
+ text = operator.sensitive ? message(:filter_value_hidden, key: operator.key) : message(:filter_value, key: operator.key, value: value)
174
+ raise Invalid.new([text, Copy.text(operator.invalid)].compact.join(" "), term.range)
140
175
  end
141
176
  Term.new(:filter, operator, normalized, term.quoted, term.range, interpreted, term.value)
142
177
  end
@@ -144,7 +179,7 @@ module Querylane
144
179
  def flag_term(operator, value, term, interpreted)
145
180
  symbol = value.to_s.downcase.to_sym
146
181
  unless operator.values.key?(symbol)
147
- raise Invalid.new("#{value} is not a value of #{operator.key}:. Try #{operator.values.keys.join(", ")}.", term.range)
182
+ raise Invalid.new(message(:flag_value, key: operator.key, value: value, values: operator.values.keys.join(", ")), term.range)
148
183
  end
149
184
  Term.new(:flag, operator, symbol, term.quoted, term.range, interpreted, term.value)
150
185
  end
@@ -154,19 +189,26 @@ module Querylane
154
189
  return if operators.size <= 1
155
190
 
156
191
  second = text_terms.find { |term| term.operator == operators.last }
157
- others = definition.filters.map { |filter| "#{filter.key}:" }.first(2).join(" or ")
158
- raise Invalid.new("Search #{operators.map(&:key).join(" or ")} one at a time, or combine with #{others}.", second.range)
192
+ joiner = message(:list_or)
193
+ keys = operators.map(&:key).join(joiner)
194
+ # The first two filters, in declaration order (a host declares its best companions first).
195
+ others = definition.filters.first(2).map { |filter| "#{filter.key}:" }.join(joiner)
196
+ text = others.empty? ? message(:one_text_operator_alone, operators: keys) : message(:one_text_operator, operators: keys, filters: others)
197
+ raise Invalid.new(text, second.range)
159
198
  end
160
199
 
161
200
  # "stauts: is not an operator. Did you mean status:?", with the corrected term as the fix.
162
201
  def unknown_key(key, term)
163
202
  suggestion = definition.operator_keys.min_by { |candidate| distance(candidate, key) }
164
- return Invalid.new("#{key}: is not an operator.", term.range) unless suggestion && distance(suggestion, key) <= 2
203
+ return Invalid.new(message(:unknown_operator, key: key), term.range) unless suggestion && distance(suggestion, key) <= 2
165
204
 
166
205
  fixed = source[term.range].sub(/\A[^:]+/, suggestion)
167
- Invalid.new("#{key}: is not an operator. Did you mean #{suggestion}:?", term.range, fixed)
206
+ Invalid.new(message(:did_you_mean, key: key, suggestion: suggestion), term.range, fixed)
168
207
  end
169
208
 
209
+ # A query error from `querylane.errors.*`, English by default.
210
+ def message(name, **values) = Query.message(name, **values)
211
+
170
212
  # Levenshtein distance (short strings only).
171
213
  def distance(a, b)
172
214
  previous = (0..b.length).to_a
@@ -4,29 +4,31 @@ module Querylane
4
4
  # One search request, bounded and allowlisted against the definition.
5
5
  #
6
6
  # Params: query (Configuration#param_name), toggle ("facet:value"),
7
- # remove (chip index), clear, instead, widen, relax, compose (with
7
+ # remove (chip index), as_words (chip index), clear, instead, widen, relax, compose (with
8
8
  # compose_submit), options, sort, page, per, revision.
9
9
  class Request
10
- attr_reader :fix, :saved, :save_search, :saved_name, :group, :query, :toggle, :remove, :clear, :instead, :widen, :relax, :compose, :options, :sort, :page, :per, :revision
10
+ attr_reader :fix, :saved, :save_search, :saved_name, :group, :query, :toggle, :remove, :as_words, :clear, :instead, :widen, :relax, :compose, :options, :sort, :page, :per, :revision
11
11
 
12
12
  # @param params [ActionController::Parameters, Hash]
13
13
  # @param definition [Querylane::Definition]
14
14
  def self.from_params(params, definition:, config: Querylane.configuration)
15
15
  values = params.respond_to?(:to_unsafe_h) ? params.to_unsafe_h : params.to_h
16
16
  values = values.transform_keys(&:to_s)
17
- new(definition: definition, query: values[config.param_name.to_s], toggle: values["toggle"], remove: values["remove"], clear: values["clear"],
17
+ new(definition: definition, query: values[config.param_name.to_s], toggle: values["toggle"], remove: values["remove"],
18
+ as_words: values["as_words"], clear: values["clear"],
18
19
  instead: values["instead"], widen: values["widen"], relax: values["relax"],
19
20
  compose: (values["compose"] if values["compose_submit"].present?), options: values["options"],
20
21
  fix: values["fix"], group: values["group"], saved: values["saved"], save_search: values["save_search"], saved_name: values["saved_name"], sort: values["sort"], page: values["page"], per: values["per"], revision: values["revision"])
21
22
  end
22
23
 
23
24
  # instead: "chip.alternative" (use another reading of an interpreted term);
24
- # widen: a facet key (drop that facet's terms); relax: match fewer words.
25
+ # widen: a facet key (drop that facet's terms); relax: match fewer words;
26
+ # as_words: a chip index (search that filter's typed value as plain words).
25
27
  # compose: options-panel fields {"name" => "…", "policy" => "1 2", "is" => [...], "options" => [...]}
26
28
  # (applied only with compose_submit); options: open the options panel;
27
29
  # group: a GroupToken (all records of one group, see Search#related);
28
30
  # saved: a saved search id to run; save_search with saved_name: save the query.
29
- def initialize(definition:, query: nil, toggle: nil, remove: nil, clear: nil, instead: nil, widen: nil, relax: nil,
31
+ def initialize(definition:, query: nil, toggle: nil, remove: nil, as_words: nil, clear: nil, instead: nil, widen: nil, relax: nil,
30
32
  compose: nil, options: nil, fix: nil, group: nil, saved: nil, save_search: nil, saved_name: nil, sort: nil, page: nil, per: nil, revision: nil)
31
33
  @query = query.to_s
32
34
  @clear = clear.present?
@@ -42,6 +44,7 @@ module Querylane
42
44
  @compose = compose.is_a?(Hash) ? compose.to_h.transform_keys(&:to_s) : nil
43
45
  @toggle = parse_toggle(definition, toggle)
44
46
  @remove = (Integer(remove.to_s, exception: false) if remove.present?)
47
+ @as_words = (Integer(as_words.to_s, exception: false) if as_words.present?)
45
48
  @sort = definition.sort(sort)
46
49
  @per = definition.per_options.include?(per.to_i) ? per.to_i : definition.per_options.first
47
50
  @page = rewriting? ? 1 : [page.to_i, 1].max
@@ -52,7 +55,7 @@ module Querylane
52
55
  def offset = (page - 1) * per
53
56
 
54
57
  # Whether this request asks the server to rewrite the query text.
55
- def rewriting? = (@toggle || @remove || @clear || @instead || @widen || @compose || @saved) ? true : false
58
+ def rewriting? = (@toggle || @remove || @as_words || @clear || @instead || @widen || @compose || @saved) ? true : false
56
59
 
57
60
  private
58
61