diff --git a/com.woltlab.wcf/package.xml b/com.woltlab.wcf/package.xml
index b0598667426..882df82c685 100644
--- a/com.woltlab.wcf/package.xml
+++ b/com.woltlab.wcf/package.xml
@@ -51,10 +51,8 @@
diff --git a/com.woltlab.wcf/templates/shared_conditionFormContainer.tpl b/com.woltlab.wcf/templates/shared_conditionFormContainer.tpl
new file mode 100644
index 00000000000..bf7fabb2f1f
--- /dev/null
+++ b/com.woltlab.wcf/templates/shared_conditionFormContainer.tpl
@@ -0,0 +1,36 @@
+getClasses()|empty} class="{implode from=$container->getClasses() item='class' glue=' '}{$class}{/implode}"{/if}{*
+ *}{foreach from=$container->getAttributes() key='attributeName' item='attributeValue'} {$attributeName}="{$attributeValue}"{/foreach}{*
+ *}{if !$container->checkDependencies()} style="display: none" {/if}{*
+*}>
+ {if $container->getLabel() !== null}
+ {if $container->getDescription() !== null}
+
+ {else}
+ {unsafe:$container->getLabel()}{if $container->markAsRequired()}*{/if}
+ {/if}
+ {/if}
+
+
+ {include file='shared_formContainerChildren'}
+
+
+
+
+
+{include file='shared_formContainerDependencies'}
+
+
diff --git a/com.woltlab.wcf/templates/shared_prefixFormFieldContainer.tpl b/com.woltlab.wcf/templates/shared_prefixFormFieldContainer.tpl
new file mode 100644
index 00000000000..ff0c5b4d9a7
--- /dev/null
+++ b/com.woltlab.wcf/templates/shared_prefixFormFieldContainer.tpl
@@ -0,0 +1,60 @@
+{assign var='field' value=$container->getField()}
+{assign var='prefixField' value=$container->getPrefixField()}
+
+
getClasses()|empty} class="{implode from=$field->getClasses() item='class' glue=' '}{$class}{/implode}"{/if}{*
+ *}{foreach from=$field->getAttributes() key='attributeName' item='attributeValue'} {$attributeName}="{$attributeValue}"{/foreach}{*
+ *}{if !$field->checkDependencies()} style="display: none
+;"{/if}{*
+*}>
+ - {if $container->getLabel() !== null}{if $field->isRequired() && $form->marksRequiredFields()} *{/if}{/if}
+ -
+
+ {if $prefixField->isAvailable()}
+ {if !$container->prefixHasSelectableOptions()}
+ {unsafe:$prefixField->getFieldHtml()}
+ {else}
+
+ {unsafe:$container->getSelectedPrefixOption()[label]} {icon name='caret-down' type='solid'}
+
+
+
+
+ {/if}
+ {include file='shared_formFieldDependencies' field=$prefixField sandbox=true}
+ {include file='shared_formFieldDataHandler' field=$prefixField sandbox=true}
+ {/if}
+ {unsafe:$field->getFieldHtml()}
+
+
+ {if $container->getDescription() !== null}
+ {unsafe:$container->getDescription()}
+ {/if}
+
+ {include file='shared_formFieldErrors' field=$field sandbox=true}
+
+ {if $prefixField !== null && $prefixField->isAvailable()}
+ {foreach from=$prefixField->getValidationErrors() item='validationError'}
+ {unsafe:$validationError->getHtml()}
+ {/foreach}
+ {/if}
+
+ {include file='shared_formFieldDependencies' field=$field sandbox=true}
+ {include file='shared_formFieldDataHandler' field=$field sandbox=true}
+
+
+
+{if $prefixField->isAvailable() && !$prefixField->isImmutable() && $container->prefixHasSelectableOptions()}
+
+{/if}
diff --git a/ts/WoltLabSuite/Core/Form/Builder/Container/ConditionFormField.ts b/ts/WoltLabSuite/Core/Form/Builder/Container/ConditionFormField.ts
new file mode 100644
index 00000000000..3014325cadc
--- /dev/null
+++ b/ts/WoltLabSuite/Core/Form/Builder/Container/ConditionFormField.ts
@@ -0,0 +1,72 @@
+/**
+ * @author Olaf Braun
+ * @copyright 2001-2025 WoltLab GmbH
+ * @license GNU Lesser General Public License
+ * @since 6.3
+ */
+
+import { promiseMutex } from "WoltLabSuite/Core/Helper/PromiseMutex";
+import { dialogFactory } from "WoltLabSuite/Core/Component/Dialog";
+import { insertHtml } from "WoltLabSuite/Core/Dom/Util";
+import { unescapeHTML } from "WoltLabSuite/Core/StringUtil";
+import { wheneverFirstSeen } from "WoltLabSuite/Core/Helper/Selector";
+import { getPhrase } from "WoltLabSuite/Core/Language";
+
+interface ConditionAddResponse {
+ field: string;
+ conditionType: string;
+}
+
+export class ConditionFormField {
+ readonly #containerId: string;
+ readonly #container: HTMLElement;
+ readonly #button: HTMLButtonElement;
+ #index: number = 0;
+
+ constructor(containerId: string, endpoint: string) {
+ this.#containerId = containerId;
+ this.#container = document.getElementById(`${containerId}Conditions`) as HTMLElement;
+
+ this.#button = document.getElementById(`${containerId}AddCondition`) as HTMLButtonElement;
+ this.#button?.addEventListener(
+ "click",
+ promiseMutex(async () => {
+ await this.#showConditionAddDialog(endpoint);
+ }),
+ );
+
+ wheneverFirstSeen(`#${containerId}Container .condition__container`, (container: HTMLElement) => {
+ const deleteButton = document.createElement("button");
+ deleteButton.type = "button";
+ deleteButton.classList.add("button", "small", "jsTooltip", "condition__remove");
+ deleteButton.title = getPhrase("wcf.global.button.delete");
+ const icon = document.createElement("fa-icon");
+ icon.setIcon("times");
+ deleteButton.appendChild(icon);
+ container.prepend(deleteButton);
+ deleteButton.addEventListener("click", () => {
+ container.remove();
+ });
+
+ const index = parseInt(container.dataset.conditionIndex!);
+ this.#index = Math.max(this.#index, index);
+ const hidden = document.createElement("input");
+ hidden.type = "hidden";
+ hidden.name = `${containerId}[${index}]`;
+ hidden.value = container.dataset.conditionType!;
+ container.appendChild(hidden);
+ });
+ }
+
+ async #showConditionAddDialog(endpoint: string) {
+ const url = new URL(unescapeHTML(endpoint));
+ url.searchParams.set("containerId", this.#containerId);
+ url.searchParams.set("index", (this.#index + 1).toString());
+
+ const { ok, result } = await dialogFactory().usingFormBuilder().fromEndpoint(url.toString());
+
+ if (ok) {
+ insertHtml(result.field, this.#container, "append");
+ }
+ }
+}
diff --git a/wcfsetup/install/files/acp/database/update_com.woltlab.wcf_6.3_step1.php b/wcfsetup/install/files/acp/database/update_com.woltlab.wcf_6.3_step1.php
new file mode 100644
index 00000000000..1a24a49c9b2
--- /dev/null
+++ b/wcfsetup/install/files/acp/database/update_com.woltlab.wcf_6.3_step1.php
@@ -0,0 +1,21 @@
+
+ */
+
+use wcf\system\database\table\column\DefaultFalseBooleanDatabaseTableColumn;
+use wcf\system\database\table\column\MediumtextDatabaseTableColumn;
+use wcf\system\database\table\PartialDatabaseTable;
+
+return [
+ PartialDatabaseTable::create('wcf1_user_group_assignment')
+ ->columns([
+ MediumtextDatabaseTableColumn::create('conditions'),
+ DefaultFalseBooleanDatabaseTableColumn::create('isLegacy'),
+ ]),
+];
diff --git a/wcfsetup/install/files/acp/templates/userGroupAssignmentAdd.tpl b/wcfsetup/install/files/acp/templates/userGroupAssignmentAdd.tpl
index cfd0d828d14..32b6b4a1bbd 100644
--- a/wcfsetup/install/files/acp/templates/userGroupAssignmentAdd.tpl
+++ b/wcfsetup/install/files/acp/templates/userGroupAssignmentAdd.tpl
@@ -17,70 +17,6 @@
-{include file='shared_formNotice'}
-
-
+{unsafe:$form->getHtml()}
{include file='footer'}
diff --git a/wcfsetup/install/files/acp/templates/userGroupAssignmentList.tpl b/wcfsetup/install/files/acp/templates/userGroupAssignmentList.tpl
index aa70b14a94a..3ea56dd5ab6 100644
--- a/wcfsetup/install/files/acp/templates/userGroupAssignmentList.tpl
+++ b/wcfsetup/install/files/acp/templates/userGroupAssignmentList.tpl
@@ -14,6 +14,12 @@
+{if $hasLegacyObjects}
+
+ {lang}wcf.acp.group.assignment.legacyNotice{/lang}
+
+{/if}
+
{unsafe:$gridView->render()}
diff --git a/wcfsetup/install/files/acp/update_com.woltlab.wcf_6.3_userGroupAssignment.php b/wcfsetup/install/files/acp/update_com.woltlab.wcf_6.3_userGroupAssignment.php
new file mode 100644
index 00000000000..fafe2d74d07
--- /dev/null
+++ b/wcfsetup/install/files/acp/update_com.woltlab.wcf_6.3_userGroupAssignment.php
@@ -0,0 +1,59 @@
+exportConditions("com.woltlab.wcf.condition.userGroupAssignment");
+if ($exportedConditions === []) {
+ return;
+}
+
+$sql = "UPDATE wcf1_user_group_assignment
+ SET conditions = ?,
+ isLegacy = ?
+ WHERE assignmentID = ?";
+$statement = WCF::getDB()->prepare($sql);
+foreach ($exportedConditions as $assignmentID => $conditionData) {
+ renameObjectTypes($conditionData);
+
+ $statement->execute([
+ JSON::encode($conditionData),
+ 1,
+ $assignmentID,
+ ]);
+}
+
+/**
+ * Rename the object types so that the migration functions can handle them.
+ * @see \wcf\system\condition\provider\UserConditionProvider
+ *
+ * @param array $conditionData
+ */
+function renameObjectTypes(array &$conditionData): void
+{
+ $objectTypeMap = [
+ 'com.woltlab.wcf.username' => 'com.woltlab.wcf.user.username',
+ 'com.woltlab.wcf.email' => 'com.woltlab.wcf.user.email',
+ 'com.woltlab.wcf.userGroup' => 'com.woltlab.wcf.user.userGroup',
+ 'com.woltlab.wcf.languages' => 'com.woltlab.wcf.user.languages',
+ 'com.woltlab.wcf.registrationDate' => 'com.woltlab.wcf.user.registrationDate',
+ 'com.woltlab.wcf.registrationDateInterval' => 'com.woltlab.wcf.user.registrationDateInterval',
+ 'com.woltlab.wcf.avatar' => 'com.woltlab.wcf.user.avatar',
+ 'com.woltlab.wcf.signature' => 'com.woltlab.wcf.user.signature',
+ 'com.woltlab.wcf.coverPhoto' => 'com.woltlab.wcf.user.coverPhoto',
+ 'com.woltlab.wcf.state' => 'com.woltlab.wcf.user.state',
+ 'com.woltlab.wcf.activityPoints' => 'com.woltlab.wcf.user.activityPoints',
+ 'com.woltlab.wcf.likesReceived' => 'com.woltlab.wcf.user.likesReceived',
+ // TODO 'com.woltlab.wcf.userOptions'
+ 'com.woltlab.wcf.userTrophyCondition' => 'com.woltlab.wcf.user.trophyCondition',
+ 'com.woltlab.wcf.trophyPoints' => 'com.woltlab.wcf.user.trophyPoints',
+ ];
+
+ foreach ($objectTypeMap as $currentName => $newName) {
+ if (isset($conditionData[$currentName])) {
+ $conditionData[$newName] = $conditionData[$currentName];
+ unset($conditionData[$currentName]);
+ }
+ }
+}
diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Form/Builder/Container/ConditionFormField.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Form/Builder/Container/ConditionFormField.js
new file mode 100644
index 00000000000..33a98b563a8
--- /dev/null
+++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Form/Builder/Container/ConditionFormField.js
@@ -0,0 +1,55 @@
+/**
+ * @author Olaf Braun
+ * @copyright 2001-2025 WoltLab GmbH
+ * @license GNU Lesser General Public License
+ * @since 6.3
+ */
+define(["require", "exports", "WoltLabSuite/Core/Helper/PromiseMutex", "WoltLabSuite/Core/Component/Dialog", "WoltLabSuite/Core/Dom/Util", "WoltLabSuite/Core/StringUtil", "WoltLabSuite/Core/Helper/Selector", "WoltLabSuite/Core/Language"], function (require, exports, PromiseMutex_1, Dialog_1, Util_1, StringUtil_1, Selector_1, Language_1) {
+ "use strict";
+ Object.defineProperty(exports, "__esModule", { value: true });
+ exports.ConditionFormField = void 0;
+ class ConditionFormField {
+ #containerId;
+ #container;
+ #button;
+ #index = 0;
+ constructor(containerId, endpoint) {
+ this.#containerId = containerId;
+ this.#container = document.getElementById(`${containerId}Conditions`);
+ this.#button = document.getElementById(`${containerId}AddCondition`);
+ this.#button?.addEventListener("click", (0, PromiseMutex_1.promiseMutex)(async () => {
+ await this.#showConditionAddDialog(endpoint);
+ }));
+ (0, Selector_1.wheneverFirstSeen)(`#${containerId}Container .condition__container`, (container) => {
+ const deleteButton = document.createElement("button");
+ deleteButton.type = "button";
+ deleteButton.classList.add("button", "small", "jsTooltip", "condition__remove");
+ deleteButton.title = (0, Language_1.getPhrase)("wcf.global.button.delete");
+ const icon = document.createElement("fa-icon");
+ icon.setIcon("times");
+ deleteButton.appendChild(icon);
+ container.prepend(deleteButton);
+ deleteButton.addEventListener("click", () => {
+ container.remove();
+ });
+ const index = parseInt(container.dataset.conditionIndex);
+ this.#index = Math.max(this.#index, index);
+ const hidden = document.createElement("input");
+ hidden.type = "hidden";
+ hidden.name = `${containerId}[${index}]`;
+ hidden.value = container.dataset.conditionType;
+ container.appendChild(hidden);
+ });
+ }
+ async #showConditionAddDialog(endpoint) {
+ const url = new URL((0, StringUtil_1.unescapeHTML)(endpoint));
+ url.searchParams.set("containerId", this.#containerId);
+ url.searchParams.set("index", (this.#index + 1).toString());
+ const { ok, result } = await (0, Dialog_1.dialogFactory)().usingFormBuilder().fromEndpoint(url.toString());
+ if (ok) {
+ (0, Util_1.insertHtml)(result.field, this.#container, "append");
+ }
+ }
+ }
+ exports.ConditionFormField = ConditionFormField;
+});
diff --git a/wcfsetup/install/files/lib/acp/form/UserGroupAssignmentAddForm.class.php b/wcfsetup/install/files/lib/acp/form/UserGroupAssignmentAddForm.class.php
index 8e037a1cb61..6929fa248ce 100644
--- a/wcfsetup/install/files/lib/acp/form/UserGroupAssignmentAddForm.class.php
+++ b/wcfsetup/install/files/lib/acp/form/UserGroupAssignmentAddForm.class.php
@@ -2,207 +2,85 @@
namespace wcf\acp\form;
-use wcf\data\object\type\ObjectType;
+use wcf\data\user\group\assignment\UserGroupAssignment;
use wcf\data\user\group\assignment\UserGroupAssignmentAction;
use wcf\data\user\group\UserGroup;
-use wcf\form\AbstractForm;
-use wcf\system\condition\ConditionHandler;
-use wcf\system\exception\UserInputException;
-use wcf\system\request\LinkHandler;
-use wcf\system\user\group\assignment\UserGroupAssignmentHandler;
-use wcf\system\WCF;
-use wcf\util\StringUtil;
+use wcf\form\AbstractFormBuilderForm;
+use wcf\system\condition\provider\UserConditionProvider;
+use wcf\system\form\builder\container\ConditionFormContainer;
+use wcf\system\form\builder\container\FormContainer;
+use wcf\system\form\builder\field\BooleanFormField;
+use wcf\system\form\builder\field\SingleSelectionFormField;
+use wcf\system\form\builder\field\TextFormField;
/**
* Shows the form to create a new automatic user group assignment.
*
- * @author Matthias Schmidt
- * @copyright 2001-2019 WoltLab GmbH
+ * @author Olaf Braun, Matthias Schmidt
+ * @copyright 2001-2025 WoltLab GmbH
* @license GNU Lesser General Public License
+ *
+ * @extends AbstractFormBuilderForm
*/
-class UserGroupAssignmentAddForm extends AbstractForm
+class UserGroupAssignmentAddForm extends AbstractFormBuilderForm
{
/**
* @inheritDoc
*/
public $activeMenuItem = 'wcf.acp.menu.link.group.assignment.add';
- /**
- * list of grouped user group assignment condition object types
- * @var ObjectType[][]
- */
- public $conditions = [];
-
- /**
- * id of the selected user group
- * @var int
- */
- public $groupID = 0;
-
- /**
- * true if the automatic assignment is disabled
- * @var int
- */
- public $isDisabled = 0;
-
/**
* @inheritDoc
*/
public $neededPermissions = ['admin.user.canManageGroupAssignment'];
/**
- * title of the user group assignment
- * @var string
- */
- public $title = '';
-
- /**
- * list of selectable user groups
- * @var UserGroup[]
+ * @inheritDoc
*/
- public $userGroups = [];
+ public $objectActionClass = UserGroupAssignmentAction::class;
/**
* @inheritDoc
*/
- public function assignVariables()
- {
- parent::assignVariables();
+ public $objectEditLinkController = UserGroupAssignmentEditForm::class;
- WCF::getTPL()->assign([
- 'action' => 'add',
- 'groupedObjectTypes' => $this->conditions,
- 'groupID' => $this->groupID,
- 'isDisabled' => $this->isDisabled,
- 'title' => $this->title,
- 'userGroups' => $this->userGroups,
+ #[\Override]
+ public function createForm()
+ {
+ parent::createForm();
+
+ $this->form->appendChildren([
+ FormContainer::create('section')
+ ->appendChildren([
+ TextFormField::create('title')
+ ->label('wcf.global.name')
+ ->maximumLength(255)
+ ->required(),
+ SingleSelectionFormField::create('groupID')
+ ->label('wcf.user.group')
+ ->required()
+ ->options($this->getUserGroups()),
+ BooleanFormField::create('isDisabled')
+ ->label('wcf.acp.group.assignment.isDisabled')
+ ->value(false),
+ ]),
+ ConditionFormContainer::create()
+ ->conditionProvider(new UserConditionProvider()),
]);
}
/**
- * @inheritDoc
+ * @return array
*/
- public function readData()
+ private function getUserGroups(): array
{
- $this->userGroups = UserGroup::getSortedGroupsByType([], [
+ $userGroups = UserGroup::getSortedGroupsByType([], [
UserGroup::EVERYONE,
UserGroup::GUESTS,
UserGroup::OWNER,
UserGroup::USERS,
]);
- foreach ($this->userGroups as $key => $userGroup) {
- if (!$userGroup->isAccessible()) {
- unset($this->userGroups[$key]);
- }
- }
-
- $this->conditions = UserGroupAssignmentHandler::getInstance()->getGroupedObjectTypes();
-
- parent::readData();
- }
-
- /**
- * @inheritDoc
- */
- public function readFormParameters()
- {
- parent::readFormParameters();
-
- if (isset($_POST['groupID'])) {
- $this->groupID = \intval($_POST['groupID']);
- }
- if (isset($_POST['isDisabled'])) {
- $this->isDisabled = 1;
- }
- if (isset($_POST['title'])) {
- $this->title = StringUtil::trim($_POST['title']);
- }
-
- foreach ($this->conditions as $conditions) {
- /** @var ObjectType $condition */
- foreach ($conditions as $condition) {
- $condition->getProcessor()->readFormParameters();
- }
- }
- }
-
- /**
- * @inheritDoc
- */
- public function save()
- {
- parent::save();
-
- $this->objectAction = new UserGroupAssignmentAction([], 'create', [
- 'data' => \array_merge($this->additionalFields, [
- 'groupID' => $this->groupID,
- 'isDisabled' => $this->isDisabled,
- 'title' => $this->title,
- ]),
- ]);
- $returnValues = $this->objectAction->executeAction();
-
- // transform conditions array into one-dimensional array
- $conditions = [];
- foreach ($this->conditions as $groupedObjectTypes) {
- $conditions = \array_merge($conditions, $groupedObjectTypes);
- }
-
- ConditionHandler::getInstance()->createConditions($returnValues['returnValues']->assignmentID, $conditions);
-
- $this->saved();
-
- // reset values
- $this->groupID = 0;
- $this->isDisabled = 0;
- $this->title = '';
-
- foreach ($this->conditions as $conditions) {
- foreach ($conditions as $condition) {
- $condition->getProcessor()->reset();
- }
- }
-
- WCF::getTPL()->assign([
- 'success' => true,
- 'objectEditLink' => LinkHandler::getInstance()->getControllerLink(
- UserGroupAssignmentEditForm::class,
- ['id' => $returnValues['returnValues']->assignmentID]
- ),
- ]);
- }
-
- /**
- * @inheritDoc
- */
- public function validate()
- {
- parent::validate();
-
- if (empty($this->title)) {
- throw new UserInputException('title');
- }
- if (\strlen($this->title) > 255) {
- throw new UserInputException('title', 'tooLong');
- }
-
- if (!isset($this->userGroups[$this->groupID])) {
- throw new UserInputException('groupID', 'noValidSelection');
- }
-
- $hasData = false;
- foreach ($this->conditions as $conditions) {
- foreach ($conditions as $condition) {
- $condition->getProcessor()->validate();
-
- if (!$hasData && $condition->getProcessor()->getData() !== null) {
- $hasData = true;
- }
- }
- }
- if (!$hasData) {
- throw new UserInputException('conditions');
- }
+ return \array_filter($userGroups, static fn ($userGroup) => $userGroup->isAccessible());
}
}
diff --git a/wcfsetup/install/files/lib/acp/form/UserGroupAssignmentEditForm.class.php b/wcfsetup/install/files/lib/acp/form/UserGroupAssignmentEditForm.class.php
index 952b7d452b5..ee4e11dd9b1 100644
--- a/wcfsetup/install/files/lib/acp/form/UserGroupAssignmentEditForm.class.php
+++ b/wcfsetup/install/files/lib/acp/form/UserGroupAssignmentEditForm.class.php
@@ -4,20 +4,19 @@
use wcf\acp\page\UserGroupAssignmentListPage;
use wcf\data\user\group\assignment\UserGroupAssignment;
-use wcf\data\user\group\assignment\UserGroupAssignmentAction;
-use wcf\form\AbstractForm;
-use wcf\system\condition\ConditionHandler;
use wcf\system\exception\IllegalLinkException;
+use wcf\system\exception\NamedUserException;
use wcf\system\interaction\admin\UserGroupAssignmentInteractions;
use wcf\system\interaction\StandaloneInteractionContextMenuComponent;
use wcf\system\request\LinkHandler;
use wcf\system\WCF;
+use wcf\util\HtmlString;
/**
* Shows the form to edit an existing automatic user group assignment.
*
- * @author Matthias Schmidt
- * @copyright 2001-2019 WoltLab GmbH
+ * @author Olaf Braun, Matthias Schmidt
+ * @copyright 2001-2025 WoltLab GmbH
* @license GNU Lesser General Public License
*/
class UserGroupAssignmentEditForm extends UserGroupAssignmentAddForm
@@ -27,54 +26,10 @@ class UserGroupAssignmentEditForm extends UserGroupAssignmentAddForm
*/
public $activeMenuItem = 'wcf.acp.menu.link.group.assignment';
- /**
- * edited automatic user group assignment
- * @var UserGroupAssignment
- */
- public $assignment;
-
- /**
- * id of the edited automatic user group assignment
- * @var int
- */
- public $assignmentID = 0;
-
- /**
- * @inheritDoc
- */
- public function assignVariables()
- {
- parent::assignVariables();
-
- WCF::getTPL()->assign([
- 'action' => 'edit',
- 'assignment' => $this->assignment,
- 'interactionContextMenu' => StandaloneInteractionContextMenuComponent::forContentHeaderButton(
- new UserGroupAssignmentInteractions(),
- $this->assignment,
- LinkHandler::getInstance()->getControllerLink(UserGroupAssignmentListPage::class)
- ),
- ]);
- }
-
/**
* @inheritDoc
*/
- public function readData()
- {
- parent::readData();
-
- if (empty($_POST)) {
- $this->groupID = $this->assignment->groupID;
- $this->title = $this->assignment->title;
-
- $conditions = $this->assignment->getConditions();
- foreach ($conditions as $condition) {
- /** @noinspection PhpUndefinedMethodInspection */
- $this->conditions[$condition->getObjectType()->conditiongroup][$condition->objectTypeID]->getProcessor()->setData($condition);
- }
- }
- }
+ public $formAction = 'edit';
/**
* @inheritDoc
@@ -83,45 +38,35 @@ public function readParameters()
{
parent::readParameters();
- if (isset($_REQUEST['id'])) {
- $this->assignmentID = \intval($_REQUEST['id']);
+ if (!isset($_REQUEST['id'])) {
+ throw new IllegalLinkException();
}
- $this->assignment = new UserGroupAssignment($this->assignmentID);
- if (!$this->assignment->assignmentID) {
+
+ $this->formObject = new UserGroupAssignment(\intval($_REQUEST['id']));
+ if (!$this->formObject->assignmentID) {
throw new IllegalLinkException();
}
+
+ if ($this->formObject->isLegacy) {
+ throw new NamedUserException(
+ HtmlString::fromSafeHtml(WCF::getLanguage()->getDynamicVariable('wcf.acp.group.assignment.legacyNotice'))
+ );
+ }
}
/**
* @inheritDoc
*/
- public function save()
+ public function assignVariables()
{
- AbstractForm::save();
+ parent::assignVariables();
- $this->objectAction = new UserGroupAssignmentAction([$this->assignment], 'update', [
- 'data' => \array_merge($this->additionalFields, [
- 'groupID' => $this->groupID,
- 'isDisabled' => $this->isDisabled,
- 'title' => $this->title,
- ]),
+ WCF::getTPL()->assign([
+ 'interactionContextMenu' => StandaloneInteractionContextMenuComponent::forContentHeaderButton(
+ new UserGroupAssignmentInteractions(),
+ $this->formObject,
+ LinkHandler::getInstance()->getControllerLink(UserGroupAssignmentListPage::class)
+ ),
]);
- $this->objectAction->executeAction();
-
- // transform conditions array into one-dimensional array
- $conditions = [];
- foreach ($this->conditions as $groupedObjectTypes) {
- $conditions = \array_merge($conditions, $groupedObjectTypes);
- }
-
- ConditionHandler::getInstance()->updateConditions(
- $this->assignment->assignmentID,
- $this->assignment->getConditions(),
- $conditions
- );
-
- $this->saved();
-
- WCF::getTPL()->assign('success', true);
}
}
diff --git a/wcfsetup/install/files/lib/acp/page/UserGroupAssignmentListPage.class.php b/wcfsetup/install/files/lib/acp/page/UserGroupAssignmentListPage.class.php
index d296fd146b0..baa50dbcfe9 100644
--- a/wcfsetup/install/files/lib/acp/page/UserGroupAssignmentListPage.class.php
+++ b/wcfsetup/install/files/lib/acp/page/UserGroupAssignmentListPage.class.php
@@ -4,6 +4,7 @@
use wcf\page\AbstractGridViewPage;
use wcf\system\gridView\admin\UserGroupAssignmentGridView;
+use wcf\system\WCF;
/**
* Lists the available automatic user group assignments.
@@ -31,4 +32,25 @@ protected function createGridView(): UserGroupAssignmentGridView
{
return new UserGroupAssignmentGridView();
}
+
+ #[\Override]
+ public function assignVariables()
+ {
+ parent::assignVariables();
+
+ WCF::getTPL()->assign([
+ 'hasLegacyObjects' => $this->hasLegacyObjects(),
+ ]);
+ }
+
+ private function hasLegacyObjects(): bool
+ {
+ $sql = "SELECT COUNT(*) AS count
+ FROM wcf1_user_group_assignment
+ WHERE isLegacy = ?";
+ $statement = WCF::getDB()->prepare($sql);
+ $statement->execute([1]);
+
+ return $statement->fetchColumn() > 0;
+ }
}
diff --git a/wcfsetup/install/files/lib/action/ConditionAddAction.class.php b/wcfsetup/install/files/lib/action/ConditionAddAction.class.php
new file mode 100644
index 00000000000..83c4c2772b7
--- /dev/null
+++ b/wcfsetup/install/files/lib/action/ConditionAddAction.class.php
@@ -0,0 +1,108 @@
+
+ * @since 6.3
+ */
+final class ConditionAddAction implements RequestHandlerInterface
+{
+ #[\Override]
+ public function handle(ServerRequestInterface $request): ResponseInterface
+ {
+ $parameters = Helper::mapQueryParameters(
+ $request->getQueryParams(),
+ <<<'EOT'
+ array {
+ provider: non-empty-string,
+ containerId: non-empty-string,
+ index: int,
+ }
+ EOT
+ );
+
+ if (!\is_subclass_of($parameters['provider'], AbstractConditionProvider::class)) {
+ throw new UserInputException('provider', 'invalid');
+ }
+
+ /** @var AbstractConditionProvider> $provider */
+ $provider = new $parameters['provider']();
+
+ $form = $this->getForm($provider);
+
+ if ($request->getMethod() === 'GET') {
+ return $form->toResponse();
+ } elseif ($request->getMethod() === 'POST') {
+ $response = $form->validateRequest($request);
+ if ($response !== null) {
+ return $response;
+ }
+
+ $data = $form->getData()['data'];
+ $condition = $provider->getConditionByIdentifier($data['conditionType']);
+ \assert($condition instanceof IConditionType);
+
+ $document = FormDocument::create('tmpForm');
+
+ return new JsonResponse([
+ 'result' => [
+ 'field' => $provider->getConditionFormField($parameters['containerId'], $data['conditionType'], $parameters['index'])
+ ->parent($document)
+ ->getHtml(),
+ 'conditionType' => $data['conditionType'],
+ ],
+ ]);
+ } else {
+ throw new \LogicException('Unreachable');
+ }
+ }
+
+ /**
+ * @param AbstractConditionProvider> $provider
+ */
+ private function getForm(AbstractConditionProvider $provider): Psr15DialogForm
+ {
+ $form = new Psr15DialogForm(
+ self::class,
+ WCF::getLanguage()->get('wcf.condition.add')
+ );
+ $options = \array_map(
+ static fn (IConditionType $conditionType) => WCF::getLanguage()->get($conditionType->getLabel()),
+ $provider->getConditionTypes()
+ );
+ $collator = new \Collator(WCF::getLanguage()->getLocale());
+ \uasort(
+ $options,
+ static fn (string $a, string $b) => $collator->compare($a, $b)
+ );
+
+ $form->appendChild(
+ SingleSelectionFormField::create('conditionType')
+ ->label('wcf.condition.condition')
+ ->filterable()
+ ->required()
+ ->options($options)
+ );
+
+ $form->markRequiredFields(false);
+ $form->build();
+
+ return $form;
+ }
+}
diff --git a/wcfsetup/install/files/lib/bootstrap/com.woltlab.wcf.php b/wcfsetup/install/files/lib/bootstrap/com.woltlab.wcf.php
index e3205fd1895..6fd9e328f4c 100644
--- a/wcfsetup/install/files/lib/bootstrap/com.woltlab.wcf.php
+++ b/wcfsetup/install/files/lib/bootstrap/com.woltlab.wcf.php
@@ -100,6 +100,7 @@ static function (\wcf\event\worker\RebuildWorkerCollecting $event) {
$event->register(\wcf\system\worker\UnfurlUrlRebuildDataWorker::class, 450);
$event->register(\wcf\system\worker\FileRebuildDataWorker::class, 475);
$event->register(\wcf\system\worker\SitemapRebuildWorker::class, 500);
+ $event->register(\wcf\system\worker\UserGroupAssignmentRebuildDataWorker::class, 600);
$event->register(\wcf\system\worker\StatDailyRebuildDataWorker::class, 800);
}
);
diff --git a/wcfsetup/install/files/lib/data/user/group/UserGroupEditor.class.php b/wcfsetup/install/files/lib/data/user/group/UserGroupEditor.class.php
index a2cb30115c1..80fe5869ea8 100644
--- a/wcfsetup/install/files/lib/data/user/group/UserGroupEditor.class.php
+++ b/wcfsetup/install/files/lib/data/user/group/UserGroupEditor.class.php
@@ -4,9 +4,9 @@
use wcf\data\DatabaseObjectEditor;
use wcf\data\IEditableCachedObject;
-use wcf\system\cache\builder\UserGroupAssignmentCacheBuilder;
use wcf\system\cache\builder\UserGroupCacheBuilder;
use wcf\system\cache\builder\UserGroupPermissionCacheBuilder;
+use wcf\system\cache\eager\UserGroupAssignmentCache;
use wcf\system\exception\SystemException;
use wcf\system\user\storage\UserStorageHandler;
use wcf\system\WCF;
@@ -208,7 +208,7 @@ public static function resetCache()
UserGroupPermissionCacheBuilder::getInstance()->reset();
// https://github.com/WoltLab/WCF/issues/4045
- UserGroupAssignmentCacheBuilder::getInstance()->reset();
+ (new UserGroupAssignmentCache())->rebuild();
// Clear cached group assignments.
UserStorageHandler::getInstance()->resetAll('groupIDs');
diff --git a/wcfsetup/install/files/lib/data/user/group/assignment/UserGroupAssignment.class.php b/wcfsetup/install/files/lib/data/user/group/assignment/UserGroupAssignment.class.php
index 476227afedc..e2be3ebac6f 100644
--- a/wcfsetup/install/files/lib/data/user/group/assignment/UserGroupAssignment.class.php
+++ b/wcfsetup/install/files/lib/data/user/group/assignment/UserGroupAssignment.class.php
@@ -2,11 +2,10 @@
namespace wcf\data\user\group\assignment;
-use wcf\data\condition\Condition;
use wcf\data\DatabaseObject;
use wcf\data\user\group\UserGroup;
-use wcf\system\condition\ConditionHandler;
use wcf\system\request\IRouteController;
+use wcf\util\JSON;
/**
* Represents an automatic assignment to a user group.
@@ -19,20 +18,19 @@
* @property-read int $groupID id of the user group to which users are automatically assigned
* @property-read string $title title of the automatic user group assignment
* @property-read int $isDisabled is `1` if the user group assignment is disabled and thus not checked for automatic assignments, otherwise `0`
+ * @property-read string $conditions JSON-encoded string containing the conditions of the automatic user group assignment
+ * @property-read bool $isLegacy indicates whether the conditions need to be migrated to the new format
*/
class UserGroupAssignment extends DatabaseObject implements IRouteController
{
/**
* Returns the conditions of the automatic assignment to a user group.
*
- * @return Condition[]
+ * @return array{identifier: string, value: mixed}[]
*/
- public function getConditions()
+ public function getConditions(): array
{
- return ConditionHandler::getInstance()->getConditions(
- 'com.woltlab.wcf.condition.userGroupAssignment',
- $this->assignmentID
- );
+ return JSON::decode($this->conditions);
}
/**
diff --git a/wcfsetup/install/files/lib/data/user/group/assignment/UserGroupAssignmentAction.class.php b/wcfsetup/install/files/lib/data/user/group/assignment/UserGroupAssignmentAction.class.php
index f900e1b5e2d..9abcb3cdac6 100644
--- a/wcfsetup/install/files/lib/data/user/group/assignment/UserGroupAssignmentAction.class.php
+++ b/wcfsetup/install/files/lib/data/user/group/assignment/UserGroupAssignmentAction.class.php
@@ -5,7 +5,6 @@
use wcf\data\AbstractDatabaseObjectAction;
use wcf\data\IToggleAction;
use wcf\data\TDatabaseObjectToggle;
-use wcf\system\condition\ConditionHandler;
/**
* Executes user group assignment-related actions.
@@ -34,17 +33,4 @@ class UserGroupAssignmentAction extends AbstractDatabaseObjectAction implements
* @inheritDoc
*/
protected $requireACP = ['create', 'delete', 'toggle', 'update'];
-
- /**
- * @inheritDoc
- */
- public function delete()
- {
- ConditionHandler::getInstance()->deleteConditions(
- 'com.woltlab.wcf.condition.userGroupAssignment',
- $this->objectIDs
- );
-
- return parent::delete();
- }
}
diff --git a/wcfsetup/install/files/lib/data/user/group/assignment/UserGroupAssignmentEditor.class.php b/wcfsetup/install/files/lib/data/user/group/assignment/UserGroupAssignmentEditor.class.php
index d7cb60e334b..ccadb8fb5c5 100644
--- a/wcfsetup/install/files/lib/data/user/group/assignment/UserGroupAssignmentEditor.class.php
+++ b/wcfsetup/install/files/lib/data/user/group/assignment/UserGroupAssignmentEditor.class.php
@@ -4,9 +4,7 @@
use wcf\data\DatabaseObjectEditor;
use wcf\data\IEditableCachedObject;
-use wcf\data\object\type\ObjectTypeCache;
-use wcf\system\cache\builder\ConditionCacheBuilder;
-use wcf\system\cache\builder\UserGroupAssignmentCacheBuilder;
+use wcf\system\cache\eager\UserGroupAssignmentCache;
/**
* Executes user group assignment-related actions.
@@ -31,9 +29,6 @@ class UserGroupAssignmentEditor extends DatabaseObjectEditor implements IEditabl
*/
public static function resetCache()
{
- UserGroupAssignmentCacheBuilder::getInstance()->reset();
- ConditionCacheBuilder::getInstance()->reset([
- 'definitionID' => ObjectTypeCache::getInstance()->getDefinitionByName('com.woltlab.wcf.condition.userGroupAssignment')->definitionID,
- ]);
+ (new UserGroupAssignmentCache())->rebuild();
}
}
diff --git a/wcfsetup/install/files/lib/event/acp/dashboard/box/MigrationCollecting.class.php b/wcfsetup/install/files/lib/event/acp/dashboard/box/MigrationCollecting.class.php
new file mode 100644
index 00000000000..d7cc204ece6
--- /dev/null
+++ b/wcfsetup/install/files/lib/event/acp/dashboard/box/MigrationCollecting.class.php
@@ -0,0 +1,37 @@
+
+ * @since 6.2
+ */
+final class MigrationCollecting implements IPsr14Event
+{
+ /**
+ * @var string[]
+ */
+ private array $needsMigration = [];
+
+ /**
+ * Adds the name of objects that still need to be migrated on the `RebuildDataPage`
+ */
+ public function migrationNeeded(string $title): void
+ {
+ $this->needsMigration[] = $title;
+ }
+
+ /**
+ * @return string[]
+ */
+ public function needsMigration(): array
+ {
+ return $this->needsMigration;
+ }
+}
diff --git a/wcfsetup/install/files/lib/event/condition/provider/UserConditionProviderCollecting.class.php b/wcfsetup/install/files/lib/event/condition/provider/UserConditionProviderCollecting.class.php
new file mode 100644
index 00000000000..ccaf113baa1
--- /dev/null
+++ b/wcfsetup/install/files/lib/event/condition/provider/UserConditionProviderCollecting.class.php
@@ -0,0 +1,21 @@
+
+ * @since 6.3
+ */
+final class UserConditionProviderCollecting implements IPsr14Event
+{
+ public function __construct(public readonly UserConditionProvider $provider)
+ {
+ }
+}
diff --git a/wcfsetup/install/files/lib/system/acp/dashboard/box/StatusMessageAcpDashboardBox.class.php b/wcfsetup/install/files/lib/system/acp/dashboard/box/StatusMessageAcpDashboardBox.class.php
index c74b847361e..c70653a43b8 100644
--- a/wcfsetup/install/files/lib/system/acp/dashboard/box/StatusMessageAcpDashboardBox.class.php
+++ b/wcfsetup/install/files/lib/system/acp/dashboard/box/StatusMessageAcpDashboardBox.class.php
@@ -3,6 +3,7 @@
namespace wcf\system\acp\dashboard\box;
use wcf\data\devtools\missing\language\item\DevtoolsMissingLanguageItemList;
+use wcf\event\acp\dashboard\box\MigrationCollecting;
use wcf\event\acp\dashboard\box\PHPExtensionCollecting;
use wcf\event\acp\dashboard\box\StatusMessageCollecting;
use wcf\system\application\ApplicationHandler;
@@ -60,6 +61,7 @@ private function getMessages(): array
$this->getPHPExtensionMessage(),
$this->getEvaluationMessages(),
$this->getBasicMessages(),
+ $this->getMigrationMessage(),
$this->getCustomMessages()
);
}
@@ -271,4 +273,42 @@ private function getPHPExtensionMessage(): array
return [];
}
+
+ /**
+ * @return StatusMessage[]
+ *
+ * @since 6.2
+ */
+ private function getMigrationMessage(): array
+ {
+ $event = new MigrationCollecting();
+ EventHandler::getInstance()->fire($event);
+ if ($this->userGroupAssignmentHasLegacyObjects()) {
+ $event->migrationNeeded(WCF::getLanguage()->get('wcf.acp.group.assignment'));
+ }
+
+ if ($event->needsMigration() === []) {
+ return [];
+ }
+
+ return [
+ new StatusMessage(
+ StatusMessageType::Warning,
+ WCF::getLanguage()->getDynamicVariable('wcf.acp.dashboard.box.migrationNeeded', [
+ 'titles' => $event->needsMigration(),
+ ])
+ ),
+ ];
+ }
+
+ private function userGroupAssignmentHasLegacyObjects(): bool
+ {
+ $sql = "SELECT COUNT(*) AS count
+ FROM wcf1_user_group_assignment
+ WHERE isLegacy = ?";
+ $statement = WCF::getDB()->prepare($sql);
+ $statement->execute([1]);
+
+ return $statement->fetchColumn() > 0;
+ }
}
diff --git a/wcfsetup/install/files/lib/system/cache/builder/UserGroupAssignmentCacheBuilder.class.php b/wcfsetup/install/files/lib/system/cache/builder/UserGroupAssignmentCacheBuilder.class.php
index 589b86aa876..2283ec66597 100644
--- a/wcfsetup/install/files/lib/system/cache/builder/UserGroupAssignmentCacheBuilder.class.php
+++ b/wcfsetup/install/files/lib/system/cache/builder/UserGroupAssignmentCacheBuilder.class.php
@@ -2,7 +2,7 @@
namespace wcf\system\cache\builder;
-use wcf\data\user\group\assignment\UserGroupAssignmentList;
+use wcf\system\cache\eager\UserGroupAssignmentCache;
/**
* Caches the enabled automatic user group assignments.
@@ -10,18 +10,20 @@
* @author Matthias Schmidt
* @copyright 2001-2019 WoltLab GmbH
* @license GNU Lesser General Public License
+ *
+ * @deprecated 6.2 use `UserGroupAssignmentCache` instead
*/
-class UserGroupAssignmentCacheBuilder extends AbstractCacheBuilder
+final class UserGroupAssignmentCacheBuilder extends AbstractLegacyCacheBuilder
{
- /**
- * @inheritDoc
- */
- protected function rebuild(array $parameters)
+ #[\Override]
+ protected function rebuild(array $parameters): array
{
- $assignmentList = new UserGroupAssignmentList();
- $assignmentList->getConditionBuilder()->add('isDisabled = ?', [0]);
- $assignmentList->readObjects();
+ return (new UserGroupAssignmentCache())->getCache();
+ }
- return $assignmentList->getObjects();
+ #[\Override]
+ public function reset(array $parameters = [])
+ {
+ (new UserGroupAssignmentCache())->rebuild();
}
}
diff --git a/wcfsetup/install/files/lib/system/cache/eager/UserGroupAssignmentCache.class.php b/wcfsetup/install/files/lib/system/cache/eager/UserGroupAssignmentCache.class.php
new file mode 100644
index 00000000000..ee6219b1bca
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/cache/eager/UserGroupAssignmentCache.class.php
@@ -0,0 +1,30 @@
+
+ * @since 6.3
+ *
+ * @extends AbstractEagerCache>
+ */
+final class UserGroupAssignmentCache extends AbstractEagerCache
+{
+ #[\Override]
+ protected function getCacheData(): array
+ {
+ $assignmentList = new UserGroupAssignmentList();
+ $assignmentList->getConditionBuilder()->add('isDisabled = ?', [0]);
+ $assignmentList->getConditionBuilder()->add('isLegacy = ?', [0]);
+ $assignmentList->readObjects();
+
+ return $assignmentList->getObjects();
+ }
+}
diff --git a/wcfsetup/install/files/lib/system/condition/ConditionHandler.class.php b/wcfsetup/install/files/lib/system/condition/ConditionHandler.class.php
index e4d17cbc09b..cf0e193a276 100644
--- a/wcfsetup/install/files/lib/system/condition/ConditionHandler.class.php
+++ b/wcfsetup/install/files/lib/system/condition/ConditionHandler.class.php
@@ -8,17 +8,22 @@
use wcf\data\object\type\ObjectType;
use wcf\data\object\type\ObjectTypeCache;
use wcf\system\cache\builder\ConditionCacheBuilder;
+use wcf\system\condition\provider\AbstractConditionProvider;
+use wcf\system\condition\type\IConditionType;
+use wcf\system\condition\type\IMigrateConditionType;
+use wcf\system\database\util\PreparedStatementConditionBuilder;
use wcf\system\exception\SystemException;
use wcf\system\SingletonFactory;
+use wcf\system\WCF;
/**
* Handles general condition-related matters.
*
- * @author Matthias Schmidt
- * @copyright 2001-2019 WoltLab GmbH
+ * @author Olaf Braun, Matthias Schmidt
+ * @copyright 2001-2025 WoltLab GmbH
* @license GNU Lesser General Public License
*/
-class ConditionHandler extends SingletonFactory
+final class ConditionHandler extends SingletonFactory
{
/**
* list of available conditions grouped by the id of the related condition
@@ -139,4 +144,114 @@ public function updateConditions($objectID, array $oldConditions, array $conditi
// create new conditions
$this->createConditions($objectID, $conditionObjectTypes);
}
+
+ /**
+ * Returns the list of conditions with assigned filter for the condition provider and stored condition-values.
+ *
+ * @template TCondition of IConditionType
+ * @param AbstractConditionProvider $provider
+ * @param array{identifier: string, value: mixed}[] $conditions
+ *
+ * @return TCondition[]
+ */
+ public function getConditionsWithFilter(AbstractConditionProvider $provider, array $conditions): array
+ {
+ $result = [];
+ foreach ($conditions as $condition) {
+ $_conditionType = $provider->getConditionByIdentifier($condition['identifier']);
+ if ($_conditionType === null) {
+ if (ENABLE_DEBUG_MODE && ENABLE_DEVELOPER_TOOLS) {
+ throw new \InvalidArgumentException("Condition type with identifier '{$condition['identifier']}' not found.");
+ }
+
+ continue;
+ }
+
+ $conditionType = clone $_conditionType;
+ $conditionType->setFilter($condition['value']);
+
+ $result[] = $conditionType;
+ }
+
+ return $result;
+ }
+
+ /**
+ * Exports the conditions for all objects that belong to the specified object type definition.
+ *
+ * @return array>
+ */
+ public function exportConditions(string $definitionName): array
+ {
+ $objectTypes = ObjectTypeCache::getInstance()->getObjectTypes($definitionName);
+ if ($objectTypes === []) {
+ return [];
+ }
+
+ $conditionBuilder = new PreparedStatementConditionBuilder();
+ $conditionBuilder->add('objectTypeID IN (?)', [
+ \array_column($objectTypes, 'objectTypeID'),
+ ]);
+
+ $sql = "SELECT *
+ FROM wcf1_condition
+ {$conditionBuilder}";
+ $statement = WCF::getDB()->prepare($sql);
+ $statement->execute($conditionBuilder->getParameters());
+
+ $result = [];
+ while ($row = $statement->fetchArray()) {
+ $result[$row['objectID']] ??= [];
+ $result[$row['objectID']][ObjectTypeCache::getInstance()->getObjectType($row['objectTypeID'])->objectType] = \unserialize($row['conditionData']);
+ }
+
+ return $result;
+ }
+
+ /**
+ * The stored data from the `wcf1_condition` table is migrated to the new format.
+ * The key of `$conditionData` is the type of condition (objectType), the value is the content of the `wcf1_condition.conditionData` column, unserialize as an array.
+ *
+ * @template TCondition of IConditionType
+ * @param AbstractConditionProvider $provider
+ * @param array> $conditionData
+ */
+ public function migrateConditionData(AbstractConditionProvider $provider, array $conditionData): ConditionMigration
+ {
+ if ($conditionData === []) {
+ return ConditionMigration::withoutData();
+ }
+
+ $migratedData = [];
+ /** @var IMigrateConditionType[] $conditionTypes */
+ $conditionTypes = \array_filter(
+ $provider->getConditionTypes(),
+ static fn (IConditionType $condition): bool => $condition instanceof IMigrateConditionType
+ );
+
+ if ($conditionTypes === []) {
+ return ConditionMigration::withoutData();
+ }
+
+ foreach ($conditionData as $objectType => &$condition) {
+ foreach ($conditionTypes as $conditionType) {
+ if (!$conditionType->canMigrateConditionData($objectType)) {
+ continue;
+ }
+
+ \array_push(
+ $migratedData,
+ ...$conditionType->migrateConditionData($condition)
+ );
+
+ if ($condition === []) {
+ unset($conditionData[$objectType]);
+ break;
+ }
+ }
+ }
+ unset($condition);
+
+ return ConditionMigration::fromData($conditionData, $migratedData);
+ }
}
diff --git a/wcfsetup/install/files/lib/system/condition/ConditionMigration.class.php b/wcfsetup/install/files/lib/system/condition/ConditionMigration.class.php
new file mode 100644
index 00000000000..e07130dad89
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/condition/ConditionMigration.class.php
@@ -0,0 +1,38 @@
+
+ * @since 6.2
+ */
+final class ConditionMigration
+{
+ private function __construct(
+ public readonly bool $isFullyMigrated,
+ /** @var array{identifier: string, value: mixed}[] */
+ public readonly array $conditions,
+ ) {
+ }
+
+ /**
+ * Creates a new ConditionMigration instance based on condition data and conditions.
+ *
+ * @param array{identifier: string, value: mixed}[] $previousConditionData
+ * @param array{identifier: string, value: mixed}[] $migratedConditionData
+ */
+ public static function fromData(array $previousConditionData, array $migratedConditionData): self
+ {
+ return new self($previousConditionData === [], $migratedConditionData);
+ }
+
+ /**
+ * Creates a new ConditionMigration instance for empty data.
+ */
+ public static function withoutData(): self
+ {
+ return new self(true, []);
+ }
+}
diff --git a/wcfsetup/install/files/lib/system/condition/provider/AbstractConditionProvider.class.php b/wcfsetup/install/files/lib/system/condition/provider/AbstractConditionProvider.class.php
new file mode 100644
index 00000000000..1d26c45869e
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/condition/provider/AbstractConditionProvider.class.php
@@ -0,0 +1,77 @@
+
+ * @since 6.3
+ *
+ * @template TCondition of IConditionType
+ */
+abstract class AbstractConditionProvider
+{
+ /**
+ * @var array
+ */
+ protected array $conditionTypes = [];
+
+ /**
+ * Adds a condition type to this provider.
+ *
+ * @param TCondition $conditionType
+ */
+ public function addCondition(IConditionType $conditionType): void
+ {
+ $this->conditionTypes[$conditionType->getIdentifier()] = $conditionType;
+ }
+
+ final public function getFieldId(string $containerId, string $identifier, int $index): string
+ {
+ return "{$containerId}_{$identifier}_{$index}";
+ }
+
+ final public function getConditionFormField(string $containerId, string $identifier, int $index): FormContainer
+ {
+ $condition = $this->getConditionByIdentifier($identifier);
+ if ($condition === null) {
+ throw new \InvalidArgumentException("Condition type with identifier '{$identifier}' not found.");
+ }
+
+ $id = $this->getFieldId($containerId, $identifier, $index);
+ $formField = $condition->getFormField($id)
+ ->label($condition->getLabel());
+
+ return FormContainer::create("{$id}_container")
+ ->removeClass("section")
+ ->addClass("condition__container")
+ ->attribute("data-container-id", $containerId)
+ ->attribute("data-condition-type", $identifier)
+ ->attribute("data-condition-index", (string)$index)
+ ->appendChild($formField);
+ }
+
+ /**
+ * Returns the condition type with the given identifier.
+ *
+ * @return TCondition|null
+ */
+ public function getConditionByIdentifier(string $identifier): ?IConditionType
+ {
+ return $this->conditionTypes[$identifier] ?? null;
+ }
+
+ /**
+ * Returns all condition types of this provider.
+ *
+ * @return array
+ */
+ public function getConditionTypes(): array
+ {
+ return $this->conditionTypes;
+ }
+}
diff --git a/wcfsetup/install/files/lib/system/condition/provider/UserConditionProvider.class.php b/wcfsetup/install/files/lib/system/condition/provider/UserConditionProvider.class.php
new file mode 100644
index 00000000000..1e622a03036
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/condition/provider/UserConditionProvider.class.php
@@ -0,0 +1,148 @@
+
+ * @since 6.3
+ *
+ * @extends AbstractConditionProvider&IObjectConditionType>
+ */
+final class UserConditionProvider extends AbstractConditionProvider
+{
+ public function __construct()
+ {
+ $this->addCondition(
+ new StringUserConditionType(
+ identifier: "username",
+ columnName: "username",
+ migrateKeyName: "username",
+ migrateConditionObjectType: 'com.woltlab.wcf.user.username'
+ ),
+ );
+ $this->addCondition(
+ new StringUserConditionType(
+ identifier: "email",
+ columnName: "email",
+ migrateKeyName: "email",
+ migrateConditionObjectType: 'com.woltlab.wcf.user.email'
+ ),
+ );
+ $this->addCondition(
+ new RegistrationDateUserConditionType(),
+ );
+ $this->addCondition(
+ new RegistrationDaysUserConditionType(),
+ );
+ $this->addCondition(
+ new InGroupUserConditionType(),
+ );
+ $this->addCondition(
+ new NotInGroupUserConditionType(),
+ );
+ $this->addCondition(
+ new LanguageUserConditionType(),
+ );
+ $this->addCondition(
+ new IsNullUserConditionType(
+ identifier: "avatar",
+ columnName: 'avatarFileID',
+ migrateKeyName: 'userAvatar',
+ migrateConditionObjectType: 'com.woltlab.wcf.user.avatar'
+ ),
+ );
+ $this->addCondition(
+ new SignatureUserConditionType(),
+ );
+ $this->addCondition(
+ new IsNullUserConditionType(
+ identifier: "coverPhoto",
+ columnName: 'coverPhotoFileID',
+ migrateKeyName: 'userCoverPhoto',
+ migrateConditionObjectType: 'com.woltlab.wcf.coverPhoto'
+ ),
+ );
+ $this->addCondition(
+ new BooleanUserConditionType(
+ identifier: "isBanned",
+ columnName: 'banned',
+ migrateKeyName: 'userIsBanned',
+ migrateConditionObjectType: 'com.woltlab.wcf.user.state'
+ ),
+ );
+ $this->addCondition(
+ new IsEnabledConditionType(),
+ );
+ $this->addCondition(
+ new IsNullUserConditionType(
+ identifier: "isEmailConfirmed",
+ columnName: 'emailConfirmed',
+ migrateKeyName: 'userIsEmailConfirmed',
+ migrateConditionObjectType: 'com.woltlab.wcf.user.state'
+ ),
+ );
+ $this->addCondition(
+ new BooleanUserConditionType(
+ identifier: "isMultifactorActive",
+ columnName: 'multifactorActive',
+ migrateKeyName: 'multifactorActive',
+ migrateConditionObjectType: 'com.woltlab.wcf.user.multifactor'
+ ),
+ );
+ $this->addCondition(
+ new HasTrophyUserConditionType(),
+ );
+ $this->addCondition(
+ new HasNotTrophyUserConditionType(),
+ );
+ $this->addCondition(
+ new IntegerUserConditionType(
+ identifier: "activityPoints",
+ columnName: "activityPoints",
+ migrateConditionObjectType: 'com.woltlab.wcf.user.activityPoints'
+ ),
+ );
+ $this->addCondition(
+ new IntegerUserConditionType(
+ identifier: "likesReceived",
+ columnName: "likesReceived",
+ migrateConditionObjectType: 'com.woltlab.wcf.user.likesReceived'
+ ),
+ );
+ $this->addCondition(
+ new IntegerUserConditionType(
+ identifier: "trophyPoints",
+ columnName: "trophyPoints",
+ migrateConditionObjectType: 'com.woltlab.wcf.user.trophyPoints'
+ ),
+ );
+
+ // TODO add conditions for user options that implement `ISearchableConditionUserOption`
+
+ EventHandler::getInstance()->fire(
+ new UserConditionProviderCollecting($this)
+ );
+ }
+}
diff --git a/wcfsetup/install/files/lib/system/condition/type/AbstractConditionType.class.php b/wcfsetup/install/files/lib/system/condition/type/AbstractConditionType.class.php
new file mode 100644
index 00000000000..92d16a15e7c
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/condition/type/AbstractConditionType.class.php
@@ -0,0 +1,25 @@
+
+ * @since 6.2
+ *
+ * @template TFilter
+ * @implements IConditionType
+ */
+abstract class AbstractConditionType implements IConditionType
+{
+ protected mixed $filter;
+
+ /**
+ * @inheritDoc
+ */
+ public function setFilter(mixed $filter): void
+ {
+ $this->filter = $filter;
+ }
+}
diff --git a/wcfsetup/install/files/lib/system/condition/type/IConditionType.class.php b/wcfsetup/install/files/lib/system/condition/type/IConditionType.class.php
new file mode 100644
index 00000000000..03b9aa4219a
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/condition/type/IConditionType.class.php
@@ -0,0 +1,39 @@
+
+ * @since 6.3
+ *
+ * @template TFilter
+ */
+interface IConditionType
+{
+ /**
+ * Returns the form field for this condition type.
+ */
+ public function getFormField(string $id): IFormField|IFormContainer;
+
+ /**
+ * Returns the identifier of this condition type.
+ */
+ public function getIdentifier(): string;
+
+ /**
+ * Returns the label of this condition type.
+ */
+ public function getLabel(): string;
+
+ /**
+ * Set the filter value for this condition type.
+ *
+ * @param TFilter $filter
+ */
+ public function setFilter(mixed $filter): void;
+}
diff --git a/wcfsetup/install/files/lib/system/condition/type/IDatabaseObjectListConditionType.class.php b/wcfsetup/install/files/lib/system/condition/type/IDatabaseObjectListConditionType.class.php
new file mode 100644
index 00000000000..645d49af06e
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/condition/type/IDatabaseObjectListConditionType.class.php
@@ -0,0 +1,25 @@
+
+ * @since 6.3
+ *
+ * @template TObjectList of DatabaseObjectList
+ * @template TFilter
+ * @extends IConditionType
+ */
+interface IDatabaseObjectListConditionType extends IConditionType
+{
+ /**
+ * Adds a filter to the given object list.
+ *
+ * @param TObjectList $objectList
+ */
+ public function applyFilter(DatabaseObjectList $objectList): void;
+}
diff --git a/wcfsetup/install/files/lib/system/condition/type/IMigrateConditionType.class.php b/wcfsetup/install/files/lib/system/condition/type/IMigrateConditionType.class.php
new file mode 100644
index 00000000000..ec0fa98196e
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/condition/type/IMigrateConditionType.class.php
@@ -0,0 +1,35 @@
+
+ * @since 6.3
+ */
+interface IMigrateConditionType
+{
+ /**
+ * Migrates old condition data to the new condition format by removing all successfully migrated entries from the `$conditionData`
+ * and returns a list of condition-data in the new structure. The remaining entries are assumed to be unprocessed and are handled
+ * by other condition types and must remain untouched.
+ *
+ * Note:
+ * - Remove entries that you have successfully migrated.
+ * - Leave unrecognized or unsupported entries untouched.
+ * - If no data can be migrated, return an empty array.
+ *
+ * This allows `ConditionHandler::migrateConditionData()` to check whether all data has been migrated correctly and completely.
+ *
+ * @param array $conditionData
+ *
+ * @return list
+ */
+ public function migrateConditionData(array &$conditionData): array;
+
+ /**
+ * Returns `true` if the method `migrateConditionData()` can migrate data for the given `$objectType` and `false` otherwise.
+ */
+ public function canMigrateConditionData(string $objectType): bool;
+}
diff --git a/wcfsetup/install/files/lib/system/condition/type/IObjectConditionType.class.php b/wcfsetup/install/files/lib/system/condition/type/IObjectConditionType.class.php
new file mode 100644
index 00000000000..5acee692822
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/condition/type/IObjectConditionType.class.php
@@ -0,0 +1,21 @@
+
+ * @since 6.3
+ *
+ * @template TObject of object
+ * @template TFilter
+ * @extends IConditionType
+ */
+interface IObjectConditionType extends IConditionType
+{
+ /**
+ * @param TObject $object
+ */
+ public function matches(object $object): bool;
+}
diff --git a/wcfsetup/install/files/lib/system/condition/type/user/BooleanUserConditionType.class.php b/wcfsetup/install/files/lib/system/condition/type/user/BooleanUserConditionType.class.php
new file mode 100644
index 00000000000..c2ee9504da4
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/condition/type/user/BooleanUserConditionType.class.php
@@ -0,0 +1,95 @@
+
+ * @since 6.3
+ *
+ * @implements IDatabaseObjectListConditionType, bool>
+ * @implements IObjectConditionType
+ * @extends AbstractConditionType
+ */
+class BooleanUserConditionType extends AbstractConditionType implements IDatabaseObjectListConditionType, IObjectConditionType, IMigrateConditionType
+{
+ public function __construct(
+ public readonly string $identifier,
+ public readonly string $columnName,
+ public readonly ?string $migrateKeyName = null,
+ public readonly ?string $migrateConditionObjectType = null,
+ ) {
+ }
+
+ #[\Override]
+ public function getIdentifier(): string
+ {
+ return $this->identifier;
+ }
+
+ #[\Override]
+ public function getLabel(): string
+ {
+ return "wcf.condition.user.{$this->identifier}";
+ }
+
+ #[\Override]
+ public function getFormField(string $id): BooleanFormField
+ {
+ return BooleanFormField::create($id);
+ }
+
+ #[\Override]
+ public function applyFilter(DatabaseObjectList $objectList): void
+ {
+ if ($this->filter) {
+ $objectList->getConditionBuilder()->add("{$objectList->getDatabaseTableAlias()}.{$this->columnName} = ?", [1]);
+ } else {
+ $objectList->getConditionBuilder()->add("{$objectList->getDatabaseTableAlias()}.{$this->columnName} = ?", [0]);
+ }
+ }
+
+ #[\Override]
+ public function matches(object $object): bool
+ {
+ if ($this->filter) {
+ return (bool)$object->{$this->columnName};
+ } else {
+ return !$object->{$this->columnName};
+ }
+ }
+
+ #[\Override]
+ public function migrateConditionData(array &$conditionData): array
+ {
+ $value = $conditionData[$this->columnName] ?? null;
+ if ($value === null) {
+ return [];
+ }
+
+ unset($conditionData[$this->migrateKeyName]);
+
+ return [
+ [
+ 'identifier' => $this->identifier,
+ 'value' => \boolval($value),
+ ],
+ ];
+ }
+
+ #[\Override]
+ public function canMigrateConditionData(string $objectType): bool
+ {
+ return $this->migrateConditionObjectType === $objectType;
+ }
+}
diff --git a/wcfsetup/install/files/lib/system/condition/type/user/HasNotTrophyUserConditionType.class.php b/wcfsetup/install/files/lib/system/condition/type/user/HasNotTrophyUserConditionType.class.php
new file mode 100644
index 00000000000..2c2306ae1b7
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/condition/type/user/HasNotTrophyUserConditionType.class.php
@@ -0,0 +1,117 @@
+
+ * @since 6.3
+ *
+ * @implements IDatabaseObjectListConditionType, string>
+ * @implements IObjectConditionType
+ * @extends AbstractConditionType
+ */
+final class HasNotTrophyUserConditionType extends AbstractConditionType implements IDatabaseObjectListConditionType, IObjectConditionType, IMigrateConditionType
+{
+ #[\Override]
+ public function getFormField(string $id): SelectFormField
+ {
+ // SelectFormField stores its value as a string,
+ // so we need to convert it to an integer in the `applyFilter`&`matches` method.
+ return SelectFormField::create($id)
+ ->options($this->getTrophies())
+ ->required();
+ }
+
+ #[\Override]
+ public function getIdentifier(): string
+ {
+ return 'hasNotTrophy';
+ }
+
+ #[\Override]
+ public function getLabel(): string
+ {
+ return 'wcf.condition.user.hasNotTrophy';
+ }
+
+ #[\Override]
+ public function applyFilter(DatabaseObjectList $objectList): void
+ {
+ $objectList->getConditionBuilder()->add(
+ "{$objectList->getDatabaseTableAlias()}.userID NOT IN (
+ SELECT userID
+ FROM wcf1_user_trophy
+ WHERE trophyID = ?
+ )",
+ [(int)$this->filter]
+ );
+ }
+
+ #[\Override]
+ public function matches(object $object): bool
+ {
+ $userTrophies = UserTrophyList::getUserTrophies([$object->userID], false)[$object->userID];
+ $trophyIDs = \array_column($userTrophies, 'trophyID');
+
+ return !\in_array((int)$this->filter, $trophyIDs, true);
+ }
+
+ /**
+ * @return Trophy[]
+ */
+ private function getTrophies(): array
+ {
+ $trophyList = new TrophyList();
+ $trophyList->readObjects();
+ $trophies = $trophyList->getObjects();
+
+ $collator = new \Collator(WCF::getLanguage()->getLocale());
+ \uasort(
+ $trophies,
+ static fn (Trophy $a, Trophy $b) => $collator->compare($a->getTitle(), $b->getTitle())
+ );
+
+ return $trophies;
+ }
+
+ #[\Override]
+ public function migrateConditionData(array &$conditionData): array
+ {
+ if (!isset($conditionData['notUserTrophyIDs'])) {
+ return [];
+ }
+
+ $result = [];
+ foreach ($conditionData['notUserTrophyIDs'] as $trophyID) {
+ $result[] = [
+ 'identifier' => $this->getIdentifier(),
+ 'value' => $trophyID,
+ ];
+ }
+
+ unset($conditionData['notUserTrophyIDs']);
+
+ return $result;
+ }
+
+ #[\Override]
+ public function canMigrateConditionData(string $objectType): bool
+ {
+ return $objectType === 'com.woltlab.wcf.user.userTrophyCondition';
+ }
+}
diff --git a/wcfsetup/install/files/lib/system/condition/type/user/HasTrophyUserConditionType.class.php b/wcfsetup/install/files/lib/system/condition/type/user/HasTrophyUserConditionType.class.php
new file mode 100644
index 00000000000..72b710fb7d5
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/condition/type/user/HasTrophyUserConditionType.class.php
@@ -0,0 +1,117 @@
+
+ * @since 6.3
+ *
+ * @implements IDatabaseObjectListConditionType, string>
+ * @implements IObjectConditionType
+ * @extends AbstractConditionType
+ */
+final class HasTrophyUserConditionType extends AbstractConditionType implements IDatabaseObjectListConditionType, IObjectConditionType, IMigrateConditionType
+{
+ #[\Override]
+ public function getFormField(string $id): SelectFormField
+ {
+ // SelectFormField stores its value as a string,
+ // so we need to convert it to an integer in the `applyFilter`&`matches` method.
+ return SelectFormField::create($id)
+ ->options($this->getTrophies())
+ ->required();
+ }
+
+ #[\Override]
+ public function getIdentifier(): string
+ {
+ return 'hasTrophy';
+ }
+
+ #[\Override]
+ public function getLabel(): string
+ {
+ return 'wcf.condition.user.hasTrophy';
+ }
+
+ #[\Override]
+ public function applyFilter(DatabaseObjectList $objectList): void
+ {
+ $objectList->getConditionBuilder()->add(
+ "{$objectList->getDatabaseTableAlias()}.userID IN (
+ SELECT userID
+ FROM wcf1_user_trophy
+ WHERE trophyID = ?
+ )",
+ [(int)$this->filter]
+ );
+ }
+
+ #[\Override]
+ public function matches(object $object): bool
+ {
+ $userTrophies = UserTrophyList::getUserTrophies([$object->userID], false)[$object->userID];
+ $trophyIDs = \array_column($userTrophies, 'trophyID');
+
+ return \in_array((int)$this->filter, $trophyIDs, true);
+ }
+
+ /**
+ * @return Trophy[]
+ */
+ private function getTrophies(): array
+ {
+ $trophyList = new TrophyList();
+ $trophyList->readObjects();
+ $trophies = $trophyList->getObjects();
+
+ $collator = new \Collator(WCF::getLanguage()->getLocale());
+ \uasort(
+ $trophies,
+ static fn (Trophy $a, Trophy $b) => $collator->compare($a->getTitle(), $b->getTitle())
+ );
+
+ return $trophies;
+ }
+
+ #[\Override]
+ public function migrateConditionData(array &$conditionData): array
+ {
+ if (!isset($conditionData['userTrophyIDs'])) {
+ return [];
+ }
+
+ $result = [];
+ foreach ($conditionData['userTrophyIDs'] as $trophyID) {
+ $result[] = [
+ 'identifier' => $this->getIdentifier(),
+ 'value' => $trophyID,
+ ];
+ }
+
+ unset($conditionData['userTrophyIDs']);
+
+ return $result;
+ }
+
+ #[\Override]
+ public function canMigrateConditionData(string $objectType): bool
+ {
+ return $objectType === 'com.woltlab.wcf.user.userTrophyCondition';
+ }
+}
diff --git a/wcfsetup/install/files/lib/system/condition/type/user/InGroupUserConditionType.class.php b/wcfsetup/install/files/lib/system/condition/type/user/InGroupUserConditionType.class.php
new file mode 100644
index 00000000000..3b57a266753
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/condition/type/user/InGroupUserConditionType.class.php
@@ -0,0 +1,99 @@
+
+ * @since 6.3
+ *
+ * @implements IDatabaseObjectListConditionType, string>
+ * @implements IObjectConditionType
+ * @extends AbstractConditionType
+ */
+final class InGroupUserConditionType extends AbstractConditionType implements IDatabaseObjectListConditionType, IObjectConditionType, IMigrateConditionType
+{
+ #[\Override]
+ public function getFormField(string $id): SelectFormField
+ {
+ // SelectFormField stores its value as a string,
+ // so we need to convert it to an integer in the `applyFilter`&`matches` method.
+ return SelectFormField::create($id)
+ ->options(
+ UserGroup::getGroupsByType(invalidGroupTypes: [
+ UserGroup::EVERYONE,
+ UserGroup::GUESTS,
+ UserGroup::USERS,
+ ])
+ )
+ ->required();
+ }
+
+ #[\Override]
+ public function getIdentifier(): string
+ {
+ return 'inGroup';
+ }
+
+ #[\Override]
+ public function getLabel(): string
+ {
+ return 'wcf.condition.user.inGroup';
+ }
+
+ #[\Override]
+ public function applyFilter(DatabaseObjectList $objectList): void
+ {
+ $objectList->getConditionBuilder()->add(
+ "{$objectList->getDatabaseTableAlias()}.userID IN (
+ SELECT userID
+ FROM wcf1_user_to_group
+ WHERE groupID = ?
+ )",
+ [(int)$this->filter]
+ );
+ }
+
+ #[\Override]
+ public function matches(object $object): bool
+ {
+ return \in_array((int)$this->filter, $object->getGroupIDs(), true);
+ }
+
+ #[\Override]
+ public function canMigrateConditionData(string $objectType): bool
+ {
+ return $objectType === 'com.woltlab.wcf.user.userGroup';
+ }
+
+ #[\Override]
+ public function migrateConditionData(array &$conditionData): array
+ {
+ if (!isset($conditionData['groupIDs'])) {
+ return [];
+ }
+
+ $result = [];
+ foreach ($conditionData['groupIDs'] as $groupID) {
+ $result[] = [
+ 'identifier' => $this->getIdentifier(),
+ 'value' => $groupID,
+ ];
+ }
+
+ unset($conditionData['groupIDs']);
+
+ return $result;
+ }
+}
diff --git a/wcfsetup/install/files/lib/system/condition/type/user/IntegerUserConditionType.class.php b/wcfsetup/install/files/lib/system/condition/type/user/IntegerUserConditionType.class.php
new file mode 100644
index 00000000000..196d520968f
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/condition/type/user/IntegerUserConditionType.class.php
@@ -0,0 +1,124 @@
+
+ * @since 6.3
+ *
+ * @phpstan-type Filter = array{condition: string, value: int}
+ * @implements IDatabaseObjectListConditionType, Filter>
+ * @implements IObjectConditionType
+ * @extends AbstractConditionType
+ */
+class IntegerUserConditionType extends AbstractConditionType implements IDatabaseObjectListConditionType, IObjectConditionType, IMigrateConditionType
+{
+ public function __construct(
+ public readonly string $identifier,
+ public readonly string $columnName,
+ public readonly ?string $migrateConditionObjectType = null,
+ ) {
+ }
+
+ #[\Override]
+ public function getFormField(string $id): PrefixConditionFormFieldContainer
+ {
+ return PrefixConditionFormFieldContainer::create($id)
+ ->field(
+ IntegerFormField::create("{$id}Value")
+ ->minimum(0)
+ ->required()
+ )
+ ->prefixField(
+ SingleSelectionFormField::create("{$id}Condition")
+ ->options(\array_combine($this->getConditions(), $this->getConditions()))
+ ->required()
+ );
+ }
+
+ #[\Override]
+ public function getIdentifier(): string
+ {
+ return $this->identifier;
+ }
+
+ #[\Override]
+ public function getLabel(): string
+ {
+ return "wcf.condition.user.{$this->identifier}";
+ }
+
+ #[\Override]
+ public function applyFilter(DatabaseObjectList $objectList): void
+ {
+ $objectList->getConditionBuilder()->add(
+ "{$objectList->getDatabaseTableAlias()}.{$this->columnName} {$this->filter['condition']} ?",
+ [$this->filter['value']]
+ );
+ }
+
+ #[\Override]
+ public function matches(object $object): bool
+ {
+ return match ($this->filter['condition']) {
+ '=' => $object->{$this->columnName} == $this->filter['value'],
+ '>' => $object->{$this->columnName} > $this->filter['value'],
+ '<' => $object->{$this->columnName} < $this->filter['value'],
+ '>=' => $object->{$this->columnName} >= $this->filter['value'],
+ '<=' => $object->{$this->columnName} <= $this->filter['value'],
+ default => throw new \InvalidArgumentException("Unknown condition: {$this->filter['condition']}"),
+ };
+ }
+
+ /**
+ * @return string[]
+ */
+ protected function getConditions(): array
+ {
+ return ["=", ">", "<", ">=", "<="];
+ }
+
+ #[\Override]
+ public function canMigrateConditionData(string $objectType): bool
+ {
+ return $objectType === $this->migrateConditionObjectType;
+ }
+
+ #[\Override]
+ public function migrateConditionData(array &$conditionData): array
+ {
+ $lessThan = $conditionData['lessThan'] ?? null;
+ $greaterThan = $conditionData['greaterThan'] ?? null;
+ $conditions = [];
+
+ if ($lessThan !== null) {
+ $conditions[] = [
+ 'identifier' => $this->getIdentifier(),
+ 'value' => ["value" => $lessThan, 'condition' => '<'],
+ ];
+ }
+ if ($greaterThan !== null) {
+ $conditions[] = [
+ 'identifier' => $this->getIdentifier(),
+ 'value' => ["value" => $greaterThan, 'condition' => '>'],
+ ];
+ }
+
+ unset($conditionData['lessThan'], $conditionData['greaterThan']);
+
+ return $conditions;
+ }
+}
diff --git a/wcfsetup/install/files/lib/system/condition/type/user/IsEnabledConditionType.class.php b/wcfsetup/install/files/lib/system/condition/type/user/IsEnabledConditionType.class.php
new file mode 100644
index 00000000000..1a81c66eb14
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/condition/type/user/IsEnabledConditionType.class.php
@@ -0,0 +1,39 @@
+
+ * @since 6.3
+ */
+final class IsEnabledConditionType extends BooleanUserConditionType
+{
+ public function __construct()
+ {
+ parent::__construct("isEnabled", 'activationCode', 'userIsEnabled', 'com.woltlab.wcf.user.state');
+ }
+
+ #[\Override]
+ public function applyFilter(DatabaseObjectList $objectList): void
+ {
+ if ($this->filter) {
+ $objectList->getConditionBuilder()->add("{$objectList->getDatabaseTableAlias()}.activationCode = ?", [0]);
+ } else {
+ $objectList->getConditionBuilder()->add("{$objectList->getDatabaseTableAlias()}.activationCode <> ?", [0]);
+ }
+ }
+
+ #[\Override]
+ public function matches(object $object): bool
+ {
+ if ($this->filter) {
+ return $object->activationCode === 0;
+ } else {
+ return $object->activationCode !== 0;
+ }
+ }
+}
diff --git a/wcfsetup/install/files/lib/system/condition/type/user/IsNullUserConditionType.class.php b/wcfsetup/install/files/lib/system/condition/type/user/IsNullUserConditionType.class.php
new file mode 100644
index 00000000000..6f243bc89ff
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/condition/type/user/IsNullUserConditionType.class.php
@@ -0,0 +1,94 @@
+
+ * @since 6.3
+ *
+ * @implements IDatabaseObjectListConditionType, bool>
+ * @implements IObjectConditionType
+ * @extends AbstractConditionType
+ */
+class IsNullUserConditionType extends AbstractConditionType implements IDatabaseObjectListConditionType, IObjectConditionType, IMigrateConditionType
+{
+ public function __construct(
+ public readonly string $identifier,
+ public readonly string $columnName,
+ public readonly ?string $migrateKeyName = null,
+ public readonly ?string $migrateConditionObjectType = null,
+ ) {}
+
+ #[\Override]
+ public function getIdentifier(): string
+ {
+ return $this->identifier;
+ }
+
+ #[\Override]
+ public function getLabel(): string
+ {
+ return "wcf.condition.user.{$this->identifier}";
+ }
+
+ #[\Override]
+ public function getFormField(string $id): BooleanFormField
+ {
+ return BooleanFormField::create($id);
+ }
+
+ #[\Override]
+ public function applyFilter(DatabaseObjectList $objectList): void
+ {
+ if ($this->filter) {
+ $objectList->getConditionBuilder()->add("{$objectList->getDatabaseTableAlias()}.{$this->columnName} IS NOT NULL");
+ } else {
+ $objectList->getConditionBuilder()->add("{$objectList->getDatabaseTableAlias()}.{$this->columnName} IS NULL");
+ }
+ }
+
+ #[\Override]
+ public function matches(object $object): bool
+ {
+ if ($this->filter) {
+ return $object->{$this->columnName} !== null;
+ } else {
+ return $object->{$this->columnName} === null;
+ }
+ }
+
+ #[\Override]
+ public function migrateConditionData(array &$conditionData): array
+ {
+ $value = $conditionData[$this->columnName] ?? null;
+ if ($value === null) {
+ return [];
+ }
+
+ unset($conditionData[$this->migrateKeyName]);
+
+ return [
+ [
+ 'identifier' => $this->identifier,
+ 'value' => \boolval($value),
+ ],
+ ];
+ }
+
+ #[\Override]
+ public function canMigrateConditionData(string $objectType): bool
+ {
+ return $this->migrateConditionObjectType === $objectType;
+ }
+}
diff --git a/wcfsetup/install/files/lib/system/condition/type/user/LanguageUserConditionType.class.php b/wcfsetup/install/files/lib/system/condition/type/user/LanguageUserConditionType.class.php
new file mode 100644
index 00000000000..ba74a10afd4
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/condition/type/user/LanguageUserConditionType.class.php
@@ -0,0 +1,89 @@
+
+ * @since 6.3
+ *
+ * @implements IDatabaseObjectListConditionType, string>
+ * @implements IObjectConditionType
+ * @extends AbstractConditionType
+ */
+final class LanguageUserConditionType extends AbstractConditionType implements IDatabaseObjectListConditionType, IObjectConditionType, IMigrateConditionType
+{
+ #[\Override]
+ public function getFormField(string $id): SelectFormField
+ {
+ // SelectFormField stores its value as a string,
+ // so we need to convert it to an integer in the `applyFilter`&`matches` method.
+ return SelectFormField::create($id)
+ ->options(LanguageFactory::getInstance()->getLanguages())
+ ->required();
+ }
+
+ #[\Override]
+ public function getIdentifier(): string
+ {
+ return 'language';
+ }
+
+ #[\Override]
+ public function getLabel(): string
+ {
+ return 'wcf.condition.user.language';
+ }
+
+ #[\Override]
+ public function applyFilter(DatabaseObjectList $objectList): void
+ {
+ $objectList->getConditionBuilder()->add(
+ "{$objectList->getDatabaseTableAlias()}.languageID = ?",
+ [(int)$this->filter]
+ );
+ }
+
+ #[\Override]
+ public function matches(object $object): bool
+ {
+ return (int)$this->filter === $object->languageID;
+ }
+
+ #[\Override]
+ public function migrateConditionData(array &$conditionData): array
+ {
+ if (!isset($conditionData['languageIDs'])) {
+ return [];
+ }
+
+ $result = [];
+ foreach ($conditionData['languageIDs'] as $languageID) {
+ $result[] = [
+ 'identifier' => $this->getIdentifier(),
+ 'value' => $languageID,
+ ];
+ }
+
+ unset($conditionData['languageIDs']);
+
+ return $result;
+ }
+
+ #[\Override]
+ public function canMigrateConditionData(string $objectType): bool
+ {
+ return $objectType === 'com.woltlab.wcf.user.languages';
+ }
+}
diff --git a/wcfsetup/install/files/lib/system/condition/type/user/NotInGroupUserConditionType.class.php b/wcfsetup/install/files/lib/system/condition/type/user/NotInGroupUserConditionType.class.php
new file mode 100644
index 00000000000..12731c3864e
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/condition/type/user/NotInGroupUserConditionType.class.php
@@ -0,0 +1,99 @@
+
+ * @since 6.3
+ *
+ * @implements IDatabaseObjectListConditionType, string>
+ * @implements IObjectConditionType
+ * @extends AbstractConditionType
+ */
+final class NotInGroupUserConditionType extends AbstractConditionType implements IDatabaseObjectListConditionType, IObjectConditionType, IMigrateConditionType
+{
+ #[\Override]
+ public function getFormField(string $id): SelectFormField
+ {
+ // SelectFormField stores its value as a string,
+ // so we need to convert it to an integer in the `applyFilter`&`matches` method.
+ return SelectFormField::create($id)
+ ->options(
+ UserGroup::getGroupsByType(invalidGroupTypes: [
+ UserGroup::EVERYONE,
+ UserGroup::GUESTS,
+ UserGroup::USERS,
+ ])
+ )
+ ->required();
+ }
+
+ #[\Override]
+ public function getIdentifier(): string
+ {
+ return 'notInGroup';
+ }
+
+ #[\Override]
+ public function getLabel(): string
+ {
+ return 'wcf.condition.user.notInGroup';
+ }
+
+ #[\Override]
+ public function applyFilter(DatabaseObjectList $objectList): void
+ {
+ $objectList->getConditionBuilder()->add(
+ "{$objectList->getDatabaseTableAlias()}.userID NOT IN (
+ SELECT userID
+ FROM wcf1_user_to_group
+ WHERE groupID = ?
+ )",
+ [(int)$this->filter]
+ );
+ }
+
+ #[\Override]
+ public function matches(object $object): bool
+ {
+ return !\in_array((int)$this->filter, $object->getGroupIDs(), true);
+ }
+
+ #[\Override]
+ public function canMigrateConditionData(string $objectType): bool
+ {
+ return $objectType === 'com.woltlab.wcf.user.userGroup';
+ }
+
+ #[\Override]
+ public function migrateConditionData(array &$conditionData): array
+ {
+ if (!isset($conditionData['notGroupIDs'])) {
+ return [];
+ }
+
+ $result = [];
+ foreach ($conditionData['notGroupIDs'] as $groupID) {
+ $result[] = [
+ 'identifier' => $this->getIdentifier(),
+ 'value' => $groupID,
+ ];
+ }
+
+ unset($conditionData['notGroupIDs']);
+
+ return $result;
+ }
+}
diff --git a/wcfsetup/install/files/lib/system/condition/type/user/RegistrationDateUserConditionType.class.php b/wcfsetup/install/files/lib/system/condition/type/user/RegistrationDateUserConditionType.class.php
new file mode 100644
index 00000000000..80b3f8a95a0
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/condition/type/user/RegistrationDateUserConditionType.class.php
@@ -0,0 +1,134 @@
+
+ * @since 6.3
+ *
+ * @phpstan-type Filter = array{condition: string, value: int}
+ * @implements IDatabaseObjectListConditionType, Filter>
+ * @implements IObjectConditionType
+ * @extends AbstractConditionType
+ */
+final class RegistrationDateUserConditionType extends AbstractConditionType implements IDatabaseObjectListConditionType, IObjectConditionType, IMigrateConditionType
+{
+ #[\Override]
+ public function getFormField(string $id): PrefixConditionFormFieldContainer
+ {
+ return PrefixConditionFormFieldContainer::create($id)
+ ->field(
+ DateFormField::create("{$id}Value")
+ ->supportTime()
+ ->required()
+ )
+ ->prefixField(
+ SingleSelectionFormField::create("{$id}Condition")
+ ->options(\array_combine($this->getConditions(), $this->getConditions()))
+ ->required()
+ );
+ }
+
+ #[\Override]
+ public function getIdentifier(): string
+ {
+ return 'registrationDate';
+ }
+
+ #[\Override]
+ public function getLabel(): string
+ {
+ return 'wcf.condition.user.registrationDate';
+ }
+
+ #[\Override]
+ public function applyFilter(DatabaseObjectList $objectList): void
+ {
+ ["condition" => $condition, "value" => $time] = $this->filter;
+
+ $objectList->getConditionBuilder()->add(
+ "{$objectList->getDatabaseTableAlias()}.registrationDate {$condition} ?",
+ [$time]
+ );
+ }
+
+ #[\Override]
+ public function matches(object $object): bool
+ {
+ ["condition" => $condition, "value" => $time] = $this->filter;
+
+ return match ($condition) {
+ ">" => $object->registrationDate > $time,
+ "<" => $object->registrationDate < $time,
+ ">=" => $object->registrationDate >= $time,
+ "<=" => $object->registrationDate <= $time,
+ default => false,
+ };
+ }
+
+ /**
+ * @return string[]
+ */
+ private function getConditions(): array
+ {
+ return [">", "<", ">=", "<="];
+ }
+
+ #[\Override]
+ public function migrateConditionData(array &$conditionData): array
+ {
+ $registrationDateStart = $conditionData['registrationDateStart'] ?? null;
+ $registrationDateEnd = $conditionData['registrationDateEnd'] ?? null;
+ $conditions = [];
+
+ if ($registrationDateStart !== null) {
+ $conditions[] = [
+ 'identifier' => $this->getIdentifier(),
+ 'value' => [
+ 'value' => $this->convertDateStringTimestamp($registrationDateStart, 0, 0, 0),
+ 'condition' => '>=',
+ ],
+ ];
+ }
+ if ($registrationDateEnd !== null) {
+ $conditions[] = [
+ 'identifier' => $this->getIdentifier(),
+ 'value' => [
+ 'value' => $this->convertDateStringTimestamp($registrationDateEnd, 23, 59, 59),
+ 'condition' => '<=',
+ ],
+ ];
+ }
+
+ unset($conditionData['registrationDateStart'], $conditionData['registrationDateEnd']);
+
+ return $conditions;
+ }
+
+ private function convertDateStringTimestamp(string $date, int $hour, int $minute, int $seconds): int
+ {
+ $dateTime = new \DateTimeImmutable($date, new \DateTimeZone(TIMEZONE));
+ $dateTime = $dateTime->setTime($hour, $minute, $seconds);
+
+ return $dateTime->getTimestamp();
+ }
+
+ #[\Override]
+ public function canMigrateConditionData(string $objectType): bool
+ {
+ return $objectType === "com.woltlab.wcf.user.registrationDate";
+ }
+}
diff --git a/wcfsetup/install/files/lib/system/condition/type/user/RegistrationDaysUserConditionType.class.php b/wcfsetup/install/files/lib/system/condition/type/user/RegistrationDaysUserConditionType.class.php
new file mode 100644
index 00000000000..c0e2cfe97f1
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/condition/type/user/RegistrationDaysUserConditionType.class.php
@@ -0,0 +1,83 @@
+
+ * @since 6.3
+ */
+final class RegistrationDaysUserConditionType extends IntegerUserConditionType
+{
+ public function __construct()
+ {
+ parent::__construct('registrationDays', 'registrationDate', 'com.woltlab.wcf.user.registrationDateInterval');
+ }
+
+ #[\Override]
+ public function getFormField(string $id): PrefixConditionFormFieldContainer
+ {
+ $container = parent::getFormField($id);
+ $field = $container->getField();
+ \assert($field instanceof IntegerFormField);
+ $field->suffix("wcf.acp.option.suffix.days");
+
+ return $container;
+ }
+
+ #[\Override]
+ public function applyFilter(DatabaseObjectList $objectList): void
+ {
+ ["condition" => $condition, "timestamp" => $timestamp] = $this->getParsedFilter();
+
+ $objectList->getConditionBuilder()->add(
+ "{$objectList->getDatabaseTableAlias()}.registrationDate {$condition} ?",
+ [$timestamp]
+ );
+ }
+
+ #[\Override]
+ public function matches(object $object): bool
+ {
+ ["condition" => $condition, "timestamp" => $timestamp] = $this->getParsedFilter();
+
+ return match ($condition) {
+ '>' => $object->registrationDate > $timestamp,
+ '<' => $object->registrationDate < $timestamp,
+ '>=' => $object->registrationDate >= $timestamp,
+ '<=' => $object->registrationDate <= $timestamp,
+ default => throw new \InvalidArgumentException("Unknown condition: {$condition}"),
+ };
+ }
+
+ /**
+ * @return array{condition: string, timestamp: int}
+ */
+ private function getParsedFilter(): array
+ {
+ if (!isset($this->filter['condition'], $this->filter['value'])) {
+ throw new \InvalidArgumentException("Invalid filter format");
+ }
+
+ $date = DateUtil::getDateTimeByTimestamp(TIME_NOW);
+ $date->setTimezone(new \DateTimeZone(TIMEZONE));
+ $date->sub(new \DateInterval("P{$this->filter['value']}D"));
+
+ return [
+ 'condition' => $this->filter['condition'],
+ 'timestamp' => $date->getTimestamp(),
+ ];
+ }
+
+ #[\Override]
+ protected function getConditions(): array
+ {
+ return [">", "<", ">=", "<="];
+ }
+}
diff --git a/wcfsetup/install/files/lib/system/condition/type/user/SignatureUserConditionType.class.php b/wcfsetup/install/files/lib/system/condition/type/user/SignatureUserConditionType.class.php
new file mode 100644
index 00000000000..30e5e74b8ad
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/condition/type/user/SignatureUserConditionType.class.php
@@ -0,0 +1,50 @@
+
+ * @since 6.3
+ */
+final class SignatureUserConditionType extends BooleanUserConditionType
+{
+ public function __construct()
+ {
+ parent::__construct(
+ 'signature',
+ 'signature',
+ 'userSignature',
+ 'com.woltlab.wcf.user.signature'
+ );
+ }
+
+ #[\Override]
+ public function applyFilter(DatabaseObjectList $objectList): void
+ {
+ if ($this->filter) {
+ $objectList->getConditionBuilder()->add(
+ "({$objectList->getDatabaseTableAlias()}.signature <> ? AND {$objectList->getDatabaseTableAlias()}.signature IS NOT NULL)",
+ ['']
+ );
+ } else {
+ $objectList->getConditionBuilder()->add(
+ "({$objectList->getDatabaseTableAlias()}.signature = ? OR {$objectList->getDatabaseTableAlias()}.signature IS NULL)",
+ ['']
+ );
+ }
+ }
+
+ #[\Override]
+ public function matches(object $object): bool
+ {
+ if ($this->filter) {
+ return $object->signature !== '' && $object->signature !== null;
+ } else {
+ return $object->signature === '' || $object->signature === null;
+ }
+ }
+}
diff --git a/wcfsetup/install/files/lib/system/condition/type/user/StringUserConditionType.class.php b/wcfsetup/install/files/lib/system/condition/type/user/StringUserConditionType.class.php
new file mode 100644
index 00000000000..9093decab28
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/condition/type/user/StringUserConditionType.class.php
@@ -0,0 +1,136 @@
+
+ * @since 6.3
+ *
+ * @phpstan-type Filter = array{condition: string, value: string}
+ * @implements IDatabaseObjectListConditionType, Filter>
+ * @implements IObjectConditionType
+ * @extends AbstractConditionType
+ */
+class StringUserConditionType extends AbstractConditionType implements IDatabaseObjectListConditionType, IObjectConditionType, IMigrateConditionType
+{
+ public function __construct(
+ public readonly string $identifier,
+ public readonly string $columnName,
+ public readonly ?string $migrateKeyName = null,
+ public readonly ?string $migrateConditionObjectType = null,
+ ) {}
+
+ #[\Override]
+ public function getFormField(string $id): PrefixConditionFormFieldContainer
+ {
+ return PrefixConditionFormFieldContainer::create($id)
+ ->field(
+ TextFormField::create("{$id}Value")
+ ->required()
+ )
+ ->prefixField(
+ SingleSelectionFormField::create("{$id}Condition")
+ ->options($this->getConditions())
+ ->required()
+ );
+ }
+
+ #[\Override]
+ public function getIdentifier(): string
+ {
+ return $this->identifier;
+ }
+
+ #[\Override]
+ public function getLabel(): string
+ {
+ return "wcf.condition.user.{$this->identifier}";
+ }
+
+ #[\Override]
+ public function applyFilter(DatabaseObjectList $objectList): void
+ {
+ ["condition" => $condition, "value" => $value] = $this->filter;
+ $value = WCF::getDB()->escapeLikeValue($value);
+
+ $filter = match ($condition) {
+ "_%" => $value . '%',
+ "%_%" => '%' . $value . '%',
+ "%_" => '%' . $value,
+ default => throw new \InvalidArgumentException("Unknown condition: {$condition}"),
+ };
+
+ $objectList->getConditionBuilder()->add(
+ $objectList->getDatabaseTableAlias() . ".{$this->columnName} LIKE ?",
+ [$filter]
+ );
+ }
+
+ #[\Override]
+ public function matches(object $object): bool
+ {
+ ["condition" => $condition, "value" => $value] = $this->filter;
+ $value = \mb_strtolower($value);
+ $objectValue = \mb_strtolower($object->{$this->columnName});
+
+ return match ($condition) {
+ "_%" => \str_starts_with($objectValue, $value),
+ "%_%" => \str_contains($objectValue, $value),
+ "%_" => \str_ends_with($objectValue, $value),
+ default => throw new \InvalidArgumentException("Unknown condition: {$condition}"),
+ };
+ }
+
+ /**
+ * @return array
+ */
+ private function getConditions(): array
+ {
+ return [
+ "_%" => "wcf.condition.startsWith",
+ "%_%" => "wcf.condition.contains",
+ "%_" => "wcf.condition.endsWith",
+ ];
+ }
+
+ #[\Override]
+ public function canMigrateConditionData(string $objectType): bool
+ {
+ return $objectType === $this->migrateConditionObjectType;
+ }
+
+ #[\Override]
+ public function migrateConditionData(array &$conditionData): array
+ {
+ $value = $conditionData[$this->migrateKeyName] ?? null;
+ if ($value === null) {
+ return [];
+ }
+
+ unset($conditionData[$this->migrateKeyName]);
+
+ return [
+ [
+ 'identifier' => $this->identifier,
+ 'value' => [
+ 'condition' => "%_%",
+ 'value' => $value,
+ ],
+ ],
+ ];
+ }
+}
diff --git a/wcfsetup/install/files/lib/system/cronjob/UserGroupAssignmentCronjob.class.php b/wcfsetup/install/files/lib/system/cronjob/UserGroupAssignmentCronjob.class.php
index 8714c867ab6..196b5a06be6 100644
--- a/wcfsetup/install/files/lib/system/cronjob/UserGroupAssignmentCronjob.class.php
+++ b/wcfsetup/install/files/lib/system/cronjob/UserGroupAssignmentCronjob.class.php
@@ -4,7 +4,7 @@
use wcf\data\cronjob\Cronjob;
use wcf\data\user\UserAction;
-use wcf\system\cache\builder\UserGroupAssignmentCacheBuilder;
+use wcf\system\cache\eager\UserGroupAssignmentCache;
use wcf\system\user\group\assignment\UserGroupAssignmentHandler;
/**
@@ -25,7 +25,7 @@ public function execute(Cronjob $cronjob)
{
parent::execute($cronjob);
- $assignments = UserGroupAssignmentCacheBuilder::getInstance()->getData();
+ $assignments = (new UserGroupAssignmentCache())->getCache();
$usersToGroup = [];
$assignmentCount = 0;
diff --git a/wcfsetup/install/files/lib/system/form/builder/container/ConditionFormContainer.class.php b/wcfsetup/install/files/lib/system/form/builder/container/ConditionFormContainer.class.php
new file mode 100644
index 00000000000..dad15b6b305
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/form/builder/container/ConditionFormContainer.class.php
@@ -0,0 +1,167 @@
+
+ * @since 6.3
+ */
+final class ConditionFormContainer extends FormContainer
+{
+ use TDefaultIdFormField;
+
+ /**
+ * @inheritDoc
+ */
+ protected $templateName = 'shared_conditionFormContainer';
+
+ /**
+ * @phpstan-ignore missingType.generics
+ */
+ protected AbstractConditionProvider $conditionProvider;
+
+ public function __construct()
+ {
+ parent::__construct();
+ $this->label("wcf.form.field.condition");
+ }
+
+ #[\Override]
+ protected static function getDefaultId(): string
+ {
+ return 'conditions';
+ }
+
+ #[\Override]
+ public function isAvailable(): bool
+ {
+ return isset($this->conditionProvider);
+ }
+
+ #[\Override]
+ public function readValues(): static
+ {
+ $prefixId = $this->getPrefixedId();
+
+ if ($this->getDocument()->hasRequestData($prefixId)) {
+ $conditions = $this->getDocument()->getRequestData($prefixId);
+
+ foreach ($conditions as $index => $identifier) {
+ $this->appendCondition($identifier, $index);
+ }
+ }
+
+ return parent::readValues();
+ }
+
+ #[\Override]
+ public function updatedObject(array $data, IStorableObject $object, $loadValues = true)
+ {
+ if ($loadValues && isset($data[$this->getPrefixedId()])) {
+ $conditions = JSON::decode($data[$this->getPrefixedId()]);
+
+ $data = $containers = [];
+ foreach ($conditions as $index => $condition) {
+ $containers[] = $this->appendCondition($condition['identifier'], $index);
+ $fieldId = $this->getConditionProvider()->getFieldId($this->getPrefixedId(), $condition['identifier'], $index);
+ $data[$fieldId] = $condition['value'];
+ }
+
+ foreach ($containers as $container) {
+ /** @var IFormNode $child */
+ foreach ($container->getIterator() as $child) {
+ if ($child instanceof IFormField || $child instanceof FormContainer) {
+ $child->updatedObject($data, $object);
+ }
+ }
+ }
+ }
+
+ return $this;
+ }
+
+ private function appendCondition(string $identifier, int $index): FormContainer
+ {
+ $prefixId = $this->getPrefixedId();
+
+ $node = $this->getConditionProvider()->getConditionFormField($prefixId, $identifier, $index);
+ $this->appendChild($node);
+
+ $fieldId = $this->getConditionProvider()->getFieldId($this->getPrefixedId(), $identifier, $index);
+
+ /** @var IFormNode $child */
+ foreach ($node->getIterator() as $child) {
+ $child->populate();
+ }
+
+ $this->getDocument()->getDataHandler()->addProcessor(
+ new CustomFormDataProcessor(
+ "{$fieldId}DataProcessor",
+ static function (IFormDocument $document, array $parameters) use ($prefixId, $identifier, $fieldId) {
+ $conditions = isset($parameters['data'][$prefixId]) ? JSON::decode($parameters['data'][$prefixId]) : [];
+
+ if (isset($parameters['data'][$fieldId])) {
+ $conditions[] = [
+ "identifier" => $identifier,
+ "value" => $parameters['data'][$fieldId],
+ ];
+ }
+
+ unset($parameters['data'][$fieldId]);
+
+ $parameters['data'][$prefixId] = JSON::encode($conditions);
+
+ return $parameters;
+ }
+ )
+ );
+
+ return $node;
+ }
+
+ /**
+ * @phpstan-ignore missingType.generics
+ */
+ public function conditionProvider(AbstractConditionProvider $conditionProvider): self
+ {
+ $this->conditionProvider = $conditionProvider;
+
+ return $this;
+ }
+
+ /**
+ * @phpstan-ignore missingType.generics
+ */
+ public function getConditionProvider(): AbstractConditionProvider
+ {
+ if (!isset($this->conditionProvider)) {
+ throw new \BadMethodCallException(
+ "Condition provider has not been set yet for node '{$this->getId()}'."
+ );
+ }
+
+ return $this->conditionProvider;
+ }
+
+ public function getConditionProviderClass(): string
+ {
+ if (!isset($this->conditionProvider)) {
+ throw new \BadMethodCallException(
+ "Condition provider has not been set yet for node '{$this->getId()}'."
+ );
+ }
+
+ return $this->conditionProvider::class;
+ }
+}
diff --git a/wcfsetup/install/files/lib/system/form/builder/container/FormContainer.class.php b/wcfsetup/install/files/lib/system/form/builder/container/FormContainer.class.php
index 1e3e3a4c95d..2760a6f7b12 100644
--- a/wcfsetup/install/files/lib/system/form/builder/container/FormContainer.class.php
+++ b/wcfsetup/install/files/lib/system/form/builder/container/FormContainer.class.php
@@ -59,6 +59,7 @@ public function getHtml()
$this->templateName,
\array_merge($this->getHtmlVariables(), [
'container' => $this,
+ 'form' => $this->getDocument(),
]),
);
}
diff --git a/wcfsetup/install/files/lib/system/form/builder/container/PrefixConditionFormFieldContainer.class.php b/wcfsetup/install/files/lib/system/form/builder/container/PrefixConditionFormFieldContainer.class.php
new file mode 100644
index 00000000000..c1bbf6eb21e
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/form/builder/container/PrefixConditionFormFieldContainer.class.php
@@ -0,0 +1,193 @@
+
+ * @since 6.3
+ */
+final class PrefixConditionFormFieldContainer extends FormContainer
+{
+ /**
+ * form field to which the prefix is added
+ */
+ protected IFormField $field;
+
+ /**
+ * form field containing the prefix field
+ */
+ protected IFormField $prefixField;
+
+ /**
+ * @inheritDoc
+ */
+ protected $templateName = 'shared_prefixFormFieldContainer';
+
+ #[\Override]
+ public function populate()
+ {
+ $this->getDocument()->getDataHandler()
+ ->addProcessor(
+ new CustomFormDataProcessor(
+ $this->getId() . "DataProcessor",
+ function (IFormDocument $document, array $parameters) {
+ $fieldId = $this->getField()->getId();
+ $prefixId = $this->getPrefixField()->getId();
+ if (isset($parameters['data'][$prefixId], $parameters['data'][$fieldId])) {
+ $parameters['data'][$this->getId()] = [
+ 'condition' => $parameters['data'][$prefixId],
+ 'value' => $parameters['data'][$fieldId],
+ ];
+ }
+
+ unset(
+ $parameters['data'][$fieldId],
+ $parameters['data'][$prefixId]
+ );
+
+ return $parameters;
+ },
+ )
+ );
+
+ return parent::populate();
+ }
+
+ #[\Override]
+ public function updatedObject(array $data, IStorableObject $object, $loadValues = true)
+ {
+ if ($loadValues && isset($data[$this->getId()])) {
+ ["condition" => $condition, "value" => $value] = $data[$this->getId()];
+
+ $this->getField()->updatedObject([$this->getField()->getId() => $value], $object);
+ $this->getPrefixField()->updatedObject([$this->getPrefixField()->getId() => $condition], $object);
+ }
+
+ return $this;
+ }
+
+ public function field(IFormField $formField): static
+ {
+ if (isset($this->field)) {
+ throw new \BadMethodCallException("Field has already been set for container '{$this->getId()}'.");
+ }
+
+ $this->field = $formField;
+ $this->appendChild($formField);
+
+ return $this;
+ }
+
+ public function getField(): IFormField
+ {
+ if (!isset($this->field)) {
+ throw new \BadMethodCallException("Field has not been set yet for container '{$this->getId()}'.");
+ }
+
+ return $this->field;
+ }
+
+ /**
+ * Returns the initial option of the prefix selection dropdown.
+ *
+ * @return array{label: string, value: mixed, depth: int, isSelectable: bool}
+ * @throws \BadMethodCallException if no prefix field is set or has no options
+ */
+ public function getSelectedPrefixOption(): array
+ {
+ $prefixField = $this->getPrefixField();
+
+ \assert($prefixField instanceof ISelectionFormField);
+
+ if (empty($prefixField->getOptions())) {
+ throw new \BadMethodCallException(
+ "The prefix field has no options for container '{$this->getId()}'."
+ );
+ }
+
+ foreach ($prefixField->getNestedOptions() as $option) {
+ if ($prefixField->getValue() === null) {
+ if ($option['isSelectable']) {
+ return $option;
+ }
+ } elseif ($option['value'] == $prefixField->getValue()) {
+ return $option;
+ }
+ }
+
+ // Return the first selectable option if no valid value is selected.
+ foreach ($prefixField->getNestedOptions() as $option) {
+ if ($option['isSelectable']) {
+ return $option;
+ }
+ }
+
+ throw new \RuntimeException(
+ "Cannot determine selected prefix option for container '{$this->getId()}'."
+ );
+ }
+
+ /**
+ * Returns the prefix form field.
+ */
+ public function getPrefixField(): IFormField
+ {
+ if (!isset($this->prefixField)) {
+ throw new \BadMethodCallException(
+ "Prefix field has not been set yet for container '{$this->getId()}'."
+ );
+ }
+
+ return $this->prefixField;
+ }
+
+ /**
+ * Sets the prefix form field.
+ */
+ public function prefixField(IFormField $formField): static
+ {
+ if (isset($this->prefixField)) {
+ throw new \BadMethodCallException(
+ "Prefix field has already been set for container '{$this->getId()}'."
+ );
+ }
+
+ $this->prefixField = $formField;
+ $this->appendChild($formField);
+
+ return $this;
+ }
+
+ /**
+ * Returns `true` if the prefix form field has any selectable options.
+ */
+ public function prefixHasSelectableOptions(): bool
+ {
+ $prefixField = $this->getPrefixField();
+
+ if (!($prefixField instanceof ISelectionFormField)) {
+ return false;
+ }
+
+ if ($prefixField instanceof IImmutableFormField && $prefixField->isImmutable()) {
+ return false;
+ }
+
+ foreach ($prefixField->getNestedOptions() as $option) {
+ if ($option['isSelectable']) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+}
diff --git a/wcfsetup/install/files/lib/system/user/group/assignment/UserGroupAssignmentHandler.class.php b/wcfsetup/install/files/lib/system/user/group/assignment/UserGroupAssignmentHandler.class.php
index affd2528056..b1fdf6df144 100644
--- a/wcfsetup/install/files/lib/system/user/group/assignment/UserGroupAssignmentHandler.class.php
+++ b/wcfsetup/install/files/lib/system/user/group/assignment/UserGroupAssignmentHandler.class.php
@@ -3,12 +3,13 @@
namespace wcf\system\user\group\assignment;
use wcf\data\object\type\ObjectType;
-use wcf\data\object\type\ObjectTypeCache;
use wcf\data\user\group\assignment\UserGroupAssignment;
use wcf\data\user\User;
use wcf\data\user\UserAction;
use wcf\data\user\UserList;
-use wcf\system\cache\builder\UserGroupAssignmentCacheBuilder;
+use wcf\system\cache\eager\UserGroupAssignmentCache;
+use wcf\system\condition\ConditionHandler;
+use wcf\system\condition\provider\UserConditionProvider;
use wcf\system\SingletonFactory;
/**
@@ -46,8 +47,8 @@ public function checkUsers(array $userIDs)
$userList->setObjectIDs($userIDs);
$userList->readObjects();
- /** @var UserGroupAssignment[] $assignments */
- $assignments = UserGroupAssignmentCacheBuilder::getInstance()->getData();
+ $assignments = (new UserGroupAssignmentCache())->getCache();
+ $conditionProvider = new UserConditionProvider();
foreach ($userList as $user) {
$groupIDs = $user->getGroupIDs();
$newGroupIDs = [];
@@ -58,9 +59,9 @@ public function checkUsers(array $userIDs)
}
$checkFailed = false;
- $conditions = $assignment->getConditions();
- foreach ($conditions as $condition) {
- if (!$condition->getObjectType()->getProcessor()->checkUser($condition, $user)) {
+ $conditions = ConditionHandler::getInstance()->getConditionsWithFilter($conditionProvider, $assignment->getConditions());
+ foreach ($conditions as $conditionType) {
+ if (!$conditionType->matches($user)) {
$checkFailed = true;
break;
}
@@ -116,31 +117,12 @@ public function getUsers(UserGroupAssignment $assignment, $maxUsers = null)
$userList->sqlLimit = $maxUsers;
}
- $conditions = $assignment->getConditions();
- foreach ($conditions as $condition) {
- $condition->getObjectType()->getProcessor()->addUserCondition($condition, $userList);
+ $conditions = ConditionHandler::getInstance()->getConditionsWithFilter(new UserConditionProvider(), $assignment->getConditions());
+ foreach ($conditions as $conditionType) {
+ $conditionType->applyFilter($userList);
}
$userList->readObjects();
return $userList->getObjects();
}
-
- /**
- * @inheritDoc
- */
- protected function init()
- {
- $objectTypes = ObjectTypeCache::getInstance()->getObjectTypes('com.woltlab.wcf.condition.userGroupAssignment');
- foreach ($objectTypes as $objectType) {
- if (!$objectType->conditiongroup) {
- continue;
- }
-
- if (!isset($this->groupedObjectTypes[$objectType->conditiongroup])) {
- $this->groupedObjectTypes[$objectType->conditiongroup] = [];
- }
-
- $this->groupedObjectTypes[$objectType->conditiongroup][$objectType->objectTypeID] = $objectType;
- }
- }
}
diff --git a/wcfsetup/install/files/lib/system/user/group/assignment/command/MigrateLegacyCondition.class.php b/wcfsetup/install/files/lib/system/user/group/assignment/command/MigrateLegacyCondition.class.php
new file mode 100644
index 00000000000..797018ea938
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/user/group/assignment/command/MigrateLegacyCondition.class.php
@@ -0,0 +1,50 @@
+
+ * @since 6.3
+ */
+final class MigrateLegacyCondition
+{
+ public function __construct(
+ public readonly UserGroupAssignment $assignment,
+ ) {
+ }
+
+ public function __invoke(): void
+ {
+ if (!$this->assignment->isLegacy) {
+ return;
+ }
+
+ try {
+ $json = JSON::decode($this->assignment->conditions);
+ } catch (SystemException $ex) {
+ $ex->getExceptionID(); // Log the exception if JSON decoding fails
+
+ return;
+ }
+
+ $migratedData = ConditionHandler::getInstance()->migrateConditionData(new UserConditionProvider(), $json);
+
+ $editor = new UserGroupAssignmentEditor($this->assignment);
+ $editor->update([
+ 'conditions' => JSON::encode($migratedData->conditions),
+ 'isLegacy' => 0,
+ 'isDisabled' => $migratedData->isFullyMigrated ? $this->assignment->isDisabled : 1,
+ ]);
+ }
+}
diff --git a/wcfsetup/install/files/lib/system/worker/UserGroupAssignmentRebuildDataWorker.class.php b/wcfsetup/install/files/lib/system/worker/UserGroupAssignmentRebuildDataWorker.class.php
new file mode 100644
index 00000000000..3be1fbaf549
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/worker/UserGroupAssignmentRebuildDataWorker.class.php
@@ -0,0 +1,38 @@
+
+ *
+ * @extends AbstractLinearRebuildDataWorker
+ */
+final class UserGroupAssignmentRebuildDataWorker extends AbstractLinearRebuildDataWorker
+{
+ /**
+ * @inheritDoc
+ */
+ protected $objectListClassName = UserGroupAssignmentList::class;
+
+ /**
+ * @inheritDoc
+ */
+ protected $limit = 100;
+
+ #[\Override]
+ public function execute()
+ {
+ parent::execute();
+
+ foreach ($this->objectList as $assignment) {
+ (new MigrateLegacyCondition($assignment))();
+ }
+ }
+}
diff --git a/wcfsetup/install/files/style/ui/condition.scss b/wcfsetup/install/files/style/ui/condition.scss
new file mode 100644
index 00000000000..d018ea92d99
--- /dev/null
+++ b/wcfsetup/install/files/style/ui/condition.scss
@@ -0,0 +1,14 @@
+.condition__container {
+ display: flex;
+ flex-direction: row;
+}
+
+.condition__remove {
+ flex: 0 30px;
+}
+
+.condition__container > dl {
+ flex: 1;
+ display: flex;
+ flex-direction: row;
+}
diff --git a/wcfsetup/install/lang/de.xml b/wcfsetup/install/lang/de.xml
index a0adbf31d3d..69319c89e62 100644
--- a/wcfsetup/install/lang/de.xml
+++ b/wcfsetup/install/lang/de.xml
@@ -860,6 +860,7 @@ Sie erreichen das Fehlerprotokoll unter: {link controller='ExceptionLogView' isE
+
@@ -870,6 +871,7 @@ Sie erreichen das Fehlerprotokoll unter: {link controller='ExceptionLogView' isE
+ - Anzeigen aktualisieren durch.]]>
@@ -977,6 +979,7 @@ Sie erreichen das Fehlerprotokoll unter: {link controller='ExceptionLogView' isE
+ - {$title}{/implode} wurden noch nicht aktualisiert. Bitte {if LANGUAGE_USE_INFORMAL_VARIANT}führe{else}führen Sie{/if} dies unter Anzeigen-Aktualisierung durch.]]>
- Apps verwalten.]]>
@@ -2700,6 +2703,8 @@ Abschnitte dürfen nicht leer sein und nur folgende Zeichen enthalten: [a-z
+
+
@@ -3533,6 +3538,30 @@ Erlaubte Dateiendungen: {', '|implode:$attachmentHandler->getFormattedAllowedExt
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -4150,6 +4179,7 @@ Dateianhänge:
- 1}{#$minimum} Dateien{else}eine Datei{/if} hochladen.]]>
- 1}{#$maximum} Dateien{else}eine Datei{/if} hochladen.]]>
+
diff --git a/wcfsetup/install/lang/en.xml b/wcfsetup/install/lang/en.xml
index f3b97d8f561..e7d1bff1f71 100644
--- a/wcfsetup/install/lang/en.xml
+++ b/wcfsetup/install/lang/en.xml
@@ -836,6 +836,7 @@ You can access the error log at: {link controller='ExceptionLogView' isEmail=tru
+
@@ -846,6 +847,7 @@ You can access the error log at: {link controller='ExceptionLogView' isEmail=tru
+ - Rebuild Data.]]>
@@ -953,6 +955,7 @@ You can access the error log at: {link controller='ExceptionLogView' isEmail=tru
+ - {$title}{/implode} need to be recalculated. Please perform this task by visiting Rebuild Data.]]>
- Manage Apps.]]>
@@ -2627,6 +2630,8 @@ If you have already bought the licenses for the listed apps, th
+
+
@@ -3456,6 +3461,30 @@ Allowed extensions: {', '|implode:$attachmentHandler->getFormattedAllowedExtensi
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -4096,6 +4125,7 @@ Attachments:
- 1}{#$minimum} files{else}one file{/if}.]]>
- 1}{#$maximum} files{else}one file{/if}.]]>
+
diff --git a/wcfsetup/setup/db/install.sql b/wcfsetup/setup/db/install.sql
index 3872d826f2e..1eb3e7d493e 100644
--- a/wcfsetup/setup/db/install.sql
+++ b/wcfsetup/setup/db/install.sql
@@ -1684,7 +1684,9 @@ CREATE TABLE wcf1_user_group_assignment (
assignmentID INT(10) NOT NULL AUTO_INCREMENT PRIMARY KEY,
groupID INT(10) NOT NULL,
title VARCHAR(255) NOT NULL,
- isDisabled TINYINT(1) NOT NULL DEFAULT 0
+ isDisabled TINYINT(1) NOT NULL DEFAULT 0,
+ conditions MEDIUMTEXT,
+ isLegacy TINYINT(1) NOT NULL DEFAULT 0
);
DROP TABLE IF EXISTS wcf1_user_group_option;