otwarchive-symphonyarchive/app/models/tag.rb
2026-03-11 22:22:11 +00:00

1254 lines
44 KiB
Ruby
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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: , &#94; * < > { } = ` \\ %" }
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, "<a href=\"#{edit_tag_path(new_merger)}\">#{new_merger.name}</a> 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