mirror of
https://github.com/NodeBB/NodeBB.git
synced 2025-12-24 09:20:32 +01:00
Compare commits
7 Commits
renovate/s
...
crossposti
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce8c0d10e8 | ||
|
|
677e01ab39 | ||
|
|
2661c63e1b | ||
|
|
dddc43e0fe | ||
|
|
a98b1ca39d | ||
|
|
a6178a837f | ||
|
|
5ad54531c6 |
@@ -170,6 +170,8 @@
|
||||
"topic-already-deleted": "This topic has already been deleted",
|
||||
"topic-already-restored": "This topic has already been restored",
|
||||
|
||||
"topic-already-crossposted": "This topic has already been cross-posted there.",
|
||||
|
||||
"cant-purge-main-post": "You can't purge the main post, please delete the topic instead",
|
||||
|
||||
"topic-thumbnails-are-disabled": "Topic thumbnails are disabled.",
|
||||
@@ -262,6 +264,7 @@
|
||||
"no-topics-selected": "No topics selected!",
|
||||
"cant-move-to-same-topic": "Can't move post to same topic!",
|
||||
"cant-move-topic-to-same-category": "Can't move topic to the same category!",
|
||||
"cant-move-topic-to-from-remote-categories": "You cannot move topics in or out of remote categories; consider cross-posting instead.",
|
||||
|
||||
"cannot-block-self": "You cannot block yourself!",
|
||||
"cannot-block-privileged": "You cannot block administrators or global moderators",
|
||||
|
||||
@@ -116,6 +116,7 @@
|
||||
"thread-tools.lock": "Lock Topic",
|
||||
"thread-tools.unlock": "Unlock Topic",
|
||||
"thread-tools.move": "Move Topic",
|
||||
"thread-tools.crosspost": "Crosspost Topic",
|
||||
"thread-tools.move-posts": "Move Posts",
|
||||
"thread-tools.move-all": "Move All",
|
||||
"thread-tools.change-owner": "Change Owner",
|
||||
@@ -149,6 +150,7 @@
|
||||
|
||||
"load-categories": "Loading Categories",
|
||||
"confirm-move": "Move",
|
||||
"confirm-crosspost": "Cross-post",
|
||||
"confirm-fork": "Fork",
|
||||
|
||||
"bookmark": "Bookmark",
|
||||
@@ -161,6 +163,7 @@
|
||||
"loading-more-posts": "Loading More Posts",
|
||||
"move-topic": "Move Topic",
|
||||
"move-topics": "Move Topics",
|
||||
"crosspost-topic": "Cross-post Topic",
|
||||
"move-post": "Move Post",
|
||||
"post-moved": "Post moved!",
|
||||
"fork-topic": "Fork Topic",
|
||||
@@ -181,6 +184,7 @@
|
||||
"topic-id": "Topic ID",
|
||||
"move-posts-instruction": "Click the posts you want to move then enter a topic ID or go to the target topic",
|
||||
"move-topic-instruction": "Select the target category and then click move",
|
||||
"crosspost-topic-instruction": "Select one or more categories to cross-post to. Topic(s) will be accessible from the original category and all cross-posted categories.",
|
||||
"change-owner-instruction": "Click the posts you want to assign to another user",
|
||||
"manage-editors-instruction": "Manage the users who can edit this post below.",
|
||||
|
||||
|
||||
34
public/openapi/components/schemas/CrosspostObject.yaml
Normal file
34
public/openapi/components/schemas/CrosspostObject.yaml
Normal file
@@ -0,0 +1,34 @@
|
||||
CrosspostObject:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
description: The cross-post ID
|
||||
cid:
|
||||
type: object
|
||||
description: The category id that the topic was cross-posted to
|
||||
additionalProperties:
|
||||
oneOf:
|
||||
- type: string
|
||||
- type: number
|
||||
tid:
|
||||
type: object
|
||||
description: The topic id that was cross-posted
|
||||
additionalProperties:
|
||||
oneOf:
|
||||
- type: string
|
||||
- type: number
|
||||
timestamp:
|
||||
type: number
|
||||
uid:
|
||||
type: object
|
||||
description: The user id that initiated the cross-post
|
||||
additionalProperties:
|
||||
oneOf:
|
||||
- type: string
|
||||
- type: number
|
||||
CrosspostsArray:
|
||||
type: array
|
||||
description: A list of crosspost objects
|
||||
items:
|
||||
$ref: '#/CrosspostObject'
|
||||
@@ -168,6 +168,8 @@ paths:
|
||||
$ref: 'write/topics/tid/bump.yaml'
|
||||
/topics/{tid}/move:
|
||||
$ref: 'write/topics/tid/move.yaml'
|
||||
/topics/{tid}/crossposts:
|
||||
$ref: 'write/topics/tid/crossposts.yaml'
|
||||
/tags/{tag}/follow:
|
||||
$ref: 'write/tags/tag/follow.yaml'
|
||||
/posts/{pid}:
|
||||
|
||||
@@ -86,7 +86,6 @@ put:
|
||||
privileges:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: boolean
|
||||
description: A set of privileges with either true or false
|
||||
types:
|
||||
type: object
|
||||
@@ -103,7 +102,6 @@ put:
|
||||
privileges:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: boolean
|
||||
description: A set of privileges with either true or false
|
||||
types:
|
||||
type: object
|
||||
|
||||
@@ -47,7 +47,6 @@ get:
|
||||
privileges:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: boolean
|
||||
description: A set of privileges with either true or false
|
||||
isPrivate:
|
||||
type: boolean
|
||||
@@ -65,7 +64,6 @@ get:
|
||||
privileges:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: boolean
|
||||
description: A set of privileges with either true or false
|
||||
types:
|
||||
type: object
|
||||
|
||||
@@ -93,7 +93,6 @@ put:
|
||||
privileges:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: boolean
|
||||
description: A set of privileges with either true or false
|
||||
groups:
|
||||
type: array
|
||||
@@ -107,7 +106,6 @@ put:
|
||||
privileges:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: boolean
|
||||
description: A set of privileges with either true or false
|
||||
types:
|
||||
type: object
|
||||
@@ -230,7 +228,6 @@ delete:
|
||||
privileges:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: boolean
|
||||
description: A set of privileges with either true or false
|
||||
groups:
|
||||
type: array
|
||||
@@ -244,7 +241,6 @@ delete:
|
||||
privileges:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: boolean
|
||||
description: A set of privileges with either true or false
|
||||
types:
|
||||
type: object
|
||||
|
||||
@@ -71,5 +71,4 @@ get:
|
||||
privileges:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: boolean
|
||||
description: A set of privileges with either true or false
|
||||
76
public/openapi/write/topics/tid/crossposts.yaml
Normal file
76
public/openapi/write/topics/tid/crossposts.yaml
Normal file
@@ -0,0 +1,76 @@
|
||||
post:
|
||||
tags:
|
||||
- topics
|
||||
summary: crosspost a topic
|
||||
description: This operation crossposts a topic to another category.
|
||||
parameters:
|
||||
- in: path
|
||||
name: tid
|
||||
schema:
|
||||
type: string
|
||||
required: true
|
||||
description: a valid topic id
|
||||
example: 1
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
cid:
|
||||
type: number
|
||||
example: 1
|
||||
responses:
|
||||
'200':
|
||||
description: Topic successfully crossposted
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
status:
|
||||
$ref: ../../../components/schemas/Status.yaml#/Status
|
||||
response:
|
||||
type: object
|
||||
properties:
|
||||
crossposts:
|
||||
$ref: ../../../components/schemas/CrosspostObject.yaml#/CrosspostsArray
|
||||
delete:
|
||||
tags:
|
||||
- topics
|
||||
summary: uncrossposts a topic
|
||||
description: This operation uncrossposts a topic from a category.
|
||||
parameters:
|
||||
- in: path
|
||||
name: tid
|
||||
schema:
|
||||
type: string
|
||||
required: true
|
||||
description: a valid topic id
|
||||
example: 1
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
cid:
|
||||
type: number
|
||||
example: 1
|
||||
responses:
|
||||
'200':
|
||||
description: Topic successfully uncrossposted
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
status:
|
||||
$ref: ../../../components/schemas/Status.yaml#/Status
|
||||
response:
|
||||
type: object
|
||||
properties:
|
||||
crossposts:
|
||||
$ref: ../../../components/schemas/CrosspostObject.yaml#/CrosspostsArray
|
||||
@@ -34,6 +34,7 @@ define('forum/topic/move', [
|
||||
categorySelector.init(dropdownEl, {
|
||||
onSelect: onCategorySelected,
|
||||
privilege: 'moderate',
|
||||
localOnly: true,
|
||||
});
|
||||
|
||||
modal.find('#move_thread_commit').on('click', onCommitClicked);
|
||||
|
||||
@@ -116,6 +116,12 @@ define('forum/topic/threadTools', [
|
||||
return false;
|
||||
});
|
||||
|
||||
topicContainer.on('click', '[component="topic/crosspost"]', () => {
|
||||
require(['forum/topic/crosspost'], (crosspost) => {
|
||||
crosspost.init(tid, ajaxify.data.cid);
|
||||
});
|
||||
});
|
||||
|
||||
topicContainer.on('click', '[component="topic/delete/posts"]', function () {
|
||||
require(['forum/topic/delete-posts'], function (deletePosts) {
|
||||
deletePosts.init();
|
||||
|
||||
@@ -76,6 +76,8 @@ define('categorySearch', ['alerts', 'bootstrap', 'api'], function (alerts, boots
|
||||
privilege: options.privilege,
|
||||
states: options.states,
|
||||
showLinks: options.showLinks,
|
||||
localOnly: options.localOnly,
|
||||
hideUncategorized: options.hideUncategorized,
|
||||
}, function (err, { categories }) {
|
||||
if (err) {
|
||||
return alerts.error(err);
|
||||
@@ -93,6 +95,7 @@ define('categorySearch', ['alerts', 'bootstrap', 'api'], function (alerts, boots
|
||||
categoryItems: categories.slice(0, 200),
|
||||
selectedCategory: ajaxify.data.selectedCategory,
|
||||
allCategoriesUrl: ajaxify.data.allCategoriesUrl,
|
||||
hideAll: options.hideAll,
|
||||
}, function (html) {
|
||||
el.find('[component="category/list"]')
|
||||
.html(html.find('[component="category/list"]').html());
|
||||
|
||||
@@ -9,6 +9,7 @@ const messaging = require('../messaging');
|
||||
const privileges = require('../privileges');
|
||||
const meta = require('../meta');
|
||||
const plugins = require('../plugins');
|
||||
const utils = require('../utils');
|
||||
|
||||
const controllersHelpers = require('../controllers/helpers');
|
||||
|
||||
@@ -29,9 +30,12 @@ searchApi.categories = async (caller, data) => {
|
||||
({ cids, matchedCids } = await findMatchedCids(caller.uid, data));
|
||||
} else {
|
||||
cids = await loadCids(caller.uid, data.parentCid);
|
||||
if (meta.config.activitypubEnabled) {
|
||||
if (!data.hideUncategorized && meta.config.activitypubEnabled) {
|
||||
cids.unshift(-1);
|
||||
}
|
||||
if (data.localOnly) {
|
||||
cids = cids.filter(cid => utils.isNumber(cid));
|
||||
}
|
||||
}
|
||||
|
||||
const visibleCategories = await controllersHelpers.getVisibleCategories({
|
||||
@@ -66,6 +70,7 @@ async function findMatchedCids(uid, data) {
|
||||
query: data.search,
|
||||
qs: data.query,
|
||||
paginate: false,
|
||||
localOnly: data.localOnly,
|
||||
});
|
||||
|
||||
let matchedCids = result.categories.map(c => c.cid);
|
||||
|
||||
@@ -6,12 +6,14 @@ const privileges = require('../privileges');
|
||||
const activitypub = require('../activitypub');
|
||||
const plugins = require('../plugins');
|
||||
const db = require('../database');
|
||||
const utils = require('../utils');
|
||||
|
||||
module.exports = function (Categories) {
|
||||
Categories.search = async function (data) {
|
||||
const query = data.query || '';
|
||||
const page = data.page || 1;
|
||||
const uid = data.uid || 0;
|
||||
const localOnly = data.localOnly || false;
|
||||
const paginate = data.hasOwnProperty('paginate') ? data.paginate : true;
|
||||
|
||||
const startTime = process.hrtime();
|
||||
@@ -21,6 +23,9 @@ module.exports = function (Categories) {
|
||||
}
|
||||
|
||||
let cids = await findCids(query, data.hardCap);
|
||||
if (localOnly) {
|
||||
cids = cids.filter(cid => utils.isNumber(cid));
|
||||
}
|
||||
|
||||
const result = await plugins.hooks.fire('filter:categories.search', {
|
||||
data: data,
|
||||
|
||||
@@ -123,8 +123,9 @@ topicsController.get = async function getTopic(req, res, next) {
|
||||
p => parseInt(p.index, 10) === parseInt(Math.max(0, postIndex - 1), 10)
|
||||
);
|
||||
|
||||
const [author] = await Promise.all([
|
||||
const [author, crossposts] = await Promise.all([
|
||||
user.getUserFields(topicData.uid, ['username', 'userslug']),
|
||||
topics.crossposts.get(topicData.tid),
|
||||
buildBreadcrumbs(topicData),
|
||||
addOldCategory(topicData, userPrivileges),
|
||||
addTags(topicData, req, res, currentPage, postAtIndex),
|
||||
@@ -134,6 +135,7 @@ topicsController.get = async function getTopic(req, res, next) {
|
||||
]);
|
||||
|
||||
topicData.author = author;
|
||||
topicData.crossposts = crossposts;
|
||||
topicData.pagination = pagination.create(currentPage, pageCount, req.query);
|
||||
topicData.pagination.rel.forEach((rel) => {
|
||||
rel.href = `${url}/topic/${topicData.slug}${rel.href}`;
|
||||
|
||||
@@ -213,3 +213,17 @@ Topics.move = async (req, res) => {
|
||||
|
||||
helpers.formatApiResponse(200, res);
|
||||
};
|
||||
|
||||
Topics.crosspost = async (req, res) => {
|
||||
const { cid } = req.body;
|
||||
const crossposts = await topics.crossposts.add(req.params.tid, cid, req.uid);
|
||||
|
||||
helpers.formatApiResponse(200, res, { crossposts });
|
||||
};
|
||||
|
||||
Topics.uncrosspost = async (req, res) => {
|
||||
const { cid } = req.body;
|
||||
const crossposts = await topics.crossposts.remove(req.params.tid, cid, req.uid);
|
||||
|
||||
helpers.formatApiResponse(200, res, { crossposts });
|
||||
};
|
||||
|
||||
@@ -54,5 +54,8 @@ module.exports = function () {
|
||||
|
||||
setupApiRoute(router, 'put', '/:tid/move', [...middlewares, middleware.assert.topic], controllers.write.topics.move);
|
||||
|
||||
setupApiRoute(router, 'post', '/:tid/crossposts', [...middlewares, middleware.assert.topic], controllers.write.topics.crosspost);
|
||||
setupApiRoute(router, 'delete', '/:tid/crossposts', [...middlewares, middleware.assert.topic], controllers.write.topics.uncrosspost);
|
||||
|
||||
return router;
|
||||
};
|
||||
|
||||
127
src/topics/crossposts.js
Normal file
127
src/topics/crossposts.js
Normal file
@@ -0,0 +1,127 @@
|
||||
'use strict';
|
||||
|
||||
const db = require('../database');
|
||||
const topics = require('.');
|
||||
const categories = require('../categories');
|
||||
const posts = require('../posts');
|
||||
const activitypub = require('../activitypub');
|
||||
const utils = require('../utils');
|
||||
|
||||
const Crossposts = module.exports;
|
||||
|
||||
Crossposts.get = async function (tid) {
|
||||
const crosspostIds = await db.getSortedSetMembers(`tid:${tid}:crossposts`);
|
||||
let crossposts = await db.getObjects(crosspostIds.map(id => `crosspost:${id}`));
|
||||
const cids = crossposts.reduce((cids, crossposts) => {
|
||||
cids.add(crossposts.cid);
|
||||
return cids;
|
||||
}, new Set());
|
||||
let categoriesData = await categories.getCategoriesFields(
|
||||
cids, ['cid', 'name', 'icon', 'bgColor', 'color', 'slug']
|
||||
);
|
||||
categoriesData = categoriesData.reduce((map, category) => {
|
||||
map.set(parseInt(category.cid, 10), category);
|
||||
return map;
|
||||
}, new Map());
|
||||
crossposts = crossposts.map((crosspost, idx) => {
|
||||
crosspost.id = crosspostIds[idx];
|
||||
crosspost.category = categoriesData.get(parseInt(crosspost.cid, 10));
|
||||
return crosspost;
|
||||
});
|
||||
|
||||
return crossposts;
|
||||
};
|
||||
|
||||
Crossposts.add = async function (tid, cid, uid) {
|
||||
// Target cid must exist
|
||||
if (!utils.isNumber(cid)) {
|
||||
await activitypub.actors.assert(cid);
|
||||
}
|
||||
const exists = await categories.exists(cid);
|
||||
if (!exists) {
|
||||
throw new Error('[[error:invalid-cid]]');
|
||||
}
|
||||
|
||||
const crossposts = await Crossposts.get(tid);
|
||||
const crosspostedCids = crossposts.map(crosspost => String(crosspost.cid));
|
||||
const now = Date.now();
|
||||
const crosspostId = utils.generateUUID();
|
||||
if (!crosspostedCids.includes(String(cid))) {
|
||||
const [topicData, pids] = await Promise.all([
|
||||
topics.getTopicFields(tid, ['uid', 'cid', 'timestamp']),
|
||||
topics.getPids(tid),
|
||||
]);
|
||||
let pidTimestamps = await posts.getPostsFields(pids, ['timestamp']);
|
||||
pidTimestamps = pidTimestamps.map(({ timestamp }) => timestamp);
|
||||
|
||||
if (cid === topicData.cid) {
|
||||
throw new Error('[[error:invalid-cid]]');
|
||||
}
|
||||
const zsets = [
|
||||
`cid:${topicData.cid}:tids`,
|
||||
`cid:${topicData.cid}:tids:create`,
|
||||
`cid:${topicData.cid}:tids:lastposttime`,
|
||||
`cid:${topicData.cid}:uid:${topicData.uid}:tids`,
|
||||
`cid:${topicData.cid}:tids:votes`,
|
||||
`cid:${topicData.cid}:tids:posts`,
|
||||
`cid:${topicData.cid}:tids:views`,
|
||||
];
|
||||
const scores = await db.sortedSetsScore(zsets, tid);
|
||||
const bulkAdd = zsets.map((zset, idx) => {
|
||||
return [zset.replace(`cid:${topicData.cid}`, `cid:${cid}`), scores[idx], tid];
|
||||
});
|
||||
await Promise.all([
|
||||
db.sortedSetAddBulk(bulkAdd),
|
||||
db.sortedSetAdd(`cid:${cid}:pids`, pidTimestamps, pids),
|
||||
db.setObject(`crosspost:${crosspostId}`, { uid, tid, cid, timestamp: now }),
|
||||
db.sortedSetAdd(`tid:${tid}:crossposts`, now, crosspostId),
|
||||
db.sortedSetAdd(`uid:${uid}:crossposts`, now, crosspostId),
|
||||
]);
|
||||
await categories.onTopicsMoved([cid]);
|
||||
} else {
|
||||
throw new Error('[[error:topic-already-crossposted]]');
|
||||
}
|
||||
|
||||
return [...crossposts, { id: crosspostId, uid, tid, cid, timestamp: now }];
|
||||
};
|
||||
|
||||
Crossposts.remove = async function (tid, cid, uid) {
|
||||
let crossposts = await Crossposts.get(tid);
|
||||
const crosspostId = crossposts.reduce((id, { id: _id, cid: _cid, uid: _uid }) => {
|
||||
if (String(cid) === String(_cid) && String(uid) === String(_uid)) {
|
||||
id = _id;
|
||||
}
|
||||
|
||||
return id;
|
||||
}, null);
|
||||
if (!crosspostId) {
|
||||
throw new Error('[[error:invalid-data]]');
|
||||
}
|
||||
|
||||
const [author, pids] = await Promise.all([
|
||||
topics.getTopicField(tid, 'uid'),
|
||||
topics.getPids(tid),
|
||||
]);
|
||||
let bulkRemove = [
|
||||
`cid:${cid}:tids`,
|
||||
`cid:${cid}:tids:create`,
|
||||
`cid:${cid}:tids:lastposttime`,
|
||||
`cid:${cid}:uid:${author}:tids`,
|
||||
`cid:${cid}:tids:votes`,
|
||||
`cid:${cid}:tids:posts`,
|
||||
`cid:${cid}:tids:views`,
|
||||
];
|
||||
bulkRemove = bulkRemove.map(zset => [zset, tid]);
|
||||
bulkRemove.push([`cid:${cid}:pids`, pids]);
|
||||
|
||||
await Promise.all([
|
||||
db.sortedSetRemoveBulk(bulkRemove),
|
||||
db.delete(`crosspost:${crosspostId}`),
|
||||
db.sortedSetRemove(`tid:${tid}:crossposts`, crosspostId),
|
||||
db.sortedSetRemove(`uid:${uid}:crossposts`, crosspostId),
|
||||
]);
|
||||
await categories.onTopicsMoved([cid]);
|
||||
|
||||
crossposts = await Crossposts.get(tid);
|
||||
return crossposts;
|
||||
};
|
||||
@@ -35,6 +35,7 @@ Topics.thumbs = require('./thumbs');
|
||||
require('./bookmarks')(Topics);
|
||||
require('./merge')(Topics);
|
||||
Topics.events = require('./events');
|
||||
Topics.crossposts = require('./crossposts');
|
||||
|
||||
Topics.exists = async function (tids) {
|
||||
return await db.exists(
|
||||
|
||||
@@ -5,9 +5,11 @@ const _ = require('lodash');
|
||||
const db = require('../database');
|
||||
const topics = require('.');
|
||||
const categories = require('../categories');
|
||||
const posts = require('../posts');
|
||||
const user = require('../user');
|
||||
const plugins = require('../plugins');
|
||||
const privileges = require('../privileges');
|
||||
const activitypub = require('../activitypub');
|
||||
const utils = require('../utils');
|
||||
|
||||
|
||||
@@ -233,7 +235,7 @@ module.exports = function (Topics) {
|
||||
};
|
||||
|
||||
topicTools.move = async function (tid, data) {
|
||||
const cid = utils.isNumber(data.cid) ? parseInt(data.cid, 10) : data.cid;
|
||||
const cid = parseInt(data.cid, 10);
|
||||
const topicData = await Topics.getTopicData(tid);
|
||||
if (!topicData) {
|
||||
throw new Error('[[error:no-topic]]');
|
||||
@@ -241,6 +243,10 @@ module.exports = function (Topics) {
|
||||
if (cid === topicData.cid) {
|
||||
throw new Error('[[error:cant-move-topic-to-same-category]]');
|
||||
}
|
||||
if (!utils.isNumber(cid) || !utils.isNumber(topicData.cid)) {
|
||||
throw new Error('[[error:cant-move-topic-to-from-remote-categories]]');
|
||||
}
|
||||
|
||||
const tags = await Topics.getTopicTags(tid);
|
||||
await db.sortedSetsRemove([
|
||||
`cid:${topicData.cid}:tids`,
|
||||
|
||||
@@ -16,12 +16,14 @@
|
||||
</div>
|
||||
|
||||
<ul component="category/list" class="list-unstyled mb-0 text-sm category-dropdown-menu ghost-scrollbar" role="menu">
|
||||
{{{ if !hideAll }}}
|
||||
<li role="presentation" class="category" data-cid="all">
|
||||
<a class="dropdown-item rounded-1 d-flex align-items-center gap-2" role="menuitem" href="{{{ if allCategoriesUrl }}}{config.relative_path}/{allCategoriesUrl}{{{ else }}}#{{{ end }}}">
|
||||
<div class="flex-grow-1">[[unread:all-categories]]</div>
|
||||
<i component="category/select/icon" class="flex-shrink-0 fa fa-fw fa-check {{{if selectedCategory}}}invisible{{{end}}}"></i>
|
||||
</a>
|
||||
</li>
|
||||
{{{ end }}}
|
||||
{{{each categoryItems}}}
|
||||
<li role="presentation" class="category {{{ if ./disabledClass }}}disabled{{{ end }}}" data-cid="{./cid}" data-parent-cid="{./parentCid}" data-name="{./name}">
|
||||
<a class="dropdown-item rounded-1 d-flex align-items-center gap-2 {{{ if ./disabledClass }}}disabled{{{ end }}}" role="menuitem" href="#">
|
||||
|
||||
@@ -15,9 +15,15 @@
|
||||
<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>
|
||||
|
||||
{{{ if isNumber(cid) }}}
|
||||
<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>
|
||||
{{{ end }}}
|
||||
|
||||
<li>
|
||||
<a component="topic/crosspost" href="#" class="dropdown-item rounded-1 d-flex align-items-center gap-2" role="menuitem"><i class="fa fa-fw fa-clone text-secondary"></i> [[topic:thread-tools.crosspost]]</a>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<a component="topic/merge" href="#" class="dropdown-item rounded-1 d-flex align-items-center gap-2" role="menuitem"><i class="fa fa-fw fa-code-fork text-secondary"></i> [[topic:thread-tools.merge]]</a>
|
||||
|
||||
@@ -659,10 +659,15 @@ describe('API', async () => {
|
||||
case 'boolean':
|
||||
assert.strictEqual(typeof response[prop], 'boolean', `"${prop}" was expected to be a boolean, but was ${typeof response[prop]} instead (path: ${method} ${path}, context: ${context})`);
|
||||
break;
|
||||
case 'object':
|
||||
assert.strictEqual(typeof response[prop], 'object', `"${prop}" was expected to be an object, but was ${typeof response[prop]} instead (path: ${method} ${path}, context: ${context})`);
|
||||
case 'object': {
|
||||
let valid = ['object'];
|
||||
if (schema[prop].additionalProperties && schema[prop].additionalProperties.oneOf) {
|
||||
valid = schema[prop].additionalProperties.oneOf.map(({ type }) => type);
|
||||
}
|
||||
assert(valid.includes(typeof response[prop]), `"${prop}" was expected to be an object, but was ${typeof response[prop]} instead (path: ${method} ${path}, context: ${context})`);
|
||||
compare(schema[prop], response[prop], method, path, context ? [context, prop].join('.') : prop);
|
||||
break;
|
||||
}
|
||||
case 'array':
|
||||
assert.strictEqual(Array.isArray(response[prop]), true, `"${prop}" was expected to be an array, but was ${typeof response[prop]} instead (path: ${method} ${path}, context: ${context})`);
|
||||
|
||||
|
||||
160
test/topics/crossposts.js
Normal file
160
test/topics/crossposts.js
Normal file
@@ -0,0 +1,160 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('assert');
|
||||
|
||||
const db = require('../mocks/databasemock');
|
||||
|
||||
const user = require('../../src/user');
|
||||
const categories = require('../../src/categories');
|
||||
const topics = require('../../src/topics');
|
||||
const utils = require('../../src/utils');
|
||||
|
||||
describe('Crossposting (& related logic)', () => {
|
||||
describe('topic already in multiple categories', () => {
|
||||
let tid;
|
||||
let cid1;
|
||||
let cid2;
|
||||
let uid;
|
||||
|
||||
before(async () => {
|
||||
({ cid: cid1 } = await categories.create({ name: utils.generateUUID().slice(0, 8) }));
|
||||
uid = await user.create({ username: utils.generateUUID().slice(0, 8) });
|
||||
const { topicData } = await topics.post({
|
||||
uid,
|
||||
cid: cid1,
|
||||
title: utils.generateUUID(),
|
||||
content: utils.generateUUID(),
|
||||
});
|
||||
tid = topicData.tid;
|
||||
|
||||
// Add topic to another category's zset
|
||||
const crosspostCategory = await categories.create({ name: utils.generateUUID().slice(0, 8) });
|
||||
cid2 = crosspostCategory.cid;
|
||||
await db.sortedSetAdd(`cid:${crosspostCategory.cid}:tids`, topicData.timestamp, tid);
|
||||
});
|
||||
|
||||
it('should contain the topic in both categories when requested', async () => {
|
||||
const tids1 = await categories.getTopicIds({
|
||||
uid,
|
||||
cid: cid1,
|
||||
start: 0,
|
||||
stop: 1,
|
||||
});
|
||||
|
||||
const tids2 = await categories.getTopicIds({
|
||||
uid,
|
||||
cid: cid2,
|
||||
start: 0,
|
||||
stop: 1,
|
||||
});
|
||||
|
||||
assert.deepStrictEqual(tids1, tids2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('crosspost', () => {
|
||||
let tid;
|
||||
let cid1;
|
||||
let cid2;
|
||||
let uid;
|
||||
|
||||
before(async () => {
|
||||
({ cid: cid1 } = await categories.create({ name: utils.generateUUID().slice(0, 8) }));
|
||||
const crosspostCategory = await categories.create({ name: utils.generateUUID().slice(0, 8) });
|
||||
cid2 = crosspostCategory.cid;
|
||||
uid = await user.create({ username: utils.generateUUID().slice(0, 8) });
|
||||
const { topicData } = await topics.post({
|
||||
uid,
|
||||
cid: cid1,
|
||||
title: utils.generateUUID(),
|
||||
content: utils.generateUUID(),
|
||||
});
|
||||
tid = topicData.tid;
|
||||
});
|
||||
|
||||
it('should successfully crosspost to another cid', async () => {
|
||||
const crossposts = await topics.crossposts.add(tid, cid2, uid);
|
||||
|
||||
assert(Array.isArray(crossposts));
|
||||
assert.strictEqual(crossposts.length, 1);
|
||||
assert.partialDeepStrictEqual(crossposts[0], {
|
||||
uid,
|
||||
tid,
|
||||
cid: cid2,
|
||||
});
|
||||
});
|
||||
|
||||
it('should show the tid in both categories when requested', async () => {
|
||||
const tids1 = await categories.getTopicIds({
|
||||
uid,
|
||||
cid: cid1,
|
||||
start: 0,
|
||||
stop: 1,
|
||||
});
|
||||
|
||||
const tids2 = await categories.getTopicIds({
|
||||
uid,
|
||||
cid: cid2,
|
||||
start: 0,
|
||||
stop: 1,
|
||||
});
|
||||
|
||||
assert.deepStrictEqual(tids1, tids2);
|
||||
});
|
||||
|
||||
it('should throw on cross-posting again when already cross-posted', async () => {
|
||||
await assert.rejects(
|
||||
topics.crossposts.add(tid, cid2, uid),
|
||||
{ message: '[[error:topic-already-crossposted]]' },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('uncrosspost', () => {
|
||||
let tid;
|
||||
let cid1;
|
||||
let cid2;
|
||||
let uid;
|
||||
|
||||
before(async () => {
|
||||
({ cid: cid1 } = await categories.create({ name: utils.generateUUID().slice(0, 8) }));
|
||||
const crosspostCategory = await categories.create({ name: utils.generateUUID().slice(0, 8) });
|
||||
cid2 = crosspostCategory.cid;
|
||||
uid = await user.create({ username: utils.generateUUID().slice(0, 8) });
|
||||
const { topicData } = await topics.post({
|
||||
uid,
|
||||
cid: cid1,
|
||||
title: utils.generateUUID(),
|
||||
content: utils.generateUUID(),
|
||||
});
|
||||
tid = topicData.tid;
|
||||
|
||||
await topics.crossposts.add(tid, cid2, uid);
|
||||
});
|
||||
|
||||
it('should successfully uncrosspost from a cid', async () => {
|
||||
const crossposts = await topics.crossposts.remove(tid, cid2, uid);
|
||||
|
||||
assert(Array.isArray(crossposts));
|
||||
assert.strictEqual(crossposts.length, 0);
|
||||
});
|
||||
|
||||
it('should not contain the topic in the category the topic was uncrossposted from', async () => {
|
||||
const tids = await categories.getTopicIds({
|
||||
uid,
|
||||
cid: cid2,
|
||||
start: 0,
|
||||
stop: 1,
|
||||
});
|
||||
|
||||
assert(!tids.includes(tid));
|
||||
});
|
||||
|
||||
it('should throw on uncrossposting if already uncrossposted', async () => {
|
||||
assert.rejects(
|
||||
topics.crossposts.remove(tid, cid2, uid),
|
||||
'[[error:invalid-data]]',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
109
test/topics/tools.js
Normal file
109
test/topics/tools.js
Normal file
@@ -0,0 +1,109 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('assert');
|
||||
|
||||
const db = require('../mocks/databasemock');
|
||||
|
||||
const user = require('../../src/user');
|
||||
const categories = require('../../src/categories');
|
||||
const topics = require('../../src/topics');
|
||||
const utils = require('../../src/utils');
|
||||
|
||||
describe('Topic tools', () => {
|
||||
describe('Topic moving', () => {
|
||||
let cid1;
|
||||
let cid2;
|
||||
let tid;
|
||||
let uid;
|
||||
|
||||
before(async () => {
|
||||
({ cid: cid1 } = await categories.create({ name: utils.generateUUID().slice(0, 8) }));
|
||||
({ cid: cid2 } = await categories.create({ name: utils.generateUUID().slice(0, 8) }));
|
||||
|
||||
uid = await user.create({ username: utils.generateUUID().slice(0, 8) });
|
||||
const { topicData } = await topics.post({
|
||||
uid,
|
||||
cid: cid1,
|
||||
title: utils.generateUUID(),
|
||||
content: utils.generateUUID(),
|
||||
});
|
||||
tid = topicData.tid;
|
||||
});
|
||||
|
||||
it('should not error when moving a topic from one cid to another', async () => {
|
||||
await topics.tools.move(tid, {
|
||||
cid: cid2,
|
||||
uid,
|
||||
});
|
||||
});
|
||||
|
||||
it('should reflect the topic in the new category', async () => {
|
||||
const tids = await categories.getTopicIds({
|
||||
uid,
|
||||
cid: cid2,
|
||||
start: 0,
|
||||
stop: 1,
|
||||
});
|
||||
|
||||
assert(Array.isArray(tids));
|
||||
assert.deepStrictEqual(tids, [String(tid)]);
|
||||
});
|
||||
|
||||
it('should NOT reflect the topic in the old category', async () => {
|
||||
const tids = await categories.getTopicIds({
|
||||
uid,
|
||||
cid: cid1,
|
||||
start: 0,
|
||||
stop: 1,
|
||||
});
|
||||
|
||||
assert(Array.isArray(tids));
|
||||
assert.deepStrictEqual(tids, []);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with remote categories', () => {
|
||||
let remoteCid;
|
||||
let localCid;
|
||||
let tid1;
|
||||
let tid2;
|
||||
|
||||
before(async () => {
|
||||
const helpers = require('../activitypub/helpers');
|
||||
({ id: remoteCid } = helpers.mocks.group());
|
||||
({ cid: localCid } = await categories.create({ name: utils.generateUUID().slice(0, 8) }));
|
||||
|
||||
({ id: tid1 } = helpers.mocks.note({
|
||||
audience: remoteCid,
|
||||
}));
|
||||
const uid = await user.create({ username: utils.generateUUID().slice(0, 8) });
|
||||
const { topicData } = await topics.post({
|
||||
uid,
|
||||
cid: localCid,
|
||||
title: utils.generateUUID(),
|
||||
content: utils.generateUUID(),
|
||||
});
|
||||
tid2 = topicData.tid;
|
||||
});
|
||||
|
||||
it('should throw when attempting to move a topic from a remote category', async () => {
|
||||
assert.rejects(
|
||||
topics.tools.move(tid1, {
|
||||
cid: localCid,
|
||||
uid: 'system',
|
||||
}),
|
||||
'[[error:cant-move-topic-to-from-remote-categories]]'
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw when attempting to move a topic to a remote category', async () => {
|
||||
assert.rejects(
|
||||
topics.tools.move(tid2, {
|
||||
cid: remoteCid,
|
||||
uid: 'system',
|
||||
}),
|
||||
'[[error:cant-move-topic-to-from-remote-categories]]'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user