From b9abd7dc9e449d1c92cf593c925c53187bb742de Mon Sep 17 00:00:00 2001 From: Robin Ward Date: Tue, 13 Mar 2018 15:59:12 -0400 Subject: [PATCH] FEATURE: Shared Drafts This feature can be enabled by choosing a destination for the `shared drafts category` site setting. * Staff members can create shared drafts, choosing a destination category for the topic when it is published. * Shared Drafts can be viewed in their category, or above the topic list for the destination category where it will end up. * When the shared draft is ready, it can be published to the appropriate category by clicking a button on the topic view. * When published, Drafts change their timestamps to the current time, and any edits to the original post are removed. --- .../controllers/admin-site-settings.js.es6 | 2 +- .../admin/mixins/setting-component.js.es6 | 13 +- .../components/site-settings/category.hbs | 3 + app/assets/javascripts/application.js | 3 +- .../components/composer-action-title.js.es6 | 16 +- .../discourse/components/composer-body.js.es6 | 1 + .../components/shared-draft-controls.js.es6 | 29 +++ .../discourse/components/topic-list.js.es6 | 1 + .../discourse/controllers/topic.js.es6 | 6 + .../discourse/models/composer.js.es6 | 45 ++-- .../discourse/models/topic-list.js.es6 | 30 ++- .../javascripts/discourse/models/topic.js.es6 | 9 + .../components/shared-draft-controls.hbs | 22 ++ .../templates/components/topic-category.hbs | 1 + .../templates/components/topic-list.hbs | 1 + .../discourse/templates/discovery/topics.hbs | 11 + .../templates/topic-list-header.raw.hbs | 2 +- .../javascripts/discourse/templates/topic.hbs | 5 + .../components/composer-actions.js.es6 | 164 ++++++++++----- .../stylesheets/common/base/_topic-list.scss | 4 + .../common/base/shared-drafts.scss | 10 + app/controllers/list_controller.rb | 12 ++ app/controllers/posts_controller.rb | 6 + app/controllers/topics_controller.rb | 16 +- app/jobs/regular/publish_topic_to_category.rb | 16 +- app/models/category.rb | 10 + app/models/shared_draft.rb | 4 + app/models/site_setting.rb | 5 + app/models/topic.rb | 2 + app/models/topic_list.rb | 3 +- app/models/user_history.rb | 196 +++++++++--------- app/serializers/site_serializer.rb | 60 +++--- app/serializers/topic_list_item_serializer.rb | 9 + app/serializers/topic_list_serializer.rb | 8 +- app/serializers/topic_view_serializer.rb | 13 +- app/services/staff_action_logger.rb | 8 + config/locales/client.en.yml | 12 ++ config/locales/server.en.yml | 2 + config/routes.rb | 1 + config/site_settings.yml | 3 + .../20180316165104_create_shared_drafts.rb | 10 + lib/guardian/topic_guardian.rb | 8 + lib/post_creator.rb | 15 +- lib/site_settings/type_supervisor.rb | 33 +-- lib/topic_creator.rb | 10 + lib/topic_publisher.rb | 41 ++++ lib/topic_query.rb | 8 +- spec/components/topic_publisher_spec.rb | 47 +++++ spec/fabricators/shared_draft_fabricator.rb | 4 + spec/models/category_spec.rb | 2 + spec/models/site_setting_spec.rb | 15 ++ spec/requests/posts_controller_spec.rb | 54 ++++- spec/requests/topics_controller_spec.rb | 45 +++- .../acceptance/composer-actions-test.js.es6 | 18 +- .../acceptance/shared-drafts-test.js.es6 | 19 ++ .../acceptance/topic-anonymous-test.js.es6 | 3 +- .../javascripts/fixtures/site-fixtures.js.es6 | 12 +- test/javascripts/fixtures/topic.js.es6 | 2 +- .../helpers/create-pretender.js.es6 | 1 + 59 files changed, 851 insertions(+), 260 deletions(-) create mode 100644 app/assets/javascripts/admin/templates/components/site-settings/category.hbs create mode 100644 app/assets/javascripts/discourse/components/shared-draft-controls.js.es6 create mode 100644 app/assets/javascripts/discourse/templates/components/shared-draft-controls.hbs create mode 100644 app/assets/stylesheets/common/base/shared-drafts.scss create mode 100644 app/models/shared_draft.rb create mode 100644 db/migrate/20180316165104_create_shared_drafts.rb create mode 100644 lib/topic_publisher.rb create mode 100644 spec/components/topic_publisher_spec.rb create mode 100644 spec/fabricators/shared_draft_fabricator.rb create mode 100644 test/javascripts/acceptance/shared-drafts-test.js.es6 diff --git a/app/assets/javascripts/admin/controllers/admin-site-settings.js.es6 b/app/assets/javascripts/admin/controllers/admin-site-settings.js.es6 index 02a907d7af9..6edbe9e01fe 100644 --- a/app/assets/javascripts/admin/controllers/admin-site-settings.js.es6 +++ b/app/assets/javascripts/admin/controllers/admin-site-settings.js.es6 @@ -31,7 +31,7 @@ export default Ember.Controller.extend({ if (item.get('setting').toLowerCase().indexOf(filter) > -1) return true; if (item.get('setting').toLowerCase().replace(/_/g, ' ').indexOf(filter) > -1) return true; if (item.get('description').toLowerCase().indexOf(filter) > -1) return true; - if (item.get('value').toLowerCase().indexOf(filter) > -1) return true; + if ((item.get('value') || '').toLowerCase().indexOf(filter) > -1) return true; return false; } else { return true; diff --git a/app/assets/javascripts/admin/mixins/setting-component.js.es6 b/app/assets/javascripts/admin/mixins/setting-component.js.es6 index a5b85af9a33..71fc679f8d0 100644 --- a/app/assets/javascripts/admin/mixins/setting-component.js.es6 +++ b/app/assets/javascripts/admin/mixins/setting-component.js.es6 @@ -1,7 +1,16 @@ import computed from 'ember-addons/ember-computed-decorators'; import { categoryLinkHTML } from 'discourse/helpers/category-link'; -const CustomTypes = ['bool', 'enum', 'list', 'url_list', 'host_list', 'category_list', 'value_list']; +const CUSTOM_TYPES = [ + 'bool', + 'enum', + 'list', + 'url_list', + 'host_list', + 'category_list', + 'value_list', + 'category' +]; export default Ember.Mixin.create({ classNameBindings: [':row', ':setting', 'setting.overridden', 'typeClass'], @@ -46,7 +55,7 @@ export default Ember.Mixin.create({ @computed("setting.type") componentType(type) { - return CustomTypes.indexOf(type) !== -1 ? type : 'string'; + return CUSTOM_TYPES.indexOf(type) !== -1 ? type : 'string'; }, @computed("typeClass") diff --git a/app/assets/javascripts/admin/templates/components/site-settings/category.hbs b/app/assets/javascripts/admin/templates/components/site-settings/category.hbs new file mode 100644 index 00000000000..e3ab41137c1 --- /dev/null +++ b/app/assets/javascripts/admin/templates/components/site-settings/category.hbs @@ -0,0 +1,3 @@ +{{category-chooser value=value allowUncategorized="true"}} +{{setting-validation-message message=validationMessage}} +
{{{unbound setting.description}}}
diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js index f231c52bf8a..5b2f654383c 100644 --- a/app/assets/javascripts/application.js +++ b/app/assets/javascripts/application.js @@ -36,6 +36,8 @@ //= require ./discourse/models/result-set //= require ./discourse/models/store //= require ./discourse/models/action-summary +//= require ./discourse/models/permission-type +//= require ./discourse/models/category //= require ./discourse/models/topic //= require ./discourse/models/draft //= require ./discourse/models/composer @@ -43,7 +45,6 @@ //= require ./discourse/models/badge //= require ./discourse/models/permission-type //= require ./discourse/models/user-action-group -//= require ./discourse/models/category //= require ./discourse/models/input-validation //= require ./discourse/lib/search //= require ./discourse/lib/user-search diff --git a/app/assets/javascripts/discourse/components/composer-action-title.js.es6 b/app/assets/javascripts/discourse/components/composer-action-title.js.es6 index 5b4a148e4d1..43639cbe63f 100644 --- a/app/assets/javascripts/discourse/components/composer-action-title.js.es6 +++ b/app/assets/javascripts/discourse/components/composer-action-title.js.es6 @@ -1,7 +1,13 @@ import { default as computed } from 'ember-addons/ember-computed-decorators'; -import { PRIVATE_MESSAGE, CREATE_TOPIC, REPLY, EDIT } from "discourse/models/composer"; +import { PRIVATE_MESSAGE, CREATE_TOPIC, CREATE_SHARED_DRAFT, REPLY, EDIT } from "discourse/models/composer"; import { iconHTML } from 'discourse-common/lib/icon-library'; +const TITLES = { + [PRIVATE_MESSAGE]: 'topic.private_message', + [CREATE_TOPIC]: 'topic.create_long', + [CREATE_SHARED_DRAFT]: 'composer.create_shared_draft' +} + export default Ember.Component.extend({ classNames: ["composer-action-title"], options: Ember.computed.alias("model.replyOptions"), @@ -10,11 +16,11 @@ export default Ember.Component.extend({ @computed("options", "action") actionTitle(opts, action) { + if (TITLES[action]) { + return I18n.t(TITLES[action]); + } + switch (action) { - case PRIVATE_MESSAGE: - return I18n.t("topic.private_message"); - case CREATE_TOPIC: - return I18n.t("topic.create_long"); case REPLY: if (opts.userAvatar && opts.userLink) { return this._formatReplyToUserPost(opts.userAvatar, opts.userLink); diff --git a/app/assets/javascripts/discourse/components/composer-body.js.es6 b/app/assets/javascripts/discourse/components/composer-body.js.es6 index 8df136d3724..e868268c350 100644 --- a/app/assets/javascripts/discourse/components/composer-body.js.es6 +++ b/app/assets/javascripts/discourse/components/composer-body.js.es6 @@ -15,6 +15,7 @@ export default Ember.Component.extend(KeyEnterEscape, { 'composer.createdPost:created-post', 'composer.creatingTopic:topic', 'composer.whisper:composing-whisper', + 'composer.sharedDraft:composing-shared-draft', 'showPreview:show-preview:hide-preview', 'currentUserPrimaryGroupClass'], diff --git a/app/assets/javascripts/discourse/components/shared-draft-controls.js.es6 b/app/assets/javascripts/discourse/components/shared-draft-controls.js.es6 new file mode 100644 index 00000000000..a2b11f460b2 --- /dev/null +++ b/app/assets/javascripts/discourse/components/shared-draft-controls.js.es6 @@ -0,0 +1,29 @@ +import computed from 'ember-addons/ember-computed-decorators'; + +export default Ember.Component.extend({ + tagName: '', + publishing: false, + + @computed('topic.destination_category_id') + validCategory(destCatId) { + return destCatId && + destCatId !== this.site.shared_drafts_category_id; + }, + + actions: { + publish() { + + bootbox.confirm(I18n.t('shared_drafts.confirm_publish'), result => { + if (result) { + this.set('publishing', true); + let destId = this.get('topic.destination_category_id'); + this.get('topic').publish().then(() => { + this.set('topic.category_id', destId); + }).finally(() => { + this.set('publishing', false); + }); + } + }); + } + } +}); diff --git a/app/assets/javascripts/discourse/components/topic-list.js.es6 b/app/assets/javascripts/discourse/components/topic-list.js.es6 index 7dde582385e..bff2c380a60 100644 --- a/app/assets/javascripts/discourse/components/topic-list.js.es6 +++ b/app/assets/javascripts/discourse/components/topic-list.js.es6 @@ -4,6 +4,7 @@ export default Ember.Component.extend({ tagName: 'table', classNames: ['topic-list'], showTopicPostBadges: true, + listTitle: 'topic.title', // Overwrite this to perform client side filtering of topics, if desired filteredTopics: Ember.computed.alias('topics'), diff --git a/app/assets/javascripts/discourse/controllers/topic.js.es6 b/app/assets/javascripts/discourse/controllers/topic.js.es6 index dca8ddd0da6..b2c471af130 100644 --- a/app/assets/javascripts/discourse/controllers/topic.js.es6 +++ b/app/assets/javascripts/discourse/controllers/topic.js.es6 @@ -46,6 +46,12 @@ export default Ember.Controller.extend(BufferedContent, { } }, + @computed('model.postStream.loaded', 'model.category_id') + showSharedDraftControls(loaded, categoryId) { + let draftCat = this.site.shared_drafts_category_id; + return loaded && draftCat && categoryId && draftCat === categoryId; + }, + @computed('site.mobileView', 'model.posts_count') showSelectedPostsAtBottom(mobileView, postsCount) { return mobileView && postsCount > 3; diff --git a/app/assets/javascripts/discourse/models/composer.js.es6 b/app/assets/javascripts/discourse/models/composer.js.es6 index 3b9815b9d8a..2726e0fc2dd 100644 --- a/app/assets/javascripts/discourse/models/composer.js.es6 +++ b/app/assets/javascripts/discourse/models/composer.js.es6 @@ -9,6 +9,7 @@ import { escapeExpression, tinyAvatar } from 'discourse/lib/utilities'; // The actions the composer can take export const CREATE_TOPIC = 'createTopic', + CREATE_SHARED_DRAFT = 'createSharedDraft', PRIVATE_MESSAGE = 'privateMessage', NEW_PRIVATE_MESSAGE_KEY = 'new_private_message', REPLY = 'reply', @@ -35,7 +36,8 @@ const CLOSED = 'closed', typing_duration_msecs: 'typingTime', composer_open_duration_msecs: 'composerTime', tags: 'tags', - featured_link: 'featuredLink' + featured_link: 'featuredLink', + shared_draft: 'sharedDraft' }, _edit_topic_serializer = { @@ -45,11 +47,21 @@ const CLOSED = 'closed', featuredLink: 'topic.featured_link' }; -const _saveLabels = {}; -_saveLabels[EDIT] = 'composer.save_edit'; -_saveLabels[REPLY] = 'composer.reply'; -_saveLabels[CREATE_TOPIC] = 'composer.create_topic'; -_saveLabels[PRIVATE_MESSAGE] = 'composer.create_pm'; +const SAVE_LABELS = { + [EDIT]: 'composer.save_edit', + [REPLY]: 'composer.reply', + [CREATE_TOPIC]: 'composer.create_topic', + [PRIVATE_MESSAGE]: 'composer.create_pm', + [CREATE_SHARED_DRAFT]: 'composer.create_shared_draft' +}; + +const SAVE_ICONS = { + [EDIT]: 'pencil', + [REPLY]: 'reply', + [CREATE_TOPIC]: 'plus', + [PRIVATE_MESSAGE]: 'envelope', + [CREATE_SHARED_DRAFT]: 'clipboard' +}; const Composer = RestModel.extend({ _categoryId: null, @@ -59,6 +71,8 @@ const Composer = RestModel.extend({ return this.site.get('archetypes'); }.property(), + @computed('action') + sharedDraft: action => action === CREATE_SHARED_DRAFT, @computed categoryId: { @@ -85,6 +99,7 @@ const Composer = RestModel.extend({ }, creatingTopic: Em.computed.equal('action', CREATE_TOPIC), + creatingSharedDraft: Em.computed.equal('action', CREATE_SHARED_DRAFT), creatingPrivateMessage: Em.computed.equal('action', PRIVATE_MESSAGE), notCreatingPrivateMessage: Em.computed.not('creatingPrivateMessage'), @@ -148,7 +163,14 @@ const Composer = RestModel.extend({ }, 100, {leading: false, trailing: true}), editingFirstPost: Em.computed.and('editingPost', 'post.firstPost'), - canEditTitle: Em.computed.or('creatingTopic', 'creatingPrivateMessage', 'editingFirstPost'), + + canEditTitle: Em.computed.or( + 'creatingTopic', + 'creatingPrivateMessage', + 'editingFirstPost', + 'creatingSharedDraft' + ), + canCategorize: Em.computed.and('canEditTitle', 'notCreatingPrivateMessage'), @computed('canEditTitle', 'creatingPrivateMessage', 'categoryId') @@ -250,17 +272,12 @@ const Composer = RestModel.extend({ @computed('action') saveIcon(action) { - switch (action) { - case EDIT: return 'pencil'; - case REPLY: return 'reply'; - case CREATE_TOPIC: 'plus'; - case PRIVATE_MESSAGE: 'envelope'; - } + return SAVE_ICONS[action]; }, @computed('action', 'whisper') saveLabel(action, whisper) { - return whisper ? 'composer.create_whisper' : _saveLabels[action]; + return whisper ? 'composer.create_whisper' : SAVE_LABELS[action]; }, hasMetaData: function() { diff --git a/app/assets/javascripts/discourse/models/topic-list.js.es6 b/app/assets/javascripts/discourse/models/topic-list.js.es6 index 67293574042..df357eed513 100644 --- a/app/assets/javascripts/discourse/models/topic-list.js.es6 +++ b/app/assets/javascripts/discourse/models/topic-list.js.es6 @@ -2,6 +2,23 @@ import { ajax } from 'discourse/lib/ajax'; import RestModel from 'discourse/models/rest'; import Model from 'discourse/models/model'; +// Whether to show the category badge in topic lists +function displayCategoryInList(site, category) { + if (category) { + if (category.get('has_children')) { + return true; + } + let draftCategoryId = site.get('shared_drafts_category_id'); + if (draftCategoryId && category.get('id') === draftCategoryId) { + return true; + } + + return false; + } + + return true; +} + const TopicList = RestModel.extend({ canLoadMore: Em.computed.notEmpty("more_topics_url"), @@ -108,16 +125,19 @@ const TopicList = RestModel.extend({ }); TopicList.reopenClass({ - topicsFrom(store, result) { + topicsFrom(store, result, opts) { if (!result) { return; } + opts = opts || {}; + let listKey = opts.listKey || 'topics'; + // Stitch together our side loaded data const categories = Discourse.Category.list(), users = Model.extractByKey(result.users, Discourse.User), groups = Model.extractByKey(result.primary_groups, Ember.Object); - return result.topic_list.topics.map(function (t) { + return result.topic_list[listKey].map(function (t) { t.category = categories.findBy('id', t.category_id); t.posters.forEach(function(p) { p.user = users[p.user_id]; @@ -150,6 +170,10 @@ TopicList.reopenClass({ json.per_page = json.topic_list.per_page; json.topics = this.topicsFrom(store, json); + if (json.topic_list.shared_drafts) { + json.sharedDrafts = this.topicsFrom(store, json, { listKey: 'shared_drafts' }); + } + return json; }, @@ -160,7 +184,7 @@ TopicList.reopenClass({ // hide the category when it has no children hideUniformCategory(list, category) { - list.set('hideCategory', category && !category.get("has_children")); + list.set('hideCategory', !displayCategoryInList(list.site, category)); } }); diff --git a/app/assets/javascripts/discourse/models/topic.js.es6 b/app/assets/javascripts/discourse/models/topic.js.es6 index b8e6f897259..e72d0e4c302 100644 --- a/app/assets/javascripts/discourse/models/topic.js.es6 +++ b/app/assets/javascripts/discourse/models/topic.js.es6 @@ -476,6 +476,15 @@ const Topic = RestModel.extend({ return promise; }, + publish() { + return ajax(`/t/${this.get('id')}/publish`, { + type: 'PUT', + data: this.getProperties('destination_category_id') + }).then(() => { + this.set('destination_category_id', null); + }).catch(popupAjaxError); + }, + convertTopic(type) { return ajax(`/t/${this.get('id')}/convert-topic/${type}`, {type: 'PUT'}).then(() => { window.location.reload(); diff --git a/app/assets/javascripts/discourse/templates/components/shared-draft-controls.hbs b/app/assets/javascripts/discourse/templates/components/shared-draft-controls.hbs new file mode 100644 index 00000000000..056d9d5af54 --- /dev/null +++ b/app/assets/javascripts/discourse/templates/components/shared-draft-controls.hbs @@ -0,0 +1,22 @@ +
+ {{#if publishing}} + {{i18n "shared_drafts.publishing"}} + {{else}} + {{{i18n "shared_drafts.notice" category=topic.category.name}}} + +
+ + {{category-chooser value=topic.destination_category_id}} +
+ +
+ {{#if validCategory}} + {{d-button + action=(action "publish") + label="shared_drafts.publish" + class="btn-primary publish-shared-draft" + icon="clipboard"}} + {{/if}} +
+ {{/if}} +
diff --git a/app/assets/javascripts/discourse/templates/components/topic-category.hbs b/app/assets/javascripts/discourse/templates/components/topic-category.hbs index 3c380e5af0d..122727e3829 100644 --- a/app/assets/javascripts/discourse/templates/components/topic-category.hbs +++ b/app/assets/javascripts/discourse/templates/components/topic-category.hbs @@ -14,4 +14,5 @@ {{topic-featured-link topic}} {{/if}} + {{plugin-outlet name="topic-category" args=(hash topic=topic category=topic.category)}} diff --git a/app/assets/javascripts/discourse/templates/components/topic-list.hbs b/app/assets/javascripts/discourse/templates/components/topic-list.hbs index dcb3f4c307b..bcdbf0123cd 100644 --- a/app/assets/javascripts/discourse/templates/components/topic-list.hbs +++ b/app/assets/javascripts/discourse/templates/components/topic-list.hbs @@ -11,6 +11,7 @@ order=order ascending=ascending sortable=sortable + listTitle=listTitle bulkSelectEnabled=bulkSelectEnabled}} {{/unless}} diff --git a/app/assets/javascripts/discourse/templates/discovery/topics.hbs b/app/assets/javascripts/discourse/templates/discovery/topics.hbs index 3ae66d589d2..b656c10c172 100644 --- a/app/assets/javascripts/discourse/templates/discovery/topics.hbs +++ b/app/assets/javascripts/discourse/templates/discovery/topics.hbs @@ -13,6 +13,17 @@ {{/if}} +{{#if model.sharedDrafts}} + {{topic-list + class="shared-drafts" + listTitle="shared_drafts.title" + top=top + hideCategory="true" + category=category + topics=model.sharedDrafts + discoveryList=true}} +{{/if}} + {{bulk-select-button selected=selected action="refresh" category=category}} {{#discovery-topics-list model=model refresh="refresh" incomingCount=topicTrackingState.incomingCount}} diff --git a/app/assets/javascripts/discourse/templates/topic-list-header.raw.hbs b/app/assets/javascripts/discourse/templates/topic-list-header.raw.hbs index 9b530b0b69e..361ffb91b95 100644 --- a/app/assets/javascripts/discourse/templates/topic-list-header.raw.hbs +++ b/app/assets/javascripts/discourse/templates/topic-list-header.raw.hbs @@ -5,7 +5,7 @@ {{/if}} {{/if}} -{{raw "topic-list-header-column" order='default' name='topic.title' bulkSelectEnabled=bulkSelectEnabled showBulkToggle=toggleInTitle canBulkSelect=canBulkSelect}} +{{raw "topic-list-header-column" order='default' name=listTitle bulkSelectEnabled=bulkSelectEnabled showBulkToggle=toggleInTitle canBulkSelect=canBulkSelect}} {{#unless hideCategory}} {{raw "topic-list-header-column" sortable=sortable order='category' name='category_title'}} {{/unless}} diff --git a/app/assets/javascripts/discourse/templates/topic.hbs b/app/assets/javascripts/discourse/templates/topic.hbs index 4223c7cdd4b..47f221ec08a 100644 --- a/app/assets/javascripts/discourse/templates/topic.hbs +++ b/app/assets/javascripts/discourse/templates/topic.hbs @@ -6,6 +6,10 @@ {{/if}} + {{#if showSharedDraftControls}} + {{shared-draft-controls topic=model}} + {{/if}} + {{plugin-outlet name="topic-above-post-stream" args=(hash model=model)}} {{#if model.postStream.loaded}} @@ -68,6 +72,7 @@ {{/topic-title}} {{/if}} +
{{partial "selected-posts"}} diff --git a/app/assets/javascripts/select-kit/components/composer-actions.js.es6 b/app/assets/javascripts/select-kit/components/composer-actions.js.es6 index 174563ba040..a95d688053f 100644 --- a/app/assets/javascripts/select-kit/components/composer-actions.js.es6 +++ b/app/assets/javascripts/select-kit/components/composer-actions.js.es6 @@ -1,6 +1,13 @@ import DropdownSelectBoxComponent from "select-kit/components/dropdown-select-box"; import computed from "ember-addons/ember-computed-decorators"; -import { default as Composer, PRIVATE_MESSAGE, CREATE_TOPIC, REPLY, EDIT } from "discourse/models/composer"; +import { + PRIVATE_MESSAGE, + CREATE_TOPIC, + CREATE_SHARED_DRAFT, + REPLY, + EDIT, + NEW_PRIVATE_MESSAGE_KEY +} from "discourse/models/composer"; // Component can get destroyed and lose state let _topicSnapshot = null; @@ -49,6 +56,9 @@ export default DropdownSelectBoxComponent.extend({ case EDIT: content.icon = "pencil"; break; + case CREATE_SHARED_DRAFT: + content.icon = 'clipboard'; + break; }; return content; @@ -58,7 +68,7 @@ export default DropdownSelectBoxComponent.extend({ content(options, canWhisper, action) { let items = []; - if (action !== CREATE_TOPIC) { + if (action !== CREATE_TOPIC && action !== CREATE_SHARED_DRAFT) { items.push({ name: I18n.t("composer.composer_actions.reply_as_new_topic.label"), description: I18n.t("composer.composer_actions.reply_as_new_topic.desc"), @@ -107,10 +117,31 @@ export default DropdownSelectBoxComponent.extend({ }); } - // Edge case: If personal messages are disabled, it is possible to have - // no items which stil renders a button that pops up nothing. In this - // case, add an option for what you're currently doing. - if (action === CREATE_TOPIC && items.length === 0) { + let showCreateTopic = false; + if (action === CREATE_SHARED_DRAFT) { + showCreateTopic = true; + } + + if (action === CREATE_TOPIC) { + if (this.site.shared_drafts_category_id) { + // Shared Drafts Choice + items.push({ + name: I18n.t("composer.composer_actions.shared_draft.label"), + description: I18n.t("composer.composer_actions.shared_draft.desc"), + icon: "clipboard", + id: "shared_draft" + }); + } + + // Edge case: If personal messages are disabled, it is possible to have + // no items which stil renders a button that pops up nothing. In this + // case, add an option for what you're currently doing. + if (items.length === 0) { + showCreateTopic = true; + } + } + + if (showCreateTopic) { items.push({ name: I18n.t("composer.composer_actions.create_topic.label"), description: I18n.t("composer.composer_actions.reply_as_new_topic.desc"), @@ -118,6 +149,7 @@ export default DropdownSelectBoxComponent.extend({ id: "create_topic" }); } + return items; }, @@ -147,59 +179,77 @@ export default DropdownSelectBoxComponent.extend({ }); }, + _openComposer(options) { + this.get("composerController").close(); + this.get("composerController").open(options); + }, + + toggleWhisperSelected(options, model) { + model.toggleProperty('whisper'); + }, + + replyToTopicSelected(options) { + options.action = REPLY; + options.topic = _topicSnapshot; + this._openComposer(options); + }, + + replyToPostSelected(options) { + options.action = REPLY; + options.post = _postSnapshot; + this._openComposer(options); + }, + + replyAsNewTopicSelected(options) { + options.action = CREATE_TOPIC; + options.categoryId = this.get("composerModel.topic.category.id"); + this._replyFromExisting(options, _postSnapshot, _topicSnapshot); + }, + + replyAsPrivateMessageSelected(options) { + let usernames; + + if (_postSnapshot && !_postSnapshot.get("yours")) { + const postUsername = _postSnapshot.get("username"); + if (postUsername) { + usernames = postUsername; + } + } + + options.action = PRIVATE_MESSAGE; + options.usernames = usernames; + options.archetypeId = "private_message"; + options.draftKey = NEW_PRIVATE_MESSAGE_KEY; + + this._replyFromExisting(options, _postSnapshot, _topicSnapshot); + }, + + _switchCreate(options, action) { + options.action = action; + options.categoryId = this.get("composerModel.categoryId"); + options.topicTitle = this.get('composerModel.title'); + this._openComposer(options); + }, + + createTopicSelected(options) { + this._switchCreate(options, CREATE_TOPIC); + }, + + sharedDraftSelected(options) { + this._switchCreate(options, CREATE_SHARED_DRAFT); + }, + actions: { onSelect(value) { - let options = { - draftKey: this.get("composerModel.draftKey"), - draftSequence: this.get("composerModel.draftSequence"), - reply: this.get("composerModel.reply") - }; - - switch(value) { - case "toggle_whisper": - this.set("composerModel.whisper", !this.get("composerModel.whisper")); - break; - - case "reply_to_post": - options.action = Composer.REPLY; - options.post = _postSnapshot; - - this.get("composerController").close(); - this.get("composerController").open(options); - break; - - case "reply_to_topic": - options.action = Composer.REPLY; - options.topic = _topicSnapshot; - - this.get("composerController").close(); - this.get("composerController").open(options); - break; - - case "reply_as_new_topic": - options.action = Composer.CREATE_TOPIC; - options.categoryId = this.get("composerModel.topic.category.id"); - - this._replyFromExisting(options, _postSnapshot, _topicSnapshot); - break; - - case "reply_as_private_message": - let usernames; - - if (_postSnapshot && !_postSnapshot.get("yours")) { - const postUsername = _postSnapshot.get("username"); - if (postUsername) { - usernames = postUsername; - } - } - - options.action = Composer.PRIVATE_MESSAGE; - options.usernames = usernames; - options.archetypeId = "private_message"; - options.draftKey = Composer.NEW_PRIVATE_MESSAGE_KEY; - - this._replyFromExisting(options, _postSnapshot, _topicSnapshot); - break; + let action = `${Ember.String.camelize(value)}Selected`; + if (this[action]) { + let model = this.get('composerModel'); + this[action]( + model.getProperties('draftKey', 'draftSequence', 'reply'), + model + ); + } else { + Ember.Logger.error(`No method '${action}' found`); } } } diff --git a/app/assets/stylesheets/common/base/_topic-list.scss b/app/assets/stylesheets/common/base/_topic-list.scss index 6bc899d66ff..a9033684d85 100644 --- a/app/assets/stylesheets/common/base/_topic-list.scss +++ b/app/assets/stylesheets/common/base/_topic-list.scss @@ -1,3 +1,7 @@ +.topic-list.shared-drafts { + margin-bottom: 1.5em; +} + .show-more { width: 100%; z-index: z("base"); diff --git a/app/assets/stylesheets/common/base/shared-drafts.scss b/app/assets/stylesheets/common/base/shared-drafts.scss new file mode 100644 index 00000000000..bddff920b0f --- /dev/null +++ b/app/assets/stylesheets/common/base/shared-drafts.scss @@ -0,0 +1,10 @@ +.shared-draft-controls { + background-color: $tertiary-low; + padding: 1em; + + .publish-field { + margin-top: 1em; + } + + clear: both; +} diff --git a/app/controllers/list_controller.rb b/app/controllers/list_controller.rb index e267de7bd8a..659e66f008a 100644 --- a/app/controllers/list_controller.rb +++ b/app/controllers/list_controller.rb @@ -71,6 +71,18 @@ class ListController < ApplicationController list = TopicQuery.new(user, list_opts).public_send("list_#{filter}") + if @category.present? && guardian.can_create_shared_draft? + shared_drafts = TopicQuery.new( + user, + category: SiteSetting.shared_drafts_category, + destination_category_id: list_opts[:category] + ).list_latest + + if shared_drafts.present? && shared_drafts.topics.present? + list.shared_drafts = shared_drafts.topics + end + end + list.more_topics_url = construct_url_with(:next, list_opts) list.prev_topics_url = construct_url_with(:prev, list_opts) if Discourse.anonymous_filters.include?(filter) diff --git a/app/controllers/posts_controller.rb b/app/controllers/posts_controller.rb index 78960fa4064..64ba76894e8 100644 --- a/app/controllers/posts_controller.rb +++ b/app/controllers/posts_controller.rb @@ -641,6 +641,12 @@ class PostsController < ApplicationController result[:is_warning] = false end + if params[:shared_draft] == 'true' + raise Discourse::InvalidParameters.new(:shared_draft) unless guardian.can_create_shared_draft? + + result[:shared_draft] = true + end + if current_user.staff? && SiteSetting.enable_whispers? && params[:whisper] == "true" result[:post_type] = Post.types[:whisper] end diff --git a/app/controllers/topics_controller.rb b/app/controllers/topics_controller.rb index 505882f67b0..1c77d492463 100644 --- a/app/controllers/topics_controller.rb +++ b/app/controllers/topics_controller.rb @@ -4,6 +4,7 @@ require_dependency 'url_helper' require_dependency 'topics_bulk_action' require_dependency 'discourse_event' require_dependency 'rate_limiter' +require_dependency 'topic_publisher' class TopicsController < ApplicationController requires_login only: [ @@ -30,7 +31,8 @@ class TopicsController < ApplicationController :archive_message, :move_to_inbox, :convert_topic, - :bookmark + :bookmark, + :publish ] before_action :consider_user_for_promotion, only: :show @@ -131,6 +133,18 @@ class TopicsController < ApplicationController raise ex end + def publish + params.permit(:id, :destination_category_id) + + topic = Topic.find(params[:id]) + category = Category.find(params[:destination_category_id]) + + guardian.ensure_can_publish_topic!(topic, category) + topic = TopicPublisher.new(topic, current_user, category.id).publish! + + render_serialized(topic.reload, BasicTopicSerializer) + end + def unsubscribe if current_user.blank? cookies[:destination_url] = request.fullpath diff --git a/app/jobs/regular/publish_topic_to_category.rb b/app/jobs/regular/publish_topic_to_category.rb index a98f0dddff9..713f5d87bfc 100644 --- a/app/jobs/regular/publish_topic_to_category.rb +++ b/app/jobs/regular/publish_topic_to_category.rb @@ -1,3 +1,5 @@ +require_dependency 'topic_publisher' + module Jobs class PublishTopicToCategory < Jobs::Base def execute(args) @@ -7,19 +9,9 @@ module Jobs topic = topic_timer.topic return if topic.blank? - TopicTimestampChanger.new(timestamp: Time.zone.now, topic: topic).change! do - if topic.private_message? - topic = TopicConverter.new(topic, Discourse.system_user) - .convert_to_public_topic(topic_timer.category_id) - else - topic.change_category_to_id(topic_timer.category_id) - end - - topic.update_columns(visible: true) - topic_timer.trash!(Discourse.system_user) + TopicTimer.transaction do + TopicPublisher.new(topic, Discourse.system_user, topic_timer.category_id).publish! end - - MessageBus.publish("/topic/#{topic.id}", reload_topic: true, refresh_stream: true) end end end diff --git a/app/models/category.rb b/app/models/category.rb index 9f8cd2870ff..e551227ed6f 100644 --- a/app/models/category.rb +++ b/app/models/category.rb @@ -54,6 +54,7 @@ class Category < ActiveRecord::Base after_destroy :reset_topic_ids_cache after_destroy :publish_category_deletion + after_destroy :remove_site_settings after_create :delete_category_permalink @@ -250,6 +251,15 @@ SQL MessageBus.publish('/categories', { categories: ActiveModel::ArraySerializer.new([self]).as_json }, group_ids: group_ids) end + def remove_site_settings + SiteSetting.all_settings.each do |s| + if s[:type] == 'category' && s[:value].to_i == self.id + SiteSetting.send("#{s[:setting]}=", '') + end + end + + end + def publish_category_deletion MessageBus.publish('/categories', deleted_categories: [self.id]) end diff --git a/app/models/shared_draft.rb b/app/models/shared_draft.rb new file mode 100644 index 00000000000..3f4dccee95a --- /dev/null +++ b/app/models/shared_draft.rb @@ -0,0 +1,4 @@ +class SharedDraft < ActiveRecord::Base + belongs_to :topic + belongs_to :category +end diff --git a/app/models/site_setting.rb b/app/models/site_setting.rb index 481ec0eb7ff..8f8332a39c1 100644 --- a/app/models/site_setting.rb +++ b/app/models/site_setting.rb @@ -150,6 +150,11 @@ class SiteSetting < ActiveRecord::Base SiteSetting::Upload end + def self.shared_drafts_enabled? + c = SiteSetting.shared_drafts_category + c.present? && c.to_i != SiteSetting.uncategorized_category_id.to_i + end + end # == Schema Information diff --git a/app/models/topic.rb b/app/models/topic.rb index 102f4593de5..55f016c4b44 100644 --- a/app/models/topic.rb +++ b/app/models/topic.rb @@ -134,6 +134,8 @@ class Topic < ActiveRecord::Base has_many :tag_users, through: :tags has_one :top_topic + has_one :shared_draft, dependent: :destroy + belongs_to :user belongs_to :last_poster, class_name: 'User', foreign_key: :last_post_user_id belongs_to :featured_user1, class_name: 'User', foreign_key: :featured_user1_id diff --git a/app/models/topic_list.rb b/app/models/topic_list.rb index dbb468cb489..6de3eafcbe6 100644 --- a/app/models/topic_list.rb +++ b/app/models/topic_list.rb @@ -36,7 +36,8 @@ class TopicList :per_page, :top_tags, :current_user, - :tags + :tags, + :shared_drafts def initialize(filter, current_user, topics, opts = nil) @filter = filter diff --git a/app/models/user_history.rb b/app/models/user_history.rb index 608ed772503..c0980568fd7 100644 --- a/app/models/user_history.rb +++ b/app/models/user_history.rb @@ -16,105 +16,111 @@ class UserHistory < ActiveRecord::Base before_save :set_admin_only def self.actions - @actions ||= Enum.new(delete_user: 1, - change_trust_level: 2, - change_site_setting: 3, - change_theme: 4, - delete_theme: 5, - checked_for_custom_avatar: 6, # not used anymore - notified_about_avatar: 7, - notified_about_sequential_replies: 8, - notified_about_dominating_topic: 9, - suspend_user: 10, - unsuspend_user: 11, - facebook_no_email: 12, - grant_badge: 13, - revoke_badge: 14, - auto_trust_level_change: 15, - check_email: 16, - delete_post: 17, - delete_topic: 18, - impersonate: 19, - roll_up: 20, - change_username: 21, - custom: 22, - custom_staff: 23, - anonymize_user: 24, - reviewed_post: 25, - change_category_settings: 26, - delete_category: 27, - create_category: 28, - change_site_text: 29, - silence_user: 30, - unsilence_user: 31, - grant_admin: 32, - revoke_admin: 33, - grant_moderation: 34, - revoke_moderation: 35, - backup_create: 36, - rate_limited_like: 37, # not used anymore - revoke_email: 38, - deactivate_user: 39, - wizard_step: 40, - lock_trust_level: 41, - unlock_trust_level: 42, - activate_user: 43, - change_readonly_mode: 44, - backup_download: 45, - backup_destroy: 46, - notified_about_get_a_room: 47, - change_name: 48, - post_locked: 49, - post_unlocked: 50, - check_personal_message: 51, - disabled_second_factor: 52, - post_edit: 53) + @actions ||= Enum.new( + delete_user: 1, + change_trust_level: 2, + change_site_setting: 3, + change_theme: 4, + delete_theme: 5, + checked_for_custom_avatar: 6, # not used anymore + notified_about_avatar: 7, + notified_about_sequential_replies: 8, + notified_about_dominating_topic: 9, + suspend_user: 10, + unsuspend_user: 11, + facebook_no_email: 12, + grant_badge: 13, + revoke_badge: 14, + auto_trust_level_change: 15, + check_email: 16, + delete_post: 17, + delete_topic: 18, + impersonate: 19, + roll_up: 20, + change_username: 21, + custom: 22, + custom_staff: 23, + anonymize_user: 24, + reviewed_post: 25, + change_category_settings: 26, + delete_category: 27, + create_category: 28, + change_site_text: 29, + silence_user: 30, + unsilence_user: 31, + grant_admin: 32, + revoke_admin: 33, + grant_moderation: 34, + revoke_moderation: 35, + backup_create: 36, + rate_limited_like: 37, # not used anymore + revoke_email: 38, + deactivate_user: 39, + wizard_step: 40, + lock_trust_level: 41, + unlock_trust_level: 42, + activate_user: 43, + change_readonly_mode: 44, + backup_download: 45, + backup_destroy: 46, + notified_about_get_a_room: 47, + change_name: 48, + post_locked: 49, + post_unlocked: 50, + check_personal_message: 51, + disabled_second_factor: 52, + post_edit: 53, + topic_published: 54 + ) end # Staff actions is a subset of all actions, used to audit actions taken by staff users. def self.staff_actions - @staff_actions ||= [:delete_user, - :change_trust_level, - :change_site_setting, - :change_theme, - :delete_theme, - :change_site_text, - :suspend_user, - :unsuspend_user, - :grant_badge, - :revoke_badge, - :check_email, - :delete_post, - :delete_topic, - :impersonate, - :roll_up, - :change_username, - :custom_staff, - :anonymize_user, - :reviewed_post, - :change_category_settings, - :delete_category, - :create_category, - :silence_user, - :unsilence_user, - :grant_admin, - :revoke_admin, - :grant_moderation, - :revoke_moderation, - :backup_create, - :revoke_email, - :deactivate_user, - :lock_trust_level, - :unlock_trust_level, - :activate_user, - :change_readonly_mode, - :backup_download, - :backup_destroy, - :post_locked, - :post_unlocked, - :check_personal_message, - :disabled_second_factor, - :post_edit] + @staff_actions ||= [ + :delete_user, + :change_trust_level, + :change_site_setting, + :change_theme, + :delete_theme, + :change_site_text, + :suspend_user, + :unsuspend_user, + :grant_badge, + :revoke_badge, + :check_email, + :delete_post, + :delete_topic, + :impersonate, + :roll_up, + :change_username, + :custom_staff, + :anonymize_user, + :reviewed_post, + :change_category_settings, + :delete_category, + :create_category, + :silence_user, + :unsilence_user, + :grant_admin, + :revoke_admin, + :grant_moderation, + :revoke_moderation, + :backup_create, + :revoke_email, + :deactivate_user, + :lock_trust_level, + :unlock_trust_level, + :activate_user, + :change_readonly_mode, + :backup_download, + :backup_destroy, + :post_locked, + :post_unlocked, + :check_personal_message, + :disabled_second_factor, + :post_edit, + :topic_published + ] end def self.staff_action_ids diff --git a/app/serializers/site_serializer.rb b/app/serializers/site_serializer.rb index 9c3ab5a570b..bd52b8d2e2d 100644 --- a/app/serializers/site_serializer.rb +++ b/app/serializers/site_serializer.rb @@ -4,30 +4,33 @@ require_dependency 'wizard/builder' class SiteSerializer < ApplicationSerializer - attributes :default_archetype, - :notification_types, - :post_types, - :groups, - :filters, - :periods, - :top_menu_items, - :anonymous_top_menu_items, - :uncategorized_category_id, # this is hidden so putting it here - :is_readonly, - :disabled_plugins, - :user_field_max_length, - :suppressed_from_latest_category_ids, - :post_action_types, - :topic_flag_types, - :can_create_tag, - :can_tag_topics, - :can_tag_pms, - :tags_filter_regexp, - :top_tags, - :wizard_required, - :topic_featured_link_allowed_category_ids, - :user_themes, - :censored_words + attributes( + :default_archetype, + :notification_types, + :post_types, + :groups, + :filters, + :periods, + :top_menu_items, + :anonymous_top_menu_items, + :uncategorized_category_id, # this is hidden so putting it here + :is_readonly, + :disabled_plugins, + :user_field_max_length, + :suppressed_from_latest_category_ids, + :post_action_types, + :topic_flag_types, + :can_create_tag, + :can_tag_topics, + :can_tag_pms, + :tags_filter_regexp, + :top_tags, + :wizard_required, + :topic_featured_link_allowed_category_ids, + :user_themes, + :censored_words, + :shared_drafts_category_id + ) has_many :categories, serializer: BasicCategorySerializer, embed: :objects has_many :trust_levels, embed: :objects @@ -153,4 +156,13 @@ class SiteSerializer < ApplicationSerializer def censored_words WordWatcher.words_for_action(:censor).join('|') end + + def shared_drafts_category_id + SiteSetting.shared_drafts_category.to_i + end + + def include_shared_drafts_category_id? + scope.can_create_shared_draft? + end + end diff --git a/app/serializers/topic_list_item_serializer.rb b/app/serializers/topic_list_item_serializer.rb index 66092c62466..663cc2c9a26 100644 --- a/app/serializers/topic_list_item_serializer.rb +++ b/app/serializers/topic_list_item_serializer.rb @@ -29,6 +29,15 @@ class TopicListItemSerializer < ListableTopicSerializer posters.find { |poster| poster.user.id == object.last_post_user_id }.try(:user).try(:username) end + def category_id + # If it's a shared draft, show the destination topic instead + if object.category_id == SiteSetting.shared_drafts_category.to_i && object.shared_draft + return object.shared_draft.category_id + end + + object.category_id + end + def participants object.participants_summary || [] end diff --git a/app/serializers/topic_list_serializer.rb b/app/serializers/topic_list_serializer.rb index 994ea84950c..6ee27854ca8 100644 --- a/app/serializers/topic_list_serializer.rb +++ b/app/serializers/topic_list_serializer.rb @@ -8,15 +8,21 @@ class TopicListSerializer < ApplicationSerializer :for_period, :per_page, :top_tags, - :tags + :tags, + :shared_drafts has_many :topics, serializer: TopicListItemSerializer, embed: :objects + has_many :shared_drafts, serializer: TopicListItemSerializer, embed: :objects has_many :tags, serializer: TagSerializer, embed: :objects def can_create_topic scope.can_create?(Topic) end + def include_shared_drafts? + object.shared_drafts.present? + end + def include_for_period? for_period.present? end diff --git a/app/serializers/topic_view_serializer.rb b/app/serializers/topic_view_serializer.rb index fc98d32aeaf..7253032ab55 100644 --- a/app/serializers/topic_view_serializer.rb +++ b/app/serializers/topic_view_serializer.rb @@ -65,7 +65,8 @@ class TopicViewSerializer < ApplicationSerializer :private_topic_timer, :unicode_title, :message_bus_last_id, - :participant_count + :participant_count, + :destination_category_id # TODO: Split off into proper object / serializer def details @@ -275,6 +276,16 @@ class TopicViewSerializer < ApplicationSerializer object.participant_count end + def destination_category_id + object.topic.shared_draft.category_id + end + + def include_destination_category_id? + scope.can_create_shared_draft? && + object.topic.category_id == SiteSetting.shared_drafts_category.to_i && + object.topic.shared_draft.present? + end + private def private_message?(topic) diff --git a/app/services/staff_action_logger.rb b/app/services/staff_action_logger.rb index 4fd88f3b587..312817027cd 100644 --- a/app/services/staff_action_logger.rb +++ b/app/services/staff_action_logger.rb @@ -95,6 +95,14 @@ class StaffActionLogger target_user_id: user.id)) end + def log_topic_published(topic, opts = {}) + raise Discourse::InvalidParameters.new(:topic) unless topic && topic.is_a?(Topic) + UserHistory.create!(params(opts).merge( + action: UserHistory.actions[:topic_published], + topic_id: topic.id) + ) + end + def log_post_lock(post, opts = {}) raise Discourse::InvalidParameters.new(:post) unless post && post.is_a?(Post) UserHistory.create!(params(opts).merge( diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index e1973f01878..aebfd9ea785 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -1243,6 +1243,14 @@ en: medium_dark_tone: Medium dark skin tone dark_tone: Dark skin tone + shared_drafts: + title: "Shared Drafts" + notice: "This topic is only visible to those who can see the {{category}} category." + destination_category: "Destination Category" + publish: "Publish Shared Draft" + confirm_publish: "Are you sure you want to publish this draft?" + publishing: "Publishing Topic..." + composer: emoji: "Emoji :)" more_emoji: "more..." @@ -1287,6 +1295,7 @@ en: create_topic: "Create Topic" create_pm: "Message" create_whisper: "Whisper" + create_shared_draft: "Create Shared Draft" title: "Or press Ctrl+Enter" users_placeholder: "Add a user" @@ -1359,6 +1368,9 @@ en: desc: Whispers are only visible to staff members create_topic: label: "New Topic" + shared_draft: + label: "Shared Draft" + desc: "Draft a topic that will only be visible to staff" notifications: tooltip: diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index 52fd114f69d..e0531dbd3bc 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -1649,6 +1649,8 @@ en: company_full_name: "Company Name (full)" company_domain: "Company Domain" + shared_drafts_category: "Enable the Shared Drafts feature by designating a category for topic drafts." + errors: invalid_email: "Invalid email address." invalid_username: "There's no user with that username." diff --git a/config/routes.rb b/config/routes.rb index 23b2bebef95..587e93ab7d9 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -598,6 +598,7 @@ Discourse::Application.routes.draw do put "t/:id/archive-message" => "topics#archive_message" put "t/:id/move-to-inbox" => "topics#move_to_inbox" put "t/:id/convert-topic/:type" => "topics#convert_topic" + put "t/:id/publish" => "topics#publish" put "topics/bulk" put "topics/reset-new" => 'topics#reset_new' post "topics/timings" diff --git a/config/site_settings.yml b/config/site_settings.yml index a503701bfdb..dc00b023fe5 100644 --- a/config/site_settings.yml +++ b/config/site_settings.yml @@ -537,6 +537,9 @@ posting: editing_grace_period_max_diff: 100 editing_grace_period_max_diff_high_trust: 400 staff_edit_locks_post: false + shared_drafts_category: + type: category + default: '' post_edit_time_limit: default: 86400 max: 525600 diff --git a/db/migrate/20180316165104_create_shared_drafts.rb b/db/migrate/20180316165104_create_shared_drafts.rb new file mode 100644 index 00000000000..0371e695f9a --- /dev/null +++ b/db/migrate/20180316165104_create_shared_drafts.rb @@ -0,0 +1,10 @@ +class CreateSharedDrafts < ActiveRecord::Migration[5.1] + def change + create_table :shared_drafts, id: false do |t| + t.integer :topic_id, null: false + t.integer :category_id, null: false + t.timestamps + end + add_index :shared_drafts, :topic_id, unique: true + end +end diff --git a/lib/guardian/topic_guardian.rb b/lib/guardian/topic_guardian.rb index 6aac2ac216b..0d75e290d87 100644 --- a/lib/guardian/topic_guardian.rb +++ b/lib/guardian/topic_guardian.rb @@ -10,6 +10,14 @@ module TopicGuardian ) end + def can_create_shared_draft? + is_staff? && SiteSetting.shared_drafts_enabled? + end + + def can_publish_topic?(topic, category) + is_staff? && can_see?(topic) && can_create_topic?(category) + end + # Creating Methods def can_create_topic?(parent) is_staff? || diff --git a/lib/post_creator.rb b/lib/post_creator.rb index bc21c7254f2..522dc288da4 100644 --- a/lib/post_creator.rb +++ b/lib/post_creator.rb @@ -52,6 +52,7 @@ class PostCreator # created_at - Topic creation time (optional) # pinned_at - Topic pinned time (optional) # pinned_globally - Is the topic pinned globally (optional) + # shared_draft - Is the topic meant to be a shared draft # def initialize(user, opts) # TODO: we should reload user in case it is tainted, should take in a user_id as opposed to user @@ -375,20 +376,6 @@ class PostCreator private - # TODO: merge the similar function in TopicCreator and fix parameter naming for `category` - def find_category_id - @opts.delete(:category) if @opts[:archetype].present? && @opts[:archetype] == Archetype.private_message - - category = - if (@opts[:category].is_a? Integer) || (@opts[:category] =~ /^\d+$/) - Category.find_by(id: @opts[:category]) - else - Category.find_by(name_lower: @opts[:category].try(:downcase)) - end - - category&.id - end - def create_topic return if @topic begin diff --git a/lib/site_settings/type_supervisor.rb b/lib/site_settings/type_supervisor.rb index 0cd9f272bc9..a9da1a27dcf 100644 --- a/lib/site_settings/type_supervisor.rb +++ b/lib/site_settings/type_supervisor.rb @@ -13,21 +13,24 @@ class SiteSettings::TypeSupervisor SUPPORTED_TYPES = %i[email username list enum].freeze def self.types - @types ||= Enum.new(string: 1, - time: 2, - integer: 3, - float: 4, - bool: 5, - null: 6, - enum: 7, - list: 8, - url_list: 9, - host_list: 10, - category_list: 11, - value_list: 12, - regex: 13, - email: 14, - username: 15) + @types ||= Enum.new( + string: 1, + time: 2, + integer: 3, + float: 4, + bool: 5, + null: 6, + enum: 7, + list: 8, + url_list: 9, + host_list: 10, + category_list: 11, + value_list: 12, + regex: 13, + email: 14, + username: 15, + category: 16 + ) end def self.parse_value_type(val) diff --git a/lib/topic_creator.rb b/lib/topic_creator.rb index 69ca76d73cd..2c1103ace69 100644 --- a/lib/topic_creator.rb +++ b/lib/topic_creator.rb @@ -48,12 +48,18 @@ class TopicCreator save_topic(topic) create_warning(topic) watch_topic(topic) + create_shared_draft(topic) topic end private + def create_shared_draft(topic) + return unless @opts[:shared_draft] && @opts[:category].present? + SharedDraft.create(topic_id: topic.id, category_id: @opts[:category]) + end + def create_warning(topic) return unless @opts[:is_warning] @@ -138,6 +144,10 @@ class TopicCreator # PM can't have a category @opts.delete(:category) if @opts[:archetype].present? && @opts[:archetype] == Archetype.private_message + if @opts[:shared_draft] + return Category.find(SiteSetting.shared_drafts_category) + end + # Temporary fix to allow older clients to create topics. # When all clients are updated the category variable should # be set directly to the contents of the if statement. diff --git a/lib/topic_publisher.rb b/lib/topic_publisher.rb new file mode 100644 index 00000000000..3a901ad1c9b --- /dev/null +++ b/lib/topic_publisher.rb @@ -0,0 +1,41 @@ +class TopicPublisher + + def initialize(topic, published_by, category_id) + @topic = topic + @published_by = published_by + @category_id = category_id + end + + def publish! + TopicTimestampChanger.new(timestamp: Time.zone.now, topic: @topic).change! do + if @topic.private_message? + @topic = TopicConverter.new(@topic, @published_by) + .convert_to_public_topic(@category_id) + else + @topic.change_category_to_id(@category_id) + end + + @topic.update_columns(visible: true) + + StaffActionLogger.new(@published_by).log_topic_published(@topic) + + # Clean up any publishing artifacts + SharedDraft.where(topic: @topic).delete_all + TopicTimer.where(topic: @topic).update_all( + deleted_at: DateTime.now, + deleted_by_id: @published_by.id + ) + + op = @topic.first_post + if op.present? + op.revisions.delete_all + op.update_column(:version, 1) + end + end + + MessageBus.publish("/topic/#{@topic.id}", reload_topic: true, refresh_stream: true) + + @topic + end + +end diff --git a/lib/topic_query.rb b/lib/topic_query.rb index 5557fd6d6de..541f69db762 100644 --- a/lib/topic_query.rb +++ b/lib/topic_query.rb @@ -44,7 +44,8 @@ class TopicQuery per_page visible guardian - no_definitions) + no_definitions + destination_category_id) end # Maps `order` to a columns in `topics` @@ -606,6 +607,11 @@ class TopicQuery result = apply_ordering(result, options) result = result.listable_topics.includes(:category) + if options[:destination_category_id] + destination_category_id = get_category_id(options[:destination_category_id]) + result = result.includes(:shared_draft).where("shared_drafts.category_id" => destination_category_id) + end + if options[:exclude_category_ids] && options[:exclude_category_ids].is_a?(Array) && options[:exclude_category_ids].size > 0 result = result.where("categories.id NOT IN (?)", options[:exclude_category_ids]).references(:categories) end diff --git a/spec/components/topic_publisher_spec.rb b/spec/components/topic_publisher_spec.rb new file mode 100644 index 00000000000..7f4dd740b15 --- /dev/null +++ b/spec/components/topic_publisher_spec.rb @@ -0,0 +1,47 @@ +require 'topic_publisher' +require 'rails_helper' + +describe TopicPublisher do + + context "shared drafts" do + let(:shared_drafts_category) { Fabricate(:category) } + let(:category) { Fabricate(:category) } + + before do + SiteSetting.shared_drafts_category = shared_drafts_category.id + end + + context "publishing" do + let(:topic) { Fabricate(:topic, category: shared_drafts_category, visible: false) } + let(:shared_draft) { Fabricate(:shared_draft, topic: topic, category: category) } + let(:moderator) { Fabricate(:moderator) } + let(:op) { Fabricate(:post, topic: topic) } + + before do + # Create a revision + op.set_owner(Fabricate(:coding_horror), Discourse.system_user) + op.reload + end + + it "will publish the topic properly" do + TopicPublisher.new(topic, moderator, shared_draft.category_id).publish! + + topic.reload + expect(topic.category).to eq(category) + expect(topic).to be_visible + expect(topic.shared_draft).to be_blank + expect(UserHistory.where( + acting_user_id: moderator.id, + action: UserHistory.actions[:topic_published] + )).to be_present + op.reload + + # Should delete any edits on the OP + expect(op.revisions.size).to eq(0) + expect(op.version).to eq(1) + end + end + + end + +end diff --git a/spec/fabricators/shared_draft_fabricator.rb b/spec/fabricators/shared_draft_fabricator.rb new file mode 100644 index 00000000000..4a286374e4a --- /dev/null +++ b/spec/fabricators/shared_draft_fabricator.rb @@ -0,0 +1,4 @@ +Fabricator(:shared_draft) do + topic + category +end diff --git a/spec/models/category_spec.rb b/spec/models/category_spec.rb index 7c9fbc565bd..9f756f394f1 100644 --- a/spec/models/category_spec.rb +++ b/spec/models/category_spec.rb @@ -383,12 +383,14 @@ describe Category do @category = Fabricate(:category) @category_id = @category.id @topic_id = @category.topic_id + SiteSetting.shared_drafts_category = @category.id.to_s @category.destroy end it 'is deleted correctly' do expect(Category.exists?(id: @category_id)).to be false expect(Topic.exists?(id: @topic_id)).to be false + expect(SiteSetting.shared_drafts_category).to be_blank end end diff --git a/spec/models/site_setting_spec.rb b/spec/models/site_setting_spec.rb index a81cd57909e..d849147ee06 100644 --- a/spec/models/site_setting_spec.rb +++ b/spec/models/site_setting_spec.rb @@ -119,7 +119,22 @@ describe SiteSetting do it "returns https when using ssl" do expect(SiteSetting.scheme).to eq("https") end + end + context "shared_drafts_enabled?" do + it "returns false by default" do + expect(SiteSetting.shared_drafts_enabled?).to eq(false) + end + + it "returns false when the category is uncategorized" do + SiteSetting.shared_drafts_category = SiteSetting.uncategorized_category_id + expect(SiteSetting.shared_drafts_enabled?).to eq(false) + end + + it "returns true when the category is valid" do + SiteSetting.shared_drafts_category = Fabricate(:category).id + expect(SiteSetting.shared_drafts_enabled?).to eq(true) + end end context 'deprecated site settings' do diff --git a/spec/requests/posts_controller_spec.rb b/spec/requests/posts_controller_spec.rb index b0c4a61debb..f239bd79fab 100644 --- a/spec/requests/posts_controller_spec.rb +++ b/spec/requests/posts_controller_spec.rb @@ -38,7 +38,6 @@ RSpec.describe PostsController do end it 'can not create a post in a disallowed category' do - category.set_permissions(staff: :full) category.save! @@ -120,6 +119,59 @@ RSpec.describe PostsController do expect(new_topic.allowed_users).to contain_exactly(user, user_2, user_3) end + describe 'shared draft' do + let(:destination_category) { Fabricate(:category) } + + it "will raise an error for regular users" do + post "/posts.json", params: { + raw: 'this is the shared draft content', + title: "this is the shared draft title", + category: destination_category.id, + shared_draft: 'true' + } + expect(response).not_to be_success + end + + describe "as a staff user" do + before do + sign_in(Fabricate(:moderator)) + end + + it "will raise an error if there is no shared draft category" do + post "/posts.json", params: { + raw: 'this is the shared draft content', + title: "this is the shared draft title", + category: destination_category.id, + shared_draft: 'true' + } + expect(response).not_to be_success + end + + context "with a shared category" do + let(:shared_category) { Fabricate(:category) } + before do + SiteSetting.shared_drafts_category = shared_category.id + end + + it "will work if the shared draft category is present" do + post "/posts.json", params: { + raw: 'this is the shared draft content', + title: "this is the shared draft title", + category: destination_category.id, + shared_draft: 'true' + } + expect(response).to be_success + result = JSON.parse(response.body) + topic = Topic.find(result['topic_id']) + expect(topic.category_id).to eq(shared_category.id) + expect(topic.shared_draft.category_id).to eq(destination_category.id) + end + end + + end + + end + describe 'warnings' do let(:user_2) { Fabricate(:user) } diff --git a/spec/requests/topics_controller_spec.rb b/spec/requests/topics_controller_spec.rb index 3df66f4283f..a9f43be6fa2 100644 --- a/spec/requests/topics_controller_spec.rb +++ b/spec/requests/topics_controller_spec.rb @@ -5,7 +5,6 @@ RSpec.describe TopicsController do let(:user) { Fabricate(:user) } describe '#update' do - it "won't allow us to update a topic when we're not logged in" do put "/t/1.json", params: { slug: 'xyz' } expect(response.status).to eq(403) @@ -457,4 +456,48 @@ RSpec.describe TopicsController do end end end + + describe 'shared drafts' do + let(:shared_drafts_category) { Fabricate(:category) } + let(:category) { Fabricate(:category) } + + before do + SiteSetting.shared_drafts_category = shared_drafts_category.id + end + + describe "#publish" do + let(:category) { Fabricate(:category) } + let(:topic) { Fabricate(:topic, category: shared_drafts_category, visible: false) } + let(:shared_draft) { Fabricate(:shared_draft, topic: topic, category: category) } + let(:moderator) { Fabricate(:moderator) } + + it "fails for anonymous users" do + put "/t/#{topic.id}/publish.json", params: { category_id: category.id } + expect(response).not_to be_success + end + + it "fails as a regular user" do + sign_in(Fabricate(:user)) + put "/t/#{topic.id}/publish.json", params: { category_id: category.id } + expect(response).not_to be_success + end + + context "as staff" do + before do + sign_in(moderator) + end + + it "will publish the topic" do + put "/t/#{topic.id}/publish.json", params: { destination_category_id: category.id } + expect(response).to be_success + json = ::JSON.parse(response.body)['basic_topic'] + + result = Topic.find(json['id']) + expect(result.category_id).to eq(category.id) + expect(result.visible).to eq(true) + end + end + end + end + end diff --git a/test/javascripts/acceptance/composer-actions-test.js.es6 b/test/javascripts/acceptance/composer-actions-test.js.es6 index f6a48248ac0..03afbca67a8 100644 --- a/test/javascripts/acceptance/composer-actions-test.js.es6 +++ b/test/javascripts/acceptance/composer-actions-test.js.es6 @@ -91,6 +91,22 @@ QUnit.test('replying to post - reply_as_new_topic', assert => { }); }); +QUnit.test('shared draft', assert => { + let composerActions = selectKit('.composer-actions'); + + visit("/"); + click('#create-topic'); + andThen(() => { + composerActions.expand().selectRowByValue('shared_draft'); + }); + andThen(() => { + assert.equal( + find('#reply-control .btn-primary.create .d-button-label').text(), + I18n.t('composer.create_shared_draft') + ); + assert.ok(find('#reply-control.composing-shared-draft').length === 1); + }); +}); QUnit.test('interactions', assert => { const composerActions = selectKit('.composer-actions'); @@ -137,7 +153,7 @@ QUnit.test('interactions', assert => { assert.equal(composerActions.rowByIndex(0).value(), 'reply_to_post'); assert.equal(composerActions.rowByIndex(1).value(), 'reply_as_private_message'); assert.equal(composerActions.rowByIndex(2).value(), 'reply_to_topic'); - assert.equal(composerActions.rowByIndex(3).value(), undefined); + assert.equal(composerActions.rowByIndex(3).value(), 'shared_draft'); }); composerActions.selectRowByValue('reply_as_private_message').expand(); diff --git a/test/javascripts/acceptance/shared-drafts-test.js.es6 b/test/javascripts/acceptance/shared-drafts-test.js.es6 new file mode 100644 index 00000000000..6aea6ec38be --- /dev/null +++ b/test/javascripts/acceptance/shared-drafts-test.js.es6 @@ -0,0 +1,19 @@ +import { acceptance } from "helpers/qunit-helpers"; + +acceptance("Shared Drafts", { loggedIn: true }); + +QUnit.test('Viewing', assert => { + visit("/t/some-topic/9"); + andThen(() => { + assert.ok(find('.shared-draft-controls').length === 1); + let categoryChooser = selectKit('.shared-draft-controls .category-chooser'); + assert.equal(categoryChooser.header().value(), '3'); + }); + + click('.publish-shared-draft'); + click('.bootbox .btn-primary'); + + andThen(() => { + assert.ok(find('.shared-draft-controls').length === 0); + }); +}); diff --git a/test/javascripts/acceptance/topic-anonymous-test.js.es6 b/test/javascripts/acceptance/topic-anonymous-test.js.es6 index f077e99fca3..c0eff198659 100644 --- a/test/javascripts/acceptance/topic-anonymous-test.js.es6 +++ b/test/javascripts/acceptance/topic-anonymous-test.js.es6 @@ -6,6 +6,7 @@ QUnit.test("Enter a Topic", assert => { andThen(() => { assert.ok(exists("#topic"), "The topic was rendered"); assert.ok(exists("#topic .cooked"), "The topic has cooked posts"); + assert.ok(find('.shared-draft-notice').length === 0, "no shared draft unless there's a dest category id"); }); }); @@ -38,4 +39,4 @@ QUnit.test("Enter with 500 errors", assert => { assert.ok(!exists("#topic"), "The topic was not rendered"); assert.ok(exists(".topic-error"), "An error message is displayed"); }); -}); \ No newline at end of file +}); diff --git a/test/javascripts/fixtures/site-fixtures.js.es6 b/test/javascripts/fixtures/site-fixtures.js.es6 index 4c22c04f0c1..3b4eac5a701 100644 --- a/test/javascripts/fixtures/site-fixtures.js.es6 +++ b/test/javascripts/fixtures/site-fixtures.js.es6 @@ -3,6 +3,7 @@ export default { "site":{ "default_archetype":"regular", "disabled_plugins":[], + "shared_drafts_category_id":24, "notification_types":{ "mentioned":1, "replied":2, @@ -171,17 +172,16 @@ export default { }, { "id":24, - "name":"sso", + "name":"Shared Drafts", "color":"92278F", "text_color":"FFFFFF", - "slug":"sso", + "slug":"shared-drafts", "topic_count":13, "post_count":53, - "description":"Only include actual maintained SSO (single sign on) implementations in this category. See the official documentation on Discourse's SSO support.", - "topic_url":"/t/about-the-sso-category/13110", - "read_restricted":false, + "description":"An area for staff members to post shared drafts", + "topic_url":"/t/about-the-shared-drafts-category/13110", + "read_restricted":true, "permission":1, - "parent_category_id":5, "notification_level":null, "logo_url":null, "background_url":null diff --git a/test/javascripts/fixtures/topic.js.es6 b/test/javascripts/fixtures/topic.js.es6 index 3b8344c8999..c1a75bba168 100644 --- a/test/javascripts/fixtures/topic.js.es6 +++ b/test/javascripts/fixtures/topic.js.es6 @@ -1,6 +1,6 @@ /*jshint maxlen:10000000 */ export default {"/t/280/1.json": {"post_stream":{"posts":[{"id":398,"name":"Uwe Keim","username":"uwe_keim","avatar_template":"/images/avatar.png","uploaded_avatar_id":5697,"created_at":"2013-02-05T21:29:00.280Z","cooked":"

Any plans to support localization of UI elements, so that I (for example) could set up a completely German speaking forum?

","post_number":1,"post_type":1,"updated_at":"2013-02-05T21:29:00.280Z","like_count":0,"reply_count":1,"reply_to_post_number":null,"quote_count":0,"avg_time":25,"incoming_link_count":314,"reads":475,"score":1702.25,"yours":false,"topic_id":280,"topic_slug":"internationalization-localization","display_username":"Uwe Keim","primary_group_name":null,"version":1,"can_edit":true,"can_delete":false,"can_recover":true,"link_counts":[{"url":"https://meta.discourse.org/t/language-mirrors/2378/2","internal":true,"reflection":true,"title":"Language mirrors","clicks":3},{"url":"https://meta.discourse.org/t/translation-workflow/6102","internal":true,"reflection":true,"title":"Translation workflow","clicks":2},{"url":"https://meta.discourse.org/t/solving-xda-developer-style-forums/4368/4","internal":true,"reflection":true,"title":"Solving XDA-Developer style forums","clicks":2},{"url":"https://meta.discourse.org/t/comrades-lets-join-our-efforts-on-ukrainian-and-russian-translations/4403/5","internal":true,"reflection":true,"title":"Comrades let's join our efforts on ukrainian and russian translations","clicks":1},{"url":"https://meta.discourse.org/t/bookmark-last-read-sometimes-doesn-t-go-to-the-end-of-a-topic/4825/9","internal":true,"reflection":true,"title":"Bookmark/last read sometimes doesn't go to the end of a topic","clicks":0},{"url":"https://meta.discourse.org/t/roadplan-for-discourse/2939/5","internal":true,"reflection":true,"title":"Roadplan for Discourse 2013","clicks":0}],"read":true,"user_title":null,"actions_summary":[{"id":2,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":3,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":4,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":5,"count":0,"hidden":true,"can_act":true,"can_defer_flags":false},{"id":6,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":7,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":8,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false}],"moderator":false,"admin":false,"staff":false,"user_id":255,"hidden":false,"hidden_reason_id":null,"trust_level":2,"deleted_at":null,"user_deleted":false,"edit_reason":null,"can_view_edit_history":true,"wiki":false},{"id":419,"name":"Tim Stone","username":"tms","avatar_template":"/images/avatar.png","uploaded_avatar_id":40181,"created_at":"2013-02-05T21:32:47.649Z","cooked":"

The application strings are externalized, so localization should be entirely possible with enough translation effort.

","post_number":2,"post_type":1,"updated_at":"2013-02-06T10:15:27.965Z","like_count":4,"reply_count":0,"reply_to_post_number":null,"quote_count":0,"avg_time":27,"incoming_link_count":16,"reads":460,"score":308.35,"yours":false,"topic_id":280,"topic_slug":"internationalization-localization","display_username":"Tim Stone","primary_group_name":null,"version":2,"can_edit":true,"can_delete":true,"can_recover":true,"link_counts":[{"url":"https://github.com/discourse/discourse/blob/master/config/locales/en.yml","internal":false,"reflection":false,"clicks":118}],"read":true,"user_title":"Great contributor","actions_summary":[{"id":2,"count":4,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":3,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":4,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":5,"count":0,"hidden":true,"can_act":true,"can_defer_flags":false},{"id":6,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":7,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":8,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false}],"moderator":false,"admin":false,"staff":false,"user_id":9,"hidden":false,"hidden_reason_id":null,"trust_level":2,"deleted_at":null,"user_deleted":false,"edit_reason":null,"can_view_edit_history":true,"wiki":false},{"id":1060,"name":"Jeff Atwood","username":"codinghorror","avatar_template":"/images/avatar.png","uploaded_avatar_id":5297,"created_at":"2013-02-06T02:26:24.922Z","cooked":"

Yep, all strings are going through a lookup table.*

\n\n

master/config/locales

\n\n

So you could replace that lookup table with the \"de\" one to get German.

\n\n

* we didn't get all the strings into the lookup table for launch, but the vast, vast majority of them are and the ones that are not, we will fix as we go!

","post_number":3,"post_type":1,"updated_at":"2014-02-24T05:23:39.211Z","like_count":4,"reply_count":3,"reply_to_post_number":null,"quote_count":0,"avg_time":33,"incoming_link_count":5,"reads":449,"score":191.45,"yours":false,"topic_id":280,"topic_slug":"internationalization-localization","display_username":"Jeff Atwood","primary_group_name":"discourse","version":4,"can_edit":true,"can_delete":true,"can_recover":true,"link_counts":[{"url":"https://github.com/discourse/discourse/blob/master/config/locales","internal":false,"reflection":false,"title":"discourse/config/locales at master · discourse/discourse · GitHub","clicks":62},{"url":"https://meta.discourse.org/t/github-onebox-rendering-issue/7616","internal":true,"reflection":true,"title":"GitHub OneBox Rendering Issue","clicks":0}],"read":true,"user_title":"co-founder","actions_summary":[{"id":2,"count":4,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":3,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":4,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":5,"count":0,"hidden":true,"can_act":true,"can_defer_flags":false},{"id":6,"count":1,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":7,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":8,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false}],"moderator":true,"admin":true,"staff":true,"user_id":32,"hidden":false,"hidden_reason_id":null,"trust_level":3,"deleted_at":null,"user_deleted":false,"edit_reason":"","can_view_edit_history":true,"wiki":false},{"id":3623,"name":"Shade","username":"shade","avatar_template":"/images/avatar.png","uploaded_avatar_id":8306,"created_at":"2013-02-07T12:55:33.129Z","cooked":"

Is it a coincidence that the strings file is 1337 lines long? \"smiley\"

","post_number":4,"post_type":1,"updated_at":"2013-02-07T12:55:33.129Z","like_count":7,"reply_count":0,"reply_to_post_number":null,"quote_count":0,"avg_time":20,"incoming_link_count":15,"reads":401,"score":291.2,"yours":false,"topic_id":280,"topic_slug":"internationalization-localization","display_username":"Shade","primary_group_name":null,"version":1,"can_edit":true,"can_delete":true,"can_recover":true,"link_counts":[{"url":"https://meta.discourse.org/t/hi-support-chinese/4393/6","internal":true,"reflection":true,"title":"Hi, support Chinese?","clicks":0}],"read":true,"user_title":null,"actions_summary":[{"id":2,"count":7,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":3,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":4,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":5,"count":0,"hidden":true,"can_act":true,"can_defer_flags":false},{"id":6,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":7,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":8,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false}],"moderator":false,"admin":false,"staff":false,"user_id":1808,"hidden":false,"hidden_reason_id":null,"trust_level":1,"deleted_at":null,"user_deleted":false,"edit_reason":null,"can_view_edit_history":true,"wiki":false},{"id":3651,"name":"Pekka Gaiser","username":"pekka","avatar_template":"/images/avatar.png","uploaded_avatar_id":5253,"created_at":"2013-02-07T14:02:07.869Z","cooked":"

\n\n

The problem I see here is that this file is likely two grow and change massively over the next couple months, and tracking these changes in order to keep a localized file up to date is going to be a bitch.

\n\n

I wonder where there is a tool that can compare two yml structures and point out which nodes are missing? That would help keep track of new strings.

\n\n

Re keeping track of changed strings, @codinghorror I found this very interesting: http://stackoverflow.com/questions/4232922/why-do-people-use-plain-english-as-translation-placeholders if plain English placeholders were used, any change in strings would lead to a new node in the yml file, making keeping the translation up to date easier. Maybe worth thinking about in the future.

","post_number":5,"post_type":1,"updated_at":"2013-02-07T14:05:42.328Z","like_count":2,"reply_count":2,"reply_to_post_number":3,"quote_count":1,"avg_time":22,"incoming_link_count":10,"reads":386,"score":213.3,"yours":false,"topic_id":280,"topic_slug":"internationalization-localization","display_username":"Pekka Gaiser","primary_group_name":null,"version":1,"can_edit":true,"can_delete":true,"can_recover":true,"link_counts":[{"url":"http://stackoverflow.com/questions/4232922/why-do-people-use-plain-english-as-translation-placeholders","internal":false,"reflection":false,"title":"internationalization - Why do people use plain english as translation placeholders? - Stack Overflow","clicks":63}],"read":true,"user_title":null,"actions_summary":[{"id":2,"count":2,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":3,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":4,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":5,"count":0,"hidden":true,"can_act":true,"can_defer_flags":false},{"id":6,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":7,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":8,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false}],"moderator":false,"admin":false,"staff":false,"user_id":7,"hidden":false,"hidden_reason_id":null,"trust_level":2,"deleted_at":null,"user_deleted":false,"edit_reason":null,"can_view_edit_history":true,"wiki":false},{"id":3654,"name":"Sam Saffron","username":"sam","avatar_template":"/images/avatar.png","uploaded_avatar_id":5243,"created_at":"2013-02-07T14:05:39.910Z","cooked":"

Yes, I really like the concept of fuzzy matching for localization, perhaps you can chase up alex sexton he was meaning to upload a localization tool for this kind of stuff.

\n\n

Also, I am a big fan of ICU message format, but it is not the \"Rails way (tm)\".

","post_number":6,"post_type":1,"updated_at":"2013-02-07T14:05:39.910Z","like_count":1,"reply_count":1,"reply_to_post_number":5,"quote_count":0,"avg_time":17,"incoming_link_count":4,"reads":329,"score":106.65,"yours":false,"topic_id":280,"topic_slug":"internationalization-localization","display_username":"Sam Saffron","primary_group_name":"discourse","version":1,"can_edit":true,"can_delete":true,"can_recover":true,"link_counts":[{"url":"https://github.com/SlexAxton/messageformat.js","internal":false,"reflection":false,"title":"SlexAxton/messageformat.js · GitHub","clicks":46},{"url":"https://github.com/SlexAxton","internal":false,"reflection":false,"title":"SlexAxton (Alex Sexton) · GitHub","clicks":10}],"read":true,"user_title":"co-founder","reply_to_user":{"username":"pekka","avatar_template":"/images/avatar.png","uploaded_avatar_id":5253},"actions_summary":[{"id":2,"count":1,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":3,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":4,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":5,"count":0,"hidden":true,"can_act":true,"can_defer_flags":false},{"id":6,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":7,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":8,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false}],"moderator":true,"admin":true,"staff":true,"user_id":1,"hidden":false,"hidden_reason_id":null,"trust_level":4,"deleted_at":null,"user_deleted":false,"edit_reason":null,"can_view_edit_history":true,"wiki":false},{"id":3655,"name":"Pekka Gaiser","username":"pekka","avatar_template":"/images/avatar.png","uploaded_avatar_id":5253,"created_at":"2013-02-07T14:08:17.493Z","cooked":"

Looks interesting, I'll take a peek.

\n\n

As said on dev, the best tool I can see in terms of giving translators a proper interface and quality control would be something like GlotPress. It's based on the PO messages format (is that somehow related to ICU?) but looks pretty great.

\n\n

\n\n

I'm not familiar with the term in this context, you mean keeping the English version in the code base (instead of a generic code like message_error_nametooshort ?)

","post_number":7,"post_type":1,"updated_at":"2013-02-07T14:12:02.965Z","like_count":1,"reply_count":1,"reply_to_post_number":6,"quote_count":1,"avg_time":16,"incoming_link_count":0,"reads":326,"score":86.0,"yours":false,"topic_id":280,"topic_slug":"internationalization-localization","display_username":"Pekka Gaiser","primary_group_name":null,"version":1,"can_edit":true,"can_delete":true,"can_recover":true,"link_counts":[{"url":"http://translate.wordpress.org/projects/bbpress/dev","internal":false,"reflection":false,"title":"WordPress › Development < GlotPress","clicks":16}],"read":true,"user_title":null,"actions_summary":[{"id":2,"count":1,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":3,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":4,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":5,"count":0,"hidden":true,"can_act":true,"can_defer_flags":false},{"id":6,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":7,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":8,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false}],"moderator":false,"admin":false,"staff":false,"user_id":7,"hidden":false,"hidden_reason_id":null,"trust_level":2,"deleted_at":null,"user_deleted":false,"edit_reason":null,"can_view_edit_history":true,"wiki":false},{"id":3658,"name":"Sam Saffron","username":"sam","avatar_template":"/images/avatar.png","uploaded_avatar_id":5243,"created_at":"2013-02-07T14:12:22.582Z","cooked":"

ICU Message format is basically Gettext on steroids, Gettext has been around for so many years and actually works pretty well, being super prevalent in Linux.

\n\n

Trouble is you need a fuzzy matcher for translators if you are going to store stuff like mf.compile( 'This is a message.' ) in source, one letter change and all your translators need to validate it.

","post_number":8,"post_type":1,"updated_at":"2013-02-07T14:12:22.582Z","like_count":1,"reply_count":1,"reply_to_post_number":7,"quote_count":0,"avg_time":11,"incoming_link_count":2,"reads":296,"score":89.75,"yours":false,"topic_id":280,"topic_slug":"internationalization-localization","display_username":"Sam Saffron","primary_group_name":"discourse","version":1,"can_edit":true,"can_delete":true,"can_recover":true,"link_counts":[{"url":"https://meta.discourse.org/t/what-i-love-about-wordpress-plugins/5697","internal":true,"reflection":true,"title":"What I love about WordPress plugins","clicks":0}],"read":true,"user_title":"co-founder","reply_to_user":{"username":"pekka","avatar_template":"/images/avatar.png","uploaded_avatar_id":5253},"actions_summary":[{"id":2,"count":1,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":3,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":4,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":5,"count":0,"hidden":true,"can_act":true,"can_defer_flags":false},{"id":6,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":7,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":8,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false}],"moderator":true,"admin":true,"staff":true,"user_id":1,"hidden":false,"hidden_reason_id":null,"trust_level":4,"deleted_at":null,"user_deleted":false,"edit_reason":null,"can_view_edit_history":true,"wiki":false},{"id":3660,"name":"Pekka Gaiser","username":"pekka","avatar_template":"/images/avatar.png","uploaded_avatar_id":5253,"created_at":"2013-02-07T14:14:12.666Z","cooked":"

\n\n

Yeah, that's why I've always been a friend of message_error_nametooshort placeholders, until I asked the SO question linked above. The accepted answer makes a good argument against those placeholders: you want translations to break even on small changes in the English original because the translations will probably need to reflect the change, too. Maybe that's not the case right now as new stuff is being checked in pretty much every couple of hours, but in the long run, it'll be overwhelmingly true.

","post_number":9,"post_type":1,"updated_at":"2013-02-07T14:18:09.569Z","like_count":1,"reply_count":1,"reply_to_post_number":8,"quote_count":1,"avg_time":10,"incoming_link_count":0,"reads":293,"score":79.1,"yours":false,"topic_id":280,"topic_slug":"internationalization-localization","display_username":"Pekka Gaiser","primary_group_name":null,"version":1,"can_edit":true,"can_delete":true,"can_recover":true,"read":true,"user_title":null,"actions_summary":[{"id":2,"count":1,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":3,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":4,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":5,"count":0,"hidden":true,"can_act":true,"can_defer_flags":false},{"id":6,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":7,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":8,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false}],"moderator":false,"admin":false,"staff":false,"user_id":7,"hidden":false,"hidden_reason_id":null,"trust_level":2,"deleted_at":null,"user_deleted":false,"edit_reason":null,"can_view_edit_history":true,"wiki":false},{"id":3667,"name":"Tim Stone","username":"tms","avatar_template":"/images/avatar.png","uploaded_avatar_id":40181,"created_at":"2013-02-07T14:25:16.859Z","cooked":"

Hmm...You could theoretically also build something into the development process that would monitor changes to the English locale file and make a translator-friendly list of changes between versions.

","post_number":10,"post_type":1,"updated_at":"2013-02-07T14:25:16.859Z","like_count":1,"reply_count":1,"reply_to_post_number":9,"quote_count":0,"avg_time":7,"incoming_link_count":0,"reads":275,"score":75.35,"yours":false,"topic_id":280,"topic_slug":"internationalization-localization","display_username":"Tim Stone","primary_group_name":null,"version":1,"can_edit":true,"can_delete":true,"can_recover":true,"read":true,"user_title":"Great contributor","reply_to_user":{"username":"pekka","avatar_template":"/images/avatar.png","uploaded_avatar_id":5253},"actions_summary":[{"id":2,"count":1,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":3,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":4,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":5,"count":0,"hidden":true,"can_act":true,"can_defer_flags":false},{"id":6,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":7,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":8,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false}],"moderator":false,"admin":false,"staff":false,"user_id":9,"hidden":false,"hidden_reason_id":null,"trust_level":2,"deleted_at":null,"user_deleted":false,"edit_reason":null,"can_view_edit_history":true,"wiki":false},{"id":3673,"name":"Sam Saffron","username":"sam","avatar_template":"/images/avatar.png","uploaded_avatar_id":5243,"created_at":"2013-02-07T14:30:21.401Z","cooked":"

Yeah, totally, also we could build tools for dev that make introducing string less annoying and make it possible to garbage collect old unused strings, I hate trudging through that file.

","post_number":11,"post_type":1,"updated_at":"2013-02-07T14:30:21.401Z","like_count":1,"reply_count":1,"reply_to_post_number":10,"quote_count":0,"avg_time":7,"incoming_link_count":1,"reads":273,"score":79.95,"yours":false,"topic_id":280,"topic_slug":"internationalization-localization","display_username":"Sam Saffron","primary_group_name":"discourse","version":1,"can_edit":true,"can_delete":true,"can_recover":true,"read":true,"user_title":"co-founder","reply_to_user":{"username":"tms","avatar_template":"/images/avatar.png","uploaded_avatar_id":40181},"actions_summary":[{"id":2,"count":1,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":3,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":4,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":5,"count":0,"hidden":true,"can_act":true,"can_defer_flags":false},{"id":6,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":7,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":8,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false}],"moderator":true,"admin":true,"staff":true,"user_id":1,"hidden":false,"hidden_reason_id":null,"trust_level":4,"deleted_at":null,"user_deleted":false,"edit_reason":null,"can_view_edit_history":true,"wiki":false},{"id":3675,"name":"Pekka Gaiser","username":"pekka","avatar_template":"/images/avatar.png","uploaded_avatar_id":5253,"created_at":"2013-02-07T14:33:38.280Z","cooked":"

\n\n

As said, I'd look into whether WP's tools can't be reused for this with some tweaking. They seem to be able to scan a code base for new strings, and make them available automatically to translators.

\n\n

They're PHP based which isn't ideal, but it looks like they've done a crapload of work to take the hassle out of translations.

","post_number":12,"post_type":1,"updated_at":"2013-02-07T14:34:39.910Z","like_count":1,"reply_count":1,"reply_to_post_number":11,"quote_count":1,"avg_time":7,"incoming_link_count":2,"reads":273,"score":84.95,"yours":false,"topic_id":280,"topic_slug":"internationalization-localization","display_username":"Pekka Gaiser","primary_group_name":null,"version":1,"can_edit":true,"can_delete":true,"can_recover":true,"read":true,"user_title":null,"actions_summary":[{"id":2,"count":1,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":3,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":4,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":5,"count":0,"hidden":true,"can_act":true,"can_defer_flags":false},{"id":6,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":7,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":8,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false}],"moderator":false,"admin":false,"staff":false,"user_id":7,"hidden":false,"hidden_reason_id":null,"trust_level":2,"deleted_at":null,"user_deleted":false,"edit_reason":null,"can_view_edit_history":true,"wiki":false},{"id":3690,"name":"Valts","username":"Vilx","avatar_template":"/images/avatar.png","uploaded_avatar_id":7299,"created_at":"2013-02-07T15:05:35.867Z","cooked":"

This site looks so nice with all the little tweaks like \"10 minutes ago\" instead of simply time, etc - I wonder if there will also be support for proper pluralization in other languages? That's a pretty hard task though, I don't think I've ever seen a website that has done that. But it would be awesome.

","post_number":13,"post_type":1,"updated_at":"2013-02-07T15:05:35.867Z","like_count":3,"reply_count":0,"reply_to_post_number":null,"quote_count":0,"avg_time":7,"incoming_link_count":11,"reads":290,"score":158.35,"yours":false,"topic_id":280,"topic_slug":"internationalization-localization","display_username":"Valts","primary_group_name":null,"version":1,"can_edit":true,"can_delete":true,"can_recover":true,"read":true,"user_title":null,"actions_summary":[{"id":2,"count":3,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":3,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":4,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":5,"count":0,"hidden":true,"can_act":true,"can_defer_flags":false},{"id":6,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":7,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":8,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false}],"moderator":false,"admin":false,"staff":false,"user_id":1216,"hidden":false,"hidden_reason_id":null,"trust_level":2,"deleted_at":null,"user_deleted":false,"edit_reason":null,"can_view_edit_history":true,"wiki":false},{"id":3925,"name":"Eric Kidd","username":"emk","avatar_template":"/images/avatar.png","uploaded_avatar_id":8400,"created_at":"2013-02-07T19:37:06.194Z","cooked":"

\n\n

I've had pretty decent luck using Localeapp to localize Rails applications:

\n\n

http://www.localeapp.com/

\n\n

The developer workflow took me about an hour to really get used to, and there were a few minor glitches. But the non-technical translators had very few problems. One limitation: It insists on rewriting all those yaml files full of strings.

\n\n

Anyway, it's worth a look, and it's free for open source, if I recall correctly. Certainly easier than doing a whole bunch of toolsmithing from scratch.

","post_number":14,"post_type":1,"updated_at":"2013-02-07T19:37:06.194Z","like_count":3,"reply_count":1,"reply_to_post_number":12,"quote_count":1,"avg_time":9,"incoming_link_count":0,"reads":283,"score":137.05,"yours":false,"topic_id":280,"topic_slug":"internationalization-localization","display_username":"Eric Kidd","primary_group_name":null,"version":1,"can_edit":true,"can_delete":true,"can_recover":true,"link_counts":[{"url":"http://www.localeapp.com/","internal":false,"reflection":false,"title":"Easy localization for Rails apps | Locale","clicks":69}],"read":true,"user_title":null,"actions_summary":[{"id":2,"count":3,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":3,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":4,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":5,"count":0,"hidden":true,"can_act":true,"can_defer_flags":false},{"id":6,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":7,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":8,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false}],"moderator":false,"admin":false,"staff":false,"user_id":1860,"hidden":false,"hidden_reason_id":null,"trust_level":1,"deleted_at":null,"user_deleted":false,"edit_reason":null,"can_view_edit_history":true,"wiki":false},{"id":3938,"name":"Pekka Gaiser","username":"pekka","avatar_template":"/images/avatar.png","uploaded_avatar_id":5253,"created_at":"2013-02-07T19:52:13.748Z","cooked":"

\n\n

Ohhh. Looking sexy. droool

","post_number":15,"post_type":1,"updated_at":"2013-02-07T19:52:13.748Z","like_count":1,"reply_count":1,"reply_to_post_number":14,"quote_count":1,"avg_time":7,"incoming_link_count":0,"reads":260,"score":72.35,"yours":false,"topic_id":280,"topic_slug":"internationalization-localization","display_username":"Pekka Gaiser","primary_group_name":null,"version":1,"can_edit":true,"can_delete":true,"can_recover":true,"read":true,"user_title":null,"actions_summary":[{"id":2,"count":1,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":3,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":4,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":5,"count":0,"hidden":true,"can_act":true,"can_defer_flags":false},{"id":6,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":7,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":8,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false}],"moderator":false,"admin":false,"staff":false,"user_id":7,"hidden":false,"hidden_reason_id":null,"trust_level":2,"deleted_at":null,"user_deleted":false,"edit_reason":null,"can_view_edit_history":true,"wiki":false},{"id":3982,"name":"Eric Kidd","username":"emk","avatar_template":"/images/avatar.png","uploaded_avatar_id":8400,"created_at":"2013-02-07T20:52:22.454Z","cooked":"

\n\n

Yeah, it's pretty. \"smile\" But there were still some rough edges as of a few months ago.

\n\n

Whether or not those rough edges are a deal-breaker will probably depends on whether or not localization is already a source of acute pain. If you're already hurting, Localeapp is a pretty useful tool, especially when it comes to enlisting non-technical translators.

\n\n

But it does require changing how you work with text, and adding one new tool to the mix. So for projects that just don't want to know about non-English languages, it's not yet seamless the way Unicode is these days.

\n\n

(Sweet forum software, by the way. I was just testing out Egyptian hieroglyphics on the test server, because they're well off the Basic Multilingual Plane, and tend to flush Unicode bugs. Everything worked flawlessly.)

","post_number":16,"post_type":1,"updated_at":"2013-02-07T20:52:22.454Z","like_count":1,"reply_count":1,"reply_to_post_number":15,"quote_count":1,"avg_time":7,"incoming_link_count":0,"reads":254,"score":71.15,"yours":false,"topic_id":280,"topic_slug":"internationalization-localization","display_username":"Eric Kidd","primary_group_name":null,"version":1,"can_edit":true,"can_delete":true,"can_recover":true,"read":true,"user_title":null,"actions_summary":[{"id":2,"count":1,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":3,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":4,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":5,"count":0,"hidden":true,"can_act":true,"can_defer_flags":false},{"id":6,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":7,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":8,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false}],"moderator":false,"admin":false,"staff":false,"user_id":1860,"hidden":false,"hidden_reason_id":null,"trust_level":1,"deleted_at":null,"user_deleted":false,"edit_reason":null,"can_view_edit_history":true,"wiki":false},{"id":3989,"name":"Pekka Gaiser","username":"pekka","avatar_template":"/images/avatar.png","uploaded_avatar_id":5253,"created_at":"2013-02-07T21:04:15.405Z","cooked":"

\n\n

Interesting, thanks for the insight. I don't think localization is seriously on their table right now, there's likely to be many other things on the table before it... but it will become an issue sooner or later.

","post_number":17,"post_type":1,"updated_at":"2013-02-07T21:04:15.405Z","like_count":1,"reply_count":2,"reply_to_post_number":16,"quote_count":1,"avg_time":7,"incoming_link_count":0,"reads":255,"score":76.35,"yours":false,"topic_id":280,"topic_slug":"internationalization-localization","display_username":"Pekka Gaiser","primary_group_name":null,"version":1,"can_edit":true,"can_delete":true,"can_recover":true,"read":true,"user_title":null,"actions_summary":[{"id":2,"count":1,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":3,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":4,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":5,"count":0,"hidden":true,"can_act":true,"can_defer_flags":false},{"id":6,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":7,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":8,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false}],"moderator":false,"admin":false,"staff":false,"user_id":7,"hidden":false,"hidden_reason_id":null,"trust_level":2,"deleted_at":null,"user_deleted":false,"edit_reason":null,"can_view_edit_history":true,"wiki":false},{"id":3996,"name":"Sam Saffron","username":"sam","avatar_template":"/images/avatar.png","uploaded_avatar_id":5243,"created_at":"2013-02-07T21:12:06.575Z","cooked":"

I had an idea ... what if in dev mode, you could right-click on a page and get access to all the translations on the page, make your edits and have it refreshed live.

\n\n

I think it would be awesome, very doable technically.

","post_number":18,"post_type":1,"updated_at":"2013-02-07T21:12:06.575Z","like_count":7,"reply_count":2,"reply_to_post_number":17,"quote_count":0,"avg_time":8,"incoming_link_count":0,"reads":264,"score":168.2,"yours":false,"topic_id":280,"topic_slug":"internationalization-localization","display_username":"Sam Saffron","primary_group_name":"discourse","version":1,"can_edit":true,"can_delete":true,"can_recover":true,"read":true,"user_title":"co-founder","reply_to_user":{"username":"pekka","avatar_template":"/images/avatar.png","uploaded_avatar_id":5253},"actions_summary":[{"id":2,"count":7,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":3,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":4,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":5,"count":0,"hidden":true,"can_act":true,"can_defer_flags":false},{"id":6,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":7,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":8,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false}],"moderator":true,"admin":true,"staff":true,"user_id":1,"hidden":false,"hidden_reason_id":null,"trust_level":4,"deleted_at":null,"user_deleted":false,"edit_reason":null,"can_view_edit_history":true,"wiki":false},{"id":4009,"name":"Pekka Gaiser","username":"pekka","avatar_template":"/images/avatar.png","uploaded_avatar_id":5253,"created_at":"2013-02-07T21:18:47.422Z","cooked":"

That would be fricking cool. There'd still be some leftovers (like error messages that normally never show up, etc.) but you could corral those up on a specific page.

\n\n

It could have a dropdown giving you all the languages that you have a .yml for in the locale directory, and write the changes into the one selected. I'm sure people would love it.

","post_number":19,"post_type":1,"updated_at":"2013-02-07T21:22:10.692Z","like_count":1,"reply_count":0,"reply_to_post_number":18,"quote_count":0,"avg_time":8,"incoming_link_count":1,"reads":241,"score":68.6,"yours":false,"topic_id":280,"topic_slug":"internationalization-localization","display_username":"Pekka Gaiser","primary_group_name":null,"version":1,"can_edit":true,"can_delete":true,"can_recover":true,"read":true,"user_title":null,"reply_to_user":{"username":"sam","avatar_template":"/images/avatar.png","uploaded_avatar_id":5243},"actions_summary":[{"id":2,"count":1,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":3,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":4,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":5,"count":0,"hidden":true,"can_act":true,"can_defer_flags":false},{"id":6,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":7,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":8,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false}],"moderator":false,"admin":false,"staff":false,"user_id":7,"hidden":false,"hidden_reason_id":null,"trust_level":2,"deleted_at":null,"user_deleted":false,"edit_reason":null,"can_view_edit_history":true,"wiki":false},{"id":4012,"name":"Marco Ceppi","username":"marcoceppi","avatar_template":"/images/avatar.png","uploaded_avatar_id":6552,"created_at":"2013-02-07T21:22:46.376Z","cooked":"

If you use gettext format you could leverage Launchpad translations and the community behind it.

","post_number":20,"post_type":1,"updated_at":"2013-02-07T21:22:46.376Z","like_count":1,"reply_count":0,"reply_to_post_number":null,"quote_count":0,"avg_time":9,"incoming_link_count":2,"reads":244,"score":74.25,"yours":false,"topic_id":280,"topic_slug":"internationalization-localization","display_username":"Marco Ceppi","primary_group_name":null,"version":1,"can_edit":true,"can_delete":true,"can_recover":true,"link_counts":[{"url":"https://translations.launchpad.net/","internal":false,"reflection":false,"title":"Launchpad Translations","clicks":13}],"read":true,"user_title":null,"actions_summary":[{"id":2,"count":1,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":3,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":4,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":5,"count":0,"hidden":true,"can_act":true,"can_defer_flags":false},{"id":6,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":7,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false},{"id":8,"count":0,"hidden":false,"can_act":true,"can_defer_flags":false}],"moderator":false,"admin":false,"staff":false,"user_id":761,"hidden":false,"hidden_reason_id":null,"trust_level":2,"deleted_at":null,"user_deleted":false,"edit_reason":null,"can_view_edit_history":true,"wiki":false}],"stream":[398,419,1060,3623,3651,3654,3655,3658,3660,3667,3673,3675,3690,3925,3938,3982,3989,3996,4009,4012],"gaps":{"before":{"20706":[20125]},"after":{}}},"id":280,"title":"Internationalization / localization","fancy_title":"Internationalization / localization","posts_count":103,"created_at":"2013-02-05T21:29:00.174Z","views":5211,"reply_count":67,"participant_count":40,"like_count":135,"last_posted_at":"2015-03-04T15:07:10.487Z","visible":true,"closed":false,"archived":false,"has_summary":true,"archetype":"regular","slug":"internationalization-localization","category_id":2,"word_count":6198,"deleted_at":null,"draft":null,"draft_key":"topic_280","draft_sequence":4,"posted":true,"unpinned":null,"pinned_globally":false,"pinned":false,"pinned_at":null,"details":{"auto_close_at":null,"auto_close_hours":null,"auto_close_based_on_last_post":false,"created_by":{"id":255,"username":"uwe_keim","uploaded_avatar_id":5697,"avatar_template":"/images/avatar.png"},"last_poster":{"id":14091,"username":"Luciano_Fantuzzi","uploaded_avatar_id":39484,"avatar_template":"/images/avatar.png"},"participants":[{"id":212,"username":"alxndr","uploaded_avatar_id":5619,"avatar_template":"/images/avatar.png","post_count":11},{"id":1,"username":"sam","uploaded_avatar_id":5243,"avatar_template":"/images/avatar.png","post_count":11},{"id":7,"username":"pekka","uploaded_avatar_id":5253,"avatar_template":"/images/avatar.png","post_count":8},{"id":461,"username":"kuba","uploaded_avatar_id":6049,"avatar_template":"/images/avatar.png","post_count":7},{"id":2995,"username":"tattoo","uploaded_avatar_id":null,"avatar_template":"/images/avatar.png","post_count":6},{"id":2540,"username":"jgourdon","uploaded_avatar_id":9537,"avatar_template":"/images/avatar.png","post_count":5},{"id":1860,"username":"emk","uploaded_avatar_id":8400,"avatar_template":"/images/avatar.png","post_count":4},{"id":1275,"username":"dacap","uploaded_avatar_id":7401,"avatar_template":"/images/avatar.png","post_count":4},{"id":19,"username":"eviltrout","uploaded_avatar_id":5275,"avatar_template":"/images/avatar.png","post_count":4},{"id":3704,"username":"mojzis","uploaded_avatar_id":31201,"avatar_template":"/images/avatar.png","post_count":3},{"id":3190,"username":"gururea","uploaded_avatar_id":10663,"avatar_template":"/images/avatar.png","post_count":3},{"id":1895,"username":"maciek","uploaded_avatar_id":8463,"avatar_template":"/images/avatar.png","post_count":3},{"id":22,"username":"splattne","uploaded_avatar_id":5280,"avatar_template":"/images/avatar.png","post_count":2},{"id":1979,"username":"Superuser","uploaded_avatar_id":8604,"avatar_template":"/images/avatar.png","post_count":2},{"id":3818,"username":"Tudor","uploaded_avatar_id":11675,"avatar_template":"/images/avatar.png","post_count":2},{"id":32,"username":"codinghorror","uploaded_avatar_id":5297,"avatar_template":"/images/avatar.png","post_count":2},{"id":3620,"username":"potthast","uploaded_avatar_id":11363,"avatar_template":"/images/avatar.png","post_count":2},{"id":9,"username":"tms","uploaded_avatar_id":40181,"avatar_template":"/images/avatar.png","post_count":2},{"id":14091,"username":"Luciano_Fantuzzi","uploaded_avatar_id":39484,"avatar_template":"/images/avatar.png","post_count":1},{"id":255,"username":"uwe_keim","uploaded_avatar_id":5697,"avatar_template":"/images/avatar.png","post_count":1},{"id":9006,"username":"berk","uploaded_avatar_id":19348,"avatar_template":"/images/avatar.png","post_count":1},{"id":754,"username":"danneu","uploaded_avatar_id":6540,"avatar_template":"/images/avatar.png","post_count":1},{"id":761,"username":"marcoceppi","uploaded_avatar_id":6552,"avatar_template":"/images/avatar.png","post_count":1},{"id":2753,"username":"mikl","uploaded_avatar_id":9918,"avatar_template":"/images/avatar.png","post_count":1}],"suggested_topics":[{"id":27331,"title":"Polls are still very buggy","fancy_title":"Polls are still very buggy","slug":"polls-are-still-very-buggy","posts_count":4,"reply_count":1,"highest_post_number":4,"image_url":"/uploads/default/_optimized/cd1/b8c/c162528887_690x401.png","created_at":"2015-04-08T09:51:00.357Z","last_posted_at":"2015-04-08T15:59:16.258Z","bumped":true,"bumped_at":"2015-04-08T16:05:09.842Z","unseen":false,"last_read_post_number":3,"unread":0,"new_posts":1,"pinned":false,"unpinned":null,"visible":true,"closed":false,"archived":false,"notification_level":2,"bookmarked":false,"liked":false,"archetype":"regular","like_count":11,"views":55,"category_id":1},{"id":27343,"title":"Mobile theme doesn't show last activity time for topics on category page","fancy_title":"Mobile theme doesn’t show last activity time for topics on category page","slug":"mobile-theme-doesnt-show-last-activity-time-for-topics-on-category-page","posts_count":4,"reply_count":2,"highest_post_number":4,"image_url":"/uploads/default/_optimized/13e/25c/bd30b466be_281x500.png","created_at":"2015-04-08T14:20:51.177Z","last_posted_at":"2015-04-08T15:40:30.037Z","bumped":true,"bumped_at":"2015-04-08T15:40:30.037Z","unseen":false,"last_read_post_number":2,"unread":0,"new_posts":2,"pinned":false,"unpinned":null,"visible":true,"closed":false,"archived":false,"notification_level":2,"bookmarked":false,"liked":false,"archetype":"regular","like_count":3,"views":23,"category_id":9},{"id":27346,"title":"Reply+{messagekey}@... optionaly in header \"from\" in addition to \"reply-to\"","fancy_title":"Reply+{messagekey}@… optionaly in header “from” in addition to “reply-to”","slug":"reply-messagekey-optionaly-in-header-from-in-addition-to-reply-to","posts_count":1,"reply_count":0,"highest_post_number":1,"image_url":null,"created_at":"2015-04-08T16:05:13.103Z","last_posted_at":"2015-04-08T16:05:13.415Z","bumped":true,"bumped_at":"2015-04-08T16:05:13.415Z","unseen":true,"pinned":false,"unpinned":null,"visible":true,"closed":false,"archived":false,"bookmarked":null,"liked":null,"archetype":"regular","like_count":0,"views":8,"category_id":2},{"id":19670,"title":"Parsing (Oneboxing) IMDB links","fancy_title":"Parsing (Oneboxing) IMDB links","slug":"parsing-oneboxing-imdb-links","posts_count":8,"reply_count":1,"highest_post_number":8,"image_url":null,"created_at":"2014-09-05T07:19:26.161Z","last_posted_at":"2015-04-07T09:21:21.570Z","bumped":true,"bumped_at":"2015-04-07T09:21:21.570Z","unseen":false,"last_read_post_number":8,"unread":0,"new_posts":0,"pinned":false,"unpinned":null,"visible":true,"closed":false,"archived":false,"notification_level":2,"bookmarked":false,"liked":false,"archetype":"regular","like_count":4,"views":253,"category_id":2},{"id":7512,"title":"Support for Piwik Analytics as an alternative to Google Analytics","fancy_title":"Support for Piwik Analytics as an alternative to Google Analytics","slug":"support-for-piwik-analytics-as-an-alternative-to-google-analytics","posts_count":53,"reply_count":41,"highest_post_number":65,"image_url":"/plugins/emoji/images/smile.png","created_at":"2013-06-16T01:32:30.596Z","last_posted_at":"2015-02-22T13:46:26.845Z","bumped":true,"bumped_at":"2015-02-22T13:46:26.845Z","unseen":false,"last_read_post_number":65,"unread":0,"new_posts":0,"pinned":false,"unpinned":null,"visible":true,"closed":false,"archived":false,"notification_level":2,"bookmarked":false,"liked":false,"archetype":"regular","like_count":62,"views":1877,"category_id":2},{"id":25480,"title":"CSS admin-contents reloaded","fancy_title":"CSS admin-contents reloaded","slug":"css-admin-contents-reloaded","posts_count":22,"reply_count":15,"highest_post_number":22,"image_url":null,"created_at":"2015-02-21T12:15:57.707Z","last_posted_at":"2015-03-02T23:24:18.899Z","bumped":true,"bumped_at":"2015-03-02T23:24:18.899Z","unseen":false,"pinned":false,"unpinned":null,"visible":true,"closed":false,"archived":false,"bookmarked":null,"liked":null,"archetype":"regular","like_count":21,"views":185,"category_id":2},{"id":26576,"title":"Badge timestamp should be the time the badge was granted?","fancy_title":"Badge timestamp should be the time the badge was granted?","slug":"badge-timestamp-should-be-the-time-the-badge-was-granted","posts_count":2,"reply_count":0,"highest_post_number":2,"image_url":null,"created_at":"2015-03-20T13:22:08.266Z","last_posted_at":"2015-03-21T00:33:52.243Z","bumped":true,"bumped_at":"2015-03-21T00:33:52.243Z","unseen":false,"last_read_post_number":1,"unread":0,"new_posts":0,"pinned":false,"unpinned":null,"visible":true,"closed":false,"archived":false,"notification_level":1,"bookmarked":false,"liked":false,"archetype":"regular","like_count":9,"views":87,"category_id":2}],"links":[{"url":"https://github.com/discourse/discourse/blob/master/config/locales/en.yml","title":null,"fancy_title":null,"internal":false,"reflection":false,"clicks":118,"user_id":9,"domain":"github.com"},{"url":"http://www.localeapp.com/","title":"Easy localization for Rails apps | Locale","fancy_title":null,"internal":false,"reflection":false,"clicks":69,"user_id":1860,"domain":"www.localeapp.com"},{"url":"http://stackoverflow.com/questions/4232922/why-do-people-use-plain-english-as-translation-placeholders","title":"internationalization - Why do people use plain english as translation placeholders? - Stack Overflow","fancy_title":null,"internal":false,"reflection":false,"clicks":63,"user_id":7,"domain":"stackoverflow.com"},{"url":"https://github.com/discourse/discourse/blob/master/config/locales","title":"discourse/config/locales at master · discourse/discourse · GitHub","fancy_title":null,"internal":false,"reflection":false,"clicks":62,"user_id":32,"domain":"github.com"},{"url":"https://github.com/SlexAxton/messageformat.js","title":"SlexAxton/messageformat.js · GitHub","fancy_title":null,"internal":false,"reflection":false,"clicks":46,"user_id":1,"domain":"github.com"},{"url":"http://www.localeapp.com/projects/1537/translations?utf8=%E2%9C%93&search=source_code","title":"langforums | Locale","fancy_title":null,"internal":false,"reflection":false,"clicks":25,"user_id":1860,"domain":"www.localeapp.com"},{"url":"https://translations.launchpad.net/","title":"Launchpad Translations","fancy_title":null,"internal":false,"reflection":false,"clicks":23,"user_id":761,"domain":"translations.launchpad.net"},{"url":"https://www.transifex.com/","title":"Transifex - Continuous Localization Platform","fancy_title":null,"internal":false,"reflection":false,"clicks":22,"user_id":1979,"domain":"www.transifex.com"},{"url":"https://github.com/berk/tr8n","title":"berk/tr8n · GitHub","fancy_title":null,"internal":false,"reflection":false,"clicks":22,"user_id":1,"domain":"github.com"},{"url":"http://translate.wordpress.org/projects/bbpress/dev","title":"WordPress › Development < GlotPress","fancy_title":null,"internal":false,"reflection":false,"clicks":16,"user_id":7,"domain":"translate.wordpress.org"},{"url":"http://weblate.org","title":"Weblate - web-based translation","fancy_title":null,"internal":false,"reflection":false,"clicks":15,"user_id":2316,"domain":"weblate.org"},{"url":"https://github.com/discourse/discourse/tree/master/config/locales","title":"discourse/config/locales at master · discourse/discourse · GitHub","fancy_title":null,"internal":false,"reflection":false,"clicks":14,"user_id":19,"domain":"github.com"},{"url":"https://github.com/discourse/discourse/pull/493","title":"Danish translation. by mikl · Pull Request #493 · discourse/discourse · GitHub","fancy_title":null,"internal":false,"reflection":false,"clicks":12,"user_id":2753,"domain":"github.com"},{"url":"https://github.com/SlexAxton","title":"SlexAxton (Alex Sexton) · GitHub","fancy_title":null,"internal":false,"reflection":false,"clicks":10,"user_id":1,"domain":"github.com"},{"url":"https://github.com/gururea/discourse/tree/master/config/locales","title":"discourse/config/locales at master · gururea/discourse · GitHub","fancy_title":null,"internal":false,"reflection":false,"clicks":9,"user_id":3190,"domain":"github.com"},{"url":"https://github.com/discourse/discourse/blob/master/config/locales/client.en.yml#L691","title":"discourse/config/locales/client.en.yml at master · discourse/discourse · GitHub","fancy_title":null,"internal":false,"reflection":false,"clicks":7,"user_id":461,"domain":"github.com"},{"url":"https://github.com/dacap/discourse/tree/spanish","title":"dacap/discourse · GitHub","fancy_title":null,"internal":false,"reflection":false,"clicks":7,"user_id":1275,"domain":"github.com"},{"url":"https://github.com/discourse/discourse/blob/master/config/locales/client.nl.yml","title":"discourse/config/locales/client.nl.yml at master · discourse/discourse · GitHub","fancy_title":null,"internal":false,"reflection":false,"clicks":7,"user_id":461,"domain":"github.com"},{"url":"https://github.com/discourse/discourse/commit/c5761eae8afe37e20cec0d0f9d14b85b6e585bda","title":"Support for Simplified Chinese thanks to tangramor · c5761ea · discourse/discourse · GitHub","fancy_title":null,"internal":false,"reflection":false,"clicks":6,"user_id":212,"domain":"github.com"},{"url":"http://tr8n.github.com/","title":"tr8n","fancy_title":null,"internal":false,"reflection":false,"clicks":6,"user_id":212,"domain":"tr8n.github.com"},{"url":"http://www.getlocalization.com/","title":"Crowdsourced, Social and Collaborative App & Website Translation - Get Localization","fancy_title":null,"internal":false,"reflection":false,"clicks":6,"user_id":22,"domain":"www.getlocalization.com"},{"url":"http://blog.discourse.org/2013/04/discourse-as-your-first-rails-app/","title":"Discourse as Your First Rails App","fancy_title":null,"internal":false,"reflection":false,"clicks":5,"user_id":1995,"domain":"blog.discourse.org"},{"url":"https://github.com/alxndr/discourse/blob/i18n-chinese/config/locales/server.zh.yml","title":null,"fancy_title":null,"internal":false,"reflection":false,"clicks":5,"user_id":212,"domain":"github.com"},{"url":"http://translate.sourceforge.net/wiki/virtaal/index","title":"Easy-to-use and powerful offline translation tool | Virtaal","fancy_title":null,"internal":false,"reflection":false,"clicks":4,"user_id":1979,"domain":"translate.sourceforge.net"},{"url":"https://poeditor.com/","title":"POEditor - online software localization tool","fancy_title":null,"internal":false,"reflection":false,"clicks":4,"user_id":1979,"domain":"poeditor.com"},{"url":"http://en.lichess.org/@/Hellball","title":null,"fancy_title":null,"internal":false,"reflection":false,"clicks":3,"user_id":1979,"domain":"en.lichess.org"},{"url":"http://en.wikipedia.org/wiki/T%E2%80%93V_distinction","title":"T–V distinction - Wikipedia, the free encyclopedia","fancy_title":null,"internal":false,"reflection":false,"clicks":3,"user_id":3620,"domain":"en.wikipedia.org"},{"url":"http://www.slideshare.net/HeatherRivers/linguistic-potluck-crowdsourcing-localization-with-rails","title":"Linguistic Potluck: Crowdsourcing localization with Rails","fancy_title":null,"internal":false,"reflection":false,"clicks":3,"user_id":212,"domain":"www.slideshare.net"},{"url":"https://meta.discourse.org/t/language-mirrors/2378/2","title":"Language mirrors","fancy_title":null,"internal":true,"reflection":true,"clicks":3,"user_id":32,"domain":"meta.discourse.org"},{"url":"http://www.madanalogy.com/2012/06/rails-i18n-translations-in-yaml.html","title":"Mad Analogy: Rails i18n translations in Yaml: translation tool support","fancy_title":null,"internal":false,"reflection":false,"clicks":3,"user_id":3190,"domain":"www.madanalogy.com"},{"url":"https://github.com/tr8n","title":"Translation Exchange · GitHub","fancy_title":null,"internal":false,"reflection":false,"clicks":3,"user_id":9006,"domain":"github.com"},{"url":"http://pootle.locamotion.org/","title":"Main | Pootle Demo","fancy_title":null,"internal":false,"reflection":false,"clicks":2,"user_id":3190,"domain":"pootle.locamotion.org"},{"url":"http://www.youtube.com/watch?v=MqqdzJ98q7s","title":"GoGaRuCo 2012 - Linguistic Potluck: Crowdsourcing Localization in Rails by Heather Rivers - YouTube","fancy_title":null,"internal":false,"reflection":false,"clicks":2,"user_id":212,"domain":"www.youtube.com"},{"url":"https://meta.discourse.org/t/translation-workflow/6102","title":"Translation workflow","fancy_title":null,"internal":true,"reflection":true,"clicks":2,"user_id":4702,"domain":"meta.discourse.org"},{"url":"https://meta.discourse.org/t/solving-xda-developer-style-forums/4368/4","title":"Solving XDA-Developer style forums","fancy_title":null,"internal":true,"reflection":true,"clicks":2,"user_id":639,"domain":"meta.discourse.org"},{"url":"https://tr8nhub.com","title":"TranslationExchange","fancy_title":null,"internal":false,"reflection":false,"clicks":2,"user_id":9006,"domain":"tr8nhub.com"},{"url":"https://meta.discourse.org/t/roadplan-for-discourse/2939/3","title":"Roadplan for Discourse 2013","fancy_title":null,"internal":true,"reflection":true,"clicks":1,"user_id":2540,"domain":"meta.discourse.org"},{"url":"http://sugarjs.com/dates#date_locales","title":"Dates - Sugar","fancy_title":null,"internal":false,"reflection":false,"clicks":1,"user_id":461,"domain":"sugarjs.com"},{"url":"http://blog.discourse.org/2013/03/localizing-discourse/","title":"Localizing Discourse","fancy_title":null,"internal":false,"reflection":false,"clicks":1,"user_id":893,"domain":"blog.discourse.org"},{"url":"https://github.com/discourse/discourse/blob/master/app/assets/javascripts/locales/date_locales.js","title":null,"fancy_title":null,"internal":false,"reflection":false,"clicks":1,"user_id":461,"domain":"github.com"},{"url":"http://transifex.com/projects/p/discourse-pt-br/","title":"Discourse-Translations-Project localization","fancy_title":null,"internal":false,"reflection":false,"clicks":1,"user_id":893,"domain":"transifex.com"},{"url":"https://github.com/discourse/discourse/issues/279","title":null,"fancy_title":null,"internal":false,"reflection":false,"clicks":1,"user_id":893,"domain":"github.com"},{"url":"https://meta.discourse.org/t/comrades-lets-join-our-efforts-on-ukrainian-and-russian-translations/4403/5","title":"Comrades let's join our efforts on ukrainian and russian translations","fancy_title":null,"internal":true,"reflection":true,"clicks":1,"user_id":3417,"domain":"meta.discourse.org"},{"url":"https://meta.discourse.org/t/translation-workflow/6102/6","title":"Translation workflow","fancy_title":null,"internal":true,"reflection":false,"clicks":0,"user_id":1995,"domain":"meta.discourse.org"},{"url":"https://meta.discourse.org/t/bookmark-last-read-sometimes-doesn-t-go-to-the-end-of-a-topic/4825/9","title":"Bookmark/last read sometimes doesn't go to the end of a topic","fancy_title":null,"internal":true,"reflection":true,"clicks":0,"user_id":3681,"domain":"meta.discourse.org"},{"url":"https://github.com/discourse/discourse/blob/master/config/locales/client.de.yml","title":"discourse/config/locales/client.de.yml at master · discourse/discourse · GitHub","fancy_title":null,"internal":false,"reflection":false,"clicks":0,"user_id":2,"domain":"github.com"},{"url":"https://meta.discourse.org/t/what-i-love-about-wordpress-plugins/5697","title":"What I love about WordPress plugins","fancy_title":null,"internal":true,"reflection":true,"clicks":0,"user_id":1,"domain":"meta.discourse.org"},{"url":"https://meta.discourse.org/t/github-onebox-rendering-issue/7616","title":"GitHub OneBox Rendering Issue","fancy_title":null,"internal":true,"reflection":true,"clicks":0,"user_id":5372,"domain":"meta.discourse.org"},{"url":"https://github.com/discourse/discourse/blob/master/config/locales/server.de.yml","title":"discourse/config/locales/server.de.yml at master · discourse/discourse · GitHub","fancy_title":null,"internal":false,"reflection":false,"clicks":0,"user_id":2,"domain":"github.com"},{"url":"https://meta.discourse.org/t/roadplan-for-discourse/2939/5","title":"Roadplan for Discourse 2013","fancy_title":null,"internal":true,"reflection":true,"clicks":0,"user_id":32,"domain":"meta.discourse.org"},{"url":"https://meta.discourse.org/t/translation-tools-transifex-localeapp/7763","title":"Translation Tools: Transifex? Localeapp?","fancy_title":null,"internal":true,"reflection":true,"clicks":0,"user_id":2,"domain":"meta.discourse.org"},{"url":"http://guides.rubyonrails.org/i18n.html#the-public-i18n-api","title":"Rails Internationalization (I18n) API — Ruby on Rails Guides","fancy_title":null,"internal":false,"reflection":false,"clicks":0,"user_id":1895,"domain":"guides.rubyonrails.org"},{"url":"https://meta.discourse.org/t/hi-support-chinese/4393/6","title":"Hi, support Chinese?","fancy_title":null,"internal":true,"reflection":true,"clicks":0,"user_id":2014,"domain":"meta.discourse.org"},{"url":"https://meta.discourse.org/t/translation-tools-transifex-localeapp/7763/41","title":"Translation Tools: Transifex? Localeapp?","fancy_title":null,"internal":true,"reflection":false,"clicks":0,"user_id":6626,"domain":"meta.discourse.org"}],"notification_level":2,"notifications_reason_id":4,"can_move_posts":true,"can_edit":true,"can_delete":true,"can_recover":true,"can_remove_allowed_users":true,"can_invite_to":true,"can_create_post":true,"can_reply_as_new_topic":true,"can_flag_topic":true},"highest_post_number":10,"last_read_post_number":10,"deleted_by":null,"has_deleted":true,"actions_summary":[{"id":4,"count":0,"hidden":false,"can_act":true},{"id":7,"count":0,"hidden":false,"can_act":true},{"id":8,"count":0,"hidden":false,"can_act":true}],"chunk_size":20,"bookmarked":false,"tags":null}, "/t/28830/1.json": {"post_stream":{"posts":[{"id":118591,"name":"spends too much time on WTDWTF","username":"RaceProUK","avatar_template":"/images/avatar.png","uploaded_avatar_id":40071,"created_at":"2015-05-14T20:18:17.954Z","cooked":"

Normally, actions such as Liking are rate-limited, and when you hit the limit, you get a message telling you you've hit the limit. However, in 1.3.0beta9, it seems those popups are no longer appearing.

\n\n

Edit: Possibly linked to this issue?

","post_number":1,"post_type":1,"updated_at":"2015-05-14T20:21:42.825Z","like_count":6,"reply_count":0,"reply_to_post_number":null,"quote_count":0,"avg_time":null,"incoming_link_count":14,"reads":24,"score":224.6,"yours":false,"topic_id":28830,"topic_slug":"1-3-0beta9-no-rate-limit-popups","display_username":"spends too much time on WTDWTF","primary_group_name":null,"version":1,"can_edit":false,"can_delete":false,"can_recover":false,"link_counts":[{"url":"https://meta.discourse.org/t/post-reply-on-different-topic-no-longer-works/28825","internal":true,"reflection":false,"title":"Post reply on different topic no longer works","clicks":6}],"read":true,"user_title":"Contributor","actions_summary":[{"id":2,"count":6,"hidden":false,"can_act":false},{"id":3,"count":0,"hidden":false,"can_act":false},{"id":4,"count":0,"hidden":false,"can_act":false},{"id":5,"count":0,"hidden":true,"can_act":false},{"id":6,"count":0,"hidden":false,"can_act":false},{"id":7,"count":0,"hidden":false,"can_act":false},{"id":8,"count":0,"hidden":false,"can_act":false}],"moderator":false,"admin":false,"staff":false,"user_id":14169,"hidden":false,"hidden_reason_id":null,"trust_level":2,"deleted_at":null,"user_deleted":false,"edit_reason":"","can_view_edit_history":true,"wiki":false},{"id":118597,"name":"Sam","username":"Yuun","avatar_template":"/images/avatar.png","uploaded_avatar_id":null,"created_at":"2015-05-14T20:35:03.793Z","cooked":"

I'm seeing this issue as well. When you hit the rate limit, any further likes look like the forum is attempting and failing to apply them - the text saying 'you liked this' comes into place before quickly being removed.

\n\n

This makes it look (to the user) like the forum software is running into errors instead of said user hitting an intentional limit, which is a bit unfortunate.

","post_number":2,"post_type":1,"updated_at":"2015-05-14T20:35:03.793Z","like_count":0,"reply_count":0,"reply_to_post_number":null,"quote_count":0,"avg_time":null,"incoming_link_count":6,"reads":22,"score":34.2,"yours":false,"topic_id":28830,"topic_slug":"1-3-0beta9-no-rate-limit-popups","display_username":"Sam","primary_group_name":null,"version":1,"can_edit":false,"can_delete":false,"can_recover":false,"read":true,"user_title":null,"actions_summary":[{"id":2,"count":0,"hidden":false,"can_act":false},{"id":3,"count":0,"hidden":false,"can_act":false},{"id":4,"count":0,"hidden":false,"can_act":false},{"id":5,"count":0,"hidden":true,"can_act":false},{"id":6,"count":0,"hidden":false,"can_act":false},{"id":7,"count":0,"hidden":false,"can_act":false},{"id":8,"count":0,"hidden":false,"can_act":false}],"moderator":false,"admin":false,"staff":false,"user_id":14795,"hidden":false,"hidden_reason_id":null,"trust_level":2,"deleted_at":null,"user_deleted":false,"edit_reason":null,"can_view_edit_history":true,"wiki":false},{"id":118601,"name":"Kane York","username":"riking","avatar_template":"/images/avatar.png","uploaded_avatar_id":40212,"created_at":"2015-05-14T21:05:19.837Z","cooked":"

I'm going to guess that the bootbox library got broken somehow?

","post_number":3,"post_type":1,"updated_at":"2015-05-14T21:05:19.837Z","like_count":0,"reply_count":1,"reply_to_post_number":null,"quote_count":0,"avg_time":null,"incoming_link_count":0,"reads":14,"score":7.2,"yours":false,"topic_id":28830,"topic_slug":"1-3-0beta9-no-rate-limit-popups","display_username":"Kane York","primary_group_name":null,"version":1,"can_edit":false,"can_delete":false,"can_recover":false,"read":true,"user_title":"team summer intern 2014","actions_summary":[{"id":2,"count":0,"hidden":false,"can_act":false},{"id":3,"count":0,"hidden":false,"can_act":false},{"id":4,"count":0,"hidden":false,"can_act":false},{"id":5,"count":0,"hidden":true,"can_act":false},{"id":6,"count":0,"hidden":false,"can_act":false},{"id":7,"count":0,"hidden":false,"can_act":false},{"id":8,"count":0,"hidden":false,"can_act":false}],"moderator":false,"admin":false,"staff":false,"user_id":6626,"hidden":false,"hidden_reason_id":null,"trust_level":4,"deleted_at":null,"user_deleted":false,"edit_reason":null,"can_view_edit_history":true,"wiki":false},{"id":118606,"name":"Jeff Atwood","username":"codinghorror","avatar_template":"/images/avatar.png","uploaded_avatar_id":5297,"created_at":"2015-05-14T21:15:41.612Z","cooked":"

Yeah maybe another Ember 1.10 regression for @eviltrout ?

","post_number":4,"post_type":1,"updated_at":"2015-05-14T21:15:41.612Z","like_count":0,"reply_count":0,"reply_to_post_number":null,"quote_count":0,"avg_time":null,"incoming_link_count":6,"reads":12,"score":31.6,"yours":false,"topic_id":28830,"topic_slug":"1-3-0beta9-no-rate-limit-popups","display_username":"Jeff Atwood","primary_group_name":"discourse","version":1,"can_edit":false,"can_delete":false,"can_recover":false,"read":true,"user_title":"co-founder","actions_summary":[{"id":2,"count":0,"hidden":false,"can_act":false},{"id":3,"count":0,"hidden":false,"can_act":false},{"id":4,"count":0,"hidden":false,"can_act":false},{"id":5,"count":0,"hidden":true,"can_act":false},{"id":6,"count":0,"hidden":false,"can_act":false},{"id":7,"count":0,"hidden":false,"can_act":false},{"id":8,"count":0,"hidden":false,"can_act":false}],"moderator":true,"admin":true,"staff":true,"user_id":32,"hidden":false,"hidden_reason_id":null,"trust_level":4,"deleted_at":null,"user_deleted":false,"edit_reason":null,"can_view_edit_history":true,"wiki":false},{"id":118612,"name":"TDWTF member","username":"Onyx","avatar_template":"/images/avatar.png","uploaded_avatar_id":33015,"created_at":"2015-05-14T21:23:09.562Z","cooked":"\n\n

You mean the popup box library, guessing by the name? Still shows up when you want to cancel a post, so it's not all popups it seems.

","post_number":5,"post_type":1,"updated_at":"2015-05-14T21:23:09.562Z","like_count":1,"reply_count":0,"reply_to_post_number":3,"quote_count":1,"avg_time":null,"incoming_link_count":0,"reads":11,"score":16.0,"yours":false,"topic_id":28830,"topic_slug":"1-3-0beta9-no-rate-limit-popups","display_username":"TDWTF member","primary_group_name":null,"version":1,"can_edit":false,"can_delete":false,"can_recover":false,"read":true,"user_title":null,"actions_summary":[{"id":2,"count":1,"hidden":false,"can_act":false},{"id":3,"count":0,"hidden":false,"can_act":false},{"id":4,"count":0,"hidden":false,"can_act":false},{"id":5,"count":0,"hidden":true,"can_act":false},{"id":6,"count":0,"hidden":false,"can_act":false},{"id":7,"count":0,"hidden":false,"can_act":false},{"id":8,"count":0,"hidden":false,"can_act":false}],"moderator":false,"admin":false,"staff":false,"user_id":10886,"hidden":false,"hidden_reason_id":null,"trust_level":2,"deleted_at":null,"user_deleted":false,"edit_reason":null,"can_view_edit_history":true,"wiki":false}],"stream":[118591,118597,118601,118606,118612]},"id":28830,"title":"1.3.0beta9: No rate-limit popups","fancy_title":"1.3.0beta9: No rate-limit popups","posts_count":5,"created_at":"2015-05-14T20:18:17.877Z","views":38,"reply_count":1,"participant_count":5,"like_count":7,"last_posted_at":"2015-05-14T21:23:09.562Z","visible":true,"closed":false,"archived":false,"has_summary":false,"archetype":"regular","slug":"1-3-0beta9-no-rate-limit-popups","category_id":1,"word_count":198,"deleted_at":null,"pending_posts_count":0,"draft":null,"draft_key":"topic_28830","draft_sequence":null,"unpinned":null,"pinned_globally":false,"pinned":false,"pinned_at":null,"details":{"auto_close_at":null,"auto_close_hours":null,"auto_close_based_on_last_post":false,"created_by":{"id":14169,"username":"RaceProUK","uploaded_avatar_id":40071,"avatar_template":"/images/avatar.png"},"last_poster":{"id":10886,"username":"Onyx","uploaded_avatar_id":33015,"avatar_template":"/images/avatar.png"},"participants":[{"id":14795,"username":"Yuun","uploaded_avatar_id":null,"avatar_template":"/images/avatar.png","post_count":1},{"id":10886,"username":"Onyx","uploaded_avatar_id":33015,"avatar_template":"/images/avatar.png","post_count":1},{"id":14169,"username":"RaceProUK","uploaded_avatar_id":40071,"avatar_template":"/images/avatar.png","post_count":1},{"id":6626,"username":"riking","uploaded_avatar_id":40212,"avatar_template":"/images/avatar.png","post_count":1},{"id":32,"username":"codinghorror","uploaded_avatar_id":5297,"avatar_template":"/images/avatar.png","post_count":1}],"suggested_topics":[{"id":2890,"title":"Expanded quoted text not highlighting when text is formatted","fancy_title":"Expanded quoted text not highlighting when text is formatted","slug":"expanded-quoted-text-not-highlighting-when-text-is-formatted","posts_count":8,"reply_count":5,"highest_post_number":8,"image_url":null,"created_at":"2013-02-12T12:18:02.181Z","last_posted_at":"2013-02-14T15:59:40.014Z","bumped":true,"bumped_at":"2013-02-14T15:59:40.014Z","unseen":false,"pinned":false,"unpinned":null,"visible":true,"closed":false,"archived":false,"bookmarked":null,"liked":null,"archetype":"regular","like_count":3,"views":361,"category_id":1},{"id":14213,"title":"Plugins not being parsed in correct javascript context when loaded for jobs","fancy_title":"Plugins not being parsed in correct javascript context when loaded for jobs","slug":"plugins-not-being-parsed-in-correct-javascript-context-when-loaded-for-jobs","posts_count":2,"reply_count":0,"highest_post_number":2,"image_url":"/plugins/emoji/images/frowning.png","created_at":"2014-03-27T23:57:00.974Z","last_posted_at":"2015-03-20T04:56:03.982Z","bumped":true,"bumped_at":"2015-03-20T04:56:03.982Z","unseen":false,"pinned":false,"unpinned":null,"visible":true,"closed":false,"archived":false,"bookmarked":null,"liked":null,"archetype":"regular","like_count":0,"views":156,"category_id":1},{"id":22544,"title":"Like count on profile off by one","fancy_title":"Like count on profile off by one","slug":"like-count-on-profile-off-by-one","posts_count":7,"reply_count":2,"highest_post_number":7,"image_url":null,"created_at":"2014-11-26T08:15:39.802Z","last_posted_at":"2014-11-27T07:23:37.638Z","bumped":true,"bumped_at":"2014-11-27T07:23:37.638Z","unseen":false,"pinned":false,"unpinned":null,"visible":true,"closed":false,"archived":false,"bookmarked":null,"liked":null,"archetype":"regular","like_count":18,"views":192,"category_id":1},{"id":27670,"title":"Using back still shows unread indicator on the topic","fancy_title":"Using back still shows unread indicator on the topic","slug":"using-back-still-shows-unread-indicator-on-the-topic","posts_count":2,"reply_count":0,"highest_post_number":2,"image_url":null,"created_at":"2015-04-16T23:21:42.739Z","last_posted_at":"2015-04-17T02:43:08.447Z","bumped":true,"bumped_at":"2015-04-17T02:43:08.447Z","unseen":false,"pinned":false,"unpinned":null,"visible":true,"closed":false,"archived":false,"bookmarked":null,"liked":null,"archetype":"regular","like_count":1,"views":85,"category_id":1},{"id":26628,"title":"Embed blacklist selector is broken","fancy_title":"Embed blacklist selector is broken","slug":"embed-blacklist-selector-is-broken","posts_count":11,"reply_count":7,"highest_post_number":11,"image_url":null,"created_at":"2015-03-22T11:21:14.825Z","last_posted_at":"2015-04-20T09:11:38.999Z","bumped":true,"bumped_at":"2015-04-20T09:11:38.999Z","unseen":false,"pinned":false,"unpinned":null,"visible":true,"closed":false,"archived":false,"bookmarked":null,"liked":null,"archetype":"regular","like_count":1,"views":247,"category_id":1},{"id":18027,"title":"Minor: delete/undelete needs a rate limit","fancy_title":"Minor: delete/undelete needs a rate limit","slug":"minor-delete-undelete-needs-a-rate-limit","posts_count":4,"reply_count":1,"highest_post_number":4,"image_url":null,"created_at":"2014-07-25T02:51:41.158Z","last_posted_at":"2014-07-25T04:01:15.343Z","bumped":true,"bumped_at":"2014-07-25T11:06:46.213Z","unseen":false,"pinned":false,"unpinned":null,"visible":true,"closed":false,"archived":false,"bookmarked":null,"liked":null,"archetype":"regular","like_count":1,"views":165,"category_id":1},{"id":17396,"title":"Bad Reply Key when pulling Autoforwarded Emails to Discourse","fancy_title":"Bad Reply Key when pulling Autoforwarded Emails to Discourse","slug":"bad-reply-key-when-pulling-autoforwarded-emails-to-discourse","posts_count":20,"reply_count":15,"highest_post_number":20,"image_url":null,"created_at":"2014-07-09T18:34:57.114Z","last_posted_at":"2014-10-21T15:08:50.441Z","bumped":true,"bumped_at":"2014-10-21T15:08:50.441Z","unseen":false,"pinned":false,"unpinned":null,"visible":true,"closed":false,"archived":false,"bookmarked":null,"liked":null,"archetype":"regular","like_count":7,"views":542,"category_id":1}],"links":[{"url":"https://meta.discourse.org/t/post-reply-on-different-topic-no-longer-works/28825","title":"Post reply on different topic no longer works","fancy_title":null,"internal":true,"reflection":false,"clicks":6,"user_id":14169,"domain":"meta.discourse.org"}],"notification_level":1,"can_flag_topic":false},"highest_post_number":5,"deleted_by":null,"actions_summary":[{"id":4,"count":0,"hidden":false,"can_act":false},{"id":7,"count":0,"hidden":false,"can_act":false},{"id":8,"count":0,"hidden":false,"can_act":false}],"chunk_size":20,"bookmarked":null,"tags":null}, -"/t/9/1.json": {"post_stream":{"posts":[{"id":18,"username":"eviltrout","avatar_template":"/images/avatar.png","name":"Evil Trout","uploaded_avatar_id":9,"created_at":"2015-08-13T14:49:11.840Z","cooked":"

This is the first post.

","post_number":1,"post_type":1,"updated_at":"2015-08-13T14:49:11.840Z","reply_count":0,"reply_to_post_number":null,"quote_count":0,"avg_time":null,"incoming_link_count":0,"reads":1,"score":0,"yours":true,"topic_id":9,"topic_slug":"this-is-a-test-topic","display_username":"","primary_group_name":null,"version":1,"can_edit":true,"can_delete":false,"can_recover":true,"read":true,"user_title":null,"actions_summary":[{"id":3,"can_act":true},{"id":4,"can_act":true},{"id":5,"hidden":true,"can_act":true},{"id":7,"can_act":true},{"id":8,"can_act":true}],"moderator":false,"admin":true,"staff":true,"user_id":1,"hidden":false,"hidden_reason_id":null,"trust_level":4,"deleted_at":null,"user_deleted":false,"edit_reason":null,"can_view_edit_history":true,"wiki":false},{"id":19,"username":"eviltrout","avatar_template":"/images/avatar.png","name":"Evil Trout","uploaded_avatar_id":9,"created_at":"2015-08-13T14:49:18.231Z","cooked":"

This is the second post.

","post_number":2,"post_type":1,"updated_at":"2015-08-13T14:49:18.231Z","reply_count":0,"reply_to_post_number":null,"quote_count":0,"avg_time":null,"incoming_link_count":0,"reads":1,"score":0,"yours":true,"topic_id":9,"topic_slug":"this-is-a-test-topic","display_username":"","primary_group_name":null,"version":1,"can_edit":true,"can_delete":true,"can_recover":true,"read":true,"user_title":null,"actions_summary":[{"id":3,"can_act":true},{"id":4,"can_act":true},{"id":5,"hidden":true,"can_act":true},{"id":7,"can_act":true},{"id":8,"can_act":true}],"moderator":false,"admin":true,"staff":true,"user_id":1,"hidden":false,"hidden_reason_id":null,"trust_level":4,"deleted_at":null,"user_deleted":false,"edit_reason":null,"can_view_edit_history":true,"wiki":false},{"id":20,"username":"eviltrout","avatar_template":"/images/avatar.png","name":"Evil Trout","uploaded_avatar_id":9,"created_at":"2015-08-13T14:49:23.927Z","cooked":"

This is the third post.

","post_number":3,"post_type":1,"updated_at":"2015-08-13T14:49:23.927Z","reply_count":0,"reply_to_post_number":null,"quote_count":0,"avg_time":null,"incoming_link_count":0,"reads":1,"score":0,"yours":true,"topic_id":9,"topic_slug":"this-is-a-test-topic","display_username":"","primary_group_name":null,"version":1,"can_edit":true,"can_delete":true,"can_recover":true,"read":true,"user_title":null,"actions_summary":[{"id":3,"can_act":true},{"id":4,"can_act":true},{"id":5,"hidden":true,"can_act":true},{"id":7,"can_act":true},{"id":8,"can_act":true}],"moderator":false,"admin":true,"staff":true,"user_id":1,"hidden":false,"hidden_reason_id":null,"trust_level":4,"deleted_at":null,"user_deleted":false,"edit_reason":null,"can_view_edit_history":true,"wiki":false}],"stream":[18,19,20]},"id":9,"title":"This is a test topic!","fancy_title":"This is a test topic!","posts_count":3,"created_at":"2015-08-13T14:49:11.720Z","views":1,"reply_count":0,"participant_count":1,"like_count":0,"last_posted_at":"2015-08-13T14:49:23.927Z","visible":true,"closed":false,"archived":false,"has_summary":false,"archetype":"regular","slug":"this-is-a-test-topic","category_id":1,"word_count":15,"deleted_at":null,"pending_posts_count":0,"user_id":1,"draft":null,"draft_key":"topic_9","draft_sequence":3,"posted":true,"unpinned":null,"pinned_globally":false,"pinned":false,"pinned_at":null,"pinned_until":null,"details":{"auto_close_at":null,"auto_close_hours":null,"auto_close_based_on_last_post":false,"created_by":{"id":1,"username":"tgxworld","uploaded_avatar_id":9,"avatar_template":"/images/avatar.png"},"last_poster":{"id":1,"username":"tgxworld","uploaded_avatar_id":9,"avatar_template":"/images/avatar.png"},"participants":[{"id":1,"username":"tgxworld","uploaded_avatar_id":9,"avatar_template":"/images/avatar.png","post_count":3}],"suggested_topics":[{"id":8,"title":"This is a new and awesome topic!","fancy_title":"This is a new and awesome topic!","slug":"this-is-a-new-and-awesome-topic","posts_count":3,"reply_count":0,"highest_post_number":5,"image_url":null,"created_at":"2015-08-13T05:17:00.000Z","last_posted_at":"2015-08-13T10:14:34.799Z","bumped":true,"bumped_at":"2015-08-13T10:14:34.799Z","unseen":false,"last_read_post_number":5,"unread":0,"new_posts":0,"pinned":false,"unpinned":null,"visible":true,"closed":false,"archived":false,"notification_level":2,"bookmarked":false,"liked":false,"archetype":"regular","like_count":0,"views":2,"category_id":1},{"id":7,"title":"This is a test category!","fancy_title":"This is a test category!","slug":"this-is-a-test-category","posts_count":3,"reply_count":0,"highest_post_number":3,"image_url":null,"created_at":"2015-08-10T13:40:38.439Z","last_posted_at":"2015-08-13T01:59:44.928Z","bumped":true,"bumped_at":"2015-08-13T01:58:35.206Z","unseen":false,"last_read_post_number":3,"unread":0,"new_posts":0,"pinned":false,"unpinned":null,"visible":true,"closed":false,"archived":false,"notification_level":3,"bookmarked":false,"liked":false,"archetype":"regular","like_count":0,"views":2,"category_id":1}],"notification_level":3,"notifications_reason_id":1,"can_move_posts":true,"can_edit":true,"can_delete":true,"can_recover":true,"can_remove_allowed_users":true,"can_invite_to":true,"can_create_post":true,"can_reply_as_new_topic":true,"can_flag_topic":true},"highest_post_number":3,"last_read_post_number":3,"deleted_by":null,"has_deleted":false,"actions_summary":[{"id":4,"count":0,"hidden":false,"can_act":true},{"id":7,"count":0,"hidden":false,"can_act":true},{"id":8,"count":0,"hidden":false,"can_act":true}],"chunk_size":20,"bookmarked":false}, +"/t/9/1.json": {"post_stream":{"posts":[{"id":18,"username":"eviltrout","avatar_template":"/images/avatar.png","name":"Evil Trout","uploaded_avatar_id":9,"created_at":"2015-08-13T14:49:11.840Z","cooked":"

This is the first post.

","post_number":1,"post_type":1,"updated_at":"2015-08-13T14:49:11.840Z","reply_count":0,"reply_to_post_number":null,"quote_count":0,"avg_time":null,"incoming_link_count":0,"reads":1,"score":0,"yours":true,"topic_id":9,"topic_slug":"this-is-a-test-topic","display_username":"","primary_group_name":null,"version":1,"can_edit":true,"can_delete":false,"can_recover":true,"read":true,"user_title":null,"actions_summary":[{"id":3,"can_act":true},{"id":4,"can_act":true},{"id":5,"hidden":true,"can_act":true},{"id":7,"can_act":true},{"id":8,"can_act":true}],"moderator":false,"admin":true,"staff":true,"user_id":1,"hidden":false,"hidden_reason_id":null,"trust_level":4,"deleted_at":null,"user_deleted":false,"edit_reason":null,"can_view_edit_history":true,"wiki":false},{"id":19,"username":"eviltrout","avatar_template":"/images/avatar.png","name":"Evil Trout","uploaded_avatar_id":9,"created_at":"2015-08-13T14:49:18.231Z","cooked":"

This is the second post.

","post_number":2,"post_type":1,"updated_at":"2015-08-13T14:49:18.231Z","reply_count":0,"reply_to_post_number":null,"quote_count":0,"avg_time":null,"incoming_link_count":0,"reads":1,"score":0,"yours":true,"topic_id":9,"topic_slug":"this-is-a-test-topic","display_username":"","primary_group_name":null,"version":1,"can_edit":true,"can_delete":true,"can_recover":true,"read":true,"user_title":null,"actions_summary":[{"id":3,"can_act":true},{"id":4,"can_act":true},{"id":5,"hidden":true,"can_act":true},{"id":7,"can_act":true},{"id":8,"can_act":true}],"moderator":false,"admin":true,"staff":true,"user_id":1,"hidden":false,"hidden_reason_id":null,"trust_level":4,"deleted_at":null,"user_deleted":false,"edit_reason":null,"can_view_edit_history":true,"wiki":false},{"id":20,"username":"eviltrout","avatar_template":"/images/avatar.png","name":"Evil Trout","uploaded_avatar_id":9,"created_at":"2015-08-13T14:49:23.927Z","cooked":"

This is the third post.

","post_number":3,"post_type":1,"updated_at":"2015-08-13T14:49:23.927Z","reply_count":0,"reply_to_post_number":null,"quote_count":0,"avg_time":null,"incoming_link_count":0,"reads":1,"score":0,"yours":true,"topic_id":9,"topic_slug":"this-is-a-test-topic","display_username":"","primary_group_name":null,"version":1,"can_edit":true,"can_delete":true,"can_recover":true,"read":true,"user_title":null,"actions_summary":[{"id":3,"can_act":true},{"id":4,"can_act":true},{"id":5,"hidden":true,"can_act":true},{"id":7,"can_act":true},{"id":8,"can_act":true}],"moderator":false,"admin":true,"staff":true,"user_id":1,"hidden":false,"hidden_reason_id":null,"trust_level":4,"deleted_at":null,"user_deleted":false,"edit_reason":null,"can_view_edit_history":true,"wiki":false}],"stream":[18,19,20]},"id":9,"title":"This is a test topic!","fancy_title":"This is a test topic!","posts_count":3,"created_at":"2015-08-13T14:49:11.720Z","views":1,"reply_count":0,"participant_count":1,"like_count":0,"last_posted_at":"2015-08-13T14:49:23.927Z","visible":true,"closed":false,"archived":false,"has_summary":false,"archetype":"regular","slug":"this-is-a-test-topic","category_id":24,"word_count":15,"deleted_at":null,"pending_posts_count":0,"user_id":1,"draft":null,"draft_key":"topic_9","draft_sequence":3,"posted":true,"unpinned":null,"pinned_globally":false,"pinned":false,"pinned_at":null,"pinned_until":null,"details":{"auto_close_at":null,"auto_close_hours":null,"auto_close_based_on_last_post":false,"created_by":{"id":1,"username":"tgxworld","uploaded_avatar_id":9,"avatar_template":"/images/avatar.png"},"last_poster":{"id":1,"username":"tgxworld","uploaded_avatar_id":9,"avatar_template":"/images/avatar.png"},"participants":[{"id":1,"username":"tgxworld","uploaded_avatar_id":9,"avatar_template":"/images/avatar.png","post_count":3}],"suggested_topics":[{"id":8,"title":"This is a new and awesome topic!","fancy_title":"This is a new and awesome topic!","slug":"this-is-a-new-and-awesome-topic","posts_count":3,"reply_count":0,"highest_post_number":5,"image_url":null,"created_at":"2015-08-13T05:17:00.000Z","last_posted_at":"2015-08-13T10:14:34.799Z","bumped":true,"bumped_at":"2015-08-13T10:14:34.799Z","unseen":false,"last_read_post_number":5,"unread":0,"new_posts":0,"pinned":false,"unpinned":null,"visible":true,"closed":false,"archived":false,"notification_level":2,"bookmarked":false,"liked":false,"archetype":"regular","like_count":0,"views":2,"category_id":1},{"id":7,"title":"This is a test category!","fancy_title":"This is a test category!","slug":"this-is-a-test-category","posts_count":3,"reply_count":0,"highest_post_number":3,"image_url":null,"created_at":"2015-08-10T13:40:38.439Z","last_posted_at":"2015-08-13T01:59:44.928Z","bumped":true,"bumped_at":"2015-08-13T01:58:35.206Z","unseen":false,"last_read_post_number":3,"unread":0,"new_posts":0,"pinned":false,"unpinned":null,"visible":true,"closed":false,"archived":false,"notification_level":3,"bookmarked":false,"liked":false,"archetype":"regular","like_count":0,"views":2,"category_id":1}],"notification_level":3,"notifications_reason_id":1,"can_move_posts":true,"can_edit":true,"can_delete":true,"can_recover":true,"can_remove_allowed_users":true,"can_invite_to":true,"can_create_post":true,"can_reply_as_new_topic":true,"can_flag_topic":true},"highest_post_number":3,"last_read_post_number":3,"deleted_by":null,"has_deleted":false,"actions_summary":[{"id":4,"count":0,"hidden":false,"can_act":true},{"id":7,"count":0,"hidden":false,"can_act":true},{"id":8,"count":0,"hidden":false,"can_act":true}],"chunk_size":20,"bookmarked":false,"destination_category_id":3}, "/t/12/1.json": {"post_stream":{"posts":[{"id":15,"name":null,"username":"test","avatar_template":"/images/avatar.png","created_at":"2017-01-27T03:53:58.394Z","cooked":"

I have a pen, I have an apple

","post_number":1,"post_type":1,"updated_at":"2017-01-27T03:53:58.394Z","reply_count":0,"reply_to_post_number":null,"quote_count":0,"avg_time":null,"incoming_link_count":0,"reads":1,"score":0,"yours":true,"topic_id":12,"topic_slug":"pm-for-testing","display_username":null,"primary_group_name":null,"primary_group_flair_url":null,"primary_group_flair_bg_color":null,"primary_group_flair_color":null,"version":1,"can_edit":true,"can_delete":false,"can_recover":true,"can_wiki":true,"read":true,"user_title":null,"actions_summary":[{"id":3,"can_act":true},{"id":4,"can_act":true},{"id":5,"hidden":true,"can_act":true},{"id":7,"can_act":true},{"id":8,"can_act":true}],"moderator":false,"admin":true,"staff":true,"user_id":1,"hidden":false,"hidden_reason_id":null,"trust_level":4,"deleted_at":null,"user_deleted":false,"edit_reason":null,"can_view_edit_history":true,"wiki":false,"can_translate":false},{"id":16,"name":null,"username":"test","avatar_template":"/images/avatar.png","created_at":"2017-01-27T04:10:02.941Z","cooked":"","post_number":2,"post_type":3,"updated_at":"2017-01-27T04:10:02.941Z","reply_count":0,"reply_to_post_number":null,"quote_count":0,"avg_time":null,"incoming_link_count":0,"reads":1,"score":0,"yours":true,"topic_id":12,"topic_slug":"pm-for-testing","display_username":null,"primary_group_name":null,"primary_group_flair_url":null,"primary_group_flair_bg_color":null,"primary_group_flair_color":null,"version":1,"can_edit":true,"can_delete":true,"can_recover":true,"can_wiki":true,"read":true,"user_title":null,"actions_summary":[{"id":3,"can_act":true},{"id":4,"can_act":true},{"id":5,"hidden":true,"can_act":true},{"id":7,"can_act":true},{"id":8,"can_act":true}],"moderator":false,"admin":true,"staff":true,"user_id":1,"hidden":false,"hidden_reason_id":null,"trust_level":4,"deleted_at":null,"user_deleted":false,"edit_reason":null,"can_view_edit_history":true,"wiki":false,"action_code":"invited_group","action_code_who":"Group","can_translate":false}],"stream":[15,16]},"timeline_lookup":[[1,0]],"id":12,"title":"PM for testing","fancy_title":"PM for testing","posts_count":2,"created_at":"2017-01-27T03:53:58.360Z","views":1,"reply_count":0,"participant_count":1,"like_count":0,"last_posted_at":"2017-01-27T04:10:02.941Z","visible":true,"closed":false,"archived":false,"has_summary":false,"archetype":"private_message","slug":"pm-for-testing","category_id":null,"word_count":8,"deleted_at":null,"user_id":1,"draft":null,"draft_key":"topic_12","draft_sequence":2,"posted":true,"unpinned":null,"pinned_globally":false,"pinned":false,"pinned_at":null,"pinned_until":null,"details":{"auto_close_at":null,"auto_close_hours":null,"auto_close_based_on_last_post":false,"created_by":{"id":1,"username":"test","avatar_template":"/images/avatar.png"},"last_poster":{"id":1,"username":"test","avatar_template":"/images/avatar.png"},"allowed_groups":[{"id":41,"automatic":false,"name":"Group","user_count":0,"alias_level":99,"visible":true,"automatic_membership_email_domains":"","automatic_membership_retroactive":false,"primary_group":false,"title":null,"grant_trust_level":null,"incoming_email":null,"has_messages":false,"flair_url":null,"flair_bg_color":null,"flair_color":null,"bio_raw":null,"bio_cooked":null,"public":false,"allow_membership_requests":false,"full_name":null}],"allowed_users":[{"id":2,"username":"someguy","avatar_template":"/images/avatar.png"},{"id":1,"username":"test","avatar_template":"/images/avatar.png"}],"participants":[{"id":1,"username":"test","avatar_template":"/images/avatar.png","post_count":2,"primary_group_name":null,"primary_group_flair_url":null,"primary_group_flair_color":null,"primary_group_flair_bg_color":null}],"suggested_topics":[{"id":11,"title":"This is a very important announcement","fancy_title":"This is a very important announcement","slug":"this-is-a-very-important-announcement","posts_count":1,"reply_count":0,"highest_post_number":1,"image_url":null,"created_at":"2017-01-27T03:52:02.061Z","last_posted_at":"2017-01-27T03:52:02.119Z","bumped":true,"bumped_at":"2017-01-27T03:52:02.119Z","unseen":false,"last_read_post_number":1,"unread":0,"new_posts":0,"pinned":false,"unpinned":null,"visible":true,"closed":false,"archived":false,"notification_level":3,"bookmarked":false,"liked":false,"archetype":"private_message","like_count":0,"views":1,"category_id":null,"featured_link":null,"posters":[{"extras":"latest single","description":"Original Poster, Most Recent Poster","user":{"id":1,"username":"test","avatar_template":"/images/avatar.png"}}]}],"notification_level":3,"notifications_reason_id":1,"can_move_posts":true,"can_edit":true,"can_delete":true,"can_recover":true,"can_remove_allowed_users":true,"can_invite_to":true,"can_create_post":true,"can_reply_as_new_topic":true,"can_flag_topic":true},"highest_post_number":2,"last_read_post_number":2,"last_read_post_id":16,"deleted_by":null,"has_deleted":false,"actions_summary":[{"id":4,"count":0,"hidden":false,"can_act":true},{"id":7,"count":0,"hidden":false,"can_act":true},{"id":8,"count":0,"hidden":false,"can_act":true}],"chunk_size":20,"bookmarked":false,"message_archived":false,"featured_link":null}, "/t/299/1.json": {"post_stream":{"posts":[{"id":18,"username":"eviltrout","avatar_template":"/images/avatar.png","name":"Evil Trout","uploaded_avatar_id":9,"created_at":"2015-08-13T14:49:11.840Z","cooked":"

This is the first post.

","post_number":1,"post_type":1,"updated_at":"2015-08-13T14:49:11.840Z","reply_count":0,"reply_to_post_number":null,"quote_count":0,"avg_time":null,"incoming_link_count":0,"reads":1,"score":0,"yours":true,"topic_id":9,"topic_slug":"this-is-a-test-topic","display_username":"","primary_group_name":null,"version":1,"can_edit":true,"can_delete":false,"can_recover":true,"read":true,"user_title":null,"actions_summary":[{"id":3,"can_act":true},{"id":4,"can_act":true},{"id":5,"hidden":true,"can_act":true},{"id":7,"can_act":true},{"id":8,"can_act":true}],"moderator":false,"admin":true,"staff":true,"user_id":1,"hidden":false,"hidden_reason_id":null,"trust_level":4,"deleted_at":null,"user_deleted":false,"edit_reason":null,"can_view_edit_history":true,"wiki":false},{"id":19,"username":"eviltrout","avatar_template":"/images/avatar.png","name":"Evil Trout","uploaded_avatar_id":9,"created_at":"2015-08-13T14:49:18.231Z","cooked":"

This is the second post.

","post_number":2,"post_type":1,"updated_at":"2015-08-13T14:49:18.231Z","reply_count":0,"reply_to_post_number":null,"quote_count":0,"avg_time":null,"incoming_link_count":0,"reads":1,"score":0,"yours":true,"topic_id":9,"topic_slug":"this-is-a-test-topic","display_username":"","primary_group_name":null,"version":1,"can_edit":true,"can_delete":true,"can_recover":true,"read":true,"user_title":null,"actions_summary":[{"id":3,"can_act":true},{"id":4,"can_act":true},{"id":5,"hidden":true,"can_act":true},{"id":7,"can_act":true},{"id":8,"can_act":true}],"moderator":false,"admin":true,"staff":true,"user_id":1,"hidden":false,"hidden_reason_id":null,"trust_level":4,"deleted_at":null,"user_deleted":false,"edit_reason":null,"can_view_edit_history":true,"wiki":false},{"id":20,"username":"eviltrout","avatar_template":"/images/avatar.png","name":"Evil Trout","uploaded_avatar_id":9,"created_at":"2015-08-13T14:49:23.927Z","cooked":"

This is the third post.

","post_number":3,"post_type":1,"updated_at":"2015-08-13T14:49:23.927Z","reply_count":0,"reply_to_post_number":null,"quote_count":0,"avg_time":null,"incoming_link_count":0,"reads":1,"score":0,"yours":true,"topic_id":9,"topic_slug":"this-is-a-test-topic","display_username":"","primary_group_name":null,"version":1,"can_edit":true,"can_delete":true,"can_recover":true,"read":true,"user_title":null,"actions_summary":[{"id":3,"can_act":true},{"id":4,"can_act":true},{"id":5,"hidden":true,"can_act":true},{"id":7,"can_act":true},{"id":8,"can_act":true}],"moderator":false,"admin":true,"staff":true,"user_id":1,"hidden":false,"hidden_reason_id":null,"trust_level":4,"deleted_at":null,"user_deleted":false,"edit_reason":null,"can_view_edit_history":true,"wiki":false}],"stream":[18,19,20]},"id":299,"title":"Look at this link","fancy_title":"Look at this link","posts_count":3,"created_at":"2015-08-13T14:49:11.720Z","views":1,"reply_count":0,"participant_count":1,"like_count":0,"last_posted_at":"2015-08-13T14:49:23.927Z","visible":true,"closed":false,"archived":false,"has_summary":false,"archetype":"regular","slug":"this-is-a-test-topic","category_id":1,"word_count":15,"deleted_at":null,"pending_posts_count":0,"user_id":1,"draft":null,"draft_key":"topic_9","draft_sequence":3,"posted":true,"unpinned":null,"pinned_globally":false,"pinned":false,"pinned_at":null,"pinned_until":null,featured_link:"http://www.example.com/has-title.html","details":{"auto_close_at":null,"auto_close_hours":null,"auto_close_based_on_last_post":false,"created_by":{"id":1,"username":"tgxworld","uploaded_avatar_id":9,"avatar_template":"/images/avatar.png"},"last_poster":{"id":1,"username":"tgxworld","uploaded_avatar_id":9,"avatar_template":"/images/avatar.png"},"participants":[{"id":1,"username":"tgxworld","uploaded_avatar_id":9,"avatar_template":"/images/avatar.png","post_count":3}],"suggested_topics":[{"id":8,"title":"This is a new and awesome topic!","fancy_title":"This is a new and awesome topic!","slug":"this-is-a-new-and-awesome-topic","posts_count":3,"reply_count":0,"highest_post_number":5,"image_url":null,"created_at":"2015-08-13T05:17:00.000Z","last_posted_at":"2015-08-13T10:14:34.799Z","bumped":true,"bumped_at":"2015-08-13T10:14:34.799Z","unseen":false,"last_read_post_number":5,"unread":0,"new_posts":0,"pinned":false,"unpinned":null,"visible":true,"closed":false,"archived":false,"notification_level":2,"bookmarked":false,"liked":false,"archetype":"regular","like_count":0,"views":2,"category_id":1},{"id":7,"title":"This is a test category!","fancy_title":"This is a test category!","slug":"this-is-a-test-category","posts_count":3,"reply_count":0,"highest_post_number":3,"image_url":null,"created_at":"2015-08-10T13:40:38.439Z","last_posted_at":"2015-08-13T01:59:44.928Z","bumped":true,"bumped_at":"2015-08-13T01:58:35.206Z","unseen":false,"last_read_post_number":3,"unread":0,"new_posts":0,"pinned":false,"unpinned":null,"visible":true,"closed":false,"archived":false,"notification_level":3,"bookmarked":false,"liked":false,"archetype":"regular","like_count":0,"views":2,"category_id":1}],"notification_level":3,"notifications_reason_id":1,"can_move_posts":true,"can_edit":true,"can_delete":true,"can_recover":true,"can_remove_allowed_users":true,"can_invite_to":true,"can_create_post":true,"can_reply_as_new_topic":true,"can_flag_topic":true},"highest_post_number":3,"last_read_post_number":3,"deleted_by":null,"has_deleted":false,"actions_summary":[{"id":4,"count":0,"hidden":false,"can_act":true},{"id":7,"count":0,"hidden":false,"can_act":true},{"id":8,"count":0,"hidden":false,"can_act":true}],"chunk_size":20,"bookmarked":false} }; diff --git a/test/javascripts/helpers/create-pretender.js.es6 b/test/javascripts/helpers/create-pretender.js.es6 index 30d0e2a6ef7..541c1743206 100644 --- a/test/javascripts/helpers/create-pretender.js.es6 +++ b/test/javascripts/helpers/create-pretender.js.es6 @@ -151,6 +151,7 @@ export default function() { this.delete('/t/:id', success); this.put('/t/:id/recover', success); + this.put('/t/:id/publish', success); this.get("/404-body", () => { return [200, {"Content-Type": "text/html"}, "
not found
"];