When processing an alias, rename, implication, mass update, or nuke, update the posts in parallel. This means that if we alias foo to bar, for example, then we use four processes at once to retag the posts from foo to bar. This doesn't mean that if we have two aliases in a BUR, we process both aliases in parallel. It simply means that when processing an alias, we update the posts in parallel for that alias.
284 lines
10 KiB
Ruby
284 lines
10 KiB
Ruby
# 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]
|
|
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 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 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, :nuke
|
|
# okay
|
|
|
|
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]))
|
|
|
|
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 for search BURs by tag.
|
|
# @return [Tag] 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
|
|
tags = PostQueryBuilder.new(args[0]).tags + PostQueryBuilder.new(args[1]).tags
|
|
tags.reject(&:negated).reject(&:optional).reject(&:wildcard).map(&:name)
|
|
when :nuke
|
|
PostQueryBuilder.new(args[0]).tags.map(&:name)
|
|
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])
|
|
when :mass_update
|
|
lhs = PostQueryBuilder.new(args[0])
|
|
rhs = PostQueryBuilder.new(args[1])
|
|
|
|
lhs.is_simple_tag? && rhs.is_simple_tag? && 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
|
|
query = PostQueryBuilder.new(args[0])
|
|
|
|
if query.is_simple_tag?
|
|
"nuke [[#{args[0]}]]"
|
|
else
|
|
"nuke {{#{args[0]}}}"
|
|
end
|
|
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 PostQueryBuilder.new(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)
|
|
normalized_antecedent = PostQueryBuilder.new(antecedent).split_query
|
|
normalized_consequent = PostQueryBuilder.new(consequent).parse_tag_edit
|
|
|
|
CurrentUser.scoped(user) do
|
|
Post.anon_tag_match(normalized_antecedent.join(" ")).reorder(nil).parallel_each do |post|
|
|
post.with_lock do
|
|
tags = (post.tag_array - normalized_antecedent + normalized_consequent).join(" ")
|
|
post.update(tag_string: tags)
|
|
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
|