refactor: category listing logic to allow remote categories to be added, disabled, and re-arranged in main forum index

This commit is contained in:
Julian Lam
2025-08-12 15:38:49 -04:00
parent 75639c86bd
commit cb0b609289
9 changed files with 114 additions and 20 deletions

View File

@@ -3,11 +3,13 @@
"add-category": "Add category", "add-category": "Add category",
"add-local-category": "Add Local category", "add-local-category": "Add Local category",
"add-remote-category": "Add Remote category", "add-remote-category": "Add Remote category",
"remove": "Remove",
"jump-to": "Jump to...", "jump-to": "Jump to...",
"settings": "Category Settings", "settings": "Category Settings",
"edit-category": "Edit Category", "edit-category": "Edit Category",
"privileges": "Privileges", "privileges": "Privileges",
"back-to-categories": "Back to categories", "back-to-categories": "Back to categories",
"id": "Category ID",
"name": "Category Name", "name": "Category Name",
"handle": "Category Handle", "handle": "Category Handle",
"handle.help": "Your category handle is used as a representation of this category across other networks, similar to a username. A category handle must not match an existing username or user group.", "handle.help": "Your category handle is used as a representation of this category across other networks, similar to a username. A category handle must not match an existing username or user group.",

View File

@@ -69,7 +69,7 @@ define('admin/manage/categories', [
if (val && cid) { if (val && cid) {
const modified = {}; const modified = {};
modified[cid] = { order: Math.max(1, parseInt(val, 10)) }; modified[cid] = { order: Math.max(1, parseInt(val, 10)) };
api.put('/categories/' + cid, modified[cid]).then(function () { api.put('/categories/' + encodeURIComponent(cid), modified[cid]).then(function () {
ajaxify.refresh(); ajaxify.refresh();
}).catch(alerts.error); }).catch(alerts.error);
} else { } else {
@@ -81,6 +81,8 @@ define('admin/manage/categories', [
}); });
}); });
$('.categories').on('click', 'a[data-action="remove"]', Categories.removeCategory);
$('#toggle-collapse-all').on('click', function () { $('#toggle-collapse-all').on('click', function () {
const $this = $(this); const $this = $(this);
const isCollapsed = parseInt($this.attr('data-collapsed'), 10) === 1; const isCollapsed = parseInt($this.attr('data-collapsed'), 10) === 1;
@@ -167,8 +169,11 @@ define('admin/manage/categories', [
}); });
function submit() { function submit() {
// const formData = modal.find('form').serializeObject(); const formData = modal.find('form').serializeObject();
api.post('/api/admin/manage/categories', formData).then(() => {
ajaxify.refresh();
modal.modal('hide'); modal.modal('hide');
}).catch(alerts.error);
return false; return false;
} }
@@ -176,6 +181,11 @@ define('admin/manage/categories', [
}); });
}; };
Categories.removeCategory = function () {
const cid = this.getAttribute('data-cid');
api.del(`/api/admin/manage/categories/${encodeURIComponent(cid)}`).then(ajaxify.refresh);
};
Categories.create = function (payload) { Categories.create = function (payload) {
api.post('/categories', payload, function (err, data) { api.post('/categories', payload, function (err, data) {
if (err) { if (err) {
@@ -212,7 +222,7 @@ define('admin/manage/categories', [
Categories.toggle = function (cids, disabled) { Categories.toggle = function (cids, disabled) {
const listEl = document.querySelector('.categories [data-cid="0"]'); const listEl = document.querySelector('.categories [data-cid="0"]');
Promise.all(cids.map(cid => api.put('/categories/' + cid, { Promise.all(cids.map(cid => api.put('/categories/' + encodeURIComponent(cid), {
disabled: disabled ? 1 : 0, disabled: disabled ? 1 : 0,
}).then(() => { }).then(() => {
const categoryEl = listEl.querySelector(`li[data-cid="${cid}"]`); const categoryEl = listEl.querySelector(`li[data-cid="${cid}"]`);
@@ -264,7 +274,7 @@ define('admin/manage/categories', [
} }
newCategoryId = -1; newCategoryId = -1;
api.put('/categories/' + cid, modified[cid]).catch(alerts.error); api.put('/categories/' + encodeURIComponent(cid), modified[cid]).catch(alerts.error);
} }
} }

View File

@@ -187,6 +187,11 @@ activitypubApi.create.privateNote = enabledCheck(async (caller, { messageObj })
activitypubApi.update = {}; activitypubApi.update = {};
activitypubApi.update.profile = enabledCheck(async (caller, { uid }) => { activitypubApi.update.profile = enabledCheck(async (caller, { uid }) => {
// Local users only
if (!utils.isNumber(uid)) {
return;
}
const [object, targets] = await Promise.all([ const [object, targets] = await Promise.all([
activitypub.mocks.actors.user(uid), activitypub.mocks.actors.user(uid),
db.getSortedSetMembers(`followersRemote:${caller.uid}`), db.getSortedSetMembers(`followersRemote:${caller.uid}`),
@@ -203,6 +208,11 @@ activitypubApi.update.profile = enabledCheck(async (caller, { uid }) => {
}); });
activitypubApi.update.category = enabledCheck(async (caller, { cid }) => { activitypubApi.update.category = enabledCheck(async (caller, { cid }) => {
// Local categories only
if (!utils.isNumber(cid)) {
return;
}
const [object, targets] = await Promise.all([ const [object, targets] = await Promise.all([
activitypub.mocks.actors.category(cid), activitypub.mocks.actors.category(cid),
activitypub.notes.getCategoryFollowers(cid), activitypub.notes.getCategoryFollowers(cid),

View File

@@ -101,7 +101,7 @@ Categories.getAllCidsFromSet = async function (key) {
} }
cids = await db.getSortedSetRange(key, 0, -1); cids = await db.getSortedSetRange(key, 0, -1);
cids = cids.map(cid => parseInt(cid, 10)); cids = cids.map(cid => utils.isNumber(cid) ? parseInt(cid, 10) : cid);
cache.set(key, cids); cache.set(key, cids);
return cids.slice(); return cids.slice();
}; };
@@ -274,7 +274,7 @@ Categories.getChildrenTree = getChildrenTree;
Categories.getParentCids = async function (currentCid) { Categories.getParentCids = async function (currentCid) {
let cid = currentCid; let cid = currentCid;
const parents = []; const parents = [];
while (parseInt(cid, 10)) { while (utils.isNumber(cid) ? parseInt(cid, 10) : cid) {
// eslint-disable-next-line // eslint-disable-next-line
cid = await Categories.getCategoryField(cid, 'parentCid'); cid = await Categories.getCategoryField(cid, 'parentCid');
if (cid) { if (cid) {
@@ -289,12 +289,12 @@ Categories.getChildrenCids = async function (rootCid) {
async function recursive(keys) { async function recursive(keys) {
let childrenCids = await db.getSortedSetRange(keys, 0, -1); let childrenCids = await db.getSortedSetRange(keys, 0, -1);
childrenCids = childrenCids.filter(cid => !allCids.includes(parseInt(cid, 10))); childrenCids = childrenCids.filter(cid => !allCids.includes(utils.isNumber(cid) ? parseInt(cid, 10) : cid));
if (!childrenCids.length) { if (!childrenCids.length) {
return; return;
} }
keys = childrenCids.map(cid => `cid:${cid}:children`); keys = childrenCids.map(cid => `cid:${cid}:children`);
childrenCids.forEach(cid => allCids.push(parseInt(cid, 10))); childrenCids.forEach(cid => allCids.push(utils.isNumber(cid) ? parseInt(cid, 10) : cid));
await recursive(keys); await recursive(keys);
} }
const key = `cid:${rootCid}:children`; const key = `cid:${rootCid}:children`;

View File

@@ -60,7 +60,7 @@ module.exports = function (Categories) {
return await updateOrder(cid, value); return await updateOrder(cid, value);
} }
await db.setObjectField(`category:${cid}`, key, value); await db.setObjectField(`${utils.isNumber(cid) ? 'category' : 'categoryRemote'}:${cid}`, key, value);
if (key === 'description') { if (key === 'description') {
await Categories.parseDescription(cid, value); await Categories.parseDescription(cid, value);
} }
@@ -83,7 +83,7 @@ module.exports = function (Categories) {
await Promise.all([ await Promise.all([
db.sortedSetRemove(`cid:${oldParent}:children`, cid), db.sortedSetRemove(`cid:${oldParent}:children`, cid),
db.sortedSetAdd(`cid:${newParent}:children`, categoryData.order, cid), db.sortedSetAdd(`cid:${newParent}:children`, categoryData.order, cid),
db.setObjectField(`category:${cid}`, 'parentCid', newParent), db.setObjectField(`${utils.isNumber(cid) ? 'category' : 'categoryRemote'}:${cid}`, 'parentCid', newParent),
]); ]);
cache.del([ cache.del([
@@ -104,8 +104,12 @@ module.exports = function (Categories) {
} }
async function updateOrder(cid, order) { async function updateOrder(cid, order) {
const parentCid = await Categories.getCategoryField(cid, 'parentCid'); const parentCid = (await Categories.getCategoryField(cid, 'parentCid')) || 0;
const isLocal = utils.isNumber(cid);
if (isLocal) {
await db.sortedSetsAdd('categories:cid', order, cid); await db.sortedSetsAdd('categories:cid', order, cid);
}
const childrenCids = await db.getSortedSetRange( const childrenCids = await db.getSortedSetRange(
`cid:${parentCid}:children`, 0, -1 `cid:${parentCid}:children`, 0, -1
@@ -128,7 +132,7 @@ module.exports = function (Categories) {
); );
await db.setObjectBulk( await db.setObjectBulk(
childrenCids.map((cid, index) => [`category:${cid}`, { order: index + 1 }]) childrenCids.map((cid, index) => [`${utils.isNumber(cid) ? 'category' : 'categoryRemote'}:${cid}`, { order: index + 1 }])
); );
cache.del([ cache.del([

View File

@@ -12,6 +12,8 @@ const meta = require('../../meta');
const activitypub = require('../../activitypub'); const activitypub = require('../../activitypub');
const helpers = require('../helpers'); const helpers = require('../helpers');
const pagination = require('../../pagination'); const pagination = require('../../pagination');
const utils = require('../../utils');
const cache = require('../../cache');
const categoriesController = module.exports; const categoriesController = module.exports;
@@ -48,14 +50,14 @@ categoriesController.get = async function (req, res, next) {
categoriesController.getAll = async function (req, res) { categoriesController.getAll = async function (req, res) {
const rootCid = parseInt(req.query.cid, 10) || 0; const rootCid = parseInt(req.query.cid, 10) || 0;
async function getRootAndChildren() {
const rootChildren = await categories.getAllCidsFromSet(`cid:${rootCid}:children`); const rootChildren = await categories.getAllCidsFromSet(`cid:${rootCid}:children`);
async function getRootAndChildren() {
const childCids = _.flatten(await Promise.all(rootChildren.map(cid => categories.getChildrenCids(cid)))); const childCids = _.flatten(await Promise.all(rootChildren.map(cid => categories.getChildrenCids(cid))));
return [rootCid].concat(rootChildren.concat(childCids)); return [rootCid].concat(rootChildren.concat(childCids));
} }
// Categories list will be rendered on client side with recursion, etc. // Categories list will be rendered on client side with recursion, etc.
const cids = await (rootCid ? getRootAndChildren() : categories.getAllCidsFromSet('categories:cid')); const cids = await getRootAndChildren();
let rootParent = 0; let rootParent = 0;
if (rootCid) { if (rootCid) {
@@ -67,9 +69,16 @@ categoriesController.getAll = async function (req, res) {
'order', 'color', 'bgColor', 'backgroundImage', 'imageClass', 'order', 'color', 'bgColor', 'backgroundImage', 'imageClass',
'subCategoriesPerPage', 'description', 'subCategoriesPerPage', 'description',
]; ];
const categoriesData = await categories.getCategoriesFields(cids, fields); let categoriesData = await categories.getCategoriesFields(cids, fields);
const result = await plugins.hooks.fire('filter:admin.categories.get', { categories: categoriesData, fields: fields }); ({ categories: categoriesData } = await plugins.hooks.fire('filter:admin.categories.get', { categories: categoriesData, fields: fields }));
let tree = categories.getTree(result.categories, rootParent);
// Append remote categories
categoriesData = categoriesData.map((category) => {
category.isLocal = utils.isNumber(category.cid);
return category;
});
let tree = categories.getTree(categoriesData, rootParent);
const cidsCount = rootCid && tree[0] ? tree[0].children.length : tree.length; const cidsCount = rootCid && tree[0] ? tree[0].children.length : tree.length;
const pageCount = Math.max(1, Math.ceil(cidsCount / meta.config.categoriesPerPage)); const pageCount = Math.max(1, Math.ceil(cidsCount / meta.config.categoriesPerPage));
@@ -176,3 +185,41 @@ categoriesController.getFederation = async function (req, res) {
followers, followers,
}); });
}; };
categoriesController.addRemote = async function (req, res) {
let { handle, id } = req.body;
if (handle && !id) {
({ actorUri: id } = await activitypub.helpers.query(handle));
}
if (!id) {
return res.sendStatus(404);
}
await activitypub.actors.assertGroup(id);
const exists = await categories.exists(id);
if (!exists) {
return res.sendStatus(404);
}
const score = await db.sortedSetCard('cid:0:children');
const order = score + 1; // order is 1-based lol
await Promise.all([
db.sortedSetAdd('cid:0:children', order, id),
categories.setCategoryField(id, 'order', order),
]);
cache.del('cid:0:children');
res.sendStatus(200);
};
categoriesController.removeRemote = async function (req, res) {
if (utils.isNumber(req.params.cid)) {
return helpers.formatApiResponse(400, res);
}
await db.sortedSetRemove('cid:0:children', req.params.cid);
cache.del('cid:0:children');
res.sendStatus(200);
};

View File

@@ -81,6 +81,8 @@ function apiRoutes(router, name, middleware, controllers) {
router.get(`/api/${name}/groups/:groupname/csv`, middleware.ensureLoggedIn, helpers.tryRoute(controllers.admin.groups.getCSV)); router.get(`/api/${name}/groups/:groupname/csv`, middleware.ensureLoggedIn, helpers.tryRoute(controllers.admin.groups.getCSV));
router.get(`/api/${name}/analytics`, middleware.ensureLoggedIn, helpers.tryRoute(controllers.admin.dashboard.getAnalytics)); router.get(`/api/${name}/analytics`, middleware.ensureLoggedIn, helpers.tryRoute(controllers.admin.dashboard.getAnalytics));
router.get(`/api/${name}/advanced/cache/dump`, middleware.ensureLoggedIn, helpers.tryRoute(controllers.admin.cache.dump)); router.get(`/api/${name}/advanced/cache/dump`, middleware.ensureLoggedIn, helpers.tryRoute(controllers.admin.cache.dump));
router.post(`/api/${name}/manage/categories`, middleware.ensureLoggedIn, helpers.tryRoute(controllers.admin.categories.addRemote));
router.delete(`/api/${name}/manage/categories/:cid`, middleware.ensureLoggedIn, helpers.tryRoute(controllers.admin.categories.removeRemote));
const multer = require('multer'); const multer = require('multer');
const storage = multer.diskStorage({}); const storage = multer.diskStorage({});

View File

@@ -0,0 +1,12 @@
<form type="form">
<div class="mb-3">
<label class="form-label" for="handle">[[admin/manage/categories:handle]]</label>
<input type="text" class="form-control" name="handle" id="handle" placeholder="handle@example.org" />
</div>
<div class="mb-3">
<label class="form-label" for="id">[[admin/manage/categories:id]]</label>
<input type="text" class="form-control" name="id" id="id" placeholder="https://example.org/category/1" />
<p class="form-text">[[admin/manage/categories:alert.add-help]]</p>
</div>
</form>

View File

@@ -24,9 +24,11 @@
</div> </div>
</div> </div>
<div class="flex-shrink-0 d-flex gap-1 align-items-start"> <div class="flex-shrink-0 d-flex gap-1 align-items-start">
<a href="{{{if ./link}}}{./link}{{{else}}}{config.relative_path}/category/{./cid}{{{end}}}" class="btn btn-light btn-sm d-none d-sm-block" target="_blank">[[admin/admin:view]]</a> <a href="{{{if ./link}}}{./link}{{{else}}}{config.relative_path}/category/{encodeURIComponent(./cid)}{{{end}}}" class="btn btn-light btn-sm d-none d-sm-block" target="_blank">[[admin/admin:view]]</a>
{{{ if ./isLocal }}}
<a href="./categories/{./cid}" class="btn btn-light btn-sm d-none d-sm-block">[[admin/manage/categories:edit]]</a> <a href="./categories/{./cid}" class="btn btn-light btn-sm d-none d-sm-block">[[admin/manage/categories:edit]]</a>
{{{ end }}}
<div class="category-tools"> <div class="category-tools">
<button class="btn btn-light btn-sm dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false" type="button"><i class="fa fa-fw fa-gear text-primary"></i></button> <button class="btn btn-light btn-sm dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false" type="button"><i class="fa fa-fw fa-gear text-primary"></i></button>
@@ -35,11 +37,13 @@
<a href="{{{if ./link}}}{./link}{{{else}}}{config.relative_path}/category/{./cid}{{{end}}}" class="dropdown-item rounded-1 d-block d-sm-none" target="_blank" role="menuitem">[[admin/admin:view]]</a> <a href="{{{if ./link}}}{./link}{{{else}}}{config.relative_path}/category/{./cid}{{{end}}}" class="dropdown-item rounded-1 d-block d-sm-none" target="_blank" role="menuitem">[[admin/admin:view]]</a>
</li> </li>
{{{ if ./isLocal }}}
<li><a href="./categories/{./cid}" class="dropdown-item rounded-1 d-block d-sm-none" role="menuitem">[[admin/manage/categories:edit]]</a></li> <li><a href="./categories/{./cid}" class="dropdown-item rounded-1 d-block d-sm-none" role="menuitem">[[admin/manage/categories:edit]]</a></li>
<li><a class="dropdown-item rounded-1" href="./categories/{categories.cid}/analytics" role="menuitem">[[admin/manage/categories:analytics]]</a></li> <li><a class="dropdown-item rounded-1" href="./categories/{categories.cid}/analytics" role="menuitem">[[admin/manage/categories:analytics]]</a></li>
<li><a class="dropdown-item rounded-1" href="{config.relative_path}/admin/manage/privileges/{categories.cid}" role="menuitem">[[admin/manage/categories:privileges]]</a></li> <li><a class="dropdown-item rounded-1" href="{config.relative_path}/admin/manage/privileges/{categories.cid}" role="menuitem">[[admin/manage/categories:privileges]]</a></li>
<li><a class="dropdown-item rounded-1" href="./categories/{categories.cid}/federation" role="menuitem">[[admin/manage/categories:federation]]</a></li> <li><a class="dropdown-item rounded-1" href="./categories/{categories.cid}/federation" role="menuitem">[[admin/manage/categories:federation]]</a></li>
{{{ end }}}
<li><a href="#" class="set-order dropdown-item rounded-1" data-cid="{categories.cid}" data-order="{categories.order}" role="menuitem">[[admin/manage/categories:set-order]]</a></li> <li><a href="#" class="set-order dropdown-item rounded-1" data-cid="{categories.cid}" data-order="{categories.order}" role="menuitem">[[admin/manage/categories:set-order]]</a></li>
<li class="dropdown-divider"></li> <li class="dropdown-divider"></li>
<li> <li>
@@ -51,6 +55,9 @@
{{{end}}} {{{end}}}
</a> </a>
</li> </li>
{{{ if !./isLocal }}}
<li><a class="dropdown-item rounded-1 text-danger" href="#" data-cid="{./cid}" data-action="remove" role="menuitem">[[admin/manage/categories:remove]]</a></li>
{{{ end }}}
</ul> </ul>
</div> </div>
</div> </div>