Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Modified code in src/topics to allow topics to be categorized as "answered" and "unanswered" #16

Merged
merged 23 commits into from
Feb 28, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
2f770f9
Added answered.js to src/topics to allow users to mark topics as "ans…
bingbhakdibhumi Feb 10, 2025
0346dfa
Added line 21 in posts.js and line 36 in index.js to let questions be…
bingbhakdibhumi Feb 10, 2025
4c69c73
Added comments to src/topics/answered.js to clarify the source of hel…
bingbhakdibhumi Feb 10, 2025
7df8ad4
Removed unused constant in line 3 of answered.js
bingbhakdibhumi Feb 10, 2025
9319285
Converted indentation from spaces to tabs in answered.js
bingbhakdibhumi Feb 10, 2025
97b5fc2
Removed whitespace and unused variables in answered.js
bingbhakdibhumi Feb 10, 2025
6375500
Removed erroneous white space in posts.js
bingbhakdibhumi Feb 10, 2025
f37ac83
Added getTids function from unread.js to answered.js to retrieve topi…
bingbhakdibhumi Feb 10, 2025
450e190
Added constants of topics functionalities to answered.js and removed …
bingbhakdibhumi Feb 10, 2025
5d91317
Fixed syntax error on line 124 in answered.js
bingbhakdibhumi Feb 10, 2025
53d4d1f
Fixed variable being referenced before initialization error in answer…
bingbhakdibhumi Feb 10, 2025
3be1afc
Added constant unreadTopics to answered.js to fix referencing issue
bingbhakdibhumi Feb 10, 2025
a89b4ad
Removed unused references to ignoredTids in answered.js
bingbhakdibhumi Feb 10, 2025
22d0bb2
added dummy front end headers to topic templates
bingbhakdibhumi Feb 21, 2025
56a57b6
removed dummy label in the header
bingbhakdibhumi Feb 23, 2025
164dbae
added a "mark topic as answered" button in the tools dropdown
bingbhakdibhumi Feb 23, 2025
9fba030
changed icon for "mark as answered/unanswered" in the dropdown topic …
bingbhakdibhumi Feb 28, 2025
29b11b9
Created test file "answered.js" to test categorizing functionality
bingbhakdibhumi Feb 28, 2025
524296f
added "answered" button to the topic drop down menu
bingbhakdibhumi Feb 28, 2025
0e3f3a8
removed filler header in templates/topic.tpl
bingbhakdibhumi Feb 28, 2025
463be00
Created UserGuide.md to the specifications on the writeup
bingbhakdibhumi Feb 28, 2025
3762e7d
Merge remote-tracking branch 'origin/main' into categorize-questions
bingbhakdibhumi Feb 28, 2025
1ce6d9f
Merged and resolved conflicts for UserGuide.md
bingbhakdibhumi Feb 28, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions UserGuide.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,16 @@ as this is the only feature implemented; I did not work on "un-voting" or any ot

![Voting](/img2.png)

# User Guide for Categorizing Topics as "Answered/Unanswered"

To use this feature, navigate to a topic and select "Topic Tools."

There should be a button within to dropdown menu to mark the topic as "answered."

![Tools](image.png)

The automated tests for this can be found in test/answered.js. The tests create and categorize topics as answered and unanswered, and assure that they are valid topics. This is sufficient for the features I implemented, as there are no feature to interact with these new categories yet.

# User Guide for Search Functionality

## Overview
Expand Down Expand Up @@ -78,3 +88,5 @@ For further assistance, please contact support or refer to the application's hel
## Conclusion

The search functionality is a powerful tool for navigating and finding content within the application. By understanding and utilizing the available parameters and filters, you can efficiently locate the information you need.


Binary file added dump.rdb
Binary file not shown.
Binary file added image.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
12 changes: 12 additions & 0 deletions nodebb-theme-harmony/templates/partials/category/tools.tpl
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,18 @@
</a>
</li>

<li>
<a component="topic/answered" href="#" class="dropdown-item rounded-1 d-flex align-items-center gap-2" role="menuitem">
<i class="fa fa-fw fa-tag text-secondary"></i> [[topic:thread-tools.answered]]
</a>
</li>
<li>
<a component="topic/unanswered" href="#" class="hidden dropdown-item rounded-1" role="menuitem">
<i class="fa fa-fw fa-tag text-secondary"></i> [[topic:thread-tools.unanswered]]
</a>
</li>


<li>
<a component="topic/lock" href="#" class="dropdown-item rounded-1 d-flex align-items-center gap-2" role="menuitem">
<i class="fa fa-fw fa-lock text-secondary"></i> [[topic:thread-tools.lock]]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@
<a component="topic/unpin" href="#" class="dropdown-item rounded-1 d-flex align-items-center gap-2 {{{ if !pinned }}}hidden{{{ end }}}" role="menuitem"><i class="fa fa-fw fa-thumb-tack fa-rotate-90 text-secondary"></i> [[topic:thread-tools.unpin]]</a>
</li>

<li>
<a component="topic/answered" href="#" class="dropdown-item rounded-1 d-flex align-items-center gap-2" role="menuitem"><i class="fa fa-fw fa-tag text-secondary"></i> [[topic:thread-tools.answered]]</a>
</li>

<li>
<a component="topic/move" href="#" class="dropdown-item rounded-1 d-flex align-items-center gap-2" role="menuitem"><i class="fa fa-fw fa-arrows text-secondary"></i> [[topic:thread-tools.move]]</a>
</li>
Expand Down
2 changes: 2 additions & 0 deletions public/language/en-GB/topic.json
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,8 @@
"thread-tools.markAsUnreadForAll": "Mark Unread For All",
"thread-tools.pin": "Pin Topic",
"thread-tools.unpin": "Unpin Topic",
"thread-tools.answered": "Mark Topic as Answered",
"thread-tools.unanswered": "Mark Topic as Unanswered",
"thread-tools.lock": "Lock Topic",
"thread-tools.unlock": "Unlock Topic",
"thread-tools.move": "Move Topic",
Expand Down
2 changes: 2 additions & 0 deletions public/language/en-US/topic.json
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,8 @@
"thread-tools.markAsUnreadForAll": "Mark Unread For All",
"thread-tools.pin": "Pin Topic",
"thread-tools.unpin": "Unpin Topic",
"thread-tools.answered": "Mark Topic as Answered",
"thread-tools.unanswered": "Mark Topic as Unanswered",
"thread-tools.lock": "Lock Topic",
"thread-tools.unlock": "Unlock Topic",
"thread-tools.move": "Move Topic",
Expand Down
2 changes: 2 additions & 0 deletions public/language/en-x-pirate/topic.json
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,8 @@
"thread-tools.markAsUnreadForAll": "Mark Unread For All",
"thread-tools.pin": "Pin Topic",
"thread-tools.unpin": "Unpin Topic",
"thread-tools.answered": "Mark Topic as Answered",
"thread-tools.unanswered": "Mark Topic as Unanswered",
"thread-tools.lock": "Lock Topic",
"thread-tools.unlock": "Unlock Topic",
"thread-tools.move": "Move Topic",
Expand Down
1 change: 1 addition & 0 deletions src/privileges/topics.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ privsTopics.get = async function (tid, uid) {
'topics:delete', 'posts:edit', 'posts:history',
'posts:upvote', 'posts:downvote', 'posts:goodquestion',
'posts:delete', 'posts:view_deleted', 'read', 'purge',
'posts:answered', 'posts:unanswered',
];
const topicData = await topics.getTopicFields(tid, ['cid', 'uid', 'locked', 'deleted', 'scheduled']);
const [userPrivileges, isAdministrator, isModerator, disabled] = await Promise.all([
Expand Down
183 changes: 183 additions & 0 deletions src/topics/answered.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
'use strict';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add some comments in this file explaining particular functions


const _ = require('lodash');

const db = require('../database');
const user = require('../user');
const categories = require('../categories');
const privileges = require('../privileges');
const plugins = require('../plugins');


module.exports = function (Topics) {
Topics.markAsAnswered = async function (uid, tid) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe it's better to first check if uid and tid is valid here

const topicData = await Topics.getTopicFields(tid, ['answered']);
if (!topicData || !topicData.cid) {
throw new Error('[[error:no-topic]]');
}
// function from ../data.js
await Topics.setTopicField(tid, 'answered', 1);
plugins.hooks.fire('action:topic.answered', { tid: tid, uid: uid });
return topicData;
};
Topics.markAsUnanswered = async function (uid, tid) {
const topicData = await Topics.getTopicFields(tid, ['answered']);
if (!topicData || !topicData.cid) {
throw new Error('[[error:no-topic]]');
}

await Topics.setTopicField(tid, 'answered', 0);
plugins.hooks.fire('action:topic.answered', { tid: tid, uid: uid });
return topicData;
};
// based on getUnread functions from ../unread.js
Topics.getUnansweredTopics = async function (params) {
const unansweredTopics = {
showSelect: true,
nextStart: 0,
topics: [],
};
let tids = await Topics.getUnansweredTids(params);
unansweredTopics.topicCount = tids.length;

if (!tids.length) {
return unansweredTopics;
}

tids = tids.slice(params.start, params.stop !== -1 ? params.stop + 1 : undefined);

const topicData = await Topics.getTopicsByTids(tids, params.uid);
if (!topicData.length) {
return unansweredTopics;
}
Topics.calculateTopicIndices(topicData, params.start);
unansweredTopics.topics = topicData;
unansweredTopics.nextStart = params.stop + 1;
return unansweredTopics;
};
Topics.getUnAnsweredTids = async function (params) {
const results = await Topics.getAnsweredData(params);
return params.count ? results.counts : results.tids;
};
Topics.getAnsweredData = async function (params) {
const uid = parseInt(params.uid, 10);

params.filter = params.filter || '';

if (params.cid && !Array.isArray(params.cid)) {
params.cid = [params.cid];
}

if (params.tag && !Array.isArray(params.tag)) {
params.tag = [params.tag];
}

const data = await getTids(params);
if (uid <= 0) {
return data;
}

const result = await plugins.hooks.fire('filter:topics.getAnsweredTids', {
uid: uid,
tids: data.tids,
counts: data.counts,
tidsByFilter: data.tidsByFilter,
answeredCids: data.answeredCids,
cid: params.cid,
filter: params.filter,
query: params.query || {},
});
return result;
};
// from unread.js
async function getTids(params) {
const counts = { '': 0, new: 0, watched: 0, unreplied: 0 };
const tidsByFilter = { '': [], new: [], watched: [], unreplied: [] };
const unreadCids = [];
if (params.uid <= 0) {
return { counts, tids: [], tidsByFilter, unreadCids };
}

params.cutoff = await Topics.unreadCutoff(params.uid);

const [followedTids, userScores, tids_unread] = await Promise.all([
getFollowedTids(params),
db.getSortedSetRevRangeByScoreWithScores(`uid:${params.uid}:tids_read`, 0, -1, '+inf', params.cutoff),
db.getSortedSetRevRangeWithScores(`uid:${params.uid}:tids_unread`, 0, -1),
]);

const userReadTimes = _.mapValues(_.keyBy(userScores, 'value'), 'score');
const isTopicsFollowed = {};
followedTids.forEach((t) => {
isTopicsFollowed[t.value] = true;
});
const unreadFollowed = await db.isSortedSetMembers(
`uid:${params.uid}:followed_tids`, tids_unread.map(t => t.value)
);

tids_unread.forEach((t, i) => {
isTopicsFollowed[t.value] = unreadFollowed[i];
});

const unreadTopics = followedTids;
const blockedUids = await user.blocks.list(params.uid);
let tids = _.uniq(unreadTopics.map(topic => topic.value)).slice(0, 200);

tids = await privileges.topics.filterTids('topics:read', tids, params.uid);
const topicData = (await Topics.getTopicsFields(tids, ['tid', 'cid', 'uid', 'postcount', 'deleted', 'scheduled', 'tags']))
.filter(t => t.scheduled || !t.deleted);
const topicCids = _.uniq(topicData.map(topic => topic.cid)).filter(Boolean);

const categoryWatchState = await categories.getWatchState(topicCids, params.uid);
const userCidState = _.zipObject(topicCids, categoryWatchState);

const filterCids = params.cid && params.cid.map(cid => parseInt(cid, 10));
const filterTags = params.tag && params.tag.map(tag => String(tag));

topicData.forEach((topic) => {
if (topic && topic.cid &&
(!filterCids || filterCids.includes(topic.cid)) &&
(!filterTags || filterTags.every(tag => topic.tags.find(topicTag => topicTag.value === tag))) &&
!blockedUids.includes(topic.uid)) {
if (isTopicsFollowed[topic.tid] ||
[categories.watchStates.watching, categories.watchStates.tracking].includes(userCidState[topic.cid])) {
tidsByFilter[''].push(topic.tid);
unreadCids.push(topic.cid);
}

if (isTopicsFollowed[topic.tid]) {
tidsByFilter.watched.push(topic.tid);
}

if (topic.postcount <= 1) {
tidsByFilter.unreplied.push(topic.tid);
}

if (!userReadTimes[topic.tid]) {
tidsByFilter.new.push(topic.tid);
}
}
});

counts[''] = tidsByFilter[''].length;
counts.watched = tidsByFilter.watched.length;
counts.unreplied = tidsByFilter.unreplied.length;
counts.new = tidsByFilter.new.length;

return {
counts: counts,
tids: tidsByFilter[params.filter],
tidsByFilter: tidsByFilter,
unreadCids: unreadCids,
};
}
async function getFollowedTids(params) {
const keys = params.cid ?
params.cid.map(cid => `cid:${cid}:tids:lastposttime`) :
'topics:recent';

const recentTopicData = await db.getSortedSetRevRangeByScoreWithScores(keys, 0, -1, '+inf', params.cutoff);
const isFollowed = await db.isSortedSetMembers(`uid:${params.uid}:followed_tids`, recentTopicData.map(t => t.tid));
return recentTopicData.filter((t, i) => isFollowed[i]);
}
};
1 change: 1 addition & 0 deletions src/topics/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ require('./tools')(Topics);
Topics.thumbs = require('./thumbs');
require('./bookmarks')(Topics);
require('./merge')(Topics);
require('./answered')(Topics);
Topics.events = require('./events');

Topics.exists = async function (tids) {
Expand Down
1 change: 1 addition & 0 deletions src/topics/posts.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ module.exports = function (Topics) {
Topics.onNewPostMade = async function (postData) {
await Topics.updateLastPostTime(postData.tid, postData.timestamp);
await Topics.addPostToTopic(postData.tid, postData);
postData.answered = false;
};

Topics.getTopicPosts = async function (topicData, set, start, stop, uid, reverse) {
Expand Down
102 changes: 102 additions & 0 deletions test/answered.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
'use strict';

const path = require('path');
const assert = require('assert');
const validator = require('validator');
const mockdate = require('mockdate');
const nconf = require('nconf');
const util = require('util');

const sleep = util.promisify(setTimeout);

const db = require('./mocks/databasemock');
const file = require('../src/file');
const topics = require('../src/topics');
const posts = require('../src/posts');
const categories = require('../src/categories');
const privileges = require('../src/privileges');
const meta = require('../src/meta');
const User = require('../src/user');
const groups = require('../src/groups');
const utils = require('../src/utils');
const helpers = require('./helpers');
const socketTopics = require('../src/socket.io/topics');
const apiTopics = require('../src/api/topics');
const apiPosts = require('../src/api/posts');
const request = require('../src/request');


const answered = async function (pid, uid) {
try {
privileges.posts.can('posts:answered', pid, uid);
} catch (e) { }
return { answered: true };
};

describe('Categorize questions as Answered', () => {
let topicAnswered;
let topicUnanswered;
let categoryAnswered;
let categoryUnanswered;
let testUid;
let fooUid;
let adminJar;
let csrf_token;

// modified from ./topics.js
before(async () => {
testUid = await User.create({ username: 'test', password: 'password' });
fooUid = await User.create({ username: 'foo' });
await groups.join('administrators', testUid);
const adminLogin = await helpers.loginUser('test', 'password');
adminJar = adminLogin.jar;
csrf_token = adminLogin.csrf_token;
categoryAnswered = await categories.create({
name: 'Answered Category',
description: 'Answered category created by testing script',
});
categoryUnanswered = await categories.create({
name: 'Unanswered Category',
description: 'Unanswered category created by testing script',
});
topicAnswered = {
userId: testUid,
categoryId: categoryAnswered.cid,
title: 'Test Answered',
content: 'Post has been answered',
};
topicUnanswered = {
userId: testUid,
categoryId: categoryUnanswered.cid,
title: 'Test Unanswered',
content: 'Post has been unanswered',
};
});

it('should categorize a topic as answered', (done) => {
topics.post({
uid: topicAnswered.userId,
title: topicAnswered.title,
content: topicAnswered.content,
cid: topicAnswered.categoryId,
}, (err, result) => {
assert.ifError(err);
assert(result);
topicAnswered.tid = result.topicData.tid;
done();
});
});
it('should categorize a topic as unanswered', (done) => {
topics.post({
uid: topicUnanswered.userId,
title: topicUnanswered.title,
content: topicUnanswered.content,
cid: topicUnanswered.categoryId,
}, (err, result) => {
assert.ifError(err);
assert(result);
topicUnanswered.tid = result.topicData.tid;
done();
});
});
});
Loading