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} +
+

{unsafe:$container->getLabel()}{if $container->markAsRequired()}*{/if}

+

{unsafe:$container->getDescription()}

+
+ {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'} - -
-
- -
-
- - {if $errorField == 'title'} - - {if $errorType == 'empty'} - {lang}wcf.global.form.error.empty{/lang} - {else} - {lang}wcf.acp.group.assignment.title.error.{$errorType}{/lang} - {/if} - - {/if} -
- - - -
-
- {htmlOptions name='groupID' id='groupID' options=$userGroups selected=$groupID} - {if $errorField == 'groupID'} - {if $errorType == 'noValidSelection'} - {lang}wcf.global.form.error.noValidSelection{/lang} - {else} - {lang}wcf.acp.group.assignment.groupID.error.{$errorType}{/lang} - {/if} - {/if} -
- - -
-
-
- -
-
- - {event name='dataFields'} -
- - {event name='sections'} - -
-
-

{lang}wcf.acp.group.assignment.conditions{/lang}

-

{lang}wcf.acp.group.assignment.conditions.description{/lang}

-
- - {if $errorField == 'conditions'} - {lang}wcf.acp.group.assignment.error.noConditions{/lang} - {/if} - - {include file='shared_userConditions'} -
- -
- - - {csrfToken} -
-
+{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;