1 | 'use strict';
|
2 |
|
3 | const topics = require.main.require('./src/topics');
|
4 | const posts = require.main.require('./src/posts');
|
5 | const categories = require.main.require('./src/categories');
|
6 | const meta = require.main.require('./src/meta');
|
7 | const privileges = require.main.require('./src/privileges');
|
8 | const rewards = require.main.require('./src/rewards');
|
9 | const user = require.main.require('./src/user');
|
10 | const helpers = require.main.require('./src/controllers/helpers');
|
11 | const db = require.main.require('./src/database');
|
12 | const plugins = require.main.require('./src/plugins');
|
13 | const SocketPlugins = require.main.require('./src/socket.io/plugins');
|
14 | const pagination = require.main.require('./src/pagination');
|
15 | const social = require.main.require('./src/social');
|
16 |
|
17 | const plugin = module.exports;
|
18 |
|
19 | plugin.init = async function (params) {
|
20 | const { router, middleware } = params;
|
21 | const routeHelpers = require.main.require('./src/routes/helpers');
|
22 |
|
23 | routeHelpers.setupAdminPageRoute(router, '/admin/plugins/question-and-answer', middleware, [], renderAdmin);
|
24 | routeHelpers.setupPageRoute(router, '/unsolved', middleware, [], renderUnsolved);
|
25 | routeHelpers.setupPageRoute(router, '/solved', middleware, [], renderSolved);
|
26 |
|
27 | handleSocketIO();
|
28 |
|
29 | plugin._settings = await meta.settings.get('question-and-answer');
|
30 | };
|
31 |
|
32 | plugin.appendConfig = async function (config) {
|
33 | config['question-and-answer'] = plugin._settings;
|
34 | return config;
|
35 | };
|
36 |
|
37 | plugin.addNavigation = async function (menu) {
|
38 | menu = menu.concat(
|
39 | [
|
40 | {
|
41 | route: '/unsolved',
|
42 | title: '[[qanda:menu.unsolved]]',
|
43 | iconClass: 'fa-question-circle',
|
44 | textClass: 'visible-xs-inline',
|
45 | text: '[[qanda:menu.unsolved]]',
|
46 | },
|
47 | {
|
48 | route: '/solved',
|
49 | title: '[[qanda:menu.solved]]',
|
50 | iconClass: 'fa-check-circle',
|
51 | textClass: 'visible-xs-inline',
|
52 | text: '[[qanda:menu.solved]]',
|
53 | },
|
54 | ]
|
55 | );
|
56 | return menu;
|
57 | };
|
58 |
|
59 | plugin.addAdminNavigation = async function (header) {
|
60 | header.plugins.push({
|
61 | route: '/plugins/question-and-answer',
|
62 | icon: 'fa-question-circle',
|
63 | name: 'Q&A',
|
64 | });
|
65 | return header;
|
66 | };
|
67 |
|
68 | plugin.addAnswerDataToTopic = async function (hookData) {
|
69 | if (!parseInt(hookData.templateData.isQuestion, 10)) {
|
70 | return hookData;
|
71 | }
|
72 |
|
73 | hookData.templateData.icons.push(
|
74 | parseInt(hookData.templateData.isSolved, 10) ?
|
75 | '<span class="answered"><i class="fa fa-check"></i> [[qanda:topic_solved]]</span>' :
|
76 | '<span class="unanswered"><i class="fa fa-question-circle"></i> [[qanda:topic_unsolved]]</span>'
|
77 | );
|
78 |
|
79 | return await addMetaData(hookData);
|
80 | };
|
81 |
|
82 | plugin.filterTopicGetPosts = async (hookData) => {
|
83 | const solvedPid = parseInt(hookData.topic.solvedPid, 10);
|
84 | const showBestAnswer = hookData.posts.length && hookData.posts[0].index === 0;
|
85 | if (!solvedPid || !showBestAnswer) {
|
86 | return hookData;
|
87 | }
|
88 | const answers = await posts.getPostsByPids([solvedPid], hookData.uid);
|
89 | const [postsData, postSharing] = await Promise.all([
|
90 | topics.addPostData(answers, hookData.uid),
|
91 | social.getActivePostSharing(),
|
92 | ]);
|
93 | let post = postsData[0];
|
94 | if (post) {
|
95 | const bestAnswerTopicData = { ...hookData.topic };
|
96 | bestAnswerTopicData.posts = postsData;
|
97 | bestAnswerTopicData.postSharing = postSharing;
|
98 |
|
99 | const topicPrivileges = await privileges.topics.get(hookData.topic.tid, hookData.uid);
|
100 | await topics.modifyPostsByPrivilege(bestAnswerTopicData, topicPrivileges);
|
101 |
|
102 | post = bestAnswerTopicData.posts[0];
|
103 | post.index = -1;
|
104 |
|
105 | const op = hookData.posts.shift();
|
106 | hookData.posts.unshift(post);
|
107 | hookData.posts.unshift(op);
|
108 | }
|
109 |
|
110 | hookData.posts.forEach((post) => {
|
111 | if (post) {
|
112 | post.isAnswer = post.pid === solvedPid;
|
113 | }
|
114 | });
|
115 |
|
116 | return hookData;
|
117 | };
|
118 |
|
119 | async function addMetaData(data) {
|
120 | const { tid } = data.templateData;
|
121 | const { uid } = data.req;
|
122 | const pidsToFetch = [data.templateData.mainPid, await posts.getPidsFromSet(`tid:${tid}:posts:votes`, 0, 0, true)];
|
123 | let mainPost; let suggestedAnswer; let
|
124 | acceptedAnswer;
|
125 |
|
126 | if (data.templateData.solvedPid) {
|
127 | pidsToFetch.push(data.templateData.solvedPid);
|
128 | }
|
129 |
|
130 | const postsData = [mainPost, suggestedAnswer, acceptedAnswer] = await posts.getPostsByPids(pidsToFetch, uid);
|
131 | await topics.addPostData(postsData, uid);
|
132 |
|
133 | postsData.forEach((p) => {
|
134 | p.content = String(p.content || '')
|
135 | .replace(/\\/g, '\\\\')
|
136 | .replace(/\n/g, '\\n')
|
137 | .replace(/"/g, '\\"')
|
138 | .replace(/\t/g, '\\t');
|
139 | });
|
140 |
|
141 | data.templateData.mainPost = mainPost || {};
|
142 | data.templateData.acceptedAnswer = acceptedAnswer || {};
|
143 | if (suggestedAnswer && suggestedAnswer.pid !== data.templateData.mainPid) {
|
144 | data.templateData.suggestedAnswer = suggestedAnswer || {};
|
145 | }
|
146 |
|
147 | data.res.locals.postHeader = await data.req.app.renderAsync('partials/question-and-answer/topic-jsonld', data.templateData);
|
148 | return data;
|
149 | }
|
150 |
|
151 | plugin.getTopics = async function (hookData) {
|
152 | hookData.topics.forEach((topic) => {
|
153 | if (topic && parseInt(topic.isQuestion, 10)) {
|
154 | if (parseInt(topic.isSolved, 10)) {
|
155 | topic.icons.push('<span class="answered"><i class="fa fa-check"></i> [[qanda:topic_solved]]</span>');
|
156 | } else {
|
157 | topic.icons.push('<span class="unanswered"><i class="fa fa-question-circle"></i> [[qanda:topic_unsolved]]</span>');
|
158 | }
|
159 | }
|
160 | });
|
161 | return hookData;
|
162 | };
|
163 |
|
164 | plugin.filterPostGetPostSummaryByPids = async function (hookData) {
|
165 | const tids = hookData.posts.map(p => p && p.tid);
|
166 | const topicData = await topics.getTopicsFields(tids, ['isQuestion', 'isSolved']);
|
167 | hookData.posts.forEach((p, index) => {
|
168 | if (p && p.topic && topicData[index]) {
|
169 | p.topic.isQuestion = parseInt(topicData[index].isQuestion, 10);
|
170 | p.topic.isSolved = parseInt(topicData[index].isSolved, 10);
|
171 | }
|
172 | });
|
173 | return hookData;
|
174 | };
|
175 |
|
176 | plugin.addThreadTool = async function (hookData) {
|
177 | const isSolved = parseInt(hookData.topic.isSolved, 10);
|
178 |
|
179 | if (parseInt(hookData.topic.isQuestion, 10)) {
|
180 | hookData.tools = hookData.tools.concat([
|
181 | {
|
182 | class: `toggleSolved ${isSolved ? 'alert-warning topic-solved' : 'alert-success topic-unsolved'}`,
|
183 | title: isSolved ? '[[qanda:thread.tool.mark_unsolved]]' : '[[qanda:thread.tool.mark_solved]]',
|
184 | icon: isSolved ? 'fa-question-circle' : 'fa-check-circle',
|
185 | },
|
186 | {
|
187 | class: 'toggleQuestionStatus',
|
188 | title: '[[qanda:thread.tool.make_normal]]',
|
189 | icon: 'fa-comments',
|
190 | },
|
191 | ]);
|
192 | } else {
|
193 | hookData.tools.push({
|
194 | class: 'toggleQuestionStatus alert-warning',
|
195 | title: '[[qanda:thread.tool.as_question]]',
|
196 | icon: 'fa-question-circle',
|
197 | });
|
198 | }
|
199 | return hookData;
|
200 | };
|
201 |
|
202 | plugin.addPostTool = async function (hookData) {
|
203 | const data = await topics.getTopicDataByPid(hookData.pid);
|
204 | if (!data) {
|
205 | return hookData;
|
206 | }
|
207 |
|
208 | data.isSolved = parseInt(data.isSolved, 10) === 1;
|
209 | data.isQuestion = parseInt(data.isQuestion, 10) === 1;
|
210 | const canEdit = await privileges.topics.canEdit(data.tid, hookData.uid);
|
211 | if (canEdit && !data.isSolved && data.isQuestion && parseInt(data.mainPid, 10) !== parseInt(hookData.pid, 10)) {
|
212 | hookData.tools.push({
|
213 | action: 'qanda/post-solved',
|
214 | html: '[[qanda:post.tool.mark_correct]]',
|
215 | icon: 'fa-check-circle',
|
216 | });
|
217 | }
|
218 | return hookData;
|
219 | };
|
220 |
|
221 | plugin.getConditions = async function (conditions) {
|
222 | conditions.push({
|
223 | name: 'Times questions accepted',
|
224 | condition: 'qanda/question.accepted',
|
225 | });
|
226 | return conditions;
|
227 | };
|
228 |
|
229 | plugin.onTopicCreate = async function (payload) {
|
230 | let isQuestion;
|
231 | if (payload.data.hasOwnProperty('isQuestion')) {
|
232 | isQuestion = true;
|
233 | }
|
234 |
|
235 |
|
236 | if (plugin._settings.forceQuestions === 'on' || plugin._settings[`defaultCid_${payload.topic.cid}`] === 'on') {
|
237 | isQuestion = true;
|
238 | }
|
239 |
|
240 | if (!isQuestion) {
|
241 | return payload;
|
242 | }
|
243 |
|
244 | await topics.setTopicFields(payload.topic.tid, { isQuestion: 1, isSolved: 0 });
|
245 | await db.sortedSetAdd('topics:unsolved', Date.now(), payload.topic.tid);
|
246 | return payload;
|
247 | };
|
248 |
|
249 | plugin.actionTopicSave = async function (hookData) {
|
250 | if (hookData.topic && hookData.topic.isQuestion) {
|
251 | await db.sortedSetAdd(hookData.topic.isSolved === 1 ? 'topics:solved' : 'topics:unsolved', Date.now(), hookData.topic.tid);
|
252 | }
|
253 | };
|
254 |
|
255 | plugin.filterTopicEdit = async function (hookData) {
|
256 | const isNowQuestion = hookData.data.isQuestion === true || parseInt(hookData.data.isQuestion, 10) === 1;
|
257 | const wasQuestion = parseInt(await topics.getTopicField(hookData.topic.tid, 'isQuestion'), 10) === 1;
|
258 | if (isNowQuestion !== wasQuestion) {
|
259 | await toggleQuestionStatus(hookData.req.uid, hookData.topic.tid);
|
260 | }
|
261 |
|
262 | return hookData;
|
263 | };
|
264 |
|
265 | plugin.actionTopicPurge = async function (hookData) {
|
266 | if (hookData.topic) {
|
267 | await db.sortedSetsRemove(['topics:solved', 'topics:unsolved'], hookData.topic.tid);
|
268 | }
|
269 | };
|
270 |
|
271 | plugin.filterComposerPush = async function (hookData) {
|
272 | const tid = await posts.getPostField(hookData.pid, 'tid');
|
273 | const isQuestion = await topics.getTopicField(tid, 'isQuestion');
|
274 | hookData.isQuestion = isQuestion;
|
275 |
|
276 | return hookData;
|
277 | };
|
278 |
|
279 | plugin.staticApiRoutes = async function ({ router, middleware, helpers }) {
|
280 | router.get('/qna/:tid', middleware.assert.topic, async (req, res) => {
|
281 | let { isQuestion, isSolved } = await topics.getTopicFields(req.params.tid, ['isQuestion', 'isSolved']);
|
282 | isQuestion = isQuestion || '0';
|
283 | isSolved = isSolved || '0';
|
284 | helpers.formatApiResponse(200, res, { isQuestion, isSolved });
|
285 | });
|
286 | };
|
287 |
|
288 | plugin.registerTopicEvents = async function ({ types }) {
|
289 | types = {
|
290 | ...types,
|
291 | 'qanda.as_question': {
|
292 | icon: 'fa-question',
|
293 | text: '[[qanda:thread.alert.as_question]]',
|
294 | },
|
295 | 'qanda.make_normal': {
|
296 | type: 'qanda.make_normal',
|
297 | text: '[[qanda:thread.alert.make_normal]]',
|
298 | },
|
299 | 'qanda.solved': {
|
300 | icon: 'fa-check',
|
301 | text: '[[qanda:thread.alert.solved]]',
|
302 | },
|
303 | 'qanda.unsolved': {
|
304 | icon: 'fa-question',
|
305 | text: '[[qanda:thread.alert.unsolved]]',
|
306 | },
|
307 | };
|
308 |
|
309 | return { types };
|
310 | };
|
311 |
|
312 | async function renderAdmin(req, res) {
|
313 | const cids = await db.getSortedSetRange('categories:cid', 0, -1);
|
314 | const data = await categories.getCategoriesFields(cids, ['cid', 'name', 'parentCid']);
|
315 | res.render('admin/plugins/question-and-answer', {
|
316 | categories: categories.getTree(data),
|
317 | });
|
318 | }
|
319 |
|
320 | function handleSocketIO() {
|
321 | SocketPlugins.QandA = {};
|
322 |
|
323 | SocketPlugins.QandA.toggleSolved = async function (socket, data) {
|
324 | const canEdit = await privileges.topics.canEdit(data.tid, socket.uid);
|
325 | if (!canEdit) {
|
326 | throw new Error('[[error:no-privileges]]');
|
327 | }
|
328 |
|
329 | return await toggleSolved(socket.uid, data.tid, data.pid);
|
330 | };
|
331 |
|
332 | SocketPlugins.QandA.toggleQuestionStatus = async function (socket, data) {
|
333 | const canEdit = await privileges.topics.canEdit(data.tid, socket.uid);
|
334 | if (!canEdit) {
|
335 | throw new Error('[[error:no-privileges]]');
|
336 | }
|
337 |
|
338 | return await toggleQuestionStatus(socket.uid, data.tid);
|
339 | };
|
340 | }
|
341 |
|
342 | async function toggleSolved(uid, tid, pid) {
|
343 | let isSolved = await topics.getTopicField(tid, 'isSolved');
|
344 | isSolved = parseInt(isSolved, 10) === 1;
|
345 |
|
346 | const updatedTopicFields = isSolved ?
|
347 | { isSolved: 0, solvedPid: 0 } :
|
348 | { isSolved: 1, solvedPid: pid };
|
349 |
|
350 | if (plugin._settings.toggleLock === 'on') {
|
351 | updatedTopicFields.locked = isSolved ? 0 : 1;
|
352 | }
|
353 |
|
354 | await topics.setTopicFields(tid, updatedTopicFields);
|
355 |
|
356 | if (isSolved) {
|
357 | await Promise.all([
|
358 | db.sortedSetAdd('topics:unsolved', Date.now(), tid),
|
359 | db.sortedSetRemove('topics:solved', tid),
|
360 | topics.events.log(tid, { type: 'qanda.unsolved', uid }),
|
361 | ]);
|
362 | } else {
|
363 | await Promise.all([
|
364 | db.sortedSetRemove('topics:unsolved', tid),
|
365 | db.sortedSetAdd('topics:solved', Date.now(), tid),
|
366 | topics.events.log(tid, { type: 'qanda.solved', uid }),
|
367 | ]);
|
368 |
|
369 | if (pid) {
|
370 | const data = await posts.getPostData(pid);
|
371 | await rewards.checkConditionAndRewardUser({
|
372 | uid: data.uid,
|
373 | condition: 'qanda/question.accepted',
|
374 | method: async function () {
|
375 | await user.incrementUserFieldBy(data.uid, 'qanda/question.accepted', 1);
|
376 | },
|
377 | });
|
378 | }
|
379 | }
|
380 |
|
381 | plugins.hooks.fire('action:topic.toggleSolved', { uid: uid, tid: tid, pid: pid, isSolved: !isSolved });
|
382 | return { isSolved: !isSolved };
|
383 | }
|
384 |
|
385 | async function toggleQuestionStatus(uid, tid) {
|
386 | let isQuestion = await topics.getTopicField(tid, 'isQuestion');
|
387 | isQuestion = parseInt(isQuestion, 10) === 1;
|
388 |
|
389 | if (!isQuestion) {
|
390 | await Promise.all([
|
391 | topics.setTopicFields(tid, { isQuestion: 1, isSolved: 0, solvedPid: 0 }),
|
392 | db.sortedSetAdd('topics:unsolved', Date.now(), tid),
|
393 | db.sortedSetRemove('topics:solved', tid),
|
394 | topics.events.log(tid, { type: 'qanda.as_question', uid }),
|
395 | ]);
|
396 | } else {
|
397 | await Promise.all([
|
398 | topics.deleteTopicFields(tid, ['isQuestion', 'isSolved', 'solvedPid']),
|
399 | db.sortedSetsRemove(['topics:solved', 'topics:unsolved'], tid),
|
400 | topics.events.log(tid, { type: 'qanda.make_normal', uid }),
|
401 | ]);
|
402 | }
|
403 |
|
404 | plugins.hooks.fire('action:topic.toggleQuestion', { uid: uid, tid: tid, isQuestion: !isQuestion });
|
405 | return { isQuestion: !isQuestion };
|
406 | }
|
407 |
|
408 | async function canPostTopic(uid) {
|
409 | let cids = await categories.getAllCidsFromSet('categories:cid');
|
410 | cids = await privileges.categories.filterCids('topics:create', cids, uid);
|
411 | return cids.length > 0;
|
412 | }
|
413 |
|
414 | async function renderUnsolved(req, res) {
|
415 | await renderQnAPage('unsolved', req, res);
|
416 | }
|
417 |
|
418 | async function renderSolved(req, res) {
|
419 | await renderQnAPage('solved', req, res);
|
420 | }
|
421 |
|
422 | async function renderQnAPage(type, req, res) {
|
423 | const page = parseInt(req.query.page, 10) || 1;
|
424 | const { cid } = req.query;
|
425 | const [settings, categoryData, canPost, isPrivileged] = await Promise.all([
|
426 | user.getSettings(req.uid),
|
427 | helpers.getSelectedCategory(cid),
|
428 | canPostTopic(req.uid),
|
429 | user.isPrivileged(req.uid),
|
430 | ]);
|
431 |
|
432 | const topicsData = await getTopics(type, page, cid, req.uid, settings);
|
433 |
|
434 | const data = {};
|
435 | data.topics = topicsData.topics;
|
436 | data.showSelect = isPrivileged;
|
437 | data.showTopicTools = isPrivileged;
|
438 | data.allCategoriesUrl = type + helpers.buildQueryString(req.query, 'cid', '');
|
439 | data.selectedCategory = categoryData.selectedCategory;
|
440 | data.selectedCids = categoryData.selectedCids;
|
441 |
|
442 | data['feeds:disableRSS'] = true;
|
443 | const pageCount = Math.max(1, Math.ceil(topicsData.topicCount / settings.topicsPerPage));
|
444 | data.pagination = pagination.create(page, pageCount);
|
445 | data.canPost = canPost;
|
446 | data.title = `[[qanda:menu.${type}]]`;
|
447 |
|
448 | if (req.path.startsWith(`/api/${type}`) || req.path.startsWith(`/${type}`)) {
|
449 | data.breadcrumbs = helpers.buildBreadcrumbs([{ text: `[[qanda:menu.${type}]]` }]);
|
450 | }
|
451 |
|
452 | res.render('recent', data);
|
453 | }
|
454 |
|
455 | async function getTopics(type, page, cids, uid, settings) {
|
456 | cids = cids || [];
|
457 | if (!Array.isArray(cids)) {
|
458 | cids = [cids];
|
459 | }
|
460 | const set = `topics:${type}`;
|
461 | let tids = [];
|
462 | if (cids.length) {
|
463 | cids = await privileges.categories.filterCids('read', cids, uid);
|
464 | const allTids = await Promise.all(cids.map(async cid => await db.getSortedSetRevIntersect({
|
465 | sets: [set, `cid:${cid}:tids:lastposttime`],
|
466 | start: 0,
|
467 | stop: 199,
|
468 | })));
|
469 | tids = allTids.flat().sort((tid1, tid2) => tid2 - tid1);
|
470 | } else {
|
471 | tids = await db.getSortedSetRevRange(set, 0, 199);
|
472 | tids = await privileges.topics.filterTids('read', tids, uid);
|
473 | }
|
474 |
|
475 | const start = Math.max(0, (page - 1) * settings.topicsPerPage);
|
476 | const stop = start + settings.topicsPerPage - 1;
|
477 |
|
478 | const topicCount = tids.length;
|
479 |
|
480 | tids = tids.slice(start, stop + 1);
|
481 |
|
482 | const topicsData = await topics.getTopicsByTids(tids, uid);
|
483 | topics.calculateTopicIndices(topicsData, start);
|
484 | return {
|
485 | topicCount,
|
486 | topics: topicsData,
|
487 | };
|
488 | }
|
489 |
|
\ | No newline at end of file |