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