1320 lines
45 KiB
Ruby
Executable file
1320 lines
45 KiB
Ruby
Executable file
class Work < ApplicationRecord
|
|
include Filterable
|
|
include CreationNotifier
|
|
include Collectible
|
|
include Bookmarkable
|
|
include Searchable
|
|
include BookmarkCountCaching
|
|
include WorkChapterCountCaching
|
|
include Creatable
|
|
|
|
########################################################################
|
|
# ASSOCIATIONS
|
|
########################################################################
|
|
|
|
has_many :external_creatorships, as: :creation, dependent: :destroy, inverse_of: :creation
|
|
has_many :archivists, through: :external_creatorships
|
|
has_many :external_author_names, through: :external_creatorships, inverse_of: :works
|
|
has_many :external_authors, -> { distinct }, through: :external_author_names
|
|
|
|
# we do NOT use dependent => destroy here because we want to destroy chapters in REVERSE order
|
|
has_many :chapters, inverse_of: :work, autosave: true
|
|
|
|
has_many :serial_works, dependent: :destroy
|
|
has_many :series, through: :serial_works
|
|
|
|
has_many :related_works, as: :parent
|
|
has_many :approved_related_works, -> { where(reciprocal: 1) }, as: :parent, class_name: "RelatedWork"
|
|
has_many :parent_work_relationships, class_name: "RelatedWork", dependent: :destroy
|
|
has_many :children, through: :related_works, source: :work
|
|
has_many :approved_children, through: :approved_related_works, source: :work
|
|
|
|
accepts_nested_attributes_for :parent_work_relationships, allow_destroy: true, reject_if: proc { |attrs| attrs.values_at(:url, :author, :title).all?(&:blank?) }
|
|
|
|
has_many :gifts, dependent: :destroy
|
|
accepts_nested_attributes_for :gifts, allow_destroy: true
|
|
|
|
has_many :subscriptions, as: :subscribable, dependent: :destroy
|
|
|
|
has_many :challenge_assignments, as: :creation
|
|
has_many :challenge_claims, as: :creation
|
|
accepts_nested_attributes_for :challenge_claims
|
|
|
|
acts_as_commentable
|
|
has_many :total_comments, class_name: 'Comment', through: :chapters
|
|
has_many :kudos, as: :commentable, dependent: :destroy
|
|
|
|
has_many :original_creators, class_name: "WorkOriginalCreator", dependent: :destroy
|
|
|
|
belongs_to :language
|
|
belongs_to :work_skin
|
|
validate :work_skin_allowed, on: :save
|
|
def work_skin_allowed
|
|
unless self.users.include?(self.work_skin.author) || (self.work_skin.public? && self.work_skin.official?)
|
|
errors.add(:base, ts("You do not have permission to use that custom work stylesheet."))
|
|
end
|
|
end
|
|
# statistics
|
|
has_one :stat_counter, dependent: :destroy
|
|
after_create :create_stat_counter
|
|
def create_stat_counter
|
|
counter = self.build_stat_counter
|
|
counter.save
|
|
end
|
|
# moderation
|
|
has_one :moderated_work, dependent: :destroy
|
|
|
|
########################################################################
|
|
# VIRTUAL ATTRIBUTES
|
|
########################################################################
|
|
|
|
# Virtual attribute to use as a placeholder for pseuds before the work has been saved
|
|
# Can't write to work.pseuds until the work has an id
|
|
attr_accessor :new_parent, :url_for_parent
|
|
attr_accessor :new_gifts
|
|
attr_accessor :preview_mode
|
|
|
|
# Virtual attribute for whether the hidden-for-spam email has been sent, so the normal work-hidden email should not be sent
|
|
attr_accessor :notified_of_hiding_for_spam
|
|
|
|
# return title.html_safe to overcome escaping done by sanitiser
|
|
def title
|
|
read_attribute(:title).try(:html_safe)
|
|
end
|
|
|
|
########################################################################
|
|
# VALIDATION
|
|
########################################################################
|
|
validates_presence_of :title
|
|
validates_length_of :title,
|
|
minimum: ArchiveConfig.TITLE_MIN,
|
|
too_short: ts("must be at least %{min} characters long.", min: ArchiveConfig.TITLE_MIN)
|
|
|
|
validates_length_of :title,
|
|
maximum: ArchiveConfig.TITLE_MAX,
|
|
too_long: ts("must be less than %{max} characters long.", max: ArchiveConfig.TITLE_MAX)
|
|
|
|
validates_length_of :summary,
|
|
allow_blank: true,
|
|
maximum: ArchiveConfig.SUMMARY_MAX,
|
|
too_long: ts("must be less than %{max} characters long.", max: ArchiveConfig.SUMMARY_MAX)
|
|
|
|
validates_length_of :notes,
|
|
allow_blank: true,
|
|
maximum: ArchiveConfig.NOTES_MAX,
|
|
too_long: ts("must be less than %{max} characters long.", max: ArchiveConfig.NOTES_MAX)
|
|
|
|
validates_length_of :endnotes,
|
|
allow_blank: true,
|
|
maximum: ArchiveConfig.NOTES_MAX,
|
|
too_long: ts("must be less than %{max} characters long.", max: ArchiveConfig.NOTES_MAX)
|
|
|
|
validate :language_present_and_supported
|
|
|
|
def language_present_and_supported
|
|
errors.add(:base, ts("Language cannot be blank.")) if self.language.blank?
|
|
end
|
|
|
|
# Makes sure the title has no leading spaces
|
|
validate :clean_and_validate_title
|
|
|
|
def clean_and_validate_title
|
|
unless self.title.blank?
|
|
self.title = self.title.strip
|
|
if self.title.length < ArchiveConfig.TITLE_MIN
|
|
errors.add(:base, ts("Title must be at least %{min} characters long without leading spaces.", min: ArchiveConfig.TITLE_MIN))
|
|
throw :abort
|
|
else
|
|
self.title_to_sort_on = self.sorted_title
|
|
end
|
|
end
|
|
end
|
|
|
|
def validate_published_at
|
|
return unless first_chapter
|
|
|
|
if !self.first_chapter.published_at
|
|
self.first_chapter.published_at = Date.current
|
|
elsif self.first_chapter.published_at > Date.current
|
|
errors.add(:base, ts("Publication date can't be in the future."))
|
|
throw :abort
|
|
end
|
|
end
|
|
|
|
validates :fandom_string,
|
|
presence: { message: "^Please fill in at least one fandom." }
|
|
validates :archive_warning_string,
|
|
presence: { message: "^Please select at least one warning." }
|
|
validates :rating_string,
|
|
presence: { message: "^Please choose a rating." }
|
|
|
|
validate :only_one_rating
|
|
def only_one_rating
|
|
return unless split_tag_string(rating_string).count > 1
|
|
|
|
errors.add(:base, ts("Only one rating is allowed."))
|
|
end
|
|
|
|
# rephrases the "chapters is invalid" message
|
|
after_validation :check_for_invalid_chapters
|
|
def check_for_invalid_chapters
|
|
if self.errors[:chapters].any?
|
|
self.errors.add(:base, ts("Please enter your story in the text field below."))
|
|
self.errors.delete(:chapters)
|
|
end
|
|
end
|
|
|
|
validates :user_defined_tags_count,
|
|
at_most: { maximum: proc { ArchiveConfig.USER_DEFINED_TAGS_MAX } }
|
|
|
|
# If the recipient doesn't allow gifts, it should not be possible to give them
|
|
# a gift work unless it fulfills a gift exchange assignment or non-anonymous
|
|
# prompt meme claim for the recipient.
|
|
# We don't want the work to save if the gift shouldn't exist, but the gift
|
|
# model can't access a work's challenge_assignments or challenge_claims until
|
|
# the work and its assignments and claims are saved. Gifts are created after
|
|
# the work is saved, so it's too late then to prevent the work from saving.
|
|
# Additionally, the work's assignments and claims don't appear to be available
|
|
# by the time gift validations run, which means the gift is never created if
|
|
# the user doesn't allow them.
|
|
validate :new_recipients_allow_gifts
|
|
|
|
def new_recipients_allow_gifts
|
|
return if self.new_gifts.blank?
|
|
|
|
self.new_gifts.each do |gift|
|
|
next if gift.pseud.blank?
|
|
next if gift.pseud&.user&.preference&.allow_gifts?
|
|
next if challenge_bypass(gift)
|
|
|
|
self.errors.add(:base, :blocked_gifts, byline: gift.pseud.byline)
|
|
end
|
|
end
|
|
|
|
validate :new_recipients_have_not_blocked_gift_giver
|
|
def new_recipients_have_not_blocked_gift_giver
|
|
return if self.new_gifts.blank?
|
|
|
|
self.new_gifts.each do |gift|
|
|
# Already dealt with in #new_recipients_allow_gifts
|
|
next if gift.pseud&.user&.preference && !gift.pseud.user.preference.allow_gifts?
|
|
|
|
next if challenge_bypass(gift)
|
|
|
|
blocked_users = gift.pseud&.user&.blocked_users || []
|
|
next if blocked_users.empty?
|
|
|
|
pseuds_after_saving.each do |pseud|
|
|
next unless blocked_users.include?(pseud.user)
|
|
|
|
if User.current_user == pseud.user
|
|
self.errors.add(:base, :blocked_your_gifts, byline: gift.pseud.byline)
|
|
else
|
|
self.errors.add(:base, :blocked_gifts, byline: gift.pseud.byline)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
enum :comment_permissions, {
|
|
enable_all: 0,
|
|
disable_anon: 1,
|
|
disable_all: 2
|
|
}, suffix: :comments, default: 0
|
|
|
|
########################################################################
|
|
# HOOKS
|
|
# These are methods that run before/after saves and updates to ensure
|
|
# consistency and that associated variables are updated.
|
|
########################################################################
|
|
|
|
before_save :clean_and_validate_title, :validate_published_at, :ensure_revised_at
|
|
|
|
after_save :post_first_chapter
|
|
before_save :set_word_count
|
|
|
|
after_save :save_chapters, :save_new_gifts
|
|
|
|
before_create :set_anon_unrevealed
|
|
after_create :notify_after_creation
|
|
|
|
after_update :adjust_series_restriction, :notify_after_update
|
|
|
|
before_save :hide_spam
|
|
after_save :moderate_spam
|
|
after_save :notify_of_hiding
|
|
|
|
after_save :notify_recipients, :expire_caches, :update_pseud_index, :update_tag_index, :touch_series, :touch_related_works
|
|
after_destroy :expire_caches, :update_pseud_index
|
|
|
|
before_destroy :send_deleted_work_notification, prepend: true
|
|
def send_deleted_work_notification
|
|
return unless self.posted? && users.present?
|
|
|
|
orphan_account = User.orphan_account
|
|
users.each do |user|
|
|
next if user == orphan_account
|
|
|
|
I18n.with_locale(user.preference.locale_for_mails) do
|
|
# Check to see if this work is being deleted by an Admin
|
|
if User.current_user.is_a?(Admin)
|
|
# this has to use the synchronous version because the work is going to be destroyed
|
|
UserMailer.admin_deleted_work_notification(user, self).deliver_now
|
|
else
|
|
# this has to use the synchronous version because the work is going to be destroyed
|
|
UserMailer.delete_work_notification(user, self, User.current_user).deliver_now
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
def expire_caches
|
|
pseuds.each do |pseud|
|
|
pseud.update_works_index_timestamp!
|
|
pseud.user.update_works_index_timestamp!
|
|
end
|
|
|
|
collections.each do |this_collection|
|
|
collection = this_collection
|
|
# Flush this collection and all its parents
|
|
loop do
|
|
collection.update_works_index_timestamp!
|
|
collection = collection.parent
|
|
break unless collection
|
|
end
|
|
end
|
|
|
|
filters.each do |tag|
|
|
tag.update_works_index_timestamp!
|
|
end
|
|
|
|
tags.each do |tag|
|
|
tag.update_tag_cache
|
|
end
|
|
|
|
series.each(&:expire_caches)
|
|
|
|
Work.expire_work_blurb_version(id)
|
|
Work.flush_find_by_url_cache unless imported_from_url.blank?
|
|
end
|
|
|
|
def update_pseud_index
|
|
return unless should_reindex_pseuds?
|
|
IndexQueue.enqueue_ids(Pseud, pseud_ids, :background)
|
|
end
|
|
|
|
# Visibility has changed, which means we need to reindex
|
|
# the work's pseuds, to update their work counts, as well as
|
|
# the work's bookmarker pseuds, to update their bookmark counts.
|
|
def should_reindex_pseuds?
|
|
pertinent_attributes = %w(id posted restricted in_anon_collection
|
|
in_unrevealed_collection hidden_by_admin)
|
|
destroyed? || (saved_changes.keys & pertinent_attributes).present?
|
|
end
|
|
|
|
# If the work gets posted, (un)hidden, or (un)revealed, we should (potentially) reindex the tags,
|
|
# so they get the correct visibility status.
|
|
def update_tag_index
|
|
return unless saved_change_to_posted? || saved_change_to_hidden_by_admin? || saved_change_to_in_unrevealed_collection?
|
|
|
|
taggings.each(&:update_search)
|
|
end
|
|
|
|
def self.work_blurb_version_key(id)
|
|
"/v4/work_blurb_tag_cache_key/#{id}"
|
|
end
|
|
|
|
def self.work_blurb_version(id)
|
|
Rails.cache.fetch(Work.work_blurb_version_key(id), raw: true) { rand(1..1000) }
|
|
end
|
|
|
|
def self.expire_work_blurb_version(id)
|
|
Rails.cache.increment(Work.work_blurb_version_key(id))
|
|
end
|
|
|
|
# When works are done being reindexed, expire the appropriate caches
|
|
def self.successful_reindex(ids)
|
|
CacheMaster.expire_caches(ids)
|
|
tag_ids = FilterTagging.where(filterable_id: ids, filterable_type: 'Work').
|
|
group(:filter_id).
|
|
pluck(:filter_id)
|
|
|
|
collection_ids = CollectionItem.where(item_id: ids, item_type: 'Work').
|
|
group(:collection_id).
|
|
pluck(:collection_id)
|
|
|
|
pseuds = Pseud.select("pseuds.id, pseuds.user_id").
|
|
joins(:creatorships).
|
|
where(creatorships: {
|
|
creation_id: ids,
|
|
creation_type: 'Work'
|
|
}
|
|
)
|
|
|
|
pseuds.each { |p| p.update_works_index_timestamp! }
|
|
User.expire_ids(pseuds.map(&:user_id).uniq)
|
|
Tag.expire_ids(tag_ids)
|
|
Collection.expire_ids(collection_ids)
|
|
end
|
|
|
|
def touch_series
|
|
series.touch_all if saved_change_to_in_anon_collection?
|
|
end
|
|
|
|
after_destroy :destroy_chapters_in_reverse
|
|
def destroy_chapters_in_reverse
|
|
chapters.sort_by(&:position).reverse.each(&:destroy)
|
|
end
|
|
|
|
after_destroy :clean_up_assignments
|
|
def clean_up_assignments
|
|
self.challenge_assignments.each {|a| a.creation = nil; a.save!}
|
|
end
|
|
|
|
########################################################################
|
|
# RESQUE
|
|
########################################################################
|
|
|
|
include AsyncWithResque
|
|
@queue = :utilities
|
|
|
|
########################################################################
|
|
# IMPORTING
|
|
########################################################################
|
|
|
|
def self.find_by_url_generation_key
|
|
"/v1/find_by_url_generation_key"
|
|
end
|
|
|
|
def self.find_by_url_generation
|
|
Rails.cache.fetch(Work.find_by_url_generation_key, raw: true) { rand(1..1000) }
|
|
end
|
|
|
|
def self.flush_find_by_url_cache
|
|
Rails.cache.increment(Work.find_by_url_generation_key)
|
|
end
|
|
|
|
def self.find_by_url_cache_key(url)
|
|
url = UrlFormatter.new(url)
|
|
"/v1/find_by_url/#{Work.find_by_url_generation}/#{url.encoded}"
|
|
end
|
|
|
|
# Match `url` to a work's imported_from_url field using progressively fuzzier matching:
|
|
# 1. first exact match
|
|
# 2. first exact match with variants of the provided url
|
|
# 3. first match on variants of both the imported_from_url and the provided url if there is a partial match
|
|
|
|
def self.find_by_url_uncached(url)
|
|
url = UrlFormatter.new(url)
|
|
Work.where(imported_from_url: url.original).first ||
|
|
Work.where(imported_from_url: [url.minimal,
|
|
url.with_http, url.with_https,
|
|
url.no_www, url.with_www,
|
|
url.encoded, url.decoded,
|
|
url.minimal_no_protocol_no_www]).first ||
|
|
Work.where("imported_from_url LIKE ? or imported_from_url LIKE ?",
|
|
"http://#{url.minimal_no_protocol_no_www}%",
|
|
"https://#{url.minimal_no_protocol_no_www}%").select do |w|
|
|
work_url = UrlFormatter.new(w.imported_from_url)
|
|
%w[original minimal no_www with_www with_http with_https encoded decoded].any? do |method|
|
|
work_url.send(method) == url.send(method)
|
|
end
|
|
end.first
|
|
end
|
|
|
|
def self.find_by_url(url)
|
|
Rails.cache.fetch(Work.find_by_url_cache_key(url)) do
|
|
find_by_url_uncached(url)
|
|
end
|
|
end
|
|
|
|
# Remove all pseuds associated with a particular user. Raises an exception if
|
|
# this would result in removing all creators from the work.
|
|
#
|
|
# Callbacks handle most of the work when deleting creatorships, but we do
|
|
# have one special case: if a co-created work has a chapter that only has
|
|
# one listed creator, and that creator removes themselves from the work, we
|
|
# need to update the chapter to add the other creators on the work.
|
|
def remove_author(author_to_remove)
|
|
pseuds_with_author_removed = pseuds.where.not(user_id: author_to_remove.id)
|
|
raise Exception.new("Sorry, we can't remove all creators of a work.") if pseuds_with_author_removed.empty?
|
|
|
|
transaction do
|
|
chapters.each do |chapter|
|
|
if (chapter.pseuds - author_to_remove.pseuds).empty?
|
|
pseuds_with_author_removed.each do |new_pseud|
|
|
chapter.creatorships.find_or_create_by(pseud: new_pseud)
|
|
end
|
|
end
|
|
|
|
chapter.creatorships.where(pseud: author_to_remove.pseuds).destroy_all
|
|
end
|
|
|
|
creatorships.where(pseud: author_to_remove.pseuds).destroy_all
|
|
end
|
|
end
|
|
|
|
# Override the default behavior so that we also check for creatorships
|
|
# associated with one of the chapters.
|
|
def user_is_owner_or_invited?(user)
|
|
return false unless user.is_a?(User)
|
|
return true if super
|
|
|
|
chapters.joins(:creatorships).merge(user.creatorships).exists?
|
|
end
|
|
|
|
def set_challenge_info
|
|
# if this is fulfilling a challenge, add the collection and recipient
|
|
challenge_assignments.each do |assignment|
|
|
add_to_collection(assignment.collection)
|
|
self.gifts << Gift.new(pseud: assignment.requesting_pseud) unless (assignment.requesting_pseud.blank? || recipients && recipients.include?(assignment.request_byline))
|
|
end
|
|
end
|
|
|
|
# If this is fulfilling a challenge claim, add the collection.
|
|
#
|
|
# Unlike set_challenge_info, we don't automatically add the prompter as a
|
|
# recipient, because (a) some prompters are anonymous, so there has to be a
|
|
# prompter notification (separate from the recipient notification) ensuring
|
|
# that anonymous prompters are notified, and (b) if the prompter is not
|
|
# anonymous, they'll receive two notifications with roughly the same info
|
|
# (gift notification + prompter notification).
|
|
def set_challenge_claim_info
|
|
challenge_claims.each do |claim|
|
|
add_to_collection(claim.collection)
|
|
end
|
|
end
|
|
|
|
def challenge_assignment_ids
|
|
challenge_assignments.map(&:id)
|
|
end
|
|
|
|
def challenge_claim_ids
|
|
challenge_claims.map(&:id)
|
|
end
|
|
|
|
# Only allow a work to fulfill an assignment assigned to one of this work's authors
|
|
def challenge_assignment_ids=(ids)
|
|
valid_users = (self.users + [User.current_user]).compact
|
|
|
|
self.challenge_assignments =
|
|
ChallengeAssignment.where(id: ids)
|
|
.select { |assign| valid_users.include?(assign.offering_user) }
|
|
end
|
|
|
|
def recipients=(recipient_names)
|
|
new_gifts = []
|
|
gifts = [] # rebuild the list of associated gifts using the new list of names
|
|
# add back in the rejected gift recips; we don't let users delete rejected gifts in order to prevent regifting
|
|
recip_names = recipient_names.split(',') + self.gifts.are_rejected.collect(&:recipient)
|
|
recip_names.uniq.each do |name|
|
|
name.strip!
|
|
gift = self.gifts.for_name_or_byline(name).first
|
|
if gift
|
|
gifts << gift # new gifts are added after saving, not now
|
|
new_gifts << gift unless self.posted # all gifts are new if work not posted
|
|
else
|
|
g = self.gifts.new(recipient: name)
|
|
if g.valid?
|
|
new_gifts << g # new gifts are added after saving, not now
|
|
else
|
|
g.errors.full_messages.each { |msg| self.errors.add(:base, msg) }
|
|
end
|
|
end
|
|
end
|
|
self.gifts = gifts
|
|
self.new_gifts = new_gifts
|
|
end
|
|
|
|
def recipients(for_form = false)
|
|
names = (for_form ? self.gifts.not_rejected : self.gifts).collect(&:recipient)
|
|
names << self.new_gifts.collect(&:recipient) if self.new_gifts.present?
|
|
names.flatten.uniq.join(",")
|
|
end
|
|
|
|
def save_new_gifts
|
|
return if self.new_gifts.blank?
|
|
|
|
self.new_gifts.each do |gift|
|
|
next if self.gifts.for_name_or_byline(gift.recipient).present?
|
|
|
|
# Recreate the gift once the work is saved. This ensures the work_id is
|
|
# set properly.
|
|
Gift.create(recipient: gift.recipient, work: self)
|
|
end
|
|
end
|
|
|
|
def marked_for_later?(user)
|
|
Reading.where(work_id: self.id, user_id: user.id, toread: true).exists?
|
|
end
|
|
|
|
########################################################################
|
|
# VISIBILITY
|
|
########################################################################
|
|
|
|
def visible?(user = User.current_user)
|
|
return true if user.is_a?(Admin)
|
|
|
|
if posted && !hidden_by_admin
|
|
user.is_a?(User) || !restricted
|
|
else
|
|
user_is_owner_or_invited?(user)
|
|
end
|
|
end
|
|
|
|
def unrevealed?(user=User.current_user)
|
|
# eventually here is where we check if it's in a challenge that hasn't been made public yet
|
|
#!self.collection_items.unrevealed.empty?
|
|
in_unrevealed_collection?
|
|
end
|
|
|
|
def anonymous?(user = User.current_user)
|
|
# here we check if the story is in a currently-anonymous challenge
|
|
#!self.collection_items.anonymous.empty?
|
|
in_anon_collection?
|
|
end
|
|
|
|
before_update :bust_anon_caching
|
|
def bust_anon_caching
|
|
if in_anon_collection_changed?
|
|
async(:poke_cached_creator_comments)
|
|
end
|
|
end
|
|
|
|
# This work's collections and parent collections
|
|
def all_collections
|
|
Collection.where(id: self.collection_ids) || []
|
|
end
|
|
|
|
########################################################################
|
|
# VERSIONS & REVISION DATES
|
|
########################################################################
|
|
|
|
def set_revised_at(date=nil)
|
|
date ||= self.chapters.where(posted: true).maximum('published_at') ||
|
|
self.revised_at || self.created_at || Time.current
|
|
|
|
if date.instance_of?(Date)
|
|
# We need a time, not a Date. So if the date is today, set it to the
|
|
# current time; otherwise, set it to noon UTC (so that almost every
|
|
# single time zone will have the revised_at date match the published_at
|
|
# date, and those that don't will have revised_at follow published_at).
|
|
date = (date == Date.current) ? Time.current : date.to_time(:utc).noon
|
|
end
|
|
|
|
self.revised_at = date
|
|
end
|
|
|
|
def set_revised_at_by_chapter(chapter)
|
|
# Invalidate chapter count cache
|
|
self.invalidate_work_chapter_count(self)
|
|
return if self.posted? && !chapter.posted?
|
|
|
|
unless self.posted_changed?
|
|
if chapter.posted_changed?
|
|
self.major_version = self.major_version + 1
|
|
else
|
|
self.minor_version = self.minor_version + 1
|
|
end
|
|
end
|
|
|
|
if (self.new_record? || chapter.posted_changed?) && chapter.published_at == Date.current
|
|
self.set_revised_at(Time.current) # a new chapter is being posted, so most recent update is now
|
|
else
|
|
# Calculate the most recent chapter publication date:
|
|
max_date = self.chapters.where('id != ? AND posted = 1', chapter.id).maximum('published_at')
|
|
max_date = max_date.nil? ? chapter.published_at : [max_date, chapter.published_at].max
|
|
|
|
# Update revised_at to match the chapter publication date unless the
|
|
# dates already match:
|
|
set_revised_at(max_date) unless revised_at && revised_at.to_date == max_date
|
|
end
|
|
end
|
|
|
|
# Just to catch any cases that haven't gone through set_revised_at
|
|
def ensure_revised_at
|
|
self.set_revised_at if self.revised_at.nil?
|
|
end
|
|
|
|
def published_at
|
|
self.first_chapter.published_at
|
|
end
|
|
|
|
# ensure published_at date is correct: reset its value for non-backdated works
|
|
# "chapter" arg should be the unsaved session instance of the work's first chapter
|
|
def reset_published_at(chapter)
|
|
if !self.backdate
|
|
if self.backdate_changed? # work was backdated but now it's not
|
|
# so reset its date to our best guess at its original pub date:
|
|
chapter.published_at = self.created_at.to_date
|
|
else # pub date may have changed without user's explicitly setting backdate option
|
|
# so reset it to the previous value:
|
|
chapter.published_at = chapter.published_at_was || Date.current
|
|
end
|
|
end
|
|
end
|
|
|
|
def default_date
|
|
backdate = first_chapter.try(:published_at) if self.backdate
|
|
backdate || Date.current
|
|
end
|
|
|
|
########################################################################
|
|
# SERIES
|
|
########################################################################
|
|
|
|
# Virtual attribute for series
|
|
def series_attributes=(attributes)
|
|
if !attributes[:id].blank?
|
|
old_series = Series.find(attributes[:id])
|
|
if old_series.pseuds.none? { |pseud| pseud.user == User.current_user }
|
|
errors.add(:base, ts("You can't add a work to that series."))
|
|
return
|
|
end
|
|
unless old_series.blank? || self.series.include?(old_series)
|
|
self.serial_works.build(series: old_series)
|
|
end
|
|
elsif !attributes[:title].blank?
|
|
new_series = Series.new
|
|
new_series.title = attributes[:title]
|
|
new_series.restricted = self.restricted
|
|
(User.current_user.pseuds & self.pseuds_after_saving).each do |pseud|
|
|
# Only add the current user's pseuds now -- the after_create callback
|
|
# on the serial work will do the rest.
|
|
new_series.creatorships.build(pseud: pseud)
|
|
end
|
|
self.serial_works.build(series: new_series)
|
|
end
|
|
end
|
|
|
|
# Make sure the series restriction level is in line with its works
|
|
def adjust_series_restriction
|
|
unless self.series.blank?
|
|
self.series.each {|s| s.adjust_restricted }
|
|
end
|
|
end
|
|
|
|
########################################################################
|
|
# CHAPTERS
|
|
########################################################################
|
|
|
|
# Save chapter data when the work is updated
|
|
def save_chapters
|
|
!self.chapters.first.save(validate: false)
|
|
end
|
|
|
|
# If the work is posted, the first chapter should be posted too
|
|
def post_first_chapter
|
|
chapter_one = self.first_chapter
|
|
|
|
return unless self.saved_change_to_posted? && self.posted
|
|
return if chapter_one&.posted
|
|
|
|
chapter_one.published_at = Date.current unless self.backdate
|
|
chapter_one.posted = true
|
|
chapter_one.save
|
|
end
|
|
|
|
# Virtual attribute for first chapter
|
|
def chapter_attributes=(attributes)
|
|
self.new_record? ? self.chapters.build(attributes) : self.chapters.first.attributes = attributes
|
|
self.chapters.first.posted = self.posted
|
|
end
|
|
|
|
# Virtual attribute for # of chapters
|
|
def wip_length
|
|
self.expected_number_of_chapters.nil? ? "?" : self.expected_number_of_chapters
|
|
end
|
|
|
|
def wip_length=(number)
|
|
number = number.to_i
|
|
self.expected_number_of_chapters = (number != 0 && number >= self.chapters.length) ? number : nil
|
|
end
|
|
|
|
# Change the positions of the chapters in the work
|
|
def reorder_list(positions)
|
|
SortableList.new(chapters_in_order(include_drafts: true)).reorder_list(positions)
|
|
# We're caching the chapter positions in the comment blurbs
|
|
# so we need to expire them
|
|
async(:poke_cached_comments)
|
|
end
|
|
|
|
def poke_cached_comments
|
|
self.comments.each { |c| c.touch }
|
|
end
|
|
|
|
def poke_cached_creator_comments
|
|
self.creator_comments.each { |c| c.touch }
|
|
end
|
|
|
|
# Get the total number of chapters for a work
|
|
def number_of_chapters
|
|
Rails.cache.fetch(key_for_chapter_total_counting(self)) do
|
|
self.chapters.count
|
|
end
|
|
end
|
|
|
|
# Get the total number of posted chapters for a work
|
|
# Issue 1316: total number needs to reflect the actual number of chapters posted
|
|
# rather than the total number of chapters indicated by user
|
|
def number_of_posted_chapters
|
|
Rails.cache.fetch(key_for_chapter_posted_counting(self)) do
|
|
self.chapters.posted.count
|
|
end
|
|
end
|
|
|
|
def chapters_in_order(include_drafts: false, include_content: true)
|
|
# in order
|
|
chapters = self.chapters.order('position ASC')
|
|
# only posted chapters unless specified
|
|
chapters = chapters.where(posted: true) unless include_drafts
|
|
# when doing navigation pass false as contents are not needed
|
|
chapters = chapters.select('published_at, id, work_id, title, position, posted') unless include_content
|
|
chapters
|
|
end
|
|
|
|
# Gets the current first chapter
|
|
def first_chapter
|
|
if self.new_record?
|
|
self.chapters.first || self.chapters.build
|
|
else
|
|
self.chapters.order('position ASC').first
|
|
end
|
|
end
|
|
|
|
# Gets the current last chapter
|
|
def last_chapter
|
|
self.chapters.order('position DESC').first
|
|
end
|
|
|
|
# Gets the current last posted chapter
|
|
def last_posted_chapter
|
|
self.chapters.posted.order('position DESC').first
|
|
end
|
|
|
|
# Returns true if a work has or will have more than one chapter
|
|
def chaptered?
|
|
self.expected_number_of_chapters != 1
|
|
end
|
|
|
|
# Returns true if a work has more than one chapter
|
|
def multipart?
|
|
self.number_of_chapters > 1
|
|
end
|
|
|
|
after_save :update_complete_status
|
|
# Note: this can mark a work complete but it can also mark a complete work
|
|
# as incomplete if its status has changed
|
|
def update_complete_status
|
|
# self.chapters.posted.count ( not self.number_of_posted_chapter , here be dragons )
|
|
self.complete = self.chapters.posted.count == expected_number_of_chapters
|
|
if self.will_save_change_to_attribute?(:complete)
|
|
Work.where(id: id).update_all(["complete = ?", complete])
|
|
end
|
|
end
|
|
|
|
# Returns true if a work is not yet complete
|
|
def is_wip
|
|
self.expected_number_of_chapters.nil? || self.expected_number_of_chapters != self.number_of_posted_chapters
|
|
end
|
|
|
|
# Returns true if a work is complete
|
|
def is_complete
|
|
return !self.is_wip
|
|
end
|
|
|
|
# Set the value of word_count to reflect the length of the chapter content
|
|
# Called before_save
|
|
def set_word_count(preview = false)
|
|
if self.new_record? || preview
|
|
self.word_count = 0
|
|
chapters.each do |chapter|
|
|
self.word_count += chapter.set_word_count
|
|
end
|
|
else
|
|
# AO3-3498: For posted works, the word count is visible to people other than the creator and
|
|
# should only include posted chapters. For drafts, we can count everything.
|
|
self.word_count = if self.posted
|
|
Chapter.select("SUM(word_count) AS work_word_count").where(work_id: self.id, posted: true).first.work_word_count
|
|
else
|
|
Chapter.select("SUM(word_count) AS work_word_count").where(work_id: self.id).first.work_word_count
|
|
end
|
|
end
|
|
end
|
|
|
|
#######################################################################
|
|
# TAGGING
|
|
# Works are taggable objects.
|
|
#######################################################################
|
|
|
|
# When the filters on a work change, we need to perform some extra checks.
|
|
def self.reindex_for_filter_changes(ids, filter_taggings, queue)
|
|
# The crossover/OTP status of a work can change without actually changing
|
|
# the filters (e.g. if you have a work tagged with canonical fandom A and
|
|
# unfilterable fandom B, synning B to A won't change the work's filters,
|
|
# but the work will immediately stop qualifying as a crossover). So we want
|
|
# to reindex all works whose filters were checked, not just the works that
|
|
# had their filters changed.
|
|
IndexQueue.enqueue_ids(Work, ids, queue)
|
|
|
|
# Only works are included in the filter count, so if a work's
|
|
# filter-taggings change, the FilterCount probably needs updating.
|
|
FilterCount.enqueue_filters(filter_taggings.map(&:filter_id))
|
|
|
|
# From here, we only want to update works whose filter_taggings have
|
|
# actually changed.
|
|
changed_ids = filter_taggings.map(&:filterable_id)
|
|
return unless changed_ids.present?
|
|
|
|
# Reindex any series associated with works whose filters have changed.
|
|
series_ids = SerialWork.where(work_id: changed_ids).pluck(:series_id)
|
|
IndexQueue.enqueue_ids(Series, series_ids, queue)
|
|
|
|
# Reindex any pseuds associated with works whose filters have changed.
|
|
pseud_ids = Creatorship.where(creation_id: changed_ids,
|
|
creation_type: "Work",
|
|
approved: true).pluck(:pseud_id)
|
|
IndexQueue.enqueue_ids(Pseud, pseud_ids, queue)
|
|
end
|
|
|
|
# FILTERING CALLBACKS
|
|
after_save :adjust_filter_counts
|
|
|
|
# We need to do a recount for our filters if:
|
|
# - the work is brand new
|
|
# - the work is posted from a draft
|
|
# - the work is hidden or unhidden by an admin
|
|
# - the work's restricted status has changed
|
|
# Note that because the two filter counts both include unrevealed works, we
|
|
# don't need to check whether in_unrevealed_collection has changed -- it
|
|
# won't change the counts either way.
|
|
# (Modelled on Work.should_reindex_pseuds?)
|
|
def should_reset_filters?
|
|
pertinent_attributes = %w(id posted restricted hidden_by_admin)
|
|
(saved_changes.keys & pertinent_attributes).present?
|
|
end
|
|
|
|
# Recalculates filter counts on all the work's filters
|
|
def adjust_filter_counts
|
|
FilterCount.enqueue_filters(filters.reload) if should_reset_filters?
|
|
end
|
|
|
|
################################################################################
|
|
# COMMENTING & BOOKMARKS
|
|
# We don't actually have comments on works currently but on chapters.
|
|
# Comment support -- work acts as a commentable object even though really we
|
|
# override to consolidate the comments on all the chapters.
|
|
################################################################################
|
|
|
|
# Gets all comments for all chapters in the work
|
|
def find_all_comments
|
|
Comment.where(
|
|
parent_type: 'Chapter',
|
|
parent_id: self.chapters.pluck(:id)
|
|
)
|
|
end
|
|
|
|
# Returns number of comments
|
|
# Hidden and deleted comments are referenced in the view because of
|
|
# the threading system - we don't necessarily need to
|
|
# hide their existence from other users
|
|
def count_all_comments
|
|
find_all_comments.count
|
|
end
|
|
|
|
# Count the number of comment threads visible to the user (i.e. excluding
|
|
# threads that have been marked as spam). Used on the work stats page.
|
|
def comment_thread_count
|
|
comments.where(approved: true).count
|
|
end
|
|
|
|
# returns the top-level comments for all chapters in the work
|
|
def comments
|
|
Comment.where(
|
|
commentable_type: 'Chapter',
|
|
commentable_id: self.chapters.pluck(:id)
|
|
)
|
|
end
|
|
|
|
# All comments left by the creators of this work
|
|
def creator_comments
|
|
pseud_ids = Pseud.where(user_id: self.pseuds.pluck(:user_id)).pluck(:id)
|
|
find_all_comments.where(pseud_id: pseud_ids)
|
|
end
|
|
|
|
def guest_kudos_count
|
|
Rails.cache.fetch "works/#{id}/guest_kudos_count-v2" do
|
|
kudos.by_guest.count
|
|
end
|
|
end
|
|
|
|
def all_kudos_count
|
|
Rails.cache.fetch "works/#{id}/kudos_count-v2" do
|
|
kudos.count
|
|
end
|
|
end
|
|
|
|
def update_stat_counter
|
|
counter = self.stat_counter || self.create_stat_counter
|
|
counter.update(
|
|
kudos_count: self.kudos.count,
|
|
comments_count: self.count_visible_comments_uncached,
|
|
bookmarks_count: self.bookmarks.where(private: false).count
|
|
)
|
|
end
|
|
|
|
########################################################################
|
|
# RELATED WORKS
|
|
# These are for inspirations/remixes/etc
|
|
########################################################################
|
|
|
|
def parents_after_saving
|
|
parent_work_relationships.reject(&:marked_for_destruction?)
|
|
end
|
|
|
|
def touch_related_works
|
|
return unless saved_change_to_in_unrevealed_collection?
|
|
|
|
# Make sure download URLs of child and parent works expire to preserve anonymity.
|
|
children.touch_all
|
|
parents_after_saving.each { |rw| rw.parent.touch }
|
|
end
|
|
|
|
#################################################################################
|
|
#
|
|
# In this section we define various named scopes that can be chained together
|
|
# to do finds in the database
|
|
#
|
|
#################################################################################
|
|
|
|
public
|
|
|
|
scope :id_only, -> { select("works.id") }
|
|
|
|
scope :ordered_by_title_desc, -> { order("title_to_sort_on DESC") }
|
|
scope :ordered_by_title_asc, -> { order("title_to_sort_on ASC") }
|
|
scope :ordered_by_word_count_desc, -> { order("word_count DESC") }
|
|
scope :ordered_by_word_count_asc, -> { order("word_count ASC") }
|
|
scope :ordered_by_hit_count_desc, -> { order("hit_count DESC") }
|
|
scope :ordered_by_hit_count_asc, -> { order("hit_count ASC") }
|
|
scope :ordered_by_date_desc, -> { order("revised_at DESC") }
|
|
scope :ordered_by_date_asc, -> { order("revised_at ASC") }
|
|
|
|
scope :recent, lambda { |*args| where("revised_at > ?", (args.first || 4.weeks.ago.to_date)) }
|
|
scope :within_date_range, lambda { |*args| where("revised_at BETWEEN ? AND ?", (args.first || 4.weeks.ago), (args.last || Time.now)) }
|
|
scope :posted, -> { where(posted: true) }
|
|
scope :unposted, -> { where(posted: false) }
|
|
scope :not_spam, -> { where(spam: false) }
|
|
scope :restricted , -> { where(restricted: true) }
|
|
scope :unrestricted, -> { where(restricted: false) }
|
|
scope :hidden, -> { where(hidden_by_admin: true) }
|
|
scope :unhidden, -> { where(hidden_by_admin: false) }
|
|
scope :visible_to_all, -> { posted.unrestricted.unhidden }
|
|
scope :visible_to_registered_user, -> { posted.unhidden }
|
|
scope :visible_to_admin, -> { posted }
|
|
scope :visible_to_owner, -> { posted }
|
|
scope :all_with_tags, -> { includes(:tags) }
|
|
|
|
scope :giftworks_for_recipient_name, lambda { |name| select("DISTINCT works.*").joins(:gifts).where("recipient_name = ?", name).where("gifts.rejected = FALSE") }
|
|
|
|
scope :non_anon, -> { where(in_anon_collection: false) }
|
|
scope :unrevealed, -> { where(in_unrevealed_collection: true) }
|
|
scope :revealed, -> { where(in_unrevealed_collection: false) }
|
|
scope :latest, -> { visible_to_all.
|
|
revealed.
|
|
order("revised_at DESC").
|
|
limit(ArchiveConfig.ITEMS_PER_PAGE) }
|
|
|
|
# a complicated dynamic scope here:
|
|
# if the user is an Admin, we use the "visible_to_admin" scope
|
|
# if the user is not a logged-in User, we use the "visible_to_all" scope
|
|
# otherwise, we use a join to get userids and then get all posted works that are either unhidden OR belong to this user.
|
|
# Note: in that last case we have to use select("DISTINCT works.") because of cases where the same user appears twice
|
|
# on a work.
|
|
def self.visible_to_user(user=User.current_user)
|
|
case user.class.to_s
|
|
when 'Admin'
|
|
visible_to_admin
|
|
when 'User'
|
|
select("DISTINCT works.*").
|
|
posted.
|
|
joins({pseuds: :user}).
|
|
where("works.hidden_by_admin = false OR users.id = ?", user.id)
|
|
else
|
|
visible_to_all
|
|
end
|
|
end
|
|
|
|
# Use the current user to determine what works are visible
|
|
def self.visible(user=User.current_user)
|
|
visible_to_user(user)
|
|
end
|
|
|
|
scope :owned_by, lambda {|user| select("DISTINCT works.*").joins({pseuds: :user}).where('users.id = ?', user.id)}
|
|
|
|
def self.in_series(series)
|
|
joins(:series).
|
|
where("series.id = ?", series.id)
|
|
end
|
|
|
|
scope :with_columns_for_blurb, lambda {
|
|
select(:id, :created_at, :updated_at, :expected_number_of_chapters,
|
|
:posted, :language_id, :restricted, :title, :summary, :word_count,
|
|
:hidden_by_admin, :revised_at, :complete, :in_anon_collection,
|
|
:in_unrevealed_collection, :summary_sanitizer_version)
|
|
}
|
|
|
|
scope :with_includes_for_blurb, lambda {
|
|
includes(:pseuds, :approved_collections, :stat_counter)
|
|
}
|
|
|
|
scope :for_blurb, -> { with_columns_for_blurb.with_includes_for_blurb }
|
|
|
|
########################################################################
|
|
# SORTING
|
|
########################################################################
|
|
|
|
SORTED_AUTHOR_REGEX = %r{^[\+\-=_\?!'"\.\/]}
|
|
|
|
def authors_to_sort_on
|
|
if self.anonymous?
|
|
"Anonymous"
|
|
else
|
|
self.pseuds.sort.map(&:name).join(", ").downcase.gsub(SORTED_AUTHOR_REGEX, '')
|
|
end
|
|
end
|
|
|
|
def sorted_title
|
|
sorted_title = self.title.downcase.gsub(/^["'\.\/]/, '')
|
|
sorted_title = sorted_title.gsub(/^(an?) (.*)/, '\2, \1')
|
|
sorted_title = sorted_title.gsub(/^the (.*)/, '\1, the')
|
|
sorted_title = sorted_title.rjust(5, "0") if sorted_title.match(/^\d/)
|
|
sorted_title
|
|
end
|
|
|
|
# sort works by title
|
|
def <=>(another_work)
|
|
self.title_to_sort_on <=> another_work.title_to_sort_on
|
|
end
|
|
|
|
########################################################################
|
|
# SPAM CHECKING
|
|
########################################################################
|
|
|
|
def akismet_attributes
|
|
content = chapters_in_order(include_drafts: true).map(&:content).join
|
|
user = users.first
|
|
{
|
|
comment_type: "fanwork-post",
|
|
key: ArchiveConfig.AKISMET_KEY,
|
|
blog: ArchiveConfig.AKISMET_NAME,
|
|
user_ip: ip_address,
|
|
user_role: "user",
|
|
comment_date_gmt: created_at.to_time.iso8601,
|
|
blog_lang: language.short,
|
|
comment_author: user.login,
|
|
comment_author_email: user.email,
|
|
comment_content: content
|
|
}
|
|
end
|
|
|
|
def spam_checked?
|
|
spam_checked_at.present?
|
|
end
|
|
|
|
def check_for_spam
|
|
return unless %w(staging production).include?(Rails.env)
|
|
self.spam = Akismetor.spam?(akismet_attributes)
|
|
self.spam_checked_at = Time.now
|
|
save
|
|
end
|
|
|
|
def hide_spam
|
|
return unless spam?
|
|
admin_settings = AdminSetting.current
|
|
if admin_settings.hide_spam?
|
|
return if self.hidden_by_admin
|
|
|
|
self.hidden_by_admin = true
|
|
notify_of_hiding_for_spam
|
|
end
|
|
end
|
|
|
|
def moderate_spam
|
|
ModeratedWork.register(self) if spam?
|
|
end
|
|
|
|
def mark_as_spam!
|
|
update_attribute(:spam, true)
|
|
ModeratedWork.mark_reviewed(self)
|
|
# don't submit spam reports unless in production mode
|
|
Rails.env.production? && Akismetor.submit_spam(akismet_attributes)
|
|
end
|
|
|
|
def mark_as_ham!
|
|
update(spam: false, hidden_by_admin: false)
|
|
ModeratedWork.mark_approved(self)
|
|
# don't submit ham reports unless in production mode
|
|
Rails.env.production? && Akismetor.submit_ham(akismet_attributes)
|
|
end
|
|
|
|
def notify_of_hiding
|
|
return unless hidden_by_admin? && saved_change_to_hidden_by_admin?
|
|
return if notified_of_hiding_for_spam
|
|
|
|
users.each do |user|
|
|
I18n.with_locale(user.preference.locale_for_mails) do
|
|
UserMailer.admin_hidden_work_notification([id], user.id).deliver_after_commit
|
|
end
|
|
end
|
|
end
|
|
|
|
def notify_of_hiding_for_spam
|
|
users.each do |user|
|
|
I18n.with_locale(user.preference.locale_for_mails) do
|
|
UserMailer.admin_spam_work_notification(id, user.id).deliver_after_commit
|
|
end
|
|
end
|
|
self.notified_of_hiding_for_spam = true
|
|
end
|
|
|
|
#############################################################################
|
|
#
|
|
# SEARCH INDEX
|
|
#
|
|
#############################################################################
|
|
|
|
def document_json
|
|
WorkIndexer.new({}).document(self)
|
|
end
|
|
|
|
def bookmarkable_json
|
|
as_json(
|
|
root: false,
|
|
only: [
|
|
:title, :summary, :hidden_by_admin, :restricted, :posted,
|
|
:created_at, :revised_at, :word_count, :complete
|
|
],
|
|
methods: [
|
|
:tag, :filter_ids, :rating_ids, :archive_warning_ids, :category_ids,
|
|
:fandom_ids, :character_ids, :relationship_ids, :freeform_ids,
|
|
:creators, :collection_ids, :work_types
|
|
]
|
|
).merge(
|
|
language_id: language&.short,
|
|
anonymous: anonymous?,
|
|
unrevealed: unrevealed?,
|
|
pseud_ids: anonymous? || unrevealed? ? nil : pseud_ids,
|
|
user_ids: anonymous? || unrevealed? ? nil : user_ids,
|
|
bookmarkable_type: 'Work',
|
|
bookmarkable_join: { name: "bookmarkable" }
|
|
)
|
|
end
|
|
|
|
def collection_ids
|
|
approved_collections.pluck(:id, :parent_id).flatten.uniq.compact
|
|
end
|
|
|
|
delegate :comments_count, :kudos_count, :bookmarks_count,
|
|
to: :stat_counter, allow_nil: true
|
|
|
|
def hits
|
|
stat_counter&.hit_count
|
|
end
|
|
|
|
def creators
|
|
if anonymous?
|
|
["Anonymous"]
|
|
else
|
|
pseuds.map(&:byline) + external_author_names.pluck(:name)
|
|
end
|
|
end
|
|
|
|
# A work with multiple fandoms which are not related
|
|
# to one another can be considered a crossover
|
|
def crossover
|
|
# Short-circuit the check if there's only one fandom tag:
|
|
return false if fandoms.size == 1
|
|
|
|
# Replace fandoms with their mergers if possible,
|
|
# as synonyms should have no meta tags themselves
|
|
all_without_syns = fandoms.map { |f| f.merger || f }.uniq
|
|
|
|
# For each fandom, find the set of all meta tags for that fandom (including
|
|
# the fandom itself).
|
|
meta_tag_groups = all_without_syns.map do |f|
|
|
# TODO: This is more complicated than it has to be. Once the
|
|
# meta_taggings table is fixed so that the inherited meta-tags are
|
|
# correctly calculated, this can be simplified.
|
|
boundary = [f] + f.meta_tags
|
|
all_meta_tags = []
|
|
|
|
until boundary.empty?
|
|
all_meta_tags.concat(boundary)
|
|
boundary = boundary.flat_map(&:meta_tags).uniq - all_meta_tags
|
|
end
|
|
|
|
all_meta_tags.uniq
|
|
end
|
|
|
|
# Two fandoms are "related" if they share at least one meta tag. A work is
|
|
# considered a crossover if there is no single fandom on the work that all
|
|
# the other fandoms on the work are "related" to.
|
|
meta_tag_groups.none? do |meta_tags1|
|
|
meta_tag_groups.all? do |meta_tags2|
|
|
(meta_tags1 & meta_tags2).any?
|
|
end
|
|
end
|
|
end
|
|
|
|
# Does this work have only one relationship tag?
|
|
# (not counting synonyms)
|
|
def otp
|
|
return true if relationships.size == 1
|
|
|
|
all_without_syns = relationships.map { |r| r.merger_id || r.id }
|
|
.uniq
|
|
all_without_syns.count == 1
|
|
end
|
|
|
|
# Quick and dirty categorization of the most obvious stuff
|
|
# To be replaced by actual categories
|
|
def work_types
|
|
types = []
|
|
video_ids = [44011] # Video
|
|
audio_ids = [70308, 1098169] # Podfic, Audio Content
|
|
art_ids = [7844, 125758, 3863] # Fanart, Arts
|
|
types << "Video" if (filter_ids & video_ids).present?
|
|
types << "Audio" if (filter_ids & audio_ids).present?
|
|
types << "Art" if (filter_ids & art_ids).present?
|
|
# Very arbitrary cut off here, but wanted to make sure we
|
|
# got fic + art/podfic/video tagged as text as well
|
|
if types.empty? || (word_count && word_count > 200)
|
|
types << "Text"
|
|
end
|
|
types
|
|
end
|
|
|
|
# To be replaced by actual category
|
|
# Can't use the 'Meta' tag since that has too many different uses
|
|
def nonfiction
|
|
nonfiction_tags = [125773, 66586, 123921, 747397] # Essays, Nonfiction, Reviews, Reference
|
|
(filter_ids & nonfiction_tags).present?
|
|
end
|
|
|
|
# Determines if this work allows invitations to collections,
|
|
# meaning that at least one of the creators has opted-in.
|
|
def allow_collection_invitation?
|
|
users.any? { |user| user.preference.allow_collection_invitation }
|
|
end
|
|
|
|
private
|
|
|
|
def challenge_bypass(gift)
|
|
self.challenge_assignments.map(&:requesting_pseud).include?(gift.pseud) ||
|
|
self.challenge_claims
|
|
.reject { |c| c.request_prompt.anonymous? }
|
|
.map(&:requesting_pseud)
|
|
.include?(gift.pseud)
|
|
end
|
|
end
|