Files
danbooru/app/logical/post_edit.rb

166 lines
6.8 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:]]/, " ")
@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.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