diff --git a/com.woltlab.wcf/package.xml b/com.woltlab.wcf/package.xml
index 1f51200606b..7e7e5e9fe4a 100644
--- a/com.woltlab.wcf/package.xml
+++ b/com.woltlab.wcf/package.xml
@@ -80,6 +80,18 @@
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_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_userOptionL10n.php
+ acp/update_com.woltlab.wcf_6.3_captchaQuestionL10n.php
acp/database/update_com.woltlab.wcf_6.3_step3.php
+
+ The following step requires the new files and must run before the `cronjob` PIP, which precedes the `objectType` PIP:
+ acp/update_com.woltlab.wcf_6.3_cronjobL10n.php
+
+ `acp/database/update_com.woltlab.wcf_6.3_step3.php` must run after all of the scripts above, including `acp/update_com.woltlab.wcf_6.3_cronjobL10n.php`.
-->
diff --git a/com.woltlab.wcf/templates/messageSidebar.tpl b/com.woltlab.wcf/templates/messageSidebar.tpl
index b1b73d95f05..1933caca995 100644
--- a/com.woltlab.wcf/templates/messageSidebar.tpl
+++ b/com.woltlab.wcf/templates/messageSidebar.tpl
@@ -117,7 +117,7 @@
{if $userProfile->getUserOption($__sidebarUserOption)}
{assign var='__formattedUserOption' value=$userProfile->getFormattedUserOption($__sidebarUserOption)}
{if $__formattedUserOption}
-
{lang}wcf.user.option.{$__sidebarUserOption}{/lang}
+ {$userProfile->getUserOptionTitle($__sidebarUserOption)}
{unsafe:$__formattedUserOption}
{/if}
{/if}
diff --git a/com.woltlab.wcf/templates/optionFieldList.tpl b/com.woltlab.wcf/templates/optionFieldList.tpl
index 0466f05027d..076c4e7602d 100644
--- a/com.woltlab.wcf/templates/optionFieldList.tpl
+++ b/com.woltlab.wcf/templates/optionFieldList.tpl
@@ -15,7 +15,7 @@
{icon name='bolt'}
{/if}
- {lang}{$langPrefix}{$option->optionName}{/lang}
+ {if $optionData[title]|isset}{$optionData[title]}{else}{lang}{$langPrefix}{$option->optionName}{/lang}{/if}
{/if}
@@ -29,7 +29,7 @@
{/if}
{/if}
- {lang __optional=true}{$langPrefix}{$option->optionName}.description{/lang}
+ {if $optionData[description]|isset}{$optionData[description]}{else}{lang __optional=true}{$langPrefix}{$option->optionName}.description{/lang}{/if}
{/foreach}
diff --git a/com.woltlab.wcf/templates/shared_wysiwygFormField.tpl b/com.woltlab.wcf/templates/shared_wysiwygFormField.tpl
index 85fbaeb7ca0..55bbbdead8b 100644
--- a/com.woltlab.wcf/templates/shared_wysiwygFormField.tpl
+++ b/com.woltlab.wcf/templates/shared_wysiwygFormField.tpl
@@ -11,6 +11,10 @@
*}{/if}{*
*}{/if}{*
*}{foreach from=$field->getFieldAttributes() key='attributeName' item='attributeValue'} {$attributeName}="{$attributeValue}"{/foreach}{*
-*}>{$field->getValue()}
+*}>{if !$field->isL10n() || !$field->hasI18nValues() || $availableLanguages|count === 1}{$field->getValue()}{/if}
{include file='shared_wysiwyg' wysiwygSelector=$field->getPrefixedId()}
+
+{if $field->isL10n()}
+ {include file='shared_multipleLanguageInputJavascript'}
+{/if}
diff --git a/com.woltlab.wcf/templates/userOptionFieldList.tpl b/com.woltlab.wcf/templates/userOptionFieldList.tpl
index 423340d4f60..4af1dcf5bde 100644
--- a/com.woltlab.wcf/templates/userOptionFieldList.tpl
+++ b/com.woltlab.wcf/templates/userOptionFieldList.tpl
@@ -1,9 +1,9 @@
{foreach from=$options item=optionData}
{assign var=option value=$optionData[object]}
- {if $isSearchMode|empty || !$optionData[hideLabelInSearch]}{$langPrefix|concat:$option->optionName|phrase} {if $isSearchMode|empty && $option->required} * {/if}{/if}
+ {if $isSearchMode|empty || !$optionData[hideLabelInSearch]}{$option->getTitle()} {if $isSearchMode|empty && $option->required} * {/if}{/if}
{unsafe:$optionData[html]}
- {lang __optional=true}{$langPrefix}{$option->optionName}.description{/lang}
+ {$option->getDescription()}
{if $errorType|is_array && $errorType[$option->optionName]|isset}
diff --git a/com.woltlab.wcf/templates/userProfileOptionFieldList.tpl b/com.woltlab.wcf/templates/userProfileOptionFieldList.tpl
index cb00c78c65c..6d654a1b5a2 100644
--- a/com.woltlab.wcf/templates/userProfileOptionFieldList.tpl
+++ b/com.woltlab.wcf/templates/userProfileOptionFieldList.tpl
@@ -6,7 +6,7 @@
{assign var=error value=''}
{/if}
- {$langPrefix|concat:$option->optionName|phrase}
+ {$option->getTitle()}
{unsafe:$optionData[html]}
{if $error}
@@ -17,7 +17,7 @@
{/if}
{/if}
- {lang __optional=true}{$langPrefix}{$option->optionName}.description{/lang}
+ {$option->getDescription()}
{/foreach}
diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon
index 5620e62d133..601699ca85c 100644
--- a/phpstan-baseline.neon
+++ b/phpstan-baseline.neon
@@ -121,12 +121,6 @@ parameters:
count: 11
path: wcfsetup/install/files/lib/acp/form/PageEditForm.class.php
- -
- message: '#^Construct empty\(\) is not allowed\. Use more strict comparison\.$#'
- identifier: empty.notAllowed
- count: 3
- path: wcfsetup/install/files/lib/acp/form/PaidSubscriptionAddForm.class.php
-
-
message: '#^Construct empty\(\) is not allowed\. Use more strict comparison\.$#'
identifier: empty.notAllowed
@@ -457,12 +451,6 @@ parameters:
count: 1
path: wcfsetup/install/files/lib/data/comment/response/CommentResponseCollection.class.php
- -
- message: '#^Construct empty\(\) is not allowed\. Use more strict comparison\.$#'
- identifier: empty.notAllowed
- count: 2
- path: wcfsetup/install/files/lib/data/contact/recipient/ContactRecipient.class.php
-
-
message: '#^Construct empty\(\) is not allowed\. Use more strict comparison\.$#'
identifier: empty.notAllowed
diff --git a/ts/WoltLabSuite/Core/Component/Reaction/Button.ts b/ts/WoltLabSuite/Core/Component/Reaction/Button.ts
index 8417189c2a9..9eaf4f9ab8d 100644
--- a/ts/WoltLabSuite/Core/Component/Reaction/Button.ts
+++ b/ts/WoltLabSuite/Core/Component/Reaction/Button.ts
@@ -95,7 +95,7 @@ class ReactionPopover {
const reactionTypeButtonTitle = document.createElement("span");
reactionTypeButtonTitle.className = "reactionTypeButtonTitle";
- reactionTypeButtonTitle.innerHTML = reactionType.title;
+ reactionTypeButtonTitle.textContent = reactionType.title;
reactionTypeButton.appendChild(reactionTypeButtonTitle);
reactionTypeButton.addEventListener("click", () => this.#click(reactionType.reactionTypeID));
diff --git a/ts/WoltLabSuite/Core/Form/Builder/Field/CkeditorI18n.ts b/ts/WoltLabSuite/Core/Form/Builder/Field/CkeditorI18n.ts
new file mode 100644
index 00000000000..2a8009d0410
--- /dev/null
+++ b/ts/WoltLabSuite/Core/Form/Builder/Field/CkeditorI18n.ts
@@ -0,0 +1,30 @@
+/**
+ * Data handler for CKEditor with l10n support.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2026 WoltLab GmbH
+ * @license GNU Lesser General Public License
+ * @since 6.3
+ */
+
+import ValueI18n from "./ValueI18n";
+import { FormBuilderData } from "../Data";
+import { dispatchToCkeditor } from "WoltLabSuite/Core/Component/Ckeditor/Event";
+import { getCkeditorById } from "WoltLabSuite/Core/Component/Ckeditor";
+
+export class CkeditorI18n extends ValueI18n {
+ protected _getData(): FormBuilderData {
+ // CKEditor does not permanently mirror the contents to the
{/if}
- {lang __optional=true}{$langPrefix}{$option->optionName}.description{/lang}
+ {if $optionData[description]|isset}{$optionData[description]}{else}{lang __optional=true}{$langPrefix}{$option->optionName}.description{/lang}{/if}
{/foreach}
diff --git a/wcfsetup/install/files/acp/templates/paidSubscriptionAdd.tpl b/wcfsetup/install/files/acp/templates/paidSubscriptionAdd.tpl
index 9c54882eee9..84d3a5c2c1b 100644
--- a/wcfsetup/install/files/acp/templates/paidSubscriptionAdd.tpl
+++ b/wcfsetup/install/files/acp/templates/paidSubscriptionAdd.tpl
@@ -1,34 +1,10 @@
{include file='header' pageTitle='wcf.acp.paidSubscription.'|concat:$action}
-
-
-{include file='shared_multipleLanguageInputJavascript' elementIdentifier='description' forceSelection=false}
-{include file='shared_multipleLanguageInputJavascript' elementIdentifier='title' forceSelection=false}
-
-{include file='shared_formNotice'}
-
-
+{unsafe:$form->getHtml()}
{include file='footer'}
diff --git a/wcfsetup/install/files/acp/templates/useroptionsOptionType.tpl b/wcfsetup/install/files/acp/templates/useroptionsOptionType.tpl
index 32616118450..f478c6ff844 100644
--- a/wcfsetup/install/files/acp/templates/useroptionsOptionType.tpl
+++ b/wcfsetup/install/files/acp/templates/useroptionsOptionType.tpl
@@ -31,7 +31,7 @@
- {lang}wcf.user.option.{$availableOption}{/lang}
+ {$optionTitles[$availableOption]}
@@ -40,6 +40,6 @@
{else}
{foreach from=$availableOptions item=availableOption}
- {lang}wcf.user.option.{$availableOption}{/lang}
+ {$optionTitles[$availableOption]}
{/foreach}
{/if}
diff --git a/wcfsetup/install/files/acp/update_com.woltlab.wcf_6.3_captchaQuestionL10n.php b/wcfsetup/install/files/acp/update_com.woltlab.wcf_6.3_captchaQuestionL10n.php
new file mode 100644
index 00000000000..5187b9f241e
--- /dev/null
+++ b/wcfsetup/install/files/acp/update_com.woltlab.wcf_6.3_captchaQuestionL10n.php
@@ -0,0 +1,54 @@
+prepare("DELETE FROM wcf1_captcha_question_l10n")->execute();
+
+L10nLanguageItemSync::migrate(
+ CaptchaQuestion::getL10nDefinition(),
+ static function (array $row): array {
+ $questionIsPhrase = (bool)\preg_match(
+ '~^wcf\.captcha\.question\.question\.question\d+$~',
+ $row['question']
+ );
+ $answersIsPhrase = $row['answers'] !== null && (bool)\preg_match(
+ '~^wcf\.captcha\.question\.answers\.question\d+$~',
+ $row['answers']
+ );
+
+ return [
+ 'sources' => [
+ 'question' => new L10nLanguageItemSource(
+ languageItem: $questionIsPhrase ? $row['question'] : null,
+ literal: $row['question'],
+ deleteAfterMigration: true,
+ ),
+ 'answers' => new L10nLanguageItemSource(
+ languageItem: $answersIsPhrase ? $row['answers'] : null,
+ literal: $row['answers'],
+ deleteAfterMigration: true,
+ ),
+ ],
+ ];
+ }
+);
+
+// Cached question objects were created without their localized values.
+CaptchaQuestionCacheBuilder::getInstance()->reset();
diff --git a/wcfsetup/install/files/acp/update_com.woltlab.wcf_6.3_contactRecipientL10n.php b/wcfsetup/install/files/acp/update_com.woltlab.wcf_6.3_contactRecipientL10n.php
new file mode 100644
index 00000000000..22380e78985
--- /dev/null
+++ b/wcfsetup/install/files/acp/update_com.woltlab.wcf_6.3_contactRecipientL10n.php
@@ -0,0 +1,105 @@
+` and
+ * `wcf.contact.recipient.email` phrases are removed.
+ *
+ * IMPORTANT ordering constraints for package.xml:
+ * - The database script `acp/database/update_com.woltlab.wcf_6.3_step1.php`
+ * (adding the `l10nIdentifier` column and creating the
+ * `wcf1_contact_recipient_l10n` table) must run BEFORE this script.
+ * - The database script `acp/database/update_com.woltlab.wcf_6.3_step3.php`
+ * (dropping the `name` and `email` columns) must run AFTER this script.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2026 WoltLab GmbH
+ * @license GNU Lesser General Public License
+ */
+
+use wcf\data\contact\recipient\ContactRecipient;
+use wcf\system\l10n\L10nLanguageItemSource;
+use wcf\system\l10n\L10nLanguageItemSync;
+use wcf\system\language\LanguageFactory;
+use wcf\system\WCF;
+
+$shippedLanguageItem = 'wcf.contact.recipient.name1';
+
+// Legacy `ContactRecipient::getName()` and `getEmail()` resolved any existing
+// phrase, which includes the phrases of other recipients and phrases named by
+// other packages.
+$sql = "SELECT DISTINCT languageItem
+ FROM wcf1_language_item
+ WHERE languageItem IN (
+ SELECT name
+ FROM wcf1_contact_recipient
+ UNION
+ SELECT email
+ FROM wcf1_contact_recipient
+ )";
+$statement = WCF::getDB()->prepare($sql);
+$statement->execute();
+$existingLanguageItems = $statement->fetchAll(\PDO::FETCH_COLUMN);
+
+// This script owns the table's content at this point (idempotency on re-runs).
+WCF::getDB()->prepare("DELETE FROM wcf1_contact_recipient_l10n")->execute();
+
+// Link the shipped administrator recipient to its language variable. A legacy
+// monolingual edit deleted the phrase and stored a literal name, such a
+// recipient owns its name and stays unlinked.
+$sql = "UPDATE wcf1_contact_recipient
+ SET l10nIdentifier = CASE
+ WHEN isAdministrator = ? AND originIsSystem = ? AND name = ? THEN name
+ ELSE NULL
+ END";
+$statement = WCF::getDB()->prepare($sql);
+$statement->execute([1, 1, $shippedLanguageItem]);
+
+// 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(
+ ContactRecipient::getL10nDefinition(),
+ static function (array $row) use ($existingLanguageItems): array {
+ $nameIsLanguageItem = \in_array($row['name'], $existingLanguageItems, true);
+ if ($row['isAdministrator'] === 1) {
+ $emailSource = new L10nLanguageItemSource(null, '');
+ } else {
+ $emailIsLanguageItem = \in_array($row['email'], $existingLanguageItems, true);
+ $emailSource = new L10nLanguageItemSource(
+ languageItem: $emailIsLanguageItem ? $row['email'] : null,
+ literal: $row['email'],
+ );
+ }
+
+ return [
+ 'sources' => [
+ 'name' => new L10nLanguageItemSource(
+ languageItem: $nameIsLanguageItem ? $row['name'] : null,
+ literal: $row['name'],
+ ),
+ 'email' => $emailSource,
+ ],
+ ];
+ }
+);
+
+$sql = "DELETE FROM wcf1_language_item
+ WHERE languageItem REGEXP ?
+ AND languageItem <> ?";
+$statement = WCF::getDB()->prepare($sql);
+$statement->execute([
+ '^wcf\.contact\.recipient\.(name|email)[0-9]+$',
+ $shippedLanguageItem,
+]);
+if ($statement->getAffectedRows() > 0) {
+ LanguageFactory::getInstance()->deleteLanguageCache();
+}
diff --git a/wcfsetup/install/files/acp/update_com.woltlab.wcf_6.3_cronjobL10n.php b/wcfsetup/install/files/acp/update_com.woltlab.wcf_6.3_cronjobL10n.php
new file mode 100644
index 00000000000..4c2561a11de
--- /dev/null
+++ b/wcfsetup/install/files/acp/update_com.woltlab.wcf_6.3_cronjobL10n.php
@@ -0,0 +1,72 @@
+` phrases, together with the
+ * phrases left behind by previously deleted cronjobs.
+ *
+ * Cronjobs delivered by a package receive their descriptions from the
+ * `cronjob` PIP on every later update of that package.
+ *
+ * IMPORTANT ordering constraints for package.xml:
+ * - The database script `acp/database/update_com.woltlab.wcf_6.3_step1.php`
+ * (creating the `wcf1_cronjob_l10n` table) must run BEFORE this script.
+ * - The `cronjob` PIP must run AFTER this script: a cronjob created by the
+ * PIP before would be migrated from its empty `description` column, losing
+ * the values written by the PIP.
+ * - The database script `acp/database/update_com.woltlab.wcf_6.3_step3.php`
+ * (dropping the `description` column) must run AFTER this script.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2026 WoltLab GmbH
+ * @license GNU Lesser General Public License
+ */
+
+use wcf\data\cronjob\Cronjob;
+use wcf\system\l10n\L10nLanguageItemSource;
+use wcf\system\l10n\L10nLanguageItemSync;
+use wcf\system\language\LanguageFactory;
+use wcf\system\WCF;
+
+// Legacy `Cronjob::getDescription()` resolved any existing phrase, not only
+// the cronjob's own `wcf.acp.cronjob.description.cronjob`.
+$sql = "SELECT DISTINCT languageItem
+ FROM wcf1_language_item
+ WHERE languageItem IN (
+ SELECT description
+ FROM wcf1_cronjob
+ )";
+$statement = WCF::getDB()->prepare($sql);
+$statement->execute();
+$existingLanguageItems = $statement->fetchAll(\PDO::FETCH_COLUMN);
+
+// This script owns the table's content at this point (idempotency on re-runs).
+WCF::getDB()->prepare("DELETE FROM wcf1_cronjob_l10n")->execute();
+
+// 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(
+ Cronjob::getL10nDefinition(),
+ static function (array $row) use ($existingLanguageItems): array {
+ $isLanguageItem = \in_array($row['description'], $existingLanguageItems, true);
+
+ return [
+ 'sources' => [
+ 'description' => new L10nLanguageItemSource(
+ languageItem: $isLanguageItem ? $row['description'] : null,
+ literal: $row['description'],
+ ),
+ ],
+ ];
+ }
+);
+
+$sql = "DELETE FROM wcf1_language_item
+ WHERE languageItem REGEXP ?";
+$statement = WCF::getDB()->prepare($sql);
+$statement->execute(['^wcf\.acp\.cronjob\.description\.cronjob[0-9]+$']);
+if ($statement->getAffectedRows() > 0) {
+ LanguageFactory::getInstance()->deleteLanguageCache();
+}
diff --git a/wcfsetup/install/files/acp/update_com.woltlab.wcf_6.3_labelGroupL10n.php b/wcfsetup/install/files/acp/update_com.woltlab.wcf_6.3_labelGroupL10n.php
new file mode 100644
index 00000000000..227113f3df9
--- /dev/null
+++ b/wcfsetup/install/files/acp/update_com.woltlab.wcf_6.3_labelGroupL10n.php
@@ -0,0 +1,74 @@
+` phrases
+ * are removed, together with the phrases that older versions left behind when
+ * a label group was deleted.
+ *
+ * IMPORTANT ordering constraints for package.xml:
+ * - The database script `acp/database/update_com.woltlab.wcf_6.3_step1.php`
+ * (creating the `wcf1_label_group_l10n` table) must run BEFORE this script.
+ * - The database script `acp/database/update_com.woltlab.wcf_6.3_step3.php`
+ * (dropping the `groupName` column) must run AFTER this script.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2026 WoltLab GmbH
+ * @license GNU Lesser General Public License
+ */
+
+use wcf\data\label\group\LabelGroup;
+use wcf\system\cache\builder\LabelCacheBuilder;
+use wcf\system\l10n\L10nLanguageItemSource;
+use wcf\system\l10n\L10nLanguageItemSync;
+use wcf\system\language\LanguageFactory;
+use wcf\system\WCF;
+
+// Legacy `LabelGroup::getTitle()` resolved any existing phrase, which includes
+// the phrases of other label groups and phrases named by other packages.
+$sql = "SELECT DISTINCT languageItem
+ FROM wcf1_language_item
+ WHERE languageItem IN (
+ SELECT groupName
+ FROM wcf1_label_group
+ )";
+$statement = WCF::getDB()->prepare($sql);
+$statement->execute();
+$existingLanguageItems = $statement->fetchAll(\PDO::FETCH_COLUMN);
+
+// This script owns the table's content at this point (idempotency on re-runs).
+WCF::getDB()->prepare("DELETE FROM wcf1_label_group_l10n")->execute();
+
+// 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(
+ LabelGroup::getL10nDefinition(),
+ static function (array $row) use ($existingLanguageItems): array {
+ $isLanguageItem = \in_array($row['groupName'], $existingLanguageItems, true);
+
+ return [
+ 'sources' => [
+ 'groupName' => new L10nLanguageItemSource(
+ languageItem: $isLanguageItem ? $row['groupName'] : null,
+ literal: $row['groupName'],
+ ),
+ ],
+ ];
+ }
+);
+
+// Cached label groups 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.
+LabelCacheBuilder::getInstance()->reset();
+
+$sql = "DELETE FROM wcf1_language_item
+ WHERE languageItem REGEXP ?";
+$statement = WCF::getDB()->prepare($sql);
+$statement->execute(['^wcf\.acp\.label\.group[0-9]+$']);
+if ($statement->getAffectedRows() > 0) {
+ LanguageFactory::getInstance()->deleteLanguageCache();
+}
diff --git a/wcfsetup/install/files/acp/update_com.woltlab.wcf_6.3_paidSubscriptionL10n.php b/wcfsetup/install/files/acp/update_com.woltlab.wcf_6.3_paidSubscriptionL10n.php
new file mode 100644
index 00000000000..98442fda92f
--- /dev/null
+++ b/wcfsetup/install/files/acp/update_com.woltlab.wcf_6.3_paidSubscriptionL10n.php
@@ -0,0 +1,163 @@
+` and
+ * `wcf.paidSubscription.subscription.description` phrases are removed,
+ * including those left behind by previously deleted subscriptions.
+ *
+ * The positions of the subscriptions are renumbered consecutively, because
+ * the show order is now maintained relative to the other subscriptions.
+ *
+ * IMPORTANT ordering constraints for package.xml:
+ * - The database script `acp/database/update_com.woltlab.wcf_6.3_step1.php`
+ * (creating the `wcf1_paid_subscription_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 Alexander Ebert
+ * @copyright 2001-2026 WoltLab GmbH
+ * @license GNU Lesser General Public License
+ */
+
+use wcf\data\paid\subscription\PaidSubscription;
+use wcf\system\html\input\HtmlInputProcessor;
+use wcf\system\l10n\L10nLanguageItemSource;
+use wcf\system\l10n\L10nLanguageItemSync;
+use wcf\system\language\LanguageFactory;
+use wcf\system\WCF;
+
+$languageItemPattern = '^wcf\.paidSubscription\.subscription[0-9]+(\.description)?$';
+
+// This script owns the table's content at this point (idempotency on re-runs).
+WCF::getDB()->prepare("DELETE FROM wcf1_paid_subscription_l10n")->execute();
+
+// The length of phrases was never validated, and the language editor allows
+// phrases of any length. Titles are shortened by the migration, but the limit of
+// the description is measured in bytes. The phrases are removed below anyway,
+// shortening them is lossless for a retry. A shortened HTML description is
+// repaired by the input processor below.
+foreach (['languageItemValue', 'languageCustomItemValue'] as $valueColumn) {
+ $sql = "UPDATE wcf1_language_item
+ SET {$valueColumn} = SUBSTRING({$valueColumn}, 1, 16383)
+ WHERE languageItem REGEXP ?
+ AND LENGTH({$valueColumn}) > 65535";
+ $statement = WCF::getDB()->prepare($sql);
+ $statement->execute(['^wcf\.paidSubscription\.subscription[0-9]+\.description$']);
+}
+
+// Legacy `PaidSubscription::getTitle()` resolved any existing phrase, which
+// includes the phrases of other subscriptions and phrases named by other
+// packages. `getDescription()` only resolved the subscription phrase pattern.
+$sql = "SELECT DISTINCT languageItem
+ FROM wcf1_language_item
+ WHERE languageItem IN (
+ SELECT title
+ FROM wcf1_paid_subscription
+ )";
+$statement = WCF::getDB()->prepare($sql);
+$statement->execute();
+$existingTitleLanguageItems = $statement->fetchAll(\PDO::FETCH_COLUMN);
+
+$sql = "SELECT DISTINCT languageItem
+ FROM wcf1_language_item
+ WHERE languageItem IN (
+ SELECT description
+ FROM wcf1_paid_subscription
+ )";
+$statement = WCF::getDB()->prepare($sql);
+$statement->execute();
+$existingDescriptionLanguageItems = \array_filter(
+ $statement->fetchAll(\PDO::FETCH_COLUMN),
+ static fn(string $languageItem) => \preg_match('~^wcf.paidSubscription.subscription\d+.description$~', $languageItem) === 1
+);
+
+$sql = "SELECT subscriptionID
+ FROM wcf1_paid_subscription
+ ORDER BY showOrder, subscriptionID";
+$statement = WCF::getDB()->prepare($sql);
+$statement->execute();
+$subscriptionIDs = $statement->fetchAll(\PDO::FETCH_COLUMN);
+
+$sql = "UPDATE wcf1_paid_subscription
+ SET showOrder = ?
+ WHERE subscriptionID = ?";
+$statement = WCF::getDB()->prepare($sql);
+foreach ($subscriptionIDs as $index => $subscriptionID) {
+ $statement->execute([
+ $index + 1,
+ $subscriptionID,
+ ]);
+}
+
+// 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(
+ PaidSubscription::getL10nDefinition(),
+ static function (array $row) use ($existingTitleLanguageItems, $existingDescriptionLanguageItems): array {
+ $description = $row['description'] ?? '';
+
+ return [
+ 'sources' => [
+ 'title' => new L10nLanguageItemSource(
+ languageItem: \in_array($row['title'], $existingTitleLanguageItems, true) ? $row['title'] : null,
+ literal: $row['title'],
+ ),
+ 'description' => new L10nLanguageItemSource(
+ languageItem: \in_array($description, $existingDescriptionLanguageItems, true) ? $description : null,
+ literal: $description,
+ ),
+ ],
+ ];
+ }
+);
+
+$sql = "SELECT subscriptionID, languageID, description
+ FROM wcf1_paid_subscription_l10n";
+$statement = WCF::getDB()->prepare($sql);
+$statement->execute();
+$rows = $statement->fetchAll(\PDO::FETCH_ASSOC);
+
+$sql = "UPDATE wcf1_paid_subscription_l10n
+ SET description = ?
+ WHERE subscriptionID = ?
+ AND languageID <=> ?";
+$updateStatement = WCF::getDB()->prepare($sql);
+foreach ($rows as $row) {
+ // Descriptions without HTML are legacy plain text, which is still rendered
+ // as such by `PaidSubscription::getFormattedDescription()`.
+ if ($row['description'] === null || \preg_match('~^<[a-z]+~', $row['description']) !== 1) {
+ continue;
+ }
+
+ // The processed HTML can be longer than its source and exceed the column.
+ $description = $row['description'];
+ do {
+ $htmlInputProcessor = new HtmlInputProcessor();
+ $htmlInputProcessor->process($description, 'com.woltlab.wcf.paidSubscription', $row['subscriptionID']);
+ $html = $htmlInputProcessor->getHtml();
+
+ $description = \mb_substr($description, 0, (int)(\mb_strlen($description) * 0.9));
+ } while (\strlen($html) > 65535);
+
+ $updateStatement->execute([
+ $html,
+ $row['subscriptionID'],
+ $row['languageID'],
+ ]);
+}
+
+$sql = "DELETE FROM wcf1_language_item
+ WHERE languageItem REGEXP ?";
+$statement = WCF::getDB()->prepare($sql);
+$statement->execute([$languageItemPattern]);
+if ($statement->getAffectedRows() > 0) {
+ LanguageFactory::getInstance()->deleteLanguageCache();
+}
diff --git a/wcfsetup/install/files/acp/update_com.woltlab.wcf_6.3_reactionTypeL10n.php b/wcfsetup/install/files/acp/update_com.woltlab.wcf_6.3_reactionTypeL10n.php
new file mode 100644
index 00000000000..b63f64b78d5
--- /dev/null
+++ b/wcfsetup/install/files/acp/update_com.woltlab.wcf_6.3_reactionTypeL10n.php
@@ -0,0 +1,122 @@
+` language
+ * variable) into the `wcf1_reaction_type_l10n` table.
+ *
+ * The reaction types shipped with the package (`wcf.reactionType.title1` to
+ * `wcf.reactionType.title5`) are linked to their language variable via
+ * `l10nIdentifier`; their localized values are stored as pristine copies and
+ * kept in sync with the phrases. Reaction types created by an administrator
+ * own their title: they stay unlinked and their obsolete phrases are removed,
+ * together with the phrases left behind by previously deleted reaction types.
+ *
+ * IMPORTANT ordering constraints for package.xml:
+ * - The database script `acp/database/update_com.woltlab.wcf_6.3_step1.php`
+ * (adding the `l10nIdentifier` column and creating the
+ * `wcf1_reaction_type_l10n` table) must run BEFORE this script.
+ * - The database script `acp/database/update_com.woltlab.wcf_6.3_step3.php`
+ * (dropping the `title` column) must run AFTER this script.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2026 WoltLab GmbH
+ * @license GNU Lesser General Public License
+ */
+
+use wcf\data\reaction\type\ReactionType;
+use wcf\system\cache\builder\ReactionTypeCacheBuilder;
+use wcf\system\database\util\PreparedStatementConditionBuilder;
+use wcf\system\l10n\L10nLanguageItemSource;
+use wcf\system\l10n\L10nLanguageItemSync;
+use wcf\system\language\LanguageFactory;
+use wcf\system\WCF;
+
+$shippedLanguageItems = [
+ 'wcf.reactionType.title1',
+ 'wcf.reactionType.title2',
+ 'wcf.reactionType.title3',
+ 'wcf.reactionType.title4',
+ 'wcf.reactionType.title5',
+];
+
+// Legacy `ReactionType::getTitle()` resolved any existing phrase, which
+// includes phrases named by other packages.
+$sql = "SELECT DISTINCT languageItem
+ FROM wcf1_language_item
+ WHERE languageItem IN (
+ SELECT title
+ FROM wcf1_reaction_type
+ )";
+$statement = WCF::getDB()->prepare($sql);
+$statement->execute();
+$existingLanguageItems = $statement->fetchAll(\PDO::FETCH_COLUMN);
+
+$getOwnLanguageItem = static function (array $row): ?string {
+ $languageItem = 'wcf.reactionType.title' . $row['reactionTypeID'];
+
+ // `wcf.reactionType.title` is a placeholder written by the legacy create
+ // action before the phrase of the new reaction type was saved.
+ if ($row['title'] === $languageItem || $row['title'] === 'wcf.reactionType.title') {
+ return $languageItem;
+ }
+
+ return null;
+};
+
+// This script owns the table's content at this point (idempotency on re-runs).
+WCF::getDB()->prepare("DELETE FROM wcf1_reaction_type_l10n")->execute();
+
+// Link the shipped reaction types to their language variable; reaction types
+// created by an administrator own their title and stay unlinked.
+$statement = WCF::getDB()->prepare("SELECT reactionTypeID, title FROM wcf1_reaction_type");
+$statement->execute();
+$updateStatement = WCF::getDB()->prepare(
+ "UPDATE wcf1_reaction_type SET l10nIdentifier = ? WHERE reactionTypeID = ?"
+);
+while ($row = $statement->fetchArray()) {
+ $languageItem = $getOwnLanguageItem($row);
+
+ $updateStatement->execute([
+ \in_array($languageItem, $shippedLanguageItems, true) ? $languageItem : null,
+ $row['reactionTypeID'],
+ ]);
+}
+
+// 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(
+ ReactionType::getL10nDefinition(),
+ static function (array $row) use ($getOwnLanguageItem, $existingLanguageItems): array {
+ $languageItem = $getOwnLanguageItem($row);
+ if ($languageItem === null && \in_array($row['title'], $existingLanguageItems, true)) {
+ $languageItem = $row['title'];
+ }
+
+ return [
+ 'sources' => [
+ 'title' => new L10nLanguageItemSource(
+ languageItem: $languageItem,
+ literal: $row['title'],
+ ),
+ ],
+ ];
+ }
+);
+
+// Removes the migrated phrases as well as those the legacy code left behind:
+// the delete action never removed the phrase of a reaction type, and switching
+// a title to a monolingual value kept the previous phrase.
+$conditions = new PreparedStatementConditionBuilder();
+$conditions->add('languageItem REGEXP ?', ['^wcf\.reactionType\.title[0-9]+$']);
+$conditions->add('languageItem NOT IN (?)', [$shippedLanguageItems]);
+$sql = "DELETE FROM wcf1_language_item
+ {$conditions}";
+$statement = WCF::getDB()->prepare($sql);
+$statement->execute($conditions->getParameters());
+if ($statement->getAffectedRows() > 0) {
+ LanguageFactory::getInstance()->deleteLanguageCache();
+}
+
+// Cached reaction types were created without their localized values.
+ReactionTypeCacheBuilder::getInstance()->reset();
diff --git a/wcfsetup/install/files/acp/update_com.woltlab.wcf_6.3_userOptionL10n.php b/wcfsetup/install/files/acp/update_com.woltlab.wcf_6.3_userOptionL10n.php
new file mode 100644
index 00000000000..ce9306e4712
--- /dev/null
+++ b/wcfsetup/install/files/acp/update_com.woltlab.wcf_6.3_userOptionL10n.php
@@ -0,0 +1,67 @@
+[.description]` language variables into the
+ * `wcf1_user_option_l10n` table.
+ *
+ * System options (shipped by a package) are linked to their language variable
+ * via `l10nIdentifier`; their localized values are stored as pristine copies
+ * and kept in sync with the phrases. Options created by an administrator
+ * (`option`) own their localized value: they stay unlinked and their
+ * obsolete phrases are removed.
+ *
+ * IMPORTANT ordering constraint for package.xml: The database script
+ * `acp/database/update_com.woltlab.wcf_6.3_step1.php` (adding the
+ * `l10nIdentifier` column and creating the `wcf1_user_option_l10n` table) must
+ * run BEFORE this script.
+ */
+
+use wcf\data\user\option\UserOption;
+use wcf\system\cache\builder\UserOptionCacheBuilder;
+use wcf\system\l10n\L10nLanguageItemSource;
+use wcf\system\l10n\L10nLanguageItemSync;
+use wcf\system\WCF;
+
+$isAdminCreated = static fn(string $optionName): bool => (bool)\preg_match('/^option\d+$/', $optionName);
+
+// This script owns the table's content at this point (idempotency on re-runs).
+WCF::getDB()->prepare("DELETE FROM wcf1_user_option_l10n")->execute();
+
+// Link system options to their language variable; administrator created
+// options own their localized value and stay unlinked.
+$statement = WCF::getDB()->prepare("SELECT optionID, optionName FROM wcf1_user_option");
+$statement->execute();
+$updateStatement = WCF::getDB()->prepare(
+ "UPDATE wcf1_user_option SET l10nIdentifier = ? WHERE optionID = ?"
+);
+while ($row = $statement->fetchArray()) {
+ $updateStatement->execute([
+ $isAdminCreated($row['optionName']) ? null : 'wcf.user.option.' . $row['optionName'],
+ $row['optionID'],
+ ]);
+}
+
+// Migrate the phrase values into the l10n storage.
+L10nLanguageItemSync::migrate(
+ UserOption::getL10nDefinition(),
+ static function (array $row) use ($isAdminCreated): array {
+ $adminCreated = $isAdminCreated($row['optionName']);
+ $identifier = 'wcf.user.option.' . $row['optionName'];
+
+ return [
+ 'sources' => [
+ 'title' => new L10nLanguageItemSource(
+ languageItem: $identifier,
+ deleteAfterMigration: $adminCreated,
+ ),
+ 'description' => new L10nLanguageItemSource(
+ languageItem: $identifier . '.description',
+ deleteAfterMigration: $adminCreated,
+ ),
+ ],
+ ];
+ }
+);
+
+UserOptionCacheBuilder::getInstance()->reset();
diff --git a/wcfsetup/install/files/acp/update_com.woltlab.wcf_6.3_userRankL10n.php b/wcfsetup/install/files/acp/update_com.woltlab.wcf_6.3_userRankL10n.php
new file mode 100644
index 00000000000..84d1d980d68
--- /dev/null
+++ b/wcfsetup/install/files/acp/update_com.woltlab.wcf_6.3_userRankL10n.php
@@ -0,0 +1,109 @@
+` phrases are removed, together with the
+ * phrases left behind by previously deleted user ranks.
+ *
+ * IMPORTANT ordering constraints for package.xml:
+ * - The database script `acp/database/update_com.woltlab.wcf_6.3_step1.php`
+ * (adding the `l10nIdentifier` column and creating the
+ * `wcf1_user_rank_l10n` table) must run BEFORE this script.
+ * - The database script `acp/database/update_com.woltlab.wcf_6.3_step3.php`
+ * (dropping the `rankTitle` column) must run AFTER this script.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2026 WoltLab GmbH
+ * @license GNU Lesser General Public License
+ */
+
+use wcf\data\user\rank\UserRank;
+use wcf\system\cache\builder\UserRankCacheBuilder;
+use wcf\system\l10n\L10nLanguageItemSource;
+use wcf\system\l10n\L10nLanguageItemSync;
+use wcf\system\language\LanguageFactory;
+use wcf\system\WCF;
+
+$shippedLanguageItems = [
+ 'wcf.user.rank.administrator',
+ 'wcf.user.rank.moderator',
+ 'wcf.user.rank.user0',
+ 'wcf.user.rank.user1',
+ 'wcf.user.rank.user2',
+ 'wcf.user.rank.user3',
+ 'wcf.user.rank.user4',
+ 'wcf.user.rank.user5',
+];
+
+// Legacy `UserRank::getTitle()` resolved any existing phrase, which includes
+// the phrases of other user ranks and phrases named by other packages.
+$sql = "SELECT DISTINCT languageItem
+ FROM wcf1_language_item
+ WHERE languageItem IN (
+ SELECT rankTitle
+ FROM wcf1_user_rank
+ )";
+$statement = WCF::getDB()->prepare($sql);
+$statement->execute();
+$existingLanguageItems = $statement->fetchAll(\PDO::FETCH_COLUMN);
+
+// This script owns the table's content at this point (idempotency on re-runs).
+WCF::getDB()->prepare("DELETE FROM wcf1_user_rank_l10n")->execute();
+
+// Link the shipped user ranks to their language variable; user ranks created
+// by an administrator own their title and stay unlinked.
+$statement = WCF::getDB()->prepare("SELECT rankID, rankTitle FROM wcf1_user_rank");
+$statement->execute();
+$updateStatement = WCF::getDB()->prepare(
+ "UPDATE wcf1_user_rank SET l10nIdentifier = ? WHERE rankID = ?"
+);
+while ($row = $statement->fetchArray()) {
+ $updateStatement->execute([
+ \in_array($row['rankTitle'], $shippedLanguageItems, true) ? $row['rankTitle'] : null,
+ $row['rankID'],
+ ]);
+}
+
+// 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(
+ UserRank::getL10nDefinition(),
+ static function (array $row) use ($existingLanguageItems): array {
+ $isLanguageItem = \in_array($row['rankTitle'], $existingLanguageItems, true);
+
+ return [
+ 'sources' => [
+ 'rankTitle' => new L10nLanguageItemSource(
+ languageItem: $isLanguageItem ? $row['rankTitle'] : null,
+ literal: $row['rankTitle'],
+ ),
+ ],
+ ];
+ }
+);
+
+// Cached user ranks 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.
+UserRankCacheBuilder::getInstance()->reset();
+
+// Removes the migrated phrases as well as those the legacy code left behind:
+// deleting a user group removed its user ranks without their phrases, and
+// before 6.2 the phrases were saved in the `wcf.user` category, which the
+// legacy cleanup did not cover.
+$sql = "DELETE FROM wcf1_language_item
+ WHERE languageItem REGEXP ?";
+$statement = WCF::getDB()->prepare($sql);
+$statement->execute(['^wcf\.user\.rank\.userRank[0-9]+$']);
+if ($statement->getAffectedRows() > 0) {
+ LanguageFactory::getInstance()->deleteLanguageCache();
+}
diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Reaction/Button.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Reaction/Button.js
index 024a2cc1a68..b23668dbb83 100644
--- a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Reaction/Button.js
+++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Reaction/Button.js
@@ -69,7 +69,7 @@ define(["require", "exports", "tslib", "WoltLabSuite/Core/Api/Reactions/RevertRe
reactionTypeButton.innerHTML = reactionType.renderedIcon;
const reactionTypeButtonTitle = document.createElement("span");
reactionTypeButtonTitle.className = "reactionTypeButtonTitle";
- reactionTypeButtonTitle.innerHTML = reactionType.title;
+ reactionTypeButtonTitle.textContent = reactionType.title;
reactionTypeButton.appendChild(reactionTypeButtonTitle);
reactionTypeButton.addEventListener("click", () => this.#click(reactionType.reactionTypeID));
if (!reactionType.isAssignable) {
diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Form/Builder/Field/CkeditorI18n.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Form/Builder/Field/CkeditorI18n.js
new file mode 100644
index 00000000000..c2256673134
--- /dev/null
+++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Form/Builder/Field/CkeditorI18n.js
@@ -0,0 +1,27 @@
+/**
+ * Data handler for CKEditor with l10n support.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2026 WoltLab GmbH
+ * @license GNU Lesser General Public License
+ * @since 6.3
+ */
+define(["require", "exports", "tslib", "./ValueI18n", "WoltLabSuite/Core/Component/Ckeditor/Event", "WoltLabSuite/Core/Component/Ckeditor"], function (require, exports, tslib_1, ValueI18n_1, Event_1, Ckeditor_1) {
+ "use strict";
+ Object.defineProperty(exports, "__esModule", { value: true });
+ exports.CkeditorI18n = void 0;
+ ValueI18n_1 = tslib_1.__importDefault(ValueI18n_1);
+ class CkeditorI18n extends ValueI18n_1.default {
+ _getData() {
+ // CKEditor does not permanently mirror the contents to the .
+ this._field.value = (0, Ckeditor_1.getCkeditorById)(this._fieldId).getHtml();
+ return super._getData();
+ }
+ destroy() {
+ super.destroy();
+ (0, Event_1.dispatchToCkeditor)(this._field).destroy();
+ }
+ }
+ exports.CkeditorI18n = CkeditorI18n;
+ exports.default = CkeditorI18n;
+});
diff --git a/wcfsetup/install/files/lib/acp/form/CaptchaQuestionAddForm.class.php b/wcfsetup/install/files/lib/acp/form/CaptchaQuestionAddForm.class.php
index 37b0da3e67a..094bc88a1d2 100644
--- a/wcfsetup/install/files/lib/acp/form/CaptchaQuestionAddForm.class.php
+++ b/wcfsetup/install/files/lib/acp/form/CaptchaQuestionAddForm.class.php
@@ -2,12 +2,16 @@
namespace wcf\acp\form;
+use wcf\command\captcha\question\CreateCaptchaQuestion;
+use wcf\command\captcha\question\UpdateCaptchaQuestion;
use wcf\data\captcha\question\CaptchaQuestion;
-use wcf\data\captcha\question\CaptchaQuestionAction;
+use wcf\data\captcha\question\CaptchaQuestionBuilder;
+use wcf\data\DatabaseObjectBuilder;
use wcf\data\language\Language;
-use wcf\form\AbstractFormBuilderForm;
+use wcf\form\AbstractDatabaseObjectBuilderForm;
use wcf\system\form\builder\container\FormContainer;
use wcf\system\form\builder\field\BooleanFormField;
+use wcf\system\form\builder\field\IFormField;
use wcf\system\form\builder\field\MultilineTextFormField;
use wcf\system\form\builder\field\TextFormField;
use wcf\system\form\builder\field\validation\FormFieldValidationError;
@@ -19,12 +23,12 @@
* Shows the form to create a new captcha question.
*
* @author Olaf Braun, Matthias Schmidt
- * @copyright 2001-2024 WoltLab GmbH
+ * @copyright 2001-2026 WoltLab GmbH
* @license GNU Lesser General Public License
*
- * @extends AbstractFormBuilderForm
+ * @extends AbstractDatabaseObjectBuilderForm
*/
-class CaptchaQuestionAddForm extends AbstractFormBuilderForm
+class CaptchaQuestionAddForm extends AbstractDatabaseObjectBuilderForm
{
/**
* @inheritDoc
@@ -39,30 +43,48 @@ class CaptchaQuestionAddForm extends AbstractFormBuilderForm
/**
* @inheritDoc
*/
- public $objectActionClass = CaptchaQuestionAction::class;
+ public string $objectEditLinkController = CaptchaQuestionEditForm::class;
- /**
- * @inheritDoc
- */
- public $objectEditLinkController = CaptchaQuestionEditForm::class;
+ #[\Override]
+ protected function getDatabaseObjectBuilder(): CaptchaQuestionBuilder
+ {
+ if ($this->formObject !== null) {
+ return CaptchaQuestionBuilder::forUpdate($this->formObject);
+ }
+
+ return CaptchaQuestionBuilder::forCreate();
+ }
#[\Override]
- protected function createForm()
+ protected function getCommand(DatabaseObjectBuilder $builder): callable
{
- parent::createForm();
+ if ($this->formObject !== null) {
+ return new UpdateCaptchaQuestion($builder);
+ }
+
+ return new CreateCaptchaQuestion($builder);
+ }
+ #[\Override]
+ protected function createForm(): void
+ {
$this->form->appendChildren([
FormContainer::create('general')
->appendChildren([
TextFormField::create('question')
->label('wcf.acp.captcha.question.question')
- ->i18n()
- ->languageItemPattern('wcf.captcha.question.question.question\d+')
- ->required(),
+ ->l10n()
+ ->required()
+ ->maximumLength(255)
+ ->saveValueCallback(static function (CaptchaQuestionBuilder $builder, TextFormField $field) {
+ $builder->setQuestion($field->getL10nValues());
+ })
+ ->loadValueCallback(static function (CaptchaQuestion $object, IFormField $field) {
+ $field->value($object->getL10nValues('question'));
+ }),
MultilineTextFormField::create('answers')
->label('wcf.acp.captcha.question.answers')
- ->i18n()
- ->languageItemPattern('wcf.captcha.question.answers.question\d+')
+ ->l10n()
->required()
->addValidator(
new FormFieldValidator('regexValidator', function (MultilineTextFormField $formField) {
@@ -80,10 +102,22 @@ protected function createForm()
}
}
})
- ),
+ )
+ ->saveValueCallback(static function (CaptchaQuestionBuilder $builder, MultilineTextFormField $field) {
+ $builder->setAnswers($field->getL10nValues());
+ })
+ ->loadValueCallback(static function (CaptchaQuestion $object, IFormField $field) {
+ $field->value($object->getL10nValues('answers'));
+ }),
BooleanFormField::create('isDisabled')
->label('wcf.acp.captcha.question.isDisabled')
->value(false)
+ ->saveValueCallback(static function (CaptchaQuestionBuilder $builder, IFormField $field) {
+ $builder->setIsDisabled((bool)$field->getSaveValue());
+ })
+ ->loadValueCallback(static function (CaptchaQuestion $object, IFormField $field) {
+ $field->value($object->isDisabled);
+ }),
])
]);
}
diff --git a/wcfsetup/install/files/lib/acp/form/CaptchaQuestionEditForm.class.php b/wcfsetup/install/files/lib/acp/form/CaptchaQuestionEditForm.class.php
index e7a416112c1..18fa5a0df9f 100644
--- a/wcfsetup/install/files/lib/acp/form/CaptchaQuestionEditForm.class.php
+++ b/wcfsetup/install/files/lib/acp/form/CaptchaQuestionEditForm.class.php
@@ -27,7 +27,7 @@ class CaptchaQuestionEditForm extends CaptchaQuestionAddForm
/**
* @inheritDoc
*/
- public $formAction = 'edit';
+ public string $formAction = 'edit';
#[\Override]
public function readParameters()
diff --git a/wcfsetup/install/files/lib/acp/form/ContactRecipientAddForm.class.php b/wcfsetup/install/files/lib/acp/form/ContactRecipientAddForm.class.php
index b76925f1928..9e6da42ed99 100644
--- a/wcfsetup/install/files/lib/acp/form/ContactRecipientAddForm.class.php
+++ b/wcfsetup/install/files/lib/acp/form/ContactRecipientAddForm.class.php
@@ -2,14 +2,19 @@
namespace wcf\acp\form;
+use wcf\command\contact\recipient\CreateContactRecipient;
+use wcf\command\contact\recipient\UpdateContactRecipient;
use wcf\data\contact\recipient\ContactRecipient;
-use wcf\data\contact\recipient\ContactRecipientAction;
+use wcf\data\contact\recipient\ContactRecipientBuilder;
use wcf\data\contact\recipient\ContactRecipientList;
-use wcf\form\AbstractFormBuilderForm;
+use wcf\data\DatabaseObjectBuilder;
+use wcf\form\AbstractDatabaseObjectBuilderForm;
use wcf\system\form\builder\field\BooleanFormField;
use wcf\system\form\builder\field\EmailFormField;
+use wcf\system\form\builder\field\IFormField;
use wcf\system\form\builder\field\ShowOrderFormField;
use wcf\system\form\builder\field\TextFormField;
+use wcf\util\StringUtil;
/**
* Shows the form to create a new contact form recipient.
@@ -18,9 +23,9 @@
* @copyright 2001-2025 WoltLab GmbH
* @license GNU Lesser General Public License
*
- * @extends AbstractFormBuilderForm
+ * @extends AbstractDatabaseObjectBuilderForm
*/
-class ContactRecipientAddForm extends AbstractFormBuilderForm
+class ContactRecipientAddForm extends AbstractDatabaseObjectBuilderForm
{
/**
* @inheritDoc
@@ -40,41 +45,91 @@ class ContactRecipientAddForm extends AbstractFormBuilderForm
/**
* @inheritDoc
*/
- public $objectActionClass = ContactRecipientAction::class;
+ public string $objectEditLinkController = ContactRecipientEditForm::class;
- /**
- * @inheritDoc
- */
- public $objectEditLinkController = ContactRecipientEditForm::class;
+ #[\Override]
+ protected function getDatabaseObjectBuilder(): ContactRecipientBuilder
+ {
+ if ($this->formObject !== null) {
+ return ContactRecipientBuilder::forUpdate($this->formObject);
+ }
+
+ return ContactRecipientBuilder::forCreate();
+ }
#[\Override]
- protected function createForm()
+ protected function getCommand(DatabaseObjectBuilder $builder): callable
+ {
+ if ($this->formObject !== null) {
+ return new UpdateContactRecipient($builder);
+ }
+
+ return new CreateContactRecipient($builder);
+ }
+
+ #[\Override]
+ protected function createForm(): void
{
parent::createForm();
- $isAdministratorRecipient = $this->formAction === 'edit' && $this->formObject->isAdministrator !== 0;
+ $isAdministratorRecipient = $this->formObject !== null && $this->formObject->isAdministrator !== 0;
$emailFormField = EmailFormField::create('email')
->label('wcf.user.email')
->immutable($isAdministratorRecipient)
- ->required();
+ ->required()
+ ->saveValueCallback(static function (ContactRecipientBuilder $builder, EmailFormField $field): void {
+ // The administrator recipient always uses `MAIL_ADMIN_ADDRESS`.
+ if ($field->isImmutable()) {
+ return;
+ }
+
+ $builder->setEmail($field->getL10nValues());
+ })
+ ->loadValueCallback(static function (ContactRecipient $object, IFormField $field): void {
+ if ($object->isAdministrator !== 0) {
+ $field->value($object->getEmail());
+ } else {
+ $field->value($object->getL10nValues('email'));
+ }
+ });
if (!$isAdministratorRecipient) {
- $emailFormField->i18n()
- ->languageItemPattern('wcf.contact.recipient.email\d+');
+ $emailFormField->l10n();
}
$this->form->appendChildren([
TextFormField::create('name')
->label('wcf.acp.contact.recipient.name')
- ->i18n()
- ->languageItemPattern('wcf.contact.recipient.name\d+')
- ->required(),
+ ->l10n()
+ ->required()
+ ->maximumLength(255)
+ ->saveValueCallback(static function (ContactRecipientBuilder $builder, TextFormField $field): void {
+ $builder->setName($field->getL10nValues());
+ })
+ ->loadValueCallback(static function (ContactRecipient $object, IFormField $field): void {
+ $field->value($object->getL10nValues('name'));
+ }),
$emailFormField,
ShowOrderFormField::create()
- ->options($this->getContactRecipient()),
+ ->options($this->getContactRecipient(), labelLanguageItems: false)
+ ->saveValueCallback(static function (ContactRecipientBuilder $builder, ShowOrderFormField $field): void {
+ $showOrder = $field->getSaveValue();
+ if ($showOrder !== null) {
+ $builder->setShowOrder($showOrder);
+ }
+ })
+ ->loadValueCallback(static function (ContactRecipient $object, IFormField $field): void {
+ $field->value($object->showOrder);
+ }),
BooleanFormField::create('isDisabled')
- ->label('wcf.acp.contact.recipient.isDisabled'),
+ ->label('wcf.acp.contact.recipient.isDisabled')
+ ->saveValueCallback(static function (ContactRecipientBuilder $builder, BooleanFormField $field): void {
+ $builder->setIsDisabled((bool)$field->getSaveValue());
+ })
+ ->loadValueCallback(static function (ContactRecipient $object, IFormField $field): void {
+ $field->value($object->isDisabled);
+ }),
]);
}
@@ -87,6 +142,10 @@ protected function getContactRecipient(): array
$recipientList->sqlOrderBy = 'showOrder ASC';
$recipientList->readObjects();
- return \array_map(static fn($recipient) => $recipient->getName(), $recipientList->getObjects());
+ // The labels of selection options are printed as HTML.
+ return \array_map(
+ static fn(ContactRecipient $recipient) => StringUtil::encodeHTML($recipient->getName()),
+ $recipientList->getObjects()
+ );
}
}
diff --git a/wcfsetup/install/files/lib/acp/form/ContactRecipientEditForm.class.php b/wcfsetup/install/files/lib/acp/form/ContactRecipientEditForm.class.php
index 2fb3757d980..be4e0eb44ab 100644
--- a/wcfsetup/install/files/lib/acp/form/ContactRecipientEditForm.class.php
+++ b/wcfsetup/install/files/lib/acp/form/ContactRecipientEditForm.class.php
@@ -37,7 +37,7 @@ class ContactRecipientEditForm extends ContactRecipientAddForm
/**
* @inheritDoc
*/
- public $formAction = 'edit';
+ public string $formAction = 'edit';
#[\Override]
public function readParameters()
diff --git a/wcfsetup/install/files/lib/acp/form/CronjobAddForm.class.php b/wcfsetup/install/files/lib/acp/form/CronjobAddForm.class.php
index 4be31bccdc6..68106b51479 100755
--- a/wcfsetup/install/files/lib/acp/form/CronjobAddForm.class.php
+++ b/wcfsetup/install/files/lib/acp/form/CronjobAddForm.class.php
@@ -4,18 +4,20 @@
use Cron\CronExpression;
use Cron\FieldFactory;
+use wcf\command\cronjob\CreateCronjob;
+use wcf\command\cronjob\UpdateCronjob;
use wcf\data\cronjob\Cronjob;
-use wcf\data\cronjob\CronjobAction;
-use wcf\data\cronjob\CronjobEditor;
-use wcf\form\AbstractFormBuilderForm;
+use wcf\data\cronjob\CronjobBuilder;
+use wcf\data\DatabaseObjectBuilder;
+use wcf\form\AbstractDatabaseObjectBuilderForm;
use wcf\system\cronjob\ICronjob;
use wcf\system\form\builder\container\FormContainer;
use wcf\system\form\builder\field\BooleanFormField;
use wcf\system\form\builder\field\ClassNameFormField;
+use wcf\system\form\builder\field\IFormField;
use wcf\system\form\builder\field\TextFormField;
use wcf\system\form\builder\field\validation\FormFieldValidationError;
use wcf\system\form\builder\field\validation\FormFieldValidator;
-use wcf\system\language\I18nHandler;
/**
* Shows the cronjob add form.
@@ -24,9 +26,9 @@
* @copyright 2001-2024 WoltLab GmbH
* @license GNU Lesser General Public License
*
- * @extends AbstractFormBuilderForm
+ * @extends AbstractDatabaseObjectBuilderForm
*/
-class CronjobAddForm extends AbstractFormBuilderForm
+class CronjobAddForm extends AbstractDatabaseObjectBuilderForm
{
/**
* @inheritDoc
@@ -38,18 +40,31 @@ class CronjobAddForm extends AbstractFormBuilderForm
*/
public $neededPermissions = ['admin.management.canManageCronjob'];
- /**
- * @inheritDoc
- */
- public $objectActionClass = CronjobAction::class;
+ public string $objectEditLinkController = CronjobEditForm::class;
- /**
- * @inheritDoc
- */
- public $objectEditLinkController = CronjobEditForm::class;
+ #[\Override]
+ protected function getDatabaseObjectBuilder(): CronjobBuilder
+ {
+ if ($this->formObject !== null) {
+ return CronjobBuilder::forUpdate($this->formObject);
+ }
+
+ return CronjobBuilder::forCreate()
+ ->setPackageID(\PACKAGE_ID);
+ }
+
+ #[\Override]
+ protected function getCommand(DatabaseObjectBuilder $builder): callable
+ {
+ if ($this->formObject !== null) {
+ return new UpdateCronjob($builder);
+ }
+
+ return new CreateCronjob($builder);
+ }
#[\Override]
- protected function createForm()
+ protected function createForm(): void
{
parent::createForm();
@@ -59,14 +74,32 @@ protected function createForm()
ClassNameFormField::create('className')
->label('wcf.acp.cronjob.className')
->implementedInterface(ICronjob::class)
- ->required(),
+ ->required()
+ ->saveValueCallback(static function (CronjobBuilder $builder, ClassNameFormField $field): void {
+ $builder->setClassName($field->getSaveValue());
+ })
+ ->loadValueCallback(static function (Cronjob $object, IFormField $field): void {
+ $field->value($object->className);
+ }),
TextFormField::create('description')
->label('wcf.acp.cronjob.description')
->required()
- ->i18n()
- ->languageItemPattern('wcf.acp.cronjob.description.cronjob\d+'),
+ ->l10n()
+ ->maximumLength(255)
+ ->saveValueCallback(static function (CronjobBuilder $builder, TextFormField $field): void {
+ $builder->setDescription($field->getL10nValues());
+ })
+ ->loadValueCallback(static function (Cronjob $object, IFormField $field): void {
+ $field->value($object->getL10nValues('description'));
+ }),
BooleanFormField::create('isDisabled')
- ->label('wcf.global.button.disable'),
+ ->label('wcf.global.button.disable')
+ ->saveValueCallback(static function (CronjobBuilder $builder, BooleanFormField $field): void {
+ $builder->setIsDisabled((bool)$field->getSaveValue());
+ })
+ ->loadValueCallback(static function (Cronjob $object, IFormField $field): void {
+ $field->value($object->isDisabled);
+ }),
]),
FormContainer::create('timingContainer')
->label('wcf.acp.cronjob.timing')
@@ -77,35 +110,65 @@ protected function createForm()
->addFieldClass('short')
->value('*')
->addValidator(self::getTimeFormFieldValidator())
- ->required(),
+ ->required()
+ ->saveValueCallback(static function (CronjobBuilder $builder, TextFormField $field): void {
+ $builder->setStartMinute($field->getSaveValue());
+ })
+ ->loadValueCallback(static function (Cronjob $object, IFormField $field): void {
+ $field->value($object->startMinute);
+ }),
TextFormField::create('startHour')
->label('wcf.acp.cronjob.startHour')
->description('wcf.acp.cronjob.startHour.description')
->addFieldClass('short')
->value('*')
->addValidator(self::getTimeFormFieldValidator())
- ->required(),
+ ->required()
+ ->saveValueCallback(static function (CronjobBuilder $builder, TextFormField $field): void {
+ $builder->setStartHour($field->getSaveValue());
+ })
+ ->loadValueCallback(static function (Cronjob $object, IFormField $field): void {
+ $field->value($object->startHour);
+ }),
TextFormField::create('startDom')
->label('wcf.acp.cronjob.startDom')
->description('wcf.acp.cronjob.startDom.description')
->addFieldClass('short')
->value('*')
->addValidator(self::getTimeFormFieldValidator())
- ->required(),
+ ->required()
+ ->saveValueCallback(static function (CronjobBuilder $builder, TextFormField $field): void {
+ $builder->setStartDom($field->getSaveValue());
+ })
+ ->loadValueCallback(static function (Cronjob $object, IFormField $field): void {
+ $field->value($object->startDom);
+ }),
TextFormField::create('startMonth')
->label('wcf.acp.cronjob.startMonth')
->description('wcf.acp.cronjob.startMonth.description')
->addFieldClass('short')
->value('*')
->addValidator(self::getTimeFormFieldValidator())
- ->required(),
+ ->required()
+ ->saveValueCallback(static function (CronjobBuilder $builder, TextFormField $field): void {
+ $builder->setStartMonth($field->getSaveValue());
+ })
+ ->loadValueCallback(static function (Cronjob $object, IFormField $field): void {
+ $field->value($object->startMonth);
+ }),
TextFormField::create('startDow')
->label('wcf.acp.cronjob.startDow')
->description('wcf.acp.cronjob.startDow.description')
->addFieldClass('short')
->value('*')
->addValidator(self::getTimeFormFieldValidator())
- ->required(),
+ ->required()
+ ->saveValueCallback(static function (CronjobBuilder $builder, TextFormField $field): void {
+ $builder->setStartDow($field->getSaveValue());
+ })
+ ->loadValueCallback(static function (Cronjob $object, IFormField $field): void {
+ $field->value($object->startDow);
+ }),
]),
]);
}
@@ -146,50 +209,4 @@ public static function getTimeFormFiledValidator(): FormFieldValidator
{
return self::getTimeFormFieldValidator();
}
-
- #[\Override]
- public function save()
- {
- if ($this->formAction === 'create') {
- $this->additionalFields['packageID'] = \PACKAGE_ID;
- $this->additionalFields['cronjobName'] = 'com.woltlab.wcf.cronjob';
- }
-
- parent::save();
- }
-
- #[\Override]
- public function saved()
- {
- $updateData = [];
- $formData = $this->form->getData();
-
- if ($this->formAction === 'create') {
- $cronjob = $this->objectAction->getReturnValues()['returnValues'];
- \assert($cronjob instanceof Cronjob);
-
- // update `cronjobName`
- $updateData['cronjobName'] = 'com.woltlab.wcf.cronjob' . $cronjob->cronjobID;
- } else {
- $cronjob = $this->formObject;
- }
-
- $languageItem = "wcf.acp.cronjob.description.cronjob{$cronjob->cronjobID}";
- if (isset($formData['description_i18n'])) {
- $updateData['description'] = $languageItem;
- I18nHandler::getInstance()->save(
- $formData['description_i18n'],
- $languageItem,
- 'wcf.acp.cronjob',
- $cronjob->packageID
- );
- } elseif ($cronjob->description === $languageItem) {
- I18nHandler::getInstance()->remove($cronjob->description);
- }
-
- $cronjobEditor = new CronjobEditor($cronjob);
- $cronjobEditor->update($updateData);
-
- parent::saved();
- }
}
diff --git a/wcfsetup/install/files/lib/acp/form/CronjobEditForm.class.php b/wcfsetup/install/files/lib/acp/form/CronjobEditForm.class.php
index d03789f5dbf..05203ed6a9b 100755
--- a/wcfsetup/install/files/lib/acp/form/CronjobEditForm.class.php
+++ b/wcfsetup/install/files/lib/acp/form/CronjobEditForm.class.php
@@ -5,6 +5,7 @@
use wcf\acp\page\CronjobListPage;
use wcf\data\cronjob\Cronjob;
use wcf\http\Helper;
+use wcf\system\exception\PermissionDeniedException;
use wcf\system\interaction\admin\CronjobInteractions;
use wcf\system\interaction\StandaloneInteractionContextMenuComponent;
use wcf\system\request\LinkHandler;
@@ -27,7 +28,7 @@ class CronjobEditForm extends CronjobAddForm
/**
* @inheritDoc
*/
- public $formAction = 'edit';
+ public string $formAction = 'edit';
#[\Override]
public function readParameters()
@@ -35,10 +36,13 @@ public function readParameters()
parent::readParameters();
$this->formObject = Helper::fetchObjectFromQueryParameter(Cronjob::class);
+ if (!$this->formObject->isEditable()) {
+ throw new PermissionDeniedException();
+ }
}
#[\Override]
- protected function createForm()
+ protected function createForm(): void
{
parent::createForm();
diff --git a/wcfsetup/install/files/lib/acp/form/LabelGroupAddForm.class.php b/wcfsetup/install/files/lib/acp/form/LabelGroupAddForm.class.php
index b82fc978d89..55eeabf7dca 100644
--- a/wcfsetup/install/files/lib/acp/form/LabelGroupAddForm.class.php
+++ b/wcfsetup/install/files/lib/acp/form/LabelGroupAddForm.class.php
@@ -2,25 +2,25 @@
namespace wcf\acp\form;
+use wcf\command\label\group\CreateLabelGroup;
+use wcf\command\label\group\UpdateLabelGroup;
+use wcf\data\DatabaseObjectBuilder;
use wcf\data\label\group\LabelGroup;
-use wcf\data\label\group\LabelGroupAction;
-use wcf\data\label\group\LabelGroupEditor;
+use wcf\data\label\group\LabelGroupBuilder;
use wcf\data\object\type\ObjectTypeCache;
-use wcf\form\AbstractFormBuilderForm;
+use wcf\form\AbstractDatabaseObjectBuilderForm;
use wcf\system\acl\ACLHandler;
use wcf\system\form\builder\container\FormContainer;
use wcf\system\form\builder\container\TabFormContainer;
use wcf\system\form\builder\container\TabMenuFormContainer;
-use wcf\system\form\builder\data\processor\CustomFormDataProcessor;
use wcf\system\form\builder\field\acl\AclFormField;
use wcf\system\form\builder\field\BooleanFormField;
+use wcf\system\form\builder\field\IFormField;
use wcf\system\form\builder\field\IntegerFormField;
use wcf\system\form\builder\field\TextFormField;
-use wcf\system\form\builder\IFormDocument;
use wcf\system\form\builder\TemplateFormNode;
use wcf\system\label\object\type\ILabelObjectTypeHandler;
use wcf\system\label\object\type\LabelObjectTypeContainer;
-use wcf\system\language\I18nHandler;
use wcf\system\WCF;
use wcf\util\ArrayUtil;
@@ -31,9 +31,9 @@
* @copyright 2001-2026 WoltLab GmbH
* @license GNU Lesser General Public License
*
- * @extends AbstractFormBuilderForm
+ * @extends AbstractDatabaseObjectBuilderForm
*/
-class LabelGroupAddForm extends AbstractFormBuilderForm
+class LabelGroupAddForm extends AbstractDatabaseObjectBuilderForm
{
/**
* @inheritDoc
@@ -48,12 +48,7 @@ class LabelGroupAddForm extends AbstractFormBuilderForm
/**
* @inheritDoc
*/
- public $objectActionClass = LabelGroupAction::class;
-
- /**
- * @inheritDoc
- */
- public $objectEditLinkController = LabelGroupEditForm::class;
+ public string $objectEditLinkController = LabelGroupEditForm::class;
/**
* list of label group to object type relations
@@ -94,7 +89,27 @@ public function readParameters()
}
#[\Override]
- protected function createForm()
+ protected function getDatabaseObjectBuilder(): LabelGroupBuilder
+ {
+ if ($this->formObject !== null) {
+ return LabelGroupBuilder::forUpdate($this->formObject);
+ }
+
+ return LabelGroupBuilder::forCreate();
+ }
+
+ #[\Override]
+ protected function getCommand(DatabaseObjectBuilder $builder): callable
+ {
+ if ($this->formObject !== null) {
+ return new UpdateLabelGroup($builder);
+ }
+
+ return new CreateLabelGroup($builder);
+ }
+
+ #[\Override]
+ protected function createForm(): void
{
parent::createForm();
@@ -110,20 +125,49 @@ protected function createForm()
->required()
->autoFocus()
->maximumLength(80)
- ->i18n()
- ->languageItemPattern('wcf.acp.label.group\d+'),
+ ->l10n()
+ ->saveValueCallback(static function (LabelGroupBuilder $builder, TextFormField $field): void {
+ $builder->setGroupName($field->getL10nValues());
+ })
+ ->loadValueCallback(static function (LabelGroup $object, IFormField $field): void {
+ $field->value($object->getL10nValues('groupName'));
+ }),
TextFormField::create('groupDescription')
->label('wcf.global.description')
->description('wcf.acp.label.group.groupDescription.description')
- ->maximumLength(255),
+ ->maximumLength(255)
+ ->saveValueCallback(static function (LabelGroupBuilder $builder, TextFormField $field): void {
+ $builder->setGroupDescription($field->getSaveValue());
+ })
+ ->loadValueCallback(static function (LabelGroup $object, IFormField $field): void {
+ $field->value($object->groupDescription);
+ }),
IntegerFormField::create('showOrder')
->label('wcf.global.showOrder')
->minimum(0)
- ->value(0),
+ ->value(0)
+ ->saveValueCallback(static function (LabelGroupBuilder $builder, IntegerFormField $field): void {
+ $builder->setShowOrder($field->getSaveValue());
+ })
+ ->loadValueCallback(static function (LabelGroup $object, IFormField $field): void {
+ $field->value($object->showOrder);
+ }),
BooleanFormField::create('forceSelection')
- ->label('wcf.acp.label.group.forceSelection'),
+ ->label('wcf.acp.label.group.forceSelection')
+ ->saveValueCallback(static function (LabelGroupBuilder $builder, BooleanFormField $field): void {
+ $builder->setForceSelection((bool)$field->getSaveValue());
+ })
+ ->loadValueCallback(static function (LabelGroup $object, IFormField $field): void {
+ $field->value($object->forceSelection);
+ }),
BooleanFormField::create('sortAlphabetically')
- ->label('wcf.acp.label.group.sortAlphabetically'),
+ ->label('wcf.acp.label.group.sortAlphabetically')
+ ->saveValueCallback(static function (LabelGroupBuilder $builder, BooleanFormField $field): void {
+ $builder->setSortAlphabetically((bool)$field->getSaveValue());
+ })
+ ->loadValueCallback(static function (LabelGroup $object, IFormField $field): void {
+ $field->value($object->sortAlphabetically);
+ }),
AclFormField::create('aclPermissions')
->label('wcf.acl.permissions')
->objectType('com.woltlab.wcf.label'),
@@ -147,29 +191,7 @@ protected function createForm()
}
#[\Override]
- protected function finalizeForm()
- {
- parent::finalizeForm();
-
- // The groupName column is NOT NULL without a default. When i18n values
- // are used, hasSaveValue() returns false and groupName would be missing
- // from the data array. This processor ensures it's always present.
- $this->form->getDataHandler()->addProcessor(
- new CustomFormDataProcessor(
- 'groupNameFallback',
- function (IFormDocument $document, array $parameters) {
- if (!isset($parameters['data']['groupName'])) {
- $parameters['data']['groupName'] = '';
- }
-
- return $parameters;
- }
- )
- );
- }
-
- #[\Override]
- public function readFormParameters()
+ public function readFormParameters(): void
{
parent::readFormParameters();
@@ -180,7 +202,7 @@ public function readFormParameters()
}
#[\Override]
- public function validate()
+ public function validate(): void
{
parent::validate();
@@ -193,7 +215,7 @@ public function validate()
}
#[\Override]
- public function readData()
+ public function readData(): void
{
parent::readData();
@@ -201,41 +223,12 @@ public function readData()
}
#[\Override]
- public function saved()
+ public function saved(): void
{
- $formData = $this->form->getData();
-
- if ($this->formAction === 'create') {
- $group = $this->objectAction->getReturnValues()['returnValues'];
- \assert($group instanceof LabelGroup);
- $groupID = $group->groupID;
- } else {
- $groupID = $this->formObject->groupID;
- }
-
- // Handle i18n groupName.
- $languageItem = 'wcf.acp.label.group' . $groupID;
- if (isset($formData['groupName_i18n'])) {
- I18nHandler::getInstance()->save(
- $formData['groupName_i18n'],
- $languageItem,
- 'wcf.acp.label',
- 1
- );
-
- if ($this->formAction === 'create') {
- \assert(isset($group));
- (new LabelGroupEditor($group))->update(['groupName' => $languageItem]);
- } else {
- (new LabelGroupEditor($this->formObject))->update(['groupName' => $languageItem]);
- }
- } elseif ($this->formAction === 'edit') {
- // Switched from i18n to plain value — remove old language items.
- I18nHandler::getInstance()->remove($languageItem);
- }
+ $groupID = $this->object->groupID;
// Save ACL.
- ACLHandler::getInstance()->save($groupID, $formData['aclPermissions_aclObjectTypeID']);
+ ACLHandler::getInstance()->save($groupID, $this->form->getData()['aclPermissions_aclObjectTypeID']);
// Save object type relations.
$this->saveObjectTypeRelations($groupID);
diff --git a/wcfsetup/install/files/lib/acp/form/LabelGroupEditForm.class.php b/wcfsetup/install/files/lib/acp/form/LabelGroupEditForm.class.php
index 539cef9416d..ba274be4e5a 100644
--- a/wcfsetup/install/files/lib/acp/form/LabelGroupEditForm.class.php
+++ b/wcfsetup/install/files/lib/acp/form/LabelGroupEditForm.class.php
@@ -27,7 +27,7 @@ class LabelGroupEditForm extends LabelGroupAddForm
/**
* @inheritDoc
*/
- public $formAction = 'edit';
+ public string $formAction = 'edit';
#[\Override]
public function readParameters()
diff --git a/wcfsetup/install/files/lib/acp/form/LanguageAddForm.class.php b/wcfsetup/install/files/lib/acp/form/LanguageAddForm.class.php
index e7eaf4d6a07..b9ed9bc00b5 100644
--- a/wcfsetup/install/files/lib/acp/form/LanguageAddForm.class.php
+++ b/wcfsetup/install/files/lib/acp/form/LanguageAddForm.class.php
@@ -5,6 +5,7 @@
use wcf\data\language\Language;
use wcf\data\language\LanguageAction;
use wcf\form\AbstractFormBuilderForm;
+use wcf\system\cache\CacheHandler;
use wcf\system\form\builder\container\FormContainer;
use wcf\system\form\builder\data\processor\CustomFormDataProcessor;
use wcf\system\form\builder\field\BooleanFormField;
@@ -190,4 +191,15 @@ static function (IFormDocument $document, array $parameters) {
}
));
}
+
+ #[\Override]
+ public function saved()
+ {
+ if ($this->formAction === 'create') {
+ // The localized values synchronized for the language are part of cached objects.
+ CacheHandler::getInstance()->flushAll();
+ }
+
+ parent::saved();
+ }
}
diff --git a/wcfsetup/install/files/lib/acp/form/LanguageImportForm.class.php b/wcfsetup/install/files/lib/acp/form/LanguageImportForm.class.php
index fdcebf6ab20..244aa3812bc 100644
--- a/wcfsetup/install/files/lib/acp/form/LanguageImportForm.class.php
+++ b/wcfsetup/install/files/lib/acp/form/LanguageImportForm.class.php
@@ -8,6 +8,7 @@
use wcf\data\package\PackageCache;
use wcf\event\language\LanguageImported;
use wcf\form\AbstractForm;
+use wcf\system\cache\CacheHandler;
use wcf\system\event\EventHandler;
use wcf\system\exception\SystemException;
use wcf\system\exception\UserInputException;
@@ -148,6 +149,9 @@ public function save()
EventHandler::getInstance()->fire(new LanguageImported(new Language($this->language->languageID)));
+ // The localized values synchronized for the language are part of cached objects.
+ CacheHandler::getInstance()->flushAll();
+
$this->saved();
// reset fields
diff --git a/wcfsetup/install/files/lib/acp/form/PaidSubscriptionAddForm.class.php b/wcfsetup/install/files/lib/acp/form/PaidSubscriptionAddForm.class.php
index cea6c5d355f..4a459cd333f 100644
--- a/wcfsetup/install/files/lib/acp/form/PaidSubscriptionAddForm.class.php
+++ b/wcfsetup/install/files/lib/acp/form/PaidSubscriptionAddForm.class.php
@@ -2,29 +2,45 @@
namespace wcf\acp\form;
+use wcf\command\paid\subscription\CreatePaidSubscription;
+use wcf\command\paid\subscription\UpdatePaidSubscription;
+use wcf\data\DatabaseObjectBuilder;
+use wcf\data\paid\subscription\L10nPaidSubscriptionList;
use wcf\data\paid\subscription\PaidSubscription;
-use wcf\data\paid\subscription\PaidSubscriptionAction;
-use wcf\data\paid\subscription\PaidSubscriptionEditor;
-use wcf\data\paid\subscription\PaidSubscriptionList;
+use wcf\data\paid\subscription\PaidSubscriptionBuilder;
use wcf\data\user\group\UserGroup;
-use wcf\form\AbstractForm;
+use wcf\form\AbstractDatabaseObjectBuilderForm;
use wcf\system\exception\NamedUserException;
-use wcf\system\exception\UserInputException;
-use wcf\system\language\I18nHandler;
+use wcf\system\form\builder\container\FormContainer;
+use wcf\system\form\builder\container\SuffixFormFieldContainer;
+use wcf\system\form\builder\field\BooleanFormField;
+use wcf\system\form\builder\field\dependency\EmptyFormFieldDependency;
+use wcf\system\form\builder\field\FloatFormField;
+use wcf\system\form\builder\field\IFormField;
+use wcf\system\form\builder\field\IntegerFormField;
+use wcf\system\form\builder\field\MultipleSelectionFormField;
+use wcf\system\form\builder\field\ShowOrderFormField;
+use wcf\system\form\builder\field\SingleSelectionFormField;
+use wcf\system\form\builder\field\TitleFormField;
+use wcf\system\form\builder\field\validation\FormFieldValidationError;
+use wcf\system\form\builder\field\validation\FormFieldValidator;
+use wcf\system\form\builder\field\wysiwyg\WysiwygFormField;
use wcf\system\payment\method\PaymentMethodHandler;
-use wcf\system\request\LinkHandler;
use wcf\system\WCF;
use wcf\util\ArrayUtil;
use wcf\util\HtmlString;
+use wcf\util\StringUtil;
/**
* Shows the paid subscription add form.
*
- * @author Marcel Werk
- * @copyright 2001-2019 WoltLab GmbH
+ * @author Marcel Werk, Alexander Ebert
+ * @copyright 2001-2026 WoltLab GmbH
* @license GNU Lesser General Public License
+ *
+ * @extends AbstractDatabaseObjectBuilderForm
*/
-class PaidSubscriptionAddForm extends AbstractForm
+class PaidSubscriptionAddForm extends AbstractDatabaseObjectBuilderForm
{
/**
* @inheritDoc
@@ -44,340 +60,350 @@ class PaidSubscriptionAddForm extends AbstractForm
/**
* @inheritDoc
*/
- public $templateName = 'paidSubscriptionAdd';
-
- /**
- * subscription title
- * @var string
- */
- public $title = '';
+ public string $objectEditLinkController = PaidSubscriptionEditForm::class;
/**
- * subscription description
- * @var string
+ * Maximum subscription length per unit.
*/
- public $description = '';
-
- /**
- * indicates if the subscription is disabled
- * @var int
- */
- public $isDisabled = 0;
-
- /**
- * subscription show order
- * @var int
- */
- public $showOrder = 0;
-
- /**
- * subscription cost
- * @var double
- */
- public $cost = 0.0;
-
- /**
- * subscription currency
- * @var string
- */
- public $currency = 'USD';
-
- /**
- * indicates if the subscription is permanent
- * @var int
- */
- public $subscriptionLengthPermanent = 0;
-
- /**
- * subscription length
- * @var int
- */
- public $subscriptionLength = 0;
-
- /**
- * subscription length unit
- * @var string
- */
- public $subscriptionLengthUnit = '';
-
- /**
- * indicates if the subscription is a recurring payment
- * @var int
- */
- public $isRecurring = 0;
-
- /**
- * list of group ids
- * @var int[]
- */
- public $groupIDs = [];
-
- /**
- * list of excluded subscriptions
- * @var int[]
- */
- public $excludedSubscriptionIDs = [];
-
- /**
- * available user groups
- * @var UserGroup[]
- */
- public $availableUserGroups = [];
-
- /**
- * list of available currencies
- * @var string[]
- */
- public $availableCurrencies = [];
-
- /**
- * list of available subscriptions
- * @var array
- */
- public $availableSubscriptions = [];
+ private const MAXIMUM_LENGTH = [
+ 'D' => 90,
+ 'M' => 24,
+ 'Y' => 5,
+ ];
#[\Override]
public function readParameters()
{
parent::readParameters();
- I18nHandler::getInstance()->register('description');
- I18nHandler::getInstance()->register('title');
-
- // get available user groups
- $this->availableUserGroups = UserGroup::getSortedAccessibleGroups(
- [],
- [UserGroup::GUESTS, UserGroup::EVERYONE, UserGroup::USERS]
- );
-
- if (\count(PaymentMethodHandler::getInstance()->getPaymentMethods()) === 0) {
+ if (PaymentMethodHandler::getInstance()->getPaymentMethods() === []) {
throw new NamedUserException(HtmlString::fromSafeHtml(
WCF::getLanguage()->get('wcf.acp.paidSubscription.error.noPaymentMethods')
));
}
-
- // get available currencies
- foreach (PaymentMethodHandler::getInstance()->getPaymentMethods() as $paymentMethod) {
- $this->availableCurrencies = \array_merge(
- $this->availableCurrencies,
- $paymentMethod->getSupportedCurrencies()
- );
- }
- $this->availableCurrencies = \array_unique($this->availableCurrencies);
- \sort($this->availableCurrencies);
-
- // get available subscriptions
- $this->getAvailableSubscriptions();
}
- /**
- * @return void
- */
- protected function getAvailableSubscriptions()
+ #[\Override]
+ protected function getDatabaseObjectBuilder(): PaidSubscriptionBuilder
{
- $subscriptionList = new PaidSubscriptionList();
- $subscriptionList->sqlOrderBy = 'title';
- $subscriptionList->readObjects();
- $this->availableSubscriptions = $subscriptionList->getObjects();
+ if ($this->formObject !== null) {
+ return PaidSubscriptionBuilder::forUpdate($this->formObject);
+ }
+
+ return PaidSubscriptionBuilder::forCreate();
}
#[\Override]
- public function readFormParameters()
+ protected function getCommand(DatabaseObjectBuilder $builder): callable
{
- parent::readFormParameters();
-
- // read i18n values
- I18nHandler::getInstance()->readValues();
-
- // handle i18n plain input
- if (I18nHandler::getInstance()->isPlainValue('description')) {
- $this->description = I18nHandler::getInstance()->getValue('description');
- }
- if (I18nHandler::getInstance()->isPlainValue('title')) {
- $this->title = I18nHandler::getInstance()->getValue('title');
+ if ($this->formObject !== null) {
+ return new UpdatePaidSubscription($builder);
}
- if (!empty($_POST['isDisabled'])) {
- $this->isDisabled = 1;
- }
- if (isset($_POST['showOrder'])) {
- $this->showOrder = \intval($_POST['showOrder']);
- }
- if (isset($_POST['cost'])) {
- $this->cost = \floatval($_POST['cost']);
- }
- if (isset($_POST['currency'])) {
- $this->currency = $_POST['currency'];
- }
- if (!empty($_POST['subscriptionLengthPermanent'])) {
- $this->subscriptionLengthPermanent = 1;
- }
- if ($this->subscriptionLengthPermanent === 0) {
- if (isset($_POST['subscriptionLength'])) {
- $this->subscriptionLength = \intval($_POST['subscriptionLength']);
- }
- if (isset($_POST['subscriptionLengthUnit'])) {
- $this->subscriptionLengthUnit = $_POST['subscriptionLengthUnit'];
- }
- if (!empty($_POST['isRecurring'])) {
- $this->isRecurring = 1;
- }
- }
- if (isset($_POST['groupIDs']) && \is_array($_POST['groupIDs'])) {
- $this->groupIDs = ArrayUtil::toIntegerArray($_POST['groupIDs']);
- }
- if (isset($_POST['excludedSubscriptionIDs']) && \is_array($_POST['excludedSubscriptionIDs'])) {
- $this->excludedSubscriptionIDs = ArrayUtil::toIntegerArray($_POST['excludedSubscriptionIDs']);
- }
+ return new CreatePaidSubscription($builder);
}
#[\Override]
- public function validate()
+ protected function createForm(): void
{
- parent::validate();
-
- // validate title
- if (!I18nHandler::getInstance()->validateValue('title')) {
- if (I18nHandler::getInstance()->isPlainValue('title')) {
- throw new UserInputException('title');
- } else {
- throw new UserInputException('title', 'multilingual');
- }
- }
+ parent::createForm();
+
+ $canChangePaymentOptions = $this->formObject === null || !$this->formObject->hasActiveSubscriptions();
+ $availableSubscriptions = $this->getAvailableSubscriptions();
+ $availableUserGroups = $this->getAvailableUserGroups();
+ $availableCurrencies = $this->getAvailableCurrencies();
+
+ $this->form->appendChildren([
+ FormContainer::create('general')
+ ->appendChildren([
+ TitleFormField::create()
+ ->l10n()
+ ->required()
+ ->autoFocus()
+ ->maximumLength(255)
+ ->saveValueCallback(static function (PaidSubscriptionBuilder $builder, TitleFormField $field): void {
+ $builder->setTitle($field->getL10nValues());
+ })
+ ->loadValueCallback(static function (PaidSubscription $object, IFormField $field): void {
+ $field->value($object->getL10nValues('title'));
+ }),
+ WysiwygFormField::create('description')
+ ->label('wcf.global.description')
+ ->objectType('com.woltlab.wcf.paidSubscription')
+ ->l10n()
+ ->saveValueCallback(static function (PaidSubscriptionBuilder $builder, WysiwygFormField $field): void {
+ $builder->setDescription($field->getL10nValues());
+ })
+ ->loadValueCallback(static function (PaidSubscription $object, IFormField $field): void {
+ $field->value($object->getL10nValues('description'));
+ }),
+ ShowOrderFormField::create()
+ ->description('wcf.acp.paidSubscription.showOrder.description')
+ ->options($this->getSubscriptionsByShowOrder(), labelLanguageItems: false)
+ ->saveValueCallback(static function (PaidSubscriptionBuilder $builder, ShowOrderFormField $field): void {
+ $showOrder = $field->getSaveValue();
+ if ($showOrder !== null) {
+ $builder->setShowOrder($showOrder);
+ }
+ })
+ ->loadValueCallback(static function (PaidSubscription $object, IFormField $field): void {
+ $field->value($object->showOrder);
+ }),
+ BooleanFormField::create('isDisabled')
+ ->label('wcf.acp.paidSubscription.isDisabled')
+ ->description('wcf.acp.paidSubscription.isDisabled.description')
+ ->saveValueCallback(static function (PaidSubscriptionBuilder $builder, BooleanFormField $field): void {
+ $builder->setIsDisabled((bool)$field->getSaveValue());
+ })
+ ->loadValueCallback(static function (PaidSubscription $object, IFormField $field): void {
+ $field->value($object->isDisabled);
+ }),
+ MultipleSelectionFormField::create('excludedSubscriptionIDs')
+ ->label('wcf.acp.paidSubscription.excludedSubscriptions')
+ ->description('wcf.acp.paidSubscription.excludedSubscriptions.description')
+ ->available($availableSubscriptions !== [])
+ ->options($availableSubscriptions, labelLanguageItems: false)
+ ->saveValueCallback(static function (PaidSubscriptionBuilder $builder, MultipleSelectionFormField $field): void {
+ $builder->setExcludedSubscriptionIDs(\array_values(ArrayUtil::toIntegerArray($field->getValue() ?? [])));
+ })
+ ->loadValueCallback(static function (PaidSubscription $object, IFormField $field) use ($availableSubscriptions): void {
+ // Deleted subscriptions were never removed from the list.
+ $field->value(\array_values(\array_intersect(
+ self::explodeIDs($object->excludedSubscriptionIDs),
+ \array_keys($availableSubscriptions)
+ )));
+ }),
+ ]),
+ FormContainer::create('paymentOptions')
+ ->label('wcf.acp.paidSubscription.paymentOptions')
+ ->appendChildren([
+ SuffixFormFieldContainer::create('costContainer')
+ ->label('wcf.acp.paidSubscription.cost')
+ ->field(
+ FloatFormField::create('cost')
+ ->required()
+ ->minimum(0.01)
+ ->step(0.01)
+ ->immutable(!$canChangePaymentOptions)
+ ->saveValueCallback(static function (PaidSubscriptionBuilder $builder, FloatFormField $field): void {
+ // The payment options of subscriptions with active
+ // subscribers cannot be changed.
+ if ($field->isImmutable()) {
+ return;
+ }
+
+ $currency = $field->getDocument()->getNodeById('currency');
+ \assert($currency instanceof SingleSelectionFormField);
+
+ $builder->setPrice((float)$field->getSaveValue(), $currency->getSaveValue());
+ })
+ ->loadValueCallback(static function (PaidSubscription $object, IFormField $field): void {
+ $field->value((float)$object->cost);
+ })
+ )
+ ->suffixField(
+ SingleSelectionFormField::create('currency')
+ ->required()
+ ->options(\array_combine($availableCurrencies, $availableCurrencies), labelLanguageItems: false)
+ ->value(\in_array('USD', $availableCurrencies, true) ? 'USD' : \reset($availableCurrencies))
+ ->immutable(!$canChangePaymentOptions)
+ ->loadValueCallback(static function (PaidSubscription $object, IFormField $field) use ($availableCurrencies): void {
+ // The payment methods may no longer offer the stored currency.
+ if (\in_array($object->currency, $availableCurrencies, true)) {
+ $field->value($object->currency);
+ }
+ })
+ ),
+ BooleanFormField::create('subscriptionLengthPermanent')
+ ->label('wcf.acp.paidSubscription.subscriptionLength.permanent')
+ ->immutable(!$canChangePaymentOptions)
+ ->saveValueCallback(static function (PaidSubscriptionBuilder $builder, BooleanFormField $field): void {
+ if ($field->isImmutable()) {
+ return;
+ }
+
+ if ($field->getSaveValue() === 1) {
+ $builder->setPermanent();
+ } else {
+ $document = $field->getDocument();
+ $length = $document->getNodeById('subscriptionLength');
+ \assert($length instanceof IntegerFormField);
+ $unit = $document->getNodeById('subscriptionLengthUnit');
+ \assert($unit instanceof SingleSelectionFormField);
+ $isRecurring = $document->getNodeById('isRecurring');
+ \assert($isRecurring instanceof BooleanFormField);
+
+ $builder->setLength(
+ $length->getSaveValue(),
+ $unit->getSaveValue(),
+ (bool)$isRecurring->getSaveValue()
+ );
+ }
+ })
+ ->loadValueCallback(static function (PaidSubscription $object, IFormField $field): void {
+ $field->value($object->subscriptionLength === 0);
+ }),
+ SuffixFormFieldContainer::create('subscriptionLengthContainer')
+ ->label('wcf.acp.paidSubscription.subscriptionLength')
+ ->addDependency(
+ EmptyFormFieldDependency::create('subscriptionLengthPermanent')
+ ->fieldId('subscriptionLengthPermanent')
+ )
+ ->field(
+ IntegerFormField::create('subscriptionLength')
+ ->required()
+ ->minimum(1)
+ ->value(1)
+ ->immutable(!$canChangePaymentOptions)
+ ->addValidator(new FormFieldValidator(
+ 'maximumLength',
+ static function (IntegerFormField $field): void {
+ $unit = $field->getDocument()->getNodeById('subscriptionLengthUnit');
+ \assert($unit instanceof SingleSelectionFormField);
+
+ $maximumLength = self::MAXIMUM_LENGTH[(string)$unit->getValue()] ?? null;
+ if ($maximumLength !== null && $field->getValue() > $maximumLength) {
+ $field->addValidationError(new FormFieldValidationError(
+ 'invalid',
+ 'wcf.acp.paidSubscription.subscriptionLength.error.invalid'
+ ));
+ }
+ }
+ ))
+ ->loadValueCallback(static function (PaidSubscription $object, IFormField $field): void {
+ if ($object->subscriptionLength !== 0) {
+ $field->value($object->subscriptionLength);
+ }
+ })
+ )
+ ->suffixField(
+ SingleSelectionFormField::create('subscriptionLengthUnit')
+ ->required()
+ ->options([
+ 'D' => 'wcf.acp.paidSubscription.subscriptionLengthUnit.D',
+ 'M' => 'wcf.acp.paidSubscription.subscriptionLengthUnit.M',
+ 'Y' => 'wcf.acp.paidSubscription.subscriptionLengthUnit.Y',
+ ])
+ ->value('D')
+ ->immutable(!$canChangePaymentOptions)
+ ->loadValueCallback(static function (PaidSubscription $object, IFormField $field): void {
+ if ($object->subscriptionLengthUnit !== '') {
+ $field->value($object->subscriptionLengthUnit);
+ }
+ })
+ ),
+ BooleanFormField::create('isRecurring')
+ ->label('wcf.acp.paidSubscription.isRecurring')
+ ->description('wcf.acp.paidSubscription.isRecurring.description')
+ ->immutable(!$canChangePaymentOptions)
+ ->addDependency(
+ EmptyFormFieldDependency::create('subscriptionLengthPermanent')
+ ->fieldId('subscriptionLengthPermanent')
+ )
+ ->loadValueCallback(static function (PaidSubscription $object, IFormField $field): void {
+ $field->value($object->isRecurring);
+ }),
+ MultipleSelectionFormField::create('groupIDs')
+ ->label('wcf.acp.paidSubscription.userGroups')
+ ->description('wcf.acp.paidSubscription.userGroups.description')
+ ->required()
+ ->options($availableUserGroups, labelLanguageItems: false)
+ ->saveValueCallback(static function (PaidSubscriptionBuilder $builder, MultipleSelectionFormField $field): void {
+ $builder->setGroupIDs(\array_values(ArrayUtil::toIntegerArray($field->getValue() ?? [])));
+ })
+ ->loadValueCallback(static function (PaidSubscription $object, IFormField $field) use ($availableUserGroups): void {
+ $field->value(\array_values(\array_intersect(
+ self::explodeIDs($object->groupIDs),
+ \array_keys($availableUserGroups)
+ )));
+ }),
+ ]),
+ ]);
- // validate description
- if (!I18nHandler::getInstance()->validateValue('description', false, true)) {
- throw new UserInputException('description');
+ if ($this->formObject === null) {
+ // New subscriptions are appended by default.
+ $showOrder = $this->form->getNodeById('showOrder');
+ \assert($showOrder instanceof ShowOrderFormField);
+ $showOrder->value(\count($showOrder->getOptions()) - 1);
}
+ }
- // validate cost
- if ($this->cost < 0.01) {
- throw new UserInputException('cost');
- }
- // validate currency
- if (!\in_array($this->currency, $this->availableCurrencies, true)) {
- throw new UserInputException('cost');
- }
+ /**
+ * Returns the titles of the subscriptions that can be excluded, sorted by
+ * their title.
+ *
+ * @return array
+ */
+ protected function getAvailableSubscriptions(): array
+ {
+ $subscriptionList = new L10nPaidSubscriptionList();
+ $subscriptionList->sqlOrderBy = 'title';
+ $subscriptionList->readObjects();
- if ($this->subscriptionLengthPermanent === 0) {
- if ($this->subscriptionLength < 1) {
- throw new UserInputException('subscriptionLength');
- }
- if ($this->subscriptionLengthUnit !== 'D' && $this->subscriptionLengthUnit !== 'M' && $this->subscriptionLengthUnit !== 'Y') {
- throw new UserInputException('subscriptionLength');
- }
- if (($this->subscriptionLengthUnit === 'D' && $this->subscriptionLength > 90) || ($this->subscriptionLengthUnit === 'M' && $this->subscriptionLength > 24) || ($this->subscriptionLengthUnit === 'Y' && $this->subscriptionLength > 5)) {
- throw new UserInputException('subscriptionLength', 'invalid');
- }
- }
+ // The labels of selection options are printed as HTML.
+ return \array_map(
+ static fn(PaidSubscription $subscription) => StringUtil::encodeHTML($subscription->getTitle()),
+ $subscriptionList->getObjects()
+ );
+ }
- // validate group ids
- if ($this->groupIDs === []) {
- throw new UserInputException('groupIDs');
- }
- foreach ($this->groupIDs as $groupID) {
- if (!isset($this->availableUserGroups[$groupID])) {
- throw new UserInputException('groupIDs');
- }
- }
- // validate excluded subscriptions
- foreach ($this->excludedSubscriptionIDs as $key => $subscriptionID) {
- if (!isset($this->availableSubscriptions[$subscriptionID])) {
- unset($this->excludedSubscriptionIDs[$key]);
- }
- }
+ /**
+ * Returns the titles of the other subscriptions, sorted by their position.
+ *
+ * @return array
+ */
+ protected function getSubscriptionsByShowOrder(): array
+ {
+ $subscriptionList = new L10nPaidSubscriptionList();
+ $subscriptionList->sqlOrderBy = 'showOrder, subscriptionID';
+ $subscriptionList->readObjects();
+
+ return \array_map(
+ static fn(PaidSubscription $subscription) => StringUtil::encodeHTML($subscription->getTitle()),
+ $subscriptionList->getObjects()
+ );
}
- #[\Override]
- public function save()
+ /**
+ * @return array
+ */
+ private function getAvailableUserGroups(): array
{
- parent::save();
-
- // save subscription
- $this->objectAction = new PaidSubscriptionAction([], 'create', [
- 'data' => \array_merge($this->additionalFields, [
- 'title' => $this->title,
- 'description' => $this->description,
- 'isDisabled' => $this->isDisabled,
- 'showOrder' => $this->showOrder,
- 'cost' => $this->cost,
- 'currency' => $this->currency,
- 'subscriptionLength' => $this->subscriptionLength,
- 'subscriptionLengthUnit' => $this->subscriptionLengthUnit,
- 'isRecurring' => $this->isRecurring,
- 'groupIDs' => \implode(',', $this->groupIDs),
- 'excludedSubscriptionIDs' => \implode(',', $this->excludedSubscriptionIDs),
- ]),
- ]);
- $returnValues = $this->objectAction->executeAction();
-
- // save i18n values
- $this->saveI18nValue($returnValues['returnValues'], 'description');
- $this->saveI18nValue($returnValues['returnValues'], 'title');
- $this->saved();
-
- // reset values
- $this->title = $this->description = $this->subscriptionLengthUnit = '';
- $this->isDisabled = $this->showOrder = $this->cost = $this->subscriptionLength = $this->isRecurring = 0;
- $this->currency = 'EUR';
- $this->groupIDs = [];
- I18nHandler::getInstance()->reset();
-
- // show success message
- WCF::getTPL()->assign([
- 'success' => true,
- 'objectEditLink' => LinkHandler::getInstance()->getControllerLink(
- PaidSubscriptionEditForm::class,
- ['id' => $returnValues['returnValues']->subscriptionID]
- ),
- ]);
+ $userGroups = UserGroup::getSortedAccessibleGroups(
+ [],
+ [UserGroup::GUESTS, UserGroup::EVERYONE, UserGroup::USERS]
+ );
+
+ return \array_map(
+ static fn(UserGroup $userGroup) => StringUtil::encodeHTML($userGroup->getTitle()),
+ $userGroups
+ );
}
/**
- * Saves i18n values.
- *
- * @return void
+ * @return list
*/
- public function saveI18nValue(PaidSubscription $subscription, string $columnName)
+ private function getAvailableCurrencies(): array
{
- if (!I18nHandler::getInstance()->isPlainValue($columnName)) {
- I18nHandler::getInstance()->save(
- $columnName,
- 'wcf.paidSubscription.subscription' . $subscription->subscriptionID . ($columnName === 'description' ? '.description' : ''),
- 'wcf.paidSubscription',
- 1
- );
-
- // update database
- $editor = new PaidSubscriptionEditor($subscription);
- $editor->update([
- $columnName => 'wcf.paidSubscription.subscription' . $subscription->subscriptionID . ($columnName === 'description' ? '.description' : ''),
- ]);
+ $availableCurrencies = [];
+ foreach (PaymentMethodHandler::getInstance()->getPaymentMethods() as $paymentMethod) {
+ $availableCurrencies = [...$availableCurrencies, ...$paymentMethod->getSupportedCurrencies()];
}
+ $availableCurrencies = \array_values(\array_unique($availableCurrencies));
+ \sort($availableCurrencies);
+
+ return $availableCurrencies;
}
- #[\Override]
- public function assignVariables()
+ /**
+ * @return list
+ */
+ private static function explodeIDs(?string $ids): array
{
- parent::assignVariables();
-
- I18nHandler::getInstance()->assignVariables();
-
- WCF::getTPL()->assign([
- 'action' => 'add',
- 'isDisabled' => $this->isDisabled,
- 'showOrder' => $this->showOrder,
- 'cost' => $this->cost,
- 'currency' => $this->currency,
- 'subscriptionLength' => $this->subscriptionLength,
- 'subscriptionLengthUnit' => $this->subscriptionLengthUnit,
- 'isRecurring' => $this->isRecurring,
- 'groupIDs' => $this->groupIDs,
- 'excludedSubscriptionIDs' => $this->excludedSubscriptionIDs,
- 'availableCurrencies' => $this->availableCurrencies,
- 'availableUserGroups' => $this->availableUserGroups,
- 'availableSubscriptions' => $this->availableSubscriptions,
- 'canChangePaymentOptions' => true,
- ]);
+ if ($ids === null || $ids === '') {
+ return [];
+ }
+
+ return \array_values(ArrayUtil::toIntegerArray(\explode(',', $ids)));
}
}
diff --git a/wcfsetup/install/files/lib/acp/form/PaidSubscriptionEditForm.class.php b/wcfsetup/install/files/lib/acp/form/PaidSubscriptionEditForm.class.php
index 4b8d477caa5..8d16cd2f0e0 100644
--- a/wcfsetup/install/files/lib/acp/form/PaidSubscriptionEditForm.class.php
+++ b/wcfsetup/install/files/lib/acp/form/PaidSubscriptionEditForm.class.php
@@ -4,22 +4,17 @@
use wcf\acp\page\PaidSubscriptionListPage;
use wcf\data\paid\subscription\PaidSubscription;
-use wcf\data\paid\subscription\PaidSubscriptionAction;
-use wcf\data\paid\subscription\PaidSubscriptionList;
-use wcf\form\AbstractForm;
-use wcf\system\exception\PermissionDeniedException;
+use wcf\http\Helper;
use wcf\system\interaction\admin\PaidSubscriptionInteractions;
use wcf\system\interaction\StandaloneInteractionContextMenuComponent;
-use wcf\system\language\I18nHandler;
use wcf\system\request\LinkHandler;
use wcf\system\WCF;
-use wcf\util\ArrayUtil;
/**
* Shows the paid subscription edit form.
*
- * @author Marcel Werk
- * @copyright 2001-2019 WoltLab GmbH
+ * @author Marcel Werk, Alexander Ebert
+ * @copyright 2001-2026 WoltLab GmbH
* @license GNU Lesser General Public License
*/
class PaidSubscriptionEditForm extends PaidSubscriptionAddForm
@@ -30,136 +25,16 @@ class PaidSubscriptionEditForm extends PaidSubscriptionAddForm
public $activeMenuItem = 'wcf.acp.menu.link.paidSubscription.list';
/**
- * id of the edited subscription
- * @var int
- */
- public $subscriptionID = 0;
-
- /**
- * edited subscription object
- * @var ?PaidSubscription
+ * @inheritDoc
*/
- public $subscription;
+ public string $formAction = 'edit';
#[\Override]
public function readParameters()
{
- if (isset($_REQUEST['id'])) {
- $this->subscriptionID = \intval($_REQUEST['id']);
- }
- $this->subscription = new PaidSubscription($this->subscriptionID);
- if ($this->subscription->isNil()) {
- throw new PermissionDeniedException();
- }
-
parent::readParameters();
- }
-
- #[\Override]
- public function readFormParameters()
- {
- parent::readFormParameters();
-
- if ($this->subscription->hasActiveSubscriptions()) {
- $this->cost = (float)$this->subscription->cost;
- $this->currency = $this->subscription->currency;
- $this->subscriptionLength = $this->subscription->subscriptionLength;
- $this->subscriptionLengthUnit = $this->subscription->subscriptionLengthUnit;
- $this->isRecurring = $this->subscription->isRecurring;
- if ($this->subscriptionLength === 0) {
- $this->subscriptionLengthPermanent = 1;
- }
- }
- }
- /**
- * @return void
- */
- #[\Override]
- protected function getAvailableSubscriptions()
- {
- $subscriptionList = new PaidSubscriptionList();
- $subscriptionList->getConditionBuilder()->add('subscriptionID <> ?', [$this->subscriptionID]);
- $subscriptionList->sqlOrderBy = 'title';
- $subscriptionList->readObjects();
- $this->availableSubscriptions = $subscriptionList->getObjects();
- }
-
- #[\Override]
- public function readData()
- {
- parent::readData();
-
- if ($_POST === []) {
- I18nHandler::getInstance()->setOptions(
- 'description',
- 1,
- $this->subscription->description,
- 'wcf.paidSubscription.subscription\d+.description'
- );
- I18nHandler::getInstance()->setOptions(
- 'title',
- 1,
- $this->subscription->title,
- 'wcf.paidSubscription.subscription\d+'
- );
-
- $this->isDisabled = $this->subscription->isDisabled;
- $this->showOrder = $this->subscription->showOrder;
- $this->cost = (float)$this->subscription->cost;
- $this->currency = $this->subscription->currency;
- $this->subscriptionLength = $this->subscription->subscriptionLength;
- $this->subscriptionLengthUnit = $this->subscription->subscriptionLengthUnit;
- $this->isRecurring = $this->subscription->isRecurring;
- $this->groupIDs = ArrayUtil::toIntegerArray(\explode(',', $this->subscription->groupIDs));
- $this->excludedSubscriptionIDs = ArrayUtil::toIntegerArray(\explode(',', $this->subscription->excludedSubscriptionIDs));
- }
- }
-
- #[\Override]
- public function save()
- {
- AbstractForm::save();
-
- // update description
- $this->description = 'wcf.paidSubscription.subscription' . $this->subscription->subscriptionID . '.description';
- if (I18nHandler::getInstance()->isPlainValue('description')) {
- I18nHandler::getInstance()->remove($this->description);
- $this->description = I18nHandler::getInstance()->getValue('description');
- } else {
- I18nHandler::getInstance()->save('description', $this->description, 'wcf.paidSubscription', 1);
- }
-
- // update title
- $this->title = 'wcf.paidSubscription.subscription' . $this->subscription->subscriptionID;
- if (I18nHandler::getInstance()->isPlainValue('title')) {
- I18nHandler::getInstance()->remove($this->title);
- $this->title = I18nHandler::getInstance()->getValue('title');
- } else {
- I18nHandler::getInstance()->save('title', $this->title, 'wcf.paidSubscription', 1);
- }
-
- // save subscription
- $this->objectAction = new PaidSubscriptionAction([$this->subscription], 'update', [
- 'data' => \array_merge($this->additionalFields, [
- 'title' => $this->title,
- 'description' => $this->description,
- 'isDisabled' => $this->isDisabled,
- 'showOrder' => $this->showOrder,
- 'cost' => $this->cost,
- 'currency' => $this->currency,
- 'subscriptionLength' => $this->subscriptionLength,
- 'subscriptionLengthUnit' => $this->subscriptionLengthUnit,
- 'isRecurring' => $this->isRecurring,
- 'groupIDs' => \implode(',', $this->groupIDs),
- 'excludedSubscriptionIDs' => \implode(',', $this->excludedSubscriptionIDs),
- ]),
- ]);
- $this->objectAction->executeAction();
- $this->saved();
-
- // show success message
- WCF::getTPL()->assign('success', true);
+ $this->formObject = Helper::fetchObjectFromQueryParameter(PaidSubscription::class);
}
#[\Override]
@@ -167,19 +42,32 @@ public function assignVariables()
{
parent::assignVariables();
- $useRequestData = $_POST !== [];
- I18nHandler::getInstance()->assignVariables($useRequestData);
-
WCF::getTPL()->assign([
- 'action' => 'edit',
- 'subscriptionID' => $this->subscriptionID,
- 'subscription' => $this->subscription,
- 'canChangePaymentOptions' => !$this->subscription->hasActiveSubscriptions(),
'interactionContextMenu' => StandaloneInteractionContextMenuComponent::forContentHeaderButton(
new PaidSubscriptionInteractions(),
- $this->subscription,
+ $this->formObject,
LinkHandler::getInstance()->getControllerLink(PaidSubscriptionListPage::class)
),
]);
}
+
+ #[\Override]
+ protected function getAvailableSubscriptions(): array
+ {
+ return \array_filter(
+ parent::getAvailableSubscriptions(),
+ fn(int $key) => $key !== $this->formObject->getObjectID(),
+ \ARRAY_FILTER_USE_KEY
+ );
+ }
+
+ #[\Override]
+ protected function getSubscriptionsByShowOrder(): array
+ {
+ return \array_filter(
+ parent::getSubscriptionsByShowOrder(),
+ fn(int $key) => $key !== $this->formObject->getObjectID(),
+ \ARRAY_FILTER_USE_KEY
+ );
+ }
}
diff --git a/wcfsetup/install/files/lib/acp/form/ReactionTypeAddForm.class.php b/wcfsetup/install/files/lib/acp/form/ReactionTypeAddForm.class.php
index 70c3baef3e5..cf82accd00e 100644
--- a/wcfsetup/install/files/lib/acp/form/ReactionTypeAddForm.class.php
+++ b/wcfsetup/install/files/lib/acp/form/ReactionTypeAddForm.class.php
@@ -2,15 +2,20 @@
namespace wcf\acp\form;
+use wcf\command\reaction\type\CreateReactionType;
+use wcf\command\reaction\type\UpdateReactionType;
+use wcf\data\DatabaseObjectBuilder;
use wcf\data\reaction\type\ReactionType;
-use wcf\data\reaction\type\ReactionTypeAction;
+use wcf\data\reaction\type\ReactionTypeBuilder;
use wcf\data\reaction\type\ReactionTypeList;
-use wcf\form\AbstractFormBuilderForm;
+use wcf\form\AbstractDatabaseObjectBuilderForm;
use wcf\system\form\builder\container\FormContainer;
use wcf\system\form\builder\field\BooleanFormField;
use wcf\system\form\builder\field\FileProcessorFormField;
+use wcf\system\form\builder\field\IFormField;
use wcf\system\form\builder\field\ShowOrderFormField;
use wcf\system\form\builder\field\TitleFormField;
+use wcf\util\StringUtil;
/**
* Represents the reaction type add form.
@@ -20,20 +25,10 @@
* @license GNU Lesser General Public License
* @since 5.2
*
- * @extends AbstractFormBuilderForm
+ * @extends AbstractDatabaseObjectBuilderForm
*/
-class ReactionTypeAddForm extends AbstractFormBuilderForm
+class ReactionTypeAddForm extends AbstractDatabaseObjectBuilderForm
{
- /**
- * @inheritDoc
- */
- public $formAction = 'create';
-
- /**
- * @inheritDoc
- */
- public $objectActionClass = ReactionTypeAction::class;
-
/**
* @inheritDoc
*/
@@ -52,10 +47,30 @@ class ReactionTypeAddForm extends AbstractFormBuilderForm
/**
* @inheritDoc
*/
- public $objectEditLinkController = ReactionTypeEditForm::class;
+ public string $objectEditLinkController = ReactionTypeEditForm::class;
+
+ #[\Override]
+ protected function getDatabaseObjectBuilder(): ReactionTypeBuilder
+ {
+ if ($this->formObject !== null) {
+ return ReactionTypeBuilder::forUpdate($this->formObject);
+ }
+
+ return ReactionTypeBuilder::forCreate();
+ }
+
+ #[\Override]
+ protected function getCommand(DatabaseObjectBuilder $builder): callable
+ {
+ if ($this->formObject !== null) {
+ return new UpdateReactionType($builder);
+ }
+
+ return new CreateReactionType($builder);
+ }
#[\Override]
- protected function createForm()
+ protected function createForm(): void
{
parent::createForm();
@@ -65,15 +80,32 @@ protected function createForm()
->required()
->autoFocus()
->maximumLength(255)
- ->i18n()
- ->languageItemPattern('wcf.reactionType.title\d+'),
+ ->l10n()
+ ->saveValueCallback(static function (ReactionTypeBuilder $builder, TitleFormField $field) {
+ $builder->setTitle($field->getL10nValues());
+ })
+ ->loadValueCallback(static function (ReactionType $object, IFormField $field) {
+ $field->value($object->getL10nValues('title'));
+ }),
ShowOrderFormField::create()
->required()
- ->options($this->getReactionTypes()),
+ ->options($this->getReactionTypes(), labelLanguageItems: false)
+ ->saveValueCallback(static function (ReactionTypeBuilder $builder, ShowOrderFormField $field) {
+ $builder->setShowOrder($field->getSaveValue());
+ })
+ ->loadValueCallback(static function (ReactionType $object, IFormField $field) {
+ $field->value($object->showOrder);
+ }),
BooleanFormField::create('isAssignable')
->label('wcf.acp.reactionType.isAssignable')
->description('wcf.acp.reactionType.isAssignable.description')
- ->value(true),
+ ->value(true)
+ ->saveValueCallback(static function (ReactionTypeBuilder $builder, IFormField $field) {
+ $builder->setIsAssignable((bool)$field->getSaveValue());
+ })
+ ->loadValueCallback(static function (ReactionType $object, IFormField $field) {
+ $field->value($object->isAssignable);
+ }),
]);
$iconContainer = FormContainer::create('imageSection')
@@ -84,7 +116,15 @@ protected function createForm()
->label('wcf.acp.reactionType.image')
->required()
->singleFileUpload()
- ->bigPreview(),
+ ->bigPreview()
+ ->saveValueCallback(static function (ReactionTypeBuilder $builder, FileProcessorFormField $field) {
+ $builder->setIconFileID($field->getSaveValue());
+ })
+ ->loadValueCallback(static function (ReactionType $object, IFormField $field) {
+ if ($object->iconFileID !== null) {
+ $field->value($object->iconFileID);
+ }
+ }),
]);
$this->form->appendChildren([
@@ -101,6 +141,6 @@ protected function getReactionTypes(): array
$list = new ReactionTypeList();
$list->readObjects();
- return \array_map(static fn($option) => $option->getTitle(), $list->getObjects());
+ return \array_map(static fn($option) => StringUtil::encodeHTML($option->getTitle()), $list->getObjects());
}
}
diff --git a/wcfsetup/install/files/lib/acp/form/ReactionTypeEditForm.class.php b/wcfsetup/install/files/lib/acp/form/ReactionTypeEditForm.class.php
index c21993c0d8d..028ed3292bb 100644
--- a/wcfsetup/install/files/lib/acp/form/ReactionTypeEditForm.class.php
+++ b/wcfsetup/install/files/lib/acp/form/ReactionTypeEditForm.class.php
@@ -28,7 +28,7 @@ class ReactionTypeEditForm extends ReactionTypeAddForm
/**
* @inheritDoc
*/
- public $formAction = 'edit';
+ public string $formAction = 'edit';
#[\Override]
public function readParameters()
diff --git a/wcfsetup/install/files/lib/acp/form/UserOptionAddForm.class.php b/wcfsetup/install/files/lib/acp/form/UserOptionAddForm.class.php
index df74ee77efb..93e5a97f864 100644
--- a/wcfsetup/install/files/lib/acp/form/UserOptionAddForm.class.php
+++ b/wcfsetup/install/files/lib/acp/form/UserOptionAddForm.class.php
@@ -3,19 +3,20 @@
namespace wcf\acp\form;
use Laminas\Diactoros\Response\HtmlResponse;
-use wcf\data\IStorableObject;
+use wcf\command\user\option\CreateUserOption;
+use wcf\command\user\option\UpdateUserOption;
+use wcf\data\DatabaseObjectBuilder;
use wcf\data\user\option\category\UserOptionCategory;
use wcf\data\user\option\category\UserOptionCategoryList;
use wcf\data\user\option\UserOption;
-use wcf\data\user\option\UserOptionAction;
-use wcf\data\user\option\UserOptionEditor;
-use wcf\form\AbstractFormBuilderForm;
+use wcf\data\user\option\UserOptionBuilder;
+use wcf\form\AbstractDatabaseObjectBuilderForm;
use wcf\http\error\HtmlErrorRenderer;
use wcf\system\form\builder\container\FormContainer;
-use wcf\system\form\builder\data\processor\CustomFormDataProcessor;
use wcf\system\form\builder\field\BooleanFormField;
use wcf\system\form\builder\field\ClassNameFormField;
use wcf\system\form\builder\field\dependency\ValueFormFieldDependency;
+use wcf\system\form\builder\field\IFormField;
use wcf\system\form\builder\field\IntegerFormField;
use wcf\system\form\builder\field\ItemListFormField;
use wcf\system\form\builder\field\MultilineItemListFormField;
@@ -24,8 +25,6 @@
use wcf\system\form\builder\field\TextFormField;
use wcf\system\form\builder\field\validation\FormFieldValidationError;
use wcf\system\form\builder\field\validation\FormFieldValidator;
-use wcf\system\form\builder\IFormDocument;
-use wcf\system\language\I18nHandler;
use wcf\system\option\user\DateUserOptionOutput;
use wcf\system\option\user\IUserOptionOutput;
use wcf\system\option\user\LabeledUrlUserOptionOutput;
@@ -33,7 +32,6 @@
use wcf\system\option\user\SelectOptionsUserOptionOutput;
use wcf\system\option\user\URLUserOptionOutput;
use wcf\system\WCF;
-use wcf\util\StringUtil;
/**
* Shows the user option add form.
@@ -42,9 +40,9 @@
* @copyright 2001-2024 WoltLab GmbH
* @license GNU Lesser General Public License
*
- * @extends AbstractFormBuilderForm
+ * @extends AbstractDatabaseObjectBuilderForm
*/
-class UserOptionAddForm extends AbstractFormBuilderForm
+class UserOptionAddForm extends AbstractDatabaseObjectBuilderForm
{
/**
* @inheritDoc
@@ -97,12 +95,7 @@ class UserOptionAddForm extends AbstractFormBuilderForm
/**
* @inheritDoc
*/
- public $objectActionClass = UserOptionAction::class;
-
- /**
- * @inheritDoc
- */
- public $objectEditLinkController = UserOptionEditForm::class;
+ public string $objectEditLinkController = UserOptionEditForm::class;
#[\Override]
public function readParameters()
@@ -131,26 +124,62 @@ public function readParameters()
}
#[\Override]
- public function createForm()
+ protected function getDatabaseObjectBuilder(): UserOptionBuilder
+ {
+ if ($this->formObject !== null) {
+ return UserOptionBuilder::forUpdate($this->formObject);
+ }
+
+ return UserOptionBuilder::forCreate()->setGenericOptionName();
+ }
+
+ #[\Override]
+ protected function getCommand(DatabaseObjectBuilder $builder): callable
{
- parent::createForm();
+ if ($this->formObject !== null) {
+ return new UpdateUserOption($builder);
+ }
+
+ return new CreateUserOption($builder);
+ }
+ #[\Override]
+ protected function createForm(): void
+ {
+ $formAction = $this->formAction;
$this->form->appendChildren([
FormContainer::create('general')
->appendChildren([
+ // The localized title and description are stored in the
+ // `wcf1_user_option_l10n` table via the builder, not in
+ // columns of `wcf1_user_option`.
TextFormField::create('optionName')
->label('wcf.global.name')
->required()
- ->i18n()
- ->i18nRequired()
- ->languageItemPattern('wcf.user.option.(option\d+|\w+)'),
+ ->l10n()
+ ->saveValueCallback(static function (UserOptionBuilder $builder, TextFormField $field) {
+ $builder->setL10nTitle($field->getL10nValues());
+ })
+ ->loadValueCallback(static function (UserOption $object, IFormField $field) {
+ $field->value($object->getL10nValues('title'));
+ }),
MultilineTextFormField::create('optionDescription')
->label('wcf.acp.user.option.description')
- ->i18n()
- ->i18nRequired()
- ->languageItemPattern('wcf.user.option.(option\d+|\w+).description'),
+ ->l10n()
+ ->saveValueCallback(static function (UserOptionBuilder $builder, MultilineTextFormField $field) {
+ $builder->setL10nDescription($field->getL10nValues());
+ })
+ ->loadValueCallback(static function (UserOption $object, IFormField $field) {
+ $field->value($object->getL10nValues('description'));
+ }),
BooleanFormField::create('isDisabled')
- ->label('wcf.global.button.disable'),
+ ->label('wcf.global.button.disable')
+ ->saveValueCallback(static function (UserOptionBuilder $builder, IFormField $field) {
+ $builder->setIsDisabled((bool)$field->getSaveValue());
+ })
+ ->loadValueCallback(static function (UserOption $object, IFormField $field) {
+ $field->value($object->isDisabled);
+ }),
SingleSelectionFormField::create('categoryName')
->label('wcf.global.category')
->required()
@@ -161,10 +190,22 @@ public function createForm()
}
return $options;
+ })
+ ->saveValueCallback(static function (UserOptionBuilder $builder, IFormField $field) {
+ $builder->setCategoryName((string)$field->getSaveValue());
+ })
+ ->loadValueCallback(static function (UserOption $object, IFormField $field) {
+ $field->value($object->categoryName);
}),
IntegerFormField::create('showOrder')
->label('wcf.form.field.showOrder')
->value(0)
+ ->saveValueCallback(static function (UserOptionBuilder $builder, IFormField $field) {
+ $builder->setShowOrder((int)$field->getSaveValue());
+ })
+ ->loadValueCallback(static function (UserOption $object, IFormField $field) {
+ $field->value($object->showOrder);
+ }),
]),
FormContainer::create('typeDataContainer')
->label('wcf.acp.user.option.typeData')
@@ -175,16 +216,43 @@ public function createForm()
->required()
->immutable($this->formAction !== 'create')
->options(\array_combine(self::$availableOptionTypes, self::$availableOptionTypes))
- ->value('text'),
+ ->value('text')
+ ->saveValueCallback(static function (UserOptionBuilder $builder, IFormField $field) {
+ $builder->setOptionType((string)$field->getSaveValue());
+ })
+ ->loadValueCallback(static function (UserOption $object, IFormField $field) {
+ $field->value($object->optionType);
+ }),
TextFormField::create('defaultValue')
->label('wcf.acp.user.option.defaultValue')
->description('wcf.acp.user.option.defaultValue.description')
- ->addFieldClass('long'),
+ ->addFieldClass('long')
+ ->loadValueCallback(static function (UserOption $object, IFormField $field) {
+ $field->value($object->defaultValue ?? '');
+ })
+ ->saveValueCallback(static function (UserOptionBuilder $builder, IFormField $field) {
+ // type-cast the default value
+ $defaultValue = $field->getValue();
+ $builder->setDefaultValue(
+ match ($field->getDocument()->getFormField('optionType')->getValue()) {
+ 'boolean', 'integer' => \intval($defaultValue),
+ 'float' => \floatval($defaultValue),
+ 'date' => \preg_match('/\d{4}-\d{2}-\d{2}/', (string)$defaultValue) ? $defaultValue : '',
+ default => $defaultValue,
+ }
+ );
+ }),
MultilineItemListFormField::create('selectOptions')
->label('wcf.acp.user.option.selectOptions')
->description('wcf.acp.user.option.selectOptions.description')
->required()
->saveValueType(ItemListFormField::SAVE_VALUE_TYPE_NSV)
+ ->saveValueCallback(static function (UserOptionBuilder $builder, IFormField $field) {
+ $builder->setSelectOptions((string)$field->getSaveValue());
+ })
+ ->loadValueCallback(static function (UserOption $object, IFormField $field) {
+ $field->value($object->selectOptions ?? '');
+ })
->addDependency(
ValueFormFieldDependency::create('optionType')
->fieldId('optionType')
@@ -218,6 +286,12 @@ public function createForm()
}
})
)
+ ->saveValueCallback(static function (UserOptionBuilder $builder, IFormField $field) {
+ $builder->setLabeledUrl((string)$field->getSaveValue());
+ })
+ ->loadValueCallback(static function (UserOption $object, IFormField $field) {
+ $field->value($object->labeledUrl ?? '');
+ })
->addDependency(
ValueFormFieldDependency::create('optionType')
->fieldId('optionType')
@@ -227,6 +301,28 @@ public function createForm()
->label('wcf.acp.user.option.outputClass')
->description('wcf.acp.user.option.outputClass.description')
->implementedInterface(IUserOptionOutput::class)
+ ->saveValueCallback(static function (UserOptionBuilder $builder, IFormField $field) use ($formAction) {
+ // handle auto-assign of the output class on create
+ $outputClass = $field->getValue();
+ $optionType = $field->getDocument()->getFormField('optionType')->getValue();
+ if ($formAction === 'create' && $outputClass === '') {
+ if (\in_array($optionType, self::$optionTypesUsingSelectOptions)) {
+ $outputClass = SelectOptionsUserOptionOutput::class;
+ } else {
+ $outputClass = match ($optionType) {
+ 'date' => DateUserOptionOutput::class,
+ 'URL' => URLUserOptionOutput::class,
+ 'labeledUrl' => LabeledUrlUserOptionOutput::class,
+ 'message' => MessageUserOptionOutput::class,
+ default => ''
+ };
+ }
+ }
+ $builder->setOutputClass($outputClass);
+ })
+ ->loadValueCallback(static function (UserOption $object, IFormField $field) {
+ $field->value($object->outputClass);
+ }),
]),
FormContainer::create('access')
->label('wcf.acp.user.option.access')
@@ -239,7 +335,13 @@ public function createForm()
3 => 'wcf.acp.user.option.editable.3',
6 => 'wcf.acp.user.option.editable.6',
])
- ->value(3),
+ ->value(3)
+ ->saveValueCallback(static function (UserOptionBuilder $builder, IFormField $field) {
+ $builder->setEditable((int)$field->getSaveValue());
+ })
+ ->loadValueCallback(static function (UserOption $object, IFormField $field) {
+ $field->value($object->editable);
+ }),
SingleSelectionFormField::create('visible')
->label('wcf.acp.user.option.visible')
->options([
@@ -250,10 +352,22 @@ public function createForm()
7 => 'wcf.acp.user.option.visible.7',
15 => 'wcf.acp.user.option.visible.15',
])
- ->value(15),
+ ->value(15)
+ ->saveValueCallback(static function (UserOptionBuilder $builder, IFormField $field) {
+ $builder->setVisible((int)$field->getSaveValue());
+ })
+ ->loadValueCallback(static function (UserOption $object, IFormField $field) {
+ $field->value($object->visible);
+ }),
TextFormField::create('validationPattern')
->label('wcf.acp.user.option.validationPattern')
->description('wcf.acp.user.option.validationPattern.description')
+ ->saveValueCallback(static function (UserOptionBuilder $builder, IFormField $field) {
+ $builder->setValidationPattern((string)$field->getSaveValue());
+ })
+ ->loadValueCallback(static function (UserOption $object, IFormField $field) {
+ $field->value($object->validationPattern ?? '');
+ })
->addDependency(
ValueFormFieldDependency::create('validationPatternOptionTypeDependency')
->fieldId('optionType')
@@ -262,146 +376,73 @@ public function createForm()
),
BooleanFormField::create('required')
->label('wcf.acp.user.option.required')
- ->value(false),
+ ->value(false)
+ ->saveValueCallback(static function (UserOptionBuilder $builder, IFormField $field) {
+ $builder->setRequired((bool)$field->getSaveValue());
+ })
+ ->loadValueCallback(static function (UserOption $object, IFormField $field) {
+ $field->value($object->required);
+ }),
BooleanFormField::create('askDuringRegistration')
->label('wcf.acp.user.option.askDuringRegistration')
- ->value(false),
+ ->value(false)
+ ->saveValueCallback(static function (UserOptionBuilder $builder, IFormField $field) {
+ $builder->setAskDuringRegistration((bool)$field->getSaveValue());
+ })
+ ->loadValueCallback(static function (UserOption $object, IFormField $field) {
+ $field->value($object->askDuringRegistration);
+ }),
BooleanFormField::create('searchable')
->label('wcf.acp.user.option.searchable')
- ->value(false),
+ ->value(false)
+ ->saveValueCallback(static function (UserOptionBuilder $builder, IFormField $field) {
+ $builder->setSearchable((bool)$field->getSaveValue());
+ })
+ ->loadValueCallback(static function (UserOption $object, IFormField $field) {
+ $field->value($object->searchable);
+ }),
BooleanFormField::create('showOnUserCard')
->label('wcf.acp.user.option.showOnUserCard')
- ->value(false),
- ])
+ ->value(false)
+ ->saveValueCallback(static function (UserOptionBuilder $builder, IFormField $field) {
+ $builder->setShowOnUserCard((bool)$field->getSaveValue());
+ })
+ ->loadValueCallback(static function (UserOption $object, IFormField $field) {
+ $field->value($object->showOnUserCard);
+ }),
+ ]),
]);
}
#[\Override]
- protected function finalizeForm()
- {
- parent::finalizeForm();
-
- $this->form->getDataHandler()
- ->addProcessor(
- new CustomFormDataProcessor(
- 'optionNameDataProcessor',
- function (IFormDocument $document, array $parameters) {
- // These values are unconditionally stored in phrases and
- // never in actual columns as it is usually the case with
- // the `I18nHandler`.
- unset($parameters['data']['optionName']);
- unset($parameters['data']['optionDescription']);
-
- return $parameters;
- },
- function (IFormDocument $document, array $data, IStorableObject $object) {
- \assert($object instanceof UserOption);
- $data['optionName'] = 'wcf.user.option.' . $object->optionName;
- $data['optionDescription'] = 'wcf.user.option.' . $object->optionName . '.description';
-
- return $data;
- }
- ),
- )
- ->addProcessor(
- new CustomFormDataProcessor(
- 'additionDataProcessor',
- function (IFormDocument $document, array $parameters) {
- $additionalData = $this->formObject?->additionalData ?: [];
-
- if ($parameters['data']['optionType'] === 'select') {
- $additionalData['allowEmptyValue'] = true;
- } elseif ($parameters['data']['optionType'] === 'message') {
- $additionalData['messageObjectType'] = 'com.woltlab.wcf.user.option.generic';
- }
-
- $parameters['data']['additionalData'] = \serialize($additionalData);
-
- return $parameters;
- }
- )
- )
- ->addProcessor(
- new CustomFormDataProcessor(
- 'outputClassDataProcessor',
- function (IFormDocument $document, array $parameters) {
- if ($this->formAction !== 'create') {
- return $parameters;
- }
-
- $outputClass = $parameters['data']['outputClass'];
- $optionType = $parameters['data']['optionType'];
-
- if ((string)$outputClass === '') {
- if (\in_array($optionType, self::$optionTypesUsingSelectOptions, true)) {
- $parameters['data']['outputClass'] = SelectOptionsUserOptionOutput::class;
- } else {
- $parameters['data']['outputClass'] = match ($optionType) {
- 'date' => DateUserOptionOutput::class,
- 'URL' => URLUserOptionOutput::class,
- 'labeledUrl' => LabeledUrlUserOptionOutput::class,
- 'message' => MessageUserOptionOutput::class,
- default => ''
- };
- }
- }
-
- return $parameters;
- }
- )
- )
- ->addProcessor(
- new CustomFormDataProcessor(
- 'defaultValueDataProcessor',
- function (IFormDocument $document, array $parameters) {
- $optionType = $parameters['data']['optionType'];
- $defaultValue = $parameters['data']['defaultValue'];
-
- $parameters['data']['defaultValue'] = match ($optionType) {
- 'boolean', 'integer' => \intval($defaultValue),
- 'float' => \floatval($defaultValue),
- 'date' => \preg_match('/\d{4}-\d{2}-\d{2}/', $defaultValue) ? $defaultValue : '',
- default => $defaultValue,
- };
-
- return $parameters;
- }
- )
- );
- }
-
- #[\Override]
- public function save()
+ public function save(): void
{
if ($this->formAction === 'create') {
- $this->additionalFields['optionName'] = StringUtil::getRandomID();
$this->additionalFields['packageID'] = \PACKAGE_ID;
}
+ $optionType = (string)$this->getFieldValue('optionType');
+
+ // additionalData
+ $additionalData = $this->formObject?->additionalData ?: [];
+ if ($optionType === 'select') {
+ $additionalData['allowEmptyValue'] = true;
+ } elseif ($optionType === 'message') {
+ $additionalData['messageObjectType'] = 'com.woltlab.wcf.user.option.generic';
+ }
+ $this->additionalFields['additionalData'] = \serialize($additionalData);
+
parent::save();
}
- #[\Override]
- public function saved()
+ /**
+ * Returns the current value of the form field with the given id.
+ */
+ private function getFieldValue(string $id): mixed
{
- $userOption = $this->objectAction->getReturnValues()['returnValues'];
- \assert($userOption instanceof UserOption);
-
- I18nHandler::getInstance()->save(
- 'optionName',
- 'wcf.user.option.option' . $userOption->optionID,
- 'wcf.user.option'
- );
- I18nHandler::getInstance()->save(
- 'optionDescription',
- 'wcf.user.option.option' . $userOption->optionID . '.description',
- 'wcf.user.option'
- );
- $editor = new UserOptionEditor($userOption);
- $editor->update([
- 'optionName' => 'option' . $userOption->optionID,
- ]);
+ $node = $this->form->getNodeById($id);
+ \assert($node instanceof IFormField);
- parent::saved();
+ return $node->getValue();
}
}
diff --git a/wcfsetup/install/files/lib/acp/form/UserOptionEditForm.class.php b/wcfsetup/install/files/lib/acp/form/UserOptionEditForm.class.php
index f1a0a8c00ea..bf50f74e90b 100644
--- a/wcfsetup/install/files/lib/acp/form/UserOptionEditForm.class.php
+++ b/wcfsetup/install/files/lib/acp/form/UserOptionEditForm.class.php
@@ -4,12 +4,10 @@
use wcf\acp\page\UserOptionListPage;
use wcf\data\user\option\UserOption;
-use wcf\form\AbstractFormBuilderForm;
use wcf\http\Helper;
use wcf\system\form\builder\field\SingleSelectionFormField;
use wcf\system\interaction\admin\UserOptionInteractions;
use wcf\system\interaction\StandaloneInteractionContextMenuComponent;
-use wcf\system\language\I18nHandler;
use wcf\system\request\LinkHandler;
use wcf\system\WCF;
@@ -30,7 +28,7 @@ class UserOptionEditForm extends UserOptionAddForm
/**
* @inheritDoc
*/
- public $formAction = 'edit';
+ public string $formAction = 'edit';
#[\Override]
public function readParameters()
@@ -41,7 +39,7 @@ public function readParameters()
}
#[\Override]
- public function createForm()
+ protected function createForm(): void
{
parent::createForm();
@@ -56,23 +54,6 @@ public function createForm()
}
}
- #[\Override]
- public function saved()
- {
- I18nHandler::getInstance()->save(
- 'optionName',
- 'wcf.user.option.' . $this->formObject->optionName,
- 'wcf.user.option'
- );
- I18nHandler::getInstance()->save(
- 'optionDescription',
- 'wcf.user.option.' . $this->formObject->optionName . '.description',
- 'wcf.user.option'
- );
-
- AbstractFormBuilderForm::saved();
- }
-
#[\Override]
public function assignVariables()
{
diff --git a/wcfsetup/install/files/lib/acp/form/UserRankAddForm.class.php b/wcfsetup/install/files/lib/acp/form/UserRankAddForm.class.php
index 4d89853e9a6..8d2443d147a 100644
--- a/wcfsetup/install/files/lib/acp/form/UserRankAddForm.class.php
+++ b/wcfsetup/install/files/lib/acp/form/UserRankAddForm.class.php
@@ -2,21 +2,22 @@
namespace wcf\acp\form;
-use wcf\data\IStorableObject;
+use wcf\command\user\rank\CreateUserRank;
+use wcf\command\user\rank\UpdateUserRank;
+use wcf\data\DatabaseObjectBuilder;
use wcf\data\user\group\UserGroup;
use wcf\data\user\rank\UserRank;
-use wcf\data\user\rank\UserRankAction;
-use wcf\form\AbstractFormBuilderForm;
+use wcf\data\user\rank\UserRankBuilder;
+use wcf\form\AbstractDatabaseObjectBuilderForm;
use wcf\system\form\builder\container\FormContainer;
-use wcf\system\form\builder\data\processor\CustomFormDataProcessor;
use wcf\system\form\builder\field\BadgeColorFormField;
use wcf\system\form\builder\field\BooleanFormField;
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\SelectFormField;
use wcf\system\form\builder\field\SingleSelectionFormField;
use wcf\system\form\builder\field\TextFormField;
-use wcf\system\form\builder\IFormDocument;
use wcf\system\WCF;
/**
@@ -26,9 +27,9 @@
* @copyright 2001-2024 WoltLab GmbH
* @license GNU Lesser General Public License
*
- * @extends AbstractFormBuilderForm
+ * @extends AbstractDatabaseObjectBuilderForm
*/
-class UserRankAddForm extends AbstractFormBuilderForm
+class UserRankAddForm extends AbstractDatabaseObjectBuilderForm
{
/**
* @inheritDoc
@@ -48,15 +49,30 @@ class UserRankAddForm extends AbstractFormBuilderForm
/**
* @inheritDoc
*/
- public $objectActionClass = UserRankAction::class;
+ public string $objectEditLinkController = UserRankEditForm::class;
- /**
- * @inheritDoc
- */
- public $objectEditLinkController = UserRankEditForm::class;
+ #[\Override]
+ protected function getDatabaseObjectBuilder(): UserRankBuilder
+ {
+ if ($this->formObject !== null) {
+ return UserRankBuilder::forUpdate($this->formObject);
+ }
+
+ return UserRankBuilder::forCreate();
+ }
+
+ #[\Override]
+ protected function getCommand(DatabaseObjectBuilder $builder): callable
+ {
+ if ($this->formObject !== null) {
+ return new UpdateUserRank($builder);
+ }
+
+ return new CreateUserRank($builder);
+ }
#[\Override]
- public function createForm()
+ protected function createForm(): void
{
parent::createForm();
@@ -65,15 +81,27 @@ public function createForm()
->appendChildren([
TextFormField::create('rankTitle')
->label('wcf.acp.user.rank.title')
- ->i18n()
- ->languageItemPattern('wcf.user.rank.\w+')
- ->required(),
+ ->l10n()
+ ->required()
+ ->maximumLength(255)
+ ->saveValueCallback(static function (UserRankBuilder $builder, TextFormField $field): void {
+ $builder->setRankTitle($field->getL10nValues());
+ })
+ ->loadValueCallback(static function (UserRank $object, IFormField $field): void {
+ $field->value($object->getL10nValues('rankTitle'));
+ }),
BadgeColorFormField::create('cssClassName')
->label('wcf.acp.user.rank.cssClassName')
->description('wcf.acp.user.rank.cssClassName.description')
->textReferenceNodeId('rankTitle')
->defaultLabelText(WCF::getLanguage()->get('wcf.acp.user.rank.title'))
->required()
+ ->saveValueCallback(static function (UserRankBuilder $builder, BadgeColorFormField $field): void {
+ $builder->setCssClassName($field->getSaveValue());
+ })
+ ->loadValueCallback(static function (UserRank $object, IFormField $field): void {
+ $field->value($object->cssClassName);
+ }),
]),
FormContainer::create('imageContainer')
->label('wcf.acp.user.rank.image')
@@ -82,17 +110,37 @@ public function createForm()
->objectType('com.woltlab.wcf.user.rank.image')
->label('wcf.acp.user.rank.image')
->singleFileUpload()
- ->bigPreview(),
+ ->bigPreview()
+ ->saveValueCallback(static function (UserRankBuilder $builder, FileProcessorFormField $field): void {
+ $builder->setRankImageFileID($field->getSaveValue());
+ })
+ ->loadValueCallback(static function (UserRank $object, IFormField $field): void {
+ if ($object->rankImageFileID !== null) {
+ $field->value($object->rankImageFileID);
+ }
+ }),
IntegerFormField::create('repeatImage')
->label('wcf.acp.user.rank.repeatImage')
->description('wcf.acp.user.rank.repeatImage.description')
->addFieldClass('tiny')
->minimum(1)
- ->value(1),
+ ->value(1)
+ ->saveValueCallback(static function (UserRankBuilder $builder, IntegerFormField $field): void {
+ $builder->setRepeatImage($field->getSaveValue());
+ })
+ ->loadValueCallback(static function (UserRank $object, IFormField $field): void {
+ $field->value($object->repeatImage);
+ }),
BooleanFormField::create('hideTitle')
->label('wcf.acp.user.rank.hideTitle')
->description('wcf.acp.user.rank.hideTitle.description')
->value(false)
+ ->saveValueCallback(static function (UserRankBuilder $builder, BooleanFormField $field): void {
+ $builder->setHideTitle((bool)$field->getSaveValue());
+ })
+ ->loadValueCallback(static function (UserRank $object, IFormField $field): void {
+ $field->value($object->hideTitle);
+ }),
]),
FormContainer::create('requirementsContainer')
->label('wcf.acp.user.rank.requirement')
@@ -101,7 +149,13 @@ public function createForm()
->label('wcf.user.group')
->description('wcf.acp.user.rank.userGroup.description')
->options(UserGroup::getSortedGroupsByType([], [UserGroup::GUESTS, UserGroup::EVERYONE]))
- ->required(),
+ ->required()
+ ->saveValueCallback(static function (UserRankBuilder $builder, SingleSelectionFormField $field): void {
+ $builder->setGroupID((int)$field->getSaveValue());
+ })
+ ->loadValueCallback(static function (UserRank $object, IFormField $field): void {
+ $field->value($object->groupID);
+ }),
SelectFormField::create('requiredGender')
->label('wcf.user.option.gender')
->description('wcf.acp.user.rank.requiredGender.description')
@@ -109,39 +163,27 @@ public function createForm()
1 => 'wcf.user.gender.male',
2 => 'wcf.user.gender.female',
3 => 'wcf.user.gender.other'
- ]),
+ ])
+ ->saveValueCallback(static function (UserRankBuilder $builder, SelectFormField $field): void {
+ $builder->setRequiredGender((int)($field->getSaveValue() ?? 0));
+ })
+ ->loadValueCallback(static function (UserRank $object, IFormField $field): void {
+ // `0` means that no specific gender is required, which the field represents as `null`.
+ $field->value($object->requiredGender === 0 ? null : $object->requiredGender);
+ }),
IntegerFormField::create('requiredPoints')
->label('wcf.acp.user.rank.requiredPoints')
->description('wcf.acp.user.rank.requiredPoints.description')
->addFieldClass('tiny')
->minimum(0)
- ->value(0),
+ ->value(0)
+ ->saveValueCallback(static function (UserRankBuilder $builder, IntegerFormField $field): void {
+ $builder->setRequiredPoints($field->getSaveValue());
+ })
+ ->loadValueCallback(static function (UserRank $object, IFormField $field): void {
+ $field->value($object->requiredPoints);
+ }),
])
]);
}
-
- #[\Override]
- protected function finalizeForm()
- {
- parent::finalizeForm();
-
- $this->form->getDataHandler()
- ->addProcessor(
- new CustomFormDataProcessor(
- 'requiredGenderProcessor',
- function (IFormDocument $document, array $parameters) {
- $parameters['data']['requiredGender'] = $parameters['data']['requiredGender'] ?: 0;
-
- return $parameters;
- },
- function (IFormDocument $document, array $data, IStorableObject $object) {
- \assert($object instanceof UserRank);
-
- $data['requiredGender'] = $data['requiredGender'] ?: null;
-
- return $data;
- }
- )
- );
- }
}
diff --git a/wcfsetup/install/files/lib/acp/form/UserRankEditForm.class.php b/wcfsetup/install/files/lib/acp/form/UserRankEditForm.class.php
index f7b381002d8..c0c9f684046 100644
--- a/wcfsetup/install/files/lib/acp/form/UserRankEditForm.class.php
+++ b/wcfsetup/install/files/lib/acp/form/UserRankEditForm.class.php
@@ -27,7 +27,7 @@ class UserRankEditForm extends UserRankAddForm
/**
* @inheritDoc
*/
- public $formAction = 'edit';
+ public string $formAction = 'edit';
#[\Override]
public function readParameters()
diff --git a/wcfsetup/install/files/lib/acp/page/UserListPage.class.php b/wcfsetup/install/files/lib/acp/page/UserListPage.class.php
index 021c2286776..cf1a258b3f6 100755
--- a/wcfsetup/install/files/lib/acp/page/UserListPage.class.php
+++ b/wcfsetup/install/files/lib/acp/page/UserListPage.class.php
@@ -419,7 +419,11 @@ protected function readColumnsHeads()
}
if (isset($this->options[$column]) && $column !== 'email') {
- $this->columnHeads[$column] = 'wcf.user.option.' . $column;
+ // system options keep their `wcf.user.option.*` phrase, options
+ // created by an administrator only have their localized title in
+ // the l10n storage
+ $this->columnHeads[$column] = $this->options[$column]->l10nIdentifier
+ ?? $this->options[$column]->getTitle();
} else {
$this->columnHeads[$column] = 'wcf.user.' . $column;
}
diff --git a/wcfsetup/install/files/lib/bootstrap/com.woltlab.wcf.php b/wcfsetup/install/files/lib/bootstrap/com.woltlab.wcf.php
index bcc1a6eb8c2..8fac497592e 100644
--- a/wcfsetup/install/files/lib/bootstrap/com.woltlab.wcf.php
+++ b/wcfsetup/install/files/lib/bootstrap/com.woltlab.wcf.php
@@ -69,10 +69,23 @@ static function () {
}
}
);
+
+ $eventHandler->register(
+ \wcf\event\l10n\L10nDefinitionCollecting::class,
+ static function (\wcf\event\l10n\L10nDefinitionCollecting $event) {
+ $event->register(\wcf\data\user\option\UserOption::getL10nDefinition());
+ $event->register(\wcf\data\reaction\type\ReactionType::getL10nDefinition());
+ $event->register(\wcf\data\user\rank\UserRank::getL10nDefinition());
+ $event->register(\wcf\data\contact\recipient\ContactRecipient::getL10nDefinition());
+ }
+ );
$eventHandler->register(
\wcf\event\language\LanguageImported::class,
static function (\wcf\event\language\LanguageImported $event) {
new ResetPreloadCache($event->language)();
+
+ // A new language has no localized values yet.
+ (new \wcf\command\l10n\SyncL10nLanguageItems())();
}
);
$eventHandler->register(
diff --git a/wcfsetup/install/files/lib/command/captcha/question/CreateCaptchaQuestion.class.php b/wcfsetup/install/files/lib/command/captcha/question/CreateCaptchaQuestion.class.php
new file mode 100644
index 00000000000..bc953c05adc
--- /dev/null
+++ b/wcfsetup/install/files/lib/command/captcha/question/CreateCaptchaQuestion.class.php
@@ -0,0 +1,38 @@
+
+ * @since 6.3
+ */
+final class CreateCaptchaQuestion
+{
+ public function __construct(
+ private readonly CaptchaQuestionBuilder $builder,
+ ) {}
+
+ public function __invoke(): CaptchaQuestion
+ {
+ $question = $this->builder->create();
+
+ CaptchaQuestionCacheBuilder::getInstance()->reset();
+
+ EventHandler::getInstance()->fire(new CaptchaQuestionCreated(
+ $question,
+ $this->builder
+ ));
+
+ return $question;
+ }
+}
diff --git a/wcfsetup/install/files/lib/command/captcha/question/UpdateCaptchaQuestion.class.php b/wcfsetup/install/files/lib/command/captcha/question/UpdateCaptchaQuestion.class.php
new file mode 100644
index 00000000000..a6b64580411
--- /dev/null
+++ b/wcfsetup/install/files/lib/command/captcha/question/UpdateCaptchaQuestion.class.php
@@ -0,0 +1,38 @@
+
+ * @since 6.3
+ */
+final class UpdateCaptchaQuestion
+{
+ public function __construct(
+ private readonly CaptchaQuestionBuilder $builder,
+ ) {}
+
+ public function __invoke(): CaptchaQuestion
+ {
+ $question = $this->builder->update();
+
+ CaptchaQuestionCacheBuilder::getInstance()->reset();
+
+ EventHandler::getInstance()->fire(new CaptchaQuestionUpdated(
+ $question,
+ $this->builder
+ ));
+
+ return $question;
+ }
+}
diff --git a/wcfsetup/install/files/lib/command/contact/recipient/CreateContactRecipient.class.php b/wcfsetup/install/files/lib/command/contact/recipient/CreateContactRecipient.class.php
new file mode 100644
index 00000000000..32148fc14f7
--- /dev/null
+++ b/wcfsetup/install/files/lib/command/contact/recipient/CreateContactRecipient.class.php
@@ -0,0 +1,35 @@
+
+ * @since 6.3
+ */
+final class CreateContactRecipient
+{
+ public function __construct(
+ private readonly ContactRecipientBuilder $builder,
+ ) {}
+
+ public function __invoke(): ContactRecipient
+ {
+ $recipient = $this->builder->create();
+
+ EventHandler::getInstance()->fire(new ContactRecipientCreated(
+ $recipient,
+ $this->builder
+ ));
+
+ return $recipient;
+ }
+}
diff --git a/wcfsetup/install/files/lib/command/contact/recipient/DeleteContactRecipient.class.php b/wcfsetup/install/files/lib/command/contact/recipient/DeleteContactRecipient.class.php
new file mode 100644
index 00000000000..0f0fcb61001
--- /dev/null
+++ b/wcfsetup/install/files/lib/command/contact/recipient/DeleteContactRecipient.class.php
@@ -0,0 +1,30 @@
+
+ * @since 6.3
+ */
+final class DeleteContactRecipient
+{
+ public function __construct(
+ private readonly ContactRecipient $recipient,
+ ) {}
+
+ public function __invoke(): void
+ {
+ ContactRecipientBuilder::delete($this->recipient);
+
+ EventHandler::getInstance()->fire(new ContactRecipientDeleted($this->recipient));
+ }
+}
diff --git a/wcfsetup/install/files/lib/command/contact/recipient/DisableContactRecipient.class.php b/wcfsetup/install/files/lib/command/contact/recipient/DisableContactRecipient.class.php
index 5d3230c4b25..6763de9305c 100644
--- a/wcfsetup/install/files/lib/command/contact/recipient/DisableContactRecipient.class.php
+++ b/wcfsetup/install/files/lib/command/contact/recipient/DisableContactRecipient.class.php
@@ -3,7 +3,7 @@
namespace wcf\command\contact\recipient;
use wcf\data\contact\recipient\ContactRecipient;
-use wcf\data\contact\recipient\ContactRecipientEditor;
+use wcf\data\contact\recipient\ContactRecipientBuilder;
use wcf\event\contact\recipient\ContactRecipientDisabled;
use wcf\system\event\EventHandler;
@@ -20,9 +20,9 @@ public function __construct(private readonly ContactRecipient $recipient) {}
public function __invoke(): void
{
- (new ContactRecipientEditor($this->recipient))->update([
- 'isDisabled' => 1,
- ]);
+ ContactRecipientBuilder::forUpdate($this->recipient)
+ ->setIsDisabled(true)
+ ->update();
$event = new ContactRecipientDisabled($this->recipient);
EventHandler::getInstance()->fire($event);
diff --git a/wcfsetup/install/files/lib/command/contact/recipient/EnableContactRecipient.class.php b/wcfsetup/install/files/lib/command/contact/recipient/EnableContactRecipient.class.php
index ac14a6f24ec..9b81dbdadd3 100644
--- a/wcfsetup/install/files/lib/command/contact/recipient/EnableContactRecipient.class.php
+++ b/wcfsetup/install/files/lib/command/contact/recipient/EnableContactRecipient.class.php
@@ -3,7 +3,7 @@
namespace wcf\command\contact\recipient;
use wcf\data\contact\recipient\ContactRecipient;
-use wcf\data\contact\recipient\ContactRecipientEditor;
+use wcf\data\contact\recipient\ContactRecipientBuilder;
use wcf\event\contact\recipient\ContactRecipientEnabled;
use wcf\system\event\EventHandler;
@@ -20,9 +20,9 @@ public function __construct(private readonly ContactRecipient $recipient) {}
public function __invoke(): void
{
- (new ContactRecipientEditor($this->recipient))->update([
- 'isDisabled' => 0,
- ]);
+ ContactRecipientBuilder::forUpdate($this->recipient)
+ ->setIsDisabled(false)
+ ->update();
$event = new ContactRecipientEnabled($this->recipient);
EventHandler::getInstance()->fire($event);
diff --git a/wcfsetup/install/files/lib/command/contact/recipient/UpdateContactRecipient.class.php b/wcfsetup/install/files/lib/command/contact/recipient/UpdateContactRecipient.class.php
new file mode 100644
index 00000000000..dde5141ed7c
--- /dev/null
+++ b/wcfsetup/install/files/lib/command/contact/recipient/UpdateContactRecipient.class.php
@@ -0,0 +1,35 @@
+
+ * @since 6.3
+ */
+final class UpdateContactRecipient
+{
+ public function __construct(
+ private readonly ContactRecipientBuilder $builder,
+ ) {}
+
+ public function __invoke(): ContactRecipient
+ {
+ $recipient = $this->builder->update();
+
+ EventHandler::getInstance()->fire(new ContactRecipientUpdated(
+ $recipient,
+ $this->builder
+ ));
+
+ return $recipient;
+ }
+}
diff --git a/wcfsetup/install/files/lib/command/cronjob/CreateCronjob.class.php b/wcfsetup/install/files/lib/command/cronjob/CreateCronjob.class.php
new file mode 100644
index 00000000000..11ebb7780aa
--- /dev/null
+++ b/wcfsetup/install/files/lib/command/cronjob/CreateCronjob.class.php
@@ -0,0 +1,40 @@
+
+ * @since 6.3
+ */
+final class CreateCronjob
+{
+ public function __construct(
+ private readonly CronjobBuilder $builder,
+ ) {}
+
+ public function __invoke(): Cronjob
+ {
+ // `CronjobBuilder` may rename the cronjob after its creation, the
+ // returned object would carry the placeholder name.
+ $cronjob = new Cronjob($this->builder->create()->cronjobID);
+
+ CronjobCacheBuilder::getInstance()->reset();
+
+ EventHandler::getInstance()->fire(new CronjobCreated(
+ $cronjob,
+ $this->builder
+ ));
+
+ return $cronjob;
+ }
+}
diff --git a/wcfsetup/install/files/lib/command/cronjob/DeleteCronjob.class.php b/wcfsetup/install/files/lib/command/cronjob/DeleteCronjob.class.php
new file mode 100644
index 00000000000..5961bdbef99
--- /dev/null
+++ b/wcfsetup/install/files/lib/command/cronjob/DeleteCronjob.class.php
@@ -0,0 +1,33 @@
+
+ * @since 6.3
+ */
+final class DeleteCronjob
+{
+ public function __construct(
+ private readonly Cronjob $cronjob,
+ ) {}
+
+ public function __invoke(): void
+ {
+ CronjobBuilder::delete($this->cronjob);
+
+ CronjobCacheBuilder::getInstance()->reset();
+
+ EventHandler::getInstance()->fire(new CronjobDeleted($this->cronjob));
+ }
+}
diff --git a/wcfsetup/install/files/lib/command/cronjob/UpdateCronjob.class.php b/wcfsetup/install/files/lib/command/cronjob/UpdateCronjob.class.php
new file mode 100644
index 00000000000..4f1c10b4598
--- /dev/null
+++ b/wcfsetup/install/files/lib/command/cronjob/UpdateCronjob.class.php
@@ -0,0 +1,38 @@
+
+ * @since 6.3
+ */
+final class UpdateCronjob
+{
+ public function __construct(
+ private readonly CronjobBuilder $builder,
+ ) {}
+
+ public function __invoke(): Cronjob
+ {
+ $cronjob = $this->builder->update();
+
+ CronjobCacheBuilder::getInstance()->reset();
+
+ EventHandler::getInstance()->fire(new CronjobUpdated(
+ $cronjob,
+ $this->builder
+ ));
+
+ return $cronjob;
+ }
+}
diff --git a/wcfsetup/install/files/lib/command/l10n/SyncL10nLanguageItems.class.php b/wcfsetup/install/files/lib/command/l10n/SyncL10nLanguageItems.class.php
new file mode 100644
index 00000000000..70c3b522cbc
--- /dev/null
+++ b/wcfsetup/install/files/lib/command/l10n/SyncL10nLanguageItems.class.php
@@ -0,0 +1,33 @@
+
+ * @since 6.3
+ */
+final class SyncL10nLanguageItems
+{
+ public function __invoke(): void
+ {
+ $event = new L10nDefinitionCollecting();
+ EventHandler::getInstance()->fire($event);
+
+ foreach ($event->getDefinitions() as $definition) {
+ L10nLanguageItemSync::sync($definition);
+ }
+ }
+}
diff --git a/wcfsetup/install/files/lib/command/label/group/CreateLabelGroup.class.php b/wcfsetup/install/files/lib/command/label/group/CreateLabelGroup.class.php
new file mode 100644
index 00000000000..de6de31310e
--- /dev/null
+++ b/wcfsetup/install/files/lib/command/label/group/CreateLabelGroup.class.php
@@ -0,0 +1,38 @@
+
+ * @since 6.3
+ */
+final class CreateLabelGroup
+{
+ public function __construct(
+ private readonly LabelGroupBuilder $builder,
+ ) {}
+
+ public function __invoke(): LabelGroup
+ {
+ $group = $this->builder->create();
+
+ LabelCacheBuilder::getInstance()->reset();
+
+ EventHandler::getInstance()->fire(new LabelGroupCreated(
+ $group,
+ $this->builder
+ ));
+
+ return $group;
+ }
+}
diff --git a/wcfsetup/install/files/lib/command/label/group/DeleteLabelGroup.class.php b/wcfsetup/install/files/lib/command/label/group/DeleteLabelGroup.class.php
new file mode 100644
index 00000000000..f614b64323d
--- /dev/null
+++ b/wcfsetup/install/files/lib/command/label/group/DeleteLabelGroup.class.php
@@ -0,0 +1,55 @@
+
+ * @since 6.3
+ */
+final class DeleteLabelGroup
+{
+ public function __construct(
+ private readonly LabelGroup $group,
+ ) {}
+
+ public function __invoke(): void
+ {
+ // The builder removes the labels and the ACL values outside of its own
+ // transaction, which must not persist if the deletion fails.
+ WCF::getDB()->beginTransaction();
+ $committed = false;
+ try {
+ LabelGroupBuilder::delete($this->group);
+
+ WCF::getDB()->commitTransaction();
+ $committed = true;
+ } finally {
+ if (!$committed) {
+ WCF::getDB()->rollBackTransaction();
+ }
+ }
+
+ foreach (ObjectTypeCache::getInstance()->getObjectTypes('com.woltlab.wcf.label.objectType') as $objectType) {
+ $handler = $objectType->getProcessor();
+ \assert($handler instanceof ILabelObjectTypeHandler);
+ $handler->save();
+ }
+
+ LabelCacheBuilder::getInstance()->reset();
+
+ EventHandler::getInstance()->fire(new LabelGroupDeleted($this->group));
+ }
+}
diff --git a/wcfsetup/install/files/lib/command/label/group/UpdateLabelGroup.class.php b/wcfsetup/install/files/lib/command/label/group/UpdateLabelGroup.class.php
new file mode 100644
index 00000000000..d2077b56de4
--- /dev/null
+++ b/wcfsetup/install/files/lib/command/label/group/UpdateLabelGroup.class.php
@@ -0,0 +1,38 @@
+
+ * @since 6.3
+ */
+final class UpdateLabelGroup
+{
+ public function __construct(
+ private readonly LabelGroupBuilder $builder,
+ ) {}
+
+ public function __invoke(): LabelGroup
+ {
+ $group = $this->builder->update();
+
+ LabelCacheBuilder::getInstance()->reset();
+
+ EventHandler::getInstance()->fire(new LabelGroupUpdated(
+ $group,
+ $this->builder
+ ));
+
+ return $group;
+ }
+}
diff --git a/wcfsetup/install/files/lib/command/paid/subscription/CreatePaidSubscription.class.php b/wcfsetup/install/files/lib/command/paid/subscription/CreatePaidSubscription.class.php
new file mode 100644
index 00000000000..e53cde4a399
--- /dev/null
+++ b/wcfsetup/install/files/lib/command/paid/subscription/CreatePaidSubscription.class.php
@@ -0,0 +1,41 @@
+
+ * @since 6.3
+ */
+final class CreatePaidSubscription
+{
+ public function __construct(
+ private readonly PaidSubscriptionBuilder $builder,
+ ) {}
+
+ public function __invoke(): PaidSubscription
+ {
+ $subscription = $this->builder->create();
+
+ // The builder assigns the final position after the object was read.
+ $subscription = new PaidSubscription($subscription->subscriptionID);
+
+ PaidSubscriptionCacheBuilder::getInstance()->reset();
+
+ EventHandler::getInstance()->fire(new PaidSubscriptionCreated(
+ $subscription,
+ $this->builder
+ ));
+
+ return $subscription;
+ }
+}
diff --git a/wcfsetup/install/files/lib/command/paid/subscription/DeletePaidSubscription.class.php b/wcfsetup/install/files/lib/command/paid/subscription/DeletePaidSubscription.class.php
new file mode 100644
index 00000000000..e6bf23e589a
--- /dev/null
+++ b/wcfsetup/install/files/lib/command/paid/subscription/DeletePaidSubscription.class.php
@@ -0,0 +1,44 @@
+
+ * @since 6.3
+ */
+final class DeletePaidSubscription
+{
+ public function __construct(
+ private readonly PaidSubscription $subscription,
+ ) {}
+
+ public function __invoke(): void
+ {
+ // The given object may predate the deletion of another subscription,
+ // which moved this one to a lower position.
+ $showOrder = (new PaidSubscription($this->subscription->subscriptionID))->showOrder;
+
+ PaidSubscriptionBuilder::delete($this->subscription);
+
+ $sql = "UPDATE wcf1_paid_subscription
+ SET showOrder = showOrder - 1
+ WHERE showOrder > ?";
+ $statement = WCF::getDB()->prepare($sql);
+ $statement->execute([$showOrder]);
+
+ PaidSubscriptionCacheBuilder::getInstance()->reset();
+
+ EventHandler::getInstance()->fire(new PaidSubscriptionDeleted($this->subscription));
+ }
+}
diff --git a/wcfsetup/install/files/lib/command/paid/subscription/DisablePaidSubscription.class.php b/wcfsetup/install/files/lib/command/paid/subscription/DisablePaidSubscription.class.php
index 2bce6030f3b..aa3241d322a 100644
--- a/wcfsetup/install/files/lib/command/paid/subscription/DisablePaidSubscription.class.php
+++ b/wcfsetup/install/files/lib/command/paid/subscription/DisablePaidSubscription.class.php
@@ -3,8 +3,9 @@
namespace wcf\command\paid\subscription;
use wcf\data\paid\subscription\PaidSubscription;
-use wcf\data\paid\subscription\PaidSubscriptionEditor;
+use wcf\data\paid\subscription\PaidSubscriptionBuilder;
use wcf\event\paid\subscription\PaidSubscriptionDisabled;
+use wcf\system\cache\builder\PaidSubscriptionCacheBuilder;
use wcf\system\event\EventHandler;
/**
@@ -21,11 +22,11 @@ public function __construct(private readonly PaidSubscription $subscription) {}
public function __invoke(): void
{
- (new PaidSubscriptionEditor($this->subscription))->update([
- 'isDisabled' => 1,
- ]);
+ PaidSubscriptionBuilder::forUpdate($this->subscription)
+ ->setIsDisabled(true)
+ ->update();
- PaidSubscriptionEditor::resetCache();
+ PaidSubscriptionCacheBuilder::getInstance()->reset();
$event = new PaidSubscriptionDisabled($this->subscription);
EventHandler::getInstance()->fire($event);
diff --git a/wcfsetup/install/files/lib/command/paid/subscription/EnablePaidSubscription.class.php b/wcfsetup/install/files/lib/command/paid/subscription/EnablePaidSubscription.class.php
index 5d17b31abc0..2d845852c8c 100644
--- a/wcfsetup/install/files/lib/command/paid/subscription/EnablePaidSubscription.class.php
+++ b/wcfsetup/install/files/lib/command/paid/subscription/EnablePaidSubscription.class.php
@@ -3,8 +3,9 @@
namespace wcf\command\paid\subscription;
use wcf\data\paid\subscription\PaidSubscription;
-use wcf\data\paid\subscription\PaidSubscriptionEditor;
+use wcf\data\paid\subscription\PaidSubscriptionBuilder;
use wcf\event\paid\subscription\PaidSubscriptionEnabled;
+use wcf\system\cache\builder\PaidSubscriptionCacheBuilder;
use wcf\system\event\EventHandler;
/**
@@ -21,11 +22,11 @@ public function __construct(private readonly PaidSubscription $subscription) {}
public function __invoke(): void
{
- (new PaidSubscriptionEditor($this->subscription))->update([
- 'isDisabled' => 0,
- ]);
+ PaidSubscriptionBuilder::forUpdate($this->subscription)
+ ->setIsDisabled(false)
+ ->update();
- PaidSubscriptionEditor::resetCache();
+ PaidSubscriptionCacheBuilder::getInstance()->reset();
$event = new PaidSubscriptionEnabled($this->subscription);
EventHandler::getInstance()->fire($event);
diff --git a/wcfsetup/install/files/lib/command/paid/subscription/UpdatePaidSubscription.class.php b/wcfsetup/install/files/lib/command/paid/subscription/UpdatePaidSubscription.class.php
new file mode 100644
index 00000000000..62261ed3a39
--- /dev/null
+++ b/wcfsetup/install/files/lib/command/paid/subscription/UpdatePaidSubscription.class.php
@@ -0,0 +1,41 @@
+
+ * @since 6.3
+ */
+final class UpdatePaidSubscription
+{
+ public function __construct(
+ private readonly PaidSubscriptionBuilder $builder,
+ ) {}
+
+ public function __invoke(): PaidSubscription
+ {
+ $subscription = $this->builder->update();
+
+ // The builder may move the subscription after the object was read.
+ $subscription = new PaidSubscription($subscription->subscriptionID);
+
+ PaidSubscriptionCacheBuilder::getInstance()->reset();
+
+ EventHandler::getInstance()->fire(new PaidSubscriptionUpdated(
+ $subscription,
+ $this->builder
+ ));
+
+ return $subscription;
+ }
+}
diff --git a/wcfsetup/install/files/lib/command/reaction/type/CreateReactionType.class.php b/wcfsetup/install/files/lib/command/reaction/type/CreateReactionType.class.php
new file mode 100644
index 00000000000..4ab7ae67b9d
--- /dev/null
+++ b/wcfsetup/install/files/lib/command/reaction/type/CreateReactionType.class.php
@@ -0,0 +1,38 @@
+
+ * @since 6.3
+ */
+final class CreateReactionType
+{
+ public function __construct(
+ private readonly ReactionTypeBuilder $builder,
+ ) {}
+
+ public function __invoke(): ReactionType
+ {
+ $reactionType = $this->builder->create();
+
+ ReactionTypeCacheBuilder::getInstance()->reset();
+
+ EventHandler::getInstance()->fire(new ReactionTypeCreated(
+ $reactionType,
+ $this->builder
+ ));
+
+ return $reactionType;
+ }
+}
diff --git a/wcfsetup/install/files/lib/command/reaction/type/UpdateReactionType.class.php b/wcfsetup/install/files/lib/command/reaction/type/UpdateReactionType.class.php
new file mode 100644
index 00000000000..6b483add421
--- /dev/null
+++ b/wcfsetup/install/files/lib/command/reaction/type/UpdateReactionType.class.php
@@ -0,0 +1,38 @@
+
+ * @since 6.3
+ */
+final class UpdateReactionType
+{
+ public function __construct(
+ private readonly ReactionTypeBuilder $builder,
+ ) {}
+
+ public function __invoke(): ReactionType
+ {
+ $reactionType = $this->builder->update();
+
+ ReactionTypeCacheBuilder::getInstance()->reset();
+
+ EventHandler::getInstance()->fire(new ReactionTypeUpdated(
+ $reactionType,
+ $this->builder
+ ));
+
+ return $reactionType;
+ }
+}
diff --git a/wcfsetup/install/files/lib/command/user/option/CreateUserOption.class.php b/wcfsetup/install/files/lib/command/user/option/CreateUserOption.class.php
new file mode 100644
index 00000000000..e15e95c7340
--- /dev/null
+++ b/wcfsetup/install/files/lib/command/user/option/CreateUserOption.class.php
@@ -0,0 +1,38 @@
+
+ * @since 6.3
+ */
+final class CreateUserOption
+{
+ public function __construct(
+ private readonly UserOptionBuilder $builder,
+ ) {}
+
+ public function __invoke(): UserOption
+ {
+ $option = $this->builder->create();
+
+ UserOptionCacheBuilder::getInstance()->reset();
+
+ EventHandler::getInstance()->fire(new UserOptionCreated(
+ $option,
+ $this->builder
+ ));
+
+ return $option;
+ }
+}
diff --git a/wcfsetup/install/files/lib/command/user/option/DeleteOption.class.php b/wcfsetup/install/files/lib/command/user/option/DeleteOption.class.php
index 4ba83f75a6e..96fbfcc5204 100644
--- a/wcfsetup/install/files/lib/command/user/option/DeleteOption.class.php
+++ b/wcfsetup/install/files/lib/command/user/option/DeleteOption.class.php
@@ -3,7 +3,10 @@
namespace wcf\command\user\option;
use wcf\data\user\option\UserOption;
-use wcf\data\user\option\UserOptionAction;
+use wcf\data\user\option\UserOptionBuilder;
+use wcf\event\user\option\UserOptionDeleted;
+use wcf\system\cache\builder\UserOptionCacheBuilder;
+use wcf\system\event\EventHandler;
/**
* Deletes a user option.
@@ -21,7 +24,10 @@ public function __construct(
public function __invoke(): void
{
- $action = new UserOptionAction([$this->option], 'delete');
- $action->executeAction();
+ UserOptionBuilder::delete($this->option);
+
+ UserOptionCacheBuilder::getInstance()->reset();
+
+ EventHandler::getInstance()->fire(new UserOptionDeleted($this->option));
}
}
diff --git a/wcfsetup/install/files/lib/command/user/option/DisableOption.class.php b/wcfsetup/install/files/lib/command/user/option/DisableOption.class.php
index 6a2d48e3f8b..dfeaeafbedb 100644
--- a/wcfsetup/install/files/lib/command/user/option/DisableOption.class.php
+++ b/wcfsetup/install/files/lib/command/user/option/DisableOption.class.php
@@ -3,7 +3,10 @@
namespace wcf\command\user\option;
use wcf\data\user\option\UserOption;
-use wcf\data\user\option\UserOptionEditor;
+use wcf\data\user\option\UserOptionBuilder;
+use wcf\event\user\option\UserOptionDisabled;
+use wcf\system\cache\builder\UserOptionCacheBuilder;
+use wcf\system\event\EventHandler;
/**
* Disables a user option.
@@ -21,8 +24,12 @@ public function __construct(
public function __invoke(): void
{
- (new UserOptionEditor($this->option))->update([
- 'isDisabled' => 1,
- ]);
+ UserOptionBuilder::forUpdate($this->option)
+ ->setIsDisabled(true)
+ ->update();
+
+ UserOptionCacheBuilder::getInstance()->reset();
+
+ EventHandler::getInstance()->fire(new UserOptionDisabled($this->option));
}
}
diff --git a/wcfsetup/install/files/lib/command/user/option/EnableOption.class.php b/wcfsetup/install/files/lib/command/user/option/EnableOption.class.php
index 6431f39c044..425c27ff7cd 100644
--- a/wcfsetup/install/files/lib/command/user/option/EnableOption.class.php
+++ b/wcfsetup/install/files/lib/command/user/option/EnableOption.class.php
@@ -3,7 +3,10 @@
namespace wcf\command\user\option;
use wcf\data\user\option\UserOption;
-use wcf\data\user\option\UserOptionEditor;
+use wcf\data\user\option\UserOptionBuilder;
+use wcf\event\user\option\UserOptionEnabled;
+use wcf\system\cache\builder\UserOptionCacheBuilder;
+use wcf\system\event\EventHandler;
/**
* Enables a user option.
@@ -21,8 +24,12 @@ public function __construct(
public function __invoke(): void
{
- (new UserOptionEditor($this->option))->update([
- 'isDisabled' => 0,
- ]);
+ UserOptionBuilder::forUpdate($this->option)
+ ->setIsDisabled(false)
+ ->update();
+
+ UserOptionCacheBuilder::getInstance()->reset();
+
+ EventHandler::getInstance()->fire(new UserOptionEnabled($this->option));
}
}
diff --git a/wcfsetup/install/files/lib/command/user/option/UpdateUserOption.class.php b/wcfsetup/install/files/lib/command/user/option/UpdateUserOption.class.php
new file mode 100644
index 00000000000..bdd1339a305
--- /dev/null
+++ b/wcfsetup/install/files/lib/command/user/option/UpdateUserOption.class.php
@@ -0,0 +1,38 @@
+
+ * @since 6.3
+ */
+final class UpdateUserOption
+{
+ public function __construct(
+ private readonly UserOptionBuilder $builder,
+ ) {}
+
+ public function __invoke(): UserOption
+ {
+ $option = $this->builder->update();
+
+ UserOptionCacheBuilder::getInstance()->reset();
+
+ EventHandler::getInstance()->fire(new UserOptionUpdated(
+ $option,
+ $this->builder
+ ));
+
+ return $option;
+ }
+}
diff --git a/wcfsetup/install/files/lib/command/user/rank/CreateUserRank.class.php b/wcfsetup/install/files/lib/command/user/rank/CreateUserRank.class.php
new file mode 100644
index 00000000000..9caa06150d7
--- /dev/null
+++ b/wcfsetup/install/files/lib/command/user/rank/CreateUserRank.class.php
@@ -0,0 +1,38 @@
+
+ * @since 6.3
+ */
+final class CreateUserRank
+{
+ public function __construct(
+ private readonly UserRankBuilder $builder,
+ ) {}
+
+ public function __invoke(): UserRank
+ {
+ $rank = $this->builder->create();
+
+ UserRankCacheBuilder::getInstance()->reset();
+
+ EventHandler::getInstance()->fire(new UserRankCreated(
+ $rank,
+ $this->builder
+ ));
+
+ return $rank;
+ }
+}
diff --git a/wcfsetup/install/files/lib/command/user/rank/DeleteUserRank.class.php b/wcfsetup/install/files/lib/command/user/rank/DeleteUserRank.class.php
new file mode 100644
index 00000000000..a57af19e2fd
--- /dev/null
+++ b/wcfsetup/install/files/lib/command/user/rank/DeleteUserRank.class.php
@@ -0,0 +1,44 @@
+
+ * @since 6.3
+ */
+final class DeleteUserRank
+{
+ public function __construct(
+ private readonly UserRank $rank,
+ ) {}
+
+ public function __invoke(): void
+ {
+ UserRankBuilder::delete($this->rank);
+
+ // The image is deleted only after the rank, because deleting a file
+ // cannot be rolled back.
+ if ($this->rank->rankImageFileID !== null) {
+ $file = FileRuntimeCache::getInstance()->getObject($this->rank->rankImageFileID);
+ if ($file !== null) {
+ new DeleteFiles([$file])();
+ }
+ }
+
+ UserRankCacheBuilder::getInstance()->reset();
+
+ EventHandler::getInstance()->fire(new UserRankDeleted($this->rank));
+ }
+}
diff --git a/wcfsetup/install/files/lib/command/user/rank/UpdateUserRank.class.php b/wcfsetup/install/files/lib/command/user/rank/UpdateUserRank.class.php
new file mode 100644
index 00000000000..079a6c4a834
--- /dev/null
+++ b/wcfsetup/install/files/lib/command/user/rank/UpdateUserRank.class.php
@@ -0,0 +1,38 @@
+
+ * @since 6.3
+ */
+final class UpdateUserRank
+{
+ public function __construct(
+ private readonly UserRankBuilder $builder,
+ ) {}
+
+ public function __invoke(): UserRank
+ {
+ $rank = $this->builder->update();
+
+ UserRankCacheBuilder::getInstance()->reset();
+
+ EventHandler::getInstance()->fire(new UserRankUpdated(
+ $rank,
+ $this->builder
+ ));
+
+ return $rank;
+ }
+}
diff --git a/wcfsetup/install/files/lib/data/DatabaseObjectCollection.class.php b/wcfsetup/install/files/lib/data/DatabaseObjectCollection.class.php
index f919cece03a..fe07c2c8318 100644
--- a/wcfsetup/install/files/lib/data/DatabaseObjectCollection.class.php
+++ b/wcfsetup/install/files/lib/data/DatabaseObjectCollection.class.php
@@ -17,10 +17,15 @@ abstract class DatabaseObjectCollection
/**
* @param TDatabaseObject[] $objects
*/
- public function __construct(protected readonly array $objects) {}
+ public function __construct(protected readonly array $objects)
+ {
+ if ($objects === []) {
+ throw new \InvalidArgumentException("A collection must contain at least one object.");
+ }
+ }
/**
- * @return int[]
+ * @return non-empty-array
*/
public function getObjectIDs(): array
{
@@ -28,7 +33,7 @@ public function getObjectIDs(): array
}
/**
- * @return TDatabaseObject[]
+ * @return non-empty-array
*/
public function getObjects(): array
{
diff --git a/wcfsetup/install/files/lib/data/TCollectionL10n.class.php b/wcfsetup/install/files/lib/data/TCollectionL10n.class.php
new file mode 100644
index 00000000000..1a7f6f75195
--- /dev/null
+++ b/wcfsetup/install/files/lib/data/TCollectionL10n.class.php
@@ -0,0 +1,55 @@
+
+ * @since 6.3
+ *
+ * @phpstan-import-type L10nValue from L10nStorage
+ */
+trait TCollectionL10n
+{
+ /**
+ * @var array>
+ */
+ private array $l10nValues;
+
+ public function getResolvedL10nValue(DatabaseObject $object, string $columnName): string
+ {
+ $this->loadL10nValues();
+
+ return L10nStorage::resolveValue($this->l10nValues[$object->getObjectID()][$columnName] ?? []);
+ }
+
+ /**
+ * @return L10nValue
+ */
+ public function getL10nValues(DatabaseObject $object, string $columnName): array
+ {
+ $this->loadL10nValues();
+
+ return $this->l10nValues[$object->getObjectID()][$columnName] ?? [];
+ }
+
+ private function loadL10nValues(): void
+ {
+ if (isset($this->l10nValues)) {
+ return;
+ }
+
+ $this->l10nValues = (new L10nStorage($this->getL10nDefinition()))->getValuesForObjects(
+ $this->getObjectIDs()
+ );
+ }
+
+ abstract protected function getL10nDefinition(): L10nDefinition;
+}
diff --git a/wcfsetup/install/files/lib/data/captcha/question/CaptchaQuestion.class.php b/wcfsetup/install/files/lib/data/captcha/question/CaptchaQuestion.class.php
index 966a6d206c7..777c9d7a2af 100644
--- a/wcfsetup/install/files/lib/data/captcha/question/CaptchaQuestion.class.php
+++ b/wcfsetup/install/files/lib/data/captcha/question/CaptchaQuestion.class.php
@@ -2,48 +2,59 @@
namespace wcf\data\captcha\question;
-use wcf\data\DatabaseObject;
+use wcf\data\CollectionDatabaseObject;
use wcf\data\ITitledObject;
+use wcf\system\l10n\L10nDefinition;
+use wcf\system\l10n\L10nStorage;
use wcf\system\Regex;
-use wcf\system\WCF;
use wcf\util\StringUtil;
/**
* Represents a captcha question.
*
- * @author Matthias Schmidt
- * @copyright 2001-2019 WoltLab GmbH
- * @license GNU Lesser General Public License
+ * The localized values (`question` and `answers`) are stored in the
+ * `wcf1_captcha_question_l10n` table.
+ *
+ * @author Matthias Schmidt, Marcel Werk
+ * @copyright 2001-2026 WoltLab GmbH
+ * @license GNU Lesser General Public License
*
* @property-read int $questionID unique id of the captcha question
- * @property-read string $question question of the captcha or name of language item which contains the question
- * @property-read ?string $answers newline-separated list of answers or name of language item which contains the answers
* @property-read 0|1 $isDisabled is `1` if the captcha question is disabled and thus not offered to answer, otherwise `0`
* @property-read int $views
* @property-read int $correctSubmissions
* @property-read int $incorrectSubmissions
+ *
+ * @extends CollectionDatabaseObject
+ *
+ * @phpstan-import-type L10nValue from L10nStorage
*/
-class CaptchaQuestion extends DatabaseObject implements ITitledObject
+class CaptchaQuestion extends CollectionDatabaseObject implements ITitledObject
{
/**
* Returns the question in the active user's language.
+ */
+ public function getQuestion(): string
+ {
+ return $this->getCollection()->getResolvedL10nValue($this, 'question');
+ }
+
+ /**
+ * Returns the newline-separated list of answers in the active user's language.
*
- * @return string
- * @since 5.2
+ * @since 6.3
*/
- public function getQuestion()
+ public function getAnswers(): string
{
- return WCF::getLanguage()->get($this->question);
+ return $this->getCollection()->getResolvedL10nValue($this, 'answers');
}
/**
* Returns true if the given user input is an answer to this question.
- *
- * @return bool
*/
- public function isAnswer(string $answer)
+ public function isAnswer(string $answer): bool
{
- $answers = \explode("\n", StringUtil::unifyNewlines(WCF::getLanguage()->get($this->answers)));
+ $answers = \explode("\n", StringUtil::unifyNewlines($this->getAnswers()));
foreach ($answers as $__answer) {
if (\mb_substr($__answer, 0, 1) === '~' && \mb_substr($__answer, -1, 1) === '~') {
if (Regex::compile(\mb_substr($__answer, 1, \mb_strlen($__answer) - 2), Regex::CASE_INSENSITIVE)->match($answer) !== 0) {
@@ -59,9 +70,38 @@ public function isAnswer(string $answer)
return false;
}
+ /**
+ * Returns the localized values of this question.
+ *
+ * @return L10nValue
+ * @since 6.3
+ */
+ public function getL10nValues(string $columnName): array
+ {
+ if ($columnName !== 'question' && $columnName !== 'answers') {
+ throw new \InvalidArgumentException("Invalid column name given.");
+ }
+
+ return $this->getCollection()->getL10nValues($this, $columnName);
+ }
+
#[\Override]
public function getTitle(): string
{
return $this->getQuestion();
}
+
+ /**
+ * @since 6.3
+ */
+ public static function getL10nDefinition(): L10nDefinition
+ {
+ return new L10nDefinition(
+ 'wcf1_captcha_question',
+ 'wcf1_captcha_question_l10n',
+ 'questionID',
+ ['question', 'answers'],
+ maximumLengths: ['question' => 255],
+ );
+ }
}
diff --git a/wcfsetup/install/files/lib/data/captcha/question/CaptchaQuestionAction.class.php b/wcfsetup/install/files/lib/data/captcha/question/CaptchaQuestionAction.class.php
index 9bbaf84df18..80b1017f98a 100644
--- a/wcfsetup/install/files/lib/data/captcha/question/CaptchaQuestionAction.class.php
+++ b/wcfsetup/install/files/lib/data/captcha/question/CaptchaQuestionAction.class.php
@@ -6,11 +6,14 @@
use wcf\command\captcha\question\EnableCaptchaQuestion;
use wcf\data\AbstractDatabaseObjectAction;
use wcf\data\IToggleAction;
-use wcf\data\TI18nDatabaseObjectAction;
/**
* Executes captcha question-related actions.
*
+ * Captcha questions should be created and updated through the
+ * `CreateCaptchaQuestion` and `UpdateCaptchaQuestion` commands, the `create`
+ * and `update` actions are `@deprecated 6.3`.
+ *
* @author Matthias Schmidt
* @copyright 2001-2019 WoltLab GmbH
* @license GNU Lesser General Public License
@@ -19,8 +22,6 @@
*/
class CaptchaQuestionAction extends AbstractDatabaseObjectAction implements IToggleAction
{
- use TI18nDatabaseObjectAction;
-
/**
* @inheritDoc
*/
@@ -31,63 +32,6 @@ class CaptchaQuestionAction extends AbstractDatabaseObjectAction implements ITog
*/
protected $permissionsUpdate = ['admin.captcha.canManageCaptchaQuestion'];
- /**
- * @return array
- */
- #[\Override]
- public function getI18nSaveTypes(): array
- {
- return [
- 'question' => 'wcf.captcha.question.question.question\d+',
- 'answers' => 'wcf.captcha.question.answers.question\d+',
- ];
- }
-
- #[\Override]
- public function getLanguageCategory(): string
- {
- return 'wcf.captcha.question';
- }
-
- #[\Override]
- public function getPackageID(): int
- {
- return \PACKAGE_ID;
- }
-
- #[\Override]
- public function update()
- {
- parent::update();
-
- foreach ($this->objects as $object) {
- $this->saveI18nValue($object->getDecoratedObject());
- }
- }
-
- #[\Override]
- public function create()
- {
- // Question column doesn't have a default value
- $this->parameters['data']['question'] = $this->parameters['data']['question'] ?? '';
-
- $captchaQuestion = parent::create();
-
- $this->saveI18nValue($captchaQuestion);
-
- return $captchaQuestion;
- }
-
- #[\Override]
- public function delete()
- {
- $returnValue = parent::delete();
-
- $this->deleteI18nValues();
-
- return $returnValue;
- }
-
/**
* @deprecated 6.3
*/
diff --git a/wcfsetup/install/files/lib/data/captcha/question/CaptchaQuestionBuilder.class.php b/wcfsetup/install/files/lib/data/captcha/question/CaptchaQuestionBuilder.class.php
new file mode 100644
index 00000000000..296afd0700d
--- /dev/null
+++ b/wcfsetup/install/files/lib/data/captcha/question/CaptchaQuestionBuilder.class.php
@@ -0,0 +1,102 @@
+
+ * @since 6.3
+ *
+ * @extends DatabaseObjectBuilder
+ *
+ * @phpstan-import-type L10nValue from L10nStorage
+ */
+final class CaptchaQuestionBuilder extends DatabaseObjectBuilder
+{
+ /**
+ * @var L10nValue
+ */
+ private array $question;
+
+ /**
+ * @var L10nValue
+ */
+ private array $answers;
+
+ /**
+ * @param L10nValue $question
+ */
+ public function setQuestion(array $question): static
+ {
+ $this->question = $question;
+
+ return $this;
+ }
+
+ /**
+ * @param L10nValue $answers
+ */
+ public function setAnswers(array $answers): static
+ {
+ $this->answers = $answers;
+
+ return $this;
+ }
+
+ public function setIsDisabled(bool $isDisabled): static
+ {
+ $this->properties['isDisabled'] = $isDisabled ? 1 : 0;
+
+ return $this;
+ }
+
+ #[\Override]
+ protected function afterValidateCreate(): void
+ {
+ if (!isset($this->question) || !isset($this->answers)) {
+ throw new \BadMethodCallException("Missing values for 'question' or 'answers'.");
+ }
+ }
+
+ #[\Override]
+ protected function afterCreate(DatabaseObject $object): void
+ {
+ $this->saveL10nValues($object);
+ }
+
+ #[\Override]
+ protected function afterUpdate(DatabaseObject $object): void
+ {
+ if (isset($this->question) || isset($this->answers)) {
+ if (!isset($this->question) || !isset($this->answers)) {
+ // `L10nStorage::setValues()` replaces all rows of the object,
+ // writing only one of the two columns would wipe the other.
+ throw new \BadMethodCallException("'question' and 'answers' must be set together.");
+ }
+
+ $this->saveL10nValues($object);
+ }
+ }
+
+ private function saveL10nValues(CaptchaQuestion $question): void
+ {
+ (new L10nStorage(CaptchaQuestion::getL10nDefinition()))->setValues(
+ $question->questionID,
+ [
+ 'question' => $this->question,
+ 'answers' => $this->answers,
+ ]
+ );
+ }
+}
diff --git a/wcfsetup/install/files/lib/data/captcha/question/CaptchaQuestionCollection.class.php b/wcfsetup/install/files/lib/data/captcha/question/CaptchaQuestionCollection.class.php
new file mode 100644
index 00000000000..b308b08a4a5
--- /dev/null
+++ b/wcfsetup/install/files/lib/data/captcha/question/CaptchaQuestionCollection.class.php
@@ -0,0 +1,28 @@
+
+ * @since 6.3
+ *
+ * @extends DatabaseObjectCollection
+ */
+class CaptchaQuestionCollection extends DatabaseObjectCollection
+{
+ use TCollectionL10n;
+
+ #[\Override]
+ protected function getL10nDefinition(): L10nDefinition
+ {
+ return CaptchaQuestion::getL10nDefinition();
+ }
+}
diff --git a/wcfsetup/install/files/lib/data/captcha/question/CaptchaQuestionList.class.php b/wcfsetup/install/files/lib/data/captcha/question/CaptchaQuestionList.class.php
index 5cfaa578011..44d39e67ec2 100644
--- a/wcfsetup/install/files/lib/data/captcha/question/CaptchaQuestionList.class.php
+++ b/wcfsetup/install/files/lib/data/captcha/question/CaptchaQuestionList.class.php
@@ -13,4 +13,10 @@
*
* @extends DatabaseObjectList
*/
-class CaptchaQuestionList extends DatabaseObjectList {}
+class CaptchaQuestionList extends DatabaseObjectList
+{
+ /**
+ * @inheritDoc
+ */
+ public $className = CaptchaQuestion::class;
+}
diff --git a/wcfsetup/install/files/lib/data/captcha/question/I18nCaptchaQuestionList.class.php b/wcfsetup/install/files/lib/data/captcha/question/I18nCaptchaQuestionList.class.php
deleted file mode 100644
index 4784e731df5..00000000000
--- a/wcfsetup/install/files/lib/data/captcha/question/I18nCaptchaQuestionList.class.php
+++ /dev/null
@@ -1,28 +0,0 @@
-
- * @since 6.2
- *
- * @extends I18nDatabaseObjectList
- */
-class I18nCaptchaQuestionList extends I18nDatabaseObjectList
-{
- /**
- * @inheritDoc
- */
- public $i18nFields = ['question' => 'questionI18n'];
-
- /**
- * @inheritDoc
- */
- public $className = CaptchaQuestion::class;
-}
diff --git a/wcfsetup/install/files/lib/data/captcha/question/L10nCaptchaQuestionList.class.php b/wcfsetup/install/files/lib/data/captcha/question/L10nCaptchaQuestionList.class.php
new file mode 100644
index 00000000000..f86eb606a89
--- /dev/null
+++ b/wcfsetup/install/files/lib/data/captcha/question/L10nCaptchaQuestionList.class.php
@@ -0,0 +1,27 @@
+
+ * @since 6.3
+ */
+class L10nCaptchaQuestionList extends CaptchaQuestionList
+{
+ public function __construct()
+ {
+ parent::__construct();
+
+ $storage = new L10nStorage(CaptchaQuestion::getL10nDefinition());
+
+ $this->sqlSelects .= (!empty($this->sqlSelects) ? ', ' : '')
+ . $storage->getSubSelect('question', $this->getDatabaseTableAlias())
+ . ' AS question';
+ }
+}
diff --git a/wcfsetup/install/files/lib/data/contact/recipient/ContactRecipient.class.php b/wcfsetup/install/files/lib/data/contact/recipient/ContactRecipient.class.php
index 5a54789f7d7..043b315a7fc 100644
--- a/wcfsetup/install/files/lib/data/contact/recipient/ContactRecipient.class.php
+++ b/wcfsetup/install/files/lib/data/contact/recipient/ContactRecipient.class.php
@@ -2,33 +2,41 @@
namespace wcf\data\contact\recipient;
-use wcf\data\DatabaseObject;
+use wcf\data\CollectionDatabaseObject;
use wcf\data\ITitledObject;
use wcf\system\email\Mailbox;
-use wcf\system\WCF;
+use wcf\system\l10n\L10nDefinition;
+use wcf\system\l10n\L10nStorage;
/**
* Represents a contact recipient.
*
+ * The localized name and email address are stored in the
+ * `wcf1_contact_recipient_l10n` table.
+ *
* @author Alexander Ebert
* @copyright 2001-2019 WoltLab GmbH
* @license GNU Lesser General Public License
*
* @property-read int $recipientID unique id of the recipient
- * @property-read string $name name of the recipient
- * @property-read string $email email address of the recipient
* @property-read int $showOrder position of the recipient in relation to other recipients
- * @property-read 0|1 $isAdministrator is `1` if the recipient is the administrator and the `email` value equals `MAIL_ADMIN_ADDRESS`, otherwise `0`
+ * @property-read 0|1 $isAdministrator is `1` if the recipient is the administrator and the email address equals `MAIL_ADMIN_ADDRESS`, otherwise `0`
* @property-read 0|1 $isDisabled is `1` if the recipient is disabled and thus is not available for selection, otherwise `0`
* @property-read 0|1 $originIsSystem is `1` if the recipient has been delivered by a package, otherwise `0` (i.e. the recipient has been created in the ACP)
+ * @property-read ?string $l10nIdentifier name of the language variable the localized name is derived from, `null` for recipients created by an administrator or renamed monolingually before 6.3
+ *
+ * @extends CollectionDatabaseObject
+ *
+ * @phpstan-import-type L10nValue from L10nStorage
*/
-class ContactRecipient extends DatabaseObject implements ITitledObject, \Stringable
+class ContactRecipient extends CollectionDatabaseObject implements ITitledObject, \Stringable
{
#[\Override]
protected function handleData(array $data)
{
- // dynamically set email address for the administrator
- if (!empty($data['isAdministrator'])) {
+ // `L10nContactRecipientList` reads the email address into the data,
+ // which is empty for the administrator.
+ if (($data['isAdministrator'] ?? 0) === 1) {
$data['email'] = \MAIL_ADMIN_ADDRESS;
}
@@ -41,14 +49,6 @@ public function __toString(): string
return $this->getName();
}
- public function __wakeup()
- {
- // update the administrator's email address on de-serialization, avoids outdated caches
- if (!empty($this->data['isAdministrator'])) {
- $this->data['isAdministrator'] = \MAIL_ADMIN_ADDRESS;
- }
- }
-
/**
* Returns the localized name of this recipient.
*
@@ -56,7 +56,7 @@ public function __wakeup()
*/
public function getName(): string
{
- return WCF::getLanguage()->get($this->name);
+ return $this->getCollection()->getResolvedL10nValue($this, 'name');
}
/**
@@ -66,7 +66,11 @@ public function getName(): string
*/
public function getEmail(): string
{
- return WCF::getLanguage()->get($this->email);
+ if ($this->isAdministrator === 1) {
+ return \MAIL_ADMIN_ADDRESS;
+ }
+
+ return $this->getCollection()->getResolvedL10nValue($this, 'email');
}
/**
@@ -87,4 +91,38 @@ public function getTitle(): string
{
return $this->getName();
}
+
+ /**
+ * 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 !== 'name' && $columnName !== 'email') {
+ throw new \InvalidArgumentException("Invalid column name given.");
+ }
+
+ return $this->getCollection()->getL10nValues($this, $columnName);
+ }
+
+ /**
+ * @since 6.3
+ */
+ public static function getL10nDefinition(): L10nDefinition
+ {
+ return new L10nDefinition(
+ 'wcf1_contact_recipient',
+ 'wcf1_contact_recipient_l10n',
+ 'recipientID',
+ ['name', 'email'],
+ 'l10nIdentifier',
+ // No language variable exists for the email address, the
+ // administrator's address is `MAIL_ADMIN_ADDRESS`.
+ ['name' => '', 'email' => '.email'],
+ ['name' => 255, 'email' => 255],
+ );
+ }
}
diff --git a/wcfsetup/install/files/lib/data/contact/recipient/ContactRecipientAction.class.php b/wcfsetup/install/files/lib/data/contact/recipient/ContactRecipientAction.class.php
index 0cf39fbc006..7873bee8818 100644
--- a/wcfsetup/install/files/lib/data/contact/recipient/ContactRecipientAction.class.php
+++ b/wcfsetup/install/files/lib/data/contact/recipient/ContactRecipientAction.class.php
@@ -2,16 +2,21 @@
namespace wcf\data\contact\recipient;
+use wcf\command\contact\recipient\DeleteContactRecipient;
use wcf\command\contact\recipient\DisableContactRecipient;
use wcf\command\contact\recipient\EnableContactRecipient;
use wcf\data\AbstractDatabaseObjectAction;
use wcf\data\IToggleAction;
-use wcf\data\TI18nDatabaseObjectAction;
use wcf\system\exception\PermissionDeniedException;
/**
* Executes contact recipient related actions.
*
+ * Contact recipients should be created, updated and deleted through the
+ * `CreateContactRecipient`, `UpdateContactRecipient` and
+ * `DeleteContactRecipient` commands, the `create`, `update` and `delete`
+ * actions are `@deprecated 6.3`.
+ *
* @author Olaf Braun, Alexander Ebert
* @copyright 2001-2025 WoltLab GmbH
* @license GNU Lesser General Public License
@@ -20,8 +25,6 @@
*/
class ContactRecipientAction extends AbstractDatabaseObjectAction implements IToggleAction
{
- use TI18nDatabaseObjectAction;
-
/**
* @inheritDoc
*/
@@ -68,66 +71,21 @@ public function validateToggle()
parent::validateUpdate();
}
- #[\Override]
- public function create()
- {
- // Database columns do not have default values
- if (!isset($this->parameters['data']['name'])) {
- $this->parameters['data']['name'] = '';
- }
- if (!isset($this->parameters['data']['email'])) {
- $this->parameters['data']['email'] = '';
- }
-
- $contactRecipient = parent::create();
-
- $this->saveI18nValue($contactRecipient);
-
- return $contactRecipient;
- }
-
- #[\Override]
- public function delete()
- {
- $count = parent::delete();
-
- $this->deleteI18nValues();
-
- return $count;
- }
-
- #[\Override]
- public function update()
- {
- parent::update();
-
- foreach ($this->getObjects() as $contactRecipient) {
- $this->saveI18nValue($contactRecipient->getDecoratedObject());
- }
- }
-
/**
- * @return array
+ * @deprecated 6.3 use the `DeleteContactRecipient` command instead.
*/
#[\Override]
- public function getI18nSaveTypes(): array
+ public function delete()
{
- return [
- 'name' => 'wcf.contact.recipient.name\d+',
- 'email' => 'wcf.contact.recipient.email\d+',
- ];
- }
+ if ($this->objects === []) {
+ $this->readObjects();
+ }
- #[\Override]
- public function getLanguageCategory(): string
- {
- return 'wcf.contact';
- }
+ foreach ($this->objects as $object) {
+ new DeleteContactRecipient($object->getDecoratedObject())();
+ }
- #[\Override]
- public function getPackageID(): int
- {
- return 1;
+ return \count($this->objects);
}
/**
diff --git a/wcfsetup/install/files/lib/data/contact/recipient/ContactRecipientBuilder.class.php b/wcfsetup/install/files/lib/data/contact/recipient/ContactRecipientBuilder.class.php
new file mode 100644
index 00000000000..25048bc9f06
--- /dev/null
+++ b/wcfsetup/install/files/lib/data/contact/recipient/ContactRecipientBuilder.class.php
@@ -0,0 +1,150 @@
+
+ * @since 6.3
+ *
+ * @extends DatabaseObjectBuilder
+ *
+ * @phpstan-import-type L10nValue from L10nStorage
+ */
+final class ContactRecipientBuilder extends DatabaseObjectBuilder
+{
+ /**
+ * @var L10nValue
+ */
+ private array $name;
+
+ /**
+ * @var L10nValue
+ */
+ private array $email;
+
+ /**
+ * @param L10nValue $name
+ */
+ public function setName(array $name): static
+ {
+ $this->name = $name;
+
+ return $this;
+ }
+
+ /**
+ * @param L10nValue $email
+ */
+ public function setEmail(array $email): static
+ {
+ if ($this->isAdministratorRecipient()) {
+ throw new \BadMethodCallException("The email address of the administrator recipient cannot be changed.");
+ }
+
+ $this->email = $email;
+
+ return $this;
+ }
+
+ public function setL10nIdentifier(?string $l10nIdentifier): static
+ {
+ $this->properties['l10nIdentifier'] = $l10nIdentifier;
+
+ return $this;
+ }
+
+ public function setShowOrder(int $showOrder): static
+ {
+ $this->properties['showOrder'] = $showOrder;
+
+ return $this;
+ }
+
+ public function setIsDisabled(bool $isDisabled): static
+ {
+ $this->properties['isDisabled'] = $isDisabled ? 1 : 0;
+
+ return $this;
+ }
+
+ #[\Override]
+ protected function allowEmptyCreate(): bool
+ {
+ return true;
+ }
+
+ #[\Override]
+ protected function afterValidateCreate(): void
+ {
+ // A shipped recipient receives its values from the language variable
+ // through `SyncL10nLanguageItems`.
+ if (
+ !isset($this->name)
+ && !isset($this->email)
+ && ($this->properties['l10nIdentifier'] ?? null) !== null
+ ) {
+ return;
+ }
+
+ if (!isset($this->name)) {
+ throw new \BadMethodCallException("Missing value for 'name'.");
+ }
+ if (!isset($this->email)) {
+ throw new \BadMethodCallException("Missing value for 'email'.");
+ }
+ }
+
+ #[\Override]
+ protected function afterCreate(DatabaseObject $object): void
+ {
+ if (isset($this->name) || isset($this->email)) {
+ $this->saveL10nValues($object);
+ }
+ }
+
+ #[\Override]
+ protected function afterUpdate(DatabaseObject $object): void
+ {
+ if (isset($this->name) || isset($this->email)) {
+ $this->saveL10nValues($object);
+ }
+ }
+
+ private function isAdministratorRecipient(): bool
+ {
+ return $this->isUpdate() && $this->getObject()->isAdministrator === 1;
+ }
+
+ private function saveL10nValues(ContactRecipient $recipient): void
+ {
+ if ($this->isAdministratorRecipient()) {
+ // The administrator recipient has no email address of its own.
+ $this->email = \array_fill_keys(\array_keys($this->name), '');
+ }
+
+ // `L10nStorage::setValues()` replaces all rows of the recipient.
+ (new L10nStorage(ContactRecipient::getL10nDefinition()))->setValues(
+ $recipient->recipientID,
+ [
+ 'name' => $this->name ?? $this->getObject()->getL10nValues('name'),
+ 'email' => $this->email ?? $this->getObject()->getL10nValues('email'),
+ ]
+ );
+ }
+}
diff --git a/wcfsetup/install/files/lib/data/contact/recipient/ContactRecipientCollection.class.php b/wcfsetup/install/files/lib/data/contact/recipient/ContactRecipientCollection.class.php
new file mode 100644
index 00000000000..4dc052abf63
--- /dev/null
+++ b/wcfsetup/install/files/lib/data/contact/recipient/ContactRecipientCollection.class.php
@@ -0,0 +1,28 @@
+
+ * @since 6.3
+ *
+ * @extends DatabaseObjectCollection
+ */
+class ContactRecipientCollection extends DatabaseObjectCollection
+{
+ use TCollectionL10n;
+
+ #[\Override]
+ protected function getL10nDefinition(): L10nDefinition
+ {
+ return ContactRecipient::getL10nDefinition();
+ }
+}
diff --git a/wcfsetup/install/files/lib/data/contact/recipient/ContactRecipientEditor.class.php b/wcfsetup/install/files/lib/data/contact/recipient/ContactRecipientEditor.class.php
index 1318246f5e7..000e39a4c06 100644
--- a/wcfsetup/install/files/lib/data/contact/recipient/ContactRecipientEditor.class.php
+++ b/wcfsetup/install/files/lib/data/contact/recipient/ContactRecipientEditor.class.php
@@ -13,6 +13,7 @@
*
* @mixin ContactRecipient
* @extends DatabaseObjectEditor
+ * @deprecated 6.3 use `ContactRecipientBuilder` instead.
*/
class ContactRecipientEditor extends DatabaseObjectEditor
{
diff --git a/wcfsetup/install/files/lib/data/contact/recipient/L10nContactRecipientList.class.php b/wcfsetup/install/files/lib/data/contact/recipient/L10nContactRecipientList.class.php
new file mode 100644
index 00000000000..c7aad4237c7
--- /dev/null
+++ b/wcfsetup/install/files/lib/data/contact/recipient/L10nContactRecipientList.class.php
@@ -0,0 +1,29 @@
+
+ * @since 6.3
+ */
+class L10nContactRecipientList extends ContactRecipientList
+{
+ public function __construct()
+ {
+ parent::__construct();
+
+ $storage = new L10nStorage(ContactRecipient::getL10nDefinition());
+
+ $this->sqlSelects .= ($this->sqlSelects !== '' ? ', ' : '')
+ . $storage->getSubSelect('name', $this->getDatabaseTableAlias())
+ . ' AS name, '
+ . $storage->getSubSelect('email', $this->getDatabaseTableAlias())
+ . ' AS email';
+ }
+}
diff --git a/wcfsetup/install/files/lib/data/cronjob/Cronjob.class.php b/wcfsetup/install/files/lib/data/cronjob/Cronjob.class.php
index 7a80cf5aca9..d2e47e0be06 100644
--- a/wcfsetup/install/files/lib/data/cronjob/Cronjob.class.php
+++ b/wcfsetup/install/files/lib/data/cronjob/Cronjob.class.php
@@ -3,15 +3,18 @@
namespace wcf\data\cronjob;
use Cron\CronExpression;
-use wcf\data\DatabaseObject;
+use wcf\data\CollectionDatabaseObject;
use wcf\data\package\Package;
use wcf\data\package\PackageCache;
use wcf\data\TDatabaseObjectOptions;
-use wcf\system\WCF;
+use wcf\system\l10n\L10nDefinition;
+use wcf\system\l10n\L10nStorage;
/**
* Represents a cronjob.
*
+ * The localized description is stored in the `wcf1_cronjob_l10n` table.
+ *
* @author Alexander Ebert
* @copyright 2001-2019 WoltLab GmbH
* @license GNU Lesser General Public License
@@ -20,7 +23,6 @@
* @property-read string $className PHP class name implementing `wcf\system\cronjob\ICronjob`
* @property-read int $packageID id of the package which delivers the cronjob or the id of the active application during creation in the ACP
* @property-read string $cronjobName name and textual identifier of the cronjob
- * @property-read string $description description of the cronjob or name of language item which contains the description
* @property-read string $startMinute minutes in the hour at which the cronjob is executed, wildcard `*` (any minute) or a rule using wildcard `*`
* @property-read string $startHour hour in the day at which the cronjob is executed, wildcard `*` (any hour) or a rule using wildcard `*`
* @property-read string $startDom day of the month at which the cronjob is executed, wildcard `*` (any day) or a rule using wildcard `*`
@@ -35,8 +37,12 @@
* @property-read int $state current state of the cronjob (see `Cronjob::READY`, `Cronjob::PENDING`, `Cronjob::EXECUTING` and `Cronjob::MAX_FAIL_COUNT`)
* @property-read int $failCount number of times the cronjob execution failed consecutively
* @property-read ?string $options comma separated list of options of which at least one needs to be enabled for the cronjob to be executed
+ *
+ * @extends CollectionDatabaseObject
+ *
+ * @phpstan-import-type L10nValue from L10nStorage
*/
-class Cronjob extends DatabaseObject
+class Cronjob extends CollectionDatabaseObject
{
use TDatabaseObjectOptions;
@@ -137,7 +143,23 @@ public function canBeDisabled()
*/
public function getDescription()
{
- return WCF::getLanguage()->get($this->description);
+ return $this->getCollection()->getResolvedL10nValue($this, '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 !== 'description') {
+ throw new \InvalidArgumentException("Invalid column name given.");
+ }
+
+ return $this->getCollection()->getL10nValues($this, $columnName);
}
/**
@@ -147,4 +169,18 @@ public function getPackage(): Package
{
return PackageCache::getInstance()->getPackage($this->packageID);
}
+
+ /**
+ * @since 6.3
+ */
+ public static function getL10nDefinition(): L10nDefinition
+ {
+ return new L10nDefinition(
+ 'wcf1_cronjob',
+ 'wcf1_cronjob_l10n',
+ 'cronjobID',
+ ['description'],
+ maximumLengths: ['description' => 255],
+ );
+ }
}
diff --git a/wcfsetup/install/files/lib/data/cronjob/CronjobAction.class.php b/wcfsetup/install/files/lib/data/cronjob/CronjobAction.class.php
index 8a518e35ea5..3c3b2994deb 100644
--- a/wcfsetup/install/files/lib/data/cronjob/CronjobAction.class.php
+++ b/wcfsetup/install/files/lib/data/cronjob/CronjobAction.class.php
@@ -2,6 +2,7 @@
namespace wcf\data\cronjob;
+use wcf\command\cronjob\DeleteCronjob;
use wcf\command\cronjob\DisableCronjob;
use wcf\command\cronjob\EnableCronjob;
use wcf\data\AbstractDatabaseObjectAction;
@@ -17,6 +18,10 @@
/**
* Executes cronjob-related actions.
*
+ * Cronjobs should be created, updated and deleted through the
+ * `CreateCronjob`, `UpdateCronjob` and `DeleteCronjob` commands, the
+ * `create`, `update` and `delete` actions are `@deprecated 6.3`.
+ *
* @author Tim Duesterhus, Alexander Ebert
* @copyright 2001-2019 WoltLab GmbH
* @license GNU Lesser General Public License
@@ -94,6 +99,23 @@ public function validateToggle()
}
}
+ /**
+ * @deprecated 6.3 use the `DeleteCronjob` command instead.
+ */
+ #[\Override]
+ public function delete()
+ {
+ if ($this->objects === []) {
+ $this->readObjects();
+ }
+
+ foreach ($this->objects as $object) {
+ new DeleteCronjob($object->getDecoratedObject())();
+ }
+
+ return \count($this->objects);
+ }
+
/**
* Validates the 'execute' action.
*
diff --git a/wcfsetup/install/files/lib/data/cronjob/CronjobBuilder.class.php b/wcfsetup/install/files/lib/data/cronjob/CronjobBuilder.class.php
new file mode 100644
index 00000000000..2478fd99a3f
--- /dev/null
+++ b/wcfsetup/install/files/lib/data/cronjob/CronjobBuilder.class.php
@@ -0,0 +1,196 @@
+`.
+ *
+ * @author Alexander Ebert
+ * @copyright 2001-2026 WoltLab GmbH
+ * @license GNU Lesser General Public License
+ * @since 6.3
+ *
+ * @extends DatabaseObjectBuilder
+ *
+ * @phpstan-import-type L10nValue from L10nStorage
+ */
+final class CronjobBuilder extends DatabaseObjectBuilder
+{
+ /**
+ * @var L10nValue
+ */
+ private array $description;
+
+ private bool $generateCronjobName = false;
+
+ /**
+ * @param L10nValue $description
+ */
+ public function setDescription(array $description): static
+ {
+ $this->description = $description;
+
+ return $this;
+ }
+
+ public function setClassName(string $className): static
+ {
+ $this->properties['className'] = $className;
+
+ return $this;
+ }
+
+ public function setPackageID(int $packageID): static
+ {
+ $this->properties['packageID'] = $packageID;
+
+ return $this;
+ }
+
+ public function setCronjobName(string $cronjobName): static
+ {
+ $this->properties['cronjobName'] = $cronjobName;
+
+ return $this;
+ }
+
+ public function setStartMinute(string $startMinute): static
+ {
+ $this->properties['startMinute'] = $startMinute;
+
+ return $this;
+ }
+
+ public function setStartHour(string $startHour): static
+ {
+ $this->properties['startHour'] = $startHour;
+
+ return $this;
+ }
+
+ public function setStartDom(string $startDom): static
+ {
+ $this->properties['startDom'] = $startDom;
+
+ return $this;
+ }
+
+ public function setStartMonth(string $startMonth): static
+ {
+ $this->properties['startMonth'] = $startMonth;
+
+ return $this;
+ }
+
+ public function setStartDow(string $startDow): static
+ {
+ $this->properties['startDow'] = $startDow;
+
+ return $this;
+ }
+
+ public function setNextExec(int $nextExec): static
+ {
+ $this->properties['nextExec'] = $nextExec;
+
+ return $this;
+ }
+
+ public function setIsDisabled(bool $isDisabled): static
+ {
+ $this->properties['isDisabled'] = $isDisabled ? 1 : 0;
+
+ return $this;
+ }
+
+ public function setCanBeEdited(bool $canBeEdited): static
+ {
+ $this->properties['canBeEdited'] = $canBeEdited ? 1 : 0;
+
+ return $this;
+ }
+
+ public function setCanBeDisabled(bool $canBeDisabled): static
+ {
+ $this->properties['canBeDisabled'] = $canBeDisabled ? 1 : 0;
+
+ return $this;
+ }
+
+ /**
+ * @param string $options comma separated list of options, see `TDatabaseObjectOptions`
+ */
+ public function setOptions(string $options): static
+ {
+ $this->properties['options'] = $options;
+
+ return $this;
+ }
+
+ #[\Override]
+ protected function getRequiredProperties(): array
+ {
+ return ['className', 'packageID'];
+ }
+
+ #[\Override]
+ protected function afterValidateCreate(): void
+ {
+ if (!isset($this->description)) {
+ throw new \BadMethodCallException("Missing value for 'description'.");
+ }
+
+ if (!isset($this->properties['cronjobName'])) {
+ // The final name requires the cronjob's id, see `afterCreate()`. A
+ // unique placeholder avoids collisions on the unique key
+ // `(cronjobName, packageID)` between concurrent creations.
+ $this->properties['cronjobName'] = 'com.woltlab.wcf.cronjob.' . StringUtil::getRandomID();
+ $this->generateCronjobName = true;
+ }
+ }
+
+ #[\Override]
+ protected function afterCreate(DatabaseObject $object): void
+ {
+ if ($this->generateCronjobName) {
+ $sql = "UPDATE wcf1_cronjob
+ SET cronjobName = ?
+ WHERE cronjobID = ?";
+ $statement = WCF::getDB()->prepare($sql);
+ $statement->execute([
+ 'com.woltlab.wcf.cronjob' . $object->cronjobID,
+ $object->cronjobID,
+ ]);
+ }
+
+ $this->saveL10nValues($object);
+ }
+
+ #[\Override]
+ protected function afterUpdate(DatabaseObject $object): void
+ {
+ if (isset($this->description)) {
+ $this->saveL10nValues($object);
+ }
+ }
+
+ private function saveL10nValues(Cronjob $cronjob): void
+ {
+ (new L10nStorage(Cronjob::getL10nDefinition()))->setValues(
+ $cronjob->cronjobID,
+ [
+ 'description' => $this->description,
+ ]
+ );
+ }
+}
diff --git a/wcfsetup/install/files/lib/data/cronjob/CronjobCollection.class.php b/wcfsetup/install/files/lib/data/cronjob/CronjobCollection.class.php
new file mode 100644
index 00000000000..0c5f8ce2e56
--- /dev/null
+++ b/wcfsetup/install/files/lib/data/cronjob/CronjobCollection.class.php
@@ -0,0 +1,28 @@
+
+ * @since 6.3
+ *
+ * @extends DatabaseObjectCollection
+ */
+class CronjobCollection extends DatabaseObjectCollection
+{
+ use TCollectionL10n;
+
+ #[\Override]
+ protected function getL10nDefinition(): L10nDefinition
+ {
+ return Cronjob::getL10nDefinition();
+ }
+}
diff --git a/wcfsetup/install/files/lib/data/cronjob/CronjobEditor.class.php b/wcfsetup/install/files/lib/data/cronjob/CronjobEditor.class.php
index d8edfdc446c..e03b22fce7b 100644
--- a/wcfsetup/install/files/lib/data/cronjob/CronjobEditor.class.php
+++ b/wcfsetup/install/files/lib/data/cronjob/CronjobEditor.class.php
@@ -4,15 +4,13 @@
use wcf\data\DatabaseObjectEditor;
use wcf\data\IEditableCachedObject;
-use wcf\data\language\category\LanguageCategory;
-use wcf\data\language\LanguageList;
use wcf\system\cache\builder\CronjobCacheBuilder;
-use wcf\system\language\LanguageFactory;
-use wcf\system\WCF;
/**
* Provides functions to edit cronjobs.
*
+ * The localized description is written by `CronjobBuilder`.
+ *
* @author Alexander Ebert, Matthias Schmidt
* @copyright 2001-2020 WoltLab GmbH
* @license GNU Lesser General Public License
@@ -28,140 +26,9 @@ class CronjobEditor extends DatabaseObjectEditor implements IEditableCachedObjec
*/
protected static $baseClass = Cronjob::class;
- #[\Override]
- public static function create(array $parameters = [])
- {
- $descriptions = [];
- if (isset($parameters['description']) && \is_array($parameters['description'])) {
- if (\count($parameters['description']) > 1) {
- $descriptions = $parameters['description'];
- $parameters['description'] = '';
- } else {
- $parameters['description'] = \reset($parameters['description']);
- }
- }
-
- $cronjob = parent::create($parameters);
-
- // save cronjob description
- if ($descriptions !== []) {
- $cronjobEditor = new self($cronjob);
- $cronjobEditor->saveDescriptions($descriptions);
- }
-
- return $cronjob;
- }
-
- /**
- * Saves the descriptions of the cronjob in language items.
- *
- * @param string[] $descriptions
- * @return void
- */
- protected function saveDescriptions(array $descriptions)
- {
- // set default value
- if (isset($descriptions[''])) {
- $defaultValue = $descriptions[''];
- } elseif (isset($descriptions['en'])) {
- // fallback to English
- $defaultValue = $descriptions['en'];
- } elseif (isset($descriptions[WCF::getLanguage()->getFixedLanguageCode()])) {
- // fallback to the language of the current user
- $defaultValue = $descriptions[WCF::getLanguage()->getFixedLanguageCode()];
- } else {
- // fallback to first description
- $defaultValue = \reset($descriptions);
- }
-
- // fetch data directly from database during framework installation
- if (\PACKAGE_ID === 0) {
- $sql = "SELECT *
- FROM wcf1_language_category
- WHERE languageCategory = ?";
- $statement = WCF::getDB()->prepare($sql);
- $statement->execute(['wcf.acp.cronjob']);
- $languageCategory = $statement->fetchObject(LanguageCategory::class);
-
- $languages = new LanguageList();
- $languages->readObjects();
- } else {
- $languages = LanguageFactory::getInstance()->getLanguages();
- $languageCategory = LanguageFactory::getInstance()->getCategory('wcf.acp.cronjob');
- }
-
- // save new descriptions
- $sql = "INSERT INTO wcf1_language_item
- (languageID, languageItem, languageItemValue, languageCategoryID, packageID)
- VALUES (?, ?, ?, ?, ?)
- ON DUPLICATE KEY UPDATE languageItemValue = VALUES(languageItemValue),
- languageCategoryID = VALUES(languageCategoryID)";
- $statement = WCF::getDB()->prepare($sql);
-
- foreach ($languages as $language) {
- $value = $defaultValue;
- if (isset($descriptions[$language->languageCode])) {
- $value = $descriptions[$language->languageCode];
- }
-
- $statement->execute([
- $language->languageID,
- 'wcf.acp.cronjob.description.cronjob' . $this->cronjobID,
- $value,
- $languageCategory->languageCategoryID,
- $this->packageID,
- ]);
- }
-
- // update cronjob
- $this->update(['description' => 'wcf.acp.cronjob.description.cronjob' . $this->cronjobID]);
- }
-
- #[\Override]
- public function update(array $parameters = [])
- {
- $descriptions = [];
- if (isset($parameters['description']) && \is_array($parameters['description'])) {
- if (\count($parameters['description']) > 1) {
- $descriptions = $parameters['description'];
- $parameters['description'] = '';
- } else {
- $parameters['description'] = \reset($parameters['description']);
- }
- }
-
- parent::update($parameters);
-
- // save cronjob description
- if ($descriptions !== []) {
- $this->saveDescriptions($descriptions);
- }
- }
-
- #[\Override]
- public static function deleteAll(array $objectIDs = [])
- {
- // delete language items
- if ($objectIDs !== []) {
- $sql = "DELETE FROM wcf1_language_item
- WHERE languageItem = ?";
- $statement = WCF::getDB()->prepare($sql);
-
- WCF::getDB()->beginTransaction();
- foreach ($objectIDs as $cronjobID) {
- $statement->execute(['wcf.acp.cronjob.description.cronjob' . $cronjobID]);
- }
- WCF::getDB()->commitTransaction();
- }
-
- return parent::deleteAll($objectIDs);
- }
-
#[\Override]
public static function resetCache()
{
CronjobCacheBuilder::getInstance()->reset();
- LanguageFactory::getInstance()->clearCache();
- LanguageFactory::getInstance()->deleteLanguageCache();
}
}
diff --git a/wcfsetup/install/files/lib/data/cronjob/I18nCronjobList.class.php b/wcfsetup/install/files/lib/data/cronjob/I18nCronjobList.class.php
deleted file mode 100644
index 5c349c1e23c..00000000000
--- a/wcfsetup/install/files/lib/data/cronjob/I18nCronjobList.class.php
+++ /dev/null
@@ -1,28 +0,0 @@
-
- * @since 5.3
- *
- * @extends I18nDatabaseObjectList
- */
-class I18nCronjobList extends I18nDatabaseObjectList
-{
- /**
- * @inheritDoc
- */
- public $i18nFields = ['description' => 'descriptionI18n'];
-
- /**
- * @inheritDoc
- */
- public $className = Cronjob::class;
-}
diff --git a/wcfsetup/install/files/lib/data/cronjob/L10nCronjobList.class.php b/wcfsetup/install/files/lib/data/cronjob/L10nCronjobList.class.php
new file mode 100644
index 00000000000..43f67ff9acf
--- /dev/null
+++ b/wcfsetup/install/files/lib/data/cronjob/L10nCronjobList.class.php
@@ -0,0 +1,32 @@
+
+ * @since 6.3
+ */
+class L10nCronjobList extends CronjobList
+{
+ /**
+ * @inheritDoc
+ */
+ public $className = Cronjob::class;
+
+ public function __construct()
+ {
+ parent::__construct();
+
+ $storage = new L10nStorage(Cronjob::getL10nDefinition());
+
+ $this->sqlSelects .= ($this->sqlSelects !== '' ? ', ' : '')
+ . $storage->getSubSelect('description', $this->getDatabaseTableAlias())
+ . ' AS description';
+ }
+}
diff --git a/wcfsetup/install/files/lib/data/label/group/I18nLabelGroupList.class.php b/wcfsetup/install/files/lib/data/label/group/I18nLabelGroupList.class.php
deleted file mode 100644
index 8274780d436..00000000000
--- a/wcfsetup/install/files/lib/data/label/group/I18nLabelGroupList.class.php
+++ /dev/null
@@ -1,28 +0,0 @@
-
- * @since 6.2
- *
- * @extends I18nDatabaseObjectList
- */
-class I18nLabelGroupList extends I18nDatabaseObjectList
-{
- /**
- * @inheritDoc
- */
- public $i18nFields = ['groupName' => 'groupNameI18n'];
-
- /**
- * @inheritDoc
- */
- public $className = LabelGroup::class;
-}
diff --git a/wcfsetup/install/files/lib/data/label/group/L10nLabelGroupList.class.php b/wcfsetup/install/files/lib/data/label/group/L10nLabelGroupList.class.php
new file mode 100644
index 00000000000..27d8da5b092
--- /dev/null
+++ b/wcfsetup/install/files/lib/data/label/group/L10nLabelGroupList.class.php
@@ -0,0 +1,27 @@
+
+ * @since 6.3
+ */
+class L10nLabelGroupList extends LabelGroupList
+{
+ public function __construct()
+ {
+ parent::__construct();
+
+ $storage = new L10nStorage(LabelGroup::getL10nDefinition());
+
+ $this->sqlSelects .= ($this->sqlSelects !== '' ? ', ' : '')
+ . $storage->getSubSelect('groupName', $this->getDatabaseTableAlias())
+ . ' AS groupName';
+ }
+}
diff --git a/wcfsetup/install/files/lib/data/label/group/LabelGroup.class.php b/wcfsetup/install/files/lib/data/label/group/LabelGroup.class.php
index 3dcc7f5a841..95cf78ee9c8 100644
--- a/wcfsetup/install/files/lib/data/label/group/LabelGroup.class.php
+++ b/wcfsetup/install/files/lib/data/label/group/LabelGroup.class.php
@@ -2,30 +2,53 @@
namespace wcf\data\label\group;
+use wcf\data\CollectionDatabaseObject;
use wcf\data\DatabaseObject;
+use wcf\system\l10n\L10nDefinition;
+use wcf\system\l10n\L10nStorage;
use wcf\system\request\IRouteController;
-use wcf\system\WCF;
/**
* Represents a label group.
*
+ * The localized title is stored in the `wcf1_label_group_l10n` table.
+ *
* @author Alexander Ebert
* @copyright 2001-2019 WoltLab GmbH
* @license GNU Lesser General Public License
*
* @property-read int $groupID unique id of the label group
- * @property-read string $groupName name of the label group or name of language item which contains the label text
* @property-read string $groupDescription description of the label group (only shown in ACP)
* @property-read 0|1 $forceSelection is `1` if a label in the label group has to be selected when creating an object for which the label group is available, otherwise `0`
* @property-read 0|1 $sortAlphabetically is `1` if labels in the label group are sorted alphabetically by their translated name, otherwise `0`
* @property-read int $showOrder position of the label group in relation to the other label groups
+ *
+ * @extends CollectionDatabaseObject
+ *
+ * @phpstan-import-type L10nValue from L10nStorage
*/
-class LabelGroup extends DatabaseObject implements IRouteController, \Stringable
+class LabelGroup extends CollectionDatabaseObject implements IRouteController, \Stringable
{
#[\Override]
public function getTitle(): string
{
- return WCF::getLanguage()->get($this->groupName);
+ return $this->getCollection()->getResolvedL10nValue($this, 'groupName');
+ }
+
+ /**
+ * 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 !== 'groupName') {
+ throw new \InvalidArgumentException("Invalid column name given.");
+ }
+
+ return $this->getCollection()->getL10nValues($this, $columnName);
}
/**
@@ -70,4 +93,18 @@ public static function sortLabelGroups(DatabaseObject $groupA, DatabaseObject $g
return ($groupA->showOrder > $groupB->showOrder) ? 1 : -1;
}
+
+ /**
+ * @since 6.3
+ */
+ public static function getL10nDefinition(): L10nDefinition
+ {
+ return new L10nDefinition(
+ 'wcf1_label_group',
+ 'wcf1_label_group_l10n',
+ 'groupID',
+ ['groupName'],
+ maximumLengths: ['groupName' => 80],
+ );
+ }
}
diff --git a/wcfsetup/install/files/lib/data/label/group/LabelGroupAction.class.php b/wcfsetup/install/files/lib/data/label/group/LabelGroupAction.class.php
index 316b211ae73..4f7281f3327 100644
--- a/wcfsetup/install/files/lib/data/label/group/LabelGroupAction.class.php
+++ b/wcfsetup/install/files/lib/data/label/group/LabelGroupAction.class.php
@@ -2,16 +2,16 @@
namespace wcf\data\label\group;
+use wcf\command\label\group\DeleteLabelGroup;
use wcf\data\AbstractDatabaseObjectAction;
-use wcf\data\label\LabelAction;
-use wcf\data\language\item\LanguageItemAction;
-use wcf\data\object\type\ObjectTypeCache;
-use wcf\system\database\util\PreparedStatementConditionBuilder;
-use wcf\system\WCF;
/**
* Executes label group-related actions.
*
+ * Label groups should be created, updated and deleted through the
+ * `CreateLabelGroup`, `UpdateLabelGroup` and `DeleteLabelGroup` commands, the
+ * `create`, `update` and `delete` actions are `@deprecated 6.3`.
+ *
* @author Alexander Ebert
* @copyright 2001-2019 WoltLab GmbH
* @license GNU Lesser General Public License
@@ -45,59 +45,20 @@ class LabelGroupAction extends AbstractDatabaseObjectAction
*/
protected $requireACP = ['create', 'delete', 'update'];
+ /**
+ * @deprecated 6.3 use the `DeleteLabelGroup` command instead.
+ */
#[\Override]
public function delete()
{
- // remove labels and their potential language variables
- if ($this->objectIDs !== []) {
- $conditions = new PreparedStatementConditionBuilder();
- $conditions->add('groupID IN (?)', [$this->objectIDs]);
-
- $sql = "SELECT labelID
- FROM wcf1_label
- " . $conditions;
- $statement = WCF::getDB()->prepare($sql);
- $statement->execute($conditions->getParameters());
- $labelIDs = $statement->fetchAll(\PDO::FETCH_COLUMN);
-
- if ($labelIDs !== []) {
- $objectAction = new LabelAction($labelIDs, 'delete');
- $objectAction->executeAction();
- }
- }
-
- $count = parent::delete();
-
- if ($this->objects !== []) {
- // identify i18n labels
- $languageVariables = [];
- foreach ($this->objects as $labelGroup) {
- if ($labelGroup->groupName === 'wcf.acp.label.group' . $labelGroup->groupID) {
- $languageVariables[] = $labelGroup->groupName;
- }
- }
-
- // remove language variables
- if ($languageVariables !== []) {
- $conditions = new PreparedStatementConditionBuilder();
- $conditions->add('languageItem IN (?)', [$languageVariables]);
-
- $sql = "SELECT languageItemID
- FROM wcf1_language_item
- " . $conditions;
- $statement = WCF::getDB()->prepare($sql);
- $statement->execute($conditions->getParameters());
- $languageItemIDs = $statement->fetchAll(\PDO::FETCH_COLUMN);
-
- $objectAction = new LanguageItemAction($languageItemIDs, 'delete');
- $objectAction->executeAction();
- }
+ if ($this->objects === []) {
+ $this->readObjects();
}
- foreach (ObjectTypeCache::getInstance()->getObjectTypes('com.woltlab.wcf.label.objectType') as $objectType) {
- $objectType->getProcessor()->save();
+ foreach ($this->objects as $object) {
+ new DeleteLabelGroup($object->getDecoratedObject())();
}
- return $count;
+ return \count($this->objects);
}
}
diff --git a/wcfsetup/install/files/lib/data/label/group/LabelGroupBuilder.class.php b/wcfsetup/install/files/lib/data/label/group/LabelGroupBuilder.class.php
new file mode 100644
index 00000000000..13024bb33ac
--- /dev/null
+++ b/wcfsetup/install/files/lib/data/label/group/LabelGroupBuilder.class.php
@@ -0,0 +1,135 @@
+
+ * @since 6.3
+ *
+ * @extends DatabaseObjectBuilder
+ *
+ * @phpstan-import-type L10nValue from L10nStorage
+ */
+final class LabelGroupBuilder extends DatabaseObjectBuilder
+{
+ /**
+ * @var L10nValue
+ */
+ private array $groupName;
+
+ /**
+ * @param L10nValue $groupName
+ */
+ public function setGroupName(array $groupName): static
+ {
+ $this->groupName = $groupName;
+
+ return $this;
+ }
+
+ public function setGroupDescription(string $groupDescription): static
+ {
+ $this->properties['groupDescription'] = $groupDescription;
+
+ return $this;
+ }
+
+ public function setForceSelection(bool $forceSelection): static
+ {
+ $this->properties['forceSelection'] = $forceSelection ? 1 : 0;
+
+ return $this;
+ }
+
+ public function setSortAlphabetically(bool $sortAlphabetically): static
+ {
+ $this->properties['sortAlphabetically'] = $sortAlphabetically ? 1 : 0;
+
+ return $this;
+ }
+
+ public function setShowOrder(int $showOrder): static
+ {
+ $this->properties['showOrder'] = $showOrder;
+
+ return $this;
+ }
+
+ #[\Override]
+ protected function allowEmptyCreate(): bool
+ {
+ return true;
+ }
+
+ #[\Override]
+ protected function afterValidateCreate(): void
+ {
+ if (!isset($this->groupName)) {
+ throw new \BadMethodCallException("Missing value for 'groupName'.");
+ }
+ }
+
+ #[\Override]
+ protected function afterCreate(DatabaseObject $object): void
+ {
+ $this->saveL10nValues($object);
+ }
+
+ #[\Override]
+ protected function afterUpdate(DatabaseObject $object): void
+ {
+ if (isset($this->groupName)) {
+ $this->saveL10nValues($object);
+ }
+ }
+
+ #[\Override]
+ protected static function beforeDeleteAll(array $objectIDs): void
+ {
+ // The labels would be removed by the foreign key, but their phrases
+ // would be left behind.
+ $conditions = new PreparedStatementConditionBuilder();
+ $conditions->add('groupID IN (?)', [$objectIDs]);
+
+ $sql = "SELECT labelID
+ FROM wcf1_label
+ " . $conditions;
+ $statement = WCF::getDB()->prepare($sql);
+ $statement->execute($conditions->getParameters());
+ $labelIDs = $statement->fetchAll(\PDO::FETCH_COLUMN);
+
+ if ($labelIDs !== []) {
+ (new LabelAction($labelIDs, 'delete'))->executeAction();
+ }
+
+ ACLHandler::getInstance()->removeValues(
+ ACLHandler::getInstance()->getObjectTypeID('com.woltlab.wcf.label'),
+ $objectIDs
+ );
+ }
+
+ private function saveL10nValues(LabelGroup $group): void
+ {
+ (new L10nStorage(LabelGroup::getL10nDefinition()))->setValues(
+ $group->groupID,
+ [
+ 'groupName' => $this->groupName,
+ ]
+ );
+ }
+}
diff --git a/wcfsetup/install/files/lib/data/label/group/LabelGroupCollection.class.php b/wcfsetup/install/files/lib/data/label/group/LabelGroupCollection.class.php
new file mode 100644
index 00000000000..432029cd871
--- /dev/null
+++ b/wcfsetup/install/files/lib/data/label/group/LabelGroupCollection.class.php
@@ -0,0 +1,28 @@
+
+ * @since 6.3
+ *
+ * @extends DatabaseObjectCollection
+ */
+class LabelGroupCollection extends DatabaseObjectCollection
+{
+ use TCollectionL10n;
+
+ #[\Override]
+ protected function getL10nDefinition(): L10nDefinition
+ {
+ return LabelGroup::getL10nDefinition();
+ }
+}
diff --git a/wcfsetup/install/files/lib/data/label/group/LabelGroupEditor.class.php b/wcfsetup/install/files/lib/data/label/group/LabelGroupEditor.class.php
index 957d47fea94..bcafc0f4c36 100644
--- a/wcfsetup/install/files/lib/data/label/group/LabelGroupEditor.class.php
+++ b/wcfsetup/install/files/lib/data/label/group/LabelGroupEditor.class.php
@@ -4,8 +4,8 @@
use wcf\data\DatabaseObjectEditor;
use wcf\data\IEditableCachedObject;
-use wcf\system\acl\ACLHandler;
use wcf\system\cache\builder\LabelCacheBuilder;
+use wcf\system\WCF;
/**
* Provides functions to edit label groups.
@@ -17,6 +17,7 @@
* @mixin LabelGroup
* @extends DatabaseObjectEditor
* @implements IEditableCachedObject
+ * @deprecated 6.3 use `LabelGroupBuilder` instead.
*/
class LabelGroupEditor extends DatabaseObjectEditor implements IEditableCachedObject
{
@@ -28,13 +29,25 @@ class LabelGroupEditor extends DatabaseObjectEditor implements IEditableCachedOb
#[\Override]
public static function deleteAll(array $objectIDs = [])
{
- $count = parent::deleteAll($objectIDs);
+ if ($objectIDs === []) {
+ return 0;
+ }
- // remove ACL values
- $objectTypeID = ACLHandler::getInstance()->getObjectTypeID('com.woltlab.wcf.label');
- ACLHandler::getInstance()->removeValues($objectTypeID, $objectIDs);
+ // See `DeleteLabelGroup` for the transaction.
+ WCF::getDB()->beginTransaction();
+ $committed = false;
+ try {
+ LabelGroupBuilder::deleteAll(\array_values($objectIDs));
- return $count;
+ WCF::getDB()->commitTransaction();
+ $committed = true;
+ } finally {
+ if (!$committed) {
+ WCF::getDB()->rollBackTransaction();
+ }
+ }
+
+ return \count($objectIDs);
}
#[\Override]
diff --git a/wcfsetup/install/files/lib/data/language/LanguageAction.class.php b/wcfsetup/install/files/lib/data/language/LanguageAction.class.php
index 8627816f1d8..4be9df190e4 100644
--- a/wcfsetup/install/files/lib/data/language/LanguageAction.class.php
+++ b/wcfsetup/install/files/lib/data/language/LanguageAction.class.php
@@ -2,6 +2,7 @@
namespace wcf\data\language;
+use wcf\command\l10n\SyncL10nLanguageItems;
use wcf\command\language\DisableLanguage;
use wcf\command\language\EnableLanguage;
use wcf\data\AbstractDatabaseObjectAction;
@@ -66,6 +67,9 @@ public function create()
LanguageFactory::getInstance()->clearCache();
LanguageFactory::getInstance()->deleteLanguageCache();
+
+ // The copied phrases provide the localized values of the new language.
+ (new SyncL10nLanguageItems())();
}
StyleHandler::resetStylesheets();
diff --git a/wcfsetup/install/files/lib/data/option/Option.class.php b/wcfsetup/install/files/lib/data/option/Option.class.php
index b770a1b0515..24b21ecb9de 100644
--- a/wcfsetup/install/files/lib/data/option/Option.class.php
+++ b/wcfsetup/install/files/lib/data/option/Option.class.php
@@ -2,7 +2,7 @@
namespace wcf\data\option;
-use wcf\data\DatabaseObject;
+use wcf\data\CollectionDatabaseObject;
use wcf\data\TDatabaseObjectOptions;
use wcf\data\TDatabaseObjectPermissions;
use wcf\system\WCF;
@@ -32,12 +32,20 @@
* @property-read 0|1 $supportI18n is `1` if the option supports different values for all available languages, otherwise `0`
* @property-read 0|1 $requireI18n is `1` if `$supportI18n = 1` and the option's value has to explicitly set for all values so that the `monolingual` option is not available, otherwise `0`
* @property-read mixed[] $additionalData array with additional data of the option
+ *
+ * @extends CollectionDatabaseObject
*/
-class Option extends DatabaseObject
+class Option extends CollectionDatabaseObject
{
use TDatabaseObjectOptions;
use TDatabaseObjectPermissions;
+ #[\Override]
+ public function getCollectionClassName(): string
+ {
+ return OptionCollection::class;
+ }
+
#[\Override]
public function __get(string $name)
{
diff --git a/wcfsetup/install/files/lib/data/option/OptionCollection.class.php b/wcfsetup/install/files/lib/data/option/OptionCollection.class.php
new file mode 100644
index 00000000000..433665ef7b4
--- /dev/null
+++ b/wcfsetup/install/files/lib/data/option/OptionCollection.class.php
@@ -0,0 +1,18 @@
+
+ * @since 6.3
+ *
+ * @extends DatabaseObjectCollection
+ */
+class OptionCollection extends DatabaseObjectCollection {}
diff --git a/wcfsetup/install/files/lib/data/paid/subscription/I18nPaidSubscriptionList.class.php b/wcfsetup/install/files/lib/data/paid/subscription/I18nPaidSubscriptionList.class.php
deleted file mode 100644
index 4fef56c84a5..00000000000
--- a/wcfsetup/install/files/lib/data/paid/subscription/I18nPaidSubscriptionList.class.php
+++ /dev/null
@@ -1,27 +0,0 @@
-
- *
- * @extends I18nDatabaseObjectList
- */
-class I18nPaidSubscriptionList extends I18nDatabaseObjectList
-{
- /**
- * @inheritDoc
- */
- public $i18nFields = ['title' => 'titleI18n'];
-
- /**
- * @inheritDoc
- */
- public $className = PaidSubscription::class;
-}
diff --git a/wcfsetup/install/files/lib/data/paid/subscription/L10nPaidSubscriptionList.class.php b/wcfsetup/install/files/lib/data/paid/subscription/L10nPaidSubscriptionList.class.php
new file mode 100644
index 00000000000..af0bffb5eff
--- /dev/null
+++ b/wcfsetup/install/files/lib/data/paid/subscription/L10nPaidSubscriptionList.class.php
@@ -0,0 +1,34 @@
+
+ * @since 6.3
+ */
+class L10nPaidSubscriptionList extends PaidSubscriptionList
+{
+ /**
+ * @inheritDoc
+ */
+ public $className = PaidSubscription::class;
+
+ public function __construct()
+ {
+ parent::__construct();
+
+ $storage = new L10nStorage(PaidSubscription::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/paid/subscription/PaidSubscription.class.php b/wcfsetup/install/files/lib/data/paid/subscription/PaidSubscription.class.php
index 1299f295fe9..01f8db52234 100644
--- a/wcfsetup/install/files/lib/data/paid/subscription/PaidSubscription.class.php
+++ b/wcfsetup/install/files/lib/data/paid/subscription/PaidSubscription.class.php
@@ -2,12 +2,14 @@
namespace wcf\data\paid\subscription;
-use wcf\data\DatabaseObject;
+use wcf\data\CollectionDatabaseObject;
use wcf\data\ITitledObject;
use wcf\data\object\type\ObjectTypeCache;
use wcf\data\paid\subscription\user\PaidSubscriptionUserList;
use wcf\page\PaidSubscriptionReturnPage;
use wcf\system\html\output\HtmlOutputProcessor;
+use wcf\system\l10n\L10nDefinition;
+use wcf\system\l10n\L10nStorage;
use wcf\system\payment\method\PaymentMethodHandler;
use wcf\system\request\LinkHandler;
use wcf\system\WCF;
@@ -16,13 +18,14 @@
/**
* Represents a paid subscription.
*
+ * The localized title and description are stored in the
+ * `wcf1_paid_subscription_l10n` table.
+ *
* @author Marcel Werk
* @copyright 2001-2019 WoltLab GmbH
* @license GNU Lesser General Public License
*
* @property-read int $subscriptionID unique id of the paid subscription
- * @property-read string $title title of the paid subscription or name of language item which contains the title
- * @property-read ?string $description description of the paid subscription or name of language item which contains the description
* @property-read 0|1 $isDisabled is `1` if the paid subscription is disabled and thus cannot be bought, otherwise `0`
* @property-read int $showOrder position of the paid subscription in relation to the other paid subscriptions
* @property-read string $cost cost of the paid subscription as a decimal string
@@ -32,8 +35,12 @@
* @property-read 0|1 $isRecurring is `1` if the paid subscription is recurring and thus requires regular (automatic) payments, otherwise `0`
* @property-read ?string $groupIDs comma-separated list with the ids of the user groups for which the subscription pays membership
* @property-read ?string $excludedSubscriptionIDs comma-separated list with the ids of paid subscriptions which prohibit purchase of this paid subscription
+ *
+ * @extends CollectionDatabaseObject
+ *
+ * @phpstan-import-type L10nValue from L10nStorage
*/
-class PaidSubscription extends DatabaseObject implements ITitledObject
+class PaidSubscription extends CollectionDatabaseObject implements ITitledObject
{
/**
* Returns list of purchase buttons.
@@ -61,7 +68,7 @@ public function getPurchaseButtons()
$buttons[] = $paymentMethod->getPurchaseButton(
(float)$this->cost,
$this->currency,
- WCF::getLanguage()->get($this->title),
+ $this->getTitle(),
$objectTypeID . ':' . WCF::getUser()->userID . ':' . $this->subscriptionID,
LinkHandler::getInstance()->getControllerLink(PaidSubscriptionReturnPage::class),
LinkHandler::getInstance()->getLink(),
@@ -103,17 +110,13 @@ public function getFormattedDescription()
}
/**
- * Returns the description with transparent handling of phrases.
+ * Returns the localized description.
*
* @return string
*/
protected function getDescription()
{
- if (\preg_match('~^wcf.paidSubscription.subscription\d+.description$~', $this->description)) {
- return WCF::getLanguage()->get($this->description);
- }
-
- return $this->description;
+ return $this->getCollection()->getResolvedL10nValue($this, 'description');
}
/**
@@ -122,7 +125,37 @@ protected function getDescription()
#[\Override]
public function getTitle(): string
{
- return WCF::getLanguage()->get($this->title);
+ return $this->getCollection()->getResolvedL10nValue($this, 'title');
+ }
+
+ /**
+ * 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 $this->getCollection()->getL10nValues($this, $columnName);
+ }
+
+ /**
+ * @since 6.3
+ */
+ public static function getL10nDefinition(): L10nDefinition
+ {
+ return new L10nDefinition(
+ 'wcf1_paid_subscription',
+ 'wcf1_paid_subscription_l10n',
+ 'subscriptionID',
+ ['title', 'description'],
+ maximumLengths: ['title' => 255],
+ );
}
/**
diff --git a/wcfsetup/install/files/lib/data/paid/subscription/PaidSubscriptionAction.class.php b/wcfsetup/install/files/lib/data/paid/subscription/PaidSubscriptionAction.class.php
index 9a0939fea44..8917b59c85e 100644
--- a/wcfsetup/install/files/lib/data/paid/subscription/PaidSubscriptionAction.class.php
+++ b/wcfsetup/install/files/lib/data/paid/subscription/PaidSubscriptionAction.class.php
@@ -2,6 +2,7 @@
namespace wcf\data\paid\subscription;
+use wcf\command\paid\subscription\DeletePaidSubscription;
use wcf\command\paid\subscription\DisablePaidSubscription;
use wcf\command\paid\subscription\EnablePaidSubscription;
use wcf\data\AbstractDatabaseObjectAction;
@@ -10,6 +11,11 @@
/**
* Executes paid subscription-related actions.
*
+ * Paid subscriptions should be created, updated and deleted through the
+ * `CreatePaidSubscription`, `UpdatePaidSubscription` and
+ * `DeletePaidSubscription` commands, the `create`, `update` and `delete`
+ * actions are `@deprecated 6.3`.
+ *
* @author Marcel Werk
* @copyright 2001-2019 WoltLab GmbH
* @license GNU Lesser General Public License
@@ -33,35 +39,21 @@ class PaidSubscriptionAction extends AbstractDatabaseObjectAction implements ITo
*/
protected $requireACP = ['create', 'delete', 'toggle', 'update'];
+ /**
+ * @deprecated 6.3 use the `DeletePaidSubscription` command instead.
+ */
#[\Override]
- public function create()
+ public function delete()
{
- $showOrder = 0;
- if (isset($this->parameters['data']['showOrder'])) {
- $showOrder = $this->parameters['data']['showOrder'];
- unset($this->parameters['data']['showOrder']);
+ if ($this->objects === []) {
+ $this->readObjects();
}
- /** @var PaidSubscription $subscription */
- $subscription = parent::create();
- $editor = new PaidSubscriptionEditor($subscription);
- $editor->setShowOrder($showOrder);
-
- return new PaidSubscription($subscription->subscriptionID);
- }
-
- #[\Override]
- public function update()
- {
- parent::update();
-
- 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']);
+ foreach ($this->objects as $object) {
+ new DeletePaidSubscription($object->getDecoratedObject())();
}
+
+ return \count($this->objects);
}
/**
diff --git a/wcfsetup/install/files/lib/data/paid/subscription/PaidSubscriptionBuilder.class.php b/wcfsetup/install/files/lib/data/paid/subscription/PaidSubscriptionBuilder.class.php
new file mode 100644
index 00000000000..3ee909aeb62
--- /dev/null
+++ b/wcfsetup/install/files/lib/data/paid/subscription/PaidSubscriptionBuilder.class.php
@@ -0,0 +1,244 @@
+
+ * @since 6.3
+ *
+ * @extends DatabaseObjectBuilder
+ *
+ * @phpstan-import-type L10nValue from L10nStorage
+ */
+final class PaidSubscriptionBuilder extends DatabaseObjectBuilder
+{
+ /**
+ * @var L10nValue
+ */
+ private array $title;
+
+ /**
+ * @var L10nValue
+ */
+ private array $description;
+
+ /**
+ * @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 setIsDisabled(bool $isDisabled): static
+ {
+ $this->properties['isDisabled'] = $isDisabled ? 1 : 0;
+
+ return $this;
+ }
+
+ /**
+ * Sets the position relative to the other subscriptions, starting at `1`.
+ * `0` or a position after the last subscription appends it.
+ */
+ public function setShowOrder(int $showOrder): static
+ {
+ $this->properties['showOrder'] = $showOrder;
+
+ return $this;
+ }
+
+ public function setPrice(float $cost, string $currency): static
+ {
+ $this->properties['cost'] = $cost;
+ $this->properties['currency'] = $currency;
+
+ return $this;
+ }
+
+ /**
+ * Marks the subscription as permanent, i.e. without an expiration.
+ */
+ public function setPermanent(): static
+ {
+ $this->properties['subscriptionLength'] = 0;
+ $this->properties['subscriptionLengthUnit'] = '';
+ $this->properties['isRecurring'] = 0;
+
+ return $this;
+ }
+
+ /**
+ * Sets the duration, `$unit` is one of `D` (days), `M` (months) or `Y` (years).
+ */
+ public function setLength(int $length, string $unit, bool $isRecurring): static
+ {
+ if ($length < 1) {
+ throw new \InvalidArgumentException("The length must be at least 1, use setPermanent() instead.");
+ }
+ if (!\in_array($unit, ['D', 'M', 'Y'], true)) {
+ throw new \InvalidArgumentException("The unit must be one of 'D', 'M' or 'Y'.");
+ }
+
+ $this->properties['subscriptionLength'] = $length;
+ $this->properties['subscriptionLengthUnit'] = $unit;
+ $this->properties['isRecurring'] = $isRecurring ? 1 : 0;
+
+ return $this;
+ }
+
+ /**
+ * @param list $groupIDs
+ */
+ public function setGroupIDs(array $groupIDs): static
+ {
+ $this->properties['groupIDs'] = \implode(',', $groupIDs);
+
+ return $this;
+ }
+
+ /**
+ * @param list $excludedSubscriptionIDs
+ */
+ public function setExcludedSubscriptionIDs(array $excludedSubscriptionIDs): static
+ {
+ $this->properties['excludedSubscriptionIDs'] = \implode(',', $excludedSubscriptionIDs);
+
+ return $this;
+ }
+
+ #[\Override]
+ protected function allowEmptyCreate(): bool
+ {
+ return true;
+ }
+
+ #[\Override]
+ protected function afterValidateCreate(): void
+ {
+ if (!isset($this->title)) {
+ throw new \BadMethodCallException("Missing value for 'title'.");
+ }
+ }
+
+ #[\Override]
+ protected function afterCreate(DatabaseObject $object): void
+ {
+ $showOrder = $this->properties['showOrder'] ?? 0;
+ $maxShowOrder = $this->getMaxShowOrder($object->subscriptionID);
+ if ($showOrder === 0 || $showOrder > $maxShowOrder) {
+ $this->updateShowOrder($object->subscriptionID, $maxShowOrder + 1);
+ } else {
+ $this->shiftShowOrder($object->subscriptionID, $showOrder, 1);
+ }
+
+ $this->saveL10nValues($object);
+ }
+
+ #[\Override]
+ protected function afterUpdate(DatabaseObject $object): void
+ {
+ $oldObject = $this->getObject();
+
+ if (isset($this->properties['showOrder']) && $this->properties['showOrder'] !== $oldObject->showOrder) {
+ // Closes the gap at the previous position before making room at
+ // the new one, the position is relative to the other subscriptions.
+ $this->shiftShowOrder($object->subscriptionID, $oldObject->showOrder + 1, -1);
+
+ $showOrder = $this->properties['showOrder'];
+ $maxShowOrder = $this->getMaxShowOrder($object->subscriptionID);
+ if ($showOrder === 0 || $showOrder > $maxShowOrder) {
+ $this->updateShowOrder($object->subscriptionID, $maxShowOrder + 1);
+ } else {
+ $this->shiftShowOrder($object->subscriptionID, $showOrder, 1);
+ }
+ }
+
+ if (isset($this->title) || isset($this->description)) {
+ $this->saveL10nValues($object);
+ }
+ }
+
+ private function getMaxShowOrder(int $excludedSubscriptionID): int
+ {
+ $sql = "SELECT COALESCE(MAX(showOrder), 0)
+ FROM wcf1_paid_subscription
+ WHERE subscriptionID <> ?";
+ $statement = WCF::getDB()->prepare($sql);
+ $statement->execute([$excludedSubscriptionID]);
+
+ return (int)$statement->fetchSingleColumn();
+ }
+
+ private function updateShowOrder(int $subscriptionID, int $showOrder): void
+ {
+ $sql = "UPDATE wcf1_paid_subscription
+ SET showOrder = ?
+ WHERE subscriptionID = ?";
+ $statement = WCF::getDB()->prepare($sql);
+ $statement->execute([
+ $showOrder,
+ $subscriptionID,
+ ]);
+ }
+
+ /**
+ * Moves the other subscriptions at or after the given position by `$offset`.
+ */
+ private function shiftShowOrder(int $excludedSubscriptionID, int $fromShowOrder, int $offset): void
+ {
+ $sql = "UPDATE wcf1_paid_subscription
+ SET showOrder = showOrder + ?
+ WHERE showOrder >= ?
+ AND subscriptionID <> ?";
+ $statement = WCF::getDB()->prepare($sql);
+ $statement->execute([
+ $offset,
+ $fromShowOrder,
+ $excludedSubscriptionID,
+ ]);
+ }
+
+ private function saveL10nValues(PaidSubscription $subscription): void
+ {
+ $description = $this->description ?? null;
+ if ($description === null) {
+ $description = $this->isUpdate()
+ ? $this->getObject()->getL10nValues('description')
+ : [L10nStorage::MONOLINGUAL => ''];
+ }
+
+ // `L10nStorage::setValues()` replaces all rows of the subscription.
+ (new L10nStorage(PaidSubscription::getL10nDefinition()))->setValues(
+ $subscription->subscriptionID,
+ [
+ 'title' => $this->title ?? $this->getObject()->getL10nValues('title'),
+ 'description' => $description,
+ ]
+ );
+ }
+}
diff --git a/wcfsetup/install/files/lib/data/paid/subscription/PaidSubscriptionCollection.class.php b/wcfsetup/install/files/lib/data/paid/subscription/PaidSubscriptionCollection.class.php
new file mode 100644
index 00000000000..ffecc42f64a
--- /dev/null
+++ b/wcfsetup/install/files/lib/data/paid/subscription/PaidSubscriptionCollection.class.php
@@ -0,0 +1,28 @@
+
+ * @since 6.3
+ *
+ * @extends DatabaseObjectCollection
+ */
+class PaidSubscriptionCollection extends DatabaseObjectCollection
+{
+ use TCollectionL10n;
+
+ #[\Override]
+ protected function getL10nDefinition(): L10nDefinition
+ {
+ return PaidSubscription::getL10nDefinition();
+ }
+}
diff --git a/wcfsetup/install/files/lib/data/paid/subscription/PaidSubscriptionEditor.class.php b/wcfsetup/install/files/lib/data/paid/subscription/PaidSubscriptionEditor.class.php
index 978ad5b8be1..061199f7ab5 100644
--- a/wcfsetup/install/files/lib/data/paid/subscription/PaidSubscriptionEditor.class.php
+++ b/wcfsetup/install/files/lib/data/paid/subscription/PaidSubscriptionEditor.class.php
@@ -17,6 +17,7 @@
* @mixin PaidSubscription
* @extends DatabaseObjectEditor
* @implements IEditableCachedObject
+ * @deprecated 6.3 use `PaidSubscriptionBuilder` instead.
*/
class PaidSubscriptionEditor extends DatabaseObjectEditor implements IEditableCachedObject
{
@@ -29,6 +30,7 @@ class PaidSubscriptionEditor extends DatabaseObjectEditor implements IEditableCa
* Sets the show order of the subscription.
*
* @return void
+ * @deprecated 6.3 use `PaidSubscriptionBuilder::setShowOrder()` instead.
*/
public function setShowOrder(int $showOrder = 0)
{
diff --git a/wcfsetup/install/files/lib/data/reaction/type/I18nReactionTypeList.class.php b/wcfsetup/install/files/lib/data/reaction/type/I18nReactionTypeList.class.php
deleted file mode 100644
index 365133e0a9e..00000000000
--- a/wcfsetup/install/files/lib/data/reaction/type/I18nReactionTypeList.class.php
+++ /dev/null
@@ -1,28 +0,0 @@
-
- * @since 6.2
- *
- * @extends I18nDatabaseObjectList
- */
-class I18nReactionTypeList extends I18nDatabaseObjectList
-{
- /**
- * @inheritDoc
- */
- public $i18nFields = ["title" => "titleI18n"];
-
- /**
- * @inheritDoc
- */
- public $className = ReactionType::class;
-}
diff --git a/wcfsetup/install/files/lib/data/reaction/type/L10nReactionTypeList.class.php b/wcfsetup/install/files/lib/data/reaction/type/L10nReactionTypeList.class.php
new file mode 100644
index 00000000000..61c69dba5c6
--- /dev/null
+++ b/wcfsetup/install/files/lib/data/reaction/type/L10nReactionTypeList.class.php
@@ -0,0 +1,32 @@
+
+ * @since 6.3
+ */
+class L10nReactionTypeList extends ReactionTypeList
+{
+ /**
+ * @inheritDoc
+ */
+ public $className = ReactionType::class;
+
+ public function __construct()
+ {
+ parent::__construct();
+
+ $storage = new L10nStorage(ReactionType::getL10nDefinition());
+
+ $this->sqlSelects .= ($this->sqlSelects !== '' ? ', ' : '')
+ . $storage->getSubSelect('title', $this->getDatabaseTableAlias())
+ . ' AS title';
+ }
+}
diff --git a/wcfsetup/install/files/lib/data/reaction/type/ReactionType.class.php b/wcfsetup/install/files/lib/data/reaction/type/ReactionType.class.php
index e988828def2..98a2d8dd772 100644
--- a/wcfsetup/install/files/lib/data/reaction/type/ReactionType.class.php
+++ b/wcfsetup/install/files/lib/data/reaction/type/ReactionType.class.php
@@ -2,27 +2,35 @@
namespace wcf\data\reaction\type;
-use wcf\data\DatabaseObject;
+use wcf\data\CollectionDatabaseObject;
use wcf\data\file\File;
use wcf\data\ITitledObject;
use wcf\system\cache\runtime\FileRuntimeCache;
+use wcf\system\l10n\L10nDefinition;
+use wcf\system\l10n\L10nStorage;
use wcf\system\WCF;
/**
* Represents a reaction type.
*
+ * The localized title is stored in the `wcf1_reaction_type_l10n` table.
+ *
* @author Joshua Ruesweg
* @copyright 2001-2019 WoltLab GmbH
* @license GNU Lesser General Public License
* @since 5.2
*
* @property-read int $reactionTypeID unique id of the reaction type
- * @property-read string $title
* @property-read int $showOrder position of the reaction type in relation to the other reaction types
* @property-read ?int $iconFileID id of the file of the icon or `null` if the file has been deleted
* @property-read 0|1 $isAssignable `1`, if the reaction can be assigned, otherwise `0`
+ * @property-read ?string $l10nIdentifier name of the language variable the localized title is derived from, `null` for reaction types created by an administrator
+ *
+ * @extends CollectionDatabaseObject
+ *
+ * @phpstan-import-type L10nValue from L10nStorage
*/
-class ReactionType extends DatabaseObject implements ITitledObject
+class ReactionType extends CollectionDatabaseObject implements ITitledObject
{
/**
* @inheritDoc
@@ -40,7 +48,23 @@ class ReactionType extends DatabaseObject implements ITitledObject
#[\Override]
public function getTitle(): string
{
- return WCF::getLanguage()->get($this->title);
+ return $this->getCollection()->getResolvedL10nValue($this, 'title');
+ }
+
+ /**
+ * 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') {
+ throw new \InvalidArgumentException("Invalid column name given.");
+ }
+
+ return $this->getCollection()->getL10nValues($this, $columnName);
}
/**
@@ -105,4 +129,20 @@ public function setIconFile(File $file): void
$this->iconFile = $file;
}
+
+ /**
+ * @since 6.3
+ */
+ public static function getL10nDefinition(): L10nDefinition
+ {
+ return new L10nDefinition(
+ 'wcf1_reaction_type',
+ 'wcf1_reaction_type_l10n',
+ 'reactionTypeID',
+ ['title'],
+ 'l10nIdentifier',
+ ['title' => ''],
+ ['title' => 255],
+ );
+ }
}
diff --git a/wcfsetup/install/files/lib/data/reaction/type/ReactionTypeAction.class.php b/wcfsetup/install/files/lib/data/reaction/type/ReactionTypeAction.class.php
index 6e743b0faa1..df921f95bac 100644
--- a/wcfsetup/install/files/lib/data/reaction/type/ReactionTypeAction.class.php
+++ b/wcfsetup/install/files/lib/data/reaction/type/ReactionTypeAction.class.php
@@ -8,12 +8,15 @@
use wcf\data\AbstractDatabaseObjectAction;
use wcf\data\file\FileList;
use wcf\data\IToggleAction;
-use wcf\system\language\I18nHandler;
use wcf\system\WCF;
/**
* ReactionType related actions.
*
+ * Reaction types should be created and updated through the
+ * `CreateReactionType` and `UpdateReactionType` commands, the `create` and
+ * `update` actions are `@deprecated 6.3`.
+ *
* @author Joshua Ruesweg
* @copyright 2001-2019 WoltLab GmbH
* @license GNU Lesser General Public License
@@ -38,109 +41,6 @@ class ReactionTypeAction extends AbstractDatabaseObjectAction implements IToggle
*/
protected $requireACP = ['delete', 'update'];
- #[\Override]
- public function create()
- {
- if (isset($this->parameters['data']['showOrder'])) {
- $sql = "UPDATE wcf1_reaction_type
- SET showOrder = showOrder + 1
- WHERE showOrder >= ?";
- $statement = WCF::getDB()->prepare($sql);
- $statement->execute([
- $this->parameters['data']['showOrder'],
- ]);
- }
-
- // The title cannot be empty by design, but cannot be filled proper if the
- // multilingualism is enabled, therefore, we must fill the tilte with a dummy value.
- if (!isset($this->parameters['data']['title']) && isset($this->parameters['title_i18n'])) {
- $this->parameters['data']['title'] = 'wcf.reactionType.title';
- }
-
- /** @var ReactionType $reactionType */
- $reactionType = parent::create();
- $reactionTypeEditor = new ReactionTypeEditor($reactionType);
-
- // i18n
- if (isset($this->parameters['title_i18n'])) {
- I18nHandler::getInstance()->save(
- $this->parameters['title_i18n'],
- 'wcf.reactionType.title' . $reactionType->reactionTypeID,
- 'wcf.reactionType',
- 1
- );
-
- $reactionTypeEditor->update([
- 'title' => 'wcf.reactionType.title' . $reactionType->reactionTypeID,
- ]);
- }
-
- return $reactionType;
- }
-
- #[\Override]
- public function update()
- {
- $replacedFileIDs = [];
- if (\array_key_exists('iconFileID', $this->parameters['data'] ?? [])) {
- if ($this->objects === []) {
- $this->readObjects();
- }
-
- foreach ($this->objects as $object) {
- $fileID = $object->iconFileID;
- if ($fileID !== null && $fileID !== $this->parameters['data']['iconFileID']) {
- $replacedFileIDs[] = $fileID;
- }
- }
- }
-
- parent::update();
-
- foreach ($this->getObjects() as $object) {
- $updateData = [];
-
- // i18n
- if (isset($this->parameters['title_i18n'])) {
- I18nHandler::getInstance()->save(
- $this->parameters['title_i18n'],
- 'wcf.reactionType.title' . $object->reactionTypeID,
- 'wcf.reactionType',
- 1
- );
-
- $updateData['title'] = 'wcf.reactionType.title' . $object->reactionTypeID;
- }
-
- // update show order
- if (isset($this->parameters['data']['showOrder'])) {
- $sql = "UPDATE wcf1_reaction_type
- SET showOrder = showOrder + 1
- WHERE showOrder >= ?
- AND reactionTypeID <> ?";
- $statement = WCF::getDB()->prepare($sql);
- $statement->execute([
- $this->parameters['data']['showOrder'],
- $object->reactionTypeID,
- ]);
-
- $sql = "UPDATE wcf1_reaction_type
- SET showOrder = showOrder - 1
- WHERE showOrder > ?";
- $statement = WCF::getDB()->prepare($sql);
- $statement->execute([
- $object->showOrder,
- ]);
- }
-
- if ($updateData !== []) {
- $object->update($updateData);
- }
- }
-
- $this->deleteFiles($replacedFileIDs);
- }
-
#[\Override]
public function delete()
{
diff --git a/wcfsetup/install/files/lib/data/reaction/type/ReactionTypeBuilder.class.php b/wcfsetup/install/files/lib/data/reaction/type/ReactionTypeBuilder.class.php
new file mode 100644
index 00000000000..e5d12498f7e
--- /dev/null
+++ b/wcfsetup/install/files/lib/data/reaction/type/ReactionTypeBuilder.class.php
@@ -0,0 +1,163 @@
+
+ * @since 6.3
+ *
+ * @extends DatabaseObjectBuilder
+ *
+ * @phpstan-import-type L10nValue from L10nStorage
+ */
+final class ReactionTypeBuilder extends DatabaseObjectBuilder
+{
+ /**
+ * @var L10nValue
+ */
+ private array $title;
+
+ /**
+ * @param L10nValue $title
+ */
+ public function setTitle(array $title): static
+ {
+ $this->title = $title;
+
+ return $this;
+ }
+
+ public function setL10nIdentifier(?string $l10nIdentifier): static
+ {
+ $this->properties['l10nIdentifier'] = $l10nIdentifier;
+
+ return $this;
+ }
+
+ public function setShowOrder(int $showOrder): static
+ {
+ $this->properties['showOrder'] = $showOrder;
+
+ return $this;
+ }
+
+ public function setIconFileID(?int $iconFileID): static
+ {
+ $this->properties['iconFileID'] = $iconFileID;
+
+ return $this;
+ }
+
+ public function setIsAssignable(bool $isAssignable): static
+ {
+ $this->properties['isAssignable'] = $isAssignable ? 1 : 0;
+
+ return $this;
+ }
+
+ #[\Override]
+ protected function afterValidateCreate(): void
+ {
+ // Shipped reaction types receive their title from the language
+ // variable through `SyncL10nLanguageItems`.
+ if (!isset($this->title) && ($this->properties['l10nIdentifier'] ?? null) === null) {
+ throw new \BadMethodCallException("Missing value for 'title'.");
+ }
+ }
+
+ #[\Override]
+ protected function afterCreate(DatabaseObject $object): void
+ {
+ if (isset($this->properties['showOrder'])) {
+ $sql = "UPDATE wcf1_reaction_type
+ SET showOrder = showOrder + 1
+ WHERE showOrder >= ?
+ AND reactionTypeID <> ?";
+ $statement = WCF::getDB()->prepare($sql);
+ $statement->execute([
+ $object->showOrder,
+ $object->reactionTypeID,
+ ]);
+ }
+
+ if (isset($this->title)) {
+ $this->saveL10nValues($object);
+ }
+ }
+
+ #[\Override]
+ protected function afterUpdate(DatabaseObject $object): void
+ {
+ $oldObject = $this->getObject();
+
+ if (isset($this->properties['showOrder'])) {
+ // Closes the gap at the previous position before making room at
+ // the new one, the position is relative to the other reaction types.
+ $sql = "UPDATE wcf1_reaction_type
+ SET showOrder = showOrder - 1
+ WHERE showOrder > ?
+ AND reactionTypeID <> ?";
+ $statement = WCF::getDB()->prepare($sql);
+ $statement->execute([
+ $oldObject->showOrder,
+ $object->reactionTypeID,
+ ]);
+
+ $sql = "UPDATE wcf1_reaction_type
+ SET showOrder = showOrder + 1
+ WHERE showOrder >= ?
+ AND reactionTypeID <> ?";
+ $statement = WCF::getDB()->prepare($sql);
+ $statement->execute([
+ $object->showOrder,
+ $object->reactionTypeID,
+ ]);
+ }
+
+ if (isset($this->title)) {
+ $this->saveL10nValues($object);
+ }
+
+ // A reaction type owns its icon file, a replaced file is orphaned.
+ if (
+ \array_key_exists('iconFileID', $this->properties)
+ && $oldObject->iconFileID !== null
+ && $oldObject->iconFileID !== $object->iconFileID
+ ) {
+ $fileList = new FileList();
+ $fileList->setObjectIDs([$oldObject->iconFileID]);
+ $fileList->readObjects();
+ $files = \array_values($fileList->getObjects());
+
+ if ($files !== []) {
+ new DeleteFiles($files)();
+ }
+ }
+ }
+
+ private function saveL10nValues(ReactionType $reactionType): void
+ {
+ (new L10nStorage(ReactionType::getL10nDefinition()))->setValues(
+ $reactionType->reactionTypeID,
+ [
+ 'title' => $this->title,
+ ]
+ );
+ }
+}
diff --git a/wcfsetup/install/files/lib/data/reaction/type/ReactionTypeCollection.class.php b/wcfsetup/install/files/lib/data/reaction/type/ReactionTypeCollection.class.php
new file mode 100644
index 00000000000..44ca88f15ab
--- /dev/null
+++ b/wcfsetup/install/files/lib/data/reaction/type/ReactionTypeCollection.class.php
@@ -0,0 +1,28 @@
+
+ * @since 6.3
+ *
+ * @extends DatabaseObjectCollection
+ */
+class ReactionTypeCollection extends DatabaseObjectCollection
+{
+ use TCollectionL10n;
+
+ #[\Override]
+ protected function getL10nDefinition(): L10nDefinition
+ {
+ return ReactionType::getL10nDefinition();
+ }
+}
diff --git a/wcfsetup/install/files/lib/data/user/UserProfile.class.php b/wcfsetup/install/files/lib/data/user/UserProfile.class.php
index ca78172f826..142e68e7919 100644
--- a/wcfsetup/install/files/lib/data/user/UserProfile.class.php
+++ b/wcfsetup/install/files/lib/data/user/UserProfile.class.php
@@ -936,7 +936,7 @@ public function getUserTitle()
return $this->userTitle;
}
if ($this->getRank() !== null && $this->getRank()->showTitle()) {
- return WCF::getLanguage()->get($this->getRank()->rankTitle);
+ return $this->getRank()->getTitle();
}
return '';
@@ -1118,6 +1118,16 @@ public function getFormattedUserOption(string $name)
return $option->optionValue;
}
+ /**
+ * Returns the localized title of the user option with the given name.
+ *
+ * @since 6.3
+ */
+ public function getUserOptionTitle(string $name): string
+ {
+ return ViewableUserOption::getUserOption($name)->getTitle();
+ }
+
/**
* Returns true, if the active user has access to the user option with the given name.
*
diff --git a/wcfsetup/install/files/lib/data/user/option/L10nUserOptionList.class.php b/wcfsetup/install/files/lib/data/user/option/L10nUserOptionList.class.php
new file mode 100644
index 00000000000..6b41f4ee49c
--- /dev/null
+++ b/wcfsetup/install/files/lib/data/user/option/L10nUserOptionList.class.php
@@ -0,0 +1,27 @@
+
+ * @since 6.3
+ */
+class L10nUserOptionList extends UserOptionList
+{
+ public function __construct()
+ {
+ parent::__construct();
+
+ $storage = new L10nStorage(UserOption::getL10nDefinition());
+
+ $this->sqlSelects .= (!empty($this->sqlSelects) ? ', ' : '')
+ . $storage->getSubSelect('title', $this->getDatabaseTableAlias())
+ . ' AS title';
+ }
+}
diff --git a/wcfsetup/install/files/lib/data/user/option/UserOption.class.php b/wcfsetup/install/files/lib/data/user/option/UserOption.class.php
index 413c5cb4edb..c27d363806a 100644
--- a/wcfsetup/install/files/lib/data/user/option/UserOption.class.php
+++ b/wcfsetup/install/files/lib/data/user/option/UserOption.class.php
@@ -5,6 +5,8 @@
use wcf\data\ITitledObject;
use wcf\data\option\Option;
use wcf\data\user\User;
+use wcf\system\l10n\L10nDefinition;
+use wcf\system\l10n\L10nStorage;
use wcf\system\WCF;
/**
@@ -14,7 +16,7 @@
* @copyright 2001-2019 WoltLab GmbH
* @license GNU Lesser General Public License
*
- * @property-read string $defaultValue default value of the user option
+ * @property-read ?string $defaultValue default value of the user option
* @property-read 0|1 $required is `1` if the user option has to be filled out, otherwise `0`
* @property-read 0|1 $askDuringRegistration is `1` if the user option will be shown during registration to be filled out, otherwise `0`
* @property-read int $editable setting for who can edit the user option, see `UserOption::EDITABILITY_*` constants
@@ -23,8 +25,11 @@
* @property-read 0|1 $searchable is `1` if the user option can be searched, otherwise `0`
* @property-read 0|1 $isDisabled is `1` if the user option is disabled and thus neither shown nor editable, otherwise `0`
* @property-read 0|1 $originIsSystem is `1` if the user option was created by the system and not manually by an administrator, otherwise `0`
- * @property-read string $labeledUrl the url, if the option type is `labeledUrl`
+ * @property-read ?string $labeledUrl the url, if the option type is `labeledUrl`
* @property-read 0|1 $showOnUserCard is `1` if the user option should be displayed on the user card, otherwise `0`
+ * @property-read ?string $l10nIdentifier base name of the language variable the localized title/description are derived from, `null` for options created by an administrator
+ *
+ * @phpstan-import-type L10nValue from L10nStorage
*/
class UserOption extends Option implements ITitledObject
{
@@ -117,10 +122,73 @@ class UserOption extends Option implements ITitledObject
*/
public $user;
+ #[\Override]
+ public function getCollectionClassName(): string
+ {
+ return UserOptionCollection::class;
+ }
+
+ #[\Override]
+ public function getCollection(): UserOptionCollection
+ {
+ $collection = parent::getCollection();
+ if (!($collection instanceof UserOptionCollection)) {
+ throw new \RuntimeException('Expected a UserOptionCollection.');
+ }
+
+ return $collection;
+ }
+
#[\Override]
public function getTitle(): string
{
- return WCF::getLanguage()->get('wcf.user.option.' . $this->optionName);
+ return $this->getCollection()->getResolvedL10nValue($this, 'title');
+ }
+
+ /**
+ * Returns the localized description of this option in the active user's
+ * language.
+ *
+ * @since 6.3
+ */
+ public function getDescription(): string
+ {
+ return $this->getCollection()->getResolvedL10nValue($this, '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 $this->getCollection()->getL10nValues($this, $columnName);
+ }
+
+ /**
+ * @since 6.3
+ */
+ public static function getL10nDefinition(): L10nDefinition
+ {
+ return new L10nDefinition(
+ 'wcf1_user_option',
+ 'wcf1_user_option_l10n',
+ 'optionID',
+ ['title', 'description'],
+ 'l10nIdentifier',
+ [
+ 'title' => '',
+ 'description' => '.description',
+ ],
+ ['title' => 255],
+ );
}
/**
diff --git a/wcfsetup/install/files/lib/data/user/option/UserOptionBuilder.class.php b/wcfsetup/install/files/lib/data/user/option/UserOptionBuilder.class.php
new file mode 100644
index 00000000000..4baf1a987fa
--- /dev/null
+++ b/wcfsetup/install/files/lib/data/user/option/UserOptionBuilder.class.php
@@ -0,0 +1,328 @@
+
+ * @since 6.3
+ *
+ * @extends DatabaseObjectBuilder
+ *
+ * @phpstan-import-type L10nValue from L10nStorage
+ */
+final class UserOptionBuilder extends DatabaseObjectBuilder
+{
+ private bool $isGenericOptionName = false;
+
+ /**
+ * @var L10nValue
+ */
+ private array $l10nTitle;
+
+ /**
+ * @var L10nValue
+ */
+ private array $l10nDescription;
+
+ /**
+ * @param L10nValue $title
+ */
+ public function setL10nTitle(array $title): static
+ {
+ $this->l10nTitle = $title;
+
+ return $this;
+ }
+
+ /**
+ * @param L10nValue $description
+ */
+ public function setL10nDescription(array $description): static
+ {
+ $this->l10nDescription = $description;
+
+ return $this;
+ }
+
+ public function setL10nIdentifier(?string $l10nIdentifier): static
+ {
+ $this->properties['l10nIdentifier'] = $l10nIdentifier;
+
+ return $this;
+ }
+
+ public function setOptionName(string $optionName): static
+ {
+ if ($this->isUpdate() && !\str_starts_with($this->getObject()->optionName, 'tmp_')) {
+ throw new \BadMethodCallException('setOptionName() is only allowed for generic option names.');
+ }
+
+ $this->properties['optionName'] = $optionName;
+
+ return $this;
+ }
+
+ /**
+ * Inserts the option with a temporary random name and renames it to
+ * `option` once the id is known (see `afterCreate()`).
+ */
+ public function setGenericOptionName(): static
+ {
+ if ($this->isUpdate()) {
+ throw new \BadMethodCallException('setGenericOptionName() can only be used with forCreate().');
+ }
+
+ $this->properties['optionName'] = 'tmp_' . StringUtil::getRandomID();
+ $this->isGenericOptionName = true;
+
+ return $this;
+ }
+
+ public function setPackageID(int $packageID): static
+ {
+ $this->properties['packageID'] = $packageID;
+
+ return $this;
+ }
+
+ public function setCategoryName(string $categoryName): static
+ {
+ $this->properties['categoryName'] = $categoryName;
+
+ return $this;
+ }
+
+ public function setOptionType(string $optionType): static
+ {
+ $this->properties['optionType'] = $optionType;
+
+ return $this;
+ }
+
+ public function setDefaultValue(string|int|float|null $defaultValue): static
+ {
+ $this->properties['defaultValue'] = $defaultValue;
+
+ return $this;
+ }
+
+ public function setValidationPattern(string $validationPattern): static
+ {
+ $this->properties['validationPattern'] = $validationPattern;
+
+ return $this;
+ }
+
+ public function setSelectOptions(string $selectOptions): static
+ {
+ $this->properties['selectOptions'] = $selectOptions;
+
+ return $this;
+ }
+
+ public function setEnableOptions(string $enableOptions): static
+ {
+ $this->properties['enableOptions'] = $enableOptions;
+
+ return $this;
+ }
+
+ public function setLabeledUrl(string $labeledUrl): static
+ {
+ $this->properties['labeledUrl'] = $labeledUrl;
+
+ return $this;
+ }
+
+ public function setShowOrder(int $showOrder): static
+ {
+ $this->properties['showOrder'] = $showOrder;
+
+ return $this;
+ }
+
+ public function setIsDisabled(bool $isDisabled): static
+ {
+ $this->properties['isDisabled'] = $isDisabled ? 1 : 0;
+
+ return $this;
+ }
+
+ public function setEditable(int $editable): static
+ {
+ $this->properties['editable'] = $editable;
+
+ return $this;
+ }
+
+ public function setVisible(int $visible): static
+ {
+ $this->properties['visible'] = $visible;
+
+ return $this;
+ }
+
+ public function setRequired(bool $required): static
+ {
+ $this->properties['required'] = $required ? 1 : 0;
+
+ return $this;
+ }
+
+ public function setAskDuringRegistration(bool $askDuringRegistration): static
+ {
+ $this->properties['askDuringRegistration'] = $askDuringRegistration ? 1 : 0;
+
+ return $this;
+ }
+
+ public function setSearchable(bool $searchable): static
+ {
+ $this->properties['searchable'] = $searchable ? 1 : 0;
+
+ return $this;
+ }
+
+ public function setShowOnUserCard(bool $showOnUserCard): static
+ {
+ $this->properties['showOnUserCard'] = $showOnUserCard ? 1 : 0;
+
+ return $this;
+ }
+
+ public function setOutputClass(string $outputClass): static
+ {
+ $this->properties['outputClass'] = $outputClass;
+
+ return $this;
+ }
+
+ public function setPermissions(string $permissions): static
+ {
+ $this->properties['permissions'] = $permissions;
+
+ return $this;
+ }
+
+ public function setOptions(string $options): static
+ {
+ $this->properties['options'] = $options;
+
+ return $this;
+ }
+
+ public function setOriginIsSystem(bool $originIsSystem): static
+ {
+ $this->properties['originIsSystem'] = $originIsSystem ? 1 : 0;
+
+ return $this;
+ }
+
+ /**
+ * @param array $additionalData
+ */
+ public function setAdditionalData(array $additionalData): static
+ {
+ $this->properties['additionalData'] = \serialize($additionalData);
+
+ return $this;
+ }
+
+ #[\Override]
+ protected function getRequiredProperties(): array
+ {
+ return ['optionName', 'optionType', 'categoryName'];
+ }
+
+ #[\Override]
+ protected function afterCreate(DatabaseObject $object): void
+ {
+ // add the dynamic value column for this option
+ WCF::getDB()->getEditor()->addColumn(
+ 'wcf1_user_option_value',
+ 'userOption' . $object->optionID,
+ UserOptionEditor::getColumnDefinition($object->optionType)
+ );
+
+ // apply the default value to all existing rows
+ if ($object->defaultValue !== null) {
+ $sql = "UPDATE wcf1_user_option_value
+ SET userOption" . $object->optionID . " = ?";
+ $statement = WCF::getDB()->prepare($sql);
+ $statement->execute([$object->defaultValue]);
+ }
+
+ // assign the generic option name now that the id is known
+ if ($this->isGenericOptionName) {
+ UserOptionBuilder::forUpdate($object)
+ ->setOptionName('option' . $object->optionID)
+ ->update();
+ }
+
+ $this->saveL10nValues($object);
+ }
+
+ #[\Override]
+ protected function afterUpdate(DatabaseObject $object): void
+ {
+ // re-type the value column if the option type changed
+ if ($object->optionType !== $this->getObject()->optionType) {
+ WCF::getDB()->getEditor()->alterColumn(
+ 'wcf1_user_option_value',
+ 'userOption' . $object->optionID,
+ 'userOption' . $object->optionID,
+ UserOptionEditor::getColumnDefinition($object->optionType)
+ );
+ }
+
+ $this->saveL10nValues($object);
+ }
+
+ /**
+ * Writes the localized title and description into the `*_l10n` table when
+ * they have been set. Both must be provided together because
+ * `L10nStorage::setValues()` replaces all rows of the object.
+ */
+ private function saveL10nValues(UserOption $object): void
+ {
+ if (!isset($this->l10nTitle) && !isset($this->l10nDescription)) {
+ return;
+ }
+ if (!isset($this->l10nTitle) || !isset($this->l10nDescription)) {
+ throw new \BadMethodCallException(
+ "The localized title and description of a user option must be set together."
+ );
+ }
+
+ (new L10nStorage(UserOption::getL10nDefinition()))->setValues(
+ $object->optionID,
+ [
+ 'title' => $this->l10nTitle,
+ 'description' => $this->l10nDescription,
+ ],
+ );
+ }
+
+ #[\Override]
+ protected static function beforeDeleteAll(array $objectIDs): void
+ {
+ foreach ($objectIDs as $objectID) {
+ WCF::getDB()->getEditor()->dropColumn('wcf1_user_option_value', 'userOption' . $objectID);
+ }
+ }
+}
diff --git a/wcfsetup/install/files/lib/data/user/option/UserOptionCollection.class.php b/wcfsetup/install/files/lib/data/user/option/UserOptionCollection.class.php
new file mode 100644
index 00000000000..896ca139304
--- /dev/null
+++ b/wcfsetup/install/files/lib/data/user/option/UserOptionCollection.class.php
@@ -0,0 +1,27 @@
+
+ * @since 6.3
+ */
+class UserOptionCollection extends OptionCollection
+{
+ use TCollectionL10n;
+
+ #[\Override]
+ protected function getL10nDefinition(): L10nDefinition
+ {
+ return UserOption::getL10nDefinition();
+ }
+}
diff --git a/wcfsetup/install/files/lib/data/user/rank/I18nUserRankList.class.php b/wcfsetup/install/files/lib/data/user/rank/I18nUserRankList.class.php
deleted file mode 100644
index 0b8be07a888..00000000000
--- a/wcfsetup/install/files/lib/data/user/rank/I18nUserRankList.class.php
+++ /dev/null
@@ -1,28 +0,0 @@
-
- * @since 6.0
- *
- * @extends I18nDatabaseObjectList
- */
-class I18nUserRankList extends I18nDatabaseObjectList
-{
- /**
- * @inheritDoc
- */
- public $i18nFields = ['rankTitle' => 'rankTitleI18n'];
-
- /**
- * @inheritDoc
- */
- public $className = UserRank::class;
-}
diff --git a/wcfsetup/install/files/lib/data/user/rank/L10nUserRankList.class.php b/wcfsetup/install/files/lib/data/user/rank/L10nUserRankList.class.php
new file mode 100644
index 00000000000..7caa544d22e
--- /dev/null
+++ b/wcfsetup/install/files/lib/data/user/rank/L10nUserRankList.class.php
@@ -0,0 +1,32 @@
+
+ * @since 6.3
+ */
+class L10nUserRankList extends UserRankList
+{
+ /**
+ * @inheritDoc
+ */
+ public $className = UserRank::class;
+
+ public function __construct()
+ {
+ parent::__construct();
+
+ $storage = new L10nStorage(UserRank::getL10nDefinition());
+
+ $this->sqlSelects .= ($this->sqlSelects !== '' ? ', ' : '')
+ . $storage->getSubSelect('rankTitle', $this->getDatabaseTableAlias())
+ . ' AS rankTitle';
+ }
+}
diff --git a/wcfsetup/install/files/lib/data/user/rank/UserRank.class.php b/wcfsetup/install/files/lib/data/user/rank/UserRank.class.php
index 95625aeaaac..32c21cb65be 100644
--- a/wcfsetup/install/files/lib/data/user/rank/UserRank.class.php
+++ b/wcfsetup/install/files/lib/data/user/rank/UserRank.class.php
@@ -2,16 +2,19 @@
namespace wcf\data\user\rank;
-use wcf\data\DatabaseObject;
+use wcf\data\CollectionDatabaseObject;
use wcf\data\file\File;
use wcf\data\ITitledObject;
use wcf\system\cache\runtime\FileRuntimeCache;
-use wcf\system\WCF;
+use wcf\system\l10n\L10nDefinition;
+use wcf\system\l10n\L10nStorage;
use wcf\util\StringUtil;
/**
* Represents a user rank.
*
+ * The localized title is stored in the `wcf1_user_rank_l10n` table.
+ *
* @author Marcel Werk
* @copyright 2001-2019 WoltLab GmbH
* @license GNU Lesser General Public License
@@ -19,15 +22,19 @@
* @property-read int $rankID unique id of the user rank
* @property-read int $groupID id of the user group to which the user rank belongs
* @property-read int $requiredPoints minimum number of user activity points required for a user to get the user rank
- * @property-read string $rankTitle title of the user rank or name of the language item which contains the rank
* @property-read string $cssClassName css class name used when displaying the user rank
* @property-read ?int $rankImageFileID id of the file of the image displayed next to the rank or `null` if no rank image exists
* @property-read string $rankImage non-empty if a rank image exists, the value itself has no meaning (deprecated since 6.3, use `rankImageFileID` instead)
* @property-read int $repeatImage number of times the rank image is displayed
* @property-read int $requiredGender numeric representation of the user's gender required for the user rank (see `UserProfile::GENDER_*` constants) or 0 if no specific gender is required
* @property-read 0|1 $hideTitle hides the generic title of the rank, but not custom titles, `0` to show the title at all times
+ * @property-read ?string $l10nIdentifier name of the language variable the localized title is derived from, `null` for user ranks created by an administrator
+ *
+ * @extends CollectionDatabaseObject
+ *
+ * @phpstan-import-type L10nValue from L10nStorage
*/
-class UserRank extends DatabaseObject implements ITitledObject
+class UserRank extends CollectionDatabaseObject implements ITitledObject
{
/**
* Directory of the legacy rank images, only used to import them.
@@ -97,7 +104,23 @@ public function setImageFile(File $file): void
#[\Override]
public function getTitle(): string
{
- return WCF::getLanguage()->get($this->rankTitle);
+ return $this->getCollection()->getResolvedL10nValue($this, 'rankTitle');
+ }
+
+ /**
+ * 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 !== 'rankTitle') {
+ throw new \InvalidArgumentException("Invalid column name given.");
+ }
+
+ return $this->getCollection()->getL10nValues($this, $columnName);
}
/**
@@ -109,4 +132,20 @@ public function showTitle()
{
return $this->rankImageFileID === null || $this->hideTitle === 0;
}
+
+ /**
+ * @since 6.3
+ */
+ public static function getL10nDefinition(): L10nDefinition
+ {
+ return new L10nDefinition(
+ 'wcf1_user_rank',
+ 'wcf1_user_rank_l10n',
+ 'rankID',
+ ['rankTitle'],
+ 'l10nIdentifier',
+ ['rankTitle' => ''],
+ ['rankTitle' => 255],
+ );
+ }
}
diff --git a/wcfsetup/install/files/lib/data/user/rank/UserRankAction.class.php b/wcfsetup/install/files/lib/data/user/rank/UserRankAction.class.php
index 2500fdec841..21ce638f556 100644
--- a/wcfsetup/install/files/lib/data/user/rank/UserRankAction.class.php
+++ b/wcfsetup/install/files/lib/data/user/rank/UserRankAction.class.php
@@ -2,14 +2,16 @@
namespace wcf\data\user\rank;
-use wcf\command\file\DeleteFiles;
+use wcf\command\user\rank\DeleteUserRank;
use wcf\data\AbstractDatabaseObjectAction;
-use wcf\data\file\FileList;
-use wcf\data\TI18nDatabaseObjectAction;
/**
* Executes user rank-related actions.
*
+ * User ranks should be created, updated and deleted through the
+ * `CreateUserRank`, `UpdateUserRank` and `DeleteUserRank` commands, the
+ * `create`, `update` and `delete` actions are `@deprecated 6.3`.
+ *
* @author Marcel Werk
* @copyright 2001-2019 WoltLab GmbH
* @license GNU Lesser General Public License
@@ -18,8 +20,6 @@
*/
class UserRankAction extends AbstractDatabaseObjectAction
{
- use TI18nDatabaseObjectAction;
-
/**
* @inheritDoc
*/
@@ -30,43 +30,9 @@ class UserRankAction extends AbstractDatabaseObjectAction
*/
protected $requireACP = ['delete'];
- #[\Override]
- public function create()
- {
- /** @var UserRank $rank */
- $rank = parent::create();
-
- $this->saveI18nValue($rank);
-
- return $rank;
- }
-
- #[\Override]
- public function update()
- {
- $replacedFileIDs = [];
- if (\array_key_exists('rankImageFileID', $this->parameters['data'] ?? [])) {
- if ($this->objects === []) {
- $this->readObjects();
- }
-
- foreach ($this->objects as $object) {
- $fileID = $object->rankImageFileID;
- if ($fileID !== null && $fileID !== $this->parameters['data']['rankImageFileID']) {
- $replacedFileIDs[] = $fileID;
- }
- }
- }
-
- parent::update();
-
- foreach ($this->objects as $object) {
- $this->saveI18nValue($object->getDecoratedObject());
- }
-
- $this->deleteFiles($replacedFileIDs);
- }
-
+ /**
+ * @deprecated 6.3 use the `DeleteUserRank` command instead.
+ */
#[\Override]
public function delete()
{
@@ -74,58 +40,10 @@ public function delete()
$this->readObjects();
}
- $fileIDs = [];
foreach ($this->objects as $object) {
- if ($object->rankImageFileID !== null) {
- $fileIDs[] = $object->rankImageFileID;
- }
- }
-
- $count = parent::delete();
-
- $this->deleteI18nValues();
- $this->deleteFiles($fileIDs);
-
- return $count;
- }
-
- /**
- * @param list $fileIDs
- */
- private function deleteFiles(array $fileIDs): void
- {
- if ($fileIDs === []) {
- return;
- }
-
- $fileList = new FileList();
- $fileList->setObjectIDs($fileIDs);
- $fileList->readObjects();
- $files = \array_values($fileList->getObjects());
-
- if ($files !== []) {
- new DeleteFiles($files)();
+ new DeleteUserRank($object->getDecoratedObject())();
}
- }
- /**
- * @return array
- */
- #[\Override]
- public function getI18nSaveTypes(): array
- {
- return ['rankTitle' => 'wcf.user.rank.userRank\d+'];
- }
-
- #[\Override]
- public function getLanguageCategory(): string
- {
- return 'wcf.user.rank';
- }
-
- #[\Override]
- public function getPackageID(): int
- {
- return \PACKAGE_ID;
+ return \count($this->objects);
}
}
diff --git a/wcfsetup/install/files/lib/data/user/rank/UserRankBuilder.class.php b/wcfsetup/install/files/lib/data/user/rank/UserRankBuilder.class.php
new file mode 100644
index 00000000000..cb54d9fec8e
--- /dev/null
+++ b/wcfsetup/install/files/lib/data/user/rank/UserRankBuilder.class.php
@@ -0,0 +1,158 @@
+
+ * @since 6.3
+ *
+ * @extends DatabaseObjectBuilder
+ *
+ * @phpstan-import-type L10nValue from L10nStorage
+ */
+final class UserRankBuilder extends DatabaseObjectBuilder
+{
+ /**
+ * @var L10nValue
+ */
+ private array $rankTitle;
+
+ /**
+ * @param L10nValue $rankTitle
+ */
+ public function setRankTitle(array $rankTitle): static
+ {
+ $this->rankTitle = $rankTitle;
+
+ return $this;
+ }
+
+ public function setL10nIdentifier(?string $l10nIdentifier): static
+ {
+ $this->properties['l10nIdentifier'] = $l10nIdentifier;
+
+ return $this;
+ }
+
+ public function setGroupID(int $groupID): static
+ {
+ $this->properties['groupID'] = $groupID;
+
+ return $this;
+ }
+
+ public function setRequiredPoints(int $requiredPoints): static
+ {
+ $this->properties['requiredPoints'] = $requiredPoints;
+
+ return $this;
+ }
+
+ /**
+ * @param int $requiredGender see `UserProfile::GENDER_*`, `0` if no specific gender is required
+ */
+ public function setRequiredGender(int $requiredGender): static
+ {
+ $this->properties['requiredGender'] = $requiredGender;
+
+ return $this;
+ }
+
+ public function setCssClassName(string $cssClassName): static
+ {
+ $this->properties['cssClassName'] = $cssClassName;
+
+ return $this;
+ }
+
+ public function setRankImageFileID(?int $rankImageFileID): static
+ {
+ $this->properties['rankImageFileID'] = $rankImageFileID;
+
+ return $this;
+ }
+
+ public function setRepeatImage(int $repeatImage): static
+ {
+ $this->properties['repeatImage'] = $repeatImage;
+
+ return $this;
+ }
+
+ public function setHideTitle(bool $hideTitle): static
+ {
+ $this->properties['hideTitle'] = $hideTitle ? 1 : 0;
+
+ return $this;
+ }
+
+ #[\Override]
+ protected function getRequiredProperties(): array
+ {
+ return ['groupID'];
+ }
+
+ #[\Override]
+ protected function afterValidateCreate(): void
+ {
+ // Shipped user ranks receive their title from the language variable
+ // through `SyncL10nLanguageItems`.
+ if (!isset($this->rankTitle) && ($this->properties['l10nIdentifier'] ?? null) === null) {
+ throw new \BadMethodCallException("Missing value for 'rankTitle'.");
+ }
+ }
+
+ #[\Override]
+ protected function afterCreate(DatabaseObject $object): void
+ {
+ if (isset($this->rankTitle)) {
+ $this->saveL10nValues($object);
+ }
+ }
+
+ #[\Override]
+ protected function afterUpdate(DatabaseObject $object): void
+ {
+ if (isset($this->rankTitle)) {
+ $this->saveL10nValues($object);
+ }
+
+ // A user rank owns its image file, a replaced file is orphaned.
+ $oldObject = $this->getObject();
+ if (
+ \array_key_exists('rankImageFileID', $this->properties)
+ && $oldObject->rankImageFileID !== null
+ && $oldObject->rankImageFileID !== $object->rankImageFileID
+ ) {
+ $file = FileRuntimeCache::getInstance()->getObject($oldObject->rankImageFileID);
+ if ($file !== null) {
+ new DeleteFiles([$file])();
+ }
+ }
+ }
+
+ private function saveL10nValues(UserRank $rank): void
+ {
+ (new L10nStorage(UserRank::getL10nDefinition()))->setValues(
+ $rank->rankID,
+ [
+ 'rankTitle' => $this->rankTitle,
+ ]
+ );
+ }
+}
diff --git a/wcfsetup/install/files/lib/data/user/rank/UserRankCollection.class.php b/wcfsetup/install/files/lib/data/user/rank/UserRankCollection.class.php
new file mode 100644
index 00000000000..bcff960dc83
--- /dev/null
+++ b/wcfsetup/install/files/lib/data/user/rank/UserRankCollection.class.php
@@ -0,0 +1,28 @@
+
+ * @since 6.3
+ *
+ * @extends DatabaseObjectCollection
+ */
+class UserRankCollection extends DatabaseObjectCollection
+{
+ use TCollectionL10n;
+
+ #[\Override]
+ protected function getL10nDefinition(): L10nDefinition
+ {
+ return UserRank::getL10nDefinition();
+ }
+}
diff --git a/wcfsetup/install/files/lib/event/captcha/question/CaptchaQuestionCreated.class.php b/wcfsetup/install/files/lib/event/captcha/question/CaptchaQuestionCreated.class.php
new file mode 100644
index 00000000000..2f8d14dad6c
--- /dev/null
+++ b/wcfsetup/install/files/lib/event/captcha/question/CaptchaQuestionCreated.class.php
@@ -0,0 +1,23 @@
+
+ * @since 6.3
+ */
+final class CaptchaQuestionCreated implements IPsr14Event
+{
+ public function __construct(
+ public readonly CaptchaQuestion $captchaQuestion,
+ public readonly CaptchaQuestionBuilder $builder,
+ ) {}
+}
diff --git a/wcfsetup/install/files/lib/event/captcha/question/CaptchaQuestionUpdated.class.php b/wcfsetup/install/files/lib/event/captcha/question/CaptchaQuestionUpdated.class.php
new file mode 100644
index 00000000000..ac50d519210
--- /dev/null
+++ b/wcfsetup/install/files/lib/event/captcha/question/CaptchaQuestionUpdated.class.php
@@ -0,0 +1,23 @@
+
+ * @since 6.3
+ */
+final class CaptchaQuestionUpdated implements IPsr14Event
+{
+ public function __construct(
+ public readonly CaptchaQuestion $captchaQuestion,
+ public readonly CaptchaQuestionBuilder $builder,
+ ) {}
+}
diff --git a/wcfsetup/install/files/lib/event/contact/recipient/ContactRecipientCreated.class.php b/wcfsetup/install/files/lib/event/contact/recipient/ContactRecipientCreated.class.php
new file mode 100644
index 00000000000..eb76e0b466a
--- /dev/null
+++ b/wcfsetup/install/files/lib/event/contact/recipient/ContactRecipientCreated.class.php
@@ -0,0 +1,23 @@
+
+ * @since 6.3
+ */
+final class ContactRecipientCreated implements IPsr14Event
+{
+ public function __construct(
+ public readonly ContactRecipient $recipient,
+ public readonly ContactRecipientBuilder $builder,
+ ) {}
+}
diff --git a/wcfsetup/install/files/lib/event/contact/recipient/ContactRecipientDeleted.class.php b/wcfsetup/install/files/lib/event/contact/recipient/ContactRecipientDeleted.class.php
new file mode 100644
index 00000000000..9e672e2eac9
--- /dev/null
+++ b/wcfsetup/install/files/lib/event/contact/recipient/ContactRecipientDeleted.class.php
@@ -0,0 +1,21 @@
+
+ * @since 6.3
+ */
+final class ContactRecipientDeleted implements IPsr14Event
+{
+ public function __construct(
+ public readonly ContactRecipient $recipient,
+ ) {}
+}
diff --git a/wcfsetup/install/files/lib/event/contact/recipient/ContactRecipientUpdated.class.php b/wcfsetup/install/files/lib/event/contact/recipient/ContactRecipientUpdated.class.php
new file mode 100644
index 00000000000..1fc0f87182b
--- /dev/null
+++ b/wcfsetup/install/files/lib/event/contact/recipient/ContactRecipientUpdated.class.php
@@ -0,0 +1,23 @@
+
+ * @since 6.3
+ */
+final class ContactRecipientUpdated implements IPsr14Event
+{
+ public function __construct(
+ public readonly ContactRecipient $recipient,
+ public readonly ContactRecipientBuilder $builder,
+ ) {}
+}
diff --git a/wcfsetup/install/files/lib/event/cronjob/CronjobCreated.class.php b/wcfsetup/install/files/lib/event/cronjob/CronjobCreated.class.php
new file mode 100644
index 00000000000..0a1ad272006
--- /dev/null
+++ b/wcfsetup/install/files/lib/event/cronjob/CronjobCreated.class.php
@@ -0,0 +1,23 @@
+
+ * @since 6.3
+ */
+final class CronjobCreated implements IPsr14Event
+{
+ public function __construct(
+ public readonly Cronjob $cronjob,
+ public readonly CronjobBuilder $builder,
+ ) {}
+}
diff --git a/wcfsetup/install/files/lib/event/cronjob/CronjobDeleted.class.php b/wcfsetup/install/files/lib/event/cronjob/CronjobDeleted.class.php
new file mode 100644
index 00000000000..84cdf74cd77
--- /dev/null
+++ b/wcfsetup/install/files/lib/event/cronjob/CronjobDeleted.class.php
@@ -0,0 +1,21 @@
+
+ * @since 6.3
+ */
+final class CronjobDeleted implements IPsr14Event
+{
+ public function __construct(
+ public readonly Cronjob $cronjob,
+ ) {}
+}
diff --git a/wcfsetup/install/files/lib/event/cronjob/CronjobUpdated.class.php b/wcfsetup/install/files/lib/event/cronjob/CronjobUpdated.class.php
new file mode 100644
index 00000000000..23d3ced3bc8
--- /dev/null
+++ b/wcfsetup/install/files/lib/event/cronjob/CronjobUpdated.class.php
@@ -0,0 +1,23 @@
+
+ * @since 6.3
+ */
+final class CronjobUpdated implements IPsr14Event
+{
+ public function __construct(
+ public readonly Cronjob $cronjob,
+ public readonly CronjobBuilder $builder,
+ ) {}
+}
diff --git a/wcfsetup/install/files/lib/event/l10n/L10nDefinitionCollecting.class.php b/wcfsetup/install/files/lib/event/l10n/L10nDefinitionCollecting.class.php
new file mode 100644
index 00000000000..6face221680
--- /dev/null
+++ b/wcfsetup/install/files/lib/event/l10n/L10nDefinitionCollecting.class.php
@@ -0,0 +1,43 @@
+
+ * @since 6.3
+ */
+final class L10nDefinitionCollecting implements IPsr14Event
+{
+ /**
+ * @var list
+ */
+ private array $definitions = [];
+
+ public function register(L10nDefinition $definition): void
+ {
+ if (!$definition->supportsLanguageItemSync()) {
+ throw new \InvalidArgumentException(
+ "The l10n definition of '{$definition->l10nTableName}' does not support the synchronization with language variables."
+ );
+ }
+
+ $this->definitions[] = $definition;
+ }
+
+ /**
+ * @return list
+ */
+ public function getDefinitions(): array
+ {
+ return $this->definitions;
+ }
+}
diff --git a/wcfsetup/install/files/lib/event/label/group/LabelGroupCreated.class.php b/wcfsetup/install/files/lib/event/label/group/LabelGroupCreated.class.php
new file mode 100644
index 00000000000..590a31059d6
--- /dev/null
+++ b/wcfsetup/install/files/lib/event/label/group/LabelGroupCreated.class.php
@@ -0,0 +1,23 @@
+
+ * @since 6.3
+ */
+final class LabelGroupCreated implements IPsr14Event
+{
+ public function __construct(
+ public readonly LabelGroup $group,
+ public readonly LabelGroupBuilder $builder,
+ ) {}
+}
diff --git a/wcfsetup/install/files/lib/event/label/group/LabelGroupDeleted.class.php b/wcfsetup/install/files/lib/event/label/group/LabelGroupDeleted.class.php
new file mode 100644
index 00000000000..f5099955ffb
--- /dev/null
+++ b/wcfsetup/install/files/lib/event/label/group/LabelGroupDeleted.class.php
@@ -0,0 +1,21 @@
+
+ * @since 6.3
+ */
+final class LabelGroupDeleted implements IPsr14Event
+{
+ public function __construct(
+ public readonly LabelGroup $group,
+ ) {}
+}
diff --git a/wcfsetup/install/files/lib/event/label/group/LabelGroupUpdated.class.php b/wcfsetup/install/files/lib/event/label/group/LabelGroupUpdated.class.php
new file mode 100644
index 00000000000..b87fc6e6001
--- /dev/null
+++ b/wcfsetup/install/files/lib/event/label/group/LabelGroupUpdated.class.php
@@ -0,0 +1,23 @@
+
+ * @since 6.3
+ */
+final class LabelGroupUpdated implements IPsr14Event
+{
+ public function __construct(
+ public readonly LabelGroup $group,
+ public readonly LabelGroupBuilder $builder,
+ ) {}
+}
diff --git a/wcfsetup/install/files/lib/event/paid/subscription/PaidSubscriptionCreated.class.php b/wcfsetup/install/files/lib/event/paid/subscription/PaidSubscriptionCreated.class.php
new file mode 100644
index 00000000000..b184ebf6c44
--- /dev/null
+++ b/wcfsetup/install/files/lib/event/paid/subscription/PaidSubscriptionCreated.class.php
@@ -0,0 +1,23 @@
+
+ * @since 6.3
+ */
+final class PaidSubscriptionCreated implements IPsr14Event
+{
+ public function __construct(
+ public readonly PaidSubscription $subscription,
+ public readonly PaidSubscriptionBuilder $builder,
+ ) {}
+}
diff --git a/wcfsetup/install/files/lib/event/paid/subscription/PaidSubscriptionDeleted.class.php b/wcfsetup/install/files/lib/event/paid/subscription/PaidSubscriptionDeleted.class.php
new file mode 100644
index 00000000000..6df12e22f06
--- /dev/null
+++ b/wcfsetup/install/files/lib/event/paid/subscription/PaidSubscriptionDeleted.class.php
@@ -0,0 +1,21 @@
+
+ * @since 6.3
+ */
+final class PaidSubscriptionDeleted implements IPsr14Event
+{
+ public function __construct(
+ public readonly PaidSubscription $subscription,
+ ) {}
+}
diff --git a/wcfsetup/install/files/lib/event/paid/subscription/PaidSubscriptionUpdated.class.php b/wcfsetup/install/files/lib/event/paid/subscription/PaidSubscriptionUpdated.class.php
new file mode 100644
index 00000000000..7fa27264500
--- /dev/null
+++ b/wcfsetup/install/files/lib/event/paid/subscription/PaidSubscriptionUpdated.class.php
@@ -0,0 +1,23 @@
+
+ * @since 6.3
+ */
+final class PaidSubscriptionUpdated implements IPsr14Event
+{
+ public function __construct(
+ public readonly PaidSubscription $subscription,
+ public readonly PaidSubscriptionBuilder $builder,
+ ) {}
+}
diff --git a/wcfsetup/install/files/lib/event/reaction/type/ReactionTypeCreated.class.php b/wcfsetup/install/files/lib/event/reaction/type/ReactionTypeCreated.class.php
new file mode 100644
index 00000000000..cd0dc53c0fa
--- /dev/null
+++ b/wcfsetup/install/files/lib/event/reaction/type/ReactionTypeCreated.class.php
@@ -0,0 +1,23 @@
+
+ * @since 6.3
+ */
+final class ReactionTypeCreated implements IPsr14Event
+{
+ public function __construct(
+ public readonly ReactionType $reactionType,
+ public readonly ReactionTypeBuilder $builder,
+ ) {}
+}
diff --git a/wcfsetup/install/files/lib/event/reaction/type/ReactionTypeUpdated.class.php b/wcfsetup/install/files/lib/event/reaction/type/ReactionTypeUpdated.class.php
new file mode 100644
index 00000000000..b73680a123b
--- /dev/null
+++ b/wcfsetup/install/files/lib/event/reaction/type/ReactionTypeUpdated.class.php
@@ -0,0 +1,23 @@
+
+ * @since 6.3
+ */
+final class ReactionTypeUpdated implements IPsr14Event
+{
+ public function __construct(
+ public readonly ReactionType $reactionType,
+ public readonly ReactionTypeBuilder $builder,
+ ) {}
+}
diff --git a/wcfsetup/install/files/lib/event/user/option/UserOptionCreated.class.php b/wcfsetup/install/files/lib/event/user/option/UserOptionCreated.class.php
new file mode 100644
index 00000000000..87c5120bb3b
--- /dev/null
+++ b/wcfsetup/install/files/lib/event/user/option/UserOptionCreated.class.php
@@ -0,0 +1,23 @@
+
+ * @since 6.3
+ */
+final class UserOptionCreated implements IPsr14Event
+{
+ public function __construct(
+ public readonly UserOption $option,
+ public readonly UserOptionBuilder $builder,
+ ) {}
+}
diff --git a/wcfsetup/install/files/lib/event/user/option/UserOptionDeleted.class.php b/wcfsetup/install/files/lib/event/user/option/UserOptionDeleted.class.php
new file mode 100644
index 00000000000..ab2e209eddb
--- /dev/null
+++ b/wcfsetup/install/files/lib/event/user/option/UserOptionDeleted.class.php
@@ -0,0 +1,19 @@
+
+ * @since 6.3
+ */
+final class UserOptionDeleted implements IPsr14Event
+{
+ public function __construct(public readonly UserOption $option) {}
+}
diff --git a/wcfsetup/install/files/lib/event/user/option/UserOptionDisabled.class.php b/wcfsetup/install/files/lib/event/user/option/UserOptionDisabled.class.php
new file mode 100644
index 00000000000..c8adb022be5
--- /dev/null
+++ b/wcfsetup/install/files/lib/event/user/option/UserOptionDisabled.class.php
@@ -0,0 +1,19 @@
+
+ * @since 6.3
+ */
+final class UserOptionDisabled implements IPsr14Event
+{
+ public function __construct(public readonly UserOption $option) {}
+}
diff --git a/wcfsetup/install/files/lib/event/user/option/UserOptionEnabled.class.php b/wcfsetup/install/files/lib/event/user/option/UserOptionEnabled.class.php
new file mode 100644
index 00000000000..fe604417328
--- /dev/null
+++ b/wcfsetup/install/files/lib/event/user/option/UserOptionEnabled.class.php
@@ -0,0 +1,19 @@
+
+ * @since 6.3
+ */
+final class UserOptionEnabled implements IPsr14Event
+{
+ public function __construct(public readonly UserOption $option) {}
+}
diff --git a/wcfsetup/install/files/lib/event/user/option/UserOptionUpdated.class.php b/wcfsetup/install/files/lib/event/user/option/UserOptionUpdated.class.php
new file mode 100644
index 00000000000..6607e5ba5c0
--- /dev/null
+++ b/wcfsetup/install/files/lib/event/user/option/UserOptionUpdated.class.php
@@ -0,0 +1,23 @@
+
+ * @since 6.3
+ */
+final class UserOptionUpdated implements IPsr14Event
+{
+ public function __construct(
+ public readonly UserOption $option,
+ public readonly UserOptionBuilder $builder,
+ ) {}
+}
diff --git a/wcfsetup/install/files/lib/event/user/rank/UserRankCreated.class.php b/wcfsetup/install/files/lib/event/user/rank/UserRankCreated.class.php
new file mode 100644
index 00000000000..79edc0e5b95
--- /dev/null
+++ b/wcfsetup/install/files/lib/event/user/rank/UserRankCreated.class.php
@@ -0,0 +1,23 @@
+
+ * @since 6.3
+ */
+final class UserRankCreated implements IPsr14Event
+{
+ public function __construct(
+ public readonly UserRank $rank,
+ public readonly UserRankBuilder $builder,
+ ) {}
+}
diff --git a/wcfsetup/install/files/lib/event/user/rank/UserRankDeleted.class.php b/wcfsetup/install/files/lib/event/user/rank/UserRankDeleted.class.php
new file mode 100644
index 00000000000..f8ffdb7e086
--- /dev/null
+++ b/wcfsetup/install/files/lib/event/user/rank/UserRankDeleted.class.php
@@ -0,0 +1,21 @@
+
+ * @since 6.3
+ */
+final class UserRankDeleted implements IPsr14Event
+{
+ public function __construct(
+ public readonly UserRank $rank,
+ ) {}
+}
diff --git a/wcfsetup/install/files/lib/event/user/rank/UserRankUpdated.class.php b/wcfsetup/install/files/lib/event/user/rank/UserRankUpdated.class.php
new file mode 100644
index 00000000000..e808d5b0f53
--- /dev/null
+++ b/wcfsetup/install/files/lib/event/user/rank/UserRankUpdated.class.php
@@ -0,0 +1,23 @@
+
+ * @since 6.3
+ */
+final class UserRankUpdated implements IPsr14Event
+{
+ public function __construct(
+ public readonly UserRank $rank,
+ public readonly UserRankBuilder $builder,
+ ) {}
+}
diff --git a/wcfsetup/install/files/lib/form/ContactForm.class.php b/wcfsetup/install/files/lib/form/ContactForm.class.php
index 6db394cb6fe..ffa10472d9a 100644
--- a/wcfsetup/install/files/lib/form/ContactForm.class.php
+++ b/wcfsetup/install/files/lib/form/ContactForm.class.php
@@ -25,6 +25,7 @@
use wcf\system\WCF;
use wcf\util\HeaderUtil;
use wcf\util\HtmlString;
+use wcf\util\StringUtil;
use wcf\util\UserUtil;
/**
@@ -212,10 +213,16 @@ protected function getRecipientFormField(): SelectFormField
throw new \BadMethodCallException('Contact form has no available recipients.');
}
+ // The labels of selection options are printed as HTML.
+ $options = \array_map(
+ static fn(ContactRecipient $recipient) => StringUtil::encodeHTML($recipient->getName()),
+ $recipients
+ );
+
return SelectFormField::create('recipientID')
->label('wcf.contact.recipientID')
->required()
- ->options($recipients)
+ ->options($options, labelLanguageItems: false)
->available(\count($recipients) > 1);
}
diff --git a/wcfsetup/install/files/lib/system/cache/builder/LabelCacheBuilder.class.php b/wcfsetup/install/files/lib/system/cache/builder/LabelCacheBuilder.class.php
index 4b65b8b806c..e2e0b61a8ad 100644
--- a/wcfsetup/install/files/lib/system/cache/builder/LabelCacheBuilder.class.php
+++ b/wcfsetup/install/files/lib/system/cache/builder/LabelCacheBuilder.class.php
@@ -35,6 +35,12 @@ protected function rebuild(array $parameters)
$groupList->readObjects();
$data['groups'] = $groupList->getObjects();
+ // The label groups share one collection, loading the titles once
+ // stores them in the cache instead of querying them on every request.
+ if ($data['groups'] !== []) {
+ \reset($data['groups'])->getL10nValues('groupName');
+ }
+
// get permissions for groups
$permissions = ACLHandler::getInstance()->getPermissions(
ACLHandler::getInstance()->getObjectTypeID('com.woltlab.wcf.label'),
diff --git a/wcfsetup/install/files/lib/system/cache/builder/PaidSubscriptionCacheBuilder.class.php b/wcfsetup/install/files/lib/system/cache/builder/PaidSubscriptionCacheBuilder.class.php
index 715a9f7215c..02a5c74b28d 100644
--- a/wcfsetup/install/files/lib/system/cache/builder/PaidSubscriptionCacheBuilder.class.php
+++ b/wcfsetup/install/files/lib/system/cache/builder/PaidSubscriptionCacheBuilder.class.php
@@ -20,7 +20,15 @@ protected function rebuild(array $parameters)
$subscriptionList->getConditionBuilder()->add('isDisabled = ?', [0]);
$subscriptionList->sqlOrderBy = 'showOrder';
$subscriptionList->readObjects();
+ $subscriptions = $subscriptionList->getObjects();
- return $subscriptionList->getObjects();
+ // The subscriptions share one collection, loading the titles and
+ // descriptions once stores them in the cache instead of querying them
+ // on every request.
+ if ($subscriptions !== []) {
+ \reset($subscriptions)->getL10nValues('title');
+ }
+
+ return $subscriptions;
}
}
diff --git a/wcfsetup/install/files/lib/system/cache/builder/ReactionTypeCacheBuilder.class.php b/wcfsetup/install/files/lib/system/cache/builder/ReactionTypeCacheBuilder.class.php
index f2833ed9cd9..95cf0ec385f 100644
--- a/wcfsetup/install/files/lib/system/cache/builder/ReactionTypeCacheBuilder.class.php
+++ b/wcfsetup/install/files/lib/system/cache/builder/ReactionTypeCacheBuilder.class.php
@@ -47,6 +47,12 @@ public function rebuild(array $parameters)
}
}
+ // The reaction types share one collection, loading the titles once
+ // stores them in the cache instead of querying them on every request.
+ if ($reactionTypes !== []) {
+ \reset($reactionTypes)->getL10nValues('title');
+ }
+
return $reactionTypes;
}
}
diff --git a/wcfsetup/install/files/lib/system/cache/builder/UserOptionCacheBuilder.class.php b/wcfsetup/install/files/lib/system/cache/builder/UserOptionCacheBuilder.class.php
index 62cff2fb818..2daef91e31d 100644
--- a/wcfsetup/install/files/lib/system/cache/builder/UserOptionCacheBuilder.class.php
+++ b/wcfsetup/install/files/lib/system/cache/builder/UserOptionCacheBuilder.class.php
@@ -3,6 +3,7 @@
namespace wcf\system\cache\builder;
use wcf\data\user\option\UserOption;
+use wcf\data\user\option\UserOptionCollection;
/**
* Caches user options and categories
@@ -22,4 +23,25 @@ class UserOptionCacheBuilder extends OptionCacheBuilder
* @inheritDoc
*/
protected $tableName = 'user_option';
+
+ #[\Override]
+ public function rebuild(array $parameters)
+ {
+ $data = parent::rebuild($parameters);
+
+ // share a single collection so the localized values of all options are
+ // batch-loaded in one query instead of one query per option
+ $options = \array_filter(
+ \is_array($data['options']) ? $data['options'] : [],
+ static fn($option) => $option instanceof UserOption
+ );
+ if ($options !== []) {
+ $collection = new UserOptionCollection(\array_values($options));
+ foreach ($options as $option) {
+ $option->setCollection($collection);
+ }
+ }
+
+ return $data;
+ }
}
diff --git a/wcfsetup/install/files/lib/system/cache/builder/UserRankCacheBuilder.class.php b/wcfsetup/install/files/lib/system/cache/builder/UserRankCacheBuilder.class.php
index 38a9926dbac..d04ac1df39c 100644
--- a/wcfsetup/install/files/lib/system/cache/builder/UserRankCacheBuilder.class.php
+++ b/wcfsetup/install/files/lib/system/cache/builder/UserRankCacheBuilder.class.php
@@ -47,6 +47,12 @@ public function rebuild(array $parameters)
}
}
+ // The user ranks share one collection, loading the titles once stores
+ // them in the cache instead of querying them on every request.
+ if ($ranks !== []) {
+ \reset($ranks)->getL10nValues('rankTitle');
+ }
+
return $ranks;
}
diff --git a/wcfsetup/install/files/lib/system/endpoint/controller/core/contact/recipients/DeleteRecipient.class.php b/wcfsetup/install/files/lib/system/endpoint/controller/core/contact/recipients/DeleteRecipient.class.php
index 84a0c0bfba5..40957947c1c 100644
--- a/wcfsetup/install/files/lib/system/endpoint/controller/core/contact/recipients/DeleteRecipient.class.php
+++ b/wcfsetup/install/files/lib/system/endpoint/controller/core/contact/recipients/DeleteRecipient.class.php
@@ -5,8 +5,8 @@
use Laminas\Diactoros\Response\JsonResponse;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
+use wcf\command\contact\recipient\DeleteContactRecipient;
use wcf\data\contact\recipient\ContactRecipient;
-use wcf\data\contact\recipient\ContactRecipientAction;
use wcf\http\Helper;
use wcf\system\endpoint\DeleteRequest;
use wcf\system\endpoint\IController;
@@ -32,7 +32,7 @@ public function __invoke(ServerRequestInterface $request, array $variables): Res
$this->assertRecipientCanBeDeleted($recipient);
- (new ContactRecipientAction([$recipient], 'delete'))->executeAction();
+ (new DeleteContactRecipient($recipient))();
return new JsonResponse([]);
}
diff --git a/wcfsetup/install/files/lib/system/endpoint/controller/core/cronjobs/DeleteCronjob.class.php b/wcfsetup/install/files/lib/system/endpoint/controller/core/cronjobs/DeleteCronjob.class.php
index f3b12782e1a..7d8c72ced90 100644
--- a/wcfsetup/install/files/lib/system/endpoint/controller/core/cronjobs/DeleteCronjob.class.php
+++ b/wcfsetup/install/files/lib/system/endpoint/controller/core/cronjobs/DeleteCronjob.class.php
@@ -5,8 +5,8 @@
use Laminas\Diactoros\Response\JsonResponse;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
+use wcf\command\cronjob\DeleteCronjob as DeleteCronjobCommand;
use wcf\data\cronjob\Cronjob;
-use wcf\data\cronjob\CronjobAction;
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->assertCronjobCanBeDeleted($cronjob);
- (new CronjobAction([$cronjob], 'delete'))->executeAction();
+ new DeleteCronjobCommand($cronjob)();
return new JsonResponse([]);
}
diff --git a/wcfsetup/install/files/lib/system/endpoint/controller/core/labels/groups/DeleteGroup.class.php b/wcfsetup/install/files/lib/system/endpoint/controller/core/labels/groups/DeleteGroup.class.php
index e282ca721d4..3fd2b210941 100644
--- a/wcfsetup/install/files/lib/system/endpoint/controller/core/labels/groups/DeleteGroup.class.php
+++ b/wcfsetup/install/files/lib/system/endpoint/controller/core/labels/groups/DeleteGroup.class.php
@@ -5,8 +5,8 @@
use Laminas\Diactoros\Response\JsonResponse;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
+use wcf\command\label\group\DeleteLabelGroup;
use wcf\data\label\group\LabelGroup;
-use wcf\data\label\group\LabelGroupAction;
use wcf\http\Helper;
use wcf\system\endpoint\DeleteRequest;
use wcf\system\endpoint\IController;
@@ -30,7 +30,7 @@ public function __invoke(ServerRequestInterface $request, array $variables): Res
$this->assertGroupCanBeDeleted();
- (new LabelGroupAction([$labelGroup], 'delete'))->executeAction();
+ (new DeleteLabelGroup($labelGroup))();
return new JsonResponse([]);
}
diff --git a/wcfsetup/install/files/lib/system/endpoint/controller/core/paidSubscriptions/DeleteSubscription.class.php b/wcfsetup/install/files/lib/system/endpoint/controller/core/paidSubscriptions/DeleteSubscription.class.php
index 5c75f00e532..a97952ff291 100644
--- a/wcfsetup/install/files/lib/system/endpoint/controller/core/paidSubscriptions/DeleteSubscription.class.php
+++ b/wcfsetup/install/files/lib/system/endpoint/controller/core/paidSubscriptions/DeleteSubscription.class.php
@@ -5,8 +5,8 @@
use Laminas\Diactoros\Response\JsonResponse;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
+use wcf\command\paid\subscription\DeletePaidSubscription;
use wcf\data\paid\subscription\PaidSubscription;
-use wcf\data\paid\subscription\PaidSubscriptionAction;
use wcf\http\Helper;
use wcf\system\endpoint\DeleteRequest;
use wcf\system\endpoint\IController;
@@ -30,7 +30,7 @@ public function __invoke(ServerRequestInterface $request, array $variables): Res
$this->assertSubscriptionCanBeDeleted();
- (new PaidSubscriptionAction([$subscription], 'delete'))->executeAction();
+ (new DeletePaidSubscription($subscription))();
return new JsonResponse([]);
}
diff --git a/wcfsetup/install/files/lib/system/endpoint/controller/core/users/ranks/DeleteUserRank.class.php b/wcfsetup/install/files/lib/system/endpoint/controller/core/users/ranks/DeleteUserRank.class.php
index 2462f38fed1..fc6d8186cd1 100644
--- a/wcfsetup/install/files/lib/system/endpoint/controller/core/users/ranks/DeleteUserRank.class.php
+++ b/wcfsetup/install/files/lib/system/endpoint/controller/core/users/ranks/DeleteUserRank.class.php
@@ -5,8 +5,8 @@
use Laminas\Diactoros\Response\JsonResponse;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
+use wcf\command\user\rank\DeleteUserRank as DeleteUserRankCommand;
use wcf\data\user\rank\UserRank;
-use wcf\data\user\rank\UserRankAction;
use wcf\http\Helper;
use wcf\system\endpoint\DeleteRequest;
use wcf\system\endpoint\IController;
@@ -30,8 +30,7 @@ public function __invoke(ServerRequestInterface $request, array $variables): Res
$rank = Helper::fetchObjectFromRequestParameter($variables['id'], UserRank::class);
- $action = new UserRankAction([$rank], 'delete');
- $action->executeAction();
+ new DeleteUserRankCommand($rank)();
return new JsonResponse([]);
}
diff --git a/wcfsetup/install/files/lib/system/form/builder/field/EmailFormField.class.php b/wcfsetup/install/files/lib/system/form/builder/field/EmailFormField.class.php
index c976c1313cd..183f1e11252 100644
--- a/wcfsetup/install/files/lib/system/form/builder/field/EmailFormField.class.php
+++ b/wcfsetup/install/files/lib/system/form/builder/field/EmailFormField.class.php
@@ -23,6 +23,7 @@ class EmailFormField extends AbstractFormField implements
IAutoFocusFormField,
ICssClassFormField,
II18nFormField,
+ IL10nFormField,
IImmutableFormField,
IInputModeFormField,
IPatternFormField,
@@ -36,7 +37,7 @@ class EmailFormField extends AbstractFormField implements
use TCssClassFormField;
use TImmutableFormField;
use TInputModeFormField;
- use TI18nFormField {
+ use TL10nFormField {
validate as protected i18nValidate;
}
use TPatternFormField;
diff --git a/wcfsetup/install/files/lib/system/form/builder/field/IL10nFormField.class.php b/wcfsetup/install/files/lib/system/form/builder/field/IL10nFormField.class.php
new file mode 100644
index 00000000000..5c0bb40ac0b
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/form/builder/field/IL10nFormField.class.php
@@ -0,0 +1,54 @@
+
+ * @since 6.3
+ *
+ * @phpstan-import-type L10nValue from L10nStorage
+ */
+interface IL10nFormField extends IFormField
+{
+ /**
+ * Sets whether this field supports l10n input and returns this field.
+ */
+ public function l10n(bool $l10n = true): static;
+
+ /**
+ * Sets whether this field's value must be entered for every language and
+ * returns this field. Enabling this also enables l10n support.
+ */
+ public function l10nRequired(bool $l10nRequired = true): static;
+
+ /**
+ * Returns `true` if this field supports l10n input.
+ */
+ public function isL10n(): bool;
+
+ /**
+ * Returns `true` if this field's value must be entered for every language.
+ */
+ public function isL10nRequired(): bool;
+
+ /**
+ * Returns the values of this field for persistence via `L10nStorage`:
+ * `[L10nStorage::MONOLINGUAL => value]` for a monolingual value or
+ * `[languageID => value, ...]` for multilingual values.
+ *
+ * @return L10nValue
+ */
+ public function getL10nValues(): array;
+}
diff --git a/wcfsetup/install/files/lib/system/form/builder/field/TL10nFormField.class.php b/wcfsetup/install/files/lib/system/form/builder/field/TL10nFormField.class.php
new file mode 100644
index 00000000000..3c39d854ab5
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/form/builder/field/TL10nFormField.class.php
@@ -0,0 +1,244 @@
+
+ * @since 6.3
+ *
+ * @mixin IL10nFormField
+ */
+trait TL10nFormField
+{
+ use TI18nFormField {
+ TI18nFormField::i18n as private i18nFieldI18n;
+ TI18nFormField::languageItemPattern as private i18nFieldLanguageItemPattern;
+ TI18nFormField::populate as private i18nFieldPopulate;
+ TI18nFormField::value as private i18nFieldValue;
+ TI18nFormField::validate as private i18nFieldValidate;
+ TI18nFormField::getHtmlVariables as private i18nFieldGetHtmlVariables;
+ }
+
+ /**
+ * `true` if this field supports l10n input and `false` otherwise
+ */
+ protected bool $l10n = false;
+
+ /**
+ * `true` if this field requires a value for every language and `false` otherwise
+ */
+ protected bool $l10nRequired = false;
+
+ public function l10n(bool $l10n = true): static
+ {
+ if ($l10n && $this->i18n) {
+ throw new \BadMethodCallException(
+ "The i18n mode and the l10n mode are mutually exclusive for field '{$this->getId()}'."
+ );
+ }
+
+ $this->l10n = $l10n;
+
+ return $this;
+ }
+
+ public function l10nRequired(bool $l10nRequired = true): static
+ {
+ $this->l10nRequired = $l10nRequired;
+ $this->l10n();
+
+ return $this;
+ }
+
+ public function isL10n(): bool
+ {
+ return $this->l10n;
+ }
+
+ public function isL10nRequired(): bool
+ {
+ return $this->l10nRequired;
+ }
+
+ /**
+ * Returns `true` if this field supports i18n or l10n input, as the l10n
+ * mode reuses the i18n input machinery.
+ *
+ * @return bool
+ */
+ public function isI18n()
+ {
+ return $this->i18n || $this->l10n;
+ }
+
+ /**
+ * @param bool $i18n determines if field supports i18n input
+ * @return II18nFormField this field
+ */
+ public function i18n(bool $i18n = true)
+ {
+ if ($i18n && $this->l10n) {
+ throw new \BadMethodCallException(
+ "The i18n mode and the l10n mode are mutually exclusive for field '{$this->getId()}'."
+ );
+ }
+
+ return $this->i18nFieldI18n($i18n);
+ }
+
+ /**
+ * @return II18nFormField this field
+ */
+ public function languageItemPattern(string $pattern)
+ {
+ if ($this->l10n) {
+ throw new \BadMethodCallException(
+ "A language item pattern cannot be used in l10n mode for field '{$this->getId()}'."
+ );
+ }
+
+ return $this->i18nFieldLanguageItemPattern($pattern);
+ }
+
+ /**
+ * @return static this node
+ */
+ public function populate()
+ {
+ if (!$this->l10n) {
+ return $this->i18nFieldPopulate();
+ }
+
+ parent::populate();
+
+ // Unlike the i18n mode, no `_i18n` parameter is added to the form data,
+ // because the values are persisted via `getL10nValues()` and would
+ // otherwise bypass any processing the field applies to them.
+ I18nHandler::getInstance()->unregister($this->getPrefixedId());
+ I18nHandler::getInstance()->register($this->getPrefixedId());
+
+ return $this;
+ }
+
+ /**
+ * @return static this field
+ */
+ public function value(mixed $value)
+ {
+ if (!$this->l10n) {
+ return $this->i18nFieldValue($value);
+ }
+
+ // Unlike the i18n mode, a string value must not be matched against a
+ // language item pattern via `setStringValue()`.
+ if (\is_string($value) || \is_numeric($value)) {
+ I18nHandler::getInstance()->setValue($this->getPrefixedId(), (string)$value, true);
+ } elseif (\is_array($value)) {
+ if ($value !== []) {
+ if (\array_key_exists(L10nStorage::MONOLINGUAL, $value)) {
+ if (\count($value) !== 1) {
+ throw new InvalidFormFieldValue(
+ $this,
+ 'monolingual value or per-language values',
+ 'mixed array'
+ );
+ }
+
+ I18nHandler::getInstance()->setValue(
+ $this->getPrefixedId(),
+ (string)$value[L10nStorage::MONOLINGUAL],
+ true
+ );
+ } else {
+ I18nHandler::getInstance()->setValues($this->getPrefixedId(), $value);
+ }
+ }
+ } else {
+ throw new InvalidFormFieldValue($this, 'string/number/array', \gettype($value));
+ }
+
+ return $this;
+ }
+
+ /**
+ * @return void
+ */
+ public function validate()
+ {
+ if (!$this->l10n) {
+ $this->i18nFieldValidate();
+
+ return;
+ }
+
+ if (!empty(ArrayUtil::trim($this->getValue())) || $this->isRequired()) {
+ if (
+ !I18nHandler::getInstance()->validateValue(
+ $this->getPrefixedId(),
+ $this->isL10nRequired(),
+ !$this->isRequired()
+ )
+ ) {
+ if ($this->hasPlainValue()) {
+ $this->addValidationError(new FormFieldValidationError('empty'));
+ } else {
+ $this->addValidationError(new FormFieldValidationError('multilingual'));
+ }
+ }
+ }
+ }
+
+ /**
+ * @return array{}|array{elementIdentifier: string, forceSelection: bool}
+ */
+ public function getHtmlVariables()
+ {
+ if (!$this->l10n) {
+ return $this->i18nFieldGetHtmlVariables();
+ }
+
+ I18nHandler::getInstance()->assignVariables();
+
+ return [
+ 'elementIdentifier' => $this->getPrefixedId(),
+ 'forceSelection' => $this->isL10nRequired(),
+ ];
+ }
+
+ public function getL10nValues(): array
+ {
+ if (!$this->l10n) {
+ throw new \BadMethodCallException("l10n is not enabled for field '{$this->getId()}'.");
+ }
+
+ if ($this->hasI18nValues()) {
+ return I18nHandler::getInstance()->getValues($this->getPrefixedId());
+ }
+
+ return [L10nStorage::MONOLINGUAL => (string)$this->getValue()];
+ }
+}
diff --git a/wcfsetup/install/files/lib/system/form/builder/field/TextFormField.class.php b/wcfsetup/install/files/lib/system/form/builder/field/TextFormField.class.php
index 11a45aeb4da..120a8297296 100644
--- a/wcfsetup/install/files/lib/system/form/builder/field/TextFormField.class.php
+++ b/wcfsetup/install/files/lib/system/form/builder/field/TextFormField.class.php
@@ -23,6 +23,7 @@ class TextFormField extends AbstractFormField implements
ICensorshipFormField,
ICssClassFormField,
II18nFormField,
+ IL10nFormField,
IImmutableFormField,
IInputModeFormField,
IMaximumLengthFormField,
@@ -37,7 +38,7 @@ class TextFormField extends AbstractFormField implements
use TCssClassFormField;
use TImmutableFormField;
use TInputModeFormField;
- use TI18nFormField {
+ use TL10nFormField {
validate as protected i18nValidate;
}
use TMaximumLengthFormField;
diff --git a/wcfsetup/install/files/lib/system/form/builder/field/wysiwyg/WysiwygFormField.class.php b/wcfsetup/install/files/lib/system/form/builder/field/wysiwyg/WysiwygFormField.class.php
index db6270d692c..15af6eb78ae 100644
--- a/wcfsetup/install/files/lib/system/form/builder/field/wysiwyg/WysiwygFormField.class.php
+++ b/wcfsetup/install/files/lib/system/form/builder/field/wysiwyg/WysiwygFormField.class.php
@@ -3,15 +3,18 @@
namespace wcf\system\form\builder\field\wysiwyg;
use wcf\data\IStorableObject;
+use wcf\data\language\Language;
use wcf\system\bbcode\BBCodeHandler;
use wcf\system\form\builder\data\processor\CustomFormDataProcessor;
use wcf\system\form\builder\field\AbstractFormField;
use wcf\system\form\builder\field\IAttributeFormField;
use wcf\system\form\builder\field\ICensorshipFormField;
+use wcf\system\form\builder\field\IL10nFormField;
use wcf\system\form\builder\field\IMaximumLengthFormField;
use wcf\system\form\builder\field\IMinimumLengthFormField;
use wcf\system\form\builder\field\TCensorshipFormField;
use wcf\system\form\builder\field\TInputAttributeFormField;
+use wcf\system\form\builder\field\TL10nFormField;
use wcf\system\form\builder\field\TMaximumLengthFormField;
use wcf\system\form\builder\field\TMinimumLengthFormField;
use wcf\system\form\builder\field\validation\FormFieldValidationError;
@@ -20,6 +23,8 @@
use wcf\system\form\builder\TObjectTypeFormNode;
use wcf\system\html\input\HtmlInputProcessor;
use wcf\system\html\upcast\HtmlUpcastProcessor;
+use wcf\system\l10n\L10nStorage;
+use wcf\system\language\LanguageFactory;
use wcf\system\message\quote\MessageQuoteManager;
use wcf\system\WCF;
use wcf\util\StringUtil;
@@ -27,6 +32,11 @@
/**
* Implementation of a form field for wysiwyg editors.
*
+ * The l10n mode processes the value of every language separately and exposes
+ * the processed HTML via `getL10nValues()`. It does not register embedded
+ * objects, because they are tracked per object rather than per language, thus
+ * it cannot be combined with autosave, attachments, mentions or quotes.
+ *
* @author Matthias Schmidt
* @copyright 2001-2019 WoltLab GmbH
* @license GNU Lesser General Public License
@@ -35,6 +45,7 @@
final class WysiwygFormField extends AbstractFormField implements
IAttributeFormField,
ICensorshipFormField,
+ IL10nFormField,
IMaximumLengthFormField,
IMinimumLengthFormField,
IObjectTypeFormNode
@@ -43,6 +54,14 @@ final class WysiwygFormField extends AbstractFormField implements
use TInputAttributeFormField {
getReservedFieldAttributes as private inputGetReservedFieldAttributes;
}
+ use TL10nFormField {
+ getL10nValues as private getRawL10nValues;
+ getValue as private i18nGetValue;
+ populate as private i18nPopulate;
+ readValue as private i18nReadValue;
+ validate as private l10nValidate;
+ value as private l10nValue;
+ }
use TMaximumLengthFormField;
use TMinimumLengthFormField;
use TObjectTypeFormNode;
@@ -57,6 +76,13 @@ final class WysiwygFormField extends AbstractFormField implements
*/
protected ?HtmlInputProcessor $htmlInputProcessor = null;
+ /**
+ * input processors containing the wysiwyg text of each language in l10n mode
+ * @var array
+ * @since 6.3
+ */
+ protected array $htmlInputProcessors = [];
+
/**
* last time the field has been edited; if `0`, the last edit time is unknown
*/
@@ -169,9 +195,74 @@ public function getQuoteData(?string $index = null): string
#[\Override]
public function getSaveValue(): string
{
+ if ($this->isL10n()) {
+ throw new \BadMethodCallException(
+ "The save value is not available in l10n mode for field '{$this->getId()}', use getL10nValues() instead."
+ );
+ }
+
return $this->htmlInputProcessor->getHtml();
}
+ #[\Override]
+ public function hasSaveValue(): bool
+ {
+ if ($this->isL10n()) {
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * @since 6.3
+ */
+ public function i18n(bool $i18n = true): static
+ {
+ if ($i18n) {
+ throw new \BadMethodCallException(
+ "The i18n mode is not supported for field '{$this->getId()}', use l10n() instead."
+ );
+ }
+
+ return $this;
+ }
+
+ /**
+ * @since 6.3
+ */
+ #[\Override]
+ public function getL10nValues(): array
+ {
+ if (!$this->isL10n()) {
+ throw new \BadMethodCallException("l10n is not enabled for field '{$this->getId()}'.");
+ }
+
+ if ($this->htmlInputProcessors === []) {
+ throw new \BadMethodCallException(
+ "The l10n values are not available before validate() has been called for field '{$this->getId()}'."
+ );
+ }
+
+ return \array_map(
+ static fn (HtmlInputProcessor $htmlInputProcessor) => $htmlInputProcessor->getHtml(),
+ $this->htmlInputProcessors
+ );
+ }
+
+ /**
+ * @since 6.3
+ */
+ #[\Override]
+ public function getJavaScriptDataHandlerModule(): string
+ {
+ if ($this->isL10n() && \count(LanguageFactory::getInstance()->getLanguages()) > 1) {
+ return 'WoltLabSuite/Core/Form/Builder/Field/CkeditorI18n';
+ }
+
+ return $this->javaScriptDataHandlerModule;
+ }
+
/**
* Sets the last time this field has been edited and returns this field.
*
@@ -187,7 +278,17 @@ public function lastEditTime(int $lastEditTime): static
#[\Override]
public function populate(): static
{
- parent::populate();
+ $this->i18nPopulate();
+
+ if ($this->isL10n()) {
+ if ($this->autosaveId !== '' || $this->supportAttachments || $this->supportMentions || $this->supportQuotes) {
+ throw new \BadMethodCallException(
+ "Autosave, attachments, mentions and quotes are not supported in l10n mode for field '{$this->getId()}'."
+ );
+ }
+
+ return $this;
+ }
$this->getDocument()->getDataHandler()->addProcessor(new CustomFormDataProcessor(
'wysiwyg',
@@ -227,6 +328,10 @@ public function quoteData(string $objectType, string $actionClass, array $select
#[\Override]
public function readValue(): static
{
+ if ($this->isL10n()) {
+ return $this->i18nReadValue();
+ }
+
if ($this->getDocument()->hasRequestData($this->getPrefixedId())) {
$value = $this->getDocument()->getRequestData($this->getPrefixedId());
@@ -320,35 +425,79 @@ public function validate(): void
WCF::getSession()->getPermission($disallowedBBCodesPermission)
));
- $this->htmlInputProcessor = new HtmlInputProcessor();
- $this->htmlInputProcessor->process($this->getValue(), $this->getObjectType()->objectType, $this->objectID ?? 0);
-
- if ($this->isRequired() && $this->htmlInputProcessor->appearsToBeEmpty()) {
- $this->addValidationError(new FormFieldValidationError('empty'));
+ if ($this->isL10n()) {
+ $this->validateL10n();
} else {
- $disallowedBBCodes = $this->htmlInputProcessor->validate();
- if ($disallowedBBCodes !== []) {
- $this->addValidationError(new FormFieldValidationError(
- 'disallowedBBCodes',
- 'wcf.message.error.disallowedBBCodes',
- ['disallowedBBCodes' => $disallowedBBCodes]
- ));
+ $this->htmlInputProcessor = new HtmlInputProcessor();
+ $this->htmlInputProcessor->process($this->getValue(), $this->getObjectType()->objectType, $this->objectID ?? 0);
+
+ if ($this->isRequired() && $this->htmlInputProcessor->appearsToBeEmpty()) {
+ $this->addValidationError(new FormFieldValidationError('empty'));
} else {
- $message = $this->htmlInputProcessor->getTextContent();
- if ($message !== '') {
- $this->validateMinimumLength($message);
- $this->validateMaximumLength($message);
-
- if ($this->getValidationErrors() === []) {
- $this->validateCensorship($message);
- }
- }
+ $this->validateHtml($this->htmlInputProcessor);
}
}
parent::validate();
}
+ private function validateL10n(): void
+ {
+ $this->htmlInputProcessors = [];
+
+ $this->l10nValidate();
+ if ($this->getValidationErrors() !== []) {
+ return;
+ }
+
+ $hasEmptyValue = false;
+ foreach ($this->getRawL10nValues() as $languageID => $value) {
+ $htmlInputProcessor = new HtmlInputProcessor();
+ $htmlInputProcessor->process($value, $this->getObjectType()->objectType, $this->objectID ?? 0);
+ $this->htmlInputProcessors[$languageID] = $htmlInputProcessor;
+
+ if ($htmlInputProcessor->appearsToBeEmpty()) {
+ $hasEmptyValue = true;
+ } else {
+ $this->validateHtml(
+ $htmlInputProcessor,
+ $languageID === L10nStorage::MONOLINGUAL ? null : LanguageFactory::getInstance()->getLanguage($languageID)
+ );
+ }
+ }
+
+ // `I18nHandler::validateValue()` only rejects empty strings, but the
+ // editor can submit markup that has no visible content.
+ if ($hasEmptyValue && $this->isRequired()) {
+ $this->addValidationError(new FormFieldValidationError($this->hasPlainValue() ? 'empty' : 'multilingual'));
+ }
+ }
+
+ /**
+ * @param ?Language $language language of the validated text or `null` for monolingual text
+ */
+ private function validateHtml(HtmlInputProcessor $htmlInputProcessor, ?Language $language = null): void
+ {
+ $disallowedBBCodes = $htmlInputProcessor->validate();
+ if ($disallowedBBCodes !== []) {
+ $this->addValidationError(new FormFieldValidationError(
+ 'disallowedBBCodes',
+ 'wcf.message.error.disallowedBBCodes',
+ ['disallowedBBCodes' => $disallowedBBCodes]
+ ));
+ } else {
+ $message = $htmlInputProcessor->getTextContent();
+ if ($message !== '') {
+ $this->validateMinimumLength($message, $language);
+ $this->validateMaximumLength($message, $language);
+
+ if ($this->getValidationErrors() === []) {
+ $this->validateCensorship($message);
+ }
+ }
+ }
+ }
+
/**
* @return string[]
* @since 5.4
@@ -366,11 +515,45 @@ protected static function getReservedFieldAttributes(): array
);
}
+ /**
+ * @return string|array
+ */
#[\Override]
- public function getValue(): string
+ public function getValue(): string|array
+ {
+ if ($this->isL10n()) {
+ return $this->i18nGetValue();
+ }
+
+ return $this->upcast(parent::getValue() ?? '');
+ }
+
+ #[\Override]
+ public function value(mixed $value): static
+ {
+ if ($this->isL10n()) {
+ // `I18nHandler` passes the stored values to the editor without
+ // calling `getValue()`, thus they must be upcast in advance.
+ if (\is_string($value)) {
+ $value = $this->upcast($value);
+ } elseif (\is_array($value)) {
+ $value = \array_map(
+ fn ($languageValue) => \is_string($languageValue) ? $this->upcast($languageValue) : $languageValue,
+ $value
+ );
+ }
+
+ return $this->l10nValue($value);
+ }
+
+ return parent::value($value);
+ }
+
+ private function upcast(string $html): string
{
$upcastProcessor = new HtmlUpcastProcessor();
- $upcastProcessor->process(parent::getValue() ?? '', $this->getObjectType()->objectType);
+ $upcastProcessor->process($html, $this->getObjectType()->objectType);
+
return $upcastProcessor->getHtml();
}
diff --git a/wcfsetup/install/files/lib/system/gridView/admin/CaptchaQuestionGridView.class.php b/wcfsetup/install/files/lib/system/gridView/admin/CaptchaQuestionGridView.class.php
index 7c891aa85c4..4eab332a9e1 100644
--- a/wcfsetup/install/files/lib/system/gridView/admin/CaptchaQuestionGridView.class.php
+++ b/wcfsetup/install/files/lib/system/gridView/admin/CaptchaQuestionGridView.class.php
@@ -4,7 +4,7 @@
use wcf\acp\form\CaptchaQuestionEditForm;
use wcf\data\captcha\question\CaptchaQuestion;
-use wcf\data\captcha\question\I18nCaptchaQuestionList;
+use wcf\data\captcha\question\L10nCaptchaQuestionList;
use wcf\event\gridView\admin\CaptchaQuestionGridViewInitialized;
use wcf\system\gridView\AbstractGridView;
use wcf\system\gridView\GridViewColumn;
@@ -15,8 +15,8 @@
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;
/**
@@ -27,7 +27,7 @@
* @license GNU Lesser General Public License
* @since 6.2
*
- * @extends AbstractGridView
+ * @extends AbstractGridView
*/
final class CaptchaQuestionGridView extends AbstractGridView
{
@@ -41,8 +41,13 @@ public function __construct()
GridViewColumn::for('question')
->label('wcf.acp.captcha.question.question')
->titleColumn()
- ->filter(I18nTextFilter::class)
- ->sortable(sortByDatabaseColumn: 'questionI18n'),
+ ->filter(new L10nTextFilter(
+ CaptchaQuestion::getL10nDefinition(),
+ 'question',
+ 'question',
+ 'wcf.acp.captcha.question.question',
+ ))
+ ->sortable(sortByDatabaseColumn: 'question'),
GridViewColumn::for('views')
->label('wcf.acp.captcha.question.views')
->sortable(defaultSortOrder: 'DESC')
@@ -84,9 +89,9 @@ public function isAccessible(): bool
}
#[\Override]
- protected function createObjectList(): I18nCaptchaQuestionList
+ protected function createObjectList(): L10nCaptchaQuestionList
{
- return new I18nCaptchaQuestionList();
+ return new L10nCaptchaQuestionList();
}
#[\Override]
diff --git a/wcfsetup/install/files/lib/system/gridView/admin/ContactRecipientGridView.class.php b/wcfsetup/install/files/lib/system/gridView/admin/ContactRecipientGridView.class.php
index 6b06dfb5142..78a14d83482 100644
--- a/wcfsetup/install/files/lib/system/gridView/admin/ContactRecipientGridView.class.php
+++ b/wcfsetup/install/files/lib/system/gridView/admin/ContactRecipientGridView.class.php
@@ -4,7 +4,7 @@
use wcf\acp\form\ContactRecipientEditForm;
use wcf\data\contact\recipient\ContactRecipient;
-use wcf\data\contact\recipient\ContactRecipientList;
+use wcf\data\contact\recipient\L10nContactRecipientList;
use wcf\event\gridView\admin\ContactRecipientGridViewInitialized;
use wcf\system\gridView\AbstractGridView;
use wcf\system\gridView\GridViewColumn;
@@ -12,13 +12,12 @@
use wcf\system\gridView\renderer\EmailColumnRenderer;
use wcf\system\gridView\renderer\NumberColumnRenderer;
use wcf\system\gridView\renderer\ObjectIdColumnRenderer;
-use wcf\system\gridView\renderer\PhraseColumnRenderer;
use wcf\system\interaction\admin\ContactRecipientInteractions;
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;
/**
@@ -29,7 +28,7 @@
* @license GNU Lesser General Public License
* @since 6.2
*
- * @extends AbstractGridView
+ * @extends AbstractGridView
*/
final class ContactRecipientGridView extends AbstractGridView
{
@@ -42,14 +41,18 @@ public function __construct()
->sortable(),
GridViewColumn::for("name")
->label("wcf.global.name")
- ->filter(I18nTextFilter::class)
+ ->filter(new L10nTextFilter(
+ ContactRecipient::getL10nDefinition(),
+ 'name',
+ 'name',
+ 'wcf.global.name',
+ ))
->titleColumn()
- ->renderer(new PhraseColumnRenderer())
- ->sortable(sortByDatabaseColumn: $this->subqueryName()),
+ ->sortable(sortByDatabaseColumn: 'name'),
GridViewColumn::for("email")
->label("wcf.user.email")
->renderer(new EmailColumnRenderer())
- ->sortable(),
+ ->sortable(sortByDatabaseColumn: $this->getEmailSortExpression()),
GridViewColumn::for("showOrder")
->label("wcf.acp.customOption.showOrder")
->filter(IntegerFilter::class)
@@ -85,23 +88,21 @@ public function isAccessible(): bool
}
#[\Override]
- protected function createObjectList(): ContactRecipientList
+ protected function createObjectList(): L10nContactRecipientList
{
- return new ContactRecipientList();
+ return new L10nContactRecipientList();
}
- private function subqueryName(): string
+ private function getEmailSortExpression(): string
{
- $languageID = WCF::getLanguage()->languageID;
+ // The administrator recipient stores an empty email address and is
+ // displayed with `MAIL_ADMIN_ADDRESS`, which cannot be bound in `ORDER BY`.
+ $administratorEmail = WCF::getDB()->escapeString(\MAIL_ADMIN_ADDRESS);
- return "
- COALESCE((
- SELECT languageItemValue
- FROM wcf1_language_item
- WHERE languageItem = contact_recipient.name
- AND languageID = {$languageID}
- ), contact_recipient.name)
- ";
+ return "CASE
+ WHEN contact_recipient.isAdministrator = 1 THEN '{$administratorEmail}'
+ ELSE email
+ END";
}
#[\Override]
diff --git a/wcfsetup/install/files/lib/system/gridView/admin/CronjobGridView.class.php b/wcfsetup/install/files/lib/system/gridView/admin/CronjobGridView.class.php
index 68ebf956175..257b9099248 100644
--- a/wcfsetup/install/files/lib/system/gridView/admin/CronjobGridView.class.php
+++ b/wcfsetup/install/files/lib/system/gridView/admin/CronjobGridView.class.php
@@ -4,7 +4,7 @@
use wcf\acp\form\CronjobEditForm;
use wcf\data\cronjob\Cronjob;
-use wcf\data\cronjob\I18nCronjobList;
+use wcf\data\cronjob\L10nCronjobList;
use wcf\data\DatabaseObject;
use wcf\data\package\PackageCache;
use wcf\event\gridView\admin\CronjobGridViewInitialized;
@@ -13,14 +13,13 @@
use wcf\system\gridView\GridViewRowLink;
use wcf\system\gridView\renderer\AbstractColumnRenderer;
use wcf\system\gridView\renderer\ObjectIdColumnRenderer;
-use wcf\system\gridView\renderer\PhraseColumnRenderer;
use wcf\system\gridView\renderer\TimeColumnRenderer;
use wcf\system\interaction\admin\CronjobInteractions;
use wcf\system\interaction\bulk\admin\CronjobBulkInteractions;
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\L10nTextFilter;
use wcf\system\view\filter\SelectFilter;
use wcf\system\view\filter\TimeFilter;
use wcf\system\WCF;
@@ -34,7 +33,7 @@
* @license GNU Lesser General Public License
* @since 6.2
*
- * @extends AbstractGridView
+ * @extends AbstractGridView
*/
final class CronjobGridView extends AbstractGridView
{
@@ -60,9 +59,13 @@ public function render(mixed $value, DatabaseObject $row): string
),
GridViewColumn::for('description')
->label('wcf.acp.cronjob.description')
- ->sortable(sortByDatabaseColumn: 'descriptionI18n')
- ->filter(I18nTextFilter::class)
- ->renderer(new PhraseColumnRenderer())
+ ->sortable(sortByDatabaseColumn: 'description')
+ ->filter(new L10nTextFilter(
+ Cronjob::getL10nDefinition(),
+ 'description',
+ 'description',
+ 'wcf.acp.cronjob.description',
+ ))
->titleColumn(),
GridViewColumn::for('packageID')
->label('wcf.acp.package.name')
@@ -126,7 +129,10 @@ public function render(mixed $value, DatabaseObject $row): string
$this->setInteractionProvider($interaction);
$this->setBulkInteractionProvider(new CronjobBulkInteractions());
- $this->addRowLink(new GridViewRowLink(CronjobEditForm::class));
+ $this->addRowLink(new GridViewRowLink(
+ CronjobEditForm::class,
+ isAvailableCallback: static fn(Cronjob $cronjob) => $cronjob->isEditable()
+ ));
$this->setDefaultSortField('description');
}
@@ -137,9 +143,9 @@ public function isAccessible(): bool
}
#[\Override]
- protected function createObjectList(): I18nCronjobList
+ protected function createObjectList(): L10nCronjobList
{
- return new I18nCronjobList();
+ return new L10nCronjobList();
}
#[\Override]
diff --git a/wcfsetup/install/files/lib/system/gridView/admin/CronjobLogGridView.class.php b/wcfsetup/install/files/lib/system/gridView/admin/CronjobLogGridView.class.php
index ac93c717da2..bb069f37723 100644
--- a/wcfsetup/install/files/lib/system/gridView/admin/CronjobLogGridView.class.php
+++ b/wcfsetup/install/files/lib/system/gridView/admin/CronjobLogGridView.class.php
@@ -3,7 +3,7 @@
namespace wcf\system\gridView\admin;
use wcf\data\cronjob\Cronjob;
-use wcf\data\cronjob\I18nCronjobList;
+use wcf\data\cronjob\L10nCronjobList;
use wcf\data\cronjob\log\CronjobLog;
use wcf\data\cronjob\log\CronjobLogList;
use wcf\data\DatabaseObject;
@@ -48,7 +48,8 @@ public function __construct()
->filter(new SelectFilter(
$availableCronjobs,
'cronjobID',
- 'wcf.acp.cronjob'
+ 'wcf.acp.cronjob',
+ labelLanguageItems: false
))
->renderer([
new class($availableCronjobs) extends DefaultColumnRenderer {
@@ -128,8 +129,8 @@ protected function getInitializedEvent(): CronjobLogGridViewInitialized
*/
private function getAvailableCronjobs(): array
{
- $list = new I18nCronjobList();
- $list->sqlOrderBy = 'descriptionI18n';
+ $list = new L10nCronjobList();
+ $list->sqlOrderBy = 'description';
$list->readObjects();
return \array_map(fn(Cronjob $cronjob) => $cronjob->getDescription(), $list->getObjects());
diff --git a/wcfsetup/install/files/lib/system/gridView/admin/LabelGroupGridView.class.php b/wcfsetup/install/files/lib/system/gridView/admin/LabelGroupGridView.class.php
index 22dc05eddc7..5c528222cd0 100644
--- a/wcfsetup/install/files/lib/system/gridView/admin/LabelGroupGridView.class.php
+++ b/wcfsetup/install/files/lib/system/gridView/admin/LabelGroupGridView.class.php
@@ -5,7 +5,7 @@
use wcf\acp\form\LabelGroupEditForm;
use wcf\acp\page\LabelListPage;
use wcf\data\DatabaseObject;
-use wcf\data\label\group\I18nLabelGroupList;
+use wcf\data\label\group\L10nLabelGroupList;
use wcf\data\label\group\LabelGroup;
use wcf\event\gridView\admin\LabelGroupGridViewInitialized;
use wcf\system\gridView\AbstractGridView;
@@ -14,14 +14,13 @@
use wcf\system\gridView\renderer\ILinkColumnRenderer;
use wcf\system\gridView\renderer\NumberColumnRenderer;
use wcf\system\gridView\renderer\ObjectIdColumnRenderer;
-use wcf\system\gridView\renderer\PhraseColumnRenderer;
use wcf\system\gridView\renderer\TruncatedTextColumnRenderer;
use wcf\system\interaction\admin\LabelGroupInteractions;
use wcf\system\interaction\Divider;
use wcf\system\interaction\EditInteraction;
use wcf\system\request\LinkHandler;
-use wcf\system\view\filter\I18nTextFilter;
use wcf\system\view\filter\IntegerFilter;
+use wcf\system\view\filter\L10nTextFilter;
use wcf\system\view\filter\TextFilter;
use wcf\system\WCF;
@@ -33,7 +32,7 @@
* @license GNU Lesser General Public License
* @since 6.2
*
- * @extends AbstractGridView
+ * @extends AbstractGridView
*/
final class LabelGroupGridView extends AbstractGridView
{
@@ -47,9 +46,13 @@ public function __construct()
GridViewColumn::for('groupName')
->label('wcf.global.title')
->titleColumn()
- ->renderer(new PhraseColumnRenderer())
- ->filter(I18nTextFilter::class)
- ->sortable(sortByDatabaseColumn: 'groupNameI18n'),
+ ->filter(new L10nTextFilter(
+ LabelGroup::getL10nDefinition(),
+ 'groupName',
+ 'groupName',
+ 'wcf.global.title',
+ ))
+ ->sortable(sortByDatabaseColumn: 'groupName'),
GridViewColumn::for('groupDescription')
->label('wcf.global.description')
->filter(TextFilter::class)
@@ -104,9 +107,9 @@ public function isAccessible(): bool
}
#[\Override]
- protected function createObjectList(): I18nLabelGroupList
+ protected function createObjectList(): L10nLabelGroupList
{
- $list = new I18nLabelGroupList();
+ $list = new L10nLabelGroupList();
if ($list->sqlSelects !== '') {
$list->sqlSelects .= ', ';
}
diff --git a/wcfsetup/install/files/lib/system/gridView/admin/PaidSubscriptionGridView.class.php b/wcfsetup/install/files/lib/system/gridView/admin/PaidSubscriptionGridView.class.php
index 89d58d7ab90..5161da5131a 100644
--- a/wcfsetup/install/files/lib/system/gridView/admin/PaidSubscriptionGridView.class.php
+++ b/wcfsetup/install/files/lib/system/gridView/admin/PaidSubscriptionGridView.class.php
@@ -4,7 +4,7 @@
use wcf\acp\form\PaidSubscriptionEditForm;
use wcf\data\DatabaseObject;
-use wcf\data\paid\subscription\I18nPaidSubscriptionList;
+use wcf\data\paid\subscription\L10nPaidSubscriptionList;
use wcf\data\paid\subscription\PaidSubscription;
use wcf\event\gridView\admin\PaidSubscriptionGridViewInitialized;
use wcf\system\gridView\AbstractGridView;
@@ -12,15 +12,14 @@
use wcf\system\gridView\GridViewRowLink;
use wcf\system\gridView\renderer\NumberColumnRenderer;
use wcf\system\gridView\renderer\ObjectIdColumnRenderer;
-use wcf\system\gridView\renderer\PhraseColumnRenderer;
use wcf\system\interaction\admin\PaidSubscriptionInteractions;
use wcf\system\interaction\Divider;
use wcf\system\interaction\EditInteraction;
use wcf\system\interaction\ToggleInteraction;
use wcf\system\payment\method\PaymentMethodHandler;
use wcf\system\view\filter\FloatFilter;
-use wcf\system\view\filter\I18nTextFilter;
use wcf\system\view\filter\IntegerFilter;
+use wcf\system\view\filter\L10nTextFilter;
use wcf\system\view\filter\SelectFilter;
use wcf\system\WCF;
@@ -32,7 +31,7 @@
* @license GNU Lesser General Public License
* @since 6.2
*
- * @extends AbstractGridView
+ * @extends AbstractGridView
*/
final class PaidSubscriptionGridView extends AbstractGridView
{
@@ -46,9 +45,13 @@ public function __construct()
GridViewColumn::for('title')
->label('wcf.global.title')
->titleColumn()
- ->renderer(new PhraseColumnRenderer())
- ->filter(I18nTextFilter::class)
- ->sortable(sortByDatabaseColumn: 'titleI18n'),
+ ->filter(new L10nTextFilter(
+ PaidSubscription::getL10nDefinition(),
+ 'title',
+ 'title',
+ 'wcf.global.title',
+ ))
+ ->sortable(sortByDatabaseColumn: 'title'),
GridViewColumn::for('cost')
->label('wcf.acp.paidSubscription.cost')
->sortable()
@@ -97,7 +100,12 @@ public function render(mixed $value, DatabaseObject $row): string
]);
$this->addAvailableFilters([
- new I18nTextFilter('description', 'wcf.global.description'),
+ new L10nTextFilter(
+ PaidSubscription::getL10nDefinition(),
+ 'description',
+ 'description',
+ 'wcf.global.description',
+ ),
new SelectFilter($this->getAvailableCurrencies(), 'currency', 'wcf.acp.paidSubscription.currency'),
]);
$provider = new PaidSubscriptionInteractions();
@@ -145,9 +153,9 @@ public function isAccessible(): bool
}
#[\Override]
- protected function createObjectList(): I18nPaidSubscriptionList
+ protected function createObjectList(): L10nPaidSubscriptionList
{
- return new I18nPaidSubscriptionList();
+ return new L10nPaidSubscriptionList();
}
#[\Override]
diff --git a/wcfsetup/install/files/lib/system/gridView/admin/PaidSubscriptionUserGridView.class.php b/wcfsetup/install/files/lib/system/gridView/admin/PaidSubscriptionUserGridView.class.php
index 7e3166fba41..3d10a8bc6c2 100644
--- a/wcfsetup/install/files/lib/system/gridView/admin/PaidSubscriptionUserGridView.class.php
+++ b/wcfsetup/install/files/lib/system/gridView/admin/PaidSubscriptionUserGridView.class.php
@@ -12,12 +12,12 @@
use wcf\system\gridView\AbstractGridView;
use wcf\system\gridView\GridViewColumn;
use wcf\system\gridView\renderer\ObjectIdColumnRenderer;
-use wcf\system\gridView\renderer\PhraseColumnRenderer;
use wcf\system\gridView\renderer\TimeColumnRenderer;
use wcf\system\gridView\renderer\UserLinkColumnRenderer;
use wcf\system\interaction\admin\PaidSubscriptionUserInteractions;
use wcf\system\interaction\Divider;
use wcf\system\interaction\EditInteraction;
+use wcf\system\l10n\L10nStorage;
use wcf\system\view\filter\SelectFilter;
use wcf\system\view\filter\TimeFilter;
use wcf\system\view\filter\UserFilter;
@@ -56,8 +56,7 @@ public function __construct()
'wcf.acp.paidSubscription.subscription',
'paid_subscription_user.subscriptionID'
))
- ->renderer(new PhraseColumnRenderer())
- ->sortable(sortByDatabaseColumn: "paid_subscription.title"),
+ ->sortable(sortByDatabaseColumn: 'title'),
GridViewColumn::for('endDate')
->label('wcf.acp.paidSubscription.user.endDate')
->renderer(new TimeColumnRenderer())
@@ -102,7 +101,8 @@ public function isAccessible(): bool
protected function createObjectList(): PaidSubscriptionUserList
{
$list = new PaidSubscriptionUserList();
- $list->sqlSelects = "paid_subscription.title";
+ $list->sqlSelects = (new L10nStorage(PaidSubscription::getL10nDefinition()))
+ ->getSubSelect('title', 'paid_subscription') . ' AS title';
$list->sqlJoins = "
LEFT JOIN wcf1_user user_table
ON user_table.userID = paid_subscription_user.userID
diff --git a/wcfsetup/install/files/lib/system/gridView/admin/ReactionTypeGridView.class.php b/wcfsetup/install/files/lib/system/gridView/admin/ReactionTypeGridView.class.php
index d2e213d78d9..0ec4d81776f 100644
--- a/wcfsetup/install/files/lib/system/gridView/admin/ReactionTypeGridView.class.php
+++ b/wcfsetup/install/files/lib/system/gridView/admin/ReactionTypeGridView.class.php
@@ -4,7 +4,7 @@
use wcf\acp\form\ReactionTypeEditForm;
use wcf\data\DatabaseObject;
-use wcf\data\reaction\type\I18nReactionTypeList;
+use wcf\data\reaction\type\L10nReactionTypeList;
use wcf\data\reaction\type\ReactionType;
use wcf\event\gridView\admin\ReactionTypeGridViewInitialized;
use wcf\system\cache\runtime\FileRuntimeCache;
@@ -14,14 +14,13 @@
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\ReactionTypeInteractions;
use wcf\system\interaction\bulk\admin\ReactionTypeBulkInteractions;
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;
/**
@@ -32,7 +31,7 @@
* @license GNU Lesser General Public License
* @since 6.2
*
- * @extends AbstractGridView
+ * @extends AbstractGridView
*/
final class ReactionTypeGridView extends AbstractGridView
{
@@ -75,9 +74,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(
+ ReactionType::getL10nDefinition(),
+ "title",
+ "title",
+ "wcf.global.title",
+ ))
+ ->sortable(sortByDatabaseColumn: "title"),
GridViewColumn::for("showOrder")
->label("wcf.global.showOrder")
->renderer(new NumberColumnRenderer())
@@ -115,9 +118,9 @@ public function isAccessible(): bool
}
#[\Override]
- protected function createObjectList(): I18nReactionTypeList
+ protected function createObjectList(): L10nReactionTypeList
{
- return new I18nReactionTypeList();
+ return new L10nReactionTypeList();
}
#[\Override]
diff --git a/wcfsetup/install/files/lib/system/gridView/admin/UserOptionGridView.class.php b/wcfsetup/install/files/lib/system/gridView/admin/UserOptionGridView.class.php
index d2b78bf9ccf..03ea0bb18c8 100644
--- a/wcfsetup/install/files/lib/system/gridView/admin/UserOptionGridView.class.php
+++ b/wcfsetup/install/files/lib/system/gridView/admin/UserOptionGridView.class.php
@@ -4,6 +4,7 @@
use wcf\acp\form\UserOptionEditForm;
use wcf\data\DatabaseObject;
+use wcf\data\user\option\L10nUserOptionList;
use wcf\data\user\option\UserOption;
use wcf\data\user\option\UserOptionList;
use wcf\event\gridView\admin\UserOptionGridViewInitialized;
@@ -40,21 +41,10 @@ public function __construct()
->label('wcf.global.objectID')
->renderer(new ObjectIdColumnRenderer())
->sortable(),
- GridViewColumn::for('optionName')
+ GridViewColumn::for('title')
->label('wcf.global.name')
- ->sortable()
->titleColumn()
- ->renderer([
- new class extends DefaultColumnRenderer {
- #[\Override]
- public function render(mixed $value, DatabaseObject $row): string
- {
- \assert($row instanceof UserOption);
-
- return StringUtil::encodeHTML($row->getTitle());
- }
- }
- ]),
+ ->sortable(sortByDatabaseColumn: 'title'),
GridViewColumn::for('categoryName')
->label('wcf.global.category')
->sortable()
@@ -107,7 +97,7 @@ public function isAccessible(): bool
#[\Override]
protected function createObjectList(): UserOptionList
{
- $list = new UserOptionList();
+ $list = new L10nUserOptionList();
$list->getConditionBuilder()->add(
"option_table.categoryName IN (
SELECT categoryName
diff --git a/wcfsetup/install/files/lib/system/gridView/admin/UserRankGridView.class.php b/wcfsetup/install/files/lib/system/gridView/admin/UserRankGridView.class.php
index 8c87f7c908e..c5fd49d0534 100644
--- a/wcfsetup/install/files/lib/system/gridView/admin/UserRankGridView.class.php
+++ b/wcfsetup/install/files/lib/system/gridView/admin/UserRankGridView.class.php
@@ -5,7 +5,7 @@
use wcf\acp\form\UserRankEditForm;
use wcf\data\DatabaseObject;
use wcf\data\user\group\UserGroup;
-use wcf\data\user\rank\I18nUserRankList;
+use wcf\data\user\rank\L10nUserRankList;
use wcf\data\user\rank\UserRank;
use wcf\event\gridView\admin\UserRankGridViewInitialized;
use wcf\system\cache\runtime\FileRuntimeCache;
@@ -19,8 +19,8 @@
use wcf\system\interaction\bulk\admin\UserRankBulkInteractions;
use wcf\system\interaction\Divider;
use wcf\system\interaction\EditInteraction;
-use wcf\system\view\filter\I18nTextFilter;
use wcf\system\view\filter\IntegerFilter;
+use wcf\system\view\filter\L10nTextFilter;
use wcf\system\view\filter\SelectFilter;
use wcf\system\WCF;
use wcf\util\StringUtil;
@@ -33,7 +33,7 @@
* @license GNU Lesser General Public License
* @since 6.2
*
- * @extends AbstractGridView
+ * @extends AbstractGridView
*/
final class UserRankGridView extends AbstractGridView
{
@@ -46,9 +46,14 @@ public function __construct()
->sortable(),
GridViewColumn::for('rankTitle')
->label('wcf.acp.user.rank.title')
- ->sortable(true, 'rankTitleI18n')
+ ->sortable(sortByDatabaseColumn: 'rankTitle')
->titleColumn()
- ->filter(I18nTextFilter::class)
+ ->filter(new L10nTextFilter(
+ UserRank::getL10nDefinition(),
+ 'rankTitle',
+ 'rankTitle',
+ 'wcf.acp.user.rank.title',
+ ))
->renderer([
new class extends DefaultColumnRenderer {
#[\Override]
@@ -57,7 +62,7 @@ public function render(mixed $value, DatabaseObject $row): string
\assert($row instanceof UserRank);
return ''
- . StringUtil::encodeHTML($row->getTitle())
+ . ($value ?? '')
. ' ';
}
}
@@ -149,9 +154,9 @@ public function isAccessible(): bool
}
#[\Override]
- protected function createObjectList(): I18nUserRankList
+ protected function createObjectList(): L10nUserRankList
{
- return new I18nUserRankList();
+ return new L10nUserRankList();
}
#[\Override]
diff --git a/wcfsetup/install/files/lib/system/importer/AbstractLikeImporter.class.php b/wcfsetup/install/files/lib/system/importer/AbstractLikeImporter.class.php
index bed6cc04bba..7e526509a1b 100644
--- a/wcfsetup/install/files/lib/system/importer/AbstractLikeImporter.class.php
+++ b/wcfsetup/install/files/lib/system/importer/AbstractLikeImporter.class.php
@@ -3,10 +3,9 @@
namespace wcf\system\importer;
use wcf\command\file\CreateFileFromExistingFile;
-use wcf\data\language\item\LanguageItemEditor;
use wcf\data\like\Like;
use wcf\data\reaction\type\ReactionType;
-use wcf\data\reaction\type\ReactionTypeEditor;
+use wcf\data\reaction\type\ReactionTypeBuilder;
use wcf\system\language\LanguageFactory;
use wcf\system\reaction\ReactionHandler;
use wcf\system\WCF;
@@ -111,30 +110,16 @@ protected static function getDislikeReactionTypeID()
copy: true,
)();
- $reaction = ReactionTypeEditor::create([
- 'iconFileID' => $file?->fileID,
- 'showOrder' => $showOrder + 1,
- ]);
-
- $sql = "SELECT languageCategoryID
- FROM wcf1_language_category
- WHERE languageCategory = ?";
- $statement = WCF::getDB()->prepare($sql, 1);
- $statement->execute(['wcf.reactionType']);
- $languageCategoryID = $statement->fetchSingleColumn();
-
+ $title = [];
foreach (LanguageFactory::getInstance()->getLanguages() as $language) {
- LanguageItemEditor::create([
- 'languageID' => $language->languageID,
- 'languageItem' => 'wcf.reactionType.title' . $reaction->reactionTypeID,
- 'languageItemValue' => ($language->getFixedLanguageCode() === 'de' ? 'Gefällt mir nicht' : 'Dislike'),
- 'languageCategoryID' => $languageCategoryID,
- 'packageID' => 1,
- ]);
+ $title[$language->languageID] = $language->getFixedLanguageCode() === 'de' ? 'Gefällt mir nicht' : 'Dislike';
}
- $editor = new ReactionTypeEditor($reaction);
- $editor->update(['title' => 'wcf.reactionType.title' . $reaction->reactionTypeID]);
+ $reaction = ReactionTypeBuilder::forCreate()
+ ->setTitle($title)
+ ->setShowOrder($showOrder + 1)
+ ->setIconFileID($file?->fileID)
+ ->create();
}
self::$dislikeReactionTypeID = $reaction->reactionTypeID;
diff --git a/wcfsetup/install/files/lib/system/importer/LabelGroupImporter.class.php b/wcfsetup/install/files/lib/system/importer/LabelGroupImporter.class.php
index 9a0c6d7af4d..eb8233f6f6f 100644
--- a/wcfsetup/install/files/lib/system/importer/LabelGroupImporter.class.php
+++ b/wcfsetup/install/files/lib/system/importer/LabelGroupImporter.class.php
@@ -3,7 +3,8 @@
namespace wcf\system\importer;
use wcf\data\label\group\LabelGroup;
-use wcf\data\label\group\LabelGroupEditor;
+use wcf\data\label\group\LabelGroupBuilder;
+use wcf\system\l10n\L10nStorage;
use wcf\system\WCF;
/**
@@ -24,7 +25,22 @@ class LabelGroupImporter extends AbstractImporter
public function import(mixed $oldID, array $data, array $additionalData = [])
{
// save label group
- $labelGroup = LabelGroupEditor::create($data);
+ $builder = LabelGroupBuilder::forCreate()
+ ->setGroupName([L10nStorage::MONOLINGUAL => (string)$data['groupName']]);
+
+ $handledColumns = ['groupID', 'groupName'];
+ foreach ($data as $key => $value) {
+ if (\in_array($key, $handledColumns, true)) {
+ continue;
+ }
+ if ($value !== null && !\is_string($value) && !\is_int($value) && !\is_float($value)) {
+ continue;
+ }
+
+ $builder->setCustomProperty($key, $value);
+ }
+
+ $labelGroup = $builder->create();
// save objects
if (!empty($additionalData['objects'])) {
diff --git a/wcfsetup/install/files/lib/system/importer/ReactionTypeImporter.class.php b/wcfsetup/install/files/lib/system/importer/ReactionTypeImporter.class.php
index 54e37b0336b..d39f6582e4f 100644
--- a/wcfsetup/install/files/lib/system/importer/ReactionTypeImporter.class.php
+++ b/wcfsetup/install/files/lib/system/importer/ReactionTypeImporter.class.php
@@ -4,7 +4,8 @@
use wcf\command\file\CreateFileFromExistingFile;
use wcf\data\reaction\type\ReactionType;
-use wcf\data\reaction\type\ReactionTypeEditor;
+use wcf\data\reaction\type\ReactionTypeBuilder;
+use wcf\system\l10n\L10nStorage;
/**
* Imports reaction types.
@@ -34,11 +35,25 @@ public function import(mixed $oldID, array $data, array $additionalData = [])
return 0;
}
- unset($data['iconFile']);
- $data['iconFileID'] = $file->fileID;
+ // Imported reaction types are owned by the administrator: they are not
+ // linked to a language variable and their title is monolingual.
+ $builder = ReactionTypeBuilder::forCreate()
+ ->setTitle([L10nStorage::MONOLINGUAL => (string)$data['title']])
+ ->setIconFileID($file->fileID);
+
+ $handledColumns = ['reactionTypeID', 'title', 'iconFile', 'iconFileID', 'l10nIdentifier'];
+ foreach ($data as $key => $value) {
+ if (\in_array($key, $handledColumns, true)) {
+ continue;
+ }
+ if ($value !== null && !\is_string($value) && !\is_int($value) && !\is_float($value)) {
+ continue;
+ }
+
+ $builder->setCustomProperty($key, $value);
+ }
- /** @var ReactionType $reactionType */
- $reactionType = ReactionTypeEditor::create($data);
+ $reactionType = $builder->create();
ImportHandler::getInstance()->saveNewID('com.woltlab.wcf.reactionType', $oldID, $reactionType->reactionTypeID);
diff --git a/wcfsetup/install/files/lib/system/importer/UserOptionImporter.class.php b/wcfsetup/install/files/lib/system/importer/UserOptionImporter.class.php
index 5b8b9fb35b7..d8df9396f58 100644
--- a/wcfsetup/install/files/lib/system/importer/UserOptionImporter.class.php
+++ b/wcfsetup/install/files/lib/system/importer/UserOptionImporter.class.php
@@ -5,11 +5,8 @@
use wcf\data\user\option\category\UserOptionCategoryEditor;
use wcf\data\user\option\category\UserOptionCategoryList;
use wcf\data\user\option\UserOption;
-use wcf\data\user\option\UserOptionAction;
-use wcf\data\user\option\UserOptionEditor;
-use wcf\system\language\LanguageFactory;
-use wcf\system\WCF;
-use wcf\util\StringUtil;
+use wcf\data\user\option\UserOptionBuilder;
+use wcf\system\l10n\L10nStorage;
/**
* Imports user options.
@@ -25,37 +22,15 @@ class UserOptionImporter extends AbstractImporter
*/
protected $className = UserOption::class;
- /**
- * language category id
- * @var int
- */
- protected $languageCategoryID;
-
/**
* list of available user option categories
* @var string[]|null
*/
protected $categoryCache;
- public function __construct()
- {
- // get language category id
- $sql = "SELECT languageCategoryID
- FROM wcf1_language_category
- WHERE languageCategory = ?";
- $statement = WCF::getDB()->prepare($sql);
- $statement->execute(['wcf.user.option']);
- $row = $statement->fetchArray();
- $this->languageCategoryID = $row['languageCategoryID'];
- }
-
#[\Override]
public function import(mixed $oldID, array $data, array $additionalData = [])
{
- $data['packageID'] = 1;
- // set temporary option name
- $data['optionName'] = StringUtil::getRandomID();
-
if ($data['optionType'] === 'boolean' || $data['optionType'] === 'integer') {
if (isset($data['defaultValue'])) {
$data['defaultValue'] = \intval($data['defaultValue']);
@@ -65,30 +40,31 @@ public function import(mixed $oldID, array $data, array $additionalData = [])
// create category
$this->createCategory($data['categoryName']);
- // save option
- $action = new UserOptionAction([], 'create', ['data' => $data]);
- $returnValues = $action->executeAction();
- $userOption = $returnValues['returnValues'];
+ // Imported options are owned by the administrator: they are not linked
+ // to a language variable (`l10nIdentifier` stays `NULL`) and their
+ // localized title/description are stored as monolingual values.
+ $builder = UserOptionBuilder::forCreate()
+ ->setGenericOptionName()
+ ->setPackageID(1)
+ ->setOptionType($data['optionType'])
+ ->setCategoryName($data['categoryName'])
+ ->setL10nTitle([L10nStorage::MONOLINGUAL => (string)$additionalData['name']])
+ ->setL10nDescription([L10nStorage::MONOLINGUAL => (string)($additionalData['description'] ?? '')]);
+
+ // carry over the remaining columns of the imported option
+ $handledColumns = ['optionName', 'packageID', 'optionType', 'categoryName', 'l10nIdentifier'];
+ foreach ($data as $key => $value) {
+ if (\in_array($key, $handledColumns, true)) {
+ continue;
+ }
+ if ($value !== null && !\is_string($value) && !\is_int($value) && !\is_float($value)) {
+ continue;
+ }
- // update generic option name
- $editor = new UserOptionEditor($userOption);
- $editor->update([
- 'optionName' => 'option' . $userOption->optionID,
- ]);
+ $builder->setCustomProperty($key, $value);
+ }
- // save name
- $sql = "INSERT IGNORE INTO wcf1_language_item
- (languageID, languageItem, languageItemValue, languageItemOriginIsSystem, languageCategoryID, packageID)
- VALUES (?, ?, ?, ?, ?, ?)";
- $statement = WCF::getDB()->prepare($sql);
- $statement->execute([
- LanguageFactory::getInstance()->getDefaultLanguageID(),
- 'wcf.user.option.option' . $userOption->optionID,
- $additionalData['name'],
- 0,
- $this->languageCategoryID,
- 1,
- ]);
+ $userOption = $builder->create();
ImportHandler::getInstance()->saveNewID('com.woltlab.wcf.user.option', $oldID, $userOption->optionID);
diff --git a/wcfsetup/install/files/lib/system/importer/UserRankImporter.class.php b/wcfsetup/install/files/lib/system/importer/UserRankImporter.class.php
index 52a83313960..959980a79a5 100644
--- a/wcfsetup/install/files/lib/system/importer/UserRankImporter.class.php
+++ b/wcfsetup/install/files/lib/system/importer/UserRankImporter.class.php
@@ -5,7 +5,9 @@
use wcf\command\file\CreateFileFromExistingFile;
use wcf\data\user\group\UserGroup;
use wcf\data\user\rank\UserRank;
-use wcf\data\user\rank\UserRankEditor;
+use wcf\data\user\rank\UserRankBuilder;
+use wcf\system\l10n\L10nLanguageItemSync;
+use wcf\system\l10n\L10nStorage;
/**
* Imports user ranks.
@@ -21,6 +23,17 @@ class UserRankImporter extends AbstractImporter
*/
protected $className = UserRank::class;
+ private const SHIPPED_LANGUAGE_ITEMS = [
+ 'wcf.user.rank.administrator',
+ 'wcf.user.rank.moderator',
+ 'wcf.user.rank.user0',
+ 'wcf.user.rank.user1',
+ 'wcf.user.rank.user2',
+ 'wcf.user.rank.user3',
+ 'wcf.user.rank.user4',
+ 'wcf.user.rank.user5',
+ ];
+
#[\Override]
public function import(mixed $oldID, array $data, array $additionalData = [])
{
@@ -29,10 +42,37 @@ public function import(mixed $oldID, array $data, array $additionalData = [])
$data['groupID'] = UserGroup::getGroupByType(UserGroup::USERS)->groupID;
}
- $data['rankImageFileID'] = $this->importImage($data['rankImage'] ?? '');
- unset($data['rankImage']);
+ $rankTitle = (string)$data['rankTitle'];
+ $builder = UserRankBuilder::forCreate()
+ ->setGroupID($data['groupID'])
+ ->setRankImageFileID($this->importImage($data['rankImage'] ?? ''));
+
+ // Exporters of WoltLab Suite pass the title of the default ranks as the
+ // name of the language variable shipped with the package.
+ $isShippedRank = \in_array($rankTitle, self::SHIPPED_LANGUAGE_ITEMS, true);
+ if ($isShippedRank) {
+ $builder->setL10nIdentifier($rankTitle);
+ } else {
+ $builder->setRankTitle([L10nStorage::MONOLINGUAL => $rankTitle]);
+ }
+
+ $handledColumns = ['rankID', 'groupID', 'rankTitle', 'rankImage', 'rankImageFileID', 'l10nIdentifier'];
+ foreach ($data as $key => $value) {
+ if (\in_array($key, $handledColumns, true)) {
+ continue;
+ }
+ if ($value !== null && !\is_string($value) && !\is_int($value) && !\is_float($value)) {
+ continue;
+ }
- $rank = UserRankEditor::create($data);
+ $builder->setCustomProperty($key, $value);
+ }
+
+ $rank = $builder->create();
+
+ if ($isShippedRank) {
+ L10nLanguageItemSync::sync(UserRank::getL10nDefinition());
+ }
ImportHandler::getInstance()->saveNewID('com.woltlab.wcf.user.rank', $oldID, $rank->rankID);
diff --git a/wcfsetup/install/files/lib/system/l10n/L10nDefinition.class.php b/wcfsetup/install/files/lib/system/l10n/L10nDefinition.class.php
new file mode 100644
index 00000000000..e47847586e2
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/l10n/L10nDefinition.class.php
@@ -0,0 +1,97 @@
+
+ * @since 6.3
+ */
+final class L10nDefinition
+{
+ /**
+ * @param string $primaryTableName name of the primary table
+ * @param string $l10nTableName name of the localized table
+ * @param string $objectColumnName name of the primary key column shared by both tables
+ * @param list $columnNames names of the localized payload columns
+ * @param ?string $identifierColumnName name of the primary table column holding the language variable base name, `null` if the definition does not support the synchronization with language variables
+ * @param array $languageItemSuffixes maps each payload column to the suffix appended to the identifier to form its language variable name
+ * @param array $maximumLengths maps payload columns of a limited character length (`VARCHAR`) to that length
+ */
+ public function __construct(
+ public readonly string $primaryTableName,
+ public readonly string $l10nTableName,
+ public readonly string $objectColumnName,
+ public readonly array $columnNames,
+ public readonly ?string $identifierColumnName = null,
+ public readonly array $languageItemSuffixes = [],
+ public readonly array $maximumLengths = [],
+ ) {
+ if (!\str_ends_with($l10nTableName, '_l10n')) {
+ throw new \InvalidArgumentException(
+ "The localized table name '{$l10nTableName}' must use the '_l10n' suffix."
+ );
+ }
+
+ if ($columnNames === []) {
+ throw new \InvalidArgumentException('At least one localized column must be defined.');
+ }
+
+ if ($identifierColumnName !== null) {
+ foreach ($columnNames as $columnName) {
+ if (!\array_key_exists($columnName, $languageItemSuffixes)) {
+ throw new \InvalidArgumentException(
+ "Missing language variable suffix for the localized column '{$columnName}'."
+ );
+ }
+ }
+ }
+
+ foreach ($maximumLengths as $columnName => $maximumLength) {
+ if (!\in_array($columnName, $columnNames, true)) {
+ throw new \InvalidArgumentException(
+ "Cannot set the maximum length of the unknown localized column '{$columnName}'."
+ );
+ }
+ }
+ }
+
+ /**
+ * Shortens the given value to the maximum length of the given column.
+ * Values from language variables are not limited in length and must be
+ * passed through this method before they are written.
+ */
+ public function truncateValue(string $columnName, string $value): string
+ {
+ if (!isset($this->maximumLengths[$columnName])) {
+ return $value;
+ }
+
+ return \mb_substr($value, 0, $this->maximumLengths[$columnName]);
+ }
+
+ /**
+ * Returns whether this definition supports the synchronization of its
+ * localized values with language variables.
+ */
+ public function supportsLanguageItemSync(): bool
+ {
+ return $this->identifierColumnName !== null;
+ }
+}
diff --git a/wcfsetup/install/files/lib/system/l10n/L10nLanguageItemSource.class.php b/wcfsetup/install/files/lib/system/l10n/L10nLanguageItemSource.class.php
new file mode 100644
index 00000000000..176699f45c2
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/l10n/L10nLanguageItemSource.class.php
@@ -0,0 +1,26 @@
+
+ * @since 6.3
+ */
+final class L10nLanguageItemSource
+{
+ public function __construct(
+ public readonly ?string $languageItem = null,
+ public readonly ?string $literal = null,
+ public readonly bool $deleteAfterMigration = false,
+ ) {}
+}
diff --git a/wcfsetup/install/files/lib/system/l10n/L10nLanguageItemSync.class.php b/wcfsetup/install/files/lib/system/l10n/L10nLanguageItemSync.class.php
new file mode 100644
index 00000000000..0a672dd1af8
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/l10n/L10nLanguageItemSync.class.php
@@ -0,0 +1,322 @@
+
+ * @since 6.3
+ *
+ * @phpstan-import-type L10nValue from L10nStorage
+ */
+final class L10nLanguageItemSync
+{
+ /**
+ * Migrates the existing values of a database object into its `*_l10n`
+ * table.
+ *
+ * `$rowMapper` is called for every row of the primary table and returns
+ * either `null` (skip the row) or an array with the key `sources`
+ * (`columnName => L10nLanguageItemSource` for every localized column).
+ * Language variables of sources flagged with `deleteAfterMigration` are
+ * removed afterwards. Objects linked to a language variable are seeded as
+ * pristine, everything else is written as owned content (see `L10nStorage`).
+ *
+ * @param \Closure(array): (array{sources: array}|null) $rowMapper
+ */
+ public static function migrate(L10nDefinition $definition, \Closure $rowMapper): void
+ {
+ $storage = new L10nStorage($definition);
+ $installedLanguageIDs = self::getInstalledLanguageIDs();
+ $defaultLanguageID = LanguageFactory::getInstance()->getDefaultLanguageID();
+ $fetchItems = self::createItemReader();
+
+ $sql = "SELECT *
+ FROM {$definition->primaryTableName}";
+ $statement = WCF::getDB()->prepare($sql);
+ $statement->execute();
+ $rows = $statement->fetchAll(\PDO::FETCH_ASSOC);
+
+ $obsoleteItems = [];
+
+ WCF::getDB()->beginTransaction();
+ $committed = false;
+ try {
+ foreach ($rows as $row) {
+ $descriptor = $rowMapper($row);
+ if ($descriptor === null) {
+ continue;
+ }
+
+ $objectID = (int)$row[$definition->objectColumnName];
+
+ $columnItems = [];
+ foreach ($definition->columnNames as $columnName) {
+ $source = $descriptor['sources'][$columnName];
+
+ $items = null;
+ if ($source->languageItem !== null) {
+ $fetched = \array_intersect_key(
+ $fetchItems($source->languageItem),
+ \array_flip($installedLanguageIDs)
+ );
+ if ($fetched !== []) {
+ $items = $fetched;
+ if ($source->deleteAfterMigration) {
+ $obsoleteItems[] = $source->languageItem;
+ }
+ }
+ }
+
+ $columnItems[$columnName] = $items;
+ }
+
+ $values = self::buildValues(
+ $definition,
+ $columnItems,
+ static fn(string $columnName): ?string => $descriptor['sources'][$columnName]->literal,
+ $defaultLanguageID
+ );
+
+ // Objects linked to a language variable are delivered content
+ // and seeded as pristine so the synchronization keeps them up to
+ // date; everything else is owned by the administrator.
+ $identifier = $definition->supportsLanguageItemSync()
+ ? ($row[$definition->identifierColumnName] ?? null)
+ : null;
+ if ($identifier !== null) {
+ $storage->syncValues($objectID, $values);
+ } else {
+ $storage->setValues($objectID, $values);
+ }
+ }
+
+ WCF::getDB()->commitTransaction();
+ $committed = true;
+ } finally {
+ if (!$committed) {
+ WCF::getDB()->rollBackTransaction();
+ }
+ }
+
+ self::deleteLanguageItems($obsoleteItems);
+ }
+
+ /**
+ * Refreshes the localized values of every object that is linked to a
+ * language variable from the current phrase values. Only pristine rows are
+ * updated, see `L10nStorage::syncValues()`.
+ */
+ public static function sync(L10nDefinition $definition): void
+ {
+ if (!$definition->supportsLanguageItemSync()) {
+ throw new \InvalidArgumentException(
+ "The l10n definition of '{$definition->l10nTableName}' does not support the synchronization with language variables."
+ );
+ }
+
+ $storage = new L10nStorage($definition);
+ $installedLanguageIDs = self::getInstalledLanguageIDs();
+ $defaultLanguageID = LanguageFactory::getInstance()->getDefaultLanguageID();
+ $fetchItems = self::createItemReader();
+
+ $sql = "SELECT {$definition->objectColumnName}, {$definition->identifierColumnName}
+ FROM {$definition->primaryTableName}
+ WHERE {$definition->identifierColumnName} IS NOT NULL";
+ $statement = WCF::getDB()->prepare($sql);
+ $statement->execute();
+
+ while ($row = $statement->fetchArray()) {
+ $objectID = (int)$row[$definition->objectColumnName];
+ $identifier = $row[$definition->identifierColumnName];
+
+ $columnItems = [];
+ $hasItems = false;
+ foreach ($definition->columnNames as $columnName) {
+ $languageItem = $identifier . $definition->languageItemSuffixes[$columnName];
+ $items = \array_intersect_key(
+ $fetchItems($languageItem),
+ \array_flip($installedLanguageIDs)
+ );
+ $columnItems[$columnName] = $items !== [] ? $items : null;
+ $hasItems = $hasItems || $items !== [];
+ }
+
+ if (!$hasItems) {
+ continue;
+ }
+
+ $values = self::buildValues(
+ $definition,
+ $columnItems,
+ static fn(string $columnName): ?string => null,
+ $defaultLanguageID
+ );
+
+ $storage->syncValues($objectID, $values);
+ }
+ }
+
+ /**
+ * Builds the `columnName => [languageID => value]` map from the resolved
+ * language items of each column. Writes a single monolingual row when none
+ * of the columns is backed by a language variable. Language variables are
+ * not limited in length, every value is shortened to its column's length.
+ *
+ * @param array $columnItems
+ * @param \Closure(string): ?string $literalProvider
+ * @return array
+ */
+ private static function buildValues(
+ L10nDefinition $definition,
+ array $columnItems,
+ \Closure $literalProvider,
+ int $defaultLanguageID
+ ): array {
+ $languageIDs = [];
+ foreach ($columnItems as $items) {
+ if ($items !== null) {
+ $languageIDs = [...$languageIDs, ...\array_keys($items)];
+ }
+ }
+ $languageIDs = \array_values(\array_unique($languageIDs));
+
+ $values = [];
+ if ($languageIDs === []) {
+ foreach ($definition->columnNames as $columnName) {
+ $values[$columnName] = [
+ L10nStorage::MONOLINGUAL => $definition->truncateValue(
+ $columnName,
+ $literalProvider($columnName) ?? ''
+ ),
+ ];
+ }
+
+ return $values;
+ }
+
+ foreach ($languageIDs as $languageID) {
+ foreach ($definition->columnNames as $columnName) {
+ $values[$columnName][$languageID] = $definition->truncateValue(
+ $columnName,
+ self::resolve(
+ $columnItems[$columnName],
+ $literalProvider($columnName),
+ $languageID,
+ $defaultLanguageID
+ ) ?? ''
+ );
+ }
+ }
+
+ return $values;
+ }
+
+ /**
+ * Resolves the value for a single language mirroring the phrase fallback
+ * semantics: requested language, default language, any value, literal.
+ *
+ * @param ?L10nValue $items
+ */
+ private static function resolve(?array $items, ?string $literal, int $languageID, int $defaultLanguageID): ?string
+ {
+ if ($items === null) {
+ return $literal;
+ }
+ if (\array_key_exists($languageID, $items)) {
+ return $items[$languageID];
+ }
+ if (\array_key_exists($defaultLanguageID, $items)) {
+ return $items[$defaultLanguageID];
+ }
+
+ return $items !== [] ? \reset($items) : $literal;
+ }
+
+ /**
+ * Returns the ids of all languages, including disabled ones.
+ *
+ * The language cache is not used, because it keeps serving the previous
+ * state for the remainder of the request after a language was added.
+ *
+ * @return list
+ */
+ private static function getInstalledLanguageIDs(): array
+ {
+ $sql = "SELECT languageID
+ FROM wcf1_language";
+ $statement = WCF::getDB()->prepare($sql);
+ $statement->execute();
+
+ return $statement->fetchAll(\PDO::FETCH_COLUMN);
+ }
+
+ /**
+ * Returns a closure that resolves a language variable to its
+ * `languageID => value` map.
+ *
+ * @return \Closure(string): L10nValue
+ */
+ private static function createItemReader(): \Closure
+ {
+ // Mirrors `LanguageEditor::writeLanguageFiles()`, a custom value set by
+ // an administrator takes precedence over the delivered value.
+ $statement = WCF::getDB()->prepare(
+ "SELECT CASE
+ WHEN languageUseCustomValue = 1 THEN languageCustomItemValue
+ ELSE languageItemValue
+ END AS languageItemValue,
+ languageID
+ FROM wcf1_language_item
+ WHERE languageItem = ?"
+ );
+
+ return static function (string $languageItem) use ($statement): array {
+ $statement->execute([$languageItem]);
+
+ return $statement->fetchMap('languageID', 'languageItemValue');
+ };
+ }
+
+ /**
+ * Removes the given language variables in chunks and resets the language
+ * cache.
+ *
+ * @param list $languageItems
+ */
+ private static function deleteLanguageItems(array $languageItems): void
+ {
+ if ($languageItems === []) {
+ return;
+ }
+
+ foreach (\array_chunk(\array_unique($languageItems), 100) as $chunk) {
+ $conditions = new PreparedStatementConditionBuilder();
+ $conditions->add('languageItem IN (?)', [$chunk]);
+
+ $sql = "DELETE FROM wcf1_language_item
+ {$conditions}";
+ $statement = WCF::getDB()->prepare($sql);
+ $statement->execute($conditions->getParameters());
+ }
+
+ LanguageFactory::getInstance()->deleteLanguageCache();
+ }
+}
diff --git a/wcfsetup/install/files/lib/system/l10n/L10nStorage.class.php b/wcfsetup/install/files/lib/system/l10n/L10nStorage.class.php
new file mode 100644
index 00000000000..6f544c4e7a9
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/l10n/L10nStorage.class.php
@@ -0,0 +1,483 @@
+ value` per column. The key
+ * `L10nStorage::MONOLINGUAL` (`0`) represents monolingual content which is
+ * stored with `languageID = NULL`. Monolingual and multilingual values are
+ * mutually exclusive per column, not per object: different columns may use
+ * different language sets, so an object may hold a `languageID = NULL` row for
+ * a monolingual column alongside per-language rows for a multilingual column.
+ * A column that has no value for a written language id is stored as `NULL` and
+ * treated as absent on read.
+ *
+ * All writes to a `*_l10n` table must go through this class.
+ *
+ * @author Marcel Werk
+ * @copyright 2001-2026 WoltLab GmbH
+ * @license GNU Lesser General Public License
+ * @since 6.3
+ *
+ * @phpstan-type L10nValue array
+ */
+final class L10nStorage
+{
+ /**
+ * pseudo language id representing the monolingual (`languageID = NULL`) row
+ */
+ public const MONOLINGUAL = 0;
+
+ public function __construct(
+ private readonly L10nDefinition $definition,
+ ) {}
+
+ /**
+ * Returns the localized values of the given object as a map of
+ * `columnName => [languageID => value]` using `MONOLINGUAL` as the key
+ * for the monolingual row.
+ *
+ * @return array
+ */
+ public function getValues(int $objectID): array
+ {
+ return $this->getValuesForObjects([$objectID])[$objectID] ?? [];
+ }
+
+ /**
+ * Returns the localized values of the given objects as a map of
+ * `columnName => [languageID => value]` using `MONOLINGUAL` as the key
+ * for the monolingual row.
+ *
+ * @param non-empty-list $objectIDs
+ * @return array>
+ */
+ public function getValuesForObjects(array $objectIDs): array
+ {
+ $columnList = \implode(', ', $this->definition->columnNames);
+ $objectColumnName = $this->definition->objectColumnName;
+
+ $conditionBuilder = new PreparedStatementConditionBuilder();
+ $conditionBuilder->add("{$objectColumnName} IN (?)", [$objectIDs]);
+ $sql = "SELECT {$objectColumnName}, languageID, {$columnList}
+ FROM {$this->definition->l10nTableName}
+ " . $conditionBuilder;
+ $statement = WCF::getDB()->prepare($sql);
+ $statement->execute($conditionBuilder->getParameters());
+
+ $values = [];
+ while ($row = $statement->fetchArray()) {
+ $languageID = $row['languageID'] === null ? self::MONOLINGUAL : (int)$row['languageID'];
+ foreach ($this->definition->columnNames as $columnName) {
+ // A row exists for every language of any column, absent values are `NULL`.
+ if ($row[$columnName] !== null) {
+ $values[$row[$objectColumnName]][$columnName][$languageID] = $row[$columnName];
+ }
+ }
+ }
+
+ return $values;
+ }
+
+ /**
+ * Replaces the localized values of the given object.
+ *
+ * Expects a value map for every localized column. Each column may use its
+ * own set of language ids; a row is written for every language id that
+ * appears in any column and a column that lacks a value for that language
+ * id is stored as `NULL`. Combining `MONOLINGUAL` with actual language ids
+ * within the same column is invalid.
+ *
+ * For definitions that support the synchronization with language variables
+ * the `isPristine` flag is maintained automatically: rows of a delivered
+ * object (`identifier IS NOT NULL`) keep the flag for languages whose values
+ * did not change, rows of user-created objects and changed values are
+ * written as modified (`isPristine = 0`).
+ *
+ * @param array $values `columnName => [languageID => value]`
+ */
+ public function setValues(int $objectID, array $values): void
+ {
+ $languageIDs = $this->validateValues($values);
+
+ $supportsSync = $this->definition->supportsLanguageItemSync();
+ $previousValues = [];
+ $previousPristineLanguageIDs = [];
+ $isDeliveredObject = false;
+ if ($supportsSync) {
+ $previousValues = $this->getValues($objectID);
+ $previousPristineLanguageIDs = $this->getPristineLanguageIDs($objectID);
+ $isDeliveredObject = $this->getIdentifier($objectID) !== null;
+
+ if ($isDeliveredObject) {
+ $values = $this->retainLanguageSpecificValues($values, $previousValues);
+ $languageIDs = $this->validateValues($values);
+ }
+ }
+
+ $columnList = \implode(', ', $this->definition->columnNames);
+ $placeholders = \implode(', ', \array_fill(0, \count($this->definition->columnNames), '?'));
+ if ($supportsSync) {
+ $columnList .= ', isPristine';
+ $placeholders .= ', ?';
+ }
+
+ $sql = "INSERT INTO {$this->definition->l10nTableName}
+ ({$this->definition->objectColumnName}, languageID, {$columnList})
+ VALUES (?, ?, {$placeholders})";
+ $insertStatement = WCF::getDB()->prepare($sql);
+
+ $sql = "DELETE FROM {$this->definition->l10nTableName}
+ WHERE {$this->definition->objectColumnName} = ?";
+ $deleteStatement = WCF::getDB()->prepare($sql);
+
+ WCF::getDB()->beginTransaction();
+ $committed = false;
+ try {
+ $deleteStatement->execute([$objectID]);
+
+ foreach ($languageIDs as $languageID) {
+ $parameters = [
+ $objectID,
+ $languageID === self::MONOLINGUAL ? null : $languageID,
+ ];
+ foreach ($this->definition->columnNames as $columnName) {
+ $parameters[] = $values[$columnName][$languageID] ?? null;
+ }
+ if ($supportsSync) {
+ $parameters[] = $this->getPristineFlag(
+ $languageID,
+ $values,
+ $previousValues,
+ $previousPristineLanguageIDs,
+ $isDeliveredObject
+ );
+ }
+
+ $insertStatement->execute($parameters);
+ }
+
+ WCF::getDB()->commitTransaction();
+ $committed = true;
+ } finally {
+ if (!$committed) {
+ WCF::getDB()->rollBackTransaction();
+ }
+ }
+ }
+
+ /**
+ * Determines the `isPristine` flag of a single row: a delivered object keeps
+ * the flag for a language whose values did not change, everything else is
+ * marked as modified.
+ *
+ * @param array $values
+ * @param array $previousValues
+ * @param list $previousPristineLanguageIDs
+ */
+ private function getPristineFlag(
+ int $languageID,
+ array $values,
+ array $previousValues,
+ array $previousPristineLanguageIDs,
+ bool $isDeliveredObject
+ ): int {
+ if (!$isDeliveredObject || !\in_array($languageID, $previousPristineLanguageIDs, true)) {
+ return 0;
+ }
+
+ foreach ($this->definition->columnNames as $columnName) {
+ $previousValue = self::normalizeValue($previousValues[$columnName][$languageID] ?? null);
+ if ($previousValue !== self::normalizeValue($values[$columnName][$languageID] ?? null)) {
+ return 0;
+ }
+ }
+
+ return 1;
+ }
+
+ /**
+ * Keeps the per-language values of a column when a monolingual value only
+ * restates them. Forms submit a monolingual value whenever a single
+ * language is installed, which would otherwise replace the pristine rows
+ * of a delivered object and stop their synchronization for good.
+ *
+ * @param array $values
+ * @param array $previousValues
+ * @return array
+ */
+ private function retainLanguageSpecificValues(array $values, array $previousValues): array
+ {
+ foreach ($this->definition->columnNames as $columnName) {
+ if (\array_keys($values[$columnName]) !== [self::MONOLINGUAL]) {
+ continue;
+ }
+
+ $previousColumnValues = $previousValues[$columnName] ?? [];
+ if ($previousColumnValues === [] || isset($previousColumnValues[self::MONOLINGUAL])) {
+ continue;
+ }
+
+ $value = self::normalizeValue($values[$columnName][self::MONOLINGUAL]);
+ foreach ($previousColumnValues as $previousValue) {
+ if (self::normalizeValue($previousValue) !== $value) {
+ continue 2;
+ }
+ }
+
+ $values[$columnName] = $previousColumnValues;
+ }
+
+ return $values;
+ }
+
+ /**
+ * Applies the normalization of `I18nHandler::readValues()`, so that a value
+ * submitted unchanged through a form compares equal to the stored value.
+ */
+ private static function normalizeValue(?string $value): ?string
+ {
+ if ($value === null) {
+ return null;
+ }
+
+ return StringUtil::unifyNewlines(StringUtil::trim($value));
+ }
+
+ /**
+ * Synchronizes the given localized values into the table, only writing rows
+ * that are still pristine (`isPristine = 1`) or missing. Rows an
+ * administrator modified (`isPristine = 0`) are preserved. All written rows
+ * are marked pristine.
+ *
+ * A monolingual value is never combined with per-language values: a modified
+ * monolingual value applies to all languages and suppresses the synchronized
+ * values, modified per-language values suppress a synchronized monolingual
+ * value.
+ *
+ * Only valid for definitions that support the synchronization with language
+ * variables.
+ *
+ * @param array $values `columnName => [languageID => value]`
+ */
+ public function syncValues(int $objectID, array $values): void
+ {
+ if (!$this->definition->supportsLanguageItemSync()) {
+ throw new \BadMethodCallException(
+ "The l10n definition of '{$this->definition->l10nTableName}' does not support the synchronization with language variables."
+ );
+ }
+
+ $languageIDs = $this->validateValues($values);
+
+ // determine the language ids that were modified by an administrator and
+ // must not be overwritten by the synchronization
+ $sql = "SELECT languageID, isPristine
+ FROM {$this->definition->l10nTableName}
+ WHERE {$this->definition->objectColumnName} = ?";
+ $statement = WCF::getDB()->prepare($sql);
+ $statement->execute([$objectID]);
+ $modifiedLanguageIDs = [];
+ while ($row = $statement->fetchArray()) {
+ if (!$row['isPristine']) {
+ $modifiedLanguageIDs[] = $row['languageID'] === null ? self::MONOLINGUAL : (int)$row['languageID'];
+ }
+ }
+
+ $columnList = \implode(', ', $this->definition->columnNames);
+ $placeholders = \implode(', ', \array_fill(0, \count($this->definition->columnNames), '?'));
+ $sql = "INSERT INTO {$this->definition->l10nTableName}
+ ({$this->definition->objectColumnName}, languageID, {$columnList}, isPristine)
+ VALUES (?, ?, {$placeholders}, 1)";
+ $insertStatement = WCF::getDB()->prepare($sql);
+
+ // only the pristine rows are replaced, administrator changes are kept
+ $sql = "DELETE FROM {$this->definition->l10nTableName}
+ WHERE {$this->definition->objectColumnName} = ?
+ AND isPristine = ?";
+ $deleteStatement = WCF::getDB()->prepare($sql);
+
+ $modifiedAllLanguages = \in_array(self::MONOLINGUAL, $modifiedLanguageIDs, true);
+
+ WCF::getDB()->beginTransaction();
+ $committed = false;
+ try {
+ // Also removes pristine rows that were previously written next to a
+ // modified monolingual value.
+ $deleteStatement->execute([$objectID, 1]);
+
+ foreach ($languageIDs as $languageID) {
+ if ($modifiedAllLanguages || \in_array($languageID, $modifiedLanguageIDs, true)) {
+ continue;
+ }
+ if ($languageID === self::MONOLINGUAL && $modifiedLanguageIDs !== []) {
+ continue;
+ }
+
+ $parameters = [
+ $objectID,
+ $languageID === self::MONOLINGUAL ? null : $languageID,
+ ];
+ foreach ($this->definition->columnNames as $columnName) {
+ $parameters[] = $values[$columnName][$languageID] ?? null;
+ }
+
+ $insertStatement->execute($parameters);
+ }
+
+ WCF::getDB()->commitTransaction();
+ $committed = true;
+ } finally {
+ if (!$committed) {
+ WCF::getDB()->rollBackTransaction();
+ }
+ }
+ }
+
+ /**
+ * Resolves the effective value from a `languageID => value` map using the
+ * deterministic fallback chain: monolingual value, requested language,
+ * default language, lowest language id.
+ *
+ * @param L10nValue $values
+ */
+ public static function resolveValue(array $values, ?int $languageID = null): string
+ {
+ if ($values === []) {
+ return '';
+ }
+
+ if (isset($values[self::MONOLINGUAL])) {
+ return $values[self::MONOLINGUAL];
+ }
+
+ $languageID ??= WCF::getLanguage()->languageID;
+ if (isset($values[$languageID])) {
+ return $values[$languageID];
+ }
+
+ $defaultLanguageID = LanguageFactory::getInstance()->getDefaultLanguageID();
+ if (isset($values[$defaultLanguageID])) {
+ return $values[$defaultLanguageID];
+ }
+
+ return $values[\min(\array_keys($values))];
+ }
+
+ /**
+ * Returns a correlated sub select that resolves the effective value of the
+ * given column for use in `SELECT`, `ORDER BY` or `WHERE` clauses. The
+ * fallback chain matches `resolveValue()`.
+ */
+ public function getSubSelect(string $columnName, string $tableAlias, ?int $languageID = null): string
+ {
+ if (!\in_array($columnName, $this->definition->columnNames, true)) {
+ throw new \InvalidArgumentException("Unknown localized column '{$columnName}'.");
+ }
+
+ $languageID ??= WCF::getLanguage()->languageID;
+ $defaultLanguageID = LanguageFactory::getInstance()->getDefaultLanguageID();
+
+ return "(
+ SELECT {$columnName}
+ FROM {$this->definition->l10nTableName}
+ WHERE {$this->definition->objectColumnName} = {$tableAlias}.{$this->definition->objectColumnName}
+ AND {$columnName} IS NOT NULL
+ ORDER BY CASE
+ WHEN languageID IS NULL THEN -3
+ WHEN languageID = {$languageID} THEN -2
+ WHEN languageID = {$defaultLanguageID} THEN -1
+ ELSE languageID
+ END
+ LIMIT 1
+ )";
+ }
+
+ /**
+ * Validates the given values and returns the union of language ids across
+ * all columns.
+ *
+ * @param array $values
+ * @return list
+ */
+ private function validateValues(array $values): array
+ {
+ $expectedColumns = $this->definition->columnNames;
+ $givenColumns = \array_keys($values);
+ \sort($expectedColumns);
+ \sort($givenColumns);
+ if ($expectedColumns !== $givenColumns) {
+ throw new \InvalidArgumentException(\sprintf(
+ "Expected values for the columns [%s], got [%s].",
+ \implode(', ', $this->definition->columnNames),
+ \implode(', ', \array_keys($values)),
+ ));
+ }
+
+ $languageIDs = [];
+ foreach ($values as $columnName => $columnValues) {
+ if ($columnValues === []) {
+ throw new \InvalidArgumentException("Missing values for column '{$columnName}'.");
+ }
+
+ $columnLanguageIDs = \array_keys($columnValues);
+ if (\in_array(self::MONOLINGUAL, $columnLanguageIDs, true) && \count($columnLanguageIDs) > 1) {
+ throw new \InvalidArgumentException(\sprintf(
+ "The monolingual value of column '%s' cannot be combined with language specific values.",
+ $columnName,
+ ));
+ }
+
+ $languageIDs = [...$languageIDs, ...$columnLanguageIDs];
+ }
+
+ $languageIDs = \array_values(\array_unique($languageIDs));
+ \sort($languageIDs);
+
+ return $languageIDs;
+ }
+
+ /**
+ * Returns the language variable identifier of the given object or `null`
+ * for user-created objects.
+ */
+ private function getIdentifier(int $objectID): ?string
+ {
+ $sql = "SELECT {$this->definition->identifierColumnName}
+ FROM {$this->definition->primaryTableName}
+ WHERE {$this->definition->objectColumnName} = ?";
+ $statement = WCF::getDB()->prepare($sql);
+ $statement->execute([$objectID]);
+
+ return $statement->fetchSingleColumn() ?: null;
+ }
+
+ /**
+ * Returns the language ids of the object's rows that are still pristine.
+ *
+ * @return list
+ */
+ private function getPristineLanguageIDs(int $objectID): array
+ {
+ $sql = "SELECT languageID
+ FROM {$this->definition->l10nTableName}
+ WHERE {$this->definition->objectColumnName} = ?
+ AND isPristine = ?";
+ $statement = WCF::getDB()->prepare($sql);
+ $statement->execute([$objectID, 1]);
+
+ $languageIDs = [];
+ while ($row = $statement->fetchArray()) {
+ $languageIDs[] = $row['languageID'] === null ? self::MONOLINGUAL : (int)$row['languageID'];
+ }
+
+ return $languageIDs;
+ }
+}
diff --git a/wcfsetup/install/files/lib/system/language/I18nHandler.class.php b/wcfsetup/install/files/lib/system/language/I18nHandler.class.php
index 46bca3ecf5d..529288c5961 100644
--- a/wcfsetup/install/files/lib/system/language/I18nHandler.class.php
+++ b/wcfsetup/install/files/lib/system/language/I18nHandler.class.php
@@ -106,7 +106,7 @@ public function readValues(?array $requestData = null): void
}
foreach ($this->elementIDs as $elementID) {
- if (isset($requestData[$elementID])) {
+ if (isset($requestData[$elementID]) && \is_string($requestData[$elementID])) {
// you should trim the string before using it; prevents unwanted newlines
$this->plainValues[$elementID] = StringUtil::unifyNewlines(StringUtil::trim($requestData[$elementID]));
continue;
@@ -117,6 +117,10 @@ public function readValues(?array $requestData = null): void
$this->i18nValues[$elementID] = [];
foreach ($requestData[$i18nElementID] as $languageID => $value) {
+ if (!isset($this->availableLanguages[$languageID]) || !\is_string($value)) {
+ continue;
+ }
+
$this->i18nValues[$elementID][$languageID] = StringUtil::unifyNewlines(StringUtil::trim($value));
}
diff --git a/wcfsetup/install/files/lib/system/option/OptionHandler.class.php b/wcfsetup/install/files/lib/system/option/OptionHandler.class.php
index a338f350731..4feb77716ae 100644
--- a/wcfsetup/install/files/lib/system/option/OptionHandler.class.php
+++ b/wcfsetup/install/files/lib/system/option/OptionHandler.class.php
@@ -30,6 +30,8 @@
* html: string,
* cssClassName: string,
* hideLabelInSearch: bool,
+ * title?: string,
+ * description?: string,
* }|null
*/
class OptionHandler implements IOptionHandler
diff --git a/wcfsetup/install/files/lib/system/option/UseroptionsOptionType.class.php b/wcfsetup/install/files/lib/system/option/UseroptionsOptionType.class.php
index 8cf143e9d21..27f2654db2c 100644
--- a/wcfsetup/install/files/lib/system/option/UseroptionsOptionType.class.php
+++ b/wcfsetup/install/files/lib/system/option/UseroptionsOptionType.class.php
@@ -3,6 +3,8 @@
namespace wcf\system\option;
use wcf\data\option\Option;
+use wcf\data\user\option\UserOption;
+use wcf\system\cache\builder\UserOptionCacheBuilder;
use wcf\system\exception\UserInputException;
use wcf\system\WCF;
@@ -59,10 +61,18 @@ public function getFormElement(Option $option, mixed $value)
$userOptions = \array_merge($sortedOptions, \array_diff($userOptions, $sortedOptions));
}
+ $options = UserOptionCacheBuilder::getInstance()->getData([], 'options');
+ $optionTitles = [];
+ foreach ($userOptions as $optionName) {
+ \assert($options[$optionName] instanceof UserOption);
+ $optionTitles[$optionName] = $options[$optionName]->getTitle();
+ }
+
return WCF::getTPL()->render('wcf', 'useroptionsOptionType', [
'option' => $option,
'value' => \explode(',', $value),
'availableOptions' => $userOptions,
+ 'optionTitles' => $optionTitles,
]);
}
diff --git a/wcfsetup/install/files/lib/system/option/user/UserOptionHandler.class.php b/wcfsetup/install/files/lib/system/option/user/UserOptionHandler.class.php
index e14dcec05c1..d43d439fb14 100644
--- a/wcfsetup/install/files/lib/system/option/user/UserOptionHandler.class.php
+++ b/wcfsetup/install/files/lib/system/option/user/UserOptionHandler.class.php
@@ -205,6 +205,11 @@ public function getOption(string $optionName)
{
$optionData = parent::getOption($optionName);
+ // The shared `optionFieldList` template cannot derive the localized
+ // title and description of user options from a language item.
+ $optionData['title'] = $optionData['object']->getTitle();
+ $optionData['description'] = $optionData['object']->getDescription();
+
if (!$this->editMode && !$this->searchMode) {
/** @var UserOption $option */
$option = $optionData['object'];
diff --git a/wcfsetup/install/files/lib/system/package/PackageInstallationDispatcher.class.php b/wcfsetup/install/files/lib/system/package/PackageInstallationDispatcher.class.php
index 1781485b8b9..0b104ee70f2 100644
--- a/wcfsetup/install/files/lib/system/package/PackageInstallationDispatcher.class.php
+++ b/wcfsetup/install/files/lib/system/package/PackageInstallationDispatcher.class.php
@@ -209,6 +209,8 @@ public function install(string $node): PackageInstallationStep
new \wcf\command\package\RebuildBootstrapper()();
+ (new \wcf\command\l10n\SyncL10nLanguageItems())();
+
EventHandler::getInstance()->fire(new PackageListChanged());
EventHandler::getInstance()->fireAction($this, 'postInstall');
diff --git a/wcfsetup/install/files/lib/system/package/plugin/CronjobPackageInstallationPlugin.class.php b/wcfsetup/install/files/lib/system/package/plugin/CronjobPackageInstallationPlugin.class.php
index ebb15bed303..3d05418e8fd 100644
--- a/wcfsetup/install/files/lib/system/package/plugin/CronjobPackageInstallationPlugin.class.php
+++ b/wcfsetup/install/files/lib/system/package/plugin/CronjobPackageInstallationPlugin.class.php
@@ -4,8 +4,11 @@
use Cron\CronExpression;
use wcf\acp\form\CronjobAddForm;
+use wcf\data\cronjob\Cronjob;
+use wcf\data\cronjob\CronjobBuilder;
use wcf\data\cronjob\CronjobEditor;
use wcf\data\cronjob\CronjobList;
+use wcf\data\language\LanguageList;
use wcf\system\cronjob\ICronjob;
use wcf\system\database\util\PreparedStatementConditionBuilder;
use wcf\system\devtools\pip\IDevtoolsPipEntryList;
@@ -19,6 +22,7 @@
use wcf\system\form\builder\field\SingleSelectionFormField;
use wcf\system\form\builder\field\TextFormField;
use wcf\system\form\builder\IFormDocument;
+use wcf\system\l10n\L10nStorage;
use wcf\system\language\LanguageFactory;
use wcf\system\WCF;
use wcf\util\StringUtil;
@@ -29,6 +33,8 @@
* @author Alexander Ebert, Matthias Schmidt
* @copyright 2001-2019 WoltLab GmbH
* @license GNU Lesser General Public License
+ *
+ * @phpstan-import-type L10nValue from L10nStorage
*/
class CronjobPackageInstallationPlugin extends AbstractXMLPackageInstallationPlugin implements
IGuiPackageInstallationPlugin,
@@ -67,8 +73,6 @@ protected function getElement(\DOMXPath $xpath, array &$elements, \DOMElement $e
#[\Override]
protected function handleDelete(array $items)
{
- // read cronjobs from database because deleting the language items requires the
- // cronjob id
$cronjobs = $legacyCronjobs = [];
foreach ($items as $item) {
if (!isset($item['attributes']['name'])) {
@@ -99,8 +103,102 @@ protected function handleDelete(array $items)
$cronjobList->readObjectIDs();
if ($cronjobList->getObjectIDs() !== []) {
- CronjobEditor::deleteAll($cronjobList->getObjectIDs());
+ CronjobBuilder::deleteAll($cronjobList->getObjectIDs());
+ }
+ }
+
+ /**
+ * @param array $row
+ * @param array $data
+ */
+ #[\Override]
+ protected function import(array $row, array $data): Cronjob
+ {
+ if ($row === []) {
+ $this->prepareCreate($data);
+
+ $builder = CronjobBuilder::forCreate()
+ ->setPackageID($data['packageID'])
+ ->setNextExec($data['nextExec']);
+ } else {
+ $builder = CronjobBuilder::forUpdate(new Cronjob(null, $row));
+ }
+
+ $definition = Cronjob::getL10nDefinition();
+ $descriptions = \array_map(
+ static fn(string $description) => $definition->truncateValue('description', $description),
+ $this->getDescriptionValues($data['description'] ?? '')
+ );
+
+ $builder
+ ->setCronjobName($data['cronjobName'])
+ ->setClassName($data['className'])
+ ->setDescription($descriptions)
+ ->setStartMinute($data['startMinute'])
+ ->setStartHour($data['startHour'])
+ ->setStartDom($data['startDom'])
+ ->setStartMonth($data['startMonth'])
+ ->setStartDow($data['startDow'])
+ ->setIsDisabled((bool)(int)$data['isDisabled'])
+ ->setCanBeEdited((bool)(int)$data['canBeEdited'])
+ ->setCanBeDisabled((bool)(int)$data['canBeDisabled'])
+ ->setOptions($data['options']);
+
+ if ($builder->isUpdate()) {
+ return $builder->update();
+ }
+
+ return $builder->create();
+ }
+
+ /**
+ * Maps the descriptions of a cronjob, indexed by language code (`''` for
+ * the element without a `language` attribute), to localized values. A
+ * single description is monolingual, otherwise every installed language
+ * receives its own value, falling back to the default description.
+ *
+ * @param string|array $descriptions
+ * @return L10nValue
+ */
+ private function getDescriptionValues(string|array $descriptions): array
+ {
+ if (\is_string($descriptions)) {
+ return [L10nStorage::MONOLINGUAL => $descriptions];
}
+
+ if ($descriptions === []) {
+ return [L10nStorage::MONOLINGUAL => ''];
+ }
+
+ if (\count($descriptions) === 1) {
+ return [L10nStorage::MONOLINGUAL => \reset($descriptions)];
+ }
+
+ if (isset($descriptions[''])) {
+ $defaultValue = $descriptions[''];
+ } elseif (isset($descriptions['en'])) {
+ $defaultValue = $descriptions['en'];
+ } elseif (isset($descriptions[WCF::getLanguage()->getFixedLanguageCode()])) {
+ $defaultValue = $descriptions[WCF::getLanguage()->getFixedLanguageCode()];
+ } else {
+ $defaultValue = \reset($descriptions);
+ }
+
+ // The language cache may predate the languages created during the
+ // framework installation.
+ if (\PACKAGE_ID === 0) {
+ $languages = new LanguageList();
+ $languages->readObjects();
+ } else {
+ $languages = LanguageFactory::getInstance()->getLanguages();
+ }
+
+ $values = [];
+ foreach ($languages as $language) {
+ $values[$language->languageID] = $descriptions[$language->languageCode] ?? $defaultValue;
+ }
+
+ return $values;
}
private function getRandomExpression(string $name, string $expression): CronExpression
diff --git a/wcfsetup/install/files/lib/system/package/plugin/UserOptionPackageInstallationPlugin.class.php b/wcfsetup/install/files/lib/system/package/plugin/UserOptionPackageInstallationPlugin.class.php
index 024edd67b7f..b56957ae46c 100644
--- a/wcfsetup/install/files/lib/system/package/plugin/UserOptionPackageInstallationPlugin.class.php
+++ b/wcfsetup/install/files/lib/system/package/plugin/UserOptionPackageInstallationPlugin.class.php
@@ -8,6 +8,7 @@
use wcf\data\user\option\category\UserOptionCategory;
use wcf\data\user\option\category\UserOptionCategoryEditor;
use wcf\data\user\option\UserOption;
+use wcf\data\user\option\UserOptionBuilder;
use wcf\data\user\option\UserOptionEditor;
use wcf\system\devtools\pip\IGuiPackageInstallationPlugin;
use wcf\system\exception\SystemException;
@@ -207,21 +208,62 @@ protected function saveOption(array $option, string $categoryName, int $existing
$data['searchable'] = \intval($option['searchable']);
}
+ // the localized title/description are shipped as `wcf.user.option.*`
+ // language variables and synchronized into the l10n storage at the end
+ // of the installation, see `SyncL10nLanguageItems`
+ $l10nIdentifier = 'wcf.user.option.' . $optionName;
+
// update option
if (!empty($result['optionID']) && $this->installation->getAction() === 'update') {
$userOption = new UserOption(null, $result);
- $userOptionEditor = new UserOptionEditor($userOption);
- $userOptionEditor->update($data);
+ $builder = UserOptionBuilder::forUpdate($userOption)
+ ->setL10nIdentifier($l10nIdentifier);
+ $this->applyOptionData($builder, $data, $additionalData);
+ $builder->update();
} // insert new option
else {
- // append option name
- $data['optionName'] = $optionName;
- // append disabled state
- $data['isDisabled'] = $isDisabled;
+ $builder = UserOptionBuilder::forCreate()
+ ->setOptionName($optionName)
+ ->setL10nIdentifier($l10nIdentifier)
+ ->setIsDisabled((bool)$isDisabled)
+ ->setPackageID($this->installation->getPackageID());
+ $this->applyOptionData($builder, $data, $additionalData);
+ $builder->create();
+ }
+ }
- // create option
- $data['packageID'] = $this->installation->getPackageID();
- UserOptionEditor::create($data);
+ /**
+ * Applies the shared option data to the given builder.
+ *
+ * @param array $data
+ * @param array $additionalData
+ */
+ private function applyOptionData(UserOptionBuilder $builder, array $data, array $additionalData): void
+ {
+ $builder
+ ->setCategoryName($data['categoryName'])
+ ->setOptionType($data['optionType'])
+ ->setDefaultValue($data['defaultValue'])
+ ->setValidationPattern($data['validationPattern'])
+ ->setSelectOptions($data['selectOptions'])
+ ->setEnableOptions($data['enableOptions'])
+ ->setEditable((int)$data['editable'])
+ ->setVisible((int)$data['visible'])
+ ->setOutputClass($data['outputClass'])
+ ->setShowOrder((int)$data['showOrder'])
+ ->setPermissions($data['permissions'])
+ ->setOptions($data['options'])
+ ->setAdditionalData($additionalData)
+ ->setOriginIsSystem((bool)$data['originIsSystem']);
+
+ if (isset($data['required'])) {
+ $builder->setRequired((bool)$data['required']);
+ }
+ if (isset($data['askDuringRegistration'])) {
+ $builder->setAskDuringRegistration((bool)$data['askDuringRegistration']);
+ }
+ if (isset($data['searchable'])) {
+ $builder->setSearchable((bool)$data['searchable']);
}
}
diff --git a/wcfsetup/install/files/lib/system/user/notification/event/ExpiringPaidSubscriptionUserUserNotificationEvent.class.php b/wcfsetup/install/files/lib/system/user/notification/event/ExpiringPaidSubscriptionUserUserNotificationEvent.class.php
index 985a06f3d14..ebbff91f756 100644
--- a/wcfsetup/install/files/lib/system/user/notification/event/ExpiringPaidSubscriptionUserUserNotificationEvent.class.php
+++ b/wcfsetup/install/files/lib/system/user/notification/event/ExpiringPaidSubscriptionUserUserNotificationEvent.class.php
@@ -2,13 +2,14 @@
namespace wcf\system\user\notification\event;
-use wcf\data\paid\subscription\PaidSubscription;
-use wcf\data\paid\subscription\PaidSubscriptionAction;
+use wcf\command\paid\subscription\CreatePaidSubscription;
+use wcf\data\paid\subscription\PaidSubscriptionBuilder;
use wcf\data\paid\subscription\user\PaidSubscriptionUser;
use wcf\data\paid\subscription\user\PaidSubscriptionUserAction;
use wcf\data\paid\subscription\user\PaidSubscriptionUserList;
use wcf\data\user\UserProfile;
use wcf\page\PaidSubscriptionListPage;
+use wcf\system\l10n\L10nStorage;
use wcf\system\request\LinkHandler;
use wcf\system\user\notification\object\PaidSubscriptionUserUserNotificationObject;
use wcf\system\WCF;
@@ -81,13 +82,11 @@ public function isVisible()
#[\Override]
public static function getTestObjects(UserProfile $recipient, UserProfile $author)
{
- /** @var PaidSubscription $paidSubscription */
- $paidSubscription = (new PaidSubscriptionAction([], 'create', [
- 'data' => [
- 'groupIDs' => '',
- 'title' => 'Test Subscription',
- ],
- ]))->executeAction()['returnValues'];
+ $paidSubscription = (new CreatePaidSubscription(
+ PaidSubscriptionBuilder::forCreate()
+ ->setTitle([L10nStorage::MONOLINGUAL => 'Test Subscription'])
+ ->setGroupIDs([])
+ ))();
/** @var PaidSubscriptionUser $paidSubscriptionUser */
$paidSubscriptionUser = (new PaidSubscriptionUserAction([], 'create', [
diff --git a/wcfsetup/install/files/lib/system/view/filter/L10nTextFilter.class.php b/wcfsetup/install/files/lib/system/view/filter/L10nTextFilter.class.php
new file mode 100644
index 00000000000..0a01be47227
--- /dev/null
+++ b/wcfsetup/install/files/lib/system/view/filter/L10nTextFilter.class.php
@@ -0,0 +1,55 @@
+
+ * @since 6.3
+ */
+class L10nTextFilter extends TextFilter
+{
+ public function __construct(
+ private readonly L10nDefinition $definition,
+ private readonly string $columnName,
+ string $id,
+ string $languageItem,
+ ) {
+ parent::__construct($id, $languageItem);
+
+ if (!\in_array($columnName, $definition->columnNames, true)) {
+ throw new \InvalidArgumentException("Unknown localized column '{$columnName}'.");
+ }
+ }
+
+ #[\Override]
+ public function applyFilter(DatabaseObjectList $list, string $value): void
+ {
+ $objectColumn = $list->getDatabaseTableAlias() . '.' . $this->definition->objectColumnName;
+
+ $list->getConditionBuilder()->add(
+ "{$objectColumn} IN (
+ SELECT {$this->definition->objectColumnName}
+ FROM {$this->definition->l10nTableName}
+ WHERE (languageID = ? OR languageID IS NULL)
+ AND {$this->columnName} LIKE ?
+ )",
+ [
+ WCF::getLanguage()->languageID,
+ '%' . WCF::getDB()->escapeLikeValue($value) . '%'
+ ]
+ );
+ }
+}
diff --git a/wcfsetup/install/lang/de.xml b/wcfsetup/install/lang/de.xml
index cb3ad482d90..c1a4fd09d92 100644
--- a/wcfsetup/install/lang/de.xml
+++ b/wcfsetup/install/lang/de.xml
@@ -5427,7 +5427,7 @@ Benachrichtigungen auf {PAGE_TITLE|phra
-
+
diff --git a/wcfsetup/setup/db/install.sql b/wcfsetup/setup/db/install.sql
index fc14661befa..adeb69e8d86 100644
--- a/wcfsetup/setup/db/install.sql
+++ b/wcfsetup/setup/db/install.sql
@@ -197,4 +197,4 @@ INSERT INTO wcf1_contact_option (optionID, optionTitle, optionDescription, optio
INSERT INTO wcf1_contact_option (optionID, optionTitle, optionDescription, optionType, configuration, showOrder, originIsSystem) VALUES (2, 'wcf.contact.option2', '', 'textarea', '{\"required\":1}', 1, 1);
-- default recipient: site administrator
-INSERT INTO wcf1_contact_recipient (recipientID, name, email, isAdministrator, originIsSystem) VALUES (1, 'wcf.contact.recipient.name1', '', 1, 1);
+INSERT INTO wcf1_contact_recipient (recipientID, isAdministrator, originIsSystem, l10nIdentifier) VALUES (1, 1, 1, 'wcf.contact.recipient.name1');
diff --git a/wcfsetup/setup/db/install_com.woltlab.wcf.php b/wcfsetup/setup/db/install_com.woltlab.wcf.php
index c780f612ba9..abda3a6a15c 100644
--- a/wcfsetup/setup/db/install_com.woltlab.wcf.php
+++ b/wcfsetup/setup/db/install_com.woltlab.wcf.php
@@ -902,8 +902,6 @@
DatabaseTable::create('wcf1_captcha_question')
->columns([
ObjectIdDatabaseTableColumn::create('questionID'),
- NotNullVarchar255DatabaseTableColumn::create('question'),
- MediumtextDatabaseTableColumn::create('answers'),
DefaultFalseBooleanDatabaseTableColumn::create('isDisabled'),
NotNullInt10DatabaseTableColumn::create('views')
->defaultValue(0),
@@ -916,6 +914,32 @@
DatabaseTablePrimaryIndex::create()
->columns(['questionID']),
]),
+ DatabaseTable::create('wcf1_captcha_question_l10n')
+ ->columns([
+ NotNullInt10DatabaseTableColumn::create('questionID'),
+ IntDatabaseTableColumn::create('languageID'),
+ VarcharDatabaseTableColumn::create('question')
+ ->length(255),
+ MediumtextDatabaseTableColumn::create('answers'),
+ ])
+ ->indices([
+ DatabaseTableIndex::create('questionID')
+ ->columns(['questionID', 'languageID']),
+ ])
+ ->foreignKeys([
+ DatabaseTableForeignKey::create()
+ ->columns(['questionID'])
+ ->referencedTable('wcf1_captcha_question')
+ ->referencedColumns(['questionID'])
+ ->onDelete('CASCADE')
+ ->onUpdate('NO ACTION'),
+ DatabaseTableForeignKey::create()
+ ->columns(['languageID'])
+ ->referencedTable('wcf1_language')
+ ->referencedColumns(['languageID'])
+ ->onDelete('CASCADE')
+ ->onUpdate('NO ACTION'),
+ ]),
DatabaseTable::create('wcf1_category')
->columns([
ObjectIdDatabaseTableColumn::create('categoryID'),
@@ -1160,18 +1184,48 @@
DatabaseTable::create('wcf1_contact_recipient')
->columns([
ObjectIdDatabaseTableColumn::create('recipientID'),
- NotNullVarchar255DatabaseTableColumn::create('name'),
- NotNullVarchar255DatabaseTableColumn::create('email'),
NotNullInt10DatabaseTableColumn::create('showOrder')
->defaultValue(0),
DefaultFalseBooleanDatabaseTableColumn::create('isAdministrator'),
DefaultFalseBooleanDatabaseTableColumn::create('isDisabled'),
DefaultFalseBooleanDatabaseTableColumn::create('originIsSystem'),
+ VarcharDatabaseTableColumn::create('l10nIdentifier')
+ ->length(255),
])
->indices([
DatabaseTablePrimaryIndex::create()
->columns(['recipientID']),
]),
+ DatabaseTable::create('wcf1_contact_recipient_l10n')
+ ->columns([
+ NotNullInt10DatabaseTableColumn::create('recipientID'),
+ IntDatabaseTableColumn::create('languageID'),
+ VarcharDatabaseTableColumn::create('name')
+ ->length(255),
+ VarcharDatabaseTableColumn::create('email')
+ ->length(255),
+ TinyintDatabaseTableColumn::create('isPristine')
+ ->notNull()
+ ->defaultValue(1),
+ ])
+ ->indices([
+ DatabaseTableIndex::create('recipientID')
+ ->columns(['recipientID', 'languageID']),
+ ])
+ ->foreignKeys([
+ DatabaseTableForeignKey::create()
+ ->columns(['recipientID'])
+ ->referencedTable('wcf1_contact_recipient')
+ ->referencedColumns(['recipientID'])
+ ->onDelete('CASCADE')
+ ->onUpdate('NO ACTION'),
+ DatabaseTableForeignKey::create()
+ ->columns(['languageID'])
+ ->referencedTable('wcf1_language')
+ ->referencedColumns(['languageID'])
+ ->onDelete('CASCADE')
+ ->onUpdate('NO ACTION'),
+ ]),
DatabaseTable::create('wcf1_core_object')
->columns([
ObjectIdDatabaseTableColumn::create('objectID'),
@@ -1201,8 +1255,6 @@
->defaultValue(''),
NotNullInt10DatabaseTableColumn::create('packageID'),
NotNullVarchar191DatabaseTableColumn::create('cronjobName'),
- NotNullVarchar255DatabaseTableColumn::create('description')
- ->defaultValue(''),
NotNullVarchar255DatabaseTableColumn::create('startMinute')
->defaultValue('*'),
NotNullVarchar255DatabaseTableColumn::create('startHour')
@@ -1245,6 +1297,31 @@
->onDelete('CASCADE')
->onUpdate('NO ACTION'),
]),
+ DatabaseTable::create('wcf1_cronjob_l10n')
+ ->columns([
+ NotNullInt10DatabaseTableColumn::create('cronjobID'),
+ IntDatabaseTableColumn::create('languageID'),
+ VarcharDatabaseTableColumn::create('description')
+ ->length(255),
+ ])
+ ->indices([
+ DatabaseTableIndex::create('cronjobID')
+ ->columns(['cronjobID', 'languageID']),
+ ])
+ ->foreignKeys([
+ DatabaseTableForeignKey::create()
+ ->columns(['cronjobID'])
+ ->referencedTable('wcf1_cronjob')
+ ->referencedColumns(['cronjobID'])
+ ->onDelete('CASCADE')
+ ->onUpdate('NO ACTION'),
+ DatabaseTableForeignKey::create()
+ ->columns(['languageID'])
+ ->referencedTable('wcf1_language')
+ ->referencedColumns(['languageID'])
+ ->onDelete('CASCADE')
+ ->onUpdate('NO ACTION'),
+ ]),
DatabaseTable::create('wcf1_cronjob_log')
->columns([
ObjectIdDatabaseTableColumn::create('cronjobLogID'),
@@ -1581,9 +1658,6 @@
DatabaseTable::create('wcf1_label_group')
->columns([
ObjectIdDatabaseTableColumn::create('groupID'),
- VarcharDatabaseTableColumn::create('groupName')
- ->notNull()
- ->length(80),
NotNullVarchar255DatabaseTableColumn::create('groupDescription')
->defaultValue(''),
DefaultFalseBooleanDatabaseTableColumn::create('forceSelection'),
@@ -1595,6 +1669,31 @@
DatabaseTablePrimaryIndex::create()
->columns(['groupID']),
]),
+ DatabaseTable::create('wcf1_label_group_l10n')
+ ->columns([
+ NotNullInt10DatabaseTableColumn::create('groupID'),
+ IntDatabaseTableColumn::create('languageID'),
+ VarcharDatabaseTableColumn::create('groupName')
+ ->length(80),
+ ])
+ ->indices([
+ DatabaseTableIndex::create('groupID')
+ ->columns(['groupID', 'languageID']),
+ ])
+ ->foreignKeys([
+ DatabaseTableForeignKey::create()
+ ->columns(['groupID'])
+ ->referencedTable('wcf1_label_group')
+ ->referencedColumns(['groupID'])
+ ->onDelete('CASCADE')
+ ->onUpdate('NO ACTION'),
+ DatabaseTableForeignKey::create()
+ ->columns(['languageID'])
+ ->referencedTable('wcf1_language')
+ ->referencedColumns(['languageID'])
+ ->onDelete('CASCADE')
+ ->onUpdate('NO ACTION'),
+ ]),
DatabaseTable::create('wcf1_label_group_to_object')
->columns([
NotNullInt10DatabaseTableColumn::create('groupID'),
@@ -2873,9 +2972,6 @@
DatabaseTable::create('wcf1_paid_subscription')
->columns([
ObjectIdDatabaseTableColumn::create('subscriptionID'),
- NotNullVarchar255DatabaseTableColumn::create('title')
- ->defaultValue(''),
- TextDatabaseTableColumn::create('description'),
DefaultFalseBooleanDatabaseTableColumn::create('isDisabled'),
NotNullInt10DatabaseTableColumn::create('showOrder')
->defaultValue(0),
@@ -2908,6 +3004,32 @@
DatabaseTablePrimaryIndex::create()
->columns(['subscriptionID']),
]),
+ DatabaseTable::create('wcf1_paid_subscription_l10n')
+ ->columns([
+ NotNullInt10DatabaseTableColumn::create('subscriptionID'),
+ IntDatabaseTableColumn::create('languageID'),
+ VarcharDatabaseTableColumn::create('title')
+ ->length(255),
+ TextDatabaseTableColumn::create('description'),
+ ])
+ ->indices([
+ DatabaseTableIndex::create('subscriptionID')
+ ->columns(['subscriptionID', 'languageID']),
+ ])
+ ->foreignKeys([
+ DatabaseTableForeignKey::create()
+ ->columns(['subscriptionID'])
+ ->referencedTable('wcf1_paid_subscription')
+ ->referencedColumns(['subscriptionID'])
+ ->onDelete('CASCADE')
+ ->onUpdate('NO ACTION'),
+ DatabaseTableForeignKey::create()
+ ->columns(['languageID'])
+ ->referencedTable('wcf1_language')
+ ->referencedColumns(['languageID'])
+ ->onDelete('CASCADE')
+ ->onUpdate('NO ACTION'),
+ ]),
DatabaseTable::create('wcf1_paid_subscription_transaction_log')
->columns([
ObjectIdDatabaseTableColumn::create('logID'),
@@ -3081,11 +3203,12 @@
DatabaseTable::create('wcf1_reaction_type')
->columns([
ObjectIdDatabaseTableColumn::create('reactionTypeID'),
- NotNullVarchar255DatabaseTableColumn::create('title'),
NotNullInt10DatabaseTableColumn::create('showOrder')
->defaultValue(0),
IntDatabaseTableColumn::create('iconFileID'),
DefaultTrueBooleanDatabaseTableColumn::create('isAssignable'),
+ VarcharDatabaseTableColumn::create('l10nIdentifier')
+ ->length(255),
])
->indices([
DatabaseTablePrimaryIndex::create()
@@ -3099,6 +3222,34 @@
->onDelete('SET NULL')
->onUpdate('NO ACTION'),
]),
+ DatabaseTable::create('wcf1_reaction_type_l10n')
+ ->columns([
+ NotNullInt10DatabaseTableColumn::create('reactionTypeID'),
+ IntDatabaseTableColumn::create('languageID'),
+ VarcharDatabaseTableColumn::create('title')
+ ->length(255),
+ TinyintDatabaseTableColumn::create('isPristine')
+ ->notNull()
+ ->defaultValue(1),
+ ])
+ ->indices([
+ DatabaseTableIndex::create('reactionTypeID')
+ ->columns(['reactionTypeID', 'languageID']),
+ ])
+ ->foreignKeys([
+ DatabaseTableForeignKey::create()
+ ->columns(['reactionTypeID'])
+ ->referencedTable('wcf1_reaction_type')
+ ->referencedColumns(['reactionTypeID'])
+ ->onDelete('CASCADE')
+ ->onUpdate('NO ACTION'),
+ DatabaseTableForeignKey::create()
+ ->columns(['languageID'])
+ ->referencedTable('wcf1_language')
+ ->referencedColumns(['languageID'])
+ ->onDelete('CASCADE')
+ ->onUpdate('NO ACTION'),
+ ]),
DatabaseTable::create('wcf1_registry')
->columns([
NotNullInt10DatabaseTableColumn::create('packageID'),
@@ -4633,6 +4784,8 @@
MediumtextDatabaseTableColumn::create('additionalData'),
DefaultFalseBooleanDatabaseTableColumn::create('originIsSystem'),
DefaultFalseBooleanDatabaseTableColumn::create('showOnUserCard'),
+ VarcharDatabaseTableColumn::create('l10nIdentifier')
+ ->length(255),
])
->indices([
DatabaseTablePrimaryIndex::create()
@@ -4651,6 +4804,35 @@
->onDelete('CASCADE')
->onUpdate('NO ACTION'),
]),
+ DatabaseTable::create('wcf1_user_option_l10n')
+ ->columns([
+ NotNullInt10DatabaseTableColumn::create('optionID'),
+ IntDatabaseTableColumn::create('languageID'),
+ VarcharDatabaseTableColumn::create('title')
+ ->length(255),
+ MediumtextDatabaseTableColumn::create('description'),
+ TinyintDatabaseTableColumn::create('isPristine')
+ ->notNull()
+ ->defaultValue(1),
+ ])
+ ->indices([
+ DatabaseTableIndex::create('optionID')
+ ->columns(['optionID', 'languageID']),
+ ])
+ ->foreignKeys([
+ DatabaseTableForeignKey::create()
+ ->columns(['optionID'])
+ ->referencedTable('wcf1_user_option')
+ ->referencedColumns(['optionID'])
+ ->onDelete('CASCADE')
+ ->onUpdate('NO ACTION'),
+ DatabaseTableForeignKey::create()
+ ->columns(['languageID'])
+ ->referencedTable('wcf1_language')
+ ->referencedColumns(['languageID'])
+ ->onDelete('CASCADE')
+ ->onUpdate('NO ACTION'),
+ ]),
DatabaseTable::create('wcf1_user_option_category')
->columns([
ObjectIdDatabaseTableColumn::create('categoryID'),
@@ -4758,8 +4940,6 @@
NotNullInt10DatabaseTableColumn::create('groupID'),
NotNullInt10DatabaseTableColumn::create('requiredPoints')
->defaultValue(0),
- NotNullVarchar255DatabaseTableColumn::create('rankTitle')
- ->defaultValue(''),
NotNullVarchar255DatabaseTableColumn::create('cssClassName')
->defaultValue(''),
IntDatabaseTableColumn::create('rankImageFileID'),
@@ -4770,6 +4950,8 @@
->notNull()
->defaultValue(0),
DefaultFalseBooleanDatabaseTableColumn::create('hideTitle'),
+ VarcharDatabaseTableColumn::create('l10nIdentifier')
+ ->length(255),
])
->indices([
DatabaseTablePrimaryIndex::create()
@@ -4789,6 +4971,34 @@
->onDelete('SET NULL')
->onUpdate('NO ACTION'),
]),
+ DatabaseTable::create('wcf1_user_rank_l10n')
+ ->columns([
+ NotNullInt10DatabaseTableColumn::create('rankID'),
+ IntDatabaseTableColumn::create('languageID'),
+ VarcharDatabaseTableColumn::create('rankTitle')
+ ->length(255),
+ TinyintDatabaseTableColumn::create('isPristine')
+ ->notNull()
+ ->defaultValue(1),
+ ])
+ ->indices([
+ DatabaseTableIndex::create('rankID')
+ ->columns(['rankID', 'languageID']),
+ ])
+ ->foreignKeys([
+ DatabaseTableForeignKey::create()
+ ->columns(['rankID'])
+ ->referencedTable('wcf1_user_rank')
+ ->referencedColumns(['rankID'])
+ ->onDelete('CASCADE')
+ ->onUpdate('NO ACTION'),
+ DatabaseTableForeignKey::create()
+ ->columns(['languageID'])
+ ->referencedTable('wcf1_language')
+ ->referencedColumns(['languageID'])
+ ->onDelete('CASCADE')
+ ->onUpdate('NO ACTION'),
+ ]),
DatabaseTable::create('wcf1_user_session')
->columns([
BinaryDatabaseTableColumn::create('sessionID')