From cc1c05af6c137397b6fbef8750d871e5841bc0ed Mon Sep 17 00:00:00 2001 From: Marcel Werk Date: Mon, 15 Jun 2026 11:35:41 +0200 Subject: [PATCH 01/36] Add `DatabaseObjectBuilder` with `TagBuilder` implementation Introduces an abstract builder for creating, updating and deleting database objects with a fluent setter API, batched transactional deletes and an `INSERT IGNORE`-style helper. `TagBuilder` is the first concrete implementation. --- .../lib/data/DatabaseObjectBuilder.class.php | 250 ++++++++++++++++++ .../files/lib/data/tag/TagBuilder.class.php | 46 ++++ 2 files changed, 296 insertions(+) create mode 100644 wcfsetup/install/files/lib/data/DatabaseObjectBuilder.class.php create mode 100644 wcfsetup/install/files/lib/data/tag/TagBuilder.class.php diff --git a/wcfsetup/install/files/lib/data/DatabaseObjectBuilder.class.php b/wcfsetup/install/files/lib/data/DatabaseObjectBuilder.class.php new file mode 100644 index 0000000000..89ec16810f --- /dev/null +++ b/wcfsetup/install/files/lib/data/DatabaseObjectBuilder.class.php @@ -0,0 +1,250 @@ + + * @since 6.3 + * + * @template TDatabaseObject of DatabaseObject + */ +abstract class DatabaseObjectBuilder +{ + /** + * @var array + */ + protected array $properties = []; + + /** + * @var array + */ + protected array $customProperties = []; + + /** + * Use forCreate() or forUpdate() to obtain a builder instance. + * + * @param ?TDatabaseObject $object + */ + private function __construct(protected readonly ?DatabaseObject $object = null) {} + + /** + * Persists the pending changes and returns the resulting database object. + * + * @return TDatabaseObject + */ + public function save(): DatabaseObject + { + return new (static::getBaseClass())($this->fastSave()); + } + + /** + * Persists the pending changes and returns the object's identifier without + * instantiating the full database object. + */ + public function fastSave(): int|string + { + if ($this->object !== null) { + $this->update(); + + return $this->object->getObjectID(); + } + + return $this->create(); + } + + /** + * Inserts a new row and returns the primary key of the created object. + */ + private function create(): int|string + { + $keys = $values = ''; + $statementParameters = []; + foreach (array_merge($this->properties, $this->customProperties) as $key => $value) { + if ($keys !== '') { + $keys .= ','; + $values .= ','; + } + + $keys .= $key; + $values .= '?'; + $statementParameters[] = $value; + } + + $sql = "INSERT INTO " . static::getBaseClass()::getDatabaseTableName() . " + (" . $keys . ") + VALUES (" . $values . ")"; + $statement = WCF::getDB()->prepare($sql); + $statement->execute($statementParameters); + + if (static::getBaseClass()::getDatabaseTableIndexIsIdentity()) { + $id = WCF::getDB()->getInsertID(static::getBaseClass()::getDatabaseTableName(), static::getBaseClass()::getDatabaseTableIndexName()); + } elseif (isset($this->properties[static::getBaseClass()::getDatabaseTableIndexName()])) { + $id = $this->properties[static::getBaseClass()::getDatabaseTableIndexName()]; + } else { + throw new \BadMethodCallException("Missing value for '" . static::getBaseClass()::getDatabaseTableIndexName() . "'"); + } + + return $id; + } + + /** + * Writes the pending property changes to the existing row. + */ + private function update(): void + { + if ($this->properties === [] && $this->customProperties === []) { + return; + } + + $updateSQL = ''; + $statementParameters = []; + foreach (array_merge($this->properties, $this->customProperties) as $key => $value) { + if ($updateSQL !== '') { + $updateSQL .= ', '; + } + $updateSQL .= $key . ' = ?'; + $statementParameters[] = $value; + } + $statementParameters[] = $this->object->getObjectID(); + + $sql = "UPDATE " . static::getBaseClass()::getDatabaseTableName() . " + SET " . $updateSQL . " + WHERE " . static::getBaseClass()::getDatabaseTableIndexName() . " = ?"; + $statement = WCF::getDB()->prepare($sql); + $statement->execute($statementParameters); + } + + /** + * Creates a new object, returns null if the row already exists. + * + * @return ?TDatabaseObject + */ + public function createOrIgnore(): ?DatabaseObject + { + if ($this->object !== null) { + throw new \BadMethodCallException("createOrIgnore() can only be used with forCreate()."); + } + + try { + return $this->save(); + } catch (DatabaseQueryExecutionException $e) { + // Error code 23000 = duplicate key + if (\intval($e->getCode()) === 23000 && $e->getDriverCode() === '1062') { + return null; + } + + throw $e; + } + } + + /** + * Deletes the given database object. + * + * @param TDatabaseObject $object + */ + public static function delete(DatabaseObject $object): void + { + static::deleteAll([$object->getObjectID()]); + } + + /** + * Deletes the rows identified by the given primary keys in batches inside + * a single transaction. + * + * @param (string|int)[] $objectIDs + */ + public static function deleteAll(array $objectIDs = []): void + { + if ($objectIDs === []) { + return; + } + + $itemsPerLoop = 1000; + $loopCount = \ceil(\count($objectIDs) / $itemsPerLoop); + + WCF::getDB()->beginTransaction(); + $committed = false; + try { + for ($i = 0; $i < $loopCount; $i++) { + $batchObjectIDs = \array_slice($objectIDs, $i * $itemsPerLoop, $itemsPerLoop); + + $conditionBuilder = new PreparedStatementConditionBuilder(); + $conditionBuilder->add(static::getBaseClass()::getDatabaseTableIndexName() . ' IN (?)', [$batchObjectIDs]); + + $sql = "DELETE FROM " . static::getBaseClass()::getDatabaseTableName() . " + " . $conditionBuilder; + $statement = WCF::getDB()->prepare($sql); + $statement->execute($conditionBuilder->getParameters()); + } + WCF::getDB()->commitTransaction(); + $committed = true; + } finally { + if (!$committed) { + WCF::getDB()->rollBackTransaction(); + } + } + } + + /** + * Returns a builder instance for inserting a new row. + */ + public static function forCreate(): static + { + return new (static::class)(); + } + + /** + * Returns a builder instance for updating an existing database object. + * + * @param TDatabaseObject $object + */ + public static function forUpdate(DatabaseObject $object): static + { + return new static($object); + } + + /** + * Resolves the database object class associated with this builder by + * stripping the `Builder` suffix from the current class name. + * + * @return class-string + */ + public static function getBaseClass(): string + { + if (!\str_ends_with(static::class, 'Builder')) { + throw new \LogicException("Builder class '" . static::class . "' must end with the 'Builder' suffix."); + } + + $className = \mb_substr(static::class, 0, -7); + if (!\class_exists($className)) { + throw new ClassNotFoundException($className); + } + + if (!\is_subclass_of($className, DatabaseObject::class)) { + throw new ImplementationException($className, DatabaseObject::class); + } + + return $className; + } + + /** + * Sets a custom property value that is written alongside the regular + * properties when the object is persisted. + */ + public function setCustomProperty(string $name, string|int|float|null $value): static + { + $this->customProperties[$name] = $value; + + return $this; + } +} diff --git a/wcfsetup/install/files/lib/data/tag/TagBuilder.class.php b/wcfsetup/install/files/lib/data/tag/TagBuilder.class.php new file mode 100644 index 0000000000..882ee6f3de --- /dev/null +++ b/wcfsetup/install/files/lib/data/tag/TagBuilder.class.php @@ -0,0 +1,46 @@ + + * @since 6.3 + * + * @extends DatabaseObjectBuilder + */ +final class TagBuilder extends DatabaseObjectBuilder +{ + public function setTagID(int $tagID): static + { + $this->properties['tagID'] = $tagID; + + return $this; + } + + public function setLanguageID(int $languageID): static + { + $this->properties['languageID'] = $languageID; + + return $this; + } + + public function setName(string $name): static + { + $this->properties['name'] = $name; + + return $this; + } + + public function setSynonymFor(Tag $tag): static + { + $this->properties['synonymFor'] = $tag->tagID; + + return $this; + } +} From 31525aa93f01887ac0525774b016b2618994fdbf Mon Sep 17 00:00:00 2001 From: Marcel Werk Date: Thu, 25 Jun 2026 19:51:01 +0200 Subject: [PATCH 02/36] Migrate tag forms to `DatabaseObjectBuilder` with command and events Replace the `TagAction`-based persistence in `TagAddForm`/`TagEditForm` with the new `DatabaseObjectBuilder` flow. --- .../files/lib/acp/form/TagAddForm.class.php | 76 +++-- .../files/lib/acp/form/TagEditForm.class.php | 2 +- .../files/lib/command/tag/CreateTag.class.php | 32 +++ .../files/lib/command/tag/UpdateTag.class.php | 32 +++ .../lib/data/DatabaseObjectBuilder.class.php | 22 ++ .../files/lib/data/tag/TagBuilder.class.php | 69 +++++ .../files/lib/event/tag/TagCreated.class.php | 21 ++ .../files/lib/event/tag/TagUpdated.class.php | 21 ++ ...bstractDatabaseObjectBuilderForm.class.php | 262 ++++++++++++++++++ ...atabaseObjectBuilderFormDocument.class.php | 67 +++++ .../builder/field/AbstractFormField.class.php | 21 ++ .../AbstractFormFieldDecorator.class.php | 14 + .../form/builder/field/IFormField.class.php | 32 +++ 13 files changed, 642 insertions(+), 29 deletions(-) create mode 100644 wcfsetup/install/files/lib/command/tag/CreateTag.class.php create mode 100644 wcfsetup/install/files/lib/command/tag/UpdateTag.class.php create mode 100644 wcfsetup/install/files/lib/event/tag/TagCreated.class.php create mode 100644 wcfsetup/install/files/lib/event/tag/TagUpdated.class.php create mode 100644 wcfsetup/install/files/lib/form/AbstractDatabaseObjectBuilderForm.class.php create mode 100644 wcfsetup/install/files/lib/system/form/builder/DatabaseObjectBuilderFormDocument.class.php diff --git a/wcfsetup/install/files/lib/acp/form/TagAddForm.class.php b/wcfsetup/install/files/lib/acp/form/TagAddForm.class.php index 64e8f14f59..09d249ee92 100644 --- a/wcfsetup/install/files/lib/acp/form/TagAddForm.class.php +++ b/wcfsetup/install/files/lib/acp/form/TagAddForm.class.php @@ -2,13 +2,17 @@ namespace wcf\acp\form; +use wcf\command\tag\CreateTag; +use wcf\command\tag\UpdateTag; +use wcf\data\DatabaseObjectBuilder; use wcf\data\IStorableObject; use wcf\data\tag\Tag; -use wcf\data\tag\TagAction; +use wcf\data\tag\TagBuilder; use wcf\data\tag\TagList; -use wcf\form\AbstractFormBuilderForm; +use wcf\form\AbstractDatabaseObjectBuilderForm; use wcf\system\form\builder\container\FormContainer; use wcf\system\form\builder\data\processor\CustomFormDataProcessor; +use wcf\system\form\builder\field\IFormField; use wcf\system\form\builder\field\SingleSelectionFormField; use wcf\system\form\builder\field\tag\TagFormField; use wcf\system\form\builder\field\TextFormField; @@ -27,9 +31,9 @@ * @copyright 2001-2024 WoltLab GmbH * @license GNU Lesser General Public License * - * @extends AbstractFormBuilderForm + * @extends AbstractDatabaseObjectBuilderForm */ -class TagAddForm extends AbstractFormBuilderForm +class TagAddForm extends AbstractDatabaseObjectBuilderForm { /** * @inheritDoc @@ -49,15 +53,30 @@ class TagAddForm extends AbstractFormBuilderForm /** * @inheritDoc */ - public $objectActionClass = TagAction::class; + public string $objectEditLinkController = TagEditForm::class; - /** - * @inheritDoc - */ - public $objectEditLinkController = TagEditForm::class; + #[\Override] + protected function getDatabaseObjectBuilder(): TagBuilder + { + if ($this->formObject !== null) { + return TagBuilder::forUpdate($this->formObject); + } + + return TagBuilder::forCreate(); + } #[\Override] - protected function createForm() + protected function getCommand(DatabaseObjectBuilder $builder): callable + { + if ($this->formObject !== null) { + return new UpdateTag($builder); + } + + return new CreateTag($builder); + } + + #[\Override] + protected function createForm(): void { parent::createForm(); @@ -70,12 +89,17 @@ protected function createForm() ->label('wcf.global.name') ->required() ->maximumLength(\TAGGING_MAX_TAG_LENGTH) + ->saveValueCallback( + static fn(TagBuilder $builder, IFormField $field) => $builder->setName( + \str_replace(',', '', StringUtil::trim($field->getSaveValue())) + ) + ) ->addValidator( new FormFieldValidator('duplicateTagValidator', function (TextFormField $field) { $languageIDFormField = $field->getDocument()->getFormField('languageID'); $languageID = $languageIDFormField->getValue(); - $tag = Tag::getTag($field->getValue(), $languageID); + $tag = Tag::getTag($field->getValue(), $languageID ?? 0); if ($tag !== null && $tag->tagID !== $this->formObject?->tagID) { $field->addValidationError( new FormFieldValidationError( @@ -92,10 +116,20 @@ protected function createForm() ->options($contentLanguages) ->value(isset($contentLanguages[WCF::getLanguage()->languageID]) ? WCF::getLanguage()->languageID : null) ->immutable($this->formAction !== 'create') - ->required(), + ->required() + ->saveValueCallback( + static fn(TagBuilder $builder, IFormField $field) => $builder->setLanguageID( + (int)$field->getSaveValue() + ) + ), TagFormField::create('synonyms') ->available($this->formObject?->synonymFor === null) - ->label('wcf.acp.tag.synonyms'), + ->label('wcf.acp.tag.synonyms') + ->saveValueCallback( + static fn(TagBuilder $builder, IFormField $field) => $builder->setSynonyms( + $field->getSaveValue() ?? [] + ) + ), TemplateFormNode::create('tagSynonymFor') ->available($this->formObject?->synonymFor !== null) ->variables([ @@ -107,25 +141,11 @@ protected function createForm() } #[\Override] - protected function finalizeForm() + protected function finalizeForm(): void { parent::finalizeForm(); $this->form->getDataHandler() - ->addProcessor( - new CustomFormDataProcessor( - 'tagNameProcessor', - static function (IFormDocument $document, array $parameters) { - $parameters['data']['name'] = \str_replace( - ',', - '', - StringUtil::trim($parameters['data']['name']) - ); - - return $parameters; - } - ) - ) ->addProcessor( new CustomFormDataProcessor( 'synonymsProcessor', diff --git a/wcfsetup/install/files/lib/acp/form/TagEditForm.class.php b/wcfsetup/install/files/lib/acp/form/TagEditForm.class.php index 2b53db1058..b79b9828fd 100644 --- a/wcfsetup/install/files/lib/acp/form/TagEditForm.class.php +++ b/wcfsetup/install/files/lib/acp/form/TagEditForm.class.php @@ -34,7 +34,7 @@ class TagEditForm extends TagAddForm /** * @inheritDoc */ - public $formAction = 'edit'; + public string $formAction = 'edit'; #[\Override] public function readParameters() diff --git a/wcfsetup/install/files/lib/command/tag/CreateTag.class.php b/wcfsetup/install/files/lib/command/tag/CreateTag.class.php new file mode 100644 index 0000000000..d1610dd668 --- /dev/null +++ b/wcfsetup/install/files/lib/command/tag/CreateTag.class.php @@ -0,0 +1,32 @@ + + * @since 6.3 + */ +final class CreateTag +{ + public function __construct( + private readonly TagBuilder $builder, + ) {} + + public function __invoke(): Tag + { + $tag = $this->builder->save(); + + EventHandler::getInstance()->fire(new TagCreated($tag)); + + return $tag; + } +} diff --git a/wcfsetup/install/files/lib/command/tag/UpdateTag.class.php b/wcfsetup/install/files/lib/command/tag/UpdateTag.class.php new file mode 100644 index 0000000000..d98cd76709 --- /dev/null +++ b/wcfsetup/install/files/lib/command/tag/UpdateTag.class.php @@ -0,0 +1,32 @@ + + * @since 6.3 + */ +final class UpdateTag +{ + public function __construct( + private readonly TagBuilder $builder, + ) {} + + public function __invoke(): Tag + { + $tag = $this->builder->save(); + + EventHandler::getInstance()->fire(new TagUpdated($tag)); + + return $tag; + } +} diff --git a/wcfsetup/install/files/lib/data/DatabaseObjectBuilder.class.php b/wcfsetup/install/files/lib/data/DatabaseObjectBuilder.class.php index 89ec16810f..603b024cdd 100644 --- a/wcfsetup/install/files/lib/data/DatabaseObjectBuilder.class.php +++ b/wcfsetup/install/files/lib/data/DatabaseObjectBuilder.class.php @@ -94,6 +94,8 @@ private function create(): int|string throw new \BadMethodCallException("Missing value for '" . static::getBaseClass()::getDatabaseTableIndexName() . "'"); } + $this->afterCreate($id); + return $id; } @@ -122,6 +124,8 @@ private function update(): void WHERE " . static::getBaseClass()::getDatabaseTableIndexName() . " = ?"; $statement = WCF::getDB()->prepare($sql); $statement->execute($statementParameters); + + $this->afterUpdate(); } /** @@ -247,4 +251,22 @@ public function setCustomProperty(string $name, string|int|float|null $value): s return $this; } + + /** + * This method is called after the creation of a new object. + * It can be overriden to handle additional tasks that are not handled by the default implementation. + */ + protected function afterCreate(int|string $id): void + { + // does nothing + } + + /** + * This method is called after an update. + * It can be overriden to handle additional tasks that are not handled by the default implementation. + */ + protected function afterUpdate(): void + { + // does nothing + } } diff --git a/wcfsetup/install/files/lib/data/tag/TagBuilder.class.php b/wcfsetup/install/files/lib/data/tag/TagBuilder.class.php index 882ee6f3de..4646ce7ff1 100644 --- a/wcfsetup/install/files/lib/data/tag/TagBuilder.class.php +++ b/wcfsetup/install/files/lib/data/tag/TagBuilder.class.php @@ -3,6 +3,7 @@ namespace wcf\data\tag; use wcf\data\DatabaseObjectBuilder; +use wcf\system\WCF; /** * Builder for creating, updating and deleting tags. @@ -16,6 +17,11 @@ */ final class TagBuilder extends DatabaseObjectBuilder { + /** + * @var ?list + */ + private ?array $synonyms = null; + public function setTagID(int $tagID): static { $this->properties['tagID'] = $tagID; @@ -43,4 +49,67 @@ public function setSynonymFor(Tag $tag): static return $this; } + + /** + * @param list $synonyms + */ + public function setSynonyms(array $synonyms): static + { + $this->synonyms = $synonyms; + + return $this; + } + + #[\Override] + protected function afterCreate(int|string $id): void + { + if ($this->synonyms !== null && $this->synonyms !== []) { + $this->saveSynonyms(new Tag($id), $this->synonyms); + } + } + + #[\Override] + protected function afterUpdate(): void + { + if ($this->synonyms !== null) { + $this->removeSynonyms($this->object); + + if ($this->synonyms !== []) { + $this->saveSynonyms($this->object, $this->synonyms); + } + } + } + + private function removeSynonyms(Tag $tag): void + { + $sql = "UPDATE wcf1_tag + SET synonymFor = ? + WHERE synonymFor = ?"; + $statement = WCF::getDB()->prepare($sql); + $statement->execute([ + null, + $tag->tagID, + ]); + } + + /** + * @param list $synonyms + */ + private function saveSynonyms(Tag $tag, array $synonyms): void + { + foreach ($synonyms as $synonym) { + $synonymObj = Tag::getTag($synonym, $tag->languageID); + if ($synonymObj === null) { + TagBuilder::forCreate() + ->setName($synonym) + ->setLanguageID($tag->languageID) + ->setSynonymFor($tag) + ->save(); + } else { + TagBuilder::forUpdate($synonymObj) + ->setSynonymFor($tag) + ->save(); + } + } + } } diff --git a/wcfsetup/install/files/lib/event/tag/TagCreated.class.php b/wcfsetup/install/files/lib/event/tag/TagCreated.class.php new file mode 100644 index 0000000000..cc1760b0c6 --- /dev/null +++ b/wcfsetup/install/files/lib/event/tag/TagCreated.class.php @@ -0,0 +1,21 @@ + + * @since 6.3 + */ +final class TagCreated implements IPsr14Event +{ + public function __construct( + public readonly Tag $tag + ) {} +} diff --git a/wcfsetup/install/files/lib/event/tag/TagUpdated.class.php b/wcfsetup/install/files/lib/event/tag/TagUpdated.class.php new file mode 100644 index 0000000000..bbe312305f --- /dev/null +++ b/wcfsetup/install/files/lib/event/tag/TagUpdated.class.php @@ -0,0 +1,21 @@ + + * @since 6.3 + */ +final class TagUpdated implements IPsr14Event +{ + public function __construct( + public readonly Tag $tag + ) {} +} diff --git a/wcfsetup/install/files/lib/form/AbstractDatabaseObjectBuilderForm.class.php b/wcfsetup/install/files/lib/form/AbstractDatabaseObjectBuilderForm.class.php new file mode 100644 index 0000000000..dc1dee200a --- /dev/null +++ b/wcfsetup/install/files/lib/form/AbstractDatabaseObjectBuilderForm.class.php @@ -0,0 +1,262 @@ + + * @since 6.3 + * + * @template TIStorableObject of IStorableObject|null + * @template TDatabaseObjectBuilder of DatabaseObjectBuilder + */ +abstract class AbstractDatabaseObjectBuilderForm extends AbstractForm +{ + public DatabaseObjectBuilderFormDocument $form; + + /** + * Action performed by the form by default `create` and `edit` is supported. + */ + public string $formAction = 'create'; + + /** + * updated object, not relevant for form action `create` + * @var ?TIStorableObject + */ + public ?IStorableObject $formObject = null; + + /** + * name of the controller for the link to the edit form + */ + public string $objectEditLinkController = ''; + + /** + * object persisted by the most recent `save()` call + */ + public ?DatabaseObject $object = null; + + /** + * Returns the builder used to persist the form data. + * + * For the `create` action a builder obtained via `forCreate()` is expected, + * for the `edit` action a builder obtained via `forUpdate($this->formObject)`. + * + * @return TDatabaseObjectBuilder + */ + abstract protected function getDatabaseObjectBuilder(): DatabaseObjectBuilder; + + /** + * Returns the invokable command that persists the given builder and returns + * the resulting database object. + * + * The default command simply calls `DatabaseObjectBuilder::save()`. Override + * this method to wrap saving in a command that performs additional side + * effects. + * + * @param TDatabaseObjectBuilder $builder + * @return callable(): DatabaseObject + */ + protected function getCommand(DatabaseObjectBuilder $builder): callable + { + return static fn() => $builder->save(); + } + + #[\Override] + public function assignVariables() + { + parent::assignVariables(); + + WCF::getTPL()->assign([ + 'action' => $this->formAction === 'create' ? 'add' : 'edit', + 'form' => $this->form, + 'formObject' => $this->formObject, + ]); + } + + /** + * Builds the form. + */ + public function buildForm(): void + { + $classNamePieces = \explode('\\', static::class); + $controller = \preg_replace('~Form$~', '', \end($classNamePieces)); + + $this->form = DatabaseObjectBuilderFormDocument::create(\lcfirst($controller)); + + if ($this->formObject !== null) { + $this->form->formMode(IFormDocument::FORM_MODE_UPDATE); + } + + $this->createForm(); + + EventHandler::getInstance()->fireAction($this, 'createForm'); + + $this->form->build(); + + $this->finalizeForm(); + + EventHandler::getInstance()->fireAction($this, 'buildForm'); + } + + /** + * Creates the form. + * + * This is the method that is intended to be overwritten by child classes + * to add the form containers and fields. + */ + protected function createForm(): void + { + // does nothing + } + + /** + * Finalizes the form after it has been successfully built. + * + * This method can be used to add form field dependencies. + */ + protected function finalizeForm(): void + { + // does nothing + } + + #[\Override] + public function readData(): void + { + if ($this->formObject !== null) { + $this->setFormObjectData(); + } elseif ($this->formAction === 'edit') { + throw new \UnexpectedValueException("Missing form object to update."); + } + + parent::readData(); + + $this->setFormAction(); + } + + #[\Override] + public function readFormParameters(): void + { + parent::readFormParameters(); + + $this->form->readValues(); + } + + #[\Override] + public function save(): void + { + parent::save(); + + $builder = $this->getDatabaseObjectBuilder(); + $this->form->applyValuesToBuilder($builder); + + foreach ($this->additionalFields as $name => $value) { + $builder->setCustomProperty($name, $value); + } + + $this->object = ($this->getCommand($builder))(); + + $this->saved(); + + WCF::getTPL()->assign('success', true); + + if ($this->formAction === 'create' && $this->objectEditLinkController) { + WCF::getTPL()->assign( + 'objectEditLink', + LinkHandler::getInstance()->getControllerLink($this->objectEditLinkController, [ + 'id' => $this->object->getObjectID(), + ]) + ); + } + } + + #[\Override] + public function saved(): void + { + parent::saved(); + + // re-build form after having created a new object + if ($this->formAction === 'create') { + $this->form->cleanup(); + + $this->buildForm(); + } + + $this->form->showSuccessMessage(true); + } + + /** + * Sets the action of the form. + */ + protected function setFormAction(): void + { + $parameters = []; + if ($this->formObject !== null) { + if ($this->formObject instanceof IRouteController) { + $parameters['object'] = $this->formObject; + } else { + $object = $this->formObject; + // @phpstan-ignore function.alreadyNarrowedType, instanceof.alwaysTrue + \assert($object instanceof IStorableObject); + + $parameters['id'] = $object->{$object::getDatabaseTableIndexName()}; + } + } + + $this->form->action(LinkHandler::getInstance()->getControllerLink(static::class, $parameters)); + } + + /** + * Sets the form data based on the current form object. + */ + protected function setFormObjectData(): void + { + $this->form->updatedObject($this->formObject, empty($_POST)); + } + + #[\Override] + public function checkPermissions(): void + { + parent::checkPermissions(); + + $this->buildForm(); + } + + #[\Override] + public function validate(): void + { + parent::validate(); + + $this->form->validate(); + + if ($this->form->hasValidationErrors()) { + throw new UserInputException($this->form->getPrefixedId()); + } + } + + #[\Override] + protected function validateSecurityToken(): void + { + // does nothing, is handled by `IFormDocument` object + } +} diff --git a/wcfsetup/install/files/lib/system/form/builder/DatabaseObjectBuilderFormDocument.class.php b/wcfsetup/install/files/lib/system/form/builder/DatabaseObjectBuilderFormDocument.class.php new file mode 100644 index 0000000000..58625d7472 --- /dev/null +++ b/wcfsetup/install/files/lib/system/form/builder/DatabaseObjectBuilderFormDocument.class.php @@ -0,0 +1,67 @@ +saveValueCallback( + * static fn(DatabaseObjectBuilder $builder, IFormField $formField) => $builder->setName($formField->getSaveValue()) + * ) + * + * @author Marcel Werk + * @copyright 2001-2026 WoltLab GmbH + * @license GNU Lesser General Public License + * @since 6.3 + */ +class DatabaseObjectBuilderFormDocument extends FormDocument +{ + /** + * Applies the save values of all available form fields that have registered + * a save value callback to the given builder and returns the builder. + * + * @param DatabaseObjectBuilder<*> $builder + * @throws \BadMethodCallException if the method is called before `readValues()` is called + */ + public function applyValuesToBuilder(DatabaseObjectBuilder $builder): void + { + if (!$this->didReadValues()) { + throw new \BadMethodCallException("Applying values to a builder is only possible after calling 'readValues()'."); + } + + $this->applyNodeValues($this, $builder); + } + + /** + * Recursively applies the save value callbacks of the given node and its + * children to the builder, mirroring the availability and dependency + * handling of `DefaultFormDataProcessor`. + * + * @param DatabaseObjectBuilder<*> $builder + */ + protected function applyNodeValues(IFormNode $node, DatabaseObjectBuilder $builder): void + { + if (!$node->isAvailable() || !$node->checkDependencies()) { + return; + } + + if ($node instanceof IFormParentNode) { + foreach ($node as $childNode) { + $this->applyNodeValues($childNode, $builder); + } + } elseif ($node instanceof IFormField) { + $callback = $node->getSaveValueCallback(); + if ($callback !== null) { + $callback($builder, $node); + } + } + } +} diff --git a/wcfsetup/install/files/lib/system/form/builder/field/AbstractFormField.class.php b/wcfsetup/install/files/lib/system/form/builder/field/AbstractFormField.class.php index badb8b8a35..a517052d6c 100644 --- a/wcfsetup/install/files/lib/system/form/builder/field/AbstractFormField.class.php +++ b/wcfsetup/install/files/lib/system/form/builder/field/AbstractFormField.class.php @@ -71,6 +71,13 @@ abstract class AbstractFormField implements IFormField */ protected $value; + /** + * callback transferring this field's save value into a `DatabaseObjectBuilder` + * @var ?\Closure(\wcf\data\DatabaseObjectBuilder<*>, IFormField): mixed + * @since 6.3 + */ + protected ?\Closure $saveValueCallback = null; + #[\Override] public function addValidationError(IFormFieldValidationError $error) { @@ -165,6 +172,20 @@ public function getValue() return $this->value; } + #[\Override] + public function saveValueCallback(\Closure $callback): static + { + $this->saveValueCallback = $callback; + + return $this; + } + + #[\Override] + public function getSaveValueCallback(): ?\Closure + { + return $this->saveValueCallback; + } + #[\Override] public function hasValidator(string $validatorId) { diff --git a/wcfsetup/install/files/lib/system/form/builder/field/AbstractFormFieldDecorator.class.php b/wcfsetup/install/files/lib/system/form/builder/field/AbstractFormFieldDecorator.class.php index 4d00dbea84..0c649ddbe9 100644 --- a/wcfsetup/install/files/lib/system/form/builder/field/AbstractFormFieldDecorator.class.php +++ b/wcfsetup/install/files/lib/system/form/builder/field/AbstractFormFieldDecorator.class.php @@ -87,6 +87,20 @@ public function getValue() return $this->field->getValue(); } + #[\Override] + public function saveValueCallback(\Closure $callback): static + { + $this->field->saveValueCallback($callback); + + return $this; + } + + #[\Override] + public function getSaveValueCallback(): ?\Closure + { + return $this->field->getSaveValueCallback(); + } + #[\Override] public function hasValidator(string $validatorId) { diff --git a/wcfsetup/install/files/lib/system/form/builder/field/IFormField.class.php b/wcfsetup/install/files/lib/system/form/builder/field/IFormField.class.php index 89330390b2..4197450242 100644 --- a/wcfsetup/install/files/lib/system/form/builder/field/IFormField.class.php +++ b/wcfsetup/install/files/lib/system/form/builder/field/IFormField.class.php @@ -2,6 +2,7 @@ namespace wcf\system\form\builder\field; +use wcf\data\DatabaseObjectBuilder; use wcf\data\IStorableObject; use wcf\system\form\builder\field\validation\IFormFieldValidationError; use wcf\system\form\builder\field\validation\IFormFieldValidator; @@ -94,6 +95,37 @@ public function getValidators(); */ public function getValue(); + /** + * Sets a callback that transfers this field's save value into a + * `DatabaseObjectBuilder` instance and returns this field. + * + * The callback is invoked by `DatabaseObjectBuilderFormDocument` when the + * builder is populated from the form's fields, for example: + * + * $field->saveValueCallback( + * static fn(DatabaseObjectBuilder $builder, IFormField $formField) => $builder->setName($formField->getSaveValue()) + * ) + * + * The builder type is a template parameter so that the callback may narrow + * it to a concrete `DatabaseObjectBuilder` implementation (e.g. `TagBuilder`) + * without triggering a contravariance error. + * + * @template TBuilder of DatabaseObjectBuilder + * @param \Closure(TBuilder, IFormField): mixed $callback + * @return static this field + * @since 6.3 + */ + public function saveValueCallback(\Closure $callback): static; + + /** + * Returns the callback set via `saveValueCallback()` or `null` if no such + * callback has been set. + * + * @return ?\Closure(DatabaseObjectBuilder<*>, IFormField): mixed + * @since 6.3 + */ + public function getSaveValueCallback(): ?\Closure; + /** * Returns `true` if this field has a validator with the given id and * returns `false` otherwise. From 2587ea6f4b1d83deb52616571f3ea706d3100202 Mon Sep 17 00:00:00 2001 From: Marcel Werk Date: Thu, 25 Jun 2026 23:01:17 +0200 Subject: [PATCH 03/36] Simplify checks in `AbstractDatabaseObjectBuilderForm` --- .../lib/form/AbstractDatabaseObjectBuilderForm.class.php | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/wcfsetup/install/files/lib/form/AbstractDatabaseObjectBuilderForm.class.php b/wcfsetup/install/files/lib/form/AbstractDatabaseObjectBuilderForm.class.php index dc1dee200a..c83d619f1b 100644 --- a/wcfsetup/install/files/lib/form/AbstractDatabaseObjectBuilderForm.class.php +++ b/wcfsetup/install/files/lib/form/AbstractDatabaseObjectBuilderForm.class.php @@ -216,9 +216,6 @@ protected function setFormAction(): void $parameters['object'] = $this->formObject; } else { $object = $this->formObject; - // @phpstan-ignore function.alreadyNarrowedType, instanceof.alwaysTrue - \assert($object instanceof IStorableObject); - $parameters['id'] = $object->{$object::getDatabaseTableIndexName()}; } } @@ -231,7 +228,7 @@ protected function setFormAction(): void */ protected function setFormObjectData(): void { - $this->form->updatedObject($this->formObject, empty($_POST)); + $this->form->updatedObject($this->formObject, $_POST === []); } #[\Override] From 9ecf8f89052873c1bbe5457d145ca9fe3184a44b Mon Sep 17 00:00:00 2001 From: Marcel Werk Date: Fri, 26 Jun 2026 13:46:03 +0200 Subject: [PATCH 04/36] Add `beforeDeleteAll()` hook and seal `DatabaseObjectBuilder` API --- .../lib/data/DatabaseObjectBuilder.class.php | 31 +++++++++++++------ 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/wcfsetup/install/files/lib/data/DatabaseObjectBuilder.class.php b/wcfsetup/install/files/lib/data/DatabaseObjectBuilder.class.php index 603b024cdd..2da48d4082 100644 --- a/wcfsetup/install/files/lib/data/DatabaseObjectBuilder.class.php +++ b/wcfsetup/install/files/lib/data/DatabaseObjectBuilder.class.php @@ -42,7 +42,7 @@ private function __construct(protected readonly ?DatabaseObject $object = null) * * @return TDatabaseObject */ - public function save(): DatabaseObject + final public function save(): DatabaseObject { return new (static::getBaseClass())($this->fastSave()); } @@ -51,7 +51,7 @@ public function save(): DatabaseObject * Persists the pending changes and returns the object's identifier without * instantiating the full database object. */ - public function fastSave(): int|string + final public function fastSave(): int|string { if ($this->object !== null) { $this->update(); @@ -133,7 +133,7 @@ private function update(): void * * @return ?TDatabaseObject */ - public function createOrIgnore(): ?DatabaseObject + final public function createOrIgnore(): ?DatabaseObject { if ($this->object !== null) { throw new \BadMethodCallException("createOrIgnore() can only be used with forCreate()."); @@ -156,7 +156,7 @@ public function createOrIgnore(): ?DatabaseObject * * @param TDatabaseObject $object */ - public static function delete(DatabaseObject $object): void + final public static function delete(DatabaseObject $object): void { static::deleteAll([$object->getObjectID()]); } @@ -167,12 +167,14 @@ public static function delete(DatabaseObject $object): void * * @param (string|int)[] $objectIDs */ - public static function deleteAll(array $objectIDs = []): void + final public static function deleteAll(array $objectIDs = []): void { if ($objectIDs === []) { return; } + static::beforeDeleteAll($objectIDs); + $itemsPerLoop = 1000; $loopCount = \ceil(\count($objectIDs) / $itemsPerLoop); @@ -202,7 +204,7 @@ public static function deleteAll(array $objectIDs = []): void /** * Returns a builder instance for inserting a new row. */ - public static function forCreate(): static + final public static function forCreate(): static { return new (static::class)(); } @@ -212,7 +214,7 @@ public static function forCreate(): static * * @param TDatabaseObject $object */ - public static function forUpdate(DatabaseObject $object): static + final public static function forUpdate(DatabaseObject $object): static { return new static($object); } @@ -223,7 +225,7 @@ public static function forUpdate(DatabaseObject $object): static * * @return class-string */ - public static function getBaseClass(): string + final public static function getBaseClass(): string { if (!\str_ends_with(static::class, 'Builder')) { throw new \LogicException("Builder class '" . static::class . "' must end with the 'Builder' suffix."); @@ -245,7 +247,7 @@ public static function getBaseClass(): string * Sets a custom property value that is written alongside the regular * properties when the object is persisted. */ - public function setCustomProperty(string $name, string|int|float|null $value): static + final public function setCustomProperty(string $name, string|int|float|null $value): static { $this->customProperties[$name] = $value; @@ -269,4 +271,15 @@ protected function afterUpdate(): void { // does nothing } + + /** + * This method is called before the deletion of objects. + * It can be overriden to handle additional tasks that are not handled by the default implementation. + * + * @param (string|int)[] $objectIDs + */ + protected static function beforeDeleteAll(array $objectIDs): void + { + // does nothing + } } From 4ac2dbf4ef8cdad7b871dae19d4f0c94eb7bf114 Mon Sep 17 00:00:00 2001 From: Marcel Werk Date: Fri, 26 Jun 2026 13:48:05 +0200 Subject: [PATCH 05/36] Use fully qualified `\array_merge()` calls --- .../install/files/lib/data/DatabaseObjectBuilder.class.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/wcfsetup/install/files/lib/data/DatabaseObjectBuilder.class.php b/wcfsetup/install/files/lib/data/DatabaseObjectBuilder.class.php index 2da48d4082..842e240460 100644 --- a/wcfsetup/install/files/lib/data/DatabaseObjectBuilder.class.php +++ b/wcfsetup/install/files/lib/data/DatabaseObjectBuilder.class.php @@ -69,7 +69,7 @@ private function create(): int|string { $keys = $values = ''; $statementParameters = []; - foreach (array_merge($this->properties, $this->customProperties) as $key => $value) { + foreach (\array_merge($this->properties, $this->customProperties) as $key => $value) { if ($keys !== '') { $keys .= ','; $values .= ','; @@ -110,7 +110,7 @@ private function update(): void $updateSQL = ''; $statementParameters = []; - foreach (array_merge($this->properties, $this->customProperties) as $key => $value) { + foreach (\array_merge($this->properties, $this->customProperties) as $key => $value) { if ($updateSQL !== '') { $updateSQL .= ', '; } From 57dea3998363cf43d452950d545bc0b347a29015 Mon Sep 17 00:00:00 2001 From: Marcel Werk Date: Fri, 26 Jun 2026 14:10:41 +0200 Subject: [PATCH 06/36] Add `setID()` for explicit ID assignment on create --- .../lib/data/DatabaseObjectBuilder.class.php | 30 +++++++++++++++---- .../files/lib/data/tag/TagBuilder.class.php | 7 ----- 2 files changed, 25 insertions(+), 12 deletions(-) diff --git a/wcfsetup/install/files/lib/data/DatabaseObjectBuilder.class.php b/wcfsetup/install/files/lib/data/DatabaseObjectBuilder.class.php index 842e240460..ed8375c7a2 100644 --- a/wcfsetup/install/files/lib/data/DatabaseObjectBuilder.class.php +++ b/wcfsetup/install/files/lib/data/DatabaseObjectBuilder.class.php @@ -86,10 +86,10 @@ private function create(): int|string $statement = WCF::getDB()->prepare($sql); $statement->execute($statementParameters); - if (static::getBaseClass()::getDatabaseTableIndexIsIdentity()) { - $id = WCF::getDB()->getInsertID(static::getBaseClass()::getDatabaseTableName(), static::getBaseClass()::getDatabaseTableIndexName()); - } elseif (isset($this->properties[static::getBaseClass()::getDatabaseTableIndexName()])) { + if (isset($this->properties[static::getBaseClass()::getDatabaseTableIndexName()])) { $id = $this->properties[static::getBaseClass()::getDatabaseTableIndexName()]; + } elseif (static::getBaseClass()::getDatabaseTableIndexIsIdentity()) { + $id = WCF::getDB()->getInsertID(static::getBaseClass()::getDatabaseTableName(), static::getBaseClass()::getDatabaseTableIndexName()); } else { throw new \BadMethodCallException("Missing value for '" . static::getBaseClass()::getDatabaseTableIndexName() . "'"); } @@ -165,7 +165,7 @@ final public static function delete(DatabaseObject $object): void * Deletes the rows identified by the given primary keys in batches inside * a single transaction. * - * @param (string|int)[] $objectIDs + * @param (int|string)[] $objectIDs */ final public static function deleteAll(array $objectIDs = []): void { @@ -276,10 +276,30 @@ protected function afterUpdate(): void * This method is called before the deletion of objects. * It can be overriden to handle additional tasks that are not handled by the default implementation. * - * @param (string|int)[] $objectIDs + * @param (int|string)[] $objectIDs */ protected static function beforeDeleteAll(array $objectIDs): void { // does nothing } + + /** + * Sets the ID of the object that is being created. + * + * This method should only be used in cases where the ID needs to be set + * explicitly, for example when importing existing records from another + * installation, where the ID should be preserved if possible. + * + * @throws \BadMethodCallException if an existing object is being updated + */ + final public function setID(int|string $id): static + { + if ($this->object !== null) { + throw new \BadMethodCallException('The ID cannot be set when updating an existing object.'); + } + + $this->properties[static::getBaseClass()::getDatabaseTableIndexName()] = $id; + + return $this; + } } diff --git a/wcfsetup/install/files/lib/data/tag/TagBuilder.class.php b/wcfsetup/install/files/lib/data/tag/TagBuilder.class.php index 4646ce7ff1..5dc775293a 100644 --- a/wcfsetup/install/files/lib/data/tag/TagBuilder.class.php +++ b/wcfsetup/install/files/lib/data/tag/TagBuilder.class.php @@ -22,13 +22,6 @@ final class TagBuilder extends DatabaseObjectBuilder */ private ?array $synonyms = null; - public function setTagID(int $tagID): static - { - $this->properties['tagID'] = $tagID; - - return $this; - } - public function setLanguageID(int $languageID): static { $this->properties['languageID'] = $languageID; From a3791a5a98f8b5faead5cf25e06716c936c9fa42 Mon Sep 17 00:00:00 2001 From: Marcel Werk Date: Sun, 28 Jun 2026 16:21:50 +0200 Subject: [PATCH 07/36] Add `loadValueCallback()` to `IFormField` for custom value loading Introduce a counterpart to `saveValueCallback()` that loads a field's value from an `IStorableObject` when an edit form is populated. When set, the callback takes precedence over the default property-based loading in `updatedObject()`, allowing values that must be derived from a related object or an additional query. --- .../files/lib/acp/form/TagAddForm.class.php | 53 ++++++------------- .../builder/field/AbstractFormField.class.php | 29 +++++++++- .../AbstractFormFieldDecorator.class.php | 14 +++++ .../form/builder/field/IFormField.class.php | 41 ++++++++++++++ .../builder/field/tag/TagFormField.class.php | 4 +- 5 files changed, 102 insertions(+), 39 deletions(-) diff --git a/wcfsetup/install/files/lib/acp/form/TagAddForm.class.php b/wcfsetup/install/files/lib/acp/form/TagAddForm.class.php index 09d249ee92..05be1bb548 100644 --- a/wcfsetup/install/files/lib/acp/form/TagAddForm.class.php +++ b/wcfsetup/install/files/lib/acp/form/TagAddForm.class.php @@ -5,20 +5,17 @@ use wcf\command\tag\CreateTag; use wcf\command\tag\UpdateTag; use wcf\data\DatabaseObjectBuilder; -use wcf\data\IStorableObject; use wcf\data\tag\Tag; use wcf\data\tag\TagBuilder; use wcf\data\tag\TagList; use wcf\form\AbstractDatabaseObjectBuilderForm; use wcf\system\form\builder\container\FormContainer; -use wcf\system\form\builder\data\processor\CustomFormDataProcessor; use wcf\system\form\builder\field\IFormField; use wcf\system\form\builder\field\SingleSelectionFormField; use wcf\system\form\builder\field\tag\TagFormField; 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\form\builder\TemplateFormNode; use wcf\system\language\LanguageFactory; use wcf\system\WCF; @@ -27,8 +24,8 @@ /** * Shows the tag add form. * - * @author Olaf Braun, Tim Duesterhus - * @copyright 2001-2024 WoltLab GmbH + * @author Olaf Braun, Tim Duesterhus, Marcel Werk + * @copyright 2001-2026 WoltLab GmbH * @license GNU Lesser General Public License * * @extends AbstractDatabaseObjectBuilderForm @@ -78,8 +75,6 @@ protected function getCommand(DatabaseObjectBuilder $builder): callable #[\Override] protected function createForm(): void { - parent::createForm(); - $contentLanguages = LanguageFactory::getInstance()->getContentLanguages(); $this->form->appendChildren([ @@ -94,6 +89,9 @@ protected function createForm(): void \str_replace(',', '', StringUtil::trim($field->getSaveValue())) ) ) + ->loadValueCallback(static function (Tag $object, IFormField $field) { + $field->value($object->name); + }) ->addValidator( new FormFieldValidator('duplicateTagValidator', function (TextFormField $field) { $languageIDFormField = $field->getDocument()->getFormField('languageID'); @@ -121,7 +119,9 @@ protected function createForm(): void static fn(TagBuilder $builder, IFormField $field) => $builder->setLanguageID( (int)$field->getSaveValue() ) - ), + )->loadValueCallback(static function (Tag $object, IFormField $field) { + $field->value($object->languageID); + }), TagFormField::create('synonyms') ->available($this->formObject?->synonymFor === null) ->label('wcf.acp.tag.synonyms') @@ -129,7 +129,15 @@ protected function createForm(): void static fn(TagBuilder $builder, IFormField $field) => $builder->setSynonyms( $field->getSaveValue() ?? [] ) - ), + )->loadValueCallback(static function (Tag $object, IFormField $field) { + $synonymList = new TagList(); + $synonymList->getConditionBuilder()->add('synonymFor = ?', [$object->getObjectID()]); + $synonymList->readObjects(); + $field->value(\array_map( + static fn($synonym) => $synonym->name, + $synonymList->getObjects() + )); + }), TemplateFormNode::create('tagSynonymFor') ->available($this->formObject?->synonymFor !== null) ->variables([ @@ -139,31 +147,4 @@ protected function createForm(): void ]) ]); } - - #[\Override] - protected function finalizeForm(): void - { - parent::finalizeForm(); - - $this->form->getDataHandler() - ->addProcessor( - new CustomFormDataProcessor( - 'synonymsProcessor', - null, - static function (IFormDocument $document, array $data, IStorableObject $tag) { - \assert($tag instanceof Tag); - - $synonymList = new TagList(); - $synonymList->getConditionBuilder()->add('synonymFor = ?', [$tag->tagID]); - $synonymList->readObjects(); - $data['synonyms'] = []; - foreach ($synonymList as $synonym) { - $data['synonyms'][] = $synonym->name; - } - - return $data; - } - ) - ); - } } diff --git a/wcfsetup/install/files/lib/system/form/builder/field/AbstractFormField.class.php b/wcfsetup/install/files/lib/system/form/builder/field/AbstractFormField.class.php index a517052d6c..73d60f44ba 100644 --- a/wcfsetup/install/files/lib/system/form/builder/field/AbstractFormField.class.php +++ b/wcfsetup/install/files/lib/system/form/builder/field/AbstractFormField.class.php @@ -78,6 +78,13 @@ abstract class AbstractFormField implements IFormField */ protected ?\Closure $saveValueCallback = null; + /** + * callback loading this field's value from an `IStorableObject` + * @var ?\Closure(\wcf\data\IStorableObject, IFormField): void + * @since 6.3 + */ + protected ?\Closure $loadValueCallback = null; + #[\Override] public function addValidationError(IFormFieldValidationError $error) { @@ -186,6 +193,20 @@ public function getSaveValueCallback(): ?\Closure return $this->saveValueCallback; } + #[\Override] + public function loadValueCallback(\Closure $callback): static + { + $this->loadValueCallback = $callback; + + return $this; + } + + #[\Override] + public function getLoadValueCallback(): ?\Closure + { + return $this->loadValueCallback; + } + #[\Override] public function hasValidator(string $validatorId) { @@ -213,8 +234,12 @@ public function updatedObject(array $data, IStorableObject $object, bool $loadVa $loadValues = true; } - if ($loadValues && isset($data[$this->getObjectProperty()])) { - $this->value($data[$this->getObjectProperty()]); + if ($loadValues) { + if ($this->loadValueCallback !== null) { + ($this->loadValueCallback)($object, $this); + } elseif (isset($data[$this->getObjectProperty()])) { + $this->value($data[$this->getObjectProperty()]); + } } return $this; diff --git a/wcfsetup/install/files/lib/system/form/builder/field/AbstractFormFieldDecorator.class.php b/wcfsetup/install/files/lib/system/form/builder/field/AbstractFormFieldDecorator.class.php index 0c649ddbe9..5ed8d4b772 100644 --- a/wcfsetup/install/files/lib/system/form/builder/field/AbstractFormFieldDecorator.class.php +++ b/wcfsetup/install/files/lib/system/form/builder/field/AbstractFormFieldDecorator.class.php @@ -101,6 +101,20 @@ public function getSaveValueCallback(): ?\Closure return $this->field->getSaveValueCallback(); } + #[\Override] + public function loadValueCallback(\Closure $callback): static + { + $this->field->loadValueCallback($callback); + + return $this; + } + + #[\Override] + public function getLoadValueCallback(): ?\Closure + { + return $this->field->getLoadValueCallback(); + } + #[\Override] public function hasValidator(string $validatorId) { diff --git a/wcfsetup/install/files/lib/system/form/builder/field/IFormField.class.php b/wcfsetup/install/files/lib/system/form/builder/field/IFormField.class.php index 4197450242..c15cf1bc56 100644 --- a/wcfsetup/install/files/lib/system/form/builder/field/IFormField.class.php +++ b/wcfsetup/install/files/lib/system/form/builder/field/IFormField.class.php @@ -126,6 +126,47 @@ public function saveValueCallback(\Closure $callback): static; */ public function getSaveValueCallback(): ?\Closure; + /** + * Sets a callback that loads this field's value from an `IStorableObject` + * and returns this field. + * + * This is the counterpart to `saveValueCallback()`: while the save callback + * writes the field's value into a builder, this callback reads the value + * back out of an existing object when an edit form is populated. It is + * invoked by `updatedObject()` and is expected to assign the value via + * `$field->value()`, for example: + * + * $field->loadValueCallback( + * static function (Tag $object, IFormField $formField) { + * $formField->value($object->name); + * } + * ) + * + * When a callback is set it takes precedence over the default behaviour of + * loading the value from the object property named after this field. Use it + * when the value cannot be read from a single property, e.g. when it must be + * derived from a related object or an additional query. + * + * The object type is a template parameter so that the callback may narrow it + * to a concrete `IStorableObject` implementation (e.g. `Tag`) without + * triggering a contravariance error. + * + * @template TObject of IStorableObject + * @param \Closure(TObject, IFormField): void $callback + * @return static this field + * @since 6.3 + */ + public function loadValueCallback(\Closure $callback): static; + + /** + * Returns the callback set via `loadValueCallback()` or `null` if no such + * callback has been set. + * + * @return ?\Closure(IStorableObject, IFormField): void + * @since 6.3 + */ + public function getLoadValueCallback(): ?\Closure; + /** * Returns `true` if this field has a validator with the given id and * returns `false` otherwise. diff --git a/wcfsetup/install/files/lib/system/form/builder/field/tag/TagFormField.class.php b/wcfsetup/install/files/lib/system/form/builder/field/tag/TagFormField.class.php index 295fca4207..bd6c1fe61a 100644 --- a/wcfsetup/install/files/lib/system/form/builder/field/tag/TagFormField.class.php +++ b/wcfsetup/install/files/lib/system/form/builder/field/tag/TagFormField.class.php @@ -66,7 +66,9 @@ public function hasSaveValue() public function updatedObject(array $data, IStorableObject $object, bool $loadValues = true) { if ($loadValues) { - if (isset($data[$this->getObjectProperty()])) { + if ($this->loadValueCallback !== null) { + ($this->loadValueCallback)($object, $this); + } elseif (isset($data[$this->getObjectProperty()])) { $this->value($data[$this->getObjectProperty()]); } else { $objectID = $object->{$object::getDatabaseTableIndexName()}; From 9bebdaea867a601153cf73db9a19cac74ddc9166 Mon Sep 17 00:00:00 2001 From: Marcel Werk Date: Sun, 28 Jun 2026 20:02:54 +0200 Subject: [PATCH 08/36] Return void from save value callbacks --- .../files/lib/acp/form/TagAddForm.class.php | 24 +++++++++---------- .../builder/field/AbstractFormField.class.php | 2 +- .../form/builder/field/IFormField.class.php | 4 ++-- 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/wcfsetup/install/files/lib/acp/form/TagAddForm.class.php b/wcfsetup/install/files/lib/acp/form/TagAddForm.class.php index 05be1bb548..6760932a7e 100644 --- a/wcfsetup/install/files/lib/acp/form/TagAddForm.class.php +++ b/wcfsetup/install/files/lib/acp/form/TagAddForm.class.php @@ -84,11 +84,11 @@ protected function createForm(): void ->label('wcf.global.name') ->required() ->maximumLength(\TAGGING_MAX_TAG_LENGTH) - ->saveValueCallback( - static fn(TagBuilder $builder, IFormField $field) => $builder->setName( + ->saveValueCallback(static function (TagBuilder $builder, IFormField $field) { + $builder->setName( \str_replace(',', '', StringUtil::trim($field->getSaveValue())) - ) - ) + ); + }) ->loadValueCallback(static function (Tag $object, IFormField $field) { $field->value($object->name); }) @@ -115,21 +115,21 @@ protected function createForm(): void ->value(isset($contentLanguages[WCF::getLanguage()->languageID]) ? WCF::getLanguage()->languageID : null) ->immutable($this->formAction !== 'create') ->required() - ->saveValueCallback( - static fn(TagBuilder $builder, IFormField $field) => $builder->setLanguageID( + ->saveValueCallback(static function (TagBuilder $builder, IFormField $field) { + $builder->setLanguageID( (int)$field->getSaveValue() - ) - )->loadValueCallback(static function (Tag $object, IFormField $field) { + ); + })->loadValueCallback(static function (Tag $object, IFormField $field) { $field->value($object->languageID); }), TagFormField::create('synonyms') ->available($this->formObject?->synonymFor === null) ->label('wcf.acp.tag.synonyms') - ->saveValueCallback( - static fn(TagBuilder $builder, IFormField $field) => $builder->setSynonyms( + ->saveValueCallback(static function (TagBuilder $builder, IFormField $field) { + $builder->setSynonyms( $field->getSaveValue() ?? [] - ) - )->loadValueCallback(static function (Tag $object, IFormField $field) { + ); + })->loadValueCallback(static function (Tag $object, IFormField $field) { $synonymList = new TagList(); $synonymList->getConditionBuilder()->add('synonymFor = ?', [$object->getObjectID()]); $synonymList->readObjects(); diff --git a/wcfsetup/install/files/lib/system/form/builder/field/AbstractFormField.class.php b/wcfsetup/install/files/lib/system/form/builder/field/AbstractFormField.class.php index 73d60f44ba..b0e3860d44 100644 --- a/wcfsetup/install/files/lib/system/form/builder/field/AbstractFormField.class.php +++ b/wcfsetup/install/files/lib/system/form/builder/field/AbstractFormField.class.php @@ -73,7 +73,7 @@ abstract class AbstractFormField implements IFormField /** * callback transferring this field's save value into a `DatabaseObjectBuilder` - * @var ?\Closure(\wcf\data\DatabaseObjectBuilder<*>, IFormField): mixed + * @var ?\Closure(\wcf\data\DatabaseObjectBuilder<*>, IFormField): void * @since 6.3 */ protected ?\Closure $saveValueCallback = null; diff --git a/wcfsetup/install/files/lib/system/form/builder/field/IFormField.class.php b/wcfsetup/install/files/lib/system/form/builder/field/IFormField.class.php index c15cf1bc56..b5006af21d 100644 --- a/wcfsetup/install/files/lib/system/form/builder/field/IFormField.class.php +++ b/wcfsetup/install/files/lib/system/form/builder/field/IFormField.class.php @@ -111,7 +111,7 @@ public function getValue(); * without triggering a contravariance error. * * @template TBuilder of DatabaseObjectBuilder - * @param \Closure(TBuilder, IFormField): mixed $callback + * @param \Closure(TBuilder, IFormField): void $callback * @return static this field * @since 6.3 */ @@ -121,7 +121,7 @@ public function saveValueCallback(\Closure $callback): static; * Returns the callback set via `saveValueCallback()` or `null` if no such * callback has been set. * - * @return ?\Closure(DatabaseObjectBuilder<*>, IFormField): mixed + * @return ?\Closure(DatabaseObjectBuilder<*>, IFormField): void * @since 6.3 */ public function getSaveValueCallback(): ?\Closure; From 3ed7ca2c08aeb9e644e127a3d5f9ef798c6a82a3 Mon Sep 17 00:00:00 2001 From: Marcel Werk Date: Tue, 30 Jun 2026 16:38:57 +0200 Subject: [PATCH 09/36] Add `updateCounters()` to `DatabaseObjectBuilder Provides a static helper to atomically increment or decrement counter columns for a given object, mirroring the behavior of the legacy `DatabaseObjectEditor::updateCounters()`. --- .../lib/data/DatabaseObjectBuilder.class.php | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/wcfsetup/install/files/lib/data/DatabaseObjectBuilder.class.php b/wcfsetup/install/files/lib/data/DatabaseObjectBuilder.class.php index ed8375c7a2..3850c1d8c1 100644 --- a/wcfsetup/install/files/lib/data/DatabaseObjectBuilder.class.php +++ b/wcfsetup/install/files/lib/data/DatabaseObjectBuilder.class.php @@ -302,4 +302,36 @@ final public function setID(int|string $id): static return $this; } + + /** + * Updates counters for the given object. + * + * @param TDatabaseObject $object + * @param array $counters + */ + final public static function updateCounters(DatabaseObject $object, array $counters): void + { + if ($counters === []) { + throw new \InvalidArgumentException("The list of counters to update must not be empty."); + } + + \assert($object instanceof (static::getBaseClass())); + + $updateSQL = ''; + $statementParameters = []; + foreach ($counters as $key => $value) { + if ($updateSQL !== '') { + $updateSQL .= ', '; + } + $updateSQL .= $key . ' = ' . $key . ' + ?'; + $statementParameters[] = $value; + } + $statementParameters[] = $object->getObjectID(); + + $sql = "UPDATE " . static::getBaseClass()::getDatabaseTableName() . " + SET " . $updateSQL . " + WHERE " . static::getBaseClass()::getDatabaseTableIndexName() . " = ?"; + $statement = WCF::getDB()->prepare($sql); + $statement->execute($statementParameters); + } } From 4156ec4349306060c35093f21324eef79b4ae9a2 Mon Sep 17 00:00:00 2001 From: Marcel Werk Date: Tue, 30 Jun 2026 16:51:21 +0200 Subject: [PATCH 10/36] Add `getHtmlInputProcessor()` to `WysiwygFormField` --- .../builder/field/wysiwyg/WysiwygFormField.class.php | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/wcfsetup/install/files/lib/system/form/builder/field/wysiwyg/WysiwygFormField.class.php b/wcfsetup/install/files/lib/system/form/builder/field/wysiwyg/WysiwygFormField.class.php index 4be0c94a24..8e82c40dad 100644 --- a/wcfsetup/install/files/lib/system/form/builder/field/wysiwyg/WysiwygFormField.class.php +++ b/wcfsetup/install/files/lib/system/form/builder/field/wysiwyg/WysiwygFormField.class.php @@ -393,4 +393,16 @@ public function getValue() $upcastProcessor->process(parent::getValue() ?? '', $this->getObjectType()->objectType); return $upcastProcessor->getHtml(); } + + /** + * @since 6.3 + */ + public function getHtmlInputProcessor(): HtmlInputProcessor + { + if ($this->htmlInputProcessor === null) { + throw new \BadMethodCallException("The html input processor is not available before validate() has been called."); + } + + return $this->htmlInputProcessor; + } } From 4728f85c7e0b4947cb014efb0fdc2e58619de42a Mon Sep 17 00:00:00 2001 From: Marcel Werk Date: Tue, 30 Jun 2026 17:09:16 +0200 Subject: [PATCH 11/36] Add `afterSave()` hook and use `DatabaseObject` for the form object --- ...bstractDatabaseObjectBuilderForm.class.php | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/wcfsetup/install/files/lib/form/AbstractDatabaseObjectBuilderForm.class.php b/wcfsetup/install/files/lib/form/AbstractDatabaseObjectBuilderForm.class.php index c83d619f1b..6a2c04568f 100644 --- a/wcfsetup/install/files/lib/form/AbstractDatabaseObjectBuilderForm.class.php +++ b/wcfsetup/install/files/lib/form/AbstractDatabaseObjectBuilderForm.class.php @@ -4,7 +4,6 @@ use wcf\data\DatabaseObject; use wcf\data\DatabaseObjectBuilder; -use wcf\data\IStorableObject; use wcf\system\event\EventHandler; use wcf\system\exception\UserInputException; use wcf\system\form\builder\DatabaseObjectBuilderFormDocument; @@ -28,7 +27,7 @@ * @license GNU Lesser General Public License * @since 6.3 * - * @template TIStorableObject of IStorableObject|null + * @template TDatabaseObject of DatabaseObject|null * @template TDatabaseObjectBuilder of DatabaseObjectBuilder */ abstract class AbstractDatabaseObjectBuilderForm extends AbstractForm @@ -42,9 +41,9 @@ abstract class AbstractDatabaseObjectBuilderForm extends AbstractForm /** * updated object, not relevant for form action `create` - * @var ?TIStorableObject + * @var ?TDatabaseObject */ - public ?IStorableObject $formObject = null; + public ?DatabaseObject $formObject = null; /** * name of the controller for the link to the edit form @@ -53,6 +52,7 @@ abstract class AbstractDatabaseObjectBuilderForm extends AbstractForm /** * object persisted by the most recent `save()` call + * @var ?TDatabaseObject */ public ?DatabaseObject $object = null; @@ -188,6 +188,8 @@ public function save(): void ]) ); } + + $this->afterSave(); } #[\Override] @@ -256,4 +258,13 @@ protected function validateSecurityToken(): void { // does nothing, is handled by `IFormDocument` object } + + /** + * This method is called after a save. + * It can be overriden to handle additional tasks that are not handled by the default implementation. + */ + protected function afterSave(): void + { + // does nothing + } } From c3b4e4111c1e69cb9e5aaa1bd6ee8f388943f9f8 Mon Sep 17 00:00:00 2001 From: Marcel Werk Date: Tue, 30 Jun 2026 17:55:05 +0200 Subject: [PATCH 12/36] Drop `DatabaseObjectBuilder::fastSave()` The method was based on `DatabaseObjectEditor::fastCreate()` which is used very rarely and therefore is not needed in the new API. The change simplifies the code and allows the DBO to be passed directly as a parameter to `afterCreate()` and `afterUpdate()` --- .../lib/data/DatabaseObjectBuilder.class.php | 89 +++++++++---------- .../files/lib/data/tag/TagBuilder.class.php | 11 +-- 2 files changed, 48 insertions(+), 52 deletions(-) diff --git a/wcfsetup/install/files/lib/data/DatabaseObjectBuilder.class.php b/wcfsetup/install/files/lib/data/DatabaseObjectBuilder.class.php index 3850c1d8c1..0b31fd8044 100644 --- a/wcfsetup/install/files/lib/data/DatabaseObjectBuilder.class.php +++ b/wcfsetup/install/files/lib/data/DatabaseObjectBuilder.class.php @@ -43,20 +43,9 @@ private function __construct(protected readonly ?DatabaseObject $object = null) * @return TDatabaseObject */ final public function save(): DatabaseObject - { - return new (static::getBaseClass())($this->fastSave()); - } - - /** - * Persists the pending changes and returns the object's identifier without - * instantiating the full database object. - */ - final public function fastSave(): int|string { if ($this->object !== null) { - $this->update(); - - return $this->object->getObjectID(); + return $this->update(); } return $this->create(); @@ -64,8 +53,10 @@ final public function fastSave(): int|string /** * Inserts a new row and returns the primary key of the created object. + * + * @return TDatabaseObject */ - private function create(): int|string + private function create(): DatabaseObject { $keys = $values = ''; $statementParameters = []; @@ -94,38 +85,46 @@ private function create(): int|string throw new \BadMethodCallException("Missing value for '" . static::getBaseClass()::getDatabaseTableIndexName() . "'"); } - $this->afterCreate($id); + $object = new (static::getBaseClass())($id); + + $this->afterCreate($object); - return $id; + return $object; } /** * Writes the pending property changes to the existing row. + * + * @return TDatabaseObject */ - private function update(): void + private function update(): DatabaseObject { - if ($this->properties === [] && $this->customProperties === []) { - return; - } - - $updateSQL = ''; - $statementParameters = []; - foreach (\array_merge($this->properties, $this->customProperties) as $key => $value) { - if ($updateSQL !== '') { - $updateSQL .= ', '; + if ($this->properties !== [] || $this->customProperties !== []) { + $updateSQL = ''; + $statementParameters = []; + foreach (\array_merge($this->properties, $this->customProperties) as $key => $value) { + if ($updateSQL !== '') { + $updateSQL .= ', '; + } + $updateSQL .= $key . ' = ?'; + $statementParameters[] = $value; } - $updateSQL .= $key . ' = ?'; - $statementParameters[] = $value; - } - $statementParameters[] = $this->object->getObjectID(); + $statementParameters[] = $this->object->getObjectID(); - $sql = "UPDATE " . static::getBaseClass()::getDatabaseTableName() . " + $sql = "UPDATE " . static::getBaseClass()::getDatabaseTableName() . " SET " . $updateSQL . " WHERE " . static::getBaseClass()::getDatabaseTableIndexName() . " = ?"; - $statement = WCF::getDB()->prepare($sql); - $statement->execute($statementParameters); + $statement = WCF::getDB()->prepare($sql); + $statement->execute($statementParameters); + + $object = new (static::getBaseClass())($this->object->getObjectID()); + } else { + $object = $this->object; + } + + $this->afterUpdate($object); - $this->afterUpdate(); + return $object; } /** @@ -165,14 +164,10 @@ final public static function delete(DatabaseObject $object): void * Deletes the rows identified by the given primary keys in batches inside * a single transaction. * - * @param (int|string)[] $objectIDs + * @param non-empty-list|non-empty-list $objectIDs */ - final public static function deleteAll(array $objectIDs = []): void + final public static function deleteAll(array $objectIDs): void { - if ($objectIDs === []) { - return; - } - static::beforeDeleteAll($objectIDs); $itemsPerLoop = 1000; @@ -257,8 +252,10 @@ final public function setCustomProperty(string $name, string|int|float|null $val /** * This method is called after the creation of a new object. * It can be overriden to handle additional tasks that are not handled by the default implementation. + * + * @param TDatabaseObject $object */ - protected function afterCreate(int|string $id): void + protected function afterCreate(DatabaseObject $object): void { // does nothing } @@ -266,8 +263,10 @@ protected function afterCreate(int|string $id): void /** * This method is called after an update. * It can be overriden to handle additional tasks that are not handled by the default implementation. + * + * @param TDatabaseObject $object */ - protected function afterUpdate(): void + protected function afterUpdate(DatabaseObject $object): void { // does nothing } @@ -276,7 +275,7 @@ protected function afterUpdate(): void * This method is called before the deletion of objects. * It can be overriden to handle additional tasks that are not handled by the default implementation. * - * @param (int|string)[] $objectIDs + * @param non-empty-list|non-empty-list $objectIDs */ protected static function beforeDeleteAll(array $objectIDs): void { @@ -307,14 +306,10 @@ final public function setID(int|string $id): static * Updates counters for the given object. * * @param TDatabaseObject $object - * @param array $counters + * @param non-empty-array $counters */ final public static function updateCounters(DatabaseObject $object, array $counters): void { - if ($counters === []) { - throw new \InvalidArgumentException("The list of counters to update must not be empty."); - } - \assert($object instanceof (static::getBaseClass())); $updateSQL = ''; diff --git a/wcfsetup/install/files/lib/data/tag/TagBuilder.class.php b/wcfsetup/install/files/lib/data/tag/TagBuilder.class.php index 5dc775293a..0f0ec75751 100644 --- a/wcfsetup/install/files/lib/data/tag/TagBuilder.class.php +++ b/wcfsetup/install/files/lib/data/tag/TagBuilder.class.php @@ -2,6 +2,7 @@ namespace wcf\data\tag; +use wcf\data\DatabaseObject; use wcf\data\DatabaseObjectBuilder; use wcf\system\WCF; @@ -54,21 +55,21 @@ public function setSynonyms(array $synonyms): static } #[\Override] - protected function afterCreate(int|string $id): void + protected function afterCreate(DatabaseObject $object): void { if ($this->synonyms !== null && $this->synonyms !== []) { - $this->saveSynonyms(new Tag($id), $this->synonyms); + $this->saveSynonyms($object, $this->synonyms); } } #[\Override] - protected function afterUpdate(): void + protected function afterUpdate(DatabaseObject $object): void { if ($this->synonyms !== null) { - $this->removeSynonyms($this->object); + $this->removeSynonyms($object); if ($this->synonyms !== []) { - $this->saveSynonyms($this->object, $this->synonyms); + $this->saveSynonyms($object, $this->synonyms); } } } From 427f1a3453c75e573400acc101025b9d872fef5e Mon Sep 17 00:00:00 2001 From: Marcel Werk Date: Tue, 30 Jun 2026 21:55:15 +0200 Subject: [PATCH 13/36] Add `getObject()` to `DatabaseObjectBuilder` --- .../files/lib/data/DatabaseObjectBuilder.class.php | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/wcfsetup/install/files/lib/data/DatabaseObjectBuilder.class.php b/wcfsetup/install/files/lib/data/DatabaseObjectBuilder.class.php index 0b31fd8044..6ad5805667 100644 --- a/wcfsetup/install/files/lib/data/DatabaseObjectBuilder.class.php +++ b/wcfsetup/install/files/lib/data/DatabaseObjectBuilder.class.php @@ -329,4 +329,12 @@ final public static function updateCounters(DatabaseObject $object, array $count $statement = WCF::getDB()->prepare($sql); $statement->execute($statementParameters); } + + /** + * @return ?TDatabaseObject + */ + public function getObject(): ?DatabaseObject + { + return $this->object; + } } From 05eed3e912b9f3192c5054fbeaac29cf1cae0fdf Mon Sep 17 00:00:00 2001 From: Marcel Werk Date: Tue, 30 Jun 2026 21:57:16 +0200 Subject: [PATCH 14/36] Allow accessing wysiwyg and attachment fields before the form is built --- .../wysiwyg/WysiwygFormContainer.class.php | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/wcfsetup/install/files/lib/system/form/builder/container/wysiwyg/WysiwygFormContainer.class.php b/wcfsetup/install/files/lib/system/form/builder/container/wysiwyg/WysiwygFormContainer.class.php index e2c9c458d5..82097d436c 100644 --- a/wcfsetup/install/files/lib/system/form/builder/container/wysiwyg/WysiwygFormContainer.class.php +++ b/wcfsetup/install/files/lib/system/form/builder/container/wysiwyg/WysiwygFormContainer.class.php @@ -290,14 +290,11 @@ public function enablePreviewButton(bool $enablePreviewButton = true) * Returns the form field handling attachments. * * @return WysiwygAttachmentFormField - * @throws \BadMethodCallException if the form field container has not been populated yet/form has not been built yet */ public function getAttachmentField() { if ($this->attachmentField === null) { - throw new \BadMethodCallException( - "Wysiwyg form field can only be requested after the form has been built for container '{$this->getId()}'." - ); + $this->attachmentField = WysiwygAttachmentFormField::create($this->wysiwygId . 'Attachments'); } return $this->attachmentField; @@ -385,14 +382,11 @@ public function getSmiliesContainer() * Returns the wysiwyg form field handling the actual text. * * @return WysiwygFormField - * @throws \BadMethodCallException if the form field container has not been populated yet/form has not been built yet */ public function getWysiwygField() { if ($this->wysiwygField === null) { - throw new \BadMethodCallException( - "Wysiwyg form field can only be requested after the form has been built for container '{$this->getId()}'." - ); + $this->wysiwygField = WysiwygFormField::create($this->wysiwygId); } return $this->wysiwygField; @@ -536,7 +530,7 @@ public function populate() { parent::populate(); - $this->wysiwygField = WysiwygFormField::create($this->wysiwygId) + $this->wysiwygField = $this->getWysiwygField() ->objectType($this->messageObjectType) ->minimumLength($this->getMinimumLength()) ->maximumLength($this->getMaximumLength()) @@ -549,7 +543,7 @@ public function populate() ->wysiwygId($this->getWysiwygId()) ->label('wcf.message.smilies') ->available($this->supportSmilies); - $this->attachmentField = WysiwygAttachmentFormField::create($this->wysiwygId . 'Attachments') + $this->attachmentField = $this->getAttachmentField() ->wysiwygId($this->getWysiwygId()); $this->settingsContainer = FormContainer::create($this->wysiwygId . 'SettingsContainer') ->appendChildren($this->settingsNodes); From 612e44a3f0f83e1a98bbb3f246e36d7887f123e7 Mon Sep 17 00:00:00 2001 From: Marcel Werk Date: Tue, 30 Jun 2026 21:57:45 +0200 Subject: [PATCH 15/36] Use a dedicated template for the form field in save/load callback types --- .../lib/system/form/builder/field/IFormField.class.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/wcfsetup/install/files/lib/system/form/builder/field/IFormField.class.php b/wcfsetup/install/files/lib/system/form/builder/field/IFormField.class.php index b5006af21d..44b2daacc7 100644 --- a/wcfsetup/install/files/lib/system/form/builder/field/IFormField.class.php +++ b/wcfsetup/install/files/lib/system/form/builder/field/IFormField.class.php @@ -111,7 +111,8 @@ public function getValue(); * without triggering a contravariance error. * * @template TBuilder of DatabaseObjectBuilder - * @param \Closure(TBuilder, IFormField): void $callback + * @template TIFormField of IFormField + * @param \Closure(TBuilder, TIFormField): void $callback * @return static this field * @since 6.3 */ @@ -152,7 +153,8 @@ public function getSaveValueCallback(): ?\Closure; * triggering a contravariance error. * * @template TObject of IStorableObject - * @param \Closure(TObject, IFormField): void $callback + * @template TIFormField of IFormField + * @param \Closure(TObject, TIFormField): void $callback * @return static this field * @since 6.3 */ From fc5cd40474a9d9c08137a94d0d68f1776dac05c5 Mon Sep 17 00:00:00 2001 From: Marcel Werk Date: Tue, 30 Jun 2026 21:58:13 +0200 Subject: [PATCH 16/36] Honor `loadValueCallback` in `TI18nFormField::updatedObject()` --- .../builder/field/TI18nFormField.class.php | 24 +++++++++++-------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/wcfsetup/install/files/lib/system/form/builder/field/TI18nFormField.class.php b/wcfsetup/install/files/lib/system/form/builder/field/TI18nFormField.class.php index cbec854598..0523a612f9 100644 --- a/wcfsetup/install/files/lib/system/form/builder/field/TI18nFormField.class.php +++ b/wcfsetup/install/files/lib/system/form/builder/field/TI18nFormField.class.php @@ -279,16 +279,20 @@ public function updatedObject(array $data, IStorableObject $object, bool $loadVa $loadValues = true; } - if ($loadValues && isset($data[$this->getObjectProperty()])) { - $value = $data[$this->getObjectProperty()]; - - if ($this->isI18n()) { - // do not use `I18nHandler::setOptions()` because then `I18nHandler` only - // reads the values when assigning the template variables and the values - // are not available in this class via `getValue()` - $this->setStringValue($value); - } else { - $this->value = $value; + if ($loadValues) { + if ($this->loadValueCallback !== null) { + ($this->loadValueCallback)($object, $this); + } elseif (isset($data[$this->getObjectProperty()])) { + $value = $data[$this->getObjectProperty()]; + + if ($this->isI18n()) { + // do not use `I18nHandler::setOptions()` because then `I18nHandler` only + // reads the values when assigning the template variables and the values + // are not available in this class via `getValue()` + $this->setStringValue($value); + } else { + $this->value = $value; + } } } From 3c6235c41d1d129cf71050a03f28a2aca871cd3c Mon Sep 17 00:00:00 2001 From: Marcel Werk Date: Wed, 1 Jul 2026 13:50:19 +0200 Subject: [PATCH 17/36] Validate required properties in `DatabaseObjectBuilder::create()` --- .../lib/data/DatabaseObjectBuilder.class.php | 32 +++++++++++++++++++ .../files/lib/data/tag/TagBuilder.class.php | 6 ++++ 2 files changed, 38 insertions(+) diff --git a/wcfsetup/install/files/lib/data/DatabaseObjectBuilder.class.php b/wcfsetup/install/files/lib/data/DatabaseObjectBuilder.class.php index 6ad5805667..0302b3f574 100644 --- a/wcfsetup/install/files/lib/data/DatabaseObjectBuilder.class.php +++ b/wcfsetup/install/files/lib/data/DatabaseObjectBuilder.class.php @@ -58,6 +58,8 @@ final public function save(): DatabaseObject */ private function create(): DatabaseObject { + $this->validateCreate(); + $keys = $values = ''; $statementParameters = []; foreach (\array_merge($this->properties, $this->customProperties) as $key => $value) { @@ -92,6 +94,36 @@ private function create(): DatabaseObject return $object; } + /** + * Validates that the pending changes are sufficient to create a new object. + * + * @throws \BadMethodCallException if no properties are set or a required property is missing + */ + private function validateCreate(): void + { + if ($this->properties === [] && $this->customProperties === []) { + throw new \BadMethodCallException("Cannot create an object without any properties."); + } + + foreach ($this->getRequiredProperties() as $property) { + if (!\array_key_exists($property, $this->properties)) { + throw new \BadMethodCallException("Missing value for required property '{$property}'."); + } + } + } + + /** + * Returns the names of the properties that must be set when creating a new + * object. Subclasses can override this method to enforce that required + * values are provided before the object is persisted. + * + * @return list + */ + protected function getRequiredProperties(): array + { + return []; + } + /** * Writes the pending property changes to the existing row. * diff --git a/wcfsetup/install/files/lib/data/tag/TagBuilder.class.php b/wcfsetup/install/files/lib/data/tag/TagBuilder.class.php index 0f0ec75751..41fc70d62a 100644 --- a/wcfsetup/install/files/lib/data/tag/TagBuilder.class.php +++ b/wcfsetup/install/files/lib/data/tag/TagBuilder.class.php @@ -106,4 +106,10 @@ private function saveSynonyms(Tag $tag, array $synonyms): void } } } + + #[\Override] + protected function getRequiredProperties(): array + { + return ['name']; + } } From 82547416dc63b0eaf5bd76234003b3aef4f9f3d1 Mon Sep 17 00:00:00 2001 From: Marcel Werk Date: Sun, 5 Jul 2026 16:31:42 +0200 Subject: [PATCH 18/36] Apply suggestions from code review --- wcfsetup/install/files/lib/data/tag/TagBuilder.class.php | 4 ++++ .../form/builder/DatabaseObjectBuilderFormDocument.class.php | 4 +++- .../files/lib/system/form/builder/field/IFormField.class.php | 4 +++- .../form/builder/field/wysiwyg/WysiwygFormField.class.php | 2 +- 4 files changed, 11 insertions(+), 3 deletions(-) diff --git a/wcfsetup/install/files/lib/data/tag/TagBuilder.class.php b/wcfsetup/install/files/lib/data/tag/TagBuilder.class.php index 41fc70d62a..87c625149d 100644 --- a/wcfsetup/install/files/lib/data/tag/TagBuilder.class.php +++ b/wcfsetup/install/files/lib/data/tag/TagBuilder.class.php @@ -40,6 +40,7 @@ public function setName(string $name): static public function setSynonymFor(Tag $tag): static { $this->properties['synonymFor'] = $tag->tagID; + $this->synonyms = []; return $this; } @@ -50,6 +51,9 @@ public function setSynonymFor(Tag $tag): static public function setSynonyms(array $synonyms): static { $this->synonyms = $synonyms; + if ($synonyms !== []) { + $this->properties['synonymFor'] = null; + } return $this; } diff --git a/wcfsetup/install/files/lib/system/form/builder/DatabaseObjectBuilderFormDocument.class.php b/wcfsetup/install/files/lib/system/form/builder/DatabaseObjectBuilderFormDocument.class.php index 58625d7472..9d3ab27d74 100644 --- a/wcfsetup/install/files/lib/system/form/builder/DatabaseObjectBuilderFormDocument.class.php +++ b/wcfsetup/install/files/lib/system/form/builder/DatabaseObjectBuilderFormDocument.class.php @@ -14,7 +14,9 @@ * `IFormField::saveValueCallback()`: * * $field->saveValueCallback( - * static fn(DatabaseObjectBuilder $builder, IFormField $formField) => $builder->setName($formField->getSaveValue()) + * static function (DatabaseObjectBuilder $builder, IFormField $formField) { + * return $builder->setName($formField->getSaveValue()); + * } * ) * * @author Marcel Werk diff --git a/wcfsetup/install/files/lib/system/form/builder/field/IFormField.class.php b/wcfsetup/install/files/lib/system/form/builder/field/IFormField.class.php index 44b2daacc7..2517015c53 100644 --- a/wcfsetup/install/files/lib/system/form/builder/field/IFormField.class.php +++ b/wcfsetup/install/files/lib/system/form/builder/field/IFormField.class.php @@ -103,7 +103,9 @@ public function getValue(); * builder is populated from the form's fields, for example: * * $field->saveValueCallback( - * static fn(DatabaseObjectBuilder $builder, IFormField $formField) => $builder->setName($formField->getSaveValue()) + * static function (DatabaseObjectBuilder $builder, IFormField $formField) { + * return $builder->setName($formField->getSaveValue()); + * } * ) * * The builder type is a template parameter so that the callback may narrow diff --git a/wcfsetup/install/files/lib/system/form/builder/field/wysiwyg/WysiwygFormField.class.php b/wcfsetup/install/files/lib/system/form/builder/field/wysiwyg/WysiwygFormField.class.php index 8e82c40dad..a4be54229c 100644 --- a/wcfsetup/install/files/lib/system/form/builder/field/wysiwyg/WysiwygFormField.class.php +++ b/wcfsetup/install/files/lib/system/form/builder/field/wysiwyg/WysiwygFormField.class.php @@ -400,7 +400,7 @@ public function getValue() public function getHtmlInputProcessor(): HtmlInputProcessor { if ($this->htmlInputProcessor === null) { - throw new \BadMethodCallException("The html input processor is not available before validate() has been called."); + throw new \BadMethodCallException("The HTML input processor is not available before validate() has been called."); } return $this->htmlInputProcessor; From 458e09de320fb84d6cc02f053c2a0d1ce641e0ec Mon Sep 17 00:00:00 2001 From: Alexander Ebert Date: Mon, 6 Jul 2026 17:21:18 +0200 Subject: [PATCH 19/36] Add `afterValidateCreate()`, restricted updates and counter increments --- .../files/lib/command/tag/UpdateTag.class.php | 1 + .../lib/data/DatabaseObjectBuilder.class.php | 70 +++++++++++-------- 2 files changed, 40 insertions(+), 31 deletions(-) diff --git a/wcfsetup/install/files/lib/command/tag/UpdateTag.class.php b/wcfsetup/install/files/lib/command/tag/UpdateTag.class.php index d98cd76709..3e45c8a257 100644 --- a/wcfsetup/install/files/lib/command/tag/UpdateTag.class.php +++ b/wcfsetup/install/files/lib/command/tag/UpdateTag.class.php @@ -23,6 +23,7 @@ public function __construct( public function __invoke(): Tag { + \assert($this->builder->isUpdate()); $tag = $this->builder->save(); EventHandler::getInstance()->fire(new TagUpdated($tag)); diff --git a/wcfsetup/install/files/lib/data/DatabaseObjectBuilder.class.php b/wcfsetup/install/files/lib/data/DatabaseObjectBuilder.class.php index 0302b3f574..bec298f121 100644 --- a/wcfsetup/install/files/lib/data/DatabaseObjectBuilder.class.php +++ b/wcfsetup/install/files/lib/data/DatabaseObjectBuilder.class.php @@ -30,6 +30,11 @@ abstract class DatabaseObjectBuilder */ protected array $customProperties = []; + /** + * @var array + */ + protected array $incrementProperties = []; + /** * Use forCreate() or forUpdate() to obtain a builder instance. * @@ -59,10 +64,11 @@ final public function save(): DatabaseObject private function create(): DatabaseObject { $this->validateCreate(); + $this->afterValidateCreate(); $keys = $values = ''; $statementParameters = []; - foreach (\array_merge($this->properties, $this->customProperties) as $key => $value) { + foreach (\array_merge($this->properties, $this->customProperties, $this->incrementProperties) as $key => $value) { if ($keys !== '') { $keys .= ','; $values .= ','; @@ -101,12 +107,12 @@ private function create(): DatabaseObject */ private function validateCreate(): void { - if ($this->properties === [] && $this->customProperties === []) { + if ($this->properties === [] && $this->customProperties === [] && $this->incrementProperties === []) { throw new \BadMethodCallException("Cannot create an object without any properties."); } foreach ($this->getRequiredProperties() as $property) { - if (!\array_key_exists($property, $this->properties)) { + if (!\array_key_exists($property, $this->properties) && !\array_key_exists($property, $this->incrementProperties)) { throw new \BadMethodCallException("Missing value for required property '{$property}'."); } } @@ -131,7 +137,7 @@ protected function getRequiredProperties(): array */ private function update(): DatabaseObject { - if ($this->properties !== [] || $this->customProperties !== []) { + if ($this->properties !== [] || $this->customProperties !== [] || $this->incrementProperties !== []) { $updateSQL = ''; $statementParameters = []; foreach (\array_merge($this->properties, $this->customProperties) as $key => $value) { @@ -141,6 +147,18 @@ private function update(): DatabaseObject $updateSQL .= $key . ' = ?'; $statementParameters[] = $value; } + foreach ($this->incrementProperties as $key => $value) { + if ($updateSQL !== '') { + $updateSQL .= ', '; + } + + $updateSQL .= \sprintf( + '%s = %s + ?', + $key, + $key, + ); + $statementParameters[] = $value; + } $statementParameters[] = $this->object->getObjectID(); $sql = "UPDATE " . static::getBaseClass()::getDatabaseTableName() . " @@ -281,6 +299,15 @@ final public function setCustomProperty(string $name, string|int|float|null $val return $this; } + /** + * This method is called after the properties have been validated. + * It can be overriden to handle additional tasks that are not handled by the default implementation. + */ + protected function afterValidateCreate(): void + { + // does nothing + } + /** * This method is called after the creation of a new object. * It can be overriden to handle additional tasks that are not handled by the default implementation. @@ -334,39 +361,20 @@ final public function setID(int|string $id): static return $this; } - /** - * Updates counters for the given object. - * - * @param TDatabaseObject $object - * @param non-empty-array $counters - */ - final public static function updateCounters(DatabaseObject $object, array $counters): void + final public function isUpdate(): bool { - \assert($object instanceof (static::getBaseClass())); - - $updateSQL = ''; - $statementParameters = []; - foreach ($counters as $key => $value) { - if ($updateSQL !== '') { - $updateSQL .= ', '; - } - $updateSQL .= $key . ' = ' . $key . ' + ?'; - $statementParameters[] = $value; - } - $statementParameters[] = $object->getObjectID(); - - $sql = "UPDATE " . static::getBaseClass()::getDatabaseTableName() . " - SET " . $updateSQL . " - WHERE " . static::getBaseClass()::getDatabaseTableIndexName() . " = ?"; - $statement = WCF::getDB()->prepare($sql); - $statement->execute($statementParameters); + return $this->object !== null; } /** - * @return ?TDatabaseObject + * @return TDatabaseObject */ - public function getObject(): ?DatabaseObject + final public function getObject(): DatabaseObject { + if ($this->object === null) { + throw new \BadMethodCallException('The object can only be retrieved for builders created with `forUpdate()`.'); + } + return $this->object; } } From 95c2d025206e9012af9bfdfe89e31b13f5fce950 Mon Sep 17 00:00:00 2001 From: Alexander Ebert Date: Mon, 6 Jul 2026 18:08:44 +0200 Subject: [PATCH 20/36] Call `create()` and `update()` directly to improve safety This avoids manual checks that the correct type of builder is inserted into `Create*` or `Update*` commands. --- .../files/lib/command/tag/CreateTag.class.php | 2 +- .../files/lib/command/tag/UpdateTag.class.php | 3 +-- .../lib/data/DatabaseObjectBuilder.class.php | 24 +++++++------------ .../files/lib/data/tag/TagBuilder.class.php | 4 ++-- ...bstractDatabaseObjectBuilderForm.class.php | 8 ++++++- 5 files changed, 20 insertions(+), 21 deletions(-) diff --git a/wcfsetup/install/files/lib/command/tag/CreateTag.class.php b/wcfsetup/install/files/lib/command/tag/CreateTag.class.php index d1610dd668..6c55de5d98 100644 --- a/wcfsetup/install/files/lib/command/tag/CreateTag.class.php +++ b/wcfsetup/install/files/lib/command/tag/CreateTag.class.php @@ -23,7 +23,7 @@ public function __construct( public function __invoke(): Tag { - $tag = $this->builder->save(); + $tag = $this->builder->create(); EventHandler::getInstance()->fire(new TagCreated($tag)); diff --git a/wcfsetup/install/files/lib/command/tag/UpdateTag.class.php b/wcfsetup/install/files/lib/command/tag/UpdateTag.class.php index 3e45c8a257..db6592c969 100644 --- a/wcfsetup/install/files/lib/command/tag/UpdateTag.class.php +++ b/wcfsetup/install/files/lib/command/tag/UpdateTag.class.php @@ -23,8 +23,7 @@ public function __construct( public function __invoke(): Tag { - \assert($this->builder->isUpdate()); - $tag = $this->builder->save(); + $tag = $this->builder->update(); EventHandler::getInstance()->fire(new TagUpdated($tag)); diff --git a/wcfsetup/install/files/lib/data/DatabaseObjectBuilder.class.php b/wcfsetup/install/files/lib/data/DatabaseObjectBuilder.class.php index bec298f121..8da0411dc7 100644 --- a/wcfsetup/install/files/lib/data/DatabaseObjectBuilder.class.php +++ b/wcfsetup/install/files/lib/data/DatabaseObjectBuilder.class.php @@ -43,26 +43,16 @@ abstract class DatabaseObjectBuilder private function __construct(protected readonly ?DatabaseObject $object = null) {} /** - * Persists the pending changes and returns the resulting database object. + * Inserts a new row and returns the created database object. * * @return TDatabaseObject */ - final public function save(): DatabaseObject + final public function create(): DatabaseObject { if ($this->object !== null) { - return $this->update(); + throw new \BadMethodCallException("create() can only be used with forCreate()."); } - return $this->create(); - } - - /** - * Inserts a new row and returns the primary key of the created object. - * - * @return TDatabaseObject - */ - private function create(): DatabaseObject - { $this->validateCreate(); $this->afterValidateCreate(); @@ -135,8 +125,12 @@ protected function getRequiredProperties(): array * * @return TDatabaseObject */ - private function update(): DatabaseObject + final public function update(): DatabaseObject { + if ($this->object === null) { + throw new \BadMethodCallException("update() can only be used with forUpdate()."); + } + if ($this->properties !== [] || $this->customProperties !== [] || $this->incrementProperties !== []) { $updateSQL = ''; $statementParameters = []; @@ -189,7 +183,7 @@ final public function createOrIgnore(): ?DatabaseObject } try { - return $this->save(); + return $this->create(); } catch (DatabaseQueryExecutionException $e) { // Error code 23000 = duplicate key if (\intval($e->getCode()) === 23000 && $e->getDriverCode() === '1062') { diff --git a/wcfsetup/install/files/lib/data/tag/TagBuilder.class.php b/wcfsetup/install/files/lib/data/tag/TagBuilder.class.php index 87c625149d..57db6c7be7 100644 --- a/wcfsetup/install/files/lib/data/tag/TagBuilder.class.php +++ b/wcfsetup/install/files/lib/data/tag/TagBuilder.class.php @@ -102,11 +102,11 @@ private function saveSynonyms(Tag $tag, array $synonyms): void ->setName($synonym) ->setLanguageID($tag->languageID) ->setSynonymFor($tag) - ->save(); + ->create(); } else { TagBuilder::forUpdate($synonymObj) ->setSynonymFor($tag) - ->save(); + ->update(); } } } diff --git a/wcfsetup/install/files/lib/form/AbstractDatabaseObjectBuilderForm.class.php b/wcfsetup/install/files/lib/form/AbstractDatabaseObjectBuilderForm.class.php index 6a2c04568f..0896888f20 100644 --- a/wcfsetup/install/files/lib/form/AbstractDatabaseObjectBuilderForm.class.php +++ b/wcfsetup/install/files/lib/form/AbstractDatabaseObjectBuilderForm.class.php @@ -79,7 +79,13 @@ abstract protected function getDatabaseObjectBuilder(): DatabaseObjectBuilder; */ protected function getCommand(DatabaseObjectBuilder $builder): callable { - return static fn() => $builder->save(); + return function () use ($builder) { + if ($builder->isUpdate()) { + return $builder->update(); + } + + return $builder->create(); + }; } #[\Override] From bec93c5e067f3f8a60cb2bfae9d1600fa37d9d6e Mon Sep 17 00:00:00 2001 From: Alexander Ebert Date: Wed, 8 Jul 2026 15:15:16 +0200 Subject: [PATCH 21/36] Infer the type of the form field --- .../files/lib/system/form/builder/field/IFormField.class.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/wcfsetup/install/files/lib/system/form/builder/field/IFormField.class.php b/wcfsetup/install/files/lib/system/form/builder/field/IFormField.class.php index 2517015c53..81499d168c 100644 --- a/wcfsetup/install/files/lib/system/form/builder/field/IFormField.class.php +++ b/wcfsetup/install/files/lib/system/form/builder/field/IFormField.class.php @@ -113,8 +113,7 @@ public function getValue(); * without triggering a contravariance error. * * @template TBuilder of DatabaseObjectBuilder - * @template TIFormField of IFormField - * @param \Closure(TBuilder, TIFormField): void $callback + * @param \Closure(TBuilder, static): void $callback * @return static this field * @since 6.3 */ From d23f6dd6000db9df5d5f53da347780b8bb19adfa Mon Sep 17 00:00:00 2001 From: Alexander Ebert Date: Sat, 11 Jul 2026 13:31:35 +0200 Subject: [PATCH 22/36] Add typings for the save value, move callbacks into a separate interface --- .../lib/data/TCollectionCoverPhotos.class.php | 7 +- .../wysiwyg/WysiwygFormContainer.class.php | 45 ++++++++- .../WysiwygPollFormContainer.class.php | 22 +++++ .../field/AbstractNumericFormField.class.php | 9 +- .../field/BadgeColorFormField.class.php | 3 + .../builder/field/BooleanFormField.class.php | 3 + .../builder/field/CheckboxFormField.class.php | 3 + .../builder/field/CurrencyFormField.class.php | 3 + .../builder/field/DateFormField.class.php | 5 +- .../field/DateRangeFormField.class.php | 3 + .../form/builder/field/IBuilderNode.class.php | 96 +++++++++++++++++++ .../form/builder/field/IFormField.class.php | 77 +-------------- .../builder/field/IconFormField.class.php | 3 + .../builder/field/IntegerFormField.class.php | 2 + .../builder/field/ItemListFormField.class.php | 3 + .../field/NumericRangeFormField.class.php | 3 + .../field/ShowOrderFormField.class.php | 5 +- .../field/SingleSelectionFormField.class.php | 5 +- .../builder/field/TextFormField.class.php | 2 + 19 files changed, 215 insertions(+), 84 deletions(-) create mode 100644 wcfsetup/install/files/lib/system/form/builder/field/IBuilderNode.class.php diff --git a/wcfsetup/install/files/lib/data/TCollectionCoverPhotos.class.php b/wcfsetup/install/files/lib/data/TCollectionCoverPhotos.class.php index bf9042952d..6d91899d7e 100644 --- a/wcfsetup/install/files/lib/data/TCollectionCoverPhotos.class.php +++ b/wcfsetup/install/files/lib/data/TCollectionCoverPhotos.class.php @@ -27,7 +27,12 @@ public function getCoverPhoto( ): ?FileCoverPhoto { $this->loadCoverPhotos($coverPhotoIdProperty); - return $this->coverPhotos[$object->{$coverPhotoIdProperty}] ?? null; + $coverPhotoFileID = $object->{$coverPhotoIdProperty}; + if ($coverPhotoFileID === null) { + return null; + } + + return $this->coverPhotos[$coverPhotoFileID] ?? null; } private function loadCoverPhotos(string $coverPhotoIdProperty): void diff --git a/wcfsetup/install/files/lib/system/form/builder/container/wysiwyg/WysiwygFormContainer.class.php b/wcfsetup/install/files/lib/system/form/builder/container/wysiwyg/WysiwygFormContainer.class.php index 82097d436c..4569cb6eba 100644 --- a/wcfsetup/install/files/lib/system/form/builder/container/wysiwyg/WysiwygFormContainer.class.php +++ b/wcfsetup/install/files/lib/system/form/builder/container/wysiwyg/WysiwygFormContainer.class.php @@ -8,6 +8,7 @@ use wcf\system\event\EventHandler; use wcf\system\form\builder\button\wysiwyg\WysiwygPreviewFormButton; use wcf\system\form\builder\container\FormContainer; +use wcf\system\form\builder\field\IBuilderNode; use wcf\system\form\builder\field\TMaximumLengthFormField; use wcf\system\form\builder\field\TMinimumLengthFormField; use wcf\system\form\builder\field\wysiwyg\WysiwygAttachmentFormField; @@ -30,7 +31,7 @@ * @license GNU Lesser General Public License * @since 5.2 */ -class WysiwygFormContainer extends FormContainer +class WysiwygFormContainer extends FormContainer implements IBuilderNode { use TMaximumLengthFormField; use TMinimumLengthFormField; @@ -152,6 +153,20 @@ class WysiwygFormContainer extends FormContainer protected WysiwygQuoteFormContainer $quoteContainer; + /** + * callback transferring this field's save value into a `DatabaseObjectBuilder` + * @var ?\Closure(\wcf\data\DatabaseObjectBuilder<*>, IFormField): void + * @since 6.3 + */ + protected ?\Closure $saveValueCallback = null; + + /** + * callback loading this field's value from an `IStorableObject` + * @var ?\Closure(\wcf\data\IStorableObject, IFormField): void + * @since 6.3 + */ + protected ?\Closure $loadValueCallback = null; + /** * @return static */ @@ -734,4 +749,32 @@ public function supportSmilies(bool $supportSmilies = true) return $this; } + + #[\Override] + public function saveValueCallback(\Closure $callback): static + { + $this->saveValueCallback = $callback; + + return $this; + } + + #[\Override] + public function getSaveValueCallback(): ?\Closure + { + return $this->saveValueCallback; + } + + #[\Override] + public function loadValueCallback(\Closure $callback): static + { + $this->loadValueCallback = $callback; + + return $this; + } + + #[\Override] + public function getLoadValueCallback(): ?\Closure + { + return $this->loadValueCallback; + } } diff --git a/wcfsetup/install/files/lib/system/form/builder/container/wysiwyg/WysiwygPollFormContainer.class.php b/wcfsetup/install/files/lib/system/form/builder/container/wysiwyg/WysiwygPollFormContainer.class.php index f0a99d7114..19c644b888 100644 --- a/wcfsetup/install/files/lib/system/form/builder/container/wysiwyg/WysiwygPollFormContainer.class.php +++ b/wcfsetup/install/files/lib/system/form/builder/container/wysiwyg/WysiwygPollFormContainer.class.php @@ -7,6 +7,7 @@ use wcf\data\poll\Poll; use wcf\system\form\builder\container\FormContainer; use wcf\system\form\builder\data\processor\CustomFormDataProcessor; +use wcf\system\form\builder\field\AbstractFormField; use wcf\system\form\builder\field\BooleanFormField; use wcf\system\form\builder\field\DateFormField; use wcf\system\form\builder\field\IntegerFormField; @@ -378,4 +379,25 @@ function (IFormDocument $document, array $parameters) use ($id) { return $this; } + + public function getPollData(): array + { + if (!$this->isAvailable()) { + return []; + } + + $wysiwygId = $this->getWysiwygId(); + + $pollData = []; + foreach ($this->children() as $child) { + \assert($child instanceof AbstractFormField); + $pollData[$child->getId()] = $child->getSaveValue(); + } + + // this will always add a poll array to the parameters but + // `PollManager::savePoll()` is capable of correctly detecting + // when, based on the given data, nothing has to be done + + return $pollData; + } } diff --git a/wcfsetup/install/files/lib/system/form/builder/field/AbstractNumericFormField.class.php b/wcfsetup/install/files/lib/system/form/builder/field/AbstractNumericFormField.class.php index 4960b78376..4cc1b19623 100644 --- a/wcfsetup/install/files/lib/system/form/builder/field/AbstractNumericFormField.class.php +++ b/wcfsetup/install/files/lib/system/form/builder/field/AbstractNumericFormField.class.php @@ -53,7 +53,7 @@ abstract class AbstractNumericFormField extends AbstractFormField implements /** * step value for the input element - * @var null|number + * @var ?int */ protected $step; @@ -70,7 +70,7 @@ public function __construct() /** * Returns the default value for the input element's step attribute. * - * @return number|string + * @return int|string */ protected function getDefaultStep() { @@ -81,6 +81,9 @@ protected function getDefaultStep() } } + /** + * @return int|float + */ #[\Override] public function getSaveValue() { @@ -102,7 +105,7 @@ public function getSaveValue() * If no step value has been set, the return value of `getDefaultStep()` * is set and returned. * - * @return number|string + * @return int|string */ public function getStep() { diff --git a/wcfsetup/install/files/lib/system/form/builder/field/BadgeColorFormField.class.php b/wcfsetup/install/files/lib/system/form/builder/field/BadgeColorFormField.class.php index d2c004ae98..68c0b6bc9b 100644 --- a/wcfsetup/install/files/lib/system/form/builder/field/BadgeColorFormField.class.php +++ b/wcfsetup/install/files/lib/system/form/builder/field/BadgeColorFormField.class.php @@ -100,6 +100,9 @@ public function value(mixed $value) return $this; } + /** + * @return string + */ #[\Override] public function getSaveValue() { diff --git a/wcfsetup/install/files/lib/system/form/builder/field/BooleanFormField.class.php b/wcfsetup/install/files/lib/system/form/builder/field/BooleanFormField.class.php index e1e11e835e..65b14885ba 100644 --- a/wcfsetup/install/files/lib/system/form/builder/field/BooleanFormField.class.php +++ b/wcfsetup/install/files/lib/system/form/builder/field/BooleanFormField.class.php @@ -34,6 +34,9 @@ class BooleanFormField extends AbstractFormField implements */ protected $templateName = 'shared_booleanFormField'; + /** + * @return 0|1 + */ #[\Override] public function getSaveValue() { diff --git a/wcfsetup/install/files/lib/system/form/builder/field/CheckboxFormField.class.php b/wcfsetup/install/files/lib/system/form/builder/field/CheckboxFormField.class.php index bac6aa0b95..cc1ff708d5 100644 --- a/wcfsetup/install/files/lib/system/form/builder/field/CheckboxFormField.class.php +++ b/wcfsetup/install/files/lib/system/form/builder/field/CheckboxFormField.class.php @@ -29,6 +29,9 @@ public function readValue() return $this; } + /** + * @return 0|1|null + */ #[\Override] public function getSaveValue() { diff --git a/wcfsetup/install/files/lib/system/form/builder/field/CurrencyFormField.class.php b/wcfsetup/install/files/lib/system/form/builder/field/CurrencyFormField.class.php index 3813a205da..694f883310 100644 --- a/wcfsetup/install/files/lib/system/form/builder/field/CurrencyFormField.class.php +++ b/wcfsetup/install/files/lib/system/form/builder/field/CurrencyFormField.class.php @@ -16,6 +16,9 @@ */ class CurrencyFormField extends AbstractNumericFormField { + /** + * @return 0|float + */ #[\Override] public function getSaveValue() { diff --git a/wcfsetup/install/files/lib/system/form/builder/field/DateFormField.class.php b/wcfsetup/install/files/lib/system/form/builder/field/DateFormField.class.php index 85d3c15e68..f114d37d41 100644 --- a/wcfsetup/install/files/lib/system/form/builder/field/DateFormField.class.php +++ b/wcfsetup/install/files/lib/system/form/builder/field/DateFormField.class.php @@ -218,12 +218,15 @@ protected function getValueDateTimeObject() return $dateTime; } + /** + * @return ?string + */ #[\Override] public function getSaveValue() { if ($this->getValue() === null) { if ($this->isNullable()) { - return; + return null; } else { return DateUtil::getDateTimeByTimestamp(0)->format($this->getSaveValueFormat()); } diff --git a/wcfsetup/install/files/lib/system/form/builder/field/DateRangeFormField.class.php b/wcfsetup/install/files/lib/system/form/builder/field/DateRangeFormField.class.php index 312f29dfbf..5d8ab7ab95 100644 --- a/wcfsetup/install/files/lib/system/form/builder/field/DateRangeFormField.class.php +++ b/wcfsetup/install/files/lib/system/form/builder/field/DateRangeFormField.class.php @@ -45,6 +45,9 @@ class DateRangeFormField extends AbstractFormField implements const TIME_FORMAT = 'Y-m-d\TH:i:sP'; + /** + * @return ?string + */ #[\Override] public function getSaveValue() { diff --git a/wcfsetup/install/files/lib/system/form/builder/field/IBuilderNode.class.php b/wcfsetup/install/files/lib/system/form/builder/field/IBuilderNode.class.php new file mode 100644 index 0000000000..00e2ee5533 --- /dev/null +++ b/wcfsetup/install/files/lib/system/form/builder/field/IBuilderNode.class.php @@ -0,0 +1,96 @@ + + * @since 6.3 + */ +interface IBuilderNode +{ + /** + * Sets a callback that transfers this field's save value into a + * `DatabaseObjectBuilder` instance and returns this field. + * + * The callback is invoked by `DatabaseObjectBuilderFormDocument` when the + * builder is populated from the form's fields, for example: + * + * $field->saveValueCallback( + * static function (DatabaseObjectBuilder $builder, IFormField $formField) { + * return $builder->setName($formField->getSaveValue()); + * } + * ) + * + * The builder type is a template parameter so that the callback may narrow + * it to a concrete `DatabaseObjectBuilder` implementation (e.g. `TagBuilder`) + * without triggering a contravariance error. + * + * @template TBuilder of DatabaseObjectBuilder + * @param \Closure(TBuilder, static): void $callback + * @return static this field + * @since 6.3 + */ + public function saveValueCallback(\Closure $callback): static; + + /** + * Returns the callback set via `saveValueCallback()` or `null` if no such + * callback has been set. + * + * @return ?\Closure(DatabaseObjectBuilder<*>, IFormField): void + * @since 6.3 + */ + public function getSaveValueCallback(): ?\Closure; + + /** + * Sets a callback that loads this field's value from an `IStorableObject` + * and returns this field. + * + * This is the counterpart to `saveValueCallback()`: while the save callback + * writes the field's value into a builder, this callback reads the value + * back out of an existing object when an edit form is populated. It is + * invoked by `updatedObject()` and is expected to assign the value via + * `$field->value()`, for example: + * + * $field->loadValueCallback( + * static function (Tag $object, IFormField $formField) { + * $formField->value($object->name); + * } + * ) + * + * When a callback is set it takes precedence over the default behaviour of + * loading the value from the object property named after this field. Use it + * when the value cannot be read from a single property, e.g. when it must be + * derived from a related object or an additional query. + * + * The object type is a template parameter so that the callback may narrow it + * to a concrete `IStorableObject` implementation (e.g. `Tag`) without + * triggering a contravariance error. + * + * @template TObject of IStorableObject + * @template TIFormField of IFormField + * @param \Closure(TObject, TIFormField): void $callback + * @return static this field + * @since 6.3 + */ + public function loadValueCallback(\Closure $callback): static; + + /** + * Returns the callback set via `loadValueCallback()` or `null` if no such + * callback has been set. + * + * @return ?\Closure(IStorableObject, IFormField): void + * @since 6.3 + */ + public function getLoadValueCallback(): ?\Closure; +} diff --git a/wcfsetup/install/files/lib/system/form/builder/field/IFormField.class.php b/wcfsetup/install/files/lib/system/form/builder/field/IFormField.class.php index 81499d168c..859965917b 100644 --- a/wcfsetup/install/files/lib/system/form/builder/field/IFormField.class.php +++ b/wcfsetup/install/files/lib/system/form/builder/field/IFormField.class.php @@ -17,7 +17,7 @@ * @license GNU Lesser General Public License * @since 5.2 */ -interface IFormField extends IFormChildNode, IFormElement +interface IFormField extends IBuilderNode, IFormChildNode, IFormElement { /** * Adds the given validation error to this field and returns this field. @@ -95,81 +95,6 @@ public function getValidators(); */ public function getValue(); - /** - * Sets a callback that transfers this field's save value into a - * `DatabaseObjectBuilder` instance and returns this field. - * - * The callback is invoked by `DatabaseObjectBuilderFormDocument` when the - * builder is populated from the form's fields, for example: - * - * $field->saveValueCallback( - * static function (DatabaseObjectBuilder $builder, IFormField $formField) { - * return $builder->setName($formField->getSaveValue()); - * } - * ) - * - * The builder type is a template parameter so that the callback may narrow - * it to a concrete `DatabaseObjectBuilder` implementation (e.g. `TagBuilder`) - * without triggering a contravariance error. - * - * @template TBuilder of DatabaseObjectBuilder - * @param \Closure(TBuilder, static): void $callback - * @return static this field - * @since 6.3 - */ - public function saveValueCallback(\Closure $callback): static; - - /** - * Returns the callback set via `saveValueCallback()` or `null` if no such - * callback has been set. - * - * @return ?\Closure(DatabaseObjectBuilder<*>, IFormField): void - * @since 6.3 - */ - public function getSaveValueCallback(): ?\Closure; - - /** - * Sets a callback that loads this field's value from an `IStorableObject` - * and returns this field. - * - * This is the counterpart to `saveValueCallback()`: while the save callback - * writes the field's value into a builder, this callback reads the value - * back out of an existing object when an edit form is populated. It is - * invoked by `updatedObject()` and is expected to assign the value via - * `$field->value()`, for example: - * - * $field->loadValueCallback( - * static function (Tag $object, IFormField $formField) { - * $formField->value($object->name); - * } - * ) - * - * When a callback is set it takes precedence over the default behaviour of - * loading the value from the object property named after this field. Use it - * when the value cannot be read from a single property, e.g. when it must be - * derived from a related object or an additional query. - * - * The object type is a template parameter so that the callback may narrow it - * to a concrete `IStorableObject` implementation (e.g. `Tag`) without - * triggering a contravariance error. - * - * @template TObject of IStorableObject - * @template TIFormField of IFormField - * @param \Closure(TObject, TIFormField): void $callback - * @return static this field - * @since 6.3 - */ - public function loadValueCallback(\Closure $callback): static; - - /** - * Returns the callback set via `loadValueCallback()` or `null` if no such - * callback has been set. - * - * @return ?\Closure(IStorableObject, IFormField): void - * @since 6.3 - */ - public function getLoadValueCallback(): ?\Closure; - /** * Returns `true` if this field has a validator with the given id and * returns `false` otherwise. diff --git a/wcfsetup/install/files/lib/system/form/builder/field/IconFormField.class.php b/wcfsetup/install/files/lib/system/form/builder/field/IconFormField.class.php index 7ca864e417..c6573e6452 100644 --- a/wcfsetup/install/files/lib/system/form/builder/field/IconFormField.class.php +++ b/wcfsetup/install/files/lib/system/form/builder/field/IconFormField.class.php @@ -47,6 +47,9 @@ public function getHtmlVariables() ]; } + /** + * @return string + */ #[\Override] public function getSaveValue() { diff --git a/wcfsetup/install/files/lib/system/form/builder/field/IntegerFormField.class.php b/wcfsetup/install/files/lib/system/form/builder/field/IntegerFormField.class.php index f322716dfc..a28b390e0e 100644 --- a/wcfsetup/install/files/lib/system/form/builder/field/IntegerFormField.class.php +++ b/wcfsetup/install/files/lib/system/form/builder/field/IntegerFormField.class.php @@ -11,6 +11,8 @@ * @copyright 2001-2019 WoltLab GmbH * @license GNU Lesser General Public License * @since 5.2 + * + * @method int getSaveValue() */ class IntegerFormField extends AbstractNumericFormField { diff --git a/wcfsetup/install/files/lib/system/form/builder/field/ItemListFormField.class.php b/wcfsetup/install/files/lib/system/form/builder/field/ItemListFormField.class.php index 662f56fdf2..74453dacbf 100644 --- a/wcfsetup/install/files/lib/system/form/builder/field/ItemListFormField.class.php +++ b/wcfsetup/install/files/lib/system/form/builder/field/ItemListFormField.class.php @@ -78,6 +78,9 @@ public function __construct() $this->addFieldClass('long'); } + /** + * @return string + */ #[\Override] public function getSaveValue() { diff --git a/wcfsetup/install/files/lib/system/form/builder/field/NumericRangeFormField.class.php b/wcfsetup/install/files/lib/system/form/builder/field/NumericRangeFormField.class.php index d5a2746eb7..a6874065e2 100644 --- a/wcfsetup/install/files/lib/system/form/builder/field/NumericRangeFormField.class.php +++ b/wcfsetup/install/files/lib/system/form/builder/field/NumericRangeFormField.class.php @@ -49,6 +49,9 @@ public function __construct() $this->addFieldClass('short'); } + /** + * @return ?string + */ #[\Override] public function getSaveValue() { diff --git a/wcfsetup/install/files/lib/system/form/builder/field/ShowOrderFormField.class.php b/wcfsetup/install/files/lib/system/form/builder/field/ShowOrderFormField.class.php index 0495859715..f8fd48e9f4 100644 --- a/wcfsetup/install/files/lib/system/form/builder/field/ShowOrderFormField.class.php +++ b/wcfsetup/install/files/lib/system/form/builder/field/ShowOrderFormField.class.php @@ -45,6 +45,9 @@ public function __construct() $this->label('wcf.form.field.showOrder'); } + /** + * @return ?int + */ #[\Override] public function getSaveValue() { @@ -55,7 +58,7 @@ public function getSaveValue() return $index + 1; } - return; + return null; } return $this->value; diff --git a/wcfsetup/install/files/lib/system/form/builder/field/SingleSelectionFormField.class.php b/wcfsetup/install/files/lib/system/form/builder/field/SingleSelectionFormField.class.php index 474ac41376..e8a18c9230 100644 --- a/wcfsetup/install/files/lib/system/form/builder/field/SingleSelectionFormField.class.php +++ b/wcfsetup/install/files/lib/system/form/builder/field/SingleSelectionFormField.class.php @@ -35,6 +35,9 @@ class SingleSelectionFormField extends AbstractFormField implements */ protected $templateName = 'shared_singleSelectionFormField'; + /** + * @return ?string + */ #[\Override] public function getSaveValue() { @@ -43,7 +46,7 @@ public function getSaveValue() && isset($this->getOptions()[$this->getValue()]) && $this->isNullable() ) { - return; + return null; } return parent::getSaveValue(); 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 d57691ccb1..ef451e63bc 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 @@ -13,6 +13,8 @@ * @copyright 2001-2019 WoltLab GmbH * @license GNU Lesser General Public License * @since 5.2 + * + * @method string getSaveValue() */ class TextFormField extends AbstractFormField implements IAttributeFormField, From b16a3727463051cd89bf8903b182d06c0a0e5039 Mon Sep 17 00:00:00 2001 From: Alexander Ebert Date: Sat, 11 Jul 2026 14:08:32 +0200 Subject: [PATCH 23/36] Improve the type inference --- .../lib/system/form/builder/field/IBuilderNode.class.php | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/wcfsetup/install/files/lib/system/form/builder/field/IBuilderNode.class.php b/wcfsetup/install/files/lib/system/form/builder/field/IBuilderNode.class.php index 00e2ee5533..45da7e6ff0 100644 --- a/wcfsetup/install/files/lib/system/form/builder/field/IBuilderNode.class.php +++ b/wcfsetup/install/files/lib/system/form/builder/field/IBuilderNode.class.php @@ -47,7 +47,7 @@ public function saveValueCallback(\Closure $callback): static; * Returns the callback set via `saveValueCallback()` or `null` if no such * callback has been set. * - * @return ?\Closure(DatabaseObjectBuilder<*>, IFormField): void + * @return ?\Closure(DatabaseObjectBuilder<*>, static): void * @since 6.3 */ public function getSaveValueCallback(): ?\Closure; @@ -78,8 +78,7 @@ public function getSaveValueCallback(): ?\Closure; * triggering a contravariance error. * * @template TObject of IStorableObject - * @template TIFormField of IFormField - * @param \Closure(TObject, TIFormField): void $callback + * @param \Closure(TObject, static): void $callback * @return static this field * @since 6.3 */ @@ -89,7 +88,7 @@ public function loadValueCallback(\Closure $callback): static; * Returns the callback set via `loadValueCallback()` or `null` if no such * callback has been set. * - * @return ?\Closure(IStorableObject, IFormField): void + * @return ?\Closure(IStorableObject, static): void * @since 6.3 */ public function getLoadValueCallback(): ?\Closure; From c0ffbd89272224eaec8115995f145ebd4b5f0f8b Mon Sep 17 00:00:00 2001 From: Alexander Ebert Date: Sat, 11 Jul 2026 14:20:03 +0200 Subject: [PATCH 24/36] Explicitly provide the date format The constant had been deprecated due to being misleading in how it works (implicitly requiring the date to be in UTC). --- wcfsetup/install/files/lib/action/FileDownloadAction.class.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/wcfsetup/install/files/lib/action/FileDownloadAction.class.php b/wcfsetup/install/files/lib/action/FileDownloadAction.class.php index 7d422a0953..ac1476cab8 100644 --- a/wcfsetup/install/files/lib/action/FileDownloadAction.class.php +++ b/wcfsetup/install/files/lib/action/FileDownloadAction.class.php @@ -118,7 +118,8 @@ public function handle(ServerRequestInterface $request): ResponseInterface if ($lifetimeInSeconds !== null) { $expiresAt = (new \DateTimeImmutable('@' . \TIME_NOW)) ->modify("+{$lifetimeInSeconds} seconds") - ->format(\DateTimeImmutable::RFC7231); + ->setTimezone(new \DateTimeZone('UTC')) + ->format('D, d M Y H:i:s \\G\\M\\T'); $maxAge = \sprintf( 'max-age=%d, private', $lifetimeInSeconds ?: 0, From af64cee0307bee3117567441718a27cb4caee7d2 Mon Sep 17 00:00:00 2001 From: Alexander Ebert Date: Sun, 12 Jul 2026 12:10:48 +0200 Subject: [PATCH 25/36] Fix the handling of polls --- ...atabaseObjectBuilderFormDocument.class.php | 14 ++++--- .../wysiwyg/WysiwygFormContainer.class.php | 4 ++ .../WysiwygPollFormContainer.class.php | 38 ++++++++++++++++++- 3 files changed, 48 insertions(+), 8 deletions(-) diff --git a/wcfsetup/install/files/lib/system/form/builder/DatabaseObjectBuilderFormDocument.class.php b/wcfsetup/install/files/lib/system/form/builder/DatabaseObjectBuilderFormDocument.class.php index 9d3ab27d74..ce417cd17c 100644 --- a/wcfsetup/install/files/lib/system/form/builder/DatabaseObjectBuilderFormDocument.class.php +++ b/wcfsetup/install/files/lib/system/form/builder/DatabaseObjectBuilderFormDocument.class.php @@ -3,7 +3,7 @@ namespace wcf\system\form\builder; use wcf\data\DatabaseObjectBuilder; -use wcf\system\form\builder\field\IFormField; +use wcf\system\form\builder\field\IBuilderNode; /** * Represents a form document whose field values are written into a @@ -55,15 +55,17 @@ protected function applyNodeValues(IFormNode $node, DatabaseObjectBuilder $build return; } - if ($node instanceof IFormParentNode) { - foreach ($node as $childNode) { - $this->applyNodeValues($childNode, $builder); - } - } elseif ($node instanceof IFormField) { + if ($node instanceof IBuilderNode) { $callback = $node->getSaveValueCallback(); if ($callback !== null) { $callback($builder, $node); } } + + if ($node instanceof IFormParentNode) { + foreach ($node as $childNode) { + $this->applyNodeValues($childNode, $builder); + } + } } } diff --git a/wcfsetup/install/files/lib/system/form/builder/container/wysiwyg/WysiwygFormContainer.class.php b/wcfsetup/install/files/lib/system/form/builder/container/wysiwyg/WysiwygFormContainer.class.php index 4569cb6eba..6ff593bc14 100644 --- a/wcfsetup/install/files/lib/system/form/builder/container/wysiwyg/WysiwygFormContainer.class.php +++ b/wcfsetup/install/files/lib/system/form/builder/container/wysiwyg/WysiwygFormContainer.class.php @@ -511,6 +511,10 @@ public function updatedObject(array $data, IStorableObject $object, bool $loadVa $this->setAttachmentHandler(); + if ($this->loadValueCallback !== null) { + ($this->loadValueCallback)($object, $this); + } + return parent::updatedObject($data, $object); } diff --git a/wcfsetup/install/files/lib/system/form/builder/container/wysiwyg/WysiwygPollFormContainer.class.php b/wcfsetup/install/files/lib/system/form/builder/container/wysiwyg/WysiwygPollFormContainer.class.php index 19c644b888..a1499f7d91 100644 --- a/wcfsetup/install/files/lib/system/form/builder/container/wysiwyg/WysiwygPollFormContainer.class.php +++ b/wcfsetup/install/files/lib/system/form/builder/container/wysiwyg/WysiwygPollFormContainer.class.php @@ -380,18 +380,30 @@ function (IFormDocument $document, array $parameters) use ($id) { return $this; } + /** + * Returns the unprefixed poll data for use with AbstractDatabaseObjectBuilderForm. + * + * @return array + * @since 6.3 + */ public function getPollData(): array { if (!$this->isAvailable()) { return []; } - $wysiwygId = $this->getWysiwygId(); + $id = $this->wysiwygId . 'poll'; $pollData = []; foreach ($this->children() as $child) { \assert($child instanceof AbstractFormField); - $pollData[$child->getId()] = $child->getSaveValue(); + $name = \lcfirst( + \substr( + $child->getId(), + \strlen($id), + ) + ); + $pollData[$name] = $child->getSaveValue(); } // this will always add a poll array to the parameters but @@ -400,4 +412,26 @@ public function getPollData(): array return $pollData; } + + /** + * Sets the poll using the data for use with AbstractDatabaseObjectBuilderForm. + * + * @since 6.3 + */ + public function setPoll(Poll $poll): void + { + $this->poll = $poll; + + // `isPublic` cannot be changed when editing polls + $this->getIsPublicField()->available(false); + + $this->getQuestionField()->value($this->poll->question); + $this->getOptionsField()->value($this->poll->getOptions()); + $this->getEndTimeField()->value($this->poll->endTime); + $this->getMaxVotesField()->value($this->poll->maxVotes); + $this->getIsChangeableField()->value($this->poll->isChangeable); + $this->getIsPublicField()->value($this->poll->isPublic); + $this->getResultsRequireVoteField()->value($this->poll->resultsRequireVote); + $this->getSortByVotesField()->value($this->poll->sortByVotes); + } } From af8abc845430e91ec42ef91fabd62884ebb84a66 Mon Sep 17 00:00:00 2001 From: Alexander Ebert Date: Sun, 12 Jul 2026 13:42:53 +0200 Subject: [PATCH 26/36] Fix the type inference --- .../acp/form/DevtoolsProjectAddForm.class.php | 1 - .../wysiwyg/WysiwygFormContainer.class.php | 4 ++-- .../builder/field/AbstractFormField.class.php | 4 ++-- .../AbstractFormFieldDecorator.class.php | 22 ++++++++++++++++++- .../builder/field/TextFormField.class.php | 2 +- 5 files changed, 26 insertions(+), 7 deletions(-) diff --git a/wcfsetup/install/files/lib/acp/form/DevtoolsProjectAddForm.class.php b/wcfsetup/install/files/lib/acp/form/DevtoolsProjectAddForm.class.php index ce6e01bafa..3a36dd5166 100644 --- a/wcfsetup/install/files/lib/acp/form/DevtoolsProjectAddForm.class.php +++ b/wcfsetup/install/files/lib/acp/form/DevtoolsProjectAddForm.class.php @@ -2,7 +2,6 @@ namespace wcf\acp\form; -use wcf\data\AbstractDatabaseObjectAction; use wcf\data\devtools\project\DevtoolsProject; use wcf\data\devtools\project\DevtoolsProjectAction; use wcf\data\devtools\project\DevtoolsProjectList; diff --git a/wcfsetup/install/files/lib/system/form/builder/container/wysiwyg/WysiwygFormContainer.class.php b/wcfsetup/install/files/lib/system/form/builder/container/wysiwyg/WysiwygFormContainer.class.php index 6ff593bc14..d2b8a6934c 100644 --- a/wcfsetup/install/files/lib/system/form/builder/container/wysiwyg/WysiwygFormContainer.class.php +++ b/wcfsetup/install/files/lib/system/form/builder/container/wysiwyg/WysiwygFormContainer.class.php @@ -155,14 +155,14 @@ class WysiwygFormContainer extends FormContainer implements IBuilderNode /** * callback transferring this field's save value into a `DatabaseObjectBuilder` - * @var ?\Closure(\wcf\data\DatabaseObjectBuilder<*>, IFormField): void + * @var ?\Closure(\wcf\data\DatabaseObjectBuilder<*>, static): void * @since 6.3 */ protected ?\Closure $saveValueCallback = null; /** * callback loading this field's value from an `IStorableObject` - * @var ?\Closure(\wcf\data\IStorableObject, IFormField): void + * @var ?\Closure(\wcf\data\IStorableObject, static): void * @since 6.3 */ protected ?\Closure $loadValueCallback = null; diff --git a/wcfsetup/install/files/lib/system/form/builder/field/AbstractFormField.class.php b/wcfsetup/install/files/lib/system/form/builder/field/AbstractFormField.class.php index b0e3860d44..fdde9fdc19 100644 --- a/wcfsetup/install/files/lib/system/form/builder/field/AbstractFormField.class.php +++ b/wcfsetup/install/files/lib/system/form/builder/field/AbstractFormField.class.php @@ -73,14 +73,14 @@ abstract class AbstractFormField implements IFormField /** * callback transferring this field's save value into a `DatabaseObjectBuilder` - * @var ?\Closure(\wcf\data\DatabaseObjectBuilder<*>, IFormField): void + * @var ?\Closure(\wcf\data\DatabaseObjectBuilder<*>, static): void * @since 6.3 */ protected ?\Closure $saveValueCallback = null; /** * callback loading this field's value from an `IStorableObject` - * @var ?\Closure(\wcf\data\IStorableObject, IFormField): void + * @var ?\Closure(\wcf\data\IStorableObject, static): void * @since 6.3 */ protected ?\Closure $loadValueCallback = null; diff --git a/wcfsetup/install/files/lib/system/form/builder/field/AbstractFormFieldDecorator.class.php b/wcfsetup/install/files/lib/system/form/builder/field/AbstractFormFieldDecorator.class.php index 5ed8d4b772..d31c55ea23 100644 --- a/wcfsetup/install/files/lib/system/form/builder/field/AbstractFormFieldDecorator.class.php +++ b/wcfsetup/install/files/lib/system/form/builder/field/AbstractFormFieldDecorator.class.php @@ -2,6 +2,7 @@ namespace wcf\system\form\builder\field; +use wcf\data\DatabaseObjectBuilder; use wcf\data\IStorableObject; use wcf\system\form\builder\field\dependency\IFormFieldDependency; use wcf\system\form\builder\field\validation\IFormFieldValidationError; @@ -16,14 +17,19 @@ * @copyright 2001-2021 WoltLab GmbH * @license GNU Lesser General Public License * @since 5.4 + * + * @template T of IFormField */ abstract class AbstractFormFieldDecorator implements IFormField { /** - * @var IFormField + * @var T */ protected $field; + /** + * @param T $field + */ public function __construct(IFormField $field) { $this->field = $field; @@ -87,6 +93,10 @@ public function getValue() return $this->field->getValue(); } + /** + * @template TBuilder of DatabaseObjectBuilder + * @param \Closure(TBuilder, T): void $callback + */ #[\Override] public function saveValueCallback(\Closure $callback): static { @@ -95,12 +105,19 @@ public function saveValueCallback(\Closure $callback): static return $this; } + /** + * @return ?\Closure(DatabaseObjectBuilder<*>, T): void + */ #[\Override] public function getSaveValueCallback(): ?\Closure { return $this->field->getSaveValueCallback(); } + /** + * @template TObject of IStorableObject + * @param \Closure(TObject, T): void $callback + */ #[\Override] public function loadValueCallback(\Closure $callback): static { @@ -109,6 +126,9 @@ public function loadValueCallback(\Closure $callback): static return $this; } + /** + * @return ?\Closure(IStorableObject, T): void + */ #[\Override] public function getLoadValueCallback(): ?\Closure { 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 ef451e63bc..e24bf8fd43 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 @@ -14,7 +14,7 @@ * @license GNU Lesser General Public License * @since 5.2 * - * @method string getSaveValue() + * @method ?string getSaveValue() */ class TextFormField extends AbstractFormField implements IAttributeFormField, From 8f9f2934d6c7a2336bbb234fb3acb39181dfc530 Mon Sep 17 00:00:00 2001 From: Marcel Werk Date: Mon, 13 Jul 2026 19:57:15 +0200 Subject: [PATCH 27/36] Improve documentation of `DatabaseObjectBuilder:: afterValidateCreate()` --- wcfsetup/install/files/lib/data/DatabaseObjectBuilder.class.php | 1 + 1 file changed, 1 insertion(+) diff --git a/wcfsetup/install/files/lib/data/DatabaseObjectBuilder.class.php b/wcfsetup/install/files/lib/data/DatabaseObjectBuilder.class.php index 8da0411dc7..158afa7f43 100644 --- a/wcfsetup/install/files/lib/data/DatabaseObjectBuilder.class.php +++ b/wcfsetup/install/files/lib/data/DatabaseObjectBuilder.class.php @@ -296,6 +296,7 @@ final public function setCustomProperty(string $name, string|int|float|null $val /** * This method is called after the properties have been validated. * It can be overriden to handle additional tasks that are not handled by the default implementation. + * You SHOULD NOT modify any properties in this method. */ protected function afterValidateCreate(): void { From a898462386c569942cef31fe737fc8afbdee7078 Mon Sep 17 00:00:00 2001 From: Marcel Werk Date: Wed, 15 Jul 2026 11:21:11 +0200 Subject: [PATCH 28/36] Use asymmetric visibility for builder properties --- .../install/files/lib/data/DatabaseObjectBuilder.class.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/wcfsetup/install/files/lib/data/DatabaseObjectBuilder.class.php b/wcfsetup/install/files/lib/data/DatabaseObjectBuilder.class.php index 158afa7f43..01b1065597 100644 --- a/wcfsetup/install/files/lib/data/DatabaseObjectBuilder.class.php +++ b/wcfsetup/install/files/lib/data/DatabaseObjectBuilder.class.php @@ -23,17 +23,17 @@ abstract class DatabaseObjectBuilder /** * @var array */ - protected array $properties = []; + public protected(set) array $properties = []; /** * @var array */ - protected array $customProperties = []; + public protected(set) array $customProperties = []; /** * @var array */ - protected array $incrementProperties = []; + public protected(set) array $incrementProperties = []; /** * Use forCreate() or forUpdate() to obtain a builder instance. From 9f97f23f803c195f21d01dfac1815e5b7e686dae Mon Sep 17 00:00:00 2001 From: Marcel Werk Date: Wed, 15 Jul 2026 15:56:03 +0200 Subject: [PATCH 29/36] Pass builder to file events --- wcfsetup/install/files/lib/command/tag/CreateTag.class.php | 2 +- wcfsetup/install/files/lib/command/tag/UpdateTag.class.php | 2 +- wcfsetup/install/files/lib/event/tag/TagCreated.class.php | 4 +++- wcfsetup/install/files/lib/event/tag/TagUpdated.class.php | 4 +++- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/wcfsetup/install/files/lib/command/tag/CreateTag.class.php b/wcfsetup/install/files/lib/command/tag/CreateTag.class.php index 6c55de5d98..90e3e6c1a3 100644 --- a/wcfsetup/install/files/lib/command/tag/CreateTag.class.php +++ b/wcfsetup/install/files/lib/command/tag/CreateTag.class.php @@ -25,7 +25,7 @@ public function __invoke(): Tag { $tag = $this->builder->create(); - EventHandler::getInstance()->fire(new TagCreated($tag)); + EventHandler::getInstance()->fire(new TagCreated($tag, $this->builder)); return $tag; } diff --git a/wcfsetup/install/files/lib/command/tag/UpdateTag.class.php b/wcfsetup/install/files/lib/command/tag/UpdateTag.class.php index db6592c969..2bec246192 100644 --- a/wcfsetup/install/files/lib/command/tag/UpdateTag.class.php +++ b/wcfsetup/install/files/lib/command/tag/UpdateTag.class.php @@ -25,7 +25,7 @@ public function __invoke(): Tag { $tag = $this->builder->update(); - EventHandler::getInstance()->fire(new TagUpdated($tag)); + EventHandler::getInstance()->fire(new TagUpdated($tag, $this->builder)); return $tag; } diff --git a/wcfsetup/install/files/lib/event/tag/TagCreated.class.php b/wcfsetup/install/files/lib/event/tag/TagCreated.class.php index cc1760b0c6..d8b26513c9 100644 --- a/wcfsetup/install/files/lib/event/tag/TagCreated.class.php +++ b/wcfsetup/install/files/lib/event/tag/TagCreated.class.php @@ -3,6 +3,7 @@ namespace wcf\event\tag; use wcf\data\tag\Tag; +use wcf\data\tag\TagBuilder; use wcf\event\IPsr14Event; /** @@ -16,6 +17,7 @@ final class TagCreated implements IPsr14Event { public function __construct( - public readonly Tag $tag + public readonly Tag $tag, + public readonly TagBuilder $builder, ) {} } diff --git a/wcfsetup/install/files/lib/event/tag/TagUpdated.class.php b/wcfsetup/install/files/lib/event/tag/TagUpdated.class.php index bbe312305f..75c7a5c09e 100644 --- a/wcfsetup/install/files/lib/event/tag/TagUpdated.class.php +++ b/wcfsetup/install/files/lib/event/tag/TagUpdated.class.php @@ -3,6 +3,7 @@ namespace wcf\event\tag; use wcf\data\tag\Tag; +use wcf\data\tag\TagBuilder; use wcf\event\IPsr14Event; /** @@ -16,6 +17,7 @@ final class TagUpdated implements IPsr14Event { public function __construct( - public readonly Tag $tag + public readonly Tag $tag, + public readonly TagBuilder $builder, ) {} } From bec8e8a0aea88d935efc40bde690fa688a7e895d Mon Sep 17 00:00:00 2001 From: Marcel Werk Date: Wed, 15 Jul 2026 16:15:56 +0200 Subject: [PATCH 30/36] Prevent reuse of a consumed builder --- .../lib/data/DatabaseObjectBuilder.class.php | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/wcfsetup/install/files/lib/data/DatabaseObjectBuilder.class.php b/wcfsetup/install/files/lib/data/DatabaseObjectBuilder.class.php index 01b1065597..3e3bac6841 100644 --- a/wcfsetup/install/files/lib/data/DatabaseObjectBuilder.class.php +++ b/wcfsetup/install/files/lib/data/DatabaseObjectBuilder.class.php @@ -35,6 +35,8 @@ abstract class DatabaseObjectBuilder */ public protected(set) array $incrementProperties = []; + private bool $consumed = false; + /** * Use forCreate() or forUpdate() to obtain a builder instance. * @@ -53,6 +55,8 @@ final public function create(): DatabaseObject throw new \BadMethodCallException("create() can only be used with forCreate()."); } + $this->markConsumed(); + $this->validateCreate(); $this->afterValidateCreate(); @@ -131,6 +135,8 @@ final public function update(): DatabaseObject throw new \BadMethodCallException("update() can only be used with forUpdate()."); } + $this->markConsumed(); + if ($this->properties !== [] || $this->customProperties !== [] || $this->incrementProperties !== []) { $updateSQL = ''; $statementParameters = []; @@ -171,6 +177,21 @@ final public function update(): DatabaseObject return $object; } + /** + * Marks this builder as consumed, preventing it from being reused. A + * builder instance may only be executed once via create() or update(). + * + * @throws \BadMethodCallException if the builder has already been consumed + */ + private function markConsumed(): void + { + if ($this->consumed) { + throw new \BadMethodCallException('This builder has already been consumed and cannot be reused.'); + } + + $this->consumed = true; + } + /** * Creates a new object, returns null if the row already exists. * From 1a8f791fbe51d58c555253f1fbebfc91418c5dfd Mon Sep 17 00:00:00 2001 From: Marcel Werk Date: Sun, 19 Jul 2026 14:21:28 +0200 Subject: [PATCH 31/36] Associate WYSIWYG embedded content with the edited object --- .../wysiwyg/WysiwygFormContainer.class.php | 2 +- .../field/wysiwyg/WysiwygFormField.class.php | 20 ++++++++++++++++++- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/wcfsetup/install/files/lib/system/form/builder/container/wysiwyg/WysiwygFormContainer.class.php b/wcfsetup/install/files/lib/system/form/builder/container/wysiwyg/WysiwygFormContainer.class.php index d2b8a6934c..354701fb06 100644 --- a/wcfsetup/install/files/lib/system/form/builder/container/wysiwyg/WysiwygFormContainer.class.php +++ b/wcfsetup/install/files/lib/system/form/builder/container/wysiwyg/WysiwygFormContainer.class.php @@ -515,7 +515,7 @@ public function updatedObject(array $data, IStorableObject $object, bool $loadVa ($this->loadValueCallback)($object, $this); } - return parent::updatedObject($data, $object); + return parent::updatedObject($data, $object, $loadValues); } /** diff --git a/wcfsetup/install/files/lib/system/form/builder/field/wysiwyg/WysiwygFormField.class.php b/wcfsetup/install/files/lib/system/form/builder/field/wysiwyg/WysiwygFormField.class.php index a4be54229c..fc84fa3653 100644 --- a/wcfsetup/install/files/lib/system/form/builder/field/wysiwyg/WysiwygFormField.class.php +++ b/wcfsetup/install/files/lib/system/form/builder/field/wysiwyg/WysiwygFormField.class.php @@ -2,6 +2,7 @@ namespace wcf\system\form\builder\field\wysiwyg; +use wcf\data\IStorableObject; use wcf\system\bbcode\BBCodeHandler; use wcf\system\form\builder\data\processor\CustomFormDataProcessor; use wcf\system\form\builder\field\AbstractFormField; @@ -92,6 +93,12 @@ final class WysiwygFormField extends AbstractFormField implements */ protected $templateName = 'shared_wysiwygFormField'; + /** + * Id of the edited object. + * @since 6.3 + */ + protected ?int $objectID = null; + public function __construct() { // WYSIWYG form fields use the censorship function by default for backward compatibility reasons. @@ -341,7 +348,7 @@ public function validate() )); $this->htmlInputProcessor = new HtmlInputProcessor(); - $this->htmlInputProcessor->process($this->getValue(), $this->getObjectType()->objectType); + $this->htmlInputProcessor->process($this->getValue(), $this->getObjectType()->objectType, $this->objectID ?? 0); if ($this->isRequired() && $this->htmlInputProcessor->appearsToBeEmpty()) { $this->addValidationError(new FormFieldValidationError('empty')); @@ -405,4 +412,15 @@ public function getHtmlInputProcessor(): HtmlInputProcessor return $this->htmlInputProcessor; } + + /** + * @since 6.3 + */ + #[\Override] + public function updatedObject(array $data, IStorableObject $object, bool $loadValues = true) + { + $this->objectID = $object->{$object::getDatabaseTableIndexName()}; + + return parent::updatedObject($data, $object, $loadValues); + } } From 2cb24e5c69278b12f0ceef088ca26952e663ba0c Mon Sep 17 00:00:00 2001 From: Marcel Werk Date: Sun, 19 Jul 2026 15:47:41 +0200 Subject: [PATCH 32/36] Refactor `ArticleAddForm` to builder pattern --- .../lib/acp/form/ArticleAddForm.class.php | 526 ++++++++++-------- .../lib/acp/form/ArticleEditForm.class.php | 107 +--- .../command/article/CreateArticle.class.php | 76 +++ .../command/article/UpdateArticle.class.php | 152 +++++ .../lib/data/article/ArticleBuilder.class.php | 208 +++++++ .../content/ArticleContentBuilder.class.php | 226 ++++++++ .../files/lib/form/ArticleAddForm.class.php | 15 +- 7 files changed, 961 insertions(+), 349 deletions(-) create mode 100644 wcfsetup/install/files/lib/command/article/CreateArticle.class.php create mode 100644 wcfsetup/install/files/lib/command/article/UpdateArticle.class.php create mode 100644 wcfsetup/install/files/lib/data/article/ArticleBuilder.class.php create mode 100644 wcfsetup/install/files/lib/data/article/content/ArticleContentBuilder.class.php diff --git a/wcfsetup/install/files/lib/acp/form/ArticleAddForm.class.php b/wcfsetup/install/files/lib/acp/form/ArticleAddForm.class.php index 9590b3ac2c..73eedd0afb 100644 --- a/wcfsetup/install/files/lib/acp/form/ArticleAddForm.class.php +++ b/wcfsetup/install/files/lib/acp/form/ArticleAddForm.class.php @@ -2,21 +2,25 @@ namespace wcf\acp\form; -use wcf\command\article\MarkArticleAsRead; +use wcf\command\article\CreateArticle; +use wcf\command\article\UpdateArticle; use wcf\data\article\Article; -use wcf\data\article\ArticleAction; +use wcf\data\article\ArticleBuilder; use wcf\data\article\category\ArticleCategory; +use wcf\data\article\content\ArticleContent; use wcf\data\article\content\ArticleContentEditor; use wcf\data\category\CategoryNodeTree; +use wcf\data\DatabaseObjectBuilder; +use wcf\data\language\Language; use wcf\data\user\User; -use wcf\form\AbstractFormBuilderForm; +use wcf\form\AbstractDatabaseObjectBuilderForm; use wcf\system\cache\builder\ArticleCategoryLabelCacheBuilder; use wcf\system\exception\NamedUserException; use wcf\system\form\builder\container\FormContainer; use wcf\system\form\builder\container\TabFormContainer; use wcf\system\form\builder\container\TabMenuFormContainer; use wcf\system\form\builder\container\wysiwyg\WysiwygFormContainer; -use wcf\system\form\builder\data\processor\CustomFormDataProcessor; +use wcf\system\form\builder\field\AbstractFormField; use wcf\system\form\builder\field\BooleanFormField; use wcf\system\form\builder\field\DateFormField; use wcf\system\form\builder\field\dependency\ValueFormFieldDependency; @@ -33,11 +37,13 @@ use wcf\system\form\builder\field\user\UserFormField; 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\form\builder\field\wysiwyg\WysiwygAttachmentFormField; +use wcf\system\form\builder\field\wysiwyg\WysiwygFormField; use wcf\system\label\LabelHandler; use wcf\system\label\object\ArticleLabelObjectHandler; use wcf\system\language\LanguageFactory; use wcf\system\request\LinkHandler; +use wcf\system\tagging\TagEngine; use wcf\system\WCF; use wcf\util\HeaderUtil; use wcf\util\HtmlString; @@ -50,9 +56,9 @@ * @copyright 2001-2019 WoltLab GmbH * @license GNU Lesser General Public License * - * @extends AbstractFormBuilderForm
+ * @extends AbstractDatabaseObjectBuilderForm */ -class ArticleAddForm extends AbstractFormBuilderForm +class ArticleAddForm extends AbstractDatabaseObjectBuilderForm { /** * @inheritDoc @@ -76,12 +82,7 @@ class ArticleAddForm extends AbstractFormBuilderForm /** * @inheritDoc */ - public $objectActionClass = ArticleAction::class; - - /** - * @inheritDoc - */ - public $objectEditLinkController = ArticleEditForm::class; + public string $objectEditLinkController = ArticleEditForm::class; /** * true if created article is multi-lingual @@ -147,7 +148,10 @@ protected function createForm(): void $this->form->appendChildren([ HiddenFormField::create('isMultilingual') - ->value($this->isMultilingual), + ->value($this->isMultilingual) + ->saveValueCallback(static function (ArticleBuilder $builder, IFormField $field) { + $builder->setIsMultilingual((bool)$field->getSaveValue()); + }), FormContainer::create('information') ->label('wcf.global.category') ->appendChildren([ @@ -162,12 +166,24 @@ protected function createForm(): void if ($category === null || !$category->isAccessible()) { $field->addValidationError(new FormFieldValidationError('invalid')); } - })), + })) + ->saveValueCallback(static function (ArticleBuilder $builder, IFormField $field) { + $builder->setCategory(ArticleCategory::getCategory((int)$field->getSaveValue())); + }) + ->loadValueCallback(static function (Article $object, IFormField $field) { + $field->value($object->categoryID); + }), ...$labelFormFields, UserFormField::create('userID') ->label('wcf.acp.article.author') ->required() - ->value(WCF::getUser()->userID), + ->value(WCF::getUser()->userID) + ->saveValueCallback(static function (ArticleBuilder $builder, UserFormField $field) { + $builder->setUser(new User((int)$field->getSaveValue())); + }) + ->loadValueCallback(static function (Article $object, IFormField $field) { + $field->value($object->userID); + }), DateFormField::create('time') ->supportTime() ->required() @@ -181,7 +197,13 @@ protected function createForm(): void if ($status === Article::PUBLISHED && (int)$field->getSaveValue() > \TIME_NOW) { $field->addValidationError(new FormFieldValidationError('invalid')); } - })), + })) + ->saveValueCallback(static function (ArticleBuilder $builder, IFormField $field) { + $builder->setTime((int)$field->getSaveValue()); + }) + ->loadValueCallback(static function (Article $object, IFormField $field) { + $field->value($object->time); + }), RadioButtonFormField::create('publicationStatus') ->label('wcf.acp.article.publicationStatus') ->options([ @@ -191,7 +213,25 @@ protected function createForm(): void ]) ->available($canManage) ->value(Article::PUBLISHED) - ->required(), + ->required() + // The publication status drives the publication date; the + // date is only relevant for a delayed publication. + ->saveValueCallback(static function (ArticleBuilder $builder, IFormField $field) { + $status = (int)$field->getSaveValue(); + $builder->setPublicationStatus($status); + + $dateField = $field->getDocument()->getFormField('publicationDate'); + $builder->setPublicationDate( + $status === Article::DELAYED_PUBLICATION + && $dateField !== null + && $dateField->getSaveValue() + ? (int)$dateField->getSaveValue() + : 0 + ); + }) + ->loadValueCallback(static function (Article $object, IFormField $field) { + $field->value($object->publicationStatus); + }), DateFormField::create('publicationDate') ->supportTime() ->required() @@ -209,10 +249,21 @@ protected function createForm(): void ) { $field->addValidationError(new FormFieldValidationError('invalid')); } - })), + })) + ->loadValueCallback(static function (Article $object, IFormField $field) { + if ($object->publicationDate !== 0) { + $field->value($object->publicationDate); + } + }), BooleanFormField::create('enableComments') ->label('wcf.acp.article.enableComments') - ->value(\ARTICLE_ENABLE_COMMENTS_DEFAULT_VALUE), + ->value(\ARTICLE_ENABLE_COMMENTS_DEFAULT_VALUE) + ->saveValueCallback(static function (ArticleBuilder $builder, IFormField $field) { + $builder->setEnableComments((bool)$field->getSaveValue()); + }) + ->loadValueCallback(static function (Article $object, IFormField $field) { + $field->value((bool)$object->enableComments); + }), ]), ]); @@ -260,7 +311,22 @@ protected function createLabelFormFields(): array ->fieldId('categoryID') ->values($categoryIDs) ) - ->labelGroup($labelGroup); + ->labelGroup($labelGroup) + ->saveValueCallback(static function (ArticleBuilder $builder, IFormField $field) { + // `-1` and `0` are special values that are irrelevant for saving. + $labelID = (int)$field->getSaveValue(); + if ($labelID > 0) { + $builder->setLabelID($labelID); + } + }) + ->loadValueCallback(static function (Article $object, LabelFormField $field) use ($groupID) { + foreach ($object->getLabels() as $label) { + if ($label->groupID === $groupID) { + $field->value($label->labelID); + break; + } + } + }); } return $labelFormFields; @@ -268,46 +334,11 @@ protected function createLabelFormFields(): array protected function createMonolingualForm(): void { - $contentFields = []; - if (WCF::getSession()->hasPermission('admin.content.cms.canUseMedia')) { - $contentFields[] = SingleMediaSelectionFormField::create('imageID') - ->label('wcf.acp.article.image') - ->imageOnly(); - $contentFields[] = SingleMediaSelectionFormField::create('teaserImageID') - ->label('wcf.acp.article.teaserImage') - ->imageOnly(); - } - - $contentFields = \array_merge($contentFields, [ - TitleFormField::create('title') - ->required() - ->maximumLength(255), - TextFormField::create('slug') - ->label('wcf.acp.article.slug') - ->description('wcf.acp.article.slug.description') - ->maximumLength(255) - ->addValidator($this->getSlugValidator(null)), - MultilineTextFormField::create('teaser') - ->label('wcf.acp.article.teaser'), - TagFormField::create('tags') - ->available(\MODULE_TAGGING !== 0) - ->objectType('com.woltlab.wcf.article'), - TextFormField::create('metaTitle') - ->label('wcf.acp.article.metaTitle') - ->maximumLength(255), - MultilineTextFormField::create('metaDescription') - ->label('wcf.acp.article.metaDescription'), - ]); - $this->form->appendChildren([ FormContainer::create('contentSection') ->label('wcf.acp.article.content') - ->appendChildren($contentFields), - WysiwygFormContainer::create('content') - ->label('wcf.acp.article.content') - ->messageObjectType('com.woltlab.wcf.article.content') - ->attachmentData('com.woltlab.wcf.article.content', objectID: $this->getAttachmentObjectID()) - ->required(), + ->appendChildren($this->getContentFormFields(null)), + $this->createContentContainer(null), ]); } @@ -319,57 +350,171 @@ protected function createMultilingualForm(): void foreach (LanguageFactory::getInstance()->getLanguages() as $language) { $lc = $language->languageCode; - $contentFields = []; - if (WCF::getSession()->hasPermission('admin.content.cms.canUseMedia')) { - $contentFields[] = SingleMediaSelectionFormField::create("imageID_{$lc}") - ->label('wcf.acp.article.image') - ->imageOnly(); - $contentFields[] = SingleMediaSelectionFormField::create("teaserImageID_{$lc}") - ->label('wcf.acp.article.teaserImage') - ->imageOnly(); - } - - $contentFields = \array_merge($contentFields, [ - TitleFormField::create("title_{$lc}") - ->required() - ->maximumLength(255), - TextFormField::create("slug_{$lc}") - ->label('wcf.acp.article.slug') - ->description('wcf.acp.article.slug.description') - ->maximumLength(255) - ->addValidator($this->getSlugValidator($language->languageID)), - MultilineTextFormField::create("teaser_{$lc}") - ->label('wcf.acp.article.teaser'), - TagFormField::create("tags_{$lc}") - ->available(\MODULE_TAGGING !== 0) - ->objectType('com.woltlab.wcf.article'), - TextFormField::create("metaTitle_{$lc}") - ->label('wcf.acp.article.metaTitle') - ->maximumLength(255), - MultilineTextFormField::create("metaDescription_{$lc}") - ->label('wcf.acp.article.metaDescription'), - ]); - $tabContainer->appendChild( TabFormContainer::create("language_{$lc}") ->label($language->languageName) ->appendChildren([ FormContainer::create("contentSection_{$lc}") - ->appendChildren($contentFields), - WysiwygFormContainer::create("content_{$lc}") - ->label('wcf.acp.article.content') - ->messageObjectType('com.woltlab.wcf.article.content') - ->attachmentData( - 'com.woltlab.wcf.article.content', - objectID: $this->getAttachmentObjectID($language->languageID) - ) - ->required() - ->enablePreviewButton(false), + ->appendChildren($this->getContentFormFields($language)), + $this->createContentContainer($language), ]) ); } } + /** + * Returns the content form fields for the given language. Pass `null` for the + * monolingual content. + * + * @return AbstractFormField[] + */ + protected function getContentFormFields(?Language $language): array + { + $languageID = $language?->languageID; + $suffix = $language !== null ? "_{$language->languageCode}" : ''; + + $fields = []; + if (WCF::getSession()->hasPermission('admin.content.cms.canUseMedia')) { + $fields[] = SingleMediaSelectionFormField::create("imageID{$suffix}") + ->label('wcf.acp.article.image') + ->imageOnly() + ->saveValueCallback(function (ArticleBuilder $builder, IFormField $field) use ($languageID) { + $value = $field->getSaveValue(); + $builder->getArticleContentBuilder($languageID)->setImageID($value ? (int)$value : null); + }) + ->loadValueCallback(function (Article $object, IFormField $field) use ($languageID) { + $field->value($this->getArticleContent($object, $languageID)?->imageID); + }); + $fields[] = SingleMediaSelectionFormField::create("teaserImageID{$suffix}") + ->label('wcf.acp.article.teaserImage') + ->imageOnly() + ->saveValueCallback(function (ArticleBuilder $builder, IFormField $field) use ($languageID) { + $value = $field->getSaveValue(); + $builder->getArticleContentBuilder($languageID)->setTeaserImageID($value ? (int)$value : null); + }) + ->loadValueCallback(function (Article $object, IFormField $field) use ($languageID) { + $field->value($this->getArticleContent($object, $languageID)?->teaserImageID); + }); + } + + $fields[] = TitleFormField::create("title{$suffix}") + ->required() + ->maximumLength(255) + ->saveValueCallback(function (ArticleBuilder $builder, IFormField $field) use ($languageID) { + $builder->getArticleContentBuilder($languageID)->setTitle((string)$field->getSaveValue()); + }) + ->loadValueCallback(function (Article $object, IFormField $field) use ($languageID) { + $field->value($this->getArticleContent($object, $languageID)?->title); + }); + $fields[] = TextFormField::create("slug{$suffix}") + ->label('wcf.acp.article.slug') + ->description('wcf.acp.article.slug.description') + ->maximumLength(255) + ->addValidator($this->getSlugValidator($languageID)) + ->saveValueCallback(function (ArticleBuilder $builder, IFormField $field) use ($languageID) { + $builder->getArticleContentBuilder($languageID) + ->setSlug(\mb_strtolower(StringUtil::trim((string)$field->getSaveValue()))); + }) + ->loadValueCallback(function (Article $object, IFormField $field) use ($languageID) { + $field->value($this->getArticleContent($object, $languageID)?->slug); + }); + $fields[] = MultilineTextFormField::create("teaser{$suffix}") + ->label('wcf.acp.article.teaser') + ->saveValueCallback(function (ArticleBuilder $builder, IFormField $field) use ($languageID) { + $builder->getArticleContentBuilder($languageID)->setTeaser((string)$field->getSaveValue()); + }) + ->loadValueCallback(function (Article $object, IFormField $field) use ($languageID) { + $field->value($this->getArticleContent($object, $languageID)?->teaser); + }); + $fields[] = TagFormField::create("tags{$suffix}") + ->available(\MODULE_TAGGING !== 0) + ->objectType('com.woltlab.wcf.article') + ->saveValueCallback(function (ArticleBuilder $builder, IFormField $field) use ($languageID) { + $builder->getArticleContentBuilder($languageID)->setTags($field->getSaveValue() ?? []); + }) + ->loadValueCallback(function (Article $object, IFormField $field) use ($languageID) { + $content = $this->getArticleContent($object, $languageID); + if ($content === null) { + return; + } + + $field->value(\array_map( + static fn($tag) => $tag->name, + TagEngine::getInstance()->getObjectTags( + 'com.woltlab.wcf.article', + $content->articleContentID, + [$content->languageID ?: LanguageFactory::getInstance()->getDefaultLanguageID()] + ) + )); + }); + $fields[] = TextFormField::create("metaTitle{$suffix}") + ->label('wcf.acp.article.metaTitle') + ->maximumLength(255) + ->saveValueCallback(function (ArticleBuilder $builder, IFormField $field) use ($languageID) { + $builder->getArticleContentBuilder($languageID)->setMetaTitle((string)$field->getSaveValue()); + }) + ->loadValueCallback(function (Article $object, IFormField $field) use ($languageID) { + $field->value($this->getArticleContent($object, $languageID)?->metaTitle); + }); + $fields[] = MultilineTextFormField::create("metaDescription{$suffix}") + ->label('wcf.acp.article.metaDescription') + ->saveValueCallback(function (ArticleBuilder $builder, IFormField $field) use ($languageID) { + $builder->getArticleContentBuilder($languageID)->setMetaDescription((string)$field->getSaveValue()); + }) + ->loadValueCallback(function (Article $object, IFormField $field) use ($languageID) { + $field->value($this->getArticleContent($object, $languageID)?->metaDescription); + }); + + return $fields; + } + + /** + * Creates and configures the WYSIWYG container for the given language. Pass + * `null` for the monolingual content. + */ + protected function createContentContainer(?Language $language): WysiwygFormContainer + { + $languageID = $language?->languageID; + $suffix = $language !== null ? "_{$language->languageCode}" : ''; + + $container = WysiwygFormContainer::create("content{$suffix}") + ->label('wcf.acp.article.content') + ->messageObjectType('com.woltlab.wcf.article.content') + ->attachmentData('com.woltlab.wcf.article.content', objectID: $this->getAttachmentObjectID($languageID)) + ->required(); + if ($language !== null) { + $container->enablePreviewButton(false); + } + + $container->getWysiwygField() + ->saveValueCallback(function (ArticleBuilder $builder, WysiwygFormField $field) use ($languageID) { + $builder->getArticleContentBuilder($languageID)->setHtmlInputProcessor($field->getHtmlInputProcessor()); + }) + ->loadValueCallback(function (Article $object, IFormField $field) use ($languageID) { + $field->value($this->getArticleContent($object, $languageID)?->content); + }); + $container->getAttachmentField()->saveValueCallback( + function (ArticleBuilder $builder, WysiwygAttachmentFormField $field) use ($languageID) { + $builder->getArticleContentBuilder($languageID)->setAttachmentHandler($field->getAttachmentHandler()); + } + ); + + return $container; + } + + /** + * Returns the article content for the given language or `null` if it does not + * exist. Pass `null` for the monolingual content. + */ + protected function getArticleContent(Article $object, ?int $languageID): ?ArticleContent + { + if ($languageID !== null) { + return $object->getArticleContents()[$languageID] ?? null; + } + + return $object->getArticleContent(); + } + /** * Returns a validator ensuring that the article slug is properly formatted * and unique within the given language. @@ -401,154 +546,65 @@ protected function getSlugValidator(?int $languageID): FormFieldValidator } #[\Override] - public function finalizeForm(): void + protected function getDatabaseObjectBuilder(): ArticleBuilder { - parent::finalizeForm(); + $canManage = WCF::getSession()->hasPermission('admin.content.article.canManageArticle') + || WCF::getSession()->hasPermission('admin.content.article.canManageOwnArticles'); - $this->form->getDataHandler() - ->addProcessor( - new CustomFormDataProcessor( - 'authorProcessor', - function (IFormDocument $document, array $parameters) { - $user = new User($parameters['data']['userID']); - $parameters['data']['username'] = $user->username; + if ($this->formObject !== null) { + $builder = ArticleBuilder::forUpdate($this->formObject); + + // The labels are saved without validating permissions and the label + // form fields of label groups that the active user is not allowed to + // set are unavailable, thus their existing labels have to be preserved + // explicitly. + $optionID = LabelHandler::getInstance()->getOptionID('canSetLabel'); + $labelIDs = []; + $labels = ArticleLabelObjectHandler::getInstance()->getAssignedLabels( + [$this->formObject->articleID], + false + )[$this->formObject->articleID] ?? []; + foreach ($labels as $label) { + $labelGroup = LabelHandler::getInstance()->getLabelGroup($label->groupID); + if ( + $labelGroup !== null + && $labelGroup->hasPermissions() + && !$labelGroup->getPermission($optionID) + ) { + $labelIDs[] = $label->labelID; + } + } + $builder->setLabelIDs($labelIDs); - return $parameters; - } - ) - ) - ->addProcessor( - new CustomFormDataProcessor( - 'publicationDateProcessor', - static function (IFormDocument $document, array $parameters) { - if ( - !isset($parameters['data']['publicationDate']) - || $parameters['data']['publicationDate'] === '' - ) { - $parameters['data']['publicationDate'] = 0; - } + // Users without management permissions must not change the publication + // status; the existing values are preserved by not setting them because + // the corresponding form field is unavailable. - return $parameters; - } - ) - ) - ->addProcessor( - new CustomFormDataProcessor( - 'contentProcessor', - function (IFormDocument $document, array $parameters) { - $parameters['content'] = []; - - if ($this->isMultilingual) { - foreach (LanguageFactory::getInstance()->getLanguages() as $language) { - $lc = $language->languageCode; - $lid = $language->languageID; - - $parameters['content'][$lid] = [ - 'title' => $parameters['data']["title_{$lc}"] ?? '', - 'slug' => \mb_strtolower(StringUtil::trim($parameters['data']["slug_{$lc}"] ?? '')), - 'tags' => $parameters["tags_{$lc}"] ?? [], - 'teaser' => $parameters['data']["teaser_{$lc}"] ?? '', - 'content' => $parameters['data']["content_{$lc}"] ?? '', - 'htmlInputProcessor' => $parameters["content_{$lc}_htmlInputProcessor"] ?? null, - 'imageID' => $parameters['data']["imageID_{$lc}"] ?? null, - 'teaserImageID' => $parameters['data']["teaserImageID_{$lc}"] ?? null, - 'metaTitle' => $parameters['data']["metaTitle_{$lc}"] ?? '', - 'metaDescription' => $parameters['data']["metaDescription_{$lc}"] ?? '', - ]; - if (isset($parameters["content_{$lc}_attachmentHandler"])) { - $parameters['content'][$lid]['attachmentHandler'] = $parameters["content_{$lc}_attachmentHandler"]; - } - - unset( - $parameters['data']["title_{$lc}"], - $parameters['data']["slug_{$lc}"], - $parameters['data']["teaser_{$lc}"], - $parameters['data']["content_{$lc}"], - $parameters['data']["imageID_{$lc}"], - $parameters['data']["teaserImageID_{$lc}"], - $parameters['data']["metaTitle_{$lc}"], - $parameters['data']["metaDescription_{$lc}"], - $parameters["tags_{$lc}"], - $parameters["content_{$lc}_htmlInputProcessor"], - $parameters["content_{$lc}_attachmentHandler"], - ); - } - } else { - $parameters['content'][0] = [ - 'title' => $parameters['data']['title'] ?? '', - 'slug' => \mb_strtolower(StringUtil::trim($parameters['data']['slug'] ?? '')), - 'tags' => $parameters['tags'] ?? [], - 'teaser' => $parameters['data']['teaser'] ?? '', - 'content' => $parameters['data']['content'] ?? '', - 'htmlInputProcessor' => $parameters['content_htmlInputProcessor'] ?? null, - 'imageID' => $parameters['data']['imageID'] ?? null, - 'teaserImageID' => $parameters['data']['teaserImageID'] ?? null, - 'metaTitle' => $parameters['data']['metaTitle'] ?? '', - 'metaDescription' => $parameters['data']['metaDescription'] ?? '', - ]; - - if (isset($parameters['content_attachmentHandler'])) { - $parameters['content'][0]['attachmentHandler'] = $parameters['content_attachmentHandler']; - } + return $builder; + } - unset( - $parameters['data']['title'], - $parameters['data']['slug'], - $parameters['data']['teaser'], - $parameters['data']['content'], - $parameters['data']['imageID'], - $parameters['data']['teaserImageID'], - $parameters['data']['metaTitle'], - $parameters['data']['metaDescription'], - $parameters['tags'], - $parameters['content_htmlInputProcessor'], - $parameters['content_attachmentHandler'], - ); - } + $builder = ArticleBuilder::forCreate() + ->setUser(WCF::getUser()) + ->setTime(\TIME_NOW) + ->setIsMultilingual($this->isMultilingual === 1); - return $parameters; - } - ) - ) - ->addProcessor( - new CustomFormDataProcessor( - 'labelProcessor', - static function (IFormDocument $document, array $parameters) { - $parameters['labelIDs'] = $parameters['labelIDs'] ?? []; - $parameters['data']['hasLabels'] = $parameters['labelIDs'] !== [] ? 1 : 0; - - return $parameters; - } - ) - ); + if (!$canManage) { + $builder + ->setPublicationStatus(Article::UNPUBLISHED) + ->setPublicationDate(0); + } + + return $builder; } #[\Override] - public function save(): void + protected function getCommand(DatabaseObjectBuilder $builder): callable { - if ( - !WCF::getSession()->hasPermission('admin.content.article.canManageArticle') - && !WCF::getSession()->hasPermission('admin.content.article.canManageOwnArticles') - ) { - $this->additionalFields['publicationStatus'] = Article::UNPUBLISHED; - $this->additionalFields['publicationDate'] = 0; + if ($this->formObject !== null) { + return new UpdateArticle($builder); } - parent::save(); - - /** @var Article $article */ - $article = $this->objectAction->getReturnValues()['returnValues']; - - // save labels - $labelIDs = $this->objectAction->getParameters()['labelIDs'] ?? []; - if (!empty($labelIDs)) { - ArticleLabelObjectHandler::getInstance()->setLabels($labelIDs, $article->articleID); - } - - // mark published article as read - if ($article->publicationStatus == Article::PUBLISHED) { - (new MarkArticleAsRead($article))(); - } + return new CreateArticle($builder); } protected function getAttachmentObjectID(?int $languageID = null): ?int diff --git a/wcfsetup/install/files/lib/acp/form/ArticleEditForm.class.php b/wcfsetup/install/files/lib/acp/form/ArticleEditForm.class.php index 80c4237274..ab8a9e2385 100644 --- a/wcfsetup/install/files/lib/acp/form/ArticleEditForm.class.php +++ b/wcfsetup/install/files/lib/acp/form/ArticleEditForm.class.php @@ -4,18 +4,11 @@ use wcf\acp\page\ArticleListPage; use wcf\data\article\Article; -use wcf\data\IStorableObject; -use wcf\form\AbstractFormBuilderForm; use wcf\http\Helper; use wcf\system\exception\PermissionDeniedException; -use wcf\system\form\builder\data\processor\CustomFormDataProcessor; -use wcf\system\form\builder\IFormDocument; use wcf\system\interaction\admin\ArticleInteractions; use wcf\system\interaction\StandaloneInteractionContextMenuComponent; -use wcf\system\label\object\ArticleLabelObjectHandler; -use wcf\system\language\LanguageFactory; use wcf\system\request\LinkHandler; -use wcf\system\tagging\TagEngine; use wcf\system\version\VersionTracker; use wcf\system\WCF; @@ -36,7 +29,7 @@ class ArticleEditForm extends ArticleAddForm /** * @inheritDoc */ - public $formAction = 'edit'; + public string $formAction = 'edit'; #[\Override] public function readParameters(): void @@ -60,104 +53,6 @@ protected function readMultilingualSetting(): void // not required for editing } - #[\Override] - public function save(): void - { - if ( - !WCF::getSession()->hasPermission('admin.content.article.canManageArticle') - && !WCF::getSession()->hasPermission('admin.content.article.canManageOwnArticles') - ) { - $this->additionalFields['publicationStatus'] = $this->formObject->publicationStatus; - $this->additionalFields['publicationDate'] = $this->formObject->publicationDate; - } - - AbstractFormBuilderForm::save(); - - // save labels - $labelIDs = $this->objectAction->getParameters()['labelIDs'] ?? []; - ArticleLabelObjectHandler::getInstance()->setLabels($labelIDs, $this->formObject->articleID); - } - - #[\Override] - public function finalizeForm(): void - { - parent::finalizeForm(); - - $this->form->getDataHandler() - ->addProcessor( - new CustomFormDataProcessor( - 'editArticleProcessor', - // Save callback: preserve images when user can't use media - function (IFormDocument $document, array $parameters) { - if (!WCF::getSession()->hasPermission('admin.content.cms.canUseMedia')) { - foreach ($this->formObject->getArticleContents() as $languageID => $content) { - $key = $this->isMultilingual ? $languageID : 0; - if (isset($parameters['content'][$key])) { - $parameters['content'][$key]['imageID'] = $content->imageID; - $parameters['content'][$key]['teaserImageID'] = $content->teaserImageID; - } - } - } - - return $parameters; - }, - // Object callback: load article data for editing - function (IFormDocument $document, array $data, IStorableObject $object) { - \assert($object instanceof Article); - - if ($object->publicationDate === 0) { - unset($data['publicationDate']); - } - - foreach ($object->getArticleContents() as $languageID => $content) { - if ($this->isMultilingual) { - $language = LanguageFactory::getInstance()->getLanguage($languageID); - if ($language === null) { - continue; - } - $lc = $language->languageCode; - - $data["title_{$lc}"] = $content->title; - $data["slug_{$lc}"] = $content->slug; - $data["teaser_{$lc}"] = $content->teaser; - $data["content_{$lc}"] = $content->content; - $data["imageID_{$lc}"] = $content->imageID; - $data["teaserImageID_{$lc}"] = $content->teaserImageID; - $data["metaTitle_{$lc}"] = $content->metaTitle; - $data["metaDescription_{$lc}"] = $content->metaDescription; - - if (\MODULE_TAGGING) { - $data["tags_{$lc}"] = TagEngine::getInstance()->getObjectTags( - 'com.woltlab.wcf.article', - $content->articleContentID, - [$languageID ?: LanguageFactory::getInstance()->getDefaultLanguageID()] - ); - } - } else { - $data['title'] = $content->title; - $data['slug'] = $content->slug; - $data['teaser'] = $content->teaser; - $data['content'] = $content->content; - $data['imageID'] = $content->imageID; - $data['teaserImageID'] = $content->teaserImageID; - $data['metaTitle'] = $content->metaTitle; - $data['metaDescription'] = $content->metaDescription; - - if (\MODULE_TAGGING) { - $data['tags'] = TagEngine::getInstance()->getObjectTags( - 'com.woltlab.wcf.article', - $content->articleContentID, - ); - } - } - } - - return $data; - } - ) - ); - } - #[\Override] public function assignVariables(): void { diff --git a/wcfsetup/install/files/lib/command/article/CreateArticle.class.php b/wcfsetup/install/files/lib/command/article/CreateArticle.class.php new file mode 100644 index 0000000000..2d02a99a4c --- /dev/null +++ b/wcfsetup/install/files/lib/command/article/CreateArticle.class.php @@ -0,0 +1,76 @@ + + * @since 6.3 + */ +final class CreateArticle +{ + public function __construct( + private readonly ArticleBuilder $builder, + ) {} + + public function __invoke(): Article + { + $article = $this->builder->create(); + + $this->updateSearchIndex($article); + + (new ResetUserStorageForUnreadArticles())(); + + if ($article->publicationStatus == Article::PUBLISHED) { + ArticleEditor::updateArticleCounter([$article->userID => 1]); + + UserObjectWatchHandler::getInstance()->updateObject( + 'com.woltlab.wcf.article.category', + $article->getCategory()->categoryID, + 'article', + 'com.woltlab.wcf.article.notification', + new ArticleUserNotificationObject($article) + ); + + UserActivityEventHandler::getInstance()->fireEvent( + 'com.woltlab.wcf.article.recentActivityEvent', + $article->articleID, + null, + $article->userID, + $article->time + ); + + (new MarkArticleAsRead($article))(); + } + + return $article; + } + + private function updateSearchIndex(Article $article): void + { + foreach ($article->getArticleContents() as $content) { + SearchIndexManager::getInstance()->set( + 'com.woltlab.wcf.article', + $content->articleContentID, + $content->content ?? '', + $content->title, + $article->time, + $article->userID, + $article->username, + $content->languageID, + $content->teaser + ); + } + } +} diff --git a/wcfsetup/install/files/lib/command/article/UpdateArticle.class.php b/wcfsetup/install/files/lib/command/article/UpdateArticle.class.php new file mode 100644 index 0000000000..dae6d2d662 --- /dev/null +++ b/wcfsetup/install/files/lib/command/article/UpdateArticle.class.php @@ -0,0 +1,152 @@ + + * @since 6.3 + */ +final class UpdateArticle +{ + public function __construct( + private readonly ArticleBuilder $builder, + ) {} + + public function __invoke(): Article + { + $oldArticle = $this->builder->getObject(); + $oldStatus = $oldArticle->publicationStatus; + $oldUserID = $oldArticle->userID; + + // Capture the current content before it is overwritten so that the + // previous state can be stored as a version. + $versionData = []; + $hasChanges = false; + foreach ($this->builder->articleContentBuilders as $languageID => $articleContentBuilder) { + $oldContent = ArticleContent::getArticleContent($oldArticle->articleID, $languageID ?: null); + if ($oldContent === null) { + $hasChanges = true; + continue; + } + + $versionData[] = $oldContent; + if ( + $oldContent->content != $articleContentBuilder->getContent() + || $oldContent->teaser != $articleContentBuilder->getTeaser() + || $oldContent->title != $articleContentBuilder->getTitle() + ) { + $hasChanges = true; + } + } + + $article = $this->builder->update(); + + if ($hasChanges && $versionData !== []) { + $articleObj = new ArticleVersionTracker($article); + $articleObj->setContent($versionData); + VersionTracker::getInstance()->add('com.woltlab.wcf.article', $articleObj); + } + + if ($this->builder->articleContentBuilders !== []) { + $this->updateSearchIndex($article); + } + + (new ResetUserStorageForUnreadArticles())(); + + $newStatus = $this->builder->properties['publicationStatus'] ?? $oldStatus; + if ($newStatus != $oldStatus) { + $this->handlePublicationStatusChange($article, (int)$oldStatus, (int)$newStatus); + } + + $newUserID = $this->builder->properties['userID'] ?? $oldUserID; + if ($newUserID != $oldUserID) { + $this->updateActivityEventAuthor($article->articleID, (int)$newUserID); + } + + return $article; + } + + private function handlePublicationStatusChange(Article $article, int $oldStatus, int $newStatus): void + { + if ($newStatus == Article::PUBLISHED || $oldStatus == Article::PUBLISHED) { + ArticleEditor::updateArticleCounter([ + $article->userID => $newStatus == Article::PUBLISHED ? 1 : -1, + ]); + } + + if ($newStatus == Article::PUBLISHED) { + UserObjectWatchHandler::getInstance()->updateObject( + 'com.woltlab.wcf.article.category', + $article->getCategory()->categoryID, + 'article', + 'com.woltlab.wcf.article.notification', + new ArticleUserNotificationObject($article) + ); + + UserActivityEventHandler::getInstance()->fireEvent( + 'com.woltlab.wcf.article.recentActivityEvent', + $article->articleID, + null, + $article->userID, + $article->time + ); + } else { + UserNotificationHandler::getInstance()->removeNotifications( + 'com.woltlab.wcf.article.notification', + [$article->articleID] + ); + UserActivityEventHandler::getInstance()->removeEvents( + 'com.woltlab.wcf.article.recentActivityEvent', + [$article->articleID] + ); + } + } + + private function updateActivityEventAuthor(int $articleID, int $userID): void + { + $sql = "UPDATE wcf1_user_activity_event + SET userID = ? + WHERE objectTypeID = ? + AND objectID = ?"; + $statement = WCF::getDB()->prepare($sql); + $statement->execute([ + $userID, + UserActivityEventHandler::getInstance()->getObjectTypeID('com.woltlab.wcf.article.recentActivityEvent'), + $articleID, + ]); + } + + private function updateSearchIndex(Article $article): void + { + foreach ($article->getArticleContents() as $content) { + SearchIndexManager::getInstance()->set( + 'com.woltlab.wcf.article', + $content->articleContentID, + $content->content ?? '', + $content->title, + $article->time, + $article->userID, + $article->username, + $content->languageID, + $content->teaser + ); + } + } +} diff --git a/wcfsetup/install/files/lib/data/article/ArticleBuilder.class.php b/wcfsetup/install/files/lib/data/article/ArticleBuilder.class.php new file mode 100644 index 0000000000..4d1e75a613 --- /dev/null +++ b/wcfsetup/install/files/lib/data/article/ArticleBuilder.class.php @@ -0,0 +1,208 @@ + + * @since 6.3 + * + * @extends DatabaseObjectBuilder
+ */ +final class ArticleBuilder extends DatabaseObjectBuilder +{ + /** + * @var array + */ + public private(set) array $articleContentBuilders = []; + + /** + * @var list + */ + public private(set) array $labelIDs; + + public function setUser(User $user): static + { + $this->properties['userID'] = $user->userID; + $this->properties['username'] = $user->username; + + return $this; + } + + public function setUsername(string $username): static + { + $this->properties['username'] = $username; + + return $this; + } + + public function setTime(int $time): static + { + $this->properties['time'] = $time; + + return $this; + } + + public function setCategory(ArticleCategory $category): static + { + $this->properties['categoryID'] = $category->categoryID; + + return $this; + } + + public function setIsMultilingual(bool $isMultilingual): static + { + $this->properties['isMultilingual'] = $isMultilingual ? 1 : 0; + + return $this; + } + + public function setPublicationStatus(int $publicationStatus): static + { + $this->properties['publicationStatus'] = $publicationStatus; + + return $this; + } + + public function setPublicationDate(int $publicationDate): static + { + $this->properties['publicationDate'] = $publicationDate; + + return $this; + } + + public function setEnableComments(bool $enableComments): static + { + $this->properties['enableComments'] = $enableComments ? 1 : 0; + + return $this; + } + + public function setIsDeleted(bool $isDeleted): static + { + $this->properties['isDeleted'] = $isDeleted ? 1 : 0; + + return $this; + } + + public function setHasLabels(bool $hasLabels): static + { + $this->properties['hasLabels'] = $hasLabels ? 1 : 0; + + return $this; + } + + public function incrementViews(int $views): static + { + $this->incrementProperties['views'] = $views; + + return $this; + } + + public function incrementReactions(int $reactions): static + { + $this->incrementProperties['cumulativeLikes'] = $reactions; + + return $this; + } + + /** + * Sets the complete list of label ids that will be assigned to the article. + * + * The labels are always saved without validating permissions, therefore this + * must be the full set of labels including the labels of label groups that the + * active user is not allowed to set. A partial update is not supported; any + * previously assigned label that is not part of `$labelIDs` will be removed. + * + * @param int[] $labelIDs + */ + public function setLabelIDs(array $labelIDs): static + { + $this->labelIDs = $labelIDs; + $this->properties['hasLabels'] = $labelIDs !== [] ? 1 : 0; + + return $this; + } + + public function setLabelID(int $labelID): static + { + if (isset($this->labelIDs)) { + $this->labelIDs[] = $labelID; + $this->properties['hasLabels'] = 1; + } else { + $this->setLabelIDs([$labelID]); + } + + return $this; + } + + /** + * Returns the content builder for the given language, creating it on demand. + * Pass `null` for the monolingual content. + */ + public function getArticleContentBuilder(?int $languageID): ArticleContentBuilder + { + if (!isset($this->articleContentBuilders[$languageID ?: 0])) { + $existingContent = $this->object !== null + ? ArticleContent::getArticleContent($this->object->getObjectID(), $languageID) + : null; + + if ($existingContent !== null) { + $this->articleContentBuilders[$languageID ?: 0] = ArticleContentBuilder::forUpdate($existingContent); + } else { + $this->articleContentBuilders[$languageID ?: 0] = ArticleContentBuilder::forCreate() + ->setLanguageID($languageID); + } + } + + return $this->articleContentBuilders[$languageID ?: 0]; + } + + #[\Override] + protected function afterCreate(DatabaseObject $object): void + { + foreach ($this->articleContentBuilders as $articleContentBuilder) { + $articleContentBuilder + ->setArticle($object) + ->create(); + } + + if (isset($this->labelIDs)) { + ArticleLabelObjectHandler::getInstance()->setLabels($this->labelIDs, $object->articleID, false); + } + } + + #[\Override] + protected function afterUpdate(DatabaseObject $object): void + { + foreach ($this->articleContentBuilders as $articleContentBuilder) { + $articleContentBuilder->setArticle($object); + if ($articleContentBuilder->isUpdate()) { + $articleContentBuilder->update(); + } else { + $articleContentBuilder->create(); + } + } + + if (isset($this->labelIDs)) { + ArticleLabelObjectHandler::getInstance()->setLabels($this->labelIDs, $object->articleID, false); + } + } + + #[\Override] + protected function getRequiredProperties(): array + { + return ['userID', 'username', 'time', 'categoryID']; + } +} diff --git a/wcfsetup/install/files/lib/data/article/content/ArticleContentBuilder.class.php b/wcfsetup/install/files/lib/data/article/content/ArticleContentBuilder.class.php new file mode 100644 index 0000000000..2896a1c526 --- /dev/null +++ b/wcfsetup/install/files/lib/data/article/content/ArticleContentBuilder.class.php @@ -0,0 +1,226 @@ + + * @since 6.3 + * + * @extends DatabaseObjectBuilder + */ +final class ArticleContentBuilder extends DatabaseObjectBuilder +{ + /** + * @var list + */ + public private(set) array $tags; + + public private(set) AttachmentHandler $attachmentHandler; + + public private(set) HtmlInputProcessor $htmlInputProcessor; + + public function setArticle(Article $article): static + { + $this->properties['articleID'] = $article->articleID; + + return $this; + } + + public function setLanguageID(?int $languageID): static + { + $this->properties['languageID'] = $languageID; + + return $this; + } + + public function setTitle(string $title): static + { + $this->properties['title'] = $title; + + return $this; + } + + public function setSlug(string $slug): static + { + $this->properties['slug'] = $slug; + + return $this; + } + + public function setTeaser(string $teaser): static + { + $this->properties['teaser'] = $teaser; + + return $this; + } + + public function setContent(string $content): static + { + $this->properties['content'] = $content; + + return $this; + } + + public function setImageID(?int $imageID): static + { + $this->properties['imageID'] = $imageID; + + return $this; + } + + public function setTeaserImageID(?int $teaserImageID): static + { + $this->properties['teaserImageID'] = $teaserImageID; + + return $this; + } + + public function setMetaTitle(string $metaTitle): static + { + $this->properties['metaTitle'] = $metaTitle; + + return $this; + } + + public function setMetaDescription(string $metaDescription): static + { + $this->properties['metaDescription'] = $metaDescription; + + return $this; + } + + public function setHasEmbeddedObjects(bool $hasEmbeddedObjects): static + { + $this->properties['hasEmbeddedObjects'] = $hasEmbeddedObjects ? 1 : 0; + + return $this; + } + + public function setAttachments(int $attachments): static + { + $this->properties['attachments'] = $attachments; + + return $this; + } + + /** + * @param list $tags + */ + public function setTags(array $tags): static + { + $this->tags = $tags; + + return $this; + } + + public function setAttachmentHandler(AttachmentHandler $attachmentHandler): static + { + $this->attachmentHandler = $attachmentHandler; + $this->properties['attachments'] = \count($attachmentHandler); + + return $this; + } + + public function setHtmlInputProcessor(HtmlInputProcessor $htmlInputProcessor): static + { + $this->htmlInputProcessor = $htmlInputProcessor; + $this->setContent($this->htmlInputProcessor->getHtml()); + + return $this; + } + + public function getTitle(): ?string + { + return $this->properties['title'] ?? null; + } + + public function getTeaser(): ?string + { + return $this->properties['teaser'] ?? null; + } + + public function getContent(): ?string + { + return $this->properties['content'] ?? null; + } + + #[\Override] + protected function afterCreate(DatabaseObject $object): void + { + if (isset($this->htmlInputProcessor)) { + $this->registerEmbeddedObjects($this->htmlInputProcessor, $object); + } + + if (isset($this->attachmentHandler)) { + $this->attachmentHandler->updateObjectID($object->articleContentID); + } + + if (isset($this->tags)) { + $this->saveTags($this->tags, $object); + } + } + + #[\Override] + protected function afterUpdate(DatabaseObject $object): void + { + if (isset($this->htmlInputProcessor)) { + $this->registerEmbeddedObjects($this->htmlInputProcessor, $object); + } + + if (isset($this->tags)) { + $this->saveTags($this->tags, $object); + } + } + + private function registerEmbeddedObjects(HtmlInputProcessor $processor, ArticleContent $articleContent): void + { + $processor->setObjectID($articleContent->articleContentID); + ArticleContentBuilder::forUpdate($articleContent) + ->setHasEmbeddedObjects( + MessageEmbeddedObjectManager::getInstance()->registerObjects($processor) + ) + ->update(); + } + + /** + * @param list $tags + */ + private function saveTags(array $tags, ArticleContent $articleContent): void + { + $languageID = $articleContent->languageID ?: LanguageFactory::getInstance()->getDefaultLanguageID(); + + if ($tags === []) { + TagEngine::getInstance()->deleteObjectTags( + 'com.woltlab.wcf.article', + $articleContent->articleContentID, + $languageID + ); + } else { + TagEngine::getInstance()->addObjectTags( + 'com.woltlab.wcf.article', + $articleContent->articleContentID, + $tags, + $languageID + ); + } + } + + #[\Override] + protected function getRequiredProperties(): array + { + return ['articleID', 'title']; + } +} diff --git a/wcfsetup/install/files/lib/form/ArticleAddForm.class.php b/wcfsetup/install/files/lib/form/ArticleAddForm.class.php index 2221e5071f..0a4d5b0c24 100644 --- a/wcfsetup/install/files/lib/form/ArticleAddForm.class.php +++ b/wcfsetup/install/files/lib/form/ArticleAddForm.class.php @@ -4,7 +4,6 @@ use Laminas\Diactoros\Response\RedirectResponse; use wcf\data\article\Article; -use wcf\system\WCF; /** * Shows the article add form. @@ -19,17 +18,17 @@ class ArticleAddForm extends \wcf\acp\form\ArticleAddForm /** * @inheritDoc */ - public $objectEditLinkController = ArticleEditForm::class; + public string $objectEditLinkController = ArticleEditForm::class; #[\Override] - public function save(): void + protected function afterSave(): void { - parent::save(); + parent::afterSave(); - /** @var Article $article */ - $article = $this->objectAction->getReturnValues()['returnValues']; - if ($article->publicationStatus === Article::PUBLISHED) { - $this->setPsr7Response(new RedirectResponse($article->getLink(), 303)); + \assert($this->object instanceof Article); + + if ($this->object->publicationStatus === Article::PUBLISHED) { + $this->setPsr7Response(new RedirectResponse($this->object->getLink(), 303)); } } } From 3c84f9e429c9b6faffa169d72df2e4166095bd92 Mon Sep 17 00:00:00 2001 From: Marcel Werk Date: Sun, 19 Jul 2026 20:09:07 +0200 Subject: [PATCH 33/36] Migrate article content editor / action to `ArticleContentBuilder` --- .../lib/acp/form/ArticleAddForm.class.php | 3 +- .../lib/command/article/DisableI18n.class.php | 14 ++-- .../lib/command/article/EnableI18n.class.php | 8 +-- .../content/DeleteArticleContent.class.php | 67 +++++++++++++++++++ .../article/content/ArticleContent.class.php | 35 ++++++++++ .../content/ArticleContentAction.class.php | 1 + .../content/ArticleContentBuilder.class.php | 7 ++ .../content/ArticleContentEditor.class.php | 37 +--------- .../content/ArticleContentDeleted.class.php | 21 ++++++ ...CommentArticleDiscussionProvider.class.php | 8 +-- .../manager/ArticleCommentManager.class.php | 10 +-- .../worker/ArticleRebuildDataWorker.class.php | 13 ++-- 12 files changed, 158 insertions(+), 66 deletions(-) create mode 100644 wcfsetup/install/files/lib/command/article/content/DeleteArticleContent.class.php create mode 100644 wcfsetup/install/files/lib/event/article/content/ArticleContentDeleted.class.php diff --git a/wcfsetup/install/files/lib/acp/form/ArticleAddForm.class.php b/wcfsetup/install/files/lib/acp/form/ArticleAddForm.class.php index 73eedd0afb..834c1daacf 100644 --- a/wcfsetup/install/files/lib/acp/form/ArticleAddForm.class.php +++ b/wcfsetup/install/files/lib/acp/form/ArticleAddForm.class.php @@ -8,7 +8,6 @@ use wcf\data\article\ArticleBuilder; use wcf\data\article\category\ArticleCategory; use wcf\data\article\content\ArticleContent; -use wcf\data\article\content\ArticleContentEditor; use wcf\data\category\CategoryNodeTree; use wcf\data\DatabaseObjectBuilder; use wcf\data\language\Language; @@ -536,7 +535,7 @@ protected function getSlugValidator(?int $languageID): FormFieldValidator } $excludedArticleID = $this->formObject !== null ? $this->formObject->articleID : null; - if (!ArticleContentEditor::isUniqueSlug($slug, $languageID, $excludedArticleID)) { + if (ArticleContent::findBySlug($slug, $languageID, $excludedArticleID) !== null) { $field->addValidationError(new FormFieldValidationError( 'notUnique', 'wcf.acp.article.slug.error.notUnique' diff --git a/wcfsetup/install/files/lib/command/article/DisableI18n.class.php b/wcfsetup/install/files/lib/command/article/DisableI18n.class.php index c195095689..73962149dc 100644 --- a/wcfsetup/install/files/lib/command/article/DisableI18n.class.php +++ b/wcfsetup/install/files/lib/command/article/DisableI18n.class.php @@ -2,10 +2,10 @@ namespace wcf\command\article; +use wcf\command\article\content\DeleteArticleContent; use wcf\data\article\Article; use wcf\data\article\ArticleAction; -use wcf\data\article\content\ArticleContentAction; -use wcf\data\article\content\ArticleContentEditor; +use wcf\data\article\content\ArticleContentBuilder; use wcf\data\language\Language; use wcf\system\version\VersionTracker; @@ -30,16 +30,18 @@ public function __invoke(): void foreach ($this->article->getArticleContents() as $articleContent) { if ($articleContent->languageID == $this->language->languageID) { - $articleContentEditor = new ArticleContentEditor($articleContent); - $articleContentEditor->update(['languageID' => null]); + ArticleContentBuilder::forUpdate($articleContent) + ->setLanguageID(null) + ->update(); } else { $removeContents[] = $articleContent; } } if ($removeContents !== []) { - $action = new ArticleContentAction($removeContents, 'delete'); - $action->executeAction(); + foreach ($removeContents as $articleContent) { + (new DeleteArticleContent($articleContent))(); + } } $action = new ArticleAction([$this->article], 'update', [ diff --git a/wcfsetup/install/files/lib/command/article/EnableI18n.class.php b/wcfsetup/install/files/lib/command/article/EnableI18n.class.php index d53c7cec4c..efa11d020d 100644 --- a/wcfsetup/install/files/lib/command/article/EnableI18n.class.php +++ b/wcfsetup/install/files/lib/command/article/EnableI18n.class.php @@ -2,16 +2,13 @@ namespace wcf\command\article; +use wcf\command\article\content\DeleteArticleContent; use wcf\data\article\Article; use wcf\data\article\ArticleAction; use wcf\data\article\content\ArticleContent; -use wcf\data\article\content\ArticleContentAction; -use wcf\data\article\content\ArticleContentEditor; -use wcf\data\object\type\ObjectTypeCache; use wcf\system\article\discussion\IArticleDiscussionProvider; use wcf\system\language\LanguageFactory; use wcf\system\version\VersionTracker; -use wcf\system\WCF; /** * Converts a monolingual article to a multilingual. @@ -55,8 +52,7 @@ public function __invoke(): void $this->migrateDiscussions($discussionProvider, $this->article, $articleContent); - $action = new ArticleContentAction([$articleContent], 'delete'); - $action->executeAction(); + (new DeleteArticleContent($articleContent))(); VersionTracker::getInstance()->reset( 'com.woltlab.wcf.article', diff --git a/wcfsetup/install/files/lib/command/article/content/DeleteArticleContent.class.php b/wcfsetup/install/files/lib/command/article/content/DeleteArticleContent.class.php new file mode 100644 index 0000000000..c9f30c684e --- /dev/null +++ b/wcfsetup/install/files/lib/command/article/content/DeleteArticleContent.class.php @@ -0,0 +1,67 @@ + + * @since 6.3 + */ +final class DeleteArticleContent +{ + public function __construct( + private readonly ArticleContent $content, + ) {} + + public function __invoke(): void + { + ArticleContentBuilder::delete($this->content); + + $this->cleanupData($this->content); + + EventHandler::getInstance()->fire(new ArticleContentDeleted($this->content)); + } + + private function cleanupData(ArticleContent $content): void + { + $contentID = $content->getObjectID(); + + CommentHandler::getInstance()->deleteObjects( + 'com.woltlab.wcf.articleComment', + [$contentID] + ); + + TagEngine::getInstance()->deleteObjects( + 'com.woltlab.wcf.article', + [$contentID] + ); + + SearchIndexManager::getInstance()->delete( + 'com.woltlab.wcf.article', + [$contentID] + ); + + MessageEmbeddedObjectManager::getInstance()->removeObjects( + 'com.woltlab.wcf.article.content', + [$contentID] + ); + + AttachmentHandler::removeAttachments( + 'com.woltlab.wcf.article.content', + [$contentID] + ); + } +} diff --git a/wcfsetup/install/files/lib/data/article/content/ArticleContent.class.php b/wcfsetup/install/files/lib/data/article/content/ArticleContent.class.php index 504a31e813..11fd17f15b 100644 --- a/wcfsetup/install/files/lib/data/article/content/ArticleContent.class.php +++ b/wcfsetup/install/files/lib/data/article/content/ArticleContent.class.php @@ -9,6 +9,7 @@ use wcf\data\language\Language; use wcf\data\media\ViewableMedia; use wcf\page\ArticlePage; +use wcf\system\database\util\PreparedStatementConditionBuilder; use wcf\system\html\output\HtmlOutputProcessor; use wcf\system\language\LanguageFactory; use wcf\system\request\IRouteController; @@ -261,4 +262,38 @@ public function getAttachments(): array { return $this->getCollection()->getAttachments($this); } + + /** + * Returns the article content with the given slug, within the given language scope. + * The `$excludedArticleID` is excluded from the lookup to allow updates of + * an existing article. + * + * @since 6.3 + */ + public static function findBySlug(string $slug, ?int $languageID, ?int $excludedArticleID = null): ?ArticleContent + { + if ($slug === '') { + return null; + } + + $conditionBuilder = new PreparedStatementConditionBuilder(); + $conditionBuilder->add('slug = ?', [$slug]); + + if ($languageID === null) { + $conditionBuilder->add('languageID IS NULL'); + } else { + $conditionBuilder->add('(languageID = ? OR languageID IS NULL)', [$languageID]); + } + if ($excludedArticleID !== null) { + $conditionBuilder->add('articleID <> ?', [$excludedArticleID]); + } + + $sql = "SELECT * + FROM wcf1_article_content + " . $conditionBuilder; + $statement = WCF::getDB()->prepare($sql); + $statement->execute($conditionBuilder->getParameters()); + + return $statement->fetchSingleObject(ArticleContent::class); + } } diff --git a/wcfsetup/install/files/lib/data/article/content/ArticleContentAction.class.php b/wcfsetup/install/files/lib/data/article/content/ArticleContentAction.class.php index 37af2212c1..e328115fd6 100644 --- a/wcfsetup/install/files/lib/data/article/content/ArticleContentAction.class.php +++ b/wcfsetup/install/files/lib/data/article/content/ArticleContentAction.class.php @@ -13,6 +13,7 @@ * @author Marcel Werk * @copyright 2001-2019 WoltLab GmbH * @license GNU Lesser General Public License + * @deprecated 6.3 Use `ArticleContentBuilder` and commands instead. * * @extends AbstractDatabaseObjectAction */ diff --git a/wcfsetup/install/files/lib/data/article/content/ArticleContentBuilder.class.php b/wcfsetup/install/files/lib/data/article/content/ArticleContentBuilder.class.php index 2896a1c526..3b097742cb 100644 --- a/wcfsetup/install/files/lib/data/article/content/ArticleContentBuilder.class.php +++ b/wcfsetup/install/files/lib/data/article/content/ArticleContentBuilder.class.php @@ -116,6 +116,13 @@ public function setAttachments(int $attachments): static return $this; } + public function incrementComments(int $comments): static + { + $this->incrementProperties['comments'] = $comments; + + return $this; + } + /** * @param list $tags */ diff --git a/wcfsetup/install/files/lib/data/article/content/ArticleContentEditor.class.php b/wcfsetup/install/files/lib/data/article/content/ArticleContentEditor.class.php index e3e5cc22ad..3ca750e9ba 100644 --- a/wcfsetup/install/files/lib/data/article/content/ArticleContentEditor.class.php +++ b/wcfsetup/install/files/lib/data/article/content/ArticleContentEditor.class.php @@ -3,8 +3,6 @@ namespace wcf\data\article\content; use wcf\data\DatabaseObjectEditor; -use wcf\system\database\util\PreparedStatementConditionBuilder; -use wcf\system\WCF; /** * Provides functions to edit article content. @@ -12,6 +10,7 @@ * @author Marcel Werk * @copyright 2001-2019 WoltLab GmbH * @license GNU Lesser General Public License + * @deprecated 6.3 Use `ArticleContentBuilder` instead. * * @mixin ArticleContent * @extends DatabaseObjectEditor @@ -22,38 +21,4 @@ class ArticleContentEditor extends DatabaseObjectEditor * @inheritDoc */ protected static $baseClass = ArticleContent::class; - - /** - * Returns whether the given slug is unique within the given language scope. - * The `$excludedArticleID` is excluded from the lookup to allow updates of - * an existing article. - * - * @since 6.3 - */ - public static function isUniqueSlug(string $slug, ?int $languageID, ?int $excludedArticleID = null): bool - { - if ($slug === '') { - return true; - } - - $conditionBuilder = new PreparedStatementConditionBuilder(); - $conditionBuilder->add('slug = ?', [$slug]); - - if ($languageID === null) { - $conditionBuilder->add('languageID IS NULL'); - } else { - $conditionBuilder->add('(languageID = ? OR languageID IS NULL)', [$languageID]); - } - if ($excludedArticleID !== null) { - $conditionBuilder->add('articleID <> ?', [$excludedArticleID]); - } - - $sql = "SELECT COUNT(*) - FROM wcf1_article_content - " . $conditionBuilder; - $statement = WCF::getDB()->prepare($sql); - $statement->execute($conditionBuilder->getParameters()); - - return $statement->fetchSingleColumn() === 0; - } } diff --git a/wcfsetup/install/files/lib/event/article/content/ArticleContentDeleted.class.php b/wcfsetup/install/files/lib/event/article/content/ArticleContentDeleted.class.php new file mode 100644 index 0000000000..a5e17116b3 --- /dev/null +++ b/wcfsetup/install/files/lib/event/article/content/ArticleContentDeleted.class.php @@ -0,0 +1,21 @@ + + * @since 6.3 + */ +final class ArticleContentDeleted implements IPsr14Event +{ + public function __construct( + public readonly ArticleContent $content, + ) {} +} diff --git a/wcfsetup/install/files/lib/system/article/discussion/CommentArticleDiscussionProvider.class.php b/wcfsetup/install/files/lib/system/article/discussion/CommentArticleDiscussionProvider.class.php index 9ccd33400f..9b38ce8109 100644 --- a/wcfsetup/install/files/lib/system/article/discussion/CommentArticleDiscussionProvider.class.php +++ b/wcfsetup/install/files/lib/system/article/discussion/CommentArticleDiscussionProvider.class.php @@ -4,7 +4,7 @@ use wcf\data\article\Article; use wcf\data\article\content\ArticleContent; -use wcf\data\article\content\ArticleContentEditor; +use wcf\data\article\content\ArticleContentBuilder; use wcf\data\object\type\ObjectTypeCache; use wcf\system\view\CommentsView; use wcf\system\WCF; @@ -73,9 +73,9 @@ public function migrateDiscussions(ArticleContent $oldContent, ArticleContent $n $oldContent->articleContentID, ]); - (new ArticleContentEditor($newContent))->update([ - 'comments' => $oldContent->comments, - ]); + ArticleContentBuilder::forUpdate($newContent) + ->incrementComments($oldContent->comments) + ->update(); } #[\Override] diff --git a/wcfsetup/install/files/lib/system/comment/manager/ArticleCommentManager.class.php b/wcfsetup/install/files/lib/system/comment/manager/ArticleCommentManager.class.php index 3562555fc7..8c67df6fc0 100644 --- a/wcfsetup/install/files/lib/system/comment/manager/ArticleCommentManager.class.php +++ b/wcfsetup/install/files/lib/system/comment/manager/ArticleCommentManager.class.php @@ -3,7 +3,7 @@ namespace wcf\system\comment\manager; use wcf\data\article\content\ArticleContent; -use wcf\data\article\content\ArticleContentEditor; +use wcf\data\article\content\ArticleContentBuilder; use wcf\data\article\content\ArticleContentList; use wcf\data\comment\Comment; use wcf\data\comment\response\CommentResponse; @@ -110,10 +110,10 @@ public function getTitle(int $objectTypeID, int $objectID, bool $isResponse = fa #[\Override] public function updateCounter(int $objectID, int $value) { - $editor = new ArticleContentEditor(new ArticleContent($objectID)); - $editor->updateCounters([ - 'comments' => $value, - ]); + $content = new ArticleContent($objectID); + ArticleContentBuilder::forUpdate($content) + ->incrementComments($value) + ->update(); } #[\Override] diff --git a/wcfsetup/install/files/lib/system/worker/ArticleRebuildDataWorker.class.php b/wcfsetup/install/files/lib/system/worker/ArticleRebuildDataWorker.class.php index 356724e0c1..602e871128 100644 --- a/wcfsetup/install/files/lib/system/worker/ArticleRebuildDataWorker.class.php +++ b/wcfsetup/install/files/lib/system/worker/ArticleRebuildDataWorker.class.php @@ -5,7 +5,7 @@ use wcf\data\article\Article; use wcf\data\article\ArticleEditor; use wcf\data\article\ArticleList; -use wcf\data\article\content\ArticleContentEditor; +use wcf\data\article\content\ArticleContentBuilder; use wcf\data\article\content\ArticleContentList; use wcf\data\object\type\ObjectTypeCache; use wcf\system\database\util\PreparedStatementConditionBuilder; @@ -88,12 +88,12 @@ public function execute() ); $articleContentList->readObjects(); foreach ($articleContentList as $articleContent) { - $data = []; + $builder = ArticleContentBuilder::forUpdate($articleContent); // count comments $commentStatement->execute([$commentObjectType->objectTypeID, $articleContent->articleContentID]); $row = $commentStatement->fetchSingleRow(); - $data['comments'] = $row['comments'] + $row['responses']; + $builder->incrementComments($row['comments'] + $row['responses'] - $articleContent->comments); // update search index SearchIndexManager::getInstance()->set( @@ -121,15 +121,14 @@ public function execute() } if ($hasEmbeddedObjects != $articleContent->hasEmbeddedObjects) { - $data['hasEmbeddedObjects'] = $hasEmbeddedObjects; + $builder->setHasEmbeddedObjects((bool)$hasEmbeddedObjects); } // count attachments $attachmentStatement->execute([$attachmentObjectType->objectTypeID, $articleContent->articleContentID]); - $data['attachments'] = $attachmentStatement->fetchSingleColumn(); + $builder->setAttachments($attachmentStatement->fetchSingleColumn()); - $articleContentEditor = new ArticleContentEditor($articleContent); - $articleContentEditor->update($data); + $builder->update(); } // fetch cumulative likes From 591a55ba5b0a01a9109a820616627f06dca84e46 Mon Sep 17 00:00:00 2001 From: Marcel Werk Date: Sun, 19 Jul 2026 21:02:01 +0200 Subject: [PATCH 34/36] Migrate article content editor / action to `ArticleContentBuilder` --- .../command/article/CreateArticle.class.php | 5 +- .../command/article/DeleteArticle.class.php | 78 +++++++++++++++++++ .../lib/command/article/DisableI18n.class.php | 11 +-- .../lib/command/article/EnableI18n.class.php | 33 ++++---- .../command/article/PublishArticle.class.php | 18 ++--- .../command/article/RestoreArticle.class.php | 6 +- .../article/SetArticleCategory.class.php | 6 +- .../article/SoftDeleteArticle.class.php | 6 +- .../article/UnpublishArticle.class.php | 12 +-- .../command/article/UpdateArticle.class.php | 7 +- .../lib/data/article/ArticleAction.class.php | 1 + .../lib/data/article/ArticleBuilder.class.php | 17 ++++ .../lib/data/article/ArticleEditor.class.php | 1 + .../data/article/LikeableArticle.class.php | 6 +- .../files/lib/page/ArticlePage.class.php | 9 +-- .../category/ArticleCategoryType.class.php | 19 ++--- .../ArticlePublicationCronjob.class.php | 20 ++--- .../core/articles/DeleteArticle.class.php | 4 +- .../core/articles/UnpublishArticle.class.php | 4 +- ...icleModerationQueueReportHandler.class.php | 4 +- .../worker/ArticleRebuildDataWorker.class.php | 10 +-- 21 files changed, 178 insertions(+), 99 deletions(-) create mode 100644 wcfsetup/install/files/lib/command/article/DeleteArticle.class.php diff --git a/wcfsetup/install/files/lib/command/article/CreateArticle.class.php b/wcfsetup/install/files/lib/command/article/CreateArticle.class.php index 2d02a99a4c..279d9648d2 100644 --- a/wcfsetup/install/files/lib/command/article/CreateArticle.class.php +++ b/wcfsetup/install/files/lib/command/article/CreateArticle.class.php @@ -4,7 +4,6 @@ use wcf\data\article\Article; use wcf\data\article\ArticleBuilder; -use wcf\data\article\ArticleEditor; use wcf\system\search\SearchIndexManager; use wcf\system\user\activity\event\UserActivityEventHandler; use wcf\system\user\notification\object\ArticleUserNotificationObject; @@ -33,7 +32,9 @@ public function __invoke(): Article (new ResetUserStorageForUnreadArticles())(); if ($article->publicationStatus == Article::PUBLISHED) { - ArticleEditor::updateArticleCounter([$article->userID => 1]); + if ($article->userID !== null) { + ArticleBuilder::incrementArticleCounter($article->userID, 1); + } UserObjectWatchHandler::getInstance()->updateObject( 'com.woltlab.wcf.article.category', diff --git a/wcfsetup/install/files/lib/command/article/DeleteArticle.class.php b/wcfsetup/install/files/lib/command/article/DeleteArticle.class.php new file mode 100644 index 0000000000..fb2638949f --- /dev/null +++ b/wcfsetup/install/files/lib/command/article/DeleteArticle.class.php @@ -0,0 +1,78 @@ + + * @since 6.3 + */ +final class DeleteArticle +{ + public function __construct(private readonly Article $article) {} + + public function __invoke(): void + { + $articleContentIDs = $attachmentArticleContentIDs = []; + foreach ($this->article->getArticleContents() as $articleContent) { + $articleContentIDs[] = $articleContent->articleContentID; + + if ($articleContent->attachments) { + $attachmentArticleContentIDs[] = $articleContent->articleContentID; + } + } + + ArticleBuilder::delete($this->article); + + // delete like data + (new DeleteObjectReactions('com.woltlab.wcf.likeableArticle', [$this->article->articleID]))(); + // delete comments + CommentHandler::getInstance()->deleteObjects('com.woltlab.wcf.articleComment', $articleContentIDs); + // delete tag to object entries + TagEngine::getInstance()->deleteObjects('com.woltlab.wcf.article', $articleContentIDs); + // delete entry from search index + SearchIndexManager::getInstance()->delete('com.woltlab.wcf.article', $articleContentIDs); + // delete user notifications + UserNotificationHandler::getInstance()->removeNotifications( + 'com.woltlab.wcf.article.notification', + [$this->article->articleID] + ); + // delete recent activity events + UserActivityEventHandler::getInstance()->removeEvents( + 'com.woltlab.wcf.article.recentActivityEvent', + [$this->article->articleID] + ); + // delete embedded object references + MessageEmbeddedObjectManager::getInstance()->removeObjects( + 'com.woltlab.wcf.article.content', + $articleContentIDs + ); + // update wcf1_user.articles + if ($this->article->publicationStatus == Article::PUBLISHED) { + if ($this->article->userID !== null) { + ArticleBuilder::incrementArticleCounter($this->article->userID, -1); + } + } + // delete attachments + if ($attachmentArticleContentIDs !== []) { + AttachmentHandler::removeAttachments( + 'com.woltlab.wcf.article.content', + $attachmentArticleContentIDs + ); + } + } +} diff --git a/wcfsetup/install/files/lib/command/article/DisableI18n.class.php b/wcfsetup/install/files/lib/command/article/DisableI18n.class.php index 73962149dc..cfdf3f72cf 100644 --- a/wcfsetup/install/files/lib/command/article/DisableI18n.class.php +++ b/wcfsetup/install/files/lib/command/article/DisableI18n.class.php @@ -4,7 +4,7 @@ use wcf\command\article\content\DeleteArticleContent; use wcf\data\article\Article; -use wcf\data\article\ArticleAction; +use wcf\data\article\ArticleBuilder; use wcf\data\article\content\ArticleContentBuilder; use wcf\data\language\Language; use wcf\system\version\VersionTracker; @@ -44,12 +44,9 @@ public function __invoke(): void } } - $action = new ArticleAction([$this->article], 'update', [ - 'data' => [ - 'isMultilingual' => 0, - ], - ]); - $action->executeAction(); + ArticleBuilder::forUpdate($this->article) + ->setIsMultilingual(false) + ->update(); VersionTracker::getInstance()->reset( 'com.woltlab.wcf.article', diff --git a/wcfsetup/install/files/lib/command/article/EnableI18n.class.php b/wcfsetup/install/files/lib/command/article/EnableI18n.class.php index efa11d020d..14220ba684 100644 --- a/wcfsetup/install/files/lib/command/article/EnableI18n.class.php +++ b/wcfsetup/install/files/lib/command/article/EnableI18n.class.php @@ -4,7 +4,7 @@ use wcf\command\article\content\DeleteArticleContent; use wcf\data\article\Article; -use wcf\data\article\ArticleAction; +use wcf\data\article\ArticleBuilder; use wcf\data\article\content\ArticleContent; use wcf\system\article\discussion\IArticleDiscussionProvider; use wcf\system\language\LanguageFactory; @@ -27,28 +27,21 @@ public function __construct( public function __invoke(): void { $articleContent = $this->article->getArticleContent(); - $data = []; + $discussionProvider = $this->article->getDiscussionProvider(); + $builder = ArticleBuilder::forUpdate($this->article) + ->setIsMultilingual(true); + foreach (LanguageFactory::getInstance()->getLanguages() as $language) { - $data[$language->languageID] = [ - 'title' => $articleContent->title, - 'slug' => $articleContent->slug, - 'teaser' => $articleContent->teaser, - 'content' => $articleContent->content, - 'imageID' => $articleContent->imageID ?: null, - 'teaserImageID' => $articleContent->teaserImageID ?: null, - ]; + $builder->getArticleContentBuilder($language->languageID) + ->setTitle($articleContent->title) + ->setSlug($articleContent->slug) + ->setTeaser($articleContent->teaser) + ->setContent($articleContent->content) + ->setImageID($articleContent->imageID ?: null) + ->setTeaserImageID($articleContent->teaserImageID ?: null); } - $discussionProvider = $this->article->getDiscussionProvider(); - - $action = new ArticleAction([$this->article], 'update', [ - 'content' => $data, - 'data' => [ - 'isMultilingual' => 1, - ], - 'migrateDiscussions' => true, - ]); - $action->executeAction(); + (new UpdateArticle($builder))(); $this->migrateDiscussions($discussionProvider, $this->article, $articleContent); diff --git a/wcfsetup/install/files/lib/command/article/PublishArticle.class.php b/wcfsetup/install/files/lib/command/article/PublishArticle.class.php index d0d31649f4..edee62736a 100644 --- a/wcfsetup/install/files/lib/command/article/PublishArticle.class.php +++ b/wcfsetup/install/files/lib/command/article/PublishArticle.class.php @@ -3,7 +3,7 @@ namespace wcf\command\article; use wcf\data\article\Article; -use wcf\data\article\ArticleEditor; +use wcf\data\article\ArticleBuilder; use wcf\event\article\ArticlePublished; use wcf\system\event\EventHandler; use wcf\system\user\activity\event\UserActivityEventHandler; @@ -24,18 +24,18 @@ public function __construct(private readonly Article $article) {} public function __invoke(): void { - (new ArticleEditor($this->article))->update([ - 'time' => \TIME_NOW, - 'publicationStatus' => Article::PUBLISHED, - 'publicationDate' => 0, - ]); + ArticleBuilder::forUpdate($this->article) + ->setTime(\TIME_NOW) + ->setPublicationStatus(Article::PUBLISHED) + ->setPublicationDate(0) + ->update(); $this->updateUserWatch($this->article); $this->addUserActivity($this->article->articleID, $this->article->userID); - ArticleEditor::updateArticleCounter([ - $this->article->userID => 1, - ]); + if ($this->article->userID !== null) { + ArticleBuilder::incrementArticleCounter($this->article->userID, 1); + } (new ResetUserStorageForUnreadArticles())(); diff --git a/wcfsetup/install/files/lib/command/article/RestoreArticle.class.php b/wcfsetup/install/files/lib/command/article/RestoreArticle.class.php index 9a9ae42b4e..bc46d171b4 100644 --- a/wcfsetup/install/files/lib/command/article/RestoreArticle.class.php +++ b/wcfsetup/install/files/lib/command/article/RestoreArticle.class.php @@ -3,7 +3,7 @@ namespace wcf\command\article; use wcf\data\article\Article; -use wcf\data\article\ArticleEditor; +use wcf\data\article\ArticleBuilder; use wcf\event\article\ArticleRestored; use wcf\system\event\EventHandler; @@ -21,7 +21,9 @@ public function __construct(private readonly Article $article) {} public function __invoke(): void { - (new ArticleEditor($this->article))->update(['isDeleted' => 0]); + ArticleBuilder::forUpdate($this->article) + ->setIsDeleted(false) + ->update(); (new ResetUserStorageForUnreadArticles())(); diff --git a/wcfsetup/install/files/lib/command/article/SetArticleCategory.class.php b/wcfsetup/install/files/lib/command/article/SetArticleCategory.class.php index c2dde690dc..7fe9843e29 100644 --- a/wcfsetup/install/files/lib/command/article/SetArticleCategory.class.php +++ b/wcfsetup/install/files/lib/command/article/SetArticleCategory.class.php @@ -3,7 +3,7 @@ namespace wcf\command\article; use wcf\data\article\Article; -use wcf\data\article\ArticleEditor; +use wcf\data\article\ArticleBuilder; use wcf\data\article\category\ArticleCategory; use wcf\event\article\ArticleCategorySet; use wcf\system\event\EventHandler; @@ -25,7 +25,9 @@ public function __construct( public function __invoke(): void { - (new ArticleEditor($this->article))->update(['categoryID' => $this->category->categoryID]); + ArticleBuilder::forUpdate($this->article) + ->setCategory($this->category) + ->update(); $event = new ArticleCategorySet($this->article, $this->article->getCategory(), $this->category); EventHandler::getInstance()->fire($event); diff --git a/wcfsetup/install/files/lib/command/article/SoftDeleteArticle.class.php b/wcfsetup/install/files/lib/command/article/SoftDeleteArticle.class.php index d170553be2..762b912dc9 100644 --- a/wcfsetup/install/files/lib/command/article/SoftDeleteArticle.class.php +++ b/wcfsetup/install/files/lib/command/article/SoftDeleteArticle.class.php @@ -3,7 +3,7 @@ namespace wcf\command\article; use wcf\data\article\Article; -use wcf\data\article\ArticleEditor; +use wcf\data\article\ArticleBuilder; use wcf\event\article\ArticleSoftDeleted; use wcf\system\event\EventHandler; @@ -21,7 +21,9 @@ public function __construct(private readonly Article $article) {} public function __invoke(): void { - (new ArticleEditor($this->article))->update(['isDeleted' => 1]); + ArticleBuilder::forUpdate($this->article) + ->setIsDeleted(true) + ->update(); (new ResetUserStorageForUnreadArticles())(); diff --git a/wcfsetup/install/files/lib/command/article/UnpublishArticle.class.php b/wcfsetup/install/files/lib/command/article/UnpublishArticle.class.php index 57e9e884d5..8d48a6c0de 100644 --- a/wcfsetup/install/files/lib/command/article/UnpublishArticle.class.php +++ b/wcfsetup/install/files/lib/command/article/UnpublishArticle.class.php @@ -3,7 +3,7 @@ namespace wcf\command\article; use wcf\data\article\Article; -use wcf\data\article\ArticleEditor; +use wcf\data\article\ArticleBuilder; use wcf\event\article\ArticleUnpublished; use wcf\system\event\EventHandler; use wcf\system\user\activity\event\UserActivityEventHandler; @@ -23,14 +23,16 @@ public function __construct(private readonly Article $article) {} public function __invoke(): void { - (new ArticleEditor($this->article))->update(['publicationStatus' => Article::UNPUBLISHED]); + ArticleBuilder::forUpdate($this->article) + ->setPublicationStatus(Article::UNPUBLISHED) + ->update(); $this->removeNotifications($this->article->articleID); $this->removeUserActivity($this->article->articleID); - ArticleEditor::updateArticleCounter([ - $this->article->userID => -1, - ]); + if ($this->article->userID !== null) { + ArticleBuilder::incrementArticleCounter($this->article->userID, -1); + } $event = new ArticleUnpublished($this->article); EventHandler::getInstance()->fire($event); diff --git a/wcfsetup/install/files/lib/command/article/UpdateArticle.class.php b/wcfsetup/install/files/lib/command/article/UpdateArticle.class.php index dae6d2d662..014ee248a3 100644 --- a/wcfsetup/install/files/lib/command/article/UpdateArticle.class.php +++ b/wcfsetup/install/files/lib/command/article/UpdateArticle.class.php @@ -4,7 +4,6 @@ use wcf\data\article\Article; use wcf\data\article\ArticleBuilder; -use wcf\data\article\ArticleEditor; use wcf\data\article\ArticleVersionTracker; use wcf\data\article\content\ArticleContent; use wcf\system\search\SearchIndexManager; @@ -86,9 +85,9 @@ public function __invoke(): Article private function handlePublicationStatusChange(Article $article, int $oldStatus, int $newStatus): void { if ($newStatus == Article::PUBLISHED || $oldStatus == Article::PUBLISHED) { - ArticleEditor::updateArticleCounter([ - $article->userID => $newStatus == Article::PUBLISHED ? 1 : -1, - ]); + if ($article->userID !== null) { + ArticleBuilder::incrementArticleCounter($article->userID, $newStatus == Article::PUBLISHED ? 1 : -1); + } } if ($newStatus == Article::PUBLISHED) { diff --git a/wcfsetup/install/files/lib/data/article/ArticleAction.class.php b/wcfsetup/install/files/lib/data/article/ArticleAction.class.php index 16ef4b7e03..b29e1680a7 100644 --- a/wcfsetup/install/files/lib/data/article/ArticleAction.class.php +++ b/wcfsetup/install/files/lib/data/article/ArticleAction.class.php @@ -42,6 +42,7 @@ * @author Marcel Werk * @copyright 2001-2019 WoltLab GmbH * @license GNU Lesser General Public License + * @deprecated 6.3 Use `ArticleBuilder` and commands instead. * * @extends AbstractDatabaseObjectAction */ diff --git a/wcfsetup/install/files/lib/data/article/ArticleBuilder.class.php b/wcfsetup/install/files/lib/data/article/ArticleBuilder.class.php index 4d1e75a613..dd99f7b68a 100644 --- a/wcfsetup/install/files/lib/data/article/ArticleBuilder.class.php +++ b/wcfsetup/install/files/lib/data/article/ArticleBuilder.class.php @@ -9,6 +9,7 @@ use wcf\data\DatabaseObjectBuilder; use wcf\data\user\User; use wcf\system\label\object\ArticleLabelObjectHandler; +use wcf\system\WCF; /** * Builder for creating and updating articles. @@ -205,4 +206,20 @@ protected function getRequiredProperties(): array { return ['userID', 'username', 'time', 'categoryID']; } + + /** + * Increases or decreases the number of articles attributed to the given user by + * the specified value. + */ + public static function incrementArticleCounter(int $userID, int $value): void + { + $sql = "UPDATE wcf1_user + SET articles = articles + ? + WHERE userID = ?"; + $statement = WCF::getDB()->prepare($sql); + $statement->execute([ + $value, + $userID, + ]); + } } diff --git a/wcfsetup/install/files/lib/data/article/ArticleEditor.class.php b/wcfsetup/install/files/lib/data/article/ArticleEditor.class.php index 7eafa20c3a..83a87b83fe 100644 --- a/wcfsetup/install/files/lib/data/article/ArticleEditor.class.php +++ b/wcfsetup/install/files/lib/data/article/ArticleEditor.class.php @@ -11,6 +11,7 @@ * @author Marcel Werk * @copyright 2001-2019 WoltLab GmbH * @license GNU Lesser General Public License + * @deprecated 6.3 Use `ArticleBuilder` instead. * * @mixin Article * @extends DatabaseObjectEditor
diff --git a/wcfsetup/install/files/lib/data/article/LikeableArticle.class.php b/wcfsetup/install/files/lib/data/article/LikeableArticle.class.php index c308ed3d67..8ed0fe01f2 100644 --- a/wcfsetup/install/files/lib/data/article/LikeableArticle.class.php +++ b/wcfsetup/install/files/lib/data/article/LikeableArticle.class.php @@ -53,9 +53,9 @@ public function getObjectID() #[\Override] public function updateLikeCounter(int $cumulativeLikes) { - // update cumulative likes - $editor = new ArticleEditor($this->getDecoratedObject()); - $editor->update(['cumulativeLikes' => $cumulativeLikes]); + ArticleBuilder::forUpdate($this->getDecoratedObject()) + ->incrementReactions($cumulativeLikes - $this->getDecoratedObject()->cumulativeLikes) + ->update(); } #[\Override] diff --git a/wcfsetup/install/files/lib/page/ArticlePage.class.php b/wcfsetup/install/files/lib/page/ArticlePage.class.php index 5aaa8169e2..6df0e036e1 100644 --- a/wcfsetup/install/files/lib/page/ArticlePage.class.php +++ b/wcfsetup/install/files/lib/page/ArticlePage.class.php @@ -4,7 +4,7 @@ use wcf\command\article\MarkArticleAsRead; use wcf\data\article\Article; -use wcf\data\article\ArticleEditor; +use wcf\data\article\ArticleBuilder; use wcf\data\article\category\ArticleCategory; use wcf\data\article\CategoryArticleList; use wcf\data\article\content\ArticleContent; @@ -114,10 +114,9 @@ protected function updateViewCounter(): void return; } - $articleEditor = new ArticleEditor($this->article); - $articleEditor->updateCounters([ - 'views' => 1, - ]); + ArticleBuilder::forUpdate($this->article) + ->incrementViews(1) + ->update(); } protected function markAsRead(): void diff --git a/wcfsetup/install/files/lib/system/category/ArticleCategoryType.class.php b/wcfsetup/install/files/lib/system/category/ArticleCategoryType.class.php index e9fdcf45f8..287c09cd52 100644 --- a/wcfsetup/install/files/lib/system/category/ArticleCategoryType.class.php +++ b/wcfsetup/install/files/lib/system/category/ArticleCategoryType.class.php @@ -4,7 +4,8 @@ use wcf\acp\form\ArticleCategoryAddForm; use wcf\acp\form\ArticleCategoryEditForm; -use wcf\data\article\ArticleAction; +use wcf\command\article\DeleteArticle; +use wcf\data\article\ArticleList; use wcf\data\category\CategoryEditor; use wcf\system\WCF; @@ -46,18 +47,12 @@ public function beforeDeletion(CategoryEditor $categoryEditor) parent::beforeDeletion($categoryEditor); // Delete articles in this category. - $sql = "SELECT articleID - FROM wcf1_article - WHERE categoryID = ?"; - $statement = WCF::getDB()->prepare($sql); - $statement->execute([ - $categoryEditor->categoryID, - ]); - $articleIDs = $statement->fetchAll(\PDO::FETCH_COLUMN); + $articleList = new ArticleList(); + $articleList->getConditionBuilder()->add("categoryID = ?", [$categoryEditor->categoryID]); + $articleList->readObjects(); - if ($articleIDs !== []) { - $articleAction = new ArticleAction($articleIDs, 'delete'); - $articleAction->executeAction(); + foreach ($articleList->getObjects() as $article) { + (new DeleteArticle($article))(); } } diff --git a/wcfsetup/install/files/lib/system/cronjob/ArticlePublicationCronjob.class.php b/wcfsetup/install/files/lib/system/cronjob/ArticlePublicationCronjob.class.php index d7ee184853..28a8157069 100644 --- a/wcfsetup/install/files/lib/system/cronjob/ArticlePublicationCronjob.class.php +++ b/wcfsetup/install/files/lib/system/cronjob/ArticlePublicationCronjob.class.php @@ -2,9 +2,9 @@ namespace wcf\system\cronjob; +use wcf\command\article\UpdateArticle; use wcf\data\article\Article; -use wcf\data\article\ArticleAction; -use wcf\data\article\ArticleEditor; +use wcf\data\article\ArticleBuilder; use wcf\data\article\ArticleList; use wcf\data\cronjob\Cronjob; @@ -27,18 +27,14 @@ public function execute(Cronjob $cronjob) $articleList->getConditionBuilder()->add('article.publicationDate > ?', [0]); $articleList->getConditionBuilder()->add('article.publicationDate <= ?', [\TIME_NOW]); $articleList->getConditionBuilder()->add('article.isDeleted = ?', [0]); - $articleList->decoratorClassName = ArticleEditor::class; $articleList->readObjects(); - foreach ($articleList as $article) { - $action = new ArticleAction([$article], 'update', [ - 'data' => [ - 'time' => $article->publicationDate, - 'publicationStatus' => Article::PUBLISHED, - 'publicationDate' => 0, - ], - ]); - $action->executeAction(); + foreach ($articleList->getObjects() as $article) { + $builder = ArticleBuilder::forUpdate($article) + ->setTime($article->publicationDate) + ->setPublicationStatus(Article::PUBLISHED) + ->setPublicationDate(0); + (new UpdateArticle($builder))(); } } } diff --git a/wcfsetup/install/files/lib/system/endpoint/controller/core/articles/DeleteArticle.class.php b/wcfsetup/install/files/lib/system/endpoint/controller/core/articles/DeleteArticle.class.php index 1f66f8ce9e..fe7c008ecb 100644 --- a/wcfsetup/install/files/lib/system/endpoint/controller/core/articles/DeleteArticle.class.php +++ b/wcfsetup/install/files/lib/system/endpoint/controller/core/articles/DeleteArticle.class.php @@ -6,7 +6,6 @@ use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use wcf\data\article\Article; -use wcf\data\article\ArticleAction; use wcf\http\Helper; use wcf\system\endpoint\DeleteRequest; use wcf\system\endpoint\IController; @@ -39,8 +38,7 @@ public function __invoke(ServerRequestInterface $request, array $variables): Res throw new IllegalLinkException(); } - $action = new ArticleAction([$article], 'delete'); - $action->executeAction(); + (new \wcf\command\article\DeleteArticle($article))(); return new JsonResponse([]); } diff --git a/wcfsetup/install/files/lib/system/endpoint/controller/core/articles/UnpublishArticle.class.php b/wcfsetup/install/files/lib/system/endpoint/controller/core/articles/UnpublishArticle.class.php index 2de9e8c268..a64e80d2aa 100644 --- a/wcfsetup/install/files/lib/system/endpoint/controller/core/articles/UnpublishArticle.class.php +++ b/wcfsetup/install/files/lib/system/endpoint/controller/core/articles/UnpublishArticle.class.php @@ -6,7 +6,6 @@ use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use wcf\data\article\Article; -use wcf\data\article\ArticleAction; use wcf\http\Helper; use wcf\system\endpoint\IController; use wcf\system\endpoint\PostRequest; @@ -39,8 +38,7 @@ public function __invoke(ServerRequestInterface $request, array $variables): Res throw new IllegalLinkException(); } - $action = new ArticleAction([$article], 'unpublish'); - $action->executeAction(); + (new \wcf\command\article\UnpublishArticle($article))(); return new JsonResponse([]); } diff --git a/wcfsetup/install/files/lib/system/moderation/queue/report/ArticleModerationQueueReportHandler.class.php b/wcfsetup/install/files/lib/system/moderation/queue/report/ArticleModerationQueueReportHandler.class.php index 3fa2e44ff4..2215390653 100644 --- a/wcfsetup/install/files/lib/system/moderation/queue/report/ArticleModerationQueueReportHandler.class.php +++ b/wcfsetup/install/files/lib/system/moderation/queue/report/ArticleModerationQueueReportHandler.class.php @@ -2,8 +2,8 @@ namespace wcf\system\moderation\queue\report; +use wcf\command\article\SoftDeleteArticle; use wcf\data\article\Article; -use wcf\data\article\ArticleAction; use wcf\data\moderation\queue\ModerationQueue; use wcf\data\moderation\queue\ViewableModerationQueue; use wcf\system\cache\runtime\ArticleRuntimeCache; @@ -152,7 +152,7 @@ public function canRemoveContent(ModerationQueue $queue) public function removeContent(ModerationQueue $queue, string $message) { if ($this->isValid($queue->objectID)) { - (new ArticleAction([$this->getArticle($queue->objectID)], 'trash'))->executeAction(); + (new SoftDeleteArticle($this->getArticle($queue->objectID)))(); } } diff --git a/wcfsetup/install/files/lib/system/worker/ArticleRebuildDataWorker.class.php b/wcfsetup/install/files/lib/system/worker/ArticleRebuildDataWorker.class.php index 602e871128..81cf8957ed 100644 --- a/wcfsetup/install/files/lib/system/worker/ArticleRebuildDataWorker.class.php +++ b/wcfsetup/install/files/lib/system/worker/ArticleRebuildDataWorker.class.php @@ -3,7 +3,7 @@ namespace wcf\system\worker; use wcf\data\article\Article; -use wcf\data\article\ArticleEditor; +use wcf\data\article\ArticleBuilder; use wcf\data\article\ArticleList; use wcf\data\article\content\ArticleContentBuilder; use wcf\data\article\content\ArticleContentList; @@ -147,11 +147,9 @@ public function execute() $cumulativeLikes = $statement->fetchMap('objectID', 'cumulativeLikes'); foreach ($this->objectList as $article) { - $data = [ - 'cumulativeLikes' => $cumulativeLikes[$article->articleID] ?? 0, - ]; - - (new ArticleEditor($article))->update($data); + ArticleBuilder::forUpdate($article) + ->incrementReactions(($cumulativeLikes[$article->articleID] ?? 0) - $article->cumulativeLikes) + ->update(); } } From c7bfb4bec598443a227f45621d2a24c1cfd64c9b Mon Sep 17 00:00:00 2001 From: Marcel Werk Date: Mon, 20 Jul 2026 12:49:53 +0200 Subject: [PATCH 35/36] Fix type error if `getParsedBody` returns null --- .../files/lib/system/form/builder/Psr15DialogForm.class.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/wcfsetup/install/files/lib/system/form/builder/Psr15DialogForm.class.php b/wcfsetup/install/files/lib/system/form/builder/Psr15DialogForm.class.php index 003e9a890b..07aa2a3589 100644 --- a/wcfsetup/install/files/lib/system/form/builder/Psr15DialogForm.class.php +++ b/wcfsetup/install/files/lib/system/form/builder/Psr15DialogForm.class.php @@ -40,7 +40,7 @@ public function __construct( */ public function validateRequest(ServerRequestInterface $request): ?ResponseInterface { - $this->requestData($request->getParsedBody()); + $this->requestData($request->getParsedBody() ?? []); $this->readValues(); $this->validate(); From 19d59f84d24e11cda91eb85e15e11341eeadd568 Mon Sep 17 00:00:00 2001 From: Marcel Werk Date: Mon, 20 Jul 2026 12:51:01 +0200 Subject: [PATCH 36/36] Add support for dialog forms using the builder pattern --- .../builder/Psr15BuilderDialogForm.class.php | 93 +++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 wcfsetup/install/files/lib/system/form/builder/Psr15BuilderDialogForm.class.php diff --git a/wcfsetup/install/files/lib/system/form/builder/Psr15BuilderDialogForm.class.php b/wcfsetup/install/files/lib/system/form/builder/Psr15BuilderDialogForm.class.php new file mode 100644 index 0000000000..64f4254246 --- /dev/null +++ b/wcfsetup/install/files/lib/system/form/builder/Psr15BuilderDialogForm.class.php @@ -0,0 +1,93 @@ + + * @since 6.3 + */ +final class Psr15BuilderDialogForm extends DatabaseObjectBuilderFormDocument +{ + private readonly string $title; + + public function __construct( + string $id, + string $title + ) { + $this->id($id); + $this->prefix($id); + $this->title = $title; + + $this->ajax = true; + } + + /** + * Processes the form using the request's parsed body. Returns 'null' + * if validation succeeded and the result of 'toResponse()' otherwise. + * + * @see Psr15BuilderDialogForm::toResponse() + */ + public function validateRequest(ServerRequestInterface $request): ?ResponseInterface + { + $this->requestData($request->getParsedBody() ?? []); + $this->readValues(); + $this->validate(); + + if ($this->hasValidationErrors()) { + return $this->toResponse(); + } + + return null; + } + + /** + * Returns a response that can be consumed by JavaScript's `dialogFactory().usingFormBuilder()`. + */ + public function toResponse(): ResponseInterface + { + return new JsonResponse([ + 'dialog' => $this->getHtml(), + 'formId' => $this->getId(), + 'title' => $this->title, + ]); + } + + #[\Override] + public function addButton(IFormButton $button) + { + throw new \LogicException(self::class . ' does not support custom buttons.'); + } + + #[\Override] + public function validate() + { + $this->traitValidate(); + } + + #[\Override] + protected function createDefaultButton() + { + /* Buttons are implicitly added by the dialog API. */ + } + + #[\Override] + public function ajax(bool $ajax = true) + { + /* This implementation forces `$ajax = true`. */ + + return $this; + } +}