From 82116d0fee6bdedd5aa16653ab7d4f9faa8583d9 Mon Sep 17 00:00:00 2001 From: Marcel Werk Date: Thu, 23 Jul 2026 12:07:12 +0200 Subject: [PATCH 01/15] Add automatic provisioning of l10n tables for database objects Packages register a `L10nDefinition` (DBO class + payload columns) via the `L10nDefinitionCollecting` event in their bootstrap file. At the end of a package installation or update, `SyncL10nTables` creates or extends the `_l10n` tables (objectID, nullable languageID for monolingual content, payload columns) using `DatabaseTableChangeProcessor`, so all changes are recorded in the sql log and dropped on package uninstallation. --- .../lib/command/l10n/SyncL10nTables.class.php | 179 ++++++++++++++++++ .../l10n/L10nDefinitionCollecting.class.php | 35 ++++ .../lib/system/l10n/L10nDefinition.class.php | 64 +++++++ .../PackageInstallationDispatcher.class.php | 2 + 4 files changed, 280 insertions(+) create mode 100644 wcfsetup/install/files/lib/command/l10n/SyncL10nTables.class.php create mode 100644 wcfsetup/install/files/lib/event/l10n/L10nDefinitionCollecting.class.php create mode 100644 wcfsetup/install/files/lib/system/l10n/L10nDefinition.class.php diff --git a/wcfsetup/install/files/lib/command/l10n/SyncL10nTables.class.php b/wcfsetup/install/files/lib/command/l10n/SyncL10nTables.class.php new file mode 100644 index 0000000000..357ca872a1 --- /dev/null +++ b/wcfsetup/install/files/lib/command/l10n/SyncL10nTables.class.php @@ -0,0 +1,179 @@ + + * @since 6.3 + */ +final class SyncL10nTables +{ + private const MAX_ITERATIONS = 100; + + public function __invoke(): void + { + $definitions = $this->collectDefinitions(); + if ($definitions === []) { + return; + } + + $tablesByPackageID = []; + foreach ($this->groupByTable($definitions) as $tableDefinitions) { + $baseTableName = $tableDefinitions[0]->getBaseTableName(); + $packageID = $this->getOwningPackageID($baseTableName); + if ($packageID === null || PackageCache::getInstance()->getPackage($packageID) === null) { + logThrowable(new \RuntimeException( + "Cannot synchronize the l10n table for '{$baseTableName}', the owning package of the base table could not be determined." + )); + + continue; + } + + $tablesByPackageID[$packageID][] = $this->buildTable($tableDefinitions); + } + + foreach ($tablesByPackageID as $packageID => $tables) { + $package = PackageCache::getInstance()->getPackage($packageID); + $processor = new DatabaseTableChangeProcessor( + $package, + null, + WCF::getDB()->getEditor(), + ); + + for ($i = 0; $i < self::MAX_ITERATIONS; $i++) { + if (!$processor->process($tables)) { + continue 2; + } + } + + throw new \RuntimeException( + "The synchronization of the l10n tables of the package '{$package->package}' did not converge." + ); + } + } + + /** + * @return list + */ + private function collectDefinitions(): array + { + $event = new L10nDefinitionCollecting(); + EventHandler::getInstance()->fire($event); + + return $event->getDefinitions(); + } + + /** + * Groups the definitions by the name of their l10n table. + * + * @param list $definitions + * @return array> + */ + private function groupByTable(array $definitions): array + { + $groups = []; + foreach ($definitions as $definition) { + $groups[$definition->getL10nTableName()][] = $definition; + } + + return $groups; + } + + /** + * Builds the intended layout of the l10n table, merging the payload + * columns of all given definitions. + * + * @param non-empty-list $definitions + */ + private function buildTable(array $definitions): DatabaseTable + { + $baseTableName = $definitions[0]->getBaseTableName(); + + $payloadColumns = []; + foreach ($definitions as $definition) { + foreach ($definition->columns as $column) { + $name = \strtolower($column->getName()); + if (isset($payloadColumns[$name])) { + throw new \LogicException( + "The column '{$column->getName()}' of the l10n table '{$definitions[0]->getL10nTableName()}' has been defined multiple times." + ); + } + + $payloadColumns[$name] = $column; + } + } + + return DatabaseTable::create($definitions[0]->getL10nTableName()) + ->columns([ + NotNullInt10DatabaseTableColumn::create('objectID'), + IntDatabaseTableColumn::create('languageID')->length(10), + ...\array_values($payloadColumns), + ]) + ->indices([ + DatabaseTableIndex::create('') + ->columns(['objectID', 'languageID']), + ]) + ->foreignKeys([ + DatabaseTableForeignKey::create() + ->columns(['objectID']) + ->referencedTable($baseTableName) + ->referencedColumns([$definitions[0]->getBaseTableIndexName()]) + ->onDelete('CASCADE'), + DatabaseTableForeignKey::create() + ->columns(['languageID']) + ->referencedTable('wcf1_language') + ->referencedColumns(['languageID']) + ->onDelete('CASCADE'), + ]); + } + + /** + * Returns the id of the package that owns the given base table or `null` + * if the table is not recorded in the sql log. + */ + private function getOwningPackageID(string $baseTableName): ?int + { + $sql = "SELECT packageID + FROM wcf1_package_installation_sql_log + WHERE sqlTable = ? + AND sqlColumn = '' + AND sqlIndex = '' + AND isDone = ?"; + $statement = WCF::getDB()->prepare($sql); + $statement->execute([ + $baseTableName, + 1, + ]); + + $packageID = $statement->fetchSingleColumn(); + + return $packageID === false ? null : (int)$packageID; + } +} 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 0000000000..d4ca593882 --- /dev/null +++ b/wcfsetup/install/files/lib/event/l10n/L10nDefinitionCollecting.class.php @@ -0,0 +1,35 @@ + + * @since 6.3 + */ +final class L10nDefinitionCollecting implements IPsr14Event +{ + /** + * @var list + */ + private array $definitions = []; + + public function register(L10nDefinition $definition): void + { + $this->definitions[] = $definition; + } + + /** + * @return list + */ + public function getDefinitions(): array + { + return $this->definitions; + } +} 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 0000000000..b0af35a7a7 --- /dev/null +++ b/wcfsetup/install/files/lib/system/l10n/L10nDefinition.class.php @@ -0,0 +1,64 @@ + + * @since 6.3 + */ +final class L10nDefinition +{ + /** + * @param class-string $class + * @param list $columns + */ + public function __construct( + public readonly string $class, + public readonly array $columns, + ) { + if (!\is_subclass_of($this->class, DatabaseObject::class)) { + throw new \InvalidArgumentException( + "Given class '{$this->class}' is no subclass of '" . DatabaseObject::class . "'." + ); + } + + $columnNames = []; + foreach ($this->columns as $column) { + $name = \strtolower($column->getName()); + if ($name === 'objectid' || $name === 'languageid') { + throw new \InvalidArgumentException( + "The column name '{$column->getName()}' is reserved for the default columns." + ); + } + + if (isset($columnNames[$name])) { + throw new \InvalidArgumentException("Duplicate column with name '{$column->getName()}'."); + } + $columnNames[$name] = true; + } + } + + public function getBaseTableName(): string + { + return $this->class::getDatabaseTableName(); + } + + public function getL10nTableName(): string + { + return $this->getBaseTableName() . '_l10n'; + } + + public function getBaseTableIndexName(): string + { + return $this->class::getDatabaseTableIndexName(); + } +} diff --git a/wcfsetup/install/files/lib/system/package/PackageInstallationDispatcher.class.php b/wcfsetup/install/files/lib/system/package/PackageInstallationDispatcher.class.php index c0cf079660..f4a60f6055 100644 --- a/wcfsetup/install/files/lib/system/package/PackageInstallationDispatcher.class.php +++ b/wcfsetup/install/files/lib/system/package/PackageInstallationDispatcher.class.php @@ -208,6 +208,8 @@ public function install(string $node): PackageInstallationStep VersionTracker::getInstance()->createStorageTables(); + (new \wcf\command\l10n\SyncL10nTables())(); + $command = new \wcf\command\package\RebuildBootstrapper(); $command(); From 229c4c805702fbe9855dca9b7b83f47ebc299946 Mon Sep 17 00:00:00 2001 From: Marcel Werk Date: Fri, 24 Jul 2026 18:50:17 +0200 Subject: [PATCH 02/15] Migrate captcha questions from i18n phrases to l10n storage --- ...te_com.woltlab.wcf_6.3_captchaQuestion.php | 21 ++ .../update_com.woltlab.wcf_6.3_step1.php | 31 +++ ...om.woltlab.wcf_6.3_captchaQuestionL10n.php | 144 +++++++++++ .../acp/form/CaptchaQuestionAddForm.class.php | 70 ++++-- .../form/CaptchaQuestionEditForm.class.php | 2 +- .../question/CreateCaptchaQuestion.class.php | 38 +++ .../question/UpdateCaptchaQuestion.class.php | 38 +++ .../lib/command/l10n/SyncL10nTables.class.php | 179 ------------- .../question/CaptchaQuestion.class.php | 68 +++-- .../question/CaptchaQuestionAction.class.php | 64 +---- .../question/CaptchaQuestionBuilder.class.php | 100 ++++++++ .../CaptchaQuestionCollection.class.php | 54 ++++ .../I18nCaptchaQuestionList.class.php | 28 --- .../L10nCaptchaQuestionList.class.php | 35 +++ .../question/CaptchaQuestionCreated.class.php | 23 ++ .../question/CaptchaQuestionUpdated.class.php | 23 ++ .../l10n/L10nDefinitionCollecting.class.php | 35 --- .../builder/field/IL10nFormField.class.php | 50 ++++ .../builder/field/TL10nFormField.class.php | 223 +++++++++++++++++ .../builder/field/TextFormField.class.php | 3 +- .../admin/CaptchaQuestionGridView.class.php | 19 +- .../lib/system/l10n/L10nDefinition.class.php | 58 ++--- .../lib/system/l10n/L10nStorage.class.php | 236 ++++++++++++++++++ .../PackageInstallationDispatcher.class.php | 2 - .../view/filter/L10nTextFilter.class.php | 55 ++++ wcfsetup/setup/db/install_com.woltlab.wcf.php | 27 +- 26 files changed, 1237 insertions(+), 389 deletions(-) create mode 100644 wcfsetup/install/files/acp/database/update_com.woltlab.wcf_6.3_captchaQuestion.php create mode 100644 wcfsetup/install/files/acp/update_com.woltlab.wcf_6.3_captchaQuestionL10n.php create mode 100644 wcfsetup/install/files/lib/command/captcha/question/CreateCaptchaQuestion.class.php create mode 100644 wcfsetup/install/files/lib/command/captcha/question/UpdateCaptchaQuestion.class.php delete mode 100644 wcfsetup/install/files/lib/command/l10n/SyncL10nTables.class.php create mode 100644 wcfsetup/install/files/lib/data/captcha/question/CaptchaQuestionBuilder.class.php create mode 100644 wcfsetup/install/files/lib/data/captcha/question/CaptchaQuestionCollection.class.php delete mode 100644 wcfsetup/install/files/lib/data/captcha/question/I18nCaptchaQuestionList.class.php create mode 100644 wcfsetup/install/files/lib/data/captcha/question/L10nCaptchaQuestionList.class.php create mode 100644 wcfsetup/install/files/lib/event/captcha/question/CaptchaQuestionCreated.class.php create mode 100644 wcfsetup/install/files/lib/event/captcha/question/CaptchaQuestionUpdated.class.php delete mode 100644 wcfsetup/install/files/lib/event/l10n/L10nDefinitionCollecting.class.php create mode 100644 wcfsetup/install/files/lib/system/form/builder/field/IL10nFormField.class.php create mode 100644 wcfsetup/install/files/lib/system/form/builder/field/TL10nFormField.class.php create mode 100644 wcfsetup/install/files/lib/system/l10n/L10nStorage.class.php create mode 100644 wcfsetup/install/files/lib/system/view/filter/L10nTextFilter.class.php 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 0000000000..2c5ceb3880 --- /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 bf5144ac10..85d725d821 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,17 @@ 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\DatabaseTable; +use wcf\system\database\table\index\DatabaseTableForeignKey; +use wcf\system\database\table\index\DatabaseTableIndex; use wcf\system\database\table\PartialDatabaseTable; return [ @@ -74,4 +80,29 @@ ->defaultValue('') ->drop(), ]), + DatabaseTable::create('wcf1_captcha_question_l10n') + ->columns([ + NotNullInt10DatabaseTableColumn::create('questionID'), + IntDatabaseTableColumn::create('languageID'), + NotNullVarchar255DatabaseTableColumn::create('question'), + 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/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 0000000000..ebf7cb8061 --- /dev/null +++ b/wcfsetup/install/files/acp/update_com.woltlab.wcf_6.3_captchaQuestionL10n.php @@ -0,0 +1,144 @@ +prepare($sql)->execute(); + +$sql = "SELECT questionID, question, answers + FROM wcf1_captcha_question"; +$statement = WCF::getDB()->prepare($sql); +$statement->execute(); +$rows = []; +while ($row = $statement->fetchArray()) { + $rows[] = $row; +} + +$installedLanguageIDs = \array_keys(LanguageFactory::getInstance()->getLanguages()); +$defaultLanguageID = LanguageFactory::getInstance()->getDefaultLanguageID(); + +$fetchItemsStatement = WCF::getDB()->prepare( + "SELECT languageID, languageItemValue + FROM wcf1_language_item + WHERE languageItem = ?" +); +$fetchItems = static function (string $languageItem) use ($fetchItemsStatement): array { + $fetchItemsStatement->execute([$languageItem]); + + return $fetchItemsStatement->fetchMap('languageID', 'languageItemValue'); +}; + +// Mirrors the phrase fallback semantics: value of the requested language, +// value of the default language, any value, literal column value. +$resolve = static function (?array $items, ?string $literal, int $languageID) use ($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; +}; + +$obsoleteItems = []; +$insertStatement = WCF::getDB()->prepare( + "INSERT INTO wcf1_captcha_question_l10n (questionID, languageID, question, answers) + VALUES (?, ?, ?, ?)" +); + +WCF::getDB()->beginTransaction(); +foreach ($rows as $row) { + $questionItems = null; + if (\preg_match('~^wcf\.captcha\.question\.question\.question\d+$~', $row['question'])) { + $items = $fetchItems($row['question']); + if ($items !== []) { + $questionItems = $items; + $obsoleteItems[] = $row['question']; + } + // Phrase name stored but items are missing: Treat the value as + // literal text, mirroring the recovery in `TI18nFormField`. + } + + $answersItems = null; + if ( + $row['answers'] !== null + && \preg_match('~^wcf\.captcha\.question\.answers\.question\d+$~', $row['answers']) + ) { + $items = $fetchItems($row['answers']); + if ($items !== []) { + $answersItems = $items; + $obsoleteItems[] = $row['answers']; + } + } + + // Consistency rule of the l10n storage: an object is either monolingual + // (a single row with `languageID IS NULL`) or multilingual (one row per + // language). The language set is the union of the phrase languages, a + // literal or missing side is filled per language via the fallback chain. + $languageIDs = \array_values(\array_intersect( + \array_unique([ + ...\array_keys($questionItems ?? []), + ...\array_keys($answersItems ?? []), + ]), + $installedLanguageIDs + )); + + if ($languageIDs === []) { + $insertStatement->execute([ + $row['questionID'], + null, + $row['question'], + $row['answers'], + ]); + + continue; + } + + foreach ($languageIDs as $languageID) { + $insertStatement->execute([ + $row['questionID'], + $languageID, + $resolve($questionItems, $row['question'], $languageID) ?? '', + $resolve($answersItems, $row['answers'], $languageID), + ]); + } +} +WCF::getDB()->commitTransaction(); + +// Remove the migrated phrases. +if ($obsoleteItems !== []) { + foreach (\array_chunk(\array_unique($obsoleteItems), 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(); +} + +// Cached question objects were created without their localized values. +CaptchaQuestionCacheBuilder::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 cb871b98d6..3bc031fe30 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 b81d2afb93..12261d48e5 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/command/captcha/question/CreateCaptchaQuestion.class.php b/wcfsetup/install/files/lib/command/captcha/question/CreateCaptchaQuestion.class.php new file mode 100644 index 0000000000..bc953c05ad --- /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 0000000000..a6b6458041 --- /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/SyncL10nTables.class.php b/wcfsetup/install/files/lib/command/l10n/SyncL10nTables.class.php deleted file mode 100644 index 357ca872a1..0000000000 --- a/wcfsetup/install/files/lib/command/l10n/SyncL10nTables.class.php +++ /dev/null @@ -1,179 +0,0 @@ - - * @since 6.3 - */ -final class SyncL10nTables -{ - private const MAX_ITERATIONS = 100; - - public function __invoke(): void - { - $definitions = $this->collectDefinitions(); - if ($definitions === []) { - return; - } - - $tablesByPackageID = []; - foreach ($this->groupByTable($definitions) as $tableDefinitions) { - $baseTableName = $tableDefinitions[0]->getBaseTableName(); - $packageID = $this->getOwningPackageID($baseTableName); - if ($packageID === null || PackageCache::getInstance()->getPackage($packageID) === null) { - logThrowable(new \RuntimeException( - "Cannot synchronize the l10n table for '{$baseTableName}', the owning package of the base table could not be determined." - )); - - continue; - } - - $tablesByPackageID[$packageID][] = $this->buildTable($tableDefinitions); - } - - foreach ($tablesByPackageID as $packageID => $tables) { - $package = PackageCache::getInstance()->getPackage($packageID); - $processor = new DatabaseTableChangeProcessor( - $package, - null, - WCF::getDB()->getEditor(), - ); - - for ($i = 0; $i < self::MAX_ITERATIONS; $i++) { - if (!$processor->process($tables)) { - continue 2; - } - } - - throw new \RuntimeException( - "The synchronization of the l10n tables of the package '{$package->package}' did not converge." - ); - } - } - - /** - * @return list - */ - private function collectDefinitions(): array - { - $event = new L10nDefinitionCollecting(); - EventHandler::getInstance()->fire($event); - - return $event->getDefinitions(); - } - - /** - * Groups the definitions by the name of their l10n table. - * - * @param list $definitions - * @return array> - */ - private function groupByTable(array $definitions): array - { - $groups = []; - foreach ($definitions as $definition) { - $groups[$definition->getL10nTableName()][] = $definition; - } - - return $groups; - } - - /** - * Builds the intended layout of the l10n table, merging the payload - * columns of all given definitions. - * - * @param non-empty-list $definitions - */ - private function buildTable(array $definitions): DatabaseTable - { - $baseTableName = $definitions[0]->getBaseTableName(); - - $payloadColumns = []; - foreach ($definitions as $definition) { - foreach ($definition->columns as $column) { - $name = \strtolower($column->getName()); - if (isset($payloadColumns[$name])) { - throw new \LogicException( - "The column '{$column->getName()}' of the l10n table '{$definitions[0]->getL10nTableName()}' has been defined multiple times." - ); - } - - $payloadColumns[$name] = $column; - } - } - - return DatabaseTable::create($definitions[0]->getL10nTableName()) - ->columns([ - NotNullInt10DatabaseTableColumn::create('objectID'), - IntDatabaseTableColumn::create('languageID')->length(10), - ...\array_values($payloadColumns), - ]) - ->indices([ - DatabaseTableIndex::create('') - ->columns(['objectID', 'languageID']), - ]) - ->foreignKeys([ - DatabaseTableForeignKey::create() - ->columns(['objectID']) - ->referencedTable($baseTableName) - ->referencedColumns([$definitions[0]->getBaseTableIndexName()]) - ->onDelete('CASCADE'), - DatabaseTableForeignKey::create() - ->columns(['languageID']) - ->referencedTable('wcf1_language') - ->referencedColumns(['languageID']) - ->onDelete('CASCADE'), - ]); - } - - /** - * Returns the id of the package that owns the given base table or `null` - * if the table is not recorded in the sql log. - */ - private function getOwningPackageID(string $baseTableName): ?int - { - $sql = "SELECT packageID - FROM wcf1_package_installation_sql_log - WHERE sqlTable = ? - AND sqlColumn = '' - AND sqlIndex = '' - AND isDone = ?"; - $statement = WCF::getDB()->prepare($sql); - $statement->execute([ - $baseTableName, - 1, - ]); - - $packageID = $statement->fetchSingleColumn(); - - return $packageID === false ? null : (int)$packageID; - } -} 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 1950e5f49a..92d6db1ee0 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 d0b47dc50d..1280a4fb4c 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 0000000000..88c732447f --- /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 0000000000..183a4e6bb2 --- /dev/null +++ b/wcfsetup/install/files/lib/data/captcha/question/CaptchaQuestionCollection.class.php @@ -0,0 +1,54 @@ + + * @since 6.3 + * + * @extends DatabaseObjectCollection + */ +class CaptchaQuestionCollection extends DatabaseObjectCollection +{ + /** + * @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(CaptchaQuestion::getL10nDefinition()))->getValuesForObjects( + $this->getObjectIDs() + ); + } +} 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 4784e731df..0000000000 --- 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 0000000000..c2bbec7567 --- /dev/null +++ b/wcfsetup/install/files/lib/data/captcha/question/L10nCaptchaQuestionList.class.php @@ -0,0 +1,35 @@ + + * @since 6.3 + * + * @extends DatabaseObjectList + */ +class L10nCaptchaQuestionList extends DatabaseObjectList +{ + /** + * @inheritDoc + */ + public $className = CaptchaQuestion::class; + + 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/event/captcha/question/CaptchaQuestionCreated.class.php b/wcfsetup/install/files/lib/event/captcha/question/CaptchaQuestionCreated.class.php new file mode 100644 index 0000000000..2f8d14dad6 --- /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 0000000000..ac50d51921 --- /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/l10n/L10nDefinitionCollecting.class.php b/wcfsetup/install/files/lib/event/l10n/L10nDefinitionCollecting.class.php deleted file mode 100644 index d4ca593882..0000000000 --- a/wcfsetup/install/files/lib/event/l10n/L10nDefinitionCollecting.class.php +++ /dev/null @@ -1,35 +0,0 @@ - - * @since 6.3 - */ -final class L10nDefinitionCollecting implements IPsr14Event -{ - /** - * @var list - */ - private array $definitions = []; - - public function register(L10nDefinition $definition): void - { - $this->definitions[] = $definition; - } - - /** - * @return list - */ - public function getDefinitions(): array - { - return $this->definitions; - } -} 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 0000000000..f52e04c2d6 --- /dev/null +++ b/wcfsetup/install/files/lib/system/form/builder/field/IL10nFormField.class.php @@ -0,0 +1,50 @@ + + * @since 6.3 + */ +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 array + */ + 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 0000000000..3ec8989d33 --- /dev/null +++ b/wcfsetup/install/files/lib/system/form/builder/field/TL10nFormField.class.php @@ -0,0 +1,223 @@ + + * @since 6.3 + * + * @mixin IL10nFormField + */ +trait TL10nFormField +{ + use TI18nFormField { + TI18nFormField::i18n as private i18nFieldI18n; + TI18nFormField::languageItemPattern as private i18nFieldLanguageItemPattern; + 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 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 e24bf8fd43..d7fe10cad9 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/gridView/admin/CaptchaQuestionGridView.class.php b/wcfsetup/install/files/lib/system/gridView/admin/CaptchaQuestionGridView.class.php index 7c891aa85c..4eab332a9e 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/l10n/L10nDefinition.class.php b/wcfsetup/install/files/lib/system/l10n/L10nDefinition.class.php index b0af35a7a7..ab749ff2cd 100644 --- a/wcfsetup/install/files/lib/system/l10n/L10nDefinition.class.php +++ b/wcfsetup/install/files/lib/system/l10n/L10nDefinition.class.php @@ -2,13 +2,13 @@ namespace wcf\system\l10n; -use wcf\data\DatabaseObject; -use wcf\system\database\table\column\IDatabaseTableColumn; - /** - * Describes the localizable payload columns of a database object. The payload - * columns are stored in a separate table named after the database object's - * table with the suffix `_l10n`. + * Describes the localized (`*_l10n`) table of a content type. + * + * The localized table stores one row per object and language with the fixed + * skeleton columns `objectColumnName` (referencing the primary table), + * `languageID` (`NULL` for monolingual content) followed by + * the localized payload columns. * * @author Marcel Werk * @copyright 2001-2026 WoltLab GmbH @@ -18,47 +18,25 @@ final class L10nDefinition { /** - * @param class-string $class - * @param list $columns + * @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 */ public function __construct( - public readonly string $class, - public readonly array $columns, + public readonly string $primaryTableName, + public readonly string $l10nTableName, + public readonly string $objectColumnName, + public readonly array $columnNames, ) { - if (!\is_subclass_of($this->class, DatabaseObject::class)) { + if (!\str_ends_with($l10nTableName, '_l10n')) { throw new \InvalidArgumentException( - "Given class '{$this->class}' is no subclass of '" . DatabaseObject::class . "'." + "The localized table name '{$l10nTableName}' must use the '_l10n' suffix." ); } - $columnNames = []; - foreach ($this->columns as $column) { - $name = \strtolower($column->getName()); - if ($name === 'objectid' || $name === 'languageid') { - throw new \InvalidArgumentException( - "The column name '{$column->getName()}' is reserved for the default columns." - ); - } - - if (isset($columnNames[$name])) { - throw new \InvalidArgumentException("Duplicate column with name '{$column->getName()}'."); - } - $columnNames[$name] = true; + if ($columnNames === []) { + throw new \InvalidArgumentException('At least one localized column must be defined.'); } } - - public function getBaseTableName(): string - { - return $this->class::getDatabaseTableName(); - } - - public function getL10nTableName(): string - { - return $this->getBaseTableName() . '_l10n'; - } - - public function getBaseTableIndexName(): string - { - return $this->class::getDatabaseTableIndexName(); - } } 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 0000000000..c2937f258b --- /dev/null +++ b/wcfsetup/install/files/lib/system/l10n/L10nStorage.class.php @@ -0,0 +1,236 @@ + value` per column. The key + * `L10nStorage::MONOLINGUAL` (`0`) represents monolingual content which is + * stored as a single row with `languageID = NULL`. Monolingual and + * multilingual values are mutually exclusive for the same object, this is + * enforced by this class because the database itself cannot enforce it + * (unique indices treat `NULL` values as distinct). + * + * 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 + */ +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) { + $values[$row[$objectColumnName]][$columnName][$languageID] = $row[$columnName]; + } + } + + return $values; + } + + /** + * Replaces the localized values of the given object. + * + * Expects a value for every localized column, each with an identical set + * of language ids. Passing values for `MONOLINGUAL` in combination with + * actual language ids is invalid. + * + * @param array> $values `columnName => [languageID => value]` + */ + public function setValues(int $objectID, array $values): void + { + $languageIDs = $this->validateValues($values); + + $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}) + 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]; + } + + $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 array $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} + 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 common list of language ids. + * + * @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 = null; + foreach ($values as $columnName => $columnValues) { + if ($columnValues === []) { + throw new \InvalidArgumentException("Missing values for column '{$columnName}'."); + } + + $columnLanguageIDs = \array_keys($columnValues); + \sort($columnLanguageIDs); + + if ($languageIDs === null) { + $languageIDs = $columnLanguageIDs; + } elseif ($languageIDs !== $columnLanguageIDs) { + throw new \InvalidArgumentException( + 'All localized columns must provide values for the same set of languages.' + ); + } + } + + if (\in_array(self::MONOLINGUAL, $languageIDs, true) && \count($languageIDs) > 1) { + throw new \InvalidArgumentException( + 'Monolingual values cannot be combined with language specific values.' + ); + } + + return $languageIDs; + } +} diff --git a/wcfsetup/install/files/lib/system/package/PackageInstallationDispatcher.class.php b/wcfsetup/install/files/lib/system/package/PackageInstallationDispatcher.class.php index f4a60f6055..c0cf079660 100644 --- a/wcfsetup/install/files/lib/system/package/PackageInstallationDispatcher.class.php +++ b/wcfsetup/install/files/lib/system/package/PackageInstallationDispatcher.class.php @@ -208,8 +208,6 @@ public function install(string $node): PackageInstallationStep VersionTracker::getInstance()->createStorageTables(); - (new \wcf\command\l10n\SyncL10nTables())(); - $command = new \wcf\command\package\RebuildBootstrapper(); $command(); 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 0000000000..0a01be4722 --- /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/setup/db/install_com.woltlab.wcf.php b/wcfsetup/setup/db/install_com.woltlab.wcf.php index a644b8604a..7d81e1672b 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,31 @@ DatabaseTablePrimaryIndex::create() ->columns(['questionID']), ]), + DatabaseTable::create('wcf1_captcha_question_l10n') + ->columns([ + NotNullInt10DatabaseTableColumn::create('questionID'), + IntDatabaseTableColumn::create('languageID'), + NotNullVarchar255DatabaseTableColumn::create('question'), + 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'), From 0528aba0de9a549faa8a62d0504b4d1cc82cfb58 Mon Sep 17 00:00:00 2001 From: Marcel Werk Date: Sat, 25 Jul 2026 15:49:09 +0200 Subject: [PATCH 03/15] Support per-column language sets in L10n storage --- .../update_com.woltlab.wcf_6.3_step1.php | 4 +- .../builder/field/TL10nFormField.class.php | 3 + .../lib/system/l10n/L10nStorage.class.php | 55 +++++++++++-------- wcfsetup/setup/db/install_com.woltlab.wcf.php | 3 +- 4 files changed, 40 insertions(+), 25 deletions(-) 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 85d725d821..f2b1265591 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 @@ -18,6 +18,7 @@ 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; @@ -84,7 +85,8 @@ ->columns([ NotNullInt10DatabaseTableColumn::create('questionID'), IntDatabaseTableColumn::create('languageID'), - NotNullVarchar255DatabaseTableColumn::create('question'), + VarcharDatabaseTableColumn::create('question') + ->length(255), MediumtextDatabaseTableColumn::create('answers'), ]) ->indices([ 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 index 3ec8989d33..aa31a0af1d 100644 --- a/wcfsetup/install/files/lib/system/form/builder/field/TL10nFormField.class.php +++ b/wcfsetup/install/files/lib/system/form/builder/field/TL10nFormField.class.php @@ -137,6 +137,9 @@ public function value(mixed $value) if (\is_string($value) || \is_numeric($value)) { I18nHandler::getInstance()->setValue($this->getPrefixedId(), (string)$value, true); } elseif (\is_array($value)) { + // A stored value map can contain `NULL` for languages that only + // exist because of another column, drop them before dispatching. + $value = \array_filter($value, static fn($v) => $v !== null); if ($value !== []) { if (\array_key_exists(L10nStorage::MONOLINGUAL, $value)) { if (\count($value) !== 1) { diff --git a/wcfsetup/install/files/lib/system/l10n/L10nStorage.class.php b/wcfsetup/install/files/lib/system/l10n/L10nStorage.class.php index c2937f258b..d5768466c0 100644 --- a/wcfsetup/install/files/lib/system/l10n/L10nStorage.class.php +++ b/wcfsetup/install/files/lib/system/l10n/L10nStorage.class.php @@ -11,10 +11,12 @@ * * Values are exchanged as a map of `languageID => value` per column. The key * `L10nStorage::MONOLINGUAL` (`0`) represents monolingual content which is - * stored as a single row with `languageID = NULL`. Monolingual and - * multilingual values are mutually exclusive for the same object, this is - * enforced by this class because the database itself cannot enforce it - * (unique indices treat `NULL` values as distinct). + * 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. * @@ -81,9 +83,11 @@ public function getValuesForObjects(array $objectIDs): array /** * Replaces the localized values of the given object. * - * Expects a value for every localized column, each with an identical set - * of language ids. Passing values for `MONOLINGUAL` in combination with - * actual language ids is invalid. + * 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. * * @param array> $values `columnName => [languageID => value]` */ @@ -114,7 +118,7 @@ public function setValues(int $objectID, array $values): void $languageID === self::MONOLINGUAL ? null : $languageID, ]; foreach ($this->definition->columnNames as $columnName) { - $parameters[] = $values[$columnName][$languageID]; + $parameters[] = $values[$columnName][$languageID] ?? null; } $insertStatement->execute($parameters); @@ -156,6 +160,13 @@ public static function resolveValue(array $values, ?int $languageID = null): str return $values[$defaultLanguageID]; } + // A column can be `NULL` for a given language, drop those before + // falling back to the value with the lowest language id. + $values = \array_filter($values, static fn($value) => $value !== null); + if ($values === []) { + return ''; + } + return $values[\min(\array_keys($values))]; } @@ -177,6 +188,7 @@ public function getSubSelect(string $columnName, string $tableAlias, ?int $langu 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 @@ -188,7 +200,8 @@ public function getSubSelect(string $columnName, string $tableAlias, ?int $langu } /** - * Validates the given values and returns the common list of language ids. + * Validates the given values and returns the union of language ids across + * all columns. * * @param array> $values * @return list @@ -207,30 +220,26 @@ private function validateValues(array $values): array )); } - $languageIDs = null; + $languageIDs = []; foreach ($values as $columnName => $columnValues) { if ($columnValues === []) { throw new \InvalidArgumentException("Missing values for column '{$columnName}'."); } $columnLanguageIDs = \array_keys($columnValues); - \sort($columnLanguageIDs); - - if ($languageIDs === null) { - $languageIDs = $columnLanguageIDs; - } elseif ($languageIDs !== $columnLanguageIDs) { - throw new \InvalidArgumentException( - 'All localized columns must provide values for the same set of languages.' - ); + 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, + )); } - } - if (\in_array(self::MONOLINGUAL, $languageIDs, true) && \count($languageIDs) > 1) { - throw new \InvalidArgumentException( - 'Monolingual values cannot be combined with language specific values.' - ); + $languageIDs = [...$languageIDs, ...$columnLanguageIDs]; } + $languageIDs = \array_values(\array_unique($languageIDs)); + \sort($languageIDs); + return $languageIDs; } } diff --git a/wcfsetup/setup/db/install_com.woltlab.wcf.php b/wcfsetup/setup/db/install_com.woltlab.wcf.php index 7d81e1672b..67aac00853 100644 --- a/wcfsetup/setup/db/install_com.woltlab.wcf.php +++ b/wcfsetup/setup/db/install_com.woltlab.wcf.php @@ -918,7 +918,8 @@ ->columns([ NotNullInt10DatabaseTableColumn::create('questionID'), IntDatabaseTableColumn::create('languageID'), - NotNullVarchar255DatabaseTableColumn::create('question'), + VarcharDatabaseTableColumn::create('question') + ->length(255), MediumtextDatabaseTableColumn::create('answers'), ]) ->indices([ From eba19ecc5522cee1c36a4522c14246d5a862011a Mon Sep 17 00:00:00 2001 From: Marcel Werk Date: Sat, 25 Jul 2026 15:50:56 +0200 Subject: [PATCH 04/15] Fix PHPStan issue --- wcfsetup/install/files/lib/system/l10n/L10nStorage.class.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/wcfsetup/install/files/lib/system/l10n/L10nStorage.class.php b/wcfsetup/install/files/lib/system/l10n/L10nStorage.class.php index d5768466c0..f399624b8b 100644 --- a/wcfsetup/install/files/lib/system/l10n/L10nStorage.class.php +++ b/wcfsetup/install/files/lib/system/l10n/L10nStorage.class.php @@ -138,7 +138,7 @@ public function setValues(int $objectID, array $values): void * deterministic fallback chain: monolingual value, requested language, * default language, lowest language id. * - * @param array $values + * @param array $values */ public static function resolveValue(array $values, ?int $languageID = null): string { From 12abf7c088d838bf24f3b33477b3f91a518186bd Mon Sep 17 00:00:00 2001 From: Marcel Werk Date: Sun, 26 Jul 2026 21:51:22 +0200 Subject: [PATCH 05/15] Refactor user options to `DatabaseObjectBuilder` --- .../lib/acp/form/UserOptionAddForm.class.php | 328 +++++++++++------- .../lib/acp/form/UserOptionEditForm.class.php | 31 +- .../user/option/CreateUserOption.class.php | 38 ++ .../user/option/DeleteOption.class.php | 12 +- .../user/option/DisableOption.class.php | 15 +- .../user/option/EnableOption.class.php | 15 +- .../user/option/UpdateUserOption.class.php | 38 ++ .../lib/data/user/option/UserOption.class.php | 2 +- .../user/option/UserOptionBuilder.class.php | 258 ++++++++++++++ .../user/option/UserOptionCreated.class.php | 23 ++ .../user/option/UserOptionDeleted.class.php | 19 + .../user/option/UserOptionDisabled.class.php | 19 + .../user/option/UserOptionEnabled.class.php | 19 + .../user/option/UserOptionUpdated.class.php | 23 ++ ...rOptionPackageInstallationPlugin.class.php | 53 ++- 15 files changed, 713 insertions(+), 180 deletions(-) create mode 100644 wcfsetup/install/files/lib/command/user/option/CreateUserOption.class.php create mode 100644 wcfsetup/install/files/lib/command/user/option/UpdateUserOption.class.php create mode 100644 wcfsetup/install/files/lib/data/user/option/UserOptionBuilder.class.php create mode 100644 wcfsetup/install/files/lib/event/user/option/UserOptionCreated.class.php create mode 100644 wcfsetup/install/files/lib/event/user/option/UserOptionDeleted.class.php create mode 100644 wcfsetup/install/files/lib/event/user/option/UserOptionDisabled.class.php create mode 100644 wcfsetup/install/files/lib/event/user/option/UserOptionEnabled.class.php create mode 100644 wcfsetup/install/files/lib/event/user/option/UserOptionUpdated.class.php diff --git a/wcfsetup/install/files/lib/acp/form/UserOptionAddForm.class.php b/wcfsetup/install/files/lib/acp/form/UserOptionAddForm.class.php index a4ba4e372e..325a10c502 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,7 +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; @@ -33,7 +33,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 +41,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 +96,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 +125,61 @@ 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 name and description are stored as i18n phrases keyed + // `wcf.user.option.option[.description]` and never + // as columns, hence they are persisted in `saved()` and not + // written to the builder. TextFormField::create('optionName') ->label('wcf.global.name') ->required() ->i18n() ->i18nRequired() - ->languageItemPattern('wcf.user.option.(option\d+|\w+)'), + ->languageItemPattern('wcf.user.option.(option\d+|\w+)') + ->loadValueCallback(static function (UserOption $object, IFormField $field) { + $field->value('wcf.user.option.' . $object->optionName); + }), MultilineTextFormField::create('optionDescription') ->label('wcf.acp.user.option.description') ->i18n() ->i18nRequired() - ->languageItemPattern('wcf.user.option.(option\d+|\w+).description'), + ->languageItemPattern('wcf.user.option.(option\d+|\w+).description') + ->loadValueCallback(static function (UserOption $object, IFormField $field) { + $field->value('wcf.user.option.' . $object->optionName . '.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,92 @@ 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() + public function saved(): void { - $userOption = $this->objectAction->getReturnValues()['returnValues']; - \assert($userOption instanceof UserOption); + \assert($this->object instanceof UserOption); I18nHandler::getInstance()->save( 'optionName', - 'wcf.user.option.option' . $userOption->optionID, + 'wcf.user.option.option' . $this->object->optionID, 'wcf.user.option' ); I18nHandler::getInstance()->save( 'optionDescription', - 'wcf.user.option.option' . $userOption->optionID . '.description', + 'wcf.user.option.option' . $this->object->optionID . '.description', 'wcf.user.option' ); - $editor = new UserOptionEditor($userOption); - $editor->update([ - 'optionName' => 'option' . $userOption->optionID, - ]); parent::saved(); } + + /** + * Returns the current value of the form field with the given id. + */ + private function getFieldValue(string $id): mixed + { + $node = $this->form->getNodeById($id); + \assert($node instanceof IFormField); + + 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 3f199f28e6..cfafeeaad7 100644 --- a/wcfsetup/install/files/lib/acp/form/UserOptionEditForm.class.php +++ b/wcfsetup/install/files/lib/acp/form/UserOptionEditForm.class.php @@ -2,12 +2,10 @@ 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\form\AbstractDatabaseObjectBuilderForm; 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; @@ -32,35 +30,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(); @@ -76,7 +57,7 @@ public function createForm() } #[\Override] - public function saved() + public function saved(): void { I18nHandler::getInstance()->save( 'optionName', @@ -89,7 +70,7 @@ public function saved() 'wcf.user.option' ); - AbstractFormBuilderForm::saved(); + AbstractDatabaseObjectBuilderForm::saved(); } #[\Override] 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 0000000000..e15e95c734 --- /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 4ba83f75a6..96fbfcc520 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 6a2d48e3f8..dfeaeafbed 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 6431f39c04..425c27ff7c 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 0000000000..bdd1339a30 --- /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/user/option/UserOption.class.php b/wcfsetup/install/files/lib/data/user/option/UserOption.class.php index 678dae9132..cfdcd5c8c9 100644 --- a/wcfsetup/install/files/lib/data/user/option/UserOption.class.php +++ b/wcfsetup/install/files/lib/data/user/option/UserOption.class.php @@ -14,7 +14,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 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 0000000000..13f004e1ab --- /dev/null +++ b/wcfsetup/install/files/lib/data/user/option/UserOptionBuilder.class.php @@ -0,0 +1,258 @@ +[.description]`) by the + * calling form. + * + * @author Marcel Werk + * @copyright 2001-2026 WoltLab GmbH + * @license GNU Lesser General Public License + * @since 6.3 + * + * @extends DatabaseObjectBuilder + */ +final class UserOptionBuilder extends DatabaseObjectBuilder +{ + private bool $isGenericOptionName = false; + + 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(); + } + } + + #[\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) + ); + } + } + + #[\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/event/user/option/UserOptionCreated.class.php b/wcfsetup/install/files/lib/event/user/option/UserOptionCreated.class.php new file mode 100644 index 0000000000..87c5120bb3 --- /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 0000000000..ab2e209edd --- /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 0000000000..c8adb022be --- /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 0000000000..fe60441732 --- /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 0000000000..6607e5ba5c --- /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/system/package/plugin/UserOptionPackageInstallationPlugin.class.php b/wcfsetup/install/files/lib/system/package/plugin/UserOptionPackageInstallationPlugin.class.php index 1aa628d454..8978b077ed 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; @@ -210,18 +211,52 @@ protected function saveOption(array $option, string $categoryName, int $existing // 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); + $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) + ->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']); } } From 105806201221e9548bde894cdd16fad3e41172fd Mon Sep 17 00:00:00 2001 From: Marcel Werk Date: Thu, 30 Jul 2026 16:31:52 +0200 Subject: [PATCH 06/15] Migrate user options from i18n phrases to l10n storage --- .../templates/userOptionFieldList.tpl | 4 +- .../templates/userProfileOptionFieldList.tpl | 4 +- .../update_com.woltlab.wcf_6.3_userOption.php | 57 ++++ ...om.woltlab.wcf_6.3_captchaQuestionL10n.php | 152 ++-------- ...ate_com.woltlab.wcf_6.3_userOptionL10n.php | 68 +++++ .../lib/acp/form/UserOptionAddForm.class.php | 45 +-- .../lib/acp/form/UserOptionEditForm.class.php | 19 -- .../files/lib/acp/page/UserListPage.class.php | 6 +- .../files/lib/bootstrap/com.woltlab.wcf.php | 7 + .../l10n/SyncL10nLanguageItems.class.php | 31 ++ .../files/lib/data/option/Option.class.php | 12 +- .../data/option/OptionCollection.class.php | 18 ++ .../lib/data/user/option/UserOption.class.php | 66 ++++- .../user/option/UserOptionBuilder.class.php | 74 ++++- .../option/UserOptionCollection.class.php | 53 ++++ .../l10n/L10nDefinitionCollecting.class.php | 43 +++ .../builder/UserOptionCacheBuilder.class.php | 22 ++ .../lib/system/l10n/L10nDefinition.class.php | 31 ++ .../l10n/L10nLanguageItemSource.class.php | 26 ++ .../l10n/L10nLanguageItemSync.class.php | 278 ++++++++++++++++++ .../lib/system/l10n/L10nStorage.class.php | 96 +++++- .../PackageInstallationDispatcher.class.php | 2 + ...rOptionPackageInstallationPlugin.class.php | 9 +- wcfsetup/setup/db/install_com.woltlab.wcf.php | 31 ++ 24 files changed, 967 insertions(+), 187 deletions(-) create mode 100644 wcfsetup/install/files/acp/database/update_com.woltlab.wcf_6.3_userOption.php create mode 100644 wcfsetup/install/files/acp/update_com.woltlab.wcf_6.3_userOptionL10n.php create mode 100644 wcfsetup/install/files/lib/command/l10n/SyncL10nLanguageItems.class.php create mode 100644 wcfsetup/install/files/lib/data/option/OptionCollection.class.php create mode 100644 wcfsetup/install/files/lib/data/user/option/UserOptionCollection.class.php create mode 100644 wcfsetup/install/files/lib/event/l10n/L10nDefinitionCollecting.class.php create mode 100644 wcfsetup/install/files/lib/system/l10n/L10nLanguageItemSource.class.php create mode 100644 wcfsetup/install/files/lib/system/l10n/L10nLanguageItemSync.class.php diff --git a/com.woltlab.wcf/templates/userOptionFieldList.tpl b/com.woltlab.wcf/templates/userOptionFieldList.tpl index 423340d4f6..4af1dcf5bd 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 cb00c78c65..6d654a1b5a 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_userOption.php b/wcfsetup/install/files/acp/database/update_com.woltlab.wcf_6.3_userOption.php new file mode 100644 index 0000000000..4f59eea6b7 --- /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 index ebf7cb8061..8f4e842459 100644 --- 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 @@ -12,133 +12,43 @@ * (dropping the migrated columns) must run AFTER this script. */ +use wcf\data\captcha\question\CaptchaQuestion; use wcf\system\cache\builder\CaptchaQuestionCacheBuilder; -use wcf\system\database\util\PreparedStatementConditionBuilder; -use wcf\system\language\LanguageFactory; +use wcf\system\l10n\L10nLanguageItemSource; +use wcf\system\l10n\L10nLanguageItemSync; use wcf\system\WCF; // This script owns the table's content at this point (idempotency on re-runs). -$sql = "DELETE FROM wcf1_captcha_question_l10n"; -WCF::getDB()->prepare($sql)->execute(); - -$sql = "SELECT questionID, question, answers - FROM wcf1_captcha_question"; -$statement = WCF::getDB()->prepare($sql); -$statement->execute(); -$rows = []; -while ($row = $statement->fetchArray()) { - $rows[] = $row; -} - -$installedLanguageIDs = \array_keys(LanguageFactory::getInstance()->getLanguages()); -$defaultLanguageID = LanguageFactory::getInstance()->getDefaultLanguageID(); - -$fetchItemsStatement = WCF::getDB()->prepare( - "SELECT languageID, languageItemValue - FROM wcf1_language_item - WHERE languageItem = ?" -); -$fetchItems = static function (string $languageItem) use ($fetchItemsStatement): array { - $fetchItemsStatement->execute([$languageItem]); - - return $fetchItemsStatement->fetchMap('languageID', 'languageItemValue'); -}; - -// Mirrors the phrase fallback semantics: value of the requested language, -// value of the default language, any value, literal column value. -$resolve = static function (?array $items, ?string $literal, int $languageID) use ($defaultLanguageID): ?string { - if ($items === null) { - return $literal; +WCF::getDB()->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, + ), + ], + ]; } - if (\array_key_exists($languageID, $items)) { - return $items[$languageID]; - } - if (\array_key_exists($defaultLanguageID, $items)) { - return $items[$defaultLanguageID]; - } - - return $items !== [] ? \reset($items) : $literal; -}; - -$obsoleteItems = []; -$insertStatement = WCF::getDB()->prepare( - "INSERT INTO wcf1_captcha_question_l10n (questionID, languageID, question, answers) - VALUES (?, ?, ?, ?)" ); -WCF::getDB()->beginTransaction(); -foreach ($rows as $row) { - $questionItems = null; - if (\preg_match('~^wcf\.captcha\.question\.question\.question\d+$~', $row['question'])) { - $items = $fetchItems($row['question']); - if ($items !== []) { - $questionItems = $items; - $obsoleteItems[] = $row['question']; - } - // Phrase name stored but items are missing: Treat the value as - // literal text, mirroring the recovery in `TI18nFormField`. - } - - $answersItems = null; - if ( - $row['answers'] !== null - && \preg_match('~^wcf\.captcha\.question\.answers\.question\d+$~', $row['answers']) - ) { - $items = $fetchItems($row['answers']); - if ($items !== []) { - $answersItems = $items; - $obsoleteItems[] = $row['answers']; - } - } - - // Consistency rule of the l10n storage: an object is either monolingual - // (a single row with `languageID IS NULL`) or multilingual (one row per - // language). The language set is the union of the phrase languages, a - // literal or missing side is filled per language via the fallback chain. - $languageIDs = \array_values(\array_intersect( - \array_unique([ - ...\array_keys($questionItems ?? []), - ...\array_keys($answersItems ?? []), - ]), - $installedLanguageIDs - )); - - if ($languageIDs === []) { - $insertStatement->execute([ - $row['questionID'], - null, - $row['question'], - $row['answers'], - ]); - - continue; - } - - foreach ($languageIDs as $languageID) { - $insertStatement->execute([ - $row['questionID'], - $languageID, - $resolve($questionItems, $row['question'], $languageID) ?? '', - $resolve($answersItems, $row['answers'], $languageID), - ]); - } -} -WCF::getDB()->commitTransaction(); - -// Remove the migrated phrases. -if ($obsoleteItems !== []) { - foreach (\array_chunk(\array_unique($obsoleteItems), 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(); -} - // 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 0000000000..7690922d9a --- /dev/null +++ b/wcfsetup/install/files/acp/update_com.woltlab.wcf_6.3_userOptionL10n.php @@ -0,0 +1,68 @@ +[.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 [ + 'isPristine' => !$adminCreated, + '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/UserOptionAddForm.class.php b/wcfsetup/install/files/lib/acp/form/UserOptionAddForm.class.php index 325a10c502..8012e9b0dd 100644 --- a/wcfsetup/install/files/lib/acp/form/UserOptionAddForm.class.php +++ b/wcfsetup/install/files/lib/acp/form/UserOptionAddForm.class.php @@ -25,7 +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\language\I18nHandler; use wcf\system\option\user\DateUserOptionOutput; use wcf\system\option\user\IUserOptionOutput; use wcf\system\option\user\LabeledUrlUserOptionOutput; @@ -151,26 +150,27 @@ protected function createForm(): void $this->form->appendChildren([ FormContainer::create('general') ->appendChildren([ - // The name and description are stored as i18n phrases keyed - // `wcf.user.option.option[.description]` and never - // as columns, hence they are persisted in `saved()` and not - // written to the builder. + // 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('wcf.user.option.' . $object->optionName); + $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('wcf.user.option.' . $object->optionName . '.description'); + $field->value($object->getL10nValues('description')); }), BooleanFormField::create('isDisabled') ->label('wcf.global.button.disable') @@ -424,25 +424,6 @@ public function save(): void parent::save(); } - #[\Override] - public function saved(): void - { - \assert($this->object instanceof UserOption); - - I18nHandler::getInstance()->save( - 'optionName', - 'wcf.user.option.option' . $this->object->optionID, - 'wcf.user.option' - ); - I18nHandler::getInstance()->save( - 'optionDescription', - 'wcf.user.option.option' . $this->object->optionID . '.description', - 'wcf.user.option' - ); - - parent::saved(); - } - /** * Returns the current value of the form field with the given id. */ diff --git a/wcfsetup/install/files/lib/acp/form/UserOptionEditForm.class.php b/wcfsetup/install/files/lib/acp/form/UserOptionEditForm.class.php index cfafeeaad7..bf50f74e90 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\AbstractDatabaseObjectBuilderForm; 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; @@ -56,23 +54,6 @@ protected function createForm(): void } } - #[\Override] - public function saved(): void - { - 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' - ); - - AbstractDatabaseObjectBuilderForm::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 a343776ee3..515c71000f 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 4efb5e9c5c..25271d8aff 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/l10n/SyncL10nLanguageItems.class.php b/wcfsetup/install/files/lib/command/l10n/SyncL10nLanguageItems.class.php new file mode 100644 index 0000000000..839c37fbec --- /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/data/option/Option.class.php b/wcfsetup/install/files/lib/data/option/Option.class.php index e26384616f..ef0bf32078 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 0000000000..433665ef7b --- /dev/null +++ b/wcfsetup/install/files/lib/data/option/OptionCollection.class.php @@ -0,0 +1,18 @@ + + * @since 6.3 + * + * @extends DatabaseObjectCollection