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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +73 -1
- data/CONTRIBUTING.md +1 -1
- data/README.md +2 -0
- data/app/assets/stylesheets/querylane.css +12 -1
- data/app/javascript/querylane/controllers/box_controller.js +1 -1
- data/app/javascript/querylane/controllers/results_controller.js +8 -1
- data/app/views/querylane/_chips.html.erb +2 -1
- data/app/views/querylane/_denied.html.erb +2 -1
- data/app/views/querylane/_empty.html.erb +10 -4
- data/app/views/querylane/_facets.html.erb +3 -2
- data/app/views/querylane/_first_load.html.erb +2 -1
- data/app/views/querylane/_form.html.erb +19 -9
- data/app/views/querylane/_group.html.erb +2 -1
- data/app/views/querylane/_help.html.erb +3 -2
- data/app/views/querylane/_options.html.erb +5 -3
- data/app/views/querylane/_options_row.html.erb +18 -7
- data/app/views/querylane/_outcome.html.erb +5 -3
- data/app/views/querylane/_pager.html.erb +3 -2
- data/app/views/querylane/_preview.html.erb +2 -1
- data/app/views/querylane/_record.html.erb +2 -1
- data/app/views/querylane/_related.html.erb +2 -1
- data/app/views/querylane/_results.html.erb +2 -2
- data/app/views/querylane/_rows.html.erb +2 -1
- data/app/views/querylane/_saved.html.erb +2 -1
- data/app/views/querylane/_suggest_box.html.erb +10 -7
- data/app/views/querylane/_suggestions.html.erb +2 -2
- data/app/views/querylane/_toolbar.html.erb +2 -1
- data/app/views/querylane/_unavailable.html.erb +2 -1
- data/app/views/querylane/_writes.html.erb +4 -3
- data/config/locales/querylane.en.yml +27 -0
- data/lib/querylane/chips.rb +24 -7
- data/lib/querylane/configuration.rb +9 -0
- data/lib/querylane/copy.rb +28 -0
- data/lib/querylane/definition.rb +44 -10
- data/lib/querylane/parser.rb +16 -13
- data/lib/querylane/presenter.rb +19 -7
- data/lib/querylane/query.rb +59 -17
- data/lib/querylane/request.rb +9 -6
- data/lib/querylane/rewriter.rb +44 -2
- data/lib/querylane/search.rb +2 -0
- data/lib/querylane/searchers/meilisearch.rb +19 -2
- data/lib/querylane/version.rb +1 -1
- data/lib/querylane.rb +1 -0
- 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."
|
data/lib/querylane/chips.rb
CHANGED
|
@@ -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
|
-
|
|
11
|
-
|
|
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
|
|
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
|
data/lib/querylane/definition.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
443
|
-
|
|
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
|
data/lib/querylane/parser.rb
CHANGED
|
@@ -40,7 +40,7 @@ module Querylane
|
|
|
40
40
|
|
|
41
41
|
def parse
|
|
42
42
|
if @source.length > @config.max_length
|
|
43
|
-
raise Error.new(
|
|
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(
|
|
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(
|
|
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("
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
151
|
-
raise Error.new(
|
|
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(
|
|
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
|
data/lib/querylane/presenter.rb
CHANGED
|
@@ -30,7 +30,13 @@ module Querylane
|
|
|
30
30
|
super
|
|
31
31
|
end
|
|
32
32
|
end
|
|
33
|
-
|
|
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
|
|
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
|
|
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
|
|
104
|
-
#
|
|
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
|
|
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.
|
|
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.
|
data/lib/querylane/query.rb
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
139
|
-
|
|
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(
|
|
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
|
-
|
|
158
|
-
|
|
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(
|
|
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(
|
|
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
|
data/lib/querylane/request.rb
CHANGED
|
@@ -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"],
|
|
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
|
|