mirror of
https://github.com/discourse/discourse.git
synced 2026-08-06 03:25:17 +08:00
Related: https://meta.discourse.org/t/manual-localization-doesnt-seem-to-be-working/407319 Users have gotten tripped over and over again when manually inserting translations for posts, not knowing they need to set the post language first before the translation is shown (explained [here](https://meta.discourse.org/t/manual-localization-doesnt-seem-to-be-working/407319/7?u=nat)). This PR adds the ability to allow the user to set the post language in the modal when they are working with translations, highlighting the fact that they need to set the post language before translations can be shown
964 lines
29 KiB
Ruby
Vendored
964 lines
29 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
|
|
# @option opts [Boolean] :hidden Force the created revision to be hidden from non-staff users
|
|
# @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
|
|
|
|
@previous_last_editor_id = @post.last_editor_id
|
|
|
|
old_raw = @post.raw
|
|
|
|
@should_bump_topic = false
|
|
|
|
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
|
|
|
|
@should_bump_topic = @version_changed && successfully_saved_post_and_topic && should_bump?
|
|
revise_topic
|
|
advance_draft_sequence if !opts[:keep_existing_draft]
|
|
|
|
raise ActiveRecord::Rollback if !successfully_saved_post_and_topic
|
|
end
|
|
|
|
# bail out if the post or topic failed to save
|
|
return false if !successfully_saved_post_and_topic
|
|
|
|
bump_topic if @should_bump_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 the same user to collapse a hidden tag-only change into their
|
|
# previous hidden revision (e.g. reverting); a different user's change must
|
|
# still create its own revision so authorship is not folded into theirs.
|
|
if only_hidden_tags_changed? && !edited_by_another_user? &&
|
|
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 = @opts[:hidden] == true || only_hidden_tags_changed?
|
|
@post.public_version += 1 unless @hidden_revision
|
|
|
|
@post.last_version_at = @revised_at
|
|
|
|
revise
|
|
perform_edit if successfully_saved_post_and_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_changes = @post.previous_changes.slice(*POST_TRACKED_FIELDS) if @post_successfully_saved
|
|
@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)
|
|
|
|
if use_cached_original_for_created_revision?
|
|
if modifications["raw"]
|
|
modifications["raw"][0] = cached_original_raw || modifications["raw"][0]
|
|
end
|
|
|
|
if modifications["cooked"]
|
|
modifications["cooked"][0] = cached_original_cooked || modifications["cooked"][0]
|
|
end
|
|
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 use_cached_original_for_created_revision?
|
|
@previous_last_editor_id == @editor.id &&
|
|
!PostRevision.exists?(post_id: @post.id, number: @post.version - 1)
|
|
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_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
|
|
@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
|
|
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 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
|