otwcode/otwarchive

View on GitHub
app/models/work.rb

Summary

Maintainability
F
5 days
Test Coverage
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_many :work_links, dependent: :destroy
  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

  # 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: 1

  ########################################################################
  # 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
    if self.posted?
      users = self.pseuds.collect(&:user).uniq
      orphan_account = User.orphan_account
      unless users.blank?
        for user in users
          next if user == orphan_account
          # 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).deliver_now
          end
        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, we should (potentially) reindex the tags,
  # so they get the correct draft-only status.
  def update_tag_index
    return unless saved_change_to_posted?
    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?

    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,
      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?
      self.hidden_by_admin = true
    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?
    users.each do |user|
      if spam?
        UserMailer.admin_spam_work_notification(id, user.id).deliver_after_commit
      else
        UserMailer.admin_hidden_work_notification(id, user.id).deliver_after_commit
      end
    end
  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 ? r.merger : r }.uniq.compact
    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