密碼重設成功,請重新登錄。",
"wrong_reset_code.title": "驗証碼不正確",
- "wrong_reset_code.message": "您輸入的驗証碼有誤,請重新輸入,或申請新的驗証碼。",
+ "wrong_reset_code.message": "重置驗証碼不正確。請重新輸入,或是申請新重置驗証碼。",
"new_password": "輸入新的密碼",
"repeat_password": "再次確認新密碼",
- "enter_email": "請輸入您的電子郵件地址,我們會寄送郵件告訴您如何重設密碼。",
+ "enter_email": "請輸入你的電子郵件地址,我們會寄送郵件告訴你如何重置你的帳戶。",
"enter_email_address": "輸入電子郵件地址",
"password_reset_sent": "密碼重設郵件已發送。",
"invalid_email": "無效的電子郵件 / 電子郵件不存在!",
diff --git a/public/language/zh_TW/search.json b/public/language/zh_TW/search.json
index 457f1bfb7b..58b8258ed4 100644
--- a/public/language/zh_TW/search.json
+++ b/public/language/zh_TW/search.json
@@ -1,17 +1,17 @@
{
- "results_matching": "有%1個跟\"%2\"相符的結果(%3秒)",
+ "results_matching": "有 %1 個與 \"%2\" 符合的結果 (%3秒)",
"no-matches": "沒有找到相符的主題",
"advanced-search": "進階搜尋",
"in": "在",
"titles": "標題",
- "titles-posts": "標題與發布",
- "posted-by": "Posted by",
+ "titles-posts": "標題與張貼",
+ "posted-by": "張貼由",
"in-categories": "在類別中",
"search-child-categories": "搜尋子類別",
"reply-count": "回覆數量",
"at-least": "最少",
"at-most": "最多",
- "post-time": "發布時間",
+ "post-time": "張貼時間",
"newer-than": "較新",
"older-than": "較舊",
"any-date": "任意日期",
@@ -22,7 +22,7 @@
"three-months": "三個月",
"six-months": "六個月",
"one-year": "一年",
- "sort-by": "排序依據",
+ "sort-by": "排序依照",
"last-reply-time": "最後回覆時間",
"topic-title": "主題",
"number-of-replies": "回覆數量",
diff --git a/public/language/zh_TW/success.json b/public/language/zh_TW/success.json
index 7d3170435b..1d652b82e6 100644
--- a/public/language/zh_TW/success.json
+++ b/public/language/zh_TW/success.json
@@ -1,6 +1,6 @@
{
"success": "成功",
- "topic-post": "您的文章已張貼成功",
+ "topic-post": "你的文章已張貼成功",
"authentication-successful": "驗證成功",
"settings-saved": "設定已儲存"
}
\ No newline at end of file
diff --git a/public/language/zh_TW/topic.json b/public/language/zh_TW/topic.json
index e0efc6b3fc..4719cd6fd1 100644
--- a/public/language/zh_TW/topic.json
+++ b/public/language/zh_TW/topic.json
@@ -32,7 +32,7 @@
"deleted_message": "此主題已被刪除。只有具有主題管理權限的用戶才能看到它。",
"following_topic.message": "有人貼文回覆主題時, 你將會收到新通知.",
"not_following_topic.message": "有人貼文回覆主題時, 你將不會收到通知.",
- "ignoring_topic.message": "You will no longer see this topic in the unread topics list. You will be notified when you are mentioned or your post is up voted.",
+ "ignoring_topic.message": "你將不會再未讀主題列表中看到這個主題。當你被提及或你的張貼被正向投票時,你會被通知。",
"login_to_subscribe": "請先註冊或登錄, 才可訂閱此主題.",
"markAsUnreadForAll.success": "將全部的主題設為未讀.",
"mark_unread": "標為未讀",
@@ -42,12 +42,12 @@
"watch.title": "當主題有新回覆時將收到通知",
"unwatch.title": "停止關注這個主題",
"share_this_post": "分享這篇文章",
- "watching": "Watching",
- "not-watching": "Not Watching",
- "ignoring": "Ignoring",
- "watching.description": "Notify me of new replies.
Show topic in unread.",
- "not-watching.description": "Do not notify me of new replies.
Show topic in unread if category is not ignored.",
- "ignoring.description": "Do not notify me of new replies.
Do not show topic in unread.",
+ "watching": "關注",
+ "not-watching": "取消關注",
+ "ignoring": "忽略",
+ "watching.description": "有新的回覆通知我。
在未讀頁中顯示主題。",
+ "not-watching.description": "有新的回覆不用通知我。
如果類別不是被忽略的,在未讀頁中顯示主題。",
+ "ignoring.description": "有新的回覆不用通知我。
在未讀頁中不顯示主題。",
"thread_tools.title": "主題工具箱",
"thread_tools.markAsUnreadForAll": "設為未讀",
"thread_tools.pin": "釘選主題",
@@ -56,7 +56,7 @@
"thread_tools.unlock": "解除主題鎖定",
"thread_tools.move": "移動主題",
"thread_tools.move_all": "移動全部",
- "thread_tools.fork": "Fork 主題",
+ "thread_tools.fork": "分叉主題",
"thread_tools.delete": "刪除主題",
"thread_tools.delete-posts": "刪除張貼",
"thread_tools.delete_confirm": "你確定要刪除這個主題?",
@@ -107,7 +107,7 @@
"oldest_to_newest": "從舊到新",
"newest_to_oldest": "從新到舊",
"most_votes": "得票最多",
- "most_posts": "最多post",
+ "most_posts": "最多張貼",
"stale.title": "改為建立新的主題?",
"stale.warning": "你正回覆的主題是非常舊的一篇。你想要改為建立一個新主題,然後參考到這篇你回覆的?",
"stale.create": "建立新主題",
diff --git a/public/language/zh_TW/user.json b/public/language/zh_TW/user.json
index a536503ff7..352b9d38a1 100644
--- a/public/language/zh_TW/user.json
+++ b/public/language/zh_TW/user.json
@@ -5,12 +5,12 @@
"joindate": "加入時間",
"postcount": "文章數量",
"email": "電子郵件",
- "confirm_email": "確認電郵",
+ "confirm_email": "確認電子郵件",
"ban_account": "禁用帳號",
"ban_account_confirm": "你確定要禁用這個使用者?",
"unban_account": "取消禁用帳號",
"delete_account": "刪除帳戶",
- "delete_account_confirm": "你確定要刪除自己的帳戶?
此操作不能復原,您將無法恢復任何數據
輸入您的使用者名稱,以確認您希望刪除這個帳戶。",
+ "delete_account_confirm": "你確定要刪除自己的帳戶?
此操作不能復原,你將無法恢復任何數據
輸入你的帳號,用來確認希望刪除這個帳戶。",
"delete_this_account_confirm": "你確定要刪除這個帳戶?
此操作是不能還原的,你將無法回復任何資料
",
"account-deleted": "帳號已刪除",
"fullname": "全名",
@@ -34,7 +34,7 @@
"follow": "跟隨",
"unfollow": "取消跟隨",
"more": "更多",
- "profile_update_success": "您的個人資料已更新成功!",
+ "profile_update_success": "你的個人資料已更新成功!",
"change_picture": "改變頭像",
"change_username": "更改帳號",
"change_email": "更改電子郵件",
@@ -50,11 +50,11 @@
"change_password_error_wrong_current": "目前的密碼不正確!",
"change_password_error_length": "密碼太短!",
"change_password_error_match": "密碼必須要一致!",
- "change_password_error_privileges": "您沒有變更此密碼的權限!",
+ "change_password_error_privileges": "你沒有變更此密碼的權限!",
"change_password_success": "你的密碼已經更新!",
"confirm_password": "確認密碼",
"password": "密碼",
- "username_taken_workaround": "您所註冊的使用者名稱已經被使用了,所以我們將它略微改變。你現在改稱 %1",
+ "username_taken_workaround": "你想要註冊的帳號已經被使用了,所以我們將它略作改變。你現在的帳號名稱是 %1",
"password_same_as_username": "你的密碼和帳號是一樣的,請選擇另一組密碼。",
"password_same_as_email": "你的密碼和電子郵件是一樣的,請選擇另一組密碼。",
"upload_picture": "上傳頭像",
@@ -66,7 +66,7 @@
"show_fullname": "顯示我的全名",
"restrict_chats": "只允許我跟隨的用戶和我聊天",
"digest_label": "訂閱摘要",
- "digest_description": "使用 eamil 訂閱本論壇新的訊息通知與主題 (根據您所設的時間表)",
+ "digest_description": "根據你所設的時間排程,用電子郵件訂閱這個討論區 (新的通知與主題)。",
"digest_off": "關閉",
"digest_daily": "每日",
"digest_weekly": "每週",
diff --git a/public/language/zh_TW/users.json b/public/language/zh_TW/users.json
index b3e5acd9cb..be4ad3a86b 100644
--- a/public/language/zh_TW/users.json
+++ b/public/language/zh_TW/users.json
@@ -5,7 +5,7 @@
"search": "搜尋",
"enter_username": "輸入想找的使用者帳號",
"load_more": "載入更多",
- "users-found-search-took": "發現 %1 用戶!搜尋只用 %2 秒。",
+ "users-found-search-took": "找到 %1 使用者! 搜尋用了 %2 秒。",
"filter-by": "過濾依照",
"online-only": "線上僅有",
"invite": "邀請",
diff --git a/public/less/admin/admin.less b/public/less/admin/admin.less
index e733d8d9bd..3af3ded05d 100644
--- a/public/less/admin/admin.less
+++ b/public/less/admin/admin.less
@@ -50,12 +50,30 @@
color: @link-color;
}
+ // .floating-button can either be a container or the button itself
.floating-button {
- background: @brand-primary !important;
position: fixed;
right: 30px;
bottom: 30px;
z-index: 1;
+ max-width: 56px;
+
+ button {
+ &.primary {
+ background: @brand-primary !important;
+ }
+
+ &.success {
+ background: @brand-success !important;
+ }
+
+ &:not(:last-child) {
+ margin-bottom: 2rem;
+ }
+ }
+ }
+ button.floating-button {
+ background: @brand-primary !important;
}
.user-img {
diff --git a/public/src/admin/advanced/errors.js b/public/src/admin/advanced/errors.js
new file mode 100644
index 0000000000..2e72bcceb5
--- /dev/null
+++ b/public/src/admin/advanced/errors.js
@@ -0,0 +1,81 @@
+"use strict";
+/*global config, define, app, socket, ajaxify, bootbox, templates, Chart, utils */
+
+define('admin/advanced/errors', ['Chart'], function(Chart) {
+ var Errors = {};
+
+ Errors.init = function() {
+ Errors.setupCharts();
+
+ $('[data-action="clear"]').on('click', Errors.clear404);
+ };
+
+ Errors.clear404 = function() {
+ bootbox.confirm('Are you sure you wish to clear the 404 error logs?', function(ok) {
+ if (ok) {
+ socket.emit('admin.errors.clear', {}, function(err) {
+ ajaxify.refresh();
+ app.alertSuccess('"404 Not Found" errors cleared');
+ });
+ }
+ });
+ };
+
+ Errors.setupCharts = function() {
+ var notFoundCanvas = document.getElementById('not-found'),
+ tooBusyCanvas = document.getElementById('toobusy'),
+ dailyLabels = utils.getDaysArray();
+
+ dailyLabels = dailyLabels.slice(-7);
+
+ if (utils.isMobile()) {
+ Chart.defaults.global.showTooltips = false;
+ }
+
+ var data = {
+ 'not-found': {
+ labels: dailyLabels,
+ datasets: [
+ {
+ label: "",
+ fillColor: "rgba(186,139,175,0.2)",
+ strokeColor: "rgba(186,139,175,1)",
+ pointColor: "rgba(186,139,175,1)",
+ pointStrokeColor: "#fff",
+ pointHighlightFill: "#fff",
+ pointHighlightStroke: "rgba(186,139,175,1)",
+ data: ajaxify.data.analytics['not-found']
+ }
+ ]
+ },
+ 'toobusy': {
+ labels: dailyLabels,
+ datasets: [
+ {
+ label: "",
+ fillColor: "rgba(151,187,205,0.2)",
+ strokeColor: "rgba(151,187,205,1)",
+ pointColor: "rgba(151,187,205,1)",
+ pointStrokeColor: "#fff",
+ pointHighlightFill: "#fff",
+ pointHighlightStroke: "rgba(151,187,205,1)",
+ data: ajaxify.data.analytics['toobusy']
+ }
+ ]
+ }
+ };
+
+ notFoundCanvas.width = $(notFoundCanvas).parent().width();
+ tooBusyCanvas.width = $(tooBusyCanvas).parent().width();
+ new Chart(notFoundCanvas.getContext('2d')).Line(data['not-found'], {
+ responsive: true,
+ animation: false
+ });
+ new Chart(tooBusyCanvas.getContext('2d')).Line(data['toobusy'], {
+ responsive: true,
+ animation: false
+ });
+ };
+
+ return Errors;
+});
\ No newline at end of file
diff --git a/public/src/admin/extend/plugins.js b/public/src/admin/extend/plugins.js
index 0cca29bef8..b4e8767184 100644
--- a/public/src/admin/extend/plugins.js
+++ b/public/src/admin/extend/plugins.js
@@ -31,12 +31,12 @@ define('admin/extend/plugins', function() {
app.alert({
alert_id: 'plugin_toggled',
title: 'Plugin ' + (status.active ? 'Enabled' : 'Disabled'),
- message: status.active ? 'Please reload your NodeBB to fully activate this plugin' : 'Plugin successfully deactivated',
+ message: status.active ? 'Please restart your NodeBB to fully activate this plugin' : 'Plugin successfully deactivated',
type: status.active ? 'warning' : 'success',
timeout: 5000,
clickfn: function() {
require(['admin/modules/instance'], function(instance) {
- instance.reload();
+ instance.restart();
});
}
});
diff --git a/public/src/admin/manage/category-analytics.js b/public/src/admin/manage/category-analytics.js
index 52a24ecfeb..cf78cfa5e3 100644
--- a/public/src/admin/manage/category-analytics.js
+++ b/public/src/admin/manage/category-analytics.js
@@ -1,7 +1,7 @@
"use strict";
-/*global config, define, app, socket, ajaxify, bootbox, templates, Chart, utils */
+/*global config, define, app, socket, ajaxify, bootbox, templates, utils */
-define('admin/manage/category-analytics', [], function() {
+define('admin/manage/category-analytics', ['Chart'], function(Chart) {
var CategoryAnalytics = {};
CategoryAnalytics.init = function() {
@@ -80,7 +80,7 @@ define('admin/manage/category-analytics', [], function() {
data: ajaxify.data.analytics['posts:daily']
}
]
- },
+ }
};
hourlyCanvas.width = $(hourlyCanvas).parent().width();
diff --git a/public/src/client/category.js b/public/src/client/category.js
index 5f44197a6c..10816286d9 100644
--- a/public/src/client/category.js
+++ b/public/src/client/category.js
@@ -59,6 +59,8 @@ define('forum/category', [
});
handleIgnoreWatch(cid);
+
+ $(window).trigger('action:topics.loaded', {topics: ajaxify.data.topics});
};
function handleIgnoreWatch(cid) {
diff --git a/public/src/client/recent.js b/public/src/client/recent.js
index 938453894e..1f6536327d 100644
--- a/public/src/client/recent.js
+++ b/public/src/client/recent.js
@@ -5,8 +5,8 @@
define('forum/recent', ['forum/infinitescroll', 'components'], function(infinitescroll, components) {
var Recent = {};
- var newTopicCount = 0,
- newPostCount = 0;
+ var newTopicCount = 0;
+ var newPostCount = 0;
$(window).on('action:ajaxify.start', function(ev, data) {
if (ajaxify.currentPage !== data.url) {
@@ -26,6 +26,8 @@ define('forum/recent', ['forum/infinitescroll', 'components'], function(infinite
if (!config.usePagination) {
infinitescroll.init(Recent.loadMoreTopics);
}
+
+ $(window).trigger('action:topics.loaded', {topics: ajaxify.data.topics});
};
Recent.watchForNewPosts = function () {
diff --git a/public/src/client/topic/postTools.js b/public/src/client/topic/postTools.js
index 98b6ef1470..0195c00417 100644
--- a/public/src/client/topic/postTools.js
+++ b/public/src/client/topic/postTools.js
@@ -168,33 +168,33 @@ define('forum/topic/postTools', ['share', 'navigator', 'components', 'translator
postContainer.on('click', '[component="post/edit"]', function() {
var btn = $(this);
-
+
var timestamp = parseInt(getData(btn, 'data-timestamp'), 10);
var postEditDuration = parseInt(ajaxify.data.postEditDuration, 10);
- if (postEditDuration && Date.now() - timestamp > postEditDuration * 1000) {
- var numDays = Math.floor(postEditDuration / 86400);
+ if (!ajaxify.data.privileges.isAdminOrMod && postEditDuration && Date.now() - timestamp > postEditDuration * 1000) {
+ var numDays = Math.floor(postEditDuration / 86400);
var numHours = Math.floor((postEditDuration % 86400) / 3600);
var numMinutes = Math.floor(((postEditDuration % 86400) % 3600) / 60);
var numSeconds = ((postEditDuration % 86400) % 3600) % 60;
var msg = '[[error:post-edit-duration-expired, ' + postEditDuration + ']]';
if (numDays) {
if (numHours) {
- msg = '[[error:post-edit-duration-expired-days-hours, ' + numDays + ', ' + numHours + ']]';
+ msg = '[[error:post-edit-duration-expired-days-hours, ' + numDays + ', ' + numHours + ']]';
} else {
msg = '[[error:post-edit-duration-expired-days, ' + numDays + ']]';
- }
+ }
} else if (numHours) {
if (numMinutes) {
- msg = '[[error:post-edit-duration-expired-hours-minutes, ' + numHours + ', ' + numMinutes + ']]';
+ msg = '[[error:post-edit-duration-expired-hours-minutes, ' + numHours + ', ' + numMinutes + ']]';
} else {
msg = '[[error:post-edit-duration-expired-hours, ' + numHours + ']]';
- }
+ }
} else if (numMinutes) {
if (numSeconds) {
- msg = '[[error:post-edit-duration-expired-minutes-seconds, ' + numMinutes + ', ' + numSeconds + ']]';
+ msg = '[[error:post-edit-duration-expired-minutes-seconds, ' + numMinutes + ', ' + numSeconds + ']]';
} else {
msg = '[[error:post-edit-duration-expired-minutes, ' + numMinutes + ']]';
- }
+ }
}
return app.alertError(msg);
}
@@ -232,7 +232,7 @@ define('forum/topic/postTools', ['share', 'navigator', 'components', 'translator
var selection = window.getSelection ? window.getSelection() : document.selection.createRange();
var content = button.parents('[component="post"]').find('[component="post/content"]').get(0);
- if (content && selection.containsNode(content, true)) {
+ if (selection && selection.containsNode && content && selection.containsNode(content, true)) {
var bounds = document.createRange();
bounds.selectNodeContents(content);
var range = selection.getRangeAt(0).cloneRange();
diff --git a/public/src/client/unread.js b/public/src/client/unread.js
index 5a3ec3543c..5e0fc8e127 100644
--- a/public/src/client/unread.js
+++ b/public/src/client/unread.js
@@ -20,6 +20,8 @@ define('forum/unread', ['forum/recent', 'topicSelect', 'forum/infinitescroll', '
recent.watchForNewPosts();
+ $(window).trigger('action:topics.loaded', {topics: ajaxify.data.topics});
+
$('#markSelectedRead').on('click', function() {
var tids = topicSelect.getSelectedTids();
if(!tids.length) {
diff --git a/public/src/modules/chat.js b/public/src/modules/chat.js
index 3bc391cfd1..f2ff94bd55 100644
--- a/public/src/modules/chat.js
+++ b/public/src/modules/chat.js
@@ -204,6 +204,7 @@ define('chat', [
app.loadJQueryUI(function() {
chatModal.find('.modal-content').resizable({
+ handles: 'n, e, s, w, se',
minHeight: 250,
minWidth: 400
});
diff --git a/public/src/modules/notifications.js b/public/src/modules/notifications.js
index 86723dc3e5..533199c31c 100644
--- a/public/src/modules/notifications.js
+++ b/public/src/modules/notifications.js
@@ -102,9 +102,11 @@ define('notifications', ['sounds', 'translator', 'components'], function(sound,
ajaxify.refresh();
}
- if (!unreadNotifs[notifData.nid]) {
- incrementNotifCount(1);
+ socket.emit('notifications.getCount', function(err, count) {
+ Notifications.updateNotifCount(count);
+ });
+ if (!unreadNotifs[notifData.nid]) {
sound.play('notification');
unreadNotifs[notifData.nid] = true;
}
diff --git a/public/src/modules/settings/checkbox.js b/public/src/modules/settings/checkbox.js
index d091d0226d..865e89c0f3 100644
--- a/public/src/modules/settings/checkbox.js
+++ b/public/src/modules/settings/checkbox.js
@@ -15,6 +15,7 @@ define('settings/checkbox', function () {
},
set: function (element, value) {
element.prop('checked', value);
+ element.closest('.mdl-switch').toggleClass('is-checked', element.is(':checked'));
},
get: function (element, trim, empty) {
var value = element.prop('checked');
diff --git a/src/analytics.js b/src/analytics.js
index c1ede42eba..bbb1c06794 100644
--- a/src/analytics.js
+++ b/src/analytics.js
@@ -187,4 +187,11 @@ var db = require('./database');
}, callback);
};
+ Analytics.getErrorAnalytics = function(callback) {
+ async.parallel({
+ 'not-found': async.apply(Analytics.getDailyStatsForSet, 'analytics:errors:404', Date.now(), 7),
+ 'toobusy': async.apply(Analytics.getDailyStatsForSet, 'analytics:errors:503', Date.now(), 7)
+ }, callback);
+ };
+
}(exports));
\ No newline at end of file
diff --git a/src/categories/create.js b/src/categories/create.js
index f6338f8ef2..82a317270b 100644
--- a/src/categories/create.js
+++ b/src/categories/create.js
@@ -62,7 +62,7 @@ module.exports = function(Categories) {
},
function(results, next) {
if (data.cloneFromCid && parseInt(data.cloneFromCid, 10)) {
- return Categories.copySettingsFrom(data.cloneFromCid, category.cid, next);
+ return Categories.copySettingsFrom(data.cloneFromCid, category.cid, !data.parentCid, next);
}
next(null, category);
},
@@ -81,7 +81,7 @@ module.exports = function(Categories) {
return [backgrounds[index], text[index]];
};
- Categories.copySettingsFrom = function(fromCid, toCid, callback) {
+ Categories.copySettingsFrom = function(fromCid, toCid, copyParent, callback) {
var destination;
async.waterfall([
function (next) {
@@ -97,11 +97,11 @@ module.exports = function(Categories) {
destination = results.destination;
var tasks = [];
- if (utils.isNumber(results.source.parentCid)) {
+ if (copyParent && utils.isNumber(results.source.parentCid)) {
tasks.push(async.apply(db.sortedSetAdd, 'cid:' + results.source.parentCid + ':children', results.source.order, toCid));
}
- if (destination && utils.isNumber(destination.parentCid)) {
+ if (copyParent && destination && utils.isNumber(destination.parentCid)) {
tasks.push(async.apply(db.sortedSetRemove, 'cid:' + destination.parentCid + ':children', toCid));
}
@@ -114,8 +114,11 @@ module.exports = function(Categories) {
destination.numRecentReplies = results.source.numRecentReplies;
destination.class = results.source.class;
destination.imageClass = results.source.imageClass;
- destination.parentCid = results.source.parentCid || 0;
-
+
+ if (copyParent) {
+ destination.parentCid = results.source.parentCid || 0;
+ }
+
tasks.push(async.apply(db.setObject, 'category:' + toCid, destination));
async.series(tasks, next);
diff --git a/src/controllers/admin.js b/src/controllers/admin.js
index 2bba60cae6..c3ce96d205 100644
--- a/src/controllers/admin.js
+++ b/src/controllers/admin.js
@@ -14,6 +14,7 @@ var adminController = {
},
events: require('./admin/events'),
logs: require('./admin/logs'),
+ errors: require('./admin/errors'),
database: require('./admin/database'),
postCache: require('./admin/postCache'),
plugins: require('./admin/plugins'),
diff --git a/src/controllers/admin/errors.js b/src/controllers/admin/errors.js
new file mode 100644
index 0000000000..4b59932965
--- /dev/null
+++ b/src/controllers/admin/errors.js
@@ -0,0 +1,30 @@
+'use strict';
+
+var async = require('async'),
+ json2csv = require('json-2-csv').json2csv;
+
+var meta = require('../../meta'),
+ analytics = require('../../analytics');
+
+var errorsController = {};
+
+errorsController.get = function(req, res) {
+ async.parallel({
+ 'not-found': async.apply(meta.errors.get, true),
+ analytics: async.apply(analytics.getErrorAnalytics)
+ }, function(err, data) {
+ res.render('admin/advanced/errors', data);
+ });
+};
+
+errorsController.export = function(req, res) {
+ async.waterfall([
+ async.apply(meta.errors.get, false),
+ async.apply(json2csv)
+ ], function(err, csv) {
+ res.set('Content-Type', 'text/csv').set('Content-Disposition', 'attachment; filename="404.csv"').send(csv);
+ });
+};
+
+
+module.exports = errorsController;
\ No newline at end of file
diff --git a/src/controllers/index.js b/src/controllers/index.js
index 09f156b19d..26b7527ce9 100644
--- a/src/controllers/index.js
+++ b/src/controllers/index.js
@@ -373,12 +373,14 @@ Controllers.handle404 = function(req, res) {
} else if (isLanguage.test(req.url)) {
res.status(200).json({});
} else if (req.path.startsWith(relativePath + '/uploads') || (req.get('accept') && req.get('accept').indexOf('text/html') === -1) || req.path === '/favicon.ico') {
+ meta.errors.log404(req.path || '');
res.sendStatus(404);
} else if (req.accepts('html')) {
if (process.env.NODE_ENV === 'development') {
winston.warn('Route requested but not found: ' + req.url);
}
+ meta.errors.log404(req.path.replace(/^\/api/, '') || '');
res.status(404);
if (res.locals.isAPI) {
diff --git a/src/controllers/users.js b/src/controllers/users.js
index 476766e7ba..c84de75b5b 100644
--- a/src/controllers/users.js
+++ b/src/controllers/users.js
@@ -126,6 +126,7 @@ usersController.getUsers = function(set, uid, page, callback) {
loadmore_display: results.usersData.count > (stop - start + 1) ? 'block' : 'hide',
users: results.usersData.users,
pagination: pagination.create(page, pageCount),
+ userCount: results.usersData.count,
title: setToTitles[set] || '[[pages:users/latest]]',
breadcrumbs: helpers.buildBreadcrumbs(breadcrumbs),
setName: set,
@@ -171,12 +172,14 @@ function render(req, res, data, next) {
data.adminInviteOnly = registrationType === 'admin-invite-only';
data['reputation:disabled'] = parseInt(meta.config['reputation:disabled'], 10) === 1;
- user.getInvitesNumber(req.uid, function(err, num) {
+ user.getInvitesNumber(req.uid, function(err, numInvites) {
if (err) {
return next(err);
}
- data.invites = num;
+ res.append('X-Total-Count', data.userCount);
+ data.invites = numInvites;
+
res.render('users', data);
});
}
diff --git a/src/database/mongo/sorted.js b/src/database/mongo/sorted.js
index d8a9205ce0..9e9f72969e 100644
--- a/src/database/mongo/sorted.js
+++ b/src/database/mongo/sorted.js
@@ -520,7 +520,7 @@ module.exports = function(db, module) {
return callback();
}
var data = {};
- value = helpers.fieldToString(value);
+ value = helpers.valueToString(value);
data.score = parseInt(increment, 10);
db.collection('objects').findAndModify({_key: key, value: value}, {}, {$inc: data}, {new: true, upsert: true}, function(err, result) {
diff --git a/src/meta.js b/src/meta.js
index eb5816fdaf..3806c69556 100644
--- a/src/meta.js
+++ b/src/meta.js
@@ -24,6 +24,7 @@ var async = require('async'),
require('./meta/sounds')(Meta);
require('./meta/settings')(Meta);
require('./meta/logs')(Meta);
+ require('./meta/errors')(Meta);
require('./meta/tags')(Meta);
require('./meta/dependencies')(Meta);
Meta.templates = require('./meta/templates');
diff --git a/src/meta/errors.js b/src/meta/errors.js
new file mode 100644
index 0000000000..4449a55f57
--- /dev/null
+++ b/src/meta/errors.js
@@ -0,0 +1,35 @@
+'use strict';
+
+var async = require('async'),
+ winston = require('winston'),
+ validator = require('validator');
+
+var db = require('../database'),
+ analytics = require('../analytics');
+
+module.exports = function(Meta) {
+
+ Meta.errors = {};
+
+ Meta.errors.log404 = function(route, callback) {
+ callback = callback || function() {};
+ route = route.replace(/\/$/, ''); // remove trailing slashes
+ analytics.increment('errors:404');
+ db.sortedSetIncrBy('errors:404', 1, route, callback);
+ };
+
+ Meta.errors.get = function(escape, callback) {
+ db.getSortedSetRevRangeByScoreWithScores('errors:404', 0, -1, '+inf', '-inf', function(err, data) {
+ data = data.map(function(nfObject) {
+ nfObject.value = escape ? validator.escape(nfObject.value) : nfObject.value;
+ return nfObject;
+ });
+
+ callback(null, data);
+ });
+ };
+
+ Meta.errors.clear = function(callback) {
+ db.delete('errors:404', callback);
+ };
+};
diff --git a/src/middleware/cls.js b/src/middleware/cls.js
index 0118fa998f..8cf0062d30 100644
--- a/src/middleware/cls.js
+++ b/src/middleware/cls.js
@@ -29,7 +29,7 @@ var namespace = continuationLocalStorage.createNamespace(APP_NAMESPACE);
};
cls.setItem = cls.set;
- cls.getItem = cls.set;
+ cls.getItem = cls.get;
cls.namespace = namespace;
cls.continuationLocalStorage = continuationLocalStorage;
diff --git a/src/middleware/header.js b/src/middleware/header.js
index c1ed6d60c2..dccefcddf7 100644
--- a/src/middleware/header.js
+++ b/src/middleware/header.js
@@ -77,17 +77,21 @@ module.exports = function(app, middleware) {
user.isGlobalModerator(req.uid, next);
},
user: function(next) {
+ var userData = {
+ uid: 0,
+ username: '[[global:guest]]',
+ userslug: '',
+ email: '',
+ picture: meta.config.defaultAvatar,
+ status: 'offline',
+ banned: false,
+ reputation: 0,
+ 'email:confirmed': false
+ };
if (req.uid) {
- user.getUserFields(req.uid, ['username', 'userslug', 'email', 'picture', 'status', 'email:confirmed', 'banned'], next);
+ user.getUserFields(req.uid, Object.keys(userData), next);
} else {
- next(null, {
- username: '[[global:guest]]',
- userslug: '',
- picture: meta.config.defaultAvatar,
- status: 'offline',
- banned: false,
- uid: 0
- });
+ next(null, userData);
}
},
navigation: async.apply(navigation.get),
diff --git a/src/middleware/middleware.js b/src/middleware/middleware.js
index 9267289d3f..7409bd1577 100644
--- a/src/middleware/middleware.js
+++ b/src/middleware/middleware.js
@@ -302,6 +302,7 @@ middleware.privateUploads = function(req, res, next) {
middleware.busyCheck = function(req, res, next) {
if (global.env === 'production' && (!meta.config.hasOwnProperty('eventLoopCheckEnabled') || parseInt(meta.config.eventLoopCheckEnabled, 10) === 1) && toobusy()) {
+ analytics.increment('errors:503');
res.status(503).type('text/html').sendFile(path.join(__dirname, '../../public/503.html'));
} else {
next();
diff --git a/src/notifications.js b/src/notifications.js
index 4f6d73f385..f95eb52248 100644
--- a/src/notifications.js
+++ b/src/notifications.js
@@ -268,6 +268,23 @@ var utils = require('../public/src/utils');
});
};
+ Notifications.rescind = function(nid, callback) {
+ callback = callback || function() {};
+
+ async.parallel([
+ async.apply(db.sortedSetRemove, 'notifications', nid),
+ async.apply(db.delete, 'notifications:' + nid)
+ ], function(err) {
+ if (err) {
+ winston.error('Encountered error rescinding notification (' + nid + '): ' + err.message);
+ } else {
+ winston.verbose('[notifications/rescind] Rescinded notification "' + nid + '"');
+ }
+
+ callback(err, nid);
+ });
+ };
+
Notifications.markRead = function(nid, uid, callback) {
callback = callback || function() {};
if (!parseInt(uid, 10) || !nid) {
@@ -411,7 +428,6 @@ var utils = require('../public/src/utils');
Notifications.merge = function(notifications, callback) {
// When passed a set of notification objects, merge any that can be merged
var mergeIds = [
- 'notifications:favourited_your_post_in',
'notifications:upvoted_your_post_in',
'notifications:user_started_following_you',
'notifications:user_posted_to',
@@ -458,7 +474,7 @@ var utils = require('../public/src/utils');
}
switch(mergeId) {
- case 'notifications:favourited_your_post_in': // intentional fall-through
+ // intentional fall-through
case 'notifications:upvoted_your_post_in':
case 'notifications:user_started_following_you':
case 'notifications:user_posted_to':
diff --git a/src/routes/admin.js b/src/routes/admin.js
index 66391053a7..521f0194bf 100644
--- a/src/routes/admin.js
+++ b/src/routes/admin.js
@@ -82,6 +82,8 @@ function addRoutes(router, middleware, controllers) {
router.get('/advanced/database', middlewares, controllers.admin.database.get);
router.get('/advanced/events', middlewares, controllers.admin.events.get);
router.get('/advanced/logs', middlewares, controllers.admin.logs.get);
+ router.get('/advanced/errors', middlewares, controllers.admin.errors.get);
+ router.get('/advanced/errors/export', middlewares, controllers.admin.errors.export);
router.get('/advanced/post-cache', middlewares, controllers.admin.postCache.get);
router.get('/development/logger', middlewares, controllers.admin.logger.get);
diff --git a/src/socket.io/admin.js b/src/socket.io/admin.js
index 6dd22134ba..8a1df2931e 100644
--- a/src/socket.io/admin.js
+++ b/src/socket.io/admin.js
@@ -33,7 +33,8 @@ var async = require('async'),
settings: {},
email: {},
analytics: {},
- logs: {}
+ logs: {},
+ errors: {}
};
SocketAdmin.before = function(socket, method, data, next) {
@@ -255,6 +256,11 @@ SocketAdmin.logs.clear = function(socket, data, callback) {
meta.logs.clear(callback);
};
+SocketAdmin.errors.clear = function(socket, data, callback) {
+ console.log('clearing errors?');
+ meta.errors.clear(callback);
+};
+
SocketAdmin.getMoreEvents = function(socket, next, callback) {
var start = parseInt(next, 10);
if (start < 0) {
diff --git a/src/socket.io/admin/categories.js b/src/socket.io/admin/categories.js
index 4061bb001a..8e24359e7a 100644
--- a/src/socket.io/admin/categories.js
+++ b/src/socket.io/admin/categories.js
@@ -72,44 +72,31 @@ Categories.getPrivilegeSettings = function(socket, cid, callback) {
};
Categories.copyPrivilegesToChildren = function(socket, cid, callback) {
- async.parallel({
- category: function(next) {
- categories.getCategories([cid], socket.uid, next);
- },
- privileges: function(next) {
- privileges.categories.list(cid, next);
- }
- }, function(err, results) {
+ categories.getCategories([cid], socket.uid, function(err, categories) {
if (err) {
return callback(err);
}
- var category = results.category[0];
+ var category = categories[0];
async.eachSeries(category.children, function(child, next) {
- copyPrivilegesToChildrenRecursive(child, results.privileges.groups, next);
+ copyPrivilegesToChildrenRecursive(cid, child, next);
}, callback);
});
};
-function copyPrivilegesToChildrenRecursive(category, privilegeGroups, callback) {
- async.eachSeries(privilegeGroups, function(privGroup, next) {
- var privs = Object.keys(privGroup.privileges);
- async.each(privs, function(privilege, next) {
- var isSet = privGroup.privileges[privilege];
- groups[isSet ? 'join' : 'leave']('cid:' + category.cid + ':privileges:' + privilege, privGroup.name, next);
- }, next);
- }, function(err) {
+function copyPrivilegesToChildrenRecursive(parentCid, category, callback) {
+ categories.copyPrivilegesFrom(parentCid, category.cid, function(err) {
if (err) {
return callback(err);
}
async.eachSeries(category.children, function(child, next) {
- copyPrivilegesToChildrenRecursive(child, privilegeGroups, next);
+ copyPrivilegesToChildrenRecursive(parentCid, child, next);
}, callback);
});
}
Categories.copySettingsFrom = function(socket, data, callback) {
- categories.copySettingsFrom(data.fromCid, data.toCid, callback);
+ categories.copySettingsFrom(data.fromCid, data.toCid, true, callback);
};
Categories.copyPrivilegesFrom = function(socket, data, callback) {
diff --git a/src/socket.io/helpers.js b/src/socket.io/helpers.js
index 68182293f4..9efeb4c5d9 100644
--- a/src/socket.io/helpers.js
+++ b/src/socket.io/helpers.js
@@ -77,7 +77,7 @@ function filterTidCidIgnorers(uids, tid, cid, callback) {
], callback);
}
-SocketHelpers.sendNotificationToPostOwner = function(pid, fromuid, notification) {
+SocketHelpers.sendNotificationToPostOwner = function(pid, fromuid, command, notification) {
if (!pid || !fromuid || !notification) {
return;
}
@@ -107,7 +107,7 @@ SocketHelpers.sendNotificationToPostOwner = function(pid, fromuid, notification)
bodyLong: results.postObj.content,
pid: pid,
path: '/post/' + pid,
- nid: 'post:' + pid + ':uid:' + fromuid,
+ nid: command + ':post:' + pid + ':uid:' + fromuid,
from: fromuid,
mergeId: notification + '|' + pid,
topicTitle: results.topicTitle
@@ -124,7 +124,7 @@ SocketHelpers.sendNotificationToPostOwner = function(pid, fromuid, notification)
};
-SocketHelpers.sendNotificationToTopicOwner = function(tid, fromuid, notification) {
+SocketHelpers.sendNotificationToTopicOwner = function(tid, fromuid, command, notification) {
if (!tid || !fromuid || !notification) {
return;
}
@@ -150,7 +150,7 @@ SocketHelpers.sendNotificationToTopicOwner = function(tid, fromuid, notification
notifications.create({
bodyShort: '[[' + notification + ', ' + results.username + ', ' + titleEscaped + ']]',
path: '/topic/' + results.topicData.slug,
- nid: 'tid:' + tid + ':uid:' + fromuid,
+ nid: command + ':tid:' + tid + ':uid:' + fromuid,
from: fromuid
}, next);
}
@@ -164,6 +164,17 @@ SocketHelpers.sendNotificationToTopicOwner = function(tid, fromuid, notification
});
};
+SocketHelpers.rescindUpvoteNotification = function(pid, fromuid) {
+ var nid = 'upvote:post:' + pid + ':uid:' + fromuid;
+ notifications.rescind(nid);
+
+ posts.getPostField(pid, 'uid', function(err, uid) {
+ user.notifications.getUnreadCount(uid, function(err, count) {
+ websockets.in('uid_' + uid).emit('event:notifications.updateCount', count);
+ });
+ });
+};
+
SocketHelpers.emitToTopicAndCategory = function(event, data) {
websockets.in('topic_' + data.tid).emit(event, data);
websockets.in('category_' + data.cid).emit(event, data);
diff --git a/src/socket.io/posts.js b/src/socket.io/posts.js
index 90163c532c..90d7a79284 100644
--- a/src/socket.io/posts.js
+++ b/src/socket.io/posts.js
@@ -52,10 +52,6 @@ SocketPosts.reply = function(socket, data, callback) {
user.updateOnlineUsers(socket.uid);
socketHelpers.notifyNew(socket.uid, 'newPost', result);
-
- if (data.lock) {
- socketTopics.doTopicAction('lock', 'event:topic_locked', socket, {tids: [postData.topic.tid], cid: postData.topic.cid});
- }
});
};
diff --git a/src/socket.io/posts/favourites.js b/src/socket.io/posts/favourites.js
index 3d93afac08..c3c4f23de9 100644
--- a/src/socket.io/posts/favourites.js
+++ b/src/socket.io/posts/favourites.js
@@ -92,7 +92,7 @@ module.exports = function(SocketPosts) {
};
SocketPosts.favourite = function(socket, data, callback) {
- favouriteCommand(socket, 'favourite', 'favourited', 'notifications:favourited_your_post_in', data, callback);
+ favouriteCommand(socket, 'favourite', 'favourited', '', data, callback);
};
SocketPosts.unfavourite = function(socket, data, callback) {
@@ -103,7 +103,7 @@ module.exports = function(SocketPosts) {
if (!socket.uid) {
return callback(new Error('[[error:not-logged-in]]'))
}
- if(!data || !data.pid || !data.room_id) {
+ if (!data || !data.pid || !data.room_id) {
return callback(new Error('[[error:invalid-data]]'));
}
async.parallel({
@@ -152,7 +152,9 @@ module.exports = function(SocketPosts) {
}
if (result && notification) {
- socketHelpers.sendNotificationToPostOwner(data.pid, socket.uid, notification);
+ socketHelpers.sendNotificationToPostOwner(data.pid, socket.uid, command, notification);
+ } else if (result && command === 'unvote') {
+ socketHelpers.rescindUpvoteNotification(data.pid, socket.uid);
}
callback();
});
diff --git a/src/socket.io/posts/move.js b/src/socket.io/posts/move.js
index 637ef7da1f..e73ad58a9a 100644
--- a/src/socket.io/posts/move.js
+++ b/src/socket.io/posts/move.js
@@ -28,7 +28,7 @@ module.exports = function(SocketPosts) {
topics.movePostToTopic(data.pid, data.tid, next);
},
function (next) {
- socketHelpers.sendNotificationToPostOwner(data.pid, socket.uid, 'notifications:moved_your_post');
+ socketHelpers.sendNotificationToPostOwner(data.pid, socket.uid, 'move', 'notifications:moved_your_post');
next();
}
], callback);
diff --git a/src/socket.io/topics.js b/src/socket.io/topics.js
index 451a1d0dce..0b8c170d8e 100644
--- a/src/socket.io/topics.js
+++ b/src/socket.io/topics.js
@@ -36,10 +36,6 @@ SocketTopics.post = function(socket, data, callback) {
return callback(err);
}
- if (data.lock) {
- SocketTopics.doTopicAction('lock', 'event:topic_locked', socket, {tids: [result.topicData.tid], cid: result.topicData.cid});
- }
-
callback(null, result.topicData);
socket.emit('event:new_post', {posts: [result.postData]});
diff --git a/src/socket.io/topics/move.js b/src/socket.io/topics/move.js
index 80fc2e3772..a25947aab5 100644
--- a/src/socket.io/topics/move.js
+++ b/src/socket.io/topics/move.js
@@ -40,7 +40,7 @@ module.exports = function(SocketTopics) {
socketHelpers.emitToTopicAndCategory('event:topic_moved', topicData);
- socketHelpers.sendNotificationToTopicOwner(tid, socket.uid, 'notifications:moved_your_topic');
+ socketHelpers.sendNotificationToTopicOwner(tid, socket.uid, 'move', 'notifications:moved_your_topic');
next();
});
diff --git a/src/views/admin/advanced/errors.tpl b/src/views/admin/advanced/errors.tpl
new file mode 100644
index 0000000000..d3c23e78b7
--- /dev/null
+++ b/src/views/admin/advanced/errors.tpl
@@ -0,0 +1,66 @@
+
| Route | +Count | + + + +
|---|---|
| {../value} | +{../score} | +
|
+
+ Hooray! There are no routes that were not found.
+
+ |
+ |
For a full list of icons, please consult: