diff --git a/com.woltlab.wcf/objectType.xml b/com.woltlab.wcf/objectType.xml index c384a1714a8..4a1cc9cdac9 100644 --- a/com.woltlab.wcf/objectType.xml +++ b/com.woltlab.wcf/objectType.xml @@ -575,98 +575,6 @@ com.woltlab.wcf.user - - - com.woltlab.wcf.username - com.woltlab.wcf.condition.trophy - wcf\system\condition\UserUsernameCondition - general - - - com.woltlab.wcf.email - com.woltlab.wcf.condition.trophy - wcf\system\condition\UserEmailCondition - general - - - com.woltlab.wcf.userGroup - com.woltlab.wcf.condition.trophy - wcf\system\condition\UserGroupCondition - general - - - com.woltlab.wcf.languages - com.woltlab.wcf.condition.trophy - wcf\system\condition\UserLanguageCondition - general - - - com.woltlab.wcf.registrationDate - com.woltlab.wcf.condition.trophy - wcf\system\condition\UserRegistrationDateCondition - general - - - com.woltlab.wcf.registrationDateInterval - com.woltlab.wcf.condition.trophy - wcf\system\condition\UserRegistrationDateIntervalCondition - general - - - com.woltlab.wcf.avatar - com.woltlab.wcf.condition.trophy - wcf\system\condition\UserAvatarCondition - general - - - com.woltlab.wcf.signature - com.woltlab.wcf.condition.trophy - wcf\system\condition\UserSignatureCondition - general - - - com.woltlab.wcf.coverPhoto - com.woltlab.wcf.condition.trophy - wcf\system\condition\UserCoverPhotoCondition - general - - - com.woltlab.wcf.state - com.woltlab.wcf.condition.trophy - wcf\system\condition\UserStateCondition - general - - - com.woltlab.wcf.activityPoints - com.woltlab.wcf.condition.trophy - wcf\system\condition\UserIntegerPropertyCondition - contents - activityPoints - 0 - - - com.woltlab.wcf.likesReceived - com.woltlab.wcf.condition.trophy - wcf\system\condition\UserIntegerPropertyCondition - contents - likesReceived - 0 - - - com.woltlab.wcf.userTrophyCondition - com.woltlab.wcf.condition.trophy - wcf\system\condition\UserTrophyCondition - general - - - com.woltlab.wcf.trophyPoints - com.woltlab.wcf.condition.trophy - wcf\system\condition\UserIntegerPropertyCondition - contents - trophyPoints - 0 - - com.woltlab.wcf.logo @@ -1263,6 +1171,11 @@ com.woltlab.wcf.file wcf\system\file\processor\ReactionTypeIconFileProcessor + + com.woltlab.wcf.trophy.image + com.woltlab.wcf.file + wcf\system\file\processor\TrophyImageFileProcessor + @@ -1323,7 +1236,7 @@ com.woltlab.wcf.clipboardItem - + com.woltlab.wcf.condition.userGroupAssignment @@ -1369,6 +1282,51 @@ com.woltlab.wcf.condition.userGroupAssignment + + com.woltlab.wcf.condition.trophy + + + com.woltlab.wcf.condition.trophy + + + com.woltlab.wcf.condition.trophy + + + com.woltlab.wcf.condition.trophy + + + com.woltlab.wcf.condition.trophy + + + com.woltlab.wcf.condition.trophy + + + com.woltlab.wcf.condition.trophy + + + com.woltlab.wcf.condition.trophy + + + com.woltlab.wcf.condition.trophy + + + com.woltlab.wcf.condition.trophy + + + com.woltlab.wcf.condition.trophy + + + com.woltlab.wcf.condition.trophy + + + com.woltlab.wcf.condition.trophy + + + com.woltlab.wcf.condition.trophy + + + com.woltlab.wcf.condition.trophy + com.woltlab.wcf.condition.notice @@ -1541,10 +1499,7 @@ com.woltlab.wcf.condition.userSearch - - - com.woltlab.wcf.condition.trophy - + com.woltlab.wcf.bulkProcessing.user.condition diff --git a/com.woltlab.wcf/objectTypeDefinition.xml b/com.woltlab.wcf/objectTypeDefinition.xml index 190e29d1ff2..247fa14e993 100644 --- a/com.woltlab.wcf/objectTypeDefinition.xml +++ b/com.woltlab.wcf/objectTypeDefinition.xml @@ -122,6 +122,7 @@ com.woltlab.wcf.condition.userGroupAssignment wcf\system\condition\IUserCondition + com.woltlab.wcf.condition.trophy wcf\system\condition\IUserCondition diff --git a/com.woltlab.wcf/package.xml b/com.woltlab.wcf/package.xml index f6e92e09037..b805138f056 100644 --- a/com.woltlab.wcf/package.xml +++ b/com.woltlab.wcf/package.xml @@ -81,11 +81,13 @@ The following steps require the new files and must run after the `objectType` PIP: acp/update_com.woltlab.wcf_6.3_userRankImage.php acp/update_com.woltlab.wcf_6.3_reactionTypeIcon.php + acp/update_com.woltlab.wcf_6.3_trophyImage.php acp/update_com.woltlab.wcf_6.3_reactionTypeL10n.php acp/update_com.woltlab.wcf_6.3_userRankL10n.php acp/update_com.woltlab.wcf_6.3_labelGroupL10n.php acp/update_com.woltlab.wcf_6.3_contactRecipientL10n.php acp/update_com.woltlab.wcf_6.3_paidSubscriptionL10n.php + acp/update_com.woltlab.wcf_6.3_trophyL10n.php acp/update_com.woltlab.wcf_6.3_userOptionL10n.php acp/update_com.woltlab.wcf_6.3_userOptionCategoryL10n.php acp/update_com.woltlab.wcf_6.3_captchaQuestionL10n.php diff --git a/com.woltlab.wcf/templates/shared_trophyBadgeFormField.tpl b/com.woltlab.wcf/templates/shared_trophyBadgeFormField.tpl new file mode 100644 index 00000000000..ddb1766efd7 --- /dev/null +++ b/com.woltlab.wcf/templates/shared_trophyBadgeFormField.tpl @@ -0,0 +1,29 @@ + + + {unsafe:$field->getIcon()->toHtml(64)} + + {if !$field->isImmutable()} + + {lang}wcf.global.fontAwesome.selectIcon{/lang} + {lang}wcf.style.colorPicker.changeColor{/lang} + {lang}wcf.style.colorPicker.changeBackgroundColor{/lang} + + {/if} + + + + + + +{if !$field->isImmutable()} + {if $__trophyBadgeFormFieldIncludeJavaScript} + {include file='shared_fontAwesomeJavaScript'} + {include file='shared_colorPickerJavaScript'} + {/if} + + +{/if} diff --git a/com.woltlab.wcf/templates/shared_trophyImage.tpl b/com.woltlab.wcf/templates/shared_trophyImage.tpl index 22d9ebb7840..8d3a0a1c0ee 100644 --- a/com.woltlab.wcf/templates/shared_trophyImage.tpl +++ b/com.woltlab.wcf/templates/shared_trophyImage.tpl @@ -1,5 +1,5 @@ getTitle()}"{/if} diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 877d3a461cb..dc6ee9c6515 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -150,18 +150,6 @@ parameters: count: 2 path: wcfsetup/install/files/lib/acp/form/StyleImportForm.class.php - - - message: '#^Construct empty\(\) is not allowed\. Use more strict comparison\.$#' - identifier: empty.notAllowed - count: 4 - path: wcfsetup/install/files/lib/acp/form/TrophyAddForm.class.php - - - - message: '#^Construct empty\(\) is not allowed\. Use more strict comparison\.$#' - identifier: empty.notAllowed - count: 4 - path: wcfsetup/install/files/lib/acp/form/TrophyEditForm.class.php - - message: '#^Construct empty\(\) is not allowed\. Use more strict comparison\.$#' identifier: empty.notAllowed @@ -588,12 +576,6 @@ parameters: count: 2 path: wcfsetup/install/files/lib/data/tag/TagAction.class.php - - - message: '#^Construct empty\(\) is not allowed\. Use more strict comparison\.$#' - identifier: empty.notAllowed - count: 1 - path: wcfsetup/install/files/lib/data/trophy/TrophyAction.class.php - - message: '#^Construct empty\(\) is not allowed\. Use more strict comparison\.$#' identifier: empty.notAllowed @@ -2412,12 +2394,6 @@ parameters: count: 1 path: wcfsetup/install/files/lib/system/page/PageLocationManager.class.php - - - message: '#^Construct empty\(\) is not allowed\. Use more strict comparison\.$#' - identifier: empty.notAllowed - count: 1 - path: wcfsetup/install/files/lib/system/page/handler/TrophyPageHandler.class.php - - message: '#^Construct empty\(\) is not allowed\. Use more strict comparison\.$#' identifier: empty.notAllowed @@ -2430,18 +2406,6 @@ parameters: count: 1 path: wcfsetup/install/files/lib/system/reaction/ReactionHandler.class.php - - - message: '#^Call to function in_array\(\) requires parameter \#3 to be set\.$#' - identifier: function.strict - count: 1 - path: wcfsetup/install/files/lib/system/registry/RegistryHandler.class.php - - - - message: '#^Construct empty\(\) is not allowed\. Use more strict comparison\.$#' - identifier: empty.notAllowed - count: 1 - path: wcfsetup/install/files/lib/system/registry/RegistryHandler.class.php - - message: '#^Construct empty\(\) is not allowed\. Use more strict comparison\.$#' identifier: empty.notAllowed diff --git a/ts/WoltLabSuite/Core/Acp/Ui/Trophy/Badge.ts b/ts/WoltLabSuite/Core/Acp/Ui/Trophy/Badge.ts deleted file mode 100644 index dc0d56b9cf7..00000000000 --- a/ts/WoltLabSuite/Core/Acp/Ui/Trophy/Badge.ts +++ /dev/null @@ -1,79 +0,0 @@ -/** - * Enables editing of the badge icon, color and - * background-color. - * - * @author Alexander Ebert - * @copyright 2001-2022 WoltLab GmbH - * @license GNU Lesser General Public License - */ - -import { open as openFontAwesomePicker } from "../../../Ui/Style/FontAwesome"; -import ColorPicker from "../../../Ui/Color/Picker"; -import DomUtil from "../../../Dom/Util"; - -const badgeContainer = document.getElementById("badgeContainer")!; -const previewWrapper = badgeContainer.querySelector(".trophyIcon") as HTMLElement; - -function renderNativePreview(icon: string, forceSolid: boolean): void { - let previewIcon = previewWrapper.querySelector("fa-icon"); - if (!(previewIcon instanceof HTMLElement) || previewIcon.parentElement !== previewWrapper) { - previewWrapper.replaceChildren(); - - previewIcon = document.createElement("fa-icon"); - previewIcon.size = 64; - previewWrapper.append(previewIcon); - } - - previewIcon.setIcon(icon, forceSolid); -} - -function setupChangeIcon(): void { - const button = badgeContainer.querySelector('.trophyIconEditButton[data-value="icon"]') as HTMLButtonElement; - const input = badgeContainer.querySelector('input[name="iconName"]') as HTMLInputElement; - - button.addEventListener("click", () => { - openFontAwesomePicker((icon, forceSolid, value, previewHtml) => { - input.value = value ?? `${icon};${String(forceSolid)}`; - - if (typeof previewHtml === "string") { - previewWrapper.innerHTML = previewHtml; - } else { - renderNativePreview(icon, forceSolid); - } - }); - }); -} - -function setupChangeColor(): void { - const button = badgeContainer.querySelector('.trophyIconEditButton[data-value="color"]') as HTMLButtonElement; - - const input = badgeContainer.querySelector('input[name="iconColor"]') as HTMLInputElement; - button.dataset.store = DomUtil.identify(input); - - new ColorPicker(button, { - callbackSubmit() { - previewWrapper.style.setProperty("color", input.value); - }, - }); -} - -function setupChangeBackgroundColor(): void { - const button = badgeContainer.querySelector( - '.trophyIconEditButton[data-value="background-color"]', - ) as HTMLButtonElement; - - const input = badgeContainer.querySelector('input[name="badgeColor"]') as HTMLInputElement; - button.dataset.store = DomUtil.identify(input); - - new ColorPicker(button, { - callbackSubmit() { - previewWrapper.style.setProperty("background-color", input.value); - }, - }); -} - -export function setup(): void { - setupChangeIcon(); - setupChangeColor(); - setupChangeBackgroundColor(); -} diff --git a/ts/WoltLabSuite/Core/Acp/Ui/Trophy/Editor.ts b/ts/WoltLabSuite/Core/Acp/Ui/Trophy/Editor.ts deleted file mode 100644 index 8908cf55f43..00000000000 --- a/ts/WoltLabSuite/Core/Acp/Ui/Trophy/Editor.ts +++ /dev/null @@ -1,62 +0,0 @@ -/** - * Switches between trophy types, automatic awarding of - * trophies and initialized the badge editor. - * - * @author Alexander Ebert - * @copyright 2001-2022 WoltLab GmbH - * @license GNU Lesser General Public License - */ - -import { setup as setupBadgeEditor } from "./Badge"; - -const enum TrophyType { - Image = "1", - Badge = "2", -} - -function setupTypeChange(): void { - const badgeContainer = document.getElementById("badgeContainer")!; - const imageContainer = document.getElementById("imageContainer")!; - - const typeSelection = document.querySelector("select[name=type]") as HTMLSelectElement; - typeSelection.addEventListener("change", () => { - if ((typeSelection.value as TrophyType) === TrophyType.Image) { - badgeContainer.hidden = true; - imageContainer.hidden = false; - } else if ((typeSelection.value as TrophyType) === TrophyType.Badge) { - badgeContainer.hidden = false; - imageContainer.hidden = true; - } - }); -} - -function setupAwardConditions(): void { - const awardAutomatically = document.querySelector("input[name=awardAutomatically]") as HTMLInputElement; - const revokeContainer = document.getElementById("revokeAutomaticallyDL")!; - const revokeCheckbox = revokeContainer.querySelector("input")!; - - awardAutomatically.addEventListener("change", () => { - document.querySelectorAll(".conditionSection").forEach((section: HTMLElement) => { - if (awardAutomatically.checked) { - section.hidden = false; - } else { - section.hidden = true; - } - }); - - if (awardAutomatically) { - revokeContainer.classList.remove("disabled"); - revokeCheckbox.disabled = false; - } else { - revokeContainer.classList.add("disabled"); - revokeCheckbox.disabled = true; - revokeCheckbox.checked = false; - } - }); -} - -export function setup(): void { - setupTypeChange(); - setupAwardConditions(); - setupBadgeEditor(); -} diff --git a/ts/WoltLabSuite/Core/Acp/Ui/Trophy/Upload.ts b/ts/WoltLabSuite/Core/Acp/Ui/Trophy/Upload.ts deleted file mode 100644 index 2d33ef798a0..00000000000 --- a/ts/WoltLabSuite/Core/Acp/Ui/Trophy/Upload.ts +++ /dev/null @@ -1,73 +0,0 @@ -/** - * Handles the trophy image upload. - * - * @author Joshua Ruesweg - * @copyright 2001-2019 WoltLab GmbH - * @license GNU Lesser General Public License - */ - -import { showDefaultSuccessSnackbar } from "WoltLabSuite/Core/Component/Snackbar"; -import * as Core from "../../../Core"; -import DomUtil from "../../../Dom/Util"; -import { getPhrase } from "WoltLabSuite/Core/Language"; -import Upload from "../../../Upload"; -import { UploadOptions } from "../../../Upload/Data"; - -interface AjaxResponse { - returnValues: { - url: string; - }; -} - -interface AjaxResponseError { - returnValues: { - errorType: string; - }; -} - -class TrophyUpload extends Upload { - private readonly trophyId: number; - private readonly tmpHash: string; - - constructor(trophyId: number, tmpHash: string, options: Partial) { - super( - "uploadIconFileButton", - "uploadIconFileContent", - Core.extend( - { - className: "wcf\\data\\trophy\\TrophyAction", - }, - options, - ), - ); - - this.trophyId = ~~trophyId; - this.tmpHash = tmpHash; - } - - protected _getParameters(): ArbitraryObject { - return { - trophyID: this.trophyId, - tmpHash: this.tmpHash, - }; - } - - protected _success(uploadId: number, data: AjaxResponse): void { - DomUtil.innerError(this._button, false); - - this._target.innerHTML = ``; - - showDefaultSuccessSnackbar(); - } - - protected _failure(uploadId: number, data: AjaxResponseError): boolean { - DomUtil.innerError(this._button, getPhrase(`wcf.acp.trophy.imageUpload.error.${data.returnValues.errorType}`)); - - // remove previous images - this._target.innerHTML = ""; - - return false; - } -} - -export = TrophyUpload; diff --git a/ts/WoltLabSuite/Core/Form/Builder/Field/Controller/TrophyBadge.ts b/ts/WoltLabSuite/Core/Form/Builder/Field/Controller/TrophyBadge.ts new file mode 100644 index 00000000000..dbe7810fab6 --- /dev/null +++ b/ts/WoltLabSuite/Core/Form/Builder/Field/Controller/TrophyBadge.ts @@ -0,0 +1,86 @@ +/** + * Enables editing of the icon, the icon color and the background color of a + * badge trophy. + * + * @author Alexander Ebert, Marcel Werk + * @copyright 2001-2026 WoltLab GmbH + * @license GNU Lesser General Public License + * @since 6.3 + * @woltlabExcludeBundle all + */ + +import { open as openFontAwesomePicker } from "../../../../Ui/Style/FontAwesome"; +import ColorPicker from "../../../../Ui/Color/Picker"; + +function getInput(fieldId: string, name: string): HTMLInputElement { + const input = document.getElementById(`${fieldId}_${name}`); + if (!(input instanceof HTMLInputElement)) { + throw new Error(`Unknown input field '${name}' of the field '${fieldId}'.`); + } + + return input; +} + +function getButton(container: HTMLElement, value: string): HTMLButtonElement { + return container.querySelector(`button[data-value="${value}"]`) as HTMLButtonElement; +} + +function renderNativePreview(preview: HTMLElement, icon: string, forceSolid: boolean): void { + let previewIcon = preview.querySelector("fa-icon"); + if (!(previewIcon instanceof HTMLElement) || previewIcon.parentElement !== preview) { + preview.replaceChildren(); + + previewIcon = document.createElement("fa-icon"); + previewIcon.size = 64; + preview.append(previewIcon); + } + + previewIcon.setIcon(icon, forceSolid); +} + +function setupChangeIcon(container: HTMLElement, preview: HTMLElement, input: HTMLInputElement): void { + getButton(container, "icon").addEventListener("click", () => { + openFontAwesomePicker((icon, forceSolid, value, previewHtml) => { + input.value = value ?? `${icon};${String(forceSolid)}`; + + if (typeof previewHtml === "string") { + preview.innerHTML = previewHtml; + } else { + renderNativePreview(preview, icon, forceSolid); + } + }); + }); +} + +function setupColorPicker( + button: HTMLButtonElement, + preview: HTMLElement, + input: HTMLInputElement, + property: "color" | "background-color", +): void { + button.dataset.store = input.id; + + new ColorPicker(button, { + callbackSubmit() { + preview.style.setProperty(property, input.value); + }, + }); +} + +export function setup(fieldId: string): void { + const container = document.getElementById(`${fieldId}_container`); + if (container === null) { + throw new Error(`Unknown field with id '${fieldId}'.`); + } + + const preview = container.querySelector(".trophyIcon") as HTMLElement; + + setupChangeIcon(container, preview, getInput(fieldId, "iconName")); + setupColorPicker(getButton(container, "color"), preview, getInput(fieldId, "iconColor"), "color"); + setupColorPicker( + getButton(container, "background-color"), + preview, + getInput(fieldId, "badgeColor"), + "background-color", + ); +} diff --git a/ts/WoltLabSuite/Core/Form/Builder/Field/TrophyBadge.ts b/ts/WoltLabSuite/Core/Form/Builder/Field/TrophyBadge.ts new file mode 100644 index 00000000000..4aa15b22d33 --- /dev/null +++ b/ts/WoltLabSuite/Core/Form/Builder/Field/TrophyBadge.ts @@ -0,0 +1,35 @@ +/** + * Data handler for a trophy badge form builder field in an Ajax form. + * + * @author Marcel Werk + * @copyright 2001-2026 WoltLab GmbH + * @license GNU Lesser General Public License + * @since 6.3 + */ +import Field from "./Field"; +import { FormBuilderData } from "../Data"; + +class TrophyBadge extends Field { + protected _getData(): FormBuilderData { + return { + [this._fieldId]: { + iconName: this.#getValue("iconName"), + iconColor: this.#getValue("iconColor"), + badgeColor: this.#getValue("badgeColor"), + }, + }; + } + + protected _readField(): void {} + + #getValue(name: string): string { + const input = document.getElementById(`${this._fieldId}_${name}`); + if (!(input instanceof HTMLInputElement)) { + throw new Error(`Unknown input field '${name}' of the field '${this._fieldId}'.`); + } + + return input.value; + } +} + +export = TrophyBadge; 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 30e9039eb29..20b7cadf780 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 @@ -49,10 +49,45 @@ ]), PartialDatabaseTable::create('wcf1_trophy') ->columns([ - NotNullVarchar255DatabaseTableColumn::create('title'), SmallintDatabaseTableColumn::create('type') ->notNull() ->defaultValue(1), + IntDatabaseTableColumn::create('iconFileID'), + JsonDatabaseTableColumn::create('conditions'), + ]) + ->foreignKeys([ + DatabaseTableForeignKey::create() + ->columns(['iconFileID']) + ->referencedTable('wcf1_file') + ->referencedColumns(['fileID']) + ->onDelete('SET NULL') + ->onUpdate('NO ACTION'), + ]), + DatabaseTable::create('wcf1_trophy_l10n') + ->columns([ + NotNullInt10DatabaseTableColumn::create('trophyID'), + IntDatabaseTableColumn::create('languageID'), + VarcharDatabaseTableColumn::create('title') + ->length(255), + MediumtextDatabaseTableColumn::create('description'), + ]) + ->indices([ + DatabaseTableIndex::create('trophyID') + ->columns(['trophyID', 'languageID']), + ]) + ->foreignKeys([ + DatabaseTableForeignKey::create() + ->columns(['trophyID']) + ->referencedTable('wcf1_trophy') + ->referencedColumns(['trophyID']) + ->onDelete('CASCADE') + ->onUpdate('NO ACTION'), + DatabaseTableForeignKey::create() + ->columns(['languageID']) + ->referencedTable('wcf1_language') + ->referencedColumns(['languageID']) + ->onDelete('CASCADE') + ->onUpdate('NO ACTION'), ]), PartialDatabaseTable::create('wcf1_like_object') ->columns([ diff --git a/wcfsetup/install/files/acp/database/update_com.woltlab.wcf_6.3_step3.php b/wcfsetup/install/files/acp/database/update_com.woltlab.wcf_6.3_step3.php index bcb448f6a4d..f464d5a010c 100644 --- a/wcfsetup/install/files/acp/database/update_com.woltlab.wcf_6.3_step3.php +++ b/wcfsetup/install/files/acp/database/update_com.woltlab.wcf_6.3_step3.php @@ -60,6 +60,15 @@ TextDatabaseTableColumn::create('description') ->drop(), ]), + PartialDatabaseTable::create('wcf1_trophy') + ->columns([ + NotNullVarchar255DatabaseTableColumn::create('title') + ->drop(), + MediumtextDatabaseTableColumn::create('description') + ->drop(), + MediumtextDatabaseTableColumn::create('iconFile') + ->drop(), + ]), PartialDatabaseTable::create('wcf1_captcha_question') ->columns([ NotNullVarchar255DatabaseTableColumn::create('question') diff --git a/wcfsetup/install/files/acp/templates/trophyAdd.tpl b/wcfsetup/install/files/acp/templates/trophyAdd.tpl index eebd0eb829f..744c0b21e37 100644 --- a/wcfsetup/install/files/acp/templates/trophyAdd.tpl +++ b/wcfsetup/install/files/acp/templates/trophyAdd.tpl @@ -1,28 +1,13 @@ {include file='header' pageTitle='wcf.acp.menu.link.trophy.'|concat:$action} -{include file='shared_colorPickerJavaScript'} -{include file='shared_fontAwesomeJavaScript'} - - - {lang}wcf.acp.menu.link.trophy.{$action}{/lang} - + - {if $action == 'edit'} + {if $action === 'edit'} {unsafe:$interactionContextMenu->render()} @@ -32,243 +17,6 @@ -{include file='shared_formNotice'} - -{if $trophyCategories|count} - - - - {lang}wcf.global.title{/lang} - - - {if $errorField == 'title'} - - {if $errorType == 'empty'} - {lang}wcf.global.form.error.empty{/lang} - {elseif $errorType == 'multilingual'} - {lang}wcf.global.form.error.multilingual{/lang} - {/if} - - {/if} - - - {include file='shared_multipleLanguageInputJavascript' elementIdentifier='title' forceSelection=false} - - - {lang}wcf.acp.trophy.description{/lang} - - {$i18nPlainValues[description]} - {if $errorField == 'description'} - - {if $errorType == 'empty'} - {lang}wcf.global.form.error.empty{/lang} - {elseif $errorType == 'multilingual'} - {lang}wcf.global.form.error.multilingual{/lang} - {/if} - - {/if} - - - {include file='shared_multipleLanguageInputJavascript' elementIdentifier='description' forceSelection=false} - - - - - {lang}wcf.acp.trophy.trophyUseHtml{/lang} - - - - - {lang}wcf.global.category{/lang} - - - {lang}wcf.global.noSelection{/lang} - - {foreach from=$trophyCategories item=category} - getObjectID() == $categoryID} selected{/if}>{$category->getTitle()} - {/foreach} - - {if $errorField == 'categoryID'} - - {if $errorType == 'empty'} - {lang}wcf.global.form.error.empty{/lang} - {/if} - - {/if} - - - - - {lang}wcf.global.showOrder{/lang} - - - {lang}wcf.acp.trophy.showOrder.description{/lang} - - - - - - - {lang}wcf.acp.trophy.isDisabled{/lang} - - - - - - - {lang}wcf.acp.trophy.awardAutomatically{/lang} - - - - - - - {lang}wcf.acp.trophy.revokeAutomatically{/lang} - - - - - {lang}wcf.acp.trophy.type{/lang} - - - {foreach from=$availableTypes item=trophyType key=key} - {lang}wcf.acp.trophy.type.{$trophyType}{/lang} - {/foreach} - - - - - {event name='dataFields'} - - - - - {lang}wcf.acp.trophy.type.imageUpload{/lang} - - - - {lang}wcf.acp.trophy.type.imageUpload{/lang} - - - - - - {if $errorField == 'imageUpload'} - - {if $errorType == 'empty'} - {lang}wcf.global.form.error.empty{/lang} - {/if} - - {/if} - {lang}wcf.acp.trophy.type.imageUpload.description{/lang} - - - {if $action == 'add'}{if !$uploadedImageURL|empty}{/if}{else}{if $trophy->type == 1}{/if}{/if} - - - - - - - - - - - {lang}wcf.acp.trophy.type.badge{/lang} - - - - {lang}wcf.acp.trophy.type.badge{/lang} - - - - {unsafe:$icon->toHtml(64)} - - - {lang}wcf.global.fontAwesome.selectIcon{/lang} - {lang}wcf.style.colorPicker.changeColor{/lang} - {lang}wcf.style.colorPicker.changeBackgroundColor{/lang} - - - - - - - - - - - {event name='sections'} - - - - {lang}wcf.acp.trophy.conditions{/lang} - {lang}wcf.acp.trophy.conditions.description{/lang} - - - {if $errorField == 'conditions'} - {lang}wcf.acp.trophy.conditions.error.noConditions{/lang} - {/if} - - {include file='shared_userConditions'} - - - {event name='conditionSections'} - - - - {csrfToken} - - -{else} - {lang}wcf.acp.trophy.error.noCategories{/lang} -{/if} - - - - - {unsafe:$icon->toHtml(128)} - - - - {lang}wcf.acp.trophy.badge.iconName{/lang} - - {$iconName} - {icon name='magnifying-glass'} - - - - - {lang}wcf.acp.trophy.badge.iconColor{/lang} - - - - - - {icon name='paintbrush'} - - - - - {lang}wcf.acp.trophy.badge.badgeColor{/lang} - - - - - - {icon name='paintbrush'} - - - - - - - {lang}wcf.global.button.save{/lang} - - +{unsafe:$form->getHTML()} {include file='footer'} 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 index ac0ef76bcde..63d7505840b 100644 --- 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 @@ -1,9 +1,10 @@ migrate($converters); -// The user option conditions of trophies are removed together with their object -// type, the affected trophies are disabled to prevent them from being awarded to -// users that do not meet the original conditions. -$sql = "UPDATE wcf1_trophy - SET isDisabled = ? - WHERE awardAutomatically = ? - AND trophyID IN ( - SELECT condition_table.objectID - FROM wcf1_condition condition_table - INNER JOIN wcf1_object_type object_type - ON object_type.objectTypeID = condition_table.objectTypeID - INNER JOIN wcf1_object_type_definition definition - ON definition.definitionID = object_type.definitionID - WHERE object_type.objectType = ? - AND definition.definitionName = ? - )"; +// The legacy form stored the conditions of trophies regardless of whether they +// are awarded automatically. Those conditions were never evaluated and must not +// cause the migration to disable the trophies. +$sql = "DELETE FROM wcf1_condition + WHERE objectID IN ( + SELECT trophyID + FROM wcf1_trophy + WHERE awardAutomatically = ? + ) + AND objectTypeID IN ( + SELECT object_type.objectTypeID + 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([ - 1, - 1, - 'com.woltlab.wcf.userOptions', + 0, 'com.woltlab.wcf.condition.trophy', ]); + +(new LegacyConditionMigration('com.woltlab.wcf.condition.trophy', 'wcf1_trophy', 'trophyID')) + ->migrate($converters); diff --git a/wcfsetup/install/files/acp/update_com.woltlab.wcf_6.3_trophyImage.php b/wcfsetup/install/files/acp/update_com.woltlab.wcf_6.3_trophyImage.php new file mode 100644 index 00000000000..9b87b1f2f56 --- /dev/null +++ b/wcfsetup/install/files/acp/update_com.woltlab.wcf_6.3_trophyImage.php @@ -0,0 +1,84 @@ + + */ + +use wcf\command\file\CreateFileFromExistingFile; +use wcf\data\trophy\Trophy; +use wcf\system\cache\builder\TrophyCacheBuilder; +use wcf\system\WCF; + +$sql = "SELECT trophyID, iconFile + FROM wcf1_trophy + WHERE type = ? + AND iconFile <> ? + AND iconFileID IS NULL"; +$statement = WCF::getDB()->prepare($sql); +$statement->execute([ + Trophy::TYPE_IMAGE, + '', +]); +$trophyIDsByImage = []; +while ($row = $statement->fetchArray()) { + $trophyIDsByImage[$row['iconFile']][] = $row['trophyID']; +} + +if ($trophyIDsByImage === []) { + return; +} + +$imageDirectory = \realpath(\WCF_DIR . 'images/trophy/'); +if ($imageDirectory === false) { + return; +} + +$sql = "UPDATE wcf1_trophy + SET iconFileID = ? + WHERE trophyID = ?"; +$statement = WCF::getDB()->prepare($sql); + +foreach ($trophyIDsByImage as $iconFile => $trophyIDs) { + $pathname = \realpath(\WCF_DIR . 'images/trophy/' . $iconFile); + if ($pathname === false || !\str_starts_with($pathname, $imageDirectory . \DIRECTORY_SEPARATOR)) { + continue; + } + + // Every trophy must own its file because the file is deleted together with + // the trophy. The last trophy takes over the original, all others receive + // a copy. + $lastTrophyID = \array_pop($trophyIDs); + foreach ($trophyIDs as $trophyID) { + $file = new CreateFileFromExistingFile( + $pathname, + \basename($pathname), + 'com.woltlab.wcf.trophy.image', + copy: true, + )(); + if ($file !== null) { + $statement->execute([$file->fileID, $trophyID]); + } + } + + $file = new CreateFileFromExistingFile( + $pathname, + \basename($pathname), + 'com.woltlab.wcf.trophy.image', + )(); + if ($file !== null) { + $statement->execute([$file->fileID, $lastTrophyID]); + } +} + +TrophyCacheBuilder::getInstance()->reset(); +TrophyCacheBuilder::getInstance()->reset(['onlyEnabled' => 1]); diff --git a/wcfsetup/install/files/acp/update_com.woltlab.wcf_6.3_trophyL10n.php b/wcfsetup/install/files/acp/update_com.woltlab.wcf_6.3_trophyL10n.php new file mode 100644 index 00000000000..d986295f06d --- /dev/null +++ b/wcfsetup/install/files/acp/update_com.woltlab.wcf_6.3_trophyL10n.php @@ -0,0 +1,101 @@ +` and + * `wcf.user.trophy.description` phrases are removed, including those left + * behind by previously deleted trophies. Imported descriptions of awarded + * trophies use the same pattern (`wcf.user.trophy.description`) + * and are kept. + * + * IMPORTANT ordering constraints for package.xml: + * - The database script `acp/database/update_com.woltlab.wcf_6.3_step1.php` + * (creating the `wcf1_trophy_l10n` table) must run BEFORE this script. + * - The database script `acp/database/update_com.woltlab.wcf_6.3_step3.php` + * (dropping the `title` and `description` columns) must run AFTER this + * script. + * + * @author Marcel Werk + * @copyright 2001-2026 WoltLab GmbH + * @license GNU Lesser General Public License + */ + +use wcf\data\trophy\Trophy; +use wcf\system\cache\builder\TrophyCacheBuilder; +use wcf\system\l10n\L10nLanguageItemSource; +use wcf\system\l10n\L10nLanguageItemSync; +use wcf\system\language\LanguageFactory; +use wcf\system\WCF; + +// This script owns the table's content at this point (idempotency on re-runs). +WCF::getDB()->prepare("DELETE FROM wcf1_trophy_l10n")->execute(); + +// Legacy `Trophy::getTitle()` and `Trophy::getDescription()` resolved any +// existing phrase, which includes phrases named by other packages. +$getExistingLanguageItems = static function (string $columnName): array { + $sql = "SELECT DISTINCT languageItem + FROM wcf1_language_item + WHERE languageItem IN ( + SELECT {$columnName} + FROM wcf1_trophy + )"; + $statement = WCF::getDB()->prepare($sql); + $statement->execute(); + + return $statement->fetchAll(\PDO::FETCH_COLUMN); +}; +$existingTitleLanguageItems = $getExistingLanguageItems('title'); +$existingDescriptionLanguageItems = $getExistingLanguageItems('description'); + +// The phrases are removed only by the final `DELETE` below, so that a failure +// before it leaves them in place for a retry. +L10nLanguageItemSync::migrate( + Trophy::getL10nDefinition(), + static function (array $row) use ($existingTitleLanguageItems, $existingDescriptionLanguageItems): array { + $title = (string)($row['title'] ?? ''); + $description = (string)($row['description'] ?? ''); + + return [ + 'sources' => [ + 'title' => new L10nLanguageItemSource( + languageItem: \in_array($title, $existingTitleLanguageItems, true) ? $title : null, + literal: $title, + ), + 'description' => new L10nLanguageItemSource( + languageItem: \in_array($description, $existingDescriptionLanguageItems, true) ? $description : null, + literal: $description, + ), + ], + ]; + } +); + +// Cached trophies were created without their localized values. Reset before +// the phrases are removed, because a failure after the removal makes a retry +// migrate the phrase names as literals. +TrophyCacheBuilder::getInstance()->reset(); +TrophyCacheBuilder::getInstance()->reset(['onlyEnabled' => 1]); + +$sql = "DELETE FROM wcf1_language_item + WHERE languageItem REGEXP ?"; +$statement = WCF::getDB()->prepare($sql); +$statement->execute(['^wcf\.user\.trophy\.title[0-9]+$']); +$deletedPhrases = $statement->getAffectedRows(); + +$sql = "DELETE FROM wcf1_language_item + WHERE languageItem REGEXP ? + AND languageItem NOT IN ( + SELECT description + FROM wcf1_user_trophy + WHERE description IS NOT NULL + )"; +$statement = WCF::getDB()->prepare($sql); +$statement->execute(['^wcf\.user\.trophy\.description[0-9]+$']); +$deletedPhrases += $statement->getAffectedRows(); + +if ($deletedPhrases > 0) { + LanguageFactory::getInstance()->deleteLanguageCache(); +} diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Acp/Ui/Trophy/Badge.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Acp/Ui/Trophy/Badge.js deleted file mode 100644 index 4f7e047bd07..00000000000 --- a/wcfsetup/install/files/js/WoltLabSuite/Core/Acp/Ui/Trophy/Badge.js +++ /dev/null @@ -1,67 +0,0 @@ -/** - * Enables editing of the badge icon, color and - * background-color. - * - * @author Alexander Ebert - * @copyright 2001-2022 WoltLab GmbH - * @license GNU Lesser General Public License - */ -define(["require", "exports", "tslib", "../../../Ui/Style/FontAwesome", "../../../Ui/Color/Picker", "../../../Dom/Util"], function (require, exports, tslib_1, FontAwesome_1, Picker_1, Util_1) { - "use strict"; - Object.defineProperty(exports, "__esModule", { value: true }); - exports.setup = setup; - Picker_1 = tslib_1.__importDefault(Picker_1); - Util_1 = tslib_1.__importDefault(Util_1); - const badgeContainer = document.getElementById("badgeContainer"); - const previewWrapper = badgeContainer.querySelector(".trophyIcon"); - function renderNativePreview(icon, forceSolid) { - let previewIcon = previewWrapper.querySelector("fa-icon"); - if (!(previewIcon instanceof HTMLElement) || previewIcon.parentElement !== previewWrapper) { - previewWrapper.replaceChildren(); - previewIcon = document.createElement("fa-icon"); - previewIcon.size = 64; - previewWrapper.append(previewIcon); - } - previewIcon.setIcon(icon, forceSolid); - } - function setupChangeIcon() { - const button = badgeContainer.querySelector('.trophyIconEditButton[data-value="icon"]'); - const input = badgeContainer.querySelector('input[name="iconName"]'); - button.addEventListener("click", () => { - (0, FontAwesome_1.open)((icon, forceSolid, value, previewHtml) => { - input.value = value ?? `${icon};${String(forceSolid)}`; - if (typeof previewHtml === "string") { - previewWrapper.innerHTML = previewHtml; - } - else { - renderNativePreview(icon, forceSolid); - } - }); - }); - } - function setupChangeColor() { - const button = badgeContainer.querySelector('.trophyIconEditButton[data-value="color"]'); - const input = badgeContainer.querySelector('input[name="iconColor"]'); - button.dataset.store = Util_1.default.identify(input); - new Picker_1.default(button, { - callbackSubmit() { - previewWrapper.style.setProperty("color", input.value); - }, - }); - } - function setupChangeBackgroundColor() { - const button = badgeContainer.querySelector('.trophyIconEditButton[data-value="background-color"]'); - const input = badgeContainer.querySelector('input[name="badgeColor"]'); - button.dataset.store = Util_1.default.identify(input); - new Picker_1.default(button, { - callbackSubmit() { - previewWrapper.style.setProperty("background-color", input.value); - }, - }); - } - function setup() { - setupChangeIcon(); - setupChangeColor(); - setupChangeBackgroundColor(); - } -}); diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Acp/Ui/Trophy/Editor.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Acp/Ui/Trophy/Editor.js deleted file mode 100644 index 7ac9ca59945..00000000000 --- a/wcfsetup/install/files/js/WoltLabSuite/Core/Acp/Ui/Trophy/Editor.js +++ /dev/null @@ -1,57 +0,0 @@ -/** - * Switches between trophy types, automatic awarding of - * trophies and initialized the badge editor. - * - * @author Alexander Ebert - * @copyright 2001-2022 WoltLab GmbH - * @license GNU Lesser General Public License - */ -define(["require", "exports", "./Badge"], function (require, exports, Badge_1) { - "use strict"; - Object.defineProperty(exports, "__esModule", { value: true }); - exports.setup = setup; - function setupTypeChange() { - const badgeContainer = document.getElementById("badgeContainer"); - const imageContainer = document.getElementById("imageContainer"); - const typeSelection = document.querySelector("select[name=type]"); - typeSelection.addEventListener("change", () => { - if (typeSelection.value === "1" /* TrophyType.Image */) { - badgeContainer.hidden = true; - imageContainer.hidden = false; - } - else if (typeSelection.value === "2" /* TrophyType.Badge */) { - badgeContainer.hidden = false; - imageContainer.hidden = true; - } - }); - } - function setupAwardConditions() { - const awardAutomatically = document.querySelector("input[name=awardAutomatically]"); - const revokeContainer = document.getElementById("revokeAutomaticallyDL"); - const revokeCheckbox = revokeContainer.querySelector("input"); - awardAutomatically.addEventListener("change", () => { - document.querySelectorAll(".conditionSection").forEach((section) => { - if (awardAutomatically.checked) { - section.hidden = false; - } - else { - section.hidden = true; - } - }); - if (awardAutomatically) { - revokeContainer.classList.remove("disabled"); - revokeCheckbox.disabled = false; - } - else { - revokeContainer.classList.add("disabled"); - revokeCheckbox.disabled = true; - revokeCheckbox.checked = false; - } - }); - } - function setup() { - setupTypeChange(); - setupAwardConditions(); - (0, Badge_1.setup)(); - } -}); diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Acp/Ui/Trophy/Upload.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Acp/Ui/Trophy/Upload.js deleted file mode 100644 index fafa5bffd67..00000000000 --- a/wcfsetup/install/files/js/WoltLabSuite/Core/Acp/Ui/Trophy/Upload.js +++ /dev/null @@ -1,42 +0,0 @@ -/** - * Handles the trophy image upload. - * - * @author Joshua Ruesweg - * @copyright 2001-2019 WoltLab GmbH - * @license GNU Lesser General Public License - */ -define(["require", "exports", "tslib", "WoltLabSuite/Core/Component/Snackbar", "../../../Core", "../../../Dom/Util", "WoltLabSuite/Core/Language", "../../../Upload"], function (require, exports, tslib_1, Snackbar_1, Core, Util_1, Language_1, Upload_1) { - "use strict"; - Core = tslib_1.__importStar(Core); - Util_1 = tslib_1.__importDefault(Util_1); - Upload_1 = tslib_1.__importDefault(Upload_1); - class TrophyUpload extends Upload_1.default { - trophyId; - tmpHash; - constructor(trophyId, tmpHash, options) { - super("uploadIconFileButton", "uploadIconFileContent", Core.extend({ - className: "wcf\\data\\trophy\\TrophyAction", - }, options)); - this.trophyId = ~~trophyId; - this.tmpHash = tmpHash; - } - _getParameters() { - return { - trophyID: this.trophyId, - tmpHash: this.tmpHash, - }; - } - _success(uploadId, data) { - Util_1.default.innerError(this._button, false); - this._target.innerHTML = ``; - (0, Snackbar_1.showDefaultSuccessSnackbar)(); - } - _failure(uploadId, data) { - Util_1.default.innerError(this._button, (0, Language_1.getPhrase)(`wcf.acp.trophy.imageUpload.error.${data.returnValues.errorType}`)); - // remove previous images - this._target.innerHTML = ""; - return false; - } - } - return TrophyUpload; -}); diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Form/Builder/Field/Controller/TrophyBadge.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Form/Builder/Field/Controller/TrophyBadge.js new file mode 100644 index 00000000000..decdc2c292e --- /dev/null +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Form/Builder/Field/Controller/TrophyBadge.js @@ -0,0 +1,67 @@ +/** + * Enables editing of the icon, the icon color and the background color of a + * badge trophy. + * + * @author Alexander Ebert, Marcel Werk + * @copyright 2001-2026 WoltLab GmbH + * @license GNU Lesser General Public License + * @since 6.3 + * @woltlabExcludeBundle all + */ +define(["require", "exports", "tslib", "../../../../Ui/Style/FontAwesome", "../../../../Ui/Color/Picker"], function (require, exports, tslib_1, FontAwesome_1, Picker_1) { + "use strict"; + Object.defineProperty(exports, "__esModule", { value: true }); + exports.setup = setup; + Picker_1 = tslib_1.__importDefault(Picker_1); + function getInput(fieldId, name) { + const input = document.getElementById(`${fieldId}_${name}`); + if (!(input instanceof HTMLInputElement)) { + throw new Error(`Unknown input field '${name}' of the field '${fieldId}'.`); + } + return input; + } + function getButton(container, value) { + return container.querySelector(`button[data-value="${value}"]`); + } + function renderNativePreview(preview, icon, forceSolid) { + let previewIcon = preview.querySelector("fa-icon"); + if (!(previewIcon instanceof HTMLElement) || previewIcon.parentElement !== preview) { + preview.replaceChildren(); + previewIcon = document.createElement("fa-icon"); + previewIcon.size = 64; + preview.append(previewIcon); + } + previewIcon.setIcon(icon, forceSolid); + } + function setupChangeIcon(container, preview, input) { + getButton(container, "icon").addEventListener("click", () => { + (0, FontAwesome_1.open)((icon, forceSolid, value, previewHtml) => { + input.value = value ?? `${icon};${String(forceSolid)}`; + if (typeof previewHtml === "string") { + preview.innerHTML = previewHtml; + } + else { + renderNativePreview(preview, icon, forceSolid); + } + }); + }); + } + function setupColorPicker(button, preview, input, property) { + button.dataset.store = input.id; + new Picker_1.default(button, { + callbackSubmit() { + preview.style.setProperty(property, input.value); + }, + }); + } + function setup(fieldId) { + const container = document.getElementById(`${fieldId}_container`); + if (container === null) { + throw new Error(`Unknown field with id '${fieldId}'.`); + } + const preview = container.querySelector(".trophyIcon"); + setupChangeIcon(container, preview, getInput(fieldId, "iconName")); + setupColorPicker(getButton(container, "color"), preview, getInput(fieldId, "iconColor"), "color"); + setupColorPicker(getButton(container, "background-color"), preview, getInput(fieldId, "badgeColor"), "background-color"); + } +}); diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Form/Builder/Field/TrophyBadge.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Form/Builder/Field/TrophyBadge.js new file mode 100644 index 00000000000..2054ae7fa90 --- /dev/null +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Form/Builder/Field/TrophyBadge.js @@ -0,0 +1,24 @@ +define(["require", "exports", "tslib", "./Field"], function (require, exports, tslib_1, Field_1) { + "use strict"; + Field_1 = tslib_1.__importDefault(Field_1); + class TrophyBadge extends Field_1.default { + _getData() { + return { + [this._fieldId]: { + iconName: this.#getValue("iconName"), + iconColor: this.#getValue("iconColor"), + badgeColor: this.#getValue("badgeColor"), + }, + }; + } + _readField() { } + #getValue(name) { + const input = document.getElementById(`${this._fieldId}_${name}`); + if (!(input instanceof HTMLInputElement)) { + throw new Error(`Unknown input field '${name}' of the field '${this._fieldId}'.`); + } + return input.value; + } + } + return TrophyBadge; +}); diff --git a/wcfsetup/install/files/lib/acp/form/TrophyAddForm.class.php b/wcfsetup/install/files/lib/acp/form/TrophyAddForm.class.php index 5f71b1f9a0f..a92b41eabfc 100644 --- a/wcfsetup/install/files/lib/acp/form/TrophyAddForm.class.php +++ b/wcfsetup/install/files/lib/acp/form/TrophyAddForm.class.php @@ -2,29 +2,41 @@ namespace wcf\acp\form; -use wcf\data\object\type\ObjectType; -use wcf\data\trophy\category\TrophyCategory; +use wcf\command\trophy\CreateTrophy; +use wcf\command\trophy\UpdateTrophy; +use wcf\data\DatabaseObjectBuilder; use wcf\data\trophy\category\TrophyCategoryCache; use wcf\data\trophy\Trophy; -use wcf\data\trophy\TrophyAction; -use wcf\data\trophy\TrophyEditor; -use wcf\system\condition\ConditionHandler; -use wcf\system\exception\UserInputException; -use wcf\system\language\I18nValue; -use wcf\system\request\LinkHandler; -use wcf\system\style\FontAwesomeIcon; -use wcf\system\trophy\condition\TrophyConditionHandler; +use wcf\data\trophy\TrophyBuilder; +use wcf\form\AbstractDatabaseObjectBuilderForm; +use wcf\system\exception\NamedUserException; +use wcf\system\form\builder\container\FormContainer; +use wcf\system\form\builder\field\BooleanFormField; +use wcf\system\form\builder\field\dependency\NonEmptyFormFieldDependency; +use wcf\system\form\builder\field\dependency\ValueFormFieldDependency; +use wcf\system\form\builder\field\FileProcessorFormField; +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\SingleSelectionFormField; +use wcf\system\form\builder\field\TitleFormField; +use wcf\system\form\builder\field\trophy\TrophyBadgeFormField; +use wcf\system\object\filter\builder\TrophyObjectFilterBuilder; use wcf\system\WCF; -use wcf\util\StringUtil; +use wcf\util\HtmlString; /** * Represents the trophy add form. * - * @author Joshua Ruesweg - * @copyright 2001-2019 WoltLab GmbH + * @author Joshua Ruesweg, Marcel Werk + * @copyright 2001-2026 WoltLab GmbH * @license GNU Lesser General Public License + * + * @extends AbstractDatabaseObjectBuilderForm */ -class TrophyAddForm extends AbstractAcpForm +class TrophyAddForm extends AbstractDatabaseObjectBuilderForm { /** * @inheritDoc @@ -42,367 +54,227 @@ class TrophyAddForm extends AbstractAcpForm public $neededModules = ['MODULE_TROPHY']; /** - * category id for the trophy. - * @var int - */ - public $categoryID = 0; - - /** - * Category object. - * @var ?TrophyCategory - */ - public $category; - - /** - * Trophy description. - * @var string - */ - public $description = ''; - - /** - * Trophy title. - * @var string - */ - public $title = ''; - - /** - * All available trophy types. - * @var array - */ - public $availableTypes = [ - Trophy::TYPE_IMAGE => 'imageUpload', - Trophy::TYPE_BADGE => 'badge', - ]; - - /** - * Type of the trophy (whether this is an image or not) - * @var int - */ - public $type = Trophy::TYPE_BADGE; - - /** - * temporary hash for image icon - * @var string - */ - public $tmpHash = ''; - - /** - * the url for the uploaded image - * @var string - */ - public $uploadedImageURL = ''; - - /** - * the icon name for CSS icons (FA-Icon) - * @var string - */ - public $iconName = "trophy;false"; - - /** - * The icon color (rgba format with rgba prefix) - * @var string - */ - public $iconColor = 'rgba(255, 255, 255, 1)'; - - /** - * The badge color (rgba format with rgba prefix) - * @var string - */ - public $badgeColor = 'rgba(50, 92, 132, 1)'; - - /** - * `1` if the trophy is disabled. - * @var int - */ - public $isDisabled = 0; - - /** - * `1` if the trophy has conditions to reward automatically trophies. - * @var int - */ - public $awardAutomatically = 0; - - /** - * `1` if the trophy should be automatically revoked once the conditions are no longer met. - * @var int - */ - public $revokeAutomatically = 0; - - /** - * `1` if the trophy contains html in the description - * @var int - */ - public $trophyUseHtml = 0; - - /** - * list of grouped user group assignment condition object types - * @var (ObjectType|ObjectType[])[][] - */ - public $conditions = []; - - /** - * the showOrder value of the trophy - * @var int + * @inheritDoc */ - public $showOrder = 0; - - #[\Override] - public function readData() - { - $this->conditions = TrophyConditionHandler::getInstance()->getGroupedObjectTypes(); - - parent::readData(); - } + public string $objectEditLinkController = TrophyEditForm::class; #[\Override] public function readParameters() { parent::readParameters(); - $titleI18n = new I18nValue('title'); - $titleI18n->setLanguageItem('wcf.user.trophy.title', 'wcf.user.trophy', 'com.woltlab.wcf'); - $this->registerI18nValue($titleI18n); - - $descriptionI18n = new I18nValue('description'); - $descriptionI18n->setLanguageItem('wcf.user.trophy.description', 'wcf.user.trophy', 'com.woltlab.wcf'); - $descriptionI18n->setFlags(I18nValue::ALLOW_EMPTY); - $this->registerI18nValue($descriptionI18n); - - if (isset($_POST['tmpHash'])) { - $this->tmpHash = StringUtil::trim($_POST['tmpHash']); - } - - if (empty($this->tmpHash)) { - $this->tmpHash = StringUtil::getRandomID(); + if (TrophyCategoryCache::getInstance()->getCategories() === []) { + throw new NamedUserException(HtmlString::fromSafeHtml( + WCF::getLanguage()->getDynamicVariable('wcf.acp.trophy.error.noCategories') + )); } } #[\Override] - public function readFormParameters() + protected function getDatabaseObjectBuilder(): TrophyBuilder { - parent::readFormParameters(); - - if (isset($_POST['categoryID'])) { - $this->categoryID = \intval($_POST['categoryID']); - } - if (isset($_POST['type'])) { - $this->type = \intval($_POST['type']); - } - if (isset($_POST['isDisabled'])) { - $this->isDisabled = \intval($_POST['isDisabled']); - } - if (isset($_POST['iconName'])) { - $this->iconName = StringUtil::trim($_POST['iconName']); - } - if (isset($_POST['iconColor'])) { - $this->iconColor = $_POST['iconColor']; - } - if (isset($_POST['badgeColor'])) { - $this->badgeColor = $_POST['badgeColor']; - } - if (isset($_POST['awardAutomatically'])) { - $this->awardAutomatically = 1; + if ($this->formObject !== null) { + return TrophyBuilder::forUpdate($this->formObject); } - if (isset($_POST['revokeAutomatically']) && $this->awardAutomatically !== 0) { - $this->revokeAutomatically = 1; - } - if (isset($_POST['trophyUseHtml'])) { - $this->trophyUseHtml = 1; - } - if (isset($_POST['showOrder'])) { - $this->showOrder = \intval($_POST['showOrder']); - } - - // read file upload - $fileExtension = WCF::getSession()->getVar('trophyImage-' . $this->tmpHash); - - if ($fileExtension !== null && \file_exists(\WCF_DIR . 'images/trophy/tmp_' . $this->tmpHash . '.' . $fileExtension)) { - $this->uploadedImageURL = WCF::getPath() . 'images/trophy/tmp_' . $this->tmpHash . '.' . $fileExtension; - } - - $this->category = TrophyCategoryCache::getInstance()->getCategoryByID($this->categoryID); - foreach ($this->conditions as $conditions) { - /** @var ObjectType $condition */ - foreach ($conditions as $condition) { - $condition->getProcessor()->readFormParameters(); - } - } + return TrophyBuilder::forCreate(); } #[\Override] - public function validate() + protected function getCommand(DatabaseObjectBuilder $builder): callable { - parent::validate(); - - if (!\in_array($this->type, \array_keys($this->availableTypes), true)) { - throw new UserInputException('type'); - } - - if ($this->categoryID === 0) { - throw new UserInputException('categoryID'); + if ($this->formObject !== null) { + return new UpdateTrophy($builder); } - if ($this->category->isNil()) { - throw new UserInputException('categoryID'); - } - - $this->validateType(); - - if ($this->awardAutomatically !== 0) { - $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'); - } - } - } - - /** - * Validates the trophy type. - * - * @return void - */ - protected function validateType() - { - switch ($this->type) { - case Trophy::TYPE_IMAGE: - $fileExtension = WCF::getSession()->getVar('trophyImage-' . $this->tmpHash); - - if ($fileExtension === null) { - throw new UserInputException('imageUpload'); - } - - if (!\file_exists(\WCF_DIR . 'images/trophy/tmp_' . $this->tmpHash . '.' . $fileExtension)) { - throw new UserInputException('imageUpload'); - } - break; - - case Trophy::TYPE_BADGE: - if (empty($this->iconName)) { - throw new UserInputException('iconName'); - } - - if (!FontAwesomeIcon::isValidString($this->iconName)) { - throw new UserInputException('iconName'); - } - - if (empty($this->iconColor)) { - throw new UserInputException('iconColor'); - } - - if (empty($this->badgeColor)) { - throw new UserInputException('badgeColor'); - } - break; - } + return new CreateTrophy($builder); } #[\Override] - public function save() + protected function createForm(): void { - parent::save(); - - $data = []; - if ($this->type === Trophy::TYPE_BADGE) { - $data['iconName'] = $this->iconName; - $data['iconColor'] = $this->iconColor; - $data['badgeColor'] = $this->badgeColor; - } - - $this->objectAction = new TrophyAction([], 'create', [ - 'data' => \array_merge($this->additionalFields, $data, [ - 'title' => $this->title, - 'description' => $this->description, - 'categoryID' => $this->categoryID, - 'type' => $this->type, - 'isDisabled' => $this->isDisabled, - 'awardAutomatically' => $this->awardAutomatically, - 'revokeAutomatically' => $this->revokeAutomatically, - 'trophyUseHtml' => $this->trophyUseHtml, - 'showOrder' => $this->showOrder, - ]), - 'tmpHash' => $this->tmpHash, - ]); - $returnValues = $this->objectAction->executeAction(); - - $this->saveI18n($returnValues['returnValues'], TrophyEditor::class); - - // transform conditions array into one-dimensional array - $conditions = []; - foreach ($this->conditions as $groupedObjectTypes) { - foreach ($groupedObjectTypes as $objectTypes) { - if (\is_array($objectTypes)) { - $conditions = \array_merge($conditions, $objectTypes); - } else { - $conditions[] = $objectTypes; - } - } - } - - ConditionHandler::getInstance()->createConditions($returnValues['returnValues']->trophyID, $conditions); - - $this->reset(); - - WCF::getTPL()->assign([ - 'objectEditLink' => LinkHandler::getInstance()->getControllerLink( - TrophyEditForm::class, - ['id' => $returnValues['returnValues']->trophyID] - ), + parent::createForm(); + + $this->form->appendChildren([ + FormContainer::create('data') + ->appendChildren([ + TitleFormField::create() + ->l10n() + ->required() + ->autoFocus() + ->maximumLength(255) + ->saveValueCallback(static function (TrophyBuilder $builder, TitleFormField $field): void { + $builder->setTitle($field->getL10nValues()); + }) + ->loadValueCallback(static function (Trophy $object, IFormField $field): void { + $field->value($object->getL10nValues('title')); + }), + MultilineTextFormField::create('description') + ->label('wcf.acp.trophy.description') + ->l10n() + ->saveValueCallback(static function (TrophyBuilder $builder, MultilineTextFormField $field): void { + $builder->setDescription($field->getL10nValues()); + }) + ->loadValueCallback(static function (Trophy $object, IFormField $field): void { + $field->value($object->getL10nValues('description')); + }), + BooleanFormField::create('trophyUseHtml') + ->label('wcf.acp.trophy.trophyUseHtml') + ->saveValueCallback(static function (TrophyBuilder $builder, BooleanFormField $field): void { + $builder->setTrophyUseHtml((bool)$field->getSaveValue()); + }) + ->loadValueCallback(static function (Trophy $object, IFormField $field): void { + $field->value($object->trophyUseHtml); + }), + SingleSelectionFormField::create('categoryID') + ->label('wcf.global.category') + ->options($this->getCategories(), labelLanguageItems: false) + ->required() + ->saveValueCallback(static function (TrophyBuilder $builder, SingleSelectionFormField $field): void { + $builder->setCategoryID((int)$field->getSaveValue()); + }) + ->loadValueCallback(static function (Trophy $object, IFormField $field): void { + $field->value($object->categoryID); + }), + IntegerFormField::create('showOrder') + ->label('wcf.global.showOrder') + ->description('wcf.acp.trophy.showOrder.description') + ->minimum(0) + ->value(0) + ->saveValueCallback(static function (TrophyBuilder $builder, IntegerFormField $field): void { + $builder->setShowOrder($field->getSaveValue()); + }) + ->loadValueCallback(static function (Trophy $object, IFormField $field): void { + $field->value($object->showOrder); + }), + BooleanFormField::create('isDisabled') + ->label('wcf.acp.trophy.isDisabled') + ->saveValueCallback(static function (TrophyBuilder $builder, BooleanFormField $field): void { + $builder->setIsDisabled((bool)$field->getSaveValue()); + }) + ->loadValueCallback(static function (Trophy $object, IFormField $field): void { + $field->value($object->isDisabled); + }), + BooleanFormField::create('awardAutomatically') + ->label('wcf.acp.trophy.awardAutomatically') + ->saveValueCallback(static function (TrophyBuilder $builder, BooleanFormField $field): void { + $awardAutomatically = (bool)$field->getSaveValue(); + $builder->setAwardAutomatically($awardAutomatically); + + // The dependent fields are not saved if the trophy is + // no longer awarded automatically. + if (!$awardAutomatically) { + $builder->setRevokeAutomatically(false); + $builder->setConditions(null); + } + }) + ->loadValueCallback(static function (Trophy $object, IFormField $field): void { + $field->value($object->awardAutomatically); + }), + BooleanFormField::create('revokeAutomatically') + ->label('wcf.acp.trophy.revokeAutomatically') + ->saveValueCallback(static function (TrophyBuilder $builder, BooleanFormField $field): void { + $builder->setRevokeAutomatically((bool)$field->getSaveValue()); + }) + ->loadValueCallback(static function (Trophy $object, IFormField $field): void { + $field->value($object->revokeAutomatically); + }) + ->addDependency( + NonEmptyFormFieldDependency::create('awardAutomatically') + ->fieldId('awardAutomatically') + ), + RadioButtonFormField::create('type') + ->label('wcf.acp.trophy.type') + ->options([ + Trophy::TYPE_BADGE => 'wcf.acp.trophy.type.badge', + Trophy::TYPE_IMAGE => 'wcf.acp.trophy.type.imageUpload', + ]) + ->value(Trophy::TYPE_BADGE) + ->required() + ->saveValueCallback(static function (TrophyBuilder $builder, RadioButtonFormField $field): void { + $builder->setType((int)$field->getSaveValue()); + }) + ->loadValueCallback(static function (Trophy $object, IFormField $field): void { + $field->value($object->type); + }), + ]), + FormContainer::create('badgeContainer') + ->label('wcf.acp.trophy.type.badge') + ->appendChild( + TrophyBadgeFormField::create('badge') + ->label('wcf.acp.trophy.type.badge') + ->required() + ->saveValueCallback(static function (TrophyBuilder $builder, TrophyBadgeFormField $field): void { + $builder->setBadge($field->getIconName(), $field->getIconColor(), $field->getBadgeColor()); + }) + ->loadValueCallback(static function (Trophy $object, IFormField $field): void { + // Image trophies keep the default badge. + if ($object->type === Trophy::TYPE_BADGE) { + $field->value([ + 'iconName' => $object->iconName, + 'iconColor' => $object->iconColor, + 'badgeColor' => $object->badgeColor, + ]); + } + }) + ) + ->addDependency( + ValueFormFieldDependency::create('type') + ->fieldId('type') + ->values([Trophy::TYPE_BADGE]) + ), + FormContainer::create('imageContainer') + ->label('wcf.acp.trophy.type.imageUpload') + ->appendChild( + FileProcessorFormField::create('iconFileID') + ->objectType('com.woltlab.wcf.trophy.image') + ->label('wcf.acp.trophy.type.imageUpload') + ->description('wcf.acp.trophy.type.imageUpload.description') + ->singleFileUpload() + ->bigPreview() + ->required() + ->saveValueCallback(static function (TrophyBuilder $builder, FileProcessorFormField $field): void { + $builder->setIconFileID($field->getSaveValue()); + }) + ->loadValueCallback(static function (Trophy $object, IFormField $field): void { + if ($object->iconFileID !== null) { + $field->value($object->iconFileID); + } + }) + ) + ->addDependency( + ValueFormFieldDependency::create('type') + ->fieldId('type') + ->values([Trophy::TYPE_IMAGE]) + ), + FormContainer::create('conditionsContainer') + ->label('wcf.acp.trophy.conditions') + ->description('wcf.acp.trophy.conditions.description') + ->appendChild( + ObjectFilterFormField::create('conditions') + ->builder(new TrophyObjectFilterBuilder()) + ->required() + ->saveValueCallback(static function (TrophyBuilder $builder, ObjectFilterFormField $field): void { + $builder->setConditions($field->getSaveValue()); + }) + ) + ->addDependency( + NonEmptyFormFieldDependency::create('awardAutomatically') + ->fieldId('awardAutomatically') + ), ]); } - #[\Override] - public function reset() + /** + * @return array + */ + private function getCategories(): array { - parent::reset(); - - $this->isDisabled = $this->awardAutomatically = $this->categoryID = $this->trophyUseHtml = $this->showOrder = $this->revokeAutomatically = 0; - $this->type = Trophy::TYPE_BADGE; - $this->iconName = $this->uploadedImageURL = ''; - $this->iconColor = 'rgba(255, 255, 255, 1)'; - $this->badgeColor = 'rgba(50, 92, 132, 1)'; - $this->iconName = 'trophy;false'; - $this->tmpHash = StringUtil::getRandomID(); - - foreach ($this->conditions as $conditions) { - foreach ($conditions as $condition) { - $condition->getProcessor()->reset(); - } + $categories = []; + foreach (TrophyCategoryCache::getInstance()->getCategories() as $category) { + $categories[$category->categoryID] = $category->getTitle(); } - } - #[\Override] - public function assignVariables() - { - parent::assignVariables(); - - WCF::getTPL()->assign([ - 'categoryID' => $this->categoryID, - 'type' => $this->type, - 'isDisabled' => $this->isDisabled, - 'iconName' => $this->iconName, - 'iconColor' => $this->iconColor, - 'badgeColor' => $this->badgeColor, - 'icon' => FontAwesomeIcon::fromString($this->iconName), - 'trophyCategories' => TrophyCategoryCache::getInstance()->getCategories(), - 'groupedObjectTypes' => $this->conditions, - 'awardAutomatically' => $this->awardAutomatically, - 'revokeAutomatically' => $this->revokeAutomatically, - 'availableTypes' => $this->availableTypes, - 'tmpHash' => $this->tmpHash, - 'uploadedImageURL' => $this->uploadedImageURL, - 'trophyUseHtml' => $this->trophyUseHtml, - 'showOrder' => $this->showOrder, - ]); + return $categories; } } diff --git a/wcfsetup/install/files/lib/acp/form/TrophyEditForm.class.php b/wcfsetup/install/files/lib/acp/form/TrophyEditForm.class.php index ab2e6d0a26d..f01bc2d405e 100644 --- a/wcfsetup/install/files/lib/acp/form/TrophyEditForm.class.php +++ b/wcfsetup/install/files/lib/acp/form/TrophyEditForm.class.php @@ -3,28 +3,18 @@ namespace wcf\acp\form; use wcf\acp\page\TrophyListPage; -use wcf\command\user\UpdateUser; use wcf\data\trophy\Trophy; -use wcf\data\trophy\TrophyAction; -use wcf\data\user\User; -use wcf\data\user\UserBuilder; use wcf\http\Helper; -use wcf\system\condition\ConditionHandler; -use wcf\system\database\util\PreparedStatementConditionBuilder; -use wcf\system\exception\UserInputException; use wcf\system\interaction\admin\TrophyInteractions; use wcf\system\interaction\StandaloneInteractionContextMenuComponent; -use wcf\system\language\I18nHandler; use wcf\system\request\LinkHandler; -use wcf\system\trophy\condition\TrophyConditionHandler; -use wcf\system\user\storage\UserStorageHandler; use wcf\system\WCF; /** * Represents the trophy edit form. * - * @author Joshua Ruesweg - * @copyright 2001-2019 WoltLab GmbH + * @author Joshua Ruesweg, Marcel Werk + * @copyright 2001-2026 WoltLab GmbH * @license GNU Lesser General Public License */ class TrophyEditForm extends TrophyAddForm @@ -37,193 +27,14 @@ class TrophyEditForm extends TrophyAddForm /** * @inheritDoc */ - public $action = 'edit'; - - /** - * trophy object - * @var ?Trophy - */ - public $trophy; + public string $formAction = 'edit'; #[\Override] public function readParameters() { - $this->trophy = Helper::fetchObjectFromQueryParameter(Trophy::class); - parent::readParameters(); - } - - #[\Override] - public function readData() - { - parent::readData(); - - if ($_POST === []) { - $this->readDataI18n($this->trophy); - - $this->categoryID = $this->trophy->categoryID; - $this->type = $this->trophy->type; - $this->isDisabled = $this->trophy->isDisabled; - $this->iconName = $this->trophy->iconName; - $this->iconColor = $this->trophy->iconColor; - $this->badgeColor = $this->trophy->badgeColor; - $this->awardAutomatically = $this->trophy->awardAutomatically; - $this->revokeAutomatically = $this->trophy->revokeAutomatically; - $this->trophyUseHtml = $this->trophy->trophyUseHtml; - $this->showOrder = $this->trophy->showOrder; - - // reset badge values for non badge trophies - if ($this->trophy->type !== Trophy::TYPE_BADGE) { - $this->iconName = 'trophy;false'; - $this->iconColor = 'rgba(255, 255, 255, 1)'; - $this->badgeColor = 'rgba(50, 92, 132, 1)'; - } - - $conditions = $this->trophy->getConditions(); - $conditionsByObjectTypeID = []; - foreach ($conditions as $condition) { - $conditionsByObjectTypeID[$condition->objectTypeID] = $condition; - } - - foreach ($this->conditions 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]); - } - } - } - } - } - - #[\Override] - protected function validateType() - { - switch ($this->type) { - case Trophy::TYPE_IMAGE: - if (empty($this->trophy->iconFile) || !\file_exists(\WCF_DIR . 'images/trophy/' . $this->trophy->iconFile)) { - throw new UserInputException('imageUpload'); - } - break; - - case Trophy::TYPE_BADGE: - if (empty($this->iconName)) { - throw new UserInputException('iconName'); - } - - if (empty($this->iconColor)) { - throw new UserInputException('iconColor'); - } - if (empty($this->badgeColor)) { - throw new UserInputException('badgeColor'); - } - break; - } - } - - #[\Override] - public function save() - { - AbstractAcpForm::save(); - - $this->beforeSaveI18n($this->trophy); - - $data = []; - if ($this->type === Trophy::TYPE_IMAGE) { - $data['iconName'] = ''; - $data['iconColor'] = ''; - $data['badgeColor'] = ''; - } elseif ($this->type === Trophy::TYPE_BADGE) { - // delete old image icon - if (\is_file(\WCF_DIR . 'images/trophy/' . $this->trophy->iconFile)) { - @\unlink(\WCF_DIR . 'images/trophy/' . $this->trophy->iconFile); - } - - $data['iconName'] = $this->iconName; - $data['iconColor'] = $this->iconColor; - $data['badgeColor'] = $this->badgeColor; - $data['iconFile'] = ''; - } - - $this->objectAction = new TrophyAction([$this->trophy], 'update', [ - 'data' => \array_merge($this->additionalFields, $data, [ - 'title' => $this->title, - 'description' => $this->description, - 'categoryID' => $this->categoryID, - 'type' => $this->type, - 'isDisabled' => $this->isDisabled, - 'awardAutomatically' => $this->awardAutomatically, - 'revokeAutomatically' => $this->revokeAutomatically, - 'trophyUseHtml' => $this->trophyUseHtml, - 'showOrder' => $this->showOrder, - ]), - ]); - $this->objectAction->executeAction(); - - // transform conditions array into one-dimensional array - $conditions = []; - foreach ($this->conditions as $groupedObjectTypes) { - foreach ($groupedObjectTypes as $objectTypes) { - if (\is_array($objectTypes)) { - $conditions = \array_merge($conditions, $objectTypes); - } else { - $conditions[] = $objectTypes; - } - } - } - - if ($this->awardAutomatically !== 0) { - ConditionHandler::getInstance()->updateConditions( - $this->trophy->trophyID, - $this->trophy->getConditions(), - $conditions - ); - } else { - ConditionHandler::getInstance()->deleteConditions( - TrophyConditionHandler::CONDITION_DEFINITION_NAME, - [$this->trophy->trophyID] - ); - } - - // reset special trophies, if trophy is disabled - if ($this->isDisabled !== 0) { - $sql = "DELETE FROM wcf1_user_special_trophy - WHERE trophyID = ?"; - $statement = WCF::getDB()->prepare($sql); - $statement->execute([$this->trophy->trophyID]); - - UserStorageHandler::getInstance()->resetAll('specialTrophies'); - } - - if ($this->isDisabled !== $this->trophy->isDisabled) { - // update trophy points - $conditionBuilder = new PreparedStatementConditionBuilder(); - $conditionBuilder->add('trophyID = ?', [$this->trophy->trophyID]); - $sql = "SELECT COUNT(*) as count, userID - FROM wcf1_user_trophy - " . $conditionBuilder . " - GROUP BY userID"; - $statement = WCF::getDB()->prepare($sql); - $statement->execute($conditionBuilder->getParameters()); - - while ($row = $statement->fetchArray()) { - (new UpdateUser( - UserBuilder::forUpdate(new User($row['userID'])) - ->incrementTrophyPoints($row['count'] * ($this->isDisabled !== 0 ? -1 : 1)) - ))(); - } - } - - $this->saved(); - - // show success message - WCF::getTPL()->assign('success', true); + $this->formObject = Helper::fetchObjectFromQueryParameter(Trophy::class); } #[\Override] @@ -231,13 +42,10 @@ public function assignVariables() { parent::assignVariables(); - I18nHandler::getInstance()->assignVariables($_POST !== []); - WCF::getTPL()->assign([ - 'trophy' => $this->trophy, 'interactionContextMenu' => StandaloneInteractionContextMenuComponent::forContentHeaderButton( new TrophyInteractions(), - $this->trophy, + $this->formObject, LinkHandler::getInstance()->getControllerLink(TrophyListPage::class) ), ]); diff --git a/wcfsetup/install/files/lib/bootstrap/com.woltlab.wcf.php b/wcfsetup/install/files/lib/bootstrap/com.woltlab.wcf.php index 50804b13162..ced0c222d5d 100644 --- a/wcfsetup/install/files/lib/bootstrap/com.woltlab.wcf.php +++ b/wcfsetup/install/files/lib/bootstrap/com.woltlab.wcf.php @@ -87,6 +87,7 @@ static function (\wcf\event\l10n\L10nDefinitionCollecting $event) { $event->register(\wcf\data\reaction\type\ReactionType::getL10nDefinition()); $event->register(\wcf\data\user\rank\UserRank::getL10nDefinition()); $event->register(\wcf\data\contact\recipient\ContactRecipient::getL10nDefinition()); + $event->register(\wcf\data\trophy\Trophy::getL10nDefinition()); } ); $eventHandler->register( @@ -206,6 +207,7 @@ private function initObjectFilters(): void 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\TrophyObjectFilterBuilder()); $event->register(new \wcf\system\object\filter\builder\UserGroupAssignmentObjectFilterBuilder()); } ); diff --git a/wcfsetup/install/files/lib/command/trophy/CreateTrophy.class.php b/wcfsetup/install/files/lib/command/trophy/CreateTrophy.class.php new file mode 100644 index 00000000000..eeba0f205bf --- /dev/null +++ b/wcfsetup/install/files/lib/command/trophy/CreateTrophy.class.php @@ -0,0 +1,34 @@ + + * @since 6.3 + */ +final class CreateTrophy +{ + public function __construct( + private readonly TrophyBuilder $builder, + ) {} + + public function __invoke(): Trophy + { + $trophy = $this->builder->create(); + + TrophyBuilder::resetCache(); + + EventHandler::getInstance()->fire(new TrophyCreated($trophy, $this->builder)); + + return $trophy; + } +} diff --git a/wcfsetup/install/files/lib/command/trophy/DeleteTrophy.class.php b/wcfsetup/install/files/lib/command/trophy/DeleteTrophy.class.php new file mode 100644 index 00000000000..73e79947b52 --- /dev/null +++ b/wcfsetup/install/files/lib/command/trophy/DeleteTrophy.class.php @@ -0,0 +1,75 @@ + + * @since 6.3 + */ +final class DeleteTrophy +{ + public function __construct( + private readonly Trophy $trophy, + ) {} + + public function __invoke(): void + { + $this->deleteUserTrophies(); + + TrophyBuilder::delete($this->trophy); + + // The image is deleted only after the trophy, because deleting a file cannot be rolled back. + if ($this->trophy->iconFileID !== null) { + $file = FileRuntimeCache::getInstance()->getObject($this->trophy->iconFileID); + if ($file !== null) { + new DeleteFiles([$file])(); + } + } + + TrophyBuilder::resetCache(); + UserStorageHandler::getInstance()->resetAll('specialTrophies'); + + EventHandler::getInstance()->fire(new TrophyDeleted($this->trophy)); + } + + /** + * Deletes the awarded trophies to update the trophy points of the users. + * The trophies of disabled trophies or categories do not count towards the + * trophy points and are deleted by the foreign key. + */ + private function deleteUserTrophies(): void + { + $userTrophyList = new UserTrophyList(); + if ($userTrophyList->sqlJoins !== '') { + $userTrophyList->sqlJoins .= ' '; + } + $userTrophyList->sqlJoins .= ' + LEFT JOIN wcf1_trophy trophy + ON user_trophy.trophyID = trophy.trophyID + LEFT JOIN wcf1_category category + ON trophy.categoryID = category.categoryID'; + + $userTrophyList->getConditionBuilder()->add('trophy.isDisabled = ?', [0]); + $userTrophyList->getConditionBuilder()->add('category.isDisabled = ?', [0]); + $userTrophyList->getConditionBuilder()->add('user_trophy.trophyID = ?', [$this->trophy->trophyID]); + $userTrophyList->readObjects(); + + if ($userTrophyList->getObjects() !== []) { + (new UserTrophyAction($userTrophyList->getObjects(), 'delete'))->executeAction(); + } + } +} diff --git a/wcfsetup/install/files/lib/command/trophy/DisableTrophy.class.php b/wcfsetup/install/files/lib/command/trophy/DisableTrophy.class.php index 72412f690e1..f5f4dc11e5b 100644 --- a/wcfsetup/install/files/lib/command/trophy/DisableTrophy.class.php +++ b/wcfsetup/install/files/lib/command/trophy/DisableTrophy.class.php @@ -3,7 +3,7 @@ namespace wcf\command\trophy; use wcf\data\trophy\Trophy; -use wcf\data\trophy\TrophyEditor; +use wcf\data\trophy\TrophyBuilder; use wcf\data\user\User; use wcf\data\user\UserBuilder; use wcf\event\trophy\TrophyDisabled; @@ -14,8 +14,8 @@ /** * Disables a trophy. * - * @author Olaf Braun - * @copyright 2001-2025 WoltLab GmbH + * @author Olaf Braun, Marcel Werk + * @copyright 2001-2026 WoltLab GmbH * @license GNU Lesser General Public License * @since 6.3 */ @@ -25,11 +25,11 @@ public function __construct(private readonly Trophy $trophy) {} public function __invoke(): void { - (new TrophyEditor($this->trophy))->update([ - 'isDisabled' => 1, - ]); + TrophyBuilder::forUpdate($this->trophy) + ->setIsDisabled(true) + ->update(); - TrophyEditor::resetCache(); + TrophyBuilder::resetCache(); $this->deleteSpecialTrophies($this->trophy->trophyID); $this->updateTrophyPoints($this->trophy->trophyID); diff --git a/wcfsetup/install/files/lib/command/trophy/EnableTrophy.class.php b/wcfsetup/install/files/lib/command/trophy/EnableTrophy.class.php index 595720c5a2c..3acc4715c1b 100644 --- a/wcfsetup/install/files/lib/command/trophy/EnableTrophy.class.php +++ b/wcfsetup/install/files/lib/command/trophy/EnableTrophy.class.php @@ -3,7 +3,7 @@ namespace wcf\command\trophy; use wcf\data\trophy\Trophy; -use wcf\data\trophy\TrophyEditor; +use wcf\data\trophy\TrophyBuilder; use wcf\data\user\User; use wcf\data\user\UserBuilder; use wcf\event\trophy\TrophyEnabled; @@ -14,8 +14,8 @@ /** * Enables a trophy. * - * @author Olaf Braun - * @copyright 2001-2025 WoltLab GmbH + * @author Olaf Braun, Marcel Werk + * @copyright 2001-2026 WoltLab GmbH * @license GNU Lesser General Public License * @since 6.3 */ @@ -25,11 +25,11 @@ public function __construct(private readonly Trophy $trophy) {} public function __invoke(): void { - (new TrophyEditor($this->trophy))->update([ - 'isDisabled' => 0, - ]); + TrophyBuilder::forUpdate($this->trophy) + ->setIsDisabled(false) + ->update(); - TrophyEditor::resetCache(); + TrophyBuilder::resetCache(); $this->updateTrophyPoints($this->trophy->trophyID); $this->resetUserStorage(); diff --git a/wcfsetup/install/files/lib/command/trophy/UpdateTrophy.class.php b/wcfsetup/install/files/lib/command/trophy/UpdateTrophy.class.php new file mode 100644 index 00000000000..3f9740c636e --- /dev/null +++ b/wcfsetup/install/files/lib/command/trophy/UpdateTrophy.class.php @@ -0,0 +1,47 @@ + + * @since 6.3 + */ +final class UpdateTrophy +{ + public function __construct( + private readonly TrophyBuilder $builder, + ) {} + + public function __invoke(): Trophy + { + $wasDisabled = $this->builder->getObject()->isDisabled; + + $trophy = $this->builder->update(); + + TrophyBuilder::resetCache(); + + if ($trophy->isDisabled !== $wasDisabled) { + if ($trophy->isDisabled !== 0) { + new DisableTrophy($trophy)(); + } else { + new EnableTrophy($trophy)(); + } + } + + EventHandler::getInstance()->fire(new TrophyUpdated($trophy, $this->builder)); + + return $trophy; + } +} diff --git a/wcfsetup/install/files/lib/data/trophy/I18nTrophyList.class.php b/wcfsetup/install/files/lib/data/trophy/I18nTrophyList.class.php deleted file mode 100644 index 27d595da41c..00000000000 --- a/wcfsetup/install/files/lib/data/trophy/I18nTrophyList.class.php +++ /dev/null @@ -1,28 +0,0 @@ - - * @since 6.2 - * - * @extends I18nDatabaseObjectList - */ -class I18nTrophyList extends I18nDatabaseObjectList -{ - /** - * @inheritDoc - */ - public $i18nFields = ['title' => 'titleI18n']; - - /** - * @inheritDoc - */ - public $className = Trophy::class; -} diff --git a/wcfsetup/install/files/lib/data/trophy/L10nTrophyList.class.php b/wcfsetup/install/files/lib/data/trophy/L10nTrophyList.class.php new file mode 100644 index 00000000000..a624cd94286 --- /dev/null +++ b/wcfsetup/install/files/lib/data/trophy/L10nTrophyList.class.php @@ -0,0 +1,34 @@ + + * @since 6.3 + */ +class L10nTrophyList extends TrophyList +{ + /** + * @inheritDoc + */ + public $className = Trophy::class; + + public function __construct() + { + parent::__construct(); + + $storage = new L10nStorage(Trophy::getL10nDefinition()); + + $this->sqlSelects .= ($this->sqlSelects !== '' ? ', ' : '') + . $storage->getSubSelect('title', $this->getDatabaseTableAlias()) + . ' AS title, ' + . $storage->getSubSelect('description', $this->getDatabaseTableAlias()) + . ' AS description'; + } +} diff --git a/wcfsetup/install/files/lib/data/trophy/Trophy.class.php b/wcfsetup/install/files/lib/data/trophy/Trophy.class.php index 4dbebbfe1a0..1e0d6f40f98 100644 --- a/wcfsetup/install/files/lib/data/trophy/Trophy.class.php +++ b/wcfsetup/install/files/lib/data/trophy/Trophy.class.php @@ -2,14 +2,18 @@ namespace wcf\data\trophy; +use wcf\data\CollectionDatabaseObject; use wcf\data\condition\Condition; -use wcf\data\DatabaseObject; +use wcf\data\file\File; use wcf\data\ITitledLinkObject; use wcf\data\trophy\category\TrophyCategory; use wcf\data\trophy\category\TrophyCategoryCache; use wcf\page\TrophyPage; +use wcf\system\cache\runtime\FileRuntimeCache; use wcf\system\condition\ConditionHandler; use wcf\system\event\EventHandler; +use wcf\system\l10n\L10nDefinition; +use wcf\system\l10n\L10nStorage; use wcf\system\request\IRouteController; use wcf\system\request\LinkHandler; use wcf\system\style\FontAwesomeIcon; @@ -19,16 +23,16 @@ /** * Represents a user trophy. * - * @author Joshua Ruesweg - * @copyright 2001-2019 WoltLab GmbH + * The localized title and description are stored in the `wcf1_trophy_l10n` table. + * + * @author Joshua Ruesweg, Marcel Werk + * @copyright 2001-2026 WoltLab GmbH * @license GNU Lesser General Public License * * @property-read int $trophyID unique id for the trophy - * @property-read string $title - * @property-read ?string $description * @property-read int $categoryID * @property-read int $type - * @property-read ?string $iconFile + * @property-read ?int $iconFileID id of the file of the image or `null` if the trophy is a badge or the file has been deleted * @property-read ?string $iconName * @property-read ?string $iconColor * @property-read ?string $badgeColor @@ -37,8 +41,13 @@ * @property-read 0|1 $revokeAutomatically `1` if the trophy should be automatically revoked once the conditions are no longer met, otherwise `0` * @property-read 0|1 $trophyUseHtml `1` if the trophy use a html description, otherwise `0` * @property-read int $showOrder position of the trophy in relation to the other trophies at the same location + * @property-read ?string $conditions JSON-encoded object filters that a user must match to be awarded automatically + * + * @extends CollectionDatabaseObject + * + * @phpstan-import-type L10nValue from L10nStorage */ -class Trophy extends DatabaseObject implements ITitledLinkObject, IRouteController, \Stringable +class Trophy extends CollectionDatabaseObject implements ITitledLinkObject, IRouteController, \Stringable { /** * The type value, if this trophy is an image trophy. @@ -57,6 +66,8 @@ class Trophy extends DatabaseObject implements ITitledLinkObject, IRouteControll */ const DEFAULT_SIZE = 32; + private ?File $iconFile = null; + /** * Returns the title of the trophy. * @@ -71,7 +82,7 @@ public function __toString(): string #[\Override] public function getTitle(): string { - return WCF::getLanguage()->get($this->title); + return $this->getCollection()->getResolvedL10nValue($this, 'title'); } #[\Override] @@ -156,17 +167,35 @@ public function isDisabled() */ public function getDescription() { + $description = $this->getCollection()->getResolvedL10nValue($this, 'description'); if ($this->trophyUseHtml === 0) { - return \nl2br(StringUtil::encodeHTML(WCF::getLanguage()->get($this->description)), false); + return \nl2br(StringUtil::encodeHTML($description), false); + } + + return $description; + } + + /** + * Returns the localized values of the given column as a + * `languageID => value` map (see `L10nStorage`). + * + * @return L10nValue + * @since 6.3 + */ + public function getL10nValues(string $columnName): array + { + if ($columnName !== 'title' && $columnName !== 'description') { + throw new \InvalidArgumentException("Invalid column name given."); } - return WCF::getLanguage()->get($this->description); + return $this->getCollection()->getL10nValues($this, $columnName); } /** * Returns the conditions of the trophy. * * @return Condition[] + * @deprecated 6.3 Use object filter API instead. */ public function getConditions() { @@ -184,4 +213,64 @@ public function getIcon(): ?FontAwesomeIcon return null; } + + /** + * Returns the url of the image of an image trophy. + * + * @since 6.3 + */ + public function getIconPath(): string + { + $file = $this->getIconFile(); + if ($file === null) { + // The file is deleted immediately from within the form, leaving the + // trophy without an image until the form is saved. + return 'data:image/svg+xml;base64,' . \base64_encode( + '' + . '' + . '?' + . '' + ); + } + + return $file->getFullSizeImageSource() ?? $file->getLink(); + } + + /** + * @since 6.3 + */ + public function getIconFile(): ?File + { + if ($this->iconFileID === null) { + return null; + } + + $this->iconFile ??= FileRuntimeCache::getInstance()->getObject($this->iconFileID); + + return $this->iconFile; + } + + /** + * @since 6.3 + */ + public function setIconFile(File $file): void + { + \assert($file->fileID === $this->iconFileID); + + $this->iconFile = $file; + } + + /** + * @since 6.3 + */ + public static function getL10nDefinition(): L10nDefinition + { + return new L10nDefinition( + 'wcf1_trophy', + 'wcf1_trophy_l10n', + 'trophyID', + ['title', 'description'], + maximumLengths: ['title' => 255], + ); + } } diff --git a/wcfsetup/install/files/lib/data/trophy/TrophyAction.class.php b/wcfsetup/install/files/lib/data/trophy/TrophyAction.class.php index 881ac483fb5..c67e56cb462 100644 --- a/wcfsetup/install/files/lib/data/trophy/TrophyAction.class.php +++ b/wcfsetup/install/files/lib/data/trophy/TrophyAction.class.php @@ -2,31 +2,26 @@ namespace wcf\data\trophy; +use wcf\command\trophy\DeleteTrophy; use wcf\command\trophy\DisableTrophy; use wcf\command\trophy\EnableTrophy; use wcf\data\AbstractDatabaseObjectAction; use wcf\data\IToggleAction; -use wcf\data\IUploadAction; -use wcf\data\user\trophy\UserTrophyAction; -use wcf\data\user\trophy\UserTrophyList; -use wcf\system\exception\IllegalLinkException; -use wcf\system\exception\UserInputException; -use wcf\system\image\ImageHandler; -use wcf\system\upload\TrophyImageUploadFileValidationStrategy; -use wcf\system\upload\UploadFile; -use wcf\system\user\storage\UserStorageHandler; -use wcf\system\WCF; /** * Trophy related actions. * + * Trophies should be created, updated and deleted through the `CreateTrophy`, + * `UpdateTrophy` and `DeleteTrophy` commands, the `create`, `update` and + * `delete` actions are `@deprecated 6.3`. + * * @author Joshua Ruesweg * @copyright 2001-2019 WoltLab GmbH * @license GNU Lesser General Public License * * @extends AbstractDatabaseObjectAction */ -class TrophyAction extends AbstractDatabaseObjectAction implements IToggleAction, IUploadAction +class TrophyAction extends AbstractDatabaseObjectAction implements IToggleAction { /** * @inheritDoc @@ -43,78 +38,21 @@ class TrophyAction extends AbstractDatabaseObjectAction implements IToggleAction */ protected $requireACP = ['toggle', 'delete']; - #[\Override] - public function create() - { - $showOrder = 0; - if (isset($this->parameters['data']['showOrder'])) { - $showOrder = $this->parameters['data']['showOrder']; - unset($this->parameters['data']['showOrder']); - } - - $trophy = parent::create(); - - if (isset($this->parameters['tmpHash']) && $this->parameters['data']['type'] === Trophy::TYPE_IMAGE) { - $this->updateTrophyImage($trophy); - } - - $trophyEditor = new TrophyEditor($trophy); - $trophyEditor->setShowOrder($showOrder); - - return new Trophy($trophy->trophyID); - } - + /** + * @deprecated 6.3 use the `DeleteTrophy` command instead. + */ #[\Override] public function delete() { - // update trophy points - $userTrophyList = new UserTrophyList(); - if (!empty($userTrophyList->sqlJoins)) { - $userTrophyList->sqlJoins .= ' '; - } - $userTrophyList->sqlJoins .= ' - LEFT JOIN wcf1_trophy trophy - ON user_trophy.trophyID = trophy.trophyID - LEFT JOIN wcf1_category category - ON trophy.categoryID = category.categoryID'; - - $userTrophyList->getConditionBuilder()->add('trophy.isDisabled = ?', [0]); - $userTrophyList->getConditionBuilder()->add('category.isDisabled = ?', [0]); - $userTrophyList->getConditionBuilder()->add('user_trophy.trophyID IN (?)', [$this->getObjectIDs()]); - $userTrophyList->readObjects(); - - $userTrophyAction = new UserTrophyAction($userTrophyList->getObjects(), 'delete'); - $userTrophyAction->executeAction(); - - foreach ($this->getObjects() as $trophy) { - if ($trophy->iconFile !== null && $trophy->iconFile !== '') { - @\unlink(\WCF_DIR . 'images/trophy/' . $trophy->iconFile); - } + if ($this->objects === []) { + $this->readObjects(); } - $returnValues = parent::delete(); - - UserStorageHandler::getInstance()->resetAll('specialTrophies'); - - return $returnValues; - } - - #[\Override] - public function update() - { - parent::update(); - - if (isset($this->parameters['data']['type']) && $this->parameters['data']['type'] === Trophy::TYPE_IMAGE) { - foreach ($this->getObjects() as $trophy) { - if (isset($this->parameters['tmpHash'])) { - $this->updateTrophyImage($trophy->getDecoratedObject()); - } - } + foreach ($this->objects as $object) { + new DeleteTrophy($object->getDecoratedObject())(); } - if (\count($this->objects) === 1 && isset($this->parameters['data']['showOrder']) && $this->parameters['data']['showOrder'] !== \reset($this->objects)->showOrder) { - \reset($this->objects)->setShowOrder($this->parameters['data']['showOrder']); - } + return \count($this->objects); } /** @@ -140,113 +78,4 @@ public function toggle() } } } - - #[\Override] - public function validateUpload() - { - WCF::getSession()->checkPermissions(['admin.trophy.canManageTrophy']); - - $this->readString('tmpHash'); - $this->readInteger('trophyID', true); - - if ($this->parameters['trophyID'] !== 0) { - $this->parameters['trophy'] = new Trophy($this->parameters['trophyID']); - - if ($this->parameters['trophy']->trophyID === 0) { - throw new IllegalLinkException(); - } - } - - $this->parameters['__files']->validateFiles(new TrophyImageUploadFileValidationStrategy()); - - /** @var UploadFile[] $files */ - $files = $this->parameters['__files']->getFiles(); - - // only one file is allowed - if (\count($files) !== 1) { - throw new UserInputException('file'); - } - - $this->parameters['file'] = \reset($files); - - if ($this->parameters['file']->getValidationErrorType() !== '') { - throw new UserInputException('file', $this->parameters['file']->getValidationErrorType()); - } - } - - #[\Override] - public function upload() - { - $fileName = \WCF_DIR . 'images/trophy/tmp_' . $this->parameters['tmpHash'] . '.' . $this->parameters['file']->getFileExtension(); - if ($this->parameters['file']->getImageData()['height'] > 128) { - $adapter = ImageHandler::getInstance()->getAdapter(); - $adapter->loadFile($this->parameters['file']->getLocation()); - $adapter->resize( - 0, - 0, - $this->parameters['file']->getImageData()['height'], - $this->parameters['file']->getImageData()['height'], - 128, - 128 - ); - $adapter->writeImage($adapter->getImage(), $fileName); - } else { - \copy($this->parameters['file']->getLocation(), $fileName); - } - - // remove old image - @\unlink($this->parameters['file']->getLocation()); - - // store extension within session variables - WCF::getSession()->register( - 'trophyImage-' . $this->parameters['tmpHash'], - $this->parameters['file']->getFileExtension() - ); - - if ($this->parameters['trophyID'] !== 0) { - $this->updateTrophyImage($this->parameters['trophy']); - - return [ - 'url' => WCF::getPath() . 'images/trophy/trophyImage-' . $this->parameters['trophyID'] . '.' . $this->parameters['file']->getFileExtension(), - ]; - } - - return [ - 'url' => WCF::getPath() . 'images/trophy/' . \basename($fileName), - ]; - } - - /** - * Updates style preview image. - * - * @return void - */ - protected function updateTrophyImage(Trophy $trophy) - { - if (!isset($this->parameters['tmpHash'])) { - return; - } - - $fileExtension = WCF::getSession()->getVar('trophyImage-' . $this->parameters['tmpHash']); - if ($fileExtension !== null) { - $oldFilename = \WCF_DIR . 'images/trophy/tmp_' . $this->parameters['tmpHash'] . '.' . $fileExtension; - if (\file_exists($oldFilename)) { - $filename = 'trophyImage-' . $trophy->trophyID . '.' . $fileExtension; - if (@\rename($oldFilename, \WCF_DIR . 'images/trophy/' . $filename)) { - // delete old file if it has a different file extension - if ($trophy->iconFile !== $filename) { - @\unlink(\WCF_DIR . 'images/trophy/' . $trophy->iconFile); - - $trophyEditor = new TrophyEditor($trophy); - $trophyEditor->update([ - 'iconFile' => $filename, - ]); - } - } else { - // remove temp file - @\unlink($oldFilename); - } - } - } - } } diff --git a/wcfsetup/install/files/lib/data/trophy/TrophyBuilder.class.php b/wcfsetup/install/files/lib/data/trophy/TrophyBuilder.class.php new file mode 100644 index 00000000000..2c13ae55ef2 --- /dev/null +++ b/wcfsetup/install/files/lib/data/trophy/TrophyBuilder.class.php @@ -0,0 +1,287 @@ + + * @since 6.3 + * + * @extends DatabaseObjectBuilder + * + * @phpstan-import-type L10nValue from L10nStorage + */ +final class TrophyBuilder extends DatabaseObjectBuilder +{ + /** + * @var L10nValue + */ + private array $title; + + /** + * @var L10nValue + */ + private array $description; + + private ?int $showOrder = null; + + /** + * @param L10nValue $title + */ + public function setTitle(array $title): static + { + $this->title = $title; + + return $this; + } + + /** + * @param L10nValue $description + */ + public function setDescription(array $description): static + { + $this->description = $description; + + return $this; + } + + public function setCategoryID(int $categoryID): static + { + $this->properties['categoryID'] = $categoryID; + + return $this; + } + + /** + * Sets the type of the trophy (`Trophy::TYPE_IMAGE` or `Trophy::TYPE_BADGE`) + * and resets the properties that are exclusive to the other type. + */ + public function setType(int $type): static + { + $this->properties['type'] = $type; + + if ($type === Trophy::TYPE_IMAGE) { + $this->properties['iconName'] = ''; + $this->properties['iconColor'] = ''; + $this->properties['badgeColor'] = ''; + } elseif ($type === Trophy::TYPE_BADGE) { + $this->properties['iconFileID'] = null; + } + + return $this; + } + + /** + * Sets the file of the image of an image trophy. + */ + public function setIconFileID(?int $iconFileID): static + { + $this->properties['iconFileID'] = $iconFileID; + + return $this; + } + + /** + * Sets the icon and the colors of a badge trophy. + */ + public function setBadge(string $iconName, string $iconColor, string $badgeColor): static + { + $this->properties['iconName'] = $iconName; + $this->properties['iconColor'] = $iconColor; + $this->properties['badgeColor'] = $badgeColor; + + return $this; + } + + public function setIsDisabled(bool $isDisabled): static + { + $this->properties['isDisabled'] = (int)$isDisabled; + + return $this; + } + + public function setAwardAutomatically(bool $awardAutomatically): static + { + $this->properties['awardAutomatically'] = (int)$awardAutomatically; + + return $this; + } + + public function setRevokeAutomatically(bool $revokeAutomatically): static + { + $this->properties['revokeAutomatically'] = (int)$revokeAutomatically; + + return $this; + } + + public function setTrophyUseHtml(bool $trophyUseHtml): static + { + $this->properties['trophyUseHtml'] = (int)$trophyUseHtml; + + return $this; + } + + /** + * Sets the position of the trophy, shifting the trophies that follow it. + * A value of `0` appends the trophy at the end. + */ + public function setShowOrder(int $showOrder): static + { + $this->showOrder = $showOrder; + + return $this; + } + + /** + * Sets the serialized object filters that a user must match to be awarded + * automatically. + */ + public function setConditions(?string $conditions): static + { + if ($conditions === '[]') { + $conditions = null; + } + + $this->properties['conditions'] = $conditions; + + return $this; + } + + #[\Override] + protected function getRequiredProperties(): array + { + return ['categoryID', 'type']; + } + + #[\Override] + protected function afterValidateCreate(): void + { + if (!isset($this->title)) { + throw new \BadMethodCallException("Missing value for 'title'."); + } + } + + #[\Override] + protected function afterCreate(DatabaseObject $object): void + { + $this->saveL10nValues($object); + + if ($this->showOrder !== null) { + self::applyShowOrder($object, $this->showOrder); + } + } + + #[\Override] + protected function afterUpdate(DatabaseObject $object): void + { + $oldObject = $this->getObject(); + + if (isset($this->title) || isset($this->description)) { + $this->saveL10nValues($object); + } + + if ($this->showOrder !== null && $this->showOrder !== $oldObject->showOrder) { + self::applyShowOrder($object, $this->showOrder); + } + + if ( + \array_key_exists('iconFileID', $this->properties) + && $oldObject->iconFileID !== null + && $oldObject->iconFileID !== $object->iconFileID + ) { + $file = FileRuntimeCache::getInstance()->getObject($oldObject->iconFileID); + if ($file !== null) { + new DeleteFiles([$file])(); + } + } + } + + #[\Override] + protected static function beforeDeleteAll(array $objectIDs): void + { + ConditionHandler::getInstance()->deleteConditions('com.woltlab.wcf.condition.trophy', $objectIDs); + } + + private function saveL10nValues(Trophy $trophy): void + { + $description = $this->description ?? null; + if ($description === null) { + $description = $this->isUpdate() + ? $this->getObject()->getL10nValues('description') + : [L10nStorage::MONOLINGUAL => '']; + } + + // `L10nStorage::setValues()` replaces all rows of the trophy. + (new L10nStorage(Trophy::getL10nDefinition()))->setValues( + $trophy->trophyID, + [ + 'title' => $this->title ?? $this->getObject()->getL10nValues('title'), + 'description' => $description, + ] + ); + } + + /** + * Moves the trophy to the given position, shifting the trophies that follow + * it. A value of `0` appends the trophy at the end. + */ + private static function applyShowOrder(Trophy $trophy, int $showOrder): void + { + $sql = "SELECT MAX(showOrder) + FROM wcf1_trophy"; + $statement = WCF::getDB()->prepare($sql); + $statement->execute(); + $maxShowOrder = (int)$statement->fetchSingleColumn(); + + if ($showOrder === 0 || $showOrder > $maxShowOrder) { + $newShowOrder = $maxShowOrder + 1; + } else { + $sql = "UPDATE wcf1_trophy + SET showOrder = showOrder + 1 + WHERE showOrder >= ? + AND trophyID <> ?"; + $statement = WCF::getDB()->prepare($sql); + $statement->execute([ + $showOrder, + $trophy->trophyID, + ]); + + $newShowOrder = $showOrder; + } + + $sql = "UPDATE wcf1_trophy + SET showOrder = ? + WHERE trophyID = ?"; + $statement = WCF::getDB()->prepare($sql); + $statement->execute([ + $newShowOrder, + $trophy->trophyID, + ]); + } + + public static function resetCache(): void + { + TrophyCache::getInstance()->clearCache(); + ConditionCacheBuilder::getInstance()->reset([ + 'definitionID' => ObjectTypeCache::getInstance() + ->getDefinitionByName('com.woltlab.wcf.condition.trophy') + ->definitionID, + ]); + } +} diff --git a/wcfsetup/install/files/lib/data/trophy/TrophyCollection.class.php b/wcfsetup/install/files/lib/data/trophy/TrophyCollection.class.php new file mode 100644 index 00000000000..3789d3296db --- /dev/null +++ b/wcfsetup/install/files/lib/data/trophy/TrophyCollection.class.php @@ -0,0 +1,28 @@ + + * @since 6.3 + * + * @extends DatabaseObjectCollection + */ +class TrophyCollection extends DatabaseObjectCollection +{ + use TCollectionL10n; + + #[\Override] + protected function getL10nDefinition(): L10nDefinition + { + return Trophy::getL10nDefinition(); + } +} diff --git a/wcfsetup/install/files/lib/data/trophy/TrophyEditor.class.php b/wcfsetup/install/files/lib/data/trophy/TrophyEditor.class.php index a34eef02b5d..93743d8e270 100644 --- a/wcfsetup/install/files/lib/data/trophy/TrophyEditor.class.php +++ b/wcfsetup/install/files/lib/data/trophy/TrophyEditor.class.php @@ -16,6 +16,7 @@ * @mixin Trophy * @extends DatabaseObjectEditor * @implements IEditableCachedObject + * @deprecated 6.3 use `TrophyBuilder` instead. */ class TrophyEditor extends DatabaseObjectEditor implements IEditableCachedObject { @@ -28,6 +29,7 @@ class TrophyEditor extends DatabaseObjectEditor implements IEditableCachedObject * Sets the show order of the trophy. * * @return void + * @deprecated 6.3 Use `TrophyBuilder::setShowOrder()` instead. */ public function setShowOrder(int $showOrder = 0) { diff --git a/wcfsetup/install/files/lib/data/trophy/TrophyList.class.php b/wcfsetup/install/files/lib/data/trophy/TrophyList.class.php index 11f57e19ab5..aafed86adb2 100644 --- a/wcfsetup/install/files/lib/data/trophy/TrophyList.class.php +++ b/wcfsetup/install/files/lib/data/trophy/TrophyList.class.php @@ -3,14 +3,30 @@ namespace wcf\data\trophy; use wcf\data\DatabaseObjectList; +use wcf\system\cache\runtime\FileRuntimeCache; /** * Represents a trophy list. * - * @author Joshua Ruesweg - * @copyright 2001-2019 WoltLab GmbH + * @author Joshua Ruesweg, Marcel Werk + * @copyright 2001-2026 WoltLab GmbH * @license GNU Lesser General Public License * * @extends DatabaseObjectList */ -class TrophyList extends DatabaseObjectList {} +class TrophyList extends DatabaseObjectList +{ + #[\Override] + public function readObjects() + { + parent::readObjects(); + + $iconFileIDs = []; + foreach ($this->objects as $object) { + if ($object->iconFileID !== null) { + $iconFileIDs[] = $object->iconFileID; + } + } + FileRuntimeCache::getInstance()->cacheObjectIDs($iconFileIDs); + } +} diff --git a/wcfsetup/install/files/lib/event/trophy/TrophyCreated.class.php b/wcfsetup/install/files/lib/event/trophy/TrophyCreated.class.php new file mode 100644 index 00000000000..6c1553784b9 --- /dev/null +++ b/wcfsetup/install/files/lib/event/trophy/TrophyCreated.class.php @@ -0,0 +1,23 @@ + + * @since 6.3 + */ +final class TrophyCreated implements IPsr14Event +{ + public function __construct( + public readonly Trophy $trophy, + public readonly TrophyBuilder $builder, + ) {} +} diff --git a/wcfsetup/install/files/lib/event/trophy/TrophyDeleted.class.php b/wcfsetup/install/files/lib/event/trophy/TrophyDeleted.class.php new file mode 100644 index 00000000000..05c0fbb3899 --- /dev/null +++ b/wcfsetup/install/files/lib/event/trophy/TrophyDeleted.class.php @@ -0,0 +1,21 @@ + + * @since 6.3 + */ +final class TrophyDeleted implements IPsr14Event +{ + public function __construct( + public readonly Trophy $trophy, + ) {} +} diff --git a/wcfsetup/install/files/lib/event/trophy/TrophyObjectFilterCollecting.class.php b/wcfsetup/install/files/lib/event/trophy/TrophyObjectFilterCollecting.class.php new file mode 100644 index 00000000000..78fa94e561a --- /dev/null +++ b/wcfsetup/install/files/lib/event/trophy/TrophyObjectFilterCollecting.class.php @@ -0,0 +1,45 @@ + + * @since 6.3 + */ +final class TrophyObjectFilterCollecting 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/trophy/TrophyUpdated.class.php b/wcfsetup/install/files/lib/event/trophy/TrophyUpdated.class.php new file mode 100644 index 00000000000..4201a13feb0 --- /dev/null +++ b/wcfsetup/install/files/lib/event/trophy/TrophyUpdated.class.php @@ -0,0 +1,23 @@ + + * @since 6.3 + */ +final class TrophyUpdated implements IPsr14Event +{ + public function __construct( + public readonly Trophy $trophy, + public readonly TrophyBuilder $builder, + ) {} +} diff --git a/wcfsetup/install/files/lib/page/TrophyPage.class.php b/wcfsetup/install/files/lib/page/TrophyPage.class.php index 668453fd8d8..3d670b1dae0 100644 --- a/wcfsetup/install/files/lib/page/TrophyPage.class.php +++ b/wcfsetup/install/files/lib/page/TrophyPage.class.php @@ -107,11 +107,12 @@ public function readData() ); } - if ($this->trophy->type === Trophy::TYPE_IMAGE) { + $iconFile = $this->trophy->type === Trophy::TYPE_IMAGE ? $this->trophy->getIconFile() : null; + if ($iconFile !== null) { MetaTagHandler::getInstance()->addTag( 'og:image', 'og:image', - WCF::getPath() . 'images/trophy/' . $this->trophy->iconFile, + $iconFile->getFullSizeImageSource() ?? $iconFile->getLink(), true ); } diff --git a/wcfsetup/install/files/lib/system/cache/builder/TrophyCacheBuilder.class.php b/wcfsetup/install/files/lib/system/cache/builder/TrophyCacheBuilder.class.php index 81c6f726cc6..040f1d63736 100644 --- a/wcfsetup/install/files/lib/system/cache/builder/TrophyCacheBuilder.class.php +++ b/wcfsetup/install/files/lib/system/cache/builder/TrophyCacheBuilder.class.php @@ -2,13 +2,14 @@ namespace wcf\system\cache\builder; +use wcf\data\file\FileList; use wcf\data\trophy\TrophyList; /** * Caches the trophies. * - * @author Joshua Ruesweg - * @copyright 2001-2019 WoltLab GmbH + * @author Joshua Ruesweg, Marcel Werk + * @copyright 2001-2026 WoltLab GmbH * @license GNU Lesser General Public License */ class TrophyCacheBuilder extends AbstractCacheBuilder @@ -24,7 +25,38 @@ public function rebuild(array $parameters) $trophyList->sqlOrderBy = 'trophy.showOrder ASC'; $trophyList->readObjects(); + $trophies = $trophyList->getObjects(); - return $trophyList->getObjects(); + $fileIDs = []; + foreach ($trophies as $trophy) { + if ($trophy->iconFileID !== null) { + $fileIDs[] = $trophy->iconFileID; + } + } + + if ($fileIDs !== []) { + $fileList = new FileList(); + $fileList->setObjectIDs($fileIDs); + $fileList->readObjects(); + + foreach ($trophies as $trophy) { + if ($trophy->iconFileID === null) { + continue; + } + + $file = $fileList->search($trophy->iconFileID); + if ($file !== null) { + $trophy->setIconFile($file); + } + } + } + + // The trophies share one collection, loading the titles once stores + // them in the cache instead of querying them on every request. + if ($trophies !== []) { + \reset($trophies)->getL10nValues('title'); + } + + return $trophies; } } diff --git a/wcfsetup/install/files/lib/system/endpoint/controller/core/trophies/DeleteTrophy.class.php b/wcfsetup/install/files/lib/system/endpoint/controller/core/trophies/DeleteTrophy.class.php index 8c7054a0d9b..a79263256f2 100644 --- a/wcfsetup/install/files/lib/system/endpoint/controller/core/trophies/DeleteTrophy.class.php +++ b/wcfsetup/install/files/lib/system/endpoint/controller/core/trophies/DeleteTrophy.class.php @@ -5,8 +5,8 @@ use Laminas\Diactoros\Response\JsonResponse; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; +use wcf\command\trophy\DeleteTrophy as DeleteTrophyCommand; use wcf\data\trophy\Trophy; -use wcf\data\trophy\TrophyAction; 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->assertTrophyCanBeDeleted(); - (new TrophyAction([$trophy], 'delete'))->executeAction(); + (new DeleteTrophyCommand($trophy))(); return new JsonResponse([]); } diff --git a/wcfsetup/install/files/lib/system/file/processor/TrophyImageFileProcessor.class.php b/wcfsetup/install/files/lib/system/file/processor/TrophyImageFileProcessor.class.php new file mode 100644 index 00000000000..0ceb37179c2 --- /dev/null +++ b/wcfsetup/install/files/lib/system/file/processor/TrophyImageFileProcessor.class.php @@ -0,0 +1,164 @@ + + * @since 6.3 + */ +final class TrophyImageFileProcessor extends AbstractFileProcessor +{ + /** + * Minimum width and height of the image. + */ + public const MINIMUM_SIZE = 64; + + /** + * Images that are larger than this are downscaled before the upload. + */ + public const MAXIMUM_SIZE = 128; + + #[\Override] + public function getObjectTypeName(): string + { + return 'com.woltlab.wcf.trophy.image'; + } + + #[\Override] + public function getAllowedFileExtensions(array $context): array + { + return [ + 'gif', + 'jpg', + 'jpeg', + 'png', + 'webp', + ]; + } + + #[\Override] + public function acceptUpload(string $filename, int $fileSize, array $context): FileProcessorPreflightResult + { + if (!WCF::getSession()->hasPermission('admin.trophy.canManageTrophy')) { + return FileProcessorPreflightResult::InsufficientPermissions; + } + + if (!FileUtil::endsWithAllowedExtension($filename, $this->getAllowedFileExtensions($context))) { + return FileProcessorPreflightResult::FileExtensionNotPermitted; + } + + return FileProcessorPreflightResult::Passed; + } + + #[\Override] + public function validateUpload(File $file): void + { + $imageData = @\getimagesize($file->getPathname()); + if ($imageData === false) { + throw new UserInputException('file', 'noImage'); + } + + if ($imageData[0] !== $imageData[1]) { + throw new UserInputException('file', 'notSquare'); + } + + if ($imageData[0] < self::MINIMUM_SIZE) { + throw new UserInputException('file', 'tooSmall'); + } + } + + #[\Override] + public function getResizeConfiguration(): ResizeConfiguration + { + return new ResizeConfiguration( + self::MAXIMUM_SIZE, + self::MAXIMUM_SIZE, + ResizeFileType::Keep, + 100 + ); + } + + #[\Override] + public function canAdopt(File $file, array $context): bool + { + $trophyID = $this->getTrophyIDByFile($file); + if ($trophyID === null) { + return true; + } + + return $trophyID === ($context['objectID'] ?? null); + } + + #[\Override] + public function adopt(File $file, array $context): void + { + // The file is assigned when the form is saved. + } + + #[\Override] + public function canDelete(File $file): bool + { + return WCF::getSession()->hasPermission('admin.trophy.canManageTrophy'); + } + + #[\Override] + public function canDownload(File $file): bool + { + return true; + } + + #[\Override] + public function delete(array $fileIDs, array $thumbnailIDs): void + { + $conditionBuilder = new PreparedStatementConditionBuilder(); + $conditionBuilder->add('iconFileID IN (?)', [$fileIDs]); + + $sql = "UPDATE wcf1_trophy + SET iconFileID = ? + " . $conditionBuilder; + $statement = WCF::getDB()->prepare($sql); + $statement->execute([null, ...$conditionBuilder->getParameters()]); + + if ($statement->getAffectedRows() > 0) { + TrophyBuilder::resetCache(); + } + } + + #[\Override] + public function sourceFilenameChanged(File $file): void + { + // The trophy cache holds the file and therefore its pathname. + if ($this->getTrophyIDByFile($file) !== null) { + TrophyBuilder::resetCache(); + } + } + + #[\Override] + public function isSingleFile(): bool + { + return true; + } + + private function getTrophyIDByFile(File $file): ?int + { + $sql = "SELECT trophyID + FROM wcf1_trophy + WHERE iconFileID = ?"; + $statement = WCF::getDB()->prepare($sql); + $statement->execute([$file->fileID]); + $trophyID = $statement->fetchSingleColumn(); + + return $trophyID === false ? null : $trophyID; + } +} diff --git a/wcfsetup/install/files/lib/system/form/builder/field/trophy/TrophyBadgeFormField.class.php b/wcfsetup/install/files/lib/system/form/builder/field/trophy/TrophyBadgeFormField.class.php new file mode 100644 index 00000000000..36aa6aa2598 --- /dev/null +++ b/wcfsetup/install/files/lib/system/form/builder/field/trophy/TrophyBadgeFormField.class.php @@ -0,0 +1,163 @@ + + * @since 6.3 + */ +final class TrophyBadgeFormField extends AbstractFormField implements IImmutableFormField +{ + use TImmutableFormField; + + public const DEFAULT_ICON_NAME = 'trophy;false'; + + public const DEFAULT_ICON_COLOR = 'rgba(255, 255, 255, 1)'; + + public const DEFAULT_BADGE_COLOR = 'rgba(50, 92, 132, 1)'; + + private const COLOR_PATTERN = '~^rgba\(\d{1,3}, ?\d{1,3}, ?\d{1,3}, ?(1|1\.00?|0|0?\.[0-9]{1,2})\)$~'; + + /** + * @inheritDoc + */ + protected $javaScriptDataHandlerModule = 'WoltLabSuite/Core/Form/Builder/Field/TrophyBadge'; + + /** + * @inheritDoc + */ + protected $templateName = 'shared_trophyBadgeFormField'; + + /** + * @inheritDoc + */ + protected $value = [ + 'iconName' => self::DEFAULT_ICON_NAME, + 'iconColor' => self::DEFAULT_ICON_COLOR, + 'badgeColor' => self::DEFAULT_BADGE_COLOR, + ]; + + /** + * `true` if the global icon-related JavaScript code has not been included yet. + */ + private static bool $includeJavaScript = true; + + #[\Override] + public function getHtmlVariables(): array + { + $includeJavaScript = self::$includeJavaScript; + self::$includeJavaScript = false; + + return [ + '__trophyBadgeFormFieldIncludeJavaScript' => $includeJavaScript, + ]; + } + + #[\Override] + public function readValue(): TrophyBadgeFormField + { + if ($this->getDocument()->hasRequestData($this->getPrefixedId())) { + $value = $this->getDocument()->getRequestData($this->getPrefixedId()); + if (\is_array($value)) { + $this->value = [ + 'iconName' => (string)($value['iconName'] ?? ''), + 'iconColor' => (string)($value['iconColor'] ?? ''), + 'badgeColor' => (string)($value['badgeColor'] ?? ''), + ]; + } + } + + return $this; + } + + #[\Override] + public function value(mixed $value): TrophyBadgeFormField + { + if (!\is_array($value)) { + throw new \InvalidArgumentException( + "Given value is no array, '" . \gettype($value) . "' given for field '{$this->getId()}'." + ); + } + + $this->value = [ + 'iconName' => (string)($value['iconName'] ?? self::DEFAULT_ICON_NAME), + 'iconColor' => (string)($value['iconColor'] ?? self::DEFAULT_ICON_COLOR), + 'badgeColor' => (string)($value['badgeColor'] ?? self::DEFAULT_BADGE_COLOR), + ]; + + return $this; + } + + #[\Override] + public function validate(): void + { + parent::validate(); + + if ($this->getIconName() === '') { + if ($this->isRequired()) { + $this->addValidationError(new FormFieldValidationError('empty')); + } + + return; + } + + if (!FontAwesomeIcon::isValidString($this->getIconName())) { + $this->addValidationError(new FormFieldValidationError( + 'invalidValue', + 'wcf.global.form.error.noValidSelection' + )); + + return; + } + + if ( + \preg_match(self::COLOR_PATTERN, $this->getIconColor()) !== 1 + || \preg_match(self::COLOR_PATTERN, $this->getBadgeColor()) !== 1 + ) { + $this->addValidationError(new FormFieldValidationError( + 'invalidValue', + 'wcf.style.colorPicker.error.invalidColor' + )); + } + } + + public function getIconName(): string + { + return $this->value['iconName']; + } + + public function getIconColor(): string + { + return $this->value['iconColor']; + } + + public function getBadgeColor(): string + { + return $this->value['badgeColor']; + } + + /** + * Returns the selected icon or the default icon if the selection is invalid. + */ + public function getIcon(): FontAwesomeIcon + { + if (FontAwesomeIcon::isValidString($this->getIconName())) { + return FontAwesomeIcon::fromString($this->getIconName()); + } + + return FontAwesomeIcon::fromString(self::DEFAULT_ICON_NAME); + } +} diff --git a/wcfsetup/install/files/lib/system/gridView/admin/TrophyGridView.class.php b/wcfsetup/install/files/lib/system/gridView/admin/TrophyGridView.class.php index 5223c6e0fe9..454a42f2290 100644 --- a/wcfsetup/install/files/lib/system/gridView/admin/TrophyGridView.class.php +++ b/wcfsetup/install/files/lib/system/gridView/admin/TrophyGridView.class.php @@ -4,7 +4,7 @@ use wcf\acp\form\TrophyEditForm; use wcf\data\DatabaseObject; -use wcf\data\trophy\I18nTrophyList; +use wcf\data\trophy\L10nTrophyList; use wcf\data\trophy\Trophy; use wcf\event\gridView\admin\TrophyGridViewInitialized; use wcf\system\gridView\AbstractGridView; @@ -13,25 +13,24 @@ use wcf\system\gridView\renderer\AbstractColumnRenderer; use wcf\system\gridView\renderer\NumberColumnRenderer; use wcf\system\gridView\renderer\ObjectIdColumnRenderer; -use wcf\system\gridView\renderer\PhraseColumnRenderer; use wcf\system\interaction\admin\TrophyInteractions; use wcf\system\interaction\bulk\admin\TrophyBulkInteractions; use wcf\system\interaction\Divider; use wcf\system\interaction\EditInteraction; use wcf\system\interaction\ToggleInteraction; -use wcf\system\view\filter\I18nTextFilter; use wcf\system\view\filter\IntegerFilter; +use wcf\system\view\filter\L10nTextFilter; use wcf\system\WCF; /** * Grid view for the list of trophies. * - * @author Olaf Braun - * @copyright 2001-2025 WoltLab GmbH + * @author Olaf Braun, Marcel Werk + * @copyright 2001-2026 WoltLab GmbH * @license GNU Lesser General Public License * @since 6.2 * - * @extends AbstractGridView + * @extends AbstractGridView */ final class TrophyGridView extends AbstractGridView { @@ -64,9 +63,13 @@ public function getClasses(): string GridViewColumn::for("title") ->titleColumn() ->label("wcf.global.title") - ->renderer(new PhraseColumnRenderer()) - ->filter(I18nTextFilter::class) - ->sortable(sortByDatabaseColumn: "titleI18n"), + ->filter(new L10nTextFilter( + Trophy::getL10nDefinition(), + "title", + "title", + "wcf.global.title", + )) + ->sortable(sortByDatabaseColumn: "title"), GridViewColumn::for("showOrder") ->label("wcf.global.showOrder") ->renderer(new NumberColumnRenderer()) @@ -74,6 +77,15 @@ public function getClasses(): string ->sortable(), ]); + $this->addAvailableFilters([ + new L10nTextFilter( + Trophy::getL10nDefinition(), + "description", + "description", + "wcf.global.description", + ), + ]); + $provider = new TrophyInteractions(); $provider->addInteractions([ new Divider(), @@ -102,9 +114,9 @@ public function isAccessible(): bool } #[\Override] - protected function createObjectList(): I18nTrophyList + protected function createObjectList(): L10nTrophyList { - return new I18nTrophyList(); + return new L10nTrophyList(); } #[\Override] diff --git a/wcfsetup/install/files/lib/system/importer/TrophyImporter.class.php b/wcfsetup/install/files/lib/system/importer/TrophyImporter.class.php index b32ed163208..4155509855a 100644 --- a/wcfsetup/install/files/lib/system/importer/TrophyImporter.class.php +++ b/wcfsetup/install/files/lib/system/importer/TrophyImporter.class.php @@ -2,17 +2,18 @@ namespace wcf\system\importer; +use wcf\command\file\CreateFileFromExistingFile; use wcf\data\object\type\ObjectTypeCache; use wcf\data\trophy\Trophy; -use wcf\data\trophy\TrophyEditor; +use wcf\data\trophy\TrophyBuilder; +use wcf\system\l10n\L10nStorage; use wcf\system\WCF; -use wcf\util\StringUtil; /** * Represents a trophy importer. * - * @author Joshua Ruesweg - * @copyright 2001-2019 WoltLab GmbH + * @author Joshua Ruesweg, Marcel Werk + * @copyright 2001-2026 WoltLab GmbH * @license GNU Lesser General Public License */ class TrophyImporter extends AbstractImporter @@ -40,66 +41,73 @@ public function import(mixed $oldID, array $data, array $additionalData = []) $data['categoryID'] = $this->getImportCategoryID(); } - if ((int)$data['type'] === Trophy::TYPE_IMAGE) { - if (!@\file_exists($additionalData['fileLocation'])) { - return 0; - } + $type = (int)$data['type']; + $builder = TrophyBuilder::forCreate() + ->setCategoryID((int)$data['categoryID']) + ->setType($type) + ->setTitle($this->getL10nValues('title', $data, $additionalData)) + ->setDescription($this->getL10nValues('description', $data, $additionalData)); - $filename = \basename($additionalData['fileLocation']); - while (\file_exists(\WCF_DIR . 'images/trophy/' . $filename)) { - $filename = \substr(StringUtil::getRandomID(), 0, 5) . '_' . \basename($additionalData['fileLocation']); + if ($type === Trophy::TYPE_IMAGE) { + if (!isset($additionalData['fileLocation']) || !@\file_exists($additionalData['fileLocation'])) { + return 0; } - if (!@\copy($additionalData['fileLocation'], \WCF_DIR . 'images/trophy/' . $filename)) { + $file = new CreateFileFromExistingFile( + $additionalData['fileLocation'], + \basename($additionalData['fileLocation']), + 'com.woltlab.wcf.trophy.image', + copy: true, + )(); + if ($file === null) { return 0; } - $data['iconFile'] = $filename; + $builder->setIconFileID($file->fileID); } - /** @var Trophy $trophy */ - $trophy = TrophyEditor::create($data); - - if (!empty($additionalData['i18n'])) { - $values = []; - - foreach (['title', 'description'] as $property) { - if (isset($additionalData['i18n'][$property])) { - $values[$property] = $additionalData['i18n'][$property]; - } + // The show order is imported as is to keep the order of the other trophies. + $handledColumns = ['trophyID', 'categoryID', 'type', 'title', 'description', 'iconFile', 'iconFileID']; + foreach ($data as $key => $value) { + if (\in_array($key, $handledColumns, true)) { + continue; } - - if ($values !== []) { - $updateData = []; - if (isset($values['title'])) { - $updateData['title'] = 'wcf.user.trophy.title' . $trophy->trophyID; - } - if (isset($values['description'])) { - $updateData['description'] = 'wcf.user.trophy.description' . $trophy->trophyID; - } - - $items = []; - foreach ($values as $property => $propertyValues) { - foreach ($propertyValues as $languageID => $languageItemValue) { - $items[] = [ - 'languageID' => $languageID, - 'languageItem' => 'wcf.user.trophy.' . $property . $trophy->trophyID, - 'languageItemValue' => $languageItemValue, - ]; - } - } - - $this->importI18nValues($items, 'wcf.user.trophy', 'com.woltlab.wcf'); - - (new TrophyEditor($trophy))->update($updateData); + if ($value !== null && !\is_string($value) && !\is_int($value) && !\is_float($value)) { + continue; } + + $builder->setCustomProperty($key, $value); } + $trophy = $builder->create(); + ImportHandler::getInstance()->saveNewID('com.woltlab.wcf.trophy', $oldID, $trophy->trophyID); return $trophy->trophyID; } + /** + * Returns the localized values of the given property, which are either + * provided per language or as a monolingual value. + * + * @param array $data + * @param array $additionalData + * @return array + */ + private function getL10nValues(string $property, array $data, array $additionalData): array + { + if (!empty($additionalData['i18n'][$property]) && \is_array($additionalData['i18n'][$property])) { + $values = []; + foreach ($additionalData['i18n'][$property] as $languageID => $value) { + $values[(int)$languageID] = (string)$value; + } + + return $values; + } + + return [L10nStorage::MONOLINGUAL => (string)($data[$property] ?? '')]; + } + /** * Returns a categoryID for trophies without categoryID. * diff --git a/wcfsetup/install/files/lib/system/object/filter/ObjectListFilterHandler.class.php b/wcfsetup/install/files/lib/system/object/filter/ObjectListFilterHandler.class.php index fe7169a756e..bd78d5c7f47 100644 --- a/wcfsetup/install/files/lib/system/object/filter/ObjectListFilterHandler.class.php +++ b/wcfsetup/install/files/lib/system/object/filter/ObjectListFilterHandler.class.php @@ -35,17 +35,18 @@ public function __construct( * list. Values of unknown filters are ignored. * * Adds a condition that matches no objects if there are no active filters. + * Returns whether any active filter has been applied. * * @param DatabaseObjectList $list * @throws MappingError if the stored filter values are malformed */ - public function applyFilters(DatabaseObjectList $list, ?string $json): void + public function applyFilters(DatabaseObjectList $list, ?string $json): bool { $values = $this->unserializeValues($json); if ($values === []) { $list->getConditionBuilder()->add('1=0'); - return; + return false; } $filters = $this->getFiltersByIdentifier(); @@ -69,5 +70,7 @@ public function applyFilters(DatabaseObjectList $list, ?string $json): void if (!$hasActiveFilters) { $list->getConditionBuilder()->add('1=0'); } + + return $hasActiveFilters; } } diff --git a/wcfsetup/install/files/lib/system/object/filter/builder/TrophyObjectFilterBuilder.class.php b/wcfsetup/install/files/lib/system/object/filter/builder/TrophyObjectFilterBuilder.class.php new file mode 100644 index 00000000000..5995e03db47 --- /dev/null +++ b/wcfsetup/install/files/lib/system/object/filter/builder/TrophyObjectFilterBuilder.class.php @@ -0,0 +1,110 @@ + + * @since 6.3 + * + * @extends AbstractObjectFilterBuilder, ObjectListFilterHandler> + */ +final class TrophyObjectFilterBuilder extends AbstractObjectFilterBuilder +{ + #[\Override] + protected function createFilters(): array + { + $event = new TrophyObjectFilterCollecting(); + $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()); + foreach ((new UserOptionObjectFilterFactory())->getFilters() as $filter) { + $event->register($filter); + } + + 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.trophy'; + } + + #[\Override] + public function isAccessible(): bool + { + return WCF::getSession()->hasPermission('admin.trophy.canManageTrophy'); + } + + /** + * Adds the filters of the trophy to the given user list. Returns whether + * any active filter has been applied. + * + * A trophy without any active filters matches no users. + */ + public function applyFilters(Trophy $trophy, UserList $userList): bool + { + return $this->getHandler()->applyFilters( + $userList, + $trophy->conditions, + ); + } +} diff --git a/wcfsetup/install/files/lib/system/page/handler/TrophyPageHandler.class.php b/wcfsetup/install/files/lib/system/page/handler/TrophyPageHandler.class.php index faa44103d0e..111ead5d4ed 100644 --- a/wcfsetup/install/files/lib/system/page/handler/TrophyPageHandler.class.php +++ b/wcfsetup/install/files/lib/system/page/handler/TrophyPageHandler.class.php @@ -3,7 +3,7 @@ namespace wcf\system\page\handler; use wcf\data\trophy\TrophyCache; -use wcf\data\trophy\TrophyList; +use wcf\data\trophy\L10nTrophyList; use wcf\system\WCF; /** @@ -36,16 +36,18 @@ public function isVisible(?int $objectID = null) #[\Override] public function lookup(string $searchString) { - $trophyList = new TrophyList(); - if (!empty($trophyList->sqlJoins)) { - $trophyList->sqlJoins .= ', '; - } - $trophyList->sqlJoins = " - LEFT JOIN wcf1_language_item language_item - ON language_item.languageItem = trophy.title"; + $trophyList = new L10nTrophyList(); $trophyList->getConditionBuilder()->add( - '(trophy.title LIKE ? OR language_item.languageItemValue LIKE ?)', - ['%' . WCF::getDB()->escapeLikeValue($searchString) . '%', '%' . WCF::getDB()->escapeLikeValue($searchString) . '%'] + "trophy.trophyID IN ( + SELECT trophyID + FROM wcf1_trophy_l10n + WHERE (languageID = ? OR languageID IS NULL) + AND title LIKE ? + )", + [ + WCF::getLanguage()->languageID, + '%' . WCF::getDB()->escapeLikeValue($searchString) . '%', + ] ); $trophyList->sqlLimit = 10; $trophyList->sqlOrderBy = 'title'; diff --git a/wcfsetup/install/files/lib/system/search/acp/TrophyACPSearchResultProvider.class.php b/wcfsetup/install/files/lib/system/search/acp/TrophyACPSearchResultProvider.class.php index 19441f582d3..a0ef86fc02f 100644 --- a/wcfsetup/install/files/lib/system/search/acp/TrophyACPSearchResultProvider.class.php +++ b/wcfsetup/install/files/lib/system/search/acp/TrophyACPSearchResultProvider.class.php @@ -3,16 +3,15 @@ namespace wcf\system\search\acp; use wcf\acp\form\TrophyEditForm; -use wcf\data\trophy\Trophy; -use wcf\system\database\util\PreparedStatementConditionBuilder; +use wcf\data\trophy\L10nTrophyList; use wcf\system\request\LinkHandler; use wcf\system\WCF; /** * ACP search result provider implementation for trophies. * - * @author Joshua Ruesweg - * @copyright 2001-2019 WoltLab GmbH + * @author Joshua Ruesweg, Marcel Werk + * @copyright 2001-2026 WoltLab GmbH * @license GNU Lesser General Public License */ class TrophyACPSearchResultProvider implements IACPSearchResultProvider @@ -24,40 +23,24 @@ public function search(string $query) return []; } - $results = []; - - // search by language item - $conditions = new PreparedStatementConditionBuilder(); - $conditions->add("languageID = ?", [WCF::getLanguage()->languageID]); - $conditions->add("languageItem LIKE ?", ['wcf.user.trophy.title%']); - $conditions->add("languageItemValue LIKE ?", ['%' . WCF::getDB()->escapeLikeValue($query) . '%']); - - $sql = "SELECT languageItem - FROM wcf1_language_item - " . $conditions; - $statement = WCF::getDB()->prepare($sql); - $statement->execute($conditions->getParameters()); - - $trophyIDs = []; - while ($row = $statement->fetchArray()) { - $trophyIDs[] = \str_replace('wcf.user.trophy.title', '', $row['languageItem']); - } + $trophyList = new L10nTrophyList(); + $trophyList->getConditionBuilder()->add( + "trophy.trophyID IN ( + SELECT trophyID + FROM wcf1_trophy_l10n + WHERE (languageID = ? OR languageID IS NULL) + AND title LIKE ? + )", + [ + WCF::getLanguage()->languageID, + '%' . WCF::getDB()->escapeLikeValue($query) . '%', + ] + ); + $trophyList->sqlOrderBy = 'title'; + $trophyList->readObjects(); - $conditions = new PreparedStatementConditionBuilder(false); - if ($trophyIDs !== []) { - $conditions->add("trophyID IN (?)", [$trophyIDs]); - } - - $sql = "SELECT * - FROM wcf1_trophy - WHERE title LIKE ? - " . ($conditions->getParameters() !== [] ? "OR " . $conditions : ""); - $statement = WCF::getDB()->prepare($sql); - $statement->execute(\array_merge([ - '%' . WCF::getDB()->escapeLikeValue($query) . '%', - ], $conditions->getParameters())); - - while ($trophy = $statement->fetchObject(Trophy::class)) { + $results = []; + foreach ($trophyList as $trophy) { $results[] = new ACPSearchResult($trophy->getTitle(), LinkHandler::getInstance()->getControllerLink(TrophyEditForm::class, [ 'id' => $trophy->trophyID, ])); diff --git a/wcfsetup/install/files/lib/system/trophy/condition/TrophyConditionHandler.class.php b/wcfsetup/install/files/lib/system/trophy/condition/TrophyConditionHandler.class.php index ecbffbcd547..3c879a8e95d 100644 --- a/wcfsetup/install/files/lib/system/trophy/condition/TrophyConditionHandler.class.php +++ b/wcfsetup/install/files/lib/system/trophy/condition/TrophyConditionHandler.class.php @@ -8,31 +8,36 @@ use wcf\data\trophy\TrophyList; use wcf\data\user\trophy\UserTrophyAction; use wcf\data\user\UserList; +use wcf\system\object\filter\builder\TrophyObjectFilterBuilder; use wcf\system\SingletonFactory; +use wcf\system\WCF; /** * Handles trophy conditions. * - * @author Joshua Ruesweg - * @copyright 2001-2019 WoltLab GmbH - * @license GNU Lesser General Public License + * @author Joshua Ruesweg, Marcel Werk + * @copyright 2001-2026 WoltLab GmbH + * @license GNU Lesser General Public License */ -class TrophyConditionHandler extends SingletonFactory +final class TrophyConditionHandler extends SingletonFactory { /** * definition name for trophy conditions - * @var string + * @deprecated 6.3 The conditions of trophies are stored as object filters. */ - const CONDITION_DEFINITION_NAME = 'com.woltlab.wcf.condition.trophy'; + const string CONDITION_DEFINITION_NAME = 'com.woltlab.wcf.condition.trophy'; /** * list of grouped trophy condition object types * @var ObjectType[][] + * @deprecated 6.3 The conditions of trophies are stored as object filters. */ - protected $groupedObjectTypes = []; + protected array $groupedObjectTypes = []; + + private TrophyObjectFilterBuilder $objectFilterBuilder; #[\Override] - protected function init() + protected function init(): void { $objectTypes = ObjectTypeCache::getInstance()->getObjectTypes(self::CONDITION_DEFINITION_NAME); @@ -52,19 +57,18 @@ protected function init() /** * Returns the list of grouped trophy condition object types. * - * @return ObjectType[][] + * @return ObjectType[][] + * @deprecated 6.3 The conditions of trophies are stored as object filters. */ - public function getGroupedObjectTypes() + public function getGroupedObjectTypes(): array { return $this->groupedObjectTypes; } /** * Assign trophies based on rules. - * - * @return void */ - public function assignTrophies(int $maxAssigns = 500) + public function assignTrophies(int $maxAssigns = 500): void { $trophyList = new TrophyList(); $trophyList->getConditionBuilder()->add('awardAutomatically = ?', [1]); @@ -73,7 +77,7 @@ public function assignTrophies(int $maxAssigns = 500) $i = 0; foreach ($trophyList as $trophy) { - $userIDs = $this->getUserIDs($trophy); + $userIDs = $this->getUserIDs($trophy, $maxAssigns - $i); foreach ($userIDs as $userID) { (new UserTrophyAction([], 'create', [ @@ -94,11 +98,8 @@ public function assignTrophies(int $maxAssigns = 500) /** * Revoke user trophies which are not longer fulfills the conditions. - * - * @return void - * @since 5.2 */ - public function revokeTrophies(int $maxRevokes = 500) + public function revokeTrophies(int $maxRevokes = 500): void { $trophyList = new TrophyList(); $trophyList->getConditionBuilder()->add('awardAutomatically = ?', [1]); @@ -112,7 +113,9 @@ public function revokeTrophies(int $maxRevokes = 500) $i += \count($userTrophyIDs); - (new UserTrophyAction($userTrophyIDs, 'delete'))->executeAction(); + if ($userTrophyIDs !== []) { + (new UserTrophyAction($userTrophyIDs, 'delete'))->executeAction(); + } if ($i >= $maxRevokes) { return; @@ -121,22 +124,21 @@ public function revokeTrophies(int $maxRevokes = 500) } /** - * Returns the users who fulfill all conditions of the given trophy. + * Returns the ids of the users who fulfill all conditions of the given + * trophy and have not received it yet. * - * @param Trophy $trophy - * @return int[] - * @since 5.2 + * @return int[] */ - private function getUserIDs(Trophy $trophy) + private function getUserIDs(Trophy $trophy, int $maxUserIDs): array { $userList = new UserList(); + // `readObjectIDs()` ignores the join of `UserList::$sqlJoins` that the + // filters of user options rely on. $userList->sqlConditionJoins .= " LEFT JOIN wcf1_user_option_value user_option_value ON user_option_value.userID = user_table.userID"; - - $conditions = $trophy->getConditions(); - foreach ($conditions as $condition) { - $condition->getObjectType()->getProcessor()->addUserCondition($condition, $userList); + if (!$this->getObjectFilterBuilder()->applyFilters($trophy, $userList)) { + return []; } // prevent multiple awards from a trophy for a user @@ -144,10 +146,11 @@ private function getUserIDs(Trophy $trophy) 'user_table.userID NOT IN ( SELECT userID FROM wcf1_user_trophy - WHERE trophyID IN (?) + WHERE trophyID = ? )', [$trophy->trophyID] ); + $userList->sqlLimit = $maxUserIDs; $userList->readObjectIDs(); return $userList->getObjectIDs(); @@ -156,80 +159,49 @@ private function getUserIDs(Trophy $trophy) /** * Returns the userTrophyIDs of the users, which no longer fulfills the trophy conditions. * - * @param int $maxTrophyIDs maximum number of trophies that are processed - * @return int[] - * @since 5.2 + * @return int[] */ - private function getRevocableUserTrophyIDs(Trophy $trophy, int $maxTrophyIDs) + private function getRevocableUserTrophyIDs(Trophy $trophy, int $maxProcessedTrophies): array { - // Unfortunately, the condition system does not support negated conditions. - // Therefore, we need to build our own SQL query. To get to the conditions - // that must be fulfilled for earning a specific trophy, we first create - // a pseudo DBOList element to pass them to the condition handler. Then we - // extract the condition builder from the object. + // The filters are applied to a pseudo list to negate the resulting + // conditions in a custom query. $pseudoUserList = new UserList(); - $conditions = $trophy->getConditions(); - - // Check if there are conditions for the award of the trophy for the given trophy. - // If there are no conditions, we simply return an empty list and do not remove any trophy. - // A trophy without conditions that is awarded automatically cannot be created by default. - if ($conditions === []) { + // A trophy without active filters matches no users, negating its + // conditions would revoke the trophy from all users. + if (!$this->getObjectFilterBuilder()->applyFilters($trophy, $pseudoUserList)) { return []; } - // Assign the condition to the pseudo DBOList object - foreach ($conditions as $condition) { - $condition->getObjectType()->getProcessor()->addUserCondition($condition, $pseudoUserList); - } - - // Now we create our own query to find out which users no longer meet the conditions. - // For this we use a UserList object again and transfer basic data from the pseudo object. - $userList = new UserList(); - $userList->sqlOrderBy = $pseudoUserList->sqlOrderBy; - $userList->sqlLimit = $maxTrophyIDs; - - // Now we copy the sql joins from the pseudo object to the new one if a condition - // has joined a table. - $userList->sqlJoins = $pseudoUserList->sqlJoins; - - // We joining the user_trophy table to receive the userTrophyID, which should be deleted. - $userList->sqlJoins .= " - LEFT JOIN wcf1_user_trophy user_trophy - ON user_table.userID = user_trophy.userID"; - - // We do not need the complete user object, but only the userTrophyID. - // So that the UserList object can also assign the users (which is used - // as an array index), we also get the userID. - $userList->useQualifiedShorthand = false; - $userList->sqlSelects = "user_trophy.userTrophyID, user_table.userID"; - - // Now we transfer the old conditions to our new object. To avoid having two WHERE keywords, - // we deactivate it in the pseudo-object. - $pseudoUserList->getConditionBuilder()->enableWhereKeyword(false); - $userList->getConditionBuilder()->add( - 'NOT(' . $pseudoUserList->getConditionBuilder() . ')', - $pseudoUserList->getConditionBuilder()->getParameters() - ); - - // In order not to get all users who do not fulfill the conditions (in case of - // doubt there can be many), we filter for users who have received the trophy. - $userList->getConditionBuilder()->add( - 'user_table.userID IN ( - SELECT userID - FROM wcf1_user_trophy - WHERE trophyID IN (?) - )', - [$trophy->trophyID] - ); + $conditionBuilder = $pseudoUserList->getConditionBuilder(); + $conditionBuilder->enableWhereKeyword(false); + + // The conditions are `NULL` for users without a matching row of a + // joined table, `IS NOT TRUE` treats those users as not matching. + $sql = "SELECT user_trophy.userTrophyID + FROM wcf1_user_trophy user_trophy + INNER JOIN wcf1_user user_table + ON user_table.userID = user_trophy.userID + LEFT JOIN wcf1_user_option_value user_option_value + ON user_option_value.userID = user_table.userID + {$pseudoUserList->sqlConditionJoins} + WHERE user_trophy.trophyID = ? + AND ({$conditionBuilder}) IS NOT TRUE"; + $statement = WCF::getDB()->prepare($sql, $maxProcessedTrophies); + $statement->execute([ + $trophy->trophyID, + ...$conditionBuilder->getParameters(), + ]); + + return \array_map(static fn($userTrophyID) => (int)$userTrophyID, $statement->fetchAll(\PDO::FETCH_COLUMN)); + } - // Prevents us from getting faulty UserTrophyIDs. - $userList->getConditionBuilder()->add('user_trophy.trophyID = ?', [$trophy->trophyID]); - $userList->readObjects(); + private function getObjectFilterBuilder(): TrophyObjectFilterBuilder + { + if (!isset($this->objectFilterBuilder)) { + $this->objectFilterBuilder = new TrophyObjectFilterBuilder(); + } - // We now return an array of userTrophyIDs instead of user objects to remove them directly via DBOAction. - return \array_map(static function ($object) { - return $object->userTrophyID; - }, $userList->getObjects()); + return $this->objectFilterBuilder; } } diff --git a/wcfsetup/install/files/lib/system/upload/TrophyImageUploadFileValidationStrategy.class.php b/wcfsetup/install/files/lib/system/upload/TrophyImageUploadFileValidationStrategy.class.php deleted file mode 100644 index 7123f82a878..00000000000 --- a/wcfsetup/install/files/lib/system/upload/TrophyImageUploadFileValidationStrategy.class.php +++ /dev/null @@ -1,63 +0,0 @@ - - */ -class TrophyImageUploadFileValidationStrategy implements IUploadFileValidationStrategy -{ - /** - * minimum trophy image width and height - * @var int - */ - const MIN_TROPHY_IMAGE_SIZE = 64; - - #[\Override] - public function validate(UploadFile $uploadFile) - { - if ($uploadFile->getErrorCode() !== 0) { - $uploadFile->setValidationErrorType('uploadFailed'); - - return false; - } - - if ($uploadFile->getImageData() === null) { - $uploadFile->setValidationErrorType('noImage'); - - return false; - } - - if ($uploadFile->getImageData()['width'] !== $uploadFile->getImageData()['height']) { - $uploadFile->setValidationErrorType('notSquared'); - - return false; - } - - if ($uploadFile->getImageData()['width'] < self::MIN_TROPHY_IMAGE_SIZE) { - $uploadFile->setValidationErrorType('tooSmall'); - - return false; - } - - if (!ImageUtil::checkImageContent($uploadFile->getLocation())) { - $uploadFile->setValidationErrorType('noImage'); - - return false; - } - - if (!ImageUtil::isImage($uploadFile->getLocation(), $uploadFile->getFilename())) { - $uploadFile->setValidationErrorType('noImage'); - - return false; - } - - return true; - } -} diff --git a/wcfsetup/install/files/lib/system/user/notification/event/UserTrophyReceivedNotificationEvent.class.php b/wcfsetup/install/files/lib/system/user/notification/event/UserTrophyReceivedNotificationEvent.class.php index ca01e0ae38d..461d76381e3 100644 --- a/wcfsetup/install/files/lib/system/user/notification/event/UserTrophyReceivedNotificationEvent.class.php +++ b/wcfsetup/install/files/lib/system/user/notification/event/UserTrophyReceivedNotificationEvent.class.php @@ -5,7 +5,7 @@ use wcf\data\trophy\category\TrophyCategory; use wcf\data\trophy\category\TrophyCategoryCache; use wcf\data\trophy\Trophy; -use wcf\data\trophy\TrophyAction; +use wcf\data\trophy\TrophyBuilder; use wcf\data\trophy\TrophyCache; use wcf\data\user\trophy\UserTrophy; use wcf\data\user\trophy\UserTrophyAction; @@ -13,6 +13,7 @@ use wcf\system\cache\builder\TrophyCacheBuilder; use wcf\system\cache\eager\CategoryCache; use wcf\system\category\CategoryHandler; +use wcf\system\l10n\L10nStorage; use wcf\system\style\FontAwesomeIcon; use wcf\system\user\notification\object\UserTrophyNotificationObject; use wcf\system\user\notification\TestableUserNotificationEventHandler; @@ -71,20 +72,17 @@ public function checkAccess() #[\Override] public static function getTestObjects(UserProfile $recipient, UserProfile $author) { - /** @var Trophy $trophy */ - $trophy = (new TrophyAction([], 'create', [ - 'data' => [ - 'title' => 'Trophy Title', - 'description' => 'Trophy Description', - 'categoryID' => self::createTestCategory(TrophyCategory::OBJECT_TYPE_NAME)->categoryID, - 'type' => Trophy::TYPE_BADGE, - 'isDisabled' => 0, - 'awardAutomatically' => 0, - 'iconName' => FontAwesomeIcon::fromString('trophy;false')->__toString(), - 'iconColor' => 'rgba(255, 255, 255, 1)', - 'badgeColor' => 'rgba(50, 92, 132, 1)', - ], - ]))->executeAction()['returnValues']; + $trophy = TrophyBuilder::forCreate() + ->setTitle([L10nStorage::MONOLINGUAL => 'Trophy Title']) + ->setDescription([L10nStorage::MONOLINGUAL => 'Trophy Description']) + ->setCategoryID(self::createTestCategory(TrophyCategory::OBJECT_TYPE_NAME)->categoryID) + ->setType(Trophy::TYPE_BADGE) + ->setBadge( + FontAwesomeIcon::fromString('trophy;false')->__toString(), + 'rgba(255, 255, 255, 1)', + 'rgba(50, 92, 132, 1)' + ) + ->create(); TestableUserNotificationEventHandler::getInstance()->resetCacheBuilder(TrophyCacheBuilder::getInstance()); TrophyCache::getInstance()->clearCache(); diff --git a/wcfsetup/install/files/style/ui/trophy.scss b/wcfsetup/install/files/style/ui/trophy.scss index 169817a6d70..1b3e3908657 100644 --- a/wcfsetup/install/files/style/ui/trophy.scss +++ b/wcfsetup/install/files/style/ui/trophy.scss @@ -1,17 +1,3 @@ -#trophyIconEditor .colorBoxValue { - display: block; - height: 24px; - width: 24px; -} - -#trophyIconEditor .colorBox { - background-color: rgb(255, 255, 255); - border: 1px solid rgb(204, 204, 204); - display: inline-block; - padding: 1px; - vertical-align: middle; -} - .badgeIconEditor { column-gap: 10px; display: flex; diff --git a/wcfsetup/install/lang/de.xml b/wcfsetup/install/lang/de.xml index 14f5314f423..f9951c4d2e1 100644 --- a/wcfsetup/install/lang/de.xml +++ b/wcfsetup/install/lang/de.xml @@ -5021,16 +5021,8 @@ Sobald {if LANGUAGE_USE_INFORMAL_VARIANT}dein{else}Ihr{/if} Benutzerkonto freige - - - - - - - - diff --git a/wcfsetup/install/lang/en.xml b/wcfsetup/install/lang/en.xml index 6b194c6ae2d..ec5e8847677 100644 --- a/wcfsetup/install/lang/en.xml +++ b/wcfsetup/install/lang/en.xml @@ -5014,16 +5014,8 @@ You also received a list of backup codes to use when your second factor becomes - - - - - - - - diff --git a/wcfsetup/setup/db/install_com.woltlab.wcf.php b/wcfsetup/setup/db/install_com.woltlab.wcf.php index 1721892d994..268c9ced0d8 100644 --- a/wcfsetup/setup/db/install_com.woltlab.wcf.php +++ b/wcfsetup/setup/db/install_com.woltlab.wcf.php @@ -3857,13 +3857,11 @@ DatabaseTable::create('wcf1_trophy') ->columns([ ObjectIdDatabaseTableColumn::create('trophyID'), - NotNullVarchar255DatabaseTableColumn::create('title'), - MediumtextDatabaseTableColumn::create('description'), NotNullInt10DatabaseTableColumn::create('categoryID'), SmallintDatabaseTableColumn::create('type') ->notNull() ->defaultValue(1), - MediumtextDatabaseTableColumn::create('iconFile'), + IntDatabaseTableColumn::create('iconFileID'), VarcharDatabaseTableColumn::create('iconName') ->length(255), VarcharDatabaseTableColumn::create('iconColor') @@ -3876,6 +3874,7 @@ DefaultFalseBooleanDatabaseTableColumn::create('trophyUseHtml'), NotNullInt10DatabaseTableColumn::create('showOrder') ->defaultValue(0), + JsonDatabaseTableColumn::create('conditions'), ]) ->indices([ DatabaseTablePrimaryIndex::create() @@ -3888,6 +3887,38 @@ ->referencedColumns(['categoryID']) ->onDelete('CASCADE') ->onUpdate('NO ACTION'), + DatabaseTableForeignKey::create() + ->columns(['iconFileID']) + ->referencedTable('wcf1_file') + ->referencedColumns(['fileID']) + ->onDelete('SET NULL') + ->onUpdate('NO ACTION'), + ]), + DatabaseTable::create('wcf1_trophy_l10n') + ->columns([ + NotNullInt10DatabaseTableColumn::create('trophyID'), + IntDatabaseTableColumn::create('languageID'), + VarcharDatabaseTableColumn::create('title') + ->length(255), + MediumtextDatabaseTableColumn::create('description'), + ]) + ->indices([ + DatabaseTableIndex::create('trophyID') + ->columns(['trophyID', 'languageID']), + ]) + ->foreignKeys([ + DatabaseTableForeignKey::create() + ->columns(['trophyID']) + ->referencedTable('wcf1_trophy') + ->referencedColumns(['trophyID']) + ->onDelete('CASCADE') + ->onUpdate('NO ACTION'), + DatabaseTableForeignKey::create() + ->columns(['languageID']) + ->referencedTable('wcf1_language') + ->referencedColumns(['languageID']) + ->onDelete('CASCADE') + ->onUpdate('NO ACTION'), ]), DatabaseTable::create('wcf1_unfurl_url') ->columns([
{lang}wcf.acp.trophy.conditions.description{/lang}