# encoding=utf-8 # ^ to put the regex in utf-8 mode class Item module Search class Query FIELD_CLASSES = { :is_nc => Fields::Flag, :is_pb => Fields::Flag, :species_support_id => Fields::SetField, :occupied_zone_id => Fields::SetField, :restricted_zone_id => Fields::SetField, :name => Fields::SetField, :user_closet_hanger_ownership => Fields::SetField, :fits_pet_type => Fields::SetField } FIELD_KEYS = FIELD_CLASSES.keys def initialize(filters, user, text=nil) @filters = filters @user = user @text = text end def fields initial_fields.tap do |fields| @filters.each { |filter| fields[filter.key] << filter } end end def to_flex_params fields.values.map(&:to_flex_params).inject(&:merge) end def paginate(options={}) begin flex_params = self.to_flex_params rescue Item::Search::Contradiction # If we have a contradictory query, no need to raise a stink about # it, but no need to actually run a search, either. return Item.build_proxies([]) end final_flex_params = { :page => (options[:page] || 1), :size => (options[:per_page] || 30), :type => 'item' }.merge(flex_params) locales = I18n.fallbacks[I18n.locale] & I18n.locales_with_neopets_language_code final_flex_params[:locale] = locales.first # Extend the names/negative_names queries with the corresponding # localalized field names. if final_flex_params[:_names] || final_flex_params[:_negative_names] locale_entries = locales.map do |locale| boost = (locale == I18n.locale) ? 4 : 1 "name.#{locale}^#{boost}" end # We *could* have set _name_locales once as a partial, but Flex won't # let us call partials from inside other partials. Whatever. Assign # it to each name entry instead. I also feel bad doing this # afterwards, since it's kinda the field's job to return proper flex # params, but that's a refactor for another day. valid_name_lengths = (3..16) [:_names, :_negative_names].each do |key| if final_flex_params[key] # This part is also kinda weak. Oh well. Maybe we need # NGramField that inherits from SetField while also applying # these restrictions? Remove all name filters that are too # small or too large. final_flex_params[key].select! do |name_query| valid_name_lengths.include?(name_query[:name].length) end final_flex_params[key].each do |name_query| name_query[:fields] = locale_entries end end end end # Okay, yeah, looks like this really does deserve a refactor, like # _names and _negative_names do. (Or Flex could just make all variables # accessible from partials... hint, hint) [:_user_closet_hanger_ownerships, :_negative_user_closet_hanger_ownerships].each do |key| if final_flex_params[key] Item::Search.error 'not_logged_in' unless @user final_flex_params[key].each do |entry| entry[:user_id] = @user.id end end end [:_fits_pet_types, :_negative_fits_pet_types].each do |key| if final_flex_params[key] final_flex_params[key].each do |entry| # These are pet types at first. Yikes, dem hax. entry[:body_id] = entry[:fits_pet_type].body_id end end end result = FlexSearch.item_search(final_flex_params) result.proxied_collection end # Load the text query labels from I18n, so that when we see, say, # the filter "species:acara", we know it means species_support_id. TEXT_KEYS_BY_LABEL = {} IS_KEYWORDS = {} OWNERSHIP_KEYWORDS = {} I18n.available_locales.each do |locale| TEXT_KEYS_BY_LABEL[locale] = {} IS_KEYWORDS[locale] = Set.new OWNERSHIP_KEYWORDS[locale] = {} I18n.fallbacks[locale].each do |fallback| FIELD_KEYS.each do |key| # A locale can specify multiple labels for a key by separating by # commas: "occupies,zone,type" labels = I18n.translate("items.search.labels.#{key}", :locale => fallback).split(',') labels.each do |label| plain_label = label.parameterize # 'é' => 'e' TEXT_KEYS_BY_LABEL[locale][plain_label] = key end is_keyword = I18n.translate('items.search.flag_keywords.is', :locale => fallback) IS_KEYWORDS[locale] << is_keyword.parameterize {:owns => true, :wants => false}.each do |key, value| translated_key = I18n.translate("items.search.labels.user_#{key}", :locale => fallback) OWNERSHIP_KEYWORDS[locale][translated_key] = value end end end end def to_s @text || @filters.map(&:to_s).join(' ') end TEXT_QUERY_RESOURCE_FINDERS = { :species => lambda { |name| species = Species.find_by_name(name) unless species Item::Search.error 'not_found.species', :species_name => name end species.id }, :zone => lambda { |label| zone_set = Zone.with_plain_label(label) if zone_set.empty? Item::Search.error 'not_found.zone', :zone_name => label end zone_set.map(&:id) }, :ownership => lambda { |keyword| OWNERSHIP_KEYWORDS[I18n.locale][keyword].tap do |value| if value.nil? Item::Search.error 'not_found.ownership', :keyword => keyword end end }, :pet_type => lambda { |keyword| name1, name2 = keyword.split('-') [[name1, name2], [name2, name1]].each do |species_name, color_name| species = Species.find_by_name(species_name) next if species.nil? color = Color.find_by_name(color_name) next if color.nil? pet_type = PetType.find_by_species_id_and_color_id(species, color) return pet_type if pet_type.present? end Item::Search.error 'not_found.pet_type', name1: name1.humanize, name2: name2.humanize } } REVERSE_RESOURCE_FINDERS = { species: lambda { |id| Species.find(id).name }, zone: lambda { |ids| Zone.find(ids.first).plain_label }, ownership: lambda { |owned| owned_key = owned ? 'owns' : 'wants' I18n.translate("items.search.labels.user_#{owned_key}") }, pet_type: lambda { |pet_type| "#{pet_type.color.name}-#{pet_type.species.name}" } } TEXT_QUERY_RESOURCE_TYPES_BY_KEY = { :species_support_id => :species, :occupied_zone_id => :zone, :restricted_zone_id => :zone, :user_closet_hanger_ownership => :ownership, :fits_pet_type => :pet_type } TEXT_FILTER_EXPR = /([+-]?)(?:(\p{Word}+):)?(?:"([^"]+)"|(\S+))/ def self.from_text(text, user=nil) filters = [] text.scan(TEXT_FILTER_EXPR) do |sign, label, quoted_value, unquoted_value| raw_value = quoted_value || unquoted_value is_positive = (sign != '-') Rails.logger.debug(label.inspect) Rails.logger.debug(TEXT_KEYS_BY_LABEL[I18n.locale].inspect) Rails.logger.debug(IS_KEYWORDS[I18n.locale].inspect) if label plain_label = label.parameterize if IS_KEYWORDS[I18n.locale].include?(plain_label) # is-filters are weird. "-is:nc" is transposed to something more # like "-nc:", then it's translated into a negative "is_nc" # flag. Fun fact: "nc:foobar" and "-nc:foobar" also work. A bonus, # I guess. There's probably a good way to refactor this to avoid # the unintended bonus syntax, but this is a darn good cheap # technique for the time being. label = raw_value plain_label = raw_value.parameterize raw_value = nil end key = TEXT_KEYS_BY_LABEL[I18n.locale][plain_label] else key = :name end if key.nil? message = I18n.translate('items.search.errors.not_found.label', :label => label) raise Item::Search::Error, message end if (!Flex::Configuration.hangers_enabled && key == :user_closet_hanger_ownership) Item::Search.error 'user_filters_disabled' end if TEXT_QUERY_RESOURCE_TYPES_BY_KEY.has_key?(key) resource_type = TEXT_QUERY_RESOURCE_TYPES_BY_KEY[key] finder = TEXT_QUERY_RESOURCE_FINDERS[resource_type] value = finder.call(raw_value) else value = raw_value end filters << Filter.new(key, value, is_positive) end self.new(filters, user, text) end def self.from_params(params, user=nil) filters = params.values.map { |filter_params| if filter_params.has_key?(:key) key = filter_params[:key].to_sym value = filter_params[:value] # Ew, the hackiest of hacks! if key == :occupied_zone_set_name key = :occupied_zone_id value = TEXT_QUERY_RESOURCE_FINDERS[:zone].call(value) elsif key == :restricted_zone_set_name key = :restricted_zone_id value = TEXT_QUERY_RESOURCE_FINDERS[:zone].call(value) end if FIELD_KEYS.include?(key) # Ugh, this bit feels so hacky :P if key == :user_closet_hanger_ownership value = (value == 'true') end if key == :fits_pet_type begin value = PetType.find(value) rescue ActiveRecord::RecordNotFound Item::Search.error 'not_found.pet_type_id', id: value end end is_positive = filter_params[:is_positive] == 'true' Filter.new(key, value, is_positive) end end }.compact self.new(filters, user) end private # The fields start out empty, then have the filters inserted into 'em, # so that the fields can validate and aggregate their requirements. def initial_fields {}.tap do |fields| FIELD_CLASSES.map do |key, klass| fields[key] = klass.new(key) end end end end end end