nodebb-widget-essentials/library.js
2025-08-08 13:20:53 -04:00

681 lines
20 KiB
JavaScript

'use strict';
const nconf = require.main.require('nconf');
const validator = require.main.require('validator');
const benchpressjs = require.main.require('benchpressjs');
const _ = require.main.require('lodash');
const db = require.main.require('./src/database');
const categories = require.main.require('./src/categories');
const user = require.main.require('./src/user');
const plugins = require.main.require('./src/plugins');
const topics = require.main.require('./src/topics');
const posts = require.main.require('./src/posts');
const groups = require.main.require('./src/groups');
const utils = require.main.require('./src/utils');
const meta = require.main.require('./src/meta');
const privileges = require.main.require('./src/privileges');
let app;
const Widget = module.exports;
const sidebarLocations = ['left', 'right', 'sidebar'];
Widget.init = async function (params) {
app = params.app;
};
Widget.renderHTMLWidget = async function (widget) {
if (!isVisibleInCategory(widget) || !isVisibleInTopic(widget)) {
return null;
}
const tpl = widget.data ? widget.data.html : '';
widget.html = await benchpressjs.compileRender(String(tpl), widget.templateData);
return widget;
};
Widget.renderTextWidget = async function (widget) {
if (!isVisibleInCategory(widget)) {
return null;
}
const parseAsPost = !!widget.data.parseAsPost;
const text = String(widget.data.text);
if (parseAsPost) {
widget.html = await plugins.hooks.fire('filter:parse.raw', text);
} else {
widget.html = text.replace(/\r\n/g, '<br />');
}
return widget;
};
Widget.renderSearchWidget = async function (widget) {
if (widget.templateData.template.search) {
return null;
}
const userPrivileges = await privileges.global.get(widget.uid);
const inOptions = [
{ value: 'titles', label: '[[search:in-titles]]' },
{ value: 'titlesposts', label: '[[search:in-titles-posts]]' },
{ value: 'posts', label: '[[global:posts]]' },
{ value: 'categories', label: '[[global:header.categories]]' },
];
if (userPrivileges['search:users']) {
inOptions.push({ value: 'users', label: '[[global:users]]' });
}
if (userPrivileges['search:tags']) {
inOptions.push({ value: 'tags', label: '[[tags:tags]]' });
}
inOptions.forEach((option) => {
option.selected = option.value === widget.data.defaultIn;
});
widget.html = await app.renderAsync('widgets/search', {
inOptions: inOptions,
showInControl: widget.data.showInControl === 'on',
enableQuickSearch: widget.data.enableQuickSearch === 'on',
relative_path: nconf.get('relative_path'),
});
return widget;
};
function getValuesArray(widget, field) {
const values = widget.data[field] || '';
return values.split(',').map(c => parseInt(c, 10)).filter(Boolean);
}
function isVisibleInCategory(widget) {
const cids = getValuesArray(widget, 'cid');
return !(
cids.length &&
(widget.templateData.template.category || widget.templateData.template.topic) &&
!cids.includes(parseInt(widget.templateData.cid, 10))
);
}
function isVisibleInTopic(widget) {
const tids = getValuesArray(widget, 'tid');
return !(
tids.length &&
widget.templateData.template.topic &&
!tids.includes(parseInt(widget.templateData.tid, 10))
);
}
Widget.renderRecentViewWidget = async function (widget) {
const [data, allowedCids] = await Promise.all([
topics.getLatestTopics({
uid:
widget.uid,
start: 0,
stop: 19,
term: 'month',
}),
categories.getCidsByPrivilege('categories:cid', widget.uid, 'topics:create'),
]);
data.relative_path = nconf.get('relative_path');
data.loggedIn = !!widget.req.uid;
data.config = data.config || {};
data.config.relative_path = nconf.get('relative_path');
data.canPost = allowedCids.length > 0;
widget.html = await app.renderAsync('recent', data);
widget.html = widget.html.replace(/<ol[\s\S]*?<br \/>/, '').replace('<br>', '');
return widget;
};
Widget.renderOnlineUsersWidget = async function (widget) {
const count = Math.max(1, widget.data.numUsers || 24);
const uids = await user.getUidsFromSet('users:online', 0, count - 1);
let userData = await user.getUsersFields(uids, ['uid', 'username', 'userslug', 'picture', 'status', 'lastonline']);
userData = userData.filter(user => user.status !== 'offline');
userData.sort((a, b) => b.lastonline - a.lastonline);
widget.html = await app.renderAsync('widgets/onlineusers', {
online_users: userData,
sidebar: sidebarLocations.includes(widget.location),
relative_path: nconf.get('relative_path'),
});
return widget;
};
Widget.renderActiveUsersWidget = async function (widget) {
const count = Math.max(1, widget.data.numUsers || 24);
const cids = getValuesArray(widget, 'cid');
let uids;
if (cids.length) {
uids = await categories.getActiveUsers(cids);
} else if (widget.templateData.template.topic) {
uids = await topics.getUids(widget.templateData.tid);
} else {
uids = await posts.getRecentPosterUids(0, count - 1);
}
uids = uids.slice(0, count);
const userData = await user.getUsersFields(uids, ['uid', 'username', 'userslug', 'picture', 'lastposttime']);
userData.sort((a, b) => b.lastposttime - a.lastposttime);
userData.forEach((u) => {
if (u) {
u.lastposttimeISO = utils.toISOString(u.lastposttime);
}
});
widget.html = await app.renderAsync('widgets/activeusers', {
active_users: userData,
sidebar: sidebarLocations.includes(widget.location),
relative_path: nconf.get('relative_path'),
});
return widget;
};
Widget.renderLatestUsersWidget = async function (widget) {
const count = Math.max(1, widget.data.numUsers || 24);
const users = await user.getUsersFromSet('users:joindate', widget.uid, 0, count - 1);
widget.html = await app.renderAsync('widgets/latestusers', {
users: users,
sidebar: sidebarLocations.includes(widget.location),
relative_path: nconf.get('relative_path'),
});
return widget;
};
Widget.renderTopPostersWidget = async function (widget) {
const count = Math.max(1, widget.data.numUsers || 24);
const users = await user.getUsersFromSet('users:postcount', widget.uid, 0, count - 1);
widget.html = await app.renderAsync('widgets/topposters', {
users: users,
sidebar: sidebarLocations.includes(widget.location),
relative_path: nconf.get('relative_path'),
});
return widget;
};
Widget.renderModeratorsWidget = async function (widget) {
let cid;
if (widget.data.cid) {
cid = widget.data.cid;
} else if (widget.templateData.template.category) {
cid = widget.templateData.cid;
} else if (widget.templateData.template.topic && widget.templateData.category) {
cid = widget.templateData.category.cid;
}
const moderators = await categories.getModerators(cid);
if (!moderators.length) {
return null;
}
widget.html = await app.renderAsync('widgets/moderators', {
moderators: moderators,
relative_path: nconf.get('relative_path'),
});
return widget;
};
Widget.renderForumStatsWidget = async function (widget) {
const socketRooms = require.main.require('./src/socket.io/admin/rooms');
const [global, onlineCount, guestCount] = await Promise.all([
db.getObjectFields('global', ['topicCount', 'postCount', 'userCount']),
db.sortedSetCount('users:online', Date.now() - (meta.config.onlineCutoff * 60000), '+inf'),
socketRooms.getTotalGuestCount(),
]);
const stats = {
topics: utils.makeNumberHumanReadable(global.topicCount ? global.topicCount : 0),
posts: utils.makeNumberHumanReadable(global.postCount ? global.postCount : 0),
users: utils.makeNumberHumanReadable(global.userCount ? global.userCount : 0),
online: utils.makeNumberHumanReadable(onlineCount + guestCount),
statsClass: widget.data.statsClass,
};
widget.html = await app.renderAsync('widgets/forumstats', stats);
return widget;
};
Widget.renderRecentPostsWidget = async function (widget) {
let cid;
if (widget.data.cid) {
cid = widget.data.cid;
} else if (widget.templateData.template.category) {
cid = widget.templateData.cid;
} else if (widget.templateData.template.topic && widget.templateData.category) {
cid = widget.templateData.category.cid;
}
const numPosts = widget.data.numPosts || 4;
let postsData;
if (cid) {
postsData = await categories.getRecentReplies(cid, widget.uid, 0, Math.max(0, numPosts - 1));
} else {
let cids = await categories.getCidsByPrivilege('categories:cid', widget.uid, 'topics:read');
cids = cids.filter(cid => cid !== -1);
const pids = await db.getSortedSetRevRange(
cids.map(cid => `cid:${cid}:pids`), 0, Math.max(0, numPosts - 1),
);
postsData = await posts.getPostSummaryByPids(pids, widget.uid, { stripTags: true });
}
const data = {
posts: postsData,
numPosts: numPosts,
cid: cid,
relative_path: nconf.get('relative_path'),
};
widget.html = await app.renderAsync('widgets/recentposts', data);
return widget;
};
Widget.renderRecentTopicsWidget = async function (widget) {
const numTopics = (widget.data.numTopics || 8) - 1;
let cids = getValuesArray(widget, 'cid');
let key;
if (!cids.length) {
cids = await categories.getCidsByPrivilege('categories:cid', widget.uid, 'topics:read');
cids = cids.filter(cid => cid !== -1);
}
if (cids.length) {
if (cids.length === 1) {
key = `cid:${cids[0]}:tids:lastposttime`;
} else {
key = cids.map(cid => `cid:${cid}:tids:lastposttime`);
}
}
const data = await topics.getTopicsFromSet(key, widget.uid, 0, Math.max(0, numTopics));
data.topics.forEach((topicData) => {
if (topicData && !topicData.teaser) {
topicData.teaser = {
user: topicData.user,
timestampISO: topicData.timestampISO,
};
}
});
widget.html = await app.renderAsync('widgets/recenttopics', {
topics: data.topics,
numTopics: numTopics,
relative_path: nconf.get('relative_path'),
sidebar: sidebarLocations.includes(widget.location),
});
return widget;
};
Widget.renderCategories = async function (widget) {
let categoryData = await categories.getCategoriesByPrivilege('categories:cid', widget.uid, 'find');
categoryData = categoryData.filter(c => c && c.cid !== -1);
const tree = categories.getTree(categoryData, 0);
widget.html = await app.renderAsync('widgets/categories', {
categories: tree,
relative_path: nconf.get('relative_path'),
});
return widget;
};
Widget.renderPopularTags = async function (widget) {
const numTags = widget.data.numTags || 8;
const display = widget.data.display || 'buttons';
let tags = [];
if (widget.templateData.template.category) {
tags = await topics.getCategoryTagsData(widget.templateData.cid, 0, numTags - 1);
} else {
let cids = await categories.getCidsByPrivilege('categories:cid', widget.uid, 'topics:read');
cids = cids.filter(cid => cid !== -1);
tags = await topics.getCategoryTagsData(cids, 0, numTags - 1);
}
let maxCount = 1;
tags.forEach((t) => {
if (t.score > maxCount) {
maxCount = t.score;
}
});
tags.forEach((t) => {
t.widthPercent = ((t.score / maxCount) * 100).toFixed(2);
});
widget.html = await app.renderAsync('widgets/populartags', {
tags: tags,
display,
template: widget.templateData.template,
relative_path: nconf.get('relative_path'),
});
return widget;
};
Widget.renderPopularTopics = async function (widget) {
const numTopics = widget.data.numTopics || 8;
const data = await topics.getSortedTopics({
uid: widget.uid,
start: 0,
stop: numTopics - 1,
term: widget.data.duration || 'alltime',
sort: 'posts',
});
widget.html = await app.renderAsync('widgets/populartopics', {
topics: data.topics,
numTopics: numTopics,
relative_path: nconf.get('relative_path'),
sidebar: sidebarLocations.includes(widget.location),
});
return widget;
};
Widget.renderTopTopics = async function (widget) {
const numTopics = widget.data.numTopics || 8;
const data = await topics.getSortedTopics({
uid: widget.uid,
start: 0,
stop: numTopics - 1,
term: widget.data.duration || 'alltime',
sort: 'votes',
});
widget.html = await app.renderAsync('widgets/toptopics', {
topics: data.topics,
numTopics: numTopics,
relative_path: nconf.get('relative_path'),
sidebar: sidebarLocations.includes(widget.location),
});
return widget;
};
Widget.renderMyGroups = async function (widget) {
const { uid } = widget;
const numGroups = parseInt(widget.data.numGroups, 10) || 9;
const groupsData = await groups.getUserGroups([uid]);
let userGroupData = groupsData.length ? groupsData[0] : [];
userGroupData = userGroupData.slice(0, numGroups);
widget.html = await app.renderAsync('widgets/groups', {
groups: userGroupData,
relative_path: nconf.get('relative_path'),
});
return widget;
};
Widget.renderGroupPosts = async function (widget) {
const numPosts = parseInt(widget.data.numPosts, 10) || 4;
const postsData = await groups.getLatestMemberPosts(widget.data.groupName, numPosts, widget.uid);
widget.html = await app.renderAsync('widgets/groupposts', { posts: postsData });
return widget;
};
Widget.renderNewGroups = async function (widget) {
const numGroups = parseInt(widget.data.numGroups, 10) || 8;
const groupNames = await db.getSortedSetRevRange('groups:visible:createtime', 0, numGroups - 1);
const groupsData = await groups.getGroupsData(groupNames);
widget.html = await app.renderAsync('widgets/groups', {
groups: groupsData.filter(Boolean),
relative_path: nconf.get('relative_path'),
});
return widget;
};
Widget.renderSuggestedTopics = async function (widget) {
const numTopics = Math.max(0, (widget.data.numTopics || 8) - 1);
const cutoff = Math.max(0, parseInt(widget.data.cutoff, 10) || 0);
async function getCategoryTopics(term, sort) {
const data = await topics.getSortedTopics({
cids: widget.templateData.cid,
uid: widget.uid,
start: 0,
stop: 2 * numTopics,
term: term,
sort: sort,
});
return data.topics;
}
let topicData;
if (widget.templateData.template.topic) {
topicData = await topics.getSuggestedTopics(widget.templateData.tid, widget.uid, 0, numTopics, cutoff);
} else if (widget.templateData.template.category) {
topicData = await getCategoryTopics('month', 'votes');
if (!topicData.length) {
topicData = await getCategoryTopics('alltime', 'recent');
}
topicData = _.shuffle(topicData).slice(0, numTopics + 1);
topicData = topicData.filter(topic => topic && !topic.deleted);
} else {
const data = await topics.getTopicsFromSet('topics:recent', widget.uid, 0, numTopics);
topicData = data ? data.topics : [];
topicData = topicData.filter(topic => topic && !topic.deleted);
}
widget.html = await app.renderAsync('widgets/suggestedtopics', {
topics: topicData,
config: widget.templateData.config,
sidebar: sidebarLocations.includes(widget.location),
relative_path: nconf.get('relative_path'),
});
return widget;
};
Widget.renderUserPost = async function (widget) {
const numPosts = Math.max(1, (widget.data.numPosts || 1));
const type = widget.data.postType || 'last';
let { uid } = widget;
if (widget.templateData.template['account/profile']) {
uid = widget.templateData.uid;
} else if (widget.data.uid) {
uid = widget.data.uid;
}
let pids = [];
const cids = await categories.getCidsByPrivilege('categories:cid', widget.uid, 'topics:read');
const sets = cids.map(c => `cid:${c}:uid:${uid}:pids`);
const now = Date.now();
if (type === 'last') {
pids = await db.getSortedSetRevRangeByScore(sets, 0, numPosts, now, '-inf');
} else if (type === 'first') {
pids = await db.getSortedSetRange(sets, 0, numPosts, now, '-inf');
} else if (type === 'best') {
pids = await db.getSortedSetRevRange(
cids.map(c => `cid:${c}:uid:${uid}:pids:votes`),
0,
numPosts,
now,
'-inf'
);
}
const postObjs = await posts.getPostSummaryByPids(pids, widget.uid, { stripTags: false });
if (!postObjs.length) {
return null;
}
widget.html = await app.renderAsync('widgets/userpost', {
posts: postObjs,
config: widget.templateData.config,
relative_path: nconf.get('relative_path'),
});
return widget;
};
Widget.renderChatRoom = async function (widget) {
const roomId = (widget.data.roomId || 0);
if (!roomId) {
return null;
}
const { uid } = widget;
const chatsAPI = require.main.require('./src/api/chats');
const messaging = require.main.require('./src/messaging');
try {
const [roomData, publicRooms] = await Promise.all([
chatsAPI.get({ uid: uid }, { uid, roomId }),
messaging.getPublicRooms(uid, uid),
]);
if (!roomData) {
return null;
}
publicRooms.forEach((room) => {
if (room && parseInt(room.roomId, 10) === parseInt(roomId, 10)) {
room.selected = true;
}
});
widget.html = await app.renderAsync('widgets/chat', {
roomId: roomId,
isWidget: true,
...roomData,
publicRooms,
config: widget.templateData.config,
relative_path: nconf.get('relative_path'),
});
} catch (err) {
if (err.message === '[[error:no-privileges]]') {
return null;
}
throw err;
}
return widget;
};
Widget.defineWidgets = async function (widgets) {
const widgetData = [
{
widget: 'html',
name: 'HTML',
description: 'Any text, html, or embedded script.',
content: 'admin/partials/widgets/html',
},
{
widget: 'text',
name: 'Text',
description: 'Text, optionally parsed as a post.',
content: 'admin/partials/widgets/text',
},
{
widget: 'search',
name: 'Search',
description: 'A search widget',
content: 'admin/partials/widgets/search',
},
{
widget: 'onlineusers',
name: 'Online Users',
description: 'List of online users',
content: 'admin/partials/widgets/onlineusers',
},
{
widget: 'activeusers',
name: 'Active Users',
description: 'List of active users in a category/topic',
content: 'admin/partials/widgets/activeusers',
},
{
widget: 'latestusers',
name: 'Latest Users',
description: 'List of latest registered users.',
content: 'admin/partials/widgets/latestusers',
},
{
widget: 'topposters',
name: 'Top Posters',
description: 'List of users with the most posts.',
content: 'admin/partials/widgets/topposters',
},
{
widget: 'moderators',
name: 'Moderators',
description: 'List of moderators in a category.',
content: 'admin/partials/widgets/moderators',
},
{
widget: 'forumstats',
name: 'Forum Stats',
description: 'Lists user, topics, and post count.',
content: 'admin/partials/widgets/forumstats',
},
{
widget: 'recentposts',
name: 'Recent Posts',
description: 'Lists the latest posts on your forum.',
content: 'admin/partials/widgets/recentposts',
},
{
widget: 'recenttopics',
name: 'Recent Topics',
description: 'Lists the latest topics on your forum.',
content: 'admin/partials/widgets/recenttopics',
},
{
widget: 'recentview',
name: 'Recent View',
description: 'Renders the /recent page',
content: 'admin/partials/widgets/defaultwidget',
},
{
widget: 'categories',
name: 'Categories',
description: 'Lists the categories on your forum',
content: 'admin/partials/widgets/categories',
},
{
widget: 'populartags',
name: 'Popular Tags',
description: 'Lists popular tags on your forum',
content: 'admin/partials/widgets/populartags',
},
{
widget: 'populartopics',
name: 'Popular Topics',
description: 'Lists popular topics on your forum',
content: 'admin/partials/widgets/populartopics',
},
{
widget: 'toptopics',
name: 'Top Topics',
description: 'Lists top topics on your forum',
content: 'admin/partials/widgets/toptopics',
},
{
widget: 'mygroups',
name: 'My Groups',
description: 'List of groups that you are in',
content: 'admin/partials/widgets/mygroups',
},
{
widget: 'newgroups',
name: 'New Groups',
description: 'List of newest groups',
content: 'admin/partials/widgets/mygroups',
},
{
widget: 'suggestedtopics',
name: 'Suggested Topics',
description: 'Lists of suggested topics.',
content: 'admin/partials/widgets/suggestedtopics',
},
{
widget: 'userpost',
name: 'User Post',
description: 'Display a users first/last/best post on their profile or by user id.',
content: 'admin/partials/widgets/userpost',
},
{
widget: 'chat',
name: 'Chat Room',
description: 'Display a chat room as a widget',
content: 'admin/partials/widgets/chat',
},
];
await Promise.all(widgetData.map(async (widget) => {
widget.content = await app.renderAsync(widget.content, {});
}));
widgets = widgets.concat(widgetData);
const groupNames = await db.getSortedSetRevRange('groups:visible:createtime', 0, -1);
let groupsData = await groups.getGroupsData(groupNames);
groupsData = groupsData.filter(Boolean);
groupsData.forEach((group) => {
group.name = validator.escape(String(group.name));
});
const html = await app.renderAsync('admin/partials/widgets/groupposts', { groups: groupsData });
widgets.push({
widget: 'groupposts',
name: 'Group Posts',
description: 'Posts made my members of a group',
content: html,
});
return widgets;
};