# 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("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 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