From 64d0eb567df3eadad1c70a5eadec58e9560ef0de Mon Sep 17 00:00:00 2001 From: Arpit Jalan Date: Tue, 14 Apr 2015 08:02:01 +0530 Subject: [PATCH 001/113] FIX: scale large vertial images for onebox --- app/assets/stylesheets/common/base/onebox.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/assets/stylesheets/common/base/onebox.scss b/app/assets/stylesheets/common/base/onebox.scss index cc2ce2c2d1..b8f2e93b36 100644 --- a/app/assets/stylesheets/common/base/onebox.scss +++ b/app/assets/stylesheets/common/base/onebox.scss @@ -131,7 +131,7 @@ aside.onebox { img { max-height: 80%; - max-width: 25%; + max-width: 20%; height: auto; float: left; margin-right: 10px; From dba8cbc6ced42c78dd824c3dbcf76ae556c5b830 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcus=20R=C3=BCckert?= Date: Tue, 14 Apr 2015 13:54:33 +0200 Subject: [PATCH 002/113] Update poll readme to include warning about the topic prefix. --- plugins/poll/README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/plugins/poll/README.md b/plugins/poll/README.md index 805c0818c7..0af9a14877 100644 --- a/plugins/poll/README.md +++ b/plugins/poll/README.md @@ -7,6 +7,11 @@ Allows you to add a poll to the first post of a topic. 1. Make your topic title start with "Poll: " 2. Include a list in your post (the **first list** will be used) +Important note: + +Make sure you have the "Poll: " prefix in the title right from the start. +Editing the title to include it later is not possible atm. + ## Closing the poll Change the start of the topic title from "Poll: " to "Closed Poll: ". This feature uses the locale of the user who started the topic. From c3b461f58d20c413469c2248b08f20356449e5a7 Mon Sep 17 00:00:00 2001 From: Ben Hadley-Evans Date: Tue, 14 Apr 2015 12:28:39 +0100 Subject: [PATCH 003/113] Add blank alt attribute to avatars. This was giving an ugly border to avatars in the user card as the full size version loaded in Firefox. --- app/assets/javascripts/discourse/lib/utilities.js | 2 +- spec/components/pretty_text_spec.rb | 6 +++--- test/javascripts/lib/utilities-test.js.es6 | 6 +++--- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/app/assets/javascripts/discourse/lib/utilities.js b/app/assets/javascripts/discourse/lib/utilities.js index 11f5af2923..f51fd4d46f 100644 --- a/app/assets/javascripts/discourse/lib/utilities.js +++ b/app/assets/javascripts/discourse/lib/utilities.js @@ -51,7 +51,7 @@ Discourse.Utilities = { var classes = "avatar" + (options.extraClasses ? " " + options.extraClasses : ""); var title = (options.title) ? " title='" + Handlebars.Utils.escapeExpression(options.title || "") + "'" : ""; - return ""; + return ""; }, tinyAvatar: function(avatarTemplate, options) { diff --git a/spec/components/pretty_text_spec.rb b/spec/components/pretty_text_spec.rb index 57576faca2..5b1dfd26cf 100644 --- a/spec/components/pretty_text_spec.rb +++ b/spec/components/pretty_text_spec.rb @@ -17,15 +17,15 @@ describe PrettyText do end it "produces a quote even with new lines in it" do - expect(PrettyText.cook("[quote=\"EvilTrout, post:123, topic:456, full:true\"]ddd\n[/quote]")).to match_html "" + expect(PrettyText.cook("[quote=\"EvilTrout, post:123, topic:456, full:true\"]ddd\n[/quote]")).to match_html "" end it "should produce a quote" do - expect(PrettyText.cook("[quote=\"EvilTrout, post:123, topic:456, full:true\"]ddd[/quote]")).to match_html "" + expect(PrettyText.cook("[quote=\"EvilTrout, post:123, topic:456, full:true\"]ddd[/quote]")).to match_html "" end it "trims spaces on quote params" do - expect(PrettyText.cook("[quote=\"EvilTrout, post:555, topic: 666\"]ddd[/quote]")).to match_html "" + expect(PrettyText.cook("[quote=\"EvilTrout, post:555, topic: 666\"]ddd[/quote]")).to match_html "" end end diff --git a/test/javascripts/lib/utilities-test.js.es6 b/test/javascripts/lib/utilities-test.js.es6 index c73153f7da..75d4f14b86 100644 --- a/test/javascripts/lib/utilities-test.js.es6 +++ b/test/javascripts/lib/utilities-test.js.es6 @@ -137,15 +137,15 @@ test("avatarImg", function() { var avatarTemplate = "/path/to/avatar/{size}.png"; equal(utils.avatarImg({avatarTemplate: avatarTemplate, size: 'tiny'}), - "", + "", "it returns the avatar html"); equal(utils.avatarImg({avatarTemplate: avatarTemplate, size: 'tiny', title: 'evilest trout'}), - "", + "", "it adds a title if supplied"); equal(utils.avatarImg({avatarTemplate: avatarTemplate, size: 'tiny', extraClasses: 'evil fish'}), - "", + "", "it adds extra classes if supplied"); blank(utils.avatarImg({avatarTemplate: "", size: 'tiny'}), From eaf5d21c41477cee4241aa310fbe6bfe9284f9b2 Mon Sep 17 00:00:00 2001 From: Robin Ward Date: Tue, 14 Apr 2015 11:49:44 -0400 Subject: [PATCH 004/113] Don't store post timings that are greater than the account lifetime --- app/models/post_timing.rb | 4 +++- spec/models/post_timing_spec.rb | 16 ++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/app/models/post_timing.rb b/app/models/post_timing.rb index 245f512c6a..a1cf4dd60b 100644 --- a/app/models/post_timing.rb +++ b/app/models/post_timing.rb @@ -65,9 +65,11 @@ class PostTiming < ActiveRecord::Base def self.process_timings(current_user, topic_id, topic_time, timings) current_user.user_stat.update_time_read! + account_age_msecs = ((Time.now - current_user.created_at) * 1000.0) + highest_seen = 1 timings.each do |post_number, time| - if post_number >= 0 + if post_number >= 0 && time < account_age_msecs PostTiming.record_timing(topic_id: topic_id, post_number: post_number, user_id: current_user.id, diff --git a/spec/models/post_timing_spec.rb b/spec/models/post_timing_spec.rb index 452d6ad3e8..b2c18dd804 100644 --- a/spec/models/post_timing_spec.rb +++ b/spec/models/post_timing_spec.rb @@ -60,6 +60,22 @@ describe PostTiming do end end + describe 'safeguard' do + it "doesn't store timings that are larger than the account lifetime" do + user = Fabricate(:user, created_at: 3.minutes.ago) + post = Fabricate(:post) + + PostTiming.process_timings(user, post.topic_id, 1, [[post.post_number, 123]]) + msecs = PostTiming.where(post_number: post.post_number, user_id: user.id).pluck(:msecs)[0] + expect(msecs).to eq(123) + + PostTiming.process_timings(user, post.topic_id, 1, [[post.post_number, 10.minutes.to_i * 1000]]) + msecs = PostTiming.where(post_number: post.post_number, user_id: user.id).pluck(:msecs)[0] + expect(msecs).to eq(123) + end + + end + describe 'process_timings' do # integration test From 869d8e25ad053b2b9673f25a84e1928b6aef54e8 Mon Sep 17 00:00:00 2001 From: Robin Ward Date: Tue, 14 Apr 2015 12:05:09 -0400 Subject: [PATCH 005/113] Promotion fails if the user account isn't old enough yet. --- lib/promotion.rb | 3 ++ spec/components/promotion_spec.rb | 36 +++++++++++++++++-- spec/components/topic_view_spec.rb | 2 ++ .../admin/users_controller_spec.rb | 2 +- 4 files changed, 40 insertions(+), 3 deletions(-) diff --git a/lib/promotion.rb b/lib/promotion.rb index 14e89c8688..82dd5a998c 100644 --- a/lib/promotion.rb +++ b/lib/promotion.rb @@ -83,6 +83,7 @@ class Promotion return false if stat.topics_entered < SiteSetting.tl2_requires_topics_entered return false if stat.posts_read_count < SiteSetting.tl2_requires_read_posts return false if (stat.time_read / 60) < SiteSetting.tl2_requires_time_spent_mins + return false if ((Time.now - user.created_at) / 60) < SiteSetting.tl2_requires_time_spent_mins return false if stat.days_visited < SiteSetting.tl2_requires_days_visited return false if stat.likes_received < SiteSetting.tl2_requires_likes_received return false if stat.likes_given < SiteSetting.tl2_requires_likes_given @@ -96,6 +97,8 @@ class Promotion return false if stat.topics_entered < SiteSetting.tl1_requires_topics_entered return false if stat.posts_read_count < SiteSetting.tl1_requires_read_posts return false if (stat.time_read / 60) < SiteSetting.tl1_requires_time_spent_mins + return false if ((Time.now - user.created_at) / 60) < SiteSetting.tl1_requires_time_spent_mins + return true end diff --git a/spec/components/promotion_spec.rb b/spec/components/promotion_spec.rb index 0729e63672..71d80a2738 100644 --- a/spec/components/promotion_spec.rb +++ b/spec/components/promotion_spec.rb @@ -15,7 +15,7 @@ describe Promotion do context "newuser" do - let(:user) { Fabricate(:user, trust_level: TrustLevel[0])} + let(:user) { Fabricate(:user, trust_level: TrustLevel[0], created_at: 2.days.ago)} let(:promotion) { Promotion.new(user) } it "doesn't raise an error with a nil user" do @@ -53,11 +53,24 @@ describe Promotion do end end + context "that has done the requisite things" do + it "does not promote the user" do + user.created_at = 1.minute.ago + stat = user.user_stat + stat.topics_entered = SiteSetting.tl1_requires_topics_entered + stat.posts_read_count = SiteSetting.tl1_requires_read_posts + stat.time_read = SiteSetting.tl1_requires_time_spent_mins * 60 + @result = promotion.review + expect(@result).to eq(false) + expect(user.trust_level).to eq(TrustLevel[0]) + end + end + end context "basic" do - let(:user) { Fabricate(:user, trust_level: TrustLevel[1])} + let(:user) { Fabricate(:user, trust_level: TrustLevel[1], created_at: 2.days.ago)} let(:promotion) { Promotion.new(user) } context 'that has done nothing' do @@ -96,6 +109,25 @@ describe Promotion do end end + context "when the account hasn't existed long enough" do + it "does not promote the user" do + user.created_at = 1.minute.ago + + stat = user.user_stat + stat.topics_entered = SiteSetting.tl2_requires_topics_entered + stat.posts_read_count = SiteSetting.tl2_requires_read_posts + stat.time_read = SiteSetting.tl2_requires_time_spent_mins * 60 + stat.days_visited = SiteSetting.tl2_requires_days_visited * 60 + stat.likes_received = SiteSetting.tl2_requires_likes_received + stat.likes_given = SiteSetting.tl2_requires_likes_given + stat.topic_reply_count = SiteSetting.tl2_requires_topic_reply_count + + result = promotion.review + expect(result).to eq(false) + expect(user.trust_level).to eq(TrustLevel[1]) + end + end + end context "regular" do diff --git a/spec/components/topic_view_spec.rb b/spec/components/topic_view_spec.rb index c10fc0128a..dedc080b9f 100644 --- a/spec/components/topic_view_spec.rb +++ b/spec/components/topic_view_spec.rb @@ -214,6 +214,8 @@ describe TopicView do # random user has nothing expect(topic_view.read?(1)).to eq(false) + coding_horror.created_at = 2.days.ago + # a real user that just read it should have it marked PostTiming.process_timings(coding_horror, topic.id, 1, [[1,1000]]) expect(TopicView.new(topic.id, coding_horror).read?(1)).to eq(true) diff --git a/spec/controllers/admin/users_controller_spec.rb b/spec/controllers/admin/users_controller_spec.rb index 114b62b67c..d76ea7c696 100644 --- a/spec/controllers/admin/users_controller_spec.rb +++ b/spec/controllers/admin/users_controller_spec.rb @@ -187,7 +187,7 @@ describe Admin::UsersController do context '.trust_level' do before do - @another_user = Fabricate(:coding_horror) + @another_user = Fabricate(:coding_horror, created_at: 1.month.ago) end it "raises an error when the user doesn't have permission" do From 32e02411bd93bfa6167d8591f1cd735c8665ff48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9gis=20Hanol?= Date: Tue, 14 Apr 2015 18:16:42 +0200 Subject: [PATCH 006/113] add custom importer for sfn.org --- script/import_scripts/sfn.rb | 269 +++++++++++++++++++++++++++++++++++ 1 file changed, 269 insertions(+) create mode 100644 script/import_scripts/sfn.rb diff --git a/script/import_scripts/sfn.rb b/script/import_scripts/sfn.rb new file mode 100644 index 0000000000..3be18a741f --- /dev/null +++ b/script/import_scripts/sfn.rb @@ -0,0 +1,269 @@ +# custom importer for www.sfn.org, feel free to borrow ideas + +require 'mysql2' +require File.expand_path(File.dirname(__FILE__) + "/base.rb") + +class ImportScripts::Sfn < ImportScripts::Base + + BATCH_SIZE = 1000 + + def initialize + super + end + + def execute + import_users + import_categories + import_topics + import_posts + end + + def import_users + puts "", "importing users..." + + user_count = mysql_query <<-SQL + SELECT COUNT(DISTINCT cm.ContactKey) AS "count" + FROM CommunityMember cm + LEFT JOIN EgroupSubscription es ON es.ContactKey = cm.ContactKey + WHERE LENGTH(COALESCE(es.EmailAddr_, "")) > 5 + SQL + + user_count = user_count.first["count"] + + batches(BATCH_SIZE) do |offset| + users = mysql_query <<-SQL + SELECT cm.ContactKey AS "id", + cm.InvitedOn AS "created_at", + es.EmailAddr_ AS "email", + es.FullName_ AS "name", + c.Bio AS "bio", + c.ProfileImage AS "avatar" + FROM CommunityMember cm + LEFT JOIN EgroupSubscription es ON es.ContactKey = cm.ContactKey + LEFT JOIN Contact c ON c.ContactKey = cm.ContactKey + WHERE LENGTH(COALESCE(es.EmailAddr_, "")) > 5 + GROUP BY cm.ContactKey + ORDER BY "created_at" + LIMIT #{BATCH_SIZE} + OFFSET #{offset} + SQL + + break if users.size < 1 + + create_users(users, total: user_count, offset: offset) do |user| + { + id: user["id"], + name: user["name"], + email: user["email"], + bio_raw: user["bio"], + created_at: user["created_at"], + post_create_action: proc do |newuser| + next if user["avatar"].blank? + + avatar = Tempfile.new("sfn-avatar") + avatar.write(user["avatar"].encode("ASCII-8BIT").force_encoding("UTF-8")) + avatar.rewind + + upload = Upload.create_for(newuser.id, avatar, "avatar.jpg", avatar.size) + if upload.persisted? + newuser.create_user_avatar + newuser.user_avatar.update(custom_upload_id: upload.id) + newuser.update(uploaded_avatar_id: upload.id) + end + + avatar.try(:close!) rescue nil + end + } + end + end + end + + NEW_CATEGORIES = [ + "Abstract Topic Matching Forum", + "Animals in Research", + "Brain Awareness and Teaching", + "Career Advice", + "Career Paths", + "Diversity", + "Early Career Policy Advocates", + "LATP Associates", + "LATP Fellows", + "Mid and Advanced Career", + "Neurobiology of Disease Workshop", + "Neuroscience 2015", + "Neuroscience Scholars Program", + "NSP Associates", + "NSP Fellows", + "Outreach", + "Postdocs and Early Career", + "Program Committee", + "Program Development", + "Roommate Matching Forum", + "Scientific Research", + "Students", + ] + + # EgroupKey => New Category Name + CATEGORY_MAPPING = { + "{DE10E4F4-621A-48BF-9B45-05D9F774A590}" => "Abstract Topic Matching Forum", + "{3FFC1217-1576-4D38-BB81-D6CADC7FB793}" => "Animals in Research", + "{9362BB21-BF6C-4E55-A3E0-18CD5D9F3323}" => "Brain Awareness and Teaching", + "{3AC01B09-A21F-4166-95DA-0E585E271075}" => "Brain Awareness and Teaching", + "{C249728D-8C9E-4138-AA49-D02467C28EAD}" => "Career Advice", + "{01570B85-0124-478F-A8B9-B028BD1B1F2F}" => "Career Paths", + "{2A430528-278A-46CD-BE1A-07CFA1122919}" => "Diversity", + "{2F211345-3C19-43C9-90B5-27BA9FCD4DB0}" => "Diversity", + "{8092297D-8DF4-404A-8BEB-4D5D0DC6A191}" => "Early Career Policy Advocates", + "{8CB58762-D562-448C-9AF1-8DAE6C482C9B}" => "LATP Associates", + "{CDF80A92-925A-46DD-A867-8558FA72D016}" => "LATP Fellows", + "{E71E237B-7C23-4596-AECA-655BD8ED50DB}" => "Mid and Advanced Career", + "{1D674C38-17CB-4C48-826A-D465AC3F8948}" => "Neurobiology of Disease Workshop", + "{3D4F885B-0037-403B-83DD-62FAA8E81DF1}" => "Neuroscience 2015", + "{9ACC3B40-E4A3-4FFD-AADC-C8403EB6231D}" => "Neuroscience 2015", + "{9FC30FFB-E450-4361-8844-0266C3D96868}" => "Neuroscience Scholars Program", + "{3E78123E-87CE-435E-B4B7-7DAB1A21C541}" => "NSP Associates", + "{12D889D3-5CFD-49D5-93E4-32AAB2CFFCDA}" => "NSP Fellows", + "{FA86D79E-170E-4F53-8F1C-942CB3FFB19E}" => "Outreach", + "{D7041C64-3D32-4010-B3D8-71858323CB4A}" => "Outreach", + "{69B76913-4E23-4C80-A11E-9CDB4130722E}" => "Outreach", + "{774878EA-96AD-49F5-9D29-105AEA488007}" => "Outreach", + "{E6349704-FD01-41B1-9C59-68E928DD4318}" => "Postdocs and Early Career", + "{31CF5944-2567-4E79-9730-18EEC23E5B52}" => "Postdocs and Early Career", + "{5625C403-AFAE-4323-A470-33FC32B12B53}" => "Program Committee", + "{8415D871-54F5-4128-B099-E5A376A6B41B}" => "Program Development", + "{B4DF2044-47AB-4329-8BF7-0D832CAB402C}" => "Roommate Matching Forum", + "{6A3A12B9-5C72-472F-97AC-F34983674960}" => "Scientific Research", + "{2CF635E9-4866-451C-A4F2-E2A8A80FED54}" => "Scientific Research", + "{CF2DDCCE-737F-499D-AFE4-E5C36F195C8B}" => "Scientific Research", + "{282B48D7-AC1D-453E-9806-3C6CE6830EF9}" => "Scientific Research", + "{6D750CAF-E96F-4AD1-A45B-7B74FDFF0B40}" => "Scientific Research", + "{10AF5D45-BEB3-4F07-BE77-0BAB6910DE10}" => "Scientific Research", + "{18D7F624-26D1-44B9-BF33-AB5C5A2AB2BF}" => "Scientific Research", + "{6016FF4F-D834-4888-BA03-F9FE8CB1D4CC}" => "Scientific Research", + "{B0290A37-EA39-4CB8-B6CB-3E0B7EF6D036}" => "Scientific Research", + "{97CC60D0-B93A-43FF-BB48-366FAAEE2BAC}" => "Scientific Research", + "{8FC9B57B-2755-4FC5-90E8-CCDB56CF2F66}" => "Scientific Research", + "{57C8BF37-357E-4FE6-952D-906248642792}" => "Scientific Research", + "{7B2A3B63-BC2C-4219-830C-BA1DECB33337}" => "Scientific Research", + "{0ED1D205-0E48-48D2-B82B-3CE80C6C553F}" => "Scientific Research", + "{10355962-D172-4294-AA8E-1BC381B67971}" => "Scientific Research", + "{C84B0222-5232-4B94-9FB8-DDF802241171}" => "Scientific Research", + "{9143F984-0D67-46CB-AAAF-7FE3B6335E07}" => "Scientific Research", + "{1392DC10-37A0-46A6-9979-4568D0224C5F}" => "Scientific Research", + "{E4891409-0F4F-4151-B550-ECE53655E231}" => "Scientific Research", + "{9613BAC2-229B-4563-9E1C-35C31CDDCE2F}" => "Students", + } + + def import_categories + puts "", "importing categories..." + + create_categories(NEW_CATEGORIES) do |category| + { id: category, name: category } + end + end + + def import_topics + puts "", "importing topics..." + + topic_count = mysql_query <<-SQL + SELECT COUNT(MessageID_) AS "count" + FROM EgroupMessages + WHERE ParentId_ = 0 + AND ApprovedRejectedPendingInd = "Approved" + SQL + + topic_count = topic_count.first["count"] + + batches(BATCH_SIZE) do |offset| + topics = mysql_query <<-SQL + SELECT MessageID_ AS "id", + EgroupKey AS "category_id", + ContactKey AS "user_id", + HdrSubject_ AS "title", + Body_ AS "raw", + CreatStamp_ AS "created_at" + FROM EgroupMessages + WHERE ParentId_ = 0 + AND ApprovedRejectedPendingInd = "Approved" + ORDER BY "created_at" + LIMIT #{BATCH_SIZE} + OFFSET #{offset} + SQL + + break if topics.size < 1 + + create_posts(topics, total: topic_count, offset: offset) do |topic| + next unless category_id = CATEGORY_MAPPING[topic["category_id"]] + { + id: topic["id"], + category: category_id_from_imported_category_id(category_id), + user_id: user_id_from_imported_user_id(topic["user_id"]) || Discourse::SYSTEM_USER_ID, + title: topic["title"][0..250], + raw: cleanup_raw(topic["raw"]), + created_at: topic["created_at"], + } + end + end + end + + def import_posts + puts "", "importing posts..." + + posts_count = mysql_query <<-SQL + SELECT COUNT(MessageID_) AS "count" + FROM EgroupMessages + WHERE ParentId_ > 0 + AND ApprovedRejectedPendingInd = "Approved" + SQL + + posts_count = posts_count.first["count"] + + batches(BATCH_SIZE) do |offset| + posts = mysql_query <<-SQL + SELECT MessageID_ AS "id", + ContactKey AS "user_id", + ParentID_ AS "topic_id", + Body_ AS "raw", + CreatStamp_ AS "created_at" + FROM EgroupMessages + WHERE ParentId_ > 0 + AND ApprovedRejectedPendingInd = "Approved" + ORDER BY "created_at" + LIMIT #{BATCH_SIZE} + OFFSET #{offset} + SQL + + break if posts.size < 1 + + create_posts(posts, total: posts_count, offset: offset) do |post| + next unless parent = topic_lookup_from_imported_post_id(post["topic_id"]) + { + id: post["id"], + topic_id: parent[:topic_id], + user_id: user_id_from_imported_user_id(post["user_id"]) || Discourse::SYSTEM_USER_ID, + raw: cleanup_raw(post["raw"]), + created_at: post["created_at"], + } + end + end + end + + def cleanup_raw(raw) + # fix some html + raw.gsub!(//i, "\n") + # remove "This message has been cross posted to the following eGroups: ..." + raw.gsub!(/^This message has been cross posted to the following eGroups: .+\n-{3,}/i, "") + # remove signatures + raw.gsub!(/-{3,}.+/m, "") + # strip leading/trailing whitespaces + raw.strip + end + + def mysql_query(sql) + @client ||= Mysql2::Client.new(username: "root", database: "sfn") + @client.query(sql) + end + +end + +ImportScripts::Sfn.new.perform From a87bf1d9aac4557317d258cb5115dc1da17e6dab Mon Sep 17 00:00:00 2001 From: Kris Aubuchon Date: Tue, 14 Apr 2015 15:54:17 -0400 Subject: [PATCH 007/113] some fixes for tag alignment --- app/assets/stylesheets/common/base/topic.scss | 17 +++++++++++++++++ .../common/components/badges.css.scss | 5 ++++- app/assets/stylesheets/mobile/topic.scss | 2 +- 3 files changed, 22 insertions(+), 2 deletions(-) diff --git a/app/assets/stylesheets/common/base/topic.scss b/app/assets/stylesheets/common/base/topic.scss index 13201331cc..4eb62c3089 100644 --- a/app/assets/stylesheets/common/base/topic.scss +++ b/app/assets/stylesheets/common/base/topic.scss @@ -6,6 +6,13 @@ .btn-small { margin: 0 6px 0 0; } + + .badge-wrapper { + float: left; + &.bullet { + margin-top: 5px; + } + } } a.badge-category { @@ -24,3 +31,13 @@ top: 0; } } + +.extra-info-wrapper { + .badge-wrapper { + float: left; + &.bullet { + margin-top: 5px; + } + } + +} diff --git a/app/assets/stylesheets/common/components/badges.css.scss b/app/assets/stylesheets/common/components/badges.css.scss index 9e8ace19fb..d4ff996ce4 100644 --- a/app/assets/stylesheets/common/components/badges.css.scss +++ b/app/assets/stylesheets/common/components/badges.css.scss @@ -24,6 +24,8 @@ &.bar { //bar category style line-height: 1.25; + margin-right: 5px; + span.badge-category { color: $primary !important; padding: 3px; @@ -51,7 +53,7 @@ &.bullet { //bullet category style line-height: 1; - margin-right: 5px; + margin-right: 10px; h3 & { line-height: .9; @@ -98,6 +100,7 @@ &.box { //box category style (apply custom widths to the wrapper, not the children) line-height: 1.5; margin-top: 5px; + margin-right: 5px; span { display: block; diff --git a/app/assets/stylesheets/mobile/topic.scss b/app/assets/stylesheets/mobile/topic.scss index 70812a772f..313bcfcd7c 100644 --- a/app/assets/stylesheets/mobile/topic.scss +++ b/app/assets/stylesheets/mobile/topic.scss @@ -19,7 +19,7 @@ h1 { font-size: 1.5em; line-height: 1.25em; - margin:0; + margin: 0; a { color: $primary; vertical-align: middle; From 6a0cce8571e024a63acd501a700fb620714dbaa3 Mon Sep 17 00:00:00 2001 From: Sam Date: Wed, 15 Apr 2015 08:38:42 +1000 Subject: [PATCH 008/113] UX: tweak copy and position of tracking and new prefs --- .../javascripts/discourse/controllers/preferences.js.es6 | 2 +- .../javascripts/discourse/templates/user/preferences.hbs | 9 +++++---- config/locales/client.en.yml | 2 +- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/app/assets/javascripts/discourse/controllers/preferences.js.es6 b/app/assets/javascripts/discourse/controllers/preferences.js.es6 index 07120a7c84..7b5810ff30 100644 --- a/app/assets/javascripts/discourse/controllers/preferences.js.es6 +++ b/app/assets/javascripts/discourse/controllers/preferences.js.es6 @@ -67,7 +67,7 @@ export default ObjectController.extend(CanCheckEmails, { { name: I18n.t('user.email_digests.every_two_weeks'), value: 14 }], autoTrackDurations: [{ name: I18n.t('user.auto_track_options.never'), value: -1 }, - { name: I18n.t('user.auto_track_options.always'), value: 0 }, + { name: I18n.t('user.auto_track_options.immediately'), value: 0 }, { name: I18n.t('user.auto_track_options.after_n_seconds', { count: 30 }), value: 30000 }, { name: I18n.t('user.auto_track_options.after_n_minutes', { count: 1 }), value: 60000 }, { name: I18n.t('user.auto_track_options.after_n_minutes', { count: 2 }), value: 120000 }, diff --git a/app/assets/javascripts/discourse/templates/user/preferences.hbs b/app/assets/javascripts/discourse/templates/user/preferences.hbs index d8e5ff1e2b..0133f6537b 100644 --- a/app/assets/javascripts/discourse/templates/user/preferences.hbs +++ b/app/assets/javascripts/discourse/templates/user/preferences.hbs @@ -197,16 +197,17 @@
-
- - {{combo-box valueAttribute="value" content=autoTrackDurations value=auto_track_topics_after_msecs}} -
{{combo-box valueAttribute="value" content=considerNewTopicOptions value=new_topic_duration_minutes}}
+
+ + {{combo-box valueAttribute="value" content=autoTrackDurations value=auto_track_topics_after_msecs}} +
+ {{preference-checkbox labelKey="user.external_links_in_new_tab" checked=external_links_in_new_tab}} {{preference-checkbox labelKey="user.enable_quoting" checked=enable_quoting}} {{preference-checkbox labelKey="user.dynamic_favicon" checked=dynamic_favicon}} diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index c2d95b258e..7ca4043164 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -509,7 +509,7 @@ en: auto_track_topics: "Automatically track topics I enter" auto_track_options: never: "never" - always: "always" + immediately: "immediately" after_n_seconds: one: "after 1 second" other: "after {{count}} seconds" From 2a3f71a9a1717c420c047dc379d3367858c82391 Mon Sep 17 00:00:00 2001 From: Sam Date: Wed, 15 Apr 2015 08:57:43 +1000 Subject: [PATCH 009/113] SECURITY: log off all existing sessions when resetting password --- app/controllers/users_controller.rb | 1 + spec/controllers/users_controller_spec.rb | 8 +++++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 23dfb48705..137cf9d6bd 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -321,6 +321,7 @@ class UsersController < ApplicationController else @user.password = params[:password] @user.password_required! + @user.auth_token = nil if @user.save Invite.invalidate_for_email(@user.email) # invite link can't be used to log in anymore logon_after_password_reset diff --git a/spec/controllers/users_controller_spec.rb b/spec/controllers/users_controller_spec.rb index 159e007c71..39e4aae8e1 100644 --- a/spec/controllers/users_controller_spec.rb +++ b/spec/controllers/users_controller_spec.rb @@ -266,13 +266,19 @@ describe UsersController do context 'valid token' do it 'returns success' do - user = Fabricate(:user) + user = Fabricate(:user, auth_token: SecureRandom.hex(16)) token = user.email_tokens.create(email: user.email).token + old_token = user.auth_token + get :password_reset, token: token put :password_reset, token: token, password: 'newpassword' expect(response).to be_success expect(assigns[:error]).to be_blank + + user.reload + expect(user.auth_token).to_not eq old_token + expect(user.auth_token.length).to eq 32 end end From 9191fbe9fb66ae4fd2fc6f28030e25096220229a Mon Sep 17 00:00:00 2001 From: Sam Date: Wed, 15 Apr 2015 09:21:52 +1000 Subject: [PATCH 010/113] Upgrade ruby racer so we can remove our freedom patch --- Gemfile.lock | 2 +- .../fix_rubyracer_memory_leak.rb | 33 ------------------- 2 files changed, 1 insertion(+), 34 deletions(-) delete mode 100644 lib/freedom_patches/fix_rubyracer_memory_leak.rb diff --git a/Gemfile.lock b/Gemfile.lock index 79946b29bf..fca49b6c97 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -422,7 +422,7 @@ GEM sprockets (~> 2.8) stackprof (0.2.7) stringex (2.5.2) - therubyracer (0.12.1) + therubyracer (0.12.2) libv8 (~> 3.16.14.0) ref thin (1.6.3) diff --git a/lib/freedom_patches/fix_rubyracer_memory_leak.rb b/lib/freedom_patches/fix_rubyracer_memory_leak.rb deleted file mode 100644 index 2971797b84..0000000000 --- a/lib/freedom_patches/fix_rubyracer_memory_leak.rb +++ /dev/null @@ -1,33 +0,0 @@ -## TODO: DELETE ME WHEN https://github.com/cowboyd/therubyracer/pull/336 -# is upstreamed and released -# -module V8 - module Weak - class WeakValueMap - def initialize - @values = {} - end - - def [](key) - if ref = @values[key] - ref.object - end - end - - def []=(key, value) - ref = V8::Weak::Ref.new(value) - ObjectSpace.define_finalizer(value, self.class.ensure_cleanup(@values, key, ref)) - - @values[key] = ref - end - - private - - def self.ensure_cleanup(values,key,ref) - proc { - values.delete(key) if values[key] == ref - } - end - end - end -end From 499bed69e2365a7e9dad329cf69bb8169886d2b6 Mon Sep 17 00:00:00 2001 From: Arpit Jalan Date: Wed, 15 Apr 2015 13:36:54 +0530 Subject: [PATCH 011/113] FIX: show error message if user already exist in group --- app/assets/javascripts/discourse/models/group.js.es6 | 6 ++++++ app/controllers/admin/groups_controller.rb | 6 +++++- config/locales/server.en.yml | 1 + spec/controllers/admin/groups_controller_spec.rb | 10 ++++++++++ 4 files changed, 22 insertions(+), 1 deletion(-) diff --git a/app/assets/javascripts/discourse/models/group.js.es6 b/app/assets/javascripts/discourse/models/group.js.es6 index 6f426073aa..984941e89f 100644 --- a/app/assets/javascripts/discourse/models/group.js.es6 +++ b/app/assets/javascripts/discourse/models/group.js.es6 @@ -52,6 +52,12 @@ const Group = Discourse.Model.extend({ }).then(function() { // reload member list self.findMembers(); + }).catch(function(error) { + if (error && error.responseText) { + bootbox.alert($.parseJSON(error.responseText).errors[0]); + } else { + bootbox.alert(I18n.t('generic_error')); + } }); }, diff --git a/app/controllers/admin/groups_controller.rb b/app/controllers/admin/groups_controller.rb index 69288921f4..42ed206bfd 100644 --- a/app/controllers/admin/groups_controller.rb +++ b/app/controllers/admin/groups_controller.rb @@ -83,7 +83,11 @@ class Admin::GroupsController < Admin::AdminController end users.each do |user| - group.add(user) + if !group.users.include?(user) + group.add(user) + else + return render_json_error I18n.t('groups.errors.member_already_exist', username: user.username) + end end if group.save diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index ee0ec4e0e5..f7d1fd83ac 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -180,6 +180,7 @@ en: groups: errors: can_not_modify_automatic: "You can not modify an automatic group" + member_already_exist: "'%{username}' is already a member of this group." default_names: everyone: "everyone" admins: "admins" diff --git a/spec/controllers/admin/groups_controller_spec.rb b/spec/controllers/admin/groups_controller_spec.rb index b8c543f4cc..b7710e7007 100644 --- a/spec/controllers/admin/groups_controller_spec.rb +++ b/spec/controllers/admin/groups_controller_spec.rb @@ -136,6 +136,16 @@ describe Admin::GroupsController do end end + it "returns 422 if member already exists" do + group = Fabricate(:group) + existing_member = Fabricate(:user) + group.add(existing_member) + group.save + + xhr :put, :add_members, id: group.id, usernames: existing_member.username + expect(response.status).to eq(422) + end + end context ".remove_member" do From 539861cc3fa3591ce8529880adedea143b513227 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9gis=20Hanol?= Date: Wed, 15 Apr 2015 11:31:27 +0200 Subject: [PATCH 012/113] FIX: generate pngout-compatible letter avatars --- lib/letter_avatar.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/letter_avatar.rb b/lib/letter_avatar.rb index a9288be339..75708e2546 100644 --- a/lib/letter_avatar.rb +++ b/lib/letter_avatar.rb @@ -77,6 +77,7 @@ class LetterAvatar -stroke #{to_rgb(stroke)} -strokewidth 2 -annotate -0+20 '#{letter}' + -depth 8 '#{filename}' } From b28a004413cfe1637de635b5ef827a280746824b Mon Sep 17 00:00:00 2001 From: betson Date: Wed, 15 Apr 2015 11:42:39 -0400 Subject: [PATCH 013/113] indicate that lists can be pasted if pipe-delimited In the configuration for a list-setting component, the Select2 component is initialized with a pipe ("|") as the separator. (app/assets/javascripts/admin/components/list-setting.js.es6) This should be communicated to the user in the event they want to paste a list of domains for the blacklist/whitelist. --- config/locales/server.en.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index f7d1fd83ac..edae071b27 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -850,8 +850,8 @@ en: enable_badges: "Enable the badge system" allow_index_in_robots_txt: "Specify in robots.txt that this site is allowed to be indexed by web search engines." - email_domains_blacklist: "A list of email domains that users are not allowed to register accounts with. Example: mailinator.com trashmail.net" - email_domains_whitelist: "A list of email domains that users MUST register accounts with. WARNING: Users with email domains other than those listed will not be allowed!" + email_domains_blacklist: "A pipe-delimited list of email domains that users are not allowed to register accounts with. Example: mailinator.com|trashmail.net" + email_domains_whitelist: "A pipe-delimited list of email domains that users MUST register accounts with. WARNING: Users with email domains other than those listed will not be allowed!" forgot_password_strict: "Don't inform users of an account's existance when they use the forgot password dialog." log_out_strict: "When logging out, log out ALL sessions for the user on all devices" version_checks: "Ping the Discourse Hub for version updates and show new version messages on the /admin dashboard" From 535f7aab8887aaaa1350049c844976e4fc7fc874 Mon Sep 17 00:00:00 2001 From: Jens Maier Date: Wed, 15 Apr 2015 18:35:08 +0200 Subject: [PATCH 014/113] Fix admin badge UI style when no badge selected --- app/assets/javascripts/admin/templates/badges-index.hbs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/assets/javascripts/admin/templates/badges-index.hbs b/app/assets/javascripts/admin/templates/badges-index.hbs index fcb54478d8..6ee80748f8 100644 --- a/app/assets/javascripts/admin/templates/badges-index.hbs +++ b/app/assets/javascripts/admin/templates/badges-index.hbs @@ -1,4 +1,4 @@ -
+

{{i18n 'admin.badges.none_selected'}}

From 8ba6a45cd70c6413746592229998020ae75238d6 Mon Sep 17 00:00:00 2001 From: Robin Ward Date: Wed, 25 Mar 2015 14:24:34 -0400 Subject: [PATCH 015/113] Post Queue model to enqueue creation of posts --- app/models/queued_post.rb | 77 +++++++++++ .../20150325160959_create_queued_posts.rb | 20 +++ spec/models/queued_post_spec.rb | 124 ++++++++++++++++++ 3 files changed, 221 insertions(+) create mode 100644 app/models/queued_post.rb create mode 100644 db/migrate/20150325160959_create_queued_posts.rb create mode 100644 spec/models/queued_post_spec.rb diff --git a/app/models/queued_post.rb b/app/models/queued_post.rb new file mode 100644 index 0000000000..a7b4dcec44 --- /dev/null +++ b/app/models/queued_post.rb @@ -0,0 +1,77 @@ +class QueuedPost < ActiveRecord::Base + + class InvalidStateTransition < StandardError; end; + + serialize :post_options, JSON + + belongs_to :user + belongs_to :topic + belongs_to :approved_by, class_name: "User" + belongs_to :rejected_by, class_name: "User" + + def self.attributes_by_queue + @attributes_by_queue ||= { + base: [:archetype, + :via_email, + :raw_email, + :auto_track, + :custom_fields, + :cooking_options, + :cook_method, + :image_sizes], + new_post: [:reply_to_post_number], + new_topic: [:title, :category, :meta_data, :archetype], + } + end + + def self.states + @states ||= Enum.new(:new, :approved, :rejected) + end + + def reject!(rejected_by) + change_to!(:rejected, rejected_by) + end + + def create_options + opts = {raw: raw} + post_attributes.each {|a| opts[a] = post_options[a.to_s] } + + opts[:topic_id] = topic_id if topic_id + opts + end + + def approve!(approved_by) + created_post = nil + QueuedPost.transaction do + change_to!(:approved, approved_by) + + creator = PostCreator.new(user, create_options) + created_post = creator.create + end + created_post + end + + private + def post_attributes + [QueuedPost.attributes_by_queue[:base], QueuedPost.attributes_by_queue[queue.to_sym]].flatten.compact + end + + def change_to!(state, changed_by) + state_val = QueuedPost.states[state] + + updates = { state: state_val, + "#{state}_by_id" => changed_by.id, + "#{state}_at" => Time.now } + + # We use an update with `row_count` trick here to avoid stampeding requests to + # update the same row simultaneously. Only one state change should go through and + # we can use the DB to enforce this + row_count = QueuedPost.where('id = ? AND state <> ?', id, state_val).update_all(updates) + raise InvalidStateTransition.new if row_count == 0 + + # Update the record in memory too, and clear the dirty flag + updates.each {|k, v| send("#{k}=", v) } + changes_applied + end + +end diff --git a/db/migrate/20150325160959_create_queued_posts.rb b/db/migrate/20150325160959_create_queued_posts.rb new file mode 100644 index 0000000000..4941753ee2 --- /dev/null +++ b/db/migrate/20150325160959_create_queued_posts.rb @@ -0,0 +1,20 @@ +class CreateQueuedPosts < ActiveRecord::Migration + def change + create_table :queued_posts do |t| + t.string :queue, null: false + t.integer :state, null: false + t.integer :user_id, null: false + t.text :raw, null: false + t.text :post_options, null: false + t.integer :topic_id + t.integer :approved_by_id + t.timestamp :approved_at + t.integer :rejected_by_id + t.timestamp :rejected_at + t.timestamps + end + + add_index :queued_posts, [:queue, :state, :created_at], name: 'by_queue_status' + add_index :queued_posts, [:topic, :queue, :state, :created_at], name: 'by_queue_status_topic' + end +end diff --git a/spec/models/queued_post_spec.rb b/spec/models/queued_post_spec.rb new file mode 100644 index 0000000000..7fa07b1991 --- /dev/null +++ b/spec/models/queued_post_spec.rb @@ -0,0 +1,124 @@ +require 'spec_helper' +require_dependency 'queued_post' + +describe QueuedPost do + + context "creating a post" do + let(:topic) { Fabricate(:topic) } + let(:user) { Fabricate(:user) } + let(:admin) { Fabricate(:admin) } + let(:qp) { QueuedPost.create(queue: 'new_post', + state: QueuedPost.states[:new], + user_id: user.id, + topic_id: topic.id, + raw: 'This post should be queued up', + post_options: { + reply_to_post_number: 1, + via_email: true, + raw_email: 'store_me', + auto_track: true, + custom_fields: { hello: 'world' }, + cooking_options: { cat: 'hat' }, + cook_method: 'regular', + not_create_option: true, + image_sizes: {"http://foo.bar/image.png" => {"width" => 0, "height" => 222}} + }) } + + it "returns the appropriate options for posting" do + create_options = qp.create_options + + expect(create_options[:topic_id]).to eq(topic.id) + expect(create_options[:raw]).to eq('This post should be queued up') + expect(create_options[:reply_to_post_number]).to eq(1) + expect(create_options[:via_email]).to eq(true) + expect(create_options[:raw_email]).to eq('store_me') + expect(create_options[:auto_track]).to eq(true) + expect(create_options[:custom_fields]).to eq('hello' => 'world') + expect(create_options[:cooking_options]).to eq('cat' => 'hat') + expect(create_options[:cook_method]).to eq('regular') + expect(create_options[:not_create_option]).to eq(nil) + expect(create_options[:image_sizes]).to eq("http://foo.bar/image.png" => {"width" => 0, "height" => 222}) + end + + it "follows the correct workflow for approval" do + post = qp.approve!(admin) + + # Creates the post with the attributes + expect(post).to be_present + expect(post).to be_valid + expect(post.topic).to eq(topic) + + # Updates the QP record + expect(qp.approved_by).to eq(admin) + expect(qp.state).to eq(QueuedPost.states[:approved]) + expect(qp.approved_at).to be_present + + # We can't approve twice + expect(-> { qp.approve!(admin) }).to raise_error(QueuedPost::InvalidStateTransition) + + end + + it "follows the correct workflow for rejection" do + qp.reject!(admin) + + # Updates the QP record + expect(qp.rejected_by).to eq(admin) + expect(qp.state).to eq(QueuedPost.states[:rejected]) + expect(qp.rejected_at).to be_present + + # We can't reject twice + expect(-> { qp.reject!(admin) }).to raise_error(QueuedPost::InvalidStateTransition) + end + end + + context "creating a topic" do + let(:user) { Fabricate(:user) } + let(:admin) { Fabricate(:admin) } + let!(:category) { Fabricate(:category) } + + let(:qp) { QueuedPost.create(queue: 'new_topic', + state: QueuedPost.states[:new], + user_id: user.id, + raw: 'This post should be queued up', + post_options: { + title: 'This is the topic title to queue up', + archetype: 'regular', + category: category.id, + meta_data: {evil: 'trout'} + }) } + + + it "returns the appropriate options for creating a topic" do + create_options = qp.create_options + + expect(create_options[:category]).to eq(category.id) + expect(create_options[:archetype]).to eq('regular') + expect(create_options[:meta_data]).to eq('evil' => 'trout') + end + + it "creates the post and topic" do + topic_count, post_count = Topic.count, Post.count + post = qp.approve!(admin) + + expect(Topic.count).to eq(topic_count + 1) + expect(Post.count).to eq(post_count + 1) + + expect(post).to be_present + expect(post).to be_valid + + topic = post.topic + expect(topic).to be_present + expect(topic.category).to eq(category) + end + + it "doesn't create the post and topic" do + topic_count, post_count = Topic.count, Post.count + + qp.reject!(admin) + + expect(Topic.count).to eq(topic_count) + expect(Post.count).to eq(post_count) + end + end + +end From a5ee45ccbec1a8933f721c10fb14956d292c1ae7 Mon Sep 17 00:00:00 2001 From: Robin Ward Date: Thu, 26 Mar 2015 16:57:50 -0400 Subject: [PATCH 016/113] `PostEnqueuer` object to handle validation of enqueued posts --- app/models/post.rb | 4 +- app/models/queued_post.rb | 5 +- .../20150318143915_create_directory_items.rb | 2 +- db/migrate/20150323234856_add_muted_users.rb | 2 +- ... => 20150325190959_create_queued_posts.rb} | 6 +- lib/has_errors.rb | 31 ++++ lib/post_creator.rb | 136 +++++++++--------- lib/post_enqueuer.rb | 41 ++++++ lib/topic_creator.rb | 93 ++++++------ lib/validators/post_validator.rb | 8 +- spec/components/has_errors_spec.rb | 55 +++++++ spec/components/post_creator_spec.rb | 8 +- spec/components/post_enqueuer_spec.rb | 47 ++++++ spec/models/queued_post_spec.rb | 75 +++++----- 14 files changed, 348 insertions(+), 165 deletions(-) rename db/migrate/{20150325160959_create_queued_posts.rb => 20150325190959_create_queued_posts.rb} (71%) create mode 100644 lib/has_errors.rb create mode 100644 lib/post_enqueuer.rb create mode 100644 spec/components/has_errors_spec.rb create mode 100644 spec/components/post_enqueuer_spec.rb diff --git a/app/models/post.rb b/app/models/post.rb index 9e07e94f93..7e0e5e4559 100644 --- a/app/models/post.rb +++ b/app/models/post.rb @@ -369,7 +369,9 @@ class Post < ActiveRecord::Base problems end - def rebake!(opts={}) + def rebake!(opts=nil) + opts ||= {} + new_cooked = cook( raw, topic_id: topic_id, diff --git a/app/models/queued_post.rb b/app/models/queued_post.rb index a7b4dcec44..2b1cdacaf2 100644 --- a/app/models/queued_post.rb +++ b/app/models/queued_post.rb @@ -2,8 +2,6 @@ class QueuedPost < ActiveRecord::Base class InvalidStateTransition < StandardError; end; - serialize :post_options, JSON - belongs_to :user belongs_to :topic belongs_to :approved_by, class_name: "User" @@ -36,6 +34,8 @@ class QueuedPost < ActiveRecord::Base opts = {raw: raw} post_attributes.each {|a| opts[a] = post_options[a.to_s] } + opts[:cooking_options].symbolize_keys! if opts[:cooking_options] + opts[:topic_id] = topic_id if topic_id opts end @@ -52,6 +52,7 @@ class QueuedPost < ActiveRecord::Base end private + def post_attributes [QueuedPost.attributes_by_queue[:base], QueuedPost.attributes_by_queue[queue.to_sym]].flatten.compact end diff --git a/db/migrate/20150318143915_create_directory_items.rb b/db/migrate/20150318143915_create_directory_items.rb index 37f08336a1..088bfba0d0 100644 --- a/db/migrate/20150318143915_create_directory_items.rb +++ b/db/migrate/20150318143915_create_directory_items.rb @@ -1,6 +1,6 @@ class CreateDirectoryItems < ActiveRecord::Migration def change - create_table :directory_items do |t| + create_table :directory_items, force: true do |t| t.integer :period_type, null: false t.references :user, null: false t.integer :likes_received, null: false diff --git a/db/migrate/20150323234856_add_muted_users.rb b/db/migrate/20150323234856_add_muted_users.rb index 2e7c5048f6..8192d38284 100644 --- a/db/migrate/20150323234856_add_muted_users.rb +++ b/db/migrate/20150323234856_add_muted_users.rb @@ -1,6 +1,6 @@ class AddMutedUsers < ActiveRecord::Migration def change - create_table :muted_users do |t| + create_table :muted_users, force: true do |t| t.integer :user_id, null: false t.integer :muted_user_id, null: false t.timestamps diff --git a/db/migrate/20150325160959_create_queued_posts.rb b/db/migrate/20150325190959_create_queued_posts.rb similarity index 71% rename from db/migrate/20150325160959_create_queued_posts.rb rename to db/migrate/20150325190959_create_queued_posts.rb index 4941753ee2..9946a642b7 100644 --- a/db/migrate/20150325160959_create_queued_posts.rb +++ b/db/migrate/20150325190959_create_queued_posts.rb @@ -1,11 +1,11 @@ class CreateQueuedPosts < ActiveRecord::Migration def change - create_table :queued_posts do |t| + create_table :queued_posts, force: true do |t| t.string :queue, null: false t.integer :state, null: false t.integer :user_id, null: false t.text :raw, null: false - t.text :post_options, null: false + t.json :post_options, null: false t.integer :topic_id t.integer :approved_by_id t.timestamp :approved_at @@ -15,6 +15,6 @@ class CreateQueuedPosts < ActiveRecord::Migration end add_index :queued_posts, [:queue, :state, :created_at], name: 'by_queue_status' - add_index :queued_posts, [:topic, :queue, :state, :created_at], name: 'by_queue_status_topic' + add_index :queued_posts, [:topic_id, :queue, :state, :created_at], name: 'by_queue_status_topic' end end diff --git a/lib/has_errors.rb b/lib/has_errors.rb new file mode 100644 index 0000000000..9aae07e05c --- /dev/null +++ b/lib/has_errors.rb @@ -0,0 +1,31 @@ +# Helper functions for dealing with errors and objects that have +# child objects with errors +module HasErrors + + def errors + @errors ||= ActiveModel::Errors.new(self) + end + + def validate_child(obj) + return true if obj.valid? + add_errors_from(obj) + false + end + + def rollback_with!(obj, error) + obj.errors[:base] << error + rollback_from_errors!(obj) + end + + def rollback_from_errors!(obj) + add_errors_from(obj) + raise ActiveRecord::Rollback.new + end + + def add_errors_from(obj) + obj.errors.full_messages.each do |msg| + errors[:base] << msg unless errors[:base].include?(msg) + end + end + +end diff --git a/lib/post_creator.rb b/lib/post_creator.rb index d4627ca2ee..9660a96b9d 100644 --- a/lib/post_creator.rb +++ b/lib/post_creator.rb @@ -4,10 +4,12 @@ require_dependency 'rate_limiter' require_dependency 'topic_creator' require_dependency 'post_jobs_enqueuer' require_dependency 'distributed_mutex' +require_dependency 'has_errors' class PostCreator + include HasErrors - attr_reader :errors, :opts + attr_reader :opts # Acceptable options: # @@ -66,36 +68,66 @@ class PostCreator @guardian ||= Guardian.new(@user) end - def create + def valid? @topic = nil @post = nil if @user.suspended? && !skip_validations? - @errors = Post.new.errors - @errors.add(:base, I18n.t(:user_is_suspended)) - return + errors[:base] << I18n.t(:user_is_suspended) + return false end - transaction do - setup_topic - setup_post - rollback_if_host_spam_detected - plugin_callbacks - save_post - extract_links - store_unique_post_key - track_topic - update_topic_stats - update_topic_auto_close - update_user_counts - create_embedded_topic - - ensure_in_allowed_users if guardian.is_staff? - @post.advance_draft_sequence - @post.save_reply_relationships + if new_topic? + topic_creator = TopicCreator.new(@user, guardian, @opts) + return false unless skip_validations? || validate_child(topic_creator) + else + @topic = Topic.find_by(id: @opts[:topic_id]) + if (@topic.blank? || !guardian.can_create?(Post, @topic)) + errors[:base] << I18n.t(:topic_not_found) + return false + end end - if @post && @post.errors.empty? + setup_post + + return true if skip_validations? + if @post.has_host_spam? + @spam = true + errors[:base] << I18n.t(:spamming_host) + return false + end + + DiscourseEvent.trigger :before_create_post, @post + DiscourseEvent.trigger :validate_post, @post + + post_validator = Validators::PostValidator.new(skip_topic: true) + post_validator.validate(@post) + + valid = @post.errors.blank? + add_errors_from(@post) unless valid + valid + end + + def create + if valid? + transaction do + create_topic + save_post + extract_links + store_unique_post_key + track_topic + update_topic_stats + update_topic_auto_close + update_user_counts + create_embedded_topic + + ensure_in_allowed_users if guardian.is_staff? + @post.advance_draft_sequence + @post.save_reply_relationships + end + end + + if @post && errors.blank? publish PostAlerter.post_created(@post) unless @opts[:import_mode] @@ -164,16 +196,11 @@ class PostCreator { user: @user, limit_once_per: 24.hours, message_params: {domains: @post.linked_hosts.keys.join(', ')} } ) - elsif @post && !@post.errors.present? && !skip_validations? + elsif @post && errors.blank? && !skip_validations? SpamRulesEnforcer.enforce!(@post) end end - def plugin_callbacks - DiscourseEvent.trigger :before_create_post, @post - DiscourseEvent.trigger :validate_post, @post - end - def track_latest_on_category return unless @post && @post.errors.count == 0 && @topic && @topic.category_id @@ -191,27 +218,17 @@ class PostCreator private - def setup_topic - if new_topic? + def create_topic + return if @topic + begin topic_creator = TopicCreator.new(@user, guardian, @opts) - - begin - topic = topic_creator.create - @errors = topic_creator.errors - rescue ActiveRecord::Rollback => ex - # In the event of a rollback, grab the errors from the topic - @errors = topic_creator.errors - raise ex - end - else - topic = Topic.find_by(id: @opts[:topic_id]) - if (topic.blank? || !guardian.can_create?(Post, topic)) - @errors = Post.new.errors - @errors.add(:base, I18n.t(:topic_not_found)) - raise ActiveRecord::Rollback.new - end + @topic = topic_creator.create + rescue ActiveRecord::Rollback + add_errors_from(topic_creator) + return end - @topic = topic + @post.topic_id = @topic.id + @post.topic = @topic end def update_topic_stats @@ -232,9 +249,10 @@ class PostCreator def setup_post @opts[:raw] = TextCleaner.normalize_whitespaces(@opts[:raw]).gsub(/\s+\z/, "") - post = @topic.posts.new(raw: @opts[:raw], - user: @user, - reply_to_post_number: @opts[:reply_to_post_number]) + post = Post.new(raw: @opts[:raw], + topic_id: @topic.try(:id), + user: @user, + reply_to_post_number: @opts[:reply_to_post_number]) # Attributes we pass through to the post instance if present [:post_type, :no_bump, :cooking_options, :image_sizes, :acting_user, :invalidate_oneboxes, :cook_method, :via_email, :raw_email].each do |a| @@ -251,21 +269,9 @@ class PostCreator @post = post end - def rollback_if_host_spam_detected - return if skip_validations? - if @post.has_host_spam? - @post.errors.add(:base, I18n.t(:spamming_host)) - @errors = @post.errors - @spam = true - raise ActiveRecord::Rollback.new - end - end - def save_post - unless @post.save(validate: !skip_validations?) - @errors = @post.errors - raise ActiveRecord::Rollback.new - end + saved = @post.save(validate: !skip_validations?) + rollback_from_errors!(@post) unless saved end def store_unique_post_key diff --git a/lib/post_enqueuer.rb b/lib/post_enqueuer.rb new file mode 100644 index 0000000000..e6ba63f554 --- /dev/null +++ b/lib/post_enqueuer.rb @@ -0,0 +1,41 @@ +require_dependency 'topic_creator' +require_dependency 'queued_post' +require_dependency 'has_errors' + +class PostEnqueuer + include HasErrors + + def initialize(user, queue) + @user = user + @queue = queue + end + + def enqueue(args) + + queued_post = QueuedPost.new(queue: @queue, + state: QueuedPost.states[:new], + user_id: @user.id, + topic_id: args[:topic_id], + raw: args[:raw], + post_options: args[:post_options] || {}) + + validate_method = :"validate_#{@queue}" + if respond_to?(validate_method) + return unless send(validate_method, queued_post.create_options) + end + + add_errors_from(queued_post) unless queued_post.save + queued_post + end + + def validate_new_topic(create_options) + topic_creator = TopicCreator.new(@user, Guardian.new(@user), create_options) + validate_child(topic_creator) + end + + def validate_new_post(create_options) + post_creator = PostCreator.new(@user, create_options) + validate_child(post_creator) + end + +end diff --git a/lib/topic_creator.rb b/lib/topic_creator.rb index 40b6aee9dc..790da802ed 100644 --- a/lib/topic_creator.rb +++ b/lib/topic_creator.rb @@ -1,6 +1,7 @@ -class TopicCreator +require_dependency 'has_errors' - attr_accessor :errors +class TopicCreator + include HasErrors def self.create(user, guardian, opts) self.new(user, guardian, opts).create @@ -13,55 +14,52 @@ class TopicCreator @added_users = [] end + def valid? + topic = Topic.new(setup_topic_params) + validate_child(topic) + end + def create - @topic = Topic.new(setup_topic_params) + topic = Topic.new(setup_topic_params) - setup_auto_close_time - process_private_message - save_topic - create_warning - watch_topic + setup_auto_close_time(topic) + process_private_message(topic) + save_topic(topic) + create_warning(topic) + watch_topic(topic) - @topic + topic end private - def create_warning + def create_warning(topic) return unless @opts[:is_warning] # We can only attach warnings to PMs - unless @topic.private_message? - @topic.errors.add(:base, :warning_requires_pm) - @errors = @topic.errors - raise ActiveRecord::Rollback.new - end + rollback_with!(topic, :warning_requires_pm) unless topic.private_message? # Don't create it if there is more than one user - if @added_users.size != 1 - @topic.errors.add(:base, :too_many_users) - @errors = @topic.errors - raise ActiveRecord::Rollback.new - end + rollback_with!(topic, :too_many_users) if @added_users.size != 1 # Create a warning record - Warning.create(topic: @topic, user: @added_users.first, created_by: @user) + Warning.create(topic: topic, user: @added_users.first, created_by: @user) end - def watch_topic + def watch_topic(topic) unless @opts[:auto_track] == false - @topic.notifier.watch_topic!(@topic.user_id) + topic.notifier.watch_topic!(topic.user_id) end - user_ids = @topic.topic_allowed_users(true).pluck(:user_id) - user_ids += @topic.topic_allowed_groups(true).map { |t| t.group.users.pluck(:id) }.flatten + user_ids = topic.topic_allowed_users(true).pluck(:user_id) + user_ids += topic.topic_allowed_groups(true).map { |t| t.group.users.pluck(:id) }.flatten - user_ids.uniq.reject{ |id| id == @topic.user_id }.each do |user_id| - @topic.notifier.watch_topic!(user_id, nil) unless user_id == -1 + user_ids.uniq.reject{ |id| id == topic.user_id }.each do |user_id| + topic.notifier.watch_topic!(user_id, nil) unless user_id == -1 end - CategoryUser.auto_watch_new_topic(@topic) - CategoryUser.auto_track_new_topic(@topic) + CategoryUser.auto_watch_new_topic(topic) + CategoryUser.auto_track_new_topic(topic) end def setup_topic_params @@ -106,31 +104,28 @@ class TopicCreator end end - def setup_auto_close_time + def setup_auto_close_time(topic) return unless @opts[:auto_close_time].present? - return unless @guardian.can_moderate?(@topic) - @topic.set_auto_close(@opts[:auto_close_time], @user) + return unless @guardian.can_moderate?(topic) + topic.set_auto_close(@opts[:auto_close_time], @user) end - def process_private_message + def process_private_message(topic) return unless @opts[:archetype] == Archetype.private_message - @topic.subtype = TopicSubtype.user_to_user unless @topic.subtype + topic.subtype = TopicSubtype.user_to_user unless topic.subtype unless @opts[:target_usernames].present? || @opts[:target_group_names].present? - @topic.errors.add(:base, :no_user_selected) - @errors = @topic.errors - raise ActiveRecord::Rollback.new + rollback_with!(topic, :no_user_selected) end - add_users(@topic,@opts[:target_usernames]) - add_groups(@topic,@opts[:target_group_names]) - @topic.topic_allowed_users.build(user_id: @user.id) + add_users(topic,@opts[:target_usernames]) + add_groups(topic,@opts[:target_group_names]) + topic.topic_allowed_users.build(user_id: @user.id) end - def save_topic - unless @topic.save(validate: !@opts[:skip_validations]) - @errors = @topic.errors - raise ActiveRecord::Rollback.new + def save_topic(topic) + unless topic.save(validate: !@opts[:skip_validations]) + rollback_from_errors!(topic) end end @@ -146,16 +141,12 @@ class TopicCreator def add_groups(topic, groups) return unless groups Group.where(name: groups.split(',')).each do |group| - check_can_send_permission!(topic,group) + check_can_send_permission!(topic, group) topic.topic_allowed_groups.build(group_id: group.id) end end - def check_can_send_permission!(topic,item) - unless @guardian.can_send_private_message?(item) - topic.errors.add(:base, :cant_send_pm) - @errors = topic.errors - raise ActiveRecord::Rollback.new - end + def check_can_send_permission!(topic, obj) + rollback_with!(topic, :cant_send_pm) unless @guardian.can_send_private_message?(obj) end end diff --git a/lib/validators/post_validator.rb b/lib/validators/post_validator.rb index a5751ccade..fc16571476 100644 --- a/lib/validators/post_validator.rb +++ b/lib/validators/post_validator.rb @@ -1,6 +1,7 @@ require_dependency 'validators/stripped_length_validator' module Validators; end class Validators::PostValidator < ActiveModel::Validator + def validate(record) presence(record) unless Discourse.static_doc_topic_ids.include?(record.topic_id) && record.acting_user.try(:admin?) @@ -16,9 +17,12 @@ class Validators::PostValidator < ActiveModel::Validator end def presence(post) - [:raw,:topic_id].each do |attr_name| - post.errors.add(attr_name, :blank, options) if post.send(attr_name).blank? + + post.errors.add(:raw, :blank, options) if post.raw.blank? + unless options[:skip_topic] + post.errors.add(:topic_id, :blank, options) if post.topic_id.blank? end + if post.new_record? and post.user_id.nil? post.errors.add(:user_id, :blank, options) end diff --git a/spec/components/has_errors_spec.rb b/spec/components/has_errors_spec.rb new file mode 100644 index 0000000000..9ea4d1a068 --- /dev/null +++ b/spec/components/has_errors_spec.rb @@ -0,0 +1,55 @@ +require 'spec_helper' +require 'has_errors' + +describe HasErrors do + + class ErrorTestClass + include HasErrors + end + + let(:error_test) { ErrorTestClass.new } + let(:title_error) { "Title can't be blank" } + + # No title is invalid + let(:invalid_topic) { Fabricate.build(:topic, title: '') } + + it "has no errors by default" do + expect(error_test.errors).to be_blank + end + + context "validate_child" do + it "adds the errors from invalid AR objects" do + expect(error_test.validate_child(invalid_topic)).to eq(false) + expect(error_test.errors).to be_present + expect(error_test.errors[:base]).to include(title_error) + end + + it "doesn't add the errors from valid AR objects" do + topic = Fabricate.build(:topic) + expect(error_test.validate_child(topic)).to eq(true) + expect(error_test.errors).to be_blank + end + end + + context "rollback_from_errors!" do + it "triggers a rollback" do + invalid_topic.valid? + + expect(-> { error_test.rollback_from_errors!(invalid_topic) }).to raise_error(ActiveRecord::Rollback) + expect(error_test.errors).to be_present + expect(error_test.errors[:base]).to include(title_error) + end + end + + context "rollback_with_error!" do + it "triggers a rollback" do + + expect(-> { + error_test.rollback_with!(invalid_topic, :custom_error) + }).to raise_error(ActiveRecord::Rollback) + expect(error_test.errors).to be_present + expect(error_test.errors[:base]).to include(:custom_error) + end + end + +end diff --git a/spec/components/post_creator_spec.rb b/spec/components/post_creator_spec.rb index 77d658f08c..7db6eb829e 100644 --- a/spec/components/post_creator_spec.rb +++ b/spec/components/post_creator_spec.rb @@ -311,7 +311,8 @@ describe PostCreator do it "does not create the post" do GroupMessage.stubs(:create) - creator.create + post = creator.create + expect(creator.errors).to be_present expect(creator.spam?).to eq(true) end @@ -521,7 +522,7 @@ describe PostCreator do it 'can save a post' do creator = PostCreator.new(user, raw: 'q', title: 'q', skip_validations: true) creator.create - expect(creator.errors).to eq(nil) + expect(creator.errors).to be_blank end end @@ -573,7 +574,8 @@ describe PostCreator do end it "doesn't strip starting whitespaces" do - post = PostCreator.new(user, { title: "testing whitespace stripping", raw: " <-- whitespaces --> " }).create + pc = PostCreator.new(user, { title: "testing whitespace stripping", raw: " <-- whitespaces --> " }) + post = pc.create expect(post.raw).to eq(" <-- whitespaces -->") end diff --git a/spec/components/post_enqueuer_spec.rb b/spec/components/post_enqueuer_spec.rb new file mode 100644 index 0000000000..0c9d5a1dee --- /dev/null +++ b/spec/components/post_enqueuer_spec.rb @@ -0,0 +1,47 @@ +require 'spec_helper' +require_dependency 'post_enqueuer' + +describe PostEnqueuer do + + let(:user) { Fabricate(:user) } + + context 'with valid arguments' do + let(:topic) { Fabricate(:topic) } + let(:enqueuer) { PostEnqueuer.new(user, 'new_post') } + + it 'enqueues the post' do + qp = enqueuer.enqueue(raw: 'This should be enqueued', + topic_id: topic.id, + post_options: { reply_to_post_number: 1 }) + + expect(enqueuer.errors).to be_blank + expect(qp).to be_present + expect(qp.topic).to eq(topic) + expect(qp.user).to eq(user) + end + end + + context "topic validations" do + let(:enqueuer) { PostEnqueuer.new(user, 'new_topic') } + + it "doesn't enqueue the post" do + qp = enqueuer.enqueue(raw: 'This should not be enqueued', + post_options: { title: 'too short' }) + + expect(enqueuer.errors).to be_present + expect(qp).to be_blank + end + end + + context "post validations" do + let(:enqueuer) { PostEnqueuer.new(user, 'new_post') } + + it "doesn't enqueue the post" do + qp = enqueuer.enqueue(raw: 'too short') + + expect(enqueuer.errors).to be_present + expect(qp).to be_blank + end + end + +end diff --git a/spec/models/queued_post_spec.rb b/spec/models/queued_post_spec.rb index 7fa07b1991..521819fef5 100644 --- a/spec/models/queued_post_spec.rb +++ b/spec/models/queued_post_spec.rb @@ -19,7 +19,7 @@ describe QueuedPost do auto_track: true, custom_fields: { hello: 'world' }, cooking_options: { cat: 'hat' }, - cook_method: 'regular', + cook_method: Post.cook_methods[:raw_html], not_create_option: true, image_sizes: {"http://foo.bar/image.png" => {"width" => 0, "height" => 222}} }) } @@ -34,8 +34,8 @@ describe QueuedPost do expect(create_options[:raw_email]).to eq('store_me') expect(create_options[:auto_track]).to eq(true) expect(create_options[:custom_fields]).to eq('hello' => 'world') - expect(create_options[:cooking_options]).to eq('cat' => 'hat') - expect(create_options[:cook_method]).to eq('regular') + expect(create_options[:cooking_options]).to eq(cat: 'hat') + expect(create_options[:cook_method]).to eq(Post.cook_methods[:raw_html]) expect(create_options[:not_create_option]).to eq(nil) expect(create_options[:image_sizes]).to eq("http://foo.bar/image.png" => {"width" => 0, "height" => 222}) end @@ -47,6 +47,7 @@ describe QueuedPost do expect(post).to be_present expect(post).to be_valid expect(post.topic).to eq(topic) + expect(post.custom_fields['hello']).to eq('world') # Updates the QP record expect(qp.approved_by).to eq(admin) @@ -74,50 +75,52 @@ describe QueuedPost do context "creating a topic" do let(:user) { Fabricate(:user) } let(:admin) { Fabricate(:admin) } - let!(:category) { Fabricate(:category) } - let(:qp) { QueuedPost.create(queue: 'new_topic', - state: QueuedPost.states[:new], - user_id: user.id, - raw: 'This post should be queued up', - post_options: { - title: 'This is the topic title to queue up', - archetype: 'regular', - category: category.id, - meta_data: {evil: 'trout'} - }) } + context "with a valid topic" do + let!(:category) { Fabricate(:category) } + let(:qp) { QueuedPost.create(queue: 'new_topic', + state: QueuedPost.states[:new], + user_id: user.id, + raw: 'This post should be queued up', + post_options: { + title: 'This is the topic title to queue up', + archetype: 'regular', + category: category.id, + meta_data: {evil: 'trout'} + }) } - it "returns the appropriate options for creating a topic" do - create_options = qp.create_options + it "returns the appropriate options for creating a topic" do + create_options = qp.create_options - expect(create_options[:category]).to eq(category.id) - expect(create_options[:archetype]).to eq('regular') - expect(create_options[:meta_data]).to eq('evil' => 'trout') - end + expect(create_options[:category]).to eq(category.id) + expect(create_options[:archetype]).to eq('regular') + expect(create_options[:meta_data]).to eq('evil' => 'trout') + end - it "creates the post and topic" do - topic_count, post_count = Topic.count, Post.count - post = qp.approve!(admin) + it "creates the post and topic" do + topic_count, post_count = Topic.count, Post.count + post = qp.approve!(admin) - expect(Topic.count).to eq(topic_count + 1) - expect(Post.count).to eq(post_count + 1) + expect(Topic.count).to eq(topic_count + 1) + expect(Post.count).to eq(post_count + 1) - expect(post).to be_present - expect(post).to be_valid + expect(post).to be_present + expect(post).to be_valid - topic = post.topic - expect(topic).to be_present - expect(topic.category).to eq(category) - end + topic = post.topic + expect(topic).to be_present + expect(topic.category).to eq(category) + end - it "doesn't create the post and topic" do - topic_count, post_count = Topic.count, Post.count + it "doesn't create the post and topic" do + topic_count, post_count = Topic.count, Post.count - qp.reject!(admin) + qp.reject!(admin) - expect(Topic.count).to eq(topic_count) - expect(Post.count).to eq(post_count) + expect(Topic.count).to eq(topic_count) + expect(Post.count).to eq(post_count) + end end end From 19a9a8b4088b56ff6996c0508ee7571d25c06c4e Mon Sep 17 00:00:00 2001 From: Robin Ward Date: Tue, 31 Mar 2015 12:58:56 -0400 Subject: [PATCH 017/113] `NewPostManager` determines whether to queue a post or not --- .travis.yml | 3 + app/controllers/application_controller.rb | 13 +-- app/controllers/posts_controller.rb | 83 ++++++++----------- app/serializers/new_post_result_serializer.rb | 39 +++++++++ lib/discourse.rb | 2 - lib/discourse_event.rb | 4 + lib/guardian/post_guardian.rb | 1 + lib/new_post_manager.rb | 66 +++++++++++++++ lib/new_post_result.rb | 30 +++++++ lib/post_creator.rb | 7 ++ lib/post_enqueuer.rb | 1 - spec/components/new_post_manager_spec.rb | 83 +++++++++++++++++++ spec/components/new_post_result_spec.rb | 12 +++ spec/components/post_creator_spec.rb | 33 ++++++++ spec/controllers/posts_controller_spec.rb | 60 ++++++++------ 15 files changed, 354 insertions(+), 83 deletions(-) create mode 100644 app/serializers/new_post_result_serializer.rb create mode 100644 lib/new_post_manager.rb create mode 100644 lib/new_post_result.rb create mode 100644 spec/components/new_post_manager_spec.rb create mode 100644 spec/components/new_post_result_spec.rb diff --git a/.travis.yml b/.travis.yml index cf7aa1b1be..64d25f1d16 100644 --- a/.travis.yml +++ b/.travis.yml @@ -8,6 +8,9 @@ env: - "RAILS_MASTER=1" - "RAILS_MASTER=0" +addons: + postgresql: 9.3 + matrix: allow_failures: - rvm: 2.0.0 diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index afaf3ef11e..3b76392c28 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -198,9 +198,9 @@ class ApplicationController < ActionController::Base @guardian ||= Guardian.new(current_user) end - def serialize_data(obj, serializer, opts={}) + def serialize_data(obj, serializer, opts=nil) # If it's an array, apply the serializer as an each_serializer to the elements - serializer_opts = {scope: guardian}.merge!(opts) + serializer_opts = {scope: guardian}.merge!(opts || {}) if obj.respond_to?(:to_ary) serializer_opts[:each_serializer] = serializer ActiveModel::ArraySerializer.new(obj.to_ary, serializer_opts).as_json @@ -213,12 +213,13 @@ class ApplicationController < ActionController::Base # 20% slower than calling MultiJSON.dump ourselves. I'm not sure why # Rails doesn't call MultiJson.dump when you pass it json: obj but # it seems we don't need whatever Rails is doing. - def render_serialized(obj, serializer, opts={}) - render_json_dump(serialize_data(obj, serializer, opts)) + def render_serialized(obj, serializer, opts=nil) + render_json_dump(serialize_data(obj, serializer, opts), opts) end - def render_json_dump(obj) - render json: MultiJson.dump(obj) + def render_json_dump(obj, opts=nil) + opts ||= {} + render json: MultiJson.dump(obj), status: opts[:status] || 200 end def can_cache_content? diff --git a/app/controllers/posts_controller.rb b/app/controllers/posts_controller.rb index 0ebb3a766c..e632b8f277 100644 --- a/app/controllers/posts_controller.rb +++ b/app/controllers/posts_controller.rb @@ -1,6 +1,8 @@ +require_dependency 'new_post_manager' require_dependency 'post_creator' require_dependency 'post_destroyer' require_dependency 'distributed_memoizer' +require_dependency 'new_post_result_serializer' class PostsController < ApplicationController @@ -76,49 +78,21 @@ class PostsController < ApplicationController end def create - params = create_params + @manager_params = create_params + manager = NewPostManager.new(current_user, @manager_params) - key = params_key(params) - error_json = nil - - if (is_api?) - payload = DistributedMemoizer.memoize(key, 120) do - success, json = create_post(params) - unless success - error_json = json - raise Discourse::InvalidPost - end - json + if is_api? + memoized_payload = DistributedMemoizer.memoize(signature_for(@manager_params), 120) do + result = manager.perform + MultiJson.dump(serialize_data(result, NewPostResultSerializer, root: false)) end + + parsed_payload = JSON.parse(memoized_payload) + backwards_compatible_json(parsed_payload, parsed_payload['success']) else - success, payload = create_post(params) - unless success - error_json = payload - raise Discourse::InvalidPost - end - end - - render json: payload - - rescue Discourse::InvalidPost - render json: error_json, status: 422 - end - - def create_post(params) - post_creator = PostCreator.new(current_user, params) - post = post_creator.create - - if post_creator.errors.present? - # If the post was spam, flag all the user's posts as spam - current_user.flag_linked_posts_as_spam if post_creator.spam? - [false, MultiJson.dump(errors: post_creator.errors.full_messages)] - - else - DiscourseEvent.trigger(:topic_created, post.topic, params, current_user) unless params[:topic_id] - DiscourseEvent.trigger(:post_created, post, params, current_user) - post_serializer = PostSerializer.new(post, scope: guardian, root: false) - post_serializer.draft_sequence = DraftSequence.current(current_user, post.topic.draft_key) - [true, MultiJson.dump(post_serializer)] + result = manager.perform + json = serialize_data(result, NewPostResultSerializer, root: false) + backwards_compatible_json(json, result.success?) end end @@ -358,6 +332,15 @@ class PostsController < ApplicationController protected + # We can't break the API for making posts. The new, queue supporting API + # doesn't return the post as the root JSON object, but as a nested object. + # If a param is present it uses that result structure. + def backwards_compatible_json(json_obj, success) + json_obj = json_obj[:post] || json_obj['post'] unless params[:nested_post] + render json: json_obj, status: (!!success) ? 200 : 422 + end + + def find_post_revision_from_params post_id = params[:id] || params[:post_id] revision = params[:revision].to_i @@ -411,15 +394,6 @@ class PostsController < ApplicationController .limit(opts[:limit]) end - def params_key(params) - "post##" << Digest::SHA1.hexdigest(params - .to_a - .concat([["user", current_user.id]]) - .sort{|x,y| x[0] <=> y[0]}.join do |x,y| - "#{x}:#{y}" - end) - end - def create_params permitted = [ :raw, @@ -454,6 +428,8 @@ class PostsController < ApplicationController if current_user.staff? params.permit(:is_warning) result[:is_warning] = (params[:is_warning] == "true") + else + result[:is_warning] = false end PostRevisor.tracked_topic_fields.keys.each do |f| @@ -469,6 +445,15 @@ class PostsController < ApplicationController result end + def signature_for(args) + "post##" << Digest::SHA1.hexdigest(args + .to_a + .concat([["user", current_user.id]]) + .sort{|x,y| x[0] <=> y[0]}.join do |x,y| + "#{x}:#{y}" + end) + end + def too_late_to(action, post) !guardian.send("can_#{action}?", post) && post.user_id == current_user.id && post.edit_time_limit_expired? end diff --git a/app/serializers/new_post_result_serializer.rb b/app/serializers/new_post_result_serializer.rb new file mode 100644 index 0000000000..ff7330cdd4 --- /dev/null +++ b/app/serializers/new_post_result_serializer.rb @@ -0,0 +1,39 @@ +require_dependency 'application_serializer' + +class NewPostResultSerializer < ApplicationSerializer + attributes :action, + :post, + :errors, + :success + + def post + post_serializer = PostSerializer.new(object.post, scope: scope, root: false) + post_serializer.draft_sequence = DraftSequence.current(scope.user, object.post.topic.draft_key) + post_serializer.as_json + end + + def include_post? + object.post.present? + end + + def success + true + end + + def include_success? + @object.success? + end + + def errors + object.errors.full_messages + end + + def include_errors? + !object.errors.empty? + end + + def action + object.action + end + +end diff --git a/lib/discourse.rb b/lib/discourse.rb index 9c37ed3505..1bafaea847 100644 --- a/lib/discourse.rb +++ b/lib/discourse.rb @@ -47,8 +47,6 @@ module Discourse # When ImageMagick is missing class ImageMagickMissing < StandardError; end - class InvalidPost < StandardError; end - # When read-only mode is enabled class ReadOnly < StandardError; end diff --git a/lib/discourse_event.rb b/lib/discourse_event.rb index c0ec064d3d..2c1110a632 100644 --- a/lib/discourse_event.rb +++ b/lib/discourse_event.rb @@ -17,6 +17,10 @@ module DiscourseEvent events[event_name] << block end + def self.off(event_name, &block) + events[event_name].delete(block) + end + def self.clear @events = nil end diff --git a/lib/guardian/post_guardian.rb b/lib/guardian/post_guardian.rb index 2e8031be78..4c0292048b 100644 --- a/lib/guardian/post_guardian.rb +++ b/lib/guardian/post_guardian.rb @@ -1,5 +1,6 @@ #mixin for all guardian methods dealing with post permissions module PostGuardian + # Can the user act on the post in a particular way. # taken_actions = the list of actions the user has already taken def post_can_act?(post, action_key, opts={}) diff --git a/lib/new_post_manager.rb b/lib/new_post_manager.rb new file mode 100644 index 0000000000..323acaa7ed --- /dev/null +++ b/lib/new_post_manager.rb @@ -0,0 +1,66 @@ +require_dependency 'post_creator' +require_dependency 'new_post_result' +require_dependency 'post_enqueuer' + +# Determines what actions should be taken with new posts. +# +# The default action is to create the post, but this can be extended +# with `NewPostManager.add_handler` to take other approaches depending +# on the user or input. +class NewPostManager + + attr_reader :user, :args + + def self.handlers + @handlers ||= Set.new + end + + def self.add_handler(&block) + handlers << block + end + + def initialize(user, args) + @user = user + @args = args + end + + def perform + + # Perform handlers until one returns a result + handled = NewPostManager.handlers.any? do |handler| + result = handler.call(self) + return result if result + + false + end + + perform_create_post unless handled + end + + # Enqueue this post in a queue + def enqueue(queue) + result = NewPostResult.new(:enqueued) + enqueuer = PostEnqueuer.new(@user, queue) + post = enqueuer.enqueue(@args) + + result.check_errors_from(enqueuer) + result + end + + def perform_create_post + result = NewPostResult.new(:create_post) + + creator = PostCreator.new(@user, @args) + post = creator.create + result.check_errors_from(creator) + + if result.success? + result.post = post + else + @user.flag_linked_posts_as_spam if creator.spam? + end + + result + end + +end diff --git a/lib/new_post_result.rb b/lib/new_post_result.rb new file mode 100644 index 0000000000..1e8bdffb04 --- /dev/null +++ b/lib/new_post_result.rb @@ -0,0 +1,30 @@ +require_dependency 'has_errors' + +class NewPostResult + include HasErrors + + attr_reader :action + attr_accessor :post + + def initialize(action, success=false) + @action = action + @success = success + end + + def check_errors_from(obj) + if obj.errors.empty? + @success = true + else + add_errors_from(obj) + end + end + + def success? + @success + end + + def failed? + !@success + end + +end diff --git a/lib/post_creator.rb b/lib/post_creator.rb index 9660a96b9d..7183793794 100644 --- a/lib/post_creator.rb +++ b/lib/post_creator.rb @@ -134,6 +134,8 @@ class PostCreator track_latest_on_category enqueue_jobs BadgeGranter.queue_badge_grant(Badge::Trigger::PostRevision, post: @post) + + trigger_after_events(@post) end if @post || @spam @@ -169,6 +171,11 @@ class PostCreator protected + def trigger_after_events(post) + DiscourseEvent.trigger(:topic_created, post.topic, @opts, @user) unless @opts[:topic_id] + DiscourseEvent.trigger(:post_created, post, @opts, @user) + end + def transaction(&blk) Post.transaction do if new_topic? diff --git a/lib/post_enqueuer.rb b/lib/post_enqueuer.rb index e6ba63f554..78242d405d 100644 --- a/lib/post_enqueuer.rb +++ b/lib/post_enqueuer.rb @@ -11,7 +11,6 @@ class PostEnqueuer end def enqueue(args) - queued_post = QueuedPost.new(queue: @queue, state: QueuedPost.states[:new], user_id: @user.id, diff --git a/spec/components/new_post_manager_spec.rb b/spec/components/new_post_manager_spec.rb new file mode 100644 index 0000000000..523d8652a0 --- /dev/null +++ b/spec/components/new_post_manager_spec.rb @@ -0,0 +1,83 @@ +require 'spec_helper' +require 'new_post_manager' + +describe NewPostManager do + + let(:topic) { Fabricate(:topic) } + + context "default action" do + it "creates the post by default" do + manager = NewPostManager.new(topic.user, raw: 'this is a new post', topic_id: topic.id) + result = manager.perform + + expect(result.action).to eq(:create_post) + expect(result).to be_success + expect(result.post).to be_present + expect(result.post).to be_a(Post) + end + end + + context "extensibility" do + + before do + @counter = 0 + + @counter_handler = lambda do |manager| + result = nil + if manager.args[:raw] == 'this post increases counter' + @counter += 1 + result = NewPostResult.new(:counter, true) + end + + result + end + + @queue_handler = -> (manager) { manager.args[:raw] =~ /queue me/ ? manager.enqueue('test') : nil } + + NewPostManager.add_handler(&@counter_handler) + NewPostManager.add_handler(&@queue_handler) + end + + after do + NewPostManager.handlers.delete(@counter_handler) + NewPostManager.handlers.delete(@queue_handler) + end + + it "calls custom handlers" do + manager = NewPostManager.new(topic.user, raw: 'this post increases counter', topic_id: topic.id) + + result = manager.perform + + expect(result.action).to eq(:counter) + expect(result).to be_success + expect(result.post).to be_blank + expect(@counter).to be(1) + expect(QueuedPost.count).to be(0) + end + + it "calls custom enqueuing handlers" do + manager = NewPostManager.new(topic.user, raw: 'to the handler I say enqueue me!', topic_id: topic.id) + + result = manager.perform + + expect(result.action).to eq(:enqueued) + expect(result).to be_success + expect(result.post).to be_blank + expect(QueuedPost.count).to be(1) + expect(@counter).to be(0) + end + + it "if nothing returns a result it creates a post" do + manager = NewPostManager.new(topic.user, raw: 'this is a new post', topic_id: topic.id) + + result = manager.perform + + expect(result.action).to eq(:create_post) + expect(result).to be_success + expect(result.post).to be_present + expect(@counter).to be(0) + end + + end + +end diff --git a/spec/components/new_post_result_spec.rb b/spec/components/new_post_result_spec.rb new file mode 100644 index 0000000000..3c555e7931 --- /dev/null +++ b/spec/components/new_post_result_spec.rb @@ -0,0 +1,12 @@ +require 'spec_helper' +require 'new_post_result' + +describe NewPostResult do + + it "fails by default" do + result = NewPostResult.new(:eviltrout) + expect(result.failed?).to eq(true) + expect(result.success?).to eq(false) + end + +end diff --git a/spec/components/post_creator_spec.rb b/spec/components/post_creator_spec.rb index 7db6eb829e..950af81885 100644 --- a/spec/components/post_creator_spec.rb +++ b/spec/components/post_creator_spec.rb @@ -579,4 +579,37 @@ describe PostCreator do expect(post.raw).to eq(" <-- whitespaces -->") end + context "events" do + let(:topic) { Fabricate(:topic, user: user) } + + before do + @posts_created = 0 + @topics_created = 0 + + @increase_posts = -> (post, opts, user) { @posts_created += 1 } + @increase_topics = -> (topic, opts, user) { @topics_created += 1 } + DiscourseEvent.on(:post_created, &@increase_posts) + DiscourseEvent.on(:topic_created, &@increase_topics) + end + + after do + DiscourseEvent.off(:post_created, &@increase_posts) + DiscourseEvent.off(:topic_created, &@increase_topics) + end + + it "fires boths event when creating a topic" do + pc = PostCreator.new(user, raw: 'this is the new content for my topic', title: 'this is my new topic title') + post = pc.create + expect(@posts_created).to eq(1) + expect(@topics_created).to eq(1) + end + + it "fires only the post event when creating a post" do + pc = PostCreator.new(user, topic_id: topic.id, raw: 'this is the new content for my post') + post = pc.create + expect(@posts_created).to eq(1) + expect(@topics_created).to eq(0) + end + end + end diff --git a/spec/controllers/posts_controller_spec.rb b/spec/controllers/posts_controller_spec.rb index 3faf22d2ae..41c114ab55 100644 --- a/spec/controllers/posts_controller_spec.rb +++ b/spec/controllers/posts_controller_spec.rb @@ -449,7 +449,7 @@ describe PostsController do include_examples 'action requires login', :post, :create context 'api' do - it 'allows dupes through' do + it 'memoizes duplicate requests' do raw = "this is a test post 123 #{SecureRandom.hash}" title = "this is a title #{SecureRandom.hash}" @@ -478,16 +478,25 @@ describe PostsController do end it 'creates the post' do - PostCreator.any_instance.expects(:create).returns(new_post) - - # Make sure our extensibility points are triggered - DiscourseEvent.expects(:trigger).with(:topic_created, new_post.topic, anything, user).once - DiscourseEvent.expects(:trigger).with(:post_created, new_post, anything, user).once - - xhr :post, :create, {raw: 'test'} + xhr :post, :create, {raw: 'this is the test content', title: 'this is the test title for the topic'} expect(response).to be_success - expect(::JSON.parse(response.body)).to be_present + parsed = ::JSON.parse(response.body) + + # Deprecated structure + expect(parsed['post']).to be_blank + expect(parsed['cooked']).to be_present + end + + it "returns the nested post with a param" do + xhr :post, :create, {raw: 'this is the test content', + title: 'this is the test title for the topic', + nested_post: true} + + expect(response).to be_success + parsed = ::JSON.parse(response.body) + expect(parsed['post']).to be_present + expect(parsed['post']['cooked']).to be_present end it 'protects against dupes' do @@ -528,72 +537,73 @@ describe PostsController do context "parameters" do - let(:post_creator) { mock } - before do - post_creator.expects(:create).returns(new_post) - post_creator.stubs(:errors).returns(nil) + # Just for performance, no reason to actually perform for these + # tests. + NewPostManager.stubs(:perform).returns(NewPostResult) end it "passes raw through" do - PostCreator.expects(:new).with(user, has_entries('raw' => 'hello')).returns(post_creator) xhr :post, :create, {raw: 'hello'} + expect(assigns(:manager_params)['raw']).to eq('hello') end it "passes title through" do - PostCreator.expects(:new).with(user, has_entries('title' => 'new topic title')).returns(post_creator) xhr :post, :create, {raw: 'hello', title: 'new topic title'} + expect(assigns(:manager_params)['title']).to eq('new topic title') end it "passes topic_id through" do - PostCreator.expects(:new).with(user, has_entries('topic_id' => '1234')).returns(post_creator) xhr :post, :create, {raw: 'hello', topic_id: 1234} + expect(assigns(:manager_params)['topic_id']).to eq('1234') end it "passes archetype through" do - PostCreator.expects(:new).with(user, has_entries('archetype' => 'private_message')).returns(post_creator) xhr :post, :create, {raw: 'hello', archetype: 'private_message'} + expect(assigns(:manager_params)['archetype']).to eq('private_message') end it "passes category through" do - PostCreator.expects(:new).with(user, has_entries('category' => 'cool')).returns(post_creator) xhr :post, :create, {raw: 'hello', category: 'cool'} + expect(assigns(:manager_params)['category']).to eq('cool') end it "passes target_usernames through" do - PostCreator.expects(:new).with(user, has_entries('target_usernames' => 'evil,trout')).returns(post_creator) xhr :post, :create, {raw: 'hello', target_usernames: 'evil,trout'} + expect(assigns(:manager_params)['target_usernames']).to eq('evil,trout') end it "passes reply_to_post_number through" do - PostCreator.expects(:new).with(user, has_entries('reply_to_post_number' => '6789')).returns(post_creator) xhr :post, :create, {raw: 'hello', reply_to_post_number: 6789} + expect(assigns(:manager_params)['reply_to_post_number']).to eq('6789') end it "passes image_sizes through" do - PostCreator.expects(:new).with(user, has_entries('image_sizes' => {'width' => '100', 'height' => '200'})).returns(post_creator) xhr :post, :create, {raw: 'hello', image_sizes: {width: '100', height: '200'}} + expect(assigns(:manager_params)['image_sizes']['width']).to eq('100') + expect(assigns(:manager_params)['image_sizes']['height']).to eq('200') end it "passes meta_data through" do - PostCreator.expects(:new).with(user, has_entries('meta_data' => {'xyz' => 'abc'})).returns(post_creator) xhr :post, :create, {raw: 'hello', meta_data: {xyz: 'abc'}} + expect(assigns(:manager_params)['meta_data']['xyz']).to eq('abc') end context "is_warning" do it "doesn't pass `is_warning` through if you're not staff" do - PostCreator.expects(:new).with(user, Not(has_entries('is_warning' => true))).returns(post_creator) xhr :post, :create, {raw: 'hello', archetype: 'private_message', is_warning: 'true'} + expect(assigns(:manager_params)['is_warning']).to eq(false) end it "passes `is_warning` through if you're staff" do - PostCreator.expects(:new).with(moderator, has_entries('is_warning' => true)).returns(post_creator) + log_in(:moderator) xhr :post, :create, {raw: 'hello', archetype: 'private_message', is_warning: 'true'} + expect(assigns(:manager_params)['is_warning']).to eq(true) end it "passes `is_warning` as false through if you're staff" do - PostCreator.expects(:new).with(moderator, has_entries('is_warning' => false)).returns(post_creator) xhr :post, :create, {raw: 'hello', archetype: 'private_message', is_warning: 'false'} + expect(assigns(:manager_params)['is_warning']).to eq(false) end end From 22ffcba8e6ac9f675c3ff619b2c6aef300fb78e0 Mon Sep 17 00:00:00 2001 From: Robin Ward Date: Wed, 1 Apr 2015 14:18:46 -0400 Subject: [PATCH 018/113] Convert `Discourse.Post` to ES6 and use Store model - Includes acceptance tests for composer (post, edit) - Supports acceptance testing of bootbox --- .../discourse/adapters/post.js.es6 | 11 + .../discourse/controllers/composer.js.es6 | 5 +- .../discourse/lib/Markdown.Editor.js | 14 +- .../discourse/models/composer.js.es6 | 190 ++++---- .../models/{_post.js => post.js.es6} | 173 +++---- app/assets/javascripts/main_include.js | 1 + app/controllers/posts_controller.rb | 6 +- .../acceptance/composer-test.js.es6 | 117 +++++ .../acceptance/header-test-staff.js.es6 | 6 +- test/javascripts/fixtures/post.js.es6 | 4 + .../fixtures/session-fixtures.js.es6 | 4 + test/javascripts/fixtures/topic.js.es6 | 2 +- .../helpers/create-pretender.js.es6 | 56 ++- test/javascripts/helpers/qunit-helpers.js.es6 | 46 +- test/javascripts/models/composer-test.js.es6 | 87 ++-- test/javascripts/test_helper.js | 6 +- vendor/assets/javascripts/bootbox.js | 8 +- vendor/assets/javascripts/bootstrap-modal.js | 438 +++++++++++------- vendor/assets/javascripts/ember-cloaking.js | 13 +- 19 files changed, 747 insertions(+), 440 deletions(-) create mode 100644 app/assets/javascripts/discourse/adapters/post.js.es6 rename app/assets/javascripts/discourse/models/{_post.js => post.js.es6} (78%) create mode 100644 test/javascripts/acceptance/composer-test.js.es6 create mode 100644 test/javascripts/fixtures/post.js.es6 create mode 100644 test/javascripts/fixtures/session-fixtures.js.es6 diff --git a/app/assets/javascripts/discourse/adapters/post.js.es6 b/app/assets/javascripts/discourse/adapters/post.js.es6 new file mode 100644 index 0000000000..1705a07e60 --- /dev/null +++ b/app/assets/javascripts/discourse/adapters/post.js.es6 @@ -0,0 +1,11 @@ +import RestAdapter from 'discourse/adapters/rest'; + +export default RestAdapter.extend({ + + // GET /posts doesn't include a type + find(store, type, findArgs) { + return this._super(store, type, findArgs).then(function(result) { + return {post: result}; + }); + } +}); diff --git a/app/assets/javascripts/discourse/controllers/composer.js.es6 b/app/assets/javascripts/discourse/controllers/composer.js.es6 index cc61d4a3b2..2a2f13cf4a 100644 --- a/app/assets/javascripts/discourse/controllers/composer.js.es6 +++ b/app/assets/javascripts/discourse/controllers/composer.js.es6 @@ -61,7 +61,8 @@ export default DiscourseController.extend({ if (postId) { this.set('model.loading', true); const composer = this; - return Discourse.Post.load(postId).then(function(post) { + + return this.store.find('post', postId).then(function(post) { const quote = Discourse.Quote.build(post, post.get("raw")); composer.appendBlockAtCursor(quote); composer.set('model.loading', false); @@ -412,7 +413,7 @@ export default DiscourseController.extend({ composerModel.set('topic', opts.topic); } } else { - composerModel = composerModel || Discourse.Composer.create(); + composerModel = composerModel || Discourse.Composer.create({ store: this.store }); composerModel.open(opts); } diff --git a/app/assets/javascripts/discourse/lib/Markdown.Editor.js b/app/assets/javascripts/discourse/lib/Markdown.Editor.js index 7c6ef13587..568b486dba 100644 --- a/app/assets/javascripts/discourse/lib/Markdown.Editor.js +++ b/app/assets/javascripts/discourse/lib/Markdown.Editor.js @@ -323,7 +323,13 @@ // Adds a listener callback to a DOM element which is fired on a specified // event. util.addEvent = function (elem, event, listener) { - elem.addEventListener(event, listener, false); + var wrapped = function() { + var wrappedArgs = Array.prototype.slice(arguments); + Ember.run(function() { + listener.call(this, wrappedArgs); + }); + }; + elem.addEventListener(event, wrapped, false); }; @@ -904,7 +910,7 @@ // TODO allow us to inject this in (its our debouncer) var debounce = function(func,wait,trickle) { var timeout = null; - return function(){ + return function() { var context = this; var args = arguments; @@ -924,8 +930,8 @@ currentWait = wait; } - if (timeout) { clearTimeout(timeout); } - timeout = setTimeout(later, currentWait); + if (timeout) { Ember.run.cancel(timeout); } + timeout = Ember.run.later(later, currentWait); } } diff --git a/app/assets/javascripts/discourse/models/composer.js.es6 b/app/assets/javascripts/discourse/models/composer.js.es6 index fa101a177c..8171432a0e 100644 --- a/app/assets/javascripts/discourse/models/composer.js.es6 +++ b/app/assets/javascripts/discourse/models/composer.js.es6 @@ -29,7 +29,7 @@ const CLOSED = 'closed', const Composer = Discourse.Model.extend({ archetypes: function() { - return Discourse.Site.currentProp('archetypes'); + return this.site.get('archetypes'); }.property(), creatingTopic: Em.computed.equal('action', CREATE_TOPIC), @@ -127,21 +127,16 @@ const Composer = Discourse.Model.extend({ } else { // has a category? (when needed) return this.get('canCategorize') && - !Discourse.SiteSettings.allow_uncategorized_topics && + !this.siteSettings.allow_uncategorized_topics && !this.get('categoryId') && - !Discourse.User.currentProp('staff'); + !this.user.get('staff'); } }.property('loading', 'canEditTitle', 'titleLength', 'targetUsernames', 'replyLength', 'categoryId', 'missingReplyCharacters'), - /** - Is the title's length valid? - - @property titleLengthValid - **/ titleLengthValid: function() { - if (Discourse.User.currentProp('admin') && this.get('post.static_doc') && this.get('titleLength') > 0) return true; + if (this.user.get('admin') && this.get('post.static_doc') && this.get('titleLength') > 0) return true; if (this.get('titleLength') < this.get('minimumTitleLength')) return false; - return (this.get('titleLength') <= Discourse.SiteSettings.max_topic_title_length); + return (this.get('titleLength') <= this.siteSettings.max_topic_title_length); }.property('minimumTitleLength', 'titleLength', 'post.static_doc'), // The icon for the save button @@ -194,9 +189,9 @@ const Composer = Discourse.Model.extend({ **/ minimumTitleLength: function() { if (this.get('privateMessage')) { - return Discourse.SiteSettings.min_private_message_title_length; + return this.siteSettings.min_private_message_title_length; } else { - return Discourse.SiteSettings.min_topic_title_length; + return this.siteSettings.min_topic_title_length; } }.property('privateMessage'), @@ -216,12 +211,12 @@ const Composer = Discourse.Model.extend({ **/ minimumPostLength: function() { if( this.get('privateMessage') ) { - return Discourse.SiteSettings.min_private_message_post_length; + return this.siteSettings.min_private_message_post_length; } else if (this.get('topicFirstPost')) { // first post (topic body) - return Discourse.SiteSettings.min_first_post_length; + return this.siteSettings.min_first_post_length; } else { - return Discourse.SiteSettings.min_post_length; + return this.siteSettings.min_post_length; } }.property('privateMessage', 'topicFirstPost'), @@ -249,7 +244,7 @@ const Composer = Discourse.Model.extend({ _setupComposer: function() { const val = (Discourse.Mobile.mobileView ? false : (Discourse.KeyValueStore.get('composer.showPreview') || 'true')); this.set('showPreview', val === 'true'); - this.set('archetypeId', Discourse.Site.currentProp('default_archetype')); + this.set('archetypeId', this.site.get('default_archetype')); }.on('init'), /** @@ -349,15 +344,15 @@ const Composer = Discourse.Model.extend({ this.setProperties({ categoryId: opts.categoryId || this.get('topic.category.id'), - archetypeId: opts.archetypeId || Discourse.Site.currentProp('default_archetype'), + archetypeId: opts.archetypeId || this.site.get('default_archetype'), metaData: opts.metaData ? Em.Object.create(opts.metaData) : null, reply: opts.reply || this.get("reply") || "" }); if (opts.postId) { this.set('loading', true); - Discourse.Post.load(opts.postId).then(function(result) { - composer.set('post', result); + this.store.find('post', opts.postId).then(function(post) { + composer.set('post', post); composer.set('loading', false); }); } @@ -370,10 +365,10 @@ const Composer = Discourse.Model.extend({ this.setProperties(topicProps); - Discourse.Post.load(opts.post.get('id')).then(function(result) { + this.store.find('post', opts.post.get('id')).then(function(post) { composer.setProperties({ - reply: result.get('raw'), - originalText: result.get('raw'), + reply: post.get('raw'), + originalText: post.get('raw'), loading: false }); }); @@ -467,7 +462,7 @@ const Composer = Discourse.Model.extend({ createPost(opts) { const post = this.get('post'), topic = this.get('topic'), - currentUser = Discourse.User.current(), + user = this.user, postStream = this.get('topic.postStream'); let addedToStream = false; @@ -477,17 +472,17 @@ const Composer = Discourse.Model.extend({ imageSizes: opts.imageSizes, cooked: this.getCookedHtml(), reply_count: 0, - name: currentUser.get('name'), - display_username: currentUser.get('name'), - username: currentUser.get('username'), - user_id: currentUser.get('id'), - user_title: currentUser.get('title'), - uploaded_avatar_id: currentUser.get('uploaded_avatar_id'), - user_custom_fields: currentUser.get('custom_fields'), - post_type: Discourse.Site.currentProp('post_types.regular'), + name: user.get('name'), + display_username: user.get('name'), + username: user.get('username'), + user_id: user.get('id'), + user_title: user.get('title'), + uploaded_avatar_id: user.get('uploaded_avatar_id'), + user_custom_fields: user.get('custom_fields'), + post_type: this.site.get('post_types.regular'), actions_summary: [], - moderator: currentUser.get('moderator'), - admin: currentUser.get('admin'), + moderator: user.get('moderator'), + admin: user.get('admin'), yours: true, newPost: true, read: true @@ -520,7 +515,7 @@ const Composer = Discourse.Model.extend({ // we would need to handle oneboxes and other bits that are not even in the // engine, staging will just cause a blank post to render if (!_.isEmpty(createdPost.get('cooked'))) { - state = postStream.stagePost(createdPost, currentUser); + state = postStream.stagePost(createdPost, user); if(state === "alreadyStaging"){ return; @@ -529,69 +524,64 @@ const Composer = Discourse.Model.extend({ } } - const composer = this, - promise = new Ember.RSVP.Promise(function(resolve, reject) { - composer.set('composeState', SAVING); - - createdPost.save(function(result) { - let saving = true; - - createdPost.updateFromJson(result); - - if (topic) { - // It's no longer a new post - createdPost.set('newPost', false); - topic.set('draft_sequence', result.draft_sequence); - postStream.commitPost(createdPost); - addedToStream = true; - } else { - // We created a new topic, let's show it. - composer.set('composeState', CLOSED); - saving = false; - - // Update topic_count for the category - const category = Discourse.Site.currentProp('categories').find(function(x) { return x.get('id') === (parseInt(createdPost.get('category'),10) || 1); }); - if (category) category.incrementProperty('topic_count'); - Discourse.notifyPropertyChange('globalNotice'); - } - - composer.clearState(); - composer.set('createdPost', createdPost); - - if (addedToStream) { - composer.set('composeState', CLOSED); - } else if (saving) { - composer.set('composeState', SAVING); - } - - return resolve({ post: result }); - }, function(error) { - // If an error occurs - if (postStream) { - postStream.undoPost(createdPost); - } - composer.set('composeState', OPEN); - - // TODO extract error handling code - let parsedError; - try { - const parsedJSON = $.parseJSON(error.responseText); - if (parsedJSON.errors) { - parsedError = parsedJSON.errors[0]; - } else if (parsedJSON.failed) { - parsedError = parsedJSON.message; - } - } - catch(ex) { - parsedError = "Unknown error saving post, try again. Error: " + error.status + " " + error.statusText; - } - reject(parsedError); - }); - }); - + const composer = this; + composer.set('composeState', SAVING); composer.set("stagedPost", state === "staged" && createdPost); - return promise; + return createdPost.save().then(function(result) { + let saving = true; + createdPost.updateFromJson(result); + + if (topic) { + // It's no longer a new post + createdPost.set('newPost', false); + topic.set('draft_sequence', result.draft_sequence); + postStream.commitPost(createdPost); + addedToStream = true; + } else { + // We created a new topic, let's show it. + composer.set('composeState', CLOSED); + saving = false; + + // Update topic_count for the category + const category = composer.site.get('categories').find(function(x) { return x.get('id') === (parseInt(createdPost.get('category'),10) || 1); }); + if (category) category.incrementProperty('topic_count'); + Discourse.notifyPropertyChange('globalNotice'); + } + + composer.clearState(); + composer.set('createdPost', createdPost); + + if (addedToStream) { + composer.set('composeState', CLOSED); + } else if (saving) { + composer.set('composeState', SAVING); + } + + return { post: result }; + }).catch(function(error) { + + // If an error occurs + if (postStream) { + postStream.undoPost(createdPost); + } + composer.set('composeState', OPEN); + + // TODO extract error handling code + let parsedError; + try { + const parsedJSON = $.parseJSON(error.responseText); + if (parsedJSON.errors) { + parsedError = parsedJSON.errors[0]; + } else if (parsedJSON.failed) { + parsedError = parsedJSON.message; + } + } + catch(ex) { + parsedError = "Unknown error saving post, try again. Error: " + error.status + " " + error.statusText; + } + throw parsedError; + }); }, getCookedHtml() { @@ -604,7 +594,7 @@ const Composer = Discourse.Model.extend({ // Do not save when there is no reply if (!this.get('reply')) return; // Do not save when the reply's length is too small - if (this.get('replyLength') < Discourse.SiteSettings.min_post_length) return; + if (this.get('replyLength') < this.siteSettings.min_post_length) return; const data = { reply: this.get('reply'), @@ -673,6 +663,14 @@ Composer.reopenClass({ } }, + create(args) { + args = args || {}; + args.user = args.user || Discourse.User.current(); + args.site = args.site || Discourse.Site.current(); + args.siteSettings = args.siteSettings || Discourse.SiteSettings; + return this._super(args); + }, + serializeToTopic(fieldName, property) { if (!property) { property = fieldName; } _edit_topic_serializer[fieldName] = property; diff --git a/app/assets/javascripts/discourse/models/_post.js b/app/assets/javascripts/discourse/models/post.js.es6 similarity index 78% rename from app/assets/javascripts/discourse/models/_post.js rename to app/assets/javascripts/discourse/models/post.js.es6 index 7ae7058cbc..1d54744332 100644 --- a/app/assets/javascripts/discourse/models/_post.js +++ b/app/assets/javascripts/discourse/models/post.js.es6 @@ -1,20 +1,12 @@ -/** - A data model representing a post in a topic +const Post = Discourse.Model.extend({ - @class Post - @extends Discourse.Model - @namespace Discourse - @module Discourse -**/ -Discourse.Post = Discourse.Model.extend({ - - init: function() { + init() { this.set('replyHistory', []); }, shareUrl: function() { - var user = Discourse.User.current(); - var userSuffix = user ? '?u=' + user.get('username_lower') : ''; + const user = Discourse.User.current(); + const userSuffix = user ? '?u=' + user.get('username_lower') : ''; if (this.get('firstPost')) { return this.get('topic.url') + userSuffix; @@ -33,7 +25,7 @@ Discourse.Post = Discourse.Model.extend({ userDeleted: Em.computed.empty('user_id'), showName: function() { - var name = this.get('name'); + const name = this.get('name'); return name && (name !== this.get('username')) && Discourse.SiteSettings.display_name_on_posts; }.property('name', 'username'), @@ -69,17 +61,17 @@ Discourse.Post = Discourse.Model.extend({ }.property("user_id"), wikiChanged: function() { - var data = { wiki: this.get("wiki") }; + const data = { wiki: this.get("wiki") }; this._updatePost("wiki", data); }.observes('wiki'), postTypeChanged: function () { - var data = { post_type: this.get("post_type") }; + const data = { post_type: this.get("post_type") }; this._updatePost("post_type", data); }.observes("post_type"), - _updatePost: function (field, data) { - var self = this; + _updatePost(field, data) { + const self = this; Discourse.ajax("/posts/" + this.get("id") + "/" + field, { type: "PUT", data: data @@ -103,7 +95,7 @@ Discourse.Post = Discourse.Model.extend({ editCount: function() { return this.get('version') - 1; }.property('version'), flagsAvailable: function() { - var post = this; + const post = this; return Discourse.Site.currentProp('flagTypes').filter(function(item) { return post.get("actionByName." + item.get('name_key') + ".can_act"); }); @@ -119,9 +111,8 @@ Discourse.Post = Discourse.Model.extend({ }); }.property('actions_summary.@each.users', 'actions_summary.@each.count'), - // Save a post and call the callback when done. - save: function(complete, error) { - var self = this; + save() { + const self = this; if (!this.get('newPost')) { // We're updating a post return Discourse.ajax("/posts/" + (this.get('id')), { @@ -135,19 +126,17 @@ Discourse.Post = Discourse.Model.extend({ // If we received a category update, update it self.set('version', result.post.version); if (result.category) Discourse.Site.current().updateCategory(result.category); - if (complete) complete(Discourse.Post.create(result.post)); - }).catch(function(result) { - // Post failed to update - if (error) error(result); + return Discourse.Post.create(result.post); }); } else { // We're saving a post - var data = this.getProperties(Discourse.Composer.serializedFieldsForCreate()); + const data = this.getProperties(Discourse.Composer.serializedFieldsForCreate()); data.reply_to_post_number = this.get('reply_to_post_number'); data.image_sizes = this.get('imageSizes'); + data.nested_post = true; - var metaData = this.get('metaData'); + const metaData = this.get('metaData'); // Put the metaData into the request if (metaData) { data.meta_data = {}; @@ -158,34 +147,22 @@ Discourse.Post = Discourse.Model.extend({ type: 'POST', data: data }).then(function(result) { - // Post created - if (complete) complete(Discourse.Post.create(result)); - }).catch(function(result) { - // Failed to create a post - if (error) error(result); + return Discourse.Post.create(result.post); }); } }, - /** - Expands the first post's content, if embedded and shortened. - - @method expandFirstPost - **/ - expand: function() { - var self = this; + // Expands the first post's content, if embedded and shortened. + expand() { + const self = this; return Discourse.ajax("/posts/" + this.get('id') + "/expand-embed").then(function(post) { self.set('cooked', "
" + post.cooked + "
" ); }); }, - /** - Recover a deleted post - - @method recover - **/ - recover: function() { - var post = this; + // Recover a deleted post + recover() { + const post = this; post.setProperties({ deleted_at: null, deleted_by: null, @@ -207,11 +184,8 @@ Discourse.Post = Discourse.Model.extend({ /** Changes the state of the post to be deleted. Does not call the server, that should be done elsewhere. - - @method setDeletedState - @param {Discourse.User} deletedBy The user deleting the post **/ - setDeletedState: function(deletedBy) { + setDeletedState(deletedBy) { this.set('oldCooked', this.get('cooked')); // Moderators can delete posts. Users can only trigger a deleted at message, unless delete_removed_posts_after is 0. @@ -237,10 +211,8 @@ Discourse.Post = Discourse.Model.extend({ Changes the state of the post to NOT be deleted. Does not call the server. This can only be called after setDeletedState was called, but the delete failed on the server. - - @method undoDeletedState **/ - undoDeleteState: function() { + undoDeleteState() { if (this.get('oldCooked')) { this.setProperties({ deleted_at: null, @@ -253,13 +225,7 @@ Discourse.Post = Discourse.Model.extend({ } }, - /** - Deletes a post - - @method destroy - @param {Discourse.User} deletedBy The user deleting the post - **/ - destroy: function(deletedBy) { + destroy(deletedBy) { this.setDeletedState(deletedBy); return Discourse.ajax("/posts/" + this.get('id'), { data: { context: window.location.pathname }, @@ -270,14 +236,11 @@ Discourse.Post = Discourse.Model.extend({ /** Updates a post from another's attributes. This will normally happen when a post is loading but is already found in an identity map. - - @method updateFromPost - @param {Discourse.Post} otherPost The post we're updating from **/ - updateFromPost: function(otherPost) { - var self = this; + updateFromPost(otherPost) { + const self = this; Object.keys(otherPost).forEach(function (key) { - var value = otherPost[key], + let value = otherPost[key], oldValue = self[key]; if (key === "replyHistory") { @@ -287,7 +250,7 @@ Discourse.Post = Discourse.Model.extend({ if (!value) { value = null; } if (!oldValue) { oldValue = null; } - var skip = false; + let skip = false; if (typeof value !== "function" && oldValue !== value) { // wishing for an identity map if (key === "reply_to_user" && value && oldValue) { @@ -304,17 +267,14 @@ Discourse.Post = Discourse.Model.extend({ /** Updates a post from a JSON packet. This is normally done after the post is saved to refresh any attributes. - - @method updateFromJson - @param {Object} obj The Json data to update with **/ - updateFromJson: function(obj) { + updateFromJson(obj) { if (!obj) return; - var skip, oldVal; + let skip, oldVal; // Update all the properties - var post = this; + const post = this; _.each(obj, function(val,key) { if (key !== 'actions_summary'){ oldVal = post[key]; @@ -336,12 +296,11 @@ Discourse.Post = Discourse.Model.extend({ // Rebuild actions summary this.set('actions_summary', Em.A()); if (obj.actions_summary) { - var lookup = Em.Object.create(); + const lookup = Em.Object.create(); _.each(obj.actions_summary,function(a) { - var actionSummary; a.post = post; a.actionType = Discourse.Site.current().postActionTypeById(a.id); - actionSummary = Discourse.ActionSummary.create(a); + const actionSummary = Discourse.ActionSummary.create(a); post.get('actions_summary').pushObject(actionSummary); lookup.set(a.actionType.get('name_key'), actionSummary); }); @@ -350,7 +309,7 @@ Discourse.Post = Discourse.Model.extend({ }, // Load replies to this post - loadReplies: function() { + loadReplies() { if(this.get('loadingReplies')){ return; } @@ -358,12 +317,12 @@ Discourse.Post = Discourse.Model.extend({ this.set('loadingReplies', true); this.set('replies', []); - var self = this; + const self = this; return Discourse.ajax("/posts/" + (this.get('id')) + "/replies") .then(function(loaded) { - var replies = self.get('replies'); + const replies = self.get('replies'); _.each(loaded,function(reply) { - var post = Discourse.Post.create(reply); + const post = Discourse.Post.create(reply); post.set('topic', self.get('topic')); replies.pushObject(post); }); @@ -375,7 +334,7 @@ Discourse.Post = Discourse.Model.extend({ // Whether to show replies directly below showRepliesBelow: function() { - var replyCount = this.get('reply_count'); + const replyCount = this.get('reply_count'); // We don't show replies if there aren't any if (replyCount === 0) return false; @@ -387,13 +346,13 @@ Discourse.Post = Discourse.Model.extend({ if (replyCount > 1) return true; // If we have *exactly* one reply, we have to consider if it's directly below us - var topic = this.get('topic'); + const topic = this.get('topic'); return !topic.isReplyDirectlyBelow(this); }.property('reply_count'), - expandHidden: function() { - var self = this; + expandHidden() { + const self = this; return Discourse.ajax("/posts/" + this.get('id') + "/cooked.json").then(function (result) { self.setProperties({ cooked: result.cooked, @@ -402,17 +361,17 @@ Discourse.Post = Discourse.Model.extend({ }); }, - rebake: function () { + rebake() { return Discourse.ajax("/posts/" + this.get("id") + "/rebake", { type: "PUT" }); }, - unhide: function () { + unhide() { return Discourse.ajax("/posts/" + this.get("id") + "/unhide", { type: "PUT" }); }, - toggleBookmark: function() { - var self = this, - bookmarkedTopic; + toggleBookmark() { + const self = this; + let bookmarkedTopic; this.toggleProperty("bookmarked"); @@ -435,16 +394,16 @@ Discourse.Post = Discourse.Model.extend({ } }); -Discourse.Post.reopenClass({ +Post.reopenClass({ - createActionSummary: function(result) { + createActionSummary(result) { if (result.actions_summary) { - var lookup = Em.Object.create(); + const lookup = Em.Object.create(); // this area should be optimized, it is creating way too many objects per post result.actions_summary = result.actions_summary.map(function(a) { a.post = result; a.actionType = Discourse.Site.current().postActionTypeById(a.id); - var actionSummary = Discourse.ActionSummary.create(a); + const actionSummary = Discourse.ActionSummary.create(a); lookup[a.actionType.name_key] = actionSummary; return actionSummary; }); @@ -452,8 +411,8 @@ Discourse.Post.reopenClass({ } }, - create: function(obj) { - var result = this._super.apply(this, arguments); + create(obj) { + const result = this._super.apply(this, arguments); this.createActionSummary(result); if (obj && obj.reply_to_user) { result.set('reply_to_user', Discourse.User.create(obj.reply_to_user)); @@ -461,14 +420,14 @@ Discourse.Post.reopenClass({ return result; }, - updateBookmark: function(postId, bookmarked) { + updateBookmark(postId, bookmarked) { return Discourse.ajax("/posts/" + postId + "/bookmark", { type: 'PUT', data: { bookmarked: bookmarked } }); }, - deleteMany: function(selectedPosts, selectedReplies) { + deleteMany(selectedPosts, selectedReplies) { return Discourse.ajax("/posts/destroy_many", { type: 'DELETE', data: { @@ -478,37 +437,33 @@ Discourse.Post.reopenClass({ }); }, - loadRevision: function(postId, version) { + loadRevision(postId, version) { return Discourse.ajax("/posts/" + postId + "/revisions/" + version + ".json").then(function (result) { return Ember.Object.create(result); }); }, - hideRevision: function(postId, version) { + hideRevision(postId, version) { return Discourse.ajax("/posts/" + postId + "/revisions/" + version + "/hide", { type: 'PUT' }); }, - showRevision: function(postId, version) { + showRevision(postId, version) { return Discourse.ajax("/posts/" + postId + "/revisions/" + version + "/show", { type: 'PUT' }); }, - loadQuote: function(postId) { + loadQuote(postId) { return Discourse.ajax("/posts/" + postId + ".json").then(function (result) { - var post = Discourse.Post.create(result); + const post = Discourse.Post.create(result); return Discourse.Quote.build(post, post.get('raw')); }); }, - loadRawEmail: function(postId) { + loadRawEmail(postId) { return Discourse.ajax("/posts/" + postId + "/raw-email").then(function (result) { return result.raw_email; }); - }, - - load: function(postId) { - return Discourse.ajax("/posts/" + postId + ".json").then(function (result) { - return Discourse.Post.create(result); - }); } }); + +export default Post; diff --git a/app/assets/javascripts/main_include.js b/app/assets/javascripts/main_include.js index 9816d42f55..9a6e975743 100644 --- a/app/assets/javascripts/main_include.js +++ b/app/assets/javascripts/main_include.js @@ -25,6 +25,7 @@ //= require ./discourse/lib/safari-hacks //= require_tree ./discourse/adapters //= require ./discourse/models/model +//= require ./discourse/models/post //= require ./discourse/models/user_action //= require ./discourse/models/composer //= require ./discourse/models/post-stream diff --git a/app/controllers/posts_controller.rb b/app/controllers/posts_controller.rb index e632b8f277..57b829e531 100644 --- a/app/controllers/posts_controller.rb +++ b/app/controllers/posts_controller.rb @@ -336,7 +336,11 @@ class PostsController < ApplicationController # doesn't return the post as the root JSON object, but as a nested object. # If a param is present it uses that result structure. def backwards_compatible_json(json_obj, success) - json_obj = json_obj[:post] || json_obj['post'] unless params[:nested_post] + json_obj.symbolize_keys! + if params[:nested_post].blank? && json_obj[:errors].blank? + json_obj = json_obj[:post] + end + render json: json_obj, status: (!!success) ? 200 : 422 end diff --git a/test/javascripts/acceptance/composer-test.js.es6 b/test/javascripts/acceptance/composer-test.js.es6 new file mode 100644 index 0000000000..14788f02ba --- /dev/null +++ b/test/javascripts/acceptance/composer-test.js.es6 @@ -0,0 +1,117 @@ +import { acceptance } from "helpers/qunit-helpers"; + +acceptance("Composer", { loggedIn: true }); + +test("Tests the Composer controls", () => { + visit("/"); + andThen(() => { + ok(exists('#create-topic'), 'the create button is visible'); + }); + + click('#create-topic'); + andThen(() => { + ok(exists('#wmd-input'), 'the composer input is visible'); + ok(exists('.title-input .popup-tip.bad.hide'), 'title errors are hidden by default'); + ok(exists('.textarea-wrapper .popup-tip.bad.hide'), 'body errors are hidden by default'); + }); + + click('a.toggle-preview'); + andThen(() => { + ok(!exists('#wmd-preview:visible'), "clicking the toggle hides the preview"); + }); + + click('a.toggle-preview'); + andThen(() => { + ok(exists('#wmd-preview:visible'), "clicking the toggle shows the preview again"); + }); + + click('#reply-control button.create'); + andThen(() => { + ok(!exists('.title-input .popup-tip.bad.hide'), 'it shows the empty title error'); + ok(!exists('.textarea-wrapper .popup-tip.bad.hide'), 'it shows the empty body error'); + }); + + fillIn('#reply-title', "this is my new topic title"); + andThen(() => { + ok(exists('.title-input .popup-tip.good'), 'the title is now good'); + }); + + fillIn('#wmd-input', "this is the *content* of a post"); + andThen(() => { + equal(find('#wmd-preview').html(), "

this is the content of a post

", "it previews content"); + ok(exists('.textarea-wrapper .popup-tip.good'), 'the body is now good'); + }); + + click('#reply-control a.cancel'); + andThen(() => { + ok(exists('.bootbox.modal'), 'it pops up a confirmation dialog'); + }); + + click('.modal-footer a:eq(1)'); + andThen(() => { + ok(!exists('.bootbox.modal'), 'the confirmation can be cancelled'); + }); + +}); + +test("Create a topic with server side errors", () => { + visit("/"); + click('#create-topic'); + fillIn('#reply-title', "this title triggers an error"); + fillIn('#wmd-input', "this is the *content* of a post"); + click('#reply-control button.create'); + andThen(() => { + ok(exists('.bootbox.modal'), 'it pops up an error message'); + }); + click('.bootbox.modal a.btn-primary'); + andThen(() => { + ok(!exists('.bootbox.modal'), 'it dismisses the error'); + ok(exists('#wmd-input'), 'the composer input is visible'); + }); +}); + +test("Create a Topic", () => { + visit("/"); + click('#create-topic'); + fillIn('#reply-title', "Internationalization Localization"); + fillIn('#wmd-input', "this is the *content* of a new topic post"); + click('#reply-control button.create'); + andThen(() => { + equal(currentURL(), "/t/internationalization-localization/280", "it transitions to the newly created topic URL"); + }); +}); + +test("Create a Reply", () => { + visit("/t/internationalization-localization/280"); + + click('#topic-footer-buttons .btn.create'); + andThen(() => { + ok(exists('#wmd-input'), 'the composer input is visible'); + ok(!exists('#reply-title'), 'there is no title since this is a reply'); + }); + + fillIn('#wmd-input', 'this is the content of my reply'); + click('#reply-control button.create'); + andThen(() => { + exists('#post_12345', 'it inserts the post into the document'); + }); +}); + +test("Edit the first post", () => { + visit("/t/internationalization-localization/280"); + + click('.topic-post:eq(0) button[data-action=showMoreActions]'); + click('.topic-post:eq(0) button[data-action=edit]'); + andThen(() => { + equal(find('#wmd-input').val().indexOf('Any plans to support'), 0, 'it populates the input with the post text'); + }); + + fillIn('#wmd-input', "This is the new text for the post"); + fillIn('#reply-title', "This is the new text for the title"); + click('#reply-control button.create'); + andThen(() => { + ok(!exists('#wmd-input'), 'it closes the composer'); + ok(find('#topic-title h1').text().indexOf('This is the new text for the title') !== -1, 'it shows the new title'); + ok(find('.topic-post:eq(0) .cooked').text().indexOf('This is the new text for the post') !== -1, 'it updates the post'); + }); +}); diff --git a/test/javascripts/acceptance/header-test-staff.js.es6 b/test/javascripts/acceptance/header-test-staff.js.es6 index 2f4db1aa2f..fb3278ee8b 100644 --- a/test/javascripts/acceptance/header-test-staff.js.es6 +++ b/test/javascripts/acceptance/header-test-staff.js.es6 @@ -1,10 +1,6 @@ import { acceptance } from "helpers/qunit-helpers"; -acceptance("Header (Staff)", { - user: { username: 'test', - staff: true, - site_flagged_posts_count: 1 } -}); +acceptance("Header (Staff)", { loggedIn: true }); test("header", () => { visit("/"); diff --git a/test/javascripts/fixtures/post.js.es6 b/test/javascripts/fixtures/post.js.es6 new file mode 100644 index 0000000000..c2d22a62cb --- /dev/null +++ b/test/javascripts/fixtures/post.js.es6 @@ -0,0 +1,4 @@ +export default { + "/posts/398": {"id":398,"name":"Uwe Keim","username":"uwe_keim","avatar_template":"/user_avatar/meta.discourse.org/uwe_keim/{size}/5697.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":2,"can_edit":true,"can_delete":false,"can_recover":true,"user_title":null,"raw":"Any plans to support localization of UI elements, so that I (for example) could set up a completely German speaking forum?","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} +}; + diff --git a/test/javascripts/fixtures/session-fixtures.js.es6 b/test/javascripts/fixtures/session-fixtures.js.es6 new file mode 100644 index 0000000000..fa940ccec9 --- /dev/null +++ b/test/javascripts/fixtures/session-fixtures.js.es6 @@ -0,0 +1,4 @@ +export default { + "/session/current.json": {"current_user":{"id":19,"username":"eviltrout","uploaded_avatar_id":5275,"avatar_template":"/user_avatar/localhost/eviltrout/{size}/5275.png","name":"Robin Ward","total_unread_notifications":205,"unread_notifications":0,"unread_private_messages":0,"admin":true,"notification_channel_position":null,"site_flagged_posts_count":1,"moderator":true,"staff":true,"title":"co-founder","reply_count":859,"topic_count":36,"enable_quoting":true,"external_links_in_new_tab":false,"dynamic_favicon":true,"trust_level":4,"can_edit":true,"can_invite_to_forum":true,"should_be_redirected_to_top":false,"disable_jump_reply":false,"custom_fields":{},"muted_category_ids":[],"dismissed_banner_key":null,"akismet_review_count":0}} +}; + diff --git a/test/javascripts/fixtures/topic.js.es6 b/test/javascripts/fixtures/topic.js.es6 index b072266f46..7d5be12cbc 100644 --- a/test/javascripts/fixtures/topic.js.es6 +++ b/test/javascripts/fixtures/topic.js.es6 @@ -1,2 +1,2 @@ /*jshint maxlen:10000000 */ -export default {"/t/280.json": {"post_stream":{"posts":[{"id":398,"name":"Uwe Keim","username":"uwe_keim","avatar_template":"//www.gravatar.com/avatar/53a82f701ae492808834e621de2586eb.png?s={size}&r=pg&d=identicon","created_at":"2013-02-05T16:29:00.000-05:00","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-05T16:29:00.000-05:00","reply_count":0,"reply_to_post_number":null,"quote_count":0,"avg_time":23,"incoming_link_count":9,"reads":390,"score":158.15,"yours":false,"topic_slug":"internationalization-localization","topic_id":280,"display_username":"Uwe Keim","version":1,"can_edit":false,"can_delete":false,"can_recover":false,"link_counts":[{"url":"http://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":"http://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":"http://meta.discourse.org/t/suggestion-translation-on-admin-panel/6923/5","internal":true,"reflection":true,"title":"Suggestion: Translation on admin panel","clicks":0},{"url":"http://meta.discourse.org/t/translation-workflow/6102","internal":true,"reflection":true,"title":"Translation workflow","clicks":0},{"url":"http://meta.discourse.org/t/missing-user-value-in-chinese-localized-page/7406/6","internal":true,"reflection":true,"title":"[missing {{user}} value] in Chinese localized page","clicks":0},{"url":"http://meta.discourse.org/t/changing-language-phrase-does-not-affect-on-the-site/8429/3","internal":true,"reflection":true,"title":"Changing language phrase does not affect on the site?","clicks":0},{"url":"http://meta.discourse.org/t/internationalization-i18n-provided-for-discourse/2073/2","internal":true,"reflection":true,"title":"Internationalization I18n provided for discourse ?","clicks":0},{"url":"http://meta.discourse.org/t/language-mirrors/2378/2","internal":true,"reflection":true,"title":"Language mirrors","clicks":0},{"url":"http://meta.discourse.org/t/roadplan-for-discourse/2939/5","internal":true,"reflection":true,"title":"Roadplan for Discourse","clicks":0},{"url":"http://meta.discourse.org/t/solving-xda-developer-style-forums/4368/4","internal":true,"reflection":true,"title":"Solving XDA-Developer style forums","clicks":0}],"read":false,"user_title":null,"actions_summary":[{"id":2,"count":0,"hidden":false,"can_act":null},{"id":3,"count":0,"hidden":false,"can_act":null},{"id":4,"count":0,"hidden":false,"can_act":null},{"id":5,"count":0,"hidden":true,"can_act":null},{"id":6,"count":0,"hidden":false,"can_act":null},{"id":7,"count":0,"hidden":false,"can_act":null},{"id":8,"count":0,"hidden":false,"can_act":null}],"moderator":false,"staff":false,"user_id":255,"hidden":false,"hidden_reason_id":null,"trust_level":1,"deleted_at":null,"user_deleted":false,"edit_reason":null},{"id":419,"name":"Tim Stone","username":"tms","avatar_template":"//www.gravatar.com/avatar/3981cd271c302f5cba628c6b6d2b32ee.png?s={size}&r=pg&d=identicon","created_at":"2013-02-05T16:32:47.000-05:00","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-06T05:15:27.000-05:00","reply_count":0,"reply_to_post_number":null,"quote_count":0,"avg_time":26,"incoming_link_count":12,"reads":376,"score":256.5,"yours":false,"topic_slug":"internationalization-localization","topic_id":280,"display_username":"Tim Stone","version":2,"can_edit":false,"can_delete":false,"can_recover":false,"link_counts":[{"url":"https://github.com/discourse/discourse/blob/master/config/locales/en.yml","internal":false,"reflection":false,"clicks":91}],"read":false,"user_title":null,"actions_summary":[{"id":2,"count":3,"hidden":false,"can_act":null},{"id":3,"count":0,"hidden":false,"can_act":null},{"id":4,"count":0,"hidden":false,"can_act":null},{"id":5,"count":0,"hidden":true,"can_act":null},{"id":6,"count":0,"hidden":false,"can_act":null},{"id":7,"count":0,"hidden":false,"can_act":null},{"id":8,"count":0,"hidden":false,"can_act":null}],"moderator":false,"staff":false,"user_id":9,"hidden":false,"hidden_reason_id":null,"trust_level":2,"deleted_at":null,"user_deleted":false,"edit_reason":null},{"id":1060,"name":"Jeff Atwood","username":"codinghorror","avatar_template":"//www.gravatar.com/avatar/51d623f33f8b83095db84ff35e15dbe8.png?s={size}&r=pg&d=identicon","created_at":"2013-02-05T21:26:24.000-05:00","cooked":"

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

\n\n
\n
\n \n
\n
\n

https://github.com/discourse/discourse/blob/master/config/locales

\n
\n\n\n\n\n<!DOCTYPE html>\n<html>\n<head>\n  <meta charset=\"utf-8\">\n  <meta name=\"viewport\" id=\"vp\" content=\"initial-scale=1.0,user-scalable=no,maximum-scale=1,width=device-width\">\n  <meta name=\"viewport\" id=\"vp\" content=\"initial-scale=1.0,user-scalable=no,maximum-scale=1\" media=\"(device-height: 568px)\">\n  <link rel=\"apple-touch-icon-precomposed\" href=\"apple-touch-icon-precomposed.png\">\n  <link rel=\"apple-touch-icon-precomposed\" sizes=\"114x114\" href=\"apple-touch-icon-114-precomposed.png\">\n  <meta name=\"google-analytics\" content=\"UA-3769691-2\">\n\n  <meta content=\"authenticity_token\" name=\"csrf-param\" />\n<meta content=\"nFiTSR4YODX1WTtDmWoO2Xbd9vatOibhb97Rm3nw+8I=\" name=\"csrf-token\" />\n\n  <meta content=\"collector.githubapp.com\" name=\"octolytics-host\" /><meta content=\"collector-cdn.github.com\" name=\"octolytics-script-host\" /><meta content=\"github\" name=\"octolytics-app-id\" /><meta content=\"40479403:2C5C:A159F60:52CDD76D\" name=\"octolytics-dimension-request_id\" />\n  <meta content=\"mobile\" name=\"octolytics-dimension-device\" />\n  <meta content=\"3220138\" name=\"octolytics-dimension-user_id\" /><meta content=\"discourse\" name=\"octolytics-dimension-user_login\" /><meta content=\"7569578\" name=\"octolytics-dimension-repository_id\" /><meta content=\"discourse/discourse\" name=\"octolytics-dimension-repository_nwo\" /><meta content=\"true\" name=\"octolytics-dimension-repository_public\" /><meta content=\"false\" name=\"octolytics-dimension-repository_is_fork\" /><meta content=\"7569578\" name=\"octolytics-dimension-repository_network_root_id\" /><meta content=\"discourse/discourse\" name=\"octolytics-dimension-repository_network_root_nwo\" />\n  \n\n  <title>discourse/discourse · GitHub</title>\n\n  <link href=\"https://github.global.ssl.fastly.net/assets/mobile-9164f493ea1c72c93c839eba1f16fb0a36e0f638.css\" media=\"all\" rel=\"stylesheet\" type=\"text/css\" />\n  <script async=\"async\" defer=\"defer\" src=\"https://github.global.ssl.fastly.net/assets/mobile-756d65616fde6492018be51d0739ed8ccfcc25b6.js\" type=\"text/javascript\"></script>\n</head>\n<body class=\"\">\n  <header class=\"nav-bar clearfix\">\n    <div class=\"nav-bar-inner\">\n      \n\n      <button class=\"header-button header-nav-button touchable js-show-global-nav \">\n        <span class=\"octicon octicon-three-bars\"></span>\n        <span class=\"octicon octicon-primitive-dot\"></span>\n      </button>\n\n      <div class=\"nav-bar-title-text\">\n              <span class=\"octicon octicon-repo\"></span>\n\n    <strong><a href=\"/discourse\">discourse</a></strong>\n    /\n    <strong><a href=\"/discourse/discourse\">discourse</a></strong>\n\n      </div>\n    </div>\n\n    <nav class=\"nav-bar-tabs repo-nav-bar-tabs \">\n      <ul>\n\n\n        <li>\n          <a href=\"/explore\"><span class=\"octicon octicon-telescope\"></span> Explore</a>\n        </li>\n\n        <li>\n            <a href=\"/login\"><span class=\"octicon octicon-log-in\"></span> Sign in</a>\n        </li>\n\n            <li class=\"section-title\">This repository</li>\n    <li><a href=\"/discourse/discourse?files=1\"><span class=\"octicon octicon-code\"></span> Code</a></li>\n      <li><a href=\"/discourse/discourse/issues\"><span class=\"octicon octicon-issue-opened\"></span> Issues</a></li>\n    <li><a href=\"/discourse/discourse/pulls\"><span class=\"octicon octicon-git-pull-request\"></span> Pull Requests</a></li>\n    <li><a href=\"/discourse/discourse/pulse\"><span class=\"octicon octicon-pulse\"></span> Pulse</a></li>\n\n      </ul>\n    </nav>\n  </header>\n\n  \n\n        \n\n\n<div class=\"file-browser\">\n\n  <p class=\"history-link\">\n    <a href=\"/discourse/discourse/commits/master/config/locales\">\n      <span class=\"octicon octicon-history\"></span>\n      View 5857 commits\n    </a>\n  </p>\n\n  <div class=\"bubble\">\n    <ul class=\"bubble-list files-list\">\n      <li class=\"path\">\n        <span class=\"branch\">master</span>\n        <span class='bold'>\n          <span itemtype=\"http://data-vocabulary.org/Breadcrumb\">\n            <a href=\"/discourse/discourse?files=1\" data-branch=\"master\" data-direction=\"back\" itemscope=\"url\">\n              <span itemprop=\"title\">discourse</span>\n            </a>\n          </span>\n        </span>\n        <span class=\"separator\"> / </span>\n\n        <span itemscope=\"\" itemtype=\"http://data-vocabulary.org/Breadcrumb\"><a href=\"/discourse/discourse/tree/master/config\" data-branch=\"master\" data-direction=\"back\" data-pjax=\"true\" itemscope=\"url\"><span itemprop=\"title\">config</span></a></span><span class=\"separator\"> / </span><strong class=\"final-path\">locales</strong>\n      </li>\n\n        <li>\n          <a href=\"/discourse/discourse/blob/master/config/locales/client.cs.yml\" class=\"file-list-item\">\n            <span class=\"octicon octicon-file-text\"></span>\n            client.cs.yml\n              <span class=\"timestamp\">28 days ago</span>\n          </a>\n        </li>\n        <li>\n          <a href=\"/discourse/discourse/blob/master/config/locales/client.da.yml\" class=\"file-list-item\">\n            <span class=\"octicon octicon-file-text\"></span>\n            client.da.yml\n          </a>\n        </li>\n        <li>
\n\n This file has been truncated. show original\n
\n
\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":"2013-06-18T22:58:28.000-04:00","reply_count":3,"reply_to_post_number":null,"quote_count":0,"avg_time":33,"incoming_link_count":4,"reads":367,"score":155.05,"yours":false,"topic_slug":"internationalization-localization","topic_id":280,"display_username":"Jeff Atwood","version":3,"can_edit":false,"can_delete":false,"can_recover":false,"link_counts":[{"url":"https://github.com/discourse/discourse/blob/master/config/locales","internal":false,"reflection":false,"clicks":27},{"url":"http://meta.discourse.org/t/github-onebox-rendering-issue/7616","internal":true,"reflection":true,"title":"GitHub OneBox Rendering Issue","clicks":0}],"read":false,"user_title":"co-founder","actions_summary":[{"id":2,"count":3,"hidden":false,"can_act":null},{"id":3,"count":0,"hidden":false,"can_act":null},{"id":4,"count":0,"hidden":false,"can_act":null},{"id":5,"count":0,"hidden":true,"can_act":null},{"id":6,"count":0,"hidden":false,"can_act":null},{"id":7,"count":0,"hidden":false,"can_act":null},{"id":8,"count":0,"hidden":false,"can_act":null}],"moderator":true,"staff":true,"user_id":32,"hidden":false,"hidden_reason_id":null,"trust_level":2,"deleted_at":null,"user_deleted":false,"edit_reason":null},{"id":3623,"name":"Shade","username":"shade","avatar_template":"//www.gravatar.com/avatar/02c3f1806f6962f56168c7bd9f8924b8.png?s={size}&r=pg&d=identicon","created_at":"2013-02-07T07:55:33.000-05:00","cooked":"

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

","post_number":4,"post_type":1,"updated_at":"2013-02-07T07:55:33.000-05:00","reply_count":0,"reply_to_post_number":null,"quote_count":0,"avg_time":21,"incoming_link_count":11,"reads":324,"score":255.85,"yours":false,"topic_slug":"internationalization-localization","topic_id":280,"display_username":"Shade","version":1,"can_edit":false,"can_delete":false,"can_recover":false,"link_counts":[{"url":"http://meta.discourse.org/t/hi-support-chinese/4393/6","internal":true,"reflection":true,"title":"Hi, support Chinese?","clicks":0}],"read":false,"user_title":null,"actions_summary":[{"id":2,"count":7,"hidden":false,"can_act":null},{"id":3,"count":0,"hidden":false,"can_act":null},{"id":4,"count":0,"hidden":false,"can_act":null},{"id":5,"count":0,"hidden":true,"can_act":null},{"id":6,"count":0,"hidden":false,"can_act":null},{"id":7,"count":0,"hidden":false,"can_act":null},{"id":8,"count":0,"hidden":false,"can_act":null}],"moderator":false,"staff":false,"user_id":1808,"hidden":false,"hidden_reason_id":null,"trust_level":1,"deleted_at":null,"user_deleted":false,"edit_reason":null},{"id":3651,"name":"Pekka Gaiser","username":"pekka","avatar_template":"//www.gravatar.com/avatar/100a6c42a31a56e882475725d65537f8.png?s={size}&r=pg&d=identicon","created_at":"2013-02-07T09:02:07.000-05:00","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-07T09:05:42.000-05:00","reply_count":2,"reply_to_post_number":3,"quote_count":1,"avg_time":23,"incoming_link_count":6,"reads":317,"score":179.55,"yours":false,"topic_slug":"internationalization-localization","topic_id":280,"display_username":"Pekka Gaiser","version":1,"can_edit":false,"can_delete":false,"can_recover":false,"link_counts":[{"url":"http://stackoverflow.com/questions/4232922/why-do-people-use-plain-english-as-translation-placeholders","internal":false,"reflection":false,"clicks":56}],"read":false,"user_title":null,"actions_summary":[{"id":2,"count":2,"hidden":false,"can_act":null},{"id":3,"count":0,"hidden":false,"can_act":null},{"id":4,"count":0,"hidden":false,"can_act":null},{"id":5,"count":0,"hidden":true,"can_act":null},{"id":6,"count":0,"hidden":false,"can_act":null},{"id":7,"count":0,"hidden":false,"can_act":null},{"id":8,"count":0,"hidden":false,"can_act":null}],"moderator":false,"staff":false,"user_id":7,"hidden":false,"hidden_reason_id":null,"trust_level":2,"deleted_at":null,"user_deleted":false,"edit_reason":null},{"id":3654,"name":"Sam Saffron","username":"sam","avatar_template":"//www.gravatar.com/avatar/3dcae8378d46c244172a115c28ca49ce.png?s={size}&r=pg&d=identicon","created_at":"2013-02-07T09:05:39.000-05:00","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-07T09:05:39.000-05:00","reply_count":1,"reply_to_post_number":5,"quote_count":0,"avg_time":19,"incoming_link_count":2,"reads":276,"score":86.15,"yours":false,"topic_slug":"internationalization-localization","topic_id":280,"display_username":"Sam Saffron","version":1,"can_edit":false,"can_delete":false,"can_recover":false,"link_counts":[{"url":"https://github.com/SlexAxton/messageformat.js","internal":false,"reflection":false,"clicks":38},{"url":"https://github.com/SlexAxton","internal":false,"reflection":false,"clicks":9}],"read":false,"user_title":"co-founder","reply_to_user":{"username":"pekka","avatar_template":"//www.gravatar.com/avatar/100a6c42a31a56e882475725d65537f8.png?s={size}&r=pg&d=identicon"},"actions_summary":[{"id":2,"count":1,"hidden":false,"can_act":null},{"id":3,"count":0,"hidden":false,"can_act":null},{"id":4,"count":0,"hidden":false,"can_act":null},{"id":5,"count":0,"hidden":true,"can_act":null},{"id":6,"count":0,"hidden":false,"can_act":null},{"id":7,"count":0,"hidden":false,"can_act":null},{"id":8,"count":0,"hidden":false,"can_act":null}],"moderator":true,"staff":true,"user_id":1,"hidden":false,"hidden_reason_id":null,"trust_level":2,"deleted_at":null,"user_deleted":false,"edit_reason":null},{"id":3655,"name":"Pekka Gaiser","username":"pekka","avatar_template":"//www.gravatar.com/avatar/100a6c42a31a56e882475725d65537f8.png?s={size}&r=pg&d=identicon","created_at":"2013-02-07T09:08:17.000-05:00","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-07T09:12:02.000-05:00","reply_count":1,"reply_to_post_number":6,"quote_count":1,"avg_time":17,"incoming_link_count":0,"reads":276,"score":76.05,"yours":false,"topic_slug":"internationalization-localization","topic_id":280,"display_username":"Pekka Gaiser","version":1,"can_edit":false,"can_delete":false,"can_recover":false,"link_counts":[{"url":"http://translate.wordpress.org/projects/bbpress/dev","internal":false,"reflection":false,"clicks":14}],"read":false,"user_title":null,"actions_summary":[{"id":2,"count":1,"hidden":false,"can_act":null},{"id":3,"count":0,"hidden":false,"can_act":null},{"id":4,"count":0,"hidden":false,"can_act":null},{"id":5,"count":0,"hidden":true,"can_act":null},{"id":6,"count":0,"hidden":false,"can_act":null},{"id":7,"count":0,"hidden":false,"can_act":null},{"id":8,"count":0,"hidden":false,"can_act":null}],"moderator":false,"staff":false,"user_id":7,"hidden":false,"hidden_reason_id":null,"trust_level":2,"deleted_at":null,"user_deleted":false,"edit_reason":null},{"id":3658,"name":"Sam Saffron","username":"sam","avatar_template":"//www.gravatar.com/avatar/3dcae8378d46c244172a115c28ca49ce.png?s={size}&r=pg&d=identicon","created_at":"2013-02-07T09:12:22.000-05:00","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-07T09:12:22.000-05:00","reply_count":1,"reply_to_post_number":7,"quote_count":0,"avg_time":11,"incoming_link_count":0,"reads":251,"score":70.75,"yours":false,"topic_slug":"internationalization-localization","topic_id":280,"display_username":"Sam Saffron","version":1,"can_edit":false,"can_delete":false,"can_recover":false,"link_counts":[{"url":"http://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":false,"user_title":"co-founder","reply_to_user":{"username":"pekka","avatar_template":"//www.gravatar.com/avatar/100a6c42a31a56e882475725d65537f8.png?s={size}&r=pg&d=identicon"},"actions_summary":[{"id":2,"count":1,"hidden":false,"can_act":null},{"id":3,"count":0,"hidden":false,"can_act":null},{"id":4,"count":0,"hidden":false,"can_act":null},{"id":5,"count":0,"hidden":true,"can_act":null},{"id":6,"count":0,"hidden":false,"can_act":null},{"id":7,"count":0,"hidden":false,"can_act":null},{"id":8,"count":0,"hidden":false,"can_act":null}],"moderator":true,"staff":true,"user_id":1,"hidden":false,"hidden_reason_id":null,"trust_level":2,"deleted_at":null,"user_deleted":false,"edit_reason":null},{"id":3660,"name":"Pekka Gaiser","username":"pekka","avatar_template":"//www.gravatar.com/avatar/100a6c42a31a56e882475725d65537f8.png?s={size}&r=pg&d=identicon","created_at":"2013-02-07T09:14:12.000-05:00","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-07T09:18:09.000-05:00","reply_count":1,"reply_to_post_number":8,"quote_count":1,"avg_time":10,"incoming_link_count":0,"reads":249,"score":70.3,"yours":false,"topic_slug":"internationalization-localization","topic_id":280,"display_username":"Pekka Gaiser","version":1,"can_edit":false,"can_delete":false,"can_recover":false,"read":false,"user_title":null,"actions_summary":[{"id":2,"count":1,"hidden":false,"can_act":null},{"id":3,"count":0,"hidden":false,"can_act":null},{"id":4,"count":0,"hidden":false,"can_act":null},{"id":5,"count":0,"hidden":true,"can_act":null},{"id":6,"count":0,"hidden":false,"can_act":null},{"id":7,"count":0,"hidden":false,"can_act":null},{"id":8,"count":0,"hidden":false,"can_act":null}],"moderator":false,"staff":false,"user_id":7,"hidden":false,"hidden_reason_id":null,"trust_level":2,"deleted_at":null,"user_deleted":false,"edit_reason":null},{"id":3667,"name":"Tim Stone","username":"tms","avatar_template":"//www.gravatar.com/avatar/3981cd271c302f5cba628c6b6d2b32ee.png?s={size}&r=pg&d=identicon","created_at":"2013-02-07T09:25:16.000-05:00","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-07T09:25:16.000-05:00","reply_count":1,"reply_to_post_number":9,"quote_count":0,"avg_time":8,"incoming_link_count":0,"reads":236,"score":67.6,"yours":false,"topic_slug":"internationalization-localization","topic_id":280,"display_username":"Tim Stone","version":1,"can_edit":false,"can_delete":false,"can_recover":false,"read":false,"user_title":null,"reply_to_user":{"username":"pekka","avatar_template":"//www.gravatar.com/avatar/100a6c42a31a56e882475725d65537f8.png?s={size}&r=pg&d=identicon"},"actions_summary":[{"id":2,"count":1,"hidden":false,"can_act":null},{"id":3,"count":0,"hidden":false,"can_act":null},{"id":4,"count":0,"hidden":false,"can_act":null},{"id":5,"count":0,"hidden":true,"can_act":null},{"id":6,"count":0,"hidden":false,"can_act":null},{"id":7,"count":0,"hidden":false,"can_act":null},{"id":8,"count":0,"hidden":false,"can_act":null}],"moderator":false,"staff":false,"user_id":9,"hidden":false,"hidden_reason_id":null,"trust_level":2,"deleted_at":null,"user_deleted":false,"edit_reason":null},{"id":3673,"name":"Sam Saffron","username":"sam","avatar_template":"//www.gravatar.com/avatar/3dcae8378d46c244172a115c28ca49ce.png?s={size}&r=pg&d=identicon","created_at":"2013-02-07T09:30:21.000-05:00","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-07T09:30:21.000-05:00","reply_count":1,"reply_to_post_number":10,"quote_count":0,"avg_time":7,"incoming_link_count":0,"reads":233,"score":66.95,"yours":false,"topic_slug":"internationalization-localization","topic_id":280,"display_username":"Sam Saffron","version":1,"can_edit":false,"can_delete":false,"can_recover":false,"read":false,"user_title":"co-founder","reply_to_user":{"username":"tms","avatar_template":"//www.gravatar.com/avatar/3981cd271c302f5cba628c6b6d2b32ee.png?s={size}&r=pg&d=identicon"},"actions_summary":[{"id":2,"count":1,"hidden":false,"can_act":null},{"id":3,"count":0,"hidden":false,"can_act":null},{"id":4,"count":0,"hidden":false,"can_act":null},{"id":5,"count":0,"hidden":true,"can_act":null},{"id":6,"count":0,"hidden":false,"can_act":null},{"id":7,"count":0,"hidden":false,"can_act":null},{"id":8,"count":0,"hidden":false,"can_act":null}],"moderator":true,"staff":true,"user_id":1,"hidden":false,"hidden_reason_id":null,"trust_level":2,"deleted_at":null,"user_deleted":false,"edit_reason":null},{"id":3675,"name":"Pekka Gaiser","username":"pekka","avatar_template":"//www.gravatar.com/avatar/100a6c42a31a56e882475725d65537f8.png?s={size}&r=pg&d=identicon","created_at":"2013-02-07T09:33:38.000-05:00","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-07T09:34:39.000-05:00","reply_count":1,"reply_to_post_number":11,"quote_count":1,"avg_time":8,"incoming_link_count":1,"reads":231,"score":71.6,"yours":false,"topic_slug":"internationalization-localization","topic_id":280,"display_username":"Pekka Gaiser","version":1,"can_edit":false,"can_delete":false,"can_recover":false,"read":false,"user_title":null,"actions_summary":[{"id":2,"count":1,"hidden":false,"can_act":null},{"id":3,"count":0,"hidden":false,"can_act":null},{"id":4,"count":0,"hidden":false,"can_act":null},{"id":5,"count":0,"hidden":true,"can_act":null},{"id":6,"count":0,"hidden":false,"can_act":null},{"id":7,"count":0,"hidden":false,"can_act":null},{"id":8,"count":0,"hidden":false,"can_act":null}],"moderator":false,"staff":false,"user_id":7,"hidden":false,"hidden_reason_id":null,"trust_level":2,"deleted_at":null,"user_deleted":false,"edit_reason":null},{"id":3690,"name":"Valts","username":"Vilx","avatar_template":"//www.gravatar.com/avatar/7bd2e50770e937761cfc3811a332bccc.png?s={size}&r=pg&d=identicon","created_at":"2013-02-07T10:05:35.000-05:00","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-07T10:05:35.000-05:00","reply_count":0,"reply_to_post_number":null,"quote_count":0,"avg_time":8,"incoming_link_count":11,"reads":246,"score":149.6,"yours":false,"topic_slug":"internationalization-localization","topic_id":280,"display_username":"Valts","version":1,"can_edit":false,"can_delete":false,"can_recover":false,"read":false,"user_title":null,"actions_summary":[{"id":2,"count":3,"hidden":false,"can_act":null},{"id":3,"count":0,"hidden":false,"can_act":null},{"id":4,"count":0,"hidden":false,"can_act":null},{"id":5,"count":0,"hidden":true,"can_act":null},{"id":6,"count":0,"hidden":false,"can_act":null},{"id":7,"count":0,"hidden":false,"can_act":null},{"id":8,"count":0,"hidden":false,"can_act":null}],"moderator":false,"staff":false,"user_id":1216,"hidden":false,"hidden_reason_id":null,"trust_level":2,"deleted_at":null,"user_deleted":false,"edit_reason":null},{"id":3925,"name":"Eric Kidd","username":"emk","avatar_template":"//www.gravatar.com/avatar/528ca205857ff8f648359dcd3e74c84a.png?s={size}&r=pg&d=identicon","created_at":"2013-02-07T14:37:06.000-05:00","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-07T14:37:06.000-05:00","reply_count":1,"reply_to_post_number":12,"quote_count":1,"avg_time":9,"incoming_link_count":0,"reads":237,"score":127.85,"yours":false,"topic_slug":"internationalization-localization","topic_id":280,"display_username":"Eric Kidd","version":1,"can_edit":false,"can_delete":false,"can_recover":false,"link_counts":[{"url":"http://www.localeapp.com/","internal":false,"reflection":false,"clicks":59}],"read":false,"user_title":null,"actions_summary":[{"id":2,"count":3,"hidden":false,"can_act":null},{"id":3,"count":0,"hidden":false,"can_act":null},{"id":4,"count":0,"hidden":false,"can_act":null},{"id":5,"count":0,"hidden":true,"can_act":null},{"id":6,"count":0,"hidden":false,"can_act":null},{"id":7,"count":0,"hidden":false,"can_act":null},{"id":8,"count":0,"hidden":false,"can_act":null}],"moderator":false,"staff":false,"user_id":1860,"hidden":false,"hidden_reason_id":null,"trust_level":1,"deleted_at":null,"user_deleted":false,"edit_reason":null},{"id":3938,"name":"Pekka Gaiser","username":"pekka","avatar_template":"//www.gravatar.com/avatar/100a6c42a31a56e882475725d65537f8.png?s={size}&r=pg&d=identicon","created_at":"2013-02-07T14:52:13.000-05:00","cooked":"

\n\n

Ohhh. Looking sexy. droool

","post_number":15,"post_type":1,"updated_at":"2013-02-07T14:52:13.000-05:00","reply_count":1,"reply_to_post_number":14,"quote_count":1,"avg_time":8,"incoming_link_count":0,"reads":217,"score":63.8,"yours":false,"topic_slug":"internationalization-localization","topic_id":280,"display_username":"Pekka Gaiser","version":1,"can_edit":false,"can_delete":false,"can_recover":false,"read":false,"user_title":null,"actions_summary":[{"id":2,"count":1,"hidden":false,"can_act":null},{"id":3,"count":0,"hidden":false,"can_act":null},{"id":4,"count":0,"hidden":false,"can_act":null},{"id":5,"count":0,"hidden":true,"can_act":null},{"id":6,"count":0,"hidden":false,"can_act":null},{"id":7,"count":0,"hidden":false,"can_act":null},{"id":8,"count":0,"hidden":false,"can_act":null}],"moderator":false,"staff":false,"user_id":7,"hidden":false,"hidden_reason_id":null,"trust_level":2,"deleted_at":null,"user_deleted":false,"edit_reason":null},{"id":3982,"name":"Eric Kidd","username":"emk","avatar_template":"//www.gravatar.com/avatar/528ca205857ff8f648359dcd3e74c84a.png?s={size}&r=pg&d=identicon","created_at":"2013-02-07T15:52:22.000-05:00","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-07T15:52:22.000-05:00","reply_count":1,"reply_to_post_number":15,"quote_count":1,"avg_time":8,"incoming_link_count":0,"reads":209,"score":62.2,"yours":false,"topic_slug":"internationalization-localization","topic_id":280,"display_username":"Eric Kidd","version":1,"can_edit":false,"can_delete":false,"can_recover":false,"read":false,"user_title":null,"actions_summary":[{"id":2,"count":1,"hidden":false,"can_act":null},{"id":3,"count":0,"hidden":false,"can_act":null},{"id":4,"count":0,"hidden":false,"can_act":null},{"id":5,"count":0,"hidden":true,"can_act":null},{"id":6,"count":0,"hidden":false,"can_act":null},{"id":7,"count":0,"hidden":false,"can_act":null},{"id":8,"count":0,"hidden":false,"can_act":null}],"moderator":false,"staff":false,"user_id":1860,"hidden":false,"hidden_reason_id":null,"trust_level":1,"deleted_at":null,"user_deleted":false,"edit_reason":null},{"id":3989,"name":"Pekka Gaiser","username":"pekka","avatar_template":"//www.gravatar.com/avatar/100a6c42a31a56e882475725d65537f8.png?s={size}&r=pg&d=identicon","created_at":"2013-02-07T16:04:15.000-05:00","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-07T16:04:15.000-05:00","reply_count":2,"reply_to_post_number":16,"quote_count":1,"avg_time":8,"incoming_link_count":0,"reads":212,"score":67.8,"yours":false,"topic_slug":"internationalization-localization","topic_id":280,"display_username":"Pekka Gaiser","version":1,"can_edit":false,"can_delete":false,"can_recover":false,"read":false,"user_title":null,"actions_summary":[{"id":2,"count":1,"hidden":false,"can_act":null},{"id":3,"count":0,"hidden":false,"can_act":null},{"id":4,"count":0,"hidden":false,"can_act":null},{"id":5,"count":0,"hidden":true,"can_act":null},{"id":6,"count":0,"hidden":false,"can_act":null},{"id":7,"count":0,"hidden":false,"can_act":null},{"id":8,"count":0,"hidden":false,"can_act":null}],"moderator":false,"staff":false,"user_id":7,"hidden":false,"hidden_reason_id":null,"trust_level":2,"deleted_at":null,"user_deleted":false,"edit_reason":null},{"id":3996,"name":"Sam Saffron","username":"sam","avatar_template":"//www.gravatar.com/avatar/3dcae8378d46c244172a115c28ca49ce.png?s={size}&r=pg&d=identicon","created_at":"2013-02-07T16:12:06.000-05:00","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-07T16:12:06.000-05:00","reply_count":2,"reply_to_post_number":17,"quote_count":0,"avg_time":9,"incoming_link_count":0,"reads":220,"score":144.45,"yours":false,"topic_slug":"internationalization-localization","topic_id":280,"display_username":"Sam Saffron","version":1,"can_edit":false,"can_delete":false,"can_recover":false,"read":false,"user_title":"co-founder","reply_to_user":{"username":"pekka","avatar_template":"//www.gravatar.com/avatar/100a6c42a31a56e882475725d65537f8.png?s={size}&r=pg&d=identicon"},"actions_summary":[{"id":2,"count":6,"hidden":false,"can_act":null},{"id":3,"count":0,"hidden":false,"can_act":null},{"id":4,"count":0,"hidden":false,"can_act":null},{"id":5,"count":0,"hidden":true,"can_act":null},{"id":6,"count":0,"hidden":false,"can_act":null},{"id":7,"count":0,"hidden":false,"can_act":null},{"id":8,"count":0,"hidden":false,"can_act":null}],"moderator":true,"staff":true,"user_id":1,"hidden":false,"hidden_reason_id":null,"trust_level":2,"deleted_at":null,"user_deleted":false,"edit_reason":null},{"id":4009,"name":"Pekka Gaiser","username":"pekka","avatar_template":"//www.gravatar.com/avatar/100a6c42a31a56e882475725d65537f8.png?s={size}&r=pg&d=identicon","created_at":"2013-02-07T16:18:47.000-05:00","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-07T16:22:10.000-05:00","reply_count":0,"reply_to_post_number":18,"quote_count":0,"avg_time":9,"incoming_link_count":0,"reads":203,"score":56.05,"yours":false,"topic_slug":"internationalization-localization","topic_id":280,"display_username":"Pekka Gaiser","version":1,"can_edit":false,"can_delete":false,"can_recover":false,"read":false,"user_title":null,"reply_to_user":{"username":"sam","avatar_template":"//www.gravatar.com/avatar/3dcae8378d46c244172a115c28ca49ce.png?s={size}&r=pg&d=identicon"},"actions_summary":[{"id":2,"count":1,"hidden":false,"can_act":null},{"id":3,"count":0,"hidden":false,"can_act":null},{"id":4,"count":0,"hidden":false,"can_act":null},{"id":5,"count":0,"hidden":true,"can_act":null},{"id":6,"count":0,"hidden":false,"can_act":null},{"id":7,"count":0,"hidden":false,"can_act":null},{"id":8,"count":0,"hidden":false,"can_act":null}],"moderator":false,"staff":false,"user_id":7,"hidden":false,"hidden_reason_id":null,"trust_level":2,"deleted_at":null,"user_deleted":false,"edit_reason":null},{"id":4012,"name":"Marco Ceppi","username":"marcoceppi","avatar_template":"//www.gravatar.com/avatar/4ddc8924e79bcec03256821af65fca91.png?s={size}&r=pg&d=identicon","created_at":"2013-02-07T16:22:46.000-05:00","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-07T16:22:46.000-05:00","reply_count":0,"reply_to_post_number":null,"quote_count":0,"avg_time":10,"incoming_link_count":1,"reads":204,"score":61.3,"yours":false,"topic_slug":"internationalization-localization","topic_id":280,"display_username":"Marco Ceppi","version":1,"can_edit":false,"can_delete":false,"can_recover":false,"link_counts":[{"url":"https://translations.launchpad.net/","internal":false,"reflection":false,"clicks":8}],"read":false,"user_title":null,"actions_summary":[{"id":2,"count":1,"hidden":false,"can_act":null},{"id":3,"count":0,"hidden":false,"can_act":null},{"id":4,"count":0,"hidden":false,"can_act":null},{"id":5,"count":0,"hidden":true,"can_act":null},{"id":6,"count":0,"hidden":false,"can_act":null},{"id":7,"count":0,"hidden":false,"can_act":null},{"id":8,"count":0,"hidden":false,"can_act":null}],"moderator":false,"staff":false,"user_id":761,"hidden":false,"hidden_reason_id":null,"trust_level":2,"deleted_at":null,"user_deleted":false,"edit_reason":null}],"stream":[398,419,1060,3623,3651,3654,3655,3658,3660,3667,3673,3675,3690,3925,3938,3982,3989,3996,4009,4012,4025,4056,4058,4093,4129,6288,6302,6683,6687,7059,7078,7197,7445,7448,7524,7528,7784,8379,8426,8427,8569,8570,8577,8861,8992,8999,9000,9002,9015,9048,9052,9104,9408,9435,9625,9631,9655,9896,10386,10400,10547,10671,10700,10710,10714,10753,10786,10846,10893,10994,11001,11107,11221,11225,11229,11251,11660,12453,12454,12462,12624,12625,12627,12628,12629,12630,12918,13501,13507,17251,17252,17977,20706,21397,25473,30505,30512]},"draft":null,"draft_key":"topic_280","draft_sequence":null,"pinned":false,"details":{"auto_close_at":null,"created_by":{"id":255,"username":"uwe_keim","avatar_template":"//www.gravatar.com/avatar/53a82f701ae492808834e621de2586eb.png?s={size}&r=pg&d=identicon"},"last_poster":{"id":19,"username":"eviltrout","avatar_template":"//www.gravatar.com/avatar/c6e17f2ae2a215e87ff9e878a4e63cd9.png?s={size}&r=pg&d=identicon"},"participants":[{"id":212,"username":"alxndr","avatar_template":"//www.gravatar.com/avatar/51c9cfe3d5ebd64a79983aa3117f4aed.png?s={size}&r=pg&d=identicon","post_count":11},{"id":1,"username":"sam","avatar_template":"//www.gravatar.com/avatar/3dcae8378d46c244172a115c28ca49ce.png?s={size}&r=pg&d=identicon","post_count":11},{"id":7,"username":"pekka","avatar_template":"//www.gravatar.com/avatar/100a6c42a31a56e882475725d65537f8.png?s={size}&r=pg&d=identicon","post_count":8},{"id":461,"username":"kuba","avatar_template":"//www.gravatar.com/avatar/1835cb6a5f35bd4089e416a99af90f5f.png?s={size}&r=pg&d=identicon","post_count":7},{"id":2995,"username":"tattoo","avatar_template":"//www.gravatar.com/avatar/645454e097898e3f0d9a54c699995678.png?s={size}&r=pg&d=identicon","post_count":6},{"id":2540,"username":"jgourdon","avatar_template":"//www.gravatar.com/avatar/3f0ee7e17ec820c458958ed7b0e8538b.png?s={size}&r=pg&d=identicon","post_count":5},{"id":1860,"username":"emk","avatar_template":"//www.gravatar.com/avatar/528ca205857ff8f648359dcd3e74c84a.png?s={size}&r=pg&d=identicon","post_count":4},{"id":19,"username":"eviltrout","avatar_template":"//www.gravatar.com/avatar/c6e17f2ae2a215e87ff9e878a4e63cd9.png?s={size}&r=pg&d=identicon","post_count":4},{"id":1275,"username":"dacap","avatar_template":"//www.gravatar.com/avatar/ec0ebc7c17f649d03ee78d4eba56ef73.png?s={size}&r=pg&d=identicon","post_count":4},{"id":3190,"username":"gururea","avatar_template":"//www.gravatar.com/avatar/5ffb222c9c1bd2d99d9267c1557ca984.png?s={size}&r=pg&d=identicon","post_count":3},{"id":1895,"username":"maciek","avatar_template":"//www.gravatar.com/avatar/e3fe0c49f509994d67045602f49808ee.png?s={size}&r=pg&d=identicon","post_count":3},{"id":3704,"username":"mojzis","avatar_template":"//localhost:3000/uploads/default/avatars/2d3/5f5/e677798a1a/{size}.jpg","post_count":3},{"id":22,"username":"splattne","avatar_template":"//www.gravatar.com/avatar/7847006dbf49f1722b07c8da396f1275.png?s={size}&r=pg&d=identicon","post_count":2},{"id":32,"username":"codinghorror","avatar_template":"//www.gravatar.com/avatar/51d623f33f8b83095db84ff35e15dbe8.png?s={size}&r=pg&d=identicon","post_count":2},{"id":1979,"username":"Superuser","avatar_template":"//www.gravatar.com/avatar/a7f1529299c8fb9a263b8e8afcab23da.png?s={size}&r=pg&d=identicon","post_count":2},{"id":3620,"username":"potthast","avatar_template":"//www.gravatar.com/avatar/1753724263a5dee3e38790e6ac3d685c.png?s={size}&r=pg&d=identicon","post_count":2},{"id":3818,"username":"Tudor","avatar_template":"//www.gravatar.com/avatar/8f367608e1d013beed72a8941bb768ca.png?s={size}&r=pg&d=identicon","post_count":2},{"id":9,"username":"tms","avatar_template":"//www.gravatar.com/avatar/3981cd271c302f5cba628c6b6d2b32ee.png?s={size}&r=pg&d=identicon","post_count":2},{"id":255,"username":"uwe_keim","avatar_template":"//www.gravatar.com/avatar/53a82f701ae492808834e621de2586eb.png?s={size}&r=pg&d=identicon","post_count":1},{"id":2753,"username":"mikl","avatar_template":"//www.gravatar.com/avatar/2c3b9882e6898958b892a218b5493af9.png?s={size}&r=pg&d=identicon","post_count":1},{"id":5052,"username":"vulkanino","avatar_template":"//www.gravatar.com/avatar/811bf232b634245aebba5323462d885c.png?s={size}&r=pg&d=identicon","post_count":1},{"id":761,"username":"marcoceppi","avatar_template":"//www.gravatar.com/avatar/4ddc8924e79bcec03256821af65fca91.png?s={size}&r=pg&d=identicon","post_count":1},{"id":2316,"username":"pakl","avatar_template":"//www.gravatar.com/avatar/42910619ef3d550e37f7150caa0d94ff.png?s={size}&r=pg&d=identicon","post_count":1},{"id":5564,"username":"Sjors","avatar_template":"//www.gravatar.com/avatar/2fb09bd6501779802459a171d3f8fbd9.png?s={size}&r=pg&d=identicon","post_count":1}],"suggested_topics":[{"id":5894,"title":"Spam-blocking URL Blacklist","fancy_title":"Spam-blocking URL Blacklist","slug":"spam-blocking-url-blacklist","posts_count":5,"reply_count":3,"highest_post_number":5,"image_url":null,"created_at":"2013-04-15T11:09:39.000-04:00","last_posted_at":"2013-04-15T13:33:11.000-04:00","bumped":true,"bumped_at":"2013-04-15T13:33:11.000-04:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"archetype":"regular","like_count":3,"views":244,"category_id":2},{"id":7116,"title":"Custom fields and Custom post types - (ex. article, recipe)","fancy_title":"Custom fields and Custom post types - (ex. article, recipe)","slug":"custom-fields-and-custom-post-types-ex-article-recipe","posts_count":2,"reply_count":1,"highest_post_number":2,"image_url":null,"created_at":"2013-06-02T17:34:37.000-04:00","last_posted_at":"2013-06-03T01:27:34.000-04:00","bumped":true,"bumped_at":"2013-06-03T01:27:34.000-04:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"archetype":"regular","like_count":0,"views":206,"category_id":2},{"id":7111,"title":"Make the 1 level of sub-categorization freeform","fancy_title":"Make the 1 level of sub-categorization freeform","slug":"make-the-1-level-of-sub-categorization-freeform","posts_count":8,"reply_count":4,"highest_post_number":8,"image_url":"https://www.gravatar.com/avatar/51d623f33f8b83095db84ff35e15dbe8.png?s=40&r=pg&d=identicon","created_at":"2013-06-02T14:47:06.000-04:00","last_posted_at":"2013-06-04T18:07:33.000-04:00","bumped":true,"bumped_at":"2013-06-04T18:07:33.000-04:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"archetype":"regular","like_count":0,"views":197,"category_id":2},{"id":1127,"title":"Automated Signatures?","fancy_title":"Automated Signatures?","slug":"automated-signatures","posts_count":75,"reply_count":57,"highest_post_number":76,"image_url":null,"created_at":"2013-02-06T05:40:37.000-05:00","last_posted_at":"2013-11-19T18:31:32.000-05:00","bumped":true,"bumped_at":"2013-11-19T18:31:32.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"archetype":"regular","like_count":156,"views":2371,"category_id":2},{"id":8667,"title":"Add \"Share\" modal to original thread post?","fancy_title":"Add “Share” modal to original thread post?","slug":"add-share-modal-to-original-thread-post","posts_count":1,"reply_count":0,"highest_post_number":1,"image_url":"/uploads/meta_discourse/1497/ff2adcd46c71a223.png","created_at":"2013-07-26T10:22:48.000-04:00","last_posted_at":"2013-07-26T10:22:49.000-04:00","bumped":true,"bumped_at":"2013-07-26T10:22:49.000-04:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"archetype":"regular","like_count":0,"views":122,"category_id":2},{"id":708,"title":"Special topic types","fancy_title":"Special topic types","slug":"special-topic-types","posts_count":1,"reply_count":0,"highest_post_number":1,"image_url":null,"created_at":"2013-02-05T21:03:26.000-05:00","last_posted_at":"2013-02-05T21:03:26.000-05:00","bumped":false,"bumped_at":"2013-02-05T21:03:26.000-05:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"archetype":"regular","like_count":1,"views":108,"category_id":2},{"id":6569,"title":"Linebreaks require two trailing spaces in blockquotes, etc","fancy_title":"Linebreaks require two trailing spaces in blockquotes, etc","slug":"linebreaks-require-two-trailing-spaces-in-blockquotes-etc","posts_count":3,"reply_count":2,"highest_post_number":3,"image_url":null,"created_at":"2013-05-12T02:40:48.000-04:00","last_posted_at":"2013-05-15T11:01:50.000-04:00","bumped":true,"bumped_at":"2013-05-15T11:01:50.000-04:00","unseen":false,"pinned":false,"visible":true,"closed":false,"archived":false,"archetype":"regular","like_count":0,"views":168,"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":"91","user_id":9},{"url":"http://www.localeapp.com/","title":null,"fancy_title":null,"internal":false,"reflection":false,"clicks":"59","user_id":1860},{"url":"http://stackoverflow.com/questions/4232922/why-do-people-use-plain-english-as-translation-placeholders","title":null,"fancy_title":null,"internal":false,"reflection":false,"clicks":"56","user_id":7},{"url":"https://github.com/SlexAxton/messageformat.js","title":null,"fancy_title":null,"internal":false,"reflection":false,"clicks":"38","user_id":1},{"url":"https://github.com/discourse/discourse/blob/master/config/locales","title":null,"fancy_title":null,"internal":false,"reflection":false,"clicks":"27","user_id":32},{"url":"http://www.localeapp.com/projects/1537/translations?utf8=%E2%9C%93&search=source_code","title":null,"fancy_title":null,"internal":false,"reflection":false,"clicks":"21","user_id":1860},{"url":"https://github.com/berk/tr8n","title":null,"fancy_title":null,"internal":false,"reflection":false,"clicks":"20","user_id":1},{"url":"https://translations.launchpad.net/","title":null,"fancy_title":null,"internal":false,"reflection":false,"clicks":"18","user_id":761},{"url":"https://www.transifex.com/","title":null,"fancy_title":null,"internal":false,"reflection":false,"clicks":"16","user_id":1979},{"url":"http://translate.wordpress.org/projects/bbpress/dev","title":null,"fancy_title":null,"internal":false,"reflection":false,"clicks":"14","user_id":7},{"url":"http://weblate.org","title":null,"fancy_title":null,"internal":false,"reflection":false,"clicks":"13","user_id":2316},{"url":"https://github.com/discourse/discourse/pull/493","title":null,"fancy_title":null,"internal":false,"reflection":false,"clicks":"12","user_id":2753},{"url":"https://github.com/SlexAxton","title":null,"fancy_title":null,"internal":false,"reflection":false,"clicks":"9","user_id":1},{"url":"https://github.com/discourse/discourse/tree/master/config/locales","title":null,"fancy_title":null,"internal":false,"reflection":false,"clicks":"8","user_id":19},{"url":"https://github.com/gururea/discourse/tree/master/config/locales","title":null,"fancy_title":null,"internal":false,"reflection":false,"clicks":"8","user_id":3190},{"url":"https://github.com/dacap/discourse/tree/spanish","title":null,"fancy_title":null,"internal":false,"reflection":false,"clicks":"7","user_id":1275},{"url":"https://github.com/discourse/discourse/blob/master/config/locales/client.en.yml#L691","title":null,"fancy_title":null,"internal":false,"reflection":false,"clicks":"7","user_id":461},{"url":"https://github.com/discourse/discourse/blob/master/config/locales/client.nl.yml","title":null,"fancy_title":null,"internal":false,"reflection":false,"clicks":"5","user_id":461},{"url":"http://tr8n.github.com/","title":null,"fancy_title":null,"internal":false,"reflection":false,"clicks":"5","user_id":212},{"url":"https://github.com/discourse/discourse/commit/c5761eae8afe37e20cec0d0f9d14b85b6e585bda","title":null,"fancy_title":null,"internal":false,"reflection":false,"clicks":"5","user_id":212},{"url":"http://www.getlocalization.com/","title":null,"fancy_title":null,"internal":false,"reflection":false,"clicks":"5","user_id":22},{"url":"http://blog.discourse.org/2013/04/discourse-as-your-first-rails-app/","title":null,"fancy_title":null,"internal":false,"reflection":false,"clicks":"4","user_id":1995},{"url":"http://translate.sourceforge.net/wiki/virtaal/index","title":null,"fancy_title":null,"internal":false,"reflection":false,"clicks":"4","user_id":1979},{"url":"https://poeditor.com/","title":null,"fancy_title":null,"internal":false,"reflection":false,"clicks":"4","user_id":1979},{"url":"http://www.madanalogy.com/2012/06/rails-i18n-translations-in-yaml.html","title":null,"fancy_title":null,"internal":false,"reflection":false,"clicks":"3","user_id":3190},{"url":"http://meta.discourse.org/t/translation-workflow/6102/6","title":"Translation workflow","fancy_title":null,"internal":true,"reflection":false,"clicks":"3","user_id":1995},{"url":"https://github.com/alxndr/discourse/blob/i18n-chinese/config/locales/server.zh.yml","title":null,"fancy_title":null,"internal":false,"reflection":false,"clicks":"3","user_id":212},{"url":"http://www.slideshare.net/HeatherRivers/linguistic-potluck-crowdsourcing-localization-with-rails","title":null,"fancy_title":null,"internal":false,"reflection":false,"clicks":"2","user_id":212},{"url":"http://www.youtube.com/watch?v=MqqdzJ98q7s","title":null,"fancy_title":null,"internal":false,"reflection":false,"clicks":"2","user_id":212},{"url":"http://en.wikipedia.org/wiki/T%E2%80%93V_distinction","title":null,"fancy_title":null,"internal":false,"reflection":false,"clicks":"2","user_id":3620},{"url":"http://pootle.locamotion.org/","title":null,"fancy_title":null,"internal":false,"reflection":false,"clicks":"2","user_id":3190},{"url":"http://en.lichess.org/@/Hellball","title":null,"fancy_title":null,"internal":false,"reflection":false,"clicks":"2","user_id":1979},{"url":"http://sugarjs.com/dates#date_locales","title":null,"fancy_title":null,"internal":false,"reflection":false,"clicks":"1","user_id":461},{"url":"http://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},{"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},{"url":"http://meta.discourse.org/t/changing-language-phrase-does-not-affect-on-the-site/8429/3","title":"Changing language phrase does not affect on the site?","fancy_title":null,"internal":true,"reflection":true,"clicks":"0","user_id":6122},{"url":"http://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":"0","user_id":639},{"url":"http://meta.discourse.org/t/translation-workflow/6102","title":"Translation workflow","fancy_title":null,"internal":true,"reflection":true,"clicks":"0","user_id":4702},{"url":"http://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},{"url":"http://meta.discourse.org/t/suggestion-translation-on-admin-panel/6923/5","title":"Suggestion: Translation on admin panel","fancy_title":null,"internal":true,"reflection":true,"clicks":"0","user_id":1},{"url":"http://meta.discourse.org/t/roadplan-for-discourse/2939/5","title":"Roadplan for Discourse","fancy_title":null,"internal":true,"reflection":true,"clicks":"0","user_id":32},{"url":"http://meta.discourse.org/t/internationalization-i18n-provided-for-discourse/2073/2","title":"Internationalization I18n provided for discourse ?","fancy_title":null,"internal":true,"reflection":true,"clicks":"0","user_id":114},{"url":"http://meta.discourse.org/t/language-mirrors/2378/2","title":"Language mirrors","fancy_title":null,"internal":true,"reflection":true,"clicks":"0","user_id":32},{"url":"http://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},{"url":"http://meta.discourse.org/t/roadplan-for-discourse/2939/3","title":"Roadplan for Discourse","fancy_title":null,"internal":true,"reflection":true,"clicks":"0","user_id":2540},{"url":"http://meta.discourse.org/t/missing-user-value-in-chinese-localized-page/7406/6","title":"[missing {{user}} value] in Chinese localized page","fancy_title":null,"internal":true,"reflection":true,"clicks":"0","user_id":1},{"url":"http://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},{"url":"http://guides.rubyonrails.org/i18n.html#the-public-i18n-api","title":null,"fancy_title":null,"internal":false,"reflection":false,"clicks":"0","user_id":1895},{"url":"http://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},{"url":"http://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}]},"highest_post_number":98,"deleted_by":null,"id":280,"title":"Internationalization / localization","fancy_title":"Internationalization / localization","posts_count":97,"created_at":"2013-02-05T16:29:00.000-05:00","views":3500,"reply_count":66,"participant_count":34,"like_count":128,"last_posted_at":"2013-10-13T13:30:47.000-04:00","visible":true,"closed":false,"archived":false,"has_summary":true,"archetype":"regular","slug":"internationalization-localization","category_id":2,"word_count":5576,"deleted_at":null}}; +export default {"/t/280.json": {"post_stream":{"posts":[{"id":398,"name":"Uwe Keim","username":"uwe_keim","avatar_template":"/user_avatar/meta.discourse.org/uwe_keim/{size}/5697.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":2,"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":"/user_avatar/meta.discourse.org/tms/{size}/40181.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":"/user_avatar/meta.discourse.org/codinghorror/{size}/5297.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":"/user_avatar/meta.discourse.org/shade/{size}/8306.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":"/user_avatar/meta.discourse.org/pekka/{size}/5253.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":"/user_avatar/meta.discourse.org/sam/{size}/5243.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":"/user_avatar/meta.discourse.org/pekka/{size}/5253.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":"/user_avatar/meta.discourse.org/pekka/{size}/5253.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":"/user_avatar/meta.discourse.org/sam/{size}/5243.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":"/user_avatar/meta.discourse.org/pekka/{size}/5253.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":"/user_avatar/meta.discourse.org/pekka/{size}/5253.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":"/user_avatar/meta.discourse.org/tms/{size}/40181.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":"/user_avatar/meta.discourse.org/pekka/{size}/5253.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":"/user_avatar/meta.discourse.org/sam/{size}/5243.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":"/user_avatar/meta.discourse.org/tms/{size}/40181.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":"/user_avatar/meta.discourse.org/pekka/{size}/5253.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":"/user_avatar/meta.discourse.org/vilx/{size}/7299.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":"/user_avatar/meta.discourse.org/emk/{size}/8400.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":"/user_avatar/meta.discourse.org/pekka/{size}/5253.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":"/user_avatar/meta.discourse.org/emk/{size}/8400.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":"/user_avatar/meta.discourse.org/pekka/{size}/5253.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":"/user_avatar/meta.discourse.org/sam/{size}/5243.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":"/user_avatar/meta.discourse.org/pekka/{size}/5253.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":"/user_avatar/meta.discourse.org/pekka/{size}/5253.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":"/user_avatar/meta.discourse.org/sam/{size}/5243.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":"/user_avatar/meta.discourse.org/marcoceppi/{size}/6552.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":"/user_avatar/meta.discourse.org/uwe_keim/{size}/5697.png"},"last_poster":{"id":14091,"username":"Luciano_Fantuzzi","uploaded_avatar_id":39484,"avatar_template":"/user_avatar/meta.discourse.org/luciano_fantuzzi/{size}/39484.png"},"participants":[{"id":212,"username":"alxndr","uploaded_avatar_id":5619,"avatar_template":"/user_avatar/meta.discourse.org/alxndr/{size}/5619.png","post_count":11},{"id":1,"username":"sam","uploaded_avatar_id":5243,"avatar_template":"/user_avatar/meta.discourse.org/sam/{size}/5243.png","post_count":11},{"id":7,"username":"pekka","uploaded_avatar_id":5253,"avatar_template":"/user_avatar/meta.discourse.org/pekka/{size}/5253.png","post_count":8},{"id":461,"username":"kuba","uploaded_avatar_id":6049,"avatar_template":"/user_avatar/meta.discourse.org/kuba/{size}/6049.png","post_count":7},{"id":2995,"username":"tattoo","uploaded_avatar_id":null,"avatar_template":"/letter_avatar/tattoo/{size}/3.png","post_count":6},{"id":2540,"username":"jgourdon","uploaded_avatar_id":9537,"avatar_template":"/user_avatar/meta.discourse.org/jgourdon/{size}/9537.png","post_count":5},{"id":1860,"username":"emk","uploaded_avatar_id":8400,"avatar_template":"/user_avatar/meta.discourse.org/emk/{size}/8400.png","post_count":4},{"id":1275,"username":"dacap","uploaded_avatar_id":7401,"avatar_template":"/user_avatar/meta.discourse.org/dacap/{size}/7401.png","post_count":4},{"id":19,"username":"eviltrout","uploaded_avatar_id":5275,"avatar_template":"/user_avatar/meta.discourse.org/eviltrout/{size}/5275.png","post_count":4},{"id":3704,"username":"mojzis","uploaded_avatar_id":31201,"avatar_template":"/user_avatar/meta.discourse.org/mojzis/{size}/31201.png","post_count":3},{"id":3190,"username":"gururea","uploaded_avatar_id":10663,"avatar_template":"/user_avatar/meta.discourse.org/gururea/{size}/10663.png","post_count":3},{"id":1895,"username":"maciek","uploaded_avatar_id":8463,"avatar_template":"/user_avatar/meta.discourse.org/maciek/{size}/8463.png","post_count":3},{"id":22,"username":"splattne","uploaded_avatar_id":5280,"avatar_template":"/user_avatar/meta.discourse.org/splattne/{size}/5280.png","post_count":2},{"id":1979,"username":"Superuser","uploaded_avatar_id":8604,"avatar_template":"/user_avatar/meta.discourse.org/superuser/{size}/8604.png","post_count":2},{"id":3818,"username":"Tudor","uploaded_avatar_id":11675,"avatar_template":"/user_avatar/meta.discourse.org/tudor/{size}/11675.png","post_count":2},{"id":32,"username":"codinghorror","uploaded_avatar_id":5297,"avatar_template":"/user_avatar/meta.discourse.org/codinghorror/{size}/5297.png","post_count":2},{"id":3620,"username":"potthast","uploaded_avatar_id":11363,"avatar_template":"/user_avatar/meta.discourse.org/potthast/{size}/11363.png","post_count":2},{"id":9,"username":"tms","uploaded_avatar_id":40181,"avatar_template":"/user_avatar/meta.discourse.org/tms/{size}/40181.png","post_count":2},{"id":14091,"username":"Luciano_Fantuzzi","uploaded_avatar_id":39484,"avatar_template":"/user_avatar/meta.discourse.org/luciano_fantuzzi/{size}/39484.png","post_count":1},{"id":255,"username":"uwe_keim","uploaded_avatar_id":5697,"avatar_template":"/user_avatar/meta.discourse.org/uwe_keim/{size}/5697.png","post_count":1},{"id":9006,"username":"berk","uploaded_avatar_id":19348,"avatar_template":"/user_avatar/meta.discourse.org/berk/{size}/19348.png","post_count":1},{"id":754,"username":"danneu","uploaded_avatar_id":6540,"avatar_template":"/user_avatar/meta.discourse.org/danneu/{size}/6540.png","post_count":1},{"id":761,"username":"marcoceppi","uploaded_avatar_id":6552,"avatar_template":"/user_avatar/meta.discourse.org/marcoceppi/{size}/6552.png","post_count":1},{"id":2753,"username":"mikl","uploaded_avatar_id":9918,"avatar_template":"/user_avatar/meta.discourse.org/mikl/{size}/9918.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":104,"last_read_post_number":104,"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}}; diff --git a/test/javascripts/helpers/create-pretender.js.es6 b/test/javascripts/helpers/create-pretender.js.es6 index cc51c3db0b..e6184eee2f 100644 --- a/test/javascripts/helpers/create-pretender.js.es6 +++ b/test/javascripts/helpers/create-pretender.js.es6 @@ -2,7 +2,7 @@ function parsePostData(query) { const result = {}; query.split("&").forEach(function(part) { const item = part.split("="); - result[item[0]] = decodeURIComponent(item[1]); + result[item[0]] = decodeURIComponent(item[1]).replace(/\+/g, ' '); }); return result; } @@ -33,9 +33,16 @@ const _moreWidgets = [ {id: 224, name: 'Good Repellant'} ]; +function loggedIn() { + return !!Discourse.User.current(); +} + export default function() { + const server = new Pretender(function() { + const fixturesByUrl = {}; + // Load any fixtures automatically const self = this; Ember.keys(require._eak_seen).forEach(function(entry) { @@ -44,6 +51,7 @@ export default function() { if (fixture && fixture.default) { const obj = fixture.default; Ember.keys(obj).forEach(function(url) { + fixturesByUrl[url] = obj[url]; self.get(url, function() { return response(obj[url]); }); @@ -52,6 +60,20 @@ export default function() { } }); + this.get('/composer-messages', () => { return response([]); }); + + this.get("/latest.json", () => { + const json = fixturesByUrl['/latest.json']; + + if (loggedIn()) { + // Stuff to let us post + json.topic_list.can_create_topic = true; + json.topic_list.draft_key = "new_topic"; + json.topic_list.draft_sequence = 1; + } + return response(json); + }); + this.get("/t/id_for/:slug", function() { return response({id: 280, slug: "internationalization-localization", url: "/t/internationalization-localization/280"}); }); @@ -99,6 +121,33 @@ export default function() { this.delete('/posts/:post_id', success); this.put('/posts/:post_id/recover', success); + this.put('/posts/:post_id', (request) => { + return response({ post: {id: request.params.post_id, version: 2 } }); + }); + + this.put('/t/:slug/:id', (request) => { + const data = parsePostData(request.requestBody); + + return response(200, { basic_topic: {id: request.params.id, + title: data.title, + fancy_title: data.title, + slug: request.params.slug } }) + }); + + this.post('/posts', function(request) { + const data = parsePostData(request.requestBody); + + if (data.title === "this title triggers an error") { + return response(422, {errors: ['That title has already been taken']}); + } else { + return response(200, { + success: true, + action: 'create_post', + post: {id: 12345, topic_id: 280, topic_slug: 'internationalization-localization'} + }); + } + }); + this.get('/widgets/:widget_id', function(request) { const w = _widgets.findBy('id', parseInt(request.params.widget_id)); if (w) { @@ -130,8 +179,11 @@ export default function() { }); this.delete('/widgets/:widget_id', success); - }); + this.post('/topics/timings', function() { + return response(200, {}); + }); + }); server.prepareBody = function(body){ if (body && typeof body === "object") { diff --git a/test/javascripts/helpers/qunit-helpers.js.es6 b/test/javascripts/helpers/qunit-helpers.js.es6 index 17b82bd171..054a4686a4 100644 --- a/test/javascripts/helpers/qunit-helpers.js.es6 +++ b/test/javascripts/helpers/qunit-helpers.js.es6 @@ -1,20 +1,59 @@ /* global asyncTest */ +import sessionFixtures from 'fixtures/session-fixtures'; import siteFixtures from 'fixtures/site_fixtures'; +import HeaderView from 'discourse/views/header'; + +function currentUser() { + return Discourse.User.create(sessionFixtures['/session/current.json'].current_user); +} + +function logIn() { + Discourse.User.resetCurrent(currentUser()); +} + +const Plugin = $.fn.modal; +const Modal = Plugin.Constructor; + +function AcceptanceModal(option, _relatedTarget) { + return this.each(function () { + var $this = $(this); + var data = $this.data('bs.modal'); + var options = $.extend({}, Modal.DEFAULTS, $this.data(), typeof option === 'object' && option); + + if (!data) $this.data('bs.modal', (data = new Modal(this, options))); + data.$body = $('#ember-testing'); + + if (typeof option === 'string') data[option](_relatedTarget); + else if (options.show) data.show(_relatedTarget); + }); +} + +window.bootbox.$body = $('#ember-testing'); +$.fn.modal = AcceptanceModal; + +var oldAvatar = Discourse.Utilities.avatarImg; function acceptance(name, options) { module("Acceptance: " + name, { setup: function() { Ember.run(Discourse, Discourse.advanceReadiness); + // Don't render avatars in acceptance tests, it's faster and no 404s + Discourse.Utilities.avatarImg = () => ""; + + // For now don't do scrolling stuff in Test Mode + Ember.CloakedCollectionView.scrolled = Ember.K; + HeaderView.reopen({examineDockHeader: Ember.K}); + var siteJson = siteFixtures['site.json'].site; if (options) { if (options.setup) { options.setup.call(this); } - if (options.user) { - Discourse.User.resetCurrent(Discourse.User.create(options.user)); + if (options.loggedIn) { + logIn(); } if (options.settings) { @@ -34,6 +73,7 @@ function acceptance(name, options) { options.teardown.call(this); } + Discourse.Utilities.avatarImg = oldAvatar; Discourse.reset(); } }); @@ -61,4 +101,4 @@ function fixture(selector) { return $("#qunit-fixture"); } -export { acceptance, controllerFor, asyncTestDiscourse, fixture }; +export { acceptance, controllerFor, asyncTestDiscourse, fixture, logIn, currentUser }; diff --git a/test/javascripts/models/composer-test.js.es6 b/test/javascripts/models/composer-test.js.es6 index 413156077e..1727d2893a 100644 --- a/test/javascripts/models/composer-test.js.es6 +++ b/test/javascripts/models/composer-test.js.es6 @@ -1,16 +1,18 @@ -module("Discourse.Composer", { - setup: function() { - sandbox.stub(Discourse.User, 'currentProp').withArgs('admin').returns(false); - }, +import { currentUser } from 'helpers/qunit-helpers'; - teardown: function() { - Discourse.User.currentProp.restore(); - } -}); +module("model:composer"); + +function createComposer(opts) { + opts = opts || {}; + opts.user = opts.user || currentUser(); + opts.site = Discourse.Site.current(); + opts.siteSettings = Discourse.SiteSettings; + return Discourse.Composer.create(opts); +} test('replyLength', function() { - var replyLength = function(val, expectedLength) { - var composer = Discourse.Composer.create({ reply: val }); + const replyLength = function(val, expectedLength) { + const composer = createComposer({ reply: val }); equal(composer.get('replyLength'), expectedLength); }; @@ -23,8 +25,8 @@ test('replyLength', function() { test('missingReplyCharacters', function() { Discourse.SiteSettings.min_first_post_length = 40; - var missingReplyCharacters = function(val, isPM, isFirstPost, expected, message) { - var composer = Discourse.Composer.create({ reply: val, creatingPrivateMessage: isPM, creatingTopic: isFirstPost }); + const missingReplyCharacters = function(val, isPM, isFirstPost, expected, message) { + const composer = createComposer({ reply: val, creatingPrivateMessage: isPM, creatingTopic: isFirstPost }); equal(composer.get('missingReplyCharacters'), expected, message); }; @@ -34,8 +36,8 @@ test('missingReplyCharacters', function() { }); test('missingTitleCharacters', function() { - var missingTitleCharacters = function(val, isPM, expected, message) { - var composer = Discourse.Composer.create({ title: val, creatingPrivateMessage: isPM }); + const missingTitleCharacters = function(val, isPM, expected, message) { + const composer = createComposer({ title: val, creatingPrivateMessage: isPM }); equal(composer.get('missingTitleCharacters'), expected, message); }; @@ -44,7 +46,7 @@ test('missingTitleCharacters', function() { }); test('replyDirty', function() { - var composer = Discourse.Composer.create(); + const composer = createComposer(); ok(!composer.get('replyDirty'), "by default it's false"); composer.setProperties({ @@ -58,7 +60,7 @@ test('replyDirty', function() { }); test("appendText", function() { - var composer = Discourse.Composer.create(); + const composer = createComposer(); blank(composer.get('reply'), "the reply is blank by default"); @@ -89,7 +91,7 @@ test("appendText", function() { test("Title length for regular topics", function() { Discourse.SiteSettings.min_topic_title_length = 5; Discourse.SiteSettings.max_topic_title_length = 10; - var composer = Discourse.Composer.create(); + const composer = createComposer(); composer.set('title', 'asdf'); ok(!composer.get('titleLengthValid'), "short titles are not valid"); @@ -104,7 +106,7 @@ test("Title length for regular topics", function() { test("Title length for private messages", function() { Discourse.SiteSettings.min_private_message_title_length = 5; Discourse.SiteSettings.max_topic_title_length = 10; - var composer = Discourse.Composer.create({action: Discourse.Composer.PRIVATE_MESSAGE}); + const composer = createComposer({action: Discourse.Composer.PRIVATE_MESSAGE}); composer.set('title', 'asdf'); ok(!composer.get('titleLengthValid'), "short titles are not valid"); @@ -119,7 +121,7 @@ test("Title length for private messages", function() { test("Title length for private messages", function() { Discourse.SiteSettings.min_private_message_title_length = 5; Discourse.SiteSettings.max_topic_title_length = 10; - var composer = Discourse.Composer.create({action: Discourse.Composer.PRIVATE_MESSAGE}); + const composer = createComposer({action: Discourse.Composer.PRIVATE_MESSAGE}); composer.set('title', 'asdf'); ok(!composer.get('titleLengthValid'), "short titles are not valid"); @@ -132,10 +134,10 @@ test("Title length for private messages", function() { }); test('editingFirstPost', function() { - var composer = Discourse.Composer.create(); + const composer = createComposer(); ok(!composer.get('editingFirstPost'), "it's false by default"); - var post = Discourse.Post.create({id: 123, post_number: 2}); + const post = Discourse.Post.create({id: 123, post_number: 2}); composer.setProperties({post: post, action: Discourse.Composer.EDIT }); ok(!composer.get('editingFirstPost'), "it's false when not editing the first post"); @@ -145,7 +147,7 @@ test('editingFirstPost', function() { }); test('clearState', function() { - var composer = Discourse.Composer.create({ + const composer = createComposer({ originalText: 'asdf', reply: 'asdf2', post: Discourse.Post.create({id: 1}), @@ -163,61 +165,48 @@ test('clearState', function() { test('initial category when uncategorized is allowed', function() { Discourse.SiteSettings.allow_uncategorized_topics = true; - var composer = Discourse.Composer.open({action: 'createTopic', draftKey: 'asfd', draftSequence: 1}); + const composer = Discourse.Composer.open({action: 'createTopic', draftKey: 'asfd', draftSequence: 1}); equal(composer.get('categoryId'),undefined,"Uncategorized by default"); }); test('initial category when uncategorized is not allowed', function() { Discourse.SiteSettings.allow_uncategorized_topics = false; - var composer = Discourse.Composer.open({action: 'createTopic', draftKey: 'asfd', draftSequence: 1}); + const composer = Discourse.Composer.open({action: 'createTopic', draftKey: 'asfd', draftSequence: 1}); ok(composer.get('categoryId') === undefined, "Uncategorized by default. Must choose a category."); }); test('showPreview', function() { - var new_composer = function() { + const newComposer = function() { return Discourse.Composer.open({action: 'createTopic', draftKey: 'asfd', draftSequence: 1}); }; Discourse.Mobile.mobileView = true; - equal(new_composer().get('showPreview'), false, "Don't show preview in mobile view"); + equal(newComposer().get('showPreview'), false, "Don't show preview in mobile view"); Discourse.KeyValueStore.set({ key: 'composer.showPreview', value: 'true' }); - equal(new_composer().get('showPreview'), false, "Don't show preview in mobile view even if KeyValueStore wants to"); + equal(newComposer().get('showPreview'), false, "Don't show preview in mobile view even if KeyValueStore wants to"); Discourse.KeyValueStore.remove('composer.showPreview'); Discourse.Mobile.mobileView = false; - equal(new_composer().get('showPreview'), true, "Show preview by default in desktop view"); + equal(newComposer().get('showPreview'), true, "Show preview by default in desktop view"); }); test('open with a quote', function() { - var quote = '[quote="neil, post:5, topic:413"]\nSimmer down you two.\n[/quote]'; - var new_composer = function() { + const quote = '[quote="neil, post:5, topic:413"]\nSimmer down you two.\n[/quote]'; + const newComposer = function() { return Discourse.Composer.open({action: Discourse.Composer.REPLY, draftKey: 'asfd', draftSequence: 1, quote: quote}); }; - equal(new_composer().get('originalText'), quote, "originalText is the quote" ); - equal(new_composer().get('replyDirty'), false, "replyDirty is initally false with a quote" ); -}); - - -module("Discourse.Composer as admin", { - setup: function() { - Discourse.SiteSettings.min_topic_title_length = 5; - Discourse.SiteSettings.max_topic_title_length = 10; - sandbox.stub(Discourse.User, 'currentProp').withArgs('admin').returns(true); - }, - - teardown: function() { - Discourse.SiteSettings.min_topic_title_length = 15; - Discourse.SiteSettings.max_topic_title_length = 255; - Discourse.User.currentProp.restore(); - } + equal(newComposer().get('originalText'), quote, "originalText is the quote" ); + equal(newComposer().get('replyDirty'), false, "replyDirty is initally false with a quote" ); }); test("Title length for static page topics as admin", function() { - var composer = Discourse.Composer.create(); + Discourse.SiteSettings.min_topic_title_length = 5; + Discourse.SiteSettings.max_topic_title_length = 10; + const composer = createComposer(); - var post = Discourse.Post.create({id: 123, post_number: 2, static_doc: true}); + const post = Discourse.Post.create({id: 123, post_number: 2, static_doc: true}); composer.setProperties({post: post, action: Discourse.Composer.EDIT }); composer.set('title', 'asdf'); diff --git a/test/javascripts/test_helper.js b/test/javascripts/test_helper.js index 87c2812f06..bc38f15d11 100644 --- a/test/javascripts/test_helper.js +++ b/test/javascripts/test_helper.js @@ -59,7 +59,11 @@ sinon.config = { useFakeServer: false }; -window.assetPath = function() { return null; }; +window.assetPath = function(url) { + if (url.indexOf('defer') === 0) { + return "/assets/" + url; + } +}; // Stop the message bus so we don't get ajax calls window.MessageBus.stop(); diff --git a/vendor/assets/javascripts/bootbox.js b/vendor/assets/javascripts/bootbox.js index d19f1557c1..69ec027eff 100644 --- a/vendor/assets/javascripts/bootbox.js +++ b/vendor/assets/javascripts/bootbox.js @@ -427,16 +427,17 @@ var bootbox = window.bootbox || (function(document, $) { }); // well, *if* we have a primary - give the first dom element focus - div.on('shown', function() { + div.on('shown.bs.modal', function() { div.find("a.btn-primary:first").focus(); }); - div.on('hidden', function() { + div.on('hidden.bs.modal', function() { div.remove(); }); // wire up button handlers div.on('click', '.modal-footer a', function(e) { + Ember.run(function() { var handler = $(this).data("handler"), cb = callbacks[handler], @@ -462,10 +463,11 @@ var bootbox = window.bootbox || (function(document, $) { if (hideModal !== false) { div.modal("hide"); } + }); }); // stick the modal right at the bottom of the main body out of the way - $("body").append(div); + (that.$body || $("body")).append(div); div.modal({ // unless explicitly overridden take whatever our default backdrop value is diff --git a/vendor/assets/javascripts/bootstrap-modal.js b/vendor/assets/javascripts/bootstrap-modal.js index 0465cb5256..1dd9c0df9d 100644 --- a/vendor/assets/javascripts/bootstrap-modal.js +++ b/vendor/assets/javascripts/bootstrap-modal.js @@ -1,218 +1,338 @@ -/* ========================================================= - * bootstrap-modal.js v2.0.3 - * http://twitter.github.com/bootstrap/javascript.html#modals - * ========================================================= - * Copyright 2012 Twitter, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * ========================================================= */ +/* ======================================================================== + * Bootstrap: modal.js v3.3.4 + * http://getbootstrap.com/javascript/#modals + * ======================================================================== + * Copyright 2011-2015 Twitter, Inc. + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + * ======================================================================== */ -!function ($) { ++function ($) { + 'use strict'; - "use strict"; // jshint ;_; + // MODAL CLASS DEFINITION + // ====================== + var Modal = function (element, options) { + this.options = options + this.$body = $(document.body) + this.$element = $(element) + this.$dialog = this.$element.find('.modal-dialog') + this.$backdrop = null + this.isShown = null + this.originalBodyPad = null + this.scrollbarWidth = 0 + this.ignoreBackdropClick = false - /* MODAL CLASS DEFINITION - * ====================== */ - - var Modal = function (content, options) { - this.options = options - this.$element = $(content) - .delegate('[data-dismiss="modal"]', 'click.dismiss.modal', $.proxy(this.hide, this)) + if (this.options.remote) { + this.$element + .find('.modal-content') + .load(this.options.remote, $.proxy(function () { + this.$element.trigger('loaded.bs.modal') + }, this)) + } } - Modal.prototype = { + Modal.VERSION = '3.3.4' - constructor: Modal - - , toggle: function () { - return this[!this.isShown ? 'show' : 'hide']() - } - - , show: function () { - var that = this - , e = $.Event('show') - - this.$element.trigger(e) - - if (this.isShown || e.isDefaultPrevented()) return - - $('body').addClass('modal-open') - - this.isShown = true - - escape.call(this) - backdrop.call(this, function () { - var transition = $.support.transition && that.$element.hasClass('fade') - - if (!that.$element.parent().length) { - that.$element.appendTo(document.body) //don't move modals dom position - } - - that.$element - .show() - - if (transition) { - that.$element[0].offsetWidth // force reflow - } - - that.$element.addClass('in') - - transition ? - that.$element.one($.support.transition.end, function () { that.$element.trigger('shown') }) : - that.$element.trigger('shown') - - }) - } - - , hide: function (e) { - e && e.preventDefault() - - var that = this - - e = $.Event('hide') - - this.$element.trigger(e) - - if (!this.isShown || e.isDefaultPrevented()) return - - this.isShown = false - - $('body').removeClass('modal-open') - - escape.call(this) - - this.$element.removeClass('in') - - $.support.transition && this.$element.hasClass('fade') ? - hideWithTransition.call(this) : - hideModal.call(this) - } + Modal.TRANSITION_DURATION = 300 + Modal.BACKDROP_TRANSITION_DURATION = 150 + Modal.DEFAULTS = { + backdrop: true, + keyboard: true, + show: true } + Modal.prototype.toggle = function (_relatedTarget) { + return this.isShown ? this.hide() : this.show(_relatedTarget) + } - /* MODAL PRIVATE METHODS - * ===================== */ - - function hideWithTransition() { + Modal.prototype.show = function (_relatedTarget) { var that = this - , timeout = setTimeout(function () { - that.$element.off($.support.transition.end) - hideModal.call(that) - }, 500) + var e = $.Event('show.bs.modal', { relatedTarget: _relatedTarget }) - this.$element.one($.support.transition.end, function () { - clearTimeout(timeout) - hideModal.call(that) + this.$element.trigger(e) + + if (this.isShown || e.isDefaultPrevented()) return + + this.isShown = true + + this.checkScrollbar() + this.setScrollbar() + this.$body.addClass('modal-open') + + this.escape() + this.resize() + + this.$element.on('click.dismiss.bs.modal', '[data-dismiss="modal"]', $.proxy(this.hide, this)) + + this.$dialog.on('mousedown.dismiss.bs.modal', function () { + that.$element.one('mouseup.dismiss.bs.modal', function (e) { + if ($(e.target).is(that.$element)) that.ignoreBackdropClick = true + }) + }) + + this.backdrop(function () { + var transition = $.support.transition && that.$element.hasClass('fade') + + if (!that.$element.parent().length) { + that.$element.appendTo(that.$body) // don't move modals dom position + } + + that.$element + .show() + .scrollTop(0) + + that.adjustDialog() + + if (transition) { + that.$element[0].offsetWidth // force reflow + } + + that.$element.addClass('in') + + that.enforceFocus() + + var e = $.Event('shown.bs.modal', { relatedTarget: _relatedTarget }) + + transition ? + that.$dialog // wait for modal to slide in + .one('bsTransitionEnd', function () { + that.$element.trigger('focus').trigger(e) + }) + .emulateTransitionEnd(Modal.TRANSITION_DURATION) : + that.$element.trigger('focus').trigger(e) }) } - function hideModal(that) { - this.$element - .hide() - .trigger('hidden') + Modal.prototype.hide = function (e) { + if (e) e.preventDefault() - backdrop.call(this) + e = $.Event('hide.bs.modal') + + this.$element.trigger(e) + + if (!this.isShown || e.isDefaultPrevented()) return + + this.isShown = false + + this.escape() + this.resize() + + $(document).off('focusin.bs.modal') + + this.$element + .removeClass('in') + .off('click.dismiss.bs.modal') + .off('mouseup.dismiss.bs.modal') + + this.$dialog.off('mousedown.dismiss.bs.modal') + + $.support.transition && this.$element.hasClass('fade') ? + this.$element + .one('bsTransitionEnd', $.proxy(this.hideModal, this)) + .emulateTransitionEnd(Modal.TRANSITION_DURATION) : + this.hideModal() } - function backdrop(callback) { + Modal.prototype.enforceFocus = function () { + $(document) + .off('focusin.bs.modal') // guard against infinite focus loop + .on('focusin.bs.modal', $.proxy(function (e) { + if (this.$element[0] !== e.target && !this.$element.has(e.target).length) { + this.$element.trigger('focus') + } + }, this)) + } + + Modal.prototype.escape = function () { + if (this.isShown && this.options.keyboard) { + this.$element.on('keydown.dismiss.bs.modal', $.proxy(function (e) { + e.which == 27 && this.hide() + }, this)) + } else if (!this.isShown) { + this.$element.off('keydown.dismiss.bs.modal') + } + } + + Modal.prototype.resize = function () { + if (this.isShown) { + $(window).on('resize.bs.modal', $.proxy(this.handleUpdate, this)) + } else { + $(window).off('resize.bs.modal') + } + } + + Modal.prototype.hideModal = function () { var that = this - , animate = this.$element.hasClass('fade') ? 'fade' : '' + this.$element.hide() + this.backdrop(function () { + that.$body.removeClass('modal-open') + that.resetAdjustments() + that.resetScrollbar() + that.$element.trigger('hidden.bs.modal') + }) + } + + Modal.prototype.removeBackdrop = function () { + this.$backdrop && this.$backdrop.remove() + this.$backdrop = null + } + + Modal.prototype.backdrop = function (callback) { + var that = this + var animate = this.$element.hasClass('fade') ? 'fade' : '' if (this.isShown && this.options.backdrop) { var doAnimate = $.support.transition && animate - this.$backdrop = $('