mirror of
https://gh.wpcy.net/https://github.com/discourse/discourse.git
synced 2026-05-28 13:16:34 +08:00
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.
950 lines
28 KiB
Ruby
Vendored
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
|