Factor out most of the tag edit logic from the Post class to a new PostEdit class. The PostEdit class contains the logic for parsing tags and metatags from the tag edit string, and for determining which tags were added or removed by the edit. Fixes various bugs caused by not calculating the set of added or removed tags correctly, for example when tag category prefixes were used (e.g. `copy:touhou`) or when the same tag was added and removed in the same edit (e.g. `touhou -touhou`). Fixes #5123: Tag categorization prefixes bypass deprecation check Fixes #5126: Negating a deprecated tag will still cause the warning to show Fixes #3477: Remove tag validator triggering on tag category changes Fixes #4848: newpool: metatag doesn't parse correctly
167 lines
6.9 KiB
Ruby
167 lines
6.9 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
# A PostEdit represents a tag edit being performed on a post. It contains most
|
|
# of the logic for performing a tag edit, including methods for parsing the tag
|
|
# string into tags and metatags, methods for determining which tags were added
|
|
# or removed by the edit, and methods for calculating the final list of tags.
|
|
class PostEdit
|
|
extend Memoist
|
|
|
|
Tag = Struct.new(:name, :negated, keyword_init: true)
|
|
Metatag = Struct.new(:name, :value, keyword_init: true)
|
|
|
|
# Metatags that change the tag's category: `art:bkub`, `char:chen`, `copy:touhou`, `gen:1girl`, `meta:animated`.
|
|
CATEGORIZATION_METATAGS = TagCategory.mapping.keys
|
|
|
|
# Pre-metatags affect the post itself, so they must be applied before the post is saved.
|
|
PRE_METATAGS = %w[parent -parent rating source] + CATEGORIZATION_METATAGS
|
|
|
|
# Post-metatags rely on the post's ID, so they must be applied after the post is saved to ensure the ID has been created.
|
|
POST_METATAGS = %w[newpool pool -pool favgroup -favgroup fav -fav child -child upvote downvote disapproved status -status]
|
|
|
|
METATAGS = PRE_METATAGS + POST_METATAGS
|
|
METATAG_NAME_REGEX = /(#{METATAGS.join("|")}):/io
|
|
|
|
private attr_reader :post, :current_tag_names, :old_tag_names, :new_tag_string, :parser
|
|
private delegate :accept, :expect, :error, :skip, :zero_or_more, :one_of, to: :parser
|
|
|
|
# @param post [Post] The post being edited.
|
|
# @param current_tag_string [String] The space-separated list of tags currently on the post.
|
|
# @param old_tag_string [String] The space-separated list of tags the user saw before the edit.
|
|
# @param new_tag_string [String] The space-separated list of tags after the edit.
|
|
def initialize(post, current_tag_string, old_tag_string, new_tag_string)
|
|
@post = post
|
|
@current_tag_names = current_tag_string.to_s.split
|
|
@old_tag_names = old_tag_string.to_s.split
|
|
@new_tag_string = new_tag_string.to_s.gsub(/[[:space:]]/, " ").strip
|
|
@parser = StringParser.new(@new_tag_string)
|
|
end
|
|
|
|
concerning :HelperMethods do
|
|
# @return [Array<String>] The final list of tags on the post after the edit.
|
|
def tag_names
|
|
tag_names = current_tag_names + effective_added_tag_names - user_removed_tag_names
|
|
tag_names = post.add_automatic_tags(tag_names)
|
|
tag_names = ::Tag.convert_cosplay_tags(tag_names)
|
|
tag_names += ::Tag.automatic_tags_for(tag_names)
|
|
tag_names += TagImplication.tags_implied_by(tag_names).map(&:name)
|
|
tag_names.uniq.sort
|
|
end
|
|
|
|
# @return [Array<String>] The list of tags in the edited tag string, including regular tags and tags with a category prefix (e.g. `artist:bkub`)
|
|
def new_tag_names
|
|
tag_terms.reject(&:negated).map(&:name) + tag_categorization_terms.map(&:value)
|
|
end
|
|
|
|
# @return [Array<Tag>] The list of tags in the edited tag string. Includes negated and non-negated tags, but not tags with a category prefix.
|
|
def tag_terms
|
|
terms.grep(Tag)
|
|
end
|
|
|
|
# @return [Array<Metatag>] The list of metatags in the edited tag string.
|
|
def metatag_terms
|
|
terms.grep(Metatag)
|
|
end
|
|
|
|
# @return [Array<Metatag>] The list of pre-save metatags in the edit (metatags that are applied before the post is saved).
|
|
def pre_metatag_terms
|
|
metatag_terms.select { |term| term.name.in?(PRE_METATAGS) }
|
|
end
|
|
|
|
# @return [Array<Metatag>] The list of post-save metatags in the edit (metatags that are applied after the post is saved).
|
|
def post_metatag_terms
|
|
metatag_terms.select { |term| term.name.in?(POST_METATAGS) }
|
|
end
|
|
|
|
# @return [Array<Metatag>] The list of tags with a category prefix (e.g. `artist:bkub`).
|
|
def tag_categorization_terms
|
|
metatag_terms.select { |term| term.name.in?(TagCategory.categories) }
|
|
end
|
|
|
|
# @return [Array<String>] The list of tags actually added by the user, excluding invalid or deprecated tags.
|
|
def effective_added_tag_names
|
|
user_added_tag_names - invalid_added_tags.map(&:name) - deprecated_added_tag_names
|
|
end
|
|
|
|
# @return [Array<String>] The list of tags the user is trying to add. Includes tags that won't
|
|
# actually be added, such as invalid or deprecated tags. Does not include tags not explicitly
|
|
# added by the user, such as implied or automatic tags.
|
|
def user_added_tag_names
|
|
TagAlias.to_aliased(new_tag_names - old_tag_names - user_removed_tag_names).uniq.sort
|
|
end
|
|
|
|
# @return [Array<String>] The list of tags the user is trying to remove. Includes tags that
|
|
# won't actually be removed, such as implied tags, automatic tags, and nonexistent tags.
|
|
def user_removed_tag_names
|
|
(explicit_removed_tag_names + implicit_removed_tag_names).uniq.sort
|
|
end
|
|
|
|
# @return [Array<String>] The list of tags explicitly removed using the '-' operator (e.g. `-tagme`).
|
|
def explicit_removed_tag_names
|
|
TagAlias.to_aliased(tag_terms.select(&:negated).map(&:name))
|
|
end
|
|
|
|
# @return [Array<String>] The list of tags implicitly removed by being deleted from the tag string (e.g. `1girl tagme` => `1girl`)
|
|
def implicit_removed_tag_names
|
|
old_tag_names - new_tag_names
|
|
end
|
|
|
|
# @return [Array<Tag>] The list of user-added tags that have invalid names.
|
|
def invalid_added_tags
|
|
user_added_tag_names.map { |name| ::Tag.new(name: name) }.select { |tag| tag.invalid?(:name) }
|
|
end
|
|
|
|
# @return [Array<String>] The list of user-added tags that are deprecated.
|
|
def deprecated_added_tag_names
|
|
::Tag.deprecated.where(name: user_added_tag_names).map(&:name)
|
|
end
|
|
end
|
|
|
|
concerning :ParserMethods do
|
|
# @return [Array<Tag, Metatag>] The list of tags and metatags in the edit.
|
|
def terms
|
|
zero_or_more { skip(/[[:space:]]+/); term }
|
|
end
|
|
|
|
private def term
|
|
one_of([method(:tag), method(:metatag)])
|
|
end
|
|
|
|
private def tag
|
|
negated = accept("-").present?
|
|
error("Invalid tag name") if accept(METATAG_NAME_REGEX)
|
|
name = expect(/[^[:space:]]+/)
|
|
|
|
Tag.new(name: name.downcase, negated: negated)
|
|
end
|
|
|
|
private def metatag
|
|
name = expect(METATAG_NAME_REGEX)
|
|
value = quoted_string
|
|
|
|
name = name.delete_suffix(":").downcase
|
|
name = TagCategory.short_name_mapping.fetch(name, name) # 'art:bkub' => 'artist:bkub'
|
|
value = value.downcase unless name.in?(["newpool", "source"])
|
|
value = value.gsub(/[[:space:]]/, "_") unless name == "source"
|
|
|
|
Metatag.new(name: name, value: value)
|
|
end
|
|
|
|
private def quoted_string
|
|
if accept('"')
|
|
string = expect(/(\\"|[^"])*/).gsub(/\\"/, '"') # handle backslash escaped quotes
|
|
expect('"')
|
|
string
|
|
elsif accept("'")
|
|
string = expect(/(\\'|[^'])*/).gsub(/\\'/, "'") # handle backslash escaped quotes
|
|
expect("'")
|
|
string
|
|
else
|
|
expect(/(\\ |[^ ])*/).gsub(/\\ /, " ") # handle backslash escaped spaces
|
|
end
|
|
end
|
|
end
|
|
|
|
memoize :tag_names, :new_tag_names, :user_added_tag_names, :user_removed_tag_names, :invalid_added_tags, :deprecated_added_tag_names, :terms, :tag_terms, :metatag_terms
|
|
end
|