require "unicode_utils/casefold" class Tag < ApplicationRecord include Searchable include StringCleaner include WorksOwner include Wrangleable include Rails.application.routes.url_helpers NAME = "Tag" # Note: the order of this array is important. # It is the order that tags are shown in the header of a work # (banned tags are not shown) TYPES = ['Rating', 'ArchiveWarning', 'Category', 'Media', 'Fandom', 'Relationship', 'Character', 'Freeform', 'Banned' ] # these tags can be filtered on FILTERS = TYPES - ['Banned', 'Media'] # these tags show up on works VISIBLE = TYPES - ['Media', 'Banned'] # these are tags which have been created by users # the order is important, and it is the order in which they appear in the tag wrangling interface USER_DEFINED = ['Fandom', 'Character', 'Relationship', 'Freeform'] def self.label_name to_s.pluralize end delegate :document_type, to: :class def document_json TagIndexer.new({}).document(self) end def self.taggings_count_expiry(count) # What we are trying to do here is work out a resonable amount of time for a work to be cached for # This should take the number of taggings and divide it by TAGGINGS_COUNT_CACHE_DIVISOR ( defaults to 1500 ) # such that for example 1500, would be naturally be tagged for one minute while 105,000 would be cached for # 70 minutes. However we then apply a filter such that the minimum amount of time we will cache something for # would be TAGGINGS_COUNT_MIN_TIME ( defaults to 3 minutes ) and the maximum amount of time would be # TAGGINGS_COUNT_MAX_TIME ( defaulting to an hour ). expiry_time = count / (ArchiveConfig.TAGGINGS_COUNT_CACHE_DIVISOR || 1500) [[expiry_time, (ArchiveConfig.TAGGINGS_COUNT_MIN_TIME || 3)].max, (ArchiveConfig.TAGGINGS_COUNT_MAX_TIME || 50) + count % 20 ].min end def taggings_count_cache_key "/v1/taggings_count/#{id}" end def write_taggings_to_redis(value) # Atomically set the value while extracting the old value. old_redis_value = REDIS_GENERAL.getset("tag_update_#{id}_value", value).to_i # If the value hasn't changed from the saved version or the REDIS version, # there's no need to write an update to the database, so let's just bail # out. return value if value == old_redis_value && value == taggings_count_cache # If we've reached here, then the value has changed, and we need to make # sure that the new value is written to the database. REDIS_GENERAL.sadd("tag_update", id) value end def taggings_count=(value) expiry_time = Tag.taggings_count_expiry(value) # Only write to the cache if there are more than a number of uses. Rails.cache.write(taggings_count_cache_key, value, race_condition_ttl: 10, expires_in: expiry_time.minutes) if value >= ArchiveConfig.TAGGINGS_COUNT_MIN_CACHE_COUNT write_taggings_to_redis(value) end def taggings_count cache_read = Rails.cache.read(taggings_count_cache_key) return cache_read unless cache_read.nil? real_value = taggings.count self.taggings_count = real_value real_value end def update_tag_cache cache_read = Rails.cache.read(taggings_count_cache_key) taggings_count if cache_read.nil? || (cache_read < ArchiveConfig.TAGGINGS_COUNT_MIN_CACHE_COUNT) end def update_counts_cache(id) tag = Tag.find(id) tag.taggings_count = tag.taggings.count end acts_as_commentable def commentable_name self.name end # For a tag, the commentable owners are the wranglers of the fandom(s) def commentable_owners # if the tag is a fandom, grab its wranglers or the wranglers of its canonical merger if self.is_a?(Fandom) self.canonical? ? self.wranglers : (self.merger_id ? self.merger.wranglers : []) # if the tag is any other tag, try to grab all the wranglers of all its parent fandoms, if applicable else begin self.fandoms.collect {|f| f.wranglers}.compact.flatten.uniq rescue [] end end end has_many :mergers, foreign_key: 'merger_id', class_name: 'Tag' belongs_to :merger, class_name: "Tag" belongs_to :fandom belongs_to :media belongs_to :last_wrangler, polymorphic: true has_many :filter_taggings, foreign_key: 'filter_id', dependent: :destroy has_many :filtered_works, through: :filter_taggings, source: :filterable, source_type: 'Work' has_many :filtered_external_works, through: :filter_taggings, source: :filterable, source_type: "ExternalWork" has_many :filtered_collections, through: :filter_taggings, source: :filterable, source_type: "Collection" has_one :filter_count, foreign_key: 'filter_id' has_many :direct_filter_taggings, -> { where(inherited: 0) }, class_name: "FilterTagging", foreign_key: 'filter_id' # not used anymore? has_many :direct_filtered_works, through: :direct_filter_taggings, source: :filterable, source_type: 'Work' has_many :common_taggings, foreign_key: 'common_tag_id', dependent: :destroy has_many :child_taggings, class_name: 'CommonTagging', as: :filterable has_many :children, through: :child_taggings, source: :common_tag has_many :parents, through: :common_taggings, source: :filterable, source_type: 'Tag', before_remove: :destroy_common_tagging, after_remove: :update_wrangler has_many :meta_taggings, foreign_key: 'sub_tag_id', dependent: :destroy has_many :meta_tags, through: :meta_taggings, source: :meta_tag, before_remove: :destroy_meta_tagging has_many :sub_taggings, class_name: 'MetaTagging', foreign_key: 'meta_tag_id', dependent: :destroy has_many :sub_tags, through: :sub_taggings, source: :sub_tag, before_remove: :destroy_sub_tagging has_many :direct_meta_tags, -> { where('meta_taggings.direct = 1') }, through: :meta_taggings, source: :meta_tag has_many :direct_sub_tags, -> { where('meta_taggings.direct = 1') }, through: :sub_taggings, source: :sub_tag has_many :taggings, as: :tagger has_many :works, through: :taggings, source: :taggable, source_type: 'Work' has_many :collections, through: :taggings, source: :taggable, source_type: "Collection" has_many :bookmarks, through: :taggings, source: :taggable, source_type: 'Bookmark' has_many :external_works, through: :taggings, source: :taggable, source_type: 'ExternalWork' has_many :approved_collections, through: :filtered_works has_many :favorite_tags, dependent: :destroy has_many :set_taggings, dependent: :destroy has_many :tag_sets, through: :set_taggings has_many :owned_tag_sets, through: :tag_sets has_many :tag_set_associations, dependent: :destroy has_many :parent_tag_set_associations, class_name: 'TagSetAssociation', foreign_key: 'parent_tag_id', dependent: :destroy validates :name, presence: true validates :name, uniqueness: true validates :name, length: { minimum: 1, message: "cannot be blank." } validates :name, length: { maximum: ArchiveConfig.TAG_MAX, message: "^Tag name '%{value}' is too long -- try using less than %{count} characters or using commas to separate your tags." } validates :name, format: { with: /\A[^,,、*<>^{}=`\\%]+\z/, message: "^Tag name '%{value}' cannot include the following restricted characters: , ^ * < > { } = ` , 、 \\ %" } validates :name, format: { without: /\A\p{Cf}+\z/, message: "^Tag name cannot be blank." } validates :sortable_name, presence: true validate :unwrangleable_status def unwrangleable_status return unless unwrangleable? self.errors.add(:unwrangleable, "can't be set on a canonical or synonymized tag.") if canonical? || merger_id.present? self.errors.add(:unwrangleable, "can't be set on an unsorted tag.") if is_a?(UnsortedTag) end before_validation :check_synonym def check_synonym if !self.new_record? && self.name_changed? # ordinary wranglers can change case and accents but not punctuation or the actual letters in the name # admins can change tags with no restriction unless User.current_user.is_a?(Admin) || only_case_changed? self.errors.add(:name, "can only be changed by an admin.") end end if self.merger_id if self.canonical? self.errors.add(:base, "A canonical can't be a synonym") end if self.merger_id == self.id self.errors.add(:base, "A tag can't be a synonym of itself.") end unless self.merger.class == self.class self.errors.add(:base, "A tag can only be a synonym of a tag in the same category as itself.") end end end before_validation :squish_name def squish_name self.name = name.squish if self.name end before_validation :set_sortable_name def set_sortable_name if sortable_name.blank? self.sortable_name = remove_articles_from_string(self.name) end end after_update :queue_flush_work_cache def queue_flush_work_cache async_after_commit(:flush_work_cache) if saved_change_to_name? || saved_change_to_type? end def flush_work_cache self.work_ids.each do |work| Work.expire_work_blurb_version(work) end end # queue_flush_work_cache will update the cached work (bookmarkable) info for # bookmarks, but we still need to expire the portion of bookmark blurbs that # contains the bookmarker's tags. after_update :queue_flush_bookmark_cache def queue_flush_bookmark_cache async_after_commit(:flush_bookmark_cache) if saved_change_to_name? end def flush_bookmark_cache self.bookmarks.each do |bookmark| ActionController::Base.new.expire_fragment("bookmark-owner-blurb-#{bookmark.cache_key}-v3") ActionController::Base.new.expire_fragment("bookmark-blurb-#{bookmark.cache_key}-v3") end end before_save :set_last_wrangler def set_last_wrangler unless User.current_user.nil? self.last_wrangler = User.current_user end end def update_wrangler(tag) unless User.current_user.nil? self.update(last_wrangler: User.current_user) end end after_save :check_type_changes, if: :saved_change_to_type? def check_type_changes return if type_before_last_save.nil? retyped = Tag.find(self.id) # Clean up invalid CommonTaggings. retyped.common_taggings.destroy_invalid retyped.child_taggings.destroy_invalid # If the tag has just become a Fandom, it needs the Uncategorized media # added to it manually (the after_save hook on Fandom won't take effect, # since it's not a Fandom yet) retyped.add_media_for_uncategorized if retyped.is_a?(Fandom) end # Callback for has_many :parents. # Destroy the common tagging so we trigger CommonTagging's callbacks when a # parent is removed. We're specifically interested in the update_search # callback that will reindex the tag and return it to the unwrangled bin. def destroy_common_tagging(parent) self.common_taggings.find_by(filterable_id: parent.id).try(:destroy) end scope :id_only, -> { select("tags.id") } scope :canonical, -> { where(canonical: true) } scope :noncanonical, -> { where(canonical: false) } scope :nonsynonymous, -> { noncanonical.where(merger_id: nil) } scope :synonymous, -> { noncanonical.where("merger_id IS NOT NULL") } scope :unfilterable, -> { nonsynonymous.where(unwrangleable: false) } scope :unwrangleable, -> { where(unwrangleable: true) } scope :in_use, -> { where("canonical = 1 OR taggings_count_cache > 0") } scope :first_class, -> { joins("LEFT JOIN `meta_taggings` ON meta_taggings.sub_tag_id = tags.id").where("meta_taggings.id IS NULL") } # Tags that have sub tags scope :meta_tag, -> { joins(:sub_taggings).where("meta_taggings.id IS NOT NULL").group("tags.id") } # Tags that don't have sub tags scope :non_meta_tag, -> { joins(:sub_taggings).where("meta_taggings.id IS NULL").group("tags.id") } scope :by_popularity, -> { order('taggings_count_cache DESC') } scope :by_name, -> { order('sortable_name ASC') } scope :by_date, -> { order('created_at DESC') } scope :visible, -> { where('type in (?)', VISIBLE).by_name } scope :by_pseud, lambda {|pseud| joins(works: :pseuds). where(pseuds: {id: pseud.id}) } scope :by_type, lambda {|*types| where(types.first.blank? ? "" : {type: types.first})} scope :with_type, lambda {|type| where({type: type}) } # This will return all tags that have one of the given tags as a parent scope :with_parents, lambda {|parents| joins(:common_taggings).where("filterable_id in (?)", parents.first.is_a?(Integer) ? parents : (parents.respond_to?(:pluck) ? parents.pluck(:id) : parents.collect(&:id))) } scope :with_no_parents, -> { joins("LEFT JOIN common_taggings ON common_taggings.common_tag_id = tags.id"). where("filterable_id IS NULL") } scope :starting_with, lambda {|letter| where('SUBSTR(name,1,1) = ?', letter)} scope :visible_to_all_with_count, -> { joins(:filter_count). select("tags.*, filter_counts.public_works_count as count"). where('filter_counts.public_works_count > 0 AND tags.canonical = 1') } scope :visible_to_registered_user_with_count, -> { joins(:filter_count). select("tags.*, filter_counts.unhidden_works_count as count"). where('filter_counts.unhidden_works_count > 0 AND tags.canonical = 1') } scope :public_top, lambda { |tag_count| visible_to_all_with_count. limit(tag_count). order('filter_counts.public_works_count DESC') } scope :unhidden_top, lambda { |tag_count| visible_to_registered_user_with_count. limit(tag_count). order('filter_counts.unhidden_works_count DESC') } scope :popular, -> { (User.current_user.is_a?(Admin) || User.current_user.is_a?(User)) ? visible_to_registered_user_with_count.order('filter_counts.unhidden_works_count DESC') : visible_to_all_with_count.order('filter_counts.public_works_count DESC') } scope :random, -> { (User.current_user.is_a?(Admin) || User.current_user.is_a?(User)) ? visible_to_registered_user_with_count.random_order : visible_to_all_with_count.random_order } scope :with_count, -> { (User.current_user.is_a?(Admin) || User.current_user.is_a?(User)) ? visible_to_registered_user_with_count : visible_to_all_with_count } scope :for_collections, lambda { |collections| joins(filtered_works: :approved_collection_items).merge(Work.posted) .where("collection_items.collection_id IN (?)", collections.collect(&:id)) } scope :for_collection, lambda { |collection| for_collections([collection]) } scope :for_collections_with_count, lambda { |collections| for_collections(collections). select("tags.*, count(tags.id) as count"). group(:id). order(:name) } scope :with_scoped_count, lambda { select("tags.*, count(tags.id) as count"). group(:id) } scope :by_relationships, lambda {|relationships| select("DISTINCT tags.*"). joins(:children). where('children_tags.id IN (?)', relationships.collect(&:id)) } # Get the tags for a challenge's signups, checking both the main tag set # and the optional tag set for each prompt def self.in_challenge(collection, prompt_type=nil) ['', 'optional_'].map { |tag_set_type| join = "INNER JOIN set_taggings ON (tags.id = set_taggings.tag_id) INNER JOIN tag_sets ON (set_taggings.tag_set_id = tag_sets.id) INNER JOIN prompts ON (prompts.#{tag_set_type}tag_set_id = tag_sets.id) INNER JOIN challenge_signups ON (prompts.challenge_signup_id = challenge_signups.id)" tags = self.joins(join).where("challenge_signups.collection_id = ?", collection.id) tags = tags.where("prompts.type = ?", prompt_type) if prompt_type.present? tags }.flatten.compact.uniq end scope :requested_in_challenge, lambda {|collection| in_challenge(collection, 'Request') } scope :offered_in_challenge, lambda {|collection| in_challenge(collection, 'Offer') } # Code for delayed jobs: include AsyncWithActiveJob self.async_job_class = TagMethodJob # Class methods def self.in_prompt_restriction(restriction) joins("INNER JOIN set_taggings ON set_taggings.tag_id = tags.id INNER JOIN tag_sets ON tag_sets.id = set_taggings.tag_set_id INNER JOIN owned_tag_sets ON owned_tag_sets.tag_set_id = tag_sets.id INNER JOIN owned_set_taggings ON owned_set_taggings.owned_tag_set_id = owned_tag_sets.id INNER JOIN prompt_restrictions ON (prompt_restrictions.id = owned_set_taggings.set_taggable_id AND owned_set_taggings.set_taggable_type = 'PromptRestriction')"). where("prompt_restrictions.id = ?", restriction.id) end def self.by_name_without_articles(fieldname = "name") fieldname = "name" unless fieldname.match(/^([\w]+\.)?[\w]+$/) order(Arel.sql("case when lower(substring(#{fieldname} from 1 for 4)) = 'the ' then substring(#{fieldname} from 5) when lower(substring(#{fieldname} from 1 for 2)) = 'a ' then substring(#{fieldname} from 3) when lower(substring(#{fieldname} from 1 for 3)) = 'an ' then substring(#{fieldname} from 4) else #{fieldname} end")) end def self.in_tag_set(tag_set) if tag_set.is_a?(OwnedTagSet) joins(:set_taggings).where("set_taggings.tag_set_id = ?", tag_set.tag_set_id) else joins(:set_taggings).where("set_taggings.tag_set_id = ?", tag_set.id) end end # gives you [parent_name, child_name], [parent_name, child_name], ... def self.parent_names(parent_type = 'fandom') joins(:parents).where("parents_tags.type = ?", parent_type.capitalize). select("parents_tags.name as parent_name, tags.name as child_name"). by_name_without_articles("parent_name"). by_name_without_articles("child_name") end # Because this can be called by a gigantor tag set and all we need are names not objects, # we do an end-run around ActiveRecord and just get the results straight from the db, but # we borrow the sql from parent_names above # returns a hash[parent_name] = child_names def self.names_by_parent(child_relation, parent_type = 'fandom') hash = {} results = ActiveRecord::Base.connection.execute(child_relation.parent_names(parent_type).to_sql) results.each {|row| hash[row.first] ||= Array.new; hash[row.first] << row.second} hash end # Used for associations, such as work.fandoms.string # Yields a comma-separated list of tag names def self.string all.map{|tag| tag.name}.join(ArchiveConfig.DELIMITER_FOR_OUTPUT) end # Use the tag name in urls and escape url-unfriendly characters def to_param # can't find a tag with a name that hasn't been saved yet saved_name = self.name_changed? ? self.name_was : self.name saved_name.gsub('/', '*s*').gsub('&', '*a*').gsub('.', '*d*').gsub('?', '*q*').gsub('#', '*h*') end def display_name name end # Make sure that the global ID doesn't depend on the type, so that we don't # experience errors when switching types: def to_global_id(options = {}) GlobalID.create(becomes(Tag), options) end ## AUTOCOMPLETE # set up autocomplete and override some methods include AutocompleteSource def autocomplete_prefixes prefixes = [ "autocomplete_tag_#{type.downcase}", "autocomplete_tag_all" ] prefixes end def add_to_autocomplete(score = nil) if eligible_for_fandom_autocomplete? parents.each do |parent| add_to_fandom_autocomplete(parent, score) if parent.is_a?(Fandom) end end super end def add_to_fandom_autocomplete(fandom, score = nil) score ||= autocomplete_score REDIS_AUTOCOMPLETE.zadd(self.transliterate("autocomplete_fandom_#{fandom.name.downcase}_#{type.downcase}"), score, autocomplete_value) end def remove_from_autocomplete super return unless was_eligible_for_fandom_autocomplete? parents.each do |parent| remove_from_fandom_autocomplete(parent) if parent.is_a?(Fandom) end end def remove_from_fandom_autocomplete(fandom) REDIS_AUTOCOMPLETE.zrem(self.transliterate("autocomplete_fandom_#{fandom.name.downcase}_#{type.downcase}"), autocomplete_value) end def eligible_for_fandom_autocomplete? (self.is_a?(Character) || self.is_a?(Relationship)) && canonical end def was_eligible_for_fandom_autocomplete? (self.is_a?(Character) || self.is_a?(Relationship)) && (canonical || canonical_before_last_save) end def remove_stale_from_autocomplete super return unless was_eligible_for_fandom_autocomplete? parents.each do |parent| REDIS_AUTOCOMPLETE.zrem(self.transliterate("autocomplete_fandom_#{parent.name.downcase}_#{type.downcase}"), autocomplete_value_before_last_save) if parent.is_a?(Fandom) end end def self.parse_autocomplete_value(current_autocomplete_value) current_autocomplete_value.split(AUTOCOMPLETE_DELIMITER, 2) end def autocomplete_score taggings_count_cache end # look up tags that have been wrangled into a given fandom def self.autocomplete_fandom_lookup(options = {}) options.reverse_merge!({term: "", tag_type: "character", fandom: "", fallback: true}) search_param = options[:term] tag_type = options[:tag_type] fandoms = Tag.get_search_terms(options[:fandom]) # fandom sets are too small to bother breaking up # we're just getting ALL the tags in the set(s) for the fandom(s) and then manually matching results = [] fandoms.each do |single_fandom| if search_param.blank? # just return ALL the characters results += REDIS_AUTOCOMPLETE.zrevrange(self.transliterate("autocomplete_fandom_#{single_fandom}_#{tag_type}"), 0, -1) else search_regex = Tag.get_search_regex(search_param) results += REDIS_AUTOCOMPLETE.zrevrange(self.transliterate("autocomplete_fandom_#{single_fandom}_#{tag_type}"), 0, -1).select { |tag| tag.match(search_regex) } end end if options[:fallback] && results.empty? && search_param.length > 0 # do a standard tag lookup instead Tag.autocomplete_lookup(search_param: search_param, autocomplete_prefix: "autocomplete_tag_#{tag_type}") else results end end ## END AUTOCOMPLETE # Substitute characters that are particularly prone to cause trouble in urls def self.find_by_name(string) return unless string.is_a? String self.find_by(name: from_param(string)) end def self.find_by_name!(string) return unless string.is_a? String self.find_by!(name: from_param(string)) end def self.from_param(string) string.gsub( /\*[sadqh]\*/, '*s*' => '/', '*a*' => '&', '*d*' => '.', '*q*' => '?', '*h*' => '#' ) end # If a tag by this name exists in another class, add a suffix to disambiguate them def self.find_or_create_by_name(new_name) if new_name && new_name.is_a?(String) new_name.squish! tag = Tag.find_by_name(new_name) # if the tag exists and has the proper class, or it is an unsorted tag and it can be sorted to the self class if tag && (tag.class == self || tag.class == UnsortedTag && tag = tag.recategorize(self.to_s)) tag elsif tag self.find_or_create_by_name(new_name + " - " + self.to_s) else self.create(name: new_name, type: self.to_s) end end end def self.create_canonical(name, adult=false) tag = self.find_or_create_by_name(name) raise "how did this happen?" unless tag tag.update_attribute(:canonical,true) tag.update_attribute(:adult, adult) raise "how did this happen?" unless tag.canonical? return tag end # Inherited tag classes can set this to indicate types of tags with which they may have a parent/child # relationship (ie. media: parent, fandom: child; fandom: parent, character: child) def parent_types [] end def child_types [] end # Instance methods that are common to all subclasses (may be overridden in the subclass) def unfilterable? !(self.canonical? || self.unwrangleable? || self.merger_id.present? || self.mergers.any?) end # Returns true if a tag has been used in posted works that are revealed and not hidden def has_posted_works? # rubocop:disable Naming/PredicateName self.works.posted.revealed.unhidden.any? end # sort tags by name def <=>(another_tag) name.downcase <=> another_tag.name.downcase end # only allow changing the tag type for unwrangled tags not used in any tag sets or on any works def can_change_type? self.unfilterable? && self.set_taggings.count == 0 && self.works.count == 0 end # tags having their type changed need to be reloaded to be seen as an instance of the proper subclass def recategorize(new_type) self.update_attribute(:type, new_type) # return a new instance of the tag, with the correct class Tag.find(self.id) end #### FILTERING #### before_update :reindex_associated_for_name_or_type_change def reindex_associated_for_name_or_type_change return unless name_changed? || type_changed? reindex_pseuds = (type == "Fandom") || (type_was == "Fandom") async_after_commit(:reindex_associated, reindex_pseuds) end # Reindex anything even remotely related to this tag. This is overkill in # most cases, but necessary when something fundamental like the name or type # of a tag has changed. def reindex_associated(reindex_pseuds = false) works.reindex_all external_works.reindex_all bookmarks.reindex_all filtered_works.reindex_all filtered_external_works.reindex_all Series.joins(works: :taggings) .merge(self.taggings).reindex_all Series.joins(works: :filter_taggings) .merge(self.filter_taggings).reindex_all # We only want to reindex pseuds if this tag is a Fandom. Unfortunately, we # can't just check the current type, because tags can change type, and we'd # still need to reindex if the old type was Fandom. So we have an option to # control it. if reindex_pseuds Pseud.joins(works: :filter_taggings) .merge(self.direct_filter_taggings).reindex_all end end # The version of the tag that should be used for filtering, if any def filter self.canonical? ? self : ((self.merger && self.merger.canonical?) ? self.merger : nil) end # Update filters for all works and external works directly tagged with this # tag. def update_filters_for_taggables works.update_filters external_works.update_filters collections.update_filters end # Update filters for all works and external works that already have this tag # as one of their filters. def update_filters_for_filterables filtered_works.update_filters filtered_external_works.update_filters filtered_collections.update_filters end # When canonical or merger_id changes, only the items directly tagged with # this tag need their filters updated, so we queue up a call to # update_filters_for_taggables after commit. # # Note that when a tag becomes non-canonical, all of its filter-taggings need # to be deleted. But when a tag becomes non-canonical, all of its mergers and # sub-tags will be deleted, which will result in the necessary items having # their filters fixed. after_update :update_filters_for_canonical_or_merger_change def update_filters_for_canonical_or_merger_change return unless saved_change_to_canonical? || saved_change_to_merger_id? async_after_commit(:update_filters_for_taggables) end # Recalculate the inherited metatags for this tag, and once those changes # are committed, update the filters for every work or external work that's # filter-tagged with this tag. def update_inherited_meta_tags MetaTagging.transaction do InheritedMetaTagUpdater.new(self).update sub_tags.find_each do |sub_tag| InheritedMetaTagUpdater.new(sub_tag).update end end async_after_commit(:update_filters_for_filterables) end # When deleting a metatag, we destroy the meta-tagging first to trigger the # appropriate destroy callback. def destroy_meta_tagging(meta_tag) meta_taggings.find_by(meta_tag: meta_tag)&.destroy end # When deleting a subtag, we destroy the sub-tagging first to trigger the # appropriate destroy callback. def destroy_sub_tagging(sub_tag) sub_taggings.find_by(sub_tag: sub_tag)&.destroy end def reset_filter_count FilterCount.enqueue_filter(filter) end #### END FILTERING #### # methods for counting visible def visible_works_count User.current_user.nil? ? self.works.posted.unhidden.unrestricted.count : self.works.posted.unhidden.count end def visible_bookmarks_count self.bookmarks.is_public.count end def visible_external_works_count self.external_works.where(hidden_by_admin: false).count end def banned self.is_a?(Banned) end def synonyms self.canonical? ? self.mergers : [self.merger] + self.merger.mergers - [self] end # Add a common tagging association def add_association(tag) build_association(tag).save end def has_parent?(tag) self.common_taggings.where(filterable_id: tag.id).count > 0 end def has_child?(tag) self.child_taggings.where(common_tag_id: tag.id).count > 0 end def associations_to_remove; @associations_to_remove ? @associations_to_remove : []; end def associations_to_remove=(taglist) taglist.reject {|tid| tid.blank?}.each do |tag_id| remove_association(tag_id) end end # Determine how two tags are related and divorce them from each other def remove_association(tag_id) tag = Tag.find(tag_id) if tag.class == self.class tag.update(merger: nil) if tag.merger == self meta_taggings.where(direct: true, meta_tag: tag).destroy_all sub_taggings.where(direct: true, sub_tag: tag).destroy_all else common_taggings.where(filterable: tag).destroy_all child_taggings.where(common_tag: tag).destroy_all end tag.touch self.touch end # When canonical or merger is changed, we need to make sure that the # associations (parents, children, metatags, mergers) are fixed. Note that # these are all async calls, so we use async_after_commit to reduce the # likelihood of issues with stale data. before_update :update_associations_for_canonical_or_merger_change def update_associations_for_canonical_or_merger_change if (merger_id_changed? && merger_id.present?) || (canonical_changed? && !canonical?) async_after_commit(:transfer_or_remove_favorite_tags) async_after_commit(:transfer_or_remove_associations) end end # Make it possible to go from a synonym to a canonical in one step. before_validation :reset_merger_when_becoming_canonical def reset_merger_when_becoming_canonical return unless self.canonical_changed? && self.canonical? self.merger_id = nil end # If this tag has a canonical merger, transfer associations to the merger. # Then, regardless of whether it has a merger, delete all canonical # associations (i.e. meta taggings, and associations where this tag is the # parent). def transfer_or_remove_associations transaction do # Try to prevent some concurrency issues. lock! # Abort if the tag has changed back to being canonical between the time # this was enqueued and the time it ran. return if self.canonical? add_associations_to_merger if self.merger&.canonical? self.mergers.find_each { |tag| tag.update(merger_id: nil) } self.child_taggings.destroy_all self.sub_taggings.destroy_all self.meta_taggings.destroy_all end end # When we make this tag a synonym of another canonical tag, we want to move # all the associations this tag has (subtags, metatags, etc) over to that # canonical tag. # # The callbacks that occur when changing the associations will trigger the # necessary reindexing, so we don't need to call extra reindexing code here. def add_associations_to_merger self.parents.find_each do |tag| self.merger.add_association(tag) end self.children.find_each do |tag| self.merger.add_association(tag) end self.mergers.find_each { |tag| tag.update(merger: self.merger) } merger.parents.where(type: %w[Media Fandom]).find_each do |tag| self.add_association(tag) end self.direct_meta_tags.find_each do |tag| meta_tagging = self.merger.meta_taggings.find_or_initialize_by(meta_tag: tag) meta_tagging.update(direct: true) end self.direct_sub_tags.find_each do |tag| sub_tagging = self.merger.sub_taggings.find_or_initialize_by(sub_tag: tag) sub_tagging.update(direct: true) end end # If this tag has a canonical merger, move all favorite tags to the merger. # Otherwise, delete all favorite tags. def transfer_or_remove_favorite_tags if merger&.canonical favorite_tags.find_each do |ft| ft.update(tag_id: merger_id) end end # We perform this after the if (instead of as a separate branch) because # updating the tag_id can fail if the user has both this tag and its merger # as favorite tags. So we want to clean up any failures, which just so # happens to be exactly the same thing we need to do if there's no # canonical merger to transfer the favorite tags to. favorite_tags.find_each(&:destroy) end attr_reader :meta_tag_string, :sub_tag_string, :merger_string # Uses the value of parent_types to determine whether the passed-in tag # should be added as a parent or a child, and then generates the association # (if it doesn't already exist). If it does already exist, returns the # existing CommonTagging object. def build_association(tag) if parent_types.include?(tag&.type) common_taggings.find_or_initialize_by(filterable: tag) else child_taggings.find_or_initialize_by(common_tag: tag) end end # Splits up the passed-in string into a sequence of individual tag names, # then finds (and yields) the tag for each. Used by add_association_string, # meta_tag_string=, and sub_tag_string=. def parse_tag_string(tag_string) tag_string.split(",").map(&:squish).each do |name| yield name, Tag.find_by_name(name) end end # Try to create new associations with the tags of type tag_type whose names # are listed in tag_string. def add_association_string(tag_type, tag_string) parse_tag_string(tag_string) do |name, parent| prefix = "Cannot add association to '#{name}':" if parent && parent.type != tag_type errors.add(:base, "#{prefix} #{parent.type} added in #{tag_type} field.") else association = build_association(parent) save_and_gather_errors(association, prefix) end end end # Save an item to the database, if it's valid. If it's invalid, read in the # error messages from the item and copy them over to this tag. def save_and_gather_errors(item, prefix) return unless item.new_record? || item.changed? return if item.valid? && item.save item.errors.full_messages.each do |message| errors.add(:base, "#{prefix} #{message}") end end # Find and destroy all invalid CommonTaggings and MetaTaggings associated # with this tag. def destroy_invalid_associations common_taggings.destroy_invalid child_taggings.destroy_invalid meta_taggings.destroy_invalid sub_taggings.destroy_invalid end # defines fandom_string=, media_string=, character_string=, relationship_string=, freeform_string= %w(Fandom Media Character Relationship Freeform).each do |tag_type| attr_reader "#{tag_type.downcase}_string" define_method("#{tag_type.downcase}_string=") do |tag_string| add_association_string(tag_type, tag_string) end end def meta_tag_string=(tag_string) parse_tag_string(tag_string) do |name, parent| meta_tagging = meta_taggings.find_or_initialize_by(meta_tag: parent) meta_tagging.direct = true save_and_gather_errors(meta_tagging, "Invalid metatag '#{name}':") end end def sub_tag_string=(tag_string) parse_tag_string(tag_string) do |name, sub| sub_tagging = sub_taggings.find_or_initialize_by(sub_tag: sub) sub_tagging.direct = true save_and_gather_errors(sub_tagging, "Invalid subtag '#{name}':") end end def syn_string self.merger.name if self.merger end # Make this tag a synonym of another tag -- tag_string is the name of the other tag (which should be canonical) # NOTE for potential confusion # "merger" is the canonical tag of which this one will be a synonym # "mergers" are the tags which are (currently) synonyms of THIS one def syn_string=(tag_string) # If the tag_string is blank, our tag should be given no merger if tag_string.blank? self.merger_id = nil return end new_merger = Tag.find_by(name: tag_string) # Bail out if the new merger is the same as the current merger return if new_merger && new_merger == self.merger # Return an error if a non-admin tries to make a canonical into a synonym if self.canonical? && !User.current_user.is_a?(Admin) self.errors.add(:base, "Only an admin can make a canonical tag into a synonym of another tag.") return end if new_merger && new_merger == self self.errors.add(:base, tag_string + " is considered the same as " + self.name + " by the database.") elsif new_merger && !new_merger.canonical? self.errors.add(:base, "#{new_merger.name} is not a canonical tag. Please make it canonical before adding synonyms to it.") elsif new_merger && new_merger.class != self.class self.errors.add(:base, new_merger.name + " is a #{new_merger.type.to_s.downcase}. Synonyms must belong to the same category.") elsif !new_merger new_merger = self.class.new(name: tag_string, canonical: true) unless new_merger.save self.errors.add(:base, tag_string + " could not be saved. Please make sure that it's a valid tag name.") end end # If we don't have any errors, update the tag to add the new merger if new_merger && self.errors.empty? self.canonical = false self.merger_id = new_merger.id end end def merger_string=(tag_string) names = tag_string.split(',').map(&:squish) names.each do |name| syn = Tag.find_by_name(name) if syn && !syn.canonical? syn.update(merger_id: self.id) end end end # unwrangleable: # - A boolean stored in the tags table # - Default false # - Set to true by wranglers on tags that should be excluded from the wrangling process altogether. Example: freeform tags like "idk how to explain it but trust me" # # unwrangled: # - A computed value # - True for "orphan" tags yet to be tied to something (fandom, character, etc.) by wranglers # - Exact meaning may change depending on the nature of the tag (search for definitions of unwrangled? overriding this one) # def unwrangled? common_taggings.empty? end ################################# ## SEARCH ####################### ################################# def unwrangled_query(tag_type, options = {}) self_type = %w[Character Fandom Media].include?(self.type) ? self.type.downcase : "fandom" TagQuery.new(options.merge( type: tag_type, unwrangleable: false, wrangled: false, has_posted_works: true, "pre_#{self_type}_ids": [self.id], per_page: Tag.per_page )) end def unwrangled_tags(tag_type, options = {}) unwrangled_query(tag_type, options).search_results end def unwrangled_tag_count(tag_type) key = "unwrangled_#{tag_type}_#{self.id}_#{self.updated_at}" Rails.cache.fetch(key, expires_in: 4.hours) do unwrangled_query(tag_type).count end end def suggested_parent_tags(parent_type, options = {}) limit = options[:limit] || 50 work_ids = works.limit(limit).pluck(:id) Tag.distinct.joins(:taggings).where( "tags.type" => parent_type, taggings: { taggable_type: 'Work', taggable_id: work_ids } ) end # For works that haven't been wrangled yet, get the fandom/character tags # that are used on their works as a place to start def suggested_parent_ids(parent_type) return [] if !parent_types.include?(parent_type) || unwrangleable? || parents.by_type(parent_type).exists? suggested_parent_tags(parent_type).pluck(:id, :merger_id). flatten.compact.uniq end def queue_child_tags_for_reindex all_with_child_type = Tag.where(type: child_types & Tag::USER_DEFINED) works.select(:id).find_in_batches do |batch| relevant_taggings = Tagging.where(taggable: batch) tag_ids = all_with_child_type.joins(:taggings).merge(relevant_taggings).distinct.pluck(:id) IndexQueue.enqueue_ids(Tag, tag_ids, :background) end end after_create :after_create def after_create tag = self if tag.canonical tag.add_to_autocomplete end end after_update :after_update def after_update tag = self if tag.saved_change_to_canonical? if tag.canonical # newly canonical tag tag.add_to_autocomplete else # decanonicalised tag tag.remove_from_autocomplete end else tag.refresh_autocomplete end # Expire caching when a merger is added or removed if tag.saved_change_to_merger_id? if tag.merger_id_before_last_save.present? old = Tag.find(tag.merger_id_before_last_save) old.update_works_index_timestamp! end if tag.merger_id.present? tag.merger.update_works_index_timestamp! end async_after_commit(:queue_child_tags_for_reindex) end # if type has changed, expire the tag's parents' children cache (it stores the children's type) if tag.saved_change_to_type? tag.parents.each do |parent_tag| ActionController::Base.new.expire_fragment("views/tags/#{parent_tag.id}/children") end end # Reindex immediately to update the unwrangled bin. if tag.saved_change_to_unwrangleable? tag.reindex_document end end def refresh_autocomplete return unless canonical remove_stale_from_autocomplete add_to_autocomplete end before_destroy :before_destroy def before_destroy tag = self if Tag::USER_DEFINED.include?(tag.type) && tag.canonical tag.remove_from_autocomplete end end private after_save :update_tag_nominations def update_tag_nominations TagNomination.where(tagname: name).update_all( canonical: canonical, synonym: merger.nil? ? nil : merger.name, parented: false, # we'll fix this later in the callback exists: true ) if canonical? # Calculate the fandoms associated with this tag, because we'll set any # TagNominations with a matching parent_tagname to have parented: true. parent_names = parents.where(type: "Fandom").pluck(:name) # If this tag has any fandoms at all, we also want to count it as parented # for nominations with a blank parent_tagname. See the set_parented # function in TagNominations for the calculation that we're trying to mimic # here. parent_names << "" if parent_names.present? TagNomination.where(tagname: name, parent_tagname: parent_names).update_all(parented: true) end return unless saved_change_to_name? && name_before_last_save.present? # Act as if the tag with the previous name was deleted and mirror clear_tag_nominations TagNomination.where(tagname: name_before_last_save).update_all( canonical: false, exists: false, parented: false, synonym: nil ) end before_destroy :clear_tag_nominations def clear_tag_nominations TagNomination.where(tagname: name).update_all( canonical: false, exists: false, parented: false, synonym: nil ) end def only_case_changed? new_normalized_name = normalize_for_tag_comparison(self.name) old_normalized_name = normalize_for_tag_comparison(self.name_was) (self.name.downcase == self.name_was.downcase) || (new_normalized_name == old_normalized_name) end def normalize_for_tag_comparison(string) UnicodeUtils.casefold(string).mb_chars.unicode_normalize(:nfkd).gsub(/[\u0300-\u036F]/u, "") end end