Compare commits

...

10 commits

Author SHA1 Message Date
9f74e6020e Add series_name database field to alt styles
Previously we did this hackily by comparing the ID to a hardcoded list
of IDs, but I think putting this in the database is clearer and more
robust, and it should also help with our upcoming item search stuff
that will filter by it!
2024-02-27 15:28:05 -08:00
cd786ffcb1 Oops, I broke user filters in my previous refactor!
Right, `user` is an important argument that I missed! 😅
2024-02-27 15:08:15 -08:00
76d741091c Extract "raise_search_error" method in item search query parsing
Just to make all this a bit more wieldy, and not repeat the same prefix
all the time!
2024-02-27 15:03:18 -08:00
61b1a1aed1 Improve parsing fits:blue-acara filter
Previously, passing in `fits:blue` would cause a crash, because
`species_name` part of the split would be `nil`, oops!

In this change, we use a regex for more explicitness about the pattern
we're trying to match. We'll also add more cases next! (You'll note the
error message mentions `fits:nostalgic-faerie-draik`, which isn't
actually possible yet, but will be!)
2024-02-27 14:56:36 -08:00
1860f5b6be Fix showing the query on the item search error page
Previously, the query wouldn't fill into the search box or page title
if e.g. parsing had failed. Now it does!

I'm not sure why the rescue strategy we previously had here doesn't
work anymore (I'm sure it must've in the past sometime?), but this is
simpler anyway, let's go!
2024-02-27 14:47:57 -08:00
bdefeb53d6 Remove weird unused begin/end block in ItemsController 2024-02-27 14:47:02 -08:00
19ebf4d78a Extract item search filter parsing into helper methods
I think this is a bit clearer and lets us clean up some of the syntax a
bit (don't need to always say `filters <<`), and also it will let us
use `return`, which I'm interested in for my next change!
2024-02-27 14:43:42 -08:00
3781c9810a Item search can filter by fitting alt styles (but missing many details!)
A query like this works for finding items containing "hat" that fit
alt style #87296 (the Faerie Acara):

http://localhost:3000/items?q%5B0%5D%5Bkey%5D=name&q%5B0%5D%5Bvalue%5D=hat&q%5B1%5D%5Bkey%5D=fits&q%5B1%5D%5Bvalue%5D%5Bspecies_id%5D=1&q%5B1%5D%5Bvalue%5D%5Bcolor_id%5D=8&q%5B1%5D%5Bvalue%5D%5Balt_style_id%5D=87296

But there's two main missing pieces still:
1. You can't do a text-filter version of this—in fact, clicking Search
   immediately on the page this loads will return an error!
2. We haven't extended this to the `with_appearances_for` parameter,
   so we add a `NotImplementedError` to that bit for now too.

I'm also thinking that the text filter should ideally be like,
`fits:nostalgic-faerie-acara` if we can pull it off; and then fall back
to the plainer `fits:alt-style-87296` if e.g. we don't know the set
it's from yet.
2024-02-27 14:32:54 -08:00
183cb40e74 Oops, don't return body_id=0 items for "-fits:blue-acara"
Right, fitting isn't just body_id = this one, it's also body_id=0!

Anyway, doing this query on its own is still deathly slow, I wonder if
the idea I had about left joins (back when I was still working in a
Rails version that didn't support it lol) could help! Might poke at
that a smidge.
2024-02-27 14:07:20 -08:00
671a79d158 Refactor fits and not_fits in Item::Search::Query
Just restructuring a bit in anticipation of changes we're gonna make
for alt style support in here!
2024-02-27 14:05:37 -08:00
7 changed files with 247 additions and 190 deletions

View file

@ -4,7 +4,6 @@ class ItemsController < ApplicationController
def index def index
if @query if @query
begin
if params[:per_page] if params[:per_page]
per_page = params[:per_page].to_i per_page = params[:per_page].to_i
per_page = 50 if per_page && per_page > 50 per_page = 50 if per_page && per_page > 50
@ -53,7 +52,6 @@ class ItemsController < ApplicationController
} }
} }
end end
end
elsif params.has_key?(:ids) && params[:ids].is_a?(Array) elsif params.has_key?(:ids) && params[:ids].is_a?(Array)
@items = Item.find(params[:ids]) @items = Item.find(params[:ids])
assign_closeted! assign_closeted!
@ -123,6 +121,7 @@ class ItemsController < ApplicationController
def load_appearances def load_appearances
appearance_params = params[:with_appearances_for] appearance_params = params[:with_appearances_for]
return {} if appearance_params.blank? return {} if appearance_params.blank?
raise NotImplementedError if appearance_params[:alt_style_id].present?
pet_type = Item::Search::Query.load_pet_type_by_color_and_species( pet_type = Item::Search::Query.load_pet_type_by_color_and_species(
appearance_params[:color_id], appearance_params[:species_id]) appearance_params[:color_id], appearance_params[:species_id])
@ -131,6 +130,7 @@ class ItemsController < ApplicationController
def search_error(e) def search_error(e)
@items = [] @items = []
@query = params[:q]
respond_to do |format| respond_to do |format|
format.html { flash.now[:alert] = e.message; render } format.html { flash.now[:alert] = e.message; render }
format.json { render :json => {error: e.message} } format.json { render :json => {error: e.message} }
@ -140,14 +140,7 @@ class ItemsController < ApplicationController
def set_query def set_query
q = params[:q] q = params[:q]
if q.is_a?(String) if q.is_a?(String)
begin
@query = Item::Search::Query.from_text(q, current_user) @query = Item::Search::Query.from_text(q, current_user)
rescue
# Set the query string for error handling messages, but let the error
# bubble up.
@query = params[:q]
raise
end
elsif q.is_a?(ActionController::Parameters) elsif q.is_a?(ActionController::Parameters)
@query = Item::Search::Query.from_params(q, current_user) @query = Item::Search::Query.from_params(q, current_user)
end end

View file

@ -6,23 +6,11 @@ class AltStyle < ApplicationRecord
has_many :swf_assets, through: :parent_swf_asset_relationships has_many :swf_assets, through: :parent_swf_asset_relationships
has_many :contributions, as: :contributed, inverse_of: :contributed has_many :contributions, as: :contributed, inverse_of: :contributed
SERIES_ID_RANGES = {
nostalgic: (87249..87503)
}
def name def name
I18n.translate('pet_types.human_name', color_human_name: color.human_name, I18n.translate('pet_types.human_name', color_human_name: color.human_name,
species_human_name: species.human_name) species_human_name: species.human_name)
end end
def series_name
if SERIES_ID_RANGES[:nostalgic].include?(id)
"Nostalgic"
else
"???"
end
end
def adjective_name def adjective_name
"#{series_name} #{color.human_name}" "#{series_name} #{color.human_name}"
end end

View file

@ -103,7 +103,11 @@ class Item < ApplicationRecord
# bother looking into this, but one thing I notice is items with no assets # bother looking into this, but one thing I notice is items with no assets
# somehow would not match either scope in this impl (but LEFT JOIN would!) # somehow would not match either scope in this impl (but LEFT JOIN would!)
joins(:swf_assets).group(i[:id]). joins(:swf_assets).group(i[:id]).
having('FIND_IN_SET(?, GROUP_CONCAT(body_id)) = 0', body_id). having(
"FIND_IN_SET(?, GROUP_CONCAT(body_id)) = 0 AND " +
"FIND_IN_SET(0, GROUP_CONCAT(body_id)) = 0",
body_id
).
distinct distinct
} }

View file

@ -27,75 +27,8 @@ class Item
value = quoted_value || unquoted_value value = quoted_value || unquoted_value
is_positive = (sign != '-') is_positive = (sign != '-')
case key filter = parse_text_filter(key, value, is_positive, user)
when 'name' filters << filter if filter.present?
filters << (is_positive ?
Filter.name_includes(value) :
Filter.name_excludes(value))
when 'occupies'
filters << (is_positive ?
Filter.occupies(value) :
Filter.not_occupies(value))
when 'restricts'
filters << (is_positive ?
Filter.restricts(value) :
Filter.not_restricts(value))
when 'fits'
color_name, species_name = value.split("-")
pet_type = load_pet_type_by_name(color_name, species_name)
filters << (is_positive ?
Filter.fits(pet_type.body_id, color_name, species_name) :
Filter.not_fits(pet_type.body_id, color_name, species_name))
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
message = I18n.translate('items.search.errors.not_found.species',
species_name: value.capitalize)
raise Item::Search::Error, message
end
filters << (is_positive ?
Filter.fits_species(pet_type.body_id, value) :
Filter.not_fits_species(pet_type.body_id, value))
when 'user'
if user.nil?
message = I18n.translate('items.search.errors.not_logged_in')
raise Item::Search::Error, message
end
case value
when 'owns'
filters << (is_positive ?
Filter.owned_by(user) :
Filter.not_owned_by(user))
when 'wants'
filters << (is_positive ?
Filter.wanted_by(user) :
Filter.not_wanted_by(user))
else
message = I18n.translate('items.search.errors.not_found.ownership',
keyword: value)
raise Item::Search::Error, message
end
when 'is'
case value
when 'nc'
filters << (is_positive ? Filter.is_nc : Filter.is_not_nc)
when 'np'
filters << (is_positive ? Filter.is_np : Filter.is_not_np)
when 'pb'
filters << (is_positive ? Filter.is_pb : Filter.is_not_pb)
else
message = I18n.translate('items.search.errors.not_found.label',
:label => "is:#{value}")
raise Item::Search::Error, message
end
else
message = I18n.translate('items.search.errors.not_found.label',
:label => key)
raise Item::Search::Error, message
end
end end
self.new(filters, user, text) self.new(filters, user, text)
@ -109,59 +42,126 @@ class Item
value = filter_params[:value] value = filter_params[:value]
is_positive = filter_params[:is_positive] != 'false' is_positive = filter_params[:is_positive] != 'false'
case filter_params[:key] 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' when 'name'
filters << (is_positive ? is_positive ?
Filter.name_includes(value) : Filter.name_includes(value) :
Filter.name_excludes(value)) Filter.name_excludes(value)
when 'is_nc' when 'occupies'
filters << (is_positive ? Filter.is_nc : Filter.is_not_nc) is_positive ? Filter.occupies(value) : Filter.not_occupies(value)
when 'is_pb' when 'restricts'
filters << (is_positive ? Filter.is_pb : Filter.is_not_pb) is_positive ? Filter.restricts(value) : Filter.not_restricts(value)
when 'is_np'
filters << (is_positive ? Filter.is_np : Filter.is_not_np)
when 'occupied_zone_set_name'
filters << (is_positive ? Filter.occupies(value) :
Filter.not_occupies(value))
when 'restricted_zone_set_name'
filters << (is_positive ?
Filter.restricts(value) :
Filter.not_restricts(value))
when 'fits' when 'fits'
raise NotImplementedError if value[:alt_style_id].present? 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
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( pet_type = load_pet_type_by_color_and_species(
value[:color_id], value[:species_id]) value[:color_id], value[:species_id])
color = Color.find value[:color_id] is_positive ?
species = Species.find value[:species_id] Filter.fits_pet_type(pet_type) :
filters << (is_positive ? Filter.not_fits_pet_type(pet_type)
Filter.fits(pet_type.body_id, color.name, species.name) : end
Filter.not_fits(pet_type.body_id, color.name, species.name))
when 'user_closet_hanger_ownership' when 'user_closet_hanger_ownership'
case value case value
when 'true' when 'true'
filters << (is_positive ? is_positive ?
Filter.owned_by(user) : Filter.owned_by(user) :
Filter.not_owned_by(user)) Filter.not_owned_by(user)
when 'false' when 'false'
filters << (is_positive ? is_positive ?
Filter.wanted_by(user) : Filter.wanted_by(user) :
Filter.not_wanted_by(user)) Filter.not_wanted_by(user)
end end
else else
Rails.logger.warn "Ignoring unexpected search filter key: #{key}" Rails.logger.warn "Ignoring unexpected search filter key: #{key}"
end end
end end
self.new(filters, user)
end
def self.load_pet_type_by_name(color_name, species_name) def self.load_pet_type_by_name(color_name, species_name)
begin begin
PetType.matching_name(color_name, species_name).first! PetType.matching_name(color_name, species_name).first!
rescue ActiveRecord::RecordNotFound rescue ActiveRecord::RecordNotFound
message = I18n.translate('items.search.errors.not_found.pet_type', raise_search_error "not_found.pet_type",
name1: color_name.capitalize, name2: species_name.capitalize) name1: color_name.capitalize, name2: species_name.capitalize
raise Item::Search::Error, message
end end
end end
@ -171,11 +171,24 @@ class Item
rescue ActiveRecord::RecordNotFound rescue ActiveRecord::RecordNotFound
color_name = Color.find(color_id).name rescue "Color #{color_id}" color_name = Color.find(color_id).name rescue "Color #{color_id}"
species_name = Species.find(species_id).name rescue "Species #{species_id}" species_name = Species.find(species_id).name rescue "Species #{species_id}"
message = I18n.translate('items.search.errors.not_found.pet_type', raise_search_error "not_found.pet_type",
name1: color_name.capitalize, name2: species_name.capitalize) name1: color_name.capitalize, name2: species_name.capitalize
raise Item::Search::Error, message
end end
end end
def self.load_alt_style_by_id(alt_style_id)
begin
AltStyle.find(alt_style_id)
rescue
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 end
class Error < Exception class Error < Exception
@ -227,16 +240,24 @@ class Item
self.new Item.not_restricts(value), "-restricts:#{q value}" self.new Item.not_restricts(value), "-restricts:#{q value}"
end end
def self.fits(body_id, color_name, species_name) def self.fits_pet_type(pet_type, color_name: nil, species_name: nil)
# NOTE: Some color syntaxes are weird, like `fits:"polka dot-aisha"`! value = pet_type_to_filter_text(pet_type, color_name:, species_name:)
value = "#{color_name}-#{species_name}".downcase self.new Item.fits(pet_type.body_id), "fits:#{q value}"
self.new Item.fits(body_id), "fits:#{q value}"
end end
def self.not_fits(body_id, color_name, species_name) def self.not_fits_pet_type(pet_type, color_name: nil, species_name: nil)
# NOTE: Some color syntaxes are weird, like `fits:"polka dot-aisha"`! value = pet_type_to_filter_text(pet_type, color_name:, species_name:)
value = "#{color_name}-#{species_name}".downcase self.new Item.not_fits(pet_type.body_id), "-fits:#{q value}"
self.new Item.not_fits(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 end
def self.fits_species(body_id, species_name) def self.fits_species(body_id, species_name)
@ -293,6 +314,20 @@ class Item
def self.q(value) def self.q(value)
/\s/.match(value) ? '"' + value + '"' : value /\s/.match(value) ? '"' + value + '"' : value
end 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)
"alt-style-#{alt_style.id}"
end
end end
end end
end end

View file

@ -358,7 +358,11 @@ en:
user:wants? user:wants?
pet_type: We have no record of the %{name1} %{name2}. pet_type: We have no record of the %{name1} %{name2}.
It is spelled correctly? It is spelled correctly?
alt_style: We have no record of the "%{filter_text}" alt style. Is it
spelled correctly?
pet_type_id: We have no record of pet type %{id}. Weird. pet_type_id: We have no record of pet type %{id}. Weird.
fits_target: I'm not sure what "fits:%{value}" means. You can
use "fits:blue-acara", "fits:nostalgic-faerie-draik", or similar!
not_logged_in: The "user" filters are only available if you're logged in. not_logged_in: The "user" filters are only available if you're logged in.
flag_keywords: flag_keywords:
is: is is: is

View file

@ -0,0 +1,18 @@
class AddSeriesNameToAltStyles < ActiveRecord::Migration[7.1]
def change
add_column :alt_styles, :series_name, :string, null: false,
default: "<New?>"
reversible do |direction|
direction.up do
# These IDs are determined by Neopets.com, so referencing them like
# this should be relatively stable! Backfill the series name
# "Nostalgic" for all alt styles in the Nostalgic ID range. (At time
# of writing, *all* alt styles are Nostalgic, but I'm writing it like
# this for clarity and future-proofing!)
AltStyle.where("id >= 87249 AND id <= 87503").update_all(
series_name: "Nostalgic")
end
end
end
end

View file

@ -10,13 +10,14 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.1].define(version: 2024_02_25_231346) do ActiveRecord::Schema[7.1].define(version: 2024_02_27_231815) do
create_table "alt_styles", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| create_table "alt_styles", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t|
t.integer "species_id", null: false t.integer "species_id", null: false
t.integer "color_id", null: false t.integer "color_id", null: false
t.integer "body_id", null: false t.integer "body_id", null: false
t.datetime "created_at", precision: nil, null: false t.datetime "created_at", precision: nil, null: false
t.datetime "updated_at", precision: nil, null: false t.datetime "updated_at", precision: nil, null: false
t.string "series_name", default: "<New?>", null: false
t.index ["color_id"], name: "index_alt_styles_on_color_id" t.index ["color_id"], name: "index_alt_styles_on_color_id"
t.index ["species_id"], name: "index_alt_styles_on_species_id" t.index ["species_id"], name: "index_alt_styles_on_species_id"
end end
@ -115,6 +116,20 @@ ActiveRecord::Schema[7.1].define(version: 2024_02_25_231346) do
t.index ["outfit_id", "is_worn"], name: "index_item_outfit_relationships_on_outfit_id_and_is_worn" t.index ["outfit_id", "is_worn"], name: "index_item_outfit_relationships_on_outfit_id_and_is_worn"
end end
create_table "item_translations", id: :integer, charset: "latin1", collation: "latin1_swedish_ci", force: :cascade do |t|
t.integer "item_id"
t.string "locale"
t.string "name"
t.text "description"
t.string "rarity"
t.datetime "created_at", precision: nil
t.datetime "updated_at", precision: nil
t.index ["item_id", "locale"], name: "index_item_translations_on_item_id_and_locale"
t.index ["item_id"], name: "index_item_translations_on_item_id"
t.index ["locale"], name: "index_item_translations_on_locale"
t.index ["name"], name: "index_item_translations_name"
end
create_table "items", id: :integer, charset: "utf8mb3", collation: "utf8mb3_unicode_ci", force: :cascade do |t| create_table "items", id: :integer, charset: "utf8mb3", collation: "utf8mb3_unicode_ci", force: :cascade do |t|
t.text "zones_restrict", null: false t.text "zones_restrict", null: false
t.text "thumbnail_url", size: :medium, null: false t.text "thumbnail_url", size: :medium, null: false