discourse/lib/post_revisor.rb
Régis Hanol 8e59848113
FIX: Allow selecting synonyms in composer tag chooser (#39974)
A recent change (#39072) made the composer tag autocomplete pass
`excludeSynonyms: true` so synonyms appeared as disabled rows with a
"Use canonical X instead" message. That blocked a legitimate workflow: a
forum where company names are the canonical tags and stock tickers are
the synonyms, so members can type the shorter ticker. The backend has
always remapped synonym names to their canonical target at save time, so
blocking the selection in the UI was overzealous.

This change keeps the disabled-row UX in admin surfaces where synonyms
genuinely can't be the target (tag groups form, embedding hosts,
webhooks, edit-category-tags, upsert-category/tags) — those still
explicitly pass `excludeSynonyms: true`. In the composer (mini-tag-
chooser) synonyms are now selectable, and the row shows an inline "→
canonical-tag" hint so the user understands what their typed tag will
become.

Fixing the autocomplete surfaced two latent bugs that the previous block
had been masking:

1. After saving, the topic header kept showing the typed synonym names
until a full page reload, because the response of
`TopicsController#update` and `#update_tags` only returned the
`BasicTopicSerializer` fields (no tags), and the client used its
in-memory pre-save tag list to update `topic.tags`. Both endpoints now
include the canonical `tags` array in the response whenever tags were
part of the request, and `Topic.update` / `Topic.updateTags` on the
client overwrite `topic.tags` with that list.

2. Submitting only synonyms of existing tags created an empty
`PostRevision` row. The controller's no-op detector compared raw tag
IDs, so synonym IDs always looked like a change. `PostRevisor` would
start a revision, `apply_tag_changes` would bail out after the canonical
remap matched the existing set, but the bumped version and empty
revision row had already been written. This is fixed at the PostRevisor
level via a new `tag_change_noop?` class method that resolves incoming
tag IDs/names through `COALESCE(target_tag_id, id)` and compares the
canonical set with `topic.tags`. When it matches, `revise!` deletes
`:tags` from `@fields` during normalization, so `should_revise?`
correctly short-circuits before any version bump. The same helper
replaces the controller-level no-op check and also subsumes the existing
"empty tags on empty topic" branch.

Specs cover: synonym row appears selectable with the hint, server
returns canonical tags after a synonym submission, no revision is
created when only synonyms of existing tags are submitted (object and
string forms), and the existing disabled-row UX still applies for
`one_per_topic` and parent-tag groups.
2026-05-22 18:47:59 +02:00

950 lines
28 KiB
Ruby
Vendored

# frozen_string_literal: true
require "edit_rate_limiter"
require "post_locker"
class PostRevisor
# Helps us track changes to a topic.
#
# It's passed to `track_topic_fields` callbacks so they can record if they
# changed a value or not. This is needed for things like custom fields.
class TopicChanges
attr_reader :topic, :user
attr_accessor :silent
def initialize(topic, user)
@topic = topic
@user = user
@changed = {}
@errored = false
@silent = false
end
def errored?
@errored
end
def guardian
@guardian ||= Guardian.new(@user)
end
def record_change(field_name, previous_val, new_val)
return if previous_val == new_val
diff[field_name] = [previous_val, new_val]
end
def check_result(res)
@errored = true if !res
end
def diff
@diff ||= {}
end
def apply_tag_changes(tag_value)
return unless guardian.can_tag_topics?
prev_tags = topic.tags.map(&:name).sort
return if tag_value.blank? && prev_tags.blank?
success = DiscourseTagging.tag_topic(topic, guardian, tag_value)
unless success
check_result(false)
return
end
new_tags = topic.tags.map(&:name).sort
return if prev_tags == new_tags
record_change("tags", prev_tags, new_tags)
DB.after_commit do
t = topic.reload
post = t.ordered_posts.first
notified_user_ids = [post.user_id, post.last_editor_id].uniq
persisted_tag_names = t.tags.pluck(:name)
added_tags = persisted_tag_names - prev_tags
removed_tags = prev_tags - persisted_tag_names
diff_tags = added_tags | removed_tags
if diff_tags.present? && !silent
Jobs.enqueue(:notify_tag_change, post_id: post.id, notified_user_ids:, diff_tags:)
PostRevisor.create_small_action_for_tag_changes(
topic: t,
user: user,
added_tags:,
removed_tags:,
)
end
end
end
end
POST_TRACKED_FIELDS = %w[
raw
cooked
edit_reason
user_id
wiki
post_type
locale
reply_to_post_number
]
# Extensions can inspect revision options via the `:post_edited` event payload.
attr_reader :category_changed, :post_revision, :opts
def initialize(post, topic = post.topic)
@post = post
@topic = topic
# Make sure we have only one Topic instance
post.topic = topic
end
def self.tracked_topic_fields
@@tracked_topic_fields ||= {}
@@tracked_topic_fields
end
def self.track_topic_field(field, &block)
tracked_topic_fields[field] = block
# Define it in the serializer unless it already has been defined
if PostRevisionSerializer.instance_methods(false).exclude?(:"#{field}_changes")
PostRevisionSerializer.add_compared_field(field)
end
end
def self.track_and_revise(topic_changes, field, attribute)
topic_changes.record_change(field, topic_changes.topic.public_send(field), attribute)
topic_changes.topic.public_send("#{field}=", attribute)
end
track_topic_field(:title) do |topic_changes, attribute|
track_and_revise topic_changes, :title, attribute
end
track_topic_field(:archetype) do |topic_changes, attribute|
if topic_changes.guardian.can_change_archetype?(topic_changes.topic, attribute)
track_and_revise topic_changes, :archetype, attribute
else
topic_changes.check_result(false)
end
end
track_topic_field(:category_id) do |tc, new_category_id, fields|
current_category = tc.topic.category
new_category =
(new_category_id.nil? || new_category_id.zero?) ? nil : Category.find(new_category_id)
if new_category.nil? && tc.topic.private_message?
tc.record_change("category_id", current_category.id, nil)
tc.topic.category_id = nil
elsif new_category.nil? || tc.guardian.can_move_topic_to_category?(new_category_id)
tags =
if fields[:tags].present?
input = fields[:tags]
if input.first.is_a?(String)
input
else
ids = input.filter_map { |t| t[:id]&.to_i }
names = input.filter_map { |t| t[:id].blank? && t[:name].presence }
names += Tag.visible(tc.guardian).where(id: ids).pluck(:name) if ids.present?
names
end
elsif fields.has_key?(:tags)
[]
else
tc.topic.tags.map(&:name)
end
if new_category &&
!DiscourseTagging.validate_category_tags(tc.guardian, tc.topic, new_category, tags)
tc.check_result(false)
next
end
tc.record_change("category_id", current_category&.id, new_category&.id)
tc.check_result(tc.topic.change_category_to_id(new_category_id, silent: tc.silent))
create_small_action_for_category_change(
topic: tc.topic,
user: tc.user,
old_category: current_category,
new_category: new_category,
)
end
end
track_topic_field(:tags) { |tc, tags| tc.apply_tag_changes(tags) }
track_topic_field(:featured_link) do |topic_changes, featured_link|
if !SiteSetting.topic_featured_link_enabled ||
!topic_changes.guardian.can_edit_featured_link?(topic_changes.topic.category_id)
topic_changes.check_result(false)
else
topic_changes.record_change("featured_link", topic_changes.topic.featured_link, featured_link)
topic_changes.topic.featured_link = featured_link
end
end
def self.create_small_action_for_category_change(topic:, user:, old_category:, new_category:)
if !old_category || !new_category || !SiteSetting.create_post_for_category_and_tag_changes ||
SiteSetting.whispers_allowed_groups.blank?
return
end
topic.add_moderator_post(
user,
I18n.t(
"topic_category_changed",
from: "##{old_category.slug_ref}",
to: "##{new_category.slug_ref}",
),
post_type: Post.types[:whisper],
action_code: "category_changed",
)
end
def self.create_small_action_for_tag_changes(topic:, user:, added_tags:, removed_tags:)
if !SiteSetting.create_post_for_category_and_tag_changes ||
SiteSetting.whispers_allowed_groups.blank?
return
end
topic.add_moderator_post(
user,
tags_changed_raw(added: added_tags, removed: removed_tags),
post_type: Post.types[:whisper],
action_code: "tags_changed",
custom_fields: {
tags_added: added_tags,
tags_removed: removed_tags,
},
)
end
def self.tags_changed_raw(added:, removed:)
if removed.present? && added.present?
I18n.t(
"topic_tag_changed.added_and_removed",
added: tag_list_to_raw(added),
removed: tag_list_to_raw(removed),
)
elsif added.present?
I18n.t("topic_tag_changed.added", added: tag_list_to_raw(added))
elsif removed.present?
I18n.t("topic_tag_changed.removed", removed: tag_list_to_raw(removed))
end
end
def self.tag_list_to_raw(tag_list)
tag_list.sort.map { |tag_name| "##{tag_name}" }.join(", ")
end
def self.tag_change_noop?(topic, incoming)
return topic.tags.empty? if incoming.blank?
ids =
if incoming.first.is_a?(String)
unique_names = incoming.map(&:downcase).uniq
found = Tag.where_name(unique_names).pluck(:id)
return false if found.size != unique_names.size
found
else
return false if incoming.any? { |t| t[:id].blank? }
incoming.filter_map { |t| t[:id]&.to_i }
end
return false if ids.blank?
canonical_ids = Tag.where(id: ids).pluck(Arel.sql("COALESCE(target_tag_id, id)")).uniq.sort
canonical_ids == topic.tags.pluck(:id).sort
end
# Revises a post with the given fields and options.
#
# @param editor [User] The user performing the revision
# @param fields [Hash] Hash of fields to update
# @param opts [Hash] Optional parameters for the revision
# @option opts [Time] :revised_at Changes the date of the revision
# @option opts [Boolean] :force_new_version Bypass grace period edit window
# @option opts [Boolean] :bypass_rate_limiter Bypass the max limits per day rate limiter
# @option opts [Boolean] :bypass_bump Do not bump the topic. Takes precedence over should_bump_topic plugin modifier, and any other should_bump? logic
# @option opts [Boolean] :skip_validations Ask ActiveRecord to skip validations
# @option opts [Boolean] :skip_revision Do not create a new PostRevision record
# @option opts [Boolean] :skip_staff_log Skip creating an entry in the staff action log
# @option opts [Boolean] :silent Don't send notifications to user
# @return [Boolean] Returns true if the revision was successful, false otherwise
def revise!(editor, fields, opts = {})
@editor = editor
@fields = fields.with_indifferent_access
@opts = opts
@topic_changes = TopicChanges.new(@topic, editor)
# some normalization
@fields[:raw] = cleanup_whitespaces(@fields[:raw]) if @fields.has_key?(:raw)
@fields[:user_id] = @fields[:user_id].to_i if @fields.has_key?(:user_id)
@fields[:category_id] = @fields[:category_id].to_i if @fields.has_key?(:category_id)
if @fields.has_key?(:tags) && PostRevisor.tag_change_noop?(@topic, @fields[:tags])
@fields.delete(:tags)
end
if @fields.has_key?(:reply_to_post_number)
normalized = @fields[:reply_to_post_number].presence
@fields[:reply_to_post_number] = normalized.nil? ? nil : normalized.to_i
return false unless resolve_reply_to_change
end
# always reset edit_reason unless provided, do not set to nil else
# previous reasons are lost
@fields.delete(:edit_reason) if @fields[:edit_reason].blank?
Post.plugin_permitted_update_params.each do |field, val|
val[:handler].call(@post, @fields[field]) if @fields.key?(field) && val[:plugin].enabled?
end
if !should_revise?
# the draft sequence is advanced here to handle the edge case where a
# user opens the composer to edit a post and makes some changes (which
# saves a draft), but then un-does the changes and clicks save. In this
# case, should_revise? returns false because nothing has really changed
# in the post, but we want to get rid of the draft so we advance the
# sequence.
advance_draft_sequence if !opts[:keep_existing_draft]
return false
end
@post.acting_user = @editor
@topic.acting_user = @editor
@revised_at = @opts[:revised_at] || Time.now
@last_version_at = @post.last_version_at || Time.now
if guardian.affected_by_slow_mode?(@topic) && !grace_period_edit? &&
SiteSetting.slow_mode_prevents_editing
@post.errors.add(:base, I18n.t("cannot_edit_on_slow_mode"))
return false
end
@version_changed = false
@post_successfully_saved = true
@validate_post = true
@validate_post = @opts[:validate_post] if @opts.has_key?(:validate_post)
@validate_post = !@opts[:skip_validations] if @opts.has_key?(:skip_validations)
@validate_topic = true
@validate_topic = @opts[:validate_topic] if @opts.has_key?(:validate_topic)
@validate_topic = !@opts[:skip_validations] if @opts.has_key?(:skip_validations)
@skip_revision = false
@skip_revision = @opts[:skip_revision] if @opts.has_key?(:skip_revision)
@silent = false
@silent = @opts[:silent] if @opts.has_key?(:silent)
@topic_changes.silent = @silent
old_raw = @post.raw
Post.transaction do
revise_post
yield if block_given?
# TODO: these callbacks are being called in a transaction
# it is kind of odd, because the callback is called "before_edit"
# but the post is already edited at this point
# Trouble is that much of the logic of should I edit? is deeper
# down so yanking this in front of the transaction will lead to
# false positive.
plugin_callbacks
revise_topic
advance_draft_sequence if !opts[:keep_existing_draft]
end
# bail out if the post or topic failed to save
return false if !successfully_saved_post_and_topic
# Lock the post by default if the appropriate setting is true
if SiteSetting.staff_edit_locks_post? && !@post.wiki? && @fields.has_key?("raw") &&
@editor.staff? && @editor != Discourse.system_user && !@post.user&.staff?
PostLocker.new(@post, @editor).lock
end
# We log staff/group moderator edits to posts
if (
@editor.staff? ||
(
@post.is_category_description? &&
guardian.can_edit_category_description?(@post.topic.category)
)
) && @editor.id != @post.user_id && @fields.has_key?("raw") && !@opts[:skip_staff_log]
StaffActionLogger.new(@editor).log_post_edit(@post, old_raw: old_raw)
end
# WARNING: do not pull this into the transaction
# it can fire events in sidekiq before the post is done saving
# leading to corrupt state
QuotedPost.extract_from(@post)
TopicLink.extract_from(@post)
# Skip heavy post processing operations if the only change was the post ownership (user merges)
only_user_id_changed =
if @fields.has_key?("user_id")
content_tracked_fields = POST_TRACKED_FIELDS - %w[user_id edit_reason]
content_tracked_fields.none? { |f| @post.previous_changes.has_key?(f) }
end
Topic.reset_highest(@topic.id) unless only_user_id_changed
post_process_post
alert_users
publish_changes
grant_badge
ReviewablePost.queue_for_review_if_possible(@post, @editor) if should_create_new_version?
successfully_saved_post_and_topic
end
def cleanup_whitespaces(raw)
raw.present? ? TextCleaner.normalize_whitespaces(raw).rstrip : ""
end
def should_revise?
post_changed? || topic_changed?
end
def post_changed?
POST_TRACKED_FIELDS.each do |field|
return true if @fields.has_key?(field) && @fields[field] != @post.public_send(field)
end
false
end
def topic_changed?
PostRevisor.tracked_topic_fields.keys.any? { |f| @fields.has_key?(f) }
end
def revise_post
if should_create_new_version?
revise_and_create_new_version
else
unless cached_original_raw
self.original_raw = @post.raw
self.original_cooked = @post.cooked
end
revise
end
end
def should_create_new_version?
return false if @skip_revision
# topic-only changes (without post content changes) should always create a new version
# since the grace period concept doesn't apply to metadata changes like tags
if topic_changed? && !post_changed?
# Allow hidden tag-only changes to update a previous hidden revision
# so that reverting hidden tag changes collapses the revisions
if only_hidden_tags_changed? &&
PostRevision.where(post_id: @post.id, number: @post.version).pick(:hidden)
return false
end
return true
end
edited_by_another_user? || flagged? || !grace_period_edit? || owner_changed? ||
force_new_version? || edit_reason_specified?
end
def edit_reason_specified?
@fields[:edit_reason].present? && @fields[:edit_reason] != @post.edit_reason
end
def flagged?
@post.is_flagged?
end
def edited_by_another_user?
@post.last_editor_id != @editor.id
end
def original_raw_key
"original_raw_#{(@last_version_at.to_f * 1000).to_i}#{@post.id}"
end
def original_cooked_key
"original_cooked_#{(@last_version_at.to_f * 1000).to_i}#{@post.id}"
end
def cached_original_raw
@cached_original_raw ||= Discourse.redis.get(original_raw_key)
end
def cached_original_cooked
@cached_original_cooked ||= Discourse.redis.get(original_cooked_key)
end
def original_raw
cached_original_raw || @post.raw
end
def original_raw=(val)
@cached_original_raw = val
Discourse.redis.setex(original_raw_key, SiteSetting.editing_grace_period + 1, val)
end
def original_cooked=(val)
@cached_original_cooked = val
Discourse.redis.setex(original_cooked_key, SiteSetting.editing_grace_period + 1, val)
end
def diff_size(before, after)
@diff_size ||=
begin
ONPDiff.new(before, after).short_diff.sum { |str, type| type == :common ? 0 : str.size }
rescue ONPDiff::DiffLimitExceeded
Float::INFINITY
end
end
def grace_period_edit?
return false if (@revised_at - @last_version_at) > SiteSetting.editing_grace_period.to_i
if new_raw = @fields[:raw]
max_diff = SiteSetting.editing_grace_period_max_diff.to_i
if @editor.staff? || (@editor.trust_level > 1)
max_diff = SiteSetting.editing_grace_period_max_diff_high_trust.to_i
end
if (original_raw.size - new_raw.size).abs > max_diff ||
diff_size(original_raw, new_raw) > max_diff
return false
end
end
true
end
def owner_changed?
@fields.has_key?(:user_id) && @fields[:user_id] != @post.user_id
end
def force_new_version?
@opts[:force_new_version] == true
end
def revise_and_create_new_version
@version_changed = true
@post.version += 1
@hidden_revision = only_hidden_tags_changed?
@post.public_version += 1 unless @hidden_revision
@post.last_version_at = @revised_at
revise
perform_edit
bump_topic
end
def revise
update_post
update_topic if topic_changed?
create_or_update_revision
remove_flags_and_unhide_post
end
USER_ACTIONS_TO_REMOVE = [UserAction::REPLY, UserAction::RESPONSE]
def update_post
if @fields.has_key?("user_id") && @fields["user_id"] != @post.user_id && @post.user_id != nil
prev_owner = User.find(@post.user_id)
new_owner = User.find(@fields["user_id"])
UserAction
.where(target_post_id: @post.id)
.where(user_id: prev_owner.id)
.where(action_type: USER_ACTIONS_TO_REMOVE)
.update_all(user_id: new_owner.id)
if @post.post_number == 1
UserAction
.where(target_topic_id: @post.topic_id)
.where(user_id: prev_owner.id)
.where(action_type: UserAction::NEW_TOPIC)
.update_all(user_id: new_owner.id)
end
end
POST_TRACKED_FIELDS.each do |field|
@post.public_send("#{field}=", @fields[field]) if @fields.has_key?(field)
end
@post.reply_to_user_id = @new_reply_to_parent&.user_id if @post.reply_to_post_number_changed?
@post.edit_reason = @fields[:edit_reason] if should_create_new_version?
@post.last_editor_id = @editor.id
@post.word_count = @fields[:raw].scan(/[[:word:]]+/).size if @fields.has_key?(:raw)
@post.self_edits += 1 if self_edit?
@post.extract_quoted_post_numbers
previous_reply_to_post_number = @post.reply_to_post_number_was
@post_successfully_saved = @post.save(validate: @validate_post)
@post.link_post_uploads
if @post_successfully_saved
@post.save_reply_relationships
cleanup_previous_reply_to_relationship
if @post.saved_change_to_reply_to_post_number?
@post.nested_replies_apply_reparent(previous_reply_to_post_number)
end
end
# we don't want to increment post count on user merge
if @post_successfully_saved && @editor.id != Discourse::SYSTEM_USER_ID
@editor.increment_post_edits_count
end
# post owner changed
if prev_owner && new_owner && prev_owner != new_owner
likes =
UserAction
.where(target_post_id: @post.id)
.where(user_id: prev_owner.id)
.where(action_type: UserAction::WAS_LIKED)
.update_all(user_id: new_owner.id)
private_message = @topic.private_message?
prev_owner_user_stat = prev_owner.user_stat
unless private_message
UserStatCountUpdater.decrement!(@post, user_stat: prev_owner_user_stat) if !@post.trashed?
prev_owner_user_stat.likes_received -= likes
end
if @post.created_at == prev_owner.user_stat.first_post_created_at
prev_owner_user_stat.update!(
first_post_created_at: prev_owner.posts.order("created_at ASC").first.try(:created_at),
)
end
new_owner_user_stat = new_owner.user_stat
unless private_message
UserStatCountUpdater.increment!(@post, user_stat: new_owner_user_stat) if !@post.trashed?
new_owner_user_stat.likes_received += likes
end
new_owner_user_stat.save!
end
end
def self_edit?
@editor == @post.user
end
def remove_flags_and_unhide_post
return if @opts[:deleting_post]
return unless editing_a_flagged_and_hidden_post?
flaggers = []
@post
.post_actions
.where(post_action_type_id: PostActionType.flag_types_without_additional_message.values)
.each do |action|
flaggers << action.user if action.user
action.remove_act!(Discourse.system_user)
end
@post.unhide!
PostActionNotifier.after_post_unhide(@post, flaggers)
end
def editing_a_flagged_and_hidden_post?
self_edit? && @post.hidden &&
@post.hidden_reason_id == Post.hidden_reasons[:flag_threshold_reached]
end
def update_topic
Topic.transaction do
PostRevisor.tracked_topic_fields.each do |f, cb|
if !@topic_changes.errored? && @fields.has_key?(f)
cb.call(@topic_changes, @fields[f], @fields)
end
end
unless @topic_changes.errored?
@topic_changes.check_result(@topic.save(validate: @validate_topic))
end
end
end
def create_or_update_revision
return if @skip_revision
# don't create an empty revision if something failed
return unless successfully_saved_post_and_topic
@version_changed ? create_revision : update_revision
end
def create_revision
modifications = post_changes.merge(topic_diff)
modifications["raw"][0] = cached_original_raw || modifications["raw"][0] if modifications["raw"]
if modifications["cooked"]
modifications["cooked"][0] = cached_original_cooked || modifications["cooked"][0]
end
@post_revision =
PostRevision.new(
user_id: @post.last_editor_id,
post_id: @post.id,
number: @post.version,
modifications:,
hidden: @hidden_revision,
)
@post_revision.silent = @silent
@post_revision.save!
end
def update_revision
return unless revision = PostRevision.find_by(post_id: @post.id, number: @post.version)
revision.user_id = @post.last_editor_id
modifications = post_changes.merge(topic_diff)
modifications.each_key do |field|
if revision.modifications.has_key?(field)
old_value = revision.modifications[field][0]
new_value = modifications[field][1]
if old_value.to_s != new_value.to_s
revision.modifications[field] = [old_value, new_value]
else
revision.modifications.delete(field)
end
else
revision.modifications[field] = modifications[field]
end
end
# should probably do this before saving the post!
if revision.modifications.empty?
hidden = revision.hidden
revision.destroy
@post.last_editor_id =
PostRevision.where(post_id: @post.id).order(number: :desc).pick(:user_id) || @post.user_id
@post.version -= 1
@post.public_version -= 1 unless hidden
@post.save(validate: @validate_post)
else
revision.save
end
end
def post_changes
@post.previous_changes.slice(*POST_TRACKED_FIELDS)
end
def topic_diff
@topic_changes.diff.with_indifferent_access
end
def perform_edit
return if bypass_rate_limiter?
EditRateLimiter.new(@editor).performed!
end
def bypass_rate_limiter?
@opts[:bypass_rate_limiter] == true
end
def bump_topic
return if !should_bump?
@topic.update_column(:bumped_at, Time.now)
TopicTrackingState.publish_muted(@topic)
TopicTrackingState.publish_unmuted(@topic)
TopicTrackingState.publish_latest(@topic)
end
def should_bump?
return false if @opts[:bypass_bump] == true
should_bump_topic_modifier_result =
DiscoursePluginRegistry.apply_modifier(
:should_bump_topic,
nil,
@post,
post_changes,
@topic_changes,
@editor,
)
return should_bump_topic_modifier_result if !should_bump_topic_modifier_result.nil?
return true if @post.is_first_post? && @post.wiki? && post_changes.any?
false
end
def only_hidden_tags_changed?
return false if post_changed?
changed_topic_fields = PostRevisor.tracked_topic_fields.keys.select { |f| @fields.key?(f) }
return false if changed_topic_fields != [:tags]
hidden_tag_names = DiscourseTagging.hidden_tag_names
return false if hidden_tag_names.blank?
new_tags = @fields[:tags] || []
current_tags = @topic.tags.map(&:name)
added_or_removed = (new_tags - current_tags) | (current_tags - new_tags)
(added_or_removed - hidden_tag_names).empty?
end
def plugin_callbacks
DiscourseEvent.trigger(:before_edit_post, @post, @fields)
DiscourseEvent.trigger(:validate_post, @post)
end
def revise_topic
return unless @post.is_first_post?
update_topic_excerpt
update_category_description
update_topic_locale
end
def update_topic_excerpt
@topic.update_excerpt(@post.excerpt_for_topic)
end
def update_category_description
return unless (category = Category.find_by(topic_id: @topic.id))
if @post.sync_category_description(category)
@category_changed = category
else
@post.errors.add(:base, I18n.t("category.errors.description_incomplete"))
end
end
def update_topic_locale
@topic.update(locale: @fields[:locale]) if @fields.has_key?(:locale)
end
def advance_draft_sequence
@post.advance_draft_sequence
end
def post_process_post
@post.invalidate_oneboxes = true
@post.trigger_post_process
DiscourseEvent.trigger(:post_edited, @post, topic_changed?, self)
end
def alert_users
return if @editor.id == Discourse::SYSTEM_USER_ID
Jobs.enqueue(:post_alert, post_id: @post.id)
end
def publish_changes
options =
if !topic_diff.empty? && !@topic_changes.errored?
{ reload_topic: true }
else
{}
end
DiscourseEvent.trigger(:before_post_publish_changes, post_changes, @topic_changes, options)
@post.publish_change_to_clients!(:revised, options)
end
def grant_badge
BadgeGranter.queue_badge_grant(Badge::Trigger::PostRevision, post: @post)
end
def successfully_saved_post_and_topic
@post_successfully_saved && !@topic_changes.errored?
end
def guardian
@guardian ||= Guardian.new(@editor)
end
def raw_changed?
@fields.has_key?(:raw) && @fields[:raw] != cached_original_raw && @post_successfully_saved
end
def topic_title_changed?
topic_changed? && @fields.has_key?(:title) && topic_diff.has_key?(:title) &&
!@topic_changes.errored?
end
def topic_category_changed?
topic_changed? && @fields.has_key?(:category_id) && topic_diff.has_key?(:category_id) &&
!@topic_changes.errored?
end
def topic_tags_changed?
topic_changed? && @fields.has_key?(:tags) && topic_diff.has_key?(:tags) &&
!@topic_changes.errored?
end
def reviewable_content_changed?
raw_changed? || topic_title_changed?
end
private
def resolve_reply_to_change
new_post_number = @fields[:reply_to_post_number]
# Resolve trashed prior parents too — their stale `PostReply` row and
# `reply_count` need cleanup on reparent.
@old_reply_to_parent =
if @post.reply_to_post_number.present?
Post.with_deleted.find_by(topic_id: @post.topic_id, post_number: @post.reply_to_post_number)
end
@new_reply_to_parent = nil
return true if new_post_number == @post.reply_to_post_number
if new_post_number.present?
@new_reply_to_parent =
Post.where(topic_id: @post.topic_id, post_number: new_post_number).first
unless valid_reply_to_parent?(@new_reply_to_parent)
@post.errors.add(:reply_to_post_number, I18n.t("post.errors.invalid_reply_to"))
return false
end
end
true
end
def valid_reply_to_parent?(parent)
return false if parent.blank?
return false if parent.id == @post.id
return false if @post.post_number.present? && parent.post_number >= @post.post_number
return false unless guardian.can_see?(parent)
true
end
def cleanup_previous_reply_to_relationship
return unless @post.saved_change_to_reply_to_post_number?
return if @old_reply_to_parent.blank?
old_post_number = @old_reply_to_parent.post_number
still_referenced =
@post.reply_to_post_number == old_post_number ||
@post.quoted_post_numbers.include?(old_post_number)
return if still_referenced
deleted = PostReply.where(post_id: @old_reply_to_parent.id, reply_post_id: @post.id).delete_all
if deleted > 0 && Topic.visible_post_types.include?(@post.post_type)
Post.where(id: @old_reply_to_parent.id).update_all(
"reply_count = GREATEST(reply_count - 1, 0)",
)
end
end
end