From bb2a530799b1e6be6fcbef23c3a43417027ab7f0 Mon Sep 17 00:00:00 2001 From: Marcel Werk Date: Thu, 23 Jul 2026 12:07:12 +0200 Subject: [PATCH 01/37] 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 00000000000..357ca872a15 --- /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 00000000000..d4ca5938822 --- /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 00000000000..b0af35a7a7a --- /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 1781485b8b9..e7ed442df41 100644 --- a/wcfsetup/install/files/lib/system/package/PackageInstallationDispatcher.class.php +++ b/wcfsetup/install/files/lib/system/package/PackageInstallationDispatcher.class.php @@ -207,6 +207,8 @@ public function install(string $node): PackageInstallationStep VersionTracker::getInstance()->createStorageTables(); + new \wcf\command\l10n\SyncL10nTables()(); + new \wcf\command\package\RebuildBootstrapper()(); EventHandler::getInstance()->fire(new PackageListChanged()); From cf52b795430fee75acd4f4f7f7da7e416cd8893a Mon Sep 17 00:00:00 2001 From: Marcel Werk Date: Fri, 24 Jul 2026 18:50:17 +0200 Subject: [PATCH 02/37] 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 | 29 +++ ...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 ++++++++++++++++++ .../view/filter/L10nTextFilter.class.php | 55 ++++ wcfsetup/setup/db/install_com.woltlab.wcf.php | 27 +- 25 files changed, 1235 insertions(+), 387 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 00000000000..2c5ceb3880b --- /dev/null +++ b/wcfsetup/install/files/acp/database/update_com.woltlab.wcf_6.3_captchaQuestion.php @@ -0,0 +1,21 @@ +columns([ + NotNullVarchar255DatabaseTableColumn::create('question')->drop(), + MediumtextDatabaseTableColumn::create('answers')->drop(), + ]), +]; diff --git a/wcfsetup/install/files/acp/database/update_com.woltlab.wcf_6.3_step1.php b/wcfsetup/install/files/acp/database/update_com.woltlab.wcf_6.3_step1.php index c2f54c2b1bb..7a44c2d30fc 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 @@ -14,10 +14,14 @@ 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\index\DatabaseTableForeignKey; +use wcf\system\database\table\index\DatabaseTableIndex; +use wcf\system\database\table\DatabaseTable; use wcf\system\database\table\PartialDatabaseTable; return [ @@ -84,6 +88,31 @@ ->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'), + ]), PartialDatabaseTable::create('wcf1_session') ->columns([ BinaryDatabaseTableColumn::create('sessionID') diff --git a/wcfsetup/install/files/acp/update_com.woltlab.wcf_6.3_captchaQuestionL10n.php b/wcfsetup/install/files/acp/update_com.woltlab.wcf_6.3_captchaQuestionL10n.php new file mode 100644 index 00000000000..ebf7cb80619 --- /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 37b0da3e67a..094bc88a1d2 100644 --- a/wcfsetup/install/files/lib/acp/form/CaptchaQuestionAddForm.class.php +++ b/wcfsetup/install/files/lib/acp/form/CaptchaQuestionAddForm.class.php @@ -2,12 +2,16 @@ namespace wcf\acp\form; +use wcf\command\captcha\question\CreateCaptchaQuestion; +use wcf\command\captcha\question\UpdateCaptchaQuestion; use wcf\data\captcha\question\CaptchaQuestion; -use wcf\data\captcha\question\CaptchaQuestionAction; +use wcf\data\captcha\question\CaptchaQuestionBuilder; +use wcf\data\DatabaseObjectBuilder; use wcf\data\language\Language; -use wcf\form\AbstractFormBuilderForm; +use wcf\form\AbstractDatabaseObjectBuilderForm; use wcf\system\form\builder\container\FormContainer; use wcf\system\form\builder\field\BooleanFormField; +use wcf\system\form\builder\field\IFormField; use wcf\system\form\builder\field\MultilineTextFormField; use wcf\system\form\builder\field\TextFormField; use wcf\system\form\builder\field\validation\FormFieldValidationError; @@ -19,12 +23,12 @@ * Shows the form to create a new captcha question. * * @author Olaf Braun, Matthias Schmidt - * @copyright 2001-2024 WoltLab GmbH + * @copyright 2001-2026 WoltLab GmbH * @license GNU Lesser General Public License * - * @extends AbstractFormBuilderForm + * @extends AbstractDatabaseObjectBuilderForm */ -class CaptchaQuestionAddForm extends AbstractFormBuilderForm +class CaptchaQuestionAddForm extends AbstractDatabaseObjectBuilderForm { /** * @inheritDoc @@ -39,30 +43,48 @@ class CaptchaQuestionAddForm extends AbstractFormBuilderForm /** * @inheritDoc */ - public $objectActionClass = CaptchaQuestionAction::class; + public string $objectEditLinkController = CaptchaQuestionEditForm::class; - /** - * @inheritDoc - */ - public $objectEditLinkController = CaptchaQuestionEditForm::class; + #[\Override] + protected function getDatabaseObjectBuilder(): CaptchaQuestionBuilder + { + if ($this->formObject !== null) { + return CaptchaQuestionBuilder::forUpdate($this->formObject); + } + + return CaptchaQuestionBuilder::forCreate(); + } #[\Override] - protected function createForm() + protected function getCommand(DatabaseObjectBuilder $builder): callable { - parent::createForm(); + if ($this->formObject !== null) { + return new UpdateCaptchaQuestion($builder); + } + + return new CreateCaptchaQuestion($builder); + } + #[\Override] + protected function createForm(): void + { $this->form->appendChildren([ FormContainer::create('general') ->appendChildren([ TextFormField::create('question') ->label('wcf.acp.captcha.question.question') - ->i18n() - ->languageItemPattern('wcf.captcha.question.question.question\d+') - ->required(), + ->l10n() + ->required() + ->maximumLength(255) + ->saveValueCallback(static function (CaptchaQuestionBuilder $builder, TextFormField $field) { + $builder->setQuestion($field->getL10nValues()); + }) + ->loadValueCallback(static function (CaptchaQuestion $object, IFormField $field) { + $field->value($object->getL10nValues('question')); + }), MultilineTextFormField::create('answers') ->label('wcf.acp.captcha.question.answers') - ->i18n() - ->languageItemPattern('wcf.captcha.question.answers.question\d+') + ->l10n() ->required() ->addValidator( new FormFieldValidator('regexValidator', function (MultilineTextFormField $formField) { @@ -80,10 +102,22 @@ protected function createForm() } } }) - ), + ) + ->saveValueCallback(static function (CaptchaQuestionBuilder $builder, MultilineTextFormField $field) { + $builder->setAnswers($field->getL10nValues()); + }) + ->loadValueCallback(static function (CaptchaQuestion $object, IFormField $field) { + $field->value($object->getL10nValues('answers')); + }), BooleanFormField::create('isDisabled') ->label('wcf.acp.captcha.question.isDisabled') ->value(false) + ->saveValueCallback(static function (CaptchaQuestionBuilder $builder, IFormField $field) { + $builder->setIsDisabled((bool)$field->getSaveValue()); + }) + ->loadValueCallback(static function (CaptchaQuestion $object, IFormField $field) { + $field->value($object->isDisabled); + }), ]) ]); } diff --git a/wcfsetup/install/files/lib/acp/form/CaptchaQuestionEditForm.class.php b/wcfsetup/install/files/lib/acp/form/CaptchaQuestionEditForm.class.php index e7a416112c1..18fa5a0df9f 100644 --- a/wcfsetup/install/files/lib/acp/form/CaptchaQuestionEditForm.class.php +++ b/wcfsetup/install/files/lib/acp/form/CaptchaQuestionEditForm.class.php @@ -27,7 +27,7 @@ class CaptchaQuestionEditForm extends CaptchaQuestionAddForm /** * @inheritDoc */ - public $formAction = 'edit'; + public string $formAction = 'edit'; #[\Override] public function readParameters() diff --git a/wcfsetup/install/files/lib/command/captcha/question/CreateCaptchaQuestion.class.php b/wcfsetup/install/files/lib/command/captcha/question/CreateCaptchaQuestion.class.php new file mode 100644 index 00000000000..bc953c05adc --- /dev/null +++ b/wcfsetup/install/files/lib/command/captcha/question/CreateCaptchaQuestion.class.php @@ -0,0 +1,38 @@ + + * @since 6.3 + */ +final class CreateCaptchaQuestion +{ + public function __construct( + private readonly CaptchaQuestionBuilder $builder, + ) {} + + public function __invoke(): CaptchaQuestion + { + $question = $this->builder->create(); + + CaptchaQuestionCacheBuilder::getInstance()->reset(); + + EventHandler::getInstance()->fire(new CaptchaQuestionCreated( + $question, + $this->builder + )); + + return $question; + } +} diff --git a/wcfsetup/install/files/lib/command/captcha/question/UpdateCaptchaQuestion.class.php b/wcfsetup/install/files/lib/command/captcha/question/UpdateCaptchaQuestion.class.php new file mode 100644 index 00000000000..a6b64580411 --- /dev/null +++ b/wcfsetup/install/files/lib/command/captcha/question/UpdateCaptchaQuestion.class.php @@ -0,0 +1,38 @@ + + * @since 6.3 + */ +final class UpdateCaptchaQuestion +{ + public function __construct( + private readonly CaptchaQuestionBuilder $builder, + ) {} + + public function __invoke(): CaptchaQuestion + { + $question = $this->builder->update(); + + CaptchaQuestionCacheBuilder::getInstance()->reset(); + + EventHandler::getInstance()->fire(new CaptchaQuestionUpdated( + $question, + $this->builder + )); + + return $question; + } +} diff --git a/wcfsetup/install/files/lib/command/l10n/SyncL10nTables.class.php b/wcfsetup/install/files/lib/command/l10n/SyncL10nTables.class.php deleted file mode 100644 index 357ca872a15..00000000000 --- 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 966a6d206c7..ef85d1b92dd 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) !== 0) { @@ -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 9bbaf84df18..80b1017f98a 100644 --- a/wcfsetup/install/files/lib/data/captcha/question/CaptchaQuestionAction.class.php +++ b/wcfsetup/install/files/lib/data/captcha/question/CaptchaQuestionAction.class.php @@ -6,11 +6,14 @@ use wcf\command\captcha\question\EnableCaptchaQuestion; use wcf\data\AbstractDatabaseObjectAction; use wcf\data\IToggleAction; -use wcf\data\TI18nDatabaseObjectAction; /** * Executes captcha question-related actions. * + * Captcha questions should be created and updated through the + * `CreateCaptchaQuestion` and `UpdateCaptchaQuestion` commands, the `create` + * and `update` actions are `@deprecated 6.3`. + * * @author Matthias Schmidt * @copyright 2001-2019 WoltLab GmbH * @license GNU Lesser General Public License @@ -19,8 +22,6 @@ */ class CaptchaQuestionAction extends AbstractDatabaseObjectAction implements IToggleAction { - use TI18nDatabaseObjectAction; - /** * @inheritDoc */ @@ -31,63 +32,6 @@ class CaptchaQuestionAction extends AbstractDatabaseObjectAction implements ITog */ protected $permissionsUpdate = ['admin.captcha.canManageCaptchaQuestion']; - /** - * @return array - */ - #[\Override] - public function getI18nSaveTypes(): array - { - return [ - 'question' => 'wcf.captcha.question.question.question\d+', - 'answers' => 'wcf.captcha.question.answers.question\d+', - ]; - } - - #[\Override] - public function getLanguageCategory(): string - { - return 'wcf.captcha.question'; - } - - #[\Override] - public function getPackageID(): int - { - return \PACKAGE_ID; - } - - #[\Override] - public function update() - { - parent::update(); - - foreach ($this->objects as $object) { - $this->saveI18nValue($object->getDecoratedObject()); - } - } - - #[\Override] - public function create() - { - // Question column doesn't have a default value - $this->parameters['data']['question'] = $this->parameters['data']['question'] ?? ''; - - $captchaQuestion = parent::create(); - - $this->saveI18nValue($captchaQuestion); - - return $captchaQuestion; - } - - #[\Override] - public function delete() - { - $returnValue = parent::delete(); - - $this->deleteI18nValues(); - - return $returnValue; - } - /** * @deprecated 6.3 */ diff --git a/wcfsetup/install/files/lib/data/captcha/question/CaptchaQuestionBuilder.class.php b/wcfsetup/install/files/lib/data/captcha/question/CaptchaQuestionBuilder.class.php new file mode 100644 index 00000000000..88c732447fe --- /dev/null +++ b/wcfsetup/install/files/lib/data/captcha/question/CaptchaQuestionBuilder.class.php @@ -0,0 +1,100 @@ + + * @since 6.3 + * + * @extends DatabaseObjectBuilder + */ +final class CaptchaQuestionBuilder extends DatabaseObjectBuilder +{ + /** + * @var array + */ + private array $question; + + /** + * @var array + */ + private array $answers; + + /** + * @param array $question + */ + public function setQuestion(array $question): static + { + $this->question = $question; + + return $this; + } + + /** + * @param array $answers + */ + public function setAnswers(array $answers): static + { + $this->answers = $answers; + + return $this; + } + + public function setIsDisabled(bool $isDisabled): static + { + $this->properties['isDisabled'] = $isDisabled ? 1 : 0; + + return $this; + } + + #[\Override] + protected function afterValidateCreate(): void + { + if (!isset($this->question) || !isset($this->answers)) { + throw new \BadMethodCallException("Missing values for 'question' or 'answers'."); + } + } + + #[\Override] + protected function afterCreate(DatabaseObject $object): void + { + $this->saveL10nValues($object); + } + + #[\Override] + protected function afterUpdate(DatabaseObject $object): void + { + if (isset($this->question) || isset($this->answers)) { + if (!isset($this->question) || !isset($this->answers)) { + // `L10nStorage::setValues()` replaces all rows of the object, + // writing only one of the two columns would wipe the other. + throw new \BadMethodCallException("'question' and 'answers' must be set together."); + } + + $this->saveL10nValues($object); + } + } + + private function saveL10nValues(CaptchaQuestion $question): void + { + (new L10nStorage(CaptchaQuestion::getL10nDefinition()))->setValues( + $question->questionID, + [ + 'question' => $this->question, + 'answers' => $this->answers, + ] + ); + } +} diff --git a/wcfsetup/install/files/lib/data/captcha/question/CaptchaQuestionCollection.class.php b/wcfsetup/install/files/lib/data/captcha/question/CaptchaQuestionCollection.class.php new file mode 100644 index 00000000000..183a4e6bb24 --- /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 4784e731df5..00000000000 --- a/wcfsetup/install/files/lib/data/captcha/question/I18nCaptchaQuestionList.class.php +++ /dev/null @@ -1,28 +0,0 @@ - - * @since 6.2 - * - * @extends I18nDatabaseObjectList - */ -class I18nCaptchaQuestionList extends I18nDatabaseObjectList -{ - /** - * @inheritDoc - */ - public $i18nFields = ['question' => 'questionI18n']; - - /** - * @inheritDoc - */ - public $className = CaptchaQuestion::class; -} diff --git a/wcfsetup/install/files/lib/data/captcha/question/L10nCaptchaQuestionList.class.php b/wcfsetup/install/files/lib/data/captcha/question/L10nCaptchaQuestionList.class.php new file mode 100644 index 00000000000..c2bbec7567a --- /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 00000000000..2f8d14dad6c --- /dev/null +++ b/wcfsetup/install/files/lib/event/captcha/question/CaptchaQuestionCreated.class.php @@ -0,0 +1,23 @@ + + * @since 6.3 + */ +final class CaptchaQuestionCreated implements IPsr14Event +{ + public function __construct( + public readonly CaptchaQuestion $captchaQuestion, + public readonly CaptchaQuestionBuilder $builder, + ) {} +} diff --git a/wcfsetup/install/files/lib/event/captcha/question/CaptchaQuestionUpdated.class.php b/wcfsetup/install/files/lib/event/captcha/question/CaptchaQuestionUpdated.class.php new file mode 100644 index 00000000000..ac50d519210 --- /dev/null +++ b/wcfsetup/install/files/lib/event/captcha/question/CaptchaQuestionUpdated.class.php @@ -0,0 +1,23 @@ + + * @since 6.3 + */ +final class CaptchaQuestionUpdated implements IPsr14Event +{ + public function __construct( + public readonly CaptchaQuestion $captchaQuestion, + public readonly CaptchaQuestionBuilder $builder, + ) {} +} diff --git a/wcfsetup/install/files/lib/event/l10n/L10nDefinitionCollecting.class.php b/wcfsetup/install/files/lib/event/l10n/L10nDefinitionCollecting.class.php deleted file mode 100644 index d4ca5938822..00000000000 --- 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 00000000000..f52e04c2d6e --- /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 00000000000..3ec8989d33a --- /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 11a45aeb4da..120a8297296 100644 --- a/wcfsetup/install/files/lib/system/form/builder/field/TextFormField.class.php +++ b/wcfsetup/install/files/lib/system/form/builder/field/TextFormField.class.php @@ -23,6 +23,7 @@ class TextFormField extends AbstractFormField implements ICensorshipFormField, ICssClassFormField, II18nFormField, + IL10nFormField, IImmutableFormField, IInputModeFormField, IMaximumLengthFormField, @@ -37,7 +38,7 @@ class TextFormField extends AbstractFormField implements use TCssClassFormField; use TImmutableFormField; use TInputModeFormField; - use TI18nFormField { + use TL10nFormField { validate as protected i18nValidate; } use TMaximumLengthFormField; diff --git a/wcfsetup/install/files/lib/system/gridView/admin/CaptchaQuestionGridView.class.php b/wcfsetup/install/files/lib/system/gridView/admin/CaptchaQuestionGridView.class.php index 7c891aa85c4..4eab332a9e1 100644 --- a/wcfsetup/install/files/lib/system/gridView/admin/CaptchaQuestionGridView.class.php +++ b/wcfsetup/install/files/lib/system/gridView/admin/CaptchaQuestionGridView.class.php @@ -4,7 +4,7 @@ use wcf\acp\form\CaptchaQuestionEditForm; use wcf\data\captcha\question\CaptchaQuestion; -use wcf\data\captcha\question\I18nCaptchaQuestionList; +use wcf\data\captcha\question\L10nCaptchaQuestionList; use wcf\event\gridView\admin\CaptchaQuestionGridViewInitialized; use wcf\system\gridView\AbstractGridView; use wcf\system\gridView\GridViewColumn; @@ -15,8 +15,8 @@ use wcf\system\interaction\Divider; use wcf\system\interaction\EditInteraction; use wcf\system\interaction\ToggleInteraction; -use wcf\system\view\filter\I18nTextFilter; use wcf\system\view\filter\IntegerFilter; +use wcf\system\view\filter\L10nTextFilter; use wcf\system\WCF; /** @@ -27,7 +27,7 @@ * @license GNU Lesser General Public License * @since 6.2 * - * @extends AbstractGridView + * @extends AbstractGridView */ final class CaptchaQuestionGridView extends AbstractGridView { @@ -41,8 +41,13 @@ public function __construct() GridViewColumn::for('question') ->label('wcf.acp.captcha.question.question') ->titleColumn() - ->filter(I18nTextFilter::class) - ->sortable(sortByDatabaseColumn: 'questionI18n'), + ->filter(new L10nTextFilter( + CaptchaQuestion::getL10nDefinition(), + 'question', + 'question', + 'wcf.acp.captcha.question.question', + )) + ->sortable(sortByDatabaseColumn: 'question'), GridViewColumn::for('views') ->label('wcf.acp.captcha.question.views') ->sortable(defaultSortOrder: 'DESC') @@ -84,9 +89,9 @@ public function isAccessible(): bool } #[\Override] - protected function createObjectList(): I18nCaptchaQuestionList + protected function createObjectList(): L10nCaptchaQuestionList { - return new I18nCaptchaQuestionList(); + return new L10nCaptchaQuestionList(); } #[\Override] diff --git a/wcfsetup/install/files/lib/system/l10n/L10nDefinition.class.php b/wcfsetup/install/files/lib/system/l10n/L10nDefinition.class.php index b0af35a7a7a..ab749ff2cdf 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 00000000000..c2937f258bd --- /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/view/filter/L10nTextFilter.class.php b/wcfsetup/install/files/lib/system/view/filter/L10nTextFilter.class.php new file mode 100644 index 00000000000..0a01be47227 --- /dev/null +++ b/wcfsetup/install/files/lib/system/view/filter/L10nTextFilter.class.php @@ -0,0 +1,55 @@ + + * @since 6.3 + */ +class L10nTextFilter extends TextFilter +{ + public function __construct( + private readonly L10nDefinition $definition, + private readonly string $columnName, + string $id, + string $languageItem, + ) { + parent::__construct($id, $languageItem); + + if (!\in_array($columnName, $definition->columnNames, true)) { + throw new \InvalidArgumentException("Unknown localized column '{$columnName}'."); + } + } + + #[\Override] + public function applyFilter(DatabaseObjectList $list, string $value): void + { + $objectColumn = $list->getDatabaseTableAlias() . '.' . $this->definition->objectColumnName; + + $list->getConditionBuilder()->add( + "{$objectColumn} IN ( + SELECT {$this->definition->objectColumnName} + FROM {$this->definition->l10nTableName} + WHERE (languageID = ? OR languageID IS NULL) + AND {$this->columnName} LIKE ? + )", + [ + WCF::getLanguage()->languageID, + '%' . WCF::getDB()->escapeLikeValue($value) . '%' + ] + ); + } +} diff --git a/wcfsetup/setup/db/install_com.woltlab.wcf.php b/wcfsetup/setup/db/install_com.woltlab.wcf.php index c780f612ba9..f9002f1d1d4 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 14887288e07a721997cf4c1c29a5514db546cdb0 Mon Sep 17 00:00:00 2001 From: Marcel Werk Date: Sat, 25 Jul 2026 15:49:09 +0200 Subject: [PATCH 03/37] 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 7a44c2d30fc..cacd808aa8d 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 @@ -19,6 +19,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\index\DatabaseTableForeignKey; use wcf\system\database\table\index\DatabaseTableIndex; use wcf\system\database\table\DatabaseTable; @@ -92,7 +93,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 3ec8989d33a..aa31a0af1dd 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 c2937f258bd..d5768466c06 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 f9002f1d1d4..359769366a0 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 729372dd27bc3a8335baa715977f1831ec290119 Mon Sep 17 00:00:00 2001 From: Marcel Werk Date: Sat, 25 Jul 2026 15:50:56 +0200 Subject: [PATCH 04/37] 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 d5768466c06..f399624b8b7 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 17022d5b1edc7f4b2280961b74ba702d34b1e9bb Mon Sep 17 00:00:00 2001 From: Marcel Werk Date: Sun, 26 Jul 2026 21:51:22 +0200 Subject: [PATCH 05/37] Refactor user options to `DatabaseObjectBuilder` --- .../lib/acp/form/UserOptionAddForm.class.php | 328 +++++++++++------- .../lib/acp/form/UserOptionEditForm.class.php | 10 +- .../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, 712 insertions(+), 160 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 df74ee77efb..4b066e94c11 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') @@ -218,6 +286,12 @@ public function createForm() } }) ) + ->saveValueCallback(static function (UserOptionBuilder $builder, IFormField $field) { + $builder->setLabeledUrl((string)$field->getSaveValue()); + }) + ->loadValueCallback(static function (UserOption $object, IFormField $field) { + $field->value($object->labeledUrl); + }) ->addDependency( ValueFormFieldDependency::create('optionType') ->fieldId('optionType') @@ -227,6 +301,28 @@ public function createForm() ->label('wcf.acp.user.option.outputClass') ->description('wcf.acp.user.option.outputClass.description') ->implementedInterface(IUserOptionOutput::class) + ->saveValueCallback(static function (UserOptionBuilder $builder, IFormField $field) use ($formAction) { + // handle auto-assign of the output class on create + $outputClass = $field->getValue(); + $optionType = $field->getDocument()->getFormField('optionType')->getValue(); + if ($formAction === 'create' && $outputClass === '') { + if (\in_array($optionType, self::$optionTypesUsingSelectOptions)) { + $outputClass = SelectOptionsUserOptionOutput::class; + } else { + $outputClass = match ($optionType) { + 'date' => DateUserOptionOutput::class, + 'URL' => URLUserOptionOutput::class, + 'labeledUrl' => LabeledUrlUserOptionOutput::class, + 'message' => MessageUserOptionOutput::class, + default => '' + }; + } + } + $builder->setOutputClass($outputClass); + }) + ->loadValueCallback(static function (UserOption $object, IFormField $field) { + $field->value($object->outputClass); + }), ]), FormContainer::create('access') ->label('wcf.acp.user.option.access') @@ -239,7 +335,13 @@ public function createForm() 3 => 'wcf.acp.user.option.editable.3', 6 => 'wcf.acp.user.option.editable.6', ]) - ->value(3), + ->value(3) + ->saveValueCallback(static function (UserOptionBuilder $builder, IFormField $field) { + $builder->setEditable((int)$field->getSaveValue()); + }) + ->loadValueCallback(static function (UserOption $object, IFormField $field) { + $field->value($object->editable); + }), SingleSelectionFormField::create('visible') ->label('wcf.acp.user.option.visible') ->options([ @@ -250,10 +352,22 @@ public function createForm() 7 => 'wcf.acp.user.option.visible.7', 15 => 'wcf.acp.user.option.visible.15', ]) - ->value(15), + ->value(15) + ->saveValueCallback(static function (UserOptionBuilder $builder, IFormField $field) { + $builder->setVisible((int)$field->getSaveValue()); + }) + ->loadValueCallback(static function (UserOption $object, IFormField $field) { + $field->value($object->visible); + }), TextFormField::create('validationPattern') ->label('wcf.acp.user.option.validationPattern') ->description('wcf.acp.user.option.validationPattern.description') + ->saveValueCallback(static function (UserOptionBuilder $builder, IFormField $field) { + $builder->setValidationPattern((string)$field->getSaveValue()); + }) + ->loadValueCallback(static function (UserOption $object, IFormField $field) { + $field->value($object->validationPattern); + }) ->addDependency( ValueFormFieldDependency::create('validationPatternOptionTypeDependency') ->fieldId('optionType') @@ -262,146 +376,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 ((string)$outputClass === '') { - if (\in_array($optionType, self::$optionTypesUsingSelectOptions, true)) { - $parameters['data']['outputClass'] = SelectOptionsUserOptionOutput::class; - } else { - $parameters['data']['outputClass'] = match ($optionType) { - 'date' => DateUserOptionOutput::class, - 'URL' => URLUserOptionOutput::class, - 'labeledUrl' => LabeledUrlUserOptionOutput::class, - 'message' => MessageUserOptionOutput::class, - default => '' - }; - } - } - - return $parameters; - } - ) - ) - ->addProcessor( - new CustomFormDataProcessor( - 'defaultValueDataProcessor', - function (IFormDocument $document, array $parameters) { - $optionType = $parameters['data']['optionType']; - $defaultValue = $parameters['data']['defaultValue']; - - $parameters['data']['defaultValue'] = match ($optionType) { - 'boolean', 'integer' => \intval($defaultValue), - 'float' => \floatval($defaultValue), - 'date' => \preg_match('/\d{4}-\d{2}-\d{2}/', $defaultValue) ? $defaultValue : '', - default => $defaultValue, - }; - - return $parameters; - } - ) - ); - } - - #[\Override] - public function save() + public function save(): void { if ($this->formAction === 'create') { - $this->additionalFields['optionName'] = StringUtil::getRandomID(); $this->additionalFields['packageID'] = \PACKAGE_ID; } + $optionType = (string)$this->getFieldValue('optionType'); + + // additionalData + $additionalData = $this->formObject?->additionalData ?: []; + if ($optionType === 'select') { + $additionalData['allowEmptyValue'] = true; + } elseif ($optionType === 'message') { + $additionalData['messageObjectType'] = 'com.woltlab.wcf.user.option.generic'; + } + $this->additionalFields['additionalData'] = \serialize($additionalData); + parent::save(); } #[\Override] - public function saved() + 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 f1a0a8c00ea..cfafeeaad7d 100644 --- a/wcfsetup/install/files/lib/acp/form/UserOptionEditForm.class.php +++ b/wcfsetup/install/files/lib/acp/form/UserOptionEditForm.class.php @@ -4,7 +4,7 @@ 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\form\builder\field\SingleSelectionFormField; use wcf\system\interaction\admin\UserOptionInteractions; @@ -30,7 +30,7 @@ class UserOptionEditForm extends UserOptionAddForm /** * @inheritDoc */ - public $formAction = 'edit'; + public string $formAction = 'edit'; #[\Override] public function readParameters() @@ -41,7 +41,7 @@ public function readParameters() } #[\Override] - public function createForm() + protected function createForm(): void { parent::createForm(); @@ -57,7 +57,7 @@ public function createForm() } #[\Override] - public function saved() + public function saved(): void { I18nHandler::getInstance()->save( 'optionName', @@ -70,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 00000000000..e15e95c7340 --- /dev/null +++ b/wcfsetup/install/files/lib/command/user/option/CreateUserOption.class.php @@ -0,0 +1,38 @@ + + * @since 6.3 + */ +final class CreateUserOption +{ + public function __construct( + private readonly UserOptionBuilder $builder, + ) {} + + public function __invoke(): UserOption + { + $option = $this->builder->create(); + + UserOptionCacheBuilder::getInstance()->reset(); + + EventHandler::getInstance()->fire(new UserOptionCreated( + $option, + $this->builder + )); + + return $option; + } +} diff --git a/wcfsetup/install/files/lib/command/user/option/DeleteOption.class.php b/wcfsetup/install/files/lib/command/user/option/DeleteOption.class.php index 4ba83f75a6e..96fbfcc5204 100644 --- a/wcfsetup/install/files/lib/command/user/option/DeleteOption.class.php +++ b/wcfsetup/install/files/lib/command/user/option/DeleteOption.class.php @@ -3,7 +3,10 @@ namespace wcf\command\user\option; use wcf\data\user\option\UserOption; -use wcf\data\user\option\UserOptionAction; +use wcf\data\user\option\UserOptionBuilder; +use wcf\event\user\option\UserOptionDeleted; +use wcf\system\cache\builder\UserOptionCacheBuilder; +use wcf\system\event\EventHandler; /** * Deletes a user option. @@ -21,7 +24,10 @@ public function __construct( public function __invoke(): void { - $action = new UserOptionAction([$this->option], 'delete'); - $action->executeAction(); + UserOptionBuilder::delete($this->option); + + UserOptionCacheBuilder::getInstance()->reset(); + + EventHandler::getInstance()->fire(new UserOptionDeleted($this->option)); } } diff --git a/wcfsetup/install/files/lib/command/user/option/DisableOption.class.php b/wcfsetup/install/files/lib/command/user/option/DisableOption.class.php index 6a2d48e3f8b..dfeaeafbedb 100644 --- a/wcfsetup/install/files/lib/command/user/option/DisableOption.class.php +++ b/wcfsetup/install/files/lib/command/user/option/DisableOption.class.php @@ -3,7 +3,10 @@ namespace wcf\command\user\option; use wcf\data\user\option\UserOption; -use wcf\data\user\option\UserOptionEditor; +use wcf\data\user\option\UserOptionBuilder; +use wcf\event\user\option\UserOptionDisabled; +use wcf\system\cache\builder\UserOptionCacheBuilder; +use wcf\system\event\EventHandler; /** * Disables a user option. @@ -21,8 +24,12 @@ public function __construct( public function __invoke(): void { - (new UserOptionEditor($this->option))->update([ - 'isDisabled' => 1, - ]); + UserOptionBuilder::forUpdate($this->option) + ->setIsDisabled(true) + ->update(); + + UserOptionCacheBuilder::getInstance()->reset(); + + EventHandler::getInstance()->fire(new UserOptionDisabled($this->option)); } } diff --git a/wcfsetup/install/files/lib/command/user/option/EnableOption.class.php b/wcfsetup/install/files/lib/command/user/option/EnableOption.class.php index 6431f39c044..425c27ff7cd 100644 --- a/wcfsetup/install/files/lib/command/user/option/EnableOption.class.php +++ b/wcfsetup/install/files/lib/command/user/option/EnableOption.class.php @@ -3,7 +3,10 @@ namespace wcf\command\user\option; use wcf\data\user\option\UserOption; -use wcf\data\user\option\UserOptionEditor; +use wcf\data\user\option\UserOptionBuilder; +use wcf\event\user\option\UserOptionEnabled; +use wcf\system\cache\builder\UserOptionCacheBuilder; +use wcf\system\event\EventHandler; /** * Enables a user option. @@ -21,8 +24,12 @@ public function __construct( public function __invoke(): void { - (new UserOptionEditor($this->option))->update([ - 'isDisabled' => 0, - ]); + UserOptionBuilder::forUpdate($this->option) + ->setIsDisabled(false) + ->update(); + + UserOptionCacheBuilder::getInstance()->reset(); + + EventHandler::getInstance()->fire(new UserOptionEnabled($this->option)); } } diff --git a/wcfsetup/install/files/lib/command/user/option/UpdateUserOption.class.php b/wcfsetup/install/files/lib/command/user/option/UpdateUserOption.class.php new file mode 100644 index 00000000000..bdd1339a305 --- /dev/null +++ b/wcfsetup/install/files/lib/command/user/option/UpdateUserOption.class.php @@ -0,0 +1,38 @@ + + * @since 6.3 + */ +final class UpdateUserOption +{ + public function __construct( + private readonly UserOptionBuilder $builder, + ) {} + + public function __invoke(): UserOption + { + $option = $this->builder->update(); + + UserOptionCacheBuilder::getInstance()->reset(); + + EventHandler::getInstance()->fire(new UserOptionUpdated( + $option, + $this->builder + )); + + return $option; + } +} diff --git a/wcfsetup/install/files/lib/data/user/option/UserOption.class.php b/wcfsetup/install/files/lib/data/user/option/UserOption.class.php index 413c5cb4edb..1a52e7e5abf 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 00000000000..13f004e1abb --- /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 00000000000..87c5120bb3b --- /dev/null +++ b/wcfsetup/install/files/lib/event/user/option/UserOptionCreated.class.php @@ -0,0 +1,23 @@ + + * @since 6.3 + */ +final class UserOptionCreated implements IPsr14Event +{ + public function __construct( + public readonly UserOption $option, + public readonly UserOptionBuilder $builder, + ) {} +} diff --git a/wcfsetup/install/files/lib/event/user/option/UserOptionDeleted.class.php b/wcfsetup/install/files/lib/event/user/option/UserOptionDeleted.class.php new file mode 100644 index 00000000000..ab2e209eddb --- /dev/null +++ b/wcfsetup/install/files/lib/event/user/option/UserOptionDeleted.class.php @@ -0,0 +1,19 @@ + + * @since 6.3 + */ +final class UserOptionDeleted implements IPsr14Event +{ + public function __construct(public readonly UserOption $option) {} +} diff --git a/wcfsetup/install/files/lib/event/user/option/UserOptionDisabled.class.php b/wcfsetup/install/files/lib/event/user/option/UserOptionDisabled.class.php new file mode 100644 index 00000000000..c8adb022be5 --- /dev/null +++ b/wcfsetup/install/files/lib/event/user/option/UserOptionDisabled.class.php @@ -0,0 +1,19 @@ + + * @since 6.3 + */ +final class UserOptionDisabled implements IPsr14Event +{ + public function __construct(public readonly UserOption $option) {} +} diff --git a/wcfsetup/install/files/lib/event/user/option/UserOptionEnabled.class.php b/wcfsetup/install/files/lib/event/user/option/UserOptionEnabled.class.php new file mode 100644 index 00000000000..fe604417328 --- /dev/null +++ b/wcfsetup/install/files/lib/event/user/option/UserOptionEnabled.class.php @@ -0,0 +1,19 @@ + + * @since 6.3 + */ +final class UserOptionEnabled implements IPsr14Event +{ + public function __construct(public readonly UserOption $option) {} +} diff --git a/wcfsetup/install/files/lib/event/user/option/UserOptionUpdated.class.php b/wcfsetup/install/files/lib/event/user/option/UserOptionUpdated.class.php new file mode 100644 index 00000000000..6607e5ba5c0 --- /dev/null +++ b/wcfsetup/install/files/lib/event/user/option/UserOptionUpdated.class.php @@ -0,0 +1,23 @@ + + * @since 6.3 + */ +final class UserOptionUpdated implements IPsr14Event +{ + public function __construct( + public readonly UserOption $option, + public readonly UserOptionBuilder $builder, + ) {} +} diff --git a/wcfsetup/install/files/lib/system/package/plugin/UserOptionPackageInstallationPlugin.class.php b/wcfsetup/install/files/lib/system/package/plugin/UserOptionPackageInstallationPlugin.class.php index 024edd67b7f..b6743c17869 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 20b0decf9021f8863a4828100d7241a6cebea80b Mon Sep 17 00:00:00 2001 From: Marcel Werk Date: Thu, 30 Jul 2026 16:31:52 +0200 Subject: [PATCH 06/37] 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 423340d4f60..4af1dcf5bde 100644 --- a/com.woltlab.wcf/templates/userOptionFieldList.tpl +++ b/com.woltlab.wcf/templates/userOptionFieldList.tpl @@ -1,9 +1,9 @@ {foreach from=$options item=optionData} {assign var=option value=$optionData[object]}
- {if $isSearchMode|empty || !$optionData[hideLabelInSearch]}{if $isSearchMode|empty && $option->required} *{/if}{/if} + {if $isSearchMode|empty || !$optionData[hideLabelInSearch]}{if $isSearchMode|empty && $option->required} *{/if}{/if}
{unsafe:$optionData[html]} - {lang __optional=true}{$langPrefix}{$option->optionName}.description{/lang} + {$option->getDescription()} {if $errorType|is_array && $errorType[$option->optionName]|isset} diff --git a/com.woltlab.wcf/templates/userProfileOptionFieldList.tpl b/com.woltlab.wcf/templates/userProfileOptionFieldList.tpl index cb00c78c65c..6d654a1b5a2 100644 --- a/com.woltlab.wcf/templates/userProfileOptionFieldList.tpl +++ b/com.woltlab.wcf/templates/userProfileOptionFieldList.tpl @@ -6,7 +6,7 @@ {assign var=error value=''} {/if}
- +
{unsafe:$optionData[html]} {if $error} @@ -17,7 +17,7 @@ {/if} {/if} - {lang __optional=true}{$langPrefix}{$option->optionName}.description{/lang} + {$option->getDescription()}
{/foreach} diff --git a/wcfsetup/install/files/acp/database/update_com.woltlab.wcf_6.3_userOption.php b/wcfsetup/install/files/acp/database/update_com.woltlab.wcf_6.3_userOption.php new file mode 100644 index 00000000000..4f59eea6b70 --- /dev/null +++ b/wcfsetup/install/files/acp/database/update_com.woltlab.wcf_6.3_userOption.php @@ -0,0 +1,57 @@ +columns([ + VarcharDatabaseTableColumn::create('l10nIdentifier') + ->length(255), + ]), + DatabaseTable::create('wcf1_user_option_l10n') + ->columns([ + NotNullInt10DatabaseTableColumn::create('optionID'), + IntDatabaseTableColumn::create('languageID'), + VarcharDatabaseTableColumn::create('title') + ->length(255), + MediumtextDatabaseTableColumn::create('description'), + TinyintDatabaseTableColumn::create('isPristine') + ->notNull() + ->defaultValue(1), + ]) + ->indices([ + DatabaseTableIndex::create('optionID') + ->columns(['optionID', 'languageID']), + ]) + ->foreignKeys([ + DatabaseTableForeignKey::create() + ->columns(['optionID']) + ->referencedTable('wcf1_user_option') + ->referencedColumns(['optionID']) + ->onDelete('CASCADE') + ->onUpdate('NO ACTION'), + DatabaseTableForeignKey::create() + ->columns(['languageID']) + ->referencedTable('wcf1_language') + ->referencedColumns(['languageID']) + ->onDelete('CASCADE') + ->onUpdate('NO ACTION'), + ]), +]; diff --git a/wcfsetup/install/files/acp/update_com.woltlab.wcf_6.3_captchaQuestionL10n.php b/wcfsetup/install/files/acp/update_com.woltlab.wcf_6.3_captchaQuestionL10n.php index ebf7cb80619..8f4e842459f 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 00000000000..7690922d9a5 --- /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 4b066e94c11..0ba49e9fdd9 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') @@ -435,25 +435,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 cfafeeaad7d..bf50f74e90b 100644 --- a/wcfsetup/install/files/lib/acp/form/UserOptionEditForm.class.php +++ b/wcfsetup/install/files/lib/acp/form/UserOptionEditForm.class.php @@ -4,12 +4,10 @@ use wcf\acp\page\UserOptionListPage; use wcf\data\user\option\UserOption; -use wcf\form\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 021c2286776..cf1a258b3f6 100755 --- a/wcfsetup/install/files/lib/acp/page/UserListPage.class.php +++ b/wcfsetup/install/files/lib/acp/page/UserListPage.class.php @@ -419,7 +419,11 @@ protected function readColumnsHeads() } if (isset($this->options[$column]) && $column !== 'email') { - $this->columnHeads[$column] = 'wcf.user.option.' . $column; + // system options keep their `wcf.user.option.*` phrase, options + // created by an administrator only have their localized title in + // the l10n storage + $this->columnHeads[$column] = $this->options[$column]->l10nIdentifier + ?? $this->options[$column]->getTitle(); } else { $this->columnHeads[$column] = 'wcf.user.' . $column; } diff --git a/wcfsetup/install/files/lib/bootstrap/com.woltlab.wcf.php b/wcfsetup/install/files/lib/bootstrap/com.woltlab.wcf.php index bcc1a6eb8c2..aae71404089 100644 --- a/wcfsetup/install/files/lib/bootstrap/com.woltlab.wcf.php +++ b/wcfsetup/install/files/lib/bootstrap/com.woltlab.wcf.php @@ -69,6 +69,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 00000000000..839c37fbecf --- /dev/null +++ b/wcfsetup/install/files/lib/command/l10n/SyncL10nLanguageItems.class.php @@ -0,0 +1,31 @@ + + * @since 6.3 + */ +final class SyncL10nLanguageItems +{ + public function __invoke(): void + { + $event = new L10nDefinitionCollecting(); + EventHandler::getInstance()->fire($event); + + foreach ($event->getDefinitions() as $definition) { + L10nLanguageItemSync::sync($definition); + } + } +} diff --git a/wcfsetup/install/files/lib/data/option/Option.class.php b/wcfsetup/install/files/lib/data/option/Option.class.php index b770a1b0515..24b21ecb9de 100644 --- a/wcfsetup/install/files/lib/data/option/Option.class.php +++ b/wcfsetup/install/files/lib/data/option/Option.class.php @@ -2,7 +2,7 @@ namespace wcf\data\option; -use wcf\data\DatabaseObject; +use wcf\data\CollectionDatabaseObject; use wcf\data\TDatabaseObjectOptions; use wcf\data\TDatabaseObjectPermissions; use wcf\system\WCF; @@ -32,12 +32,20 @@ * @property-read 0|1 $supportI18n is `1` if the option supports different values for all available languages, otherwise `0` * @property-read 0|1 $requireI18n is `1` if `$supportI18n = 1` and the option's value has to explicitly set for all values so that the `monolingual` option is not available, otherwise `0` * @property-read mixed[] $additionalData array with additional data of the option + * + * @extends CollectionDatabaseObject */ -class Option extends DatabaseObject +class Option extends CollectionDatabaseObject { use TDatabaseObjectOptions; use TDatabaseObjectPermissions; + #[\Override] + public function getCollectionClassName(): string + { + return OptionCollection::class; + } + #[\Override] public function __get(string $name) { diff --git a/wcfsetup/install/files/lib/data/option/OptionCollection.class.php b/wcfsetup/install/files/lib/data/option/OptionCollection.class.php new file mode 100644 index 00000000000..433665ef7b4 --- /dev/null +++ b/wcfsetup/install/files/lib/data/option/OptionCollection.class.php @@ -0,0 +1,18 @@ + + * @since 6.3 + * + * @extends DatabaseObjectCollection
-
- -
-
-
- - {lang}wcf.acp.paidSubscription.showOrder.description{/lang} -
-
- -
-
-
- - {lang}wcf.acp.paidSubscription.isDisabled.description{/lang} -
-
- - {if $availableSubscriptions|count} -
-
{lang}wcf.acp.paidSubscription.excludedSubscriptions{/lang}
-
- {foreach from=$availableSubscriptions item=availableSubscription} - - {/foreach} - {lang}wcf.acp.paidSubscription.excludedSubscriptions.description{/lang} -
-
- {/if} - - {event name='dataFields'} - - -
-

{lang}wcf.acp.paidSubscription.paymentOptions{/lang}

- - -
-
- - - {if $errorField == 'cost'} - - {if $errorType == 'empty'} - {lang}wcf.global.form.error.empty{/lang} - {else} - {lang}wcf.acp.paidSubscription.cost.error.{$errorType}{/lang} - {/if} - - {/if} -
- - -
-
-
- -
-
- -
-
-
- - - {if $errorField == 'subscriptionLength'} - - {if $errorType == 'empty'} - {lang}wcf.global.form.error.empty{/lang} - {else} - {lang}wcf.acp.paidSubscription.subscriptionLength.error.{$errorType}{/lang} - {/if} - - {/if} -
-
- -
-
-
- - {lang}wcf.acp.paidSubscription.isRecurring.description{/lang} -
-
- - -
-
- {foreach from=$availableUserGroups item=userGroup} - - {/foreach} - {if $errorField == 'groupIDs'} - - {if $errorType == 'empty'} - {lang}wcf.global.form.error.empty{/lang} - {else} - {lang}wcf.acp.paidSubscription.userGroups.error.{$errorType}{/lang} - {/if} - - {/if} - {lang}wcf.acp.paidSubscription.userGroups.description{/lang} - -
- -
- - {event name='sections'} - -
- - {csrfToken} -
- +{unsafe:$form->getHtml()} {include file='footer'} diff --git a/wcfsetup/install/files/acp/update_com.woltlab.wcf_6.3_paidSubscriptionL10n.php b/wcfsetup/install/files/acp/update_com.woltlab.wcf_6.3_paidSubscriptionL10n.php new file mode 100644 index 00000000000..7b246f650e8 --- /dev/null +++ b/wcfsetup/install/files/acp/update_com.woltlab.wcf_6.3_paidSubscriptionL10n.php @@ -0,0 +1,173 @@ +` and + * `wcf.paidSubscription.subscription.description` phrases are removed, + * including those left behind by previously deleted subscriptions. + * + * The positions of the subscriptions are renumbered consecutively, because + * the show order is now maintained relative to the other subscriptions. + * + * IMPORTANT ordering constraints for package.xml: + * - The database script `acp/database/update_com.woltlab.wcf_6.3_step1.php` + * (creating the `wcf1_paid_subscription_l10n` table) must run BEFORE this + * script. + * - The database script `acp/database/update_com.woltlab.wcf_6.3_step3.php` + * (dropping the `title` and `description` columns) must run AFTER this + * script. + * + * @author Alexander Ebert + * @copyright 2001-2026 WoltLab GmbH + * @license GNU Lesser General Public License + */ + +use wcf\data\paid\subscription\PaidSubscription; +use wcf\system\html\input\HtmlInputProcessor; +use wcf\system\l10n\L10nLanguageItemSource; +use wcf\system\l10n\L10nLanguageItemSync; +use wcf\system\language\LanguageFactory; +use wcf\system\WCF; + +$languageItemPattern = '^wcf\.paidSubscription\.subscription[0-9]+(\.description)?$'; + +// This script owns the table's content at this point (idempotency on re-runs). +WCF::getDB()->prepare("DELETE FROM wcf1_paid_subscription_l10n")->execute(); + +// The length of phrases was never validated, and the language editor allows +// phrases of any length, which would exceed the l10n columns. The phrases are +// removed below anyway, shortening them is lossless for a retry. A shortened +// HTML description is repaired by the input processor below. +$sql = "UPDATE wcf1_language_item + SET languageItemValue = SUBSTRING(languageItemValue, 1, 255) + WHERE languageItem REGEXP ? + AND CHAR_LENGTH(languageItemValue) > 255"; +$statement = WCF::getDB()->prepare($sql); +$statement->execute(['^wcf\.paidSubscription\.subscription[0-9]+$']); + +$sql = "UPDATE wcf1_language_item + SET languageItemValue = SUBSTRING(languageItemValue, 1, 16383) + WHERE languageItem REGEXP ? + AND LENGTH(languageItemValue) > 65535"; +$statement = WCF::getDB()->prepare($sql); +$statement->execute(['^wcf\.paidSubscription\.subscription[0-9]+\.description$']); + +// Legacy `PaidSubscription::getTitle()` resolved any existing phrase, which +// includes the phrases of other subscriptions and phrases named by other +// packages. `getDescription()` only resolved the subscription phrase pattern. +// Phrases that are not removed below keep their value, a value that exceeds +// the l10n column would block the update, the phrase name is migrated instead. +$sql = "SELECT languageItem + FROM wcf1_language_item + WHERE languageItem IN ( + SELECT title + FROM wcf1_paid_subscription + ) + GROUP BY languageItem + HAVING MAX(CHAR_LENGTH(languageItemValue)) <= 255"; +$statement = WCF::getDB()->prepare($sql); +$statement->execute(); +$existingTitleLanguageItems = $statement->fetchAll(\PDO::FETCH_COLUMN); + +$sql = "SELECT languageItem + FROM wcf1_language_item + WHERE languageItem IN ( + SELECT description + FROM wcf1_paid_subscription + ) + GROUP BY languageItem + HAVING MAX(LENGTH(languageItemValue)) <= 65535"; +$statement = WCF::getDB()->prepare($sql); +$statement->execute(); +$existingDescriptionLanguageItems = \array_filter( + $statement->fetchAll(\PDO::FETCH_COLUMN), + static fn(string $languageItem) => \preg_match('~^wcf.paidSubscription.subscription\d+.description$~', $languageItem) === 1 +); + +$sql = "SELECT subscriptionID + FROM wcf1_paid_subscription + ORDER BY showOrder, subscriptionID"; +$statement = WCF::getDB()->prepare($sql); +$statement->execute(); +$subscriptionIDs = $statement->fetchAll(\PDO::FETCH_COLUMN); + +$sql = "UPDATE wcf1_paid_subscription + SET showOrder = ? + WHERE subscriptionID = ?"; +$statement = WCF::getDB()->prepare($sql); +foreach ($subscriptionIDs as $index => $subscriptionID) { + $statement->execute([ + $index + 1, + $subscriptionID, + ]); +} + +// The phrases are removed only by the final `DELETE` below, so that a failure +// before it leaves them in place for a retry. +L10nLanguageItemSync::migrate( + PaidSubscription::getL10nDefinition(), + static function (array $row) use ($existingTitleLanguageItems, $existingDescriptionLanguageItems): array { + $description = $row['description'] ?? ''; + + return [ + 'sources' => [ + 'title' => new L10nLanguageItemSource( + languageItem: \in_array($row['title'], $existingTitleLanguageItems, true) ? $row['title'] : null, + literal: $row['title'], + ), + 'description' => new L10nLanguageItemSource( + languageItem: \in_array($description, $existingDescriptionLanguageItems, true) ? $description : null, + literal: $description, + ), + ], + ]; + } +); + +$sql = "SELECT subscriptionID, languageID, description + FROM wcf1_paid_subscription_l10n"; +$statement = WCF::getDB()->prepare($sql); +$statement->execute(); +$rows = $statement->fetchAll(\PDO::FETCH_ASSOC); + +$sql = "UPDATE wcf1_paid_subscription_l10n + SET description = ? + WHERE subscriptionID = ? + AND languageID <=> ?"; +$updateStatement = WCF::getDB()->prepare($sql); +foreach ($rows as $row) { + // Descriptions without HTML are legacy plain text, which is still rendered + // as such by `PaidSubscription::getFormattedDescription()`. + if ($row['description'] === null || \preg_match('~^<[a-z]+~', $row['description']) !== 1) { + continue; + } + + // The processed HTML can be longer than its source and exceed the column. + $description = $row['description']; + do { + $htmlInputProcessor = new HtmlInputProcessor(); + $htmlInputProcessor->process($description, 'com.woltlab.wcf.paidSubscription', $row['subscriptionID']); + $html = $htmlInputProcessor->getHtml(); + + $description = \mb_substr($description, 0, (int)(\mb_strlen($description) * 0.9)); + } while (\strlen($html) > 65535); + + $updateStatement->execute([ + $html, + $row['subscriptionID'], + $row['languageID'], + ]); +} + +$sql = "DELETE FROM wcf1_language_item + WHERE languageItem REGEXP ?"; +$statement = WCF::getDB()->prepare($sql); +$statement->execute([$languageItemPattern]); +if ($statement->getAffectedRows() > 0) { + LanguageFactory::getInstance()->deleteLanguageCache(); +} diff --git a/wcfsetup/install/files/lib/acp/form/PaidSubscriptionAddForm.class.php b/wcfsetup/install/files/lib/acp/form/PaidSubscriptionAddForm.class.php index cea6c5d355f..4a459cd333f 100644 --- a/wcfsetup/install/files/lib/acp/form/PaidSubscriptionAddForm.class.php +++ b/wcfsetup/install/files/lib/acp/form/PaidSubscriptionAddForm.class.php @@ -2,29 +2,45 @@ namespace wcf\acp\form; +use wcf\command\paid\subscription\CreatePaidSubscription; +use wcf\command\paid\subscription\UpdatePaidSubscription; +use wcf\data\DatabaseObjectBuilder; +use wcf\data\paid\subscription\L10nPaidSubscriptionList; use wcf\data\paid\subscription\PaidSubscription; -use wcf\data\paid\subscription\PaidSubscriptionAction; -use wcf\data\paid\subscription\PaidSubscriptionEditor; -use wcf\data\paid\subscription\PaidSubscriptionList; +use wcf\data\paid\subscription\PaidSubscriptionBuilder; use wcf\data\user\group\UserGroup; -use wcf\form\AbstractForm; +use wcf\form\AbstractDatabaseObjectBuilderForm; use wcf\system\exception\NamedUserException; -use wcf\system\exception\UserInputException; -use wcf\system\language\I18nHandler; +use wcf\system\form\builder\container\FormContainer; +use wcf\system\form\builder\container\SuffixFormFieldContainer; +use wcf\system\form\builder\field\BooleanFormField; +use wcf\system\form\builder\field\dependency\EmptyFormFieldDependency; +use wcf\system\form\builder\field\FloatFormField; +use wcf\system\form\builder\field\IFormField; +use wcf\system\form\builder\field\IntegerFormField; +use wcf\system\form\builder\field\MultipleSelectionFormField; +use wcf\system\form\builder\field\ShowOrderFormField; +use wcf\system\form\builder\field\SingleSelectionFormField; +use wcf\system\form\builder\field\TitleFormField; +use wcf\system\form\builder\field\validation\FormFieldValidationError; +use wcf\system\form\builder\field\validation\FormFieldValidator; +use wcf\system\form\builder\field\wysiwyg\WysiwygFormField; use wcf\system\payment\method\PaymentMethodHandler; -use wcf\system\request\LinkHandler; use wcf\system\WCF; use wcf\util\ArrayUtil; use wcf\util\HtmlString; +use wcf\util\StringUtil; /** * Shows the paid subscription add form. * - * @author Marcel Werk - * @copyright 2001-2019 WoltLab GmbH + * @author Marcel Werk, Alexander Ebert + * @copyright 2001-2026 WoltLab GmbH * @license GNU Lesser General Public License + * + * @extends AbstractDatabaseObjectBuilderForm */ -class PaidSubscriptionAddForm extends AbstractForm +class PaidSubscriptionAddForm extends AbstractDatabaseObjectBuilderForm { /** * @inheritDoc @@ -44,340 +60,350 @@ class PaidSubscriptionAddForm extends AbstractForm /** * @inheritDoc */ - public $templateName = 'paidSubscriptionAdd'; - - /** - * subscription title - * @var string - */ - public $title = ''; + public string $objectEditLinkController = PaidSubscriptionEditForm::class; /** - * subscription description - * @var string + * Maximum subscription length per unit. */ - public $description = ''; - - /** - * indicates if the subscription is disabled - * @var int - */ - public $isDisabled = 0; - - /** - * subscription show order - * @var int - */ - public $showOrder = 0; - - /** - * subscription cost - * @var double - */ - public $cost = 0.0; - - /** - * subscription currency - * @var string - */ - public $currency = 'USD'; - - /** - * indicates if the subscription is permanent - * @var int - */ - public $subscriptionLengthPermanent = 0; - - /** - * subscription length - * @var int - */ - public $subscriptionLength = 0; - - /** - * subscription length unit - * @var string - */ - public $subscriptionLengthUnit = ''; - - /** - * indicates if the subscription is a recurring payment - * @var int - */ - public $isRecurring = 0; - - /** - * list of group ids - * @var int[] - */ - public $groupIDs = []; - - /** - * list of excluded subscriptions - * @var int[] - */ - public $excludedSubscriptionIDs = []; - - /** - * available user groups - * @var UserGroup[] - */ - public $availableUserGroups = []; - - /** - * list of available currencies - * @var string[] - */ - public $availableCurrencies = []; - - /** - * list of available subscriptions - * @var array - */ - public $availableSubscriptions = []; + private const MAXIMUM_LENGTH = [ + 'D' => 90, + 'M' => 24, + 'Y' => 5, + ]; #[\Override] public function readParameters() { parent::readParameters(); - I18nHandler::getInstance()->register('description'); - I18nHandler::getInstance()->register('title'); - - // get available user groups - $this->availableUserGroups = UserGroup::getSortedAccessibleGroups( - [], - [UserGroup::GUESTS, UserGroup::EVERYONE, UserGroup::USERS] - ); - - if (\count(PaymentMethodHandler::getInstance()->getPaymentMethods()) === 0) { + if (PaymentMethodHandler::getInstance()->getPaymentMethods() === []) { throw new NamedUserException(HtmlString::fromSafeHtml( WCF::getLanguage()->get('wcf.acp.paidSubscription.error.noPaymentMethods') )); } - - // get available currencies - foreach (PaymentMethodHandler::getInstance()->getPaymentMethods() as $paymentMethod) { - $this->availableCurrencies = \array_merge( - $this->availableCurrencies, - $paymentMethod->getSupportedCurrencies() - ); - } - $this->availableCurrencies = \array_unique($this->availableCurrencies); - \sort($this->availableCurrencies); - - // get available subscriptions - $this->getAvailableSubscriptions(); } - /** - * @return void - */ - protected function getAvailableSubscriptions() + #[\Override] + protected function getDatabaseObjectBuilder(): PaidSubscriptionBuilder { - $subscriptionList = new PaidSubscriptionList(); - $subscriptionList->sqlOrderBy = 'title'; - $subscriptionList->readObjects(); - $this->availableSubscriptions = $subscriptionList->getObjects(); + if ($this->formObject !== null) { + return PaidSubscriptionBuilder::forUpdate($this->formObject); + } + + return PaidSubscriptionBuilder::forCreate(); } #[\Override] - public function readFormParameters() + protected function getCommand(DatabaseObjectBuilder $builder): callable { - parent::readFormParameters(); - - // read i18n values - I18nHandler::getInstance()->readValues(); - - // handle i18n plain input - if (I18nHandler::getInstance()->isPlainValue('description')) { - $this->description = I18nHandler::getInstance()->getValue('description'); - } - if (I18nHandler::getInstance()->isPlainValue('title')) { - $this->title = I18nHandler::getInstance()->getValue('title'); + if ($this->formObject !== null) { + return new UpdatePaidSubscription($builder); } - if (!empty($_POST['isDisabled'])) { - $this->isDisabled = 1; - } - if (isset($_POST['showOrder'])) { - $this->showOrder = \intval($_POST['showOrder']); - } - if (isset($_POST['cost'])) { - $this->cost = \floatval($_POST['cost']); - } - if (isset($_POST['currency'])) { - $this->currency = $_POST['currency']; - } - if (!empty($_POST['subscriptionLengthPermanent'])) { - $this->subscriptionLengthPermanent = 1; - } - if ($this->subscriptionLengthPermanent === 0) { - if (isset($_POST['subscriptionLength'])) { - $this->subscriptionLength = \intval($_POST['subscriptionLength']); - } - if (isset($_POST['subscriptionLengthUnit'])) { - $this->subscriptionLengthUnit = $_POST['subscriptionLengthUnit']; - } - if (!empty($_POST['isRecurring'])) { - $this->isRecurring = 1; - } - } - if (isset($_POST['groupIDs']) && \is_array($_POST['groupIDs'])) { - $this->groupIDs = ArrayUtil::toIntegerArray($_POST['groupIDs']); - } - if (isset($_POST['excludedSubscriptionIDs']) && \is_array($_POST['excludedSubscriptionIDs'])) { - $this->excludedSubscriptionIDs = ArrayUtil::toIntegerArray($_POST['excludedSubscriptionIDs']); - } + return new CreatePaidSubscription($builder); } #[\Override] - public function validate() + protected function createForm(): void { - parent::validate(); - - // validate title - if (!I18nHandler::getInstance()->validateValue('title')) { - if (I18nHandler::getInstance()->isPlainValue('title')) { - throw new UserInputException('title'); - } else { - throw new UserInputException('title', 'multilingual'); - } - } + parent::createForm(); + + $canChangePaymentOptions = $this->formObject === null || !$this->formObject->hasActiveSubscriptions(); + $availableSubscriptions = $this->getAvailableSubscriptions(); + $availableUserGroups = $this->getAvailableUserGroups(); + $availableCurrencies = $this->getAvailableCurrencies(); + + $this->form->appendChildren([ + FormContainer::create('general') + ->appendChildren([ + TitleFormField::create() + ->l10n() + ->required() + ->autoFocus() + ->maximumLength(255) + ->saveValueCallback(static function (PaidSubscriptionBuilder $builder, TitleFormField $field): void { + $builder->setTitle($field->getL10nValues()); + }) + ->loadValueCallback(static function (PaidSubscription $object, IFormField $field): void { + $field->value($object->getL10nValues('title')); + }), + WysiwygFormField::create('description') + ->label('wcf.global.description') + ->objectType('com.woltlab.wcf.paidSubscription') + ->l10n() + ->saveValueCallback(static function (PaidSubscriptionBuilder $builder, WysiwygFormField $field): void { + $builder->setDescription($field->getL10nValues()); + }) + ->loadValueCallback(static function (PaidSubscription $object, IFormField $field): void { + $field->value($object->getL10nValues('description')); + }), + ShowOrderFormField::create() + ->description('wcf.acp.paidSubscription.showOrder.description') + ->options($this->getSubscriptionsByShowOrder(), labelLanguageItems: false) + ->saveValueCallback(static function (PaidSubscriptionBuilder $builder, ShowOrderFormField $field): void { + $showOrder = $field->getSaveValue(); + if ($showOrder !== null) { + $builder->setShowOrder($showOrder); + } + }) + ->loadValueCallback(static function (PaidSubscription $object, IFormField $field): void { + $field->value($object->showOrder); + }), + BooleanFormField::create('isDisabled') + ->label('wcf.acp.paidSubscription.isDisabled') + ->description('wcf.acp.paidSubscription.isDisabled.description') + ->saveValueCallback(static function (PaidSubscriptionBuilder $builder, BooleanFormField $field): void { + $builder->setIsDisabled((bool)$field->getSaveValue()); + }) + ->loadValueCallback(static function (PaidSubscription $object, IFormField $field): void { + $field->value($object->isDisabled); + }), + MultipleSelectionFormField::create('excludedSubscriptionIDs') + ->label('wcf.acp.paidSubscription.excludedSubscriptions') + ->description('wcf.acp.paidSubscription.excludedSubscriptions.description') + ->available($availableSubscriptions !== []) + ->options($availableSubscriptions, labelLanguageItems: false) + ->saveValueCallback(static function (PaidSubscriptionBuilder $builder, MultipleSelectionFormField $field): void { + $builder->setExcludedSubscriptionIDs(\array_values(ArrayUtil::toIntegerArray($field->getValue() ?? []))); + }) + ->loadValueCallback(static function (PaidSubscription $object, IFormField $field) use ($availableSubscriptions): void { + // Deleted subscriptions were never removed from the list. + $field->value(\array_values(\array_intersect( + self::explodeIDs($object->excludedSubscriptionIDs), + \array_keys($availableSubscriptions) + ))); + }), + ]), + FormContainer::create('paymentOptions') + ->label('wcf.acp.paidSubscription.paymentOptions') + ->appendChildren([ + SuffixFormFieldContainer::create('costContainer') + ->label('wcf.acp.paidSubscription.cost') + ->field( + FloatFormField::create('cost') + ->required() + ->minimum(0.01) + ->step(0.01) + ->immutable(!$canChangePaymentOptions) + ->saveValueCallback(static function (PaidSubscriptionBuilder $builder, FloatFormField $field): void { + // The payment options of subscriptions with active + // subscribers cannot be changed. + if ($field->isImmutable()) { + return; + } + + $currency = $field->getDocument()->getNodeById('currency'); + \assert($currency instanceof SingleSelectionFormField); + + $builder->setPrice((float)$field->getSaveValue(), $currency->getSaveValue()); + }) + ->loadValueCallback(static function (PaidSubscription $object, IFormField $field): void { + $field->value((float)$object->cost); + }) + ) + ->suffixField( + SingleSelectionFormField::create('currency') + ->required() + ->options(\array_combine($availableCurrencies, $availableCurrencies), labelLanguageItems: false) + ->value(\in_array('USD', $availableCurrencies, true) ? 'USD' : \reset($availableCurrencies)) + ->immutable(!$canChangePaymentOptions) + ->loadValueCallback(static function (PaidSubscription $object, IFormField $field) use ($availableCurrencies): void { + // The payment methods may no longer offer the stored currency. + if (\in_array($object->currency, $availableCurrencies, true)) { + $field->value($object->currency); + } + }) + ), + BooleanFormField::create('subscriptionLengthPermanent') + ->label('wcf.acp.paidSubscription.subscriptionLength.permanent') + ->immutable(!$canChangePaymentOptions) + ->saveValueCallback(static function (PaidSubscriptionBuilder $builder, BooleanFormField $field): void { + if ($field->isImmutable()) { + return; + } + + if ($field->getSaveValue() === 1) { + $builder->setPermanent(); + } else { + $document = $field->getDocument(); + $length = $document->getNodeById('subscriptionLength'); + \assert($length instanceof IntegerFormField); + $unit = $document->getNodeById('subscriptionLengthUnit'); + \assert($unit instanceof SingleSelectionFormField); + $isRecurring = $document->getNodeById('isRecurring'); + \assert($isRecurring instanceof BooleanFormField); + + $builder->setLength( + $length->getSaveValue(), + $unit->getSaveValue(), + (bool)$isRecurring->getSaveValue() + ); + } + }) + ->loadValueCallback(static function (PaidSubscription $object, IFormField $field): void { + $field->value($object->subscriptionLength === 0); + }), + SuffixFormFieldContainer::create('subscriptionLengthContainer') + ->label('wcf.acp.paidSubscription.subscriptionLength') + ->addDependency( + EmptyFormFieldDependency::create('subscriptionLengthPermanent') + ->fieldId('subscriptionLengthPermanent') + ) + ->field( + IntegerFormField::create('subscriptionLength') + ->required() + ->minimum(1) + ->value(1) + ->immutable(!$canChangePaymentOptions) + ->addValidator(new FormFieldValidator( + 'maximumLength', + static function (IntegerFormField $field): void { + $unit = $field->getDocument()->getNodeById('subscriptionLengthUnit'); + \assert($unit instanceof SingleSelectionFormField); + + $maximumLength = self::MAXIMUM_LENGTH[(string)$unit->getValue()] ?? null; + if ($maximumLength !== null && $field->getValue() > $maximumLength) { + $field->addValidationError(new FormFieldValidationError( + 'invalid', + 'wcf.acp.paidSubscription.subscriptionLength.error.invalid' + )); + } + } + )) + ->loadValueCallback(static function (PaidSubscription $object, IFormField $field): void { + if ($object->subscriptionLength !== 0) { + $field->value($object->subscriptionLength); + } + }) + ) + ->suffixField( + SingleSelectionFormField::create('subscriptionLengthUnit') + ->required() + ->options([ + 'D' => 'wcf.acp.paidSubscription.subscriptionLengthUnit.D', + 'M' => 'wcf.acp.paidSubscription.subscriptionLengthUnit.M', + 'Y' => 'wcf.acp.paidSubscription.subscriptionLengthUnit.Y', + ]) + ->value('D') + ->immutable(!$canChangePaymentOptions) + ->loadValueCallback(static function (PaidSubscription $object, IFormField $field): void { + if ($object->subscriptionLengthUnit !== '') { + $field->value($object->subscriptionLengthUnit); + } + }) + ), + BooleanFormField::create('isRecurring') + ->label('wcf.acp.paidSubscription.isRecurring') + ->description('wcf.acp.paidSubscription.isRecurring.description') + ->immutable(!$canChangePaymentOptions) + ->addDependency( + EmptyFormFieldDependency::create('subscriptionLengthPermanent') + ->fieldId('subscriptionLengthPermanent') + ) + ->loadValueCallback(static function (PaidSubscription $object, IFormField $field): void { + $field->value($object->isRecurring); + }), + MultipleSelectionFormField::create('groupIDs') + ->label('wcf.acp.paidSubscription.userGroups') + ->description('wcf.acp.paidSubscription.userGroups.description') + ->required() + ->options($availableUserGroups, labelLanguageItems: false) + ->saveValueCallback(static function (PaidSubscriptionBuilder $builder, MultipleSelectionFormField $field): void { + $builder->setGroupIDs(\array_values(ArrayUtil::toIntegerArray($field->getValue() ?? []))); + }) + ->loadValueCallback(static function (PaidSubscription $object, IFormField $field) use ($availableUserGroups): void { + $field->value(\array_values(\array_intersect( + self::explodeIDs($object->groupIDs), + \array_keys($availableUserGroups) + ))); + }), + ]), + ]); - // validate description - if (!I18nHandler::getInstance()->validateValue('description', false, true)) { - throw new UserInputException('description'); + if ($this->formObject === null) { + // New subscriptions are appended by default. + $showOrder = $this->form->getNodeById('showOrder'); + \assert($showOrder instanceof ShowOrderFormField); + $showOrder->value(\count($showOrder->getOptions()) - 1); } + } - // validate cost - if ($this->cost < 0.01) { - throw new UserInputException('cost'); - } - // validate currency - if (!\in_array($this->currency, $this->availableCurrencies, true)) { - throw new UserInputException('cost'); - } + /** + * Returns the titles of the subscriptions that can be excluded, sorted by + * their title. + * + * @return array + */ + protected function getAvailableSubscriptions(): array + { + $subscriptionList = new L10nPaidSubscriptionList(); + $subscriptionList->sqlOrderBy = 'title'; + $subscriptionList->readObjects(); - if ($this->subscriptionLengthPermanent === 0) { - if ($this->subscriptionLength < 1) { - throw new UserInputException('subscriptionLength'); - } - if ($this->subscriptionLengthUnit !== 'D' && $this->subscriptionLengthUnit !== 'M' && $this->subscriptionLengthUnit !== 'Y') { - throw new UserInputException('subscriptionLength'); - } - if (($this->subscriptionLengthUnit === 'D' && $this->subscriptionLength > 90) || ($this->subscriptionLengthUnit === 'M' && $this->subscriptionLength > 24) || ($this->subscriptionLengthUnit === 'Y' && $this->subscriptionLength > 5)) { - throw new UserInputException('subscriptionLength', 'invalid'); - } - } + // The labels of selection options are printed as HTML. + return \array_map( + static fn(PaidSubscription $subscription) => StringUtil::encodeHTML($subscription->getTitle()), + $subscriptionList->getObjects() + ); + } - // validate group ids - if ($this->groupIDs === []) { - throw new UserInputException('groupIDs'); - } - foreach ($this->groupIDs as $groupID) { - if (!isset($this->availableUserGroups[$groupID])) { - throw new UserInputException('groupIDs'); - } - } - // validate excluded subscriptions - foreach ($this->excludedSubscriptionIDs as $key => $subscriptionID) { - if (!isset($this->availableSubscriptions[$subscriptionID])) { - unset($this->excludedSubscriptionIDs[$key]); - } - } + /** + * Returns the titles of the other subscriptions, sorted by their position. + * + * @return array + */ + protected function getSubscriptionsByShowOrder(): array + { + $subscriptionList = new L10nPaidSubscriptionList(); + $subscriptionList->sqlOrderBy = 'showOrder, subscriptionID'; + $subscriptionList->readObjects(); + + return \array_map( + static fn(PaidSubscription $subscription) => StringUtil::encodeHTML($subscription->getTitle()), + $subscriptionList->getObjects() + ); } - #[\Override] - public function save() + /** + * @return array + */ + private function getAvailableUserGroups(): array { - parent::save(); - - // save subscription - $this->objectAction = new PaidSubscriptionAction([], 'create', [ - 'data' => \array_merge($this->additionalFields, [ - 'title' => $this->title, - 'description' => $this->description, - 'isDisabled' => $this->isDisabled, - 'showOrder' => $this->showOrder, - 'cost' => $this->cost, - 'currency' => $this->currency, - 'subscriptionLength' => $this->subscriptionLength, - 'subscriptionLengthUnit' => $this->subscriptionLengthUnit, - 'isRecurring' => $this->isRecurring, - 'groupIDs' => \implode(',', $this->groupIDs), - 'excludedSubscriptionIDs' => \implode(',', $this->excludedSubscriptionIDs), - ]), - ]); - $returnValues = $this->objectAction->executeAction(); - - // save i18n values - $this->saveI18nValue($returnValues['returnValues'], 'description'); - $this->saveI18nValue($returnValues['returnValues'], 'title'); - $this->saved(); - - // reset values - $this->title = $this->description = $this->subscriptionLengthUnit = ''; - $this->isDisabled = $this->showOrder = $this->cost = $this->subscriptionLength = $this->isRecurring = 0; - $this->currency = 'EUR'; - $this->groupIDs = []; - I18nHandler::getInstance()->reset(); - - // show success message - WCF::getTPL()->assign([ - 'success' => true, - 'objectEditLink' => LinkHandler::getInstance()->getControllerLink( - PaidSubscriptionEditForm::class, - ['id' => $returnValues['returnValues']->subscriptionID] - ), - ]); + $userGroups = UserGroup::getSortedAccessibleGroups( + [], + [UserGroup::GUESTS, UserGroup::EVERYONE, UserGroup::USERS] + ); + + return \array_map( + static fn(UserGroup $userGroup) => StringUtil::encodeHTML($userGroup->getTitle()), + $userGroups + ); } /** - * Saves i18n values. - * - * @return void + * @return list */ - public function saveI18nValue(PaidSubscription $subscription, string $columnName) + private function getAvailableCurrencies(): array { - if (!I18nHandler::getInstance()->isPlainValue($columnName)) { - I18nHandler::getInstance()->save( - $columnName, - 'wcf.paidSubscription.subscription' . $subscription->subscriptionID . ($columnName === 'description' ? '.description' : ''), - 'wcf.paidSubscription', - 1 - ); - - // update database - $editor = new PaidSubscriptionEditor($subscription); - $editor->update([ - $columnName => 'wcf.paidSubscription.subscription' . $subscription->subscriptionID . ($columnName === 'description' ? '.description' : ''), - ]); + $availableCurrencies = []; + foreach (PaymentMethodHandler::getInstance()->getPaymentMethods() as $paymentMethod) { + $availableCurrencies = [...$availableCurrencies, ...$paymentMethod->getSupportedCurrencies()]; } + $availableCurrencies = \array_values(\array_unique($availableCurrencies)); + \sort($availableCurrencies); + + return $availableCurrencies; } - #[\Override] - public function assignVariables() + /** + * @return list + */ + private static function explodeIDs(?string $ids): array { - parent::assignVariables(); - - I18nHandler::getInstance()->assignVariables(); - - WCF::getTPL()->assign([ - 'action' => 'add', - 'isDisabled' => $this->isDisabled, - 'showOrder' => $this->showOrder, - 'cost' => $this->cost, - 'currency' => $this->currency, - 'subscriptionLength' => $this->subscriptionLength, - 'subscriptionLengthUnit' => $this->subscriptionLengthUnit, - 'isRecurring' => $this->isRecurring, - 'groupIDs' => $this->groupIDs, - 'excludedSubscriptionIDs' => $this->excludedSubscriptionIDs, - 'availableCurrencies' => $this->availableCurrencies, - 'availableUserGroups' => $this->availableUserGroups, - 'availableSubscriptions' => $this->availableSubscriptions, - 'canChangePaymentOptions' => true, - ]); + if ($ids === null || $ids === '') { + return []; + } + + return \array_values(ArrayUtil::toIntegerArray(\explode(',', $ids))); } } diff --git a/wcfsetup/install/files/lib/acp/form/PaidSubscriptionEditForm.class.php b/wcfsetup/install/files/lib/acp/form/PaidSubscriptionEditForm.class.php index 4b8d477caa5..8d16cd2f0e0 100644 --- a/wcfsetup/install/files/lib/acp/form/PaidSubscriptionEditForm.class.php +++ b/wcfsetup/install/files/lib/acp/form/PaidSubscriptionEditForm.class.php @@ -4,22 +4,17 @@ use wcf\acp\page\PaidSubscriptionListPage; use wcf\data\paid\subscription\PaidSubscription; -use wcf\data\paid\subscription\PaidSubscriptionAction; -use wcf\data\paid\subscription\PaidSubscriptionList; -use wcf\form\AbstractForm; -use wcf\system\exception\PermissionDeniedException; +use wcf\http\Helper; use wcf\system\interaction\admin\PaidSubscriptionInteractions; use wcf\system\interaction\StandaloneInteractionContextMenuComponent; -use wcf\system\language\I18nHandler; use wcf\system\request\LinkHandler; use wcf\system\WCF; -use wcf\util\ArrayUtil; /** * Shows the paid subscription edit form. * - * @author Marcel Werk - * @copyright 2001-2019 WoltLab GmbH + * @author Marcel Werk, Alexander Ebert + * @copyright 2001-2026 WoltLab GmbH * @license GNU Lesser General Public License */ class PaidSubscriptionEditForm extends PaidSubscriptionAddForm @@ -30,136 +25,16 @@ class PaidSubscriptionEditForm extends PaidSubscriptionAddForm public $activeMenuItem = 'wcf.acp.menu.link.paidSubscription.list'; /** - * id of the edited subscription - * @var int - */ - public $subscriptionID = 0; - - /** - * edited subscription object - * @var ?PaidSubscription + * @inheritDoc */ - public $subscription; + public string $formAction = 'edit'; #[\Override] public function readParameters() { - if (isset($_REQUEST['id'])) { - $this->subscriptionID = \intval($_REQUEST['id']); - } - $this->subscription = new PaidSubscription($this->subscriptionID); - if ($this->subscription->isNil()) { - throw new PermissionDeniedException(); - } - parent::readParameters(); - } - - #[\Override] - public function readFormParameters() - { - parent::readFormParameters(); - - if ($this->subscription->hasActiveSubscriptions()) { - $this->cost = (float)$this->subscription->cost; - $this->currency = $this->subscription->currency; - $this->subscriptionLength = $this->subscription->subscriptionLength; - $this->subscriptionLengthUnit = $this->subscription->subscriptionLengthUnit; - $this->isRecurring = $this->subscription->isRecurring; - if ($this->subscriptionLength === 0) { - $this->subscriptionLengthPermanent = 1; - } - } - } - /** - * @return void - */ - #[\Override] - protected function getAvailableSubscriptions() - { - $subscriptionList = new PaidSubscriptionList(); - $subscriptionList->getConditionBuilder()->add('subscriptionID <> ?', [$this->subscriptionID]); - $subscriptionList->sqlOrderBy = 'title'; - $subscriptionList->readObjects(); - $this->availableSubscriptions = $subscriptionList->getObjects(); - } - - #[\Override] - public function readData() - { - parent::readData(); - - if ($_POST === []) { - I18nHandler::getInstance()->setOptions( - 'description', - 1, - $this->subscription->description, - 'wcf.paidSubscription.subscription\d+.description' - ); - I18nHandler::getInstance()->setOptions( - 'title', - 1, - $this->subscription->title, - 'wcf.paidSubscription.subscription\d+' - ); - - $this->isDisabled = $this->subscription->isDisabled; - $this->showOrder = $this->subscription->showOrder; - $this->cost = (float)$this->subscription->cost; - $this->currency = $this->subscription->currency; - $this->subscriptionLength = $this->subscription->subscriptionLength; - $this->subscriptionLengthUnit = $this->subscription->subscriptionLengthUnit; - $this->isRecurring = $this->subscription->isRecurring; - $this->groupIDs = ArrayUtil::toIntegerArray(\explode(',', $this->subscription->groupIDs)); - $this->excludedSubscriptionIDs = ArrayUtil::toIntegerArray(\explode(',', $this->subscription->excludedSubscriptionIDs)); - } - } - - #[\Override] - public function save() - { - AbstractForm::save(); - - // update description - $this->description = 'wcf.paidSubscription.subscription' . $this->subscription->subscriptionID . '.description'; - if (I18nHandler::getInstance()->isPlainValue('description')) { - I18nHandler::getInstance()->remove($this->description); - $this->description = I18nHandler::getInstance()->getValue('description'); - } else { - I18nHandler::getInstance()->save('description', $this->description, 'wcf.paidSubscription', 1); - } - - // update title - $this->title = 'wcf.paidSubscription.subscription' . $this->subscription->subscriptionID; - if (I18nHandler::getInstance()->isPlainValue('title')) { - I18nHandler::getInstance()->remove($this->title); - $this->title = I18nHandler::getInstance()->getValue('title'); - } else { - I18nHandler::getInstance()->save('title', $this->title, 'wcf.paidSubscription', 1); - } - - // save subscription - $this->objectAction = new PaidSubscriptionAction([$this->subscription], 'update', [ - 'data' => \array_merge($this->additionalFields, [ - 'title' => $this->title, - 'description' => $this->description, - 'isDisabled' => $this->isDisabled, - 'showOrder' => $this->showOrder, - 'cost' => $this->cost, - 'currency' => $this->currency, - 'subscriptionLength' => $this->subscriptionLength, - 'subscriptionLengthUnit' => $this->subscriptionLengthUnit, - 'isRecurring' => $this->isRecurring, - 'groupIDs' => \implode(',', $this->groupIDs), - 'excludedSubscriptionIDs' => \implode(',', $this->excludedSubscriptionIDs), - ]), - ]); - $this->objectAction->executeAction(); - $this->saved(); - - // show success message - WCF::getTPL()->assign('success', true); + $this->formObject = Helper::fetchObjectFromQueryParameter(PaidSubscription::class); } #[\Override] @@ -167,19 +42,32 @@ public function assignVariables() { parent::assignVariables(); - $useRequestData = $_POST !== []; - I18nHandler::getInstance()->assignVariables($useRequestData); - WCF::getTPL()->assign([ - 'action' => 'edit', - 'subscriptionID' => $this->subscriptionID, - 'subscription' => $this->subscription, - 'canChangePaymentOptions' => !$this->subscription->hasActiveSubscriptions(), 'interactionContextMenu' => StandaloneInteractionContextMenuComponent::forContentHeaderButton( new PaidSubscriptionInteractions(), - $this->subscription, + $this->formObject, LinkHandler::getInstance()->getControllerLink(PaidSubscriptionListPage::class) ), ]); } + + #[\Override] + protected function getAvailableSubscriptions(): array + { + return \array_filter( + parent::getAvailableSubscriptions(), + fn(int $key) => $key !== $this->formObject->getObjectID(), + \ARRAY_FILTER_USE_KEY + ); + } + + #[\Override] + protected function getSubscriptionsByShowOrder(): array + { + return \array_filter( + parent::getSubscriptionsByShowOrder(), + fn(int $key) => $key !== $this->formObject->getObjectID(), + \ARRAY_FILTER_USE_KEY + ); + } } diff --git a/wcfsetup/install/files/lib/command/paid/subscription/CreatePaidSubscription.class.php b/wcfsetup/install/files/lib/command/paid/subscription/CreatePaidSubscription.class.php new file mode 100644 index 00000000000..e53cde4a399 --- /dev/null +++ b/wcfsetup/install/files/lib/command/paid/subscription/CreatePaidSubscription.class.php @@ -0,0 +1,41 @@ + + * @since 6.3 + */ +final class CreatePaidSubscription +{ + public function __construct( + private readonly PaidSubscriptionBuilder $builder, + ) {} + + public function __invoke(): PaidSubscription + { + $subscription = $this->builder->create(); + + // The builder assigns the final position after the object was read. + $subscription = new PaidSubscription($subscription->subscriptionID); + + PaidSubscriptionCacheBuilder::getInstance()->reset(); + + EventHandler::getInstance()->fire(new PaidSubscriptionCreated( + $subscription, + $this->builder + )); + + return $subscription; + } +} diff --git a/wcfsetup/install/files/lib/command/paid/subscription/DeletePaidSubscription.class.php b/wcfsetup/install/files/lib/command/paid/subscription/DeletePaidSubscription.class.php new file mode 100644 index 00000000000..e6bf23e589a --- /dev/null +++ b/wcfsetup/install/files/lib/command/paid/subscription/DeletePaidSubscription.class.php @@ -0,0 +1,44 @@ + + * @since 6.3 + */ +final class DeletePaidSubscription +{ + public function __construct( + private readonly PaidSubscription $subscription, + ) {} + + public function __invoke(): void + { + // The given object may predate the deletion of another subscription, + // which moved this one to a lower position. + $showOrder = (new PaidSubscription($this->subscription->subscriptionID))->showOrder; + + PaidSubscriptionBuilder::delete($this->subscription); + + $sql = "UPDATE wcf1_paid_subscription + SET showOrder = showOrder - 1 + WHERE showOrder > ?"; + $statement = WCF::getDB()->prepare($sql); + $statement->execute([$showOrder]); + + PaidSubscriptionCacheBuilder::getInstance()->reset(); + + EventHandler::getInstance()->fire(new PaidSubscriptionDeleted($this->subscription)); + } +} diff --git a/wcfsetup/install/files/lib/command/paid/subscription/DisablePaidSubscription.class.php b/wcfsetup/install/files/lib/command/paid/subscription/DisablePaidSubscription.class.php index 2bce6030f3b..aa3241d322a 100644 --- a/wcfsetup/install/files/lib/command/paid/subscription/DisablePaidSubscription.class.php +++ b/wcfsetup/install/files/lib/command/paid/subscription/DisablePaidSubscription.class.php @@ -3,8 +3,9 @@ namespace wcf\command\paid\subscription; use wcf\data\paid\subscription\PaidSubscription; -use wcf\data\paid\subscription\PaidSubscriptionEditor; +use wcf\data\paid\subscription\PaidSubscriptionBuilder; use wcf\event\paid\subscription\PaidSubscriptionDisabled; +use wcf\system\cache\builder\PaidSubscriptionCacheBuilder; use wcf\system\event\EventHandler; /** @@ -21,11 +22,11 @@ public function __construct(private readonly PaidSubscription $subscription) {} public function __invoke(): void { - (new PaidSubscriptionEditor($this->subscription))->update([ - 'isDisabled' => 1, - ]); + PaidSubscriptionBuilder::forUpdate($this->subscription) + ->setIsDisabled(true) + ->update(); - PaidSubscriptionEditor::resetCache(); + PaidSubscriptionCacheBuilder::getInstance()->reset(); $event = new PaidSubscriptionDisabled($this->subscription); EventHandler::getInstance()->fire($event); diff --git a/wcfsetup/install/files/lib/command/paid/subscription/EnablePaidSubscription.class.php b/wcfsetup/install/files/lib/command/paid/subscription/EnablePaidSubscription.class.php index 5d17b31abc0..2d845852c8c 100644 --- a/wcfsetup/install/files/lib/command/paid/subscription/EnablePaidSubscription.class.php +++ b/wcfsetup/install/files/lib/command/paid/subscription/EnablePaidSubscription.class.php @@ -3,8 +3,9 @@ namespace wcf\command\paid\subscription; use wcf\data\paid\subscription\PaidSubscription; -use wcf\data\paid\subscription\PaidSubscriptionEditor; +use wcf\data\paid\subscription\PaidSubscriptionBuilder; use wcf\event\paid\subscription\PaidSubscriptionEnabled; +use wcf\system\cache\builder\PaidSubscriptionCacheBuilder; use wcf\system\event\EventHandler; /** @@ -21,11 +22,11 @@ public function __construct(private readonly PaidSubscription $subscription) {} public function __invoke(): void { - (new PaidSubscriptionEditor($this->subscription))->update([ - 'isDisabled' => 0, - ]); + PaidSubscriptionBuilder::forUpdate($this->subscription) + ->setIsDisabled(false) + ->update(); - PaidSubscriptionEditor::resetCache(); + PaidSubscriptionCacheBuilder::getInstance()->reset(); $event = new PaidSubscriptionEnabled($this->subscription); EventHandler::getInstance()->fire($event); diff --git a/wcfsetup/install/files/lib/command/paid/subscription/UpdatePaidSubscription.class.php b/wcfsetup/install/files/lib/command/paid/subscription/UpdatePaidSubscription.class.php new file mode 100644 index 00000000000..62261ed3a39 --- /dev/null +++ b/wcfsetup/install/files/lib/command/paid/subscription/UpdatePaidSubscription.class.php @@ -0,0 +1,41 @@ + + * @since 6.3 + */ +final class UpdatePaidSubscription +{ + public function __construct( + private readonly PaidSubscriptionBuilder $builder, + ) {} + + public function __invoke(): PaidSubscription + { + $subscription = $this->builder->update(); + + // The builder may move the subscription after the object was read. + $subscription = new PaidSubscription($subscription->subscriptionID); + + PaidSubscriptionCacheBuilder::getInstance()->reset(); + + EventHandler::getInstance()->fire(new PaidSubscriptionUpdated( + $subscription, + $this->builder + )); + + return $subscription; + } +} diff --git a/wcfsetup/install/files/lib/data/paid/subscription/I18nPaidSubscriptionList.class.php b/wcfsetup/install/files/lib/data/paid/subscription/I18nPaidSubscriptionList.class.php deleted file mode 100644 index 4fef56c84a5..00000000000 --- a/wcfsetup/install/files/lib/data/paid/subscription/I18nPaidSubscriptionList.class.php +++ /dev/null @@ -1,27 +0,0 @@ - - * - * @extends I18nDatabaseObjectList - */ -class I18nPaidSubscriptionList extends I18nDatabaseObjectList -{ - /** - * @inheritDoc - */ - public $i18nFields = ['title' => 'titleI18n']; - - /** - * @inheritDoc - */ - public $className = PaidSubscription::class; -} diff --git a/wcfsetup/install/files/lib/data/paid/subscription/L10nPaidSubscriptionList.class.php b/wcfsetup/install/files/lib/data/paid/subscription/L10nPaidSubscriptionList.class.php new file mode 100644 index 00000000000..af0bffb5eff --- /dev/null +++ b/wcfsetup/install/files/lib/data/paid/subscription/L10nPaidSubscriptionList.class.php @@ -0,0 +1,34 @@ + + * @since 6.3 + */ +class L10nPaidSubscriptionList extends PaidSubscriptionList +{ + /** + * @inheritDoc + */ + public $className = PaidSubscription::class; + + public function __construct() + { + parent::__construct(); + + $storage = new L10nStorage(PaidSubscription::getL10nDefinition()); + + $this->sqlSelects .= ($this->sqlSelects !== '' ? ', ' : '') + . $storage->getSubSelect('title', $this->getDatabaseTableAlias()) + . ' AS title, ' + . $storage->getSubSelect('description', $this->getDatabaseTableAlias()) + . ' AS description'; + } +} diff --git a/wcfsetup/install/files/lib/data/paid/subscription/PaidSubscription.class.php b/wcfsetup/install/files/lib/data/paid/subscription/PaidSubscription.class.php index 1299f295fe9..b1d6dab4069 100644 --- a/wcfsetup/install/files/lib/data/paid/subscription/PaidSubscription.class.php +++ b/wcfsetup/install/files/lib/data/paid/subscription/PaidSubscription.class.php @@ -2,12 +2,14 @@ namespace wcf\data\paid\subscription; -use wcf\data\DatabaseObject; +use wcf\data\CollectionDatabaseObject; use wcf\data\ITitledObject; use wcf\data\object\type\ObjectTypeCache; use wcf\data\paid\subscription\user\PaidSubscriptionUserList; use wcf\page\PaidSubscriptionReturnPage; use wcf\system\html\output\HtmlOutputProcessor; +use wcf\system\l10n\L10nDefinition; +use wcf\system\l10n\L10nStorage; use wcf\system\payment\method\PaymentMethodHandler; use wcf\system\request\LinkHandler; use wcf\system\WCF; @@ -16,13 +18,14 @@ /** * Represents a paid subscription. * + * The localized title and description are stored in the + * `wcf1_paid_subscription_l10n` table. + * * @author Marcel Werk * @copyright 2001-2019 WoltLab GmbH * @license GNU Lesser General Public License * * @property-read int $subscriptionID unique id of the paid subscription - * @property-read string $title title of the paid subscription or name of language item which contains the title - * @property-read ?string $description description of the paid subscription or name of language item which contains the description * @property-read 0|1 $isDisabled is `1` if the paid subscription is disabled and thus cannot be bought, otherwise `0` * @property-read int $showOrder position of the paid subscription in relation to the other paid subscriptions * @property-read string $cost cost of the paid subscription as a decimal string @@ -32,8 +35,12 @@ * @property-read 0|1 $isRecurring is `1` if the paid subscription is recurring and thus requires regular (automatic) payments, otherwise `0` * @property-read ?string $groupIDs comma-separated list with the ids of the user groups for which the subscription pays membership * @property-read ?string $excludedSubscriptionIDs comma-separated list with the ids of paid subscriptions which prohibit purchase of this paid subscription + * + * @extends CollectionDatabaseObject + * + * @phpstan-import-type L10nValue from L10nStorage */ -class PaidSubscription extends DatabaseObject implements ITitledObject +class PaidSubscription extends CollectionDatabaseObject implements ITitledObject { /** * Returns list of purchase buttons. @@ -61,7 +68,7 @@ public function getPurchaseButtons() $buttons[] = $paymentMethod->getPurchaseButton( (float)$this->cost, $this->currency, - WCF::getLanguage()->get($this->title), + $this->getTitle(), $objectTypeID . ':' . WCF::getUser()->userID . ':' . $this->subscriptionID, LinkHandler::getInstance()->getControllerLink(PaidSubscriptionReturnPage::class), LinkHandler::getInstance()->getLink(), @@ -103,17 +110,13 @@ public function getFormattedDescription() } /** - * Returns the description with transparent handling of phrases. + * Returns the localized description. * * @return string */ protected function getDescription() { - if (\preg_match('~^wcf.paidSubscription.subscription\d+.description$~', $this->description)) { - return WCF::getLanguage()->get($this->description); - } - - return $this->description; + return $this->getCollection()->getResolvedL10nValue($this, 'description'); } /** @@ -122,7 +125,36 @@ protected function getDescription() #[\Override] public function getTitle(): string { - return WCF::getLanguage()->get($this->title); + return $this->getCollection()->getResolvedL10nValue($this, 'title'); + } + + /** + * Returns the localized values of the given column as a + * `languageID => value` map (see `L10nStorage`). + * + * @return L10nValue + * @since 6.3 + */ + public function getL10nValues(string $columnName): array + { + if ($columnName !== 'title' && $columnName !== 'description') { + throw new \InvalidArgumentException("Invalid column name given."); + } + + return $this->getCollection()->getL10nValues($this, $columnName); + } + + /** + * @since 6.3 + */ + public static function getL10nDefinition(): L10nDefinition + { + return new L10nDefinition( + 'wcf1_paid_subscription', + 'wcf1_paid_subscription_l10n', + 'subscriptionID', + ['title', 'description'], + ); } /** diff --git a/wcfsetup/install/files/lib/data/paid/subscription/PaidSubscriptionAction.class.php b/wcfsetup/install/files/lib/data/paid/subscription/PaidSubscriptionAction.class.php index 9a0939fea44..8917b59c85e 100644 --- a/wcfsetup/install/files/lib/data/paid/subscription/PaidSubscriptionAction.class.php +++ b/wcfsetup/install/files/lib/data/paid/subscription/PaidSubscriptionAction.class.php @@ -2,6 +2,7 @@ namespace wcf\data\paid\subscription; +use wcf\command\paid\subscription\DeletePaidSubscription; use wcf\command\paid\subscription\DisablePaidSubscription; use wcf\command\paid\subscription\EnablePaidSubscription; use wcf\data\AbstractDatabaseObjectAction; @@ -10,6 +11,11 @@ /** * Executes paid subscription-related actions. * + * Paid subscriptions should be created, updated and deleted through the + * `CreatePaidSubscription`, `UpdatePaidSubscription` and + * `DeletePaidSubscription` commands, the `create`, `update` and `delete` + * actions are `@deprecated 6.3`. + * * @author Marcel Werk * @copyright 2001-2019 WoltLab GmbH * @license GNU Lesser General Public License @@ -33,35 +39,21 @@ class PaidSubscriptionAction extends AbstractDatabaseObjectAction implements ITo */ protected $requireACP = ['create', 'delete', 'toggle', 'update']; + /** + * @deprecated 6.3 use the `DeletePaidSubscription` command instead. + */ #[\Override] - public function create() + public function delete() { - $showOrder = 0; - if (isset($this->parameters['data']['showOrder'])) { - $showOrder = $this->parameters['data']['showOrder']; - unset($this->parameters['data']['showOrder']); + if ($this->objects === []) { + $this->readObjects(); } - /** @var PaidSubscription $subscription */ - $subscription = parent::create(); - $editor = new PaidSubscriptionEditor($subscription); - $editor->setShowOrder($showOrder); - - return new PaidSubscription($subscription->subscriptionID); - } - - #[\Override] - public function update() - { - parent::update(); - - if ( - \count($this->objects) === 1 - && isset($this->parameters['data']['showOrder']) - && $this->parameters['data']['showOrder'] !== \reset($this->objects)->showOrder - ) { - \reset($this->objects)->setShowOrder($this->parameters['data']['showOrder']); + foreach ($this->objects as $object) { + new DeletePaidSubscription($object->getDecoratedObject())(); } + + return \count($this->objects); } /** diff --git a/wcfsetup/install/files/lib/data/paid/subscription/PaidSubscriptionBuilder.class.php b/wcfsetup/install/files/lib/data/paid/subscription/PaidSubscriptionBuilder.class.php new file mode 100644 index 00000000000..3ee909aeb62 --- /dev/null +++ b/wcfsetup/install/files/lib/data/paid/subscription/PaidSubscriptionBuilder.class.php @@ -0,0 +1,244 @@ + + * @since 6.3 + * + * @extends DatabaseObjectBuilder + * + * @phpstan-import-type L10nValue from L10nStorage + */ +final class PaidSubscriptionBuilder extends DatabaseObjectBuilder +{ + /** + * @var L10nValue + */ + private array $title; + + /** + * @var L10nValue + */ + private array $description; + + /** + * @param L10nValue $title + */ + public function setTitle(array $title): static + { + $this->title = $title; + + return $this; + } + + /** + * @param L10nValue $description + */ + public function setDescription(array $description): static + { + $this->description = $description; + + return $this; + } + + public function setIsDisabled(bool $isDisabled): static + { + $this->properties['isDisabled'] = $isDisabled ? 1 : 0; + + return $this; + } + + /** + * Sets the position relative to the other subscriptions, starting at `1`. + * `0` or a position after the last subscription appends it. + */ + public function setShowOrder(int $showOrder): static + { + $this->properties['showOrder'] = $showOrder; + + return $this; + } + + public function setPrice(float $cost, string $currency): static + { + $this->properties['cost'] = $cost; + $this->properties['currency'] = $currency; + + return $this; + } + + /** + * Marks the subscription as permanent, i.e. without an expiration. + */ + public function setPermanent(): static + { + $this->properties['subscriptionLength'] = 0; + $this->properties['subscriptionLengthUnit'] = ''; + $this->properties['isRecurring'] = 0; + + return $this; + } + + /** + * Sets the duration, `$unit` is one of `D` (days), `M` (months) or `Y` (years). + */ + public function setLength(int $length, string $unit, bool $isRecurring): static + { + if ($length < 1) { + throw new \InvalidArgumentException("The length must be at least 1, use setPermanent() instead."); + } + if (!\in_array($unit, ['D', 'M', 'Y'], true)) { + throw new \InvalidArgumentException("The unit must be one of 'D', 'M' or 'Y'."); + } + + $this->properties['subscriptionLength'] = $length; + $this->properties['subscriptionLengthUnit'] = $unit; + $this->properties['isRecurring'] = $isRecurring ? 1 : 0; + + return $this; + } + + /** + * @param list $groupIDs + */ + public function setGroupIDs(array $groupIDs): static + { + $this->properties['groupIDs'] = \implode(',', $groupIDs); + + return $this; + } + + /** + * @param list $excludedSubscriptionIDs + */ + public function setExcludedSubscriptionIDs(array $excludedSubscriptionIDs): static + { + $this->properties['excludedSubscriptionIDs'] = \implode(',', $excludedSubscriptionIDs); + + return $this; + } + + #[\Override] + protected function allowEmptyCreate(): bool + { + return true; + } + + #[\Override] + protected function afterValidateCreate(): void + { + if (!isset($this->title)) { + throw new \BadMethodCallException("Missing value for 'title'."); + } + } + + #[\Override] + protected function afterCreate(DatabaseObject $object): void + { + $showOrder = $this->properties['showOrder'] ?? 0; + $maxShowOrder = $this->getMaxShowOrder($object->subscriptionID); + if ($showOrder === 0 || $showOrder > $maxShowOrder) { + $this->updateShowOrder($object->subscriptionID, $maxShowOrder + 1); + } else { + $this->shiftShowOrder($object->subscriptionID, $showOrder, 1); + } + + $this->saveL10nValues($object); + } + + #[\Override] + protected function afterUpdate(DatabaseObject $object): void + { + $oldObject = $this->getObject(); + + if (isset($this->properties['showOrder']) && $this->properties['showOrder'] !== $oldObject->showOrder) { + // Closes the gap at the previous position before making room at + // the new one, the position is relative to the other subscriptions. + $this->shiftShowOrder($object->subscriptionID, $oldObject->showOrder + 1, -1); + + $showOrder = $this->properties['showOrder']; + $maxShowOrder = $this->getMaxShowOrder($object->subscriptionID); + if ($showOrder === 0 || $showOrder > $maxShowOrder) { + $this->updateShowOrder($object->subscriptionID, $maxShowOrder + 1); + } else { + $this->shiftShowOrder($object->subscriptionID, $showOrder, 1); + } + } + + if (isset($this->title) || isset($this->description)) { + $this->saveL10nValues($object); + } + } + + private function getMaxShowOrder(int $excludedSubscriptionID): int + { + $sql = "SELECT COALESCE(MAX(showOrder), 0) + FROM wcf1_paid_subscription + WHERE subscriptionID <> ?"; + $statement = WCF::getDB()->prepare($sql); + $statement->execute([$excludedSubscriptionID]); + + return (int)$statement->fetchSingleColumn(); + } + + private function updateShowOrder(int $subscriptionID, int $showOrder): void + { + $sql = "UPDATE wcf1_paid_subscription + SET showOrder = ? + WHERE subscriptionID = ?"; + $statement = WCF::getDB()->prepare($sql); + $statement->execute([ + $showOrder, + $subscriptionID, + ]); + } + + /** + * Moves the other subscriptions at or after the given position by `$offset`. + */ + private function shiftShowOrder(int $excludedSubscriptionID, int $fromShowOrder, int $offset): void + { + $sql = "UPDATE wcf1_paid_subscription + SET showOrder = showOrder + ? + WHERE showOrder >= ? + AND subscriptionID <> ?"; + $statement = WCF::getDB()->prepare($sql); + $statement->execute([ + $offset, + $fromShowOrder, + $excludedSubscriptionID, + ]); + } + + private function saveL10nValues(PaidSubscription $subscription): void + { + $description = $this->description ?? null; + if ($description === null) { + $description = $this->isUpdate() + ? $this->getObject()->getL10nValues('description') + : [L10nStorage::MONOLINGUAL => '']; + } + + // `L10nStorage::setValues()` replaces all rows of the subscription. + (new L10nStorage(PaidSubscription::getL10nDefinition()))->setValues( + $subscription->subscriptionID, + [ + 'title' => $this->title ?? $this->getObject()->getL10nValues('title'), + 'description' => $description, + ] + ); + } +} diff --git a/wcfsetup/install/files/lib/data/paid/subscription/PaidSubscriptionCollection.class.php b/wcfsetup/install/files/lib/data/paid/subscription/PaidSubscriptionCollection.class.php new file mode 100644 index 00000000000..ffecc42f64a --- /dev/null +++ b/wcfsetup/install/files/lib/data/paid/subscription/PaidSubscriptionCollection.class.php @@ -0,0 +1,28 @@ + + * @since 6.3 + * + * @extends DatabaseObjectCollection + */ +class PaidSubscriptionCollection extends DatabaseObjectCollection +{ + use TCollectionL10n; + + #[\Override] + protected function getL10nDefinition(): L10nDefinition + { + return PaidSubscription::getL10nDefinition(); + } +} diff --git a/wcfsetup/install/files/lib/data/paid/subscription/PaidSubscriptionEditor.class.php b/wcfsetup/install/files/lib/data/paid/subscription/PaidSubscriptionEditor.class.php index 978ad5b8be1..061199f7ab5 100644 --- a/wcfsetup/install/files/lib/data/paid/subscription/PaidSubscriptionEditor.class.php +++ b/wcfsetup/install/files/lib/data/paid/subscription/PaidSubscriptionEditor.class.php @@ -17,6 +17,7 @@ * @mixin PaidSubscription * @extends DatabaseObjectEditor * @implements IEditableCachedObject + * @deprecated 6.3 use `PaidSubscriptionBuilder` instead. */ class PaidSubscriptionEditor extends DatabaseObjectEditor implements IEditableCachedObject { @@ -29,6 +30,7 @@ class PaidSubscriptionEditor extends DatabaseObjectEditor implements IEditableCa * Sets the show order of the subscription. * * @return void + * @deprecated 6.3 use `PaidSubscriptionBuilder::setShowOrder()` instead. */ public function setShowOrder(int $showOrder = 0) { diff --git a/wcfsetup/install/files/lib/event/paid/subscription/PaidSubscriptionCreated.class.php b/wcfsetup/install/files/lib/event/paid/subscription/PaidSubscriptionCreated.class.php new file mode 100644 index 00000000000..b184ebf6c44 --- /dev/null +++ b/wcfsetup/install/files/lib/event/paid/subscription/PaidSubscriptionCreated.class.php @@ -0,0 +1,23 @@ + + * @since 6.3 + */ +final class PaidSubscriptionCreated implements IPsr14Event +{ + public function __construct( + public readonly PaidSubscription $subscription, + public readonly PaidSubscriptionBuilder $builder, + ) {} +} diff --git a/wcfsetup/install/files/lib/event/paid/subscription/PaidSubscriptionDeleted.class.php b/wcfsetup/install/files/lib/event/paid/subscription/PaidSubscriptionDeleted.class.php new file mode 100644 index 00000000000..6df12e22f06 --- /dev/null +++ b/wcfsetup/install/files/lib/event/paid/subscription/PaidSubscriptionDeleted.class.php @@ -0,0 +1,21 @@ + + * @since 6.3 + */ +final class PaidSubscriptionDeleted implements IPsr14Event +{ + public function __construct( + public readonly PaidSubscription $subscription, + ) {} +} diff --git a/wcfsetup/install/files/lib/event/paid/subscription/PaidSubscriptionUpdated.class.php b/wcfsetup/install/files/lib/event/paid/subscription/PaidSubscriptionUpdated.class.php new file mode 100644 index 00000000000..7fa27264500 --- /dev/null +++ b/wcfsetup/install/files/lib/event/paid/subscription/PaidSubscriptionUpdated.class.php @@ -0,0 +1,23 @@ + + * @since 6.3 + */ +final class PaidSubscriptionUpdated implements IPsr14Event +{ + public function __construct( + public readonly PaidSubscription $subscription, + public readonly PaidSubscriptionBuilder $builder, + ) {} +} diff --git a/wcfsetup/install/files/lib/system/cache/builder/PaidSubscriptionCacheBuilder.class.php b/wcfsetup/install/files/lib/system/cache/builder/PaidSubscriptionCacheBuilder.class.php index 715a9f7215c..02a5c74b28d 100644 --- a/wcfsetup/install/files/lib/system/cache/builder/PaidSubscriptionCacheBuilder.class.php +++ b/wcfsetup/install/files/lib/system/cache/builder/PaidSubscriptionCacheBuilder.class.php @@ -20,7 +20,15 @@ protected function rebuild(array $parameters) $subscriptionList->getConditionBuilder()->add('isDisabled = ?', [0]); $subscriptionList->sqlOrderBy = 'showOrder'; $subscriptionList->readObjects(); + $subscriptions = $subscriptionList->getObjects(); - return $subscriptionList->getObjects(); + // The subscriptions share one collection, loading the titles and + // descriptions once stores them in the cache instead of querying them + // on every request. + if ($subscriptions !== []) { + \reset($subscriptions)->getL10nValues('title'); + } + + return $subscriptions; } } diff --git a/wcfsetup/install/files/lib/system/endpoint/controller/core/paidSubscriptions/DeleteSubscription.class.php b/wcfsetup/install/files/lib/system/endpoint/controller/core/paidSubscriptions/DeleteSubscription.class.php index 5c75f00e532..a97952ff291 100644 --- a/wcfsetup/install/files/lib/system/endpoint/controller/core/paidSubscriptions/DeleteSubscription.class.php +++ b/wcfsetup/install/files/lib/system/endpoint/controller/core/paidSubscriptions/DeleteSubscription.class.php @@ -5,8 +5,8 @@ use Laminas\Diactoros\Response\JsonResponse; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; +use wcf\command\paid\subscription\DeletePaidSubscription; use wcf\data\paid\subscription\PaidSubscription; -use wcf\data\paid\subscription\PaidSubscriptionAction; use wcf\http\Helper; use wcf\system\endpoint\DeleteRequest; use wcf\system\endpoint\IController; @@ -30,7 +30,7 @@ public function __invoke(ServerRequestInterface $request, array $variables): Res $this->assertSubscriptionCanBeDeleted(); - (new PaidSubscriptionAction([$subscription], 'delete'))->executeAction(); + (new DeletePaidSubscription($subscription))(); return new JsonResponse([]); } diff --git a/wcfsetup/install/files/lib/system/gridView/admin/PaidSubscriptionGridView.class.php b/wcfsetup/install/files/lib/system/gridView/admin/PaidSubscriptionGridView.class.php index 89d58d7ab90..5161da5131a 100644 --- a/wcfsetup/install/files/lib/system/gridView/admin/PaidSubscriptionGridView.class.php +++ b/wcfsetup/install/files/lib/system/gridView/admin/PaidSubscriptionGridView.class.php @@ -4,7 +4,7 @@ use wcf\acp\form\PaidSubscriptionEditForm; use wcf\data\DatabaseObject; -use wcf\data\paid\subscription\I18nPaidSubscriptionList; +use wcf\data\paid\subscription\L10nPaidSubscriptionList; use wcf\data\paid\subscription\PaidSubscription; use wcf\event\gridView\admin\PaidSubscriptionGridViewInitialized; use wcf\system\gridView\AbstractGridView; @@ -12,15 +12,14 @@ use wcf\system\gridView\GridViewRowLink; use wcf\system\gridView\renderer\NumberColumnRenderer; use wcf\system\gridView\renderer\ObjectIdColumnRenderer; -use wcf\system\gridView\renderer\PhraseColumnRenderer; use wcf\system\interaction\admin\PaidSubscriptionInteractions; use wcf\system\interaction\Divider; use wcf\system\interaction\EditInteraction; use wcf\system\interaction\ToggleInteraction; use wcf\system\payment\method\PaymentMethodHandler; use wcf\system\view\filter\FloatFilter; -use wcf\system\view\filter\I18nTextFilter; use wcf\system\view\filter\IntegerFilter; +use wcf\system\view\filter\L10nTextFilter; use wcf\system\view\filter\SelectFilter; use wcf\system\WCF; @@ -32,7 +31,7 @@ * @license GNU Lesser General Public License * @since 6.2 * - * @extends AbstractGridView + * @extends AbstractGridView */ final class PaidSubscriptionGridView extends AbstractGridView { @@ -46,9 +45,13 @@ public function __construct() GridViewColumn::for('title') ->label('wcf.global.title') ->titleColumn() - ->renderer(new PhraseColumnRenderer()) - ->filter(I18nTextFilter::class) - ->sortable(sortByDatabaseColumn: 'titleI18n'), + ->filter(new L10nTextFilter( + PaidSubscription::getL10nDefinition(), + 'title', + 'title', + 'wcf.global.title', + )) + ->sortable(sortByDatabaseColumn: 'title'), GridViewColumn::for('cost') ->label('wcf.acp.paidSubscription.cost') ->sortable() @@ -97,7 +100,12 @@ public function render(mixed $value, DatabaseObject $row): string ]); $this->addAvailableFilters([ - new I18nTextFilter('description', 'wcf.global.description'), + new L10nTextFilter( + PaidSubscription::getL10nDefinition(), + 'description', + 'description', + 'wcf.global.description', + ), new SelectFilter($this->getAvailableCurrencies(), 'currency', 'wcf.acp.paidSubscription.currency'), ]); $provider = new PaidSubscriptionInteractions(); @@ -145,9 +153,9 @@ public function isAccessible(): bool } #[\Override] - protected function createObjectList(): I18nPaidSubscriptionList + protected function createObjectList(): L10nPaidSubscriptionList { - return new I18nPaidSubscriptionList(); + return new L10nPaidSubscriptionList(); } #[\Override] diff --git a/wcfsetup/install/files/lib/system/gridView/admin/PaidSubscriptionUserGridView.class.php b/wcfsetup/install/files/lib/system/gridView/admin/PaidSubscriptionUserGridView.class.php index 7e3166fba41..3d10a8bc6c2 100644 --- a/wcfsetup/install/files/lib/system/gridView/admin/PaidSubscriptionUserGridView.class.php +++ b/wcfsetup/install/files/lib/system/gridView/admin/PaidSubscriptionUserGridView.class.php @@ -12,12 +12,12 @@ use wcf\system\gridView\AbstractGridView; use wcf\system\gridView\GridViewColumn; use wcf\system\gridView\renderer\ObjectIdColumnRenderer; -use wcf\system\gridView\renderer\PhraseColumnRenderer; use wcf\system\gridView\renderer\TimeColumnRenderer; use wcf\system\gridView\renderer\UserLinkColumnRenderer; use wcf\system\interaction\admin\PaidSubscriptionUserInteractions; use wcf\system\interaction\Divider; use wcf\system\interaction\EditInteraction; +use wcf\system\l10n\L10nStorage; use wcf\system\view\filter\SelectFilter; use wcf\system\view\filter\TimeFilter; use wcf\system\view\filter\UserFilter; @@ -56,8 +56,7 @@ public function __construct() 'wcf.acp.paidSubscription.subscription', 'paid_subscription_user.subscriptionID' )) - ->renderer(new PhraseColumnRenderer()) - ->sortable(sortByDatabaseColumn: "paid_subscription.title"), + ->sortable(sortByDatabaseColumn: 'title'), GridViewColumn::for('endDate') ->label('wcf.acp.paidSubscription.user.endDate') ->renderer(new TimeColumnRenderer()) @@ -102,7 +101,8 @@ public function isAccessible(): bool protected function createObjectList(): PaidSubscriptionUserList { $list = new PaidSubscriptionUserList(); - $list->sqlSelects = "paid_subscription.title"; + $list->sqlSelects = (new L10nStorage(PaidSubscription::getL10nDefinition())) + ->getSubSelect('title', 'paid_subscription') . ' AS title'; $list->sqlJoins = " LEFT JOIN wcf1_user user_table ON user_table.userID = paid_subscription_user.userID diff --git a/wcfsetup/install/files/lib/system/user/notification/event/ExpiringPaidSubscriptionUserUserNotificationEvent.class.php b/wcfsetup/install/files/lib/system/user/notification/event/ExpiringPaidSubscriptionUserUserNotificationEvent.class.php index 985a06f3d14..ebbff91f756 100644 --- a/wcfsetup/install/files/lib/system/user/notification/event/ExpiringPaidSubscriptionUserUserNotificationEvent.class.php +++ b/wcfsetup/install/files/lib/system/user/notification/event/ExpiringPaidSubscriptionUserUserNotificationEvent.class.php @@ -2,13 +2,14 @@ namespace wcf\system\user\notification\event; -use wcf\data\paid\subscription\PaidSubscription; -use wcf\data\paid\subscription\PaidSubscriptionAction; +use wcf\command\paid\subscription\CreatePaidSubscription; +use wcf\data\paid\subscription\PaidSubscriptionBuilder; use wcf\data\paid\subscription\user\PaidSubscriptionUser; use wcf\data\paid\subscription\user\PaidSubscriptionUserAction; use wcf\data\paid\subscription\user\PaidSubscriptionUserList; use wcf\data\user\UserProfile; use wcf\page\PaidSubscriptionListPage; +use wcf\system\l10n\L10nStorage; use wcf\system\request\LinkHandler; use wcf\system\user\notification\object\PaidSubscriptionUserUserNotificationObject; use wcf\system\WCF; @@ -81,13 +82,11 @@ public function isVisible() #[\Override] public static function getTestObjects(UserProfile $recipient, UserProfile $author) { - /** @var PaidSubscription $paidSubscription */ - $paidSubscription = (new PaidSubscriptionAction([], 'create', [ - 'data' => [ - 'groupIDs' => '', - 'title' => 'Test Subscription', - ], - ]))->executeAction()['returnValues']; + $paidSubscription = (new CreatePaidSubscription( + PaidSubscriptionBuilder::forCreate() + ->setTitle([L10nStorage::MONOLINGUAL => 'Test Subscription']) + ->setGroupIDs([]) + ))(); /** @var PaidSubscriptionUser $paidSubscriptionUser */ $paidSubscriptionUser = (new PaidSubscriptionUserAction([], 'create', [ diff --git a/wcfsetup/setup/db/install_com.woltlab.wcf.php b/wcfsetup/setup/db/install_com.woltlab.wcf.php index ada8df6a05a..abda3a6a15c 100644 --- a/wcfsetup/setup/db/install_com.woltlab.wcf.php +++ b/wcfsetup/setup/db/install_com.woltlab.wcf.php @@ -2972,9 +2972,6 @@ DatabaseTable::create('wcf1_paid_subscription') ->columns([ ObjectIdDatabaseTableColumn::create('subscriptionID'), - NotNullVarchar255DatabaseTableColumn::create('title') - ->defaultValue(''), - TextDatabaseTableColumn::create('description'), DefaultFalseBooleanDatabaseTableColumn::create('isDisabled'), NotNullInt10DatabaseTableColumn::create('showOrder') ->defaultValue(0), @@ -3007,6 +3004,32 @@ DatabaseTablePrimaryIndex::create() ->columns(['subscriptionID']), ]), + DatabaseTable::create('wcf1_paid_subscription_l10n') + ->columns([ + NotNullInt10DatabaseTableColumn::create('subscriptionID'), + IntDatabaseTableColumn::create('languageID'), + VarcharDatabaseTableColumn::create('title') + ->length(255), + TextDatabaseTableColumn::create('description'), + ]) + ->indices([ + DatabaseTableIndex::create('subscriptionID') + ->columns(['subscriptionID', 'languageID']), + ]) + ->foreignKeys([ + DatabaseTableForeignKey::create() + ->columns(['subscriptionID']) + ->referencedTable('wcf1_paid_subscription') + ->referencedColumns(['subscriptionID']) + ->onDelete('CASCADE') + ->onUpdate('NO ACTION'), + DatabaseTableForeignKey::create() + ->columns(['languageID']) + ->referencedTable('wcf1_language') + ->referencedColumns(['languageID']) + ->onDelete('CASCADE') + ->onUpdate('NO ACTION'), + ]), DatabaseTable::create('wcf1_paid_subscription_transaction_log') ->columns([ ObjectIdDatabaseTableColumn::create('logID'), From fdbcbebdebc1592d254d34771bd71bef700ffe7b Mon Sep 17 00:00:00 2001 From: Alexander Ebert Date: Thu, 1 Oct 2026 12:05:45 +0200 Subject: [PATCH 30/37] Render user option titles and descriptions from l10n storage in shared option templates --- com.woltlab.wcf/templates/messageSidebar.tpl | 2 +- com.woltlab.wcf/templates/optionFieldList.tpl | 4 ++-- .../install/files/acp/templates/optionFieldList.tpl | 4 ++-- .../files/acp/templates/useroptionsOptionType.tpl | 4 ++-- .../install/files/lib/data/user/UserProfile.class.php | 10 ++++++++++ .../files/lib/system/option/OptionHandler.class.php | 2 ++ .../lib/system/option/UseroptionsOptionType.class.php | 10 ++++++++++ .../lib/system/option/user/UserOptionHandler.class.php | 5 +++++ 8 files changed, 34 insertions(+), 7 deletions(-) diff --git a/com.woltlab.wcf/templates/messageSidebar.tpl b/com.woltlab.wcf/templates/messageSidebar.tpl index b1b73d95f05..1933caca995 100644 --- a/com.woltlab.wcf/templates/messageSidebar.tpl +++ b/com.woltlab.wcf/templates/messageSidebar.tpl @@ -117,7 +117,7 @@ {if $userProfile->getUserOption($__sidebarUserOption)} {assign var='__formattedUserOption' value=$userProfile->getFormattedUserOption($__sidebarUserOption)} {if $__formattedUserOption} -
{lang}wcf.user.option.{$__sidebarUserOption}{/lang}
+
{$userProfile->getUserOptionTitle($__sidebarUserOption)}
{unsafe:$__formattedUserOption}
{/if} {/if} diff --git a/com.woltlab.wcf/templates/optionFieldList.tpl b/com.woltlab.wcf/templates/optionFieldList.tpl index 0466f05027d..076c4e7602d 100644 --- a/com.woltlab.wcf/templates/optionFieldList.tpl +++ b/com.woltlab.wcf/templates/optionFieldList.tpl @@ -15,7 +15,7 @@ {icon name='bolt'} {/if} - {lang}{$langPrefix}{$option->optionName}{/lang} + {if $optionData[title]|isset}{$optionData[title]}{else}{lang}{$langPrefix}{$option->optionName}{/lang}{/if} {/if} @@ -29,7 +29,7 @@ {/if}
{/if} - {lang __optional=true}{$langPrefix}{$option->optionName}.description{/lang} + {if $optionData[description]|isset}{$optionData[description]}{else}{lang __optional=true}{$langPrefix}{$option->optionName}.description{/lang}{/if} {/foreach} diff --git a/wcfsetup/install/files/acp/templates/optionFieldList.tpl b/wcfsetup/install/files/acp/templates/optionFieldList.tpl index 6a6807b1dd2..209958c3397 100644 --- a/wcfsetup/install/files/acp/templates/optionFieldList.tpl +++ b/wcfsetup/install/files/acp/templates/optionFieldList.tpl @@ -21,7 +21,7 @@ {icon name='shield-halved'} {/if} - {lang}{$langPrefix}{$option->optionName}{/lang} + {if $optionData[title]|isset}{$optionData[title]}{else}{lang}{$langPrefix}{$option->optionName}{/lang}{/if} {event name='afterOptionName'} @@ -37,7 +37,7 @@ {/if} {/if} - {lang __optional=true}{$langPrefix}{$option->optionName}.description{/lang} + {if $optionData[description]|isset}{$optionData[description]}{else}{lang __optional=true}{$langPrefix}{$option->optionName}.description{/lang}{/if} {/foreach} diff --git a/wcfsetup/install/files/acp/templates/useroptionsOptionType.tpl b/wcfsetup/install/files/acp/templates/useroptionsOptionType.tpl index 32616118450..f478c6ff844 100644 --- a/wcfsetup/install/files/acp/templates/useroptionsOptionType.tpl +++ b/wcfsetup/install/files/acp/templates/useroptionsOptionType.tpl @@ -31,7 +31,7 @@ @@ -40,6 +40,6 @@ {else} {foreach from=$availableOptions item=availableOption} - + {/foreach} {/if} diff --git a/wcfsetup/install/files/lib/data/user/UserProfile.class.php b/wcfsetup/install/files/lib/data/user/UserProfile.class.php index 7a95deb14ad..142e68e7919 100644 --- a/wcfsetup/install/files/lib/data/user/UserProfile.class.php +++ b/wcfsetup/install/files/lib/data/user/UserProfile.class.php @@ -1118,6 +1118,16 @@ public function getFormattedUserOption(string $name) return $option->optionValue; } + /** + * Returns the localized title of the user option with the given name. + * + * @since 6.3 + */ + public function getUserOptionTitle(string $name): string + { + return ViewableUserOption::getUserOption($name)->getTitle(); + } + /** * Returns true, if the active user has access to the user option with the given name. * diff --git a/wcfsetup/install/files/lib/system/option/OptionHandler.class.php b/wcfsetup/install/files/lib/system/option/OptionHandler.class.php index a338f350731..4feb77716ae 100644 --- a/wcfsetup/install/files/lib/system/option/OptionHandler.class.php +++ b/wcfsetup/install/files/lib/system/option/OptionHandler.class.php @@ -30,6 +30,8 @@ * html: string, * cssClassName: string, * hideLabelInSearch: bool, + * title?: string, + * description?: string, * }|null */ class OptionHandler implements IOptionHandler diff --git a/wcfsetup/install/files/lib/system/option/UseroptionsOptionType.class.php b/wcfsetup/install/files/lib/system/option/UseroptionsOptionType.class.php index 8cf143e9d21..27f2654db2c 100644 --- a/wcfsetup/install/files/lib/system/option/UseroptionsOptionType.class.php +++ b/wcfsetup/install/files/lib/system/option/UseroptionsOptionType.class.php @@ -3,6 +3,8 @@ namespace wcf\system\option; use wcf\data\option\Option; +use wcf\data\user\option\UserOption; +use wcf\system\cache\builder\UserOptionCacheBuilder; use wcf\system\exception\UserInputException; use wcf\system\WCF; @@ -59,10 +61,18 @@ public function getFormElement(Option $option, mixed $value) $userOptions = \array_merge($sortedOptions, \array_diff($userOptions, $sortedOptions)); } + $options = UserOptionCacheBuilder::getInstance()->getData([], 'options'); + $optionTitles = []; + foreach ($userOptions as $optionName) { + \assert($options[$optionName] instanceof UserOption); + $optionTitles[$optionName] = $options[$optionName]->getTitle(); + } + return WCF::getTPL()->render('wcf', 'useroptionsOptionType', [ 'option' => $option, 'value' => \explode(',', $value), 'availableOptions' => $userOptions, + 'optionTitles' => $optionTitles, ]); } diff --git a/wcfsetup/install/files/lib/system/option/user/UserOptionHandler.class.php b/wcfsetup/install/files/lib/system/option/user/UserOptionHandler.class.php index e14dcec05c1..d43d439fb14 100644 --- a/wcfsetup/install/files/lib/system/option/user/UserOptionHandler.class.php +++ b/wcfsetup/install/files/lib/system/option/user/UserOptionHandler.class.php @@ -205,6 +205,11 @@ public function getOption(string $optionName) { $optionData = parent::getOption($optionName); + // The shared `optionFieldList` template cannot derive the localized + // title and description of user options from a language item. + $optionData['title'] = $optionData['object']->getTitle(); + $optionData['description'] = $optionData['object']->getDescription(); + if (!$this->editMode && !$this->searchMode) { /** @var UserOption $option */ $option = $optionData['object']; From 5fab0bace33016725289bedde99cdc6de0a1748a Mon Sep 17 00:00:00 2001 From: Alexander Ebert Date: Thu, 1 Oct 2026 12:12:26 +0200 Subject: [PATCH 31/37] Prefer administrator-customized phrase values when syncing l10n storage --- .../files/lib/system/l10n/L10nLanguageItemSync.class.php | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/wcfsetup/install/files/lib/system/l10n/L10nLanguageItemSync.class.php b/wcfsetup/install/files/lib/system/l10n/L10nLanguageItemSync.class.php index 9e950cacb4d..733494bde5b 100644 --- a/wcfsetup/install/files/lib/system/l10n/L10nLanguageItemSync.class.php +++ b/wcfsetup/install/files/lib/system/l10n/L10nLanguageItemSync.class.php @@ -251,8 +251,14 @@ private static function resolve(?array $items, ?string $literal, int $languageID */ private static function createItemReader(): \Closure { + // Mirrors `LanguageEditor::writeLanguageFiles()`, a custom value set by + // an administrator takes precedence over the delivered value. $statement = WCF::getDB()->prepare( - "SELECT languageID, languageItemValue + "SELECT CASE + WHEN languageUseCustomValue = 1 THEN languageCustomItemValue + ELSE languageItemValue + END AS languageItemValue, + languageID FROM wcf1_language_item WHERE languageItem = ?" ); From 8e3510fd32f77b7126d0997086ef40f9b47147b6 Mon Sep 17 00:00:00 2001 From: Alexander Ebert Date: Thu, 1 Oct 2026 12:21:39 +0200 Subject: [PATCH 32/37] Merge user option and captcha question l10n schema changes into update steps 1 and 3 --- com.woltlab.wcf/package.xml | 2 + ...te_com.woltlab.wcf_6.3_captchaQuestion.php | 21 ------- .../update_com.woltlab.wcf_6.3_step1.php | 31 ++++++++++ .../update_com.woltlab.wcf_6.3_step3.php | 8 +++ .../update_com.woltlab.wcf_6.3_userOption.php | 57 ------------------- ...om.woltlab.wcf_6.3_captchaQuestionL10n.php | 2 +- ...ate_com.woltlab.wcf_6.3_userOptionL10n.php | 2 +- 7 files changed, 43 insertions(+), 80 deletions(-) delete mode 100644 wcfsetup/install/files/acp/database/update_com.woltlab.wcf_6.3_captchaQuestion.php delete mode 100644 wcfsetup/install/files/acp/database/update_com.woltlab.wcf_6.3_userOption.php diff --git a/com.woltlab.wcf/package.xml b/com.woltlab.wcf/package.xml index c83aa75ccb3..7e7e5e9fe4a 100644 --- a/com.woltlab.wcf/package.xml +++ b/com.woltlab.wcf/package.xml @@ -85,6 +85,8 @@ acp/update_com.woltlab.wcf_6.3_labelGroupL10n.php acp/update_com.woltlab.wcf_6.3_contactRecipientL10n.php acp/update_com.woltlab.wcf_6.3_paidSubscriptionL10n.php + acp/update_com.woltlab.wcf_6.3_userOptionL10n.php + acp/update_com.woltlab.wcf_6.3_captchaQuestionL10n.php acp/database/update_com.woltlab.wcf_6.3_step3.php The following step requires the new files and must run before the `cronjob` PIP, which precedes the `objectType` PIP: 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 deleted file mode 100644 index 2c5ceb3880b..00000000000 --- a/wcfsetup/install/files/acp/database/update_com.woltlab.wcf_6.3_captchaQuestion.php +++ /dev/null @@ -1,21 +0,0 @@ -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 cf16cb0cc28..f5df28e5fbe 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 @@ -66,6 +66,37 @@ PartialDatabaseTable::create('wcf1_user_option') ->columns([ DefaultFalseBooleanDatabaseTableColumn::create('showOnUserCard'), + 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'), ]), PartialDatabaseTable::create('wcf1_smiley') ->columns([ diff --git a/wcfsetup/install/files/acp/database/update_com.woltlab.wcf_6.3_step3.php b/wcfsetup/install/files/acp/database/update_com.woltlab.wcf_6.3_step3.php index 4018195b60a..57ba9886576 100644 --- a/wcfsetup/install/files/acp/database/update_com.woltlab.wcf_6.3_step3.php +++ b/wcfsetup/install/files/acp/database/update_com.woltlab.wcf_6.3_step3.php @@ -8,6 +8,7 @@ * @license GNU Lesser General Public License */ +use wcf\system\database\table\column\MediumtextDatabaseTableColumn; use wcf\system\database\table\column\NotNullVarchar255DatabaseTableColumn; use wcf\system\database\table\column\TextDatabaseTableColumn; use wcf\system\database\table\column\VarcharDatabaseTableColumn; @@ -59,4 +60,11 @@ TextDatabaseTableColumn::create('description') ->drop(), ]), + PartialDatabaseTable::create('wcf1_captcha_question') + ->columns([ + NotNullVarchar255DatabaseTableColumn::create('question') + ->drop(), + MediumtextDatabaseTableColumn::create('answers') + ->drop(), + ]), ]; 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 deleted file mode 100644 index 4f59eea6b70..00000000000 --- a/wcfsetup/install/files/acp/database/update_com.woltlab.wcf_6.3_userOption.php +++ /dev/null @@ -1,57 +0,0 @@ -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 8f4e842459f..5187b9f241e 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 @@ -8,7 +8,7 @@ * IMPORTANT ordering constraints for package.xml: * - The database script `acp/database/update_com.woltlab.wcf_6.3_step1.php` * (creating the `wcf1_captcha_question_l10n` table) must run BEFORE this script. - * - The database script `acp/database/update_com.woltlab.wcf_6.3_captchaQuestion.php` + * - The database script `acp/database/update_com.woltlab.wcf_6.3_step3.php` * (dropping the migrated columns) must run AFTER this script. */ 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 index 7816e70bf12..ce9306e4712 100644 --- 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 @@ -12,7 +12,7 @@ * 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 + * `acp/database/update_com.woltlab.wcf_6.3_step1.php` (adding the * `l10nIdentifier` column and creating the `wcf1_user_option_l10n` table) must * run BEFORE this script. */ From b14c96ffbe734f664dcef4a57153abfb722b190b Mon Sep 17 00:00:00 2001 From: Alexander Ebert Date: Thu, 1 Oct 2026 12:30:33 +0200 Subject: [PATCH 33/37] Keep pristine per-language l10n rows when a single-language form resubmits them unchanged --- .../lib/system/l10n/L10nStorage.class.php | 61 ++++++++++++++++++- 1 file changed, 60 insertions(+), 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 ebb371a5b9b..b543cb6f866 100644 --- a/wcfsetup/install/files/lib/system/l10n/L10nStorage.class.php +++ b/wcfsetup/install/files/lib/system/l10n/L10nStorage.class.php @@ -5,6 +5,7 @@ use wcf\system\database\util\PreparedStatementConditionBuilder; use wcf\system\language\LanguageFactory; use wcf\system\WCF; +use wcf\util\StringUtil; /** * Reads and writes the localized values of a content type. @@ -111,6 +112,11 @@ public function setValues(int $objectID, array $values): void $previousValues = $this->getValues($objectID); $previousPristineLanguageIDs = $this->getPristineLanguageIDs($objectID); $isDeliveredObject = $this->getIdentifier($objectID) !== null; + + if ($isDeliveredObject) { + $values = $this->retainLanguageSpecificValues($values, $previousValues); + $languageIDs = $this->validateValues($values); + } } $columnList = \implode(', ', $this->definition->columnNames); @@ -185,7 +191,8 @@ private function getPristineFlag( } foreach ($this->definition->columnNames as $columnName) { - if (($previousValues[$columnName][$languageID] ?? null) !== ($values[$columnName][$languageID] ?? null)) { + $previousValue = self::normalizeValue($previousValues[$columnName][$languageID] ?? null); + if ($previousValue !== self::normalizeValue($values[$columnName][$languageID] ?? null)) { return 0; } } @@ -193,6 +200,58 @@ private function getPristineFlag( return 1; } + /** + * Keeps the per-language values of a column when a monolingual value only + * restates them. Forms submit a monolingual value whenever a single + * language is installed, which would otherwise replace the pristine rows + * of a delivered object and stop their synchronization for good. + * + * @param array $values + * @param array $previousValues + * @return array + */ + private function retainLanguageSpecificValues(array $values, array $previousValues): array + { + foreach ($this->definition->columnNames as $columnName) { + if (\array_keys($values[$columnName]) !== [self::MONOLINGUAL]) { + continue; + } + + // Languages that only exist because of another column are `NULL`. + $previousColumnValues = \array_filter( + $previousValues[$columnName] ?? [], + static fn(?string $value) => $value !== null + ); + if ($previousColumnValues === [] || isset($previousColumnValues[self::MONOLINGUAL])) { + continue; + } + + $value = self::normalizeValue($values[$columnName][self::MONOLINGUAL]); + foreach ($previousColumnValues as $previousValue) { + if (self::normalizeValue($previousValue) !== $value) { + continue 2; + } + } + + $values[$columnName] = $previousColumnValues; + } + + return $values; + } + + /** + * Applies the normalization of `I18nHandler::readValues()`, so that a value + * submitted unchanged through a form compares equal to the stored value. + */ + private static function normalizeValue(?string $value): ?string + { + if ($value === null) { + return null; + } + + return StringUtil::unifyNewlines(StringUtil::trim($value)); + } + /** * Synchronizes the given localized values into the table, only writing rows * that are still pristine (`isPristine = 1`) or missing. Rows an From 1d17654faeb27889d2c8eb8708e7ef05e3628a4b Mon Sep 17 00:00:00 2001 From: Alexander Ebert Date: Thu, 1 Oct 2026 12:36:40 +0200 Subject: [PATCH 34/37] Truncate language variable values to the l10n column length on migration and sync --- ...m.woltlab.wcf_6.3_contactRecipientL10n.php | 12 ---- ...ate_com.woltlab.wcf_6.3_labelGroupL10n.php | 11 ---- ...m.woltlab.wcf_6.3_paidSubscriptionL10n.php | 58 ++++++++----------- .../question/CaptchaQuestion.class.php | 1 + .../recipient/ContactRecipient.class.php | 1 + .../files/lib/data/cronjob/Cronjob.class.php | 1 + .../lib/data/label/group/LabelGroup.class.php | 1 + .../subscription/PaidSubscription.class.php | 1 + .../data/reaction/type/ReactionType.class.php | 1 + .../lib/data/user/option/UserOption.class.php | 1 + .../lib/data/user/rank/UserRank.class.php | 1 + .../lib/system/l10n/L10nDefinition.class.php | 24 ++++++++ .../l10n/L10nLanguageItemSync.class.php | 23 +++++--- ...CronjobPackageInstallationPlugin.class.php | 8 ++- 14 files changed, 78 insertions(+), 66 deletions(-) diff --git a/wcfsetup/install/files/acp/update_com.woltlab.wcf_6.3_contactRecipientL10n.php b/wcfsetup/install/files/acp/update_com.woltlab.wcf_6.3_contactRecipientL10n.php index 4c5b591610a..22380e78985 100644 --- a/wcfsetup/install/files/acp/update_com.woltlab.wcf_6.3_contactRecipientL10n.php +++ b/wcfsetup/install/files/acp/update_com.woltlab.wcf_6.3_contactRecipientL10n.php @@ -53,18 +53,6 @@ // This script owns the table's content at this point (idempotency on re-runs). WCF::getDB()->prepare("DELETE FROM wcf1_contact_recipient_l10n")->execute(); -// The length of multilingual names was never validated, and the language -// editor allows phrases of any length, which would exceed the l10n columns. The -// phrases of recipients created by an administrator are removed below anyway, -// shortening them is lossless for a retry. The kept phrase of the shipped -// recipient is shortened for good, it would otherwise break its synchronization. -$sql = "UPDATE wcf1_language_item - SET languageItemValue = SUBSTRING(languageItemValue, 1, 255) - WHERE languageItem REGEXP ? - AND CHAR_LENGTH(languageItemValue) > 255"; -$statement = WCF::getDB()->prepare($sql); -$statement->execute(['^wcf\.contact\.recipient\.(name|email)[0-9]+$']); - // Link the shipped administrator recipient to its language variable. A legacy // monolingual edit deleted the phrase and stored a literal name, such a // recipient owns its name and stays unlinked. diff --git a/wcfsetup/install/files/acp/update_com.woltlab.wcf_6.3_labelGroupL10n.php b/wcfsetup/install/files/acp/update_com.woltlab.wcf_6.3_labelGroupL10n.php index 0a1ea06c23e..227113f3df9 100644 --- a/wcfsetup/install/files/acp/update_com.woltlab.wcf_6.3_labelGroupL10n.php +++ b/wcfsetup/install/files/acp/update_com.woltlab.wcf_6.3_labelGroupL10n.php @@ -42,17 +42,6 @@ // This script owns the table's content at this point (idempotency on re-runs). WCF::getDB()->prepare("DELETE FROM wcf1_label_group_l10n")->execute(); -// Up to 6.1 the length of multilingual titles was not validated, and the -// language editor allows phrases of any length, which would exceed the l10n -// column. The phrases are removed below anyway, shortening them is lossless for -// a retry. -$sql = "UPDATE wcf1_language_item - SET languageItemValue = SUBSTRING(languageItemValue, 1, 80) - WHERE languageItem REGEXP ? - AND CHAR_LENGTH(languageItemValue) > 80"; -$statement = WCF::getDB()->prepare($sql); -$statement->execute(['^wcf\.acp\.label\.group[0-9]+$']); - // The phrases are removed only by the final `DELETE` below, so that a failure // before it leaves them in place for a retry. L10nLanguageItemSync::migrate( diff --git a/wcfsetup/install/files/acp/update_com.woltlab.wcf_6.3_paidSubscriptionL10n.php b/wcfsetup/install/files/acp/update_com.woltlab.wcf_6.3_paidSubscriptionL10n.php index 7b246f650e8..98442fda92f 100644 --- a/wcfsetup/install/files/acp/update_com.woltlab.wcf_6.3_paidSubscriptionL10n.php +++ b/wcfsetup/install/files/acp/update_com.woltlab.wcf_6.3_paidSubscriptionL10n.php @@ -40,48 +40,38 @@ WCF::getDB()->prepare("DELETE FROM wcf1_paid_subscription_l10n")->execute(); // The length of phrases was never validated, and the language editor allows -// phrases of any length, which would exceed the l10n columns. The phrases are -// removed below anyway, shortening them is lossless for a retry. A shortened -// HTML description is repaired by the input processor below. -$sql = "UPDATE wcf1_language_item - SET languageItemValue = SUBSTRING(languageItemValue, 1, 255) - WHERE languageItem REGEXP ? - AND CHAR_LENGTH(languageItemValue) > 255"; -$statement = WCF::getDB()->prepare($sql); -$statement->execute(['^wcf\.paidSubscription\.subscription[0-9]+$']); - -$sql = "UPDATE wcf1_language_item - SET languageItemValue = SUBSTRING(languageItemValue, 1, 16383) - WHERE languageItem REGEXP ? - AND LENGTH(languageItemValue) > 65535"; -$statement = WCF::getDB()->prepare($sql); -$statement->execute(['^wcf\.paidSubscription\.subscription[0-9]+\.description$']); +// phrases of any length. Titles are shortened by the migration, but the limit of +// the description is measured in bytes. The phrases are removed below anyway, +// shortening them is lossless for a retry. A shortened HTML description is +// repaired by the input processor below. +foreach (['languageItemValue', 'languageCustomItemValue'] as $valueColumn) { + $sql = "UPDATE wcf1_language_item + SET {$valueColumn} = SUBSTRING({$valueColumn}, 1, 16383) + WHERE languageItem REGEXP ? + AND LENGTH({$valueColumn}) > 65535"; + $statement = WCF::getDB()->prepare($sql); + $statement->execute(['^wcf\.paidSubscription\.subscription[0-9]+\.description$']); +} // Legacy `PaidSubscription::getTitle()` resolved any existing phrase, which // includes the phrases of other subscriptions and phrases named by other // packages. `getDescription()` only resolved the subscription phrase pattern. -// Phrases that are not removed below keep their value, a value that exceeds -// the l10n column would block the update, the phrase name is migrated instead. -$sql = "SELECT languageItem - FROM wcf1_language_item - WHERE languageItem IN ( - SELECT title - FROM wcf1_paid_subscription - ) - GROUP BY languageItem - HAVING MAX(CHAR_LENGTH(languageItemValue)) <= 255"; +$sql = "SELECT DISTINCT languageItem + FROM wcf1_language_item + WHERE languageItem IN ( + SELECT title + FROM wcf1_paid_subscription + )"; $statement = WCF::getDB()->prepare($sql); $statement->execute(); $existingTitleLanguageItems = $statement->fetchAll(\PDO::FETCH_COLUMN); -$sql = "SELECT languageItem - FROM wcf1_language_item - WHERE languageItem IN ( - SELECT description - FROM wcf1_paid_subscription - ) - GROUP BY languageItem - HAVING MAX(LENGTH(languageItemValue)) <= 65535"; +$sql = "SELECT DISTINCT languageItem + FROM wcf1_language_item + WHERE languageItem IN ( + SELECT description + FROM wcf1_paid_subscription + )"; $statement = WCF::getDB()->prepare($sql); $statement->execute(); $existingDescriptionLanguageItems = \array_filter( 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 38bb36f2a39..777c9d7a2af 100644 --- a/wcfsetup/install/files/lib/data/captcha/question/CaptchaQuestion.class.php +++ b/wcfsetup/install/files/lib/data/captcha/question/CaptchaQuestion.class.php @@ -101,6 +101,7 @@ public static function getL10nDefinition(): L10nDefinition 'wcf1_captcha_question_l10n', 'questionID', ['question', 'answers'], + maximumLengths: ['question' => 255], ); } } diff --git a/wcfsetup/install/files/lib/data/contact/recipient/ContactRecipient.class.php b/wcfsetup/install/files/lib/data/contact/recipient/ContactRecipient.class.php index b55121c5d5c..043b315a7fc 100644 --- a/wcfsetup/install/files/lib/data/contact/recipient/ContactRecipient.class.php +++ b/wcfsetup/install/files/lib/data/contact/recipient/ContactRecipient.class.php @@ -122,6 +122,7 @@ public static function getL10nDefinition(): L10nDefinition // No language variable exists for the email address, the // administrator's address is `MAIL_ADMIN_ADDRESS`. ['name' => '', 'email' => '.email'], + ['name' => 255, 'email' => 255], ); } } diff --git a/wcfsetup/install/files/lib/data/cronjob/Cronjob.class.php b/wcfsetup/install/files/lib/data/cronjob/Cronjob.class.php index 1f30076ded1..d2e47e0be06 100644 --- a/wcfsetup/install/files/lib/data/cronjob/Cronjob.class.php +++ b/wcfsetup/install/files/lib/data/cronjob/Cronjob.class.php @@ -180,6 +180,7 @@ public static function getL10nDefinition(): L10nDefinition 'wcf1_cronjob_l10n', 'cronjobID', ['description'], + maximumLengths: ['description' => 255], ); } } diff --git a/wcfsetup/install/files/lib/data/label/group/LabelGroup.class.php b/wcfsetup/install/files/lib/data/label/group/LabelGroup.class.php index 1a75f2941bc..95cf78ee9c8 100644 --- a/wcfsetup/install/files/lib/data/label/group/LabelGroup.class.php +++ b/wcfsetup/install/files/lib/data/label/group/LabelGroup.class.php @@ -104,6 +104,7 @@ public static function getL10nDefinition(): L10nDefinition 'wcf1_label_group_l10n', 'groupID', ['groupName'], + maximumLengths: ['groupName' => 80], ); } } diff --git a/wcfsetup/install/files/lib/data/paid/subscription/PaidSubscription.class.php b/wcfsetup/install/files/lib/data/paid/subscription/PaidSubscription.class.php index b1d6dab4069..01f8db52234 100644 --- a/wcfsetup/install/files/lib/data/paid/subscription/PaidSubscription.class.php +++ b/wcfsetup/install/files/lib/data/paid/subscription/PaidSubscription.class.php @@ -154,6 +154,7 @@ public static function getL10nDefinition(): L10nDefinition 'wcf1_paid_subscription_l10n', 'subscriptionID', ['title', 'description'], + maximumLengths: ['title' => 255], ); } diff --git a/wcfsetup/install/files/lib/data/reaction/type/ReactionType.class.php b/wcfsetup/install/files/lib/data/reaction/type/ReactionType.class.php index 879218d1fa0..98a2d8dd772 100644 --- a/wcfsetup/install/files/lib/data/reaction/type/ReactionType.class.php +++ b/wcfsetup/install/files/lib/data/reaction/type/ReactionType.class.php @@ -142,6 +142,7 @@ public static function getL10nDefinition(): L10nDefinition ['title'], 'l10nIdentifier', ['title' => ''], + ['title' => 255], ); } } 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 4d1e7220af4..c27d363806a 100644 --- a/wcfsetup/install/files/lib/data/user/option/UserOption.class.php +++ b/wcfsetup/install/files/lib/data/user/option/UserOption.class.php @@ -187,6 +187,7 @@ public static function getL10nDefinition(): L10nDefinition 'title' => '', 'description' => '.description', ], + ['title' => 255], ); } diff --git a/wcfsetup/install/files/lib/data/user/rank/UserRank.class.php b/wcfsetup/install/files/lib/data/user/rank/UserRank.class.php index 3b50c1232d6..32c21cb65be 100644 --- a/wcfsetup/install/files/lib/data/user/rank/UserRank.class.php +++ b/wcfsetup/install/files/lib/data/user/rank/UserRank.class.php @@ -145,6 +145,7 @@ public static function getL10nDefinition(): L10nDefinition ['rankTitle'], 'l10nIdentifier', ['rankTitle' => ''], + ['rankTitle' => 255], ); } } diff --git a/wcfsetup/install/files/lib/system/l10n/L10nDefinition.class.php b/wcfsetup/install/files/lib/system/l10n/L10nDefinition.class.php index 89c9eb2dc39..e47847586e2 100644 --- a/wcfsetup/install/files/lib/system/l10n/L10nDefinition.class.php +++ b/wcfsetup/install/files/lib/system/l10n/L10nDefinition.class.php @@ -32,6 +32,7 @@ final class L10nDefinition * @param list $columnNames names of the localized payload columns * @param ?string $identifierColumnName name of the primary table column holding the language variable base name, `null` if the definition does not support the synchronization with language variables * @param array $languageItemSuffixes maps each payload column to the suffix appended to the identifier to form its language variable name + * @param array $maximumLengths maps payload columns of a limited character length (`VARCHAR`) to that length */ public function __construct( public readonly string $primaryTableName, @@ -40,6 +41,7 @@ public function __construct( public readonly array $columnNames, public readonly ?string $identifierColumnName = null, public readonly array $languageItemSuffixes = [], + public readonly array $maximumLengths = [], ) { if (!\str_ends_with($l10nTableName, '_l10n')) { throw new \InvalidArgumentException( @@ -60,6 +62,28 @@ public function __construct( } } } + + foreach ($maximumLengths as $columnName => $maximumLength) { + if (!\in_array($columnName, $columnNames, true)) { + throw new \InvalidArgumentException( + "Cannot set the maximum length of the unknown localized column '{$columnName}'." + ); + } + } + } + + /** + * Shortens the given value to the maximum length of the given column. + * Values from language variables are not limited in length and must be + * passed through this method before they are written. + */ + public function truncateValue(string $columnName, string $value): string + { + if (!isset($this->maximumLengths[$columnName])) { + return $value; + } + + return \mb_substr($value, 0, $this->maximumLengths[$columnName]); } /** diff --git a/wcfsetup/install/files/lib/system/l10n/L10nLanguageItemSync.class.php b/wcfsetup/install/files/lib/system/l10n/L10nLanguageItemSync.class.php index 733494bde5b..eacec68176b 100644 --- a/wcfsetup/install/files/lib/system/l10n/L10nLanguageItemSync.class.php +++ b/wcfsetup/install/files/lib/system/l10n/L10nLanguageItemSync.class.php @@ -177,7 +177,8 @@ public static function sync(L10nDefinition $definition): void /** * Builds the `columnName => [languageID => value]` map from the resolved * language items of each column. Writes a single monolingual row when none - * of the columns is backed by a language variable. + * of the columns is backed by a language variable. Language variables are + * not limited in length, every value is shortened to its column's length. * * @param array $columnItems * @param \Closure(string): ?string $literalProvider @@ -201,7 +202,10 @@ private static function buildValues( if ($languageIDs === []) { foreach ($definition->columnNames as $columnName) { $values[$columnName] = [ - L10nStorage::MONOLINGUAL => $literalProvider($columnName) ?? '', + L10nStorage::MONOLINGUAL => $definition->truncateValue( + $columnName, + $literalProvider($columnName) ?? '' + ), ]; } @@ -210,12 +214,15 @@ private static function buildValues( foreach ($languageIDs as $languageID) { foreach ($definition->columnNames as $columnName) { - $values[$columnName][$languageID] = self::resolve( - $columnItems[$columnName], - $literalProvider($columnName), - $languageID, - $defaultLanguageID - ) ?? ''; + $values[$columnName][$languageID] = $definition->truncateValue( + $columnName, + self::resolve( + $columnItems[$columnName], + $literalProvider($columnName), + $languageID, + $defaultLanguageID + ) ?? '' + ); } } diff --git a/wcfsetup/install/files/lib/system/package/plugin/CronjobPackageInstallationPlugin.class.php b/wcfsetup/install/files/lib/system/package/plugin/CronjobPackageInstallationPlugin.class.php index 688b69e1a3c..3d05418e8fd 100644 --- a/wcfsetup/install/files/lib/system/package/plugin/CronjobPackageInstallationPlugin.class.php +++ b/wcfsetup/install/files/lib/system/package/plugin/CronjobPackageInstallationPlugin.class.php @@ -124,10 +124,16 @@ protected function import(array $row, array $data): Cronjob $builder = CronjobBuilder::forUpdate(new Cronjob(null, $row)); } + $definition = Cronjob::getL10nDefinition(); + $descriptions = \array_map( + static fn(string $description) => $definition->truncateValue('description', $description), + $this->getDescriptionValues($data['description'] ?? '') + ); + $builder ->setCronjobName($data['cronjobName']) ->setClassName($data['className']) - ->setDescription($this->getDescriptionValues($data['description'] ?? '')) + ->setDescription($descriptions) ->setStartMinute($data['startMinute']) ->setStartHour($data['startHour']) ->setStartDom($data['startDom']) From 1d1737ca9fdd82ec77c5143cb76ccd51116c1e4e Mon Sep 17 00:00:00 2001 From: Alexander Ebert Date: Thu, 1 Oct 2026 12:56:22 +0200 Subject: [PATCH 35/37] Drop NULL column values when reading l10n storage, fixing builder updates of mixed per-column language sets --- .../builder/field/TL10nFormField.class.php | 3 --- .../lib/system/l10n/L10nStorage.class.php | 20 ++++++------------- 2 files changed, 6 insertions(+), 17 deletions(-) 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 30fdb11e2f4..3c39d854ab5 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 @@ -158,9 +158,6 @@ 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 b543cb6f866..6f544c4e7a9 100644 --- a/wcfsetup/install/files/lib/system/l10n/L10nStorage.class.php +++ b/wcfsetup/install/files/lib/system/l10n/L10nStorage.class.php @@ -76,7 +76,10 @@ public function getValuesForObjects(array $objectIDs): array 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]; + // A row exists for every language of any column, absent values are `NULL`. + if ($row[$columnName] !== null) { + $values[$row[$objectColumnName]][$columnName][$languageID] = $row[$columnName]; + } } } @@ -217,11 +220,7 @@ private function retainLanguageSpecificValues(array $values, array $previousValu continue; } - // Languages that only exist because of another column are `NULL`. - $previousColumnValues = \array_filter( - $previousValues[$columnName] ?? [], - static fn(?string $value) => $value !== null - ); + $previousColumnValues = $previousValues[$columnName] ?? []; if ($previousColumnValues === [] || isset($previousColumnValues[self::MONOLINGUAL])) { continue; } @@ -347,7 +346,7 @@ public function syncValues(int $objectID, array $values): void * deterministic fallback chain: monolingual value, requested language, * default language, lowest language id. * - * @param array $values + * @param L10nValue $values */ public static function resolveValue(array $values, ?int $languageID = null): string { @@ -369,13 +368,6 @@ 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))]; } From 41c7c9e56330ff332c4fb93a64fcd267c596b81c Mon Sep 17 00:00:00 2001 From: Alexander Ebert Date: Thu, 1 Oct 2026 13:03:41 +0200 Subject: [PATCH 36/37] Sync l10n storage when a language is added or imported and flush caches carrying localized values --- .../lib/acp/form/LanguageAddForm.class.php | 12 ++++++++++ .../lib/acp/form/LanguageImportForm.class.php | 4 ++++ .../files/lib/bootstrap/com.woltlab.wcf.php | 3 +++ .../l10n/SyncL10nLanguageItems.class.php | 4 +++- .../data/language/LanguageAction.class.php | 4 ++++ .../l10n/L10nLanguageItemSync.class.php | 22 +++++++++++++++++-- 6 files changed, 46 insertions(+), 3 deletions(-) diff --git a/wcfsetup/install/files/lib/acp/form/LanguageAddForm.class.php b/wcfsetup/install/files/lib/acp/form/LanguageAddForm.class.php index e7eaf4d6a07..b9ed9bc00b5 100644 --- a/wcfsetup/install/files/lib/acp/form/LanguageAddForm.class.php +++ b/wcfsetup/install/files/lib/acp/form/LanguageAddForm.class.php @@ -5,6 +5,7 @@ use wcf\data\language\Language; use wcf\data\language\LanguageAction; use wcf\form\AbstractFormBuilderForm; +use wcf\system\cache\CacheHandler; use wcf\system\form\builder\container\FormContainer; use wcf\system\form\builder\data\processor\CustomFormDataProcessor; use wcf\system\form\builder\field\BooleanFormField; @@ -190,4 +191,15 @@ static function (IFormDocument $document, array $parameters) { } )); } + + #[\Override] + public function saved() + { + if ($this->formAction === 'create') { + // The localized values synchronized for the language are part of cached objects. + CacheHandler::getInstance()->flushAll(); + } + + parent::saved(); + } } diff --git a/wcfsetup/install/files/lib/acp/form/LanguageImportForm.class.php b/wcfsetup/install/files/lib/acp/form/LanguageImportForm.class.php index fdcebf6ab20..244aa3812bc 100644 --- a/wcfsetup/install/files/lib/acp/form/LanguageImportForm.class.php +++ b/wcfsetup/install/files/lib/acp/form/LanguageImportForm.class.php @@ -8,6 +8,7 @@ use wcf\data\package\PackageCache; use wcf\event\language\LanguageImported; use wcf\form\AbstractForm; +use wcf\system\cache\CacheHandler; use wcf\system\event\EventHandler; use wcf\system\exception\SystemException; use wcf\system\exception\UserInputException; @@ -148,6 +149,9 @@ public function save() EventHandler::getInstance()->fire(new LanguageImported(new Language($this->language->languageID))); + // The localized values synchronized for the language are part of cached objects. + CacheHandler::getInstance()->flushAll(); + $this->saved(); // reset fields diff --git a/wcfsetup/install/files/lib/bootstrap/com.woltlab.wcf.php b/wcfsetup/install/files/lib/bootstrap/com.woltlab.wcf.php index 22e0ab81fef..8fac497592e 100644 --- a/wcfsetup/install/files/lib/bootstrap/com.woltlab.wcf.php +++ b/wcfsetup/install/files/lib/bootstrap/com.woltlab.wcf.php @@ -83,6 +83,9 @@ static function (\wcf\event\l10n\L10nDefinitionCollecting $event) { \wcf\event\language\LanguageImported::class, static function (\wcf\event\language\LanguageImported $event) { new ResetPreloadCache($event->language)(); + + // A new language has no localized values yet. + (new \wcf\command\l10n\SyncL10nLanguageItems())(); } ); $eventHandler->register( diff --git a/wcfsetup/install/files/lib/command/l10n/SyncL10nLanguageItems.class.php b/wcfsetup/install/files/lib/command/l10n/SyncL10nLanguageItems.class.php index 839c37fbecf..70c3b522cbc 100644 --- a/wcfsetup/install/files/lib/command/l10n/SyncL10nLanguageItems.class.php +++ b/wcfsetup/install/files/lib/command/l10n/SyncL10nLanguageItems.class.php @@ -10,7 +10,9 @@ * Synchronizes the localized values of all registered l10n definitions with * their language variables. The definitions are collected via the * `L10nDefinitionCollecting` event and this command is invoked at the end of a - * package installation or update. + * package installation or update and whenever a language is added. Cached + * objects carry their localized values (see `TCollectionL10n`), callers must + * flush the cache afterwards. * * @author Marcel Werk * @copyright 2001-2026 WoltLab GmbH diff --git a/wcfsetup/install/files/lib/data/language/LanguageAction.class.php b/wcfsetup/install/files/lib/data/language/LanguageAction.class.php index 8627816f1d8..4be9df190e4 100644 --- a/wcfsetup/install/files/lib/data/language/LanguageAction.class.php +++ b/wcfsetup/install/files/lib/data/language/LanguageAction.class.php @@ -2,6 +2,7 @@ namespace wcf\data\language; +use wcf\command\l10n\SyncL10nLanguageItems; use wcf\command\language\DisableLanguage; use wcf\command\language\EnableLanguage; use wcf\data\AbstractDatabaseObjectAction; @@ -66,6 +67,9 @@ public function create() LanguageFactory::getInstance()->clearCache(); LanguageFactory::getInstance()->deleteLanguageCache(); + + // The copied phrases provide the localized values of the new language. + (new SyncL10nLanguageItems())(); } StyleHandler::resetStylesheets(); diff --git a/wcfsetup/install/files/lib/system/l10n/L10nLanguageItemSync.class.php b/wcfsetup/install/files/lib/system/l10n/L10nLanguageItemSync.class.php index eacec68176b..0a672dd1af8 100644 --- a/wcfsetup/install/files/lib/system/l10n/L10nLanguageItemSync.class.php +++ b/wcfsetup/install/files/lib/system/l10n/L10nLanguageItemSync.class.php @@ -44,7 +44,7 @@ final class L10nLanguageItemSync public static function migrate(L10nDefinition $definition, \Closure $rowMapper): void { $storage = new L10nStorage($definition); - $installedLanguageIDs = \array_keys(LanguageFactory::getInstance()->getLanguages()); + $installedLanguageIDs = self::getInstalledLanguageIDs(); $defaultLanguageID = LanguageFactory::getInstance()->getDefaultLanguageID(); $fetchItems = self::createItemReader(); @@ -133,7 +133,7 @@ public static function sync(L10nDefinition $definition): void } $storage = new L10nStorage($definition); - $installedLanguageIDs = \array_keys(LanguageFactory::getInstance()->getLanguages()); + $installedLanguageIDs = self::getInstalledLanguageIDs(); $defaultLanguageID = LanguageFactory::getInstance()->getDefaultLanguageID(); $fetchItems = self::createItemReader(); @@ -250,6 +250,24 @@ private static function resolve(?array $items, ?string $literal, int $languageID return $items !== [] ? \reset($items) : $literal; } + /** + * Returns the ids of all languages, including disabled ones. + * + * The language cache is not used, because it keeps serving the previous + * state for the remainder of the request after a language was added. + * + * @return list + */ + private static function getInstalledLanguageIDs(): array + { + $sql = "SELECT languageID + FROM wcf1_language"; + $statement = WCF::getDB()->prepare($sql); + $statement->execute(); + + return $statement->fetchAll(\PDO::FETCH_COLUMN); + } + /** * Returns a closure that resolves a language variable to its * `languageID => value` map. From be2d352b943a81816bf0329bde55905e94a06133 Mon Sep 17 00:00:00 2001 From: Alexander Ebert Date: Thu, 1 Oct 2026 13:06:33 +0200 Subject: [PATCH 37/37] Fix double-encoded rank title in user rank grid view --- .../files/lib/system/gridView/admin/UserRankGridView.class.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/wcfsetup/install/files/lib/system/gridView/admin/UserRankGridView.class.php b/wcfsetup/install/files/lib/system/gridView/admin/UserRankGridView.class.php index 2ffdd1b431c..c5fd49d0534 100644 --- a/wcfsetup/install/files/lib/system/gridView/admin/UserRankGridView.class.php +++ b/wcfsetup/install/files/lib/system/gridView/admin/UserRankGridView.class.php @@ -62,7 +62,7 @@ public function render(mixed $value, DatabaseObject $row): string \assert($row instanceof UserRank); return '' - . StringUtil::encodeHTML($value ?? '') + . ($value ?? '') . ''; } }