Files
danbooru/app/logical/post_edit.rb
evazion bbe748bd2b posts: factor out post edit logic.
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
2022-04-29 17:13:33 -05:00

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