diff --git a/wcfsetup/install/files/acp/templates/categoryNodeTreeView.tpl b/wcfsetup/install/files/acp/templates/categoryNodeTreeView.tpl
new file mode 100644
index 0000000000..0bf0cb63ec
--- /dev/null
+++ b/wcfsetup/install/files/acp/templates/categoryNodeTreeView.tpl
@@ -0,0 +1,27 @@
+{include file='header'}
+
+
+
+
+ {unsafe:$nodeTreeView->render()}
+
+
+{include file='footer'}
diff --git a/wcfsetup/install/files/lib/acp/form/CategoryAddFormBuilderForm.class.php b/wcfsetup/install/files/lib/acp/form/CategoryAddFormBuilderForm.class.php
index 3e235b41a9..9181ed465a 100644
--- a/wcfsetup/install/files/lib/acp/form/CategoryAddFormBuilderForm.class.php
+++ b/wcfsetup/install/files/lib/acp/form/CategoryAddFormBuilderForm.class.php
@@ -82,6 +82,12 @@ abstract class CategoryAddFormBuilderForm extends AbstractFormBuilderForm
*/
public int $packageID = 0;
+ /**
+ * id of the pre-selected parent category, read from the request
+ * @since 6.3
+ */
+ public ?int $parentCategoryID = null;
+
/**
* language item with the page title
* @deprecated 6.3 No longer in use.
@@ -157,6 +163,14 @@ public function readParameters()
if ($this->formAction !== 'create') {
$this->readFormObject();
}
+
+ if (isset($_GET['parentCategoryID'])) {
+ $parentCategoryID = \intval($_GET['parentCategoryID']);
+ $category = CategoryHandler::getInstance()->getCategory($parentCategoryID);
+ if ($category !== null && $category->objectTypeID === $this->objectType->getObjectID()) {
+ $this->parentCategoryID = $parentCategoryID;
+ }
+ }
}
#[\Override]
@@ -265,6 +279,7 @@ protected function getPositionFormFields(): array
)
->options($categoryNodeTree, true)
->available((bool)$this->getObjectTypeProcessor()->getMaximumNestingLevel())
+ ->value($this->parentCategoryID)
->addValidator(
new FormFieldValidator(
'recursion',
diff --git a/wcfsetup/install/files/lib/acp/page/AbstractCategoryListPage.class.php b/wcfsetup/install/files/lib/acp/page/AbstractCategoryListPage.class.php
index 405ccff0e1..bad93335b8 100644
--- a/wcfsetup/install/files/lib/acp/page/AbstractCategoryListPage.class.php
+++ b/wcfsetup/install/files/lib/acp/page/AbstractCategoryListPage.class.php
@@ -17,6 +17,7 @@
* @author Matthias Schmidt
* @copyright 2001-2019 WoltLab GmbH
* @license GNU Lesser General Public License
+ * @deprecated 6.3 Use `AbstractCategoryNodeTreeViewPage` instead.
*/
abstract class AbstractCategoryListPage extends AbstractPage
{
diff --git a/wcfsetup/install/files/lib/acp/page/AbstractCategoryNodeTreeViewPage.class.php b/wcfsetup/install/files/lib/acp/page/AbstractCategoryNodeTreeViewPage.class.php
new file mode 100644
index 0000000000..4ad571c7b4
--- /dev/null
+++ b/wcfsetup/install/files/lib/acp/page/AbstractCategoryNodeTreeViewPage.class.php
@@ -0,0 +1,76 @@
+
+ * @since 6.3
+ *
+ * @extends AbstractNodeTreeViewPage
+ */
+abstract class AbstractCategoryNodeTreeViewPage extends AbstractNodeTreeViewPage
+{
+ /**
+ * language item with the page title
+ */
+ public string $pageTitle = 'wcf.category.list';
+
+ /**
+ * category object type object
+ */
+ public ?ObjectType $objectType = null;
+
+ /**
+ * name of the category object type
+ */
+ public string $objectTypeName = '';
+
+ /**
+ * @inheritDoc
+ */
+ public $templateName = 'categoryNodeTreeView';
+
+ #[\Override]
+ public function readData()
+ {
+ $this->objectType = CategoryHandler::getInstance()->getObjectTypeByName($this->objectTypeName);
+ if ($this->objectType === null) {
+ throw new InvalidObjectTypeException($this->objectTypeName, 'com.woltlab.wcf.category');
+ }
+
+ parent::readData();
+ }
+
+ #[\Override]
+ public function assignVariables()
+ {
+ parent::assignVariables();
+
+ WCF::getTPL()->assign([
+ 'objectType' => $this->objectType,
+ 'addFormLink' => LinkHandler::getInstance()->getControllerLink($this->objectType->getProcessor()->getAddControllerClass()),
+ ]);
+
+ if ($this->pageTitle) {
+ WCF::getTPL()->assign('pageTitle', $this->pageTitle);
+ }
+ }
+
+ #[\Override]
+ protected function createNodeTreeView(): CategoryNodeTreeView
+ {
+ return new CategoryNodeTreeView($this->objectTypeName);
+ }
+}
diff --git a/wcfsetup/install/files/lib/acp/page/ArticleCategoryListPage.class.php b/wcfsetup/install/files/lib/acp/page/ArticleCategoryListPage.class.php
index 6f0c57825f..c95aa6ea3e 100644
--- a/wcfsetup/install/files/lib/acp/page/ArticleCategoryListPage.class.php
+++ b/wcfsetup/install/files/lib/acp/page/ArticleCategoryListPage.class.php
@@ -9,7 +9,7 @@
* @copyright 2001-2019 WoltLab GmbH
* @license GNU Lesser General Public License
*/
-class ArticleCategoryListPage extends AbstractCategoryListPage
+class ArticleCategoryListPage extends AbstractCategoryNodeTreeViewPage
{
/**
* @inheritDoc
@@ -19,7 +19,7 @@ class ArticleCategoryListPage extends AbstractCategoryListPage
/**
* @inheritDoc
*/
- public $objectTypeName = 'com.woltlab.wcf.article.category';
+ public string $objectTypeName = 'com.woltlab.wcf.article.category';
/**
* @inheritDoc
diff --git a/wcfsetup/install/files/lib/acp/page/MediaCategoryListPage.class.php b/wcfsetup/install/files/lib/acp/page/MediaCategoryListPage.class.php
index b9c64b164d..6a5f8f0138 100644
--- a/wcfsetup/install/files/lib/acp/page/MediaCategoryListPage.class.php
+++ b/wcfsetup/install/files/lib/acp/page/MediaCategoryListPage.class.php
@@ -9,7 +9,7 @@
* @copyright 2001-2019 WoltLab GmbH
* @license GNU Lesser General Public License
*/
-class MediaCategoryListPage extends AbstractCategoryListPage
+class MediaCategoryListPage extends AbstractCategoryNodeTreeViewPage
{
/**
* @inheritDoc
@@ -19,5 +19,5 @@ class MediaCategoryListPage extends AbstractCategoryListPage
/**
* @inheritDoc
*/
- public $objectTypeName = 'com.woltlab.wcf.media.category';
+ public string $objectTypeName = 'com.woltlab.wcf.media.category';
}
diff --git a/wcfsetup/install/files/lib/acp/page/SmileyCategoryListPage.class.php b/wcfsetup/install/files/lib/acp/page/SmileyCategoryListPage.class.php
index 0db3a76303..13cd061e4d 100644
--- a/wcfsetup/install/files/lib/acp/page/SmileyCategoryListPage.class.php
+++ b/wcfsetup/install/files/lib/acp/page/SmileyCategoryListPage.class.php
@@ -9,7 +9,7 @@
* @copyright 2001-2019 WoltLab GmbH
* @license GNU Lesser General Public License
*/
-class SmileyCategoryListPage extends AbstractCategoryListPage
+class SmileyCategoryListPage extends AbstractCategoryNodeTreeViewPage
{
/**
* @inheritDoc
@@ -19,7 +19,7 @@ class SmileyCategoryListPage extends AbstractCategoryListPage
/**
* @inheritDoc
*/
- public $objectTypeName = 'com.woltlab.wcf.bbcode.smiley';
+ public string $objectTypeName = 'com.woltlab.wcf.bbcode.smiley';
/**
* @inheritDoc
diff --git a/wcfsetup/install/files/lib/acp/page/TrophyCategoryListPage.class.php b/wcfsetup/install/files/lib/acp/page/TrophyCategoryListPage.class.php
index 4dd8632121..9a021bf834 100644
--- a/wcfsetup/install/files/lib/acp/page/TrophyCategoryListPage.class.php
+++ b/wcfsetup/install/files/lib/acp/page/TrophyCategoryListPage.class.php
@@ -9,7 +9,7 @@
* @copyright 2001-2019 WoltLab GmbH
* @license GNU Lesser General Public License
*/
-class TrophyCategoryListPage extends AbstractCategoryListPage
+class TrophyCategoryListPage extends AbstractCategoryNodeTreeViewPage
{
/**
* @inheritDoc
@@ -19,7 +19,7 @@ class TrophyCategoryListPage extends AbstractCategoryListPage
/**
* @inheritDoc
*/
- public $objectTypeName = 'com.woltlab.wcf.trophy.category';
+ public string $objectTypeName = 'com.woltlab.wcf.trophy.category';
/**
* @inheritDoc
diff --git a/wcfsetup/install/files/lib/command/category/DisableCategory.class.php b/wcfsetup/install/files/lib/command/category/DisableCategory.class.php
new file mode 100644
index 0000000000..1fe54c8aa2
--- /dev/null
+++ b/wcfsetup/install/files/lib/command/category/DisableCategory.class.php
@@ -0,0 +1,34 @@
+
+ * @since 6.3
+ */
+final class DisableCategory
+{
+ public function __construct(private readonly Category $category) {}
+
+ public function __invoke(): void
+ {
+ (new CategoryEditor($this->category))->update([
+ 'isDisabled' => 1,
+ ]);
+
+ CategoryEditor::resetCache();
+
+ EventHandler::getInstance()->fire(
+ new CategoryDisabled($this->category)
+ );
+ }
+}
diff --git a/wcfsetup/install/files/lib/command/category/EnableCategory.class.php b/wcfsetup/install/files/lib/command/category/EnableCategory.class.php
new file mode 100644
index 0000000000..991fc4a389
--- /dev/null
+++ b/wcfsetup/install/files/lib/command/category/EnableCategory.class.php
@@ -0,0 +1,34 @@
+
+ * @since 6.3
+ */
+final class EnableCategory
+{
+ public function __construct(private readonly Category $category) {}
+
+ public function __invoke(): void
+ {
+ (new CategoryEditor($this->category))->update([
+ 'isDisabled' => 0,
+ ]);
+
+ CategoryEditor::resetCache();
+
+ EventHandler::getInstance()->fire(
+ new CategoryEnabled($this->category)
+ );
+ }
+}
diff --git a/wcfsetup/install/files/lib/command/category/SetCategoryPositions.class.php b/wcfsetup/install/files/lib/command/category/SetCategoryPositions.class.php
new file mode 100644
index 0000000000..b931ef05c3
--- /dev/null
+++ b/wcfsetup/install/files/lib/command/category/SetCategoryPositions.class.php
@@ -0,0 +1,69 @@
+
+ * @since 6.3
+ */
+final class SetCategoryPositions
+{
+ /**
+ * @param array> $positions
+ */
+ public function __construct(private readonly array $positions) {}
+
+ public function __invoke(): void
+ {
+ $parentUpdates = [];
+ $objectType = null;
+
+ $sql = "UPDATE wcf1_category
+ SET parentCategoryID = ?,
+ showOrder = ?
+ WHERE categoryID = ?";
+ $statement = WCF::getDB()->prepare($sql);
+
+ WCF::getDB()->beginTransaction();
+ foreach ($this->positions as $parentCategoryID => $children) {
+ foreach ($children as $showOrder => $categoryID) {
+ $category = CategoryHandler::getInstance()->getCategory($categoryID);
+ if ($category === null) {
+ continue;
+ }
+
+ if ($objectType === null) {
+ $objectType = $category->getObjectType();
+ }
+
+ if ($category->parentCategoryID != $parentCategoryID) {
+ $parentUpdates[$categoryID] = [
+ 'oldParentCategoryID' => $category->parentCategoryID,
+ 'newParentCategoryID' => $parentCategoryID,
+ ];
+ }
+
+ $statement->execute([
+ $parentCategoryID,
+ $showOrder + 1,
+ $categoryID,
+ ]);
+ }
+ }
+ WCF::getDB()->commitTransaction();
+
+ CategoryEditor::resetCache();
+
+ if ($parentUpdates !== [] && $objectType !== null) {
+ $objectType->getProcessor()->changedParentCategories($parentUpdates);
+ }
+ }
+}
diff --git a/wcfsetup/install/files/lib/event/category/CategoryDisabled.class.php b/wcfsetup/install/files/lib/event/category/CategoryDisabled.class.php
new file mode 100644
index 0000000000..6cf4770674
--- /dev/null
+++ b/wcfsetup/install/files/lib/event/category/CategoryDisabled.class.php
@@ -0,0 +1,19 @@
+
+ * @since 6.3
+ */
+final class CategoryDisabled implements IPsr14Event
+{
+ public function __construct(public readonly Category $category) {}
+}
diff --git a/wcfsetup/install/files/lib/event/category/CategoryEnabled.class.php b/wcfsetup/install/files/lib/event/category/CategoryEnabled.class.php
new file mode 100644
index 0000000000..10d9db4d46
--- /dev/null
+++ b/wcfsetup/install/files/lib/event/category/CategoryEnabled.class.php
@@ -0,0 +1,19 @@
+
+ * @since 6.3
+ */
+final class CategoryEnabled implements IPsr14Event
+{
+ public function __construct(public readonly Category $category) {}
+}
diff --git a/wcfsetup/install/files/lib/event/interaction/admin/CategoryInteractionCollecting.class.php b/wcfsetup/install/files/lib/event/interaction/admin/CategoryInteractionCollecting.class.php
new file mode 100644
index 0000000000..67aeeeb47c
--- /dev/null
+++ b/wcfsetup/install/files/lib/event/interaction/admin/CategoryInteractionCollecting.class.php
@@ -0,0 +1,19 @@
+
+ * @since 6.3
+ */
+final class CategoryInteractionCollecting implements IPsr14Event
+{
+ public function __construct(public readonly CategoryInteractions $provider) {}
+}
diff --git a/wcfsetup/install/files/lib/system/category/AbstractCategoryType.class.php b/wcfsetup/install/files/lib/system/category/AbstractCategoryType.class.php
index 30338aadb8..b549de28b0 100644
--- a/wcfsetup/install/files/lib/system/category/AbstractCategoryType.class.php
+++ b/wcfsetup/install/files/lib/system/category/AbstractCategoryType.class.php
@@ -183,4 +183,18 @@ public function supportsHtmlDescription()
{
return false;
}
+
+ #[\Override]
+ public function getEditControllerClass(): string
+ {
+ // @phpstan-ignore return.type
+ return '';
+ }
+
+ #[\Override]
+ public function getAddControllerClass(): string
+ {
+ // @phpstan-ignore return.type
+ return '';
+ }
}
diff --git a/wcfsetup/install/files/lib/system/category/ArticleCategoryType.class.php b/wcfsetup/install/files/lib/system/category/ArticleCategoryType.class.php
index e2737a054e..e9fdcf45f8 100644
--- a/wcfsetup/install/files/lib/system/category/ArticleCategoryType.class.php
+++ b/wcfsetup/install/files/lib/system/category/ArticleCategoryType.class.php
@@ -2,6 +2,8 @@
namespace wcf\system\category;
+use wcf\acp\form\ArticleCategoryAddForm;
+use wcf\acp\form\ArticleCategoryEditForm;
use wcf\data\article\ArticleAction;
use wcf\data\category\CategoryEditor;
use wcf\system\WCF;
@@ -85,4 +87,16 @@ public function supportsHtmlDescription()
{
return true;
}
+
+ #[\Override]
+ public function getEditControllerClass(): string
+ {
+ return ArticleCategoryEditForm::class;
+ }
+
+ #[\Override]
+ public function getAddControllerClass(): string
+ {
+ return ArticleCategoryAddForm::class;
+ }
}
diff --git a/wcfsetup/install/files/lib/system/category/ICategoryType.class.php b/wcfsetup/install/files/lib/system/category/ICategoryType.class.php
index 244b984d3b..62358134ba 100644
--- a/wcfsetup/install/files/lib/system/category/ICategoryType.class.php
+++ b/wcfsetup/install/files/lib/system/category/ICategoryType.class.php
@@ -2,6 +2,7 @@
namespace wcf\system\category;
+use wcf\acp\form\CategoryAddFormBuilderForm;
use wcf\data\category\CategoryEditor;
/**
@@ -139,4 +140,20 @@ public function hasDescription();
* @since 5.2
*/
public function supportsHtmlDescription();
+
+ /**
+ * Returns the name of the controller class used to edit categories of this type.
+ *
+ * @return class-string
+ * @since 6.3
+ */
+ public function getEditControllerClass(): string;
+
+ /**
+ * Returns the name of the controller class used to add categories of this type.
+ *
+ * @return class-string
+ * @since 6.3
+ */
+ public function getAddControllerClass(): string;
}
diff --git a/wcfsetup/install/files/lib/system/category/MediaCategoryType.class.php b/wcfsetup/install/files/lib/system/category/MediaCategoryType.class.php
index e743aae1a7..91d3e824ef 100644
--- a/wcfsetup/install/files/lib/system/category/MediaCategoryType.class.php
+++ b/wcfsetup/install/files/lib/system/category/MediaCategoryType.class.php
@@ -2,6 +2,8 @@
namespace wcf\system\category;
+use wcf\acp\form\MediaCategoryAddForm;
+use wcf\acp\form\MediaCategoryEditForm;
use wcf\system\WCF;
/**
@@ -45,4 +47,16 @@ public function canEditCategory()
{
return WCF::getSession()->hasPermission('admin.content.cms.canManageMedia');
}
+
+ #[\Override]
+ public function getEditControllerClass(): string
+ {
+ return MediaCategoryEditForm::class;
+ }
+
+ #[\Override]
+ public function getAddControllerClass(): string
+ {
+ return MediaCategoryAddForm::class;
+ }
}
diff --git a/wcfsetup/install/files/lib/system/category/SmileyCategoryType.class.php b/wcfsetup/install/files/lib/system/category/SmileyCategoryType.class.php
index 570bba29c6..2626d7f7e9 100644
--- a/wcfsetup/install/files/lib/system/category/SmileyCategoryType.class.php
+++ b/wcfsetup/install/files/lib/system/category/SmileyCategoryType.class.php
@@ -2,6 +2,8 @@
namespace wcf\system\category;
+use wcf\acp\form\SmileyCategoryAddForm;
+use wcf\acp\form\SmileyCategoryEditForm;
use wcf\data\category\CategoryEditor;
use wcf\system\cache\builder\SmileyCacheBuilder;
use wcf\system\WCF;
@@ -53,4 +55,16 @@ public function canEditCategory()
{
return WCF::getSession()->hasPermission('admin.content.smiley.canManageSmiley');
}
+
+ #[\Override]
+ public function getEditControllerClass(): string
+ {
+ return SmileyCategoryEditForm::class;
+ }
+
+ #[\Override]
+ public function getAddControllerClass(): string
+ {
+ return SmileyCategoryAddForm::class;
+ }
}
diff --git a/wcfsetup/install/files/lib/system/category/TrophyCategoryType.class.php b/wcfsetup/install/files/lib/system/category/TrophyCategoryType.class.php
index 0434ca23f6..ff197fc089 100644
--- a/wcfsetup/install/files/lib/system/category/TrophyCategoryType.class.php
+++ b/wcfsetup/install/files/lib/system/category/TrophyCategoryType.class.php
@@ -2,6 +2,8 @@
namespace wcf\system\category;
+use wcf\acp\form\TrophyCategoryAddForm;
+use wcf\acp\form\TrophyCategoryEditForm;
use wcf\data\category\CategoryEditor;
use wcf\data\user\trophy\UserTrophyAction;
use wcf\data\user\trophy\UserTrophyList;
@@ -80,4 +82,16 @@ public function supportsHtmlDescription()
{
return true;
}
+
+ #[\Override]
+ public function getEditControllerClass(): string
+ {
+ return TrophyCategoryEditForm::class;
+ }
+
+ #[\Override]
+ public function getAddControllerClass(): string
+ {
+ return TrophyCategoryAddForm::class;
+ }
}
diff --git a/wcfsetup/install/files/lib/system/endpoint/controller/core/categories/DeleteCategory.class.php b/wcfsetup/install/files/lib/system/endpoint/controller/core/categories/DeleteCategory.class.php
new file mode 100644
index 0000000000..e558d9a783
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/endpoint/controller/core/categories/DeleteCategory.class.php
@@ -0,0 +1,44 @@
+
+ * @since 6.3
+ */
+#[DeleteRequest("/core/categories/{id:\d+}")]
+final class DeleteCategory implements IController
+{
+ #[\Override]
+ public function __invoke(ServerRequestInterface $request, array $variables): ResponseInterface
+ {
+ $category = Helper::fetchObjectFromRequestParameter($variables['id'], Category::class);
+
+ $this->assertCategoryCanBeDeleted($category);
+
+ (new CategoryAction([$category->categoryID], 'delete'))->executeAction();
+
+ return new JsonResponse([]);
+ }
+
+ private function assertCategoryCanBeDeleted(Category $category): void
+ {
+ if (!$category->getObjectType()->getProcessor()->canDeleteCategory()) {
+ throw new PermissionDeniedException();
+ }
+ }
+}
diff --git a/wcfsetup/install/files/lib/system/endpoint/controller/core/categories/DisableCategory.class.php b/wcfsetup/install/files/lib/system/endpoint/controller/core/categories/DisableCategory.class.php
new file mode 100644
index 0000000000..a011de43f1
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/endpoint/controller/core/categories/DisableCategory.class.php
@@ -0,0 +1,45 @@
+
+ * @since 6.3
+ */
+#[PostRequest("/core/categories/{id:\d+}/disable")]
+final class DisableCategory implements IController
+{
+ #[\Override]
+ public function __invoke(ServerRequestInterface $request, array $variables): ResponseInterface
+ {
+ $category = Helper::fetchObjectFromRequestParameter($variables['id'], Category::class);
+
+ $this->assertCategoryCanBeDisabled($category);
+
+ if (!$category->isDisabled) {
+ (new \wcf\command\category\DisableCategory($category))();
+ }
+
+ return new JsonResponse([]);
+ }
+
+ private function assertCategoryCanBeDisabled(Category $category): void
+ {
+ if (!$category->getObjectType()->getProcessor()->canEditCategory()) {
+ throw new PermissionDeniedException();
+ }
+ }
+}
diff --git a/wcfsetup/install/files/lib/system/endpoint/controller/core/categories/EnableCategory.class.php b/wcfsetup/install/files/lib/system/endpoint/controller/core/categories/EnableCategory.class.php
new file mode 100644
index 0000000000..6c142387a4
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/endpoint/controller/core/categories/EnableCategory.class.php
@@ -0,0 +1,45 @@
+
+ * @since 6.3
+ */
+#[PostRequest("/core/categories/{id:\d+}/enable")]
+final class EnableCategory implements IController
+{
+ #[\Override]
+ public function __invoke(ServerRequestInterface $request, array $variables): ResponseInterface
+ {
+ $category = Helper::fetchObjectFromRequestParameter($variables['id'], Category::class);
+
+ $this->assertCategoryCanBeEnabled($category);
+
+ if ($category->isDisabled) {
+ (new \wcf\command\category\EnableCategory($category))();
+ }
+
+ return new JsonResponse([]);
+ }
+
+ private function assertCategoryCanBeEnabled(Category $category): void
+ {
+ if (!$category->getObjectType()->getProcessor()->canEditCategory()) {
+ throw new PermissionDeniedException();
+ }
+ }
+}
diff --git a/wcfsetup/install/files/lib/system/endpoint/controller/core/categories/SetCategoryPositions.class.php b/wcfsetup/install/files/lib/system/endpoint/controller/core/categories/SetCategoryPositions.class.php
new file mode 100644
index 0000000000..5a09673b7e
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/endpoint/controller/core/categories/SetCategoryPositions.class.php
@@ -0,0 +1,112 @@
+
+ * @since 6.3
+ */
+#[PostRequest('/core/categories/object-types/{id:\d+}/positions')]
+final class SetCategoryPositions implements IController
+{
+ #[\Override]
+ public function __invoke(ServerRequestInterface $request, array $variables): ResponseInterface
+ {
+ $objectType = CategoryHandler::getInstance()->getObjectType(\intval($variables['id']));
+ if ($objectType === null) {
+ throw new UserInputException('objectTypeID');
+ }
+
+ if (!$objectType->getProcessor()->canEditCategory()) {
+ throw new PermissionDeniedException();
+ }
+
+ $parameters = Helper::mapApiParameters($request, SetCategoryPositionsParameters::class);
+ $positions = $this->validatePositions($objectType->objectTypeID, $parameters->positions);
+
+ (new \wcf\command\category\SetCategoryPositions($positions))();
+
+ return new JsonResponse([]);
+ }
+
+ /**
+ * @param array> $positions
+ * @return array>
+ */
+ private function validatePositions(int $objectTypeID, array $positions): array
+ {
+ $categoryIDs = [];
+ foreach ($positions as $children) {
+ $categoryIDs = \array_merge($categoryIDs, $children);
+ }
+
+ if ($categoryIDs === []) {
+ return $positions;
+ }
+
+ $categoryList = new CategoryList();
+ $categoryList->getConditionBuilder()->add('category.categoryID IN (?)', [$categoryIDs]);
+ $categoryList->getConditionBuilder()->add('category.objectTypeID = ?', [$objectTypeID]);
+ $categoryList->readObjects();
+ $categories = $categoryList->getObjects();
+
+ if (\count($categories) !== \count($categoryIDs)) {
+ throw new IllegalLinkException();
+ }
+
+ foreach ($positions as $parentCategoryID => $children) {
+ if ($parentCategoryID && !isset($categories[$parentCategoryID])) {
+ throw new IllegalLinkException();
+ }
+ }
+
+ $parentOf = [];
+ foreach ($positions as $parentCategoryID => $children) {
+ foreach ($children as $childID) {
+ if (isset($parentOf[$childID])) {
+ throw new IllegalLinkException();
+ }
+ $parentOf[$childID] = $parentCategoryID;
+ }
+ }
+
+ foreach (\array_keys($parentOf) as $startID) {
+ $current = $startID;
+ $seen = [$startID => true];
+ while (!empty($parentOf[$current])) {
+ $current = $parentOf[$current];
+ if (isset($seen[$current])) {
+ throw new IllegalLinkException();
+ }
+ $seen[$current] = true;
+ }
+ }
+
+ return $positions;
+ }
+}
+
+/** @internal */
+final class SetCategoryPositionsParameters
+{
+ public function __construct(
+ /** @var array> */
+ public readonly array $positions,
+ ) {}
+}
diff --git a/wcfsetup/install/files/lib/system/interaction/admin/CategoryInteractions.class.php b/wcfsetup/install/files/lib/system/interaction/admin/CategoryInteractions.class.php
new file mode 100644
index 0000000000..f8b7e13ab8
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/interaction/admin/CategoryInteractions.class.php
@@ -0,0 +1,83 @@
+
+ * @since 6.3
+ */
+final class CategoryInteractions extends AbstractInteractionProvider
+{
+ public function __construct()
+ {
+ $this->addInteractions([
+ new class(
+ 'add-child-node',
+ '',
+ 'wcf.category.addChildNode',
+ static function (Category $category): bool {
+ $processor = $category->getObjectType()->getProcessor();
+ \assert($processor instanceof ICategoryType);
+ if (!$processor->canAddCategory()) {
+ return false;
+ }
+
+ // @phpstan-ignore identical.alwaysFalse
+ if ($processor->getAddControllerClass() === '') {
+ return false;
+ }
+
+ $maximumNestingLevel = $processor->getMaximumNestingLevel();
+ if ($maximumNestingLevel === 0) {
+ return false;
+ }
+
+ if ($maximumNestingLevel === -1) {
+ return true;
+ }
+
+ return \count($category->getParentCategories()) + 1 < $maximumNestingLevel;
+ }
+ ) extends LinkInteraction {
+ #[\Override]
+ protected function getLink(DatabaseObject $object): string
+ {
+ \assert($object instanceof Category);
+
+ return LinkHandler::getInstance()->getControllerLink(
+ $object->getObjectType()->getProcessor()->getAddControllerClass(),
+ ['parentCategoryID' => $object->getObjectID()]
+ );
+ }
+ },
+ new DeleteInteraction(
+ 'core/categories/%s',
+ static fn(Category $category) => $category->getObjectType()->getProcessor()->canDeleteCategory()
+ ),
+ ]);
+
+ EventHandler::getInstance()->fire(
+ new CategoryInteractionCollecting($this)
+ );
+ }
+
+ #[\Override]
+ public function getObjectClassName(): string
+ {
+ return Category::class;
+ }
+}
diff --git a/wcfsetup/install/files/lib/system/nodeTreeView/admin/CategoryNodeTreeView.class.php b/wcfsetup/install/files/lib/system/nodeTreeView/admin/CategoryNodeTreeView.class.php
new file mode 100644
index 0000000000..b9fde53b31
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/nodeTreeView/admin/CategoryNodeTreeView.class.php
@@ -0,0 +1,114 @@
+
+ * @since 6.3
+ */
+class CategoryNodeTreeView extends AbstractNodeTreeView
+{
+ private ?ObjectType $objectType = null;
+
+ public function __construct(public readonly string $objectTypeName)
+ {
+ $provider = new CategoryInteractions();
+ $provider->addInteractions([
+ new Divider(),
+ new EditInteraction(
+ $this->getProcessor()->getEditControllerClass(),
+ static fn(Category $category) => $category->getObjectType()->getProcessor()->canEditCategory()
+ ),
+ ]);
+ $this->setInteractionProvider($provider);
+
+ $this->addQuickInteraction(
+ new ToggleInteraction(
+ 'enable',
+ 'core/categories/%s/enable',
+ 'core/categories/%s/disable',
+ isAvailableCallback: static fn(Category $category) => $category->getObjectType()
+ ->getProcessor()
+ ->canEditCategory()
+ )
+ );
+
+ $this->setSetPositionsEndpoint(
+ "core/categories/object-types/{$this->getObjectType()->objectTypeID}/positions"
+ );
+ }
+
+ public function getObjectType(): ObjectType
+ {
+ if ($this->objectType === null) {
+ $objectType = CategoryHandler::getInstance()->getObjectTypeByName($this->objectTypeName);
+ if ($objectType === null) {
+ throw new InvalidObjectTypeException($this->objectTypeName, 'com.woltlab.wcf.category');
+ }
+ $this->objectType = $objectType;
+ }
+
+ return $this->objectType;
+ }
+
+ public function getProcessor(): ICategoryType
+ {
+ return $this->getObjectType()->getProcessor();
+ }
+
+ #[\Override]
+ public function getNodes(): \RecursiveIteratorIterator
+ {
+ $nodeTree = new CategoryNodeTree($this->objectTypeName, 0, true);
+
+ // @phpstan-ignore return.type
+ return $nodeTree->getIterator();
+ }
+
+ #[\Override]
+ public function getNodeLink(IObjectTreeNode $node): string
+ {
+ \assert($node instanceof CategoryNode);
+
+ return LinkHandler::getInstance()->getControllerLink(
+ $this->getProcessor()->getEditControllerClass(),
+ [
+ 'id' => $node->getObjectID(),
+ 'title' => $node->getTitle(),
+ ]
+ );
+ }
+
+ #[\Override]
+ public function isAccessible(): bool
+ {
+ return $this->getProcessor()->canEditCategory() || $this->getProcessor()->canDeleteCategory();
+ }
+
+ #[\Override]
+ public function getParameters(): array
+ {
+ return ['objectTypeName' => $this->objectTypeName];
+ }
+}
diff --git a/wcfsetup/install/files/lib/system/nodeTreeView/admin/MenuItemNodeTreeView.class.php b/wcfsetup/install/files/lib/system/nodeTreeView/admin/MenuItemNodeTreeView.class.php
index 7a40870c34..3a14b4c811 100644
--- a/wcfsetup/install/files/lib/system/nodeTreeView/admin/MenuItemNodeTreeView.class.php
+++ b/wcfsetup/install/files/lib/system/nodeTreeView/admin/MenuItemNodeTreeView.class.php
@@ -48,6 +48,7 @@ public function getNodes(): \RecursiveIteratorIterator
{
$nodeTree = new MenuItemNodeTree($this->menuID, null, false);
+ // @phpstan-ignore return.type
return $nodeTree->getNodeList();
}
diff --git a/wcfsetup/install/lang/de.xml b/wcfsetup/install/lang/de.xml
index 2e939a5289..d30880b1f1 100644
--- a/wcfsetup/install/lang/de.xml
+++ b/wcfsetup/install/lang/de.xml
@@ -3431,6 +3431,7 @@ Erlaubte Dateiendungen: {', '|implode:$attachmentHandler->getFormattedAllowedExt
+
diff --git a/wcfsetup/install/lang/en.xml b/wcfsetup/install/lang/en.xml
index dfbab4bd9e..7a87a8213f 100644
--- a/wcfsetup/install/lang/en.xml
+++ b/wcfsetup/install/lang/en.xml
@@ -3354,6 +3354,7 @@ Allowed extensions: {', '|implode:$attachmentHandler->getFormattedAllowedExtensi
+