Files
danbooru/app/models/artist.rb
evazion cf8b8207e2 artists: change how artist urls are normalized.
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.
2022-03-13 03:54:17 -05:00

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