Change how artist URLs are normalized in artist entries. Don't try to secretly convert image URLs to profile URLs in artist entries. For example, if someone puts a Pixiv image URL in an artist entry, don't secretly try to fetch the source and convert it into a profile URL in the `normalized_url` field. We did this because years ago, it was standard practice to put image URLs in artist entries. Pixiv image URLs used to contain the artist's username, so we used to put image URLs in artist entries for artist finding purposes. But Pixiv changed it so that image URLs no longer contained the username, so we dealt with it by adding a `normalized_url` column to artist_urls and secretly converting image URLs to profile URLs in this field. But this is no longer necessary because now we don't normally put image URLs in artist entries in the first place. Now the `profile_url` method in `Source::URL` is used to normalize URLs in artist entries. This lets us parse various profile URL formats and normalize them into a single canonical form. This also removes the `normalize_for_artist_finder` method from source strategies. Instead the `profile_url` method is used for artist finding purposes. So the profile URL returned by the source strategy needs to be the same as the URL in the artist entry in order for artist finding to work.
311 lines
9.2 KiB
Ruby
311 lines
9.2 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
class Artist < ApplicationRecord
|
|
extend Memoist
|
|
class RevertError < StandardError; end
|
|
|
|
attr_accessor :url_string_changed
|
|
|
|
deletable
|
|
|
|
normalize :name, :normalize_name
|
|
normalize :group_name, :normalize_other_name
|
|
normalize :other_names, :normalize_other_names
|
|
array_attribute :other_names # XXX must come after `normalize :other_names`
|
|
|
|
validate :validate_artist_name
|
|
validates :name, tag_name: true, uniqueness: true
|
|
|
|
before_save :update_tag_category
|
|
after_save :create_version
|
|
after_save :clear_url_string_changed
|
|
|
|
has_many :members, :class_name => "Artist", :foreign_key => "group_name", :primary_key => "name"
|
|
has_many :urls, dependent: :destroy, class_name: "ArtistURL", autosave: true
|
|
has_many :versions, -> {order("artist_versions.id ASC")}, :class_name => "ArtistVersion"
|
|
has_one :wiki_page, -> { active }, foreign_key: "title", primary_key: "name"
|
|
has_one :tag_alias, -> { active }, foreign_key: "antecedent_name", primary_key: "name"
|
|
belongs_to :tag, foreign_key: "name", primary_key: "name", default: -> { Tag.new(name: name, category: Tag.categories.artist) }
|
|
|
|
scope :banned, -> { where(is_banned: true) }
|
|
scope :unbanned, -> { where(is_banned: false) }
|
|
|
|
module UrlMethods
|
|
extend ActiveSupport::Concern
|
|
|
|
def sorted_urls
|
|
urls.sort_by do |url|
|
|
[url.is_active? ? 0 : 1, url.priority, url.domain, url.secondary_url? ? 1 : 0, url.url]
|
|
end
|
|
end
|
|
|
|
def url_array
|
|
urls.map(&:to_s).sort
|
|
end
|
|
|
|
def url_string
|
|
url_array.join("\n")
|
|
end
|
|
|
|
def url_string=(string)
|
|
url_string_was = url_string
|
|
|
|
self.urls = string.to_s.scan(/[^[:space:]]+/).map do |url|
|
|
is_active, url = ArtistURL.parse_prefix(url)
|
|
self.urls.find_or_initialize_by(url: url, is_active: is_active)
|
|
end.uniq(&:url)
|
|
|
|
self.url_string_changed = (url_string_was != url_string)
|
|
end
|
|
|
|
def clear_url_string_changed
|
|
self.url_string_changed = false
|
|
end
|
|
end
|
|
|
|
concerning :NameMethods do
|
|
class_methods do
|
|
def normalize_name(name)
|
|
name.to_s.downcase.strip.gsub(/ /, "_").to_s
|
|
end
|
|
|
|
def normalize_other_names(other_names)
|
|
other_names.map { |name| normalize_other_name(name) }.uniq.reject(&:blank?)
|
|
end
|
|
|
|
# XXX Differences from wiki page other names: allow uppercase, use NFC
|
|
# instead of NFKC, and allow repeated, leading, and trailing underscores.
|
|
def normalize_other_name(other_name)
|
|
other_name.to_s.unicode_normalize(:nfc).normalize_whitespace.squish.tr(" ", "_")
|
|
end
|
|
end
|
|
|
|
def pretty_name
|
|
name.tr("_", " ")
|
|
end
|
|
end
|
|
|
|
module VersionMethods
|
|
def create_version(force = false)
|
|
if saved_change_to_name? || url_string_changed || saved_change_to_is_deleted? || saved_change_to_is_banned? || saved_change_to_other_names? || saved_change_to_group_name? || force
|
|
if merge_version?
|
|
merge_version
|
|
else
|
|
create_new_version
|
|
end
|
|
end
|
|
end
|
|
|
|
def create_new_version
|
|
ArtistVersion.create(
|
|
:artist_id => id,
|
|
:name => name,
|
|
:updater_id => CurrentUser.id,
|
|
:updater_ip_addr => CurrentUser.ip_addr,
|
|
:urls => url_array,
|
|
:is_deleted => is_deleted,
|
|
:is_banned => is_banned,
|
|
:other_names => other_names,
|
|
:group_name => group_name
|
|
)
|
|
end
|
|
|
|
def merge_version
|
|
prev = versions.last
|
|
prev.update(name: name, urls: url_array, is_deleted: is_deleted, is_banned: is_banned, other_names: other_names, group_name: group_name)
|
|
end
|
|
|
|
def merge_version?
|
|
prev = versions.last
|
|
prev && prev.updater_id == CurrentUser.user.id && prev.updated_at > 1.hour.ago
|
|
end
|
|
|
|
def revert_to!(version)
|
|
if id != version.artist_id
|
|
raise RevertError.new("You cannot revert to a previous version of another artist.")
|
|
end
|
|
|
|
self.name = version.name
|
|
self.url_string = version.urls.join("\n")
|
|
self.is_deleted = version.is_deleted
|
|
self.other_names = version.other_names
|
|
self.group_name = version.group_name
|
|
save
|
|
end
|
|
end
|
|
|
|
module FactoryMethods
|
|
# Make a new artist, fetching the defaults either from the given source, or
|
|
# from the source of the artist's last upload.
|
|
def new_with_defaults(params)
|
|
source = params.delete(:source)
|
|
|
|
if source.blank? && params[:name].present?
|
|
post = Post.system_tag_match("source:http* #{params[:name]}").first
|
|
source = post.try(:source)
|
|
end
|
|
|
|
if source.present?
|
|
artist = Sources::Strategies.find(source).new_artist
|
|
artist.attributes = params
|
|
else
|
|
artist = Artist.new(params)
|
|
end
|
|
|
|
artist
|
|
end
|
|
end
|
|
|
|
module TagMethods
|
|
def validate_artist_name
|
|
return unless !is_deleted? && name_changed?
|
|
|
|
if tag.present? && tag.category_name != "Artist" && !tag.empty?
|
|
errors.add(:name, "'#{name}' is a #{tag.category_name.downcase} tag; artist entries can only be created for artist tags")
|
|
end
|
|
|
|
if tag_alias.present?
|
|
errors.add(:name, "'#{name}' is aliased to '#{tag_alias.consequent_name}'")
|
|
end
|
|
end
|
|
|
|
def update_tag_category
|
|
return unless !is_deleted? && name_changed? && tag.present?
|
|
|
|
if tag.category_name != "Artist" && tag.empty?
|
|
tag.update!(category: Tag.categories.artist)
|
|
end
|
|
end
|
|
end
|
|
|
|
module BanMethods
|
|
def unban!
|
|
Post.transaction do
|
|
ti = TagImplication.find_by(antecedent_name: name, consequent_name: "banned_artist")
|
|
ti&.destroy
|
|
|
|
Post.raw_tag_match(name).find_each do |post|
|
|
post.unban!
|
|
fixed_tags = post.tag_string.sub(/(?:\A| )banned_artist(?:\Z| )/, " ").strip
|
|
post.update(tag_string: fixed_tags)
|
|
end
|
|
|
|
update!(is_banned: false)
|
|
ModAction.log("unbanned artist ##{id}", :artist_unban)
|
|
end
|
|
end
|
|
|
|
def ban!(banner: CurrentUser.user)
|
|
Post.transaction do
|
|
Post.raw_tag_match(name).each(&:ban!)
|
|
|
|
# potential race condition but unlikely
|
|
unless TagImplication.where(:antecedent_name => name, :consequent_name => "banned_artist").exists?
|
|
Tag.find_or_create_by_name("artist:banned_artist") # ensure the banned_artist exists and is an artist tag.
|
|
TagImplication.approve!(antecedent_name: name, consequent_name: "banned_artist", approver: banner)
|
|
end
|
|
|
|
update!(is_banned: true)
|
|
ModAction.log("banned artist ##{id}", :artist_ban)
|
|
end
|
|
end
|
|
end
|
|
|
|
module SearchMethods
|
|
def name_matches(query)
|
|
where_like(:name, normalize_name(query))
|
|
end
|
|
|
|
def any_other_name_matches(regex)
|
|
where(id: Artist.from("unnest(other_names) AS other_name").where_regex("other_name", regex))
|
|
end
|
|
|
|
def any_other_name_like(name)
|
|
where(id: Artist.from("unnest(other_names) AS other_name").where_like("other_name", name))
|
|
end
|
|
|
|
def any_name_matches(query)
|
|
if query =~ %r{\A/(.*)/\z}
|
|
where_regex(:name, $1).or(any_other_name_matches($1)).or(where_regex(:group_name, $1))
|
|
else
|
|
normalized_name = normalize_name(query)
|
|
normalized_name = "*#{normalized_name}*" unless normalized_name.include?("*")
|
|
where_like(:name, normalized_name).or(any_other_name_like(normalized_name)).or(where_like(:group_name, normalized_name))
|
|
end
|
|
end
|
|
|
|
def url_matches(query)
|
|
query = query.strip
|
|
|
|
if query =~ %r{\A/(.*)/\z}
|
|
where(id: ArtistURL.where_regex(:url, $1).select(:artist_id))
|
|
elsif query.include?("*")
|
|
where(id: ArtistURL.where_like(:url, query).select(:artist_id))
|
|
elsif query =~ %r{\Ahttps?://}i
|
|
url = Sources::Strategies.find(query).profile_url || query
|
|
ArtistFinder.find_artists(url)
|
|
else
|
|
where(id: ArtistURL.where_like(:url, "*#{query}*").select(:artist_id))
|
|
end
|
|
end
|
|
|
|
def any_name_or_url_matches(query)
|
|
query = query.strip
|
|
|
|
if query =~ %r{\Ahttps?://}i
|
|
url_matches(query)
|
|
else
|
|
any_name_matches(query)
|
|
end
|
|
end
|
|
|
|
def search(params)
|
|
q = search_attributes(params, :id, :created_at, :updated_at, :is_deleted, :is_banned, :name, :group_name, :other_names, :urls, :wiki_page, :tag_alias, :tag)
|
|
|
|
if params[:any_other_name_like]
|
|
q = q.any_other_name_like(params[:any_other_name_like])
|
|
end
|
|
|
|
if params[:any_name_matches].present?
|
|
q = q.any_name_matches(params[:any_name_matches])
|
|
end
|
|
|
|
if params[:any_name_or_url_matches].present?
|
|
q = q.any_name_or_url_matches(params[:any_name_or_url_matches])
|
|
end
|
|
|
|
if params[:url_matches].present?
|
|
q = q.url_matches(params[:url_matches])
|
|
end
|
|
|
|
case params[:order]
|
|
when "name"
|
|
q = q.order("artists.name")
|
|
when "updated_at"
|
|
q = q.order("artists.updated_at desc")
|
|
when "post_count"
|
|
q = q.left_outer_joins(:tag).order("tags.post_count desc nulls last").order("artists.name")
|
|
else
|
|
q = q.apply_default_order(params)
|
|
end
|
|
|
|
q
|
|
end
|
|
end
|
|
|
|
include UrlMethods
|
|
include VersionMethods
|
|
extend FactoryMethods
|
|
include TagMethods
|
|
include BanMethods
|
|
extend SearchMethods
|
|
|
|
def self.model_restriction(table)
|
|
super.where(table[:is_deleted].eq(false))
|
|
end
|
|
|
|
def self.available_includes
|
|
[:members, :urls, :wiki_page, :tag_alias, :tag]
|
|
end
|
|
end
|