Merge branch 'master' into mobile

This commit is contained in:
Neil Lalonde 2013-09-05 15:54:22 -04:00
commit 45d7765936
133 changed files with 2458 additions and 1264 deletions
GemfileGemfile.lockGemfile_rails4.lock
app
config
db/migrate
docs
lib

View file

@ -187,7 +187,10 @@ gem 'lru_redux'
# IMPORTANT: mini profiler monkey patches, so it better be required last
# If you want to amend mini profiler to do the monkey patches in the railstie
# we are open to it. by deferring require to the initializer we can configure disourse installs without it
gem 'rack-mini-profiler', '0.1.29', require: false # require: false #, git: 'git://github.com/SamSaffron/MiniProfiler'
# gem 'rack-mini-profiler', '0.1.30', require: false
gem 'flamegraph', require: false
gem 'rack-mini-profiler', require: false
# used for caching, optional
# redis-rack-cache is missing a sane expiry policy, it hogs redis
@ -196,6 +199,7 @@ gem 'redis-rack-cache', git: 'https://github.com/SamSaffron/redis-rack-cache.git
gem 'rack-cache', require: false
gem 'rack-cors', require: false
gem 'unicorn', require: false
gem 'puma', require: false
# perftools only works on 1.9 atm
group :profile do

View file

@ -180,9 +180,14 @@ GEM
fast_blank (0.0.1)
rake
rake-compiler
fast_stack (0.0.5)
rake
rake-compiler
fast_xs (0.8.0)
fastimage (1.3.0)
ffi (1.8.1)
flamegraph (0.0.2)
fast_stack
fog (1.14.0)
builder
excon (~> 0.25.0)
@ -219,7 +224,7 @@ GEM
librarian (0.1.0)
highline
thor (~> 0.15)
libv8 (3.11.8.17)
libv8 (3.16.14.3)
listen (0.7.3)
lru_redux (0.0.6)
mail (2.4.4)
@ -287,6 +292,8 @@ GEM
pry (~> 0.9.10)
pry-rails (0.2.2)
pry (>= 0.9.10)
puma (2.5.1)
rack (>= 1.1, < 2.0)
qunit-rails (0.0.3)
railties (>= 3.2.3)
rack (1.4.5)
@ -294,7 +301,7 @@ GEM
rack (>= 0.4)
rack-cors (0.2.7)
rack
rack-mini-profiler (0.1.29)
rack-mini-profiler (0.1.31)
rack (>= 1.1.3)
rack-openid (1.3.1)
rack (>= 1.1.0)
@ -423,8 +430,8 @@ GEM
activemodel (~> 3.0)
railties (~> 3.0)
temple (0.6.4)
therubyracer (0.11.4)
libv8 (~> 3.11.8.12)
therubyracer (0.12.0)
libv8 (~> 3.16.14.0)
ref
thin (1.5.1)
daemons (>= 1.0.9)
@ -472,6 +479,7 @@ DEPENDENCIES
fast_xor!
fast_xs
fastimage
flamegraph
fog
handlebars-source (= 1.0.12)
highline
@ -501,10 +509,11 @@ DEPENDENCIES
pg
pry-nav
pry-rails
puma
qunit-rails
rack-cache
rack-cors
rack-mini-profiler (= 0.1.29)
rack-mini-profiler
rails (= 3.2.12)
rails_multisite!
rake

View file

@ -33,7 +33,7 @@ GIT
GIT
remote: git://github.com/rails/rails.git
revision: e36692a7466011ab51393ac8ca6dfffcb9d79ec0
revision: 025b63db308fbbf942a3bc2673d4aadab968c524
branch: 4-0-stable
specs:
actionmailer (4.0.0)
@ -216,9 +216,14 @@ GEM
fast_blank (0.0.1)
rake
rake-compiler
fast_stack (0.0.5)
rake
rake-compiler
fast_xs (0.8.0)
fastimage (1.5.0)
ffi (1.9.0)
flamegraph (0.0.2)
fast_stack
fog (1.14.0)
builder
excon (~> 0.25.0)
@ -256,7 +261,7 @@ GEM
librarian (0.1.0)
highline
thor (~> 0.15)
libv8 (3.11.8.17)
libv8 (3.16.14.3)
listen (1.2.2)
rb-fsevent (>= 0.9.3)
rb-inotify (>= 0.9)
@ -267,7 +272,7 @@ GEM
treetop (~> 1.4.8)
metaclass (0.0.1)
method_source (0.8.1)
mime-types (1.24)
mime-types (1.25)
mini_portile (0.5.1)
minitest (4.7.5)
mocha (0.14.0)
@ -327,6 +332,8 @@ GEM
pry (~> 0.9.10)
pry-rails (0.3.1)
pry (>= 0.9.10)
puma (2.5.1)
rack (>= 1.1, < 2.0)
qunit-rails (0.0.3)
railties (>= 3.2.3)
rack (1.5.2)
@ -334,7 +341,7 @@ GEM
rack (>= 0.4)
rack-cors (0.2.8)
rack
rack-mini-profiler (0.1.29)
rack-mini-profiler (0.1.31)
rack (>= 1.1.3)
rack-openid (1.3.1)
rack (>= 1.1.0)
@ -432,8 +439,8 @@ GEM
activesupport (>= 3.0)
sprockets (~> 2.8)
temple (0.6.5)
therubyracer (0.11.4)
libv8 (~> 3.11.8.12)
therubyracer (0.12.0)
libv8 (~> 3.16.14.0)
ref
thin (1.5.1)
daemons (>= 1.0.9)
@ -482,6 +489,7 @@ DEPENDENCIES
fast_xor!
fast_xs
fastimage
flamegraph
fog
handlebars-source (= 1.0.12)
highline
@ -511,10 +519,11 @@ DEPENDENCIES
pg
pry-nav
pry-rails
puma
qunit-rails
rack-cache
rack-cors
rack-mini-profiler (= 0.1.29)
rack-mini-profiler
rails!
rails-observers
rails_multisite!

View file

@ -3,6 +3,49 @@
@module $.fn.autocomplete
**/
var shiftMap = [];
shiftMap[192] = "~";
shiftMap[49] = "!";
shiftMap[50] = "@";
shiftMap[51] = "#";
shiftMap[52] = "$";
shiftMap[53] = "%";
shiftMap[54] = "^";
shiftMap[55] = "&";
shiftMap[56] = "*";
shiftMap[57] = "(";
shiftMap[48] = ")";
shiftMap[109] = "_";
shiftMap[107] = "+";
shiftMap[219] = "{";
shiftMap[221] = "}";
shiftMap[220] = "|";
shiftMap[59] = ":";
shiftMap[222] = "\"";
shiftMap[188] = "<";
shiftMap[190] = ">";
shiftMap[191] = "?";
shiftMap[32] = " ";
function mapKeyPressToActualCharacter(isShiftKey, characterCode) {
if ( characterCode === 27 || characterCode === 8 || characterCode === 9 || characterCode === 20 || characterCode === 16 || characterCode === 17 || characterCode === 91 || characterCode === 13 || characterCode === 92 || characterCode === 18 ) { return false; }
if (isShiftKey) {
if ( characterCode >= 65 && characterCode <= 90 ) {
return String.fromCharCode(characterCode);
} else {
return shiftMap[characterCode];
}
} else {
if ( characterCode >= 65 && characterCode <= 90 ) {
return String.fromCharCode(characterCode).toLowerCase();
} else {
return String.fromCharCode(characterCode);
}
}
}
$.fn.autocomplete = function(options) {
var autocompletePlugin = this;
@ -338,11 +381,15 @@ $.fn.autocomplete = function(options) {
}
term = me.val().substring(completeStart + (options.key ? 1 : 0), caretPosition);
if (e.which >= 48 && e.which <= 90) {
term += String.fromCharCode(e.which);
term += mapKeyPressToActualCharacter(e.shiftKey, e.which);
} else if (e.which === 187) {
term += "+";
} else if (e.which === 189) {
term += (e.shiftKey) ? "_" : "-";
} else if (e.which === 220) {
term += (e.shiftKey) ? "|" : "]";
} else if (e.which === 222) {
term += (e.shiftKey) ? "\"" : "'";
} else {
if (e.which !== 8) {
term += ",";

View file

@ -13,13 +13,29 @@ Discourse.Formatter = (function(){
var firstPart = string.substr(0, maxLength);
var betterSplit = firstPart.substr(1).search(/[^a-z]/);
if (betterSplit >= 0) {
var offset = 1;
if(string[betterSplit+1] === "_") {
offset = 2;
// work backward to split stuff like ABPoop to AB Poop
var i;
for(i=firstPart.length-1;i>0;i--){
if(firstPart[i].match(/[A-Z]/)){
break;
}
return string.substr(0, betterSplit + offset) + " " + string.substring(betterSplit + offset);
}
// work forwards to split stuff like ab111 to ab 111
if(i===0) {
for(i=1;i<firstPart.length;i++){
if(firstPart[i].match(/[^a-z]/)){
break;
}
}
}
if (i > 0 && i < firstPart.length) {
var offset = 0;
if(string[i] === "_") {
offset = 1;
}
return string.substr(0, i + offset) + " " + string.substring(i + offset);
} else {
return firstPart + " " + string.substr(maxLength);
}

View file

@ -23,6 +23,10 @@ Discourse.Quote = {
sansQuotes = contents.replace(this.REGEXP, '').trim();
if (sansQuotes.length === 0) return "";
// Escape the content of the quote
sansQuotes = sansQuotes.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
result = "[quote=\"" + post.get('username') + ", post:" + post.get('post_number') + ", topic:" + post.get('topic_id');
/* Strip the HTML from cooked */

View file

@ -8,7 +8,15 @@
@module Discourse
**/
Discourse.AvatarSelectorController = Discourse.Controller.extend(Discourse.ModalFunctionality, {
toggleUseUploadedAvatar: function(toggle) {
this.set("use_uploaded_avatar", toggle);
}
useUploadedAvatar: function() {
this.set("use_uploaded_avatar", true);
},
useGravatar: function() {
this.set("use_uploaded_avatar", false);
},
avatarTemplate: function() {
return this.get("use_uploaded_avatar") ? this.get("uploaded_avatar_template") : this.get("gravatar_template");
}.property("use_uploaded_avatar", "uploaded_avatar_template", "gravatar_template")
});

View file

@ -13,7 +13,7 @@ Discourse.EditTopicAutoCloseController = Discourse.ObjectController.extend(Disco
if( this.get('details.auto_close_at') ) {
var closeTime = new Date( this.get('details.auto_close_at') );
if (closeTime > new Date()) {
this.set('auto_close_days', closeTime.daysSince());
this.set('auto_close_days', Math.round(moment(closeTime).diff(new Date(), 'days', true)));
}
} else {
this.set('details.auto_close_days', '');

View file

@ -58,9 +58,11 @@ Discourse.FlagController = Discourse.ObjectController.extend(Discourse.ModalFunc
if (opts) params = $.extend(params, opts);
$('#discourse-modal').modal('hide');
postAction.act(params).then(function() {
flagController.send('closeModal');
}, function(errors) {
$('#discourse-modal').modal('show');
flagController.displayErrors(errors);
});
},

View file

@ -95,6 +95,11 @@ Discourse.LoginController = Discourse.Controller.extend(Discourse.ModalFunctiona
},
authenticationComplete: function(options) {
if (options.requires_invite) {
this.flash(I18n.t('login.requires_invite'), 'success');
this.set('authenticate', null);
return;
}
if (options.awaiting_approval) {
this.flash(I18n.t('login.awaiting_approval'), 'success');
this.set('authenticate', null);

View file

@ -12,6 +12,7 @@ Discourse.MergeTopicController = Discourse.ObjectController.extend(Discourse.Sel
topicController: Em.computed.alias('controllers.topic'),
selectedPosts: Em.computed.alias('topicController.selectedPosts'),
selectedReplies: Em.computed.alias('topicController.selectedReplies'),
allPostsSelected: Em.computed.alias('topicController.allPostsSelected'),
buttonDisabled: function() {
@ -31,10 +32,13 @@ Discourse.MergeTopicController = Discourse.ObjectController.extend(Discourse.Sel
if (this.get('allPostsSelected')) {
promise = Discourse.Topic.mergeTopic(this.get('id'), this.get('selectedTopicId'));
} else {
var postIds = this.get('selectedPosts').map(function(p) { return p.get('id'); });
var postIds = this.get('selectedPosts').map(function(p) { return p.get('id'); }),
replyPostIds = this.get('selectedReplies').map(function(p) { return p.get('id'); });
promise = Discourse.Topic.movePosts(this.get('id'), {
destination_topic_id: this.get('selectedTopicId'),
post_ids: postIds
post_ids: postIds,
reply_post_ids: replyPostIds
});
}

View file

@ -12,6 +12,7 @@ Discourse.SplitTopicController = Discourse.ObjectController.extend(Discourse.Sel
topicController: Em.computed.alias('controllers.topic'),
selectedPosts: Em.computed.alias('topicController.selectedPosts'),
selectedReplies: Em.computed.alias('topicController.selectedReplies'),
buttonDisabled: function() {
if (this.get('saving')) return true;
@ -30,21 +31,23 @@ Discourse.SplitTopicController = Discourse.ObjectController.extend(Discourse.Sel
movePostsToNewTopic: function() {
this.set('saving', true);
var postIds = this.get('selectedPosts').map(function(p) { return p.get('id'); });
var splitTopicController = this;
var postIds = this.get('selectedPosts').map(function(p) { return p.get('id'); }),
replyPostIds = this.get('selectedReplies').map(function(p) { return p.get('id'); }),
self = this;
Discourse.Topic.movePosts(this.get('id'), {
title: this.get('topicName'),
post_ids: postIds
post_ids: postIds,
reply_post_ids: replyPostIds
}).then(function(result) {
// Posts moved
splitTopicController.send('closeModal');
splitTopicController.get('topicController').toggleMultiSelect();
self.send('closeModal');
self.get('topicController').toggleMultiSelect();
Em.run.next(function() { Discourse.URL.routeTo(result.url); });
}, function() {
// Error moving posts
splitTopicController.flash(I18n.t('topic.split_topic.error'));
splitTopicController.set('saving', false);
self.flash(I18n.t('topic.split_topic.error'));
self.set('saving', false);
});
return false;
}

View file

@ -11,8 +11,15 @@ Discourse.TopicController = Discourse.ObjectController.extend(Discourse.Selected
summaryCollapsed: true,
needs: ['header', 'modal', 'composer', 'quoteButton'],
allPostsSelected: false,
selectedPosts: new Em.Set(),
editingTopic: false,
selectedPosts: null,
selectedReplies: null,
init: function() {
this._super();
this.set('selectedPosts', new Em.Set());
this.set('selectedReplies', new Em.Set());
},
jumpTopDisabled: function() {
return (this.get('progressPosition') === 1);
@ -82,18 +89,48 @@ Discourse.TopicController = Discourse.ObjectController.extend(Discourse.Selected
return false;
}.property('postStream.loaded', 'currentPost', 'postStream.filteredPostsCount'),
selectPost: function(post) {
deselectPost: function(post) {
this.get('selectedPosts').removeObject(post);
var selectedReplies = this.get('selectedReplies');
selectedReplies.removeObject(post);
var selectedReply = selectedReplies.findProperty('post_number', post.get('reply_to_post_number'));
if (selectedReply) { selectedReplies.removeObject(selectedReply); }
this.set('allPostsSelected', false);
},
postSelected: function(post) {
if (this.get('allPostsSelected')) { return true; }
if (this.get('selectedPosts').contains(post)) { return true; }
if (this.get('selectedReplies').findProperty('post_number', post.get('reply_to_post_number'))) { return true; }
return false;
},
toggledSelectedPost: function(post) {
var selectedPosts = this.get('selectedPosts');
if (selectedPosts.contains(post)) {
selectedPosts.removeObject(post);
this.set('allPostsSelected', false);
if (this.postSelected(post)) {
this.deselectPost(post);
return false;
} else {
selectedPosts.addObject(post);
// If the user manually selects all posts, all posts are selected
if (selectedPosts.length === this.get('posts_count')) {
this.set('allPostsSelected');
this.set('allPostsSelected', true);
}
return true;
}
},
toggledSelectedPostReplies: function(post) {
var selectedReplies = this.get('selectedReplies');
if (this.toggledSelectedPost(post)) {
selectedReplies.addObject(post);
} else {
selectedReplies.removeObject(post);
}
},
@ -108,6 +145,7 @@ Discourse.TopicController = Discourse.ObjectController.extend(Discourse.Selected
deselectAll: function() {
this.get('selectedPosts').clear();
this.get('selectedReplies').clear();
this.set('allPostsSelected', false);
},
@ -177,19 +215,28 @@ Discourse.TopicController = Discourse.ObjectController.extend(Discourse.Selected
},
deleteSelected: function() {
var topicController = this;
var self = this;
bootbox.confirm(I18n.t("post.delete.confirm", { count: this.get('selectedPostsCount')}), function(result) {
if (result) {
// If all posts are selected, it's the same thing as deleting the topic
if (topicController.get('allPostsSelected')) {
return topicController.deleteTopic();
if (self.get('allPostsSelected')) {
return self.deleteTopic();
}
var selectedPosts = topicController.get('selectedPosts');
Discourse.Post.deleteMany(selectedPosts);
topicController.get('model.postStream').removePosts(selectedPosts);
topicController.toggleMultiSelect();
var selectedPosts = self.get('selectedPosts'),
selectedReplies = self.get('selectedReplies'),
postStream = self.get('postStream'),
toRemove = new Ember.Set();
Discourse.Post.deleteMany(selectedPosts, selectedReplies);
postStream.get('posts').forEach(function (p) {
if (self.postSelected(p)) { toRemove.addObject(p); }
});
postStream.removePosts(toRemove);
self.toggleMultiSelect();
}
});
},
@ -410,7 +457,33 @@ Discourse.TopicController = Discourse.ObjectController.extend(Discourse.Selected
},
deletePost: function(post) {
post.destroy(Discourse.User.current());
var user = Discourse.User.current(),
replyCount = post.get('reply_count'),
self = this;
// If the user is staff and the post has replies, ask if they want to delete replies too.
if (user.get('staff') && replyCount > 0) {
bootbox.confirm(I18n.t("post.controls.delete_replies.confirm", {count: replyCount}),
I18n.t("post.controls.delete_replies.no_value"),
I18n.t("post.controls.delete_replies.yes_value"),
function(result) {
// If the user wants to delete replies, do that, otherwise delete the post as normal.
if (result) {
Discourse.Post.deleteMany([post], [post]);
self.get('postStream.posts').forEach(function (p) {
if (p === post || p.get('reply_to_post_number') === post.get('post_number')) {
p.setDeletedState(user);
}
});
} else {
post.destroy(user);
}
});
} else {
post.destroy(user);
}
},
removeAllowedUser: function(username) {

View file

@ -24,5 +24,6 @@ Discourse.UserActivityController = Discourse.ObjectController.extend({
},
privateMessagesActive: Em.computed.equal('pmView', 'index'),
privateMessagesSentActive: Em.computed.equal('pmView', 'sent')
privateMessagesMineActive: Em.computed.equal('pmView', 'mine'),
privateMessagesUnreadActive: Em.computed.equal('pmView', 'unread')
});

View file

@ -1,45 +1,19 @@
/**
This addition handles auto linking of text. When included, it will parse out links and create
a hrefs for them.
@event register
@namespace Discourse.Dialect
**/
Discourse.Dialect.on("register", function(event) {
var urlReplacerArgs = {
matcher: /^((?:https?:(?:\/{1,3}|[a-z0-9%])|www\d{0,3}[.])(?:[^\s()<>]+|\([^\s()<>]+\))+(?:\([^\s()<>]+\)|[^`!()\[\]{};:'".,<>?«»“”‘’\s]))/gm,
spaceBoundary: true,
var dialect = event.dialect,
MD = event.MD;
emitter: function(matches) {
var url = matches[1],
displayUrl = url;
/**
Parses out links from HTML.
if (url.match(/^www/)) { url = "http://" + url; }
return ['a', {href: url}, displayUrl];
}
};
@method autoLink
@param {String} text the text match
@param {Array} match the match found
@param {Array} prev the previous jsonML
@return {Array} an array containing how many chars we've replaced and the jsonML content for it.
@namespace Discourse.Dialect
**/
dialect.inline['http'] = dialect.inline['www'] = function autoLink(text, match, prev) {
// We only care about links on boundaries
if (prev && (prev.length > 0)) {
var last = prev[prev.length - 1];
if (typeof last === "string" && (!last.match(/\s$/))) { return; }
}
var pattern = /(^|\s)((?:https?:(?:\/{1,3}|[a-z0-9%])|www\d{0,3}[.])(?:[^\s()<>]+|\([^\s()<>]+\))+(?:\([^\s()<>]+\)|[^`!()\[\]{};:'".,<>?«»“”‘’\s]))/gm,
m = pattern.exec(text);
if (m) {
var url = m[2],
displayUrl = m[2];
if (url.match(/^www/)) { url = "http://" + url; }
return [m[0].length, ['a', {href: url}, displayUrl]];
}
};
});
Discourse.Dialect.inlineRegexp(_.merge({start: 'http'}, urlReplacerArgs));
Discourse.Dialect.inlineRegexp(_.merge({start: 'www'}, urlReplacerArgs));

View file

@ -1,205 +1,114 @@
/**
Regsiter all functionality for supporting BBCode in Discourse.
Create a simple BBCode tag handler
@event register
@namespace Discourse.Dialect
@method replaceBBCode
@param {tag} tag the tag we want to match
@param {function} emitter the function that creates JsonML for the tag
**/
Discourse.Dialect.on("register", function(event) {
var dialect = event.dialect,
MD = event.MD;
var createBBCode = function(tag, builder, hasArgs) {
return function(text, orig_match) {
var bbcodePattern = new RegExp("\\[" + tag + "=?([^\\[\\]]+)?\\]([\\s\\S]*?)\\[\\/" + tag + "\\]", "igm");
var m = bbcodePattern.exec(text);
if (m && m[0]) {
return [m[0].length, builder(m, this)];
}
};
};
var bbcodes = {'b': ['span', {'class': 'bbcode-b'}],
'i': ['span', {'class': 'bbcode-i'}],
'u': ['span', {'class': 'bbcode-u'}],
's': ['span', {'class': 'bbcode-s'}],
'spoiler': ['span', {'class': 'spoiler'}],
'li': ['li'],
'ul': ['ul'],
'ol': ['ol']};
Object.keys(bbcodes).forEach(function(tag) {
var element = bbcodes[tag];
dialect.inline["[" + tag + "]"] = createBBCode(tag, function(m, self) {
return element.concat(self.processInline(m[2]));
});
function replaceBBCode(tag, emitter) {
Discourse.Dialect.inlineBetween({
start: "[" + tag + "]",
stop: "[/" + tag + "]",
emitter: emitter
});
}
dialect.inline["[img]"] = createBBCode('img', function(m) {
return ['img', {href: m[2]}];
});
/**
Creates a BBCode handler that accepts parameters. Passes them to the emitter.
dialect.inline["[email]"] = createBBCode('email', function(m) {
return ['a', {href: "mailto:" + m[2], 'data-bbcode': true}, m[2]];
});
@method replaceBBCodeParamsRaw
@param {tag} tag the tag we want to match
@param {function} emitter the function that creates JsonML for the tag
**/
function replaceBBCodeParamsRaw(tag, emitter) {
Discourse.Dialect.inlineBetween({
start: "[" + tag + "=",
stop: "[/" + tag + "]",
rawContents: true,
emitter: function(contents) {
var regexp = /^([^\]]+)\](.*)$/,
m = regexp.exec(contents);
dialect.inline["[url]"] = createBBCode('url', function(m) {
return ['a', {href: m[2], 'data-bbcode': true}, m[2]];
});
dialect.inline["[url="] = createBBCode('url', function(m, self) {
return ['a', {href: m[1], 'data-bbcode': true}].concat(self.processInline(m[2]));
});
dialect.inline["[email="] = createBBCode('email', function(m, self) {
return ['a', {href: "mailto:" + m[1], 'data-bbcode': true}].concat(self.processInline(m[2]));
});
dialect.inline["[size="] = createBBCode('size', function(m, self) {
return ['span', {'class': "bbcode-size-" + m[1]}].concat(self.processInline(m[2]));
});
dialect.inline["[color="] = function(text, orig_match) {
var bbcodePattern = new RegExp("\\[color=?([^\\[\\]]+)?\\]([\\s\\S]*?)\\[\\/color\\]", "igm"),
m = bbcodePattern.exec(text);
if (m && m[0]) {
if (!/^(\#[0-9a-fA-F]{3}([0-9a-fA-F]{3})?)|(aqua|black|blue|fuchsia|gray|green|lime|maroon|navy|olive|purple|red|silver|teal|white|yellow)$/.test(m[1])) {
return [m[0].length].concat(this.processInline(m[2]));
}
return [m[0].length, ['span', {style: "color: " + m[1]}].concat(this.processInline(m[2]))];
if (m) { return emitter.call(this, m[1], m[2]); }
}
};
});
}
/**
Support BBCode [code] blocks
/**
Creates a BBCode handler that accepts parameters. Passes them to the emitter.
Processes the inside recursively so it can be nested.
@method bbcodeCode
@param {Markdown.Block} block the block to examine
@param {Array} next the next blocks in the sequence
@return {Array} the JsonML containing the markup or undefined if nothing changed.
@namespace Discourse.Dialect
**/
dialect.inline["[code]"] = function bbcodeCode(text, orig_match) {
var bbcodePattern = new RegExp("\\[code\\]([\\s\\S]*?)\\[\\/code\\]", "igm"),
m = bbcodePattern.exec(text);
@method replaceBBCodeParams
@param {tag} tag the tag we want to match
@param {function} emitter the function that creates JsonML for the tag
**/
function replaceBBCodeParams(tag, emitter) {
replaceBBCodeParamsRaw(tag, function (param, contents) {
return emitter(param, this.processInline(contents));
});
}
if (m) {
var contents = m[1].trim().split("\n");
replaceBBCode('b', function(contents) { return ['span', {'class': 'bbcode-b'}].concat(contents); });
replaceBBCode('i', function(contents) { return ['span', {'class': 'bbcode-i'}].concat(contents); });
replaceBBCode('u', function(contents) { return ['span', {'class': 'bbcode-u'}].concat(contents); });
replaceBBCode('s', function(contents) { return ['span', {'class': 'bbcode-s'}].concat(contents); });
var html = ['pre', "\n"];
contents.forEach(function (n) {
html.push(n.trim());
html.push(["br"]);
html.push("\n");
});
replaceBBCode('ul', function(contents) { return ['ul'].concat(contents); });
replaceBBCode('ol', function(contents) { return ['ol'].concat(contents); });
replaceBBCode('li', function(contents) { return ['li'].concat(contents); });
return [m[0].length, html];
}
};
replaceBBCode('spoiler', function(contents) { return ['span', {'class': 'spoiler'}].concat(contents); });
/**
Support BBCode [quote] blocks
Discourse.Dialect.inlineBetween({
start: '[img]',
stop: '[/img]',
rawContents: true,
emitter: function(contents) { return ['img', {href: contents}]; }
});
@method bbcodeQuote
@param {Markdown.Block} block the block to examine
@param {Array} next the next blocks in the sequence
@return {Array} the JsonML containing the markup or undefined if nothing changed.
@namespace Discourse.Dialect
**/
dialect.block['quote'] = function bbcodeQuote(block, next) {
var m = new RegExp("\\[quote=?([^\\[\\]]+)?\\]([\\s\\S]*)", "igm").exec(block);
if (m) {
var paramsString = m[1].replace(/\"/g, ''),
params = {'class': 'quote'},
paramsSplit = paramsString.split(/\, */),
username = paramsSplit[0],
opts = dialect.options,
startPos = block.indexOf(m[0]),
leading,
quoteContents = [],
result = [];
if (startPos > 0) {
leading = block.slice(0, startPos);
var para = ['p'];
this.processInline(leading).forEach(function (l) {
para.push(l);
});
result.push(para);
}
paramsSplit.forEach(function(p,i) {
if (i > 0) {
var assignment = p.split(':');
if (assignment[0] && assignment[1]) {
params['data-' + assignment[0]] = assignment[1].trim();
}
}
});
var avatarImg;
if (opts.lookupAvatarByPostNumber) {
// client-side, we can retrieve the avatar from the post
var postNumber = parseInt(params['data-post'], 10);
avatarImg = opts.lookupAvatarByPostNumber(postNumber);
} else if (opts.lookupAvatar) {
// server-side, we need to lookup the avatar from the username
avatarImg = opts.lookupAvatar(username);
}
if (m[2]) { next.unshift(MD.mk_block(m[2])); }
while (next.length > 0) {
var b = next.shift(),
n = b.match(/([\s\S]*)\[\/quote\]([\s\S]*)/m);
if (n) {
if (n[2]) {
next.unshift(MD.mk_block(n[2]));
}
quoteContents.push(n[1]);
break;
} else {
quoteContents.push(b);
}
}
var contents = this.processInline(quoteContents.join(" \n \n"));
contents.unshift('blockquote');
result.push(['p', ['aside', params,
['div', {'class': 'title'},
['div', {'class': 'quote-controls'}],
avatarImg ? avatarImg : "",
I18n.t('user.said',{username: username})
],
contents
]]);
return result;
}
};
Discourse.Dialect.inlineBetween({
start: '[email]',
stop: '[/email]',
rawContents: true,
emitter: function(contents) { return ['a', {href: "mailto:" + contents, 'data-bbcode': true}, contents]; }
});
Discourse.Dialect.inlineBetween({
start: '[url]',
stop: '[/url]',
rawContents: true,
emitter: function(contents) { return ['a', {href: contents, 'data-bbcode': true}, contents]; }
});
Discourse.Dialect.on("parseNode", function(event) {
replaceBBCodeParamsRaw("url", function(param, contents) {
return ['a', {href: param, 'data-bbcode': true}, contents];
});
var node = event.node,
path = event.path;
replaceBBCodeParamsRaw("email", function(param, contents) {
return ['a', {href: "mailto:" + param, 'data-bbcode': true}, contents];
});
// Make sure any quotes are followed by a <br>. The formatting looks weird otherwise.
if (node[0] === 'aside' && node[1] && node[1]['class'] === 'quote') {
var parent = path[path.length - 1],
location = parent.indexOf(node)+1,
trailing = parent.slice(location);
replaceBBCodeParams("size", function(param, contents) {
return ['span', {'class': "bbcode-size-" + param}].concat(contents);
});
if (trailing.length) {
parent.splice(location, 0, ['br']);
}
replaceBBCodeParams("color", function(param, contents) {
// Only allow valid HTML colors.
if (/^(\#[0-9a-fA-F]{3}([0-9a-fA-F]{3})?)|(aqua|black|blue|fuchsia|gray|green|lime|maroon|navy|olive|purple|red|silver|teal|white|yellow)$/.test(param)) {
return ['span', {style: "color: " + param}].concat(contents);
} else {
return ['span'].concat(contents);
}
});
// Handles `[code] ... [/code]` blocks
Discourse.Dialect.replaceBlock({
start: /(\[code\])([\s\S]*)/igm,
stop: '[/code]',
emitter: function(blockContents) {
return ['p', ['pre'].concat(blockContents)];
}
});

View file

@ -1,32 +1,42 @@
/**
Markdown.js doesn't seem to do bold and italics at the same time if you surround code with
three asterisks. This adds that support.
@event register
@namespace Discourse.Dialect
markdown-js doesn't ensure that em/strong codes are present on word boundaries.
So we create our own handlers here.
**/
Discourse.Dialect.on("register", function(event) {
// Support for simultaneous bold and italics
Discourse.Dialect.inlineBetween({
between: '***',
wordBoundary: true,
emitter: function(contents) { return ['strong', ['em'].concat(contents)]; }
});
// Builds a common markdown replacer
var replaceMarkdown = function(match, tag) {
Discourse.Dialect.inlineBetween({
between: match,
wordBoundary: true,
emitter: function(contents) { return [tag].concat(contents) }
});
};
replaceMarkdown('**', 'strong');
replaceMarkdown('__', 'strong');
replaceMarkdown('*', 'em');
replaceMarkdown('_', 'em');
// There's a weird issue with the markdown parser where it won't process simple blockquotes
// when they are prefixed with spaces. This fixes it.
Discourse.Dialect.on("register", function(event) {
var dialect = event.dialect,
MD = event.MD;
/**
Handles simultaneous bold and italics
@method parseMentions
@param {String} text the text match
@param {Array} match the match found
@param {Array} prev the previous jsonML
@return {Array} an array containing how many chars we've replaced and the jsonML content for it.
@namespace Discourse.Dialect
**/
dialect.inline['***'] = function boldItalics(text, match, prev) {
var regExp = /^\*{3}([^\*]+)\*{3}/,
m = regExp.exec(text);
if (m) {
return [m[0].length, ['strong', ['em'].concat(this.processInline(m[1]))]];
dialect.block["fix_simple_quotes"] = function(block, next) {
var m = /^ +(\>[\s\S]*)/.exec(block);
if (m && m[1] && m[1].length) {
next.unshift(MD.mk_block(m[1]));
return [];
}
};
});
});

View file

@ -3,91 +3,70 @@
Discourse uses the Markdown.js as its main parser. `Discourse.Dialect` is the framework
for extending it with additional formatting.
To extend the dialect, you can register a handler, and you will receive an `event` object
with a handle to the markdown `Dialect` from Markdown.js that we are defining. Here's
a sample dialect that replaces all occurrences of "evil trout" with a link that says
"EVIL TROUT IS AWESOME":
```javascript
Discourse.Dialect.on("register", function(event) {
var dialect = event.dialect;
// To see how this works, review one of our samples or the Markdown.js code:
dialect.inline["evil trout"] = function(text) {
return ["evil trout".length, ['a', {href: "http://eviltrout.com"}, "EVIL TROUT IS AWESOME"] ];
};
});
```
You can also manipulate the JsonML tree that is produced by the parser before it converted to HTML.
This is useful if the markup you want needs a certain structure of HTML elements. Rather than
writing regular expressions to match HTML, consider parsing the tree instead! We use this for
making sure a onebox is on one line, as an example.
This example changes the content of any `<code>` tags.
The `event.path` attribute contains the current path to the node.
```javascript
Discourse.Dialect.on("parseNode", function(event) {
var node = event.node;
if (node[0] === 'code') {
node[node.length-1] = "EVIL TROUT HACKED YOUR CODE";
}
});
```
**/
var parser = window.BetterMarkdown,
MD = parser.Markdown,
// Our dialect
dialect = MD.dialects.Discourse = MD.subclassDialect( MD.dialects.Gruber ),
initialized = false;
initialized = false,
/**
Initialize our dialects for processing.
/**
Initialize our dialects for processing.
@method initializeDialects
**/
function initializeDialects() {
Discourse.Dialect.trigger('register', {dialect: dialect, MD: MD});
MD.buildBlockOrder(dialect.block);
MD.buildInlinePatterns(dialect.inline);
initialized = true;
}
@method initializeDialects
**/
initializeDialects = function() {
Discourse.Dialect.trigger('register', {dialect: dialect, MD: MD});
MD.buildBlockOrder(dialect.block);
MD.buildInlinePatterns(dialect.inline);
initialized = true;
},
/**
Parse a JSON ML tree, using registered handlers to adjust it if necessary.
/**
Parse a JSON ML tree, using registered handlers to adjust it if necessary.
@method parseTree
@param {Array} tree the JsonML tree to parse
@param {Array} path the path of ancestors to the current node in the tree. Can be used for matching.
@param {Object} insideCounts counts what tags we're inside
@returns {Array} the parsed tree
**/
function parseTree(tree, path, insideCounts) {
if (tree instanceof Array) {
Discourse.Dialect.trigger('parseNode', {node: tree, path: path, dialect: dialect, insideCounts: insideCounts || {}});
@method parseTree
@param {Array} tree the JsonML tree to parse
@param {Array} path the path of ancestors to the current node in the tree. Can be used for matching.
@param {Object} insideCounts counts what tags we're inside
@returns {Array} the parsed tree
**/
parseTree = function parseTree(tree, path, insideCounts) {
if (tree instanceof Array) {
Discourse.Dialect.trigger('parseNode', {node: tree, path: path, dialect: dialect, insideCounts: insideCounts || {}});
path = path || [];
insideCounts = insideCounts || {};
path = path || [];
insideCounts = insideCounts || {};
path.push(tree);
tree.slice(1).forEach(function (n) {
var tagName = n[0];
insideCounts[tagName] = (insideCounts[tagName] || 0) + 1;
parseTree(n, path, insideCounts);
insideCounts[tagName] = insideCounts[tagName] - 1;
});
path.pop();
}
return tree;
}
path.push(tree);
tree.slice(1).forEach(function (n) {
var tagName = n[0];
insideCounts[tagName] = (insideCounts[tagName] || 0) + 1;
parseTree(n, path, insideCounts);
insideCounts[tagName] = insideCounts[tagName] - 1;
});
path.pop();
}
return tree;
};
/**
Returns true if there's an invalid word boundary for a match.
@method invalidBoundary
@param {Object} args our arguments, including whether we care about boundaries
@param {Array} prev the previous content, if exists
@returns {Boolean} whether there is an invalid word boundary
**/
function invalidBoundary(args, prev) {
if (!args.wordBoundary && !args.spaceBoundary) { return; }
var last = prev[prev.length - 1];
if (typeof last !== "string") { return; }
if (args.wordBoundary && (last.match(/(\w|\/)$/))) { return true; }
if (args.spaceBoundary && (!last.match(/\s$/))) { return true; }
}
/**
An object used for rendering our dialects.
@ -110,7 +89,281 @@ Discourse.Dialect = {
dialect.options = opts;
var tree = parser.toHTMLTree(text, 'Discourse');
return parser.renderJsonML(parseTree(tree));
},
/**
The simplest kind of replacement possible. Replace a stirng token with JsonML.
For example to replace all occurrances of :) with a smile image:
```javascript
Discourse.Dialect.inlineReplace(':)', function (text) {
return ['img', {src: '/images/smile.png'}];
});
```
@method inlineReplace
@param {String} token The token we want to replace
@param {Function} emitter A function that emits the JsonML for the replacement.
**/
inlineReplace: function(token, emitter) {
dialect.inline[token] = function(text, match, prev) {
return [token.length, emitter.call(this, token)];
};
},
/**
Matches inline using a regular expression. The emitter function is passed
the matches from the regular expression.
For example, this auto links URLs:
```javascript
Discourse.Dialect.inlineRegexp({
matcher: /((?:https?:(?:\/{1,3}|[a-z0-9%])|www\d{0,3}[.])(?:[^\s()<>]+|\([^\s()<>]+\))+(?:\([^\s()<>]+\)|[^`!()\[\]{};:'".,<>?«»“”‘’\s]))/gm,
spaceBoundary: true,
emitter: function(matches) {
var url = matches[1];
return ['a', {href: url}, url];
}
});
```
@method inlineRegexp
@param {Object} args Our replacement options
@param {Function} [opts.emitter] The function that will be called with the contents and regular expresison match and returns JsonML.
@param {String} [opts.start] The starting token we want to find
@param {String} [opts.matcher] The regular expression to match
@param {Boolean} [opts.wordBoundary] If true, the match must be on a word boundary
@param {Boolean} [opts.spaceBoundary] If true, the match must be on a sppace boundary
**/
inlineRegexp: function(args) {
dialect.inline[args.start] = function(text, match, prev) {
if (invalidBoundary(args, prev)) { return; }
args.matcher.lastIndex = 0;
var m = args.matcher.exec(text);
if (m) {
var result = args.emitter.call(this, m);
if (result) {
return [m[0].length, result];
}
}
};
},
/**
Handles inline replacements surrounded by tokens.
For example, to handle markdown style bold. Note we use `concat` on the array because
the contents are JsonML too since we didn't pass `rawContents` as true. This supports
recursive markup.
```javascript
Discourse.Dialect.inlineBetween({
between: '**',
wordBoundary: true.
emitter: function(contents) {
return ['strong'].concat(contents);
}
});
```
@method inlineBetween
@param {Object} args Our replacement options
@param {Function} [opts.emitter] The function that will be called with the contents and returns JsonML.
@param {String} [opts.start] The starting token we want to find
@param {String} [opts.stop] The ending token we want to find
@param {String} [opts.between] A shortcut for when the `start` and `stop` are the same.
@param {Boolean} [opts.rawContents] If true, the contents between the tokens will not be parsed.
@param {Boolean} [opts.wordBoundary] If true, the match must be on a word boundary
@param {Boolean} [opts.spaceBoundary] If true, the match must be on a sppace boundary
**/
inlineBetween: function(args) {
var start = args.start || args.between,
stop = args.stop || args.between,
startLength = start.length;
dialect.inline[start] = function(text, match, prev) {
if (invalidBoundary(args, prev)) { return; }
var endPos = text.indexOf(stop, startLength);
if (endPos === -1) { return; }
var between = text.slice(startLength, endPos);
// If rawcontents is set, don't process inline
if (!args.rawContents) {
between = this.processInline(between);
}
var contents = args.emitter.call(this, between);
if (contents) {
return [endPos+stop.length, contents];
}
};
},
/**
Replaces a block of text between a start and stop. As opposed to inline, these
might span multiple lines.
Here's an example that takes the content between `[code]` ... `[/code]` and
puts them inside a `pre` tag:
```javascript
Discourse.Dialect.replaceBlock({
start: /(\[code\])([\s\S]*)/igm,
stop: '[/code]',
emitter: function(blockContents) {
return ['p', ['pre'].concat(blockContents)];
}
});
```
@method replaceBlock
@param {Object} args Our replacement options
@param {String} [opts.start] The starting regexp we want to find
@param {String} [opts.stop] The ending token we want to find
@param {Function} [opts.emitter] The emitting function to transform the contents of the block into jsonML
**/
replaceBlock: function(args) {
dialect.block[args.start.toString()] = function(block, next) {
args.start.lastIndex = 0;
var m = (args.start).exec(block);
if (!m) { return; }
var startPos = block.indexOf(m[0]),
leading,
blockContents = [],
result = [],
lineNumber = block.lineNumber;
if (startPos > 0) {
leading = block.slice(0, startPos);
lineNumber += (leading.split("\n").length - 1);
var para = ['p'];
this.processInline(leading).forEach(function (l) {
para.push(l);
});
result.push(para);
}
if (m[2]) {
next.unshift(MD.mk_block(m[2], null, lineNumber + 1));
}
lineNumber++;
while (next.length > 0) {
var b = next.shift(),
blockLine = b.lineNumber,
diff = ((typeof blockLine === "undefined") ? lineNumber : blockLine) - lineNumber;
var endFound = b.indexOf(args.stop),
leadingContents = b.slice(0, endFound),
trailingContents = b.slice(endFound+args.stop.length);
for (var i=1; i<diff; i++) {
blockContents.push("");
}
lineNumber = blockLine + b.split("\n").length - 1;
if (endFound !== -1) {
if (trailingContents) {
next.unshift(MD.mk_block(trailingContents));
}
blockContents.push(leadingContents.replace(/\s+$/, ""));
break;
} else {
blockContents.push(b);
}
}
var test = args.emitter.call(this, blockContents, m, dialect.options);
result.push(test);
return result;
};
},
/**
After the parser has been executed, post process any text nodes in the HTML document.
This is useful if you want to apply a transformation to the text.
If you are generating HTML from the text, it is preferable to use the replacer
functions and do it in the parsing part of the pipeline. This function is best for
simple transformations or transformations that have to happen after all earlier
processing is done.
For example, to convert all text to upper case:
```javascript
Discourse.Dialect.postProcessText(function (text) {
return text.toUpperCase();
});
```
@method postProcessText
@param {Function} emitter The function to call with the text. It returns JsonML to modify the tree.
**/
postProcessText: function(emitter) {
Discourse.Dialect.on("parseNode", function(event) {
var node = event.node;
if (node.length < 2) { return; }
for (var j=1; j<node.length; j++) {
var textContent = node[j];
if (typeof textContent === "string") {
var result = emitter(textContent, event);
if (result) {
if (result instanceof Array) {
node.splice.apply(node, [j, 1].concat(result));
} else {
node[j] = result;
}
}
}
}
});
},
/**
After the parser has been executed, change the contents of a HTML tag.
Let's say you want to replace the contents of all code tags to prepend
"EVIL TROUT HACKED YOUR CODE!":
```javascript
Discourse.Dialect.postProcessTag('code', function (contents) {
return "EVIL TROUT HACKED YOUR CODE!\n\n" + contents;
});
```
@method postProcessTag
@param {String} tag The HTML tag you want to match on
@param {Function} emitter The function to call with the text. It returns JsonML to modify the tree.
**/
postProcessTag: function(tag, emitter) {
Discourse.Dialect.on('parseNode', function (event) {
var node = event.node;
if (node[0] === tag) {
node[node.length-1] = emitter(node[node.length-1]);
}
});
}
};
RSVP.EventTarget.mixin(Discourse.Dialect);

View file

@ -5,129 +5,16 @@
@event register
@namespace Discourse.Dialect
**/
Discourse.Dialect.on("register", function(event) {
var dialect = event.dialect,
MD = event.MD;
/**
Support for github style code blocks
@method githubCode
@param {Markdown.Block} block the block to examine
@param {Array} next the next blocks in the sequence
@return {Array} the JsonML containing the markup or undefined if nothing changed.
@namespace Discourse.Dialect
**/
dialect.block.github_code = function githubCode(block, next) {
var m = /^`{3}([^\n]+)?\n?([\s\S]*)?/gm.exec(block);
if (m) {
var startPos = block.indexOf(m[0]),
leading,
codeContents = [],
result = [],
lineNumber = block.lineNumber;
if (startPos > 0) {
leading = block.slice(0, startPos);
lineNumber += (leading.split("\n").length - 1);
var para = ['p'];
this.processInline(leading).forEach(function (l) {
para.push(l);
});
result.push(para);
}
if (m[2]) { next.unshift(MD.mk_block(m[2], null, lineNumber + 1)); }
lineNumber++;
while (next.length > 0) {
var b = next.shift(),
blockLine = b.lineNumber,
diff = ((typeof blockLine === "undefined") ? lineNumber : blockLine) - lineNumber;
var endFound = b.indexOf('```'),
leadingCode = b.slice(0, endFound),
trailingCode = b.slice(endFound+3);
for (var i=1; i<diff; i++) {
codeContents.push("");
}
lineNumber = blockLine + b.split("\n").length - 1;
if (endFound !== -1) {
if (trailingCode) {
next.unshift(MD.mk_block(trailingCode));
}
codeContents.push(leadingCode.replace(/\s+$/, ""));
break;
} else {
codeContents.push(b);
}
}
result.push(['p', ['pre', ['code', {'class': m[1] || 'lang-auto'}, codeContents.join("\n") ]]]);
return result;
}
};
});
/**
Ensure that content in a code block is fully escaped. This way it's not white listed
and we can use HTML and Javascript examples.
@event parseNode
@namespace Discourse.Dialect
**/
Discourse.Dialect.on("parseNode", function(event) {
var node = event.node;
if (node[0] === 'code') {
node[node.length-1] = Handlebars.Utils.escapeExpression(node[node.length-1]);
Discourse.Dialect.replaceBlock({
start: /^`{3}([^\n\[\]]+)?\n?([\s\S]*)?/gm,
stop: '```',
emitter: function(blockContents, matches) {
return ['p', ['pre', ['code', {'class': matches[1] || 'lang-auto'}, blockContents.join("\n") ]]];
}
});
Discourse.Dialect.on("parseNode", function(event) {
var node = event.node,
opts = event.dialect.options,
insideCounts = event.insideCounts,
linebreaks = opts.traditional_markdown_linebreaks || Discourse.SiteSettings.traditional_markdown_linebreaks;
if (!linebreaks) {
// We don't add line breaks inside a pre
if (insideCounts.pre > 0) { return; }
if (node.length > 1) {
for (var j=1; j<node.length; j++) {
var textContent = node[j];
if (typeof textContent === "string") {
if (textContent === "\n") {
node[j] = ['br'];
} else {
var split = textContent.split(/\n+/);
if (split.length) {
var spliceInstructions = [j, 1];
for (var i=0; i<split.length; i++) {
if (split[i].length > 0) {
spliceInstructions.push(split[i]);
if (i !== split.length-1) { spliceInstructions.push(['br']); }
}
}
node.splice.apply(node, spliceInstructions);
}
}
}
}
}
}
// Ensure that content in a code block is fully escaped. This way it's not white listed
// and we can use HTML and Javascript examples.
Discourse.Dialect.postProcessTag('code', function (contents) {
return Handlebars.Utils.escapeExpression(contents);
});

View file

@ -2,47 +2,20 @@
Supports Discourse's custom @mention syntax for calling out a user in a post.
It will add a special class to them, and create a link if the user is found in a
local map.
@event register
@namespace Discourse.Dialect
**/
Discourse.Dialect.on("register", function(event) {
Discourse.Dialect.inlineRegexp({
start: '@',
matcher: /^(@[A-Za-z0-9][A-Za-z0-9_]{2,14})/m,
wordBoundary: true,
var dialect = event.dialect,
MD = event.MD;
emitter: function(matches) {
var username = matches[1],
mentionLookup = this.dialect.options.mentionLookup || Discourse.Mention.lookupCache;
/**
Parses out @username mentions.
@method parseMentions
@param {String} text the text match
@param {Array} match the match found
@param {Array} prev the previous jsonML
@return {Array} an array containing how many chars we've replaced and the jsonML content for it.
@namespace Discourse.Dialect
**/
dialect.inline['@'] = function parseMentions(text, match, prev) {
// We only care about mentions on word boundaries
if (prev && (prev.length > 0)) {
var last = prev[prev.length - 1];
if (typeof last === "string" && (!last.match(/\W$/))) { return; }
if (mentionLookup(username.substr(1))) {
return ['a', {'class': 'mention', href: Discourse.getURL("/users/") + username.substr(1).toLowerCase()}, username];
} else {
return ['span', {'class': 'mention'}, username];
}
var pattern = /^(@[A-Za-z0-9][A-Za-z0-9_]{2,14})(?=(\W|$))/m,
m = pattern.exec(text);
if (m) {
var username = m[1],
mentionLookup = dialect.options.mentionLookup || Discourse.Mention.lookupCache;
if (mentionLookup(username.substr(1))) {
return [username.length, ['a', {'class': 'mention', href: Discourse.getURL("/users/") + username.substr(1).toLowerCase()}, username]];
} else {
return [username.length, ['span', {'class': 'mention'}, username]];
}
}
};
});
}
});

View file

@ -1,42 +1,32 @@
/**
Support for the newline behavior in markdown that most expect.
@event parseNode
@namespace Discourse.Dialect
Support for the newline behavior in markdown that most expect. Look through all text nodes
in the tree, replace any new lines with `br`s.
**/
Discourse.Dialect.on("parseNode", function(event) {
var node = event.node,
opts = event.dialect.options,
Discourse.Dialect.postProcessText(function (text, event) {
var opts = event.dialect.options,
insideCounts = event.insideCounts,
linebreaks = opts.traditional_markdown_linebreaks || Discourse.SiteSettings.traditional_markdown_linebreaks;
if (!linebreaks) {
// We don't add line breaks inside a pre
if (insideCounts.pre > 0) { return; }
if (linebreaks || (insideCounts.pre > 0)) { return; }
if (node.length > 1) {
for (var j=1; j<node.length; j++) {
var textContent = node[j];
if (text === "\n") {
// If the tage is just a new line, replace it with a `<br>`
return [['br']];
} else {
if (typeof textContent === "string") {
if (textContent === "\n") {
node[j] = ['br'];
} else {
var split = textContent.split(/\n+/);
if (split.length) {
var spliceInstructions = [j, 1];
for (var i=0; i<split.length; i++) {
if (split[i].length > 0) {
spliceInstructions.push(split[i]);
if (i !== split.length-1) { spliceInstructions.push(['br']); }
}
}
node.splice.apply(node, spliceInstructions);
}
}
// If the text node contains new lines, perhaps with text between them, insert the
// `<br>` tags.
var split = text.split(/\n+/);
if (split.length) {
var replacement = [];
for (var i=0; i<split.length; i++) {
if (split[i].length > 0) {
replacement.push(split[i]);
if (i !== split.length-1) { replacement.push(['br']); }
}
}
return replacement;
}
}
});

View file

@ -0,0 +1,62 @@
/**
Support for quoting other users.
**/
Discourse.Dialect.replaceBlock({
start: new RegExp("\\[quote=?([^\\[\\]]+)?\\]([\\s\\S]*)", "igm"),
stop: '[/quote]',
emitter: function(blockContents, matches, options) {
var paramsString = matches[1].replace(/\"/g, ''),
params = {'class': 'quote'},
paramsSplit = paramsString.split(/\, */),
username = paramsSplit[0];
paramsSplit.forEach(function(p,i) {
if (i > 0) {
var assignment = p.split(':');
if (assignment[0] && assignment[1]) {
params['data-' + assignment[0]] = assignment[1].trim();
}
}
});
var avatarImg;
if (options.lookupAvatarByPostNumber) {
// client-side, we can retrieve the avatar from the post
var postNumber = parseInt(params['data-post'], 10);
avatarImg = options.lookupAvatarByPostNumber(postNumber);
} else if (options.lookupAvatar) {
// server-side, we need to lookup the avatar from the username
avatarImg = options.lookupAvatar(username);
}
var contents = this.processInline(blockContents.join(" \n \n"));
contents.unshift('blockquote');
return ['p', ['aside', params,
['div', {'class': 'title'},
['div', {'class': 'quote-controls'}],
avatarImg ? avatarImg : "",
I18n.t('user.said', {username: username})
],
contents
]];
}
});
Discourse.Dialect.on("parseNode", function(event) {
var node = event.node,
path = event.path;
// Make sure any quotes are followed by a <br>. The formatting looks weird otherwise.
if (node[0] === 'aside' && node[1] && node[1]['class'] === 'quote') {
var parent = path[path.length - 1],
location = parent.indexOf(node)+1,
trailing = parent.slice(location);
if (trailing.length) {
parent.splice(location, 0, ['br']);
}
}
});

View file

@ -78,8 +78,6 @@ DiscourseGroupedEach.prototype = {
template = this.template;
data.insideEach = true;
data.insideGroup = true;
for (var i = 0; i < contentLength; i++) {
template(content.objectAt(i), { data: data });
}
@ -124,5 +122,6 @@ Ember.Handlebars.registerHelper('groupedEach', function(path, options) {
}
options.hash.dataSourceBinding = path;
options.data.insideGroup = true;
new DiscourseGroupedEach(this, path, options).render();
});

View file

@ -11,10 +11,15 @@ Discourse.SelectedPostsCount = Em.Mixin.create({
selectedPostsCount: function() {
if (this.get('allPostsSelected')) return this.get('posts_count') || this.get('topic.posts_count');
if (!this.get('selectedPosts')) return 0;
var sum = this.get('selectedPosts.length') || 0;
if (this.get('selectedReplies')) {
this.get('selectedReplies').forEach(function (p) {
sum += p.get('reply_count') || 0;
});
}
return this.get('selectedPosts.length');
}.property('selectedPosts.length', 'allPostsSelected')
return sum;
}.property('selectedPosts.length', 'allPostsSelected', 'selectedReplies.length')
});

View file

@ -442,6 +442,7 @@ Discourse.Composer = Discourse.Model.extend({
postStream = this.get('topic.postStream'),
addedToStream = false;
// Build the post object
var createdPost = Discourse.Post.create({
raw: this.get('reply'),
@ -482,6 +483,8 @@ Discourse.Composer = Discourse.Model.extend({
var composer = this;
return Ember.Deferred.promise(function(promise) {
composer.set('composeState', SAVING);
createdPost.save(function(result) {
var addedPost = false,
saving = true;
@ -515,8 +518,16 @@ Discourse.Composer = Discourse.Model.extend({
if (postStream) {
postStream.undoPost(createdPost);
}
promise.reject($.parseJSON(error.responseText).errors[0]);
composer.set('composeState', OPEN);
// TODO extract error handling code
var parsedError;
try {
parsedError = $.parseJSON(error.responseText).errors[0];
}
catch(ex) {
parsedError = "Unknown error saving post, try again. Error: " + error.status + " " + error.statusText;
}
promise.reject(parsedError);
});
});
},

View file

@ -30,6 +30,7 @@ Discourse.Post = Discourse.Model.extend({
deletedViaTopic: Em.computed.and('firstPost', 'topic.deleted_at'),
deleted: Em.computed.or('deleted_at', 'deletedViaTopic'),
notDeleted: Em.computed.not('deleted'),
userDeleted: Em.computed.empty('user_id'),
postDeletedBy: function() {
if (this.get('firstPost')) { return this.get('topic.deleted_by'); }
@ -224,17 +225,18 @@ Discourse.Post = Discourse.Model.extend({
},
/**
Deletes a post
Changes the state of the post to be deleted. Does not call the server, that should be
done elsewhere.
@method destroy
@param {Discourse.User} deleted_by The user deleting the post
@method setDeletedState
@param {Discourse.User} deletedBy The user deleting the post
**/
destroy: function(deleted_by) {
setDeletedState: function(deletedBy) {
// Moderators can delete posts. Regular users can only trigger a deleted at message.
if (deleted_by.get('staff')) {
if (deletedBy.get('staff')) {
this.setProperties({
deleted_at: new Date(),
deleted_by: deleted_by,
deleted_by: deletedBy,
can_delete: false
});
} else {
@ -247,7 +249,16 @@ Discourse.Post = Discourse.Model.extend({
user_deleted: true
});
}
},
/**
Deletes a post
@method destroy
@param {Discourse.User} deletedBy The user deleting the post
**/
destroy: function(deletedBy) {
this.setDeletedState(deletedBy);
return Discourse.ajax("/posts/" + (this.get('id')), { type: 'DELETE' });
},
@ -327,8 +338,7 @@ Discourse.Post = Discourse.Model.extend({
// Whether to show replies directly below
showRepliesBelow: function() {
var reply_count, topic;
reply_count = this.get('reply_count');
var reply_count = this.get('reply_count');
// We don't show replies if there aren't any
if (reply_count === 0) return false;
@ -340,7 +350,7 @@ Discourse.Post = Discourse.Model.extend({
if (reply_count > 1) return true;
// If we have *exactly* one reply, we have to consider if it's directly below us
topic = this.get('topic');
var topic = this.get('topic');
return !topic.isReplyDirectlyBelow(this);
}.property('reply_count'),
@ -376,11 +386,12 @@ Discourse.Post.reopenClass({
return result;
},
deleteMany: function(posts) {
deleteMany: function(selectedPosts, selectedReplies) {
return Discourse.ajax("/posts/destroy_many", {
type: 'DELETE',
data: {
post_ids: posts.map(function(p) { return p.get('id'); })
post_ids: selectedPosts.map(function(p) { return p.get('id'); }),
reply_post_ids: selectedReplies.map(function(p) { return p.get('id'); })
}
});
},

View file

@ -28,7 +28,11 @@ Discourse.User = Discourse.Model.extend({
searchContext: function() {
return ({ type: 'user', id: this.get('username_lower'), user: this });
return {
type: 'user',
id: this.get('username_lower'),
user: this
};
}.property('username_lower'),
/**
@ -101,7 +105,7 @@ Discourse.User = Discourse.Model.extend({
@returns Result of ajax call
**/
changeUsername: function(newUsername) {
return Discourse.ajax("/users/" + (this.get('username_lower')) + "/preferences/username", {
return Discourse.ajax("/users/" + this.get('username_lower') + "/preferences/username", {
type: 'PUT',
data: { new_username: newUsername }
});
@ -115,7 +119,7 @@ Discourse.User = Discourse.Model.extend({
@returns Result of ajax call
**/
changeEmail: function(email) {
return Discourse.ajax("/users/" + (this.get('username_lower')) + "/preferences/email", {
return Discourse.ajax("/users/" + this.get('username_lower') + "/preferences/email", {
type: 'PUT',
data: { email: email }
});
@ -173,9 +177,7 @@ Discourse.User = Discourse.Model.extend({
changePassword: function() {
return Discourse.ajax("/session/forgot_password", {
dataType: 'json',
data: {
login: this.get('username')
},
data: { login: this.get('username') },
type: 'POST'
});
},
@ -266,11 +268,14 @@ Discourse.User = Discourse.Model.extend({
Change avatar selection
@method toggleAvatarSelection
@param {Boolean} useUploadedAvatar true if the user is using the uploaded avatar
@returns {Promise} the result of the toggle avatar selection
*/
toggleAvatarSelection: function() {
var data = { use_uploaded_avatar: this.get("use_uploaded_avatar") };
return Discourse.ajax("/users/" + this.get("username") + "/preferences/avatar/toggle", { type: 'PUT', data: data });
toggleAvatarSelection: function(useUploadedAvatar) {
return Discourse.ajax("/users/" + this.get("username_lower") + "/preferences/avatar/toggle", {
type: 'PUT',
data: { use_uploaded_avatar: useUploadedAvatar }
});
}
});

View file

@ -10,7 +10,7 @@ Discourse.Route.buildRoutes(function() {
// Topic routes
this.resource('topic', { path: '/t/:slug/:id' }, function() {
this.route('fromParams', { path: '/' });
this.route('fromParams', { path: '/:nearPost' });
this.route('fromParamsNear', { path: '/:nearPost' });
});
// Generate static page routes
@ -50,7 +50,8 @@ Discourse.Route.buildRoutes(function() {
});
this.resource('userPrivateMessages', { path: '/private-messages' }, function() {
this.route('sent', {path: '/messages-sent'});
this.route('mine', {path: '/mine'});
this.route('unread', {path: '/unread'});
});
this.resource('preferences', { path: '/preferences' }, function() {

View file

@ -18,35 +18,29 @@ Discourse.PreferencesRoute = Discourse.RestrictedUserRoute.extend({
events: {
showAvatarSelector: function() {
Discourse.Route.showModal(this, 'avatarSelector');
var user = this.modelFor("user");
console.log(user);
this.controllerFor("avatarSelector").setProperties(user.getProperties(
"username",
"email",
"has_uploaded_avatar",
"use_uploaded_avatar",
"gravatar_template",
"uploaded_avatar_template"
));
// all the properties needed for displaying the avatar selector modal
var avatarSelector = this.modelFor('user').getProperties(
'username', 'email',
'has_uploaded_avatar', 'use_uploaded_avatar',
'gravatar_template', 'uploaded_avatar_template');
this.controllerFor('avatarSelector').setProperties(avatarSelector);
},
saveAvatarSelection: function() {
var user = this.modelFor("user");
var avatar = this.controllerFor("avatarSelector");
var user = this.modelFor('user');
var avatarSelector = this.controllerFor('avatarSelector');
// sends the information to the server if it has changed
if (avatar.get("use_uploaded_avatar") !== user.get("use_uploaded_avatar")) { user.toggleAvatarSelection(); }
// saves the data back
user.setProperties(avatar.getProperties(
"has_uploaded_avatar",
"use_uploaded_avatar",
"gravatar_template",
"uploaded_avatar_template"
));
if (avatar.get("use_uploaded_avatar")) {
user.set("avatar_template", avatar.get("uploaded_avatar_template"));
} else {
user.set("avatar_template", avatar.get("gravatar_template"));
if (avatarSelector.get('use_uploaded_avatar') !== user.get('use_uploaded_avatar')) {
user.toggleAvatarSelection(avatarSelector.get('use_uploaded_avatar'));
}
// saves the data back
user.setProperties(avatarSelector.getProperties(
'has_uploaded_avatar',
'use_uploaded_avatar',
'gravatar_template',
'uploaded_avatar_template'
));
user.set('avatar_template', avatarSelector.get('avatarTemplate'));
}
}
});

View file

@ -58,4 +58,5 @@ Discourse.TopicFromParamsRoute = Discourse.Route.extend({
});
Discourse.TopicFromParamsNearRoute = Discourse.TopicFromParamsRoute;

View file

@ -171,33 +171,26 @@ Discourse.UserTopicListRoute = Discourse.Route.extend({
}
});
Discourse.UserPrivateMessagesIndexRoute = Discourse.UserTopicListRoute.extend({
userActionType: Discourse.UserAction.TYPES.messages_received,
function createPMRoute(viewName, path, type) {
return Discourse.UserTopicListRoute.extend({
userActionType: Discourse.UserAction.TYPES.messages_received,
model: function() {
return Discourse.TopicList.find('topics/private-messages/' + this.modelFor('user').get('username_lower'));
},
model: function() {
return Discourse.TopicList.find('topics/' + path + '/' + this.modelFor('user').get('username_lower'));
},
setupController: function(controller, model) {
this._super(controller, model);
controller.set('hideCategories', true);
this.controllerFor('userActivity').set('pmView', 'index');
}
setupController: function(controller, model) {
this._super(controller, model);
controller.set('hideCategories', true);
this.controllerFor('userActivity').set('pmView', viewName);
}
});
}
});
Discourse.UserPrivateMessagesSentRoute = Discourse.UserTopicListRoute.extend({
userActionType: Discourse.UserAction.TYPES.messages_sent,
Discourse.UserPrivateMessagesIndexRoute = createPMRoute('index', 'private-messages');
Discourse.UserPrivateMessagesMineRoute = createPMRoute('mine', 'private-messages-sent');
Discourse.UserPrivateMessagesUnreadRoute = createPMRoute('unread', 'private-messages-unread');
model: function() {
return Discourse.TopicList.find('topics/private-messages-sent/' + this.modelFor('user').get('username_lower'));
},
setupController: function(controller, model) {
this._super(controller, model);
controller.set('hideCategories', true);
this.controllerFor('userActivity').set('pmView', 'sent');
}
});
Discourse.UserActivityTopicsRoute = Discourse.UserTopicListRoute.extend({
userActionType: Discourse.UserAction.TYPES.topics,
@ -205,7 +198,6 @@ Discourse.UserActivityTopicsRoute = Discourse.UserTopicListRoute.extend({
model: function() {
return Discourse.TopicList.find('topics/created-by/' + this.modelFor('user').get('username_lower'));
}
});
Discourse.UserActivityFavoritesRoute = Discourse.UserTopicListRoute.extend({

View file

@ -1,12 +1,12 @@
<div class="modal-body">
<div>
<div>
<input type="radio" id="avatar" name="avatar" value="gravatar" {{action toggleUseUploadedAvatar false}}>
<input type="radio" id="avatar" name="avatar" value="gravatar" {{action useGravatar}}>
<label class="radio" for="avatar">{{avatar controller imageSize="large" template="gravatar_template"}} {{{i18n user.change_avatar.gravatar}}} {{email}}</label>
<a href="//gravatar.com/emails" target="_blank" title="{{i18n user.change_avatar.gravatar_title}}" class="btn"><i class="icon-pencil"></i></a>
</div>
<div>
<input type="radio" id="uploaded_avatar" name="avatar" value="uploaded_avatar" {{action toggleUseUploadedAvatar true}}>
<input type="radio" id="uploaded_avatar" name="avatar" value="uploaded_avatar" {{action useUploadedAvatar}}>
<label class="radio" for="uploaded_avatar">
{{#if has_uploaded_avatar}}
{{boundAvatar controller imageSize="large" template="uploaded_avatar_template"}} {{i18n user.change_avatar.uploaded_avatar}}

View file

@ -17,15 +17,25 @@
{{/if}}
<div class='topic-meta-data span2'>
<div {{bindAttr class=":contents byTopicCreator:topic-creator"}}>
<a href='{{unbound usernameUrl}}'>{{avatar this imageSize="large"}}</a>
<h3 {{bindAttr class="staff new_user"}}><a href='{{unbound usernameUrl}}'>{{breakUp username}}</a></h3>
{{#if user_title}}<div class="user-title">{{user_title}}</div>{{/if}}
</div>
{{#unless userDeleted}}
<div {{bindAttr class=":contents byTopicCreator:topic-creator"}}>
<a href='{{unbound usernameUrl}}'>{{avatar this imageSize="large"}}</a>
<h3 {{bindAttr class="staff new_user"}}><a href='{{unbound usernameUrl}}'>{{breakUp username}}</a></h3>
{{#if user_title}}<div class="user-title">{{user_title}}</div>{{/if}}
</div>
{{else}}
<div class="contents">
<i class="icon icon-trash deleted-user-avatar"></i>
<h3 class="deleted-username">{{i18n user.deleted}}</h3>
</div>
{{/unless}}
</div>
<div class='topic-body span14'>
<button {{action selectPost this}} {{bindAttr class=":post-select controller.multiSelect::hidden"}}>{{view.selectText}}</button>
<div {{bindAttr class=":select-posts controller.multiSelect::hidden"}}>
<button {{action toggledSelectedPostReplies this}} {{bindAttr class="view.canSelectReplies::hidden"}}>{{i18n topic.multi_select.select_replies}}</button>
<button {{action toggledSelectedPost this}} class="select-post">{{view.selectPostText}}</button>
</div>
<div {{bindAttr class="showUserReplyTab:avoid-tab view.repliesShown::bottom-round :contents :regular view.extraClass"}}>
{{#unless controller.multiSelect}}

View file

@ -16,10 +16,13 @@
<ul class='action-list nav-stacked side-nav'>
{{#if privateMessageView}}
<li {{bindAttr class=":noGlyph privateMessagesActive:active"}}>
{{#linkTo 'userPrivateMessages.index' model}}{{i18n user.private_messages}}{{/linkTo}}
{{#linkTo 'userPrivateMessages.index' model}}{{i18n user.messages.all}}{{/linkTo}}
</li>
<li {{bindAttr class=":noGlyph privateMessagesSentActive:active"}}>
{{#linkTo 'userPrivateMessages.sent' model}}{{i18n user.private_messages_sent}}{{/linkTo}}
<li {{bindAttr class=":noGlyph privateMessagesMineActive:active"}}>
{{#linkTo 'userPrivateMessages.mine' model}}{{i18n user.messages.mine}}{{/linkTo}}
</li>
<li {{bindAttr class=":noGlyph privateMessagesUnreadActive:active"}}>
{{#linkTo 'userPrivateMessages.unread' model}}{{i18n user.messages.unread}}{{/linkTo}}
</li>
{{else}}

View file

@ -29,17 +29,20 @@ Discourse.PostView = Discourse.GroupedView.extend({
mouseUp: function(e) {
if (this.get('controller.multiSelect') && (e.metaKey || e.ctrlKey)) {
this.get('controller').selectPost(this.get('post'));
this.get('controller').toggledSelectedPost(this.get('post'));
}
},
selected: function() {
var selectedPosts = this.get('controller.selectedPosts');
if (!selectedPosts) return false;
return selectedPosts.contains(this.get('post'));
return this.get('controller').postSelected(this.get('post'));
}.property('controller.selectedPostsCount'),
selectText: function() {
canSelectReplies: function() {
if (this.get('post.reply_count') === 0) { return false; }
return !this.get('selected');
}.property('post.reply_count', 'selected'),
selectPostText: function() {
return this.get('selected') ? I18n.t('topic.multi_select.selected', { count: this.get('controller.selectedPostsCount') }) : I18n.t('topic.multi_select.select');
}.property('selected', 'controller.selectedPostsCount'),

View file

@ -1,3 +1,22 @@
/*
This is a fork of markdown-js with a few changes to support discourse:
* We have replaced the strong/em handlers because we prefer them only to work on word
boundaries.
* We removed the maraku support as we don't use it.
* We don't escape the contents of HTML as we prefer to use a whitelist.
* We fixed a bug where references can be created directly following a list.
* Fix to blockquote to handle spaces in front and when nested.
* Note the name BetterMarkdown doesn't mean it's *better* than markdown-js, it refers
to it being better than our previous markdown parser!
*/
// Released under MIT license
// Copyright (c) 2009-2010 Dominic Baggott
// Copyright (c) 2009-2010 Ash Berlin
@ -190,6 +209,35 @@ Markdown.prototype.split_blocks = function splitBlocks( input, startLine ) {
return blocks;
};
function create_attrs() {
if ( !extract_attr( this.tree ) ) {
this.tree.splice( 1, 0, {} );
}
var attrs = extract_attr( this.tree );
// make a references hash if it doesn't exist
if ( attrs.references === undefined ) {
attrs.references = {};
}
return attrs;
}
function create_reference(attrs, m) {
if ( m[2] && m[2][0] == "<" && m[2][m[2].length-1] == ">" )
m[2] = m[2].substring( 1, m[2].length - 1 );
var ref = attrs.references[ m[1].toLowerCase() ] = {
href: m[2]
};
if ( m[4] !== undefined )
ref.title = m[4];
else if ( m[5] !== undefined )
ref.title = m[5];
}
/**
* Markdown#processBlock( block, next ) -> undefined | [ JsonML, ... ]
* - block (String): the block to process
@ -516,6 +564,7 @@ Markdown.dialects.Gruber = {
// The matcher function
return function( block, next ) {
var m = block.match( is_list_re );
if ( !m ) return undefined;
@ -667,6 +716,7 @@ Markdown.dialects.Gruber = {
})(),
blockquote: function blockquote( block, next ) {
if ( !block.match( /^>/m ) )
return undefined;
@ -702,7 +752,7 @@ Markdown.dialects.Gruber = {
}
// Strip off the leading "> " and re-process as a block.
var input = block.replace( /^> ?/gm, "" ),
var input = block.replace( /^> */gm, "" ),
old_tree = this.tree,
processedBlock = this.toTree( input, [ "blockquote" ] ),
attr = extract_attr( processedBlock );
@ -721,39 +771,18 @@ Markdown.dialects.Gruber = {
},
referenceDefn: function referenceDefn( block, next) {
var re = /^\s*\[(.*?)\]:\s*(\S+)(?:\s+(?:(['"])(.*?)\3|\((.*?)\)))?\n?/;
// interesting matches are [ , ref_id, url, , title, title ]
if ( !block.match(re) )
return undefined;
// make an attribute node if it doesn't exist
if ( !extract_attr( this.tree ) ) {
this.tree.splice( 1, 0, {} );
}
var attrs = extract_attr( this.tree );
// make a references hash if it doesn't exist
if ( attrs.references === undefined ) {
attrs.references = {};
}
var attrs = create_attrs.call(this);
var b = this.loop_re_over_block(re, block, function( m ) {
if ( m[2] && m[2][0] == "<" && m[2][m[2].length-1] == ">" )
m[2] = m[2].substring( 1, m[2].length - 1 );
var ref = attrs.references[ m[1].toLowerCase() ] = {
href: m[2]
};
if ( m[4] !== undefined )
ref.title = m[4];
else if ( m[5] !== undefined )
ref.title = m[5];
} );
create_reference(attrs, m);
});
if ( b.length )
next.unshift( mk_block( b, block.trailing ) );
@ -876,6 +905,7 @@ Markdown.dialects.Gruber.inline = {
"[": function link( text ) {
var orig = String(text);
// Inline content is possible inside `link text`
var res = Markdown.DialectHelpers.inline_until_char.call( this, text.substr(1), "]" );
@ -939,7 +969,6 @@ Markdown.dialects.Gruber.inline = {
m = text.match( /^\s*\[(.*?)\]/ );
if ( m ) {
consumed += m[ 0 ].length;
// [links][] uses links as its reference
@ -953,6 +982,15 @@ Markdown.dialects.Gruber.inline = {
return [ consumed, link ];
}
m = orig.match(/^\s*\[(.*?)\]:\s*(\S+)(?:\s+(?:(['"])(.*?)\3|\((.*?)\)))?\n?/);
if (m) {
var attrs = create_attrs.call(this);
create_reference(attrs, m);
return [ m[0].length ]
}
// [id]
// Only if id is plain (no formatting.)
if ( children.length == 1 && typeof children[0] == "string" ) {
@ -1004,69 +1042,6 @@ Markdown.dialects.Gruber.inline = {
};
// Meta Helper/generator method for em and strong handling
function strong_em( tag, md ) {
var state_slot = tag + "_state",
other_slot = tag == "strong" ? "em_state" : "strong_state";
function CloseTag(len) {
this.len_after = len;
this.name = "close_" + md;
}
return function ( text, orig_match ) {
if ( this[state_slot][0] == md ) {
// Most recent em is of this type
//D:this.debug("closing", md);
this[state_slot].shift();
// "Consume" everything to go back to the recrusion in the else-block below
return[ text.length, new CloseTag(text.length-md.length) ];
}
else {
// Store a clone of the em/strong states
var other = this[other_slot].slice(),
state = this[state_slot].slice();
this[state_slot].unshift(md);
//D:this.debug_indent += " ";
// Recurse
var res = this.processInline( text.substr( md.length ) );
//D:this.debug_indent = this.debug_indent.substr(2);
var last = res[res.length - 1];
//D:this.debug("processInline from", tag + ": ", uneval( res ) );
var check = this[state_slot].shift();
if ( last instanceof CloseTag ) {
res.pop();
// We matched! Huzzah.
var consumed = text.length - last.len_after;
return [ consumed, [ tag ].concat(res) ];
}
else {
// Restore the state of the other kind. We might have mistakenly closed it.
this[other_slot] = other;
this[state_slot] = state;
// We can't reuse the processed result as it could have wrong parsing contexts in it.
return [ md.length, md ];
}
}
}; // End returned function
}
Markdown.dialects.Gruber.inline["**"] = strong_em("strong", "**");
Markdown.dialects.Gruber.inline["__"] = strong_em("strong", "__");
Markdown.dialects.Gruber.inline["*"] = strong_em("em", "*");
Markdown.dialects.Gruber.inline["_"] = strong_em("em", "_");
// Build default order from insertion order.
Markdown.buildBlockOrder = function(d) {
var ord = [];
@ -1084,7 +1059,7 @@ Markdown.buildInlinePatterns = function(d) {
for ( var i in d ) {
// __foo__ is reserved and not a pattern
if ( i.match( /^__.*__$/) ) continue;
var l = i.replace( /([\\.*+?|()\[\]{}])/g, "\\$1" )
var l = i.replace( /([\\.*+?$|()\[\]{}])/g, "\\$1" )
.replace( /\n/, "\\n" );
patterns.push( i.length == 1 ? l : "(?:" + l + ")" );
}

View file

@ -355,6 +355,10 @@
font-size: 13px;
line-height: 18px;
}
.deleted-user-avatar {
font-size: 36px;
line-height: 36px;
}
.staff a {
@include border-radius-all(3px);
@ -496,9 +500,11 @@
}
&.selected {
article.boxed {
.post-select {
background-color: $blue;
color: $white;
.select-posts {
button.select-post {
background-color: $blue;
color: $white;
}
}
.topic-body {
.contents {
@ -515,20 +521,23 @@
font-size: 16px;
line-height: 20px;
.post-select {
@include border-radius-all(4px);
background-color: $light_gray;
border-top: 1px solid $white;
border-left: 1px solid $white;
border-bottom: 1px solid $gray;
border-right: 1px solid $gray;
color: $darkish_gray;
top: 4px;
.select-posts {
position: absolute;
right: 5px;
font-size: 12px;
padding: 2px 5px;
z-index: 490;
top: 4px;
button {
@include border-radius-all(4px);
background-color: $light_gray;
border-top: 1px solid $white;
border-left: 1px solid $white;
border-bottom: 1px solid $gray;
border-right: 1px solid $gray;
color: $darkish_gray;
font-size: 12px;
padding: 2px 5px;
}
}
img {

View file

@ -196,6 +196,16 @@ class ApplicationController < ActionController::Base
user
end
def post_ids_including_replies
post_ids = params[:post_ids].map {|p| p.to_i}
if params[:reply_post_ids]
post_ids << PostReply.where(post_id: params[:reply_post_ids].map {|p| p.to_i}).pluck(:reply_id)
post_ids.flatten!
post_ids.uniq!
end
post_ids
end
private
def preload_anonymous_data

View file

@ -53,6 +53,14 @@ class ListController < ApplicationController
respond(list)
end
def private_messages_unread
list_opts = build_topic_list_options
list = TopicQuery.new(current_user, list_opts).list_private_messages_unread(fetch_user_from_params)
list.more_topics_url = url_for(topics_private_messages_unread_path(list_opts.merge(format: 'json', page: next_page)))
respond(list)
end
def category
query = TopicQuery.new(current_user, page: params[:page])

View file

@ -150,16 +150,16 @@ class PostsController < ApplicationController
params.require(:post_ids)
posts = Post.where(id: params[:post_ids])
posts = Post.where(id: post_ids_including_replies)
raise Discourse::InvalidParameters.new(:post_ids) if posts.blank?
# Make sure we can delete the posts
posts.each {|p| guardian.ensure_can_delete!(p) }
Post.transaction do
topic_id = posts.first.topic_id
posts.each {|p| p.destroy }
Topic.reset_highest(topic_id)
posts.each {|p| PostDestroyer.new(current_user, p).destroy }
end
render nothing: true

View file

@ -244,7 +244,7 @@ class TopicsController < ApplicationController
topic = Topic.where(id: params[:topic_id]).first
guardian.ensure_can_move_posts!(topic)
dest_topic = move_post_to_destination(topic)
dest_topic = move_posts_to_destination(topic)
render_topic_changes(dest_topic)
end
@ -333,12 +333,12 @@ class TopicsController < ApplicationController
private
def move_post_to_destination(topic)
def move_posts_to_destination(topic)
args = {}
args[:title] = params[:title] if params[:title].present?
args[:destination_topic_id] = params[:destination_topic_id].to_i if params[:destination_topic_id].present?
topic.move_posts(current_user, params[:post_ids].map {|p| p.to_i}, args)
topic.move_posts(current_user, post_ids_including_replies, args)
end
end

View file

@ -11,7 +11,8 @@ class Users::OmniauthCallbacksController < ApplicationController
Auth::OpenIdAuthenticator.new("yahoo", "https://me.yahoo.com", trusted: true),
Auth::GithubAuthenticator.new,
Auth::TwitterAuthenticator.new,
Auth::PersonaAuthenticator.new
Auth::PersonaAuthenticator.new,
Auth::CasAuthenticator.new
]
skip_before_filter :redirect_to_login_if_required
@ -37,9 +38,13 @@ class Users::OmniauthCallbacksController < ApplicationController
@data = authenticator.after_authenticate(auth)
@data.authenticator_name = authenticator.name
user_found(@data.user) if @data.user
session[:authentication] = @data.session_data
if @data.user
user_found(@data.user)
elsif SiteSetting.invite_only?
@data.requires_invite = true
else
session[:authentication] = @data.session_data
end
respond_to do |format|
format.html
@ -87,7 +92,7 @@ class Users::OmniauthCallbacksController < ApplicationController
session[:authentication] = nil
@data.authenticated = true
else
if SiteSetting.invite_only?
if SiteSetting.must_approve_users? && !user.approved?
@data.awaiting_approval = true
else
@data.awaiting_activation = true

View file

@ -18,12 +18,7 @@ class CategoryFeaturedTopic < ActiveRecord::Base
CategoryFeaturedTopic.transaction do
CategoryFeaturedTopic.delete_all(category_id: c.id)
# fake an admin
admin = User.new
admin.admin = true
admin.id = -1
query = TopicQuery.new(admin, per_page: SiteSetting.category_featured_topics, except_topic_id: c.topic_id, visible: true)
query = TopicQuery.new(self.fake_admin, per_page: SiteSetting.category_featured_topics, except_topic_id: c.topic_id, visible: true)
results = query.list_category(c)
if results.present?
results.topic_ids.each_with_index do |topic_id, idx|
@ -33,6 +28,15 @@ class CategoryFeaturedTopic < ActiveRecord::Base
end
end
private
def self.fake_admin
# fake an admin
admin = User.new
admin.admin = true
admin.id = -1
admin
end
end
# == Schema Information

View file

@ -8,6 +8,24 @@ InviteRedeemer = Struct.new(:invite) do
invited_user
end
# extracted from User cause it is very specific to invites
def self.create_user_for_email(email)
username = UserNameSuggester.suggest(email)
DiscourseHub.nickname_operation do
match, available, suggestion = DiscourseHub.nickname_match?(username, email)
username = suggestion unless match || available
end
user = User.new(email: email, username: username, name: username, active: true)
user.trust_level = SiteSetting.default_invitee_trust_level
user.save!
DiscourseHub.nickname_operation { DiscourseHub.register_nickname(username, email) }
user
end
private
def invited_user
@ -34,7 +52,7 @@ InviteRedeemer = Struct.new(:invite) do
def get_invited_user
result = get_existing_user
result ||= create_new_user
result ||= InviteRedeemer.create_user_for_email(invite.email)
result.send_welcome_message = false
result
end
@ -43,9 +61,6 @@ InviteRedeemer = Struct.new(:invite) do
User.where(email: invite.email).first
end
def create_new_user
User.create_for_email(invite.email, trust_level: SiteSetting.default_invitee_trust_level)
end
def add_to_private_topics_if_invited
invite.topics.private_messages.each do |t|

View file

@ -0,0 +1,18 @@
require_dependency 'enum_site_setting'
class MinTrustToCreateTopicSetting < EnumSiteSetting
def self.valid_value?(val)
valid_values.any? { |v| v.to_s == val.to_s }
end
def self.values
@values ||= valid_values.map {|x| {name: x.to_s, value: x} }
end
private
def self.valid_values
TrustLevel.levels.values.sort
end
end

View file

@ -2,3 +2,22 @@ class Oauth2UserInfo < ActiveRecord::Base
belongs_to :user
end
# == Schema Information
#
# Table name: oauth2_user_infos
#
# id :integer not null, primary key
# user_id :integer not null
# uid :string(255) not null
# provider :string(255) not null
# email :string(255)
# name :string(255)
# created_at :datetime not null
# updated_at :datetime not null
#
# Indexes
#
# index_oauth2_user_infos_on_uid_and_provider (uid,provider) UNIQUE
#

View file

@ -1,2 +1,18 @@
class PluginStoreRow < ActiveRecord::Base
end
# == Schema Information
#
# Table name: plugin_store_rows
#
# id :integer not null, primary key
# plugin_name :string(255) not null
# key :string(255) not null
# type_name :string(255) not null
# value :text
#
# Indexes
#
# index_plugin_store_rows_on_plugin_name_and_key (plugin_name,key) UNIQUE
#

View file

@ -45,7 +45,6 @@ class Post < ActiveRecord::Base
scope :public_posts, -> { joins(:topic).where('topics.archetype <> ?', Archetype.private_message) }
scope :private_posts, -> { joins(:topic).where('topics.archetype = ?', Archetype.private_message) }
scope :with_topic_subtype, ->(subtype) { joins(:topic).where('topics.subtype = ?', subtype) }
scope :without_nuked_users, -> { where(nuked_user: false) }
def self.hidden_reasons
@hidden_reasons ||= Enum.new(:flag_threshold_reached, :flag_threshold_reached_again, :new_user_spam_threshold_reached)
@ -383,7 +382,7 @@ end
# Table name: posts
#
# id :integer not null, primary key
# user_id :integer not null
# user_id :integer
# topic_id :integer not null
# post_number :integer not null
# raw :text not null
@ -419,7 +418,6 @@ end
# notify_user_count :integer default(0), not null
# like_score :integer default(0), not null
# deleted_by_id :integer
# nuked_user :boolean default(FALSE)
#
# Indexes
#

View file

@ -23,3 +23,23 @@ class ScreenedEmail < ActiveRecord::Base
end
end
# == Schema Information
#
# Table name: screened_emails
#
# id :integer not null, primary key
# email :string(255) not null
# action_type :integer not null
# match_count :integer default(0), not null
# last_match_at :datetime
# created_at :datetime not null
# updated_at :datetime not null
# ip_address :string
#
# Indexes
#
# index_blocked_emails_on_email (email) UNIQUE
# index_blocked_emails_on_last_match_at (last_match_at)
#

View file

@ -24,3 +24,24 @@ class ScreenedUrl < ActiveRecord::Base
find_by_url(url) || create(opts.slice(:action_type, :ip_address).merge(url: url, domain: domain))
end
end
# == Schema Information
#
# Table name: screened_urls
#
# id :integer not null, primary key
# url :string(255) not null
# domain :string(255) not null
# action_type :integer not null
# match_count :integer default(0), not null
# last_match_at :datetime
# created_at :datetime not null
# updated_at :datetime not null
# ip_address :string
#
# Indexes
#
# index_screened_urls_on_last_match_at (last_match_at)
# index_screened_urls_on_url (url) UNIQUE
#

View file

@ -67,7 +67,7 @@ class SiteSetting < ActiveRecord::Base
setting(:num_flags_to_block_new_user, 3)
setting(:num_users_to_block_new_user, 3)
setting(:notify_mods_when_user_blocked, true)
setting(:notify_mods_when_user_blocked, false)
# used mainly for dev, force hostname for Discourse.base_url
# You would usually use multisite for this
@ -205,6 +205,8 @@ class SiteSetting < ActiveRecord::Base
setting(:regular_requires_likes_given, 1)
setting(:regular_requires_topic_reply_count, 3)
setting(:min_trust_to_create_topic, 0, enum: 'MinTrustToCreateTopicSetting')
# Reply by Email Settings
setting(:reply_by_email_enabled, false)
setting(:reply_by_email_address, '')

View file

@ -53,11 +53,15 @@ end
# context :string(255)
# ip_address :string(255)
# email :string(255)
# subject :text
# previous_value :text
# new_value :text
#
# Indexes
#
# index_staff_action_logs_on_action_and_id (action,id)
# index_staff_action_logs_on_staff_user_id_and_id (staff_user_id,id)
# index_staff_action_logs_on_subject_and_id (subject,id)
# index_staff_action_logs_on_target_user_id_and_id (target_user_id,id)
#

View file

@ -10,6 +10,16 @@ class Topic < ActiveRecord::Base
include ActionView::Helpers::SanitizeHelper
include RateLimiter::OnCreateRecord
include Trashable
extend Forwardable
def_delegator :featured_users, :user_ids, :featured_user_ids
def_delegator :featured_users, :choose, :feature_topic_users
def_delegator :notifier, :watch!, :notify_watch!
def_delegator :notifier, :tracking!, :notify_tracking!
def_delegator :notifier, :regular!, :notify_regular!
def_delegator :notifier, :muted!, :notify_muted!
def_delegator :notifier, :toggle_mute, :toggle_mute
def self.max_sort_order
2**31 - 1
@ -21,14 +31,6 @@ class Topic < ActiveRecord::Base
@featured_users ||= TopicFeaturedUsers.new(self)
end
def featured_user_ids
featured_users.user_ids
end
def feature_topic_users(args={})
featured_users.choose(args)
end
def trash!(trashed_by=nil)
update_category_topic_count_by(-1) if deleted_at.nil?
super(trashed_by)
@ -561,34 +563,12 @@ class Topic < ActiveRecord::Base
@topic_notifier ||= TopicNotifier.new(self)
end
# notification stuff
def notify_watch!(user)
notifier.watch! user
end
def notify_tracking!(user)
notifier.tracking! user
end
def notify_regular!(user)
notifier.regular! user
end
def notify_muted!(user)
notifier.muted! user
end
def muted?(user)
if user && user.id
notifier.muted?(user.id)
end
end
# Enable/disable the mute on the topic
def toggle_mute(user_id)
notifier.toggle_mute user_id
end
def auto_close_days=(num_days)
@ignore_category_auto_close = true
set_auto_close(num_days)

View file

@ -204,7 +204,7 @@ end
#
# Indexes
#
# index_forum_thread_links_on_forum_thread_id (topic_id)
# index_forum_thread_links_on_forum_thread_id_and_post_id_and_url (topic_id,post_id,url) UNIQUE
# index_forum_thread_links_on_forum_thread_id (topic_id)
# unique_post_links (topic_id,post_id,url) UNIQUE
#

View file

@ -53,6 +53,6 @@ end
#
# Indexes
#
# index_forum_thread_link_clicks_on_forum_thread_link_id (topic_link_id)
# by_link (topic_link_id)
#

View file

@ -13,22 +13,22 @@ class User < ActiveRecord::Base
include Roleable
has_many :posts
has_many :notifications
has_many :topic_users
has_many :notifications, dependent: :destroy
has_many :topic_users, dependent: :destroy
has_many :topics
has_many :user_open_ids, dependent: :destroy
has_many :user_actions
has_many :post_actions
has_many :email_logs
has_many :user_actions, dependent: :destroy
has_many :post_actions, dependent: :destroy
has_many :email_logs, dependent: :destroy
has_many :post_timings
has_many :topic_allowed_users
has_many :topic_allowed_users, dependent: :destroy
has_many :topics_allowed, through: :topic_allowed_users, source: :topic
has_many :email_tokens
has_many :email_tokens, dependent: :destroy
has_many :views
has_many :user_visits
has_many :invites
has_many :topic_links
has_many :uploads
has_many :user_visits, dependent: :destroy
has_many :invites, dependent: :destroy
has_many :topic_links, dependent: :destroy
has_many :uploads, dependent: :destroy
has_one :facebook_user_info, dependent: :destroy
has_one :twitter_user_info, dependent: :destroy
@ -37,11 +37,11 @@ class User < ActiveRecord::Base
has_one :oauth2_user_info, dependent: :destroy
belongs_to :approved_by, class_name: 'User'
has_many :group_users
has_many :group_users, dependent: :destroy
has_many :groups, through: :group_users
has_many :secure_categories, through: :groups, source: :categories
has_one :user_search_data
has_one :user_search_data, dependent: :destroy
belongs_to :uploaded_avatar, class_name: 'Upload', dependent: :destroy
@ -61,6 +61,12 @@ class User < ActiveRecord::Base
after_create :create_email_token
before_destroy do
# These tables don't have primary keys, so destroying them with activerecord is tricky:
PostTiming.delete_all(user_id: self.id)
View.delete_all(user_id: self.id)
end
# Whether we need to be sending a system message after creation
attr_accessor :send_welcome_message
@ -96,23 +102,6 @@ class User < ActiveRecord::Base
user
end
def self.create_for_email(email, opts={})
username = UserNameSuggester.suggest(email)
discourse_hub_nickname_operation do
match, available, suggestion = DiscourseHub.nickname_match?(username, email)
username = suggestion unless match || available
end
user = User.new(email: email, username: username, name: username)
user.trust_level = opts[:trust_level] if opts[:trust_level].present?
user.save!
discourse_hub_nickname_operation { DiscourseHub.register_nickname(username, email) }
user
end
def self.suggest_name(email)
return "" unless email
name = email.split(/[@\+]/)[0]
@ -154,7 +143,7 @@ class User < ActiveRecord::Base
self.username = new_username
if current_username.downcase != new_username.downcase && valid?
User.discourse_hub_nickname_operation { DiscourseHub.change_nickname(current_username, new_username) }
DiscourseHub.nickname_operation { DiscourseHub.change_nickname(current_username, new_username) }
end
save
@ -612,17 +601,6 @@ class User < ActiveRecord::Base
private
def self.discourse_hub_nickname_operation
if SiteSetting.call_discourse_hub?
begin
yield
rescue DiscourseHub::NicknameUnavailable
false
rescue => e
Rails.logger.error e.message + "\n" + e.backtrace.join("\n")
end
end
end
end
# == Schema Information
@ -647,7 +625,7 @@ end
# website :string(255)
# admin :boolean default(FALSE), not null
# last_emailed_at :datetime
# email_digests :boolean default(TRUE), not null
# email_digests :boolean not null
# trust_level :integer not null
# bio_cooked :text
# email_private_messages :boolean default(TRUE)
@ -657,7 +635,7 @@ end
# approved_at :datetime
# topics_entered :integer default(0), not null
# posts_read_count :integer default(0), not null
# digest_after_days :integer default(7), not null
# digest_after_days :integer
# previous_visit_at :datetime
# banned_at :datetime
# banned_till :datetime
@ -690,3 +668,4 @@ end
# index_users_on_username (username) UNIQUE
# index_users_on_username_lower (username_lower) UNIQUE
#

View file

@ -196,10 +196,13 @@ ORDER BY p.created_at desc
group_ids = topic.category.groups.pluck("groups.id")
end
MessageBus.publish("/users/#{action.user.username.downcase}",
action.id,
user_ids: [user_id],
group_ids: group_ids )
if action.user
MessageBus.publish("/users/#{action.user.username.downcase}",
action.id,
user_ids: [user_id],
group_ids: group_ids )
end
action
rescue ActiveRecord::RecordNotUnique

View file

@ -8,15 +8,15 @@ class BasicPostSerializer < ApplicationSerializer
:cooked
def name
object.user.name
object.user.try(:name)
end
def username
object.user.username
object.user.try(:username)
end
def avatar_template
object.user.avatar_template
object.user.try(:avatar_template)
end
def cooked

View file

@ -46,11 +46,11 @@ class PostSerializer < BasicPostSerializer
def moderator?
object.user.moderator?
object.user.try(:moderator?) || false
end
def staff?
object.user.staff?
object.user.try(:staff?) || false
end
def yours
@ -70,7 +70,7 @@ class PostSerializer < BasicPostSerializer
end
def display_username
object.user.name
object.user.try(:name)
end
def link_counts
@ -101,11 +101,11 @@ class PostSerializer < BasicPostSerializer
end
def user_title
object.user.title
object.user.try(:title)
end
def trust_level
object.user.trust_level
object.user.try(:trust_level)
end
def reply_to_user

View file

@ -15,15 +15,13 @@ module PostStreamSerializerMixin
@highest_number_in_posts = 0
if object.posts.present?
object.posts.each_with_index do |p, idx|
if p.user
@highest_number_in_posts = p.post_number if p.post_number > @highest_number_in_posts
ps = PostSerializer.new(p, scope: scope, root: false)
ps.topic_slug = object.topic.slug
ps.topic_view = object
p.topic = object.topic
@highest_number_in_posts = p.post_number if p.post_number > @highest_number_in_posts
ps = PostSerializer.new(p, scope: scope, root: false)
ps.topic_slug = object.topic.slug
ps.topic_view = object
p.topic = object.topic
@posts << ps.as_json
end
@posts << ps.as_json
end
end
@posts

View file

@ -15,12 +15,6 @@
})();
</script>
<%# load the selected locale before any other scripts %>
<%= javascript_include_tag "locales/#{I18n.locale}" %>
<%= javascript_include_tag "application" %>
<%- if staff? %>
<%= javascript_include_tag "admin"%>
<%- end %>
<script>
Discourse.CDN = '<%= Rails.configuration.action_controller.asset_host %>';

View file

@ -13,6 +13,12 @@
<link rel="apple-touch-icon" type="image/png" href="<%=SiteSetting.apple_touch_icon_url%>">
<%= javascript_include_tag "preload_store" %>
<%= javascript_include_tag "locales/#{I18n.locale}" %>
<%= javascript_include_tag "application" %>
<%- if staff? %>
<%= javascript_include_tag "admin"%>
<%- end %>
<%= render :partial => "common/special_font_face" %>
<%= render :partial => "common/discourse_stylesheet" %>
@ -26,24 +32,6 @@
<%=SiteCustomization.custom_header(session[:preview_style])%>
<section id='main'>
<noscript data-path="<%= request.env['PATH_INFO'] %>">
<header class="d-header">
<div class="container">
<div class="contents">
<div class="row">
<div class="title span13">
<a href="/"><img src="<%=SiteSetting.logo_url%>" alt="<%=SiteSetting.title%>" id="site-logo"></a>
</div>
</div>
</div>
</div>
</header>
<div id="main-outlet" class="container">
<!-- preload-content: -->
<%= yield %>
<!-- :preload-content -->
</div>
</noscript>
</section>
<% unless current_user %>
@ -70,6 +58,24 @@
<%= render :partial => "common/discourse_javascript" %>
<%= render_google_analytics_code %>
<noscript data-path="<%= request.env['PATH_INFO'] %>">
<header class="d-header">
<div class="container">
<div class="contents">
<div class="row">
<div class="title span13">
<a href="/"><img src="<%=SiteSetting.logo_url%>" alt="<%=SiteSetting.title%>" id="site-logo"></a>
</div>
</div>
</div>
</div>
</header>
<div id="main-outlet" class="container">
<!-- preload-content: -->
<%= yield %>
<!-- :preload-content -->
</div>
</noscript>
<!-- Discourse Version: <%= Discourse::VERSION::STRING %> -->
<!-- Git Version: <%= Discourse.git_version %> -->
</body>

View file

@ -66,4 +66,9 @@ Discourse::Application.configure do
# For origin pull cdns all you need to do is register an account and configure
# config.action_controller.asset_host = "http://YOUR_CDN_HERE"
# a comma delimited list of emails your devs have
# developers have god like rights and may impersonate anyone in the system
# normal admins may only impersonate other moderators (not admins)
config.developer_emails = []
end

View file

@ -4,6 +4,8 @@ Discourse::Application.configure do
# Code is not reloaded between requests
config.cache_classes = true
config.log_level = :info
# Full error reports are disabled and caching is turned on
config.consider_all_requests_local = false
config.action_controller.perform_caching = true
@ -37,7 +39,7 @@ Discourse::Application.configure do
config.handlebars.precompile = true
# this setting enable rack_cache so it caches various requests in redis
# config.enable_rack_cache = true
config.enable_rack_cache = false
# allows users to use mini profiler
config.enable_mini_profiler = false

View file

@ -1,6 +1,7 @@
# If Mini Profiler is included via gem
if Rails.configuration.respond_to?(:enable_mini_profiler) && Rails.configuration.enable_mini_profiler
require 'rack-mini-profiler'
require 'flamegraph'
# initialization is skipped so trigger it
Rack::MiniProfilerRails.initialize!(Rails.application)
end
@ -41,6 +42,9 @@ if defined?(Rack::MiniProfiler)
Rack::MiniProfiler.config.backtrace_ignores << /config\/initializers\/silence_logger/
Rack::MiniProfiler.config.backtrace_ignores << /config\/initializers\/quiet_logger/
# Rack::MiniProfiler.counter_method(ActiveRecord::QueryMethods, 'build_arel')
# Rack::MiniProfiler.counter_method(Array, 'uniq')
# require "#{Rails.root}/vendor/backports/notification"
# inst = Class.new

View file

@ -1,10 +1,13 @@
# We have had lots of config issues with SECRET_TOKEN to avoid this mess we are moving it to redis
# if you feel strongly that it does not belong there use ENV['SECRET_TOKEN']
#
token = ENV['SECRET_TOKEN'] || $redis.get('SECRET_TOKEN')
unless token && token.length == 128
token = SecureRandom.hex(64)
$redis.set('SECRET_TOKEN',token)
token = ENV['SECRET_TOKEN']
unless token
token = $redis.get('SECRET_TOKEN')
unless token && token.length == 128
token = SecureRandom.hex(64)
$redis.set('SECRET_TOKEN',token)
end
end
Discourse::Application.config.secret_token = token

View file

@ -5,6 +5,11 @@ Sidekiq.configure_server do |config|
Sidetiq::Clock.start!
end
Sidekiq.configure_client { |config| config.redis = sidekiq_redis }
Sidetiq.configure do |config|
# we only check for new jobs once every 5 seconds
# to cut down on cpu cost
config.resolution = 5
end
Sidekiq.configure_client { |config| config.redis = sidekiq_redis }
Sidekiq.logger.level = Logger::WARN

View file

@ -92,6 +92,7 @@ predef:
- find
- sinon
- controllerFor
- testController
- Favcount
browser: true # true if the standard browser globals should be predefined

View file

@ -20,9 +20,6 @@ de:
mb: MB
tb: TB
dates:
short_date_no_year: "D MMM"
short_date: "D. MMM YYYY"
long_date: "D. MMMM YYYY, H:mm"
tiny:
half_a_minute: "< 1Min"
less_than_x_seconds:
@ -43,12 +40,6 @@ de:
x_days:
one: "1T"
other: "%{count}T"
about_x_months:
one: "1Mon"
other: "%{count}Mon"
x_months:
one: "1Mon"
other: "%{count}Mon"
about_x_years:
one: "1J"
other: "%{count}J"
@ -93,6 +84,7 @@ de:
yes_value: "Ja"
of_value: "von"
generic_error: "Entschuldigung, ein Fehler ist aufgetreten."
generic_error_with_reason: "Ein Fehler ist aufgetreten: %{error}"
log_in: "Anmelden"
age: "Alter"
last_post: "Letzter Beitrag"
@ -101,10 +93,20 @@ de:
show_more: "zeige mehr"
links: Links
faq: "FAQ"
privacy_policy: "Datenschutzrichtlinie"
you: "Du"
or: "oder"
now: "gerade eben"
read_more: 'weiterlesen'
more: "Mehr"
less: "Weniger"
never: "nie"
daily: "täglich"
weekly: "wöchentlich"
every_two_weeks: "jede zweite Woche"
character_count:
one: "{{count}} Zeichen"
other: "{{count}} Zeichen"
in_n_seconds:
one: "in einer Sekunde"
@ -137,6 +139,10 @@ de:
saving: "Wird gespeichert..."
saved: "Gespeichert!"
upload: "Hochladen"
uploading: "Hochladen..."
uploaded: "Hochgeladen!"
choose_topic:
none_found: "Keine Themen gefunden."
title:
@ -175,6 +181,7 @@ de:
"13": "Eingänge"
user:
said: "{{username}} sagte:"
profile: Profil
title: "Benutzer"
mute: Ignorieren
@ -182,6 +189,7 @@ de:
download_archive: "Archiv meiner Beiträge herunterladen"
private_message: "Private Nachricht"
private_messages: "Nachrichten"
private_messages_sent: "Gesendete Nachrichten"
activity_stream: "Aktivität"
preferences: "Einstellungen"
bio: "Über mich"
@ -191,30 +199,41 @@ de:
dynamic_favicon: "Zeige eingehende Nachrichten im Favicon"
external_links_in_new_tab: "Öffne alle externen Links in neuen Tabs"
enable_quoting: "Markierten Text bei Antwort zitieren"
change: "ändern"
moderator: "{{user}} ist Moderator"
admin: "{{user}} ist Administrator"
change_password:
action: "ändern"
success: "(Mail gesendet)"
in_progress: "(sende Mail)"
error: "(Fehler)"
action: "Passwort zurücksetzten Mail senden"
change_about:
title: "Über mich ändern"
change_username:
action: "ändern"
title: "Benutzername ändern"
confirm: "Den Benutzernamen zu ändern kann Konsequenzen nach sich ziehen. Bist Du sicher, dass du fortfahren willst?"
taken: "Entschuldige, der Benutzername ist schon vergeben."
error: "Beim Ändern des Benutzernamens ist ein Fehler aufgetreten."
invalid: "Dieser Benutzername ist ungültig, sie dürfen nur aus Zahlen und Buchstaben bestehen."
change_email:
action: 'ändern'
title: "Mailadresse ändern"
taken: "Entschuldige, diese Mailadresse ist nicht verfügbar."
error: "Beim ändern der Mailadresse ist ein Fehler aufgetreten. Möglicherweise wird diese Adresse schon benutzt."
success: "Eine Bestätigungsmail wurde an diese Adresse verschickt. Bitte folge den darin enthaltenen Anweisungen."
change_avatar:
title: "Ändere dein Avatar"
gravatar: "<a href='//gravatar.com/emails' target='_blank'>Gravatar</a>, basierend auf"
gravatar_title: "Wechsle dein Avatar auf der Gravatar Webseite"
uploaded_avatar: "Eigenes Bild"
uploaded_avatar_empty: "Eigenes Bild hinzufügen"
upload_title: "Lade dein Bild hoch"
image_is_not_a_square: "Achtung: wir haben den Bild angeschnitten, da es nicht rechteckig war."
email:
title: "Mail"
instructions: "Deine Mailadresse wird niemals öffentlich angezeigt."
@ -378,6 +397,7 @@ de:
authenticating: "Authentisiere..."
awaiting_confirmation: 'Dein Konto ist noch nicht aktiviert. Benutze den "Passwort vergesse"-Link um eine neue Aktivierungsmail zu erhalten.'
awaiting_approval: "Dein Konto wurde noch nicht von einem Moderator bewilligt. Du bekommst eine Mail, sobald das geschehen ist."
requires_invite: "Entschuldige, der Zugriff auf dieses Forum ist nur mit einer Einladung erlaubt."
not_activated: "Du kannst Dich noch nicht anmelden. Wir haben Dir kürzlich eine Aktivierungsmail an <b>{{sentTo}}</b> geschickt. Bitte folge den Anweisungen darin, um dein Konto zu aktivieren."
resend_activation_email: "Klick hier, um ein neue Aktivierungsmail zu erhalten."
sent_activation_email_again: "Wir haben noch eine Aktivierungsmail an <b>{{currentEmail}}</b> verschickt. Es kann einige Minuten dauern, bis sie ankommt. Im Zweifel schaue auch im Spam-Ordner nach."
@ -489,16 +509,23 @@ de:
total_flagged: "total markierte Einträge"
upload_selector:
title: "Bild einfügen"
from_my_computer: "von meinem Gerät"
from_the_web: "aus dem Web"
title: "Bild hochladen"
title_with_attachments: "Bild oder Datei hochladen"
from_my_computer: "Von meinem Gerät"
from_the_web: "Aus dem Web"
add_title: "Bild hinzufügen"
add_title_with_attachments: "Bild oder Datei hinzufügen"
remote_title: "Entferntes Bild"
remote_title_with_attachments: "Entferntes Bild oder Datei"
remote_tip: "Gib die Adresse eines Bildes wie folgt ein: http://example.com/image.jpg"
remote_tip_with_attachments: "Gib die Adresse eines Bildes oder Datei wie folgt ein http://example.com/file.ext (Erlaubte Dateiendungen: {{authorized_extensions}})."
local_title: "Lokales Bild"
local_title_with_attachments: "Lokales Bild oder Datei"
local_tip: "Klicke hier, um ein Bild von deinem Gerät zu wählen."
upload_title: "Hochladen"
uploading: "Bild wird hochgeladen"
local_tip_with_attachments: "Klicke hier, um ein Bild oder eine Datei von deinem Gerät zu wählen (Erlaubte Dateiendungen: {{authorized_extensions}})"
upload_title: "Bild hochladen"
upload_title_with_attachments: "Bild oder Datei hochladen"
uploading: "Hochgeladen..."
search:
title: "Such nach Themen, Beiträgen, Nutzern oder Kategorien"
@ -745,10 +772,13 @@ de:
edit: "Editing {{link}} von {{replyAvatar}} {{username}}"
post_number: "Beitrag {{number}}"
in_reply_to: "Antwort auf"
last_edited_on: "Antwort zuletzt bearbeitet am"
reply_as_new_topic: "Mit Themenwechsel antworten"
continue_discussion: "Fortsetzung des Gesprächs {{postLink}}:"
follow_quote: "Springe zu zitiertem Beitrag"
deleted_by_author: "(Beitrag vom Autor entfernt)"
follow_quote: "Springe zu dem zitiertem Beitrag"
deleted_by_author:
one: "(Antwort vom Autor zurückgezogen, wird automatisch in %{count} Stunde gelöscht falls nicht gemeldet)"
other: "(Antwort vom Autor zurückgezogen, wird automatisch in %{count} Stunden gelöscht falls nicht gemeldet)"
deleted_by: "Entfernt von"
expand_collapse: "mehr/weniger"
@ -760,11 +790,11 @@ de:
create: "Entschuldige, es gab einen Fehler beim Anlegen des Beitrags. Bitte versuche es noch einmal."
edit: "Entschuldige, es gab einen Fehler beim Bearbeiten des Beitrags. Bitte versuche es noch einmal."
upload: "Entschuldige, es gab einen Fehler beim Hochladen der Datei. Bitte versuche es noch einmal."
image_too_large: "Entschuldige, das Bild, das du hochladen wolltest, ist zu groß (Maximalgröße {{max_size_kb}}kb), bitte reduziere die Dateigröße und versuche es nochmal."
image_upload_not_allowed_for_new_user: "Entschuldige, neue Benutzer dürfen keine Bilder hochladen."
attachment_too_large: "Entschuldige, die Datei, die du hochladen wolltest, ist zu groß (Maximalgröße {{max_size_kb}}kb)."
image_too_large: "Entschuldige, das Bild, das du hochladen wolltest, ist zu groß (Maximalgröße {{max_size_kb}}kb), bitte reduziere die Dateigröße und versuche es nochmal."
too_many_uploads: "Entschuldige, du darfst immer nur eine Datei hochladen."
upload_not_authorized: "Entschuldige, die Datei, die du hochladen wolltest, ist nicht erlaubt (erlaubte Endungen: {{authorized_extensions}})."
image_upload_not_allowed_for_new_user: "Entschuldige, neue Benutzer dürfen keine Bilder hochladen."
attachment_upload_not_allowed_for_new_user: "Entschuldige, neue Benutzer dürfen keine Dateien hochladen."
abandon: "Willst Du diesen Beitrag wirklich verwerfen?"
@ -884,6 +914,7 @@ de:
other: "Bist Du sicher, dass Du all diesen Beiträge löschen willst?"
category:
can: 'kann&hellip; '
none: '(keine Kategorie)'
edit: 'Bearbeiten'
edit_long: "Kategorie bearbeiten"
@ -912,18 +943,19 @@ de:
change_in_category_topic: "Besuche die Themen dieser Kategorie um einen Eindruck für eine gute Beschreibung zu gewinnen."
hotness: "Beliebtheit"
already_used: 'Diese Farbe wird bereits für eine andere Kategorie verwendet'
is_secure: "Sichere Kategorie?"
add_group: "Gruppe hinzufügen"
security: "Sicherheit"
allowed_groups: "Erlaubte Gruppen:"
auto_close_label: "Thema automatisch schließen nach:"
edit_permissions: "Berechtigung bearbeiten"
add_permission: "Berechtigung hinzufügen"
flagging:
title: 'Aus welchem Grund meldest Du diesen Beitrag?'
action: 'Beitrag melden'
take_action: "Reagieren"
notify_action: 'Melden'
delete_spammer: "Spammer löschen"
delete_confirm: "Du wirst <b>%{posts}</b> Beiträge und <b>%{topics}</b> Themen von diesem Benutzer löschen, das Konto entfernen und die Mail <b>%{email}</b> permanent blockieren. Bist du sicher, dass dieser Benutzer wirklich ein Spammer ist?"
yes_delete_spammer: "Ja, lösche den Spammer"
cant: "Entschuldige, Du kannst diesen Beitrag augenblicklich nicht melden."
custom_placeholder_notify_user: "Weshalb erfordert der Beitrag, dass du den Benutzer direkt und privat kontaktieren möchtest? Sei spezifisch, konstruktiv und immer freundlich."
custom_placeholder_notify_moderators: "Warum soll ein Moderator sich diesen Beitrag ansehen? Bitte lass uns wissen, was genau Dich beunruhigt, und wenn möglich dafür relevante Links."
@ -956,6 +988,7 @@ de:
views_long: "Dieses Thema wurde {{number}} aufgerufen"
activity: "Aktivität"
likes: "Gefällt mir"
likes_long: "es gibt {{number}} „Gefällt mir“ in diesem Thema"
top_contributors: "Teilnehmer"
category_title: "Kategorie"
history: "Verlauf"
@ -1004,6 +1037,11 @@ de:
browser_update: '<a href="http://www.discourse.org/faq/#browser">Dein Webbrowser ist leider zu alt um dieses Forum zu besuchen</a>. Bitte <a href="http://browsehappy.com">installiere einen neueren Browser</a>.'
permission_types:
full: "Erstellen / Antworten / Anschauen"
create_post: "Antworten / Anschauen"
readonly: "Anschauen"
# This section is exported to the javascript for i18n in the admin section
admin_js:
type_to_filter: "Tippe etwas ein, um zu filtern..."
@ -1014,6 +1052,7 @@ de:
dashboard:
title: "Übersicht"
last_updated: "Übersicht zuletzt aktualisiert:"
version: "Version"
up_to_date: "Discourse ist aktuell."
critical_available: "Ein kritisches Update ist verfügbar."
@ -1065,6 +1104,7 @@ de:
disagree_unhide_title: "Verwerfe alle Meldungen über diesen Beitrag (blendet verstecke Beiträge ein)"
disagree: "Ablehnen"
disagree_title: "Meldung ablehnen, alle Meldungen über diesen Beitrag annullieren"
delete_spammer_title: "Lösche den Benutzer und alle seine Beiträge und Themen."
flagged_by: "Gemeldet von"
error: "Etwas ist schief gelaufen"
@ -1145,6 +1185,48 @@ de:
last_seen_user: "Letzer Benutzer:"
reply_key: "Antwort-Schlüssel"
logs:
title: "Logs"
action: "Aktion"
created_at: "Erstellt"
last_match_at: "Letzte Übereinstimmung"
match_count: "Übereinstimmungen"
ip_address: "IP"
screened_actions:
block: "blockieren"
do_nothing: "nichts machen"
staff_actions:
title: "Mitarbeiter Aktion"
instructions: "Kilcke auf die Benutzernamen und Aktionen um die Liste zu filtern. Klicke den Avatar um die Benutzerseite zu sehen."
clear_filters: "Alles anzeigen"
staff_user: "Mitarbeiter"
target_user: "Zielnutzer"
subject: "Betreff"
when: "Wann"
context: "Kontext"
details: "Details"
previous_value: "Vorangehend"
new_value: "Neu"
diff: "Diff"
show: "Anzeigen"
modal_title: "Details"
no_previous: "Es gibt keinen vorgängigen Wert."
deleted: "Kein neuer Wert. Der Eintrag wurde gelöscht."
actions:
delete_user: "Benutzer löschen"
change_trust_level: "Vertrauensstufe ändern"
change_site_setting: "Seiten Einstellungen ändern"
change_site_customization: "Seiten Anpassungen ändern"
delete_site_customization: "Seiten Anpassungen löschen"
screened_emails:
title: "Geschützte Mails"
description: "Wen jemand ein Konto erstellt, werden die folgenden Mail überprüft und die Registration blockiert, oder eine andere Aktion ausgeführt."
email: "Mail Adresse"
screened_urls:
title: "Geschützte URLs"
description: "Die aufgelisteten URLs wurden in Beiträgen von identifizierten Spammen verwendet."
url: "URL"
impersonate:
title: "Aus Nutzersicht betrachten"
username_or_email: "Benutzername oder Mailadresse des Nutzers"
@ -1170,6 +1252,9 @@ de:
approved_selected:
one: "Benutzer zulassen"
other: "Benutzer zulassen ({{count}})"
reject_selected:
one: "Benutzer ablehnen"
other: "Lehne ({{count}}) Benutzer ab"
titles:
active: 'Aktive Benutzer'
new: 'Neue Benutzer'
@ -1183,12 +1268,19 @@ de:
moderators: 'Moderatoren'
blocked: 'Gesperrte Benutzer'
banned: "Gebannte Benutzer"
reject_successful:
one: "Erfolgreich 1 Benutzer abgelehnt."
other: "Erfolgreich %{count} Benutzer abgelehnt."
reject_failures:
one: "Konnte 1 Benutzer nicht ablehnen."
other: "Konnte %{count} Benutzer nicht ablehnen."
user:
ban_failed: "Beim Sperren dieses Benutzers ist etwas schief gegangen {{error}}"
unban_failed: "Beim Entsperren dieses Benutzers ist etwas schief gegangen {{error}}"
ban_duration: "Wie lange soll dieser Benutzer gesperrt werden? (Tage)"
delete_all_posts: "Lösche alle Beiträge"
delete_all_posts_confirm: "Du löschst %{posts} Beiträge und %{topics} Themen. Bist du sicher?"
ban: "Sperren"
unban: "Entsperren"
banned: "Gesperrt?"
@ -1219,12 +1311,18 @@ de:
flags_received_count: Erhaltene Meldungen
approve: 'Genehmigen'
approved_by: "genehmigt von"
approve_success: "Benutzer freigeschalten und Mail mit den Anweisungen zur Aktivierung gesendet."
approve_bulk_success: "Erfolg! Alle ausgewählten Benutzer wurden freigeschalten und benachrichtigt."
approve_success: "Benutzer freigeschalten und Mail mit den Anweisungen zur Aktivierung
gesendet."
approve_bulk_success: "Erfolg! Alle ausgewählten Benutzer wurden freigeschalten und
benachrichtigt."
time_read: "Lesezeit"
delete: Benutzer löschen
delete_forbidden: "Der Benutzer kann nicht gelöscht werden, da er noch Beiträge hat. Lösche zuerst seine Beträge."
delete_forbidden:
one: "Benutzer können nicht gelöscht werden, wenn sie sich vor mehr als %{count} Tag angemeldet oder noch Beiträge haben. Lösche zuerst seine Beträge."
other: "Benutzer können nicht gelöscht werden, wenn sie sich vor mehr als %{count} Tagen angemeldet oder noch Beiträge haben. Lösche zuerst seine Beträge."
delete_confirm: "Bist du SICHER das du diesen Benutzer permanent von der Seite entfernen möchtest? Diese Aktion kann nicht rückgängig gemacht werden!"
delete_and_block: "<b>Ja</b>, und <b>blockiere</b> Anmeldungen von dieser Mail Adresse"
delete_dont_block: "<b>Ja</b>, aber <b>erlaube</b> Anmeldungen von dieser Mail Adresse"
deleted: "Der Benutzer wurde gelöscht."
delete_failed: "Beim Löschen des Benutzers ist ein Fehler aufgetreten. Stelle sicher, dass dieser Benutzer keine Beiträge mehr hat."
send_activation_email: "Aktivierungsmail senden"
@ -1239,7 +1337,7 @@ de:
deactivate_explanation: "Ein deaktivierter Benutzer muss seine E-Mail erneut bestätigen."
banned_explanation: "Ein gesperrter Benutzer kann sich nicht einloggen."
block_explanation: "Ein geblockter Benutzer kann keine Themen erstellen oder Beiträge veröffentlichen."
trust_level_change_failed: "Beim Wechsel der Vertrauensstufe ist ein Fehler aufgetreten."
site_content:
none: "Wähle einen Inhaltstyp um mit dem Bearbeiten zu beginnen."
@ -1251,3 +1349,5 @@ de:
title: 'Einstellungen'
reset: 'Zurücksetzen'
none: "Keine"

View file

@ -190,7 +190,6 @@ en:
download_archive: "download archive of my posts"
private_message: "Private Message"
private_messages: "Messages"
private_messages_sent: "Sent Messages"
activity_stream: "Activity"
preferences: "Preferences"
bio: "About me"
@ -203,6 +202,12 @@ en:
change: "change"
moderator: "{{user}} is a moderator"
admin: "{{user}} is an admin"
deleted: "User Was Deleted"
messages:
all: "All"
mine: "Mine"
unread: "Unread"
change_password:
success: "(email sent)"
@ -398,6 +403,7 @@ en:
authenticating: "Authenticating..."
awaiting_confirmation: "Your account is awaiting activation, use the forgot password link to issue another activation email."
awaiting_approval: "Your account has not been approved by a staff member yet. You will be sent an email when it is approved."
requires_invite: "Sorry, access to this forum is by invite only."
not_activated: "You can't log in yet. We previously sent an activation email to you at <b>{{sentTo}}</b>. Please follow the instructions in that email to activate your account."
resend_activation_email: "Click here to send the activation email again."
sent_activation_email_again: "We sent another activation email to you at <b>{{currentEmail}}</b>. It might take a few minutes for it to arrive; be sure to check your spam folder."
@ -505,7 +511,7 @@ en:
private_message: "<i class='icon icon-envelope-alt' title='private message'></i> {{username}} {{link}}"
invited_to_private_message: "<i class='icon icon-envelope-alt' title='private message'></i> {{username}} {{link}}"
invitee_accepted: "<i title='accepted your invitation' class='icon icon-signin'></i> {{username}} accepted your invitation"
moved_post: "<i title='moved post' class='icon icon-arrow-right'></i> {{username}} moved to {{link}}"
moved_post: "<i title='moved post' class='icon icon-arrow-right'></i> {{username}} moved {{link}}"
total_flagged: "total flagged posts"
upload_selector:
@ -759,6 +765,7 @@ en:
multi_select:
select: 'select'
selected: 'selected ({{count}})'
select_replies: 'select +replies'
delete: delete selected
cancel: cancel selecting
description:
@ -811,6 +818,12 @@ en:
undelete: "undelete this post"
share: "share a link to this post"
more: "More"
delete_replies:
confirm:
one: "Do you also want to delete the direct reply to this post?"
other: "Do you also want to delete the {{count}} direct replies to this post?"
yes_value: "Yes, delete the replies too"
no_value: "No, just this post"
actions:
flag: 'Flag'

View file

@ -220,7 +220,6 @@ ru:
download_archive: скачать архив ваших сообщений
private_message: Личное сообщение
private_messages: Личные сообщения
private_messages_sent: Отправленные сообщения
activity_stream: Активность
preferences: Настройки
bio: Обо мне
@ -233,6 +232,10 @@ ru:
change: изменить
moderator: '{{user}} - модератор'
admin: '{{user}} - админ'
messages:
all: Все
mine: Мои
unread: Непрочитанные
change_password:
success: (письмо отправлено)
in_progress: (отправка письма)
@ -258,6 +261,7 @@ ru:
uploaded_avatar: Собственный аватар
uploaded_avatar_empty: Добавить собственный аватар
upload_title: Загрузка собственного аватара
image_is_not_a_square: 'Внимание: изображение было кадрировано, т.к. оно не квадратное.'
email:
title: Email
instructions: Ваш адрес электронной почты всегда скрыт.
@ -407,6 +411,7 @@ ru:
authenticating: Проверка...
awaiting_confirmation: Ваша учетная запись требует активации. Для того чтобы получить активационное письмо повторно, воспользуйтесь опцией сброса пароля.
awaiting_approval: Ваша учетная запись еще не одобрена. Вы получите письмо, когда это случится.
requires_invite: К сожалению, доступ к форуму только по приглашениям.
not_activated: 'Прежде чем вы сможете воспользоваться новой учетной записью, вам необходимо ее активировать. Мы отправили вам на почту <b>{{sentTo}}</b> подробные инструкции, как это cделать.'
resend_activation_email: Щелкните здесь, чтобы мы повторно выслали вам письмо для активации учетной записи.
sent_activation_email_again: 'По адресу <b>{{currentEmail}}</b> повторно отправлено письмо с кодом активации. Доставка сообщения может занять несколько минут. Имейте в виду, что иногда по ошибке письмо может попасть в папку Спам.'
@ -506,7 +511,7 @@ ru:
private_message: "<i class='icon icon-envelope-alt' title='private message'></i> {{username}} {{link}}"
invited_to_private_message: "<i class='icon icon-envelope-alt' title='private message'></i> {{username}} {{link}}"
invitee_accepted: "<i title='принятое приглашение' class='icon icon-signin'></i> {{username}} принял ваше приглашение"
moved_post: "<i title='перенесенное сообщение' class='icon icon-arrow-right'></i> {{username}} перенес сообщение в {{link}}"
moved_post: "<i title='moved post' class='icon icon-arrow-right'></i> {{username}} переместил сообщение в {{link}}"
total_flagged: всего сообщений с жалобами
upload_selector:
title: Загрузить изображение

View file

@ -188,7 +188,6 @@ zh_CN:
download_archive: "下载我的帖子的存档"
private_message: "私信"
private_messages: "消息"
private_messages_sent: "已发送消息"
activity_stream: "活动"
preferences: "设置"
bio: "关于我"
@ -201,6 +200,11 @@ zh_CN:
change: "修改"
moderator: "{{user}} 是版主"
admin: "{{user}} 是管理员"
messages:
all: "所有"
mine: "我的"
unread: "未读"
change_password:
success: "(电子邮件已发送)"
@ -396,6 +400,7 @@ zh_CN:
authenticating: "验证中……"
awaiting_confirmation: "你的帐号尚未激活,点击忘记密码链接来重新发送激活邮件。"
awaiting_approval: "你的帐号尚未被论坛版主批准。一旦你的帐号获得批准,你会收到一封电子邮件。"
requires_invite: "抱歉,本论坛仅接受邀请注册。"
not_activated: "你还不能登录。我们之前在<b>{{sentTo}}</b>发送了一封激活邮件给你。请按照邮件中的介绍来激活你的帐号。"
resend_activation_email: "点击此处来重新发送激活邮件。"
sent_activation_email_again: "我们在<b>{{currentEmail}}</b>又发送了一封激活邮件给你,邮件送达可能需要几分钟,有的电子邮箱服务商可能会认为此邮件为垃圾邮件,请检查一下你邮箱的垃圾邮件文件夹。"
@ -503,7 +508,7 @@ zh_CN:
private_message: "<i class='icon icon-envelope-alt' title='私信'></i> {{username}} 发送给你一条私信:{{link}}"
invited_to_private_message: "{{username}} 邀请你进行私下交流:{{link}}"
invitee_accepted: "<i title='已接受你的邀请' class='icon icon-signin'></i> {{username}} 已接受你的邀请"
moved_post: "<i title='移动帖子' class='icon icon-arrow-right'></i> {{username}} 已将帖子移动到 {{link}}"
moved_post: "<i title='移动帖子' class='icon icon-arrow-right'></i> {{username}} 移动了该帖: {{link}}"
total_flagged: "被报告帖子的总数"
upload_selector:

View file

@ -5,9 +5,15 @@
# http://yamllint.com/
de:
dates:
short_date_no_year: "D MMM"
short_date: "D. MMM YYYY"
long_date: "D. MMMM YYYY, H:mm"
time:
formats:
short: "%d. %m. %Y"
short_no_year: "%-d. %B"
date_only: "%-d. %b %Y"
title: "Discourse"
topics: "Themen"
@ -33,6 +39,10 @@ de:
zero: "Entschuldige, neue Benutzer können Beiträge keine Bilder hinzufügen."
one: "Entschuldige, neue Benutzer können Beiträgen nur ein Bild hinzufügen."
other: "Entschuldige, neue Benutzer können Beiträge nur %{count} Bilde hinzufügen."
too_many_attachments:
zero: "Entschuldige, neue Benutzer können Beiträge keine Dateien hinzufügen."
one: "Entschuldige, neue Benutzer können Beiträgen nur eine Datei hinzufügen."
other: "Entschuldige, neue Benutzer können Beiträgen nur %{count} Dateien hinzufügen."
too_many_links:
zero: "Entschuldige, neue Benutzer können Beiträgen keine Links hinzufügen."
one: "Entschuldige, neue Benutzer können Beiträgen nur einen Link hinzufügen."
@ -50,8 +60,13 @@ de:
rss_topics_in_category: "RSS-Feed von Themen in der Kategorie '%{category}'"
author_wrote: "%{author} schrieb:"
private_message_abbrev: "PN"
rss_description:
latest: "Neuste Themen"
hot: Angesagte Themen"
groups:
errors:
can_not_modify_automatic: "Du kannst eine automatische Gruppe nicht bearbeiten"
default_names:
admins: "admins"
moderators: "moderatoren"
@ -70,8 +85,6 @@ de:
'new-topic': |
Willkommen auf %{site_name} &mdash; **Danke, dass Du ein neues Thema erstellst!**
Beachte dabei bitte die Folgenden Dinge:
- Ist der Titel eines adäquate Beschreibung dessen, was ein Nutzer vorzufinden erwartet, wenn er dieses Thema aufruft?
- Der erste Beitrag umschreibt das Thema: Worum geht es? Wer wäre interessiert daran? Warum ist es wichtig? Welche Arten von Antworten erhoffst Du dir von der Community?
@ -83,8 +96,6 @@ de:
'new-reply': |
Willkommen auf %{site_name} &mdash; **Danke für deinen Beitrag zum Thema!**
Beachte bitte folgende Dinge während des Schreibens:
- Fügt dein Beitrag dem Gespräch etwas Neues hinzu, und sei es auch wenig?
- Behandle deine Gesprächspartner mit demselben Respekt, den Du von ihnen erwartest.
@ -130,6 +141,8 @@ de:
title: "Anführer"
elder:
title: "Ältester"
change_failed_explanation: "Du wolltest %{user_name} auf '%{new_trust_level}' zurückstufen. Jedoch ist seine Vertrauensstufe bereits '%{current_trust_level}'. %{user_name} verbleibt auf '%{current_trust_level}'"
rate_limiter:
too_many_requests: "Du machst das zu häufig. Bitte warte %{time_left} vor dem nächsten Versuch."
@ -382,12 +395,17 @@ de:
cas_config_warning: 'Der Server erlaubt die Anmeldung mit CAS (enable_cas_logins), aber der Hostname und die Domäne sind nicht gesetzt.'
twitter_config_warning: 'Der Server erlaubt die Anmeldung mit Facebook Twitter (enable_twitter_logins), aber der Schlüssel und der Geheimcode sind nicht gesetzt. Besuche <a href="/admin/site_settings">die Einstellungen</a> um die fehlenden Einträge hinzuzufügen. <a href="https://github.com/discourse/discourse/wiki/The-Discourse-Admin-Quick-Start-Guide#enable-twitter-logins" target="_blank">Besuche den Leitfaden um mehr zu erfahren</a>.'
github_config_warning: 'Der Server erlaubt die Anmeldung mit Facebook GitHub (enable_github_logins), aber die Kunden ID und der Geheimcode sind nicht gesetzt. Besuche <a href="/admin/site_settings">die Einstellungen</a> um die fehlenden Einträge hinzuzufügen. <a href="https://github.com/discourse/discourse/wiki/The-Discourse-Admin-Quick-Start-Guide" target="_blank">Besuche den Leitfaden um mehr zu erfahren</a>.'
s3_config_warning: 'Der Server wurde konfiguriert um Dateien nach s3 hochzuladen, aber mindestens der folgenden Einstellungen fehlt: s3_access_key_id, s3_secret_access_key oder s3_upload_bucket. Besuche <a href="/admin/site_settings">die Einstellungen</a> um die fehlenden Einträge hinzuzufügen. <a href="http://meta.discourse.org/t/how-to-set-up-image-uploads-to-s3/7229" target="_blank">Besuche "How to set up image uploads to S3?" um mehr zu erfahren</a>.'
image_magick_warning: 'Der Server wurde konfiguriert um Vorschaubilder von grossen Bildern zu erstellen, aber ImageMagick ist nicht installiertd. Installiere ImageMagick mit deinem bevorzugten Packetmanager oder besuche <a href="http://www.imagemagick.org/script/binary-releases.php" target="_blank">um das aktuelle Paket herunterzuladen</a>.'
failing_emails_warning: 'Es konnten insgesamt %{num_failed_jobs} Mails nicht versendet werden. Bitte überprüfe die Einstellungen in config/environments/production.rb und stelle die Richtigkeit der config.action_mailer Einstellungen. <a href="/sidekiq/retries" target="_blank">Zu den Fehlern in Sidekiq</a>.'
default_logo_warning: "Das Logo der Seite wurde noch nicht angepasst. Bitte bearbeite dieses in den <a href='/admin/site_settings'>Einstellungen</a> (siehe logo_url, logo_small_url und favicon_url)."
contact_email_missing: "Du hast noch keine Kontaktmail für die Seite hinterlegt. Bitte hinterlege diese in den <a href='/admin/site_settings'>Einstellungen</a> (siehe contact_email)."
contact_email_invalid: "Die Kontaktmail der Seite ist ungültig. Bitte bearbeite diese in den <a href='/admin/site_settings'>Einstellungen</a> (siehe contact_email)."
title_nag: "Der Titel der Seite wurde noch nicht angepasst. Bitte bearbeite diesen in den <a href='/admin/site_settings'>Einstellungen</a>."
consumer_email_warning: "Deine Seite verwendet Gmail um Mails zu senden. <a href='http://support.google.com/a/bin/answer.py?hl=en&answer=166852' target='_blank'>Gmail hat eine Limite zum Senden von Mails</a>. Um die Mail-Zustellung zu gewährleisten, solltest du einen anderen Mail Service in Erwägung ziehen."
access_password_removal: "Deine Seite hat die Einstellung access_password verwendet, welche entfernt wurde. Die Einstellungen login_required und must_approve_users wurden eingeschalten und werden sofort verwendet. Du kannst diese in <a href='/admin/site_settings'>den Einstellungen</a> wechseln. Stelle sicher, <a href='/admin/users/list/pending'>dass die Benutzer in der Warteliste</a> aktiviert werden. (Diese Meldung wird in 2 Tagen nicht mehr angezeigt.)"
system_username_warning: "Die Einstellung system_username ist leer. Bitte ändere diese in <a href='/admin/site_settings'>den Einstellungen</a>. Setzte einen Benutzernamen eines Administrators, welcher als Sender der Systemnachrichten verwendet werden soll."
notification_email_warning: "Die Einstellung notification_email ist leer. Bitte ändere diese in <a href='/admin/site_settings'>den Einstellungen</a>."
content_types:
education_new_reply:
@ -405,22 +423,30 @@ de:
welcome_invite:
title: "Willkommen: Eingeladener Benutzer"
description: "Eine private Nachricht welche automatisch an alle eingeladenen Benutzer gesendet wird, wenn diese die Einladung annehmen."
privacy_policy:
title: "Datenschutzrichtlinie"
description: "Die Datenschutzrichtlinie deiner Seite. Leer lassen um die Vorgabe zu verwenden."
faq:
title: "FAQ"
description: "Die FAQ deiner Seite. Leer lassen um die Vorgabe zu verwenden."
login_required_welcome_message:
title: "Anmeldung erforderlich: Willkommensnachricht"
description: "Willkommensnachricht welche angezeigt wird wenn der Benutzer nicht angemeldet ist und die
Einstellung 'login required' aktiviert ist."
tos_user_content_license:
title: "Nutzungsbedingungen: Lizenz"
description: "Der Text für die Lizenz-Sektion in den Nutzungsbedingungen."
tos_miscellaneous:
title: "Nutzungsbedingungen: Verschiedenes"
description: "Der Text für die Verschiedene-Sektion in den Nutzungsbedingungen."
login_required:
title: "Anmeldung erforderlich: Hauptseite"
description: "Der Text welcher nicht angemeldeten Benutzer angezeigt wird, wenn eine Anmeldung erforderlich ist."
site_settings:
default_locale: "Die Standardsprache dieser Discourse-Instanz (kodiert in ISO 639-1)."
min_post_length: "Minimale Beitragslänge in Zeichen."
min_private_message_post_length: "Minimale Beitragslänge in Zeichen für private Nachrichten"
max_post_length: "Maximale Beitragslänge in Zeichen."
min_topic_title_length: "Minimale Titellänge von Themen in Zeichen."
max_topic_title_length: "Maximale Titellänge von Themen in Zeichen."
@ -441,12 +467,15 @@ de:
queue_jobs: "Benutze die Sidekiq-Queue, falls falsche Queues inline sind."
crawl_images: "Lade Bilder von Dritten herunter, um ihre Höhe und Breite zu bestimmen."
ninja_edit_window: "Sekunden nach Empfang eines Beitrag, in denen Bearbeitungen nicht als neue Version gelten."
edit_history_visible_to_public: "Erlaube jedem vorherige Versionen eines beitrages zu sehen. Wenn deaktiviert, konnen nur Mitarbeiter die Bearbeitungshistorie anschauen."
delete_removed_posts_after: "Anzahl Stunden nach welchem Beiträge die von ihrem Author entfernt wurden endgültig gelöscht werden."
max_image_width: "Maximalbreite von Bildern in einem Beitrag."
max_image_height: "Maximalhöhe von Bildern in einem Beitrag."
category_featured_topics: "Zahl der angezeigten Themen je Kategorie auf der Kategorieseite /categories."
add_rel_nofollow_to_user_content: "Füge mit Ausnahme interner Links allen nutzergenerierten Inhalten 'rel nofollow' hinzu (inkludiert übergeordnete Domains). Die Änderung dieser Einstellung erfordert, dass Du sämtliche Markdown-Beiträge aktualisierst."
exclude_rel_nofollow_domains: "Kommaseparierte Liste aller Domains, bei denen 'nofollow' nicht hinzugefügt wird (tld.com erlaubt auch sub.tld.com)."
post_excerpt_maxlength: "Maximale Länge des Exzerpts eines Beitrags in Zeichen."
post_excerpt_maxlength: "Maximale Länge des Zitates eines Beitrags in Zeichen."
post_onebox_maxlength: "Maximale Länge eines Onebox-Discourse-Beitrags."
category_post_template: "Die Beitragsvorlage zur Kategoriedefinition beim erstellen einer neuen Kategorie."
onebox_max_chars: "Maximale Zahl der Zeichen, die eine Onebox von einer externen Webseite in einen Beitrag lädt."
@ -457,6 +486,7 @@ de:
apple_touch_icon_url: "Icon für berührungsempfindliche Apple Geräte. Empfohlene Grösse ist 144px auf 144px."
notification_email: "Die Antwortadresse, die in Systemmails (zum Beispiel zur Passwortwiederherstellung, neuen Konten, etc.) eingetragen wird."
email_custom_headers: "Eine Pipe-getrennte (|) Liste von eigenen Mail Headern"
use_ssl: "Soll die Seite via SSL nutzbar sein?"
best_of_score_threshold: "Der Minimalscore eines Beitrags, um zu den Top Beiträgen zu zählen."
best_of_posts_required: "Minimale Zahl der Beiträge zu einem Thema bevor der Modus 'Top Beiträge' aktiviert wird."
@ -476,13 +506,15 @@ de:
cooldown_minutes_after_hiding_posts: "Minuten, die ein Nutzer warten muss, bevor ein Beitrag, der wegen Meldungen versteckt wurde, bearbeitet werden kann."
num_flags_to_block_new_user: "Wenn ein Beitrag eines neuen Benutzers von (n) anderen Benutzern als Werbung gemeldet wird, verstecke alle Beiträge des Benutzers und erlaube keine neue Beiträge mehr. 0 stellt diese Funktion ab."
num_users_to_block_new_user: "Wenn ein Beitrag eines neuen Benutzers von nderen Benutzern (n) mal als Werbung gemeldet wird, verstecke alle Beiträge des Benutzers und erlaube keine neue Beiträge mehr. 0 stellt diese Funktion ab."
notify_mods_when_user_blocked: "Wenn ein Benutzer automatisch gesperrt wird, sende eine Mail an alle Moderatoren."
traditional_markdown_linebreaks: "Traditionelle Zeilenumbrüche in Markdown, anstatt zwei nachfolgende Leerzeichen als Zeilenumbruch zu verwenden."
post_undo_action_window_mins: "Sekunden, die ein Nutzer hat, um Aktionen auf Beiträgen rückgängig zu machen (Like, Meldung, etc.)."
must_approve_users: "Administratoren müssen Nutzer freischalten, bevor sie Zugriff erlangen."
ga_tracking_code: "Google Analytics Trackingcode, zum Beispiel: UA-12345678-9; siehe http://google.com/analytics"
ga_domain_name: "Google Analytics Domänenname, zum Beispiel: mysite.com; siehe http://google.com/analytics"
enable_escaped_fragments: "Aktiviere Umgehungslösung um älteren Suchmaschinen-Webcrawler zu helfen die Seite zu indexieren. ACHTUNG: Nur aktivieren falls wirklich nötig."
enable_noscript_support: "Aktiviere standard Suchmaschinen-Webcrawler Unterstützung durch den noscript Tag"
top_menu: "Bestimme, welche Navigationselemente in welcher Reihenfolge auftauchen. Beispiel: latest|hot|read|favorited|unread|new|posted|categories"
post_menu: "Bestimme, welche Funktionen in welcher Reihenfolge im Beitragsmenü auftauchen. Beispiel: like|edit|flag|delete|share|bookmark|reply"
share_links: "Bestimme, welche Dienste in welcher Reihenfolge im Teilen-Dialog auftauchen. Beispiel: twitter|facebook|google+|email"
@ -491,11 +523,14 @@ de:
posts_per_page: "Zahl der Beiträge, die auf einer Themenseite gezeigt werden."
system_username: "Benutzername des Autors für automatisch vom Forum versendete private Nachrichten."
send_welcome_message: "Bekommen neue Nutzer eine Willkommensnachricht?"
suppress_reply_directly_below: "Zeige die Zahl der Antworten auf einen Beitrag nicht, falls die einzige Antwort direkt darauf folgt."
suppress_reply_directly_below: "Zeige die Zahl der Antworten auf einen Beitrag nicht, falls die einzige Antwort direkt darunter folgt."
suppress_reply_directly_above: "Zeige 'In Antwort auf' nicht, falls der Beitrag direkt über der einzigen Antwort folgt."
allow_index_in_robots_txt: "Diese Seite soll durch Suchmaschinen indiziert werden (aktualisiert robots.txt)."
email_domains_blacklist: "Eine durch senkrechte Striche getrennte Liste von unerlaubten Maildomains. Beispiel: mailinator.com|trashmail.net"
email_domains_whitelist: "Eine durch senkrechte Striche getrennte Liste von erlaubte Maildomains. WARNUNG: Benutzer mit Mailadressen anderer Domains können sich nicht registrieren."
version_checks: "Erfrage Versionsupdate bei Discourse Hub und zeige Versionsbenachrichtigungen auf der Administratorkonsole /admin."
new_version_emails: "Sende eine Mail an contact_email Adresse wenn eine neue Version verfügbar ist."
port: "NUR FÜR ENTWICKLER! ACHTUNG! Benutze diesen HTTP-Port anstatt den Standardport 80. Diese Feld leer lassen heißt 'keinen'. Dient hauptsächlich Entwicklungszwecken."
force_hostname: "NUR FÜR ENTWICKLER! ACHTUNG! Spezifiziere einen Hostnamen in der URL. Dieses Feld leer lassen heißt 'keinen'. Dient hauptsächlich Entwicklungszwecken."
@ -559,6 +594,8 @@ de:
s3_secret_access_key: "Der geheime Schlüssel von Amazon S3 welcher für das Hochladen verwendet wird"
s3_region: "Der Name der Amazon S3 Region welche für das Hochladen verwendet wird"
enable_flash_video_onebox: "Aktiviere das Einbinden von swf und flv Links in einer Onebox. ACHTUNG: Kann eine Sicherheitsrisiko sein"
default_invitee_trust_level: "Standardwert für die Stufe eines eingeladenen Nutzers (0-4)."
default_trust_level: "Standardwert für die Stufe von Nutzern (0-4)."
@ -576,10 +613,14 @@ de:
newuser_max_links: "Maximale Zahl der Links, die neue Benutzer Beiträgen hinzufügen dürfen."
newuser_max_images: "Maximale Zahl der Bilder, die neue Benutzer Beiträgen hinzufügen dürfen."
newuser_max_attachments: "Maximale Zahl der Dateien, die neue Benutzer Beiträgen hinzufügen dürfen."
newuser_max_mentions_per_post: "Maximale Zahl der @Namens-Erwähnungen, die neue Benutzer in Beiträgen nutzen dürfen."
max_mentions_per_post: "Maximale Zahl der @Namens-Erwähnungen, die man in einem Beitrag nutzen kann."
create_thumbnails: "Erstelle Vorschaubilder für Bilder in einer Lightbox"
email_time_window_mins: "Minuten Wartezeit, bevor eine Mail an Nutzer verschickt wird, um ihnen die Chance zu geben, eine Neuigkeit zuerst zu sehen."
email_posts_context: "Anzahl der Antworten welche als Konext einer Notifikations-Mail hinzugefügt werden."
flush_timings_secs: "Sekunden, nach denen Zeiteinstellungen auf den Server übertragen werden."
max_word_length: "Maximale Wortlänge in Zeichen in Thementiteln."
title_min_entropy: "Minimal nötige Entropie (einzigartige Zeichen) in einem Thementitel."
@ -591,7 +632,9 @@ de:
min_body_similar_length: "Minimale Länge eines Beitragstextes, bevor nach ähnlichen Themen gesucht wird."
category_colors: "Eine durch senkrechte Striche getrennte Liste hexadezimaler Farbwerte, die als Kategoriefarben erlaubt sind."
max_image_size_kb: "Maximale Größe in Kilobytes (kB), die von Benutzern hochgeladene Bilder groß sein dürfen. Stelle sicher, dass dieser Wert auch in nginx (client_max_body_size) / apache und Proxies konfiguriert ist."
max_image_size_kb: "Maximale Größe in Kilobytes (kB), die von Benutzern hochgeladene Bilder groß sein dürfen. Stelle sicher, dass dieser Wert auch in nginx (client_max_body_size) / Apache und Proxies konfiguriert ist."
max_attachment_size_kb: "Maximale Größe in Kilobytes (kB), die von Benutzern hochgeladenen Dateien groß sein dürfen. Stelle sicher, dass dieser Wert auch in nginx (client_max_body_size) / Apache und Proxies konfiguriert ist."
authorized_extensions: "Eine Pipe-getrennte (|) Liste von Dateiendungen welche hochgeladen werden dürfen."
max_similar_results: "Anzahl ähnlicher Themen, die ein Nutzer sieht, während er ein neues Thema erstellen."
title_prettify: "Verhindert gängige Fehler im Titel, wie reine Grossschreibung, Kleinbuchstaben am Anfang, mehrere ! und ?, überflüssiger . am Ende, etc."
@ -600,12 +643,33 @@ de:
topic_views_heat_medium: "Die Anzahl der Aufrufe bis die Popularität des Themas mittel ist."
topic_views_heat_high: "Die Anzahl der Aufrufe bis die Popularität des Themas hoch ist."
faq_url: "URL zu einer externen FAQ welche Du gerne verwenden möchtest."
tos_url: "URL zu einer externen Dienstleistungsbedingung welche Du gerne verwenden möchtest."
privacy_policy_url: "URL zu einer externen Datenschutzrichtlinie welche Du gerne verwenden möchtest."
newuser_spam_host_threshold: "Die Anzahl welche ein Frischling Beiträge mit Links auf die gleiche Seite innerhalb ihrer `newuser_spam_host_posts` veröffentlichen , bevor der Beitrag als Spam klassifiziert wird."
staff_like_weight: "Zusätzlicher Gewichtungsfaktor wenn Mitglieder „Gefällt mir“ verteilen."
reply_by_email_enabled: "Erlaube das Antworten auf Themen via Mail"
reply_by_email_address: "Vorgabe der Antwort-Mail Adresse in der Form von: %{reply_key}@reply.myforum.com"
pop3s_polling_enabled: "Antworten via POP3S anfragen"
pop3s_polling_port: "Der Port für die POP3S Anfrage"
pop3s_polling_host: "Der Host für die POP3S Anfrage"
pop3s_polling_username: "Der Benutzername für die POP3S Anfrage"
pop3s_polling_password: "Das Passwort für die POP3S Anfrage"
minimum_topics_similar: "Wie viele Themen in der Datenbank existieren müssen, bevor ähnliche Themen angezeigt werden."
relative_date_duration: "Anzahl von Tagen nach nach welchen das Beitragsdatum relativ und nicht absolut angezeigt wird. Beispiel: relatives Datum: 7T, absolutes Datum: 20 Feb"
delete_user_max_age: "Nach wievielen Tagen ein Benutzerkonto von einem Administrator gelöscht werden kann."
delete_all_posts_max: "Die maximale Anzahl von Beiträgen welche auf einmal gelöscht werden kann. Hat ein Benutzer mehr Beiträge, so können die Beiträge nicht auf einmal und der Benutzer nicht gelöscht werden."
username_change_period: "Wie lange neu registrierte Benutzer ihren Benutzernamen ändern können."
allow_uploaded_avatars: "Erlaube das Hochladen eines eigenen Avatars"
allow_animated_avatars: "Erlaube den Benutzern animierte GIFs als Avatar zu benutzen"
default_digest_email_frequency: "Wie oft man Zusammenfassungen per Mail standardmässig erhält. Diese Einstellung kann von jedem geändert werden."
notification_types:
mentioned: "%{display_username} hat Dich in %{link} erwähnt."
liked: "%{display_username} gefällt deinen Beitrag in %{link}."
@ -633,6 +697,9 @@ de:
moderator_post:
one: "Ich habe einen Beitrag in ein neues Thema verschoben: %{topic_link}"
other: "Ich habe %{count} Beiträge in ein neues Thema verschoben: %{topic_link}"
existing_topic_moderator_post:
one: "Ich habe den Beitrag in ein vorhandenes Thema verschoben: %{topic_link}"
other: "Ich hab %{count} Beiträge in ein vorhandenes Thema verschoben: %{topic_link}"
topic_statuses:
archived_enabled: "Dieses Thema ist nun archiviert. Es ist eingefroren und kann in keiner Weise mehr verändert werden."
@ -656,6 +723,7 @@ de:
active: "Dein Konto ist nun freigeschaltet und einsatzbereit."
activate_email: "Fast fertig! Wir haben eine Aktivierungsmail an <b>%{email}</b> verschickt. Bitte folge den Anweisungen in der Mail, um Dein Konto zu aktivieren."
not_activated: "Du kannst Dich noch nicht anmelden. Wir haben Dir eine Aktivierungsmail geschickt. Bitte folge zunächst den Anweisungen aus der Mail, um Dein Konto zu aktivieren."
banned: "Du kannst dich bis am %{date} nicht mehr anmelden."
errors: "%{errors}"
not_available: " Nicht verfügbar. Versuche %{suggestion}?"
something_already_taken: "Etwas ist schief gelaufen. Möglicherweise ist der Benutzername bereits registriert. Probiere den 'Passwort vergessen'-Link."
@ -716,6 +784,8 @@ de:
Deine Freunde von %{site_name}.
:smile:
[0]: %{base_url}
[1]: http://www.kitterman.com/spf/validate.html
[2]: http://mxtoolbox.com/SuperTool.aspx
@ -728,6 +798,17 @@ de:
<small>Am Fuß jeder Mail, die Du verschickst, sollte eine Möglichkeit zum Abbestellen gegeben werden. Hier ein Beispiel: Diese Mail wurde von Unternehmensname, Hauptstraße 55, 12345 Stadtname, Deutschland, versendet. Wenn Du zukünftig keine weiteren Mail erhalten möchtest, [klicke hier, um dich abzumelden][5].</small>
new_version_mailer:
subject_template: "[%{site_name}] neue Version verfügbar"
text_body_template: |
Eine neue Version von Discourse ist verfügbar.
**Neue Version: %{new_version}**
Deine Version: %{installed_version}
Bitte aktuallisiere die Installation so bald wie möglich um die neusten Fehlerbehebungen und Funktionen zu erhalten.
system_messages:
post_hidden:
subject_template: "Beitrag wegen Meldungen aus der Community versteckt"
@ -839,6 +920,15 @@ de:
Weitere Hilfe findest du in unserer [FAQ](%{base_url}/faq).
blocked_by_staff:
subject_template: "Konto gesperrt"
text_body_template: |
Hallo,
Dies ist eine automatische Nachricht von %{site_name} um dich zu informierenm, dass dein Konto durch einem Moderator gesperrt wurde.
Weitere Hilfe findest du in unserer [FAQ](%{base_url}/faq).
user_automatically_blocked:
subject_template: "Benutzer %{username} wurde automatisch gesperrt"
text_body_template: |
@ -846,6 +936,13 @@ de:
Bitte [überprüfe die Beanstandungen](/admin/flags). Wenn %{username} nicht mehr gesperrt sein soll, schalte den Benutzer in der [Benuzeradministration](%{user_url}) wieder frei.
spam_post_blocked:
subject_template: "Spam wirde in einem Beitrag von %{username} entdeckt"
text_body_template: |
Dies ist eine automatische Nachricht um dich zu informieren, dass [%{username}](%{user_url}) versucht hat einen Beitrag mit Links zu erstellen, was aber basierend auf der Einstellung newuser_spam_host_threshold unterbunden wurde.
Bitte [überprüfe den Benutzer](%{user_url}).
unblocked:
subject_template: "Benutzerkonto entsperrt"
text_body_template: |
@ -855,13 +952,28 @@ de:
Du kannst nun wieder Themen erstellen und Beiträge veröffentlichen.
pending_users_reminder:
subject_template:
one: "Es gibt einen nicht freigegebenen Benutzer"
other: "Es gibt %{count} nicht freigegebene Benutzer"
text_body_template: |
Es warten neuen Benutzer auf ihre Freigabe.
[Bitte bewerte diese im Administrationsbereich](/admin/users/list/pending).
unsubscribe_link: "Wenn Du diese Mails nicht mehr erhalten möchtest, verändere deine [Benutzereinstellungen](%{user_preferences_url})."
user_notifications:
previous_discussion: "Vorangehende Antworten"
unsubscribe:
title: "Mails Abbestellen"
description: "Nicht interessiert an diesen Mails? Kein Problem! Klicke unten um Dich abzumelden:"
reply_by_email: "Um zu Antworten, antworte auf diese Email oder besuche %{base_url}%{url} in deinem Browser."
visit_link_to_respond: "Um zu Antworten, besuche %{base_url}%{url} in deinem Browser."
posted_by: "Erstellt von %{username} am %{post_date}"
user_invited_to_private_message:
subject_template: "[%{site_name}] %{username} hat Dich zu einem privaten Gespräch eingeladen: '%{topic_title}'"
text_body_template: |
@ -872,52 +984,49 @@ de:
user_replied:
subject_template: "[%{site_name}] %{username} hat auf deinen Beitrag '%{topic_title}' geantwortet"
text_body_template: |
%{username} hat auf deinen Beitrag '%{topic_title}' auf %{site_name} geantwortet:
---
%{message}
%{context}
---
Um zu antworten, besuche den folgenden Link: %{base_url}%{url}
%{respond_instructions}
user_quoted:
subject_template: "[%{site_name}] %{username} hat Dich in '%{topic_title}' zitiert"
text_body_template: |
%{username} hat Dich in '%{topic_title}' auf %{site_name} zitiert:
---
%{message}
%{context}
---
Um zu antworten, besuche den folgenden Link: %{base_url}%{url}
%{respond_instructions}
user_mentioned:
subject_template: "[%{site_name}] %{username} hat Dich in '%{topic_title}' erwähnt"
text_body_template: |
%{username} hat Dich in '%{topic_title}' auf %{site_name} erwähnt:
---
%{message}
%{context}
---
Um zu antworten, besuche den folgenden Link: %{base_url}%{url}
%{respond_instructions}
user_posted:
subject_template: "[%{site_name}] %{subject_prefix}%{username} hat auf '%{topic_title}' geantwortet"
text_body_template: |
%{username} hat in '%{topic_title}' auf %{site_name} geantwortet:
---
%{message}
%{context}
---
Um zu antworten, besuche den folgenden Link: %{base_url}%{url}
%{respond_instructions}
digest:
why: "Hier eine kurze Zusammenfassung, was auf %{site_link} passiert ist, seit Du das letzte Mal am %{last_seen_at} da warst."
subject_template: "[%{site_name}] Forenaktivität für den %{date}"
new_activity: "Neues in deinen Themen und Beiträgen:"
top_topics: "Inhalte die dich vielleicht interessieren:"
other_new_topics: "Andere neue Themen:"
unsubscribe: "Diese Zusammenfassung wurde Dir von %{site_link} geschickt, damit Du auf dem Laufenden bleibst, und weil wir nicht eine Weile nicht begrüßen durften.\nWenn Du diese Benachrichtigungen nicht mehr erhalten möchtest, kannst Du sie in deinen Maileinstellungen abschalten: %{unsubscribe_link}."
click_here: "klicke hier"
from: "%{site_name} Übersicht"
@ -992,8 +1101,12 @@ de:
deleted: 'gelöscht'
upload:
pasted_image_filename: ""
unauthorized: "Entschuldige, die Datei die du hochladen möchtest ist nicht erlaubt (Erlaubte Dateiendungen: %{authorized_extensions})."
pasted_image_filename: "Hinzugefügtes Bild"
attachments:
too_large: "Entschuldige, die Datei die du hochladen möchtest ist zu gross (Maximale Dateigrösse ist %{max_size_kb}%kb)."
images:
fetch_failure: "Entschuldige, beim Laden des Bildes ist ein Fehler aufgetreten."
too_large: "Entschuldige, das Bild welches du hochladen möchtest ist zu gross (Maximale Dateigrösse ist %{max_size_kb}%kb), bitte verkleinere es und versuche es nochmals."
fetch_failure: "Sorry, there has been an error while fetching the image."
unknown_image_type: "Entschuldige, aber die Datei die Du hochladen möchtest schein kein Bild zu sein."
size_not_found: "Entschuldige, aber wir konnten die Grösse des Bildes nicht feststellen. Vielleicht ist das Bild defekt?"

View file

@ -94,13 +94,13 @@ en:
For more guidance, [see our FAQ](/faq). This panel will only appear for your first %{education_posts_text}.
'new-reply': |
Welcome to %{site_name} &mdash; **thanks for contributing to the conversation!**
Welcome to %{site_name} &mdash; **thanks for contributing!**
- Does your reply improve the conversation in some way?
- Be kind to your fellow community members.
- Constructive criticism is welcome, but remember to criticize *ideas*, not people.
- Constructive criticism is welcome, but criticize *ideas*, not people.
For more guidance, [see our FAQ](/faq). This panel will only appear for your first %{education_posts_text}.
@ -611,6 +611,8 @@ en:
regular_requires_likes_given: "How many likes a basic user must cast before promotion to regular (2) trust level"
regular_requires_topic_reply_count: "How many topics a basic user must reply to before promotion to regular (2) trust level"
min_trust_to_create_topic: "The minimum trust level required to create a new topic."
newuser_max_links: "How many links a new user can add to a post"
newuser_max_images: "How many images a new user can add to a post"
newuser_max_attachments: "How many attachments a new user can add to a post"
@ -937,9 +939,9 @@ en:
Please [review the flags](/admin/flags). If %{username} was incorrectly blocked from posting, click the unblock button on [the admin page for this user](%{user_url}).
spam_post_blocked:
subject_template: "Spam was detected in a post by %{username}"
subject_template: "New user %{username} is posting repeated links"
text_body_template: |
This is an automated message to inform you that [%{username}](%{user_url}) tried to make a post with links, but it was stopped as spam based on the newuser_spam_host_threshold site setting.
This is an automated message to inform you that the new user [%{username}](%{user_url}) tried to create multiple posts with links to the same domain, but they were blocked based on the newuser_spam_host_threshold site setting.
Please [review the user](%{user_url}).
@ -1027,7 +1029,7 @@ en:
new_activity: "New activity on your topics and posts:"
top_topics: "Recent posts the community enjoyed:"
other_new_topics: "Other New Topics:"
unsubscribe: "This summary email is sent as a courtesy notification from %{site_link} when we haven't seen you in a while.\nTo unsubscribe or change your email preferences, %{unsubscribe_link}."
unsubscribe: "This summary email is sent as a courtesy notification from %{site_link} when we haven't seen you in a while. To unsubscribe or change your email preferences, %{unsubscribe_link}."
click_here: "click here"
from: "%{site_name} digest"
read_more: "Read More"

View file

@ -47,13 +47,13 @@ id:
For more guidance, [see our FAQ](/faq). This panel will only appear for your first %{education_posts_text}.
'new-reply': |
Welcome to %{site_name} &mdash; **thanks for contributing to the conversation!**
Welcome to %{site_name} &mdash; **thanks for contributing!**
- Does your reply improve the conversation in some way?
- Be kind to your fellow community members.
- Constructive criticism is welcome, but remember to criticize *ideas*, not people.
- Constructive criticism is welcome, but criticize *ideas*, not people.
For more guidance, [see our FAQ](/faq). This panel will only appear for your first %{education_posts_text}.
@ -725,7 +725,7 @@ id:
subject_template: "[%{site_name}] Forum Activity for %{date}"
new_activity: "New activity on your topics and posts:"
new_topics: "New topics:"
unsubscribe: "This summary email is sent as a courtesy notification from %{site_link} when we haven't seen you in a while.\nTo unsubscribe or change your email preferences, %{unsubscribe_link}."
unsubscribe: "This summary email is sent as a courtesy notification from %{site_link} when we haven't seen you in a while. To unsubscribe or change your email preferences, %{unsubscribe_link}."
click_here: "click here"
from: "%{site_name} digest"

View file

@ -74,7 +74,7 @@ ko:
For more guidance, [see our FAQ](/faq). This panel will only appear for your first %{education_posts_text}.
'new-reply': |
Welcome to %{site_name} &mdash; **thanks for contributing to the conversation!**
Welcome to %{site_name} &mdash; **thanks for contributing!**
Keep in mind as you compose your reply:
@ -82,7 +82,7 @@ ko:
- Be kind to your fellow community members.
- Constructive criticism is welcome, but remember to criticize *ideas*, not people.
- Constructive criticism is welcome, but criticize *ideas*, not people.
For more guidance, [see our FAQ](/faq). This panel will only appear for your first %{education_posts_text}.
@ -870,7 +870,7 @@ ko:
subject_template: "[%{site_name}] Forum Activity for %{date}"
new_activity: "New activity on your topics and posts:"
new_topics: "New topics:"
unsubscribe: "This summary email is sent as a courtesy notification from %{site_link} when we haven't seen you in a while.\nTo unsubscribe or change your email preferences, %{unsubscribe_link}."
unsubscribe: "This summary email is sent as a courtesy notification from %{site_link} when we haven't seen you in a while. To unsubscribe or change your email preferences, %{unsubscribe_link}."
click_here: "click here"
from: "%{site_name} digest"

View file

@ -532,6 +532,7 @@ ru:
edit_history_visible_to_public: Позволить всем видеть предыдущие версии сообщения. Когда отключено, историю изменений может видеть только персонал.
delete_removed_posts_after: Количество часов, после которого сообщение, удаленное пользователем, удаляется.
max_image_width: Максимальная ширина изображений, добавляемых в сообщение
max_image_height: Максимальная высота изображения в сообщении
category_featured_topics: Количество отображаемых тем в категориях на странице /categories
add_rel_nofollow_to_user_content: 'Добавить "rel nofollow" для всех ссылок за исключением внутренних (включая родительский домен). Изменение данной настройки потребует обновления всех сообщений (<code>rake posts:rebake</code>)'
exclude_rel_nofollow_domains: Разделенный запятыми список доменов, в которых nofollow не добавлено (tld.com автоматически позволит также и sub.tld.com)
@ -941,11 +942,11 @@ ru:
Пожалуйста [проверьте жалобы](/admin/flags). Если пользователь %{username} был заблокирован неверно, нажмите кнопку разблокировки [на странице управления пользователем](%{user_url}).
spam_post_blocked:
subject_template: 'В сообщении пользователя %{username} обнаружен спам'
subject_template: 'Новый пользователь %{username} отправляет одинаковые ссылки'
text_body_template: |
Это автоматическое сообщение для информирования о том, что пользователь [%{username}](%{user_url}) попытался создать сообщение со ссылками, но был остановлен политикой антиспама на основе настройки сайта newuser_spam_host_threshold.
Это автоматическое сообщение. Новый пользователь [%{username}](%{user_url}) попытался создать множество сообщений со ссылкой на один и тот же домен, однако был заблокирован на основании настройки newuser_spam_host_threshold.
Пожалуйста [проверьте действия пользователя](%{user_url}).
Пожалуйста [проверьте блокировку](%{user_url}).
unblocked:
subject_template: Учетная запись разблокирована
@ -954,6 +955,17 @@ ru:
Это автоматическое сообщение сайта %{site_name}. Ваш аккаунт был разблокирован. Теперь вы можете создавать новые темы и отвечать в них.
pending_users_reminder:
subject_template:
one: Один неутвержденный пользователь
other: '%{count} неутвержденных пользователей'
few: '%{count} неутвержденных пользователя'
many: '%{count} неутвержденных пользователей'
text_body_template: |
Новые пользователи ожидают утверждения.
[Пожалуйста, проверьте их список в секции администрирования](/admin/users/list/pending).
unsubscribe_link: 'Для того, чтобы отписаться от подобных сообщений, перейдите в [настройки профиля](%{user_preferences_url}).'
user_notifications:
previous_discussion: Предыдущие ответы
@ -1016,9 +1028,7 @@ ru:
new_activity: 'Новая активность в ваших темах и сообщениях:'
top_topics: 'Последние темы, которые были оценены пользователями форума:'
other_new_topics: 'Другие новые темы:'
unsubscribe: |
Данное сообщение отправлено как напоминание с сайта %{site_link} потому что вы давно не заходили к нам.
Для того, чтобы отписаться от наших сообщений, пройдите по ссылке %{unsubscribe_link}.
unsubscribe: 'Данное сообщение отправлено как напоминание с сайта %{site_link} потому что вы давно не заходили к нам. Для того, чтобы отписаться от наших сообщений, пройдите по ссылке %{unsubscribe_link}.'
click_here: нажмите здесь
from: 'Cводка новостей сайта %{site_name}'
read_more: Читать еще

View file

@ -783,7 +783,7 @@ sv:
subject_template: "[%{site_name}] Forum Activity for %{date}"
new_activity: "New activity on your topics and posts:"
new_topics: "New topics:"
unsubscribe: "This summary email is sent as a courtesy notification from %{site_link} when we haven't seen you in a while.\nTo unsubscribe or change your email preferences, %{unsubscribe_link}."
unsubscribe: "This summary email is sent as a courtesy notification from %{site_link} when we haven't seen you in a while. To unsubscribe or change your email preferences, %{unsubscribe_link}."
click_here: "click here"
from: "%{site_name} digest"

View file

@ -216,6 +216,7 @@ Discourse::Application.routes.draw do
get 'topics/created-by/:username' => 'list#topics_by', as: 'topics_by', constraints: {username: USERNAME_ROUTE_FORMAT}
get 'topics/private-messages/:username' => 'list#private_messages', as: 'topics_private_messages', constraints: {username: USERNAME_ROUTE_FORMAT}
get 'topics/private-messages-sent/:username' => 'list#private_messages_sent', as: 'topics_private_messages_sent', constraints: {username: USERNAME_ROUTE_FORMAT}
get 'topics/private-messages-unread/:username' => 'list#private_messages_unread', as: 'topics_private_messages_unread', constraints: {username: USERNAME_ROUTE_FORMAT}
# Topic routes
get 't/:slug/:topic_id/wordpress' => 'topics#wordpress', constraints: {topic_id: /\d+/}

View file

@ -0,0 +1,19 @@
class FixOptimizedImagesUrls < ActiveRecord::Migration
def up
# `AddUrlToOptimizedImages` was wrongly computing the URLs. This fixes it!
execute "UPDATE optimized_images
SET url = substring(oi.url from '^\\/uploads\\/[^/]+\\/_optimized\\/[0-9a-f]{3}\\/[0-9a-f]{3}\\/[0-9a-f]{11}')
|| '_'
|| oi.width
|| 'x'
|| oi.height
|| substring(oi.url from '\\.\\w{3,4}$')
FROM optimized_images oi
WHERE optimized_images.id = oi.id
AND oi.url ~ '^\\/uploads\\/[^/]+\\/_optimized\\/[0-9a-f]{3}\\/[0-9a-f]{3}\\/[0-9a-f]{11}\\.';"
end
def down
raise ActiveRecord::IrreversibleMigration
end
end

View file

@ -0,0 +1,12 @@
class AllowNullUserIdOnPosts < ActiveRecord::Migration
def up
change_column :posts, :user_id, :integer, null: true
execute "UPDATE posts SET user_id = NULL WHERE nuked_user = true"
remove_column :posts, :nuked_user
end
def down
add_column :posts, :nuked_user, :boolean, default: false
change_column :posts, :user_id, :integer, null: false
end
end

View file

@ -0,0 +1,9 @@
class AllowNullUserIdOnTopics < ActiveRecord::Migration
def up
change_column :topics, :user_id, :integer, null: true
end
def down
change_column :topics, :user_id, :integer, null: false
end
end

View file

@ -19,11 +19,9 @@ Note: If you are developing on a Mac, you will probably want to look at [these i
## Before you start Rails
1. `bundle install`
2. `bundle exec rake db:migrate`
3. `bundle exec rake db:test:prepare`
4. `bundle exec rake db:seed_fu`
5. Try running the specs: `bundle exec rake autospec`
6. `bundle exec rails server`
2. `bundle exec rake db:migrate db:test:prepare db:seed_fu`
4. Try running the specs: `bundle exec rake autospec`
5. `bundle exec rails server`
You should now be able to connect to rails on [http://localhost:3000](http://localhost:3000) - try it out! The seed data includes a pinned topic that explains how to get an admin account, so start there! Happy hacking!

View file

@ -48,7 +48,7 @@ If you have a mail server responsible for handling the egress of email from your
Install necessary packages:
# Run these commands as your normal login (e.g. "michael")
sudo apt-get -y install build-essential libssl-dev libyaml-dev git libtool libxslt-dev libxml2-dev libpq-dev gawk curl pngcrush python-software-properties
sudo apt-get -y install build-essential libssl-dev libyaml-dev git libtool libxslt-dev libxml2-dev libpq-dev gawk curl pngcrush imagemagick python-software-properties
# If you're on Ubuntu >= 12.10, change:
# python-software-properties to software-properties-common
@ -187,7 +187,6 @@ Edit /var/www/discourse/config/discourse.pill
- change application name from 'discourse' if necessary
- Ensure appropriate Bluepill.application line is uncommented
- search for "host to run on" and change to current hostname
Edit /var/www/discourse/config/environments/production.rb
- browse througn all the settings

View file

@ -7,3 +7,4 @@ require_dependency 'auth/open_id_authenticator'
require_dependency 'auth/github_authenticator'
require_dependency 'auth/twitter_authenticator'
require_dependency 'auth/persona_authenticator'
require_dependency 'auth/cas_authenticator'

View file

@ -1,7 +1,8 @@
class Auth::Result
attr_accessor :user, :name, :username, :email, :user,
:email_valid, :extra_data, :awaiting_activation,
:awaiting_approval, :authenticated, :authenticator_name
:awaiting_approval, :authenticated, :authenticator_name,
:requires_invite
def session_data
{
@ -15,7 +16,9 @@ class Auth::Result
end
def to_client_hash
if user
if requires_invite
{ requires_invite: true }
elsif user
{
authenticated: !!authenticated,
awaiting_activation: !!awaiting_activation,

View file

@ -110,4 +110,16 @@ module DiscourseHub
def self.accepts
[:json, 'application/vnd.discoursehub.v1']
end
def self.nickname_operation
if SiteSetting.call_discourse_hub?
begin
yield
rescue DiscourseHub::NicknameUnavailable
false
rescue => e
Rails.logger.error e.message + "\n" + e.backtrace.join("\n")
end
end
end
end

View file

@ -2,13 +2,14 @@ require 'digest/sha1'
require 'open-uri'
class S3Store
@fog_loaded ||= require 'fog'
def store_upload(file, upload)
# <id><sha1><extension>
path = "#{upload.id}#{upload.sha1}#{upload.extension}"
# if this fails, it will throw an exception
upload(file.tempfile, path, file.content_type)
upload(file.tempfile, path, upload.original_filename, file.content_type)
# returns the url of the uploaded file
"#{absolute_base_url}/#{path}"
@ -58,9 +59,7 @@ class S3Store
end
def remove_file(url)
return unless has_been_uploaded?(url)
name = File.basename(url)
remove(name)
remove File.basename(url) if has_been_uploaded?(url)
end
def has_been_uploaded?(url)
@ -102,19 +101,17 @@ class S3Store
raise Discourse::SiteSettingMissing.new("s3_secret_access_key") if SiteSetting.s3_secret_access_key.blank?
end
def get_or_create_directory(name)
def get_or_create_directory(bucket)
check_missing_site_settings
@fog_loaded ||= require 'fog'
fog = Fog::Storage.new s3_options
fog = Fog::Storage.new generate_options
directory = fog.directories.get(name)
directory = fog.directories.create(key: name) unless directory
directory = fog.directories.get(bucket)
directory = fog.directories.create(key: bucket) unless directory
directory
end
def generate_options
def s3_options
options = {
provider: 'AWS',
aws_access_key_id: SiteSetting.s3_access_key_id,
@ -124,22 +121,21 @@ class S3Store
options
end
def upload(file, name, content_type=nil)
def upload(file, unique_filename, filename=nil, content_type=nil)
args = {
key: name,
key: unique_filename,
public: true,
body: file,
body: file
}
args[:content_disposition] = "attachment; filename=\"#{filename}\"" if filename
args[:content_type] = content_type if content_type
directory.files.create(args)
get_or_create_directory(s3_bucket).files.create(args)
end
def remove(name)
directory.files.destroy(key: name)
end
def directory
get_or_create_directory(s3_bucket)
def remove(unique_filename)
fog = Fog::Storage.new s3_options
fog.delete_object(s3_bucket, unique_filename)
end
end

View file

@ -0,0 +1,33 @@
#see: https://github.com/rails/rails/pull/12065
if rails4?
module ActiveRecord
class Result
private
def hash_rows
@hash_rows ||=
begin
# We freeze the strings to prevent them getting duped when
# used as keys in ActiveRecord::Base's @attributes hash
columns = @columns.map { |c| c.dup.freeze }
@rows.map { |row|
# In the past we used Hash[columns.zip(row)]
# though elegant, the verbose way is much more efficient
# both time and memory wise cause it avoids a big array allocation
# this method is called a lot and needs to be micro optimised
hash = {}
index = 0
length = columns.length
while index < length
hash[columns[index]] = row[index]
index += 1
end
hash
}
end
end
end
end
end

View file

@ -0,0 +1,8 @@
if rails4?
# https://github.com/rails/arel/pull/206
class Arel::Table
def hash
@name.hash
end
end
end

View file

@ -9,6 +9,7 @@ class Guardian
def secure_category_ids; []; end
def topic_create_allowed_category_ids; []; end
def has_trust_level?(level); false; end
def email; nil; end
end
def initialize(user=nil)
@ -36,6 +37,13 @@ class Guardian
@user.staff?
end
def is_developer?
@user &&
is_admin? &&
Rails.configuration.respond_to?(:developer_emails) &&
Rails.configuration.developer_emails.include?(@user.email)
end
# Can the user see the object?
def can_see?(obj)
if obj
@ -89,8 +97,8 @@ class Guardian
# You must be an admin to impersonate
is_admin? &&
# You may not impersonate other admins
not(target.admin?)
# You may not impersonate other admins unless you are a dev
(!target.admin? || is_developer?)
# Additionally, you may not impersonate yourself;
# but the two tests for different admin statuses
@ -229,11 +237,11 @@ class Guardian
end
def can_create_topic?(parent)
can_create_post?(parent)
user && user.trust_level >= SiteSetting.min_trust_to_create_topic.to_i && can_create_post?(parent)
end
def can_create_topic_on_category?(category)
can_create_post?(nil) && (
can_create_topic?(nil) && (
!category ||
Category.topic_create_allowed(self).where(:id => category.id).count == 1
)

View file

@ -1,6 +1,6 @@
module Jobs
class DashboardStats < Jobs::Scheduled
recurrence { minutely(AdminDashboardData.recalculate_interval.minutes) }
recurrence { hourly.minute_of_hour(0,30) }
def execute(args)
stats_json = AdminDashboardData.fetch_stats.as_json
@ -13,4 +13,4 @@ module Jobs
end
end
end
end

View file

@ -5,15 +5,14 @@ module Jobs
recurrence { daily.hour_of_day(6) }
def execute(args)
target_users.each do |u|
Jobs.enqueue(:user_email, type: :digest, user_id: u.id)
target_user_ids.each do |user_id|
Jobs.enqueue(:user_email, type: :digest, user_id: user_id)
end
end
def target_users
def target_user_ids
# Users who want to receive emails and haven't been emailed in the last day
query = User.select(:id)
.where(email_digests: true, active: true)
query = User.where(email_digests: true, active: true)
.where("COALESCE(last_emailed_at, '2010-01-01') <= CURRENT_TIMESTAMP - ('1 DAY'::INTERVAL * digest_after_days)")
.where("COALESCE(last_seen_at, '2010-01-01') <= CURRENT_TIMESTAMP - ('1 DAY'::INTERVAL * digest_after_days)")
@ -22,7 +21,7 @@ module Jobs
query = query.where("approved OR moderator OR admin")
end
query
query.pluck(:id)
end
end

View file

@ -2,7 +2,7 @@
module Plugin; end
class Plugin::Metadata
FIELDS = [:name, :about, :version, :authors]
FIELDS ||= [:name, :about, :version, :authors]
attr_accessor *FIELDS
def self.parse(text)

View file

@ -104,8 +104,10 @@ module PrettyText
ctx.eval("var window = {}; window.devicePixelRatio = 2;") # hack to make code think stuff is retina
ctx.eval("var I18n = {}; I18n.t = function(a,b){ return helpers.t(a,b); }");
decorate_context(ctx)
ctx_load(ctx,
"app/assets/javascripts/external/markdown.js",
"app/assets/javascripts/external/better_markdown.js",
"app/assets/javascripts/discourse/dialects/dialect.js",
"app/assets/javascripts/discourse/components/utilities.js",
"app/assets/javascripts/discourse/components/markdown.js")
@ -145,6 +147,13 @@ module PrettyText
@ctx
end
def self.decorate_context(context)
context.eval("Discourse.SiteSettings = #{SiteSetting.client_settings_json};")
context.eval("Discourse.CDN = '#{Rails.configuration.action_controller.asset_host}';")
context.eval("Discourse.BaseUrl = 'http://#{RailsMultisite::ConnectionManagement.current_hostname}';")
context.eval("Discourse.getURL = function(url) {return '#{Discourse::base_uri}' + url};")
end
def self.markdown(text, opts=nil)
# we use the exact same markdown converter as the client
# TODO: use the same extensions on both client and server (in particular the template for mentions)
@ -154,9 +163,7 @@ module PrettyText
@mutex.synchronize do
context = v8
# we need to do this to work in a multi site environment, many sites, many settings
context.eval("Discourse.SiteSettings = #{SiteSetting.client_settings_json};")
context.eval("Discourse.BaseUrl = 'http://#{RailsMultisite::ConnectionManagement.current_hostname}';")
context.eval("Discourse.getURL = function(url) {return '#{Discourse::base_uri}' + url};")
decorate_context(context)
context['opts'] = opts || {}
context['raw'] = text
context.eval('opts["mentionLookup"] = function(u){return helpers.is_username_valid(u);}')
@ -175,9 +182,7 @@ module PrettyText
@mutex.synchronize do
v8['avatarTemplate'] = avatar_template
v8['size'] = size
v8.eval("Discourse.SiteSettings = #{SiteSetting.client_settings_json};")
v8.eval("Discourse.CDN = '#{Rails.configuration.action_controller.asset_host}';")
v8.eval("Discourse.BaseUrl = '#{RailsMultisite::ConnectionManagement.current_hostname}';")
decorate_context(v8)
r = v8.eval("Discourse.Utilities.avatarImg({ avatarTemplate: avatarTemplate, size: size });")
end
r

View file

@ -231,30 +231,25 @@ module SiteSettingExtension
# trivial multi db support, we can optimize this later
current[name] = current_value
clean_name = name.to_s.sub("?", "")
setter = ("#{name}=").sub("?","")
eval "define_singleton_method :#{name} do
eval "define_singleton_method :#{clean_name} do
c = @@containers[provider.current_site]
c = c[name] if c
c
end
define_singleton_method :#{setter} do |val|
define_singleton_method :#{clean_name}? do
#{clean_name}
end
define_singleton_method :#{clean_name}= do |val|
add_override!(:#{name}, val)
refresh!
end
"
end
def method_missing(method, *args, &block)
as_question = method.to_s.gsub(/\?$/, '')
if respond_to?(as_question)
return send(as_question, *args, &block)
end
super(method, *args, &block)
end
def enum_class(name)
enums[name]
end

View file

@ -3,14 +3,15 @@ require_dependency 'topic_list'
class SuggestedTopicsBuilder
attr_reader :excluded_topic_ids
attr_reader :results
def initialize(topic)
@excluded_topic_ids = [topic.id]
@category_id = topic.category_id
@results = []
end
def add_results(results)
def add_results(results, priority=:low)
# WARNING .blank? will execute an Active Record query
return unless results
@ -23,16 +24,46 @@ class SuggestedTopicsBuilder
unless results.empty?
# Keep track of the ids we've added
@excluded_topic_ids.concat results.map {|r| r.id}
splice_results(results,priority)
end
end
def splice_results(results, priority)
if @category_id &&
priority == :high &&
non_category_index = @results.index{|r| r.category_id != @category_id}
category_results, non_category_results = results.partition{|r| r.category_id == @category_id}
@results.insert non_category_index, *category_results
@results.concat non_category_results
else
@results.concat results
end
end
def results
@results.first(SiteSetting.suggested_topics)
end
def results_left
SiteSetting.suggested_topics - @results.size
end
def full?
results_left == 0
results_left <= 0
end
def category_results_left
SiteSetting.suggested_topics - @results.count{|r| r.category_id == @category_id}
end
def category_full?
if @category_id
else
full?
end
end
def size

View file

@ -5,6 +5,8 @@ class TextSentinel
attr_accessor :text
ENTROPY_SCALE ||= 0.7
def initialize(text, opts=nil)
@opts = opts || {}
@text = text.to_s.encode('UTF-8', invalid: :replace, undef: :replace, replace: '')
@ -15,14 +17,20 @@ class TextSentinel
if opts[:private_message]
scale_entropy = SiteSetting.min_private_message_post_length.to_f / SiteSetting.min_post_length.to_f
entropy = (entropy * scale_entropy).to_i
entropy = (SiteSetting.min_private_message_post_length.to_f * ENTROPY_SCALE).to_i if entropy > SiteSetting.min_private_message_post_length
else
entropy = (SiteSetting.min_post_length.to_f * ENTROPY_SCALE).to_i if entropy > SiteSetting.min_post_length
end
TextSentinel.new(text, min_entropy: entropy)
end
def self.title_sentinel(text)
TextSentinel.new(text,
min_entropy: SiteSetting.title_min_entropy,
max_word_length: SiteSetting.max_word_length)
entropy = if SiteSetting.min_topic_title_length > SiteSetting.title_min_entropy
SiteSetting.title_min_entropy
else
(SiteSetting.min_topic_title_length.to_f * ENTROPY_SCALE).to_i
end
TextSentinel.new(text, min_entropy: entropy, max_word_length: SiteSetting.max_word_length)
end
# Entropy is a number of how many unique characters the string needs.

View file

@ -87,10 +87,10 @@ class TopicQuery
# When logged in we start with different results
if @user
builder.add_results(unread_results(topic: topic, per_page: builder.results_left))
builder.add_results(new_results(per_page: builder.results_left)) unless builder.full?
builder.add_results(unread_results(topic: topic, per_page: builder.results_left), :high)
builder.add_results(new_results(topic: topic, per_page: builder.category_results_left), :high) unless builder.category_full?
end
builder.add_results(random_suggested(topic, builder.results_left)) unless builder.full?
builder.add_results(random_suggested(topic, builder.results_left), :low) unless builder.full?
create_list(:suggested, {}, builder.results)
end
@ -146,6 +146,11 @@ class TopicQuery
TopicList.new(:private_messages, user, list)
end
def list_private_messages_unread(user)
list = private_messages_for(user)
list = TopicQuery.unread_filter(list)
TopicList.new(:private_messages, user, list)
end
def list_uncategorized
create_list(:uncategorized, unordered: true) do |list|

View file

@ -84,12 +84,13 @@ class TopicView
def summary
return nil if desired_post.blank?
# TODO, this is actually quite slow, should be cached in the post table
Summarize.new(desired_post.cooked).summary
end
def image_url
return nil if desired_post.blank?
desired_post.user.small_avatar_url
desired_post.user.try(:small_avatar_url)
end
def filter_posts(opts = {})
@ -256,7 +257,7 @@ class TopicView
def setup_filtered_posts
@filtered_posts = @topic.posts
@filtered_posts = @filtered_posts.with_deleted.without_nuked_users if @user.try(:staff?)
@filtered_posts = @filtered_posts.with_deleted if @user.try(:staff?)
@filtered_posts = @filtered_posts.best_of if @filter == 'best_of'
@filtered_posts = @filtered_posts.where('posts.post_type <> ?', Post.types[:moderator_action]) if @best.present?
return unless @username_filters.present?

Some files were not shown because too many files have changed in this diff Show more