diff --git a/app/assets/javascripts/discourse/controllers/change-timestamp.js.es6 b/app/assets/javascripts/discourse/controllers/change-timestamp.js.es6
new file mode 100644
index 000000000..1d55e74cf
--- /dev/null
+++ b/app/assets/javascripts/discourse/controllers/change-timestamp.js.es6
@@ -0,0 +1,58 @@
+import ModalFunctionality from 'discourse/mixins/modal-functionality';
+import computed from 'ember-addons/ember-computed-decorators';
+
+// Modal related to changing the timestamp of posts
+export default Ember.Controller.extend(ModalFunctionality, {
+ needs: ['topic'],
+
+ topicController: Em.computed.alias('controllers.topic'),
+ saving: false,
+ date: '',
+ time: '',
+
+ @computed('saving')
+ buttonTitle(saving) {
+ return saving ? I18n.t('saving') : I18n.t('topic.change_timestamp.action');
+ },
+
+ @computed('date', 'time')
+ createdAt(date, time) {
+ return moment(date + ' ' + time, 'YYYY-MM-DD HH:mm:ss');
+ },
+
+ @computed('createdAt')
+ validTimestamp(createdAt) {
+ return moment().diff(createdAt, 'minutes') < 0;
+ },
+
+ @computed('saving', 'date', 'validTimestamp')
+ buttonDisabled() {
+ if (this.get('saving') || this.get('validTimestamp')) return true;
+ return Ember.isEmpty(this.get('date'));
+ },
+
+ onShow: function() {
+ this.setProperties({
+ date: moment().format('YYYY-MM-DD')
+ });
+ },
+
+ actions: {
+ changeTimestamp: function() {
+ this.set('saving', true);
+ const self = this;
+
+ Discourse.Topic.changeTimestamp(
+ this.get('topicController.model.id'),
+ this.get('createdAt').unix()
+ ).then(function() {
+ self.send('closeModal');
+ self.setProperties({ date: '', time: '', saving: false });
+ }).catch(function() {
+ self.flash(I18n.t('topic.change_timestamp.error'), 'alert-error');
+ self.set('saving', false);
+ });
+ return false;
+ }
+ }
+});
diff --git a/app/assets/javascripts/discourse/models/topic.js.es6 b/app/assets/javascripts/discourse/models/topic.js.es6
index 073784934..88d99fa3b 100644
--- a/app/assets/javascripts/discourse/models/topic.js.es6
+++ b/app/assets/javascripts/discourse/models/topic.js.es6
@@ -480,6 +480,17 @@ Topic.reopenClass({
return promise;
},
+ changeTimestamp(topicId, timestamp) {
+ const promise = Discourse.ajax("/t/" + topicId + '/change-timestamp', {
+ type: 'PUT',
+ data: { timestamp: timestamp },
+ }).then(function(result) {
+ if (result.success) return result;
+ promise.reject(new Error("error updating timestamp of topic"));
+ });
+ return promise;
+ },
+
bulkOperation(topics, operation) {
return Discourse.ajax("/topics/bulk", {
type: 'PUT',
diff --git a/app/assets/javascripts/discourse/routes/topic.js.es6 b/app/assets/javascripts/discourse/routes/topic.js.es6
index c79ccbe5c..04d3ae202 100644
--- a/app/assets/javascripts/discourse/routes/topic.js.es6
+++ b/app/assets/javascripts/discourse/routes/topic.js.es6
@@ -60,6 +60,10 @@ const TopicRoute = Discourse.Route.extend({
this.controllerFor('modal').set('modalClass', 'edit-auto-close-modal');
},
+ showChangeTimestamp() {
+ showModal('change-timestamp', { model: this.modelFor('topic'), title: 'topic.change_timestamp.title' });
+ },
+
showFeatureTopic() {
showModal('featureTopic', { model: this.modelFor('topic'), title: 'topic.feature_topic.title' });
this.controllerFor('modal').set('modalClass', 'feature-topic-modal');
diff --git a/app/assets/javascripts/discourse/templates/modal/change-timestamp.hbs b/app/assets/javascripts/discourse/templates/modal/change-timestamp.hbs
new file mode 100644
index 000000000..c76c58e12
--- /dev/null
+++ b/app/assets/javascripts/discourse/templates/modal/change-timestamp.hbs
@@ -0,0 +1,18 @@
+
+
+ {{i18n 'topic.change_timestamp.instructions'}}
+
+
+
+ {{i18n 'topic.change_timestamp.invalid_timestamp'}}
+
+
+
+
+
+
diff --git a/app/assets/javascripts/discourse/templates/topic-admin-menu.hbs b/app/assets/javascripts/discourse/templates/topic-admin-menu.hbs
index 004df5255..2f190df21 100644
--- a/app/assets/javascripts/discourse/templates/topic-admin-menu.hbs
+++ b/app/assets/javascripts/discourse/templates/topic-admin-menu.hbs
@@ -38,6 +38,10 @@
{{/if}}
{{/unless}}
+
+ {{d-button action="showChangeTimestamp" icon="calendar" label="topic.change_timestamp.title" class="btn-admin"}}
+
+
{{#if model.archived}}
{{d-button action="toggleArchived" icon="folder" label="topic.actions.unarchive" class="btn-admin"}}
diff --git a/app/controllers/topics_controller.rb b/app/controllers/topics_controller.rb
index 01d960e54..f728f35c6 100644
--- a/app/controllers/topics_controller.rb
+++ b/app/controllers/topics_controller.rb
@@ -24,6 +24,7 @@ class TopicsController < ApplicationController
:bulk,
:reset_new,
:change_post_owners,
+ :change_timestamps,
:bookmark,
:unsubscribe]
@@ -375,6 +376,22 @@ class TopicsController < ApplicationController
end
end
+ def change_timestamps
+ params.require(:topic_id)
+ params.require(:timestamp)
+
+ guardian.ensure_can_change_post_owner!
+
+ begin
+ PostTimestampChanger.new( topic_id: params[:topic_id].to_i,
+ timestamp: params[:timestamp].to_i ).change!
+
+ render json: success_json
+ rescue ActiveRecord::RecordInvalid
+ render json: failed_json, status: 422
+ end
+ end
+
def clear_pin
topic = Topic.find_by(id: params[:topic_id].to_i)
guardian.ensure_can_see!(topic)
diff --git a/app/services/post_timestamp_changer.rb b/app/services/post_timestamp_changer.rb
new file mode 100644
index 000000000..65b056128
--- /dev/null
+++ b/app/services/post_timestamp_changer.rb
@@ -0,0 +1,43 @@
+class PostTimestampChanger
+ def initialize(params)
+ @topic = Topic.with_deleted.find(params[:topic_id])
+ @posts = @topic.posts
+ @timestamp = Time.at(params[:timestamp])
+ @time_difference = calculate_time_difference
+ end
+
+ def change!
+ ActiveRecord::Base.transaction do
+ update_topic
+
+ @posts.each do |post|
+ if post.is_first_post?
+ update_post(post, @timestamp)
+ else
+ update_post(post, Time.at(post.created_at.to_f + @time_difference))
+ end
+ end
+ end
+
+ # Burst the cache for stats
+ [AdminDashboardData, About].each { |klass| $redis.del klass.stats_cache_key }
+ end
+
+ private
+
+ def calculate_time_difference
+ @timestamp - @topic.created_at
+ end
+
+ def update_topic
+ @topic.update_attributes(
+ created_at: @timestamp,
+ updated_at: @timestamp,
+ bumped_at: @timestamp
+ )
+ end
+
+ def update_post(post, timestamp)
+ post.update_attributes(created_at: timestamp, updated_at: timestamp)
+ end
+end
diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml
index 520b881c7..d85e2a46d 100644
--- a/config/locales/client.en.yml
+++ b/config/locales/client.en.yml
@@ -1241,6 +1241,13 @@ en:
other: "Please choose the new owner of the {{count}} posts by {{old_user}}."
instructions_warn: "Note that any notifications about this post will not be transferred to the new user retroactively.
Warning: Currently, no post-dependent data is transferred over to the new user. Use with caution."
+ change_timestamp:
+ title: "Change Timestamp"
+ action: "change timestamp"
+ invalid_timestamp: "Timestamp cannot be in the future."
+ error: "There was an error changing the timestamp of the topic."
+ instructions: "Please select the new timestamp of the topic. Posts in the topic will be updated to have the same time difference."
+
multi_select:
select: 'select'
selected: 'selected ({{count}})'
diff --git a/config/routes.rb b/config/routes.rb
index 9b0e6cc40..235f82432 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -469,6 +469,7 @@ Discourse::Application.routes.draw do
post "t/:topic_id/move-posts" => "topics#move_posts", constraints: {topic_id: /\d+/}
post "t/:topic_id/merge-topic" => "topics#merge_topic", constraints: {topic_id: /\d+/}
post "t/:topic_id/change-owner" => "topics#change_post_owners", constraints: {topic_id: /\d+/}
+ put "t/:topic_id/change-timestamp" => "topics#change_timestamps", constraints: {topic_id: /\d+/}
delete "t/:topic_id/timings" => "topics#destroy_timings", constraints: {topic_id: /\d+/}
put "t/:topic_id/bookmark" => "topics#bookmark", constraints: {topic_id: /\d+/}
put "t/:topic_id/remove_bookmarks" => "topics#remove_bookmarks", constraints: {topic_id: /\d+/}
diff --git a/spec/controllers/topics_controller_spec.rb b/spec/controllers/topics_controller_spec.rb
index 8994e5de9..8b31305ee 100644
--- a/spec/controllers/topics_controller_spec.rb
+++ b/spec/controllers/topics_controller_spec.rb
@@ -263,6 +263,45 @@ describe TopicsController do
end
end
+ context 'change_timestamps' do
+ let(:params) { { topic_id: 1, timestamp: Time.zone.now } }
+
+ it 'needs you to be logged in' do
+ expect { xhr :put, :change_timestamps, params }.to raise_error(Discourse::NotLoggedIn)
+ end
+
+ [:moderator, :trust_level_4].each do |user|
+ describe "forbidden to #{user}" do
+ let!(user) { log_in(user) }
+
+ it 'correctly denies' do
+ xhr :put, :change_timestamps, params
+ expect(response).to be_forbidden
+ end
+ end
+ end
+
+ describe 'changing timestamps' do
+ let!(:admin) { log_in(:admin) }
+ let(:old_timestamp) { Time.zone.now }
+ let(:new_timestamp) { old_timestamp - 1.day }
+ let!(:topic) { Fabricate(:topic, created_at: old_timestamp) }
+ let!(:p1) { Fabricate(:post, topic_id: topic.id, created_at: old_timestamp) }
+ let!(:p2) { Fabricate(:post, topic_id: topic.id, created_at: old_timestamp + 1.day) }
+
+ it 'raises an error with a missing parameter' do
+ expect { xhr :put, :change_timestamps, topic_id: 1 }.to raise_error(ActionController::ParameterMissing)
+ end
+
+ it 'should update the timestamps of selected posts' do
+ xhr :put, :change_timestamps, topic_id: topic.id, timestamp: new_timestamp.to_f
+ expect(topic.reload.created_at.to_s).to eq(new_timestamp.to_s)
+ expect(p1.reload.created_at.to_s).to eq(new_timestamp.to_s)
+ expect(p2.reload.created_at.to_s).to eq(old_timestamp.to_s)
+ end
+ end
+ end
+
context 'clear_pin' do
it 'needs you to be logged in' do
expect { xhr :put, :clear_pin, topic_id: 1 }.to raise_error(Discourse::NotLoggedIn)
diff --git a/spec/services/post_timestamp_changer_spec.rb b/spec/services/post_timestamp_changer_spec.rb
new file mode 100644
index 000000000..506459687
--- /dev/null
+++ b/spec/services/post_timestamp_changer_spec.rb
@@ -0,0 +1,55 @@
+require 'spec_helper'
+
+describe PostTimestampChanger do
+ describe "change!" do
+ let(:old_timestamp) { Time.zone.now }
+ let(:new_timestamp) { old_timestamp + 1.day }
+ let!(:topic) { Fabricate(:topic, created_at: old_timestamp) }
+ let!(:p1) { Fabricate(:post, topic: topic, created_at: old_timestamp) }
+ let!(:p2) { Fabricate(:post, topic: topic, created_at: old_timestamp + 1.day) }
+ let(:params) { { topic_id: topic.id, timestamp: new_timestamp.to_f } }
+
+ it 'changes the timestamp of the topic and opening post' do
+ PostTimestampChanger.new(params).change!
+
+ topic.reload
+ [:created_at, :updated_at, :bumped_at].each do |column|
+ expect(topic.public_send(column).to_s).to eq(new_timestamp.to_s)
+ end
+
+ p1.reload
+ [:created_at, :updated_at].each do |column|
+ expect(p1.public_send(column).to_s).to eq(new_timestamp.to_s)
+ end
+ end
+
+ describe 'predated timestamp' do
+ it 'updates the timestamp of posts in the topic with the time difference applied' do
+ PostTimestampChanger.new(params).change!
+
+ p2.reload
+ [:created_at, :updated_at].each do |column|
+ expect(p2.public_send(column).to_s).to eq((old_timestamp + 2.day).to_s)
+ end
+ end
+ end
+
+ describe 'backdated timestamp' do
+ let(:new_timestamp) { old_timestamp - 1.day }
+
+ it 'updates the timestamp of posts in the topic with the time difference applied' do
+ PostTimestampChanger.new(params).change!
+
+ p2.reload
+ [:created_at, :updated_at].each do |column|
+ expect(p2.public_send(column).to_s).to eq((old_timestamp).to_s)
+ end
+ end
+ end
+
+ it 'deletes the stats cache' do
+ $redis.expects(:del).twice
+ PostTimestampChanger.new(params).change!
+ end
+ end
+end