Files
danbooru/app/models/artist.rb
evazion 015c6dc7db Fix #4965: Account for metatag prefixes when searching/linking
Drop the ability to write e.g. `create alias foo -> char:bar` in a BUR
to change the tag's type as a side effect. You can only use these
tag type prefixes in tag edits now.

This feature was only intended to be used in tag edits. The fact it
worked elsewhere was unintended behavior.

This feature was problematic because it relied on `Tag.find_or_create_by_name`
automagically changing the tag's category when the tag name contained a
tag category prefix, e.g. `char:hatsune_miku`. This meant that merely
looking up a tag could have the side effect of changing its category.
It was also bad because `find_or_create_by_name` had a hidden dependency
on the current user, which may not be set or available in all contexts.
2022-09-10 04:49:24 -05:00

336 lines
9.8 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
after_validation :add_url_warnings
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
class_methods do
# Find all artist URLs matching `regex`, and replace the `from` regex with the `to` string.
def rewrite_urls(regex, from, to)
Artist.joins(:urls).where_regex("artist_urls.url", regex).find_each do |artist|
artist.update!(url_string: artist.url_string.gsub(from, to))
end
end
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 = Source::Extractor.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&.is_deprecated?
errors.add(:name, "'#{name}' is an ambiguous tag; try another name")
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("banned_artist", category: "artist", current_user: banner)
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 urls_match(urls)
urls = Array.wrap(urls).flat_map(&:split)
return all if urls.empty?
urls.map do |url|
url_matches(url)
end.reduce(&:or)
end
def url_matches(query)
query = query.strip
if query =~ %r{\Ahttps?://}i
url = Source::Extractor.find(query).profile_url || query
ArtistFinder.find_artists(url)
else
where(id: ArtistURL.url_matches(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.urls_match(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
def add_url_warnings
urls.each do |url|
warnings.add(:base, url.warnings.full_messages.join("; ")) if url.warnings.any?
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