class Artist < ApplicationRecord extend Memoist class RevertError < Exception ; end before_create :initialize_creator before_validation :normalize_name after_save :create_version after_save :categorize_tag after_save :update_wiki validates_uniqueness_of :name validate :validate_name validate :validate_wiki, :on => :create after_validation :merge_validation_errors belongs_to :creator, :class_name => "User" has_many :members, :class_name => "Artist", :foreign_key => "group_name", :primary_key => "name" has_many :urls, :dependent => :destroy, :class_name => "ArtistUrl" has_many :versions, lambda {order("artist_versions.id ASC")}, :class_name => "ArtistVersion" has_one :wiki_page, :foreign_key => "title", :primary_key => "name" has_one :tag_alias, :foreign_key => "antecedent_name", :primary_key => "name" has_one :tag, :foreign_key => "name", :primary_key => "name" attr_accessible :body, :notes, :name, :url_string, :other_names, :other_names_comma, :group_name, :notes, :as => [:member, :gold, :builder, :platinum, :moderator, :default, :admin] attr_accessible :is_active, :as => [:builder, :moderator, :default, :admin] attr_accessible :is_banned, :as => :admin scope :active, lambda { where(is_active: true) } scope :deleted, lambda { where(is_active: false) } scope :banned, lambda { where(is_banned: true) } scope :unbanned, lambda { where(is_banned: false) } module UrlMethods extend ActiveSupport::Concern module ClassMethods def find_all_by_url(url) url = ArtistUrl.normalize(url) artists = [] # return [] unless Sources::Site.new(url).normalized_for_artist_finder? while artists.empty? && url.size > 10 u = url.sub(/\/+$/, "") + "/" u = u.to_escaped_for_sql_like.gsub(/\*/, '%') + '%' artists += Artist.joins(:urls).where(["artists.is_active = TRUE AND artist_urls.normalized_url LIKE ? ESCAPE E'\\\\'", u]).limit(10).order("artists.name").all url = File.dirname(url) + "/" break if url =~ /pixiv\.net\/(?:img\/)?$/i break if url =~ /lohas\.nicoseiga\.jp\/priv\/$/i break if url =~ /nicovideo\.jp\/user\/illust/ break if url =~ /(?:data|media)\.tumblr\.com\/[a-z0-9]+\/$/i break if url =~ /deviantart\.net\//i break if url =~ %r!\Ahttps?://(?:mobile\.)?twitter\.com/\Z!i break if url =~ %r!pawoo\.net/(?:web/)?$!i break if url =~ %r!\Ahttps?://(pic\d+\.)?nijie\.info/!i end artists.inject({}) {|h, x| h[x.name] = x; h}.values.slice(0, 20) end end included do memoize :domains end def sorted_urls urls.sort {|a, b| a.priority <=> b.priority} end def url_array urls.map(&:url) end def url_string=(string) @url_string_was = url_string self.urls = string.scan(/[^[:space:]]+/).uniq.map do |url| self.urls.find_or_initialize_by(url: url) end end def url_string url_array.join("\n") end def url_string_changed? @url_string_was != url_string end def map_domain(x) case x when "pximg.net" "pixiv.net" when "deviantart.net" "deviantart.com" else x end end def domains Cache.get("artist-domains-#{id}", 1.day) do Post.raw_tag_match(name).pluck(:source).map do |x| begin map_domain(Addressable::URI.parse(x).domain) rescue Addressable::URI::InvalidURIError nil end end.compact.inject(Hash.new(0)) {|h, x| h[x] += 1; h}.sort {|a, b| b[1] <=> a[1]} end end end module NameMethods extend ActiveSupport::Concern module ClassMethods def normalize_name(name) name.to_s.mb_chars.downcase.strip.gsub(/ /, '_').to_s end end def validate_name if name =~ /^[-~]/ errors[:name] << "cannot begin with - or ~" false elsif name =~ /\*/ errors[:name] << "cannot contain *" false else true end end def normalize_name self.name = Artist.normalize_name(name) end def pretty_name name.tr("_", " ") end def other_names_array other_names.try(:split, /\s/) end def other_names_comma other_names_array.try(:join, ", ") end def other_names_comma=(string) self.other_names = string.split(/,/).map {|x| Artist.normalize_name(x)}.join(" ") end end module GroupMethods def member_names members.map(&:name).join(", ") end end module VersionMethods def create_version(force=false) if name_changed? || url_string_changed? || is_active_changed? || is_banned_changed? || other_names_changed? || group_name_changed? || notes_changed? || 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.user.id, :updater_ip_addr => CurrentUser.ip_addr, :url_string => url_string, :is_active => is_active, :is_banned => is_banned, :other_names => other_names, :group_name => group_name ) end def merge_version prev = versions.last prev.update_attributes( :name => name, :url_string => url_string, :is_active => is_active, :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.url_string self.is_active = version.is_active self.other_names = version.other_names self.group_name = version.group_name save end end module FactoryMethods def new_with_defaults(params) Artist.new.tap do |artist| if params[:name] artist.name = params[:name] post = CurrentUser.without_safe_mode do Post.tag_match("source:http #{artist.name}").where("true /* Artist.new_with_defaults */").first end unless post.nil? || post.source.blank? artist.url_string = post.source end end if params[:other_names] artist.other_names = params[:other_names] end if params[:urls] artist.url_string = params[:urls] end end end end module NoteMethods extend ActiveSupport::Concern def notes @notes || wiki_page.try(:body) end def notes=(text) if notes != text notes_will_change! @notes = text end end def reload(options = nil) flush_cache if instance_variable_defined?(:@notes) remove_instance_variable(:@notes) end super end def notes_changed? attribute_changed?("notes") end def notes_will_change! attribute_will_change!("notes") end def update_wiki if persisted? && name_changed? && name_was.present? && WikiPage.titled(name_was).exists? # we're renaming the artist, so rename the corresponding wiki page old_page = WikiPage.titled(name_was).first if wiki_page.present? # a wiki page with the new name already exists, so update the content wiki_page.update(body: "#{wiki_page.body}\n\n#{@notes}") else # a wiki page doesn't already exist for the new name, so rename the old one old_page.update(title: name, body: @notes) end elsif wiki_page.nil? # if there are any notes, we need to create a new wiki page if @notes.present? create_wiki_page(body: @notes, title: name) end elsif wiki_page.body != @notes || wiki_page.title != name # if anything changed, we need to update the wiki page wiki_page.body = @notes unless @notes.nil? wiki_page.title = name wiki_page.save end end def validate_wiki if WikiPage.titled(name).exists? errors.add(:name, "conflicts with a wiki page") return false end end end module TagMethods def has_tag_alias? TagAlias.active.exists?(["antecedent_name = ?", name]) end def tag_alias_name TagAlias.active.find_by_antecedent_name(name).consequent_name end def category_name Tag.category_for(name) end def categorize_tag if new_record? || name_changed? Tag.find_or_create_by_name("artist:#{name}") end end end module BanMethods def unban! Post.transaction do CurrentUser.without_safe_mode do ti = TagImplication.where(:antecedent_name => name, :consequent_name => "banned_artist").first ti.destroy if ti begin Post.tag_match(name).where("true /* Artist.unban */").each do |post| post.unban! fixed_tags = post.tag_string.sub(/(?:\A| )banned_artist(?:\Z| )/, " ").strip post.update_attributes(:tag_string => fixed_tags) end rescue Post::SearchError # swallow end update_column(:is_banned, false) end end end def ban! Post.transaction do CurrentUser.without_safe_mode do begin Post.tag_match(name).where("true /* Artist.ban */").each do |post| post.ban! end rescue Post::SearchError # swallow end # potential race condition but unlikely unless TagImplication.where(:antecedent_name => name, :consequent_name => "banned_artist").exists? tag_implication = TagImplication.create!(:antecedent_name => name, :consequent_name => "banned_artist", :skip_secondary_validations => true) tag_implication.approve!(approver: CurrentUser.user) end update_column(:is_banned, true) end end end end module SearchMethods def url_matches(string) matches = find_all_by_url(string).map(&:id) if matches.any? where("id in (?)", matches) elsif matches = search_for_profile(string) where("id in (?)", matches) else where("false") end end def search_for_profile(url) source = Sources::Site.new(url) if source.strategy source.get find_all_by_url(source.profile_url) else nil end rescue Exception nil end def other_names_match(string) if string =~ /\*/ && CurrentUser.is_builder? where("artists.other_names ILIKE ? ESCAPE E'\\\\'", string.to_escaped_for_sql_like) else where("artists.other_names_index @@ to_tsquery('danbooru', E?)", Artist.normalize_name(string).to_escaped_for_tsquery) end end def group_name_matches(name) stripped_name = normalize_name(name).to_escaped_for_sql_like where("artists.group_name LIKE ? ESCAPE E'\\\\'", stripped_name) end def name_matches(name) stripped_name = normalize_name(name).to_escaped_for_sql_like where("artists.name LIKE ? ESCAPE E'\\\\'", stripped_name) end def named(name) where(name: normalize_name(name)) end def any_name_matches(name) stripped_name = normalize_name(name).to_escaped_for_sql_like if name =~ /\*/ && CurrentUser.is_builder? where("(artists.name LIKE ? ESCAPE E'\\\\' OR artists.other_names LIKE ? ESCAPE E'\\\\')", stripped_name, stripped_name) else name_for_tsquery = normalize_name(name).to_escaped_for_tsquery where("(artists.name LIKE ? ESCAPE E'\\\\' OR artists.other_names_index @@ to_tsquery('danbooru', E?))", stripped_name, name_for_tsquery) end end def search(params) q = where("true") params = {} if params.blank? case params[:name] when /^http/ q = q.url_matches(params[:name]) when /name:(.+)/ q = q.name_matches($1) when /other:(.+)/ q = q.other_names_match($1) when /group:(.+)/ q = q.group_name_matches($1) when /status:banned/ q = q.banned when /status:active/ q = q.unbanned.active when /./ q = q.any_name_matches(params[:name]) end if params[:name_matches].present? q = q.name_matches(params[:name_matches]) end if params[:other_names_match].present? q = q.other_names_match(params[:other_names_match]) end if params[:group_name_matches].present? q = q.group_name_matches(params[:group_name_matches]) end if params[:any_name_matches].present? q = q.any_name_matches(params[:any_name_matches]) end if params[:url_matches].present? q = q.url_matches(params[:url_matches]) end params[:order] ||= params.delete(:sort) 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.includes(:tag).order("tags.post_count desc nulls last").references(:tags) else q = q.order("artists.id desc") end if params[:is_active] == "true" q = q.active elsif params[:is_active] == "false" q = q.deleted end if params[:is_banned] == "true" q = q.banned elsif params[:is_banned] == "false" q = q.unbanned end if params[:id].present? q = q.where("id in (?)", params[:id].split(",").map(&:to_i)) end if params[:creator_name].present? q = q.where("creator_id = (select _.id from users _ where lower(_.name) = ?)", params[:creator_name].tr(" ", "_").mb_chars.downcase) end if params[:creator_id].present? q = q.where("creator_id = ?", params[:creator_id].to_i) end # XXX deprecated, remove at some point. if params[:empty_only] == "true" params[:has_tag] = "false" end if params[:has_tag] == "true" q = q.joins(:tag).where("tags.post_count > 0") elsif params[:has_tag] == "false" q = q.includes(:tag).where("tags.name IS NULL OR tags.post_count <= 0").references(:tags) end q end end module ApiMethods def hidden_attributes super + [:other_names_index] end end include UrlMethods include NameMethods include GroupMethods include VersionMethods extend FactoryMethods include NoteMethods include TagMethods include BanMethods extend SearchMethods include ApiMethods def merge_validation_errors errors[:urls].clear urls.select(&:invalid?).each do |url| errors[:url] << url.errors.full_messages.join("; ") end end def status if is_banned? && is_active? "Banned" elsif is_banned? "Banned Deleted" elsif is_active? "Active" else "Deleted" end end def initialize_creator self.creator_id = CurrentUser.user.id end def deletable_by?(user) user.is_builder? end def editable_by?(user) user.is_builder? || (!is_banned? && is_active?) end def visible? !is_banned? || CurrentUser.is_gold? end end