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]}{if $isSearchMode|empty && $option->required} *{/if}{/if}
+ - {if $isSearchMode|empty || !$optionData[hideLabelInSearch]}{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}
-
+
- {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/wcfsetup/install/files/acp/database/update_com.woltlab.wcf_6.3_captchaQuestion.php b/wcfsetup/install/files/acp/database/update_com.woltlab.wcf_6.3_captchaQuestion.php
new file mode 100644
index 00000000000..2c5ceb3880b
--- /dev/null
+++ b/wcfsetup/install/files/acp/database/update_com.woltlab.wcf_6.3_captchaQuestion.php
@@ -0,0 +1,21 @@
+columns([
+ NotNullVarchar255DatabaseTableColumn::create('question')->drop(),
+ MediumtextDatabaseTableColumn::create('answers')->drop(),
+ ]),
+];
diff --git a/wcfsetup/install/files/acp/database/update_com.woltlab.wcf_6.3_step1.php b/wcfsetup/install/files/acp/database/update_com.woltlab.wcf_6.3_step1.php
index bf5144ac108..f2b1265591e 100644
--- a/wcfsetup/install/files/acp/database/update_com.woltlab.wcf_6.3_step1.php
+++ b/wcfsetup/install/files/acp/database/update_com.woltlab.wcf_6.3_step1.php
@@ -10,11 +10,18 @@
use wcf\system\database\table\column\CharDatabaseTableColumn;
use wcf\system\database\table\column\DefaultFalseBooleanDatabaseTableColumn;
+use wcf\system\database\table\column\IntDatabaseTableColumn;
use wcf\system\database\table\column\JsonDatabaseTableColumn;
use wcf\system\database\table\column\MediumintDatabaseTableColumn;
+use wcf\system\database\table\column\MediumtextDatabaseTableColumn;
+use wcf\system\database\table\column\NotNullInt10DatabaseTableColumn;
use wcf\system\database\table\column\NotNullVarchar255DatabaseTableColumn;
use wcf\system\database\table\column\SmallintDatabaseTableColumn;
use wcf\system\database\table\column\TextDatabaseTableColumn;
+use wcf\system\database\table\column\VarcharDatabaseTableColumn;
+use wcf\system\database\table\DatabaseTable;
+use wcf\system\database\table\index\DatabaseTableForeignKey;
+use wcf\system\database\table\index\DatabaseTableIndex;
use wcf\system\database\table\PartialDatabaseTable;
return [
@@ -74,4 +81,30 @@
->defaultValue('')
->drop(),
]),
+ 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'),
+ ]),
];
diff --git a/wcfsetup/install/files/acp/database/update_com.woltlab.wcf_6.3_userOption.php b/wcfsetup/install/files/acp/database/update_com.woltlab.wcf_6.3_userOption.php
new file mode 100644
index 00000000000..4f59eea6b70
--- /dev/null
+++ b/wcfsetup/install/files/acp/database/update_com.woltlab.wcf_6.3_userOption.php
@@ -0,0 +1,57 @@
+columns([
+ VarcharDatabaseTableColumn::create('l10nIdentifier')
+ ->length(255),
+ ]),
+ 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'),
+ ]),
+];
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..8f4e842459f
--- /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_userOptionL10n.php b/wcfsetup/install/files/acp/update_com.woltlab.wcf_6.3_userOptionL10n.php
new file mode 100644
index 00000000000..7816e70bf12
--- /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_userOption.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/lib/acp/form/CaptchaQuestionAddForm.class.php b/wcfsetup/install/files/lib/acp/form/CaptchaQuestionAddForm.class.php
index cb871b98d69..3bc031fe303 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 b81d2afb936..12261d48e50 100644
--- a/wcfsetup/install/files/lib/acp/form/CaptchaQuestionEditForm.class.php
+++ b/wcfsetup/install/files/lib/acp/form/CaptchaQuestionEditForm.class.php
@@ -29,7 +29,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/UserOptionAddForm.class.php b/wcfsetup/install/files/lib/acp/form/UserOptionAddForm.class.php
index a4ba4e372e1..2c498b99906 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')
@@ -207,6 +275,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')
@@ -216,6 +290,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')
@@ -228,7 +324,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([
@@ -239,10 +341,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')
@@ -251,146 +365,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 (empty($outputClass)) {
- if (\in_array($optionType, self::$optionTypesUsingSelectOptions)) {
- $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 3f199f28e6f..bf50f74e90b 100644
--- a/wcfsetup/install/files/lib/acp/form/UserOptionEditForm.class.php
+++ b/wcfsetup/install/files/lib/acp/form/UserOptionEditForm.class.php
@@ -2,16 +2,12 @@
namespace wcf\acp\form;
-use CuyZ\Valinor\Mapper\MappingError;
use wcf\acp\page\UserOptionListPage;
use wcf\data\user\option\UserOption;
-use wcf\form\AbstractFormBuilderForm;
use wcf\http\Helper;
-use wcf\system\exception\IllegalLinkException;
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;
@@ -32,35 +28,18 @@ class UserOptionEditForm extends UserOptionAddForm
/**
* @inheritDoc
*/
- public $formAction = 'edit';
+ public string $formAction = 'edit';
#[\Override]
public function readParameters()
{
parent::readParameters();
- try {
- $queryParameters = Helper::mapQueryParameters(
- $_GET,
- <<<'EOT'
- array {
- id: positive-int
- }
- EOT
- );
- } catch (MappingError) {
- throw new IllegalLinkException();
- }
-
- $this->formObject = new UserOption($queryParameters['id']);
-
- if (!$this->formObject->getObjectID()) {
- throw new IllegalLinkException();
- }
+ $this->formObject = Helper::fetchObjectFromQueryParameter(UserOption::class);
}
#[\Override]
- public function createForm()
+ protected function createForm(): void
{
parent::createForm();
@@ -75,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/page/UserListPage.class.php b/wcfsetup/install/files/lib/acp/page/UserListPage.class.php
index a343776ee3f..515c71000f8 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 4efb5e9c5cd..25271d8aff0 100644
--- a/wcfsetup/install/files/lib/bootstrap/com.woltlab.wcf.php
+++ b/wcfsetup/install/files/lib/bootstrap/com.woltlab.wcf.php
@@ -68,6 +68,13 @@ static function () {
}
}
);
+
+ $eventHandler->register(
+ \wcf\event\l10n\L10nDefinitionCollecting::class,
+ static function (\wcf\event\l10n\L10nDefinitionCollecting $event) {
+ $event->register(\wcf\data\user\option\UserOption::getL10nDefinition());
+ }
+ );
$eventHandler->register(
\wcf\event\language\LanguageImported::class,
static function (\wcf\event\language\LanguageImported $event) {
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/l10n/SyncL10nLanguageItems.class.php b/wcfsetup/install/files/lib/command/l10n/SyncL10nLanguageItems.class.php
new file mode 100644
index 00000000000..839c37fbecf
--- /dev/null
+++ b/wcfsetup/install/files/lib/command/l10n/SyncL10nLanguageItems.class.php
@@ -0,0 +1,31 @@
+
+ * @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/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/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..f953d226645
--- /dev/null
+++ b/wcfsetup/install/files/lib/data/TCollectionL10n.class.php
@@ -0,0 +1,53 @@
+
+ * @since 6.3
+ */
+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 array
+ */
+ 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 1950e5f49a7..92d6db1ee00 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,56 @@
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\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
*/
-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)) {
@@ -59,9 +67,37 @@ public function isAnswer(string $answer)
return false;
}
+ /**
+ * Returns the localized values of this question.
+ *
+ * @return array
+ * @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'],
+ );
+ }
}
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 d0b47dc50d8..1280a4fb4c2 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..88c732447fe
--- /dev/null
+++ b/wcfsetup/install/files/lib/data/captcha/question/CaptchaQuestionBuilder.class.php
@@ -0,0 +1,100 @@
+
+ * @since 6.3
+ *
+ * @extends DatabaseObjectBuilder
+ */
+final class CaptchaQuestionBuilder extends DatabaseObjectBuilder
+{
+ /**
+ * @var array
+ */
+ private array $question;
+
+ /**
+ * @var array
+ */
+ private array $answers;
+
+ /**
+ * @param array $question
+ */
+ public function setQuestion(array $question): static
+ {
+ $this->question = $question;
+
+ return $this;
+ }
+
+ /**
+ * @param array $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/option/Option.class.php b/wcfsetup/install/files/lib/data/option/Option.class.php
index e26384616f4..ef0bf320782 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