diff --git a/com.woltlab.wcf/objectType.xml b/com.woltlab.wcf/objectType.xml index 1aab4d228eb..2e47222f793 100644 --- a/com.woltlab.wcf/objectType.xml +++ b/com.woltlab.wcf/objectType.xml @@ -585,104 +585,6 @@ com.woltlab.wcf.user - - - com.woltlab.wcf.username - com.woltlab.wcf.condition.userGroupAssignment - wcf\system\condition\UserUsernameCondition - general - - - com.woltlab.wcf.email - com.woltlab.wcf.condition.userGroupAssignment - wcf\system\condition\UserEmailCondition - general - - - com.woltlab.wcf.userGroup - com.woltlab.wcf.condition.userGroupAssignment - wcf\system\condition\UserGroupCondition - general - - - com.woltlab.wcf.languages - com.woltlab.wcf.condition.userGroupAssignment - wcf\system\condition\UserLanguageCondition - general - - - com.woltlab.wcf.registrationDate - com.woltlab.wcf.condition.userGroupAssignment - wcf\system\condition\UserRegistrationDateCondition - general - - - com.woltlab.wcf.registrationDateInterval - com.woltlab.wcf.condition.userGroupAssignment - wcf\system\condition\UserRegistrationDateIntervalCondition - general - - - com.woltlab.wcf.avatar - com.woltlab.wcf.condition.userGroupAssignment - wcf\system\condition\UserAvatarCondition - general - - - com.woltlab.wcf.signature - com.woltlab.wcf.condition.userGroupAssignment - wcf\system\condition\UserSignatureCondition - general - - - com.woltlab.wcf.coverPhoto - com.woltlab.wcf.condition.userGroupAssignment - wcf\system\condition\UserCoverPhotoCondition - general - - - com.woltlab.wcf.state - com.woltlab.wcf.condition.userGroupAssignment - wcf\system\condition\UserStateCondition - general - - - com.woltlab.wcf.activityPoints - com.woltlab.wcf.condition.userGroupAssignment - wcf\system\condition\UserIntegerPropertyCondition - contents - activityPoints - 0 - - - com.woltlab.wcf.likesReceived - com.woltlab.wcf.condition.userGroupAssignment - wcf\system\condition\UserIntegerPropertyCondition - contents - likesReceived - 0 - - - com.woltlab.wcf.userOptions - com.woltlab.wcf.condition.userGroupAssignment - wcf\system\condition\UserOptionsCondition - userOptions - - - com.woltlab.wcf.userTrophyCondition - com.woltlab.wcf.condition.userGroupAssignment - wcf\system\condition\UserTrophyCondition - general - - - com.woltlab.wcf.trophyPoints - com.woltlab.wcf.condition.userGroupAssignment - wcf\system\condition\UserIntegerPropertyCondition - contents - trophyPoints - 0 - - com.woltlab.wcf.username @@ -781,145 +683,6 @@ 0 - - - com.woltlab.wcf.page - com.woltlab.wcf.condition.notice - wcf\system\condition\page\MultiPageCondition - com.woltlab.wcf.page - - - com.woltlab.wcf.pointInTime.daysOfWeek - com.woltlab.wcf.condition.notice - wcf\system\condition\DaysOfWeekCondition - com.woltlab.wcf.pointInTime - - - com.woltlab.wcf.pointInTime.time - com.woltlab.wcf.condition.notice - wcf\system\condition\TimeCondition - com.woltlab.wcf.pointInTime - - - com.woltlab.wcf.user.username - com.woltlab.wcf.condition.notice - wcf\system\condition\UserUsernameCondition - com.woltlab.wcf.user - general - - - com.woltlab.wcf.user.email - com.woltlab.wcf.condition.notice - wcf\system\condition\UserEmailCondition - com.woltlab.wcf.user - general - - - com.woltlab.wcf.user.userGroup - com.woltlab.wcf.condition.notice - wcf\system\condition\UserGroupCondition - com.woltlab.wcf.user - general - 1 - - - com.woltlab.wcf.user.languages - com.woltlab.wcf.condition.notice - wcf\system\condition\UserLanguageCondition - com.woltlab.wcf.user - general - - - com.woltlab.wcf.user.registrationDate - com.woltlab.wcf.condition.notice - wcf\system\condition\UserRegistrationDateCondition - com.woltlab.wcf.user - general - - - com.woltlab.wcf.user.registrationDateInterval - com.woltlab.wcf.condition.notice - wcf\system\condition\UserRegistrationDateIntervalCondition - com.woltlab.wcf.user - general - - - com.woltlab.wcf.user.avatar - com.woltlab.wcf.condition.notice - wcf\system\condition\UserAvatarCondition - com.woltlab.wcf.user - general - - - com.woltlab.wcf.user.state - com.woltlab.wcf.condition.notice - wcf\system\condition\UserStateCondition - com.woltlab.wcf.user - general - - - com.woltlab.wcf.user.mobileBrowser - com.woltlab.wcf.condition.notice - wcf\system\condition\UserMobileBrowserCondition - com.woltlab.wcf.user - general - - - com.woltlab.wcf.user.birthday - com.woltlab.wcf.condition.notice - wcf\system\condition\UserBirthdayCondition - com.woltlab.wcf.user - general - - - com.woltlab.wcf.user.activityPoints - com.woltlab.wcf.condition.notice - wcf\system\condition\UserIntegerPropertyCondition - com.woltlab.wcf.user - contents - activityPoints - 0 - - - com.woltlab.wcf.user.likesReceived - com.woltlab.wcf.condition.notice - wcf\system\condition\UserIntegerPropertyCondition - com.woltlab.wcf.user - contents - likesReceived - 0 - - - com.woltlab.wcf.user.userOptions - com.woltlab.wcf.condition.notice - wcf\system\condition\UserOptionsCondition - com.woltlab.wcf.user - userOptions - - - com.woltlab.wcf.userTrophyCondition - com.woltlab.wcf.condition.notice - wcf\system\condition\UserTrophyCondition - com.woltlab.wcf.user - general - - - com.woltlab.wcf.trophyPoints - com.woltlab.wcf.condition.notice - wcf\system\condition\UserIntegerPropertyCondition - com.woltlab.wcf.user - contents - trophyPoints - 0 - - - com.woltlab.wcf.user.multifactor - com.woltlab.wcf.condition.notice - wcf\system\condition\UserMultifactorCondition - com.woltlab.wcf.user - general - - com.woltlab.wcf.logo @@ -994,131 +757,6 @@ wcfAdLocationArticleList wcfAdLocationAfterEvery10thArticle - - - com.woltlab.wcf.page - com.woltlab.wcf.condition.ad - wcf\system\condition\page\MultiPageCondition - com.woltlab.wcf.page - - - com.woltlab.wcf.pointInTime.daysOfWeek - com.woltlab.wcf.condition.ad - wcf\system\condition\DaysOfWeekCondition - com.woltlab.wcf.pointInTime - - - com.woltlab.wcf.pointInTime.time - com.woltlab.wcf.condition.ad - wcf\system\condition\TimeCondition - com.woltlab.wcf.pointInTime - - - com.woltlab.wcf.user.username - com.woltlab.wcf.condition.ad - wcf\system\condition\UserUsernameCondition - com.woltlab.wcf.user - general - - - com.woltlab.wcf.user.email - com.woltlab.wcf.condition.ad - wcf\system\condition\UserEmailCondition - com.woltlab.wcf.user - general - - - com.woltlab.wcf.user.userGroup - com.woltlab.wcf.condition.ad - wcf\system\condition\UserGroupCondition - com.woltlab.wcf.user - general - 1 - - - com.woltlab.wcf.user.languages - com.woltlab.wcf.condition.ad - wcf\system\condition\UserLanguageCondition - com.woltlab.wcf.user - general - - - com.woltlab.wcf.user.registrationDate - com.woltlab.wcf.condition.ad - wcf\system\condition\UserRegistrationDateCondition - com.woltlab.wcf.user - general - - - com.woltlab.wcf.user.registrationDateInterval - com.woltlab.wcf.condition.ad - wcf\system\condition\UserRegistrationDateIntervalCondition - com.woltlab.wcf.user - general - - - com.woltlab.wcf.user.avatar - com.woltlab.wcf.condition.ad - wcf\system\condition\UserAvatarCondition - com.woltlab.wcf.user - general - - - com.woltlab.wcf.user.state - com.woltlab.wcf.condition.ad - wcf\system\condition\UserStateCondition - com.woltlab.wcf.user - general - - - com.woltlab.wcf.user.mobileBrowser - com.woltlab.wcf.condition.ad - wcf\system\condition\UserMobileBrowserCondition - com.woltlab.wcf.user - general - - - com.woltlab.wcf.user.activityPoints - com.woltlab.wcf.condition.ad - wcf\system\condition\UserIntegerPropertyCondition - com.woltlab.wcf.user - contents - activityPoints - 0 - - - com.woltlab.wcf.user.likesReceived - com.woltlab.wcf.condition.ad - wcf\system\condition\UserIntegerPropertyCondition - com.woltlab.wcf.user - contents - likesReceived - 0 - - - com.woltlab.wcf.user.userOptions - com.woltlab.wcf.condition.ad - wcf\system\condition\UserOptionsCondition - com.woltlab.wcf.user - userOptions - - - com.woltlab.wcf.userTrophyCondition - com.woltlab.wcf.condition.ad - wcf\system\condition\UserTrophyCondition - com.woltlab.wcf.user - general - - - com.woltlab.wcf.trophyPoints - com.woltlab.wcf.condition.ad - wcf\system\condition\UserIntegerPropertyCondition - com.woltlab.wcf.user - contents - trophyPoints - 0 - - com.woltlab.wcf.recaptcha @@ -1750,20 +1388,6 @@ com.woltlab.wcf.file wcf\system\file\processor\ReactionTypeIconFileProcessor - - - com.woltlab.wcf.page.controller - com.woltlab.wcf.condition.notice - wcf\system\condition\MultiPageControllerCondition - com.woltlab.wcf.page - - - com.woltlab.wcf.page.controller - com.woltlab.wcf.condition.ad - wcf\system\condition\MultiPageControllerCondition - com.woltlab.wcf.page - - @@ -1824,5 +1448,165 @@ com.woltlab.wcf.clipboardItem + + + com.woltlab.wcf.condition.userGroupAssignment + + + com.woltlab.wcf.condition.userGroupAssignment + + + com.woltlab.wcf.condition.userGroupAssignment + + + com.woltlab.wcf.condition.userGroupAssignment + + + com.woltlab.wcf.condition.userGroupAssignment + + + com.woltlab.wcf.condition.userGroupAssignment + + + com.woltlab.wcf.condition.userGroupAssignment + + + com.woltlab.wcf.condition.userGroupAssignment + + + com.woltlab.wcf.condition.userGroupAssignment + + + com.woltlab.wcf.condition.userGroupAssignment + + + com.woltlab.wcf.condition.userGroupAssignment + + + com.woltlab.wcf.condition.userGroupAssignment + + + com.woltlab.wcf.condition.userGroupAssignment + + + com.woltlab.wcf.condition.userGroupAssignment + + + com.woltlab.wcf.condition.userGroupAssignment + + + com.woltlab.wcf.condition.notice + + + com.woltlab.wcf.condition.notice + + + com.woltlab.wcf.condition.notice + + + com.woltlab.wcf.condition.notice + + + com.woltlab.wcf.condition.notice + + + com.woltlab.wcf.condition.notice + + + com.woltlab.wcf.condition.notice + + + com.woltlab.wcf.condition.notice + + + com.woltlab.wcf.condition.notice + + + com.woltlab.wcf.condition.notice + + + com.woltlab.wcf.condition.notice + + + com.woltlab.wcf.condition.notice + + + com.woltlab.wcf.condition.notice + + + com.woltlab.wcf.condition.notice + + + com.woltlab.wcf.condition.notice + + + com.woltlab.wcf.condition.notice + + + com.woltlab.wcf.condition.notice + + + com.woltlab.wcf.condition.notice + + + com.woltlab.wcf.condition.notice + + + com.woltlab.wcf.condition.notice + + + com.woltlab.wcf.condition.ad + + + com.woltlab.wcf.condition.ad + + + com.woltlab.wcf.condition.ad + + + com.woltlab.wcf.condition.ad + + + com.woltlab.wcf.condition.ad + + + com.woltlab.wcf.condition.ad + + + com.woltlab.wcf.condition.ad + + + com.woltlab.wcf.condition.ad + + + com.woltlab.wcf.condition.ad + + + com.woltlab.wcf.condition.ad + + + com.woltlab.wcf.condition.ad + + + com.woltlab.wcf.condition.ad + + + com.woltlab.wcf.condition.ad + + + com.woltlab.wcf.condition.ad + + + com.woltlab.wcf.condition.ad + + + com.woltlab.wcf.condition.ad + + + com.woltlab.wcf.condition.ad + + + com.woltlab.wcf.condition.ad + diff --git a/com.woltlab.wcf/objectTypeDefinition.xml b/com.woltlab.wcf/objectTypeDefinition.xml index f3bd5c6159b..a9348bec6b4 100644 --- a/com.woltlab.wcf/objectTypeDefinition.xml +++ b/com.woltlab.wcf/objectTypeDefinition.xml @@ -117,6 +117,7 @@ com.woltlab.wcf.condition.box wcf\system\condition\IContentCondition + com.woltlab.wcf.condition.userGroupAssignment wcf\system\condition\IUserCondition @@ -125,6 +126,7 @@ com.woltlab.wcf.condition.trophy wcf\system\condition\IUserCondition + com.woltlab.wcf.condition.notice wcf\system\condition\IContentCondition @@ -136,6 +138,7 @@ com.woltlab.wcf.adLocation + com.woltlab.wcf.condition.ad wcf\system\condition\IContentCondition diff --git a/com.woltlab.wcf/package.xml b/com.woltlab.wcf/package.xml index 9fa19eea583..1f51200606b 100644 --- a/com.woltlab.wcf/package.xml +++ b/com.woltlab.wcf/package.xml @@ -69,6 +69,10 @@ Required order of the following steps for the update to 6.3: acp/update_com.woltlab.wcf_6.3_likeObject.php acp/database/update_com.woltlab.wcf_6.3_step1.php + acp/update_com.woltlab.wcf_6.3_conditions.php + The `objectType` PIP must run after `update_com.woltlab.wcf_6.3_conditions.php`, + because deleting the legacy condition object types cascades to `wcf1_condition`. + acp/update_com.woltlab.wcf_6.3_styleTemplateGroup.php acp/database/update_com.woltlab.wcf_6.3_step2.php acp/update_com.woltlab.wcf_6.3_embeddedObjects.php diff --git a/com.woltlab.wcf/templates/shared_multiPageCondition.tpl b/com.woltlab.wcf/templates/shared_multiPageCondition.tpl index a097b57fdc0..b7936ae9006 100644 --- a/com.woltlab.wcf/templates/shared_multiPageCondition.tpl +++ b/com.woltlab.wcf/templates/shared_multiPageCondition.tpl @@ -1,3 +1,4 @@ +{* @deprecated 6.3 Only used by the deprecated `MultiPageCondition`. *} diff --git a/com.woltlab.wcf/templates/shared_objectFilterFormField.tpl b/com.woltlab.wcf/templates/shared_objectFilterFormField.tpl new file mode 100644 index 00000000000..2a64dc25fb0 --- /dev/null +++ b/com.woltlab.wcf/templates/shared_objectFilterFormField.tpl @@ -0,0 +1,13 @@ + + + diff --git a/com.woltlab.wcf/templates/shared_templateFormNode.tpl b/com.woltlab.wcf/templates/shared_templateFormNode.tpl new file mode 100644 index 00000000000..962d54f9a6e --- /dev/null +++ b/com.woltlab.wcf/templates/shared_templateFormNode.tpl @@ -0,0 +1,5 @@ +checkDependencies()} style="display: none;"{/if}> + {unsafe:$html} + + +{include file='shared_formFieldDependencies' field=$node} diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 758a2757bba..7f592a28697 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -42,11 +42,6 @@ parameters: count: 2 path: wcfsetup/install/files/lib/acp/form/AbstractCustomOptionForm.class.php - - - message: '#^Construct empty\(\) is not allowed\. Use more strict comparison\.$#' - identifier: empty.notAllowed - count: 2 - path: wcfsetup/install/files/lib/acp/form/AdAddForm.class.php - message: '#^Construct empty\(\) is not allowed\. Use more strict comparison\.$#' @@ -102,12 +97,6 @@ parameters: count: 1 path: wcfsetup/install/files/lib/acp/form/LanguageImportForm.class.php - - - message: '#^Construct empty\(\) is not allowed\. Use more strict comparison\.$#' - identifier: empty.notAllowed - count: 3 - path: wcfsetup/install/files/lib/acp/form/NoticeAddForm.class.php - - message: '#^Construct empty\(\) is not allowed\. Use more strict comparison\.$#' identifier: empty.notAllowed @@ -204,12 +193,6 @@ parameters: count: 1 path: wcfsetup/install/files/lib/acp/form/UserGroupAddForm.class.php - - - message: '#^Construct empty\(\) is not allowed\. Use more strict comparison\.$#' - identifier: empty.notAllowed - count: 1 - path: wcfsetup/install/files/lib/acp/form/UserGroupAssignmentAddForm.class.php - - message: '#^Construct empty\(\) is not allowed\. Use more strict comparison\.$#' identifier: empty.notAllowed diff --git a/ts/WoltLabSuite/Core/Component/Object/Filter/Builder.ts b/ts/WoltLabSuite/Core/Component/Object/Filter/Builder.ts new file mode 100644 index 00000000000..92496c857cf --- /dev/null +++ b/ts/WoltLabSuite/Core/Component/Object/Filter/Builder.ts @@ -0,0 +1,235 @@ +/** + * Manages the list of configured object filters of an `ObjectFilterFormField`. + * New filters are configured through a dialog, the resulting list is written + * into a hidden input field when the form is submitted. + * + * @author Alexander Ebert + * @copyright 2001-2026 WoltLab GmbH + * @license GNU Lesser General Public License + * @since 6.3 + */ + +import { promiseMutex } from "WoltLabSuite/Core/Helper/PromiseMutex"; +import { confirmationFactory } from "../../Confirmation"; +import { dialogFactory } from "../../Dialog"; +import { getPhrase } from "WoltLabSuite/Core/Language"; + +/** + * The filter returned by the dialog after it has been submitted. + */ +type Response = { + identifier: string; + summary: string; + value: string; +}; + +/** + * A filter that has already been configured. + */ +type Filter = { + identifier: string; + summary: string; + value: string; +}; + +type SerializedData = Filter[]; + +class ObjectFilterBuilder { + readonly #conditions: Map = new Map(); + readonly #container: HTMLElement; + readonly #endpoint: string; + + constructor(container: HTMLElement, endpoint: string, values: SerializedData) { + this.#container = container; + this.#endpoint = endpoint; + + const button = document.createElement("button"); + button.type = "button"; + button.classList.add("button"); + + const icon = document.createElement("fa-icon"); + icon.setIcon("plus"); + button.appendChild(icon); + + const span = document.createElement("span"); + span.textContent = getPhrase("wcf.objectFilter.addFilter"); + button.appendChild(span); + + button.addEventListener( + "click", + promiseMutex(() => this.#addFilter()), + ); + + this.#container.insertAdjacentElement("afterend", button); + + const form = this.#container.closest("form"); + let shadow: HTMLInputElement | undefined = undefined; + form?.addEventListener("submit", () => { + if (shadow === undefined) { + shadow = document.createElement("input"); + shadow.type = "hidden"; + shadow.name = this.#container.id; + + this.#container.insertAdjacentElement("afterend", shadow); + } + + shadow.value = this.#serializeConditions(); + }); + + this.#fromSerializedData(values); + } + + /** + * Restores the filters that were configured before. + */ + #fromSerializedData(values: SerializedData): void { + for (const filter of values) { + this.#createCondition(filter); + } + } + + /** + * Returns the URL of the dialog, passing the identifiers of the configured + * filters except for the given one that is being edited. Filters that are + * not repeatable are no longer offered once they have been configured. + */ + #getEndpoint(editedItem?: HTMLElement): URL { + const url = new URL(this.#endpoint, window.location.href); + this.#conditions.forEach((condition, item) => { + if (item !== editedItem) { + url.searchParams.append("used[]", condition.identifier); + } + }); + + return url; + } + + /** + * Opens the dialog to configure a new filter and adds it to the list. + */ + async #addFilter(): Promise { + const url = this.#getEndpoint(); + + const response = await dialogFactory().usingFormBuilder().fromEndpoint(url.toString()); + if (response.ok) { + this.#createCondition(response.result); + } + } + + /** + * Opens the dialog prefilled with the given filter and replaces it with the result. + */ + async #editFilter(item: HTMLElement, summary: HTMLElement): Promise { + const data = this.#conditions.get(item)!; + + const url = this.#getEndpoint(item); + url.searchParams.set("filter", data.identifier); + url.searchParams.set("value", data.value); + + const response = await dialogFactory().usingFormBuilder().fromEndpoint(url.toString()); + if (response.ok) { + summary.innerHTML = response.result.summary; + this.#conditions.set(item, response.result); + } + } + + /** + * Adds the given filter to the list, showing its summary, a button to edit it + * and a button to remove it after a confirmation. + */ + #createCondition(data: Response): void { + const item = document.createElement("div"); + item.classList.add("objectFilter__item"); + + const summary = document.createElement("span"); + summary.innerHTML = data.summary; + + const buttons = document.createElement("div"); + buttons.classList.add("objectFilter__item__buttons"); + + const editButton = document.createElement("button"); + editButton.type = "button"; + editButton.classList.add("button", "small", "jsTooltip"); + editButton.title = getPhrase("wcf.global.button.edit"); + editButton.innerHTML = ''; + editButton.addEventListener( + "click", + promiseMutex(() => this.#editFilter(item, summary)), + ); + + const deleteButton = document.createElement("button"); + deleteButton.type = "button"; + deleteButton.classList.add("button", "small", "jsTooltip"); + deleteButton.title = getPhrase("wcf.global.button.delete"); + deleteButton.innerHTML = ''; + deleteButton.addEventListener( + "click", + promiseMutex(async () => { + // Read the title when clicked, the summary changes when the filter is edited. + if (await confirmationFactory().delete(summary.textContent)) { + this.#deleteCondition(item); + } + }), + ); + + buttons.append(editButton, deleteButton); + item.append(summary, buttons); + + if (this.#conditions.size > 0) { + this.#container.append(this.#createJoiner()); + } + this.#container.append(item); + + this.#conditions.set(item, data); + } + + /** + * Creates the element that is shown between two filters, all filters must be satisfied. + */ + #createJoiner(): HTMLElement { + const joiner = document.createElement("div"); + joiner.classList.add("objectFilter__joiner"); + joiner.textContent = getPhrase("wcf.objectFilter.joiner"); + + return joiner; + } + + /** + * Removes the given filter from the list together with the joiner that + * separates it from the previous filter, or from the next filter if it is + * the first one. + */ + #deleteCondition(element: HTMLElement): void { + const joiner = element.previousElementSibling ?? element.nextElementSibling; + if (joiner?.classList.contains("objectFilter__joiner")) { + joiner.remove(); + } + + element.remove(); + this.#conditions.delete(element); + } + + /** + * Serializes the configured filters as a JSON-encoded list of + * `[filterIdentifier, serializedValue]` pairs. + */ + #serializeConditions(): string { + const values: [string, string][] = []; + this.#conditions.forEach((condition) => { + values.push([condition.identifier, condition.value]); + }); + + return JSON.stringify(values); + } +} + +/** + * Initializes the filter builder for the given container. + * + * @param container element that holds the list of configured filters, its id is used as the name of the submitted value + * @param endpoint URL of the dialog to configure a new filter + * @param values filters that were configured before + */ +export function setup(container: HTMLElement, endpoint: string, values: SerializedData): void { + new ObjectFilterBuilder(container, endpoint, values); +} 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 index e701d90f026..c2f54c2b1bb 100644 --- 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 @@ -120,4 +120,16 @@ ->onDelete('SET NULL') ->onUpdate('NO ACTION'), ]), + PartialDatabaseTable::create('wcf1_user_group_assignment') + ->columns([ + JsonDatabaseTableColumn::create('conditions'), + ]), + PartialDatabaseTable::create('wcf1_notice') + ->columns([ + JsonDatabaseTableColumn::create('conditions'), + ]), + PartialDatabaseTable::create('wcf1_ad') + ->columns([ + JsonDatabaseTableColumn::create('conditions'), + ]), ]; diff --git a/wcfsetup/install/files/acp/js/WCF.ACP.js b/wcfsetup/install/files/acp/js/WCF.ACP.js index fdaca16580d..e1fa3db079c 100644 --- a/wcfsetup/install/files/acp/js/WCF.ACP.js +++ b/wcfsetup/install/files/acp/js/WCF.ACP.js @@ -827,175 +827,3 @@ WCF.ACP.Stat.Chart = Class.extend({ elBySel('.contentHeader > .contentTitle').scrollIntoView({ behavior: 'smooth' }); } }); - -/** - * Namespace for ACP ad management. - */ -WCF.ACP.Ad = { }; - -/** - * Handles the location of an ad during ad creation/editing. - */ -WCF.ACP.Ad.LocationHandler = Class.extend({ - /** - * fieldset of the page conditions - * @var jQuery - */ - _pageConditions: null, - - /** - * select elements for the page controller condition - * @var jQuery[] - */ - _pageInputs: [], - - /** - * page controller condition container - * @var jQuery[] - */ - _pageSelectionContainer: null, - - /** - * Initializes a new WCF.ACP.Ad.LocationHandler object. - * - * @param {object} variablesDescriptions - */ - init: function(variablesDescriptions) { - this._variablesDescriptions = variablesDescriptions; - - this._pageConditions = $('#pageConditions'); - this._pageInputs = $('input[name="pageIDs[]"]'); - - this._variablesDescriptionsList = $('#ad').next('small').children('ul'); - - this._pageSelectionContainer = $(this._pageInputs[0]).parents('dl:eq(0)'); - - // hide the page controller elements - this._hidePageSelection(true); - - $('#objectTypeID').on('change', $.proxy(this._setPageController, this)); - - this._setPageController(); - - $('#adForm').submit($.proxy(this._submit, this)); - }, - - /** - * Hides the page selection form field. - * - * @since 5.2 - */ - _hidePageSelection: function(addEventListeners) { - this._pageSelectionContainer.prev('dl').hide(); - this._pageSelectionContainer.hide(); - - // fix the margin of a potentially next page condition element - this._pageSelectionContainer.next('dl').css('margin-top', 0); - - var section = this._pageSelectionContainer.parent('section'); - if (!section.children('dl:visible').length) { - section.hide(); - - var nextSection = section.next('section'); - if (nextSection) { - nextSection.css('margin-top', 0); - - if (addEventListeners) { - require(['EventHandler'], function(EventHandler) { - EventHandler.add('com.woltlab.wcf.pageConditionDependence', 'checkVisivility', function() { - if (section.is(':visible')) { - nextSection.css('margin-top', '40px'); - } - else { - nextSection.css('margin-top', 0); - } - }); - }); - } - } - } - }, - - /** - * Shows the page selection form field. - * - * @since 5.2 - */ - _showPageSelection: function() { - this._pageSelectionContainer.prev('dl').show(); - this._pageSelectionContainer.show(); - this._pageSelectionContainer.next('dl').css('margin-top', '40px'); - - var section = this._pageSelectionContainer.parent('section'); - section.show(); - - var nextSection = section.next('section'); - if (nextSection) { - nextSection.css('margin-top', '40px'); - } - }, - - /** - * Sets the page controller based on the selected ad location. - */ - _setPageController: function() { - var option = $('#objectTypeID').find('option:checked'); - var parent = option.parent(); - - // the page controller can be explicitly set for global positions - if (parent.is('optgroup') && parent.data('categoryName') === 'com.woltlab.wcf.global') { - this._showPageSelection(); - } - else { - this._hidePageSelection(); - - require(['Core'], function(Core) { - var input, triggerEvent; - - // select the related page - for (var i = 0, length = this._pageInputs.length; i < length; i++) { - input = this._pageInputs[i]; - triggerEvent = false; - - if (option.data('page') && elData(input, 'identifier') === option.data('page')) { - if (!input.checked) triggerEvent = true; - - input.checked = true; - } - else { - if (input.checked) triggerEvent = true; - - input.checked = false; - } - - if (triggerEvent) Core.triggerEvent(this._pageInputs[i], 'change'); - } - }.bind(this)); - } - - this._variablesDescriptionsList.children(':not(.jsDefaultItem)').remove(); - - var objectTypeId = $('#objectTypeID').val(); - if (objectTypeId in this._variablesDescriptions) { - this._variablesDescriptionsList[0].innerHTML += this._variablesDescriptions[objectTypeId]; - } - }, - - /** - * Handles submitting the ad form. - */ - _submit: function() { - if (this._pageConditions.is(':hidden')) { - // remove hidden page condition form elements to avoid creation - // of these conditions - this._pageConditions.find('select, input').remove(); - } - else if (this._pageSelectionContainer.is(':hidden')) { - // reset page controller conditions to avoid creation of - // unnecessary conditions - for (var i = 0, length = this._pageInputs.length; i < length; i++) { - this._pageInputs[i].checked = false; - } - } - } -}); diff --git a/wcfsetup/install/files/acp/templates/__adLocationVariables.tpl b/wcfsetup/install/files/acp/templates/__adLocationVariables.tpl new file mode 100644 index 00000000000..90e8a8eab32 --- /dev/null +++ b/wcfsetup/install/files/acp/templates/__adLocationVariables.tpl @@ -0,0 +1,10 @@ + + + + + + {unsafe:$variablesDescription} + + + + diff --git a/wcfsetup/install/files/acp/templates/__noticeCssClassNameExample.tpl b/wcfsetup/install/files/acp/templates/__noticeCssClassNameExample.tpl new file mode 100644 index 00000000000..6e0110394e0 --- /dev/null +++ b/wcfsetup/install/files/acp/templates/__noticeCssClassNameExample.tpl @@ -0,0 +1,32 @@ + + + + {lang}wcf.acp.notice.example{/lang} + + + + diff --git a/wcfsetup/install/files/acp/templates/adAdd.tpl b/wcfsetup/install/files/acp/templates/adAdd.tpl index 5e134fbfd65..984d35a4f2c 100644 --- a/wcfsetup/install/files/acp/templates/adAdd.tpl +++ b/wcfsetup/install/files/acp/templates/adAdd.tpl @@ -1,21 +1,13 @@ {include file='header' pageTitle='wcf.acp.ad.'|concat:$action} - - {lang}wcf.acp.ad.{$action}{/lang} - + - {if $action == 'edit'} + {if $action === 'edit'} {unsafe:$interactionContextMenu->render()} @@ -25,135 +17,6 @@ -{include file='shared_formNotice'} - - - - - {lang}wcf.global.name{/lang} - - - {if $errorField == 'adName'} - - {if $errorType == 'empty'} - {lang}wcf.global.form.error.empty{/lang} - {else} - {lang}wcf.acp.ad.adName.error.{$errorType}{/lang} - {/if} - - {/if} - - - - - {lang}wcf.acp.ad.ad{/lang} - - {$ad} - {lang}wcf.acp.ad.ad.description{/lang} - {if $errorField == 'ad'} - - {if $errorType == 'empty'} - {lang}wcf.global.form.error.empty{/lang} - {else} - {lang}wcf.acp.ad.ad.error.{$errorType}{/lang} - {/if} - - {/if} - - - - - - - {lang}wcf.acp.ad.isDisabled{/lang} - - - - - {lang}wcf.acp.ad.location{/lang} - - - {lang}wcf.global.noSelection{/lang} - {foreach from=$locations key='locationGroupLabel' item='locationGroup'} - {assign var='__firstLocationID' value=$locationGroup|key} - - {foreach from=$locationGroup key='locationID' item='location'} - page} data-page="{$locationObjectTypes[$locationID]->page}"{/if}{if $objectTypeID == $locationID} selected{/if}>{$location} - {/foreach} - - {/foreach} - - {if $errorField == 'objectTypeID'} - - {if $errorType == 'empty'} - {lang}wcf.global.form.error.empty{/lang} - {elseif $errorType == 'noValidSelection'} - {lang}wcf.global.form.error.noValidSelection{/lang} - {else} - {lang}wcf.acp.ad.location.error.{$errorType}{/lang} - {/if} - - {/if} - - - - - {lang}wcf.global.showOrder{/lang} - - - {lang}wcf.acp.ad.showOrder.description{/lang} - - - - {event name='dataFields'} - - - {event name='sections'} - - - - {lang}wcf.acp.ad.conditions{/lang} - {lang}wcf.acp.ad.conditions.description{/lang} - - - - - {lang}wcf.acp.ad.conditions.page{/lang} - {lang}wcf.acp.ad.conditions.page.description{/lang} - - - {foreach from=$groupedConditionObjectTypes['com.woltlab.wcf.page'] item='pageConditionObjectType'} - {unsafe:$pageConditionObjectType->getProcessor()->getHtml()} - {/foreach} - - - - - {lang}wcf.acp.ad.conditions.pointInTime{/lang} - {lang}wcf.acp.ad.conditions.pointInTime.description{/lang} - - - {foreach from=$groupedConditionObjectTypes['com.woltlab.wcf.pointInTime'] item='pointInTimeConditionObjectType'} - {unsafe:$pointInTimeConditionObjectType->getProcessor()->getHtml()} - {/foreach} - - - {event name='conditionTypeFieldsets'} - - - - - {lang}wcf.acp.ad.conditions.user{/lang} - {lang}wcf.acp.ad.conditions.user.description{/lang} - - - {include file='shared_userConditions' groupedObjectTypes=$groupedConditionObjectTypes['com.woltlab.wcf.user']} - - - - - {csrfToken} - - +{unsafe:$form->getHTML()} {include file='footer'} diff --git a/wcfsetup/install/files/acp/templates/noticeAdd.tpl b/wcfsetup/install/files/acp/templates/noticeAdd.tpl index 2df7895077e..73a9303006e 100644 --- a/wcfsetup/install/files/acp/templates/noticeAdd.tpl +++ b/wcfsetup/install/files/acp/templates/noticeAdd.tpl @@ -4,10 +4,10 @@ {lang}wcf.acp.notice.{$action}{/lang} - + - {if $action == 'edit'} + {if $action === 'edit'} {unsafe:$interactionContextMenu->render()} @@ -17,195 +17,6 @@ -{include file='shared_formNotice'} - - - - - {lang}wcf.global.name{/lang} - - - {if $errorField == 'noticeName'} - - {if $errorType == 'empty'} - {lang}wcf.global.form.error.empty{/lang} - {else} - {lang}wcf.acp.notice.noticeName.error.{$errorType}{/lang} - {/if} - - {/if} - - - - - {lang}wcf.acp.notice.notice{/lang} - - {$i18nPlainValues['notice']} - {if $errorField == 'notice'} - - {if $errorType == 'empty'} - {lang}wcf.global.form.error.empty{/lang} - {elseif $errorType == 'multilingual'} - {lang}wcf.global.form.error.multilingual{/lang} - {else} - {lang}wcf.acp.notice.notice.error.{$errorType}{/lang} - {/if} - - {/if} - - - {include file='shared_multipleLanguageInputJavascript' elementIdentifier='notice' forceSelection=false} - - - - - {lang}wcf.acp.notice.noticeUseHtml{/lang} - - - - - {lang}wcf.global.showOrder{/lang} - - - {lang}wcf.acp.notice.showOrder.description{/lang} - - - - {event name='dataFields'} - - - - {lang}wcf.global.settings{/lang} - - - {lang}wcf.acp.notice.cssClassName{/lang} - - {foreach from=$availableCssClassNames item=className} - {lang}wcf.acp.notice.cssClassName.{$className}{/lang} - {/foreach} - - - - {if $errorField == 'cssClassName'} - - {if $errorType == 'empty'} - {lang}wcf.global.form.error.empty{/lang} - {else} - {lang}wcf.acp.notice.cssClassName.error.{$errorType}{/lang} - {/if} - - {/if} - {lang}wcf.acp.notice.cssClassName.description{/lang} - - {lang}wcf.acp.notice.example{/lang} - - - - - - - {lang}wcf.acp.notice.isDisabled{/lang} - - - - - - - {lang}wcf.acp.notice.isDismissible{/lang} - {lang}wcf.acp.notice.isDismissible.description{/lang} - - - - {if $action == 'edit' && $notice->isDismissible} - - - - {lang}wcf.acp.notice.resetIsDismissed{/lang} - {lang}wcf.acp.notice.resetIsDismissed.description{/lang} - - - {/if} - - {event name='settingsFields'} - - - {event name='sections'} - - - - {lang}wcf.acp.notice.conditions{/lang} - {lang}wcf.acp.notice.conditions.description{/lang} - - - - - {lang}wcf.acp.notice.conditions.page{/lang} - {lang}wcf.acp.notice.conditions.page.description{/lang} - - - {foreach from=$groupedConditionObjectTypes['com.woltlab.wcf.page'] item='pageConditionObjectType'} - {unsafe:$pageConditionObjectType->getProcessor()->getHtml()} - {/foreach} - - - - - {lang}wcf.acp.notice.conditions.pointInTime{/lang} - {lang}wcf.acp.notice.conditions.pointInTime.description{/lang} - - - {foreach from=$groupedConditionObjectTypes['com.woltlab.wcf.pointInTime'] item='pointInTimeConditionObjectType'} - {unsafe:$pointInTimeConditionObjectType->getProcessor()->getHtml()} - {/foreach} - - - {event name='conditionTypeSections'} - - - - - {lang}wcf.acp.notice.conditions.user{/lang} - {lang}wcf.acp.notice.conditions.user.description{/lang} - - - {include file='shared_userConditions' groupedObjectTypes=$groupedConditionObjectTypes['com.woltlab.wcf.user']} - - - {event name='conditionContainers'} - - - - {csrfToken} - - - - - +{unsafe:$form->getHTML()} {include file='footer'} diff --git a/wcfsetup/install/files/acp/templates/userGroupAssignmentAdd.tpl b/wcfsetup/install/files/acp/templates/userGroupAssignmentAdd.tpl index cfd0d828d14..8d0b99f820a 100644 --- a/wcfsetup/install/files/acp/templates/userGroupAssignmentAdd.tpl +++ b/wcfsetup/install/files/acp/templates/userGroupAssignmentAdd.tpl @@ -4,10 +4,10 @@ {lang}wcf.acp.group.assignment.{$action}{/lang} - + - {if $action == 'edit'} + {if $action === 'edit'} {unsafe:$interactionContextMenu->render()} @@ -17,70 +17,6 @@ -{include file='shared_formNotice'} - - - - - {lang}wcf.global.name{/lang} - - - {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} - - - - - {lang}wcf.user.group{/lang} - - {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} - - - - - - - {lang}wcf.acp.group.assignment.isDisabled{/lang} - - - - {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'} +{include file='footer'} \ No newline at end of file diff --git a/wcfsetup/install/files/acp/update_com.woltlab.wcf_6.3_conditions.php b/wcfsetup/install/files/acp/update_com.woltlab.wcf_6.3_conditions.php new file mode 100644 index 00000000000..354927af171 --- /dev/null +++ b/wcfsetup/install/files/acp/update_com.woltlab.wcf_6.3_conditions.php @@ -0,0 +1,202 @@ + + */ + +use wcf\system\condition\DaysOfWeekCondition; +use wcf\system\condition\page\MultiPageCondition; +use wcf\system\condition\UserAvatarCondition; +use wcf\system\condition\UserBirthdayCondition; +use wcf\system\condition\UserCoverPhotoCondition; +use wcf\system\condition\UserEmailCondition; +use wcf\system\condition\UserGroupCondition; +use wcf\system\condition\UserIntegerPropertyCondition; +use wcf\system\condition\UserLanguageCondition; +use wcf\system\condition\UserMobileBrowserCondition; +use wcf\system\condition\UserMultifactorCondition; +use wcf\system\condition\UserRegistrationDateCondition; +use wcf\system\condition\UserRegistrationDateIntervalCondition; +use wcf\system\condition\UserSignatureCondition; +use wcf\system\condition\UserStateCondition; +use wcf\system\condition\UserTrophyCondition; +use wcf\system\condition\UserUsernameCondition; +use wcf\system\object\filter\migration\LegacyConditionMigration; + +/** + * Returns the given ids as a comma-separated list, or `null` if there are none. + */ +$idList = static function (mixed $ids): ?string { + if (!\is_array($ids) || $ids === []) { + return null; + } + + return \implode(',', \array_map(static fn($id) => (int)$id, $ids)); +}; + +/** + * Returns one filter per id, the ids are optional. + * + * @return list + */ +$filterPerID = static function (string $identifier, mixed $ids): array { + if (!\is_array($ids)) { + return []; + } + + return \array_values(\array_map( + static fn($id) => [$identifier, (string)(int)$id], + $ids, + )); +}; + +/** + * Converts a legacy yes/no value (`0` or `1`) into a boolean filter. + * + * @return ?list + */ +$boolean = static function (string $identifier, mixed $value): ?array { + return match ((int)$value) { + 0 => [[$identifier, '0']], + 1 => [[$identifier, '1']], + default => null, + }; +}; + +$converters = [ + MultiPageCondition::class => static function (array $data) use ($idList): ?array { + $pageIDs = $idList($data['pageIDs'] ?? null); + if ($pageIDs === null) { + return null; + } + + // Older conditions do not store the reverse logic flag. + $reverseLogic = (bool)($data['pageIDs_reverseLogic'] ?? false); + $identifier = $reverseLogic ? 'com.woltlab.wcf.notRequestedPage' : 'com.woltlab.wcf.requestedPage'; + + return [[$identifier, $pageIDs]]; + }, + DaysOfWeekCondition::class => static function (array $data) use ($idList): ?array { + $days = $idList($data['daysOfWeek'] ?? null); + if ($days === null) { + return null; + } + + return [['com.woltlab.wcf.daysOfWeek', $days]]; + }, + UserUsernameCondition::class => static function (array $data): ?array { + $username = (string)($data['username'] ?? ''); + if ($username === '') { + return null; + } + + return [['com.woltlab.wcf.userUsername', $username]]; + }, + UserEmailCondition::class => static function (array $data): ?array { + $email = (string)($data['email'] ?? ''); + if ($email === '') { + return null; + } + + return [['com.woltlab.wcf.userEmail', $email]]; + }, + UserGroupCondition::class => static function (array $data) use ($filterPerID): array { + return [ + ...$filterPerID('com.woltlab.wcf.userGroup', $data['groupIDs'] ?? null), + ...$filterPerID('com.woltlab.wcf.userNotInGroup', $data['notGroupIDs'] ?? null), + ]; + }, + UserLanguageCondition::class => static function (array $data) use ($idList): ?array { + $languageIDs = $idList($data['languageIDs'] ?? null); + if ($languageIDs === null) { + return null; + } + + return [['com.woltlab.wcf.userLanguage', $languageIDs]]; + }, + UserRegistrationDateCondition::class => static function (array $data): ?array { + $start = (string)($data['registrationDateStart'] ?? ''); + $end = (string)($data['registrationDateEnd'] ?? ''); + if ($start === '' && $end === '') { + return null; + } + + return [['com.woltlab.wcf.userRegistrationDate', $start . ';' . $end]]; + }, + UserRegistrationDateIntervalCondition::class => static function (array $data): ?array { + $greaterThan = isset($data['greaterThan']) ? (string)(int)$data['greaterThan'] : ''; + $lessThan = isset($data['lessThan']) ? (string)(int)$data['lessThan'] : ''; + if ($greaterThan === '' && $lessThan === '') { + return null; + } + + return [['com.woltlab.wcf.userRegistrationDays', $greaterThan . ';' . $lessThan]]; + }, + UserAvatarCondition::class => static function (array $data) use ($boolean): ?array { + // The value `2` (Gravatar) is no longer supported and never matched. + return $boolean('com.woltlab.wcf.userAvatar', $data['userAvatar'] ?? null); + }, + UserSignatureCondition::class => static function (array $data) use ($boolean): ?array { + return $boolean('com.woltlab.wcf.userSignature', $data['userSignature'] ?? null); + }, + UserCoverPhotoCondition::class => static function (array $data) use ($boolean): ?array { + return $boolean('com.woltlab.wcf.userCoverPhoto', $data['userCoverPhoto'] ?? null); + }, + UserStateCondition::class => static function (array $data) use ($boolean): ?array { + $filters = []; + foreach ( + [ + 'userIsBanned' => 'com.woltlab.wcf.userBanned', + 'userIsEnabled' => 'com.woltlab.wcf.userActivated', + 'userIsEmailConfirmed' => 'com.woltlab.wcf.userEmailConfirmed', + ] as $key => $identifier + ) { + if (!isset($data[$key])) { + continue; + } + + $filter = $boolean($identifier, $data[$key]); + if ($filter === null) { + return null; + } + + \array_push($filters, ...$filter); + } + + return $filters; + }, + UserMobileBrowserCondition::class => static function (array $data) use ($boolean): ?array { + return $boolean('com.woltlab.wcf.userMobileBrowser', $data['usesMobileBrowser'] ?? null); + }, + UserBirthdayCondition::class => static function (array $data) use ($boolean): ?array { + return $boolean('com.woltlab.wcf.userBirthday', $data['birthdayToday'] ?? null); + }, + UserMultifactorCondition::class => static function (array $data) use ($boolean): ?array { + return $boolean('com.woltlab.wcf.userMultifactor', $data['multifactorActive'] ?? null); + }, + // Properties of apps are migrated by the apps, which provide the matching filters. + UserIntegerPropertyCondition::class => LegacyConditionMigration::getUserIntegerPropertyConverter([ + 'activityPoints', + 'likesReceived', + 'trophyPoints', + ]), + UserTrophyCondition::class => static function (array $data) use ($filterPerID): array { + return [ + ...$filterPerID('com.woltlab.wcf.userTrophy', $data['userTrophyIDs'] ?? null), + ...$filterPerID('com.woltlab.wcf.userNoTrophy', $data['notUserTrophyIDs'] ?? null), + ]; + }, +]; + +(new LegacyConditionMigration('com.woltlab.wcf.condition.notice', 'wcf1_notice', 'noticeID')) + ->migrate($converters); +(new LegacyConditionMigration('com.woltlab.wcf.condition.ad', 'wcf1_ad', 'adID')) + ->migrate($converters); +(new LegacyConditionMigration('com.woltlab.wcf.condition.userGroupAssignment', 'wcf1_user_group_assignment', 'assignmentID')) + ->migrate($converters); diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Object/Filter/Builder.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Object/Filter/Builder.js new file mode 100644 index 00000000000..7b43df57cb3 --- /dev/null +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Object/Filter/Builder.js @@ -0,0 +1,172 @@ +/** + * Manages the list of configured object filters of an `ObjectFilterFormField`. + * New filters are configured through a dialog, the resulting list is written + * into a hidden input field when the form is submitted. + * + * @author Alexander Ebert + * @copyright 2001-2026 WoltLab GmbH + * @license GNU Lesser General Public License + * @since 6.3 + */ +define(["require", "exports", "WoltLabSuite/Core/Helper/PromiseMutex", "../../Confirmation", "../../Dialog", "WoltLabSuite/Core/Language"], function (require, exports, PromiseMutex_1, Confirmation_1, Dialog_1, Language_1) { + "use strict"; + Object.defineProperty(exports, "__esModule", { value: true }); + exports.setup = setup; + class ObjectFilterBuilder { + #conditions = new Map(); + #container; + #endpoint; + constructor(container, endpoint, values) { + this.#container = container; + this.#endpoint = endpoint; + const button = document.createElement("button"); + button.type = "button"; + button.classList.add("button"); + const icon = document.createElement("fa-icon"); + icon.setIcon("plus"); + button.appendChild(icon); + const span = document.createElement("span"); + span.textContent = (0, Language_1.getPhrase)("wcf.objectFilter.addFilter"); + button.appendChild(span); + button.addEventListener("click", (0, PromiseMutex_1.promiseMutex)(() => this.#addFilter())); + this.#container.insertAdjacentElement("afterend", button); + const form = this.#container.closest("form"); + let shadow = undefined; + form?.addEventListener("submit", () => { + if (shadow === undefined) { + shadow = document.createElement("input"); + shadow.type = "hidden"; + shadow.name = this.#container.id; + this.#container.insertAdjacentElement("afterend", shadow); + } + shadow.value = this.#serializeConditions(); + }); + this.#fromSerializedData(values); + } + /** + * Restores the filters that were configured before. + */ + #fromSerializedData(values) { + for (const filter of values) { + this.#createCondition(filter); + } + } + /** + * Returns the URL of the dialog, passing the identifiers of the configured + * filters except for the given one that is being edited. Filters that are + * not repeatable are no longer offered once they have been configured. + */ + #getEndpoint(editedItem) { + const url = new URL(this.#endpoint, window.location.href); + this.#conditions.forEach((condition, item) => { + if (item !== editedItem) { + url.searchParams.append("used[]", condition.identifier); + } + }); + return url; + } + /** + * Opens the dialog to configure a new filter and adds it to the list. + */ + async #addFilter() { + const url = this.#getEndpoint(); + const response = await (0, Dialog_1.dialogFactory)().usingFormBuilder().fromEndpoint(url.toString()); + if (response.ok) { + this.#createCondition(response.result); + } + } + /** + * Opens the dialog prefilled with the given filter and replaces it with the result. + */ + async #editFilter(item, summary) { + const data = this.#conditions.get(item); + const url = this.#getEndpoint(item); + url.searchParams.set("filter", data.identifier); + url.searchParams.set("value", data.value); + const response = await (0, Dialog_1.dialogFactory)().usingFormBuilder().fromEndpoint(url.toString()); + if (response.ok) { + summary.innerHTML = response.result.summary; + this.#conditions.set(item, response.result); + } + } + /** + * Adds the given filter to the list, showing its summary, a button to edit it + * and a button to remove it after a confirmation. + */ + #createCondition(data) { + const item = document.createElement("div"); + item.classList.add("objectFilter__item"); + const summary = document.createElement("span"); + summary.innerHTML = data.summary; + const buttons = document.createElement("div"); + buttons.classList.add("objectFilter__item__buttons"); + const editButton = document.createElement("button"); + editButton.type = "button"; + editButton.classList.add("button", "small", "jsTooltip"); + editButton.title = (0, Language_1.getPhrase)("wcf.global.button.edit"); + editButton.innerHTML = ''; + editButton.addEventListener("click", (0, PromiseMutex_1.promiseMutex)(() => this.#editFilter(item, summary))); + const deleteButton = document.createElement("button"); + deleteButton.type = "button"; + deleteButton.classList.add("button", "small", "jsTooltip"); + deleteButton.title = (0, Language_1.getPhrase)("wcf.global.button.delete"); + deleteButton.innerHTML = ''; + deleteButton.addEventListener("click", (0, PromiseMutex_1.promiseMutex)(async () => { + // Read the title when clicked, the summary changes when the filter is edited. + if (await (0, Confirmation_1.confirmationFactory)().delete(summary.textContent)) { + this.#deleteCondition(item); + } + })); + buttons.append(editButton, deleteButton); + item.append(summary, buttons); + if (this.#conditions.size > 0) { + this.#container.append(this.#createJoiner()); + } + this.#container.append(item); + this.#conditions.set(item, data); + } + /** + * Creates the element that is shown between two filters, all filters must be satisfied. + */ + #createJoiner() { + const joiner = document.createElement("div"); + joiner.classList.add("objectFilter__joiner"); + joiner.textContent = (0, Language_1.getPhrase)("wcf.objectFilter.joiner"); + return joiner; + } + /** + * Removes the given filter from the list together with the joiner that + * separates it from the previous filter, or from the next filter if it is + * the first one. + */ + #deleteCondition(element) { + const joiner = element.previousElementSibling ?? element.nextElementSibling; + if (joiner?.classList.contains("objectFilter__joiner")) { + joiner.remove(); + } + element.remove(); + this.#conditions.delete(element); + } + /** + * Serializes the configured filters as a JSON-encoded list of + * `[filterIdentifier, serializedValue]` pairs. + */ + #serializeConditions() { + const values = []; + this.#conditions.forEach((condition) => { + values.push([condition.identifier, condition.value]); + }); + return JSON.stringify(values); + } + } + /** + * Initializes the filter builder for the given container. + * + * @param container element that holds the list of configured filters, its id is used as the name of the submitted value + * @param endpoint URL of the dialog to configure a new filter + * @param values filters that were configured before + */ + function setup(container, endpoint, values) { + new ObjectFilterBuilder(container, endpoint, values); + } +}); diff --git a/wcfsetup/install/files/lib/acp/form/AdAddForm.class.php b/wcfsetup/install/files/lib/acp/form/AdAddForm.class.php index 00ecb9f9ff9..366f6cd4e45 100644 --- a/wcfsetup/install/files/lib/acp/form/AdAddForm.class.php +++ b/wcfsetup/install/files/lib/acp/form/AdAddForm.class.php @@ -2,26 +2,37 @@ namespace wcf\acp\form; -use wcf\data\ad\AdAction; -use wcf\data\object\type\ObjectType; -use wcf\data\object\type\ObjectTypeCache; -use wcf\form\AbstractForm; +use wcf\command\ad\CreateAd; +use wcf\command\ad\UpdateAd; +use wcf\data\ad\Ad; +use wcf\data\ad\AdBuilder; +use wcf\data\DatabaseObjectBuilder; +use wcf\form\AbstractDatabaseObjectBuilderForm; use wcf\system\ad\AdHandler; use wcf\system\ad\location\IAdLocation; -use wcf\system\condition\ConditionHandler; -use wcf\system\exception\UserInputException; -use wcf\system\request\LinkHandler; -use wcf\system\WCF; -use wcf\util\StringUtil; +use wcf\system\form\builder\container\FormContainer; +use wcf\system\form\builder\field\BooleanFormField; +use wcf\system\form\builder\field\dependency\ValueFormFieldDependency; +use wcf\system\form\builder\field\IFormField; +use wcf\system\form\builder\field\IntegerFormField; +use wcf\system\form\builder\field\MultilineTextFormField; +use wcf\system\form\builder\field\ObjectFilterFormField; +use wcf\system\form\builder\field\SelectFormField; +use wcf\system\form\builder\field\TextFormField; +use wcf\system\form\builder\IFormChildNode; +use wcf\system\form\builder\TemplateFormNode; +use wcf\system\object\filter\builder\AdObjectFilterBuilder; /** - * Shows the form to create a new ad notice. + * Shows the form to create a new ad. * - * @author Matthias Schmidt - * @copyright 2001-2019 WoltLab GmbH - * @license GNU Lesser General Public License + * @author Matthias Schmidt, Marcel Werk + * @copyright 2001-2026 WoltLab GmbH + * @license GNU Lesser General Public License + * + * @extends AbstractDatabaseObjectBuilderForm */ -class AdAddForm extends AbstractForm +class AdAddForm extends AbstractDatabaseObjectBuilderForm { /** * @inheritDoc @@ -39,229 +50,150 @@ class AdAddForm extends AbstractForm public $neededModules = ['MODULE_WCF_AD']; /** - * html code of the ad - * @var string - */ - public $ad = ''; - - /** - * name of the notice - * @var string - */ - public $adName = ''; - - /** - * grouped ad condition object types - * @var (ObjectType|ObjectType[])[][] - */ - public $groupedConditionObjectTypes = []; - - /** - * 1 if the ad is disabled - * @var int - */ - public $isDisabled = 0; - - /** - * list of available location object types - * @var ObjectType[] - */ - public $locationObjectTypes = []; - - /** - * list of available locations - * @var array> - */ - public $locations = []; - - /** - * id of the selected location's object type - * @var int - */ - public $objectTypeID = 0; - - /** - * order used to the show the ads - * @var int + * @inheritDoc */ - public $showOrder = 0; + public string $objectEditLinkController = AdEditForm::class; #[\Override] - public function assignVariables() + protected function getDatabaseObjectBuilder(): AdBuilder { - parent::assignVariables(); - - $variablesDescriptions = []; - foreach ($this->locationObjectTypes as $objectType) { - if ($objectType->className !== '' && \is_subclass_of($objectType->className, IAdLocation::class)) { - /** @var IAdLocation $adLocation */ - $adLocation = $objectType->getProcessor(); - - $variablesDescriptions[$objectType->objectTypeID] = $adLocation->getVariablesDescription(); - } + if ($this->formObject !== null) { + return AdBuilder::forUpdate($this->formObject); } - WCF::getTPL()->assign([ - 'action' => 'add', - 'ad' => $this->ad, - 'adName' => $this->adName, - 'locationObjectTypes' => $this->locationObjectTypes, - 'locations' => $this->locations, - 'isDisabled' => $this->isDisabled, - 'groupedConditionObjectTypes' => $this->groupedConditionObjectTypes, - 'objectTypeID' => $this->objectTypeID, - 'showOrder' => $this->showOrder, - 'variablesDescriptions' => $variablesDescriptions, - ]); + return AdBuilder::forCreate(); } #[\Override] - public function readData() + protected function getCommand(DatabaseObjectBuilder $builder): callable { - $objectTypes = ObjectTypeCache::getInstance()->getObjectTypes('com.woltlab.wcf.condition.ad'); - foreach ($objectTypes as $objectType) { - if ($objectType->conditionobject === null) { - continue; - } - - if (!isset($this->groupedConditionObjectTypes[$objectType->conditionobject])) { - $this->groupedConditionObjectTypes[$objectType->conditionobject] = []; - } - - if ($objectType->conditiongroup !== null) { - if (!isset($this->groupedConditionObjectTypes[$objectType->conditionobject][$objectType->conditiongroup])) { - $this->groupedConditionObjectTypes[$objectType->conditionobject][$objectType->conditiongroup] = []; - } - - $this->groupedConditionObjectTypes[$objectType->conditionobject][$objectType->conditiongroup][$objectType->objectTypeID] = $objectType; - } else { - $this->groupedConditionObjectTypes[$objectType->conditionobject][$objectType->objectTypeID] = $objectType; - } + if ($this->formObject !== null) { + return new UpdateAd($builder); } - $this->locations = AdHandler::getInstance()->getLocationSelection(); - foreach (AdHandler::getInstance()->getLocationObjectTypes() as $objectType) { - $this->locationObjectTypes[$objectType->objectTypeID] = $objectType; - } - - parent::readData(); + return new CreateAd($builder); } #[\Override] - public function readFormParameters() + protected function createForm(): void { - parent::readFormParameters(); - - if (isset($_POST['ad'])) { - $this->ad = StringUtil::trim($_POST['ad']); - } - if (isset($_POST['adName'])) { - $this->adName = StringUtil::trim($_POST['adName']); - } - if (isset($_POST['isDisabled'])) { - $this->isDisabled = 1; - } - if (isset($_POST['objectTypeID'])) { - $this->objectTypeID = \intval($_POST['objectTypeID']); - } - if (isset($_POST['showOrder'])) { - $this->showOrder = \intval($_POST['showOrder']); - } - - foreach ($this->groupedConditionObjectTypes as $groupedObjectTypes) { - foreach ($groupedObjectTypes as $objectTypes) { - if (\is_array($objectTypes)) { - foreach ($objectTypes as $objectType) { - $objectType->getProcessor()->readFormParameters(); - } - } else { - $objectTypes->getProcessor()->readFormParameters(); - } - } - } + $location = SelectFormField::create('objectTypeID') + ->label('wcf.acp.ad.location') + ->options($this->getLocationOptions(), true, false) + ->ignoreInvalidValues() + ->required() + ->saveValueCallback(static function (AdBuilder $builder, IFormField $field) { + $builder->setObjectTypeID((int)$field->getSaveValue()); + }); + + $this->form->appendChildren([ + FormContainer::create('data') + ->appendChildren([ + TextFormField::create('adName') + ->label('wcf.global.name') + ->maximumLength(255) + ->required() + ->autoFocus() + ->saveValueCallback(static function (AdBuilder $builder, IFormField $field) { + $builder->setAdName($field->getSaveValue()); + }), + MultilineTextFormField::create('ad') + ->label('wcf.acp.ad.ad') + ->description('wcf.acp.ad.ad.description') + ->required() + ->rows(10) + ->saveValueCallback(static function (AdBuilder $builder, IFormField $field) { + $builder->setAd($field->getSaveValue()); + }), + ...$this->getLocationVariablesNodes($location), + $location, + IntegerFormField::create('showOrder') + ->label('wcf.global.showOrder') + ->description('wcf.acp.ad.showOrder.description') + ->minimum(0) + ->saveValueCallback(static function (AdBuilder $builder, IFormField $field) { + $builder->setShowOrder($field->getSaveValue()); + }), + ]), + FormContainer::create('settings') + ->label('wcf.global.settings') + ->appendChildren([ + BooleanFormField::create('isDisabled') + ->label('wcf.acp.ad.isDisabled') + ->saveValueCallback(static function (AdBuilder $builder, IFormField $field) { + $builder->setIsDisabled((bool)$field->getSaveValue()); + }), + ]), + FormContainer::create('conditionsContainer') + ->label('wcf.acp.ad.conditions') + ->description('wcf.acp.ad.conditions.description') + ->appendChild( + ObjectFilterFormField::create('conditions') + ->builder(new AdObjectFilterBuilder()) + ->saveValueCallback(static function (AdBuilder $builder, IFormField $field) { + $builder->setConditions($field->getSaveValue()); + }) + ), + ]); } - #[\Override] - public function save() + /** + * Returns the available locations grouped by their category. + * + * @return list + */ + private function getLocationOptions(): array { - parent::save(); - - $this->objectAction = new AdAction([], 'create', [ - 'data' => \array_merge($this->additionalFields, [ - 'ad' => $this->ad, - 'adName' => $this->adName, - 'isDisabled' => $this->isDisabled, - 'objectTypeID' => $this->objectTypeID, - 'showOrder' => $this->showOrder, - ]), - ]); - $returnValues = $this->objectAction->executeAction(); - - // transform conditions array into one-dimensional array - $conditions = []; - foreach ($this->groupedConditionObjectTypes as $groupedObjectTypes) { - foreach ($groupedObjectTypes as $objectTypes) { - if (\is_array($objectTypes)) { - $conditions = \array_merge($conditions, $objectTypes); - } else { - $conditions[] = $objectTypes; - } + $options = []; + foreach (AdHandler::getInstance()->getLocationSelection() as $categoryLabel => $locations) { + $options[] = [ + 'label' => $categoryLabel, + 'value' => 'category' . \count($options), + 'depth' => 0, + 'isSelectable' => false, + ]; + + foreach ($locations as $objectTypeID => $locationLabel) { + $options[] = [ + 'label' => $locationLabel, + 'value' => $objectTypeID, + 'depth' => 1, + ]; } } - ConditionHandler::getInstance()->createConditions($returnValues['returnValues']->adID, $conditions); - - $this->saved(); - - // reset values - $this->ad = ''; - $this->adName = ''; - $this->isDisabled = 0; - $this->objectTypeID = 0; - $this->showOrder = 0; - - foreach ($conditions as $condition) { - $condition->getProcessor()->reset(); - } - - WCF::getTPL()->assign([ - 'success' => true, - 'objectEditLink' => LinkHandler::getInstance()->getControllerLink( - AdEditForm::class, - ['id' => $returnValues['returnValues']->adID] - ), - ]); + return $options; } - #[\Override] - public function validate() + /** + * Returns the nodes that list the location specific variables that are + * replaced within the ad, each is only shown while its location is selected. + * + * @return list + */ + private function getLocationVariablesNodes(SelectFormField $location): array { - parent::validate(); - - if (empty($this->adName)) { - throw new UserInputException('adName'); - } - - if (empty($this->ad)) { - throw new UserInputException('ad'); - } + $nodes = []; + foreach (AdHandler::getInstance()->getLocationObjectTypes() as $objectType) { + if ($objectType->className === '' || !\is_subclass_of($objectType->className, IAdLocation::class)) { + continue; + } - if ($this->objectTypeID === 0) { - throw new UserInputException('objectTypeID'); - } elseif (!isset($this->locationObjectTypes[$this->objectTypeID])) { - throw new UserInputException('objectTypeID', 'noValidSelection'); + $adLocation = $objectType->getProcessor(); + \assert($adLocation instanceof IAdLocation); + + $nodes[] = TemplateFormNode::create('locationVariables' . $objectType->objectTypeID) + ->templateName('__adLocationVariables') + ->variables([ + 'variablesDescription' => $adLocation->getVariablesDescription(), + ]) + ->addDependency( + ValueFormFieldDependency::create('objectTypeID') + ->field($location) + ->values([$objectType->objectTypeID]) + ); } - foreach ($this->groupedConditionObjectTypes as $groupedObjectTypes) { - foreach ($groupedObjectTypes as $objectTypes) { - if (\is_array($objectTypes)) { - foreach ($objectTypes as $objectType) { - $objectType->getProcessor()->validate(); - } - } else { - $objectTypes->getProcessor()->validate(); - } - } - } + return $nodes; } } diff --git a/wcfsetup/install/files/lib/acp/form/AdEditForm.class.php b/wcfsetup/install/files/lib/acp/form/AdEditForm.class.php index 9456515bdbb..9065e06a865 100644 --- a/wcfsetup/install/files/lib/acp/form/AdEditForm.class.php +++ b/wcfsetup/install/files/lib/acp/form/AdEditForm.class.php @@ -4,21 +4,18 @@ use wcf\acp\page\AdListPage; use wcf\data\ad\Ad; -use wcf\data\ad\AdAction; -use wcf\form\AbstractForm; use wcf\http\Helper; -use wcf\system\condition\ConditionHandler; use wcf\system\interaction\admin\AdInteractions; use wcf\system\interaction\StandaloneInteractionContextMenuComponent; use wcf\system\request\LinkHandler; use wcf\system\WCF; /** - * Shows the form to edit an ad notice. + * Shows the form to edit an existing ad. * - * @author Matthias Schmidt - * @copyright 2001-2019 WoltLab GmbH - * @license GNU Lesser General Public License + * @author Matthias Schmidt, Marcel Werk + * @copyright 2001-2026 WoltLab GmbH + * @license GNU Lesser General Public License */ class AdEditForm extends AdAddForm { @@ -28,111 +25,29 @@ class AdEditForm extends AdAddForm public $activeMenuItem = 'wcf.acp.menu.link.ad.list'; /** - * edited ad object - * @var ?Ad + * @inheritDoc */ - public $adObject; - - #[\Override] - public function assignVariables() - { - parent::assignVariables(); - - WCF::getTPL()->assign([ - 'action' => 'edit', - 'adObject' => $this->adObject, - 'interactionContextMenu' => StandaloneInteractionContextMenuComponent::forContentHeaderButton( - new AdInteractions(), - $this->adObject, - LinkHandler::getInstance()->getControllerLink(AdListPage::class) - ), - ]); - } - - #[\Override] - public function readData() - { - parent::readData(); - - if ($_POST === []) { - $this->ad = $this->adObject->ad; - $this->adName = $this->adObject->adName; - $this->isDisabled = $this->adObject->isDisabled; - $this->objectTypeID = $this->adObject->objectTypeID; - $this->showOrder = $this->adObject->showOrder; - - $conditions = $this->adObject->getConditions(); - $conditionsByObjectTypeID = []; - foreach ($conditions as $condition) { - $conditionsByObjectTypeID[$condition->objectTypeID] = $condition; - } - - foreach ($this->groupedConditionObjectTypes as $objectTypes1) { - foreach ($objectTypes1 as $objectTypes2) { - if (\is_array($objectTypes2)) { - foreach ($objectTypes2 as $objectType) { - if (isset($conditionsByObjectTypeID[$objectType->objectTypeID])) { - $conditionsByObjectTypeID[$objectType->objectTypeID] - ->getObjectType() - ->getProcessor() - ->setData($conditionsByObjectTypeID[$objectType->objectTypeID]); - } - } - } elseif (isset($conditionsByObjectTypeID[$objectTypes2->objectTypeID])) { - $conditionsByObjectTypeID[$objectTypes2->objectTypeID] - ->getObjectType() - ->getProcessor() - ->setData($conditionsByObjectTypeID[$objectTypes2->objectTypeID]); - } - } - } - } - } + public string $formAction = 'edit'; #[\Override] public function readParameters() { parent::readParameters(); - $this->adObject = Helper::fetchObjectFromQueryParameter(Ad::class); + $this->formObject = Helper::fetchObjectFromQueryParameter(Ad::class); } #[\Override] - public function save() + public function assignVariables() { - AbstractForm::save(); + parent::assignVariables(); - $this->objectAction = new AdAction([$this->adObject], 'update', [ - 'data' => \array_merge($this->additionalFields, [ - 'ad' => $this->ad, - 'adName' => $this->adName, - 'isDisabled' => $this->isDisabled, - 'objectTypeID' => $this->objectTypeID, - 'showOrder' => $this->showOrder, - ]), + WCF::getTPL()->assign([ + 'interactionContextMenu' => StandaloneInteractionContextMenuComponent::forContentHeaderButton( + new AdInteractions(), + $this->formObject, + LinkHandler::getInstance()->getControllerLink(AdListPage::class) + ), ]); - $this->objectAction->executeAction(); - - // transform conditions array into one-dimensional array - $conditions = []; - foreach ($this->groupedConditionObjectTypes as $groupedObjectTypes) { - foreach ($groupedObjectTypes as $objectTypes) { - if (\is_array($objectTypes)) { - $conditions = \array_merge($conditions, $objectTypes); - } else { - $conditions[] = $objectTypes; - } - } - } - - ConditionHandler::getInstance()->updateConditions( - $this->adObject->adID, - $this->adObject->getConditions(), - $conditions - ); - - $this->saved(); - - WCF::getTPL()->assign('success', true); } } diff --git a/wcfsetup/install/files/lib/acp/form/NoticeAddForm.class.php b/wcfsetup/install/files/lib/acp/form/NoticeAddForm.class.php index 4c0bfb4a3a5..94dff3f3693 100644 --- a/wcfsetup/install/files/lib/acp/form/NoticeAddForm.class.php +++ b/wcfsetup/install/files/lib/acp/form/NoticeAddForm.class.php @@ -2,298 +2,198 @@ namespace wcf\acp\form; +use wcf\command\notice\CreateNotice; +use wcf\command\notice\UpdateNotice; +use wcf\data\DatabaseObjectBuilder; use wcf\data\notice\Notice; -use wcf\data\notice\NoticeAction; -use wcf\data\notice\NoticeEditor; -use wcf\data\object\type\ObjectType; -use wcf\data\object\type\ObjectTypeCache; -use wcf\form\AbstractForm; -use wcf\system\condition\ConditionHandler; -use wcf\system\exception\UserInputException; -use wcf\system\language\I18nHandler; +use wcf\data\notice\NoticeBuilder; +use wcf\form\AbstractDatabaseObjectBuilderForm; +use wcf\system\form\builder\container\FormContainer; +use wcf\system\form\builder\field\BooleanFormField; +use wcf\system\form\builder\field\dependency\ValueFormFieldDependency; +use wcf\system\form\builder\field\IFormField; +use wcf\system\form\builder\field\IntegerFormField; +use wcf\system\form\builder\field\MultilineTextFormField; +use wcf\system\form\builder\field\ObjectFilterFormField; +use wcf\system\form\builder\field\RadioButtonFormField; +use wcf\system\form\builder\field\TextFormField; +use wcf\system\form\builder\field\validation\FormFieldValidationError; +use wcf\system\form\builder\field\validation\FormFieldValidator; +use wcf\system\form\builder\TemplateFormNode; +use wcf\system\object\filter\builder\NoticeObjectFilterBuilder; use wcf\system\Regex; -use wcf\system\request\LinkHandler; -use wcf\system\WCF; -use wcf\util\StringUtil; /** * Shows the form to create a new notice. * - * @author Matthias Schmidt - * @copyright 2001-2019 WoltLab GmbH - * @license GNU Lesser General Public License + * @author Matthias Schmidt, Marcel Werk + * @copyright 2001-2026 WoltLab GmbH + * @license GNU Lesser General Public License + * + * @extends AbstractDatabaseObjectBuilderForm */ -class NoticeAddForm extends AbstractForm +class NoticeAddForm extends AbstractDatabaseObjectBuilderForm { /** * @inheritDoc */ public $activeMenuItem = 'wcf.acp.menu.link.notice.add'; - /** - * name of the chosen CSS class name - * @var string - */ - public $cssClassName = 'info'; - - /** - * custom CSS class name - * @var string - */ - public $customCssClassName = ''; - - /** - * grouped notice condition object types - * @var (ObjectType|ObjectType[])[][] - */ - public $groupedConditionObjectTypes = []; - - /** - * 1 if the notice is disabled - * @var int - */ - public $isDisabled = 0; - - /** - * 1 if the notice is dismissible - * @var int - */ - public $isDismissible = 0; - /** * @inheritDoc */ public $neededPermissions = ['admin.notice.canManageNotice']; /** - * name of the notice - * @var string - */ - public $noticeName = ''; - - /** - * 1 if html is used in the notice text - * @var int - */ - public $noticeUseHtml = 0; - - /** - * order used to the show the notices - * @var int + * @inheritDoc */ - public $showOrder = 0; + public string $objectEditLinkController = NoticeEditForm::class; #[\Override] - public function assignVariables() + protected function getDatabaseObjectBuilder(): NoticeBuilder { - parent::assignVariables(); - - I18nHandler::getInstance()->assignVariables(); - - WCF::getTPL()->assign([ - 'action' => 'add', - 'availableCssClassNames' => Notice::TYPES, - 'cssClassName' => $this->cssClassName, - 'customCssClassName' => $this->customCssClassName, - 'isDisabled' => $this->isDisabled, - 'isDismissible' => $this->isDismissible, - 'groupedConditionObjectTypes' => $this->groupedConditionObjectTypes, - 'noticeName' => $this->noticeName, - 'noticeUseHtml' => $this->noticeUseHtml, - 'showOrder' => $this->showOrder, - ]); - } - - #[\Override] - public function readData() - { - $objectTypes = ObjectTypeCache::getInstance()->getObjectTypes('com.woltlab.wcf.condition.notice'); - foreach ($objectTypes as $objectType) { - if ($objectType->conditionobject === null) { - continue; - } - - if (!isset($this->groupedConditionObjectTypes[$objectType->conditionobject])) { - $this->groupedConditionObjectTypes[$objectType->conditionobject] = []; - } - - if ($objectType->conditiongroup !== null) { - if (!isset($this->groupedConditionObjectTypes[$objectType->conditionobject][$objectType->conditiongroup])) { - $this->groupedConditionObjectTypes[$objectType->conditionobject][$objectType->conditiongroup] = []; - } - - $this->groupedConditionObjectTypes[$objectType->conditionobject][$objectType->conditiongroup][$objectType->objectTypeID] = $objectType; - } else { - $this->groupedConditionObjectTypes[$objectType->conditionobject][$objectType->objectTypeID] = $objectType; - } + if ($this->formObject !== null) { + return NoticeBuilder::forUpdate($this->formObject); } - parent::readData(); + return NoticeBuilder::forCreate(); } #[\Override] - public function readFormParameters() + protected function getCommand(DatabaseObjectBuilder $builder): callable { - parent::readFormParameters(); - - I18nHandler::getInstance()->readValues(); - - if (isset($_POST['cssClassName'])) { - $this->cssClassName = StringUtil::trim($_POST['cssClassName']); - } - if (isset($_POST['customCssClassName'])) { - $this->customCssClassName = StringUtil::trim($_POST['customCssClassName']); - } - if (isset($_POST['isDisabled'])) { - $this->isDisabled = 1; - } - if (isset($_POST['isDismissible'])) { - $this->isDismissible = 1; - } - if (isset($_POST['noticeName'])) { - $this->noticeName = StringUtil::trim($_POST['noticeName']); - } - if (isset($_POST['noticeUseHtml'])) { - $this->noticeUseHtml = 1; - } - if (isset($_POST['showOrder'])) { - $this->showOrder = \intval($_POST['showOrder']); + if ($this->formObject !== null) { + return new UpdateNotice($builder); } - foreach ($this->groupedConditionObjectTypes as $groupedObjectTypes) { - foreach ($groupedObjectTypes as $objectTypes) { - if (\is_array($objectTypes)) { - foreach ($objectTypes as $objectType) { - $objectType->getProcessor()->readFormParameters(); - } - } else { - $objectTypes->getProcessor()->readFormParameters(); - } - } - } + return new CreateNotice($builder); } #[\Override] - public function readParameters() + protected function createForm(): void { - parent::readParameters(); - - I18nHandler::getInstance()->register('notice'); + $this->form->appendChildren([ + FormContainer::create('data') + ->appendChildren([ + TextFormField::create('noticeName') + ->label('wcf.global.name') + ->maximumLength(255) + ->required() + ->saveValueCallback(static function (NoticeBuilder $builder, IFormField $field) { + $builder->setNoticeName($field->getSaveValue()); + }), + MultilineTextFormField::create('notice') + ->label('wcf.acp.notice.notice') + ->description('wcf.acp.notice.notice.description') + ->i18n() + ->languageItemPattern('wcf.notice.notice.notice\d+') + ->required() + ->rows(10) + ->saveValueCallback(static function (NoticeBuilder $builder, IFormField $field) { + $builder->setNotice($field->getSaveValue()); + }), + BooleanFormField::create('noticeUseHtml') + ->label('wcf.acp.notice.noticeUseHtml') + ->saveValueCallback(static function (NoticeBuilder $builder, IFormField $field) { + $builder->setNoticeUseHtml((bool)$field->getSaveValue()); + }), + IntegerFormField::create('showOrder') + ->label('wcf.global.showOrder') + ->description('wcf.acp.notice.showOrder.description') + ->minimum(0) + ->saveValueCallback(static function (NoticeBuilder $builder, IFormField $field) { + $builder->setShowOrder($field->getSaveValue()); + }), + ]), + FormContainer::create('settings') + ->label('wcf.global.settings') + ->appendChildren([ + RadioButtonFormField::create('cssClassName') + ->label('wcf.acp.notice.cssClassName') + ->description('wcf.acp.notice.cssClassName.description') + ->options($this->getAvailableCssClassNames()) + ->value('info') + ->required() + ->saveValueCallback(static function (NoticeBuilder $builder, IFormField $field) { + $cssClassName = $field->getSaveValue(); + if ($cssClassName === 'custom') { + $cssClassName = $field->getDocument() + ->getFormField('customCssClassName') + ->getSaveValue(); + } + + $builder->setCssClassName($cssClassName); + }) + ->loadValueCallback(static function (Notice $object, IFormField $field) { + $field->value($object->isCustom() ? 'custom' : $object->cssClassName); + }), + TextFormField::create('customCssClassName') + ->label('wcf.acp.notice.customCssClassName') + ->maximumLength(255) + ->required() + ->addValidator( + new FormFieldValidator('cssClassName', static function (TextFormField $field) { + if (Regex::compile('^-?[_a-zA-Z]+[_a-zA-Z0-9-]+$')->match($field->getValue()) === 0) { + $field->addValidationError( + new FormFieldValidationError( + 'invalid', + 'wcf.acp.notice.cssClassName.error.invalid' + ) + ); + } + }) + ) + ->loadValueCallback(static function (Notice $object, IFormField $field) { + $field->value($object->isCustom() ? $object->cssClassName : ''); + }), + TemplateFormNode::create('cssClassNameExample') + ->templateName('__noticeCssClassNameExample'), + BooleanFormField::create('isDisabled') + ->label('wcf.acp.notice.isDisabled') + ->saveValueCallback(static function (NoticeBuilder $builder, IFormField $field) { + $builder->setIsDisabled((bool)$field->getSaveValue()); + }), + BooleanFormField::create('isDismissible') + ->label('wcf.acp.notice.isDismissible') + ->description('wcf.acp.notice.isDismissible.description') + ->saveValueCallback(static function (NoticeBuilder $builder, IFormField $field) { + $builder->setIsDismissible((bool)$field->getSaveValue()); + }), + ]), + FormContainer::create('conditionsContainer') + ->label('wcf.acp.notice.conditions') + ->description('wcf.acp.notice.conditions.description') + ->appendChild( + ObjectFilterFormField::create('conditions') + ->builder(new NoticeObjectFilterBuilder()) + ->saveValueCallback(static function (NoticeBuilder $builder, IFormField $field) { + $builder->setConditions($field->getSaveValue()); + }) + ), + ]); } #[\Override] - public function save() + protected function finalizeForm(): void { - parent::save(); - - $this->objectAction = new NoticeAction([], 'create', [ - 'data' => \array_merge($this->additionalFields, [ - 'cssClassName' => $this->cssClassName === 'custom' ? $this->customCssClassName : $this->cssClassName, - 'isDisabled' => $this->isDisabled, - 'isDismissible' => $this->isDismissible, - 'notice' => I18nHandler::getInstance()->isPlainValue('notice') ? I18nHandler::getInstance()->getValue('notice') : '', - 'noticeName' => $this->noticeName, - 'noticeUseHtml' => $this->noticeUseHtml, - 'showOrder' => $this->showOrder, - ]), - ]); - $returnValues = $this->objectAction->executeAction(); - - if (!I18nHandler::getInstance()->isPlainValue('notice')) { - I18nHandler::getInstance()->save( - 'notice', - 'wcf.notice.notice.notice' . $returnValues['returnValues']->noticeID, - 'wcf.notice', - 1 - ); - - // update notice name - $noticeEditor = new NoticeEditor($returnValues['returnValues']); - $noticeEditor->update([ - 'notice' => 'wcf.notice.notice.notice' . $returnValues['returnValues']->noticeID, - ]); - } - - // transform conditions array into one-dimensional array - $conditions = []; - foreach ($this->groupedConditionObjectTypes as $groupedObjectTypes) { - foreach ($groupedObjectTypes as $objectTypes) { - if (\is_array($objectTypes)) { - $conditions = \array_merge($conditions, $objectTypes); - } else { - $conditions[] = $objectTypes; - } - } - } - - ConditionHandler::getInstance()->createConditions($returnValues['returnValues']->noticeID, $conditions); - - $this->saved(); - - // reset values - $this->cssClassName = ''; - $this->customCssClassName = ''; - $this->isDisabled = 0; - $this->isDismissible = 0; - $this->noticeName = ''; - $this->noticeUseHtml = 0; - $this->showOrder = 0; - I18nHandler::getInstance()->reset(); - - foreach ($conditions as $condition) { - $condition->getProcessor()->reset(); - } - - WCF::getTPL()->assign([ - 'success' => true, - 'objectEditLink' => LinkHandler::getInstance()->getControllerLink( - NoticeEditForm::class, - ['id' => $returnValues['returnValues']->noticeID] - ), - ]); + $this->form->getNodeById('customCssClassName')->addDependency( + ValueFormFieldDependency::create('cssClassName') + ->field($this->form->getFormField('cssClassName')) + ->values(['custom']) + ); } - #[\Override] - public function validate() + /** + * @return array + */ + private function getAvailableCssClassNames(): array { - parent::validate(); - - if (empty($this->noticeName)) { - throw new UserInputException('noticeName'); - } - - if (!I18nHandler::getInstance()->validateValue('notice')) { - if (I18nHandler::getInstance()->isPlainValue('notice')) { - throw new UserInputException('notice'); - } else { - throw new UserInputException('notice', 'multilingual'); - } - } - - // validate class name - if (empty($this->cssClassName)) { - throw new UserInputException('cssClassName'); - } elseif ($this->cssClassName === 'custom') { - if (empty($this->cssClassName)) { - throw new UserInputException('cssClassName'); - } - if (Regex::compile('^-?[_a-zA-Z]+[_a-zA-Z0-9-]+$')->match($this->customCssClassName) === 0) { - throw new UserInputException('cssClassName', 'invalid'); - } - } elseif (!\in_array($this->cssClassName, Notice::TYPES, true)) { - throw new UserInputException('cssClassName', 'invalid'); + $options = []; + foreach (Notice::TYPES as $type) { + $options[$type] = 'wcf.acp.notice.cssClassName.' . $type; } + $options['custom'] = 'wcf.acp.notice.cssClassName.custom'; - foreach ($this->groupedConditionObjectTypes as $groupedObjectTypes) { - foreach ($groupedObjectTypes as $objectTypes) { - if (\is_array($objectTypes)) { - foreach ($objectTypes as $objectType) { - $objectType->getProcessor()->validate(); - } - } else { - $objectTypes->getProcessor()->validate(); - } - } - } + return $options; } } diff --git a/wcfsetup/install/files/lib/acp/form/NoticeEditForm.class.php b/wcfsetup/install/files/lib/acp/form/NoticeEditForm.class.php index 6f318c07749..9cf0c426783 100644 --- a/wcfsetup/install/files/lib/acp/form/NoticeEditForm.class.php +++ b/wcfsetup/install/files/lib/acp/form/NoticeEditForm.class.php @@ -4,13 +4,11 @@ use wcf\acp\page\NoticeListPage; use wcf\data\notice\Notice; -use wcf\data\notice\NoticeAction; -use wcf\form\AbstractForm; use wcf\http\Helper; -use wcf\system\condition\ConditionHandler; +use wcf\system\form\builder\container\IFormContainer; +use wcf\system\form\builder\field\BooleanFormField; use wcf\system\interaction\admin\NoticeInteractions; use wcf\system\interaction\StandaloneInteractionContextMenuComponent; -use wcf\system\language\I18nHandler; use wcf\system\request\LinkHandler; use wcf\system\user\storage\UserStorageHandler; use wcf\system\WCF; @@ -18,9 +16,9 @@ /** * Shows the form to edit an existing notice. * - * @author Matthias Schmidt - * @copyright 2001-2019 WoltLab GmbH - * @license GNU Lesser General Public License + * @author Matthias Schmidt, Marcel Werk + * @copyright 2001-2026 WoltLab GmbH + * @license GNU Lesser General Public License */ class NoticeEditForm extends NoticeAddForm { @@ -30,163 +28,69 @@ class NoticeEditForm extends NoticeAddForm public $activeMenuItem = 'wcf.acp.menu.link.notice.list'; /** - * edited notice object - * @var ?Notice - */ - public $notice; - - /** - * 1 if the notice will be displayed for all users again - * @var int + * @inheritDoc */ - public $resetIsDismissed = 0; + public string $formAction = 'edit'; #[\Override] - public function assignVariables() + public function readParameters() { - parent::assignVariables(); - - I18nHandler::getInstance()->assignVariables($_POST !== []); + parent::readParameters(); - WCF::getTPL()->assign([ - 'action' => 'edit', - 'notice' => $this->notice, - 'resetIsDismissed' => $this->resetIsDismissed, - 'interactionContextMenu' => StandaloneInteractionContextMenuComponent::forContentHeaderButton( - new NoticeInteractions(), - $this->notice, - LinkHandler::getInstance()->getControllerLink(NoticeListPage::class) - ), - ]); + $this->formObject = Helper::fetchObjectFromQueryParameter(Notice::class); } #[\Override] - public function readData() + protected function createForm(): void { - parent::readData(); - - if ($_POST === []) { - I18nHandler::getInstance()->setOptions('notice', 1, $this->notice->notice, 'wcf.notice.notice.notice\d+'); - - $this->cssClassName = $this->notice->cssClassName; - if (!\in_array($this->cssClassName, Notice::TYPES, true)) { - $this->customCssClassName = $this->cssClassName; - $this->cssClassName = 'custom'; - } - - $this->isDisabled = $this->notice->isDisabled; - $this->isDismissible = $this->notice->isDismissible; - $this->noticeName = $this->notice->noticeName; - $this->noticeUseHtml = $this->notice->noticeUseHtml; - $this->showOrder = $this->notice->showOrder; - - $conditions = $this->notice->getConditions(); - $conditionsByObjectTypeID = []; - foreach ($conditions as $condition) { - $conditionsByObjectTypeID[$condition->objectTypeID] = $condition; - } - - foreach ($this->groupedConditionObjectTypes as $objectTypes1) { - foreach ($objectTypes1 as $objectTypes2) { - if (\is_array($objectTypes2)) { - foreach ($objectTypes2 as $objectType) { - if (isset($conditionsByObjectTypeID[$objectType->objectTypeID])) { - $conditionsByObjectTypeID[$objectType->objectTypeID]->getObjectType()->getProcessor()->setData($conditionsByObjectTypeID[$objectType->objectTypeID]); - } - } - } elseif (isset($conditionsByObjectTypeID[$objectTypes2->objectTypeID])) { - $conditionsByObjectTypeID[$objectTypes2->objectTypeID]->getObjectType()->getProcessor()->setData($conditionsByObjectTypeID[$objectTypes2->objectTypeID]); - } - } - } - } - } + parent::createForm(); - #[\Override] - public function readFormParameters() - { - parent::readFormParameters(); + $settings = $this->form->getNodeById('settings'); + \assert($settings instanceof IFormContainer); - if (isset($_POST['resetIsDismissed'])) { - $this->resetIsDismissed = 1; - } + $settings->appendChild( + BooleanFormField::create('resetIsDismissed') + ->label('wcf.acp.notice.resetIsDismissed') + ->description('wcf.acp.notice.resetIsDismissed.description') + ->available($this->formObject?->isDismissible !== 0) + ); } #[\Override] - public function readParameters() + protected function afterSave(): void { - parent::readParameters(); + parent::afterSave(); - $this->notice = Helper::fetchObjectFromQueryParameter(Notice::class); - } - - #[\Override] - public function save() - { - AbstractForm::save(); - - $this->objectAction = new NoticeAction([$this->notice], 'update', [ - 'data' => \array_merge($this->additionalFields, [ - 'cssClassName' => $this->cssClassName === 'custom' ? $this->customCssClassName : $this->cssClassName, - 'isDisabled' => $this->isDisabled, - 'isDismissible' => $this->isDismissible, - 'notice' => I18nHandler::getInstance()->isPlainValue('notice') ? I18nHandler::getInstance()->getValue('notice') : 'wcf.notice.notice.notice' . $this->notice->noticeID, - 'noticeName' => $this->noticeName, - 'noticeUseHtml' => $this->noticeUseHtml, - 'showOrder' => $this->showOrder, - ]), - ]); - $this->objectAction->executeAction(); - - if (I18nHandler::getInstance()->isPlainValue('notice')) { - if ($this->notice->notice === 'wcf.notice.notice.notice' . $this->notice->noticeID) { - I18nHandler::getInstance()->remove($this->notice->notice); - } - } else { - I18nHandler::getInstance()->save( - 'notice', - 'wcf.notice.notice.notice' . $this->notice->noticeID, - 'wcf.notice', - 1 - ); + $resetIsDismissed = $this->form->getFormField('resetIsDismissed'); + if ($resetIsDismissed === null || !$resetIsDismissed->isAvailable()) { + return; } - // transform conditions array into one-dimensional array - $conditions = []; - foreach ($this->groupedConditionObjectTypes as $groupedObjectTypes) { - foreach ($groupedObjectTypes as $objectTypes) { - if (\is_array($objectTypes)) { - $conditions = \array_merge($conditions, $objectTypes); - } else { - $conditions[] = $objectTypes; - } - } + if ($resetIsDismissed->getSaveValue() === 0) { + return; } - ConditionHandler::getInstance()->updateConditions( - $this->notice->noticeID, - $this->notice->getConditions(), - $conditions - ); - - if ($this->resetIsDismissed !== 0) { - $sql = "DELETE FROM wcf1_notice_dismissed - WHERE noticeID = ?"; - $statement = WCF::getDB()->prepare($sql); - $statement->execute([ - $this->notice->noticeID, - ]); - - $this->resetIsDismissed = 0; - - UserStorageHandler::getInstance()->resetAll('dismissedNotices'); - } + $sql = "DELETE FROM wcf1_notice_dismissed + WHERE noticeID = ?"; + $statement = WCF::getDB()->prepare($sql); + $statement->execute([ + $this->object->noticeID, + ]); - $this->saved(); + UserStorageHandler::getInstance()->resetAll('dismissedNotices'); + } - // reload notice object for proper 'isDismissible' value - $this->notice = new Notice($this->notice->noticeID); + #[\Override] + public function assignVariables() + { + parent::assignVariables(); - WCF::getTPL()->assign('success', true); + WCF::getTPL()->assign([ + 'interactionContextMenu' => StandaloneInteractionContextMenuComponent::forContentHeaderButton( + new NoticeInteractions(), + $this->formObject, + LinkHandler::getInstance()->getControllerLink(NoticeListPage::class) + ), + ]); } } diff --git a/wcfsetup/install/files/lib/acp/form/UserGroupAssignmentAddForm.class.php b/wcfsetup/install/files/lib/acp/form/UserGroupAssignmentAddForm.class.php index 1aedeb4baf7..ecea1b1e34c 100644 --- a/wcfsetup/install/files/lib/acp/form/UserGroupAssignmentAddForm.class.php +++ b/wcfsetup/install/files/lib/acp/form/UserGroupAssignmentAddForm.class.php @@ -2,197 +2,120 @@ namespace wcf\acp\form; -use wcf\data\object\type\ObjectType; -use wcf\data\user\group\assignment\UserGroupAssignmentAction; +use wcf\command\user\group\assignment\CreateUserGroupAssignment; +use wcf\command\user\group\assignment\UpdateUserGroupAssignment; +use wcf\data\DatabaseObjectBuilder; +use wcf\data\user\group\assignment\UserGroupAssignment; +use wcf\data\user\group\assignment\UserGroupAssignmentBuilder; 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\AbstractDatabaseObjectBuilderForm; +use wcf\system\form\builder\container\FormContainer; +use wcf\system\form\builder\field\BooleanFormField; +use wcf\system\form\builder\field\IFormField; +use wcf\system\form\builder\field\ObjectFilterFormField; +use wcf\system\form\builder\field\SelectFormField; +use wcf\system\form\builder\field\TitleFormField; +use wcf\system\object\filter\builder\UserGroupAssignmentObjectFilterBuilder; /** * Shows the form to create a new automatic user group assignment. * - * @author Matthias Schmidt - * @copyright 2001-2019 WoltLab GmbH - * @license GNU Lesser General Public License + * @author Alexander Ebert, Matthias Schmidt + * @copyright 2001-2026 WoltLab GmbH + * @license GNU Lesser General Public License + * + * @extends AbstractDatabaseObjectBuilderForm */ -class UserGroupAssignmentAddForm extends AbstractForm +class UserGroupAssignmentAddForm extends AbstractDatabaseObjectBuilderForm { /** * @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 = []; - - #[\Override] - public function assignVariables() - { - parent::assignVariables(); - - WCF::getTPL()->assign([ - 'action' => 'add', - 'groupedObjectTypes' => $this->conditions, - 'groupID' => $this->groupID, - 'isDisabled' => $this->isDisabled, - 'title' => $this->title, - 'userGroups' => $this->userGroups, - ]); - } + public string $objectEditLinkController = UserGroupAssignmentEditForm::class; #[\Override] - public function readData() + protected function getDatabaseObjectBuilder(): UserGroupAssignmentBuilder { - $this->userGroups = UserGroup::getSortedGroupsByType([], [ - UserGroup::EVERYONE, - UserGroup::GUESTS, - UserGroup::OWNER, - UserGroup::USERS, - ]); - foreach ($this->userGroups as $key => $userGroup) { - if (!$userGroup->isAccessible()) { - unset($this->userGroups[$key]); - } + if ($this->formObject !== null) { + return UserGroupAssignmentBuilder::forUpdate($this->formObject); } - $this->conditions = UserGroupAssignmentHandler::getInstance()->getGroupedObjectTypes(); - - parent::readData(); + return UserGroupAssignmentBuilder::forCreate(); } #[\Override] - public function readFormParameters() + protected function getCommand(DatabaseObjectBuilder $builder): callable { - 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']); + if ($this->formObject !== null) { + return new UpdateUserGroupAssignment($builder); } - foreach ($this->conditions as $conditions) { - /** @var ObjectType $condition */ - foreach ($conditions as $condition) { - $condition->getProcessor()->readFormParameters(); - } - } + return new CreateUserGroupAssignment($builder); } #[\Override] - public function save() + protected function createForm(): void { - 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] - ), + $this->form->appendChildren([ + FormContainer::create('data') + ->appendChildren([ + TitleFormField::create('title') + ->label('wcf.global.name') + ->maximumLength(255) + ->required() + ->saveValueCallback(static function (UserGroupAssignmentBuilder $builder, IFormField $field) { + $builder->setTitle($field->getSaveValue()); + }), + SelectFormField::create('groupID') + ->label('wcf.user.group') + ->options($this->getAvailableUserGroups()) + ->required() + ->saveValueCallback(static function (UserGroupAssignmentBuilder $builder, IFormField $field) { + $builder->setGroupID((int)$field->getSaveValue()); + }), + BooleanFormField::create('isDisabled') + ->label('wcf.acp.group.assignment.isDisabled') + ->saveValueCallback(static function (UserGroupAssignmentBuilder $builder, IFormField $field) { + $builder->setIsDisabled((bool)$field->getSaveValue()); + }), + ]), + FormContainer::create('conditionsContainer') + ->label('wcf.acp.group.assignment.conditions') + ->description('wcf.acp.group.assignment.conditions.description') + ->appendChild( + ObjectFilterFormField::create('conditions') + ->builder(new UserGroupAssignmentObjectFilterBuilder()) + ->required() + ->saveValueCallback(static function (UserGroupAssignmentBuilder $builder, IFormField $field) { + $builder->setConditions($field->getSaveValue()); + }), + ), ]); } - #[\Override] - public function validate() + /** + * @return array + */ + private function getAvailableUserGroups(): array { - 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( + UserGroup::getSortedGroupsByType([], [ + UserGroup::EVERYONE, + UserGroup::GUESTS, + UserGroup::OWNER, + UserGroup::USERS, + ]), + static fn(UserGroup $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 c6ae97eee42..3a28be492cd 100644 --- a/wcfsetup/install/files/lib/acp/form/UserGroupAssignmentEditForm.class.php +++ b/wcfsetup/install/files/lib/acp/form/UserGroupAssignmentEditForm.class.php @@ -4,10 +4,7 @@ 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\http\Helper; -use wcf\system\condition\ConditionHandler; use wcf\system\interaction\admin\UserGroupAssignmentInteractions; use wcf\system\interaction\StandaloneInteractionContextMenuComponent; use wcf\system\request\LinkHandler; @@ -28,79 +25,29 @@ class UserGroupAssignmentEditForm extends UserGroupAssignmentAddForm public $activeMenuItem = 'wcf.acp.menu.link.group.assignment'; /** - * edited automatic user group assignment - * @var ?UserGroupAssignment + * @inheritDoc */ - public $assignment; - - #[\Override] - 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) - ), - ]); - } - - #[\Override] - public function readData() - { - parent::readData(); - - if ($_POST === []) { - $this->groupID = $this->assignment->groupID; - $this->title = $this->assignment->title; - - $conditions = $this->assignment->getConditions(); - foreach ($conditions as $condition) { - $this->conditions[$condition->getObjectType()->conditiongroup][$condition->objectTypeID]->getProcessor()->setData($condition); - } - } - } + public string $formAction = 'edit'; #[\Override] public function readParameters() { parent::readParameters(); - $this->assignment = Helper::fetchObjectFromQueryParameter(UserGroupAssignment::class); + $this->formObject = Helper::fetchObjectFromQueryParameter(UserGroupAssignment::class); } #[\Override] - 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/action/ObjectFilterBuilderAction.class.php b/wcfsetup/install/files/lib/action/ObjectFilterBuilderAction.class.php new file mode 100644 index 00000000000..09b1b0dd61b --- /dev/null +++ b/wcfsetup/install/files/lib/action/ObjectFilterBuilderAction.class.php @@ -0,0 +1,198 @@ + + * @since 6.3 + */ +final class ObjectFilterBuilderAction implements RequestHandlerInterface +{ + #[\Override] + public function handle(ServerRequestInterface $request): ResponseInterface + { + $parameters = Helper::mapQueryParameters( + $request->getQueryParams(), + <<<'EOT' + array { + identifier: string, + filter?: string, + value?: string, + used?: list, + } + EOT + ); + + $event = new ObjectFilterBuilderCollecting(); + EventHandler::getInstance()->fire($event); + + $builder = $event->getBuilders()[$parameters['identifier']] ?? null; + if ($builder === null) { + throw new IllegalLinkException(); + } + + if (!$builder->isAccessible()) { + throw new PermissionDeniedException(); + } + + $form = $this->getForm( + $builder, + $parameters['filter'] ?? null, + $parameters['value'] ?? null, + $parameters['used'] ?? [], + ); + + if ($request->getMethod() === 'GET') { + return $form->toResponse(); + } elseif ($request->getMethod() === 'POST') { + $response = $form->validateRequest($request); + if ($response !== null) { + return $response; + } + + $rawData = $form->getData(); + $data = $rawData['data']; + + $result = []; + foreach ($builder->getFilters() as $filter) { + if ($data['filter'] !== $filter->getIdentifier()) { + continue; + } + + $id = $filter->getFormField()->getId(); + $serializedValue = $filter->serializeValue($data[$id] ?? $rawData[$id]); + + $result = [ + 'identifier' => $filter->getIdentifier(), + // Summarize the restored value to match the summaries of the stored filters. + 'summary' => $filter->summarizeValue($filter->unserializeValue($serializedValue)), + 'value' => $serializedValue, + ]; + } + + return new JsonResponse([ + 'result' => $result, + ]); + } else { + throw new \LogicException('Unreachable'); + } + } + + /** + * Builds the dialog that offers a selection of the available filters. + * The form field of each filter is only shown while it is selected. + * + * If a filter identifier and its serialized value are given, the dialog is + * prefilled with them. Filters that are not repeatable are only offered if + * they are not part of the given identifiers of the other configured filters. + * + * @param IObjectFilterBuilder<*> $builder + * @param list $usedIdentifiers + */ + private function getForm( + IObjectFilterBuilder $builder, + ?string $filterIdentifier, + ?string $serializedValue, + array $usedIdentifiers, + ): Psr15DialogForm { + $form = new Psr15DialogForm( + static::class, + WCF::getLanguage()->get( + $filterIdentifier === null ? 'wcf.objectFilter.addFilter' : 'wcf.objectFilter.editFilter' + ), + ); + + $container = FormContainer::create('container'); + $container->addClass('objectFilterBuilder__list'); + $form->appendChild($container); + + $select = SelectFormField::create('filter') + ->label('wcf.objectFilter.filter') + ->required(); + $container->appendChild($select); + + $selectValues = []; + foreach ($builder->getFilters() as $filter) { + if (!$filter->isAvailable()) { + continue; + } + if (!$filter->isRepeatable() && \in_array($filter->getIdentifier(), $usedIdentifiers, true)) { + continue; + } + + $formField = $filter->getFormField(); + $formField->addDependency( + ValueFormFieldDependency::create($formField->getId() . 'Dependency') + ->field($select) + ->values([$filter->getIdentifier()]) + ); + + $formField->addClass('objectFilterBuilder__list__item'); + $container->appendChild($formField); + + $selectValues[$filter->getIdentifier()] = $filter->getTitle(); + + if ($filter->getIdentifier() === $filterIdentifier && $serializedValue !== null) { + if ($formField instanceof MultipleSelectionFormField) { + // Keep the remaining selection if some of the selected objects + // have been deleted in the meantime. + $formField->ignoreInvalidValues(); + } + + try { + $formField->value($filter->toFormFieldValue($filter->unserializeValue($serializedValue))); + } catch (\InvalidArgumentException) { + // The value is no longer valid, e.g. a selected object has + // been deleted in the meantime, requiring a new value. + } + } + } + + $collator = new \Collator(WCF::getLanguage()->getLocale()); + \uasort( + $selectValues, + static fn(string $a, string $b) => $collator->compare($a, $b) + ); + + $select->options($selectValues); + if ($filterIdentifier !== null && isset($selectValues[$filterIdentifier])) { + $select->value($filterIdentifier); + } + + $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 93441da6eea..bcc1a6eb8c2 100644 --- a/wcfsetup/install/files/lib/bootstrap/com.woltlab.wcf.php +++ b/wcfsetup/install/files/lib/bootstrap/com.woltlab.wcf.php @@ -16,6 +16,7 @@ public function __invoke(): void $this->initSitemapObjects(); $this->initACPMenuItems(); $this->initACPSearchProviders(); + $this->initObjectFilters(); $this->initLicenseData(); } @@ -175,6 +176,18 @@ private function initACPSearchProviders(): void ); } + private function initObjectFilters(): void + { + EventHandler::getInstance()->register( + \wcf\event\object\filter\ObjectFilterBuilderCollecting::class, + static function (\wcf\event\object\filter\ObjectFilterBuilderCollecting $event) { + $event->register(new \wcf\system\object\filter\builder\AdObjectFilterBuilder()); + $event->register(new \wcf\system\object\filter\builder\NoticeObjectFilterBuilder()); + $event->register(new \wcf\system\object\filter\builder\UserGroupAssignmentObjectFilterBuilder()); + } + ); + } + private function initLicenseData(): void { try { diff --git a/wcfsetup/install/files/lib/command/ad/CreateAd.class.php b/wcfsetup/install/files/lib/command/ad/CreateAd.class.php new file mode 100644 index 00000000000..64650e49b4f --- /dev/null +++ b/wcfsetup/install/files/lib/command/ad/CreateAd.class.php @@ -0,0 +1,34 @@ + + * @since 6.3 + */ +final class CreateAd +{ + public function __construct( + private readonly AdBuilder $builder, + ) {} + + public function __invoke(): Ad + { + $ad = $this->builder->create(); + + AdBuilder::resetCache(); + + EventHandler::getInstance()->fire(new AdCreated($ad, $this->builder)); + + return $ad; + } +} diff --git a/wcfsetup/install/files/lib/command/ad/DeleteAd.class.php b/wcfsetup/install/files/lib/command/ad/DeleteAd.class.php new file mode 100644 index 00000000000..07481e18fbd --- /dev/null +++ b/wcfsetup/install/files/lib/command/ad/DeleteAd.class.php @@ -0,0 +1,32 @@ + + * @since 6.3 + */ +final class DeleteAd +{ + public function __construct( + private readonly Ad $ad, + ) {} + + public function __invoke(): void + { + AdBuilder::delete($this->ad); + + AdBuilder::resetCache(); + + EventHandler::getInstance()->fire(new AdDeleted($this->ad)); + } +} diff --git a/wcfsetup/install/files/lib/command/ad/DisableAd.class.php b/wcfsetup/install/files/lib/command/ad/DisableAd.class.php index e46894d8e82..2f067dac987 100644 --- a/wcfsetup/install/files/lib/command/ad/DisableAd.class.php +++ b/wcfsetup/install/files/lib/command/ad/DisableAd.class.php @@ -3,7 +3,7 @@ namespace wcf\command\ad; use wcf\data\ad\Ad; -use wcf\data\ad\AdEditor; +use wcf\data\ad\AdBuilder; use wcf\event\ad\AdDisabled; use wcf\system\event\EventHandler; @@ -23,11 +23,11 @@ public function __construct( public function __invoke(): void { - (new AdEditor($this->ad))->update([ - 'isDisabled' => 1, - ]); + AdBuilder::forUpdate($this->ad) + ->setIsDisabled(true) + ->update(); - AdEditor::resetCache(); + AdBuilder::resetCache(); $event = new AdDisabled($this->ad); EventHandler::getInstance()->fire($event); diff --git a/wcfsetup/install/files/lib/command/ad/EnableAd.class.php b/wcfsetup/install/files/lib/command/ad/EnableAd.class.php index 295545bca7a..f1859a4a44c 100644 --- a/wcfsetup/install/files/lib/command/ad/EnableAd.class.php +++ b/wcfsetup/install/files/lib/command/ad/EnableAd.class.php @@ -3,7 +3,7 @@ namespace wcf\command\ad; use wcf\data\ad\Ad; -use wcf\data\ad\AdEditor; +use wcf\data\ad\AdBuilder; use wcf\event\ad\AdEnabled; use wcf\system\event\EventHandler; @@ -23,11 +23,11 @@ public function __construct( public function __invoke(): void { - (new AdEditor($this->ad))->update([ - 'isDisabled' => 0, - ]); + AdBuilder::forUpdate($this->ad) + ->setIsDisabled(false) + ->update(); - AdEditor::resetCache(); + AdBuilder::resetCache(); $event = new AdEnabled($this->ad); EventHandler::getInstance()->fire($event); diff --git a/wcfsetup/install/files/lib/command/ad/UpdateAd.class.php b/wcfsetup/install/files/lib/command/ad/UpdateAd.class.php new file mode 100644 index 00000000000..3ba2320b589 --- /dev/null +++ b/wcfsetup/install/files/lib/command/ad/UpdateAd.class.php @@ -0,0 +1,34 @@ + + * @since 6.3 + */ +final class UpdateAd +{ + public function __construct( + private readonly AdBuilder $builder, + ) {} + + public function __invoke(): Ad + { + $ad = $this->builder->update(); + + AdBuilder::resetCache(); + + EventHandler::getInstance()->fire(new AdUpdated($ad, $this->builder)); + + return $ad; + } +} diff --git a/wcfsetup/install/files/lib/command/notice/CreateNotice.class.php b/wcfsetup/install/files/lib/command/notice/CreateNotice.class.php new file mode 100644 index 00000000000..df04998b078 --- /dev/null +++ b/wcfsetup/install/files/lib/command/notice/CreateNotice.class.php @@ -0,0 +1,34 @@ + + * @since 6.3 + */ +final class CreateNotice +{ + public function __construct( + private readonly NoticeBuilder $builder, + ) {} + + public function __invoke(): Notice + { + $notice = $this->builder->create(); + + NoticeBuilder::resetCache(); + + EventHandler::getInstance()->fire(new NoticeCreated($notice, $this->builder)); + + return $notice; + } +} diff --git a/wcfsetup/install/files/lib/command/notice/DeleteNotice.class.php b/wcfsetup/install/files/lib/command/notice/DeleteNotice.class.php new file mode 100644 index 00000000000..f25c7a9c201 --- /dev/null +++ b/wcfsetup/install/files/lib/command/notice/DeleteNotice.class.php @@ -0,0 +1,32 @@ + + * @since 6.3 + */ +final class DeleteNotice +{ + public function __construct( + private readonly Notice $notice, + ) {} + + public function __invoke(): void + { + NoticeBuilder::delete($this->notice); + + NoticeBuilder::resetCache(); + + EventHandler::getInstance()->fire(new NoticeDeleted($this->notice)); + } +} diff --git a/wcfsetup/install/files/lib/command/notice/DisableNotice.class.php b/wcfsetup/install/files/lib/command/notice/DisableNotice.class.php index 4444e62d220..e3129f13023 100644 --- a/wcfsetup/install/files/lib/command/notice/DisableNotice.class.php +++ b/wcfsetup/install/files/lib/command/notice/DisableNotice.class.php @@ -3,7 +3,7 @@ namespace wcf\command\notice; use wcf\data\notice\Notice; -use wcf\data\notice\NoticeEditor; +use wcf\data\notice\NoticeBuilder; use wcf\event\notice\NoticeDisabled; use wcf\system\event\EventHandler; @@ -21,11 +21,11 @@ public function __construct(private readonly Notice $notice) {} public function __invoke(): void { - (new NoticeEditor($this->notice))->update([ - 'isDisabled' => 1, - ]); + NoticeBuilder::forUpdate($this->notice) + ->setIsDisabled(true) + ->update(); - NoticeEditor::resetCache(); + NoticeBuilder::resetCache(); $event = new NoticeDisabled($this->notice); EventHandler::getInstance()->fire($event); diff --git a/wcfsetup/install/files/lib/command/notice/EnableNotice.class.php b/wcfsetup/install/files/lib/command/notice/EnableNotice.class.php index fffcd0790e5..97565895776 100644 --- a/wcfsetup/install/files/lib/command/notice/EnableNotice.class.php +++ b/wcfsetup/install/files/lib/command/notice/EnableNotice.class.php @@ -3,7 +3,7 @@ namespace wcf\command\notice; use wcf\data\notice\Notice; -use wcf\data\notice\NoticeEditor; +use wcf\data\notice\NoticeBuilder; use wcf\event\notice\NoticeEnabled; use wcf\system\event\EventHandler; @@ -21,11 +21,11 @@ public function __construct(private readonly Notice $notice) {} public function __invoke(): void { - (new NoticeEditor($this->notice))->update([ - 'isDisabled' => 0, - ]); + NoticeBuilder::forUpdate($this->notice) + ->setIsDisabled(false) + ->update(); - NoticeEditor::resetCache(); + NoticeBuilder::resetCache(); $event = new NoticeEnabled($this->notice); EventHandler::getInstance()->fire($event); diff --git a/wcfsetup/install/files/lib/command/notice/UpdateNotice.class.php b/wcfsetup/install/files/lib/command/notice/UpdateNotice.class.php new file mode 100644 index 00000000000..29b120a2c80 --- /dev/null +++ b/wcfsetup/install/files/lib/command/notice/UpdateNotice.class.php @@ -0,0 +1,34 @@ + + * @since 6.3 + */ +final class UpdateNotice +{ + public function __construct( + private readonly NoticeBuilder $builder, + ) {} + + public function __invoke(): Notice + { + $notice = $this->builder->update(); + + NoticeBuilder::resetCache(); + + EventHandler::getInstance()->fire(new NoticeUpdated($notice, $this->builder)); + + return $notice; + } +} diff --git a/wcfsetup/install/files/lib/command/user/group/assignment/CreateUserGroupAssignment.class.php b/wcfsetup/install/files/lib/command/user/group/assignment/CreateUserGroupAssignment.class.php new file mode 100644 index 00000000000..12ef74976ca --- /dev/null +++ b/wcfsetup/install/files/lib/command/user/group/assignment/CreateUserGroupAssignment.class.php @@ -0,0 +1,34 @@ + + * @since 6.3 + */ +final class CreateUserGroupAssignment +{ + public function __construct( + private readonly UserGroupAssignmentBuilder $builder, + ) {} + + public function __invoke(): UserGroupAssignment + { + $assignment = $this->builder->create(); + + UserGroupAssignmentBuilder::resetCache(); + + EventHandler::getInstance()->fire(new UserGroupAssignmentCreated($assignment, $this->builder)); + + return $assignment; + } +} diff --git a/wcfsetup/install/files/lib/command/user/group/assignment/DeleteUserGroupAssignment.class.php b/wcfsetup/install/files/lib/command/user/group/assignment/DeleteUserGroupAssignment.class.php new file mode 100644 index 00000000000..aa887a380f9 --- /dev/null +++ b/wcfsetup/install/files/lib/command/user/group/assignment/DeleteUserGroupAssignment.class.php @@ -0,0 +1,32 @@ + + * @since 6.3 + */ +final class DeleteUserGroupAssignment +{ + public function __construct( + private readonly UserGroupAssignment $assignment, + ) {} + + public function __invoke(): void + { + UserGroupAssignmentBuilder::delete($this->assignment); + + UserGroupAssignmentBuilder::resetCache(); + + EventHandler::getInstance()->fire(new UserGroupAssignmentDeleted($this->assignment)); + } +} diff --git a/wcfsetup/install/files/lib/command/user/group/assignment/DisableUserGroupAssignment.class.php b/wcfsetup/install/files/lib/command/user/group/assignment/DisableUserGroupAssignment.class.php index f91c4c99706..b22769542a1 100644 --- a/wcfsetup/install/files/lib/command/user/group/assignment/DisableUserGroupAssignment.class.php +++ b/wcfsetup/install/files/lib/command/user/group/assignment/DisableUserGroupAssignment.class.php @@ -3,7 +3,7 @@ namespace wcf\command\user\group\assignment; use wcf\data\user\group\assignment\UserGroupAssignment; -use wcf\data\user\group\assignment\UserGroupAssignmentEditor; +use wcf\data\user\group\assignment\UserGroupAssignmentBuilder; use wcf\event\user\group\assignment\UserGroupAssignmentDisabled; use wcf\system\event\EventHandler; @@ -21,13 +21,12 @@ public function __construct(private readonly UserGroupAssignment $assignment) {} public function __invoke(): void { - (new UserGroupAssignmentEditor($this->assignment))->update([ - 'isDisabled' => 1, - ]); + UserGroupAssignmentBuilder::forUpdate($this->assignment) + ->setIsDisabled(true) + ->update(); - UserGroupAssignmentEditor::resetCache(); + UserGroupAssignmentBuilder::resetCache(); - $event = new UserGroupAssignmentDisabled($this->assignment); - EventHandler::getInstance()->fire($event); + EventHandler::getInstance()->fire(new UserGroupAssignmentDisabled($this->assignment)); } } diff --git a/wcfsetup/install/files/lib/command/user/group/assignment/EnableUserGroupAssignment.class.php b/wcfsetup/install/files/lib/command/user/group/assignment/EnableUserGroupAssignment.class.php index 8ffd4949c06..00acd41115d 100644 --- a/wcfsetup/install/files/lib/command/user/group/assignment/EnableUserGroupAssignment.class.php +++ b/wcfsetup/install/files/lib/command/user/group/assignment/EnableUserGroupAssignment.class.php @@ -3,7 +3,7 @@ namespace wcf\command\user\group\assignment; use wcf\data\user\group\assignment\UserGroupAssignment; -use wcf\data\user\group\assignment\UserGroupAssignmentEditor; +use wcf\data\user\group\assignment\UserGroupAssignmentBuilder; use wcf\event\user\group\assignment\UserGroupAssignmentEnabled; use wcf\system\event\EventHandler; @@ -21,13 +21,12 @@ public function __construct(private readonly UserGroupAssignment $assignment) {} public function __invoke(): void { - (new UserGroupAssignmentEditor($this->assignment))->update([ - 'isDisabled' => 0, - ]); + UserGroupAssignmentBuilder::forUpdate($this->assignment) + ->setIsDisabled(false) + ->update(); - UserGroupAssignmentEditor::resetCache(); + UserGroupAssignmentBuilder::resetCache(); - $event = new UserGroupAssignmentEnabled($this->assignment); - EventHandler::getInstance()->fire($event); + EventHandler::getInstance()->fire(new UserGroupAssignmentEnabled($this->assignment)); } } diff --git a/wcfsetup/install/files/lib/command/user/group/assignment/UpdateUserGroupAssignment.class.php b/wcfsetup/install/files/lib/command/user/group/assignment/UpdateUserGroupAssignment.class.php new file mode 100644 index 00000000000..c92fd5fbb6d --- /dev/null +++ b/wcfsetup/install/files/lib/command/user/group/assignment/UpdateUserGroupAssignment.class.php @@ -0,0 +1,34 @@ + + * @since 6.3 + */ +final class UpdateUserGroupAssignment +{ + public function __construct( + private readonly UserGroupAssignmentBuilder $builder, + ) {} + + public function __invoke(): UserGroupAssignment + { + $assignment = $this->builder->update(); + + UserGroupAssignmentBuilder::resetCache(); + + EventHandler::getInstance()->fire(new UserGroupAssignmentUpdated($assignment, $this->builder)); + + return $assignment; + } +} diff --git a/wcfsetup/install/files/lib/data/ad/Ad.class.php b/wcfsetup/install/files/lib/data/ad/Ad.class.php index 06ec15eca00..19d91ac4523 100644 --- a/wcfsetup/install/files/lib/data/ad/Ad.class.php +++ b/wcfsetup/install/files/lib/data/ad/Ad.class.php @@ -24,6 +24,7 @@ * @property-read ?string $ad ad text * @property-read 0|1 $isDisabled is `1` if the ad is disabled and thus not shown, otherwise `0` * @property-read int $showOrder position of the ad in relation to the other ads at the same location + * @property-read ?string $conditions serialized object filters the active user must match or `null` if the ad has no filters */ class Ad extends DatabaseObject implements IRouteController { @@ -31,6 +32,7 @@ class Ad extends DatabaseObject implements IRouteController * Returns the conditions of the ad. * * @return Condition[] + * @deprecated 6.3 Use object filter API instead. */ public function getConditions() { diff --git a/wcfsetup/install/files/lib/data/ad/AdAction.class.php b/wcfsetup/install/files/lib/data/ad/AdAction.class.php index 6782298dbed..0e1b511ddbb 100644 --- a/wcfsetup/install/files/lib/data/ad/AdAction.class.php +++ b/wcfsetup/install/files/lib/data/ad/AdAction.class.php @@ -16,6 +16,7 @@ * @license GNU Lesser General Public License * * @extends AbstractDatabaseObjectAction + * @deprecated 6.3 use `AdBuilder` instead. */ class AdAction extends AbstractDatabaseObjectAction implements IToggleAction { diff --git a/wcfsetup/install/files/lib/data/ad/AdBuilder.class.php b/wcfsetup/install/files/lib/data/ad/AdBuilder.class.php new file mode 100644 index 00000000000..a4b609213f8 --- /dev/null +++ b/wcfsetup/install/files/lib/data/ad/AdBuilder.class.php @@ -0,0 +1,181 @@ + + * @since 6.3 + * + * @extends DatabaseObjectBuilder + */ +final class AdBuilder extends DatabaseObjectBuilder +{ + private ?int $showOrder = null; + + /** + * Sets the id of the `com.woltlab.wcf.adLocation` object type the ad is shown at. + */ + public function setObjectTypeID(int $objectTypeID): static + { + $this->properties['objectTypeID'] = $objectTypeID; + + return $this; + } + + public function setAdName(string $adName): static + { + $this->properties['adName'] = $adName; + + return $this; + } + + /** + * Sets the HTML code of the ad. + */ + public function setAd(string $ad): static + { + $this->properties['ad'] = $ad; + + return $this; + } + + public function setIsDisabled(bool $isDisabled): static + { + $this->properties['isDisabled'] = (int)$isDisabled; + + return $this; + } + + /** + * Sets the position of the ad in relation to the other ads at the same + * location. + * + * The other ads are shifted accordingly, a value of `0` appends the ad at + * the end. + */ + public function setShowOrder(int $showOrder): static + { + $this->showOrder = $showOrder; + + return $this; + } + + /** + * Sets the serialized object filters that the active user must match. + * + * An ad without any filters is visible to everyone, therefore an empty + * list of filters is stored as `null`. + */ + public function setConditions(?string $conditions): static + { + if ($conditions !== null && \json_decode($conditions, true) === []) { + $conditions = null; + } + + $this->properties['conditions'] = $conditions; + + return $this; + } + + #[\Override] + protected function getRequiredProperties(): array + { + return ['objectTypeID', 'adName']; + } + + #[\Override] + protected function afterCreate(DatabaseObject $object): void + { + self::applyShowOrder($object, $this->showOrder ?? 0); + } + + #[\Override] + protected function afterUpdate(DatabaseObject $object): void + { + $previousAd = $this->getObject(); + + if ($object->objectTypeID !== $previousAd->objectTypeID) { + // The ad is moved to another location, therefore its position + // must be determined in relation to the ads at the new location. + self::applyShowOrder($object, $this->showOrder ?? 0); + } elseif ($this->showOrder !== null && $this->showOrder !== $previousAd->showOrder) { + self::applyShowOrder($object, $this->showOrder); + } + } + + #[\Override] + protected static function beforeDeleteAll(array $objectIDs): void + { + ConditionHandler::getInstance()->deleteConditions('com.woltlab.wcf.condition.ad', $objectIDs); + } + + /** + * Moves the ad to the given position among the ads at the same location, + * shifting the ads that follow it. A value of `0` appends the ad at the end. + */ + private static function applyShowOrder(Ad $ad, int $showOrder): void + { + $sql = "SELECT MAX(showOrder) + FROM wcf1_ad + WHERE objectTypeID = ? + AND adID <> ?"; + $statement = WCF::getDB()->prepare($sql); + $statement->execute([ + $ad->objectTypeID, + $ad->adID, + ]); + $maxShowOrder = $statement->fetchSingleColumn(); + if ($maxShowOrder === null) { + $maxShowOrder = 0; + } + + if ($showOrder === 0 || $showOrder > $maxShowOrder) { + $newShowOrder = $maxShowOrder + 1; + } else { + $sql = "UPDATE wcf1_ad + SET showOrder = showOrder + 1 + WHERE objectTypeID = ? + AND showOrder >= ? + AND adID <> ?"; + $statement = WCF::getDB()->prepare($sql); + $statement->execute([ + $ad->objectTypeID, + $showOrder, + $ad->adID, + ]); + + $newShowOrder = $showOrder; + } + + $sql = "UPDATE wcf1_ad + SET showOrder = ? + WHERE adID = ?"; + $statement = WCF::getDB()->prepare($sql); + $statement->execute([ + $newShowOrder, + $ad->adID, + ]); + } + + public static function resetCache(): void + { + AdCacheBuilder::getInstance()->reset(); + ConditionCacheBuilder::getInstance()->reset([ + 'definitionID' => ObjectTypeCache::getInstance() + ->getDefinitionByName('com.woltlab.wcf.condition.ad') + ->definitionID, + ]); + } +} diff --git a/wcfsetup/install/files/lib/data/ad/AdEditor.class.php b/wcfsetup/install/files/lib/data/ad/AdEditor.class.php index 511e037d4c4..0bb14f4771d 100644 --- a/wcfsetup/install/files/lib/data/ad/AdEditor.class.php +++ b/wcfsetup/install/files/lib/data/ad/AdEditor.class.php @@ -19,6 +19,7 @@ * @mixin Ad * @extends DatabaseObjectEditor * @implements IEditableCachedObject + * @deprecated 6.3 use `AdBuilder` instead. */ class AdEditor extends DatabaseObjectEditor implements IEditableCachedObject { diff --git a/wcfsetup/install/files/lib/data/notice/Notice.class.php b/wcfsetup/install/files/lib/data/notice/Notice.class.php index 83d34a0a11d..401f9e7bf55 100644 --- a/wcfsetup/install/files/lib/data/notice/Notice.class.php +++ b/wcfsetup/install/files/lib/data/notice/Notice.class.php @@ -25,6 +25,7 @@ * @property-read int $showOrder position of the notice in relation to the other notices * @property-read 0|1 $isDisabled is `1` if the notice is disabled and thus not shown, otherwise `0` * @property-read 0|1 $isDismissible is `1` if the notice can be dismissed by users, otherwise `0` + * @property-read ?string $conditions serialized object filters the active user must match or `null` if the notice has no filters */ class Notice extends DatabaseObject implements IRouteController, \Stringable { @@ -66,6 +67,7 @@ public function __toString(): string * Returns the conditions of the notice. * * @return Condition[] + * @deprecated 6.3 Use object filter API instead. */ public function getConditions() { diff --git a/wcfsetup/install/files/lib/data/notice/NoticeAction.class.php b/wcfsetup/install/files/lib/data/notice/NoticeAction.class.php index 3378b9658f2..aec9f3def7b 100644 --- a/wcfsetup/install/files/lib/data/notice/NoticeAction.class.php +++ b/wcfsetup/install/files/lib/data/notice/NoticeAction.class.php @@ -17,6 +17,7 @@ * @license GNU Lesser General Public License * * @extends AbstractDatabaseObjectAction + * @deprecated 6.3 use `NoticeBuilder` instead. */ class NoticeAction extends AbstractDatabaseObjectAction implements IToggleAction { diff --git a/wcfsetup/install/files/lib/data/notice/NoticeBuilder.class.php b/wcfsetup/install/files/lib/data/notice/NoticeBuilder.class.php new file mode 100644 index 00000000000..a238a83ffee --- /dev/null +++ b/wcfsetup/install/files/lib/data/notice/NoticeBuilder.class.php @@ -0,0 +1,269 @@ + + * @since 6.3 + * + * @extends DatabaseObjectBuilder + */ +final class NoticeBuilder extends DatabaseObjectBuilder +{ + private const LANGUAGE_CATEGORY = 'wcf.notice'; + + private const LANGUAGE_ITEM_PATTERN = 'wcf.notice.notice.notice%d'; + + /** + * @var ?array + */ + private ?array $i18nValues = null; + + private ?int $showOrder = null; + + public function setNoticeName(string $noticeName): static + { + $this->properties['noticeName'] = $noticeName; + + return $this; + } + + /** + * Sets the text of the notice. + * + * Pass an array of language id to text mappings to store the text as a + * language item instead of a plain value. + * + * @param string|array $notice + */ + public function setNotice(string|array $notice): static + { + if (\is_array($notice)) { + $this->i18nValues = $notice; + // The final value is the name of the language item which is only + // known once the notice has been created. + $this->properties['notice'] = ''; + } else { + $this->i18nValues = []; + $this->properties['notice'] = $notice; + } + + return $this; + } + + public function setNoticeUseHtml(bool $noticeUseHtml): static + { + $this->properties['noticeUseHtml'] = (int)$noticeUseHtml; + + return $this; + } + + public function setCssClassName(string $cssClassName): static + { + $this->properties['cssClassName'] = $cssClassName; + + return $this; + } + + /** + * Sets the position of the notice in relation to the other notices. + * + * The other notices are shifted accordingly, a value of `0` appends the + * notice at the end. + */ + public function setShowOrder(int $showOrder): static + { + $this->showOrder = $showOrder; + + return $this; + } + + public function setIsDisabled(bool $isDisabled): static + { + $this->properties['isDisabled'] = (int)$isDisabled; + + return $this; + } + + public function setIsDismissible(bool $isDismissible): static + { + $this->properties['isDismissible'] = (int)$isDismissible; + + return $this; + } + + /** + * Sets the serialized object filters that the active user must match. + * + * A notice without any filters is visible to everyone, therefore an empty + * list of filters is stored as `null`. + */ + public function setConditions(?string $conditions): static + { + if ($conditions !== null && \json_decode($conditions, true) === []) { + $conditions = null; + } + + $this->properties['conditions'] = $conditions; + + return $this; + } + + #[\Override] + protected function getRequiredProperties(): array + { + return ['noticeName']; + } + + #[\Override] + protected function afterCreate(DatabaseObject $object): void + { + $this->saveI18nValues($object); + + if ($this->showOrder !== null) { + self::applyShowOrder($object, $this->showOrder); + } + } + + #[\Override] + protected function afterUpdate(DatabaseObject $object): void + { + $this->saveI18nValues($object); + + if ($this->showOrder !== null && $this->showOrder !== $this->getObject()->showOrder) { + self::applyShowOrder($object, $this->showOrder); + } + } + + #[\Override] + protected static function beforeDeleteAll(array $objectIDs): void + { + ConditionHandler::getInstance()->deleteConditions('com.woltlab.wcf.condition.notice', $objectIDs); + } + + /** + * Writes the text of the notice into a language item or removes a + * previously created language item if a plain value is used. + */ + private function saveI18nValues(Notice $notice): void + { + if ($this->i18nValues === null) { + return; + } + + $languageItem = \sprintf(self::LANGUAGE_ITEM_PATTERN, $notice->noticeID); + + if ($this->i18nValues === []) { + self::deleteLanguageItems([$languageItem]); + + return; + } + + I18nHandler::getInstance()->save( + $this->i18nValues, + $languageItem, + self::LANGUAGE_CATEGORY, + \PACKAGE_ID + ); + + $sql = "UPDATE wcf1_notice + SET notice = ? + WHERE noticeID = ?"; + $statement = WCF::getDB()->prepare($sql); + $statement->execute([ + $languageItem, + $notice->noticeID, + ]); + } + + /** + * @param list $languageItems + */ + private static function deleteLanguageItems(array $languageItems): void + { + $sql = "SELECT languageCategoryID + FROM wcf1_language_category + WHERE languageCategory = ?"; + $statement = WCF::getDB()->prepare($sql, 1); + $statement->execute([self::LANGUAGE_CATEGORY]); + $languageCategoryID = $statement->fetchSingleColumn(); + + $conditions = new PreparedStatementConditionBuilder(); + $conditions->add('languageItem IN (?)', [$languageItems]); + $conditions->add('packageID = ?', [\PACKAGE_ID]); + $conditions->add('languageCategoryID = ?', [$languageCategoryID]); + + $sql = "DELETE FROM wcf1_language_item + {$conditions}"; + $statement = WCF::getDB()->prepare($sql); + $statement->execute($conditions->getParameters()); + + LanguageFactory::getInstance()->deleteLanguageCache(); + } + + /** + * Moves the notice to the given position, shifting the notices that follow + * it. A value of `0` appends the notice at the end. + */ + private static function applyShowOrder(Notice $notice, int $showOrder): void + { + $sql = "SELECT MAX(showOrder) + FROM wcf1_notice"; + $statement = WCF::getDB()->prepare($sql); + $statement->execute(); + $maxShowOrder = $statement->fetchSingleColumn(); + if ($maxShowOrder === null) { + $maxShowOrder = 0; + } + + if ($showOrder === 0 || $showOrder > $maxShowOrder) { + $newShowOrder = $maxShowOrder + 1; + } else { + $sql = "UPDATE wcf1_notice + SET showOrder = showOrder + 1 + WHERE showOrder >= ? + AND noticeID <> ?"; + $statement = WCF::getDB()->prepare($sql); + $statement->execute([ + $showOrder, + $notice->noticeID, + ]); + + $newShowOrder = $showOrder; + } + + $sql = "UPDATE wcf1_notice + SET showOrder = ? + WHERE noticeID = ?"; + $statement = WCF::getDB()->prepare($sql); + $statement->execute([ + $newShowOrder, + $notice->noticeID, + ]); + } + + public static function resetCache(): void + { + NoticeCacheBuilder::getInstance()->reset(); + ConditionCacheBuilder::getInstance()->reset([ + 'definitionID' => ObjectTypeCache::getInstance() + ->getDefinitionByName('com.woltlab.wcf.condition.notice') + ->definitionID, + ]); + } +} diff --git a/wcfsetup/install/files/lib/data/notice/NoticeEditor.class.php b/wcfsetup/install/files/lib/data/notice/NoticeEditor.class.php index 5f922a7409e..b84bcb6abc0 100644 --- a/wcfsetup/install/files/lib/data/notice/NoticeEditor.class.php +++ b/wcfsetup/install/files/lib/data/notice/NoticeEditor.class.php @@ -19,6 +19,7 @@ * @mixin Notice * @extends DatabaseObjectEditor * @implements IEditableCachedObject + * @deprecated 6.3 use `NoticeBuilder` instead. */ class NoticeEditor extends DatabaseObjectEditor implements IEditableCachedObject { diff --git a/wcfsetup/install/files/lib/data/user/group/UserGroup.class.php b/wcfsetup/install/files/lib/data/user/group/UserGroup.class.php index 6284e82ec1d..d56c270641a 100644 --- a/wcfsetup/install/files/lib/data/user/group/UserGroup.class.php +++ b/wcfsetup/install/files/lib/data/user/group/UserGroup.class.php @@ -133,10 +133,9 @@ public static function getGroupsByType(array $types = [], array $invalidGroupTyp /** * Returns a sorted list of groups filtered by given type. * - * @param int[] $types - * @param int[] $invalidGroupTypes - * @return UserGroup[] - * @since 5.3 + * @param list $types + * @param list $invalidGroupTypes + * @return array */ public static function getSortedGroupsByType(array $types = [], array $invalidGroupTypes = []) { 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 d4164d45747..022d7793619 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 @@ -19,6 +19,7 @@ * @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 0|1 $isDisabled is `1` if the user group assignment is disabled and thus not checked for automatic assignments, otherwise `0` + * @property-read string $conditions */ class UserGroupAssignment extends DatabaseObject implements IRouteController { @@ -26,6 +27,7 @@ class UserGroupAssignment extends DatabaseObject implements IRouteController * Returns the conditions of the automatic assignment to a user group. * * @return Condition[] + * @deprecated 6.3 Use object filter API instead. */ public function getConditions() { 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 2e27da6153e..dc0df966b84 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 @@ -16,6 +16,7 @@ * @license GNU Lesser General Public License * * @extends AbstractDatabaseObjectAction + * @deprecated 6.3 use `UserGroupAssignmentBuilder` instead. */ class UserGroupAssignmentAction extends AbstractDatabaseObjectAction implements IToggleAction { diff --git a/wcfsetup/install/files/lib/data/user/group/assignment/UserGroupAssignmentBuilder.class.php b/wcfsetup/install/files/lib/data/user/group/assignment/UserGroupAssignmentBuilder.class.php new file mode 100644 index 00000000000..da580ac86cc --- /dev/null +++ b/wcfsetup/install/files/lib/data/user/group/assignment/UserGroupAssignmentBuilder.class.php @@ -0,0 +1,81 @@ + + * @since 6.3 + * + * @extends DatabaseObjectBuilder + */ +final class UserGroupAssignmentBuilder extends DatabaseObjectBuilder +{ + public function setTitle(string $title): static + { + $this->properties['title'] = $title; + + return $this; + } + + /** + * Sets the user group to which the users are automatically assigned. + */ + public function setGroupID(int $groupID): static + { + $this->properties['groupID'] = $groupID; + + return $this; + } + + public function setIsDisabled(bool $isDisabled): static + { + $this->properties['isDisabled'] = (int)$isDisabled; + + return $this; + } + + /** + * Sets the serialized object filters that a user must match. + */ + public function setConditions(?string $conditions): static + { + $this->properties['conditions'] = $conditions; + + return $this; + } + + #[\Override] + protected function getRequiredProperties(): array + { + return ['groupID', 'title']; + } + + #[\Override] + protected static function beforeDeleteAll(array $objectIDs): void + { + ConditionHandler::getInstance()->deleteConditions( + 'com.woltlab.wcf.condition.userGroupAssignment', + $objectIDs + ); + } + + public static function resetCache(): void + { + UserGroupAssignmentCacheBuilder::getInstance()->reset(); + ConditionCacheBuilder::getInstance()->reset([ + 'definitionID' => ObjectTypeCache::getInstance() + ->getDefinitionByName('com.woltlab.wcf.condition.userGroupAssignment') + ->definitionID, + ]); + } +} 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 ba29be98677..2ef837e16ee 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 @@ -18,6 +18,7 @@ * @mixin UserGroupAssignment * @extends DatabaseObjectEditor * @implements IEditableCachedObject + * @deprecated 6.3 use `UserGroupAssignmentBuilder` instead. */ class UserGroupAssignmentEditor extends DatabaseObjectEditor implements IEditableCachedObject { diff --git a/wcfsetup/install/files/lib/event/ad/AdCreated.class.php b/wcfsetup/install/files/lib/event/ad/AdCreated.class.php new file mode 100644 index 00000000000..81d3694ed54 --- /dev/null +++ b/wcfsetup/install/files/lib/event/ad/AdCreated.class.php @@ -0,0 +1,23 @@ + + * @since 6.3 + */ +final class AdCreated implements IPsr14Event +{ + public function __construct( + public readonly Ad $ad, + public readonly AdBuilder $builder, + ) {} +} diff --git a/wcfsetup/install/files/lib/event/ad/AdDeleted.class.php b/wcfsetup/install/files/lib/event/ad/AdDeleted.class.php new file mode 100644 index 00000000000..d7b638d250c --- /dev/null +++ b/wcfsetup/install/files/lib/event/ad/AdDeleted.class.php @@ -0,0 +1,21 @@ + + * @since 6.3 + */ +final class AdDeleted implements IPsr14Event +{ + public function __construct( + public readonly Ad $ad, + ) {} +} diff --git a/wcfsetup/install/files/lib/event/ad/AdObjectFilterCollecting.class.php b/wcfsetup/install/files/lib/event/ad/AdObjectFilterCollecting.class.php new file mode 100644 index 00000000000..7de0809b360 --- /dev/null +++ b/wcfsetup/install/files/lib/event/ad/AdObjectFilterCollecting.class.php @@ -0,0 +1,41 @@ + + * @since 6.3 + */ +final class AdObjectFilterCollecting implements IPsr14Event +{ + /** + * @var array> + */ + private array $filters = []; + + /** + * Registers a new filter. An existing filter with the same identifier is replaced. + * + * @param IObjectFilter $filter + */ + public function register(IObjectFilter $filter): void + { + $this->filters[$filter->getIdentifier()] = $filter; + } + + /** + * @return list> + */ + public function getFilters(): array + { + return \array_values($this->filters); + } +} diff --git a/wcfsetup/install/files/lib/event/ad/AdUpdated.class.php b/wcfsetup/install/files/lib/event/ad/AdUpdated.class.php new file mode 100644 index 00000000000..0579f4a7268 --- /dev/null +++ b/wcfsetup/install/files/lib/event/ad/AdUpdated.class.php @@ -0,0 +1,23 @@ + + * @since 6.3 + */ +final class AdUpdated implements IPsr14Event +{ + public function __construct( + public readonly Ad $ad, + public readonly AdBuilder $builder, + ) {} +} diff --git a/wcfsetup/install/files/lib/event/notice/NoticeCreated.class.php b/wcfsetup/install/files/lib/event/notice/NoticeCreated.class.php new file mode 100644 index 00000000000..d52d00c9b45 --- /dev/null +++ b/wcfsetup/install/files/lib/event/notice/NoticeCreated.class.php @@ -0,0 +1,23 @@ + + * @since 6.3 + */ +final class NoticeCreated implements IPsr14Event +{ + public function __construct( + public readonly Notice $notice, + public readonly NoticeBuilder $builder, + ) {} +} diff --git a/wcfsetup/install/files/lib/event/notice/NoticeDeleted.class.php b/wcfsetup/install/files/lib/event/notice/NoticeDeleted.class.php new file mode 100644 index 00000000000..b97fa230555 --- /dev/null +++ b/wcfsetup/install/files/lib/event/notice/NoticeDeleted.class.php @@ -0,0 +1,21 @@ + + * @since 6.3 + */ +final class NoticeDeleted implements IPsr14Event +{ + public function __construct( + public readonly Notice $notice, + ) {} +} diff --git a/wcfsetup/install/files/lib/event/notice/NoticeObjectFilterCollecting.class.php b/wcfsetup/install/files/lib/event/notice/NoticeObjectFilterCollecting.class.php new file mode 100644 index 00000000000..78598445542 --- /dev/null +++ b/wcfsetup/install/files/lib/event/notice/NoticeObjectFilterCollecting.class.php @@ -0,0 +1,41 @@ + + * @since 6.3 + */ +final class NoticeObjectFilterCollecting implements IPsr14Event +{ + /** + * @var array> + */ + private array $filters = []; + + /** + * Registers a new filter. An existing filter with the same identifier is replaced. + * + * @param IObjectFilter $filter + */ + public function register(IObjectFilter $filter): void + { + $this->filters[$filter->getIdentifier()] = $filter; + } + + /** + * @return list> + */ + public function getFilters(): array + { + return \array_values($this->filters); + } +} diff --git a/wcfsetup/install/files/lib/event/notice/NoticeUpdated.class.php b/wcfsetup/install/files/lib/event/notice/NoticeUpdated.class.php new file mode 100644 index 00000000000..6e28fb2173b --- /dev/null +++ b/wcfsetup/install/files/lib/event/notice/NoticeUpdated.class.php @@ -0,0 +1,23 @@ + + * @since 6.3 + */ +final class NoticeUpdated implements IPsr14Event +{ + public function __construct( + public readonly Notice $notice, + public readonly NoticeBuilder $builder, + ) {} +} diff --git a/wcfsetup/install/files/lib/event/object/filter/ObjectFilterBuilderCollecting.class.php b/wcfsetup/install/files/lib/event/object/filter/ObjectFilterBuilderCollecting.class.php new file mode 100644 index 00000000000..70563567415 --- /dev/null +++ b/wcfsetup/install/files/lib/event/object/filter/ObjectFilterBuilderCollecting.class.php @@ -0,0 +1,40 @@ + + * @since 6.3 + */ +final class ObjectFilterBuilderCollecting implements IPsr14Event +{ + /** + * @var array> + */ + private array $builders = []; + + /** + * Registers a new object filter builder. + * + * @param IObjectFilterBuilder<*> $builder + */ + public function register(IObjectFilterBuilder $builder): void + { + $this->builders[$builder->getIdentifier()] = $builder; + } + + /** + * @return array> + */ + public function getBuilders(): array + { + return $this->builders; + } +} diff --git a/wcfsetup/install/files/lib/event/user/group/assignment/UserGroupAssignmentCreated.class.php b/wcfsetup/install/files/lib/event/user/group/assignment/UserGroupAssignmentCreated.class.php new file mode 100644 index 00000000000..dfb7d1dd401 --- /dev/null +++ b/wcfsetup/install/files/lib/event/user/group/assignment/UserGroupAssignmentCreated.class.php @@ -0,0 +1,23 @@ + + * @since 6.3 + */ +final class UserGroupAssignmentCreated implements IPsr14Event +{ + public function __construct( + public readonly UserGroupAssignment $assignment, + public readonly UserGroupAssignmentBuilder $builder, + ) {} +} diff --git a/wcfsetup/install/files/lib/event/user/group/assignment/UserGroupAssignmentDeleted.class.php b/wcfsetup/install/files/lib/event/user/group/assignment/UserGroupAssignmentDeleted.class.php new file mode 100644 index 00000000000..7e01af2ccd8 --- /dev/null +++ b/wcfsetup/install/files/lib/event/user/group/assignment/UserGroupAssignmentDeleted.class.php @@ -0,0 +1,21 @@ + + * @since 6.3 + */ +final class UserGroupAssignmentDeleted implements IPsr14Event +{ + public function __construct( + public readonly UserGroupAssignment $assignment, + ) {} +} diff --git a/wcfsetup/install/files/lib/event/user/group/assignment/UserGroupAssignmentObjectFilterCollecting.class.php b/wcfsetup/install/files/lib/event/user/group/assignment/UserGroupAssignmentObjectFilterCollecting.class.php new file mode 100644 index 00000000000..c1b50a270a2 --- /dev/null +++ b/wcfsetup/install/files/lib/event/user/group/assignment/UserGroupAssignmentObjectFilterCollecting.class.php @@ -0,0 +1,45 @@ + + * @since 6.3 + */ +final class UserGroupAssignmentObjectFilterCollecting implements IPsr14Event +{ + /** + * @var array> + */ + private array $filters = []; + + /** + * Registers a new filter. An existing filter with the same identifier is replaced. + * + * @param IObjectListFilter $filter + */ + public function register(IObjectListFilter $filter): void + { + $this->filters[$filter->getIdentifier()] = $filter; + } + + /** + * @return list> + */ + public function getFilters(): array + { + return \array_values($this->filters); + } +} diff --git a/wcfsetup/install/files/lib/event/user/group/assignment/UserGroupAssignmentUpdated.class.php b/wcfsetup/install/files/lib/event/user/group/assignment/UserGroupAssignmentUpdated.class.php new file mode 100644 index 00000000000..7b7e1660cc7 --- /dev/null +++ b/wcfsetup/install/files/lib/event/user/group/assignment/UserGroupAssignmentUpdated.class.php @@ -0,0 +1,23 @@ + + * @since 6.3 + */ +final class UserGroupAssignmentUpdated implements IPsr14Event +{ + public function __construct( + public readonly UserGroupAssignment $assignment, + public readonly UserGroupAssignmentBuilder $builder, + ) {} +} diff --git a/wcfsetup/install/files/lib/form/AbstractFormBuilderForm.class.php b/wcfsetup/install/files/lib/form/AbstractFormBuilderForm.class.php index a52b933ada1..94f5b310eca 100644 --- a/wcfsetup/install/files/lib/form/AbstractFormBuilderForm.class.php +++ b/wcfsetup/install/files/lib/form/AbstractFormBuilderForm.class.php @@ -179,7 +179,6 @@ public function save() $formData['data'] = []; } $formData['data'] = \array_merge($this->additionalFields, $formData['data']); - $this->objectAction = new $this->objectActionClass( \array_filter([$this->formObject]), $action, diff --git a/wcfsetup/install/files/lib/system/ad/AdHandler.class.php b/wcfsetup/install/files/lib/system/ad/AdHandler.class.php index f0a975b94f0..1c5587fa8ec 100644 --- a/wcfsetup/install/files/lib/system/ad/AdHandler.class.php +++ b/wcfsetup/install/files/lib/system/ad/AdHandler.class.php @@ -7,6 +7,7 @@ use wcf\data\object\type\ObjectTypeCache; use wcf\system\cache\builder\AdCacheBuilder; use wcf\system\exception\SystemException; +use wcf\system\object\filter\builder\AdObjectFilterBuilder; use wcf\system\SingletonFactory; use wcf\system\WCF; @@ -31,6 +32,11 @@ class AdHandler extends SingletonFactory */ protected $objectTypes = []; + /** + * filter builder shared by all ad locations + */ + private ?AdObjectFilterBuilder $filterBuilder = null; + /** * Returns the ad output for the given ad location. * @@ -57,13 +63,12 @@ public function getAds(string $adLocation) \shuffle($ads); } + $this->filterBuilder ??= new AdObjectFilterBuilder(); + $output = ''; foreach ($ads as $ad) { - $conditions = $ad->getConditions(); - foreach ($conditions as $condition) { - if (!$condition->getObjectType()->getProcessor()->showContent($condition)) { - continue 2; - } + if (!$this->filterBuilder->testUser($ad, WCF::getUser())) { + continue; } $output .= '' . $ad->getHtmlCode() . ''; diff --git a/wcfsetup/install/files/lib/system/condition/DaysOfWeekCondition.class.php b/wcfsetup/install/files/lib/system/condition/DaysOfWeekCondition.class.php index 2964e649782..86964ebb2e5 100644 --- a/wcfsetup/install/files/lib/system/condition/DaysOfWeekCondition.class.php +++ b/wcfsetup/install/files/lib/system/condition/DaysOfWeekCondition.class.php @@ -12,6 +12,7 @@ * @author Matthias Schmidt * @copyright 2001-2019 WoltLab GmbH * @license GNU Lesser General Public License + * @deprecated 6.3 Use `wcf\system\object\filter\user\DaysOfWeekObjectFilter` instead. */ class DaysOfWeekCondition extends AbstractMultiSelectCondition implements IContentCondition { diff --git a/wcfsetup/install/files/lib/system/condition/TimeCondition.class.php b/wcfsetup/install/files/lib/system/condition/TimeCondition.class.php index e5029cd5d04..636369e8fed 100644 --- a/wcfsetup/install/files/lib/system/condition/TimeCondition.class.php +++ b/wcfsetup/install/files/lib/system/condition/TimeCondition.class.php @@ -14,6 +14,7 @@ * @author Matthias Schmidt * @copyright 2001-2019 WoltLab GmbH * @license GNU Lesser General Public License + * @deprecated 6.3 The time of day is no longer supported as a condition. */ class TimeCondition extends AbstractMultipleFieldsCondition implements IContentCondition { diff --git a/wcfsetup/install/files/lib/system/condition/UserBirthdayCondition.class.php b/wcfsetup/install/files/lib/system/condition/UserBirthdayCondition.class.php index 15dc5584e8c..0ecb4e85ce9 100644 --- a/wcfsetup/install/files/lib/system/condition/UserBirthdayCondition.class.php +++ b/wcfsetup/install/files/lib/system/condition/UserBirthdayCondition.class.php @@ -12,6 +12,7 @@ * @author Matthias Schmidt * @copyright 2001-2019 WoltLab GmbH * @license GNU Lesser General Public License + * @deprecated 6.3 Use `wcf\system\object\filter\user\UserBirthdayObjectFilter` instead. */ class UserBirthdayCondition extends AbstractCondition implements IContentCondition { diff --git a/wcfsetup/install/files/lib/system/condition/UserMobileBrowserCondition.class.php b/wcfsetup/install/files/lib/system/condition/UserMobileBrowserCondition.class.php index 3dd7ad6deff..acc6ed2772b 100644 --- a/wcfsetup/install/files/lib/system/condition/UserMobileBrowserCondition.class.php +++ b/wcfsetup/install/files/lib/system/condition/UserMobileBrowserCondition.class.php @@ -13,6 +13,7 @@ * @author Joshua Ruesweg, Matthias Schmidt * @copyright 2001-2019 WoltLab GmbH * @license GNU Lesser General Public License + * @deprecated 6.3 Use `wcf\system\object\filter\user\UserMobileBrowserObjectFilter` instead. */ class UserMobileBrowserCondition extends AbstractSingleFieldCondition implements IContentCondition { diff --git a/wcfsetup/install/files/lib/system/condition/page/MultiPageCondition.class.php b/wcfsetup/install/files/lib/system/condition/page/MultiPageCondition.class.php index 06c491f2f42..6f4fc1a0aac 100644 --- a/wcfsetup/install/files/lib/system/condition/page/MultiPageCondition.class.php +++ b/wcfsetup/install/files/lib/system/condition/page/MultiPageCondition.class.php @@ -18,6 +18,7 @@ * @author Matthias Schmidt * @copyright 2001-2019 WoltLab GmbH * @license GNU Lesser General Public License + * @deprecated 6.3 Use `wcf\system\object\filter\RequestedPageObjectFilter` or `wcf\system\object\filter\NotRequestedPageObjectFilter` instead. */ class MultiPageCondition extends AbstractMultiSelectCondition implements IContentCondition { diff --git a/wcfsetup/install/files/lib/system/endpoint/controller/core/ads/DeleteAd.class.php b/wcfsetup/install/files/lib/system/endpoint/controller/core/ads/DeleteAd.class.php index 0dc81ff9ffb..267ff24509c 100644 --- a/wcfsetup/install/files/lib/system/endpoint/controller/core/ads/DeleteAd.class.php +++ b/wcfsetup/install/files/lib/system/endpoint/controller/core/ads/DeleteAd.class.php @@ -5,8 +5,8 @@ use Laminas\Diactoros\Response\JsonResponse; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; +use wcf\command\ad\DeleteAd as DeleteAdCommand; use wcf\data\ad\Ad; -use wcf\data\ad\AdAction; use wcf\http\Helper; use wcf\system\endpoint\DeleteRequest; use wcf\system\endpoint\IController; @@ -31,7 +31,7 @@ public function __invoke(ServerRequestInterface $request, array $variables): Res $this->assertAdCanBeDeleted(); - (new AdAction([$ad], 'delete'))->executeAction(); + new DeleteAdCommand($ad)(); return new JsonResponse([]); } diff --git a/wcfsetup/install/files/lib/system/endpoint/controller/core/notices/DeleteNotice.class.php b/wcfsetup/install/files/lib/system/endpoint/controller/core/notices/DeleteNotice.class.php index e5edb1ee862..8e90bf33583 100644 --- a/wcfsetup/install/files/lib/system/endpoint/controller/core/notices/DeleteNotice.class.php +++ b/wcfsetup/install/files/lib/system/endpoint/controller/core/notices/DeleteNotice.class.php @@ -6,7 +6,7 @@ use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use wcf\data\notice\Notice; -use wcf\data\notice\NoticeAction; +use wcf\command\notice\DeleteNotice as DeleteNoticeCommand; use wcf\http\Helper; use wcf\system\endpoint\DeleteRequest; use wcf\system\endpoint\IController; @@ -30,7 +30,7 @@ public function __invoke(ServerRequestInterface $request, array $variables): Res $notice = Helper::fetchObjectFromRequestParameter($variables['id'], Notice::class); - (new NoticeAction([$notice], 'delete'))->executeAction(); + new DeleteNoticeCommand($notice)(); return new JsonResponse([]); } diff --git a/wcfsetup/install/files/lib/system/endpoint/controller/core/users/groups/assignments/DeleteAssignment.class.php b/wcfsetup/install/files/lib/system/endpoint/controller/core/users/groups/assignments/DeleteAssignment.class.php index 947ea3f6605..a531b52da51 100644 --- a/wcfsetup/install/files/lib/system/endpoint/controller/core/users/groups/assignments/DeleteAssignment.class.php +++ b/wcfsetup/install/files/lib/system/endpoint/controller/core/users/groups/assignments/DeleteAssignment.class.php @@ -5,8 +5,8 @@ use Laminas\Diactoros\Response\JsonResponse; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; +use wcf\command\user\group\assignment\DeleteUserGroupAssignment; use wcf\data\user\group\assignment\UserGroupAssignment; -use wcf\data\user\group\assignment\UserGroupAssignmentAction; use wcf\http\Helper; use wcf\system\endpoint\DeleteRequest; use wcf\system\endpoint\IController; @@ -30,7 +30,7 @@ public function __invoke(ServerRequestInterface $request, array $variables): Res $assignment = Helper::fetchObjectFromRequestParameter($variables['id'], UserGroupAssignment::class); - (new UserGroupAssignmentAction([$assignment], 'delete'))->executeAction(); + new DeleteUserGroupAssignment($assignment)(); return new JsonResponse([]); } diff --git a/wcfsetup/install/files/lib/system/form/builder/TemplateFormNode.class.php b/wcfsetup/install/files/lib/system/form/builder/TemplateFormNode.class.php index 9b6a9d66846..343cf454677 100644 --- a/wcfsetup/install/files/lib/system/form/builder/TemplateFormNode.class.php +++ b/wcfsetup/install/files/lib/system/form/builder/TemplateFormNode.class.php @@ -77,11 +77,21 @@ public function getApplication() #[\Override] public function getHtml() { - return WCF::getTPL()->render( + $html = WCF::getTPL()->render( $this->getApplication(), $this->getTemplateName(), $this->getVariables(), ); + + if ($this->getDependencies() === []) { + return $html; + } + + // Wraps the contents to allow the dependencies to toggle them on the client. + return WCF::getTPL()->render('wcf', 'shared_templateFormNode', [ + 'node' => $this, + 'html' => $html, + ]); } /** diff --git a/wcfsetup/install/files/lib/system/form/builder/field/NumericRangeFormField.class.php b/wcfsetup/install/files/lib/system/form/builder/field/NumericRangeFormField.class.php index 40d19489974..131389a4ab7 100644 --- a/wcfsetup/install/files/lib/system/form/builder/field/NumericRangeFormField.class.php +++ b/wcfsetup/install/files/lib/system/form/builder/field/NumericRangeFormField.class.php @@ -80,7 +80,55 @@ public function validate() { if ($this->isRequired() && ($this->getFromValue() === '' || $this->getToValue() === '')) { $this->addValidationError(new FormFieldValidationError('empty')); + + return; + } + + foreach ([$this->getFromValue(), $this->getToValue()] as $value) { + if ($value === '') { + continue; + } + + $error = $this->validateBound($value); + if ($error !== null) { + $this->addValidationError($error); + + return; + } + } + } + + /** + * Returns the validation error for the given lower or upper bound or `null` + * if the bound is a valid number within the minimum and maximum. + */ + private function validateBound(string $value): ?FormFieldValidationError + { + $isValid = $this->integerValues ? \preg_match('~^-?\d+$~', $value) === 1 : \is_numeric($value); + if (!$isValid) { + return new FormFieldValidationError( + 'invalid', + 'wcf.form.field.numeric.error.invalid' + ); + } + + if ($this->getMinimum() !== null && $value < $this->getMinimum()) { + return new FormFieldValidationError( + 'minimum', + 'wcf.form.field.numeric.error.minimum', + ['minimum' => $this->getMinimum()] + ); } + + if ($this->getMaximum() !== null && $value > $this->getMaximum()) { + return new FormFieldValidationError( + 'maximum', + 'wcf.form.field.numeric.error.maximum', + ['maximum' => $this->getMaximum()] + ); + } + + return null; } #[\Override] @@ -94,13 +142,25 @@ public function value(mixed $value) } $this->value = [ - 'from' => $this->integerValues ? \intval($values[0]) : \floatval($values[0]), - 'to' => $this->integerValues ? \intval($values[1]) : \floatval($values[1]), + 'from' => $this->castValue($values[0]), + 'to' => $this->castValue($values[1]), ]; return $this; } + /** + * Casts a bound of the range to its numeric type, empty bounds are preserved. + */ + private function castValue(string $value): int|float|string + { + if ($value === '') { + return ''; + } + + return $this->integerValues ? (int)$value : (float)$value; + } + public function getFromValue(): string { return $this->value['from'] ?? ''; diff --git a/wcfsetup/install/files/lib/system/form/builder/field/ObjectFilterFormField.class.php b/wcfsetup/install/files/lib/system/form/builder/field/ObjectFilterFormField.class.php new file mode 100644 index 00000000000..fd23364de89 --- /dev/null +++ b/wcfsetup/install/files/lib/system/form/builder/field/ObjectFilterFormField.class.php @@ -0,0 +1,190 @@ + + * @since 6.3 + */ +final class ObjectFilterFormField extends AbstractFormField +{ + protected $templateName = 'shared_objectFilterFormField'; + + /** + * @var IObjectFilterBuilder<*> + */ + private IObjectFilterBuilder $builder; + + /** + * Sets the builder that provides the available filters. + * + * @param IObjectFilterBuilder<*> $builder + */ + public function builder(IObjectFilterBuilder $builder): static + { + $this->builder = $builder; + + return $this; + } + + /** + * Returns the builder that provides the available filters. + * + * @return IObjectFilterBuilder<*> + * @throws \BadMethodCallException if no builder has been set + */ + public function getBuilder(): IObjectFilterBuilder + { + if (!isset($this->builder)) { + throw new \BadMethodCallException("Builder has not been set for field '{$this->getId()}'."); + } + + return $this->builder; + } + + #[\Override] + public function readValue(): ObjectFilterFormField + { + if ($this->getDocument()->hasRequestData($this->getPrefixedId())) { + $this->value = $this->getDocument()->getRequestData($this->getPrefixedId()); + } + + return $this; + } + + #[\Override] + public function validate(): void + { + try { + $values = $this->unserializeFilters($this->getValue()); + } catch (MappingError) { + $this->addValidationError( + new FormFieldValidationError('malformedJson') + ); + + return; + } + + if ($this->isRequired() && $values === []) { + $this->addValidationError( + new FormFieldValidationError('empty') + ); + } + + $filters = []; + foreach ($this->getBuilder()->getFilters() as $filter) { + $filters[$filter->getIdentifier()] = $filter; + } + + $counts = \array_count_values(\array_column($values, 'identifier')); + foreach ($counts as $identifier => $count) { + $filter = $filters[$identifier]; + if ($count > 1 && !$filter->isRepeatable()) { + $this->addValidationError( + new FormFieldValidationError( + 'notRepeatable', + 'wcf.objectFilter.error.notRepeatable', + ['title' => $filter->getTitle()] + ) + ); + } + } + } + + /** + * Returns the configured filters as JSON for the client-side filter builder. + * Each filter consists of its identifier, a human-readable summary and the + * serialized value. + * + * @throws MappingError if the value of this field is malformed + */ + public function toJson(): string + { + return \json_encode( + $this->unserializeFilters($this->getValue()), + \JSON_THROW_ON_ERROR, + ); + } + + /** + * Returns the URL of the dialog that is used to add a new filter. + */ + public function getEndpoint(): string + { + return LinkHandler::getInstance()->getControllerLink( + ObjectFilterBuilderAction::class, + [ + 'identifier' => $this->getBuilder()->getIdentifier(), + ], + ); + } + + /** + * Decodes the given value and enriches each filter with a human-readable + * summary. Values of unknown filters are ignored. + * + * @return list + * @throws MappingError if the given value is malformed + */ + private function unserializeFilters(?string $json): array + { + if ($json === null) { + return []; + } + + /** @var list $values */ + $values = (new MapperBuilder())->mapper()->map( + <<<'EOT' + list + EOT, + Source::json($json) + ); + + if ($values === []) { + return $values; + } + + $filters = []; + foreach ($this->getBuilder()->getFilters() as $filter) { + $filters[$filter->getIdentifier()] = $filter; + } + + return \array_map( + function (array $value) use ($filters): array { + [$identifier, $serializedValue] = $value; + $filter = $filters[$identifier]; + + return [ + 'identifier' => $identifier, + 'summary' => $filter->summarizeValue( + $filter->unserializeValue($serializedValue), + ), + 'value' => $serializedValue, + ]; + }, + \array_values(\array_filter( + $values, + static fn($value) => isset($filters[$value[0]]), + )), + ); + } +} diff --git a/wcfsetup/install/files/lib/system/gridView/admin/UserGroupAssignmentGridView.class.php b/wcfsetup/install/files/lib/system/gridView/admin/UserGroupAssignmentGridView.class.php index cd5201f48bd..5b4bca0cbf1 100644 --- a/wcfsetup/install/files/lib/system/gridView/admin/UserGroupAssignmentGridView.class.php +++ b/wcfsetup/install/files/lib/system/gridView/admin/UserGroupAssignmentGridView.class.php @@ -47,7 +47,7 @@ public function __construct() ->filter(TextFilter::class) ->sortable(), GridViewColumn::for("groupID") - ->label("wcf.acp.group.assignment.userGroup") + ->label("wcf.user.group") ->filter(new SelectFilter( UserGroup::getSortedGroupsByType([], [ UserGroup::EVERYONE, @@ -56,7 +56,7 @@ public function __construct() UserGroup::USERS, ]), 'groupID', - 'wcf.acp.group.assignment.userGroup' + 'wcf.user.group' )) ->sortable() ->renderer( diff --git a/wcfsetup/install/files/lib/system/notice/NoticeHandler.class.php b/wcfsetup/install/files/lib/system/notice/NoticeHandler.class.php index cf80798b72c..020707f1093 100644 --- a/wcfsetup/install/files/lib/system/notice/NoticeHandler.class.php +++ b/wcfsetup/install/files/lib/system/notice/NoticeHandler.class.php @@ -4,7 +4,9 @@ use wcf\data\notice\Notice; use wcf\system\cache\builder\NoticeCacheBuilder; +use wcf\system\object\filter\builder\NoticeObjectFilterBuilder; use wcf\system\SingletonFactory; +use wcf\system\WCF; /** * Handles notice-related matters. @@ -44,17 +46,16 @@ public function getVisibleNotices() return []; } + $filterBuilder = new NoticeObjectFilterBuilder(); + $notices = []; foreach ($this->notices as $notice) { if ($notice->isDismissed()) { continue; } - $conditions = $notice->getConditions(); - foreach ($conditions as $condition) { - if (!$condition->getObjectType()->getProcessor()->showContent($condition)) { - continue 2; - } + if (!$filterBuilder->testUser($notice, WCF::getUser())) { + continue; } $notices[$notice->noticeID] = $notice; diff --git a/wcfsetup/install/files/lib/system/object/filter/AbstractBooleanObjectFilter.class.php b/wcfsetup/install/files/lib/system/object/filter/AbstractBooleanObjectFilter.class.php new file mode 100644 index 00000000000..96a67f1f284 --- /dev/null +++ b/wcfsetup/install/files/lib/system/object/filter/AbstractBooleanObjectFilter.class.php @@ -0,0 +1,84 @@ +.summary.yes` and `.summary.no`. + * + * @author Marcel Werk + * @copyright 2001-2026 WoltLab GmbH + * @license GNU Lesser General Public License + * @since 6.3 + * + * @template TDatabaseObject of DatabaseObject + * @implements IObjectFilter + */ +abstract class AbstractBooleanObjectFilter implements IObjectFilter +{ + /** + * Returns the language item of the title of this filter. + */ + abstract protected function getLanguageItem(): string; + + #[\Override] + public function getTitle(): string + { + return WCF::getLanguage()->get($this->getLanguageItem()); + } + + #[\Override] + public function getFormField(): BooleanFormField + { + return BooleanFormField::create(\str_replace('.', '_', $this->getIdentifier())) + ->label($this->getLanguageItem()); + } + + #[\Override] + public function isAvailable(): bool + { + return true; + } + + #[\Override] + public function isRepeatable(): bool + { + return false; + } + + /** + * @param bool|int $value + */ + #[\Override] + public function serializeValue(mixed $value): string + { + return $value ? '1' : '0'; + } + + #[\Override] + public function unserializeValue(string $serializedValue): bool + { + return (bool)$serializedValue; + } + + #[\Override] + public function toFormFieldValue(mixed $value): bool + { + return $value; + } + + #[\Override] + public function summarizeValue(mixed $value): string + { + return WCF::getLanguage()->get( + $this->getLanguageItem() . ($value ? '.summary.yes' : '.summary.no') + ); + } +} diff --git a/wcfsetup/install/files/lib/system/object/filter/AbstractMultipleSelectionObjectFilter.class.php b/wcfsetup/install/files/lib/system/object/filter/AbstractMultipleSelectionObjectFilter.class.php new file mode 100644 index 00000000000..6716e4381f3 --- /dev/null +++ b/wcfsetup/install/files/lib/system/object/filter/AbstractMultipleSelectionObjectFilter.class.php @@ -0,0 +1,92 @@ +.summary` that receives + * the comma-separated labels of the selected objects as `$values`. + * + * @author Marcel Werk + * @copyright 2001-2026 WoltLab GmbH + * @license GNU Lesser General Public License + * @since 6.3 + * + * @template TDatabaseObject of DatabaseObject + * @implements IObjectFilter> + */ +abstract class AbstractMultipleSelectionObjectFilter implements IObjectFilter +{ + /** + * Returns the language item of the title of this filter. + */ + abstract protected function getLanguageItem(): string; + + /** + * Returns the labels of the objects with the given ids, ids of unknown + * objects are skipped. + * + * @param list $objectIDs + * @return list + */ + abstract protected function getLabels(array $objectIDs): array; + + #[\Override] + public function getTitle(): string + { + return WCF::getLanguage()->get($this->getLanguageItem()); + } + + #[\Override] + public function isAvailable(): bool + { + return true; + } + + /** + * Multiple values are selected in a single filter instead. + */ + #[\Override] + public function isRepeatable(): bool + { + return false; + } + + /** + * @param list $value + */ + #[\Override] + public function serializeValue(mixed $value): string + { + return \implode(',', \array_map(static fn($objectID) => (int)$objectID, $value)); + } + + #[\Override] + public function unserializeValue(string $serializedValue): array + { + return \array_map(static fn($objectID) => (int)$objectID, \explode(',', $serializedValue)); + } + + /** + * @return list + */ + #[\Override] + public function toFormFieldValue(mixed $value): array + { + return $value; + } + + #[\Override] + public function summarizeValue(mixed $value): string + { + return WCF::getLanguage()->getDynamicVariable($this->getLanguageItem() . '.summary', [ + 'values' => \implode(', ', $this->getLabels($value)), + ]); + } +} diff --git a/wcfsetup/install/files/lib/system/object/filter/AbstractRangeObjectFilter.class.php b/wcfsetup/install/files/lib/system/object/filter/AbstractRangeObjectFilter.class.php new file mode 100644 index 00000000000..34466f1588d --- /dev/null +++ b/wcfsetup/install/files/lib/system/object/filter/AbstractRangeObjectFilter.class.php @@ -0,0 +1,134 @@ +.summary` that receives + * the bounds as `$from` and `$to`, missing bounds are `null`. + * + * @author Marcel Werk + * @copyright 2001-2026 WoltLab GmbH + * @license GNU Lesser General Public License + * @since 6.3 + * + * @template TDatabaseObject of DatabaseObject + * @template TBound of int|string + * @implements IObjectFilter + */ +abstract class AbstractRangeObjectFilter implements IObjectFilter +{ + /** + * Returns the language item of the title of this filter. + */ + abstract protected function getLanguageItem(): string; + + /** + * Creates the form field to enter the range, the validation of the bounds + * is added by `getFormField()`. + */ + abstract protected function createFormField(): DateRangeFormField|NumericRangeFormField; + + /** + * Converts a non-empty bound into its native type. + * + * @return TBound + */ + abstract protected function unserializeBound(string $bound): int|string; + + #[\Override] + public function getTitle(): string + { + return WCF::getLanguage()->get($this->getLanguageItem()); + } + + #[\Override] + public function getFormField(): DateRangeFormField|NumericRangeFormField + { + return $this->createFormField() + ->addValidator(new FormFieldValidator( + 'range', + static function (IFormField $field) { + \assert($field instanceof DateRangeFormField || $field instanceof NumericRangeFormField); + $from = $field->getFromValue(); + $to = $field->getToValue(); + + if ($from === '' && $to === '') { + $field->addValidationError( + new FormFieldValidationError('empty') + ); + } elseif ($from !== '' && $to !== '' && $from > $to) { + $field->addValidationError( + new FormFieldValidationError( + 'endBeforeStart', + 'wcf.objectFilter.range.error.endBeforeStart' + ) + ); + } + } + )); + } + + #[\Override] + public function isAvailable(): bool + { + return true; + } + + #[\Override] + public function isRepeatable(): bool + { + return false; + } + + /** + * @param string|array{0: ?TBound, 1: ?TBound} $value + */ + #[\Override] + public function serializeValue(mixed $value): string + { + [$from, $to] = \is_string($value) ? $this->unserializeValue($value) : $value; + + return $from . ';' . $to; + } + + #[\Override] + public function unserializeValue(string $serializedValue): array + { + [$from, $to] = \explode(';', $serializedValue, 2) + [1 => '']; + + return [ + $from === '' ? null : $this->unserializeBound($from), + $to === '' ? null : $this->unserializeBound($to), + ]; + } + + #[\Override] + public function toFormFieldValue(mixed $value): string + { + return $this->serializeValue($value); + } + + #[\Override] + public function summarizeValue(mixed $value): string + { + [$from, $to] = $value; + + return WCF::getLanguage()->getDynamicVariable($this->getLanguageItem() . '.summary', [ + 'from' => $from, + 'to' => $to, + ]); + } +} diff --git a/wcfsetup/install/files/lib/system/object/filter/AbstractSelectionObjectFilter.class.php b/wcfsetup/install/files/lib/system/object/filter/AbstractSelectionObjectFilter.class.php new file mode 100644 index 00000000000..5bb65ffa938 --- /dev/null +++ b/wcfsetup/install/files/lib/system/object/filter/AbstractSelectionObjectFilter.class.php @@ -0,0 +1,121 @@ +.summary` that receives the label of the selected object as + * `$value`. + * + * @author Marcel Werk + * @copyright 2001-2026 WoltLab GmbH + * @license GNU Lesser General Public License + * @since 6.3 + * + * @template TDatabaseObject of DatabaseObject + * @implements IObjectFilter + */ +abstract class AbstractSelectionObjectFilter implements IObjectFilter +{ + /** + * @var array + */ + private array $options; + + /** + * Returns the language item of the title of this filter. + */ + abstract protected function getLanguageItem(): string; + + /** + * Returns the labels of the selectable objects, indexed by their id and + * sorted in the order they are offered. + * + * @return array + */ + abstract protected function createOptions(): array; + + #[\Override] + public function getTitle(): string + { + return WCF::getLanguage()->get($this->getLanguageItem()); + } + + #[\Override] + public function getFormField(): SelectFormField + { + return SelectFormField::create(\str_replace('.', '_', $this->getIdentifier())) + ->label($this->getLanguageItem()) + ->options($this->getOptions(), labelLanguageItems: false) + ->required(); + } + + /** + * Returns false if there are no objects to choose from. + */ + #[\Override] + public function isAvailable(): bool + { + return $this->getOptions() !== []; + } + + /** + * Selecting multiple objects requires the filter to be repeated. + */ + #[\Override] + public function isRepeatable(): bool + { + return true; + } + + /** + * @param int|string $value + */ + #[\Override] + public function serializeValue(mixed $value): string + { + return (string)$value; + } + + #[\Override] + public function unserializeValue(string $serializedValue): int + { + return (int)$serializedValue; + } + + #[\Override] + public function toFormFieldValue(mixed $value): int + { + return $value; + } + + #[\Override] + public function summarizeValue(mixed $value): string + { + return WCF::getLanguage()->getDynamicVariable($this->getLanguageItem() . '.summary', [ + 'value' => $this->getOptions()[$value] ?? $value, + ]); + } + + /** + * Returns the labels of the selectable objects, indexed by their id. + * + * @return array + */ + protected function getOptions(): array + { + if (!isset($this->options)) { + $this->options = $this->createOptions(); + } + + return $this->options; + } +} diff --git a/wcfsetup/install/files/lib/system/object/filter/AbstractTimestampObjectFilter.class.php b/wcfsetup/install/files/lib/system/object/filter/AbstractTimestampObjectFilter.class.php new file mode 100644 index 00000000000..7d9c872972c --- /dev/null +++ b/wcfsetup/install/files/lib/system/object/filter/AbstractTimestampObjectFilter.class.php @@ -0,0 +1,87 @@ +.summary` that receives the timestamp as `$value`. + * + * @author Marcel Werk + * @copyright 2001-2026 WoltLab GmbH + * @license GNU Lesser General Public License + * @since 6.3 + * + * @template TDatabaseObject of DatabaseObject + * @implements IObjectFilter + */ +abstract class AbstractTimestampObjectFilter implements IObjectFilter +{ + /** + * Returns the language item of the title of this filter. + */ + abstract protected function getLanguageItem(): string; + + #[\Override] + public function getTitle(): string + { + return WCF::getLanguage()->get($this->getLanguageItem()); + } + + #[\Override] + public function getFormField(): DateFormField + { + return DateFormField::create(\str_replace('.', '_', $this->getIdentifier())) + ->label($this->getLanguageItem()) + ->supportTime() + ->required(); + } + + #[\Override] + public function isAvailable(): bool + { + return true; + } + + #[\Override] + public function isRepeatable(): bool + { + return false; + } + + /** + * @param int|string $value + */ + #[\Override] + public function serializeValue(mixed $value): string + { + return (string)(int)$value; + } + + #[\Override] + public function unserializeValue(string $serializedValue): int + { + return (int)$serializedValue; + } + + #[\Override] + public function toFormFieldValue(mixed $value): string + { + return (string)$value; + } + + #[\Override] + public function summarizeValue(mixed $value): string + { + return WCF::getLanguage()->getDynamicVariable($this->getLanguageItem() . '.summary', [ + 'value' => $value, + ]); + } +} diff --git a/wcfsetup/install/files/lib/system/object/filter/EndDateObjectFilter.class.php b/wcfsetup/install/files/lib/system/object/filter/EndDateObjectFilter.class.php new file mode 100644 index 00000000000..60d959ab9a2 --- /dev/null +++ b/wcfsetup/install/files/lib/system/object/filter/EndDateObjectFilter.class.php @@ -0,0 +1,36 @@ + + * @since 6.3 + * + * @extends AbstractTimestampObjectFilter + */ +final class EndDateObjectFilter extends AbstractTimestampObjectFilter +{ + #[\Override] + public function getIdentifier(): string + { + return 'com.woltlab.wcf.endDate'; + } + + #[\Override] + protected function getLanguageItem(): string + { + return 'wcf.objectFilter.endDate'; + } + + #[\Override] + public function testObject(DatabaseObject $object, mixed $configuredValue): bool + { + return \TIME_NOW < $configuredValue; + } +} diff --git a/wcfsetup/install/files/lib/system/object/filter/IObjectFilter.class.php b/wcfsetup/install/files/lib/system/object/filter/IObjectFilter.class.php new file mode 100644 index 00000000000..7207489b965 --- /dev/null +++ b/wcfsetup/install/files/lib/system/object/filter/IObjectFilter.class.php @@ -0,0 +1,95 @@ + + * @since 6.3 + * + * @template TDatabaseObject of DatabaseObject + * @phpstan-template-contravariant TDatabaseObject of DatabaseObject + * @template TValueType of mixed + */ +interface IObjectFilter +{ + /** + * Returns the unique identifier of this filter, e.g. `com.woltlab.wcf.username`. + */ + public function getIdentifier(): string; + + /** + * Returns the title of this filter that is shown when selecting a filter. + */ + public function getTitle(): string; + + /** + * Returns the form field that the user interacts with when entering a value. + */ + public function getFormField(): AbstractFormField; + + /** + * Returns true if this filter can be selected when configuring a filter, + * e.g. false if there are no objects to choose from. Stored values of an + * unavailable filter are still evaluated. + */ + public function isAvailable(): bool; + + /** + * Returns true if this filter can be configured more than once for the + * same object. Filters whose repeated use is redundant or contradictory, + * e.g. a yes/no choice, return false. + */ + public function isRepeatable(): bool; + + /** + * Converts the given value into its string representation for storage. + * + * When a filter is configured, the given value is the value of the form + * field returned by `getFormField()`, which may differ from `TValueType`. + * + * @param TValueType $value + */ + public function serializeValue(mixed $value): string; + + /** + * Restores the value from its string representation created by `serializeValue()`. + * + * @return TValueType + */ + public function unserializeValue(string $serializedValue): mixed; + + /** + * Converts the given value into the value of the form field returned by + * `getFormField()`, used to prefill the form field when editing the filter. + * + * @param TValueType $value + */ + public function toFormFieldValue(mixed $value): mixed; + + /** + * Returns a human-readable summary of the configured value, + * e.g. "In user group %s". + * + * @param TValueType $value + */ + public function summarizeValue(mixed $value): string; + + /** + * Returns whether the given object matches the configured value. + * + * @param TDatabaseObject $object + * @param TValueType $configuredValue + */ + public function testObject(DatabaseObject $object, mixed $configuredValue): bool; +} diff --git a/wcfsetup/install/files/lib/system/object/filter/IObjectListFilter.class.php b/wcfsetup/install/files/lib/system/object/filter/IObjectListFilter.class.php new file mode 100644 index 00000000000..db2c81fdea6 --- /dev/null +++ b/wcfsetup/install/files/lib/system/object/filter/IObjectListFilter.class.php @@ -0,0 +1,30 @@ + + * @since 6.3 + * + * @template TDatabaseObject of DatabaseObject + * @template TValueType of mixed + * @extends IObjectFilter + */ +interface IObjectListFilter extends IObjectFilter +{ + /** + * Adds the SQL condition that matches the objects satisfying the given + * value to the conditions of an object list. + * + * @param TValueType $value + */ + public function applyFilter(PreparedStatementConditionBuilder $conditions, mixed $value): void; +} diff --git a/wcfsetup/install/files/lib/system/object/filter/NotRequestedPageObjectFilter.class.php b/wcfsetup/install/files/lib/system/object/filter/NotRequestedPageObjectFilter.class.php new file mode 100644 index 00000000000..d24e0a39fb1 --- /dev/null +++ b/wcfsetup/install/files/lib/system/object/filter/NotRequestedPageObjectFilter.class.php @@ -0,0 +1,58 @@ + + * @since 6.3 + * + * @extends AbstractMultipleSelectionObjectFilter + */ +final class NotRequestedPageObjectFilter extends AbstractMultipleSelectionObjectFilter +{ + #[\Override] + public function getIdentifier(): string + { + return 'com.woltlab.wcf.notRequestedPage'; + } + + #[\Override] + protected function getLanguageItem(): string + { + return 'wcf.objectFilter.notRequestedPage'; + } + + #[\Override] + protected function getLabels(array $objectIDs): array + { + return RequestedPageObjectFilter::getPageTitles($objectIDs); + } + + #[\Override] + public function getFormField(): PagesFormField + { + return PagesFormField::create('notRequestedPage') + ->label('wcf.page.requestedPage') + ->required(); + } + + #[\Override] + public function testObject(DatabaseObject $object, mixed $configuredValue): bool + { + $pageID = RequestHandler::getInstance()->getActivePageID(); + if ($pageID === null) { + return false; + } + + return !\in_array($pageID, $configuredValue, true); + } +} diff --git a/wcfsetup/install/files/lib/system/object/filter/ObjectFilterHandler.class.php b/wcfsetup/install/files/lib/system/object/filter/ObjectFilterHandler.class.php new file mode 100644 index 00000000000..cfcecd02f3d --- /dev/null +++ b/wcfsetup/install/files/lib/system/object/filter/ObjectFilterHandler.class.php @@ -0,0 +1,107 @@ + + * @since 6.3 + * + * @template TDatabaseObject of DatabaseObject + */ +class ObjectFilterHandler +{ + /** + * @param list> $filters + */ + public function __construct( + private readonly array $filters, + ) {} + + /** + * Returns whether the given object satisfies all stored filter values. + * Values of unknown filters are ignored. + * + * Returns false if there are no active filters. + * + * @param TDatabaseObject $object + * @throws MappingError if the stored filter values are malformed + */ + public function testObject(DatabaseObject $object, ?string $json): bool + { + $values = $this->unserializeValues($json); + if ($values === []) { + return false; + } + + $filters = $this->getFiltersByIdentifier(); + + $hasActiveFilters = false; + foreach ($values as [$identifier, $serializedValue]) { + $filter = $filters[$identifier] ?? null; + if ($filter === null) { + continue; + } + + $hasActiveFilters = true; + if (!$filter->testObject($object, $filter->unserializeValue($serializedValue))) { + return false; + } + } + + // Returns true if there are active filters because any negative test + // would have returned early. + return $hasActiveFilters; + } + + /** + * Returns the available filters indexed by their identifier. + * + * @return array> + */ + protected function getFiltersByIdentifier(): array + { + $filters = []; + foreach ($this->filters as $filter) { + $filters[$filter->getIdentifier()] = $filter; + } + + return $filters; + } + + /** + * Decodes the stored filter values into a list of + * `[filterIdentifier, serializedValue]` pairs. + * + * @return list + * @throws MappingError if the stored filter values are malformed + */ + protected function unserializeValues(?string $json): array + { + if ($json === null) { + return []; + } + + /** @var list $values */ + $values = (new MapperBuilder())->mapper()->map( + <<<'EOT' + list + EOT, + Source::json($json) + ); + + return $values; + } +} diff --git a/wcfsetup/install/files/lib/system/object/filter/ObjectListFilterHandler.class.php b/wcfsetup/install/files/lib/system/object/filter/ObjectListFilterHandler.class.php new file mode 100644 index 00000000000..11bc4d04067 --- /dev/null +++ b/wcfsetup/install/files/lib/system/object/filter/ObjectListFilterHandler.class.php @@ -0,0 +1,72 @@ + + * @since 6.3 + * + * @template TDatabaseObject of DatabaseObject + * @extends ObjectFilterHandler + */ +final class ObjectListFilterHandler extends ObjectFilterHandler +{ + /** + * @param list> $filters + */ + public function __construct( + array $filters, + ) { + parent::__construct($filters); + } + + /** + * Adds the SQL conditions of all stored filter values to the given + * conditions of an object list. Values of unknown filters are ignored. + * + * Adds a condition that matches no objects if there are no active filters. + * + * @throws MappingError if the stored filter values are malformed + */ + public function applyFilters(PreparedStatementConditionBuilder $conditions, ?string $json): void + { + $values = $this->unserializeValues($json); + if ($values === []) { + $conditions->add('1=0'); + + return; + } + + $filters = $this->getFiltersByIdentifier(); + + $hasActiveFilters = false; + foreach ($values as [$identifier, $serializedValue]) { + $filter = $filters[$identifier] ?? null; + if ($filter === null) { + continue; + } + + \assert($filter instanceof IObjectListFilter); + + $filter->applyFilter( + $conditions, + $filter->unserializeValue($serializedValue), + ); + $hasActiveFilters = true; + } + + if (!$hasActiveFilters) { + $conditions->add('1=0'); + } + } +} diff --git a/wcfsetup/install/files/lib/system/object/filter/RequestedPageObjectFilter.class.php b/wcfsetup/install/files/lib/system/object/filter/RequestedPageObjectFilter.class.php new file mode 100644 index 00000000000..bf8b495005d --- /dev/null +++ b/wcfsetup/install/files/lib/system/object/filter/RequestedPageObjectFilter.class.php @@ -0,0 +1,79 @@ + + * @since 6.3 + * + * @extends AbstractMultipleSelectionObjectFilter + */ +final class RequestedPageObjectFilter extends AbstractMultipleSelectionObjectFilter +{ + #[\Override] + public function getIdentifier(): string + { + return 'com.woltlab.wcf.requestedPage'; + } + + #[\Override] + protected function getLanguageItem(): string + { + return 'wcf.objectFilter.requestedPage'; + } + + #[\Override] + protected function getLabels(array $objectIDs): array + { + return RequestedPageObjectFilter::getPageTitles($objectIDs); + } + + #[\Override] + public function getFormField(): PagesFormField + { + return PagesFormField::create('requestedPage') + ->label('wcf.page.requestedPage') + ->required(); + } + + #[\Override] + public function testObject(DatabaseObject $object, mixed $configuredValue): bool + { + $pageID = RequestHandler::getInstance()->getActivePageID(); + if ($pageID === null) { + return false; + } + + return \in_array($pageID, $configuredValue, true); + } + + /** + * Returns the titles of the pages with the given ids, unknown pages are skipped. + * + * @param list $pageIDs + * @return list + * @internal + */ + public static function getPageTitles(array $pageIDs): array + { + $titles = []; + foreach ($pageIDs as $pageID) { + $page = PageCache::getInstance()->getPage($pageID); + if ($page !== null) { + $titles[] = $page->getTitle(); + } + } + + return $titles; + } +} diff --git a/wcfsetup/install/files/lib/system/object/filter/StartDateObjectFilter.class.php b/wcfsetup/install/files/lib/system/object/filter/StartDateObjectFilter.class.php new file mode 100644 index 00000000000..8a7e0e8237f --- /dev/null +++ b/wcfsetup/install/files/lib/system/object/filter/StartDateObjectFilter.class.php @@ -0,0 +1,36 @@ + + * @since 6.3 + * + * @extends AbstractTimestampObjectFilter + */ +final class StartDateObjectFilter extends AbstractTimestampObjectFilter +{ + #[\Override] + public function getIdentifier(): string + { + return 'com.woltlab.wcf.startDate'; + } + + #[\Override] + protected function getLanguageItem(): string + { + return 'wcf.objectFilter.startDate'; + } + + #[\Override] + public function testObject(DatabaseObject $object, mixed $configuredValue): bool + { + return \TIME_NOW >= $configuredValue; + } +} diff --git a/wcfsetup/install/files/lib/system/object/filter/builder/AbstractObjectFilterBuilder.class.php b/wcfsetup/install/files/lib/system/object/filter/builder/AbstractObjectFilterBuilder.class.php new file mode 100644 index 00000000000..f20e5b4289e --- /dev/null +++ b/wcfsetup/install/files/lib/system/object/filter/builder/AbstractObjectFilterBuilder.class.php @@ -0,0 +1,79 @@ + + * @since 6.3 + * + * @template TDatabaseObject of DatabaseObject + * @template TFilter of IObjectFilter + * @template THandler of ObjectFilterHandler + * @implements IObjectFilterBuilder + */ +abstract class AbstractObjectFilterBuilder implements IObjectFilterBuilder +{ + /** + * @var list + */ + private array $filters; + + /** + * @var THandler + */ + private ObjectFilterHandler $handler; + + /** + * Creates the filters that are available for this builder. This method is + * called at most once, the result is cached by `getFilters()`. + * + * @return list + */ + abstract protected function createFilters(): array; + + /** + * Creates the handler that evaluates the stored filter values using the + * given filters. This method is called at most once, the result is cached + * by `getHandler()`. + * + * @param list $filters + * @return THandler + */ + abstract protected function createHandler(array $filters): ObjectFilterHandler; + + /** + * @return list + */ + #[\Override] + public function getFilters(): array + { + if (!isset($this->filters)) { + $this->filters = $this->createFilters(); + } + + return $this->filters; + } + + /** + * Returns the handler that evaluates the stored filter values. + * + * @return THandler + */ + protected function getHandler(): ObjectFilterHandler + { + if (!isset($this->handler)) { + $this->handler = $this->createHandler($this->getFilters()); + } + + return $this->handler; + } +} diff --git a/wcfsetup/install/files/lib/system/object/filter/builder/AdObjectFilterBuilder.class.php b/wcfsetup/install/files/lib/system/object/filter/builder/AdObjectFilterBuilder.class.php new file mode 100644 index 00000000000..5440e48262d --- /dev/null +++ b/wcfsetup/install/files/lib/system/object/filter/builder/AdObjectFilterBuilder.class.php @@ -0,0 +1,117 @@ + + * @since 6.3 + * + * @extends AbstractObjectFilterBuilder, ObjectFilterHandler> + */ +final class AdObjectFilterBuilder extends AbstractObjectFilterBuilder +{ + #[\Override] + protected function createFilters(): array + { + $event = new AdObjectFilterCollecting(); + $event->register(new RequestedPageObjectFilter()); + $event->register(new NotRequestedPageObjectFilter()); + $event->register(new DaysOfWeekObjectFilter()); + $event->register(new StartDateObjectFilter()); + $event->register(new EndDateObjectFilter()); + $event->register(new UserUsernameObjectFilter()); + $event->register(new UserEmailObjectFilter()); + $event->register(new UserGroupObjectFilter(includeGuests: true)); + $event->register(new UserNotInGroupObjectFilter(includeGuests: true)); + $event->register(new UserLanguageObjectFilter()); + $event->register(new UserRegistrationDateObjectFilter()); + $event->register(new UserRegistrationDaysObjectFilter()); + $event->register(new UserAvatarObjectFilter()); + $event->register(new UserSignatureObjectFilter()); + $event->register(new UserCoverPhotoObjectFilter()); + $event->register(new UserBannedObjectFilter()); + $event->register(new UserActivatedObjectFilter()); + $event->register(new UserEmailConfirmedObjectFilter()); + $event->register(new UserMultifactorObjectFilter()); + $event->register(new UserBirthdayObjectFilter()); + $event->register(new UserMobileBrowserObjectFilter()); + $event->register(new UserIntegerPropertyObjectFilter('activityPoints', 'wcf.user.condition.activityPoints')); + $event->register(new UserIntegerPropertyObjectFilter('likesReceived', 'wcf.user.condition.likesReceived')); + $event->register(new UserIntegerPropertyObjectFilter('trophyPoints', 'wcf.user.condition.trophyPoints')); + $event->register(new UserTrophyObjectFilter()); + $event->register(new UserNoTrophyObjectFilter()); + + EventHandler::getInstance()->fire($event); + + return $event->getFilters(); + } + + #[\Override] + protected function createHandler(array $filters): ObjectFilterHandler + { + return new ObjectFilterHandler($filters); + } + + #[\Override] + public function getIdentifier(): string + { + return 'com.woltlab.wcf.ad'; + } + + #[\Override] + public function isAccessible(): bool + { + return WCF::getSession()->hasPermission('admin.ad.canManageAd'); + } + + /** + * Returns whether the given user matches the filters of the ad. + * + * An ad without any filters is visible to everyone. + */ + public function testUser(Ad $ad, User $user): bool + { + if ($ad->conditions === null) { + return true; + } + + return $this->getHandler()->testObject($user, $ad->conditions); + } +} diff --git a/wcfsetup/install/files/lib/system/object/filter/builder/IObjectFilterBuilder.class.php b/wcfsetup/install/files/lib/system/object/filter/builder/IObjectFilterBuilder.class.php new file mode 100644 index 00000000000..5647d89c18d --- /dev/null +++ b/wcfsetup/install/files/lib/system/object/filter/builder/IObjectFilterBuilder.class.php @@ -0,0 +1,40 @@ + + * @since 6.3 + * + * @template TDatabaseObject of DatabaseObject + */ +interface IObjectFilterBuilder +{ + /** + * Returns the filters that are available for this builder. + * + * @return list> + */ + public function getFilters(): array; + + /** + * Returns the unique identifier of this builder, e.g. `com.woltlab.wcf.userGroupAssignment`. + */ + public function getIdentifier(): string; + + /** + * Returns true if this builder is accessible for the active user. + */ + public function isAccessible(): bool; +} diff --git a/wcfsetup/install/files/lib/system/object/filter/builder/NoticeObjectFilterBuilder.class.php b/wcfsetup/install/files/lib/system/object/filter/builder/NoticeObjectFilterBuilder.class.php new file mode 100644 index 00000000000..2f807f3c441 --- /dev/null +++ b/wcfsetup/install/files/lib/system/object/filter/builder/NoticeObjectFilterBuilder.class.php @@ -0,0 +1,117 @@ + + * @since 6.3 + * + * @extends AbstractObjectFilterBuilder, ObjectFilterHandler> + */ +final class NoticeObjectFilterBuilder extends AbstractObjectFilterBuilder +{ + #[\Override] + protected function createFilters(): array + { + $event = new NoticeObjectFilterCollecting(); + $event->register(new RequestedPageObjectFilter()); + $event->register(new NotRequestedPageObjectFilter()); + $event->register(new DaysOfWeekObjectFilter()); + $event->register(new StartDateObjectFilter()); + $event->register(new EndDateObjectFilter()); + $event->register(new UserUsernameObjectFilter()); + $event->register(new UserEmailObjectFilter()); + $event->register(new UserGroupObjectFilter(includeGuests: true)); + $event->register(new UserNotInGroupObjectFilter(includeGuests: true)); + $event->register(new UserLanguageObjectFilter()); + $event->register(new UserRegistrationDateObjectFilter()); + $event->register(new UserRegistrationDaysObjectFilter()); + $event->register(new UserAvatarObjectFilter()); + $event->register(new UserSignatureObjectFilter()); + $event->register(new UserCoverPhotoObjectFilter()); + $event->register(new UserBannedObjectFilter()); + $event->register(new UserActivatedObjectFilter()); + $event->register(new UserEmailConfirmedObjectFilter()); + $event->register(new UserMultifactorObjectFilter()); + $event->register(new UserBirthdayObjectFilter()); + $event->register(new UserMobileBrowserObjectFilter()); + $event->register(new UserIntegerPropertyObjectFilter('activityPoints', 'wcf.user.condition.activityPoints')); + $event->register(new UserIntegerPropertyObjectFilter('likesReceived', 'wcf.user.condition.likesReceived')); + $event->register(new UserIntegerPropertyObjectFilter('trophyPoints', 'wcf.user.condition.trophyPoints')); + $event->register(new UserTrophyObjectFilter()); + $event->register(new UserNoTrophyObjectFilter()); + + EventHandler::getInstance()->fire($event); + + return $event->getFilters(); + } + + #[\Override] + protected function createHandler(array $filters): ObjectFilterHandler + { + return new ObjectFilterHandler($filters); + } + + #[\Override] + public function getIdentifier(): string + { + return 'com.woltlab.wcf.notice'; + } + + #[\Override] + public function isAccessible(): bool + { + return WCF::getSession()->hasPermission('admin.notice.canManageNotice'); + } + + /** + * Returns whether the given user matches the filters of the notice. + * + * A notice without any filters is visible to everyone. + */ + public function testUser(Notice $notice, User $user): bool + { + if ($notice->conditions === null) { + return true; + } + + return $this->getHandler()->testObject($user, $notice->conditions); + } +} diff --git a/wcfsetup/install/files/lib/system/object/filter/builder/UserGroupAssignmentObjectFilterBuilder.class.php b/wcfsetup/install/files/lib/system/object/filter/builder/UserGroupAssignmentObjectFilterBuilder.class.php new file mode 100644 index 00000000000..c8a22c6ac8c --- /dev/null +++ b/wcfsetup/install/files/lib/system/object/filter/builder/UserGroupAssignmentObjectFilterBuilder.class.php @@ -0,0 +1,117 @@ + + * @since 6.3 + * + * @extends AbstractObjectFilterBuilder, ObjectListFilterHandler> + */ +final class UserGroupAssignmentObjectFilterBuilder extends AbstractObjectFilterBuilder +{ + #[\Override] + protected function createFilters(): array + { + $event = new UserGroupAssignmentObjectFilterCollecting(); + $event->register(new UserUsernameObjectFilter()); + $event->register(new UserEmailObjectFilter()); + $event->register(new UserGroupObjectFilter()); + $event->register(new UserNotInGroupObjectFilter()); + $event->register(new UserLanguageObjectFilter()); + $event->register(new UserRegistrationDateObjectFilter()); + $event->register(new UserRegistrationDaysObjectFilter()); + $event->register(new UserAvatarObjectFilter()); + $event->register(new UserSignatureObjectFilter()); + $event->register(new UserCoverPhotoObjectFilter()); + $event->register(new UserBannedObjectFilter()); + $event->register(new UserActivatedObjectFilter()); + $event->register(new UserEmailConfirmedObjectFilter()); + $event->register(new UserIntegerPropertyObjectFilter('activityPoints', 'wcf.user.condition.activityPoints')); + $event->register(new UserIntegerPropertyObjectFilter('likesReceived', 'wcf.user.condition.likesReceived')); + $event->register(new UserIntegerPropertyObjectFilter('trophyPoints', 'wcf.user.condition.trophyPoints')); + $event->register(new UserTrophyObjectFilter()); + $event->register(new UserNoTrophyObjectFilter()); + + EventHandler::getInstance()->fire($event); + + return $event->getFilters(); + } + + #[\Override] + protected function createHandler(array $filters): ObjectFilterHandler + { + return new ObjectListFilterHandler($filters); + } + + #[\Override] + public function getIdentifier(): string + { + return 'com.woltlab.wcf.userGroupAssignment'; + } + + #[\Override] + public function isAccessible(): bool + { + return WCF::getSession()->hasPermission('admin.user.canManageGroupAssignment'); + } + + /** + * Adds the filters of the assignment to the given conditions of a user list. + * + * An assignment without any active filters matches no users. + */ + public function applyFilters(UserGroupAssignment $assignment, PreparedStatementConditionBuilder $conditions): void + { + $this->getHandler()->applyFilters( + $conditions, + $assignment->conditions, + ); + } + + /** + * Returns whether the given user matches the filters of the assignment. + * + * An assignment without any active filters matches no users. + */ + public function testUser(UserGroupAssignment $assignment, User $user): bool + { + return $this->getHandler()->testObject( + $user, + $assignment->conditions, + ); + } +} diff --git a/wcfsetup/install/files/lib/system/object/filter/migration/LegacyConditionMigration.class.php b/wcfsetup/install/files/lib/system/object/filter/migration/LegacyConditionMigration.class.php new file mode 100644 index 00000000000..eb3e3d55e1a --- /dev/null +++ b/wcfsetup/install/files/lib/system/object/filter/migration/LegacyConditionMigration.class.php @@ -0,0 +1,316 @@ + + * @since 6.3 + * @deprecated 6.3 Only intended for the update to 6.3, will be removed with the next major version. + */ +final class LegacyConditionMigration +{ + /** + * @param string $definitionName name of the object type definition of the conditions, e.g. `com.woltlab.wcf.condition.notice` + * @param string $tableName name of the table of the objects, must have the columns `conditions` and `isDisabled` + * @param string $idColumn name of the id column of the table + */ + public function __construct( + private readonly string $definitionName, + private readonly string $tableName, + private readonly string $idColumn, + ) {} + + /** + * Migrates the conditions using the given converters, indexed by the class + * name of the condition object type. + * + * A converter receives the unserialized condition data and the object type + * and returns the list of `[filterIdentifier, serializedValue]` pairs that + * replace the condition, or `null` if the condition cannot be converted. + * + * @param array, ObjectType): ?list> $converters + */ + public function migrate(array $converters): void + { + $objectTypes = $this->getObjectTypes(); + if ($objectTypes === []) { + return; + } + + $disabledObjectIDs = $this->getDisabledObjectIDs(); + + foreach ($this->getConditionsByObject(\array_keys($objectTypes)) as $objectID => $object) { + ['isDisabled' => $isDisabled, 'conditions' => $conditions] = $object; + + $filters = []; + $convertedConditionIDs = []; + $hasUnconvertedConditions = false; + + foreach ($conditions as $condition) { + $objectType = $objectTypes[$condition['objectTypeID']]; + $converter = $converters[$objectType->className] ?? null; + + $result = null; + $conditionData = @\unserialize($condition['conditionData']); + if ($converter !== null && \is_array($conditionData)) { + try { + $result = $converter($conditionData, $objectType); + } catch (\Throwable) { + // Malformed legacy data must not break the update, + // the condition is treated as not convertible. + $result = null; + } + } + + if ($result === null) { + $hasUnconvertedConditions = true; + } else { + \array_push($filters, ...$result); + $convertedConditionIDs[] = $condition['conditionID']; + } + } + + $newIsDisabled = null; + if ($hasUnconvertedConditions) { + if (!$isDisabled) { + $newIsDisabled = true; + } + } elseif (\in_array($objectID, $disabledObjectIDs, true)) { + // All remaining conditions have been converted by this run. + $newIsDisabled = false; + } + + $this->updateObject($objectID, $filters, $convertedConditionIDs, $newIsDisabled); + + // Remembered after each object to keep the list in sync with the + // committed changes if a later object fails. + if ($newIsDisabled === true) { + $disabledObjectIDs[] = $objectID; + $this->setDisabledObjectIDs($disabledObjectIDs); + } elseif ($newIsDisabled === false) { + $disabledObjectIDs = \array_values(\array_diff($disabledObjectIDs, [$objectID])); + $this->setDisabledObjectIDs($disabledObjectIDs); + } + } + } + + /** + * Returns a converter for conditions of `UserIntegerPropertyCondition` + * that handles the given properties. Conditions of other properties are + * left for the packages that provide them. + * + * The matching filter is `UserIntegerPropertyObjectFilter`, its bounds are + * inclusive while the legacy bounds are exclusive. + * + * @param list $propertyNames + * @return \Closure(array, ObjectType): ?list + */ + public static function getUserIntegerPropertyConverter(array $propertyNames): \Closure + { + return static function (array $data, ObjectType $objectType) use ($propertyNames): ?array { + $propertyName = $objectType->propertyname; + if (!\in_array($propertyName, $propertyNames, true)) { + return null; + } + + $from = isset($data['greaterThan']) ? (string)((int)$data['greaterThan'] + 1) : ''; + $to = isset($data['lessThan']) ? (string)((int)$data['lessThan'] - 1) : ''; + if ($from === '' && $to === '') { + return null; + } + + return [['com.woltlab.wcf.user' . \ucfirst($propertyName), $from . ';' . $to]]; + }; + } + + /** + * Returns the ids of the objects that have been disabled by the migration. + * + * @return list + */ + private function getDisabledObjectIDs(): array + { + $value = RegistryHandler::getInstance()->get('com.woltlab.wcf', $this->getRegistryField()); + if ($value === null) { + return []; + } + + $objectIDs = \json_decode($value, true); + if (!\is_array($objectIDs)) { + return []; + } + + return \array_values(\array_map(static fn($objectID) => (int)$objectID, $objectIDs)); + } + + /** + * Stores the ids of the objects that have been disabled by the migration. + * + * @param list $objectIDs + */ + private function setDisabledObjectIDs(array $objectIDs): void + { + RegistryHandler::getInstance()->set( + 'com.woltlab.wcf', + $this->getRegistryField(), + \json_encode(\array_values(\array_unique($objectIDs)), \JSON_THROW_ON_ERROR) + ); + } + + private function getRegistryField(): string + { + return 'legacyConditionMigration.' . $this->definitionName; + } + + /** + * Returns the condition object types of the definition, indexed by their id. + * + * @return array + */ + private function getObjectTypes(): array + { + $sql = "SELECT object_type.* + FROM wcf1_object_type object_type + INNER JOIN wcf1_object_type_definition definition + ON definition.definitionID = object_type.definitionID + WHERE definition.definitionName = ?"; + $statement = WCF::getDB()->prepare($sql); + $statement->execute([$this->definitionName]); + + $objectTypes = []; + while ($row = $statement->fetchArray()) { + $objectTypes[$row['objectTypeID']] = new ObjectType(null, $row); + } + + return $objectTypes; + } + + /** + * Returns the conditions of existing objects and whether the objects are + * disabled, grouped by the id of the object. + * + * @param list $objectTypeIDs + * @return array, + * }> + */ + private function getConditionsByObject(array $objectTypeIDs): array + { + $conditionBuilder = new PreparedStatementConditionBuilder(); + $conditionBuilder->add('condition_table.objectTypeID IN (?)', [$objectTypeIDs]); + + // Conditions of deleted objects are skipped. + $sql = "SELECT condition_table.conditionID, condition_table.objectTypeID, + condition_table.objectID, condition_table.conditionData, + object_table.isDisabled + FROM wcf1_condition condition_table + INNER JOIN {$this->tableName} object_table + ON object_table.{$this->idColumn} = condition_table.objectID + {$conditionBuilder} + ORDER BY condition_table.objectID, condition_table.conditionID"; + $statement = WCF::getDB()->prepare($sql); + $statement->execute($conditionBuilder->getParameters()); + + $objects = []; + while ($row = $statement->fetchArray()) { + $objects[$row['objectID']] ??= [ + 'isDisabled' => (bool)$row['isDisabled'], + 'conditions' => [], + ]; + $objects[$row['objectID']]['conditions'][] = [ + 'conditionID' => $row['conditionID'], + 'objectTypeID' => $row['objectTypeID'], + 'conditionData' => $row['conditionData'], + ]; + } + + return $objects; + } + + /** + * Appends the given filters to the object, deletes the converted legacy + * conditions and updates the disabled state unless it is `null`. + * + * @param list $filters + * @param list $convertedConditionIDs + */ + private function updateObject( + int $objectID, + array $filters, + array $convertedConditionIDs, + ?bool $isDisabled + ): void { + WCF::getDB()->beginTransaction(); + try { + if ($filters !== []) { + $sql = "SELECT conditions + FROM {$this->tableName} + WHERE {$this->idColumn} = ? + FOR UPDATE"; + $statement = WCF::getDB()->prepare($sql); + $statement->execute([$objectID]); + $existingFilters = \json_decode((string)$statement->fetchSingleColumn(), true); + + // Keeps filters that were migrated by an earlier run, e.g. by another package. + if (\is_array($existingFilters)) { + $filters = [...$existingFilters, ...$filters]; + } + + $sql = "UPDATE {$this->tableName} + SET conditions = ? + WHERE {$this->idColumn} = ?"; + $statement = WCF::getDB()->prepare($sql); + $statement->execute([ + \json_encode($filters, \JSON_THROW_ON_ERROR), + $objectID, + ]); + } + + if ($convertedConditionIDs !== []) { + $conditionBuilder = new PreparedStatementConditionBuilder(); + $conditionBuilder->add('conditionID IN (?)', [$convertedConditionIDs]); + + $sql = "DELETE FROM wcf1_condition + {$conditionBuilder}"; + $statement = WCF::getDB()->prepare($sql); + $statement->execute($conditionBuilder->getParameters()); + } + + if ($isDisabled !== null) { + $sql = "UPDATE {$this->tableName} + SET isDisabled = ? + WHERE {$this->idColumn} = ?"; + $statement = WCF::getDB()->prepare($sql); + $statement->execute([(int)$isDisabled, $objectID]); + } + + WCF::getDB()->commitTransaction(); + } catch (\Throwable $e) { + WCF::getDB()->rollBackTransaction(); + + throw $e; + } + } +} diff --git a/wcfsetup/install/files/lib/system/object/filter/user/DaysOfWeekObjectFilter.class.php b/wcfsetup/install/files/lib/system/object/filter/user/DaysOfWeekObjectFilter.class.php new file mode 100644 index 00000000000..86935c9d6ec --- /dev/null +++ b/wcfsetup/install/files/lib/system/object/filter/user/DaysOfWeekObjectFilter.class.php @@ -0,0 +1,77 @@ + + * @since 6.3 + * + * @extends AbstractMultipleSelectionObjectFilter + */ +final class DaysOfWeekObjectFilter extends AbstractMultipleSelectionObjectFilter +{ + #[\Override] + public function getIdentifier(): string + { + return 'com.woltlab.wcf.daysOfWeek'; + } + + #[\Override] + protected function getLanguageItem(): string + { + return 'wcf.objectFilter.daysOfWeek'; + } + + #[\Override] + protected function getLabels(array $objectIDs): array + { + return \array_values(\array_map( + static fn(string $day) => WCF::getLanguage()->get($day), + \array_intersect_key($this->getDays(), \array_flip($objectIDs)), + )); + } + + #[\Override] + public function getFormField(): MultipleSelectionFormField + { + return MultipleSelectionFormField::create('daysOfWeek') + ->label('wcf.date.daysOfWeek') + ->options($this->getDays()) + ->required(); + } + + #[\Override] + public function testObject(DatabaseObject $object, mixed $configuredValue): bool + { + $now = new \DateTimeImmutable('now', $object->getTimeZone()); + + return \in_array((int)$now->format('w'), $configuredValue, true); + } + + /** + * Returns the language items of the days of the week, indexed by their + * number and sorted by the first day of the week. + * + * @return array + */ + private function getDays(): array + { + return \array_map( + static fn(string $day) => 'wcf.date.day.' . $day, + DateUtil::getWeekDays(), + ); + } +} diff --git a/wcfsetup/install/files/lib/system/object/filter/user/UserActivatedObjectFilter.class.php b/wcfsetup/install/files/lib/system/object/filter/user/UserActivatedObjectFilter.class.php new file mode 100644 index 00000000000..8b3f374a2f8 --- /dev/null +++ b/wcfsetup/install/files/lib/system/object/filter/user/UserActivatedObjectFilter.class.php @@ -0,0 +1,55 @@ + + * @since 6.3 + * + * @extends AbstractBooleanObjectFilter + * @implements IObjectListFilter + */ +final class UserActivatedObjectFilter extends AbstractBooleanObjectFilter implements IObjectListFilter +{ + #[\Override] + public function getIdentifier(): string + { + return 'com.woltlab.wcf.userActivated'; + } + + #[\Override] + protected function getLanguageItem(): string + { + return 'wcf.objectFilter.user.activated'; + } + + #[\Override] + public function applyFilter(PreparedStatementConditionBuilder $conditions, mixed $value): void + { + if ($value) { + $conditions->add('activationCode = 0'); + } else { + $conditions->add('activationCode <> 0'); + } + } + + #[\Override] + public function testObject(DatabaseObject $object, mixed $configuredValue): bool + { + if ($object->isGuest()) { + return false; + } + + return $configuredValue === !$object->pendingActivation(); + } +} diff --git a/wcfsetup/install/files/lib/system/object/filter/user/UserAvatarObjectFilter.class.php b/wcfsetup/install/files/lib/system/object/filter/user/UserAvatarObjectFilter.class.php new file mode 100644 index 00000000000..d381259b25b --- /dev/null +++ b/wcfsetup/install/files/lib/system/object/filter/user/UserAvatarObjectFilter.class.php @@ -0,0 +1,58 @@ + + * @since 6.3 + * + * @extends AbstractBooleanObjectFilter + * @implements IObjectListFilter + */ +final class UserAvatarObjectFilter extends AbstractBooleanObjectFilter implements IObjectListFilter +{ + #[\Override] + public function getIdentifier(): string + { + return 'com.woltlab.wcf.userAvatar'; + } + + #[\Override] + protected function getLanguageItem(): string + { + return 'wcf.objectFilter.user.avatar'; + } + + #[\Override] + public function applyFilter(PreparedStatementConditionBuilder $conditions, mixed $value): void + { + if ($value) { + $conditions->add("avatarFileID IS NOT NULL"); + } else { + $conditions->add("avatarFileID IS NULL"); + } + } + + #[\Override] + public function testObject(DatabaseObject $object, mixed $configuredValue): bool + { + if ($object->isGuest()) { + return false; + } + + return match ($configuredValue) { + true => $object->avatarFileID !== null, + false => $object->avatarFileID === null, + }; + } +} diff --git a/wcfsetup/install/files/lib/system/object/filter/user/UserBannedObjectFilter.class.php b/wcfsetup/install/files/lib/system/object/filter/user/UserBannedObjectFilter.class.php new file mode 100644 index 00000000000..2845beb63b3 --- /dev/null +++ b/wcfsetup/install/files/lib/system/object/filter/user/UserBannedObjectFilter.class.php @@ -0,0 +1,55 @@ + + * @since 6.3 + * + * @extends AbstractBooleanObjectFilter + * @implements IObjectListFilter + */ +final class UserBannedObjectFilter extends AbstractBooleanObjectFilter implements IObjectListFilter +{ + #[\Override] + public function getIdentifier(): string + { + return 'com.woltlab.wcf.userBanned'; + } + + #[\Override] + protected function getLanguageItem(): string + { + return 'wcf.objectFilter.user.banned'; + } + + #[\Override] + public function applyFilter(PreparedStatementConditionBuilder $conditions, mixed $value): void + { + if ($value) { + $conditions->add('banned = 1'); + } else { + $conditions->add('banned = 0'); + } + } + + #[\Override] + public function testObject(DatabaseObject $object, mixed $configuredValue): bool + { + if ($object->isGuest()) { + return false; + } + + return $configuredValue === ($object->banned !== 0); + } +} diff --git a/wcfsetup/install/files/lib/system/object/filter/user/UserBirthdayObjectFilter.class.php b/wcfsetup/install/files/lib/system/object/filter/user/UserBirthdayObjectFilter.class.php new file mode 100644 index 00000000000..75975468b15 --- /dev/null +++ b/wcfsetup/install/files/lib/system/object/filter/user/UserBirthdayObjectFilter.class.php @@ -0,0 +1,50 @@ + + * @since 6.3 + * + * @extends AbstractBooleanObjectFilter + */ +final class UserBirthdayObjectFilter extends AbstractBooleanObjectFilter +{ + #[\Override] + public function getIdentifier(): string + { + return 'com.woltlab.wcf.userBirthday'; + } + + #[\Override] + protected function getLanguageItem(): string + { + return 'wcf.objectFilter.user.birthday'; + } + + #[\Override] + public function testObject(DatabaseObject $object, mixed $configuredValue): bool + { + if ($object->isGuest()) { + return false; + } + + $today = new \DateTimeImmutable('now', $object->getTimeZone()); + $userIDs = UserBirthdayCache::getInstance()->getBirthdays( + (int)$today->format('n'), + (int)$today->format('j'), + ); + + return $configuredValue === \in_array($object->userID, $userIDs, true); + } +} diff --git a/wcfsetup/install/files/lib/system/object/filter/user/UserCoverPhotoObjectFilter.class.php b/wcfsetup/install/files/lib/system/object/filter/user/UserCoverPhotoObjectFilter.class.php new file mode 100644 index 00000000000..2618f36d48f --- /dev/null +++ b/wcfsetup/install/files/lib/system/object/filter/user/UserCoverPhotoObjectFilter.class.php @@ -0,0 +1,55 @@ + + * @since 6.3 + * + * @extends AbstractBooleanObjectFilter + * @implements IObjectListFilter + */ +final class UserCoverPhotoObjectFilter extends AbstractBooleanObjectFilter implements IObjectListFilter +{ + #[\Override] + public function getIdentifier(): string + { + return 'com.woltlab.wcf.userCoverPhoto'; + } + + #[\Override] + protected function getLanguageItem(): string + { + return 'wcf.objectFilter.user.coverPhoto'; + } + + #[\Override] + public function applyFilter(PreparedStatementConditionBuilder $conditions, mixed $value): void + { + if ($value) { + $conditions->add("coverPhotoFileID IS NOT NULL"); + } else { + $conditions->add("coverPhotoFileID IS NULL"); + } + } + + #[\Override] + public function testObject(DatabaseObject $object, mixed $configuredValue): bool + { + if ($object->isGuest()) { + return false; + } + + return $configuredValue === ($object->coverPhotoFileID !== null); + } +} diff --git a/wcfsetup/install/files/lib/system/object/filter/user/UserEmailConfirmedObjectFilter.class.php b/wcfsetup/install/files/lib/system/object/filter/user/UserEmailConfirmedObjectFilter.class.php new file mode 100644 index 00000000000..3f6ea29c493 --- /dev/null +++ b/wcfsetup/install/files/lib/system/object/filter/user/UserEmailConfirmedObjectFilter.class.php @@ -0,0 +1,55 @@ + + * @since 6.3 + * + * @extends AbstractBooleanObjectFilter + * @implements IObjectListFilter + */ +final class UserEmailConfirmedObjectFilter extends AbstractBooleanObjectFilter implements IObjectListFilter +{ + #[\Override] + public function getIdentifier(): string + { + return 'com.woltlab.wcf.userEmailConfirmed'; + } + + #[\Override] + protected function getLanguageItem(): string + { + return 'wcf.objectFilter.user.emailConfirmed'; + } + + #[\Override] + public function applyFilter(PreparedStatementConditionBuilder $conditions, mixed $value): void + { + if ($value) { + $conditions->add('emailConfirmed IS NULL'); + } else { + $conditions->add('emailConfirmed IS NOT NULL'); + } + } + + #[\Override] + public function testObject(DatabaseObject $object, mixed $configuredValue): bool + { + if ($object->isGuest()) { + return false; + } + + return $configuredValue === $object->isEmailConfirmed(); + } +} diff --git a/wcfsetup/install/files/lib/system/object/filter/user/UserEmailObjectFilter.class.php b/wcfsetup/install/files/lib/system/object/filter/user/UserEmailObjectFilter.class.php new file mode 100644 index 00000000000..d55b2b709d7 --- /dev/null +++ b/wcfsetup/install/files/lib/system/object/filter/user/UserEmailObjectFilter.class.php @@ -0,0 +1,100 @@ + + * @since 6.3 + * + * @implements IObjectListFilter + */ +final class UserEmailObjectFilter implements IObjectListFilter +{ + #[\Override] + public function getIdentifier(): string + { + return 'com.woltlab.wcf.userEmail'; + } + + #[\Override] + public function getTitle(): string + { + return WCF::getLanguage()->get('wcf.objectFilter.user.email'); + } + + #[\Override] + public function getFormField(): TextFormField + { + return TextFormField::create('userEmail') + ->label('wcf.user.email') + ->maximumLength(255) + ->required(); + } + + #[\Override] + public function isAvailable(): bool + { + return true; + } + + #[\Override] + public function isRepeatable(): bool + { + return true; + } + + #[\Override] + public function serializeValue(mixed $value): string + { + return $value; + } + + #[\Override] + public function unserializeValue(string $serializedValue): string + { + return $serializedValue; + } + + #[\Override] + public function toFormFieldValue(mixed $value): string + { + return $value; + } + + #[\Override] + public function summarizeValue(mixed $value): string + { + return WCF::getLanguage()->getDynamicVariable('wcf.objectFilter.user.email.summary', [ + 'value' => $value, + ]); + } + + #[\Override] + public function applyFilter(PreparedStatementConditionBuilder $conditions, mixed $value): void + { + $conditions->add('email LIKE ?', ['%' . WCF::getDB()->escapeLikeValue($value) . '%']); + } + + #[\Override] + public function testObject(DatabaseObject $object, mixed $configuredValue): bool + { + if ($object->isGuest()) { + return false; + } + + // Must match the case-insensitive `LIKE` in `applyFilter()`. + return \mb_stripos($object->email, $configuredValue) !== false; + } +} diff --git a/wcfsetup/install/files/lib/system/object/filter/user/UserGroupObjectFilter.class.php b/wcfsetup/install/files/lib/system/object/filter/user/UserGroupObjectFilter.class.php new file mode 100644 index 00000000000..e7e6f623d6b --- /dev/null +++ b/wcfsetup/install/files/lib/system/object/filter/user/UserGroupObjectFilter.class.php @@ -0,0 +1,90 @@ + + * @since 6.3 + * + * @extends AbstractSelectionObjectFilter + * @implements IObjectListFilter + */ +final class UserGroupObjectFilter extends AbstractSelectionObjectFilter implements IObjectListFilter +{ + /** + * @param bool $includeGuests offers the guest group for selection, only useful if guests are tested + */ + public function __construct( + private readonly bool $includeGuests = false, + ) {} + + #[\Override] + public function getIdentifier(): string + { + return 'com.woltlab.wcf.userGroup'; + } + + #[\Override] + protected function getLanguageItem(): string + { + return 'wcf.objectFilter.user.group'; + } + + #[\Override] + protected function createOptions(): array + { + return UserGroupObjectFilter::getSelectableGroups($this->includeGuests); + } + + #[\Override] + public function applyFilter(PreparedStatementConditionBuilder $conditions, mixed $value): void + { + $conditions->add( + 'user_table.userID IN ( + SELECT userID + FROM wcf1_user_to_group + WHERE groupID = ? + )', + [$value] + ); + } + + #[\Override] + public function testObject(DatabaseObject $object, mixed $configuredValue): bool + { + return \in_array($configuredValue, $object->getGroupIDs(), true); + } + + /** + * Returns the names of the user groups that can be selected, indexed by their id. + * + * @return array + * @internal + */ + public static function getSelectableGroups(bool $includeGuests): array + { + $invalidGroupTypes = [ + UserGroup::EVERYONE, + UserGroup::USERS, + ]; + if (!$includeGuests) { + $invalidGroupTypes[] = UserGroup::GUESTS; + } + + return \array_map( + static fn(UserGroup $group) => $group->getName(), + UserGroup::getSortedAccessibleGroups([], $invalidGroupTypes), + ); + } +} diff --git a/wcfsetup/install/files/lib/system/object/filter/user/UserIntegerPropertyObjectFilter.class.php b/wcfsetup/install/files/lib/system/object/filter/user/UserIntegerPropertyObjectFilter.class.php new file mode 100644 index 00000000000..e3518808221 --- /dev/null +++ b/wcfsetup/install/files/lib/system/object/filter/user/UserIntegerPropertyObjectFilter.class.php @@ -0,0 +1,116 @@ + + * @since 6.3 + * + * @extends AbstractRangeObjectFilter + * @implements IObjectListFilter + */ +final class UserIntegerPropertyObjectFilter extends AbstractRangeObjectFilter implements IObjectListFilter +{ + /** + * @param string $propertyName name of the integer column in `wcf1_user` + * @param string $languageItem language item of the title of the property + * @param array $languageItemVariables variables of the language item of the title + */ + public function __construct( + private readonly string $propertyName, + private readonly string $languageItem, + private readonly array $languageItemVariables = [], + ) {} + + #[\Override] + public function getIdentifier(): string + { + return 'com.woltlab.wcf.user' . \ucfirst($this->propertyName); + } + + #[\Override] + protected function getLanguageItem(): string + { + return $this->languageItem; + } + + #[\Override] + public function getTitle(): string + { + return WCF::getLanguage()->getDynamicVariable($this->languageItem, $this->languageItemVariables); + } + + #[\Override] + protected function createFormField(): NumericRangeFormField + { + return NumericRangeFormField::create('user' . \ucfirst($this->propertyName)) + ->label($this->languageItem, $this->languageItemVariables) + ->integerValues() + ->minimum(0); + } + + #[\Override] + protected function unserializeBound(string $bound): int + { + return (int)$bound; + } + + #[\Override] + public function summarizeValue(mixed $value): string + { + [$from, $to] = $value; + + return WCF::getLanguage()->getDynamicVariable('wcf.objectFilter.user.integerProperty.summary', [ + 'title' => $this->getTitle(), + 'from' => $from, + 'to' => $to, + ]); + } + + #[\Override] + public function applyFilter(PreparedStatementConditionBuilder $conditions, mixed $value): void + { + [$from, $to] = $value; + + if ($from !== null) { + $conditions->add($this->propertyName . ' >= ?', [$from]); + } + if ($to !== null) { + $conditions->add($this->propertyName . ' <= ?', [$to]); + } + } + + #[\Override] + public function testObject(DatabaseObject $object, mixed $configuredValue): bool + { + if ($object->isGuest()) { + return false; + } + + [$from, $to] = $configuredValue; + $value = $object->{$this->propertyName}; + + if ($from !== null && $value < $from) { + return false; + } + if ($to !== null && $value > $to) { + return false; + } + + return true; + } +} diff --git a/wcfsetup/install/files/lib/system/object/filter/user/UserLanguageObjectFilter.class.php b/wcfsetup/install/files/lib/system/object/filter/user/UserLanguageObjectFilter.class.php new file mode 100644 index 00000000000..e8d4d652322 --- /dev/null +++ b/wcfsetup/install/files/lib/system/object/filter/user/UserLanguageObjectFilter.class.php @@ -0,0 +1,80 @@ + + * @since 6.3 + * + * @extends AbstractMultipleSelectionObjectFilter + * @implements IObjectListFilter> + */ +final class UserLanguageObjectFilter extends AbstractMultipleSelectionObjectFilter implements IObjectListFilter +{ + #[\Override] + public function getIdentifier(): string + { + return 'com.woltlab.wcf.userLanguage'; + } + + #[\Override] + protected function getLanguageItem(): string + { + return 'wcf.objectFilter.user.language'; + } + + #[\Override] + protected function getLabels(array $objectIDs): array + { + $labels = []; + foreach ($objectIDs as $languageID) { + $language = LanguageFactory::getInstance()->getLanguage($languageID); + if ($language !== null) { + $labels[] = $language->__toString(); + } + } + + return $labels; + } + + #[\Override] + public function getFormField(): MultipleSelectionFormField + { + return MultipleSelectionFormField::create('userLanguage') + ->label('wcf.user.condition.languages') + ->options(LanguageFactory::getInstance()->getLanguages()) + ->required(); + } + + #[\Override] + public function applyFilter(PreparedStatementConditionBuilder $conditions, mixed $value): void + { + $conditions->add('languageID IN (?)', [$value]); + } + + #[\Override] + public function testObject(DatabaseObject $object, mixed $configuredValue): bool + { + if ($object->isGuest()) { + return \in_array(WCF::getLanguage()->languageID, $configuredValue, true); + } + + return \in_array($object->languageID, $configuredValue, true); + } +} diff --git a/wcfsetup/install/files/lib/system/object/filter/user/UserMobileBrowserObjectFilter.class.php b/wcfsetup/install/files/lib/system/object/filter/user/UserMobileBrowserObjectFilter.class.php new file mode 100644 index 00000000000..2e193d1eead --- /dev/null +++ b/wcfsetup/install/files/lib/system/object/filter/user/UserMobileBrowserObjectFilter.class.php @@ -0,0 +1,42 @@ + + * @since 6.3 + * + * @extends AbstractBooleanObjectFilter + */ +final class UserMobileBrowserObjectFilter extends AbstractBooleanObjectFilter +{ + #[\Override] + public function getIdentifier(): string + { + return 'com.woltlab.wcf.userMobileBrowser'; + } + + #[\Override] + protected function getLanguageItem(): string + { + return 'wcf.objectFilter.user.mobileBrowser'; + } + + #[\Override] + public function testObject(DatabaseObject $object, mixed $configuredValue): bool + { + return $configuredValue === UserUtil::usesMobileBrowser(); + } +} diff --git a/wcfsetup/install/files/lib/system/object/filter/user/UserMultifactorObjectFilter.class.php b/wcfsetup/install/files/lib/system/object/filter/user/UserMultifactorObjectFilter.class.php new file mode 100644 index 00000000000..c8849510fe0 --- /dev/null +++ b/wcfsetup/install/files/lib/system/object/filter/user/UserMultifactorObjectFilter.class.php @@ -0,0 +1,55 @@ + + * @since 6.3 + * + * @extends AbstractBooleanObjectFilter + * @implements IObjectListFilter + */ +final class UserMultifactorObjectFilter extends AbstractBooleanObjectFilter implements IObjectListFilter +{ + #[\Override] + public function getIdentifier(): string + { + return 'com.woltlab.wcf.userMultifactor'; + } + + #[\Override] + protected function getLanguageItem(): string + { + return 'wcf.objectFilter.user.multifactor'; + } + + #[\Override] + public function applyFilter(PreparedStatementConditionBuilder $conditions, mixed $value): void + { + if ($value) { + $conditions->add('multifactorActive = 1'); + } else { + $conditions->add('multifactorActive = 0'); + } + } + + #[\Override] + public function testObject(DatabaseObject $object, mixed $configuredValue): bool + { + if ($object->isGuest()) { + return false; + } + + return $configuredValue === ($object->multifactorActive !== 0); + } +} diff --git a/wcfsetup/install/files/lib/system/object/filter/user/UserNoTrophyObjectFilter.class.php b/wcfsetup/install/files/lib/system/object/filter/user/UserNoTrophyObjectFilter.class.php new file mode 100644 index 00000000000..43572e1920d --- /dev/null +++ b/wcfsetup/install/files/lib/system/object/filter/user/UserNoTrophyObjectFilter.class.php @@ -0,0 +1,60 @@ + + * @since 6.3 + * + * @extends AbstractSelectionObjectFilter + * @implements IObjectListFilter + */ +final class UserNoTrophyObjectFilter extends AbstractSelectionObjectFilter implements IObjectListFilter +{ + #[\Override] + public function getIdentifier(): string + { + return 'com.woltlab.wcf.userNoTrophy'; + } + + #[\Override] + protected function getLanguageItem(): string + { + return 'wcf.objectFilter.user.noTrophy'; + } + + #[\Override] + protected function createOptions(): array + { + return UserTrophyObjectFilter::getSelectableTrophies(); + } + + #[\Override] + public function applyFilter(PreparedStatementConditionBuilder $conditions, mixed $value): void + { + $conditions->add( + 'user_table.userID NOT IN ( + SELECT userID + FROM wcf1_user_trophy + WHERE trophyID = ? + )', + [$value] + ); + } + + #[\Override] + public function testObject(DatabaseObject $object, mixed $configuredValue): bool + { + return !\in_array($configuredValue, UserTrophyObjectFilter::getTrophyIDs($object), true); + } +} diff --git a/wcfsetup/install/files/lib/system/object/filter/user/UserNotInGroupObjectFilter.class.php b/wcfsetup/install/files/lib/system/object/filter/user/UserNotInGroupObjectFilter.class.php new file mode 100644 index 00000000000..aa90489c801 --- /dev/null +++ b/wcfsetup/install/files/lib/system/object/filter/user/UserNotInGroupObjectFilter.class.php @@ -0,0 +1,67 @@ + + * @since 6.3 + * + * @extends AbstractSelectionObjectFilter + * @implements IObjectListFilter + */ +final class UserNotInGroupObjectFilter extends AbstractSelectionObjectFilter implements IObjectListFilter +{ + /** + * @param bool $includeGuests offers the guest group for selection, only useful if guests are tested + */ + public function __construct( + private readonly bool $includeGuests = false, + ) {} + + #[\Override] + public function getIdentifier(): string + { + return 'com.woltlab.wcf.userNotInGroup'; + } + + #[\Override] + protected function getLanguageItem(): string + { + return 'wcf.objectFilter.user.notInGroup'; + } + + #[\Override] + protected function createOptions(): array + { + return UserGroupObjectFilter::getSelectableGroups($this->includeGuests); + } + + #[\Override] + public function applyFilter(PreparedStatementConditionBuilder $conditions, mixed $value): void + { + $conditions->add( + 'user_table.userID NOT IN ( + SELECT userID + FROM wcf1_user_to_group + WHERE groupID = ? + )', + [$value] + ); + } + + #[\Override] + public function testObject(DatabaseObject $object, mixed $configuredValue): bool + { + return !\in_array($configuredValue, $object->getGroupIDs(), true); + } +} diff --git a/wcfsetup/install/files/lib/system/object/filter/user/UserRegistrationDateObjectFilter.class.php b/wcfsetup/install/files/lib/system/object/filter/user/UserRegistrationDateObjectFilter.class.php new file mode 100644 index 00000000000..44f9c73e2a0 --- /dev/null +++ b/wcfsetup/install/files/lib/system/object/filter/user/UserRegistrationDateObjectFilter.class.php @@ -0,0 +1,145 @@ + + * @since 6.3 + * + * @extends AbstractRangeObjectFilter + * @implements IObjectListFilter + */ +final class UserRegistrationDateObjectFilter extends AbstractRangeObjectFilter implements IObjectListFilter +{ + #[\Override] + public function getIdentifier(): string + { + return 'com.woltlab.wcf.userRegistrationDate'; + } + + #[\Override] + protected function getLanguageItem(): string + { + return 'wcf.objectFilter.user.registrationDate'; + } + + #[\Override] + protected function createFormField(): DateRangeFormField + { + return DateRangeFormField::create('userRegistrationDate') + ->label('wcf.user.condition.registrationDate'); + } + + #[\Override] + protected function unserializeBound(string $bound): string + { + return $bound; + } + + #[\Override] + public function summarizeValue(mixed $value): string + { + [$from, $to] = $value; + + $formatter = new \IntlDateFormatter( + WCF::getLanguage()->getLocale(), + \IntlDateFormatter::LONG, + \IntlDateFormatter::NONE, + 'UTC', + ); + $format = static function (?string $date) use ($formatter): ?string { + if ($date === null) { + return null; + } + + $dateTime = \DateTimeImmutable::createFromFormat('!Y-m-d', $date, new \DateTimeZone('UTC')); + if ($dateTime === false) { + return $date; + } + + return $formatter->format($dateTime); + }; + + return WCF::getLanguage()->getDynamicVariable('wcf.objectFilter.user.registrationDate.summary', [ + 'from' => $format($from), + 'to' => $format($to), + ]); + } + + #[\Override] + public function applyFilter(PreparedStatementConditionBuilder $conditions, mixed $value): void + { + [$start, $end] = $this->getBoundaries($value); + + if ($start !== null) { + $conditions->add('registrationDate >= ?', [$start]); + } + if ($end !== null) { + $conditions->add('registrationDate < ?', [$end]); + } + } + + #[\Override] + public function testObject(DatabaseObject $object, mixed $configuredValue): bool + { + if ($object->isGuest()) { + return false; + } + + [$start, $end] = $this->getBoundaries($configuredValue); + + if ($start !== null && $object->registrationDate < $start) { + return false; + } + if ($end !== null && $object->registrationDate >= $end) { + return false; + } + + return true; + } + + /** + * Returns the timestamp of the start of the first day and the timestamp + * of the start of the day after the last day. + * + * @param array{0: ?string, 1: ?string} $value + * @return array{0: ?int, 1: ?int} + */ + private function getBoundaries(array $value): array + { + [$from, $to] = $value; + $timezone = new \DateTimeZone(\TIMEZONE); + + $start = null; + if ($from !== null) { + $dateTime = \DateTimeImmutable::createFromFormat('!Y-m-d', $from, $timezone); + if ($dateTime !== false) { + $start = $dateTime->getTimestamp(); + } + } + + $end = null; + if ($to !== null) { + $dateTime = \DateTimeImmutable::createFromFormat('!Y-m-d', $to, $timezone); + if ($dateTime !== false) { + $end = $dateTime->modify('+1 day')->getTimestamp(); + } + } + + return [$start, $end]; + } +} diff --git a/wcfsetup/install/files/lib/system/object/filter/user/UserRegistrationDaysObjectFilter.class.php b/wcfsetup/install/files/lib/system/object/filter/user/UserRegistrationDaysObjectFilter.class.php new file mode 100644 index 00000000000..8305c0d639e --- /dev/null +++ b/wcfsetup/install/files/lib/system/object/filter/user/UserRegistrationDaysObjectFilter.class.php @@ -0,0 +1,85 @@ + + * @since 6.3 + * + * @extends AbstractRangeObjectFilter + * @implements IObjectListFilter + */ +final class UserRegistrationDaysObjectFilter extends AbstractRangeObjectFilter implements IObjectListFilter +{ + #[\Override] + public function getIdentifier(): string + { + return 'com.woltlab.wcf.userRegistrationDays'; + } + + #[\Override] + protected function getLanguageItem(): string + { + return 'wcf.objectFilter.user.registrationDays'; + } + + #[\Override] + protected function createFormField(): NumericRangeFormField + { + return NumericRangeFormField::create('userRegistrationDays') + ->label('wcf.user.condition.registrationDateInterval') + ->integerValues() + ->minimum(0); + } + + #[\Override] + protected function unserializeBound(string $bound): int + { + return (int)$bound; + } + + #[\Override] + public function applyFilter(PreparedStatementConditionBuilder $conditions, mixed $value): void + { + [$from, $to] = $value; + + if ($from !== null) { + $conditions->add('registrationDate <= ?', [\TIME_NOW - $from * 86400]); + } + if ($to !== null) { + $conditions->add('registrationDate >= ?', [\TIME_NOW - $to * 86400]); + } + } + + #[\Override] + public function testObject(DatabaseObject $object, mixed $configuredValue): bool + { + if ($object->isGuest()) { + return false; + } + + [$from, $to] = $configuredValue; + + if ($from !== null && $object->registrationDate > \TIME_NOW - $from * 86400) { + return false; + } + if ($to !== null && $object->registrationDate < \TIME_NOW - $to * 86400) { + return false; + } + + return true; + } +} diff --git a/wcfsetup/install/files/lib/system/object/filter/user/UserSignatureObjectFilter.class.php b/wcfsetup/install/files/lib/system/object/filter/user/UserSignatureObjectFilter.class.php new file mode 100644 index 00000000000..e70731f2e8a --- /dev/null +++ b/wcfsetup/install/files/lib/system/object/filter/user/UserSignatureObjectFilter.class.php @@ -0,0 +1,55 @@ + + * @since 6.3 + * + * @extends AbstractBooleanObjectFilter + * @implements IObjectListFilter + */ +final class UserSignatureObjectFilter extends AbstractBooleanObjectFilter implements IObjectListFilter +{ + #[\Override] + public function getIdentifier(): string + { + return 'com.woltlab.wcf.userSignature'; + } + + #[\Override] + protected function getLanguageItem(): string + { + return 'wcf.objectFilter.user.signature'; + } + + #[\Override] + public function applyFilter(PreparedStatementConditionBuilder $conditions, mixed $value): void + { + if ($value) { + $conditions->add("(signature <> '' AND signature IS NOT NULL)"); + } else { + $conditions->add("(signature = '' OR signature IS NULL)"); + } + } + + #[\Override] + public function testObject(DatabaseObject $object, mixed $configuredValue): bool + { + if ($object->isGuest()) { + return false; + } + + return $configuredValue === ($object->signature !== '' && $object->signature !== null); + } +} diff --git a/wcfsetup/install/files/lib/system/object/filter/user/UserTrophyObjectFilter.class.php b/wcfsetup/install/files/lib/system/object/filter/user/UserTrophyObjectFilter.class.php new file mode 100644 index 00000000000..51a1f9c4477 --- /dev/null +++ b/wcfsetup/install/files/lib/system/object/filter/user/UserTrophyObjectFilter.class.php @@ -0,0 +1,106 @@ + + * @since 6.3 + * + * @extends AbstractSelectionObjectFilter + * @implements IObjectListFilter + */ +final class UserTrophyObjectFilter extends AbstractSelectionObjectFilter implements IObjectListFilter +{ + #[\Override] + public function getIdentifier(): string + { + return 'com.woltlab.wcf.userTrophy'; + } + + #[\Override] + protected function getLanguageItem(): string + { + return 'wcf.objectFilter.user.trophy'; + } + + #[\Override] + protected function createOptions(): array + { + return UserTrophyObjectFilter::getSelectableTrophies(); + } + + #[\Override] + public function applyFilter(PreparedStatementConditionBuilder $conditions, mixed $value): void + { + $conditions->add( + 'user_table.userID IN ( + SELECT userID + FROM wcf1_user_trophy + WHERE trophyID = ? + )', + [$value] + ); + } + + #[\Override] + public function testObject(DatabaseObject $object, mixed $configuredValue): bool + { + return \in_array($configuredValue, UserTrophyObjectFilter::getTrophyIDs($object), true); + } + + /** + * Returns the titles of the trophies that can be selected, indexed by their id. + * + * @return array + * @internal + */ + public static function getSelectableTrophies(): array + { + $trophies = \array_map( + static fn(Trophy $trophy) => $trophy->getTitle(), + TrophyCache::getInstance()->getTrophies(), + ); + + $collator = new \Collator(WCF::getLanguage()->getLocale()); + \uasort( + $trophies, + static fn(string $a, string $b) => $collator->compare($a, $b) + ); + + return $trophies; + } + + /** + * Returns the ids of the enabled trophies the given user has received. + * + * @return list + * @internal + */ + public static function getTrophyIDs(User $user): array + { + if ($user->isGuest()) { + return []; + } + + $userTrophies = UserTrophyList::getUserTrophies([$user->userID])[$user->userID]; + + return \array_values(\array_map( + static fn($userTrophy) => $userTrophy->trophyID, + $userTrophies, + )); + } +} diff --git a/wcfsetup/install/files/lib/system/object/filter/user/UserUsernameObjectFilter.class.php b/wcfsetup/install/files/lib/system/object/filter/user/UserUsernameObjectFilter.class.php new file mode 100644 index 00000000000..c2234135c25 --- /dev/null +++ b/wcfsetup/install/files/lib/system/object/filter/user/UserUsernameObjectFilter.class.php @@ -0,0 +1,100 @@ + + * @since 6.3 + * + * @implements IObjectListFilter + */ +final class UserUsernameObjectFilter implements IObjectListFilter +{ + #[\Override] + public function getIdentifier(): string + { + return 'com.woltlab.wcf.userUsername'; + } + + #[\Override] + public function getTitle(): string + { + return WCF::getLanguage()->get('wcf.objectFilter.user.username'); + } + + #[\Override] + public function getFormField(): TextFormField + { + return TextFormField::create('userUsername') + ->label('wcf.user.username') + ->maximumLength(255) + ->required(); + } + + #[\Override] + public function isAvailable(): bool + { + return true; + } + + #[\Override] + public function isRepeatable(): bool + { + return true; + } + + #[\Override] + public function serializeValue(mixed $value): string + { + return $value; + } + + #[\Override] + public function unserializeValue(string $serializedValue): string + { + return $serializedValue; + } + + #[\Override] + public function toFormFieldValue(mixed $value): string + { + return $value; + } + + #[\Override] + public function summarizeValue(mixed $value): string + { + return WCF::getLanguage()->getDynamicVariable('wcf.objectFilter.user.username.summary', [ + 'value' => $value, + ]); + } + + #[\Override] + public function applyFilter(PreparedStatementConditionBuilder $conditions, mixed $value): void + { + $conditions->add('username LIKE ?', ['%' . WCF::getDB()->escapeLikeValue($value) . '%']); + } + + #[\Override] + public function testObject(DatabaseObject $object, mixed $configuredValue): bool + { + if ($object->isGuest()) { + return false; + } + + // Must match the case-insensitive `LIKE` in `applyFilter()`. + return \mb_stripos($object->username, $configuredValue) !== 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 396395078b3..a6548f3cf08 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 @@ -9,6 +9,7 @@ use wcf\data\user\UserAction; use wcf\data\user\UserList; use wcf\system\cache\builder\UserGroupAssignmentCacheBuilder; +use wcf\system\object\filter\builder\UserGroupAssignmentObjectFilterBuilder; use wcf\system\SingletonFactory; /** @@ -48,25 +49,21 @@ public function checkUsers(array $userIDs) /** @var UserGroupAssignment[] $assignments */ $assignments = UserGroupAssignmentCacheBuilder::getInstance()->getData(); + $builder = new UserGroupAssignmentObjectFilterBuilder(); + foreach ($userList as $user) { $groupIDs = $user->getGroupIDs(); $newGroupIDs = []; foreach ($assignments as $assignment) { - if (\in_array($assignment->groupID, $groupIDs, true) || \in_array($assignment->groupID, $newGroupIDs, true)) { + if ( + \in_array($assignment->groupID, $groupIDs, true) + || \in_array($assignment->groupID, $newGroupIDs, true) + ) { continue; } - $checkFailed = false; - $conditions = $assignment->getConditions(); - foreach ($conditions as $condition) { - if (!$condition->getObjectType()->getProcessor()->checkUser($condition, $user)) { - $checkFailed = true; - break; - } - } - - if (!$checkFailed) { + if ($builder->testUser($assignment, $user)) { $newGroupIDs[] = $assignment->groupID; } } @@ -114,10 +111,8 @@ public function getUsers(UserGroupAssignment $assignment, ?int $maxUsers = null) $userList->sqlLimit = $maxUsers; } - $conditions = $assignment->getConditions(); - foreach ($conditions as $condition) { - $condition->getObjectType()->getProcessor()->addUserCondition($condition, $userList); - } + (new UserGroupAssignmentObjectFilterBuilder())->applyFilters($assignment, $userList->getConditionBuilder()); + $userList->readObjects(); return $userList->getObjects(); diff --git a/wcfsetup/install/files/style/layout/form.scss b/wcfsetup/install/files/style/layout/form.scss index b3f78cfd927..1be981df295 100644 --- a/wcfsetup/install/files/style/layout/form.scss +++ b/wcfsetup/install/files/style/layout/form.scss @@ -409,3 +409,48 @@ html[data-color-scheme="dark"] .passwordStrengthScore { grid-column: 1 / 3; } } + +.objectFilter__container { + display: flex; + flex-direction: column; + gap: 5px; +} + +.objectFilter__container:not(:empty) { + margin-bottom: 10px; +} + +.objectFilter__item { + align-items: center; + background: var(--wcfContentBackground); + border: 1px solid var(--wcfContentBorderInner); + border-radius: var(--wcfBorderRadiusContainer); + display: flex; + gap: 10px; + justify-content: space-between; + padding: 12px; +} + +.objectFilter__item__buttons { + display: flex; + flex-direction: row; + gap: 5px; +} + +.objectFilter__joiner { + align-items: center; + color: var(--wcfContentDimmedText); + display: flex; + gap: 10px; + padding-left: 20px; + + @include wcfFontSmall; +} + +.objectFilter__joiner::before { + background: var(--wcfContentBorderInner); + content: ""; + height: 16px; + margin-left: -8px; + width: 1px; +} diff --git a/wcfsetup/install/lang/de.xml b/wcfsetup/install/lang/de.xml index a1ea5a5e502..cb3ad482d90 100644 --- a/wcfsetup/install/lang/de.xml +++ b/wcfsetup/install/lang/de.xml @@ -28,17 +28,11 @@ - {literal}{$username}{/literal} durch den Namen des aktiven Benutzers oder durch „Gast“ + {literal}{$username}{/literal} durch den Namen des aktiven Benutzers oder durch „Gast“ ]]> - - - - - - - + @@ -865,15 +859,11 @@ Sie erreichen das Fehlerprotokoll unter: {link controller='ExceptionLogView' isE - - + - - - @@ -1264,19 +1254,15 @@ Sie erreichen das Fehlerprotokoll unter: {link controller='ExceptionLogView' isE CSS-Klasse nutzen.]]> + - - - - - - - + + @@ -3470,6 +3456,70 @@ Erlaubte Dateiendungen: {', '|implode:$attachmentHandler->getFormattedAllowedExt + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {$parentClass}.]]> + diff --git a/wcfsetup/install/lang/en.xml b/wcfsetup/install/lang/en.xml index 8d1a93535b5..e84667adc03 100644 --- a/wcfsetup/install/lang/en.xml +++ b/wcfsetup/install/lang/en.xml @@ -28,17 +28,11 @@ - {literal}{$username}{/literal} with the name of the active user or “Guest” + {literal}{$username}{/literal} with the name of the active user or “Guest” ]]> - - - - - - - + @@ -843,15 +837,11 @@ You can access the error log at: {link controller='ExceptionLogView' isEmail=tru - - - - @@ -1242,19 +1232,15 @@ You can access the error log at: {link controller='ExceptionLogView' isEmail=tru CSS class name.]]> + - - - - - - - + + @@ -3395,6 +3381,70 @@ Allowed extensions: {', '|implode:$attachmentHandler->getFormattedAllowedExtensi + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {$parentClass}.]]> + diff --git a/wcfsetup/setup/db/install_com.woltlab.wcf.php b/wcfsetup/setup/db/install_com.woltlab.wcf.php index ca3b7fe0713..c780f612ba9 100644 --- a/wcfsetup/setup/db/install_com.woltlab.wcf.php +++ b/wcfsetup/setup/db/install_com.woltlab.wcf.php @@ -369,6 +369,7 @@ DefaultFalseBooleanDatabaseTableColumn::create('isDisabled'), NotNullInt10DatabaseTableColumn::create('showOrder') ->defaultValue(0), + JsonDatabaseTableColumn::create('conditions'), ]) ->indices([ DatabaseTablePrimaryIndex::create() @@ -2157,6 +2158,7 @@ ->defaultValue(0), DefaultFalseBooleanDatabaseTableColumn::create('isDisabled'), DefaultFalseBooleanDatabaseTableColumn::create('isDismissible'), + JsonDatabaseTableColumn::create('conditions'), ]) ->indices([ DatabaseTablePrimaryIndex::create() @@ -4145,6 +4147,7 @@ NotNullInt10DatabaseTableColumn::create('groupID'), NotNullVarchar255DatabaseTableColumn::create('title'), DefaultFalseBooleanDatabaseTableColumn::create('isDisabled'), + JsonDatabaseTableColumn::create('conditions'), ]) ->indices([ DatabaseTablePrimaryIndex::create()
{lang}wcf.acp.ad.conditions.description{/lang}
{lang}wcf.acp.ad.conditions.page.description{/lang}
{lang}wcf.acp.ad.conditions.pointInTime.description{/lang}
{lang}wcf.acp.ad.conditions.user.description{/lang}
{lang}wcf.acp.notice.conditions.description{/lang}
{lang}wcf.acp.notice.conditions.page.description{/lang}
{lang}wcf.acp.notice.conditions.pointInTime.description{/lang}
{lang}wcf.acp.notice.conditions.user.description{/lang}
{lang}wcf.acp.group.assignment.conditions.description{/lang}