Change the way BURs are processed. Before, we spawned a background job for each line of the BUR, then processed each job sequentially. Now, we process the entire BUR sequentially in a single background job. This means that: * BURs are truly sequential now. Before certain things like removing aliases weren't actually performed in a background job, so they were performed out-of-order before everything else in the BUR. * Before, if an alias or implication line failed, then subsequent alias or implication lines would still be processed. This was because each alias or implication line was queued as a separate job, so a failure of one job didn't block another. Now, if any alias or implication fails, the entire BUR will fail and stop processing after that line. This may be good or bad, depending on whether we actually need the BUR to be processed in order or not. * Before, BURs were processed inside a database transaction (except for the actual updating of posts). Now they're not. This is because we can't afford to hold transactions open while processing long-running aliases or implications. This means that if BUR fails in the middle when it is initially approved, it will be left in a half-complete state. Before it would be rolled back and left in a pending state with no changes performed. * Before, only one BUR at a time could be processed. If multiple BURs were approved at the same time, then they would queue up and be processed one at a time. Now, multiple BURs can be processed at the same time. This may be undesirable when processing large BURs, or BURs that must be approved in a specific order. * Before, large tag category changes could time out. This was because they weren't actually performed in a background job. Now they are, so they shouldn't time out.
277 lines
10 KiB
Ruby
277 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!
|
|
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
|
|
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(" ")).find_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
|