When searching posts by width, height, file size, or file extension, use the values from the media_assets table rather than the posts table. This makes filetype: searches faster because the file_ext is indexed on the media assets table, but not on the posts table. This paves the way for getting rid of the width, height, file_size, and file_ext indexes on the posts table in the future. It's wasteful to index these columns on both the posts table and the media assets table.
1617 lines
51 KiB
Ruby
1617 lines
51 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
class Post < ApplicationRecord
|
|
class RevertError < StandardError; end
|
|
class DeletionError < StandardError; end
|
|
|
|
# Tags to copy when copying notes.
|
|
NOTE_COPY_TAGS = %w[translated partially_translated check_translation translation_request reverse_translation
|
|
annotated partially_annotated check_annotation annotation_request]
|
|
|
|
RESTRICTED_TAGS_REGEX = /(?:^| )(?:#{Danbooru.config.restricted_tags.join("|")})(?:$| )/o
|
|
|
|
RATINGS = {
|
|
g: "General",
|
|
s: "Sensitive",
|
|
q: "Questionable",
|
|
e: "Explicit",
|
|
}.with_indifferent_access
|
|
|
|
RATING_ALIASES = {
|
|
safe: ["s"],
|
|
nsfw: ["q", "e"],
|
|
sfw: ["g", "s"],
|
|
}.with_indifferent_access
|
|
|
|
deletable
|
|
has_bit_flags %w[has_embedded_notes _unused_has_cropped is_taken_down]
|
|
|
|
normalize :source, :normalize_source
|
|
before_validation :merge_old_changes
|
|
before_validation :apply_pre_metatags
|
|
before_validation :normalize_tags
|
|
before_validation :blank_out_nonexistent_parents
|
|
before_validation :remove_parent_loops
|
|
validates :md5, uniqueness: { message: ->(post, _data) { "Duplicate of post ##{Post.find_by_md5(post.md5).id}" }}, on: :create
|
|
validates :rating, presence: { message: "not selected" }
|
|
validates :rating, inclusion: { in: RATINGS.keys, message: "must be #{RATINGS.keys.map(&:upcase).to_sentence(last_word_connector: ", or ")}" }, if: -> { rating.present? }
|
|
validates :source, length: { maximum: 1200 }
|
|
validate :post_is_not_its_own_parent
|
|
validate :uploader_is_not_limited, on: :create
|
|
before_save :parse_pixiv_id
|
|
before_save :added_tags_are_valid
|
|
before_save :removed_tags_are_valid
|
|
before_save :has_artist_tag
|
|
before_save :has_copyright_tag
|
|
before_save :has_enough_tags
|
|
before_save :update_tag_post_counts
|
|
before_save :update_tag_category_counts
|
|
before_create :autoban
|
|
after_save :create_version
|
|
after_save :update_parent_on_save
|
|
after_save :apply_post_metatags
|
|
after_create_commit :update_iqdb
|
|
|
|
belongs_to :approver, class_name: "User", optional: true
|
|
belongs_to :uploader, :class_name => "User", :counter_cache => "post_upload_count"
|
|
belongs_to :parent, class_name: "Post", optional: true
|
|
has_one :media_asset, -> { active }, foreign_key: :md5, primary_key: :md5
|
|
has_one :media_metadata, through: :media_asset
|
|
has_one :artist_commentary, :dependent => :destroy
|
|
has_one :vote_by_current_user, -> { active.where(user_id: CurrentUser.id) }, class_name: "PostVote" # XXX using current user here is wrong
|
|
has_many :flags, :class_name => "PostFlag", :dependent => :destroy
|
|
has_many :appeals, :class_name => "PostAppeal", :dependent => :destroy
|
|
has_many :votes, :class_name => "PostVote", :dependent => :destroy
|
|
has_many :notes, :dependent => :destroy
|
|
has_many :comments, :dependent => :destroy
|
|
has_many :children, -> {order("posts.id")}, :class_name => "Post", :foreign_key => "parent_id"
|
|
has_many :approvals, :class_name => "PostApproval", :dependent => :destroy
|
|
has_many :disapprovals, :class_name => "PostDisapproval", :dependent => :destroy
|
|
has_many :favorites, dependent: :destroy
|
|
has_many :replacements, class_name: "PostReplacement", :dependent => :destroy
|
|
has_many :ai_tags, through: :media_asset
|
|
has_many :events, class_name: "PostEvent"
|
|
has_many :mod_actions, as: :subject, dependent: :destroy
|
|
|
|
attr_accessor :old_tag_string, :old_parent_id, :old_source, :old_rating, :has_constraints, :disable_versioning, :post_edit
|
|
|
|
scope :pending, -> { where(is_pending: true) }
|
|
scope :flagged, -> { where(is_flagged: true) }
|
|
scope :banned, -> { where(is_banned: true) }
|
|
# XXX conflict with deletable
|
|
scope :active, -> { where(is_pending: false, is_deleted: false, is_flagged: false).where.not(id: PostAppeal.pending) }
|
|
scope :appealed, -> { where(id: PostAppeal.pending.select(:post_id)) }
|
|
scope :in_modqueue, -> { where_union(pending, flagged, appealed) }
|
|
scope :expired, -> { pending.where("posts.created_at < ?", Danbooru.config.moderation_period.ago) }
|
|
|
|
scope :unflagged, -> { where(is_flagged: false) }
|
|
scope :has_notes, -> { where.not(last_noted_at: nil) }
|
|
scope :for_user, ->(user_id) { where(uploader_id: user_id) }
|
|
|
|
if PostVersion.enabled?
|
|
has_many :versions, -> { Rails.env.test? ? order("post_versions.updated_at ASC, post_versions.id ASC") : order("post_versions.updated_at ASC") }, class_name: "PostVersion", dependent: :destroy
|
|
end
|
|
|
|
def self.new_from_upload(upload_media_asset, tag_string: nil, rating: nil, parent_id: nil, source: nil, artist_commentary_title: nil, artist_commentary_desc: nil, translated_commentary_title: nil, translated_commentary_desc: nil, is_pending: nil, add_artist_tag: false)
|
|
upload = upload_media_asset.upload
|
|
media_asset = upload_media_asset.media_asset
|
|
|
|
# XXX depends on CurrentUser
|
|
commentary = ArtistCommentary.new(
|
|
original_title: artist_commentary_title,
|
|
original_description: artist_commentary_desc,
|
|
translated_title: translated_commentary_title,
|
|
translated_description: translated_commentary_desc,
|
|
)
|
|
|
|
if add_artist_tag
|
|
tag_string = "#{tag_string} #{upload_media_asset.source_extractor&.artists.to_a.map(&:tag).map(&:name).join(" ")}".strip
|
|
tag_string += " " if tag_string.present?
|
|
end
|
|
|
|
post = Post.new(
|
|
uploader: upload.uploader,
|
|
md5: media_asset&.md5,
|
|
file_ext: media_asset&.file_ext,
|
|
file_size: media_asset&.file_size,
|
|
image_width: media_asset&.image_width,
|
|
image_height: media_asset&.image_height,
|
|
source: source.to_s,
|
|
tag_string: tag_string,
|
|
rating: rating,
|
|
parent_id: parent_id,
|
|
is_pending: !upload.uploader.is_contributor? || is_pending.to_s.truthy?,
|
|
artist_commentary: (commentary if commentary.any_field_present?),
|
|
)
|
|
end
|
|
|
|
concerning :FileMethods do
|
|
def seo_tags
|
|
presenter.humanized_essential_tag_string.gsub(/[^a-z0-9]+/, "_").gsub(/(?:^_+)|(?:_+$)/, "").gsub(/_{2,}/, "_")
|
|
end
|
|
|
|
def file(type = :original)
|
|
media_asset.variant(type).open_file!
|
|
end
|
|
|
|
def tagged_file_url(tagged_filenames: !CurrentUser.user.disable_tagged_filenames?)
|
|
slug = seo_tags if tagged_filenames
|
|
media_asset.variant(:original).file_url(slug)
|
|
end
|
|
|
|
def tagged_large_file_url(tagged_filenames: !CurrentUser.user.disable_tagged_filenames?)
|
|
slug = seo_tags if tagged_filenames
|
|
|
|
if media_asset.has_variant?(:sample)
|
|
media_asset.variant(:sample).file_url(slug)
|
|
else
|
|
media_asset.variant(:original).file_url(slug)
|
|
end
|
|
end
|
|
|
|
def file_url
|
|
media_asset.variant(:original).file_url
|
|
end
|
|
|
|
def large_file_url
|
|
if media_asset.has_variant?(:sample)
|
|
media_asset.variant(:sample).file_url
|
|
else
|
|
media_asset.variant(:original).file_url
|
|
end
|
|
end
|
|
|
|
def preview_file_url
|
|
# XXX hack to return placeholder thumbnail for Flash files the /posts.json API.
|
|
return Danbooru.config.storage_manager.file_url("/images/flash-preview.png") if media_asset.is_flash?
|
|
media_asset.variant(:preview).file_url
|
|
end
|
|
|
|
def open_graph_image_url
|
|
if is_image?
|
|
if has_large?
|
|
large_file_url
|
|
else
|
|
file_url
|
|
end
|
|
else
|
|
preview_file_url
|
|
end
|
|
end
|
|
|
|
def file_url_for(user)
|
|
if user.default_image_size == "large" && image_width > Danbooru.config.large_image_width
|
|
tagged_large_file_url
|
|
else
|
|
tagged_file_url
|
|
end
|
|
end
|
|
|
|
def is_image?
|
|
file_ext.in?(%w[jpg gif png webp avif])
|
|
end
|
|
|
|
def is_flash?
|
|
file_ext =~ /swf/i
|
|
end
|
|
|
|
def is_video?
|
|
file_ext.in?(%w[webm mp4])
|
|
end
|
|
|
|
def is_ugoira?
|
|
file_ext =~ /zip/i
|
|
end
|
|
|
|
def has_preview?
|
|
is_image? || is_video? || is_ugoira?
|
|
end
|
|
end
|
|
|
|
concerning :ImageMethods do
|
|
def twitter_card_supported?
|
|
image_width.to_i >= 280 && image_height.to_i >= 150
|
|
end
|
|
|
|
def has_large?
|
|
return false if has_tag?("animated_gif") || has_tag?("animated_png")
|
|
return true if is_ugoira?
|
|
is_image? && image_width.present? && image_width > Danbooru.config.large_image_width
|
|
end
|
|
|
|
alias has_large has_large?
|
|
|
|
def large_image_width
|
|
if has_large?
|
|
[Danbooru.config.large_image_width, image_width.to_i].min
|
|
else
|
|
image_width.to_i
|
|
end
|
|
end
|
|
|
|
def large_image_height
|
|
ratio = Danbooru.config.large_image_width.to_f / image_width.to_f
|
|
if has_large? && ratio < 1
|
|
(image_height * ratio).to_i
|
|
else
|
|
image_height
|
|
end
|
|
end
|
|
|
|
def image_width_for(user)
|
|
if user.default_image_size == "large"
|
|
large_image_width
|
|
else
|
|
image_width
|
|
end
|
|
end
|
|
|
|
def image_height_for(user)
|
|
if user.default_image_size == "large"
|
|
large_image_height
|
|
else
|
|
image_height
|
|
end
|
|
end
|
|
|
|
def resize_percentage
|
|
return 100 if image_width.to_i == 0
|
|
100 * large_image_width.to_f / image_width.to_f
|
|
end
|
|
|
|
# XXX
|
|
def current_image_size
|
|
has_large? && CurrentUser.default_image_size == "large" ? "large" : "original"
|
|
end
|
|
end
|
|
|
|
concerning :ApprovalMethods do
|
|
def in_modqueue?
|
|
is_pending? || is_flagged? || is_appealed?
|
|
end
|
|
|
|
def is_active?
|
|
!is_deleted? && !in_modqueue?
|
|
end
|
|
|
|
def is_appealed?
|
|
is_deleted? && appeals.any?(&:pending?)
|
|
end
|
|
|
|
def is_appealable?
|
|
is_deleted? && !is_appealed?
|
|
end
|
|
|
|
def is_approvable?(user = CurrentUser.user)
|
|
!is_active? && uploader != user
|
|
end
|
|
|
|
def autoban
|
|
if has_tag?("banned_artist") || has_tag?("paid_reward")
|
|
self.is_banned = true
|
|
end
|
|
end
|
|
end
|
|
|
|
concerning :PresenterMethods do
|
|
def presenter
|
|
@presenter ||= PostPresenter.new(self)
|
|
end
|
|
|
|
def status_flags
|
|
flags = []
|
|
flags << "pending" if is_pending?
|
|
flags << "flagged" if is_flagged?
|
|
flags << "deleted" if is_deleted?
|
|
flags << "banned" if is_banned?
|
|
flags.join(" ")
|
|
end
|
|
|
|
# g => 0, s => 1, q => 2, e => 3
|
|
def rating_id
|
|
RATINGS.keys.index(rating)
|
|
end
|
|
|
|
def pretty_rating
|
|
RATINGS.fetch(rating)
|
|
end
|
|
|
|
def parsed_source
|
|
Source::URL.parse(source) if web_source?
|
|
end
|
|
|
|
def normalized_source
|
|
parsed_source&.page_url || source
|
|
end
|
|
|
|
def source_domain
|
|
parsed_source&.domain.to_s
|
|
end
|
|
end
|
|
|
|
concerning :TagMethods do
|
|
def tag_array
|
|
tag_string.split
|
|
end
|
|
|
|
def tag_array_was
|
|
tag_string_was.split
|
|
end
|
|
|
|
def tags
|
|
Tag.where(name: tag_array)
|
|
end
|
|
|
|
def tags_was
|
|
Tag.where(name: tag_array_was)
|
|
end
|
|
|
|
def added_tags
|
|
tags - tags_was
|
|
end
|
|
|
|
def decrement_tag_post_counts
|
|
Tag.where(:name => tag_array).update_all("post_count = post_count - 1") if tag_array.any?
|
|
end
|
|
|
|
def update_tag_post_counts
|
|
decrement_tags = tag_array_was - tag_array
|
|
|
|
increment_tags = tag_array - tag_array_was
|
|
if increment_tags.any?
|
|
Tag.increment_post_counts(increment_tags)
|
|
end
|
|
if decrement_tags.any?
|
|
Tag.decrement_post_counts(decrement_tags)
|
|
end
|
|
end
|
|
|
|
# Update tag_count_general, tag_count_copyright, etc.
|
|
def update_tag_category_counts
|
|
TagCategory.categories.each do |category_name|
|
|
tag_count = tags.select { |t| t.category_name.downcase == category_name }.size
|
|
send("tag_count_#{category_name}=", tag_count)
|
|
end
|
|
|
|
self.tag_count = tag_array.size
|
|
end
|
|
|
|
def merge_old_changes
|
|
if old_parent_id == ""
|
|
old_parent_id = nil
|
|
else
|
|
old_parent_id = old_parent_id.to_i
|
|
end
|
|
if old_parent_id == parent_id
|
|
self.parent_id = parent_id_before_last_save || parent_id_was
|
|
end
|
|
|
|
if old_source == source.to_s
|
|
self.source = source_before_last_save || source_was
|
|
end
|
|
|
|
if old_rating == rating
|
|
self.rating = rating_before_last_save || rating_was
|
|
end
|
|
|
|
@post_edit = PostEdit.new(self, tag_string_was, old_tag_string || tag_string_was, tag_string)
|
|
end
|
|
|
|
def normalize_tags
|
|
self.tag_string = Tag.create_for_list(post_edit.tag_names).uniq.sort.join(" ")
|
|
end
|
|
|
|
def add_automatic_tags(tags)
|
|
tags -= %w[incredibly_absurdres absurdres highres lowres flash video ugoira animated_gif animated_png exif_rotation non-repeating_animation non-web_source wide_image tall_image]
|
|
|
|
if tags.size >= 30
|
|
tags -= ["tagme"]
|
|
elsif tags.empty?
|
|
tags << "tagme"
|
|
end
|
|
|
|
if image_width >= 10_000 || image_height >= 10_000
|
|
tags << "incredibly_absurdres"
|
|
end
|
|
if image_width >= 3200 || image_height >= 2400
|
|
tags << "absurdres"
|
|
end
|
|
if image_width >= 1600 || image_height >= 1200
|
|
tags << "highres"
|
|
end
|
|
if image_width <= 500 && image_height <= 500
|
|
tags << "lowres"
|
|
end
|
|
|
|
if image_width >= 1024 && image_width.to_f / image_height >= 4
|
|
tags << "wide_image"
|
|
elsif image_height >= 1024 && image_height.to_f / image_width >= 4
|
|
tags << "tall_image"
|
|
end
|
|
|
|
if is_flash?
|
|
tags << "flash"
|
|
end
|
|
|
|
if is_video?
|
|
tags << "video"
|
|
end
|
|
|
|
if is_ugoira?
|
|
tags << "ugoira"
|
|
end
|
|
|
|
if source.present? && !web_source?
|
|
tags << "non-web_source"
|
|
end
|
|
|
|
source_url = parsed_source
|
|
if source_url.present? && source_url.recognized?
|
|
# A bad_link is an image URL from a recognized site that can't be converted to a page URL.
|
|
if source_url.image_url? && source_url.page_url.nil?
|
|
tags << "bad_link"
|
|
else
|
|
tags -= ["bad_link"]
|
|
end
|
|
|
|
# A bad_source is a source from a recognized site that isn't an image url or a page url.
|
|
if !source_url.image_url? && !source_url.page_url?
|
|
tags << "bad_source"
|
|
else
|
|
tags -= ["bad_source"]
|
|
end
|
|
end
|
|
|
|
# Allow only Flash files to be manually tagged as `animated`; GIFs, PNGs, videos, and ugoiras are automatically tagged.
|
|
tags -= ["animated"] unless is_flash?
|
|
tags << "animated" if media_asset.is_animated?
|
|
tags << "animated_gif" if media_asset.is_animated_gif?
|
|
tags << "animated_png" if media_asset.is_animated_png?
|
|
|
|
tags << "greyscale" if media_asset.is_greyscale?
|
|
tags << "exif_rotation" if media_asset.is_rotated?
|
|
tags << "non-repeating_animation" if media_asset.is_non_repeating_animation?
|
|
tags << "ai-generated" if media_asset.is_ai_generated?
|
|
|
|
tags
|
|
end
|
|
|
|
def apply_post_metatags
|
|
post_edit.post_metatag_terms.each do |metatag|
|
|
case [metatag.name, metatag.value]
|
|
in "-pool", /^\d+$/ => pool_id
|
|
pool = Pool.find_by_id(pool_id)
|
|
pool&.remove!(self)
|
|
|
|
in "-pool", name
|
|
pool = Pool.find_by_name(name)
|
|
pool&.remove!(self)
|
|
|
|
in "pool", /^\d+$/ => pool_id
|
|
pool = Pool.find_by_id(pool_id)
|
|
pool&.add!(self)
|
|
|
|
in "pool", name
|
|
pool = Pool.find_by_name(name)
|
|
pool&.add!(self)
|
|
|
|
in "newpool", name
|
|
pool = Pool.find_by_name(name)
|
|
|
|
# XXX race condition
|
|
if pool.nil?
|
|
Pool.create!(name: name, description: "This pool was automatically generated", post_ids: [id])
|
|
else
|
|
pool.add!(self)
|
|
end
|
|
|
|
in "fav", name
|
|
raise User::PrivilegeError unless Pundit.policy!(CurrentUser.user, Favorite).create?
|
|
Favorite.create(post: self, user: CurrentUser.user)
|
|
|
|
in "-fav", name
|
|
raise User::PrivilegeError unless Pundit.policy!(CurrentUser.user, Favorite).create?
|
|
Favorite.destroy_by(post: self, user: CurrentUser.user)
|
|
|
|
in "upvote", name
|
|
vote!(1, CurrentUser.user)
|
|
|
|
in "downvote", name
|
|
vote!(-1, CurrentUser.user)
|
|
|
|
in "status", "active"
|
|
raise User::PrivilegeError unless CurrentUser.is_approver?
|
|
approvals.create!(user: CurrentUser.user)
|
|
|
|
in "status", "banned"
|
|
raise User::PrivilegeError unless CurrentUser.is_approver?
|
|
ban!(CurrentUser.user)
|
|
|
|
in "-status", "banned"
|
|
raise User::PrivilegeError unless CurrentUser.is_approver?
|
|
unban!(CurrentUser.user)
|
|
|
|
in "disapproved", reason
|
|
raise User::PrivilegeError unless CurrentUser.is_approver?
|
|
disapprovals.create!(user: CurrentUser.user, reason: reason.downcase)
|
|
|
|
in "child", "none"
|
|
children.each do |post|
|
|
post.update!(parent_id: nil)
|
|
end
|
|
|
|
in "-child", ids
|
|
next if ids.blank?
|
|
|
|
children.where_numeric_matches(:id, ids).each do |post|
|
|
post.update!(parent_id: nil)
|
|
end
|
|
|
|
in "child", ids
|
|
next if ids.blank?
|
|
|
|
Post.where_numeric_matches(:id, ids).where.not(id: id).limit(10).each do |post|
|
|
post.update!(parent_id: id)
|
|
end
|
|
|
|
in "-favgroup", name
|
|
favgroup = FavoriteGroup.find_by_name_or_id!(name, CurrentUser.user)
|
|
raise User::PrivilegeError unless Pundit.policy!(CurrentUser.user, favgroup).update?
|
|
favgroup&.remove(self)
|
|
|
|
in "favgroup", name
|
|
favgroup = FavoriteGroup.find_by_name_or_id!(name, CurrentUser.user)
|
|
raise User::PrivilegeError unless Pundit.policy!(CurrentUser.user, favgroup).update?
|
|
favgroup&.add(self)
|
|
|
|
end
|
|
end
|
|
rescue
|
|
# XXX Silently ignore errors so that the edit doesn't fail. We can't let
|
|
# the edit fail because then it will create a new post version even if
|
|
# the edit didn't go through.
|
|
nil
|
|
end
|
|
|
|
def apply_pre_metatags
|
|
post_edit.pre_metatag_terms.each do |metatag|
|
|
case [metatag.name, metatag.value]
|
|
in "parent", ("none" | "0")
|
|
self.parent_id = nil
|
|
|
|
in "-parent", /^\d+$/ => new_parent_id
|
|
if parent_id == new_parent_id.to_i
|
|
self.parent_id = nil
|
|
end
|
|
|
|
in "parent", /^\d+$/ => new_parent_id
|
|
if new_parent_id.to_i != id && Post.exists?(new_parent_id)
|
|
self.parent_id = new_parent_id.to_i
|
|
remove_parent_loops
|
|
end
|
|
|
|
in "rating", /\A([#{RATINGS.keys.join}])/i
|
|
self.rating = $1.downcase
|
|
|
|
in "source", "none"
|
|
self.source = ""
|
|
|
|
in "source", value
|
|
self.source = value
|
|
|
|
in category, name if category.in?(PostEdit::CATEGORIZATION_METATAGS)
|
|
Tag.find_or_create_by_name(name, category: category, current_user: CurrentUser.user)
|
|
|
|
else
|
|
nil
|
|
|
|
end
|
|
end
|
|
end
|
|
|
|
def web_source?
|
|
source.match?(%r{\Ahttps?://}i)
|
|
end
|
|
|
|
def has_tag?(tag)
|
|
tag_array.include?(tag)
|
|
end
|
|
|
|
def add_tag(tag)
|
|
self.tag_string = "#{tag_string} #{tag}"
|
|
end
|
|
|
|
def remove_tag(tag)
|
|
self.tag_string = (tag_array - Array(tag)).join(" ")
|
|
end
|
|
|
|
def tag_categories
|
|
@tag_categories ||= Tag.categories_for(tag_array)
|
|
end
|
|
|
|
def typed_tags(name)
|
|
@typed_tags ||= {}
|
|
@typed_tags[name] ||= begin
|
|
tag_array.select do |tag|
|
|
tag_categories[tag] == TagCategory.mapping[name]
|
|
end
|
|
end
|
|
end
|
|
|
|
TagCategory.categories.each do |category|
|
|
define_method("tag_string_#{category}") do
|
|
typed_tags(category).join(" ")
|
|
end
|
|
end
|
|
end
|
|
|
|
concerning :FavoriteMethods do
|
|
def favorited_by?(user)
|
|
return false if user.is_anonymous?
|
|
Favorite.exists?(post: self, user: user)
|
|
end
|
|
|
|
def favorite_groups
|
|
FavoriteGroup.for_post(id)
|
|
end
|
|
|
|
def remove_from_fav_groups
|
|
FavoriteGroup.for_post(id).find_each do |favgroup|
|
|
favgroup.remove(self)
|
|
end
|
|
end
|
|
end
|
|
|
|
concerning :PoolMethods do
|
|
def pools
|
|
Pool.where("pools.post_ids && array[?]", id)
|
|
end
|
|
|
|
def has_active_pools?
|
|
pools.undeleted.present?
|
|
end
|
|
|
|
def remove_from_all_pools
|
|
pools.find_each do |pool|
|
|
pool.remove!(self)
|
|
end
|
|
end
|
|
end
|
|
|
|
concerning :VoteMethods do
|
|
def vote!(score, voter)
|
|
# Ignore vote if user doesn't have permission to vote.
|
|
return unless Pundit.policy!(voter, PostVote).create?
|
|
|
|
with_lock do
|
|
votes.create!(user: voter, score: score) unless votes.active.exists?(user: voter, score: score)
|
|
reload # PostVote.create modifies our score. Reload to get the new score.
|
|
end
|
|
end
|
|
end
|
|
|
|
concerning :ParentMethods do
|
|
# A parent has many children. A child belongs to a parent.
|
|
# A parent cannot have a parent.
|
|
#
|
|
# After expunging a child:
|
|
# - Move favorites to parent.
|
|
# - Does the parent have any children?
|
|
# - Yes: Done.
|
|
# - No: Update parent's has_children flag to false.
|
|
#
|
|
# After expunging a parent:
|
|
# - Move favorites to the first child.
|
|
# - Reparent all children to the first child.
|
|
|
|
def update_has_children_flag
|
|
update(has_children: children.exists?, has_active_children: children.undeleted.exists?)
|
|
end
|
|
|
|
def blank_out_nonexistent_parents
|
|
if parent_id.present? && parent.nil?
|
|
self.parent_id = nil
|
|
end
|
|
end
|
|
|
|
def remove_parent_loops
|
|
if parent.present? && parent.parent_id.present? && parent.parent_id == id
|
|
parent.parent_id = nil
|
|
parent.save
|
|
end
|
|
end
|
|
|
|
def update_parent_on_destroy
|
|
parent&.update_has_children_flag
|
|
end
|
|
|
|
def update_children_on_destroy
|
|
children.update(parent: nil)
|
|
end
|
|
|
|
def update_parent_on_save
|
|
return unless saved_change_to_parent_id? || saved_change_to_is_deleted?
|
|
|
|
parent.update_has_children_flag if parent.present?
|
|
Post.find(parent_id_before_last_save).update_has_children_flag if parent_id_before_last_save.present?
|
|
rescue
|
|
# XXX Silently ignore errors so that the edit doesn't fail. We can't let
|
|
# the edit fail because then it will create a new post version even if
|
|
# the edit didn't go through.
|
|
nil
|
|
end
|
|
|
|
def give_favorites_to_parent(current_user = CurrentUser.user)
|
|
return if parent.nil?
|
|
|
|
transaction do
|
|
favorites.each do |fav|
|
|
fav.destroy!
|
|
Favorite.create(post: parent, user: fav.user)
|
|
end
|
|
end
|
|
|
|
ModAction.log("moved favorites from post ##{id} to post ##{parent.id}", :post_move_favorites, subject: self, user: current_user)
|
|
end
|
|
|
|
def has_visible_children?
|
|
return true if has_active_children?
|
|
return true if has_children? && CurrentUser.user.show_deleted_children?
|
|
return true if has_children? && is_deleted?
|
|
return false
|
|
end
|
|
|
|
def has_visible_children
|
|
has_visible_children?
|
|
end
|
|
end
|
|
|
|
concerning :DeletionMethods do
|
|
def expunge!(current_user = CurrentUser.user)
|
|
transaction do
|
|
Post.without_timeout do
|
|
ModAction.log("permanently deleted post ##{id} (md5=#{md5})", :post_permanent_delete, subject: nil, user: current_user)
|
|
|
|
update_children_on_destroy
|
|
decrement_tag_post_counts
|
|
remove_from_all_pools
|
|
remove_from_fav_groups
|
|
media_asset.trash!
|
|
destroy
|
|
update_parent_on_destroy
|
|
end
|
|
end
|
|
|
|
remove_iqdb # this is non-transactional
|
|
end
|
|
|
|
def ban!(current_user)
|
|
return if is_banned?
|
|
update_column(:is_banned, true)
|
|
ModAction.log("banned post ##{id}", :post_ban, subject: self, user: current_user)
|
|
end
|
|
|
|
def unban!(current_user)
|
|
return unless is_banned?
|
|
update_column(:is_banned, false)
|
|
ModAction.log("unbanned post ##{id}", :post_unban, subject: self, user: current_user)
|
|
end
|
|
|
|
def delete!(reason, move_favorites: false, user: CurrentUser.user)
|
|
with_lock do
|
|
automated = (user == User.system)
|
|
|
|
flags.pending.update!(status: :succeeded)
|
|
appeals.pending.update!(status: :rejected)
|
|
|
|
flags.create!(reason: reason, is_deletion: true, creator: user, status: :succeeded)
|
|
update!(is_deleted: true, is_pending: false, is_flagged: false)
|
|
|
|
# XXX This must happen *after* the `is_deleted` flag is set to true (issue #3419).
|
|
give_favorites_to_parent if move_favorites
|
|
|
|
uploader.upload_limit.update_limit!(is_pending?, false)
|
|
|
|
unless automated
|
|
ModAction.log("deleted post ##{id}, reason: #{reason}", :post_delete, subject: self, user: user)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
concerning :VersionMethods do
|
|
# XXX `create_version` must be called before `apply_post_metatags` because
|
|
# `apply_post_metatags` may update the post itself, which will clear all
|
|
# changes to the post and make saved_change_to_*? return false.
|
|
def create_version(force = false)
|
|
if new_record? || saved_change_to_watched_attributes? || force
|
|
create_new_version
|
|
end
|
|
end
|
|
|
|
def saved_change_to_watched_attributes?
|
|
saved_change_to_rating? || saved_change_to_source? || saved_change_to_parent_id? || saved_change_to_tag_string?
|
|
end
|
|
|
|
def merge_version?
|
|
prev = versions.last
|
|
prev && prev.updater_id == CurrentUser.user.id && prev.updated_at > 1.hour.ago
|
|
end
|
|
|
|
def create_new_version
|
|
User.where(id: CurrentUser.id).update_all("post_update_count = post_update_count + 1")
|
|
PostVersion.queue(self) if PostVersion.enabled?
|
|
end
|
|
|
|
def revert_to(target)
|
|
if id != target.post_id
|
|
raise RevertError, "You cannot revert to a previous version of another post."
|
|
end
|
|
|
|
self.tag_string = target.tags
|
|
self.rating = target.rating
|
|
self.source = target.source
|
|
self.parent_id = target.parent_id
|
|
end
|
|
|
|
def revert_to!(target)
|
|
revert_to(target)
|
|
save!
|
|
end
|
|
end
|
|
|
|
concerning :NoteMethods do
|
|
def has_notes?
|
|
last_noted_at.present?
|
|
end
|
|
|
|
def copy_notes_to(other_post, copy_tags: NOTE_COPY_TAGS)
|
|
transaction do
|
|
if id == other_post.id
|
|
errors.add(:base, "Source and destination posts are the same")
|
|
return false
|
|
end
|
|
unless has_notes?
|
|
errors.add(:post, "has no notes")
|
|
return false
|
|
end
|
|
|
|
notes.active.each do |note|
|
|
note.copy_to(other_post)
|
|
end
|
|
|
|
dummy = Note.new
|
|
if notes.active.length == 1
|
|
dummy.body = "Copied 1 note from post ##{id}."
|
|
else
|
|
dummy.body = "Copied #{notes.active.length} notes from post ##{id}."
|
|
end
|
|
dummy.is_active = false
|
|
dummy.post_id = other_post.id
|
|
dummy.x = dummy.y = dummy.width = dummy.height = 0
|
|
dummy.save
|
|
|
|
copy_tags.each do |tag|
|
|
other_post.remove_tag(tag)
|
|
other_post.add_tag(tag) if has_tag?(tag)
|
|
end
|
|
|
|
other_post.has_embedded_notes = has_embedded_notes
|
|
other_post.save
|
|
end
|
|
end
|
|
end
|
|
|
|
concerning :ApiMethods do
|
|
def legacy_attributes
|
|
hash = {
|
|
"has_comments" => last_commented_at.present?,
|
|
"parent_id" => parent_id,
|
|
"status" => status,
|
|
"has_children" => has_children?,
|
|
"created_at" => created_at.to_formatted_s(:db),
|
|
"has_notes" => has_notes?,
|
|
"rating" => rating,
|
|
"author" => uploader.name,
|
|
"creator_id" => uploader_id,
|
|
"width" => image_width,
|
|
"source" => source,
|
|
"score" => score,
|
|
"tags" => tag_string,
|
|
"height" => image_height,
|
|
"file_size" => file_size,
|
|
"id" => id,
|
|
}
|
|
|
|
if visible?
|
|
hash["file_url"] = file_url
|
|
hash["preview_url"] = preview_file_url
|
|
hash["md5"] = md5
|
|
end
|
|
|
|
hash
|
|
end
|
|
|
|
def status
|
|
if is_pending?
|
|
"pending"
|
|
elsif is_deleted?
|
|
"deleted"
|
|
elsif is_flagged?
|
|
"flagged"
|
|
else
|
|
"active"
|
|
end
|
|
end
|
|
end
|
|
|
|
concerning :SearchMethods do
|
|
class_methods do
|
|
# Return a set of up to N random posts. May return less if there aren't
|
|
# enough posts.
|
|
#
|
|
# @param n [Integer] The maximum number of posts to return
|
|
# @return [ActiveRecord::Relation<Post>]
|
|
def random(n = 1)
|
|
posts = n.times.map do
|
|
key = SecureRandom.hex(16)
|
|
random_up(key) || random_down(key)
|
|
end.compact.uniq
|
|
|
|
reorder(nil).in_order_of(:id, posts.map(&:id))
|
|
end
|
|
|
|
def random_up(key)
|
|
where("md5 < ?", key).reorder(md5: :desc).first
|
|
end
|
|
|
|
def random_down(key)
|
|
where("md5 >= ?", key).reorder(md5: :asc).first
|
|
end
|
|
|
|
def sample(query, sample_size)
|
|
user_tag_match(query, safe_mode: false).reorder(:md5).limit(sample_size)
|
|
end
|
|
|
|
# unflattens the tag_string into one tag per row.
|
|
def with_unflattened_tags
|
|
joins("CROSS JOIN unnest(string_to_array(tag_string, ' ')) AS tag")
|
|
end
|
|
|
|
def with_comment_stats
|
|
relation = left_outer_joins(:comments).group(:id).select("posts.*")
|
|
relation = relation.select("COUNT(comments.id) AS comment_count")
|
|
relation = relation.select("COUNT(comments.id) FILTER (WHERE comments.is_deleted = TRUE) AS deleted_comment_count")
|
|
relation = relation.select("COUNT(comments.id) FILTER (WHERE comments.is_deleted = FALSE) AS active_comment_count")
|
|
relation
|
|
end
|
|
|
|
def with_note_stats
|
|
relation = left_outer_joins(:notes).group(:id).select("posts.*")
|
|
relation = relation.select("COUNT(notes.id) AS note_count")
|
|
relation = relation.select("COUNT(notes.id) FILTER (WHERE notes.is_active = TRUE) AS active_note_count")
|
|
relation = relation.select("COUNT(notes.id) FILTER (WHERE notes.is_active = FALSE) AS deleted_note_count")
|
|
relation
|
|
end
|
|
|
|
def with_flag_stats
|
|
relation = left_outer_joins(:flags).group(:id).select("posts.*")
|
|
relation.select("COUNT(post_flags.id) AS flag_count")
|
|
end
|
|
|
|
def with_appeal_stats
|
|
relation = left_outer_joins(:appeals).group(:id).select("posts.*")
|
|
relation = relation.select("COUNT(post_appeals.id) AS appeal_count")
|
|
relation
|
|
end
|
|
|
|
def with_approval_stats
|
|
relation = left_outer_joins(:approvals).group(:id).select("posts.*")
|
|
relation = relation.select("COUNT(post_approvals.id) AS approval_count")
|
|
relation
|
|
end
|
|
|
|
def with_replacement_stats
|
|
relation = left_outer_joins(:replacements).group(:id).select("posts.*")
|
|
relation = relation.select("COUNT(post_replacements.id) AS replacement_count")
|
|
relation
|
|
end
|
|
|
|
def with_child_stats
|
|
relation = left_outer_joins(:children).group(:id).select("posts.*")
|
|
relation = relation.select("COUNT(children_posts.id) AS child_count")
|
|
relation = relation.select("COUNT(children_posts.id) FILTER (WHERE children_posts.is_deleted = TRUE) AS deleted_child_count")
|
|
relation = relation.select("COUNT(children_posts.id) FILTER (WHERE children_posts.is_deleted = FALSE) AS active_child_count")
|
|
relation
|
|
end
|
|
|
|
def with_pool_stats
|
|
pool_posts = Pool.joins("CROSS JOIN unnest(post_ids) AS post_id").select(:id, :is_deleted, :category, "post_id")
|
|
relation = joins("LEFT OUTER JOIN (#{pool_posts.to_sql}) pools ON pools.post_id = posts.id").group(:id).select("posts.*")
|
|
|
|
relation = relation.select("COUNT(pools.id) AS pool_count")
|
|
relation = relation.select("COUNT(pools.id) FILTER (WHERE pools.is_deleted = TRUE) AS deleted_pool_count")
|
|
relation = relation.select("COUNT(pools.id) FILTER (WHERE pools.is_deleted = FALSE) AS active_pool_count")
|
|
relation = relation.select("COUNT(pools.id) FILTER (WHERE pools.category = 'series') AS series_pool_count")
|
|
relation = relation.select("COUNT(pools.id) FILTER (WHERE pools.category = 'collection') AS collection_pool_count")
|
|
relation
|
|
end
|
|
|
|
def with_queued_at
|
|
relation = group(:id)
|
|
relation = relation.left_outer_joins(:flags, :appeals)
|
|
relation = relation.select("posts.*")
|
|
relation = relation.select(Arel.sql("MAX(GREATEST(posts.created_at, post_flags.created_at, post_appeals.created_at)) AS queued_at"))
|
|
relation
|
|
end
|
|
|
|
def with_stats(tables)
|
|
return all if tables.empty?
|
|
|
|
relation = all
|
|
tables.each do |table|
|
|
relation = relation.send("with_#{table}_stats")
|
|
end
|
|
|
|
from(relation.arel.as("posts"))
|
|
end
|
|
|
|
def available_for_moderation(user, hidden: false)
|
|
disapproved_posts = user.post_disapprovals.select(:post_id)
|
|
|
|
if hidden.present?
|
|
in_modqueue.where(id: disapproved_posts)
|
|
else
|
|
in_modqueue.where.not(id: disapproved_posts)
|
|
end
|
|
end
|
|
|
|
def is_matches(value, current_user = User.anonymous)
|
|
case value.downcase
|
|
when "parent"
|
|
where(has_children: true)
|
|
when "child"
|
|
where.not(parent: nil)
|
|
when *AutocompleteService::POST_STATUSES
|
|
status_matches(value, current_user)
|
|
when *MediaAsset::FILE_TYPES
|
|
attribute_matches(value, "media_assets.file_ext", :enum).joins(:media_asset)
|
|
when *Post::RATINGS.values.map(&:downcase)
|
|
rating_matches(value)
|
|
when *Post::RATING_ALIASES.keys
|
|
where(rating: Post::RATING_ALIASES.fetch(value.downcase))
|
|
else
|
|
none
|
|
end
|
|
end
|
|
|
|
def has_matches(value)
|
|
case value.downcase
|
|
when "parent"
|
|
where.not(parent: nil)
|
|
when "child", "children"
|
|
where(has_children: true)
|
|
when "source"
|
|
where.not(source: "")
|
|
when "appeals"
|
|
where(PostAppeal.where("post_appeals.post_id = posts.id").arel.exists)
|
|
when "flags"
|
|
where(PostFlag.by_users.where("post_flags.post_id = posts.id").arel.exists)
|
|
when "replacements"
|
|
where(PostReplacement.where("post_replacements.post_id = posts.id").arel.exists)
|
|
when "comments"
|
|
where(Comment.undeleted.where("comments.post_id = posts.id").arel.exists)
|
|
when "commentary"
|
|
where(ArtistCommentary.undeleted.where("artist_commentaries.post_id = posts.id").arel.exists)
|
|
when "notes"
|
|
where(Note.active.where("notes.post_id = posts.id").arel.exists)
|
|
when "pools"
|
|
where(id: Pool.undeleted.select("unnest(post_ids)"))
|
|
else
|
|
none
|
|
end
|
|
end
|
|
|
|
def status_matches(status, current_user = User.anonymous)
|
|
case status.downcase
|
|
when "pending"
|
|
pending
|
|
when "flagged"
|
|
flagged
|
|
when "appealed"
|
|
appealed
|
|
when "modqueue"
|
|
in_modqueue
|
|
when "deleted"
|
|
deleted
|
|
when "banned"
|
|
banned
|
|
when "active"
|
|
active
|
|
when "unmoderated"
|
|
available_for_moderation(current_user, hidden: false)
|
|
when "all", "any"
|
|
where("TRUE")
|
|
else
|
|
none
|
|
end
|
|
end
|
|
|
|
def parent_matches(parent)
|
|
case parent.downcase
|
|
when "none"
|
|
where(parent: nil)
|
|
when "any"
|
|
where.not(parent: nil)
|
|
when "pending", "flagged", "appealed", "modqueue", "deleted", "banned", "active", "unmoderated"
|
|
where.not(parent: nil).where(parent: status_matches(parent))
|
|
when /\A\d+\z/
|
|
# XXX must use `attribute_matches(parent, :parent_id)` instead of `where(parent_id: parent)` so that `-parent:1` works
|
|
where(id: parent).or(attribute_matches(parent, :parent_id))
|
|
else
|
|
none
|
|
end
|
|
end
|
|
|
|
def child_matches(child)
|
|
case child.downcase
|
|
when "none"
|
|
where(has_children: false)
|
|
when "any"
|
|
where(has_children: true)
|
|
when "pending", "flagged", "appealed", "modqueue", "deleted", "banned", "active", "unmoderated"
|
|
where(has_children: true).where(children: status_matches(child))
|
|
else
|
|
none
|
|
end
|
|
end
|
|
|
|
def rating_matches(rating)
|
|
where(rating: rating.downcase.split(/,/).map(&:first))
|
|
end
|
|
|
|
def source_matches(source, quoted = false)
|
|
if source.empty?
|
|
where(source: "")
|
|
elsif source.downcase == "none" && !quoted
|
|
where(source: "")
|
|
else
|
|
where_ilike(:source, source + "*")
|
|
end
|
|
end
|
|
|
|
def embedded_matches(embedded)
|
|
if embedded.truthy?
|
|
bit_flags_match(:has_embedded_notes, true)
|
|
elsif embedded.falsy?
|
|
bit_flags_match(:has_embedded_notes, false)
|
|
else
|
|
none
|
|
end
|
|
end
|
|
|
|
def commentary_matches(query, quoted = false)
|
|
case query.downcase
|
|
in "none" | "false" unless quoted
|
|
where.not(artist_commentary: ArtistCommentary.all).or(where(artist_commentary: ArtistCommentary.deleted))
|
|
in "any" | "true" unless quoted
|
|
where(artist_commentary: ArtistCommentary.undeleted)
|
|
in "translated" unless quoted
|
|
where(artist_commentary: ArtistCommentary.translated)
|
|
in "untranslated" unless quoted
|
|
where(artist_commentary: ArtistCommentary.untranslated)
|
|
else
|
|
where(artist_commentary: ArtistCommentary.text_matches(query))
|
|
end
|
|
end
|
|
|
|
def disapproved_matches(query, current_user = User.anonymous)
|
|
if query.downcase.in?(PostDisapproval::REASONS)
|
|
where(disapprovals: PostDisapproval.where(reason: query.downcase))
|
|
else
|
|
user = User.find_by_name(query)
|
|
where(disapprovals: PostDisapproval.visible_for_search(:user, current_user).where(user: user))
|
|
end
|
|
end
|
|
|
|
def note_matches(query)
|
|
where(notes: Note.where_text_matches(:body, query))
|
|
end
|
|
|
|
def comment_matches(query)
|
|
where(comments: Comment.where_text_matches(:body, query))
|
|
end
|
|
|
|
def saved_search_matches(label, current_user = User.anonymous)
|
|
case label.downcase
|
|
when "all"
|
|
where(id: SavedSearch.post_ids_for(current_user.id))
|
|
else
|
|
where(id: SavedSearch.post_ids_for(current_user.id, label: label))
|
|
end
|
|
end
|
|
|
|
def pool_matches(pool_name)
|
|
case pool_name.downcase
|
|
when "none"
|
|
where.not(id: Pool.select("unnest(post_ids)"))
|
|
when "any"
|
|
where(id: Pool.select("unnest(post_ids)"))
|
|
when "series"
|
|
where(id: Pool.series.select("unnest(post_ids)"))
|
|
when "collection"
|
|
where(id: Pool.collection.select("unnest(post_ids)"))
|
|
when /\*/
|
|
where(id: Pool.name_contains(pool_name).select("unnest(post_ids)"))
|
|
else
|
|
where(id: Pool.named(pool_name).select("unnest(post_ids)"))
|
|
end
|
|
end
|
|
|
|
def ordpool_matches(pool_name)
|
|
# XXX unify with Pool#posts
|
|
pool_posts = Pool.named(pool_name).joins("CROSS JOIN unnest(pools.post_ids) WITH ORDINALITY AS row(post_id, pool_index)").select(:post_id, :pool_index)
|
|
joins("JOIN (#{pool_posts.to_sql}) pool_posts ON pool_posts.post_id = posts.id").order("pool_posts.pool_index ASC")
|
|
end
|
|
|
|
def favgroup_matches(query, current_user)
|
|
case query.downcase
|
|
when "none"
|
|
favgroups = FavoriteGroup.where(creator: current_user)
|
|
where.not(id: favgroups.select("unnest(post_ids)"))
|
|
when "any"
|
|
favgroups = FavoriteGroup.where(creator: current_user)
|
|
where(id: favgroups.select("unnest(post_ids)"))
|
|
else
|
|
favgroup = FavoriteGroup.visible(current_user).name_or_id_matches(query, current_user)
|
|
where(id: favgroup.select("unnest(post_ids)"))
|
|
end
|
|
end
|
|
|
|
def ordfavgroup_matches(query, current_user)
|
|
# XXX unify with FavoriteGroup#posts
|
|
favgroup = FavoriteGroup.visible(current_user).name_or_id_matches(query, current_user)
|
|
favgroup_posts = favgroup.joins("CROSS JOIN unnest(favorite_groups.post_ids) WITH ORDINALITY AS row(post_id, favgroup_index)").select(:post_id, :favgroup_index)
|
|
joins("JOIN (#{favgroup_posts.to_sql}) favgroup_posts ON favgroup_posts.post_id = posts.id").order("favgroup_posts.favgroup_index ASC")
|
|
end
|
|
|
|
def favorites_include(username, current_user = User.anonymous)
|
|
favuser = User.find_by_name(username)
|
|
|
|
if favuser.present? && Pundit.policy!(current_user, favuser).can_see_favorites?
|
|
where(id: favuser.favorites.select(:post_id))
|
|
else
|
|
none
|
|
end
|
|
end
|
|
|
|
def ordfav_matches(username, current_user = User.anonymous)
|
|
user = User.find_by_name(username)
|
|
|
|
if user.present? && Pundit.policy!(current_user, user).can_see_favorites?
|
|
joins(:favorites).merge(Favorite.where(user: user)).order("favorites.id DESC")
|
|
else
|
|
none
|
|
end
|
|
end
|
|
|
|
def exif_matches(string)
|
|
# string = exif:File:ColorComponents=3
|
|
if string.include?("=")
|
|
key, value = string.split(/=/, 2)
|
|
hash = { key => value }
|
|
metadata = MediaMetadata.joins(:media_asset).where_json_contains(:metadata, hash)
|
|
# string = exif:File:ColorComponents
|
|
else
|
|
metadata = MediaMetadata.joins(:media_asset).where_json_has_key(:metadata, string)
|
|
end
|
|
|
|
where(md5: metadata.select(:md5))
|
|
end
|
|
|
|
def ai_tags_include(value, default_confidence: ">=50")
|
|
name, confidence = value.split(",")
|
|
confidence = (confidence || default_confidence).to_s.delete("%")
|
|
|
|
tag = Tag.find_by_name_or_alias(name)
|
|
return none if tag.nil?
|
|
|
|
if confidence == "0"
|
|
ai_tags = AITag.joins(:media_asset).where(tag: tag)
|
|
where.not(ai_tags.where("media_assets.md5 = posts.md5").arel.exists)
|
|
else
|
|
ai_tags = AITag.joins(:media_asset).where(tag: tag).where_numeric_matches(:score, confidence)
|
|
where(ai_tags.where("media_assets.md5 = posts.md5").arel.exists)
|
|
end
|
|
end
|
|
|
|
def uploader_matches(username)
|
|
case username.downcase
|
|
when "any"
|
|
where.not(uploader: nil)
|
|
when "none"
|
|
where(uploader: nil)
|
|
else
|
|
user = User.find_by_name(username)
|
|
return none if user.nil?
|
|
where(uploader: user)
|
|
end
|
|
end
|
|
|
|
def approver_matches(username)
|
|
case username.downcase
|
|
when "any"
|
|
where.not(approver: nil)
|
|
when "none"
|
|
where(approver: nil)
|
|
else
|
|
user = User.find_by_name(username)
|
|
return none if user.nil?
|
|
|
|
# XXX must use `attribute_matches(user.id, :approver_id)` instead of `where(approver: user)` so that `-approver:evazion` works
|
|
attribute_matches(user.id, :approver_id)
|
|
end
|
|
end
|
|
|
|
def user_subquery_matches(subquery, username, current_user, field: :creator)
|
|
subquery = subquery.where("post_id = posts.id").select(1)
|
|
|
|
if username.downcase == "any"
|
|
where("EXISTS (#{subquery.to_sql})")
|
|
elsif username.downcase == "none"
|
|
where("NOT EXISTS (#{subquery.to_sql})")
|
|
else
|
|
user = User.find_by_name(username)
|
|
return none if user.nil?
|
|
subquery = subquery.visible_for_search(field, current_user).where(field => user)
|
|
where("EXISTS (#{subquery.to_sql})")
|
|
end
|
|
end
|
|
|
|
def tags_include(*tags)
|
|
where_array_includes_all("string_to_array(posts.tag_string, ' ')", tags)
|
|
end
|
|
|
|
def raw_tag_match(tag)
|
|
Post.where_array_includes_all("string_to_array(posts.tag_string, ' ')", [tag])
|
|
end
|
|
|
|
# Perform a tag search as an anonymous user. No tag limit is enforced.
|
|
def anon_tag_match(query)
|
|
user_tag_match(query, User.anonymous, tag_limit: nil, safe_mode: false)
|
|
end
|
|
|
|
# Perform a tag search as the system user, DanbooruBot. The search will
|
|
# have moderator-level permissions. No tag limit is enforced.
|
|
def system_tag_match(query)
|
|
user_tag_match(query, User.system, tag_limit: nil, safe_mode: false)
|
|
end
|
|
|
|
# Perform a tag search as the current user, or as another user.
|
|
#
|
|
# @param query [String] the tag search to perform
|
|
# @param user [User] the user to perform the search as
|
|
# @param tag_limit [Integer] the maximum number of tags allowed per search.
|
|
# An exception will be raised if the search has too many tags.
|
|
# @param safe_mode [Boolean] if true, automatically add rating:s to the search
|
|
# @return [ActiveRecord::Relation<Post>] the set of resulting posts
|
|
def user_tag_match(query, user = CurrentUser.user, tag_limit: user.tag_query_limit, safe_mode: CurrentUser.safe_mode?)
|
|
post_query = PostQuery.normalize(query, current_user: user, tag_limit: tag_limit, safe_mode: safe_mode)
|
|
post_query.validate_tag_limit!
|
|
posts = post_query.with_implicit_metatags.posts
|
|
and_relation(posts)
|
|
end
|
|
|
|
def search(params, current_user)
|
|
q = search_attributes(
|
|
params,
|
|
[:id, :created_at, :updated_at, :rating, :source, :pixiv_id, :fav_count,
|
|
:score, :up_score, :down_score, :md5, :file_ext, :file_size, :image_width,
|
|
:image_height, :tag_count, :has_children, :has_active_children,
|
|
:is_pending, :is_flagged, :is_deleted, :is_banned,
|
|
:last_comment_bumped_at, :last_commented_at, :last_noted_at,
|
|
:uploader, :approver, :parent,
|
|
:artist_commentary, :flags, :appeals, :notes, :comments, :children,
|
|
:approvals, :replacements, :media_metadata],
|
|
current_user: current_user
|
|
)
|
|
|
|
if params[:tags].present?
|
|
q = q.where(id: user_tag_match(params[:tags], current_user).select(:id))
|
|
end
|
|
|
|
if params[:order].present?
|
|
q = PostQueryBuilder.new(nil).search_order(q, params[:order])
|
|
else
|
|
q = q.apply_default_order(params)
|
|
end
|
|
|
|
q
|
|
end
|
|
end
|
|
end
|
|
|
|
concerning :PixivMethods do
|
|
def parse_pixiv_id
|
|
self.pixiv_id = nil
|
|
return unless web_source?
|
|
|
|
site = Source::Extractor::Pixiv.new(source)
|
|
if site.match?
|
|
self.pixiv_id = site.illust_id
|
|
end
|
|
end
|
|
end
|
|
|
|
concerning :RegenerationMethods do
|
|
def regenerate_later!(category, user)
|
|
RegeneratePostJob.perform_later(post: self, category: category, user: user)
|
|
end
|
|
|
|
def regenerate!(category, user)
|
|
if category == "iqdb"
|
|
update_iqdb
|
|
|
|
ModAction.log("regenerated IQDB for post ##{id}", :post_regenerate_iqdb, subject: self, user: user)
|
|
else
|
|
media_asset.regenerate!
|
|
|
|
ModAction.log("regenerated image samples for post ##{id}", :post_regenerate, subject: self, user: user)
|
|
end
|
|
end
|
|
end
|
|
|
|
concerning :IqdbMethods do
|
|
def update_iqdb
|
|
# performs IqdbClient.new.add_post(post)
|
|
IqdbAddPostJob.perform_later(self)
|
|
end
|
|
|
|
def remove_iqdb
|
|
# performs IqdbClient.new.remove(id)
|
|
IqdbRemovePostJob.perform_later(id)
|
|
end
|
|
end
|
|
|
|
concerning :ValidationMethods do
|
|
def post_is_not_its_own_parent
|
|
if !new_record? && id == parent_id
|
|
errors.add(:base, "Post cannot have itself as a parent")
|
|
end
|
|
end
|
|
|
|
def uploader_is_not_limited
|
|
errors.add(:uploader, "have reached your upload limit") if uploader.upload_limit.limited?
|
|
end
|
|
|
|
def added_tags_are_valid
|
|
new_tags = added_tags.select(&:empty?)
|
|
new_artist_tags, new_general_tags = new_tags.partition(&:artist?)
|
|
|
|
if new_general_tags.present?
|
|
n = new_general_tags.size
|
|
tag_wiki_links = new_general_tags.map { |tag| "[[#{tag.name}]]" }
|
|
warnings.add(:base, "Created #{n} new #{(n == 1) ? "tag" : "tags"}: #{tag_wiki_links.join(", ")}")
|
|
end
|
|
|
|
new_artist_tags.each do |tag|
|
|
if tag.artist.blank?
|
|
new_artist_path = Routes.new_artist_path(artist: { name: tag.name })
|
|
warnings.add(:base, "Artist [[#{tag.name}]] requires an artist entry. \"Create new artist entry\":[#{new_artist_path}]")
|
|
end
|
|
end
|
|
|
|
post_edit.invalid_added_tags.each do |tag|
|
|
tag.errors.messages.each do |_attribute, messages|
|
|
warnings.add(:base, "Couldn't add tag: #{messages.join(';')}")
|
|
end
|
|
end
|
|
|
|
deprecated_tags = post_edit.deprecated_added_tag_names
|
|
if deprecated_tags.present?
|
|
tag_list = deprecated_tags.map { |tag| "[[#{tag}]]" }.to_sentence
|
|
warnings.add(:base, "The following tags are deprecated and could not be added: #{tag_list}")
|
|
end
|
|
end
|
|
|
|
def removed_tags_are_valid
|
|
attempted_removed_tags = post_edit.user_removed_tag_names
|
|
unremoved_tags = tag_array & attempted_removed_tags
|
|
|
|
if unremoved_tags.present?
|
|
unremoved_tags_list = unremoved_tags.map { |t| "[[#{t}]]" }.to_sentence
|
|
warnings.add(:base, "#{unremoved_tags_list} could not be removed. Check for implications and try again")
|
|
end
|
|
end
|
|
|
|
def has_artist_tag
|
|
return if !new_record?
|
|
return if !web_source?
|
|
return if has_tag?("artist_request") || has_tag?("official_art")
|
|
return if tags.any?(&:artist?)
|
|
return if Source::Extractor.find(source).is_a?(Source::Extractor::Null)
|
|
|
|
new_artist_path = Routes.new_artist_path(artist: { source: source })
|
|
warnings.add(:base, "Artist tag is required. \"Create new artist tag\":[#{new_artist_path}]. Ask on the forum if you need naming help")
|
|
end
|
|
|
|
def has_copyright_tag
|
|
return if !new_record?
|
|
return if has_tag?("copyright_request") || tags.any?(&:copyright?)
|
|
|
|
warnings.add(:base, "Copyright tag is required. Consider adding [[copyright request]] or [[original]]")
|
|
end
|
|
|
|
def has_enough_tags
|
|
return if !new_record?
|
|
|
|
if tags.count(&:general?) < 10
|
|
warnings.add(:base, "Uploads must have at least 10 general tags. Read [[howto:tag]] for guidelines on tagging your uploads")
|
|
end
|
|
end
|
|
end
|
|
|
|
def safeblocked?
|
|
CurrentUser.safe_mode? && (rating != "g" || Danbooru.config.safe_mode_restricted_tags.any? { |tag| tag.in?(tag_array) })
|
|
end
|
|
|
|
def levelblocked?(user = CurrentUser.user)
|
|
#!user.is_gold? && RESTRICTED_TAGS.any? { |tag| has_tag?(tag) }
|
|
user.id != uploader_id && !user.is_gold? && tag_string.match?(RESTRICTED_TAGS_REGEX)
|
|
end
|
|
|
|
def banblocked?(user = CurrentUser.user)
|
|
return true if is_taken_down? && !user.is_moderator?
|
|
return true if is_banned? && has_tag?("paid_reward") && !user.is_approver?
|
|
return true if is_banned? && !user.is_approver?
|
|
false
|
|
end
|
|
|
|
def visible?(user = CurrentUser.user)
|
|
!safeblocked? && !levelblocked?(user) && !banblocked?(user)
|
|
end
|
|
|
|
def reload(options = nil)
|
|
super
|
|
@pools = nil
|
|
@tag_categories = nil
|
|
@typed_tags = nil
|
|
self
|
|
end
|
|
|
|
def self.normalize_source(source)
|
|
source.to_s.strip.unicode_normalize(:nfc)
|
|
end
|
|
|
|
def mark_as_translated(params)
|
|
add_tag("check_translation") if params["check_translation"].to_s.truthy?
|
|
remove_tag("check_translation") if params["check_translation"].to_s.falsy?
|
|
|
|
add_tag("partially_translated") if params["partially_translated"].to_s.truthy?
|
|
remove_tag("partially_translated") if params["partially_translated"].to_s.falsy?
|
|
|
|
if has_tag?("check_translation") || has_tag?("partially_translated")
|
|
add_tag("translation_request")
|
|
remove_tag("translated")
|
|
else
|
|
add_tag("translated")
|
|
remove_tag("translation_request")
|
|
end
|
|
|
|
save
|
|
end
|
|
|
|
def self.model_restriction(table)
|
|
super.where(table[:is_pending].eq(false)).where(table[:is_flagged].eq(false)).where(table[:is_deleted].eq(false))
|
|
end
|
|
|
|
def self.available_includes
|
|
# attributes accessible through the ?only= parameter
|
|
%i[
|
|
uploader approver flags appeals events parent children notes
|
|
comments approvals disapprovals replacements
|
|
artist_commentary media_asset media_metadata ai_tags
|
|
]
|
|
end
|
|
end
|