diff --git a/app/controllers/items_controller.rb b/app/controllers/items_controller.rb index 8d82123d..f3924c53 100644 --- a/app/controllers/items_controller.rb +++ b/app/controllers/items_controller.rb @@ -13,15 +13,15 @@ class ItemsController < ApplicationController end # Note that we sort by name by hand, since we might have to use # fallbacks after the fact - @items = @query.paginate(page: params[:page], per_page: per_page) + @items = @query.results.includes(:translations). + paginate(page: params[:page], per_page: per_page) assign_closeted! respond_to do |format| format.html { @campaign = Campaign.current rescue nil - if @items.total_count == 1 + if @items.count == 1 redirect_to @items.first else - @items.prepare_partial(:item_link_partial) render end } diff --git a/app/models/item.rb b/app/models/item.rb index 266d46ad..aee1a7df 100644 --- a/app/models/item.rb +++ b/app/models/item.rb @@ -23,9 +23,11 @@ class Item < ActiveRecord::Base cattr_reader :per_page @@per_page = 30 - scope :alphabetize_by_translations, -> { + scope :alphabetize_by_translations, ->(locale) { + locale = locale or I18n.locale it = Item::Translation.arel_table - order(it[:name]) + joins(:translations).where(it[:locale].eq('en')). + order(it[:name]) } scope :join_swf_assets, -> { joins(:swf_assets).group(arel_table[:id]) } @@ -45,6 +47,17 @@ class Item < ActiveRecord::Base scope :with_closet_hangers, -> { joins(:closet_hangers) } + scope :is_nc, -> { + i = Item.arel_table + condition = i[:rarity_index].in(Item::NCRarities).or(i[:is_manually_nc]) + where(condition) + } + scope :is_np, -> { + i = Item.arel_table + condition = i[:rarity_index].in(Item::NCRarities).or(i[:is_manually_nc]) + where(condition.not) + } + def closeted? @owned || @wanted end @@ -551,6 +564,12 @@ class Item < ActiveRecord::Base Item::ProxyArray.new(ids) end + # TODO: Copied from modern Rails source, can delete once we're there! + def self.sanitize_sql_like(string, escape_character = "\\") + pattern = Regexp.union(escape_character, "%", "_") + string.gsub(pattern) { |x| [escape_character, x].join } + end + class << self MALL_HOST = 'ncmall.neopets.com' MALL_MAIN_PATH = '/mall/shop.phtml' diff --git a/app/models/item/search/contradiction.rb b/app/models/item/search/contradiction.rb deleted file mode 100644 index c6ef01d4..00000000 --- a/app/models/item/search/contradiction.rb +++ /dev/null @@ -1,7 +0,0 @@ -class Item - module Search - class Contradiction < Error - - end - end -end diff --git a/app/models/item/search/error.rb b/app/models/item/search/error.rb index 45d19059..7b3fa01a 100644 --- a/app/models/item/search/error.rb +++ b/app/models/item/search/error.rb @@ -1,7 +1,6 @@ class Item module Search - class Error < RuntimeError - + class Error < Exception end end -end +end \ No newline at end of file diff --git a/app/models/item/search/field.rb b/app/models/item/search/field.rb deleted file mode 100644 index 82f296a3..00000000 --- a/app/models/item/search/field.rb +++ /dev/null @@ -1,11 +0,0 @@ -class Item - module Search - class Field - attr_reader :key - - def initialize(key) - @key = key - end - end - end -end diff --git a/app/models/item/search/fields/flag.rb b/app/models/item/search/fields/flag.rb deleted file mode 100644 index d50f003a..00000000 --- a/app/models/item/search/fields/flag.rb +++ /dev/null @@ -1,20 +0,0 @@ -class Item - module Search - module Fields - class Flag < Field - def <<(filter) - if @value.nil? - @value = filter.positive? - elsif @value != filter.positive? - raise Item::Search::Contradiction, - "flag #{key} both positive and negative" - end - end - - def to_flex_params - {key => @value} - end - end - end - end -end diff --git a/app/models/item/search/fields/set_field.rb b/app/models/item/search/fields/set_field.rb deleted file mode 100644 index e089fa13..00000000 --- a/app/models/item/search/fields/set_field.rb +++ /dev/null @@ -1,34 +0,0 @@ -class Item - module Search - module Fields - class SetField < Field - def initialize(*args) - super(*args) - @values = {true => Set.new, false => Set.new} - end - - def <<(filter) - if @values[!filter.positive?].include?(filter.value) - raise Item::Search::Contradiction, - "positive #{key} and negative #{key} both contain #{filter.value}" - end - - @values[filter.positive?] << filter.value - end - - def to_flex_params - { - :"_#{key}s" => nil_if_empty(@values[true]), - :"_negative_#{key}s" => nil_if_empty(@values[false]) - } - end - - private - - def nil_if_empty(set) - set.map { |value| {key => value} } unless set.empty? - end - end - end - end -end diff --git a/app/models/item/search/fields/text_field.rb b/app/models/item/search/fields/text_field.rb deleted file mode 100644 index 9825b38d..00000000 --- a/app/models/item/search/fields/text_field.rb +++ /dev/null @@ -1,29 +0,0 @@ -class Item - module Search - module Fields - class TextField < Field - def initialize(*args) - super(*args) - @values = {true => '', false => ''} - end - - def <<(filter) - @values[filter.positive?] << (filter.value + ' ') - end - - def to_flex_params - { - key => nil_if_empty(@values[true]), - :"negative_#{key}" => nil_if_empty(@values[false]) - } - end - - private - - def nil_if_empty(str) - str unless str.empty? - end - end - end - end -end diff --git a/app/models/item/search/filter.rb b/app/models/item/search/filter.rb deleted file mode 100644 index 7645d95b..00000000 --- a/app/models/item/search/filter.rb +++ /dev/null @@ -1,54 +0,0 @@ -class Item - module Search - class Filter - attr_reader :key, :value - - def initialize(key, value, is_positive) - @key = key - @value = value - @is_positive = is_positive - end - - def positive? - @is_positive - end - - def to_s - sign = positive? ? '' : '-' - key_str = @key.to_s - if key_str.start_with?('is_') - is_label = I18n.translate("items.search.flag_keywords.is") - "#{sign}#{is_label}:#{label}" - elsif key_str.start_with?('user_') - user_label = I18n.translate("items.search.labels.user_closet_hanger_ownership") - value = Query::REVERSE_RESOURCE_FINDERS[:ownership].call(@value) - "#{sign}#{user_label}:#{value}" - else - if Query::TEXT_QUERY_RESOURCE_TYPES_BY_KEY.include?(@key) - resource_type = Query::TEXT_QUERY_RESOURCE_TYPES_BY_KEY[@key] - reverse_finder = Query::REVERSE_RESOURCE_FINDERS[resource_type] - resource_value = reverse_finder.call(@value) - else - resource_value = @value - end - if resource_value.include?(' ') - quoted_value = resource_value.inspect - else - quoted_value = resource_value - end - if @key == :name - "#{sign}#{quoted_value}" - else - "#{sign}#{label}:#{quoted_value}" - end - end - end - - private - - def label - I18n.translate("items.search.labels.#{@key}").split(',').first - end - end - end -end diff --git a/app/models/item/search/query.rb b/app/models/item/search/query.rb index e35aec9b..0c529ae2 100644 --- a/app/models/item/search/query.rb +++ b/app/models/item/search/query.rb @@ -4,315 +4,123 @@ 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 + def results + @filters.map(&:to_query).inject(Item.all, &:merge). + alphabetize_by_translations(Query.locale) 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, dash, name2 = keyword.rpartition('-') - [[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 - } + def self.locale + (I18n.fallbacks[I18n.locale] & + I18n.locales_with_neopets_language_code).first + end 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 + 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 != '-') - 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 + case key + when 'name' + filters << NameFilter.new(value, locale, is_positive) + when 'is' + case value + when 'nc' + filters << NCFilter.new(is_positive) + when 'np' + filters << NPFilter.new(is_positive) + else + message = I18n.translate('items.search.errors.not_found.label', + :label => value) + raise Item::Search::Error, message 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) + :label => key) 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) + raise NotImplementedError, "TODO: Reimplemented Advanced Search" 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 + + class Error < Exception + end + + private + + class NameFilter + def initialize(value, locale, is_positive) + @value = value + @locale = locale + @is_positive = is_positive + end + + def to_query + base = Item.joins(:translations).where('locale = ?', @locale) + if @is_positive + base.where('name LIKE ?', '%' + Item.sanitize_sql_like(@value) + '%') + else + base.where('name NOT LIKE ?', '%' + Item.sanitize_sql_like(@value) + '%') end end + + def to_s + sign = @is_positive ? '' : '-' + if /\s/.match(@value) + sign + '"' + @value + '"' + else + sign + @value + end + end + end + + class NCFilter + def initialize(is_positive) + @is_positive = is_positive + end + + def to_query + @is_positive ? Item.is_nc : Item.is_np + end + + def to_s + sign = @is_positive ? '' : '-' + sign + 'is:nc' + end + end + + class NPFilter + def initialize(is_positive) + @is_positive = is_positive + end + + def to_query + @is_positive ? Item.is_np : Item.is_nc + end + + def to_s + sign = @is_positive ? '' : '-' + sign + 'is:np' + end end end end