Switch to the post search engine using the new PostQuery parser. The new
engine fully supports AND, OR, and NOT operators and grouping expressions
with parentheses.
Highlights:
New OR operator:
* `skirt or dress` (same as `~skirt ~dress`)
Tags can be grouped with parentheses:
* `1girl (skirt or dress)`
* `(blonde_hair blue_eyes) or (red_hair green_eyes)`
* `~(blonde_hair blue_eyes) ~(red_hair green_eyes)` (same as above)
* `(pantyhose or thighhighs) (black_legwear or brown_legwear)`
* `(~pantyhose ~thighhighs) (~black_legwear ~brown_legwear)` (same as above)
Metatags can be OR'd together:
* `user:evazion or fav:evazion`
* `~user:evazion ~fav:evazion`
Wildcard tags can combined with either AND or OR:
* `black_* white_*` (find posts with at least one black_* tag AND one white_* tag)
* `black_* or white_*` (find posts with at least one black_* tag OR one white_* tag)
* `~black_* ~white_*` (same as above)
See 4c7cfc73 for more syntax examples.
Fixes #4949: And+or search?
Fixes #5056: Wildcard searches return unexpected results when combined with OR searches
329 lines
12 KiB
Ruby
329 lines
12 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
# Process a bulk update request. Parses the request and applies each line in
|
|
# sequence.
|
|
class BulkUpdateRequestProcessor
|
|
# Maximum tag size allowed by the rename command before an alias must be used.
|
|
MAXIMUM_RENAME_COUNT = 200
|
|
|
|
# Maximum size of artist tags movable by builders.
|
|
MAXIMUM_BUILDER_MOVE_COUNT = 200
|
|
|
|
# Maximum number of lines a BUR may have.
|
|
MAXIMUM_SCRIPT_LENGTH = 100
|
|
|
|
include ActiveModel::Validations
|
|
|
|
class Error < StandardError; end
|
|
|
|
attr_reader :bulk_update_request
|
|
|
|
delegate :script, :forum_topic, :approver, to: :bulk_update_request
|
|
validate :validate_script
|
|
validate :validate_script_length
|
|
|
|
# @param bulk_update_request [BulkUpdateRequest] the BUR
|
|
def initialize(bulk_update_request)
|
|
@bulk_update_request = bulk_update_request
|
|
end
|
|
|
|
# Parse the script into a list of commands.
|
|
def commands
|
|
script.split(/\r\n|\r|\n/).reject(&:blank?).map do |line|
|
|
line = line.gsub(/[[:space:]]+/, " ").strip
|
|
next if line.empty?
|
|
|
|
case line
|
|
when /\A(?:create alias|alias) (\S+) -> (\S+)\z/i
|
|
[:create_alias, Tag.normalize_name($1), Tag.normalize_name($2)]
|
|
when /\A(?:create implication|imply) (\S+) -> (\S+)\z/i
|
|
[:create_implication, Tag.normalize_name($1), Tag.normalize_name($2)]
|
|
when /\A(?:remove alias|unalias) (\S+) -> (\S+)\z/i
|
|
[:remove_alias, Tag.normalize_name($1), Tag.normalize_name($2)]
|
|
when /\A(?:remove implication|unimply) (\S+) -> (\S+)\z/i
|
|
[:remove_implication, Tag.normalize_name($1), Tag.normalize_name($2)]
|
|
when /\Arename (\S+) -> (\S+)\z/i
|
|
[:rename, Tag.normalize_name($1), Tag.normalize_name($2)]
|
|
when /\A(?:mass update|update) (.+?) -> (.*)\z/i
|
|
[:mass_update, $1, $2]
|
|
when /\Acategory (\S+) -> (#{Tag.categories.regexp})\z/i
|
|
[:change_category, Tag.normalize_name($1), $2.downcase]
|
|
when /\Anuke (\S+)\z/i
|
|
[:nuke, $1]
|
|
when /\Adeprecate (\S+)\z/i
|
|
[:deprecate, $1]
|
|
when /\Aundeprecate (\S+)\z/i
|
|
[:undeprecate, $1]
|
|
else
|
|
[:invalid_line, line]
|
|
end
|
|
end
|
|
end
|
|
|
|
# Validate the bulk update request when it is created or approved.
|
|
#
|
|
# validation_context will be either :request (when the BUR is first created
|
|
# or edited) or :approval (when the BUR is approved). Certain validations
|
|
# only run when the BUR is requested, not when it's approved.
|
|
def validate_script
|
|
BulkUpdateRequest.transaction(requires_new: true) do
|
|
commands.each do |command, *args|
|
|
case command
|
|
when :create_alias
|
|
tag_alias = TagAlias.new(creator: User.system, antecedent_name: args[0], consequent_name: args[1])
|
|
tag_alias.save(context: validation_context)
|
|
if tag_alias.errors.present?
|
|
errors.add(:base, "Can't create alias #{tag_alias.antecedent_name} -> #{tag_alias.consequent_name} (#{tag_alias.errors.full_messages.join("; ")})")
|
|
end
|
|
|
|
when :create_implication
|
|
tag_implication = TagImplication.new(creator: User.system, antecedent_name: args[0], consequent_name: args[1], status: "active")
|
|
tag_implication.save(context: validation_context)
|
|
if tag_implication.errors.present?
|
|
errors.add(:base, "Can't create implication #{tag_implication.antecedent_name} -> #{tag_implication.consequent_name} (#{tag_implication.errors.full_messages.join("; ")})")
|
|
end
|
|
|
|
when :remove_alias
|
|
tag_alias = TagAlias.active.find_by(antecedent_name: args[0], consequent_name: args[1])
|
|
|
|
if validation_context == :approval
|
|
# ignore non-existing aliases when approving a BUR
|
|
elsif tag_alias.nil?
|
|
errors.add(:base, "Can't remove alias #{args[0]} -> #{args[1]} (alias doesn't exist)")
|
|
else
|
|
tag_alias.update(status: "deleted")
|
|
end
|
|
|
|
when :remove_implication
|
|
tag_implication = TagImplication.active.find_by(antecedent_name: args[0], consequent_name: args[1])
|
|
|
|
if validation_context == :approval
|
|
# ignore non-existing implication when approving a BUR
|
|
elsif tag_implication.nil?
|
|
errors.add(:base, "Can't remove implication #{args[0]} -> #{args[1]} (implication doesn't exist)")
|
|
else
|
|
tag_implication.update(status: "deleted")
|
|
end
|
|
|
|
when :change_category
|
|
tag = Tag.find_by_name(args[0])
|
|
if tag.nil?
|
|
errors.add(:base, "Can't change category #{args[0]} -> #{args[1]} (the '#{args[0]}' tag doesn't exist)")
|
|
end
|
|
|
|
when :rename
|
|
tag = Tag.find_by_name(args[0])
|
|
if tag.nil?
|
|
errors.add(:base, "Can't rename #{args[0]} -> #{args[1]} (the '#{args[0]}' tag doesn't exist)")
|
|
elsif tag.post_count > MAXIMUM_RENAME_COUNT
|
|
errors.add(:base, "Can't rename #{args[0]} -> #{args[1]} ('#{args[0]}' has more than #{MAXIMUM_RENAME_COUNT} posts, use an alias instead)")
|
|
end
|
|
|
|
when :mass_update
|
|
query = PostQuery.new(args[0])
|
|
|
|
if query.is_null_search?
|
|
errors.add(:base, "Can't mass update #{args[0]} -> #{args[1]} (the search `#{args[0]}` has a syntax error)")
|
|
end
|
|
|
|
when :nuke
|
|
# okay
|
|
|
|
when :deprecate
|
|
tag = Tag.find_by_name(args[0])
|
|
|
|
if validation_context == :approval
|
|
# ignore already deprecated tags and missing wikis when approving a tag deprecation.
|
|
elsif tag.nil?
|
|
errors.add(:base, "Can't deprecate #{args[0]} (tag doesn't exist)")
|
|
elsif tag.is_deprecated?
|
|
errors.add(:base, "Can't deprecate #{args[0]} (tag is already deprecated)")
|
|
elsif tag.wiki_page.blank?
|
|
errors.add(:base, "Can't deprecate #{args[0]} (tag must have a wiki page)")
|
|
end
|
|
|
|
when :undeprecate
|
|
tag = Tag.find_by_name(args[0])
|
|
|
|
if validation_context == :approval
|
|
# ignore already deprecated tags and missing wikis when removing a tag deprecation.
|
|
elsif tag.nil?
|
|
errors.add(:base, "Can't undeprecate #{args[0]} (tag doesn't exist)")
|
|
elsif !tag.is_deprecated?
|
|
errors.add(:base, "Can't undeprecate #{args[0]} (tag is not deprecated)")
|
|
end
|
|
|
|
when :invalid_line
|
|
errors.add(:base, "Invalid line: #{args[0]}")
|
|
|
|
else
|
|
# should never happen
|
|
raise Error, "Unknown command: #{command}"
|
|
end
|
|
end
|
|
|
|
raise ActiveRecord::Rollback
|
|
end
|
|
end
|
|
|
|
# Validate that the script isn't too long.
|
|
def validate_script_length
|
|
if commands.size > MAXIMUM_SCRIPT_LENGTH
|
|
errors.add(:base, "Bulk update request is too long (maximum size: #{MAXIMUM_SCRIPT_LENGTH} lines). Split your request into smaller chunks and try again.")
|
|
end
|
|
end
|
|
|
|
# Schedule the bulk update request to be processed later, in the background.
|
|
def process_later!
|
|
ProcessBulkUpdateRequestJob.perform_later(bulk_update_request)
|
|
end
|
|
|
|
# Process the bulk update request immediately.
|
|
def process!
|
|
CurrentUser.scoped(User.system, "127.0.0.1") do
|
|
commands.map do |command, *args|
|
|
case command
|
|
when :create_alias
|
|
TagAlias.approve!(antecedent_name: args[0], consequent_name: args[1], approver: approver, forum_topic: forum_topic)
|
|
|
|
when :create_implication
|
|
TagImplication.approve!(antecedent_name: args[0], consequent_name: args[1], approver: approver, forum_topic: forum_topic)
|
|
|
|
when :remove_alias
|
|
tag_alias = TagAlias.active.find_by(antecedent_name: args[0], consequent_name: args[1])
|
|
tag_alias&.reject!(User.system)
|
|
|
|
when :remove_implication
|
|
tag_implication = TagImplication.active.find_by(antecedent_name: args[0], consequent_name: args[1])
|
|
tag_implication&.reject!(User.system)
|
|
|
|
when :mass_update
|
|
BulkUpdateRequestProcessor.mass_update(args[0], args[1])
|
|
|
|
when :nuke
|
|
BulkUpdateRequestProcessor.nuke(args[0])
|
|
|
|
when :rename
|
|
TagMover.new(args[0], args[1], user: User.system).move!
|
|
|
|
when :change_category
|
|
tag = Tag.find_or_create_by_name(args[0])
|
|
tag.update!(category: Tag.categories.value_for(args[1]))
|
|
|
|
when :deprecate
|
|
tag = Tag.find_or_create_by_name(args[0])
|
|
tag.update!(is_deprecated: true)
|
|
TagImplication.active.where(consequent_name: tag.name).each { |ti| ti.reject!(User.system) }
|
|
TagImplication.active.where(antecedent_name: tag.name).each { |ti| ti.reject!(User.system) }
|
|
|
|
when :undeprecate
|
|
tag = Tag.find_or_create_by_name(args[0])
|
|
tag.update!(is_deprecated: false)
|
|
|
|
else
|
|
# should never happen
|
|
raise Error, "Unknown command: #{command}"
|
|
end
|
|
end
|
|
|
|
bulk_update_request.update!(status: "approved")
|
|
rescue StandardError
|
|
bulk_update_request.update!(status: "failed")
|
|
raise
|
|
end
|
|
end
|
|
|
|
# The list of tags in the script. Used to search BURs by tag.
|
|
# @return [Array<String>] the list of tags
|
|
def affected_tags
|
|
commands.flat_map do |command, *args|
|
|
case command
|
|
when :create_alias, :remove_alias, :create_implication, :remove_implication, :rename
|
|
[args[0], args[1]]
|
|
when :mass_update
|
|
PostQuery.new(args[0]).tag_names + PostQuery.new(args[1]).tag_names
|
|
when :nuke, :deprecate, :undeprecate
|
|
PostQuery.new(args[0]).tag_names
|
|
when :change_category
|
|
args[0]
|
|
end
|
|
end.sort.uniq
|
|
end
|
|
|
|
# Returns true if a non-Admin is allowed to approve a rename or alias request.
|
|
def is_tag_move_allowed?
|
|
commands.all? do |command, *args|
|
|
case command
|
|
when :create_alias, :rename
|
|
BulkUpdateRequestProcessor.is_tag_move_allowed?(args[0], args[1])
|
|
else
|
|
false
|
|
end
|
|
end
|
|
end
|
|
|
|
# Convert the BUR to DText format.
|
|
# @return [String]
|
|
def to_dtext
|
|
commands.map do |command, *args|
|
|
case command
|
|
when :create_alias, :create_implication, :remove_alias, :remove_implication, :rename
|
|
"#{command.to_s.tr("_", " ")} [[#{args[0]}]] -> [[#{args[1]}]]"
|
|
when :mass_update
|
|
"mass update {{#{args[0]}}} -> {{#{args[1]}}}"
|
|
when :nuke
|
|
|
|
if PostQuery.normalize(args[0]).is_simple_tag?
|
|
"nuke [[#{args[0]}]]"
|
|
else
|
|
"nuke {{#{args[0]}}}"
|
|
end
|
|
when :deprecate, :undeprecate
|
|
"#{command.to_s} [[#{args[0]}]]"
|
|
when :change_category
|
|
"category [[#{args[0]}]] -> #{args[1]}"
|
|
else
|
|
# should never happen
|
|
raise Error, "Unknown command: #{command}"
|
|
end
|
|
end.join("\n")
|
|
end
|
|
|
|
def self.nuke(tag_name)
|
|
# Reject existing implications from any other tag to the one we're nuking
|
|
# otherwise the tag won't be removed from posts that have those other tags
|
|
if PostQuery.normalize(tag_name).is_simple_tag?
|
|
TagImplication.active.where(consequent_name: tag_name).each { |ti| ti.reject!(User.system) }
|
|
TagImplication.active.where(antecedent_name: tag_name).each { |ti| ti.reject!(User.system) }
|
|
end
|
|
|
|
mass_update(tag_name, "-#{tag_name}")
|
|
end
|
|
|
|
def self.mass_update(antecedent, consequent, user: User.system)
|
|
CurrentUser.scoped(user) do
|
|
Post.anon_tag_match(antecedent).reorder(nil).parallel_each do |post|
|
|
post.with_lock do
|
|
post.tag_string += " " + consequent
|
|
post.save
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
# Tag move is allowed if:
|
|
#
|
|
# * The antecedent tag is an artist tag.
|
|
# * The consequent_tag is a nonexistent tag, an empty tag (of any type), or an artist tag.
|
|
# * Both tags have less than 200 posts.
|
|
def self.is_tag_move_allowed?(antecedent_name, consequent_name)
|
|
antecedent_tag = Tag.find_by_name(Tag.normalize_name(antecedent_name))
|
|
consequent_tag = Tag.find_by_name(Tag.normalize_name(consequent_name))
|
|
|
|
antecedent_allowed = antecedent_tag.present? && antecedent_tag.artist? && antecedent_tag.post_count < MAXIMUM_BUILDER_MOVE_COUNT
|
|
consequent_allowed = consequent_tag.nil? || consequent_tag.empty? || (consequent_tag.artist? && consequent_tag.post_count < MAXIMUM_BUILDER_MOVE_COUNT)
|
|
|
|
antecedent_allowed && consequent_allowed
|
|
end
|
|
end
|