mirror of
https://github.com/discourse/discourse.git
synced 2024-11-23 09:17:08 +08:00
21ec70b509
* FIX: Displaying the wrong number of minimum tags in the composer When the minimum number of tags set for the category is larger than the minimum number of tags set in the category tag-groups, the composer was displaying the wrong value. This commit fixes the value displayed in the composer to show the max value between the required for the category and the tag-groups set for the category. This bug was reported on Meta in https://meta.discourse.org/t/tags-from-multiple-tag-groups-required-only-suggest-select-at-least-one-tag/263817 * FIX: Limiting tags in categories not working as expected When a category was restricted to a tag group A, which was set to only allow one tag from the group per topic, selecting a tag belonging only to A returned other tags from A that also belonged to other group/s (if any). Example: Tag group A: alpha, beta, gamma, epsilon, delta Tag group B: alpha, beta, gamma Both tag groups set to only allow one tag from the group per topic. If Category 1 was set to only allow tags from the tag group A, and the first tag selected was epsilon, then, because they also belonged to tag group B, the tags alpha, beta, and gamma were still returned as valid options when they should not be. This commit ensures that once a tag from a tag group that restricts its tags to one per topic is selected, no other tag from this group is returned. This bug was reported on Meta in https://meta.discourse.org/t/limiting-tags-to-categories-not-working-as-expected/263143. * FIX: Moving topics does not prompt to add required tag for new category When a topic moved from a category to another, the tag requirements of the new category were not being checked. This allowed a topic to be created and moved to a category: - that limited the tags to a tag group, with the topic containing tags not allowed. - that required N tags from a tag group, with the topic not containing the required tags. This bug was reported on Meta in https://meta.discourse.org/t/moving-tagged-topics-does-not-prompt-to-add-required-tag-for-new-category/264138. * FIX: Editing topics with tag groups from parents allows incorrect tagging When there was a combination between parent tags defined in a tag group set to allow only one tag from the group per topic, and other tag groups relying on this restriction to combine the children tag types with the parent tag, editing a topic could allow the user to insert an invalid combination of these tags. Example: Automakers tag group: landhover, toyota - group set to limit one tag from the group per topic Toyota models group: land-cruiser, hilux, corolla Landhover models group: evoque, defender, discovery If a topic was initially set up with the tags toyota, land-cruiser it was possible to edit it by removing the tag toyota and adding the tag landhover and other landhover model tags like evoque for example. In this case, the topic would end up with the tags toyota, land-cruiser, landhover, evoque because Discourse will automatically insert the missing parent tag toyota when it detects the tag land-cruiser. This combination of tags would violate the restriction specified in the Automakers tag group resulting in an invalid combination of tags. This commit enforces that the "one tag from the group per topic" restriction is verified before updating the topic tags and also make sure the verification checks the compatibility of parent tags that would be automatically inserted. After the changes, the user will receive an error similar to: The tags land-cruiser, landhover cannot be used simultaneously. Please include only one of them.
766 lines
22 KiB
Ruby
766 lines
22 KiB
Ruby
# 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
|
|
|
|
def initialize(topic, user)
|
|
@topic = topic
|
|
@user = user
|
|
@changed = {}
|
|
@errored = 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
|
|
end
|
|
|
|
POST_TRACKED_FIELDS = %w[raw cooked edit_reason user_id wiki post_type]
|
|
|
|
attr_reader :category_changed, :post_revision
|
|
|
|
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
|
|
unless PostRevisionSerializer.instance_methods(false).include?("#{field}_changes".to_sym)
|
|
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|
|
|
track_and_revise topic_changes, :archetype, attribute
|
|
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 = fields[:tags] || tc.topic.tags.map(&:name)
|
|
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))
|
|
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) do |tc, tags|
|
|
if tc.guardian.can_tag_topics?
|
|
prev_tags = tc.topic.tags.map(&:name)
|
|
next if tags.blank? && prev_tags.blank?
|
|
if !DiscourseTagging.tag_topic_by_names(tc.topic, tc.guardian, tags)
|
|
tc.check_result(false)
|
|
next
|
|
end
|
|
if prev_tags.sort != tags.sort
|
|
tc.record_change("tags", prev_tags, tags)
|
|
DB.after_commit do
|
|
post = tc.topic.ordered_posts.first
|
|
notified_user_ids = [post.user_id, post.last_editor_id].uniq
|
|
|
|
added_tags = tags - prev_tags
|
|
removed_tags = prev_tags - tags
|
|
|
|
if !SiteSetting.disable_tags_edit_notifications
|
|
Jobs.enqueue(
|
|
:notify_tag_change,
|
|
post_id: post.id,
|
|
notified_user_ids: notified_user_ids,
|
|
diff_tags: (added_tags | removed_tags),
|
|
)
|
|
end
|
|
|
|
create_small_action_for_tag_changes(
|
|
topic: tc.topic,
|
|
user: tc.user,
|
|
added_tags: added_tags,
|
|
removed_tags: removed_tags,
|
|
)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
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:)
|
|
return if !SiteSetting.create_post_for_category_and_tag_changes
|
|
|
|
topic.add_moderator_post(
|
|
user,
|
|
I18n.t(
|
|
"topic_category_changed",
|
|
from: category_name_raw(old_category),
|
|
to: category_name_raw(new_category),
|
|
),
|
|
post_type: Post.types[:small_action],
|
|
action_code: "category_changed",
|
|
)
|
|
end
|
|
|
|
def self.category_name_raw(category)
|
|
"##{CategoryHashtagDataSource.category_to_hashtag_item(category).ref}"
|
|
end
|
|
|
|
def self.create_small_action_for_tag_changes(topic:, user:, added_tags:, removed_tags:)
|
|
return if !SiteSetting.create_post_for_category_and_tag_changes
|
|
|
|
topic.add_moderator_post(
|
|
user,
|
|
tags_changed_raw(added: added_tags, removed: removed_tags),
|
|
post_type: Post.types[:small_action],
|
|
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
|
|
|
|
# AVAILABLE OPTIONS:
|
|
# - revised_at: changes the date of the revision
|
|
# - force_new_version: bypass grace period edit window
|
|
# - bypass_rate_limiter:
|
|
# - bypass_bump: do not bump the topic, even if last post
|
|
# - skip_validations: ask ActiveRecord to skip validations
|
|
# - skip_revision: do not create a new PostRevision record
|
|
# - skip_staff_log: skip creating an entry in the staff action log
|
|
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)
|
|
|
|
# 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)
|
|
|
|
@post.incoming_email&.update(imap_sync: true) if @post.incoming_email&.imap_uid
|
|
|
|
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
|
|
|
|
# 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)
|
|
|
|
# This must be done before post_process_post, because that uses
|
|
# post upload security status to cook URLs.
|
|
@post.update_uploads_secure_status(source: "post revisor")
|
|
|
|
post_process_post
|
|
|
|
update_topic_word_counts
|
|
alert_users
|
|
publish_changes
|
|
grant_badge
|
|
|
|
TopicLink.extract_from(@post)
|
|
|
|
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
|
|
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 }
|
|
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
|
|
@post.public_version += 1
|
|
@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.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
|
|
|
|
@post_successfully_saved = @post.save(validate: @validate_post)
|
|
@post.link_post_uploads
|
|
@post.save_reply_relationships
|
|
|
|
@editor.increment_post_edits_count if @post_successfully_saved
|
|
|
|
# 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_custom.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_changes.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.create!(
|
|
user_id: @post.last_editor_id,
|
|
post_id: @post.id,
|
|
number: @post.version,
|
|
modifications: modifications,
|
|
hidden: only_hidden_tags_changed?,
|
|
)
|
|
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_changes.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?
|
|
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
|
|
@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
|
|
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 bypass_bump? || !is_last_post?
|
|
@topic.update_column(:bumped_at, Time.now)
|
|
TopicTrackingState.publish_muted(@topic)
|
|
TopicTrackingState.publish_unmuted(@topic)
|
|
TopicTrackingState.publish_latest(@topic)
|
|
end
|
|
|
|
def bypass_bump?
|
|
!@post_successfully_saved || @topic_changes.errored? || @opts[:bypass_bump] == true ||
|
|
@post.whisper? || only_hidden_tags_changed?
|
|
end
|
|
|
|
def only_hidden_tags_changed?
|
|
return false if (hidden_tag_names = DiscourseTagging.hidden_tag_names).blank?
|
|
|
|
modifications = post_changes.merge(@topic_changes.diff)
|
|
if modifications.keys.size == 1 && (tags_diff = modifications["tags"]).present?
|
|
a, b = tags_diff[0] || [], tags_diff[1] || []
|
|
changed_tags = ((a + b) - (a & b)).map(&:presence).compact
|
|
return true if (changed_tags - hidden_tag_names).empty?
|
|
end
|
|
|
|
false
|
|
end
|
|
|
|
def is_last_post?
|
|
!Post.where(topic_id: @topic.id).where("post_number > ?", @post.post_number).exists?
|
|
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)
|
|
|
|
doc = Nokogiri::HTML5.fragment(@post.cooked)
|
|
doc.css("img").remove
|
|
|
|
if html = doc.css("p").first&.inner_html&.strip
|
|
new_description = html unless html.starts_with?(Category.post_template[0..50])
|
|
category.update_column(:description, new_description)
|
|
@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, self.topic_changed?, self)
|
|
end
|
|
|
|
def update_topic_word_counts
|
|
DB.exec(
|
|
"UPDATE topics
|
|
SET word_count = (
|
|
SELECT SUM(COALESCE(posts.word_count, 0))
|
|
FROM posts
|
|
WHERE posts.topic_id = :topic_id
|
|
)
|
|
WHERE topics.id = :topic_id",
|
|
topic_id: @topic.id,
|
|
)
|
|
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_changes.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
|
|
end
|