From cb0b609289ad072d153481afb711c86d8a85b709 Mon Sep 17 00:00:00 2001 From: Julian Lam Date: Tue, 12 Aug 2025 15:38:49 -0400 Subject: [PATCH] refactor: category listing logic to allow remote categories to be added, disabled, and re-arranged in main forum index --- .../en-GB/admin/manage/categories.json | 2 + public/src/admin/manage/categories.js | 20 +++++-- src/api/activitypub.js | 10 ++++ src/categories/index.js | 8 +-- src/categories/update.js | 14 +++-- src/controllers/admin/categories.js | 57 +++++++++++++++++-- src/routes/admin.js | 2 + src/views/admin/partials/categories/add.tpl | 12 ++++ .../partials/categories/category-rows.tpl | 9 ++- 9 files changed, 114 insertions(+), 20 deletions(-) create mode 100644 src/views/admin/partials/categories/add.tpl diff --git a/public/language/en-GB/admin/manage/categories.json b/public/language/en-GB/admin/manage/categories.json index 378421f30c..d66dd814a1 100644 --- a/public/language/en-GB/admin/manage/categories.json +++ b/public/language/en-GB/admin/manage/categories.json @@ -3,11 +3,13 @@ "add-category": "Add category", "add-local-category": "Add Local category", "add-remote-category": "Add Remote category", + "remove": "Remove", "jump-to": "Jump to...", "settings": "Category Settings", "edit-category": "Edit Category", "privileges": "Privileges", "back-to-categories": "Back to categories", + "id": "Category ID", "name": "Category Name", "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.", diff --git a/public/src/admin/manage/categories.js b/public/src/admin/manage/categories.js index c25e44670c..c50edfc3df 100644 --- a/public/src/admin/manage/categories.js +++ b/public/src/admin/manage/categories.js @@ -69,7 +69,7 @@ define('admin/manage/categories', [ if (val && cid) { const modified = {}; 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(); }).catch(alerts.error); } else { @@ -81,6 +81,8 @@ define('admin/manage/categories', [ }); }); + $('.categories').on('click', 'a[data-action="remove"]', Categories.removeCategory); + $('#toggle-collapse-all').on('click', function () { const $this = $(this); const isCollapsed = parseInt($this.attr('data-collapsed'), 10) === 1; @@ -167,8 +169,11 @@ define('admin/manage/categories', [ }); function submit() { - // const formData = modal.find('form').serializeObject(); - modal.modal('hide'); + const formData = modal.find('form').serializeObject(); + api.post('/api/admin/manage/categories', formData).then(() => { + ajaxify.refresh(); + modal.modal('hide'); + }).catch(alerts.error); 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) { api.post('/categories', payload, function (err, data) { if (err) { @@ -212,7 +222,7 @@ define('admin/manage/categories', [ Categories.toggle = function (cids, disabled) { 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, }).then(() => { const categoryEl = listEl.querySelector(`li[data-cid="${cid}"]`); @@ -264,7 +274,7 @@ define('admin/manage/categories', [ } newCategoryId = -1; - api.put('/categories/' + cid, modified[cid]).catch(alerts.error); + api.put('/categories/' + encodeURIComponent(cid), modified[cid]).catch(alerts.error); } } diff --git a/src/api/activitypub.js b/src/api/activitypub.js index 1f074f6776..7e4ae7da18 100644 --- a/src/api/activitypub.js +++ b/src/api/activitypub.js @@ -187,6 +187,11 @@ activitypubApi.create.privateNote = enabledCheck(async (caller, { messageObj }) activitypubApi.update = {}; activitypubApi.update.profile = enabledCheck(async (caller, { uid }) => { + // Local users only + if (!utils.isNumber(uid)) { + return; + } + const [object, targets] = await Promise.all([ activitypub.mocks.actors.user(uid), db.getSortedSetMembers(`followersRemote:${caller.uid}`), @@ -203,6 +208,11 @@ activitypubApi.update.profile = enabledCheck(async (caller, { uid }) => { }); activitypubApi.update.category = enabledCheck(async (caller, { cid }) => { + // Local categories only + if (!utils.isNumber(cid)) { + return; + } + const [object, targets] = await Promise.all([ activitypub.mocks.actors.category(cid), activitypub.notes.getCategoryFollowers(cid), diff --git a/src/categories/index.js b/src/categories/index.js index de7ff6d769..15cabac24e 100644 --- a/src/categories/index.js +++ b/src/categories/index.js @@ -101,7 +101,7 @@ Categories.getAllCidsFromSet = async function (key) { } 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); return cids.slice(); }; @@ -274,7 +274,7 @@ Categories.getChildrenTree = getChildrenTree; Categories.getParentCids = async function (currentCid) { let cid = currentCid; const parents = []; - while (parseInt(cid, 10)) { + while (utils.isNumber(cid) ? parseInt(cid, 10) : cid) { // eslint-disable-next-line cid = await Categories.getCategoryField(cid, 'parentCid'); if (cid) { @@ -289,12 +289,12 @@ Categories.getChildrenCids = async function (rootCid) { async function recursive(keys) { 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) { return; } 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); } const key = `cid:${rootCid}:children`; diff --git a/src/categories/update.js b/src/categories/update.js index 2f2effd96d..bf32317ae2 100644 --- a/src/categories/update.js +++ b/src/categories/update.js @@ -60,7 +60,7 @@ module.exports = function (Categories) { 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') { await Categories.parseDescription(cid, value); } @@ -83,7 +83,7 @@ module.exports = function (Categories) { await Promise.all([ db.sortedSetRemove(`cid:${oldParent}:children`, 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([ @@ -104,8 +104,12 @@ module.exports = function (Categories) { } async function updateOrder(cid, order) { - const parentCid = await Categories.getCategoryField(cid, 'parentCid'); - await db.sortedSetsAdd('categories:cid', order, cid); + const parentCid = (await Categories.getCategoryField(cid, 'parentCid')) || 0; + const isLocal = utils.isNumber(cid); + + if (isLocal) { + await db.sortedSetsAdd('categories:cid', order, cid); + } const childrenCids = await db.getSortedSetRange( `cid:${parentCid}:children`, 0, -1 @@ -128,7 +132,7 @@ module.exports = function (Categories) { ); 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([ diff --git a/src/controllers/admin/categories.js b/src/controllers/admin/categories.js index 7d9eb61a18..95d8376882 100644 --- a/src/controllers/admin/categories.js +++ b/src/controllers/admin/categories.js @@ -12,6 +12,8 @@ const meta = require('../../meta'); const activitypub = require('../../activitypub'); const helpers = require('../helpers'); const pagination = require('../../pagination'); +const utils = require('../../utils'); +const cache = require('../../cache'); const categoriesController = module.exports; @@ -48,14 +50,14 @@ categoriesController.get = async function (req, res, next) { categoriesController.getAll = async function (req, res) { const rootCid = parseInt(req.query.cid, 10) || 0; + const rootChildren = await categories.getAllCidsFromSet(`cid:${rootCid}:children`); async function getRootAndChildren() { - const rootChildren = await categories.getAllCidsFromSet(`cid:${rootCid}:children`); const childCids = _.flatten(await Promise.all(rootChildren.map(cid => categories.getChildrenCids(cid)))); return [rootCid].concat(rootChildren.concat(childCids)); } // 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; if (rootCid) { @@ -67,9 +69,16 @@ categoriesController.getAll = async function (req, res) { 'order', 'color', 'bgColor', 'backgroundImage', 'imageClass', 'subCategoriesPerPage', 'description', ]; - const categoriesData = await categories.getCategoriesFields(cids, fields); - const result = await plugins.hooks.fire('filter:admin.categories.get', { categories: categoriesData, fields: fields }); - let tree = categories.getTree(result.categories, rootParent); + let categoriesData = await categories.getCategoriesFields(cids, fields); + ({ categories: categoriesData } = await plugins.hooks.fire('filter:admin.categories.get', { categories: categoriesData, fields: fields })); + + // 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 pageCount = Math.max(1, Math.ceil(cidsCount / meta.config.categoriesPerPage)); @@ -176,3 +185,41 @@ categoriesController.getFederation = async function (req, res) { 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); +}; diff --git a/src/routes/admin.js b/src/routes/admin.js index b7e751695c..967746b304 100644 --- a/src/routes/admin.js +++ b/src/routes/admin.js @@ -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}/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.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 storage = multer.diskStorage({}); diff --git a/src/views/admin/partials/categories/add.tpl b/src/views/admin/partials/categories/add.tpl new file mode 100644 index 0000000000..05131c050f --- /dev/null +++ b/src/views/admin/partials/categories/add.tpl @@ -0,0 +1,12 @@ +
+
+ + +
+ +
+ + +

[[admin/manage/categories:alert.add-help]]

+
+
\ No newline at end of file diff --git a/src/views/admin/partials/categories/category-rows.tpl b/src/views/admin/partials/categories/category-rows.tpl index 57b3676093..4cef2ae416 100644 --- a/src/views/admin/partials/categories/category-rows.tpl +++ b/src/views/admin/partials/categories/category-rows.tpl @@ -24,9 +24,11 @@
- [[admin/admin:view]] + [[admin/admin:view]] + {{{ if ./isLocal }}} [[admin/manage/categories:edit]] + {{{ end }}}