impress/app/models/item/search/query.rb
Emi Matchu 421f2ce39f Use fits:nostalgic-faerie-draik filter format when we can
This only *really* shows up right now in the case where you construct
an Advanced Search form query (which only the wardrobe-2020 app does
now, and in limited form), and we return the query back (which only
gets used by the HTML view for item search, which doesn't have any way
to build one of these requests against it).

This is because, if you just type in `fits:alt-style-87305`, we always
keep your search string the same when outputting it back to you, to
avoid the weirdness of canonicalizing it and changing it up on you in
surprising ways!

But idk, this is just looking forward a bit, and keeping the system's
semantics in place. I hope someday we can bring robust text filter
and Advanced Search stuff back into the main app again, maybe!
2024-02-27 15:51:27 -08:00

381 lines
12 KiB
Ruby

# encoding=utf-8
# ^ to put the regex in utf-8 mode
class Item
module Search
class Query
def initialize(filters, user, text=nil)
@filters = filters
@user = user
@text = text
end
def results
@filters.map(&:to_query).inject(Item.all, &:merge).order(:name)
end
def to_s
@text || @filters.map(&:to_s).join(' ')
end
TEXT_FILTER_EXPR = /([+-]?)(?:(\p{Word}+):)?(?:"([^"]+)"|(\S+))/
def self.from_text(text, user=nil)
filters = []
text.scan(TEXT_FILTER_EXPR) do |sign, key, quoted_value, unquoted_value|
key = 'name' if key.blank?
value = quoted_value || unquoted_value
is_positive = (sign != '-')
filter = parse_text_filter(key, value, is_positive, user)
filters << filter if filter.present?
end
self.new(filters, user, text)
end
def self.from_params(params, user=nil)
filters = []
params.values.each do |filter_params|
key = filter_params[:key]
value = filter_params[:value]
is_positive = filter_params[:is_positive] != 'false'
filter = parse_params_filter(key, value, is_positive, user)
filters << filter if filter.present?
end
self.new(filters, user)
end
private
def self.parse_text_filter(key, value, is_positive, user)
case key
when 'name'
is_positive ?
Filter.name_includes(value) :
Filter.name_excludes(value)
when 'occupies'
is_positive ? Filter.occupies(value) : Filter.not_occupies(value)
when 'restricts'
is_positive ? Filter.restricts(value) : Filter.not_restricts(value)
when 'fits'
# First, try the `fits:blue-acara` case.
# NOTE: This will also work for `fits:"usuki girl-usul"`!
match = value.match(/^([^-]+)-([^-]+)$/)
if match.present?
color_name, species_name = match.captures
pet_type = load_pet_type_by_name(color_name, species_name)
return is_positive ?
Filter.fits_pet_type(pet_type, color_name:, species_name:) :
Filter.not_fits_pet_type(pet_type, color_name:, species_name:)
end
# Next, try the `fits:alt-style-87305` case.
match = value.match(/^alt-style-([0-9]+)$/)
if match.present?
alt_style_id, = match.captures
alt_style = load_alt_style_by_id(alt_style_id)
return is_positive ?
Filter.fits_alt_style(alt_style) :
Filter.not_fits_alt_style(alt_style)
end
# Next, try the `fits:nostalgic-faerie-draik` case.
# NOTE: This will also work for `fits:"nostalgic-usuki girl-usul"`!
match = value.match(/^([^-]+)-([^-]+)-([^-]+)$/)
if match.present?
series_name, color_name, species_name = match.captures
alt_style = load_alt_style_by_name(
series_name, color_name, species_name)
return is_positive ?
Filter.fits_alt_style(alt_style) :
Filter.not_fits_alt_style(alt_style)
end
# TODO: We could make `fits:acara` an alias for `species:acara`, or
# even the primary syntax?
# If none of these cases work, raise an error.
raise_search_error "not_found.fits_target", value: value
when 'species'
begin
species = Species.find_by_name!(value)
color = Color.find_by_name!('blue')
pet_type = PetType.where(color_id: color.id, species_id: species.id).first!
rescue ActiveRecord::RecordNotFound
raise_search_error "not_found.species",
species_name: value.capitalize
end
is_positive ?
Filter.fits_species(pet_type.body_id, value) :
Filter.not_fits_species(pet_type.body_id, value)
when 'user'
if user.nil?
raise_search_error "not_logged_in"
end
case value
when 'owns'
is_positive ? Filter.owned_by(user) : Filter.not_owned_by(user)
when 'wants'
is_positive ? Filter.wanted_by(user) : Filter.not_wanted_by(user)
else
raise_search_error "not_found.ownership", keyword: value
end
when 'is'
case value
when 'nc'
is_positive ? Filter.is_nc : Filter.is_not_nc
when 'np'
is_positive ? Filter.is_np : Filter.is_not_np
when 'pb'
is_positive ? Filter.is_pb : Filter.is_not_pb
else
raise_search_error "not_found.label", label: "is:#{value}"
end
else
raise_search_error "not_found.label", label: key
end
end
def self.parse_params_filter(key, value, is_positive, user)
case key
when 'name'
is_positive ?
Filter.name_includes(value) :
Filter.name_excludes(value)
when 'is_nc'
is_positive ? Filter.is_nc : Filter.is_not_nc
when 'is_pb'
is_positive ? Filter.is_pb : Filter.is_not_pb
when 'is_np'
is_positive ? Filter.is_np : Filter.is_not_np
when 'occupied_zone_set_name'
is_positive ? Filter.occupies(value) : Filter.not_occupies(value)
when 'restricted_zone_set_name'
is_positive ? Filter.restricts(value) : Filter.not_restricts(value)
when 'fits'
if value[:alt_style_id].present?
alt_style = load_alt_style_by_id(value[:alt_style_id])
is_positive ?
Filter.fits_alt_style(alt_style) :
Filter.fits_alt_style(alt_style)
else
pet_type = load_pet_type_by_color_and_species(
value[:color_id], value[:species_id])
is_positive ?
Filter.fits_pet_type(pet_type) :
Filter.not_fits_pet_type(pet_type)
end
when 'user_closet_hanger_ownership'
case value
when 'true'
is_positive ?
Filter.owned_by(user) :
Filter.not_owned_by(user)
when 'false'
is_positive ?
Filter.wanted_by(user) :
Filter.not_wanted_by(user)
end
else
Rails.logger.warn "Ignoring unexpected search filter key: #{key}"
end
end
def self.load_pet_type_by_name(color_name, species_name)
begin
PetType.matching_name(color_name, species_name).first!
rescue ActiveRecord::RecordNotFound
raise_search_error "not_found.pet_type",
name1: color_name.capitalize, name2: species_name.capitalize
end
end
def self.load_pet_type_by_color_and_species(color_id, species_id)
begin
PetType.where(color_id: color_id, species_id: species_id).first!
rescue ActiveRecord::RecordNotFound
color_name = Color.find(color_id).name rescue "Color #{color_id}"
species_name = Species.find(species_id).name rescue "Species #{species_id}"
raise_search_error "not_found.pet_type",
name1: color_name.capitalize, name2: species_name.capitalize
end
end
def self.load_alt_style_by_name(series_name, color_name, species_name)
begin
AltStyle.matching_name(series_name, color_name, species_name).first!
rescue ActiveRecord::RecordNotFound
raise_search_error "not_found.alt_style",
filter_text: "#{series_name}-#{color_name}-#{species_name}"
end
end
def self.load_alt_style_by_id(alt_style_id)
begin
AltStyle.find(alt_style_id)
rescue ActiveRecord::RecordNotFound
raise_search_error "not_found.alt_style",
filter_text: "alt-style-#{alt_style_id}"
end
end
def self.raise_search_error(kind, ...)
raise Item::Search::Error,
I18n.translate("items.search.errors.#{kind}", ...)
end
end
class Error < Exception
end
private
# A Filter is basically a wrapper for an Item scope, with extra info about
# how to convert it into a search query string.
class Filter
def initialize(query, text)
@query = query
@text = text
end
def to_query
@query
end
def to_s
@text
end
def inspect
"#<#{self.class.name} #{@text.inspect}>"
end
def self.name_includes(value)
self.new Item.name_includes(value), "#{q value}"
end
def self.name_excludes(value)
self.new Item.name_excludes(value), "-#{q value}"
end
def self.occupies(value)
self.new Item.occupies(value), "occupies:#{q value}"
end
def self.not_occupies(value)
self.new Item.not_occupies(value), "-occupies:#{q value}"
end
def self.restricts(value)
self.new Item.restricts(value), "restricts:#{q value}"
end
def self.not_restricts(value)
self.new Item.not_restricts(value), "-restricts:#{q value}"
end
def self.fits_pet_type(pet_type, color_name: nil, species_name: nil)
value = pet_type_to_filter_text(pet_type, color_name:, species_name:)
self.new Item.fits(pet_type.body_id), "fits:#{q value}"
end
def self.not_fits_pet_type(pet_type, color_name: nil, species_name: nil)
value = pet_type_to_filter_text(pet_type, color_name:, species_name:)
self.new Item.not_fits(pet_type.body_id), "-fits:#{q value}"
end
def self.fits_alt_style(alt_style)
value = alt_style_to_filter_text(alt_style)
self.new Item.fits(alt_style.body_id), "fits:#{q value}"
end
def self.not_fits_alt_style(alt_style)
value = alt_style_to_filter_text(alt_style)
self.new Item.not_fits(alt_style.body_id), "-fits:#{q value}"
end
def self.fits_species(body_id, species_name)
self.new Item.fits(body_id), "species:#{q species_name}"
end
def self.not_fits_species(body_id, species_name)
self.new Item.not_fits(body_id), "-species:#{q species_name}"
end
def self.owned_by(user)
self.new user.owned_items, 'user:owns'
end
def self.not_owned_by(user)
self.new user.unowned_items, 'user:owns'
end
def self.wanted_by(user)
self.new user.wanted_items, 'user:wants'
end
def self.not_wanted_by(user)
self.new user.unwanted_items, 'user:wants'
end
def self.is_nc
self.new Item.is_nc, 'is:nc'
end
def self.is_not_nc
self.new Item.is_not_nc, '-is:nc'
end
def self.is_np
self.new Item.is_np, 'is:np'
end
def self.is_not_np
self.new Item.is_not_np, '-is:np'
end
def self.is_pb
self.new Item.is_pb, 'is:pb'
end
def self.is_not_pb
self.new Item.is_not_pb, '-is:pb'
end
private
# Add quotes around the value, if needed.
def self.q(value)
/\s/.match(value) ? '"' + value + '"' : value
end
def self.pet_type_to_filter_text(pet_type, color_name: nil, species_name: nil)
# Load the color & species name if needed, or use them from the params
# if already known (e.g. from parsing a "fits:blue-acara" text query).
color_name ||= pet_type.color.name
species_name ||= pet_type.species.name
# NOTE: Some color syntaxes are weird, like `fits:"polka dot-aisha"`!
"#{color_name}-#{species_name}".downcase
end
def self.alt_style_to_filter_text(alt_style)
# If the real series name has been set in the database by support
# staff, use that for the canonical filter text for this alt style.
# Otherwise, represent this alt style by ID.
if alt_style.has_real_series_name?
series_name = alt_style.series_name.downcase
color_name = alt_style.color.name.downcase
species_name = alt_style.species.name.downcase
"#{series_name}-#{color_name}-#{species_name}"
else
"alt-style-#{alt_style.id}"
end
end
end
end
end