diff --git a/wcfsetup/install/files/lib/acp/form/ArticleAddForm.class.php b/wcfsetup/install/files/lib/acp/form/ArticleAddForm.class.php index 9590b3ac2c2..834c1daacf0 100644 --- a/wcfsetup/install/files/lib/acp/form/ArticleAddForm.class.php +++ b/wcfsetup/install/files/lib/acp/form/ArticleAddForm.class.php @@ -2,21 +2,24 @@ 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\ArticleContentEditor; +use wcf\data\article\content\ArticleContent; 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 +36,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 +55,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 +81,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 +147,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 +165,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 +196,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 +212,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 +248,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 +310,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 +333,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 +349,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. @@ -391,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' @@ -401,154 +545,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 80c42372744..ab8a9e23858 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/acp/form/DevtoolsProjectAddForm.class.php b/wcfsetup/install/files/lib/acp/form/DevtoolsProjectAddForm.class.php index ce6e01bafa9..3a36dd51669 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/acp/form/TagAddForm.class.php b/wcfsetup/install/files/lib/acp/form/TagAddForm.class.php index 64e8f14f591..6760932a7ed 100644 --- a/wcfsetup/install/files/lib/acp/form/TagAddForm.class.php +++ b/wcfsetup/install/files/lib/acp/form/TagAddForm.class.php @@ -2,19 +2,20 @@ namespace wcf\acp\form; -use wcf\data\IStorableObject; +use wcf\command\tag\CreateTag; +use wcf\command\tag\UpdateTag; +use wcf\data\DatabaseObjectBuilder; 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; 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; @@ -23,13 +24,13 @@ /** * 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 AbstractFormBuilderForm + * @extends AbstractDatabaseObjectBuilderForm */ -class TagAddForm extends AbstractFormBuilderForm +class TagAddForm extends AbstractDatabaseObjectBuilderForm { /** * @inheritDoc @@ -49,18 +50,31 @@ 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 { - parent::createForm(); + if ($this->formObject !== null) { + return new UpdateTag($builder); + } + return new CreateTag($builder); + } + + #[\Override] + protected function createForm(): void + { $contentLanguages = LanguageFactory::getInstance()->getContentLanguages(); $this->form->appendChildren([ @@ -70,12 +84,20 @@ protected function createForm() ->label('wcf.global.name') ->required() ->maximumLength(\TAGGING_MAX_TAG_LENGTH) + ->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); + }) ->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 +114,30 @@ protected function createForm() ->options($contentLanguages) ->value(isset($contentLanguages[WCF::getLanguage()->languageID]) ? WCF::getLanguage()->languageID : null) ->immutable($this->formAction !== 'create') - ->required(), + ->required() + ->saveValueCallback(static function (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'), + ->label('wcf.acp.tag.synonyms') + ->saveValueCallback(static function (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([ @@ -105,45 +147,4 @@ protected function createForm() ]) ]); } - - #[\Override] - protected function finalizeForm() - { - 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', - 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/acp/form/TagEditForm.class.php b/wcfsetup/install/files/lib/acp/form/TagEditForm.class.php index 2b53db1058c..b79b9828fdf 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/action/FileDownloadAction.class.php b/wcfsetup/install/files/lib/action/FileDownloadAction.class.php index 7d422a09531..ac1476cab82 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, 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 00000000000..279d9648d2d --- /dev/null +++ b/wcfsetup/install/files/lib/command/article/CreateArticle.class.php @@ -0,0 +1,77 @@ + + * @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) { + if ($article->userID !== null) { + ArticleBuilder::incrementArticleCounter($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/DeleteArticle.class.php b/wcfsetup/install/files/lib/command/article/DeleteArticle.class.php new file mode 100644 index 00000000000..fb2638949fe --- /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 c195095689a..cfdf3f72cfd 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\ArticleBuilder; +use wcf\data\article\content\ArticleContentBuilder; use wcf\data\language\Language; use wcf\system\version\VersionTracker; @@ -30,24 +30,23 @@ 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', [ - '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 d53c7cec4c3..14220ba684d 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\ArticleBuilder; 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. @@ -30,33 +27,25 @@ 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); - $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/PublishArticle.class.php b/wcfsetup/install/files/lib/command/article/PublishArticle.class.php index d0d31649f43..edee62736ae 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 9a9ae42b4e5..bc46d171b44 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 c2dde690dca..7fe9843e29c 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 d170553be29..762b912dc94 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 57e9e884d57..8d48a6c0de3 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 new file mode 100644 index 00000000000..014ee248a3b --- /dev/null +++ b/wcfsetup/install/files/lib/command/article/UpdateArticle.class.php @@ -0,0 +1,151 @@ + + * @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) { + if ($article->userID !== null) { + ArticleBuilder::incrementArticleCounter($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/command/article/content/DeleteArticleContent.class.php b/wcfsetup/install/files/lib/command/article/content/DeleteArticleContent.class.php new file mode 100644 index 00000000000..c9f30c684e3 --- /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/command/tag/CreateTag.class.php b/wcfsetup/install/files/lib/command/tag/CreateTag.class.php new file mode 100644 index 00000000000..90e3e6c1a3a --- /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->create(); + + 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 new file mode 100644 index 00000000000..2bec2461924 --- /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->update(); + + EventHandler::getInstance()->fire(new TagUpdated($tag, $this->builder)); + + return $tag; + } +} 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 00000000000..3e3bac6841f --- /dev/null +++ b/wcfsetup/install/files/lib/data/DatabaseObjectBuilder.class.php @@ -0,0 +1,396 @@ + + * @since 6.3 + * + * @template TDatabaseObject of DatabaseObject + */ +abstract class DatabaseObjectBuilder +{ + /** + * @var array + */ + public protected(set) array $properties = []; + + /** + * @var array + */ + public protected(set) array $customProperties = []; + + /** + * @var array + */ + public protected(set) array $incrementProperties = []; + + private bool $consumed = false; + + /** + * Use forCreate() or forUpdate() to obtain a builder instance. + * + * @param ?TDatabaseObject $object + */ + private function __construct(protected readonly ?DatabaseObject $object = null) {} + + /** + * Inserts a new row and returns the created database object. + * + * @return TDatabaseObject + */ + final public function create(): DatabaseObject + { + if ($this->object !== null) { + throw new \BadMethodCallException("create() can only be used with forCreate()."); + } + + $this->markConsumed(); + + $this->validateCreate(); + $this->afterValidateCreate(); + + $keys = $values = ''; + $statementParameters = []; + foreach (\array_merge($this->properties, $this->customProperties, $this->incrementProperties) 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 (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() . "'"); + } + + $object = new (static::getBaseClass())($id); + + $this->afterCreate($object); + + 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 === [] && $this->incrementProperties === []) { + throw new \BadMethodCallException("Cannot create an object without any properties."); + } + + foreach ($this->getRequiredProperties() as $property) { + if (!\array_key_exists($property, $this->properties) && !\array_key_exists($property, $this->incrementProperties)) { + 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. + * + * @return TDatabaseObject + */ + final public function update(): DatabaseObject + { + if ($this->object === null) { + throw new \BadMethodCallException("update() can only be used with forUpdate()."); + } + + $this->markConsumed(); + + if ($this->properties !== [] || $this->customProperties !== [] || $this->incrementProperties !== []) { + $updateSQL = ''; + $statementParameters = []; + foreach (\array_merge($this->properties, $this->customProperties) as $key => $value) { + if ($updateSQL !== '') { + $updateSQL .= ', '; + } + $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() . " + SET " . $updateSQL . " + WHERE " . static::getBaseClass()::getDatabaseTableIndexName() . " = ?"; + $statement = WCF::getDB()->prepare($sql); + $statement->execute($statementParameters); + + $object = new (static::getBaseClass())($this->object->getObjectID()); + } else { + $object = $this->object; + } + + $this->afterUpdate($object); + + 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. + * + * @return ?TDatabaseObject + */ + final public function createOrIgnore(): ?DatabaseObject + { + if ($this->object !== null) { + throw new \BadMethodCallException("createOrIgnore() can only be used with forCreate()."); + } + + try { + return $this->create(); + } 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 + */ + final 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 non-empty-list|non-empty-list $objectIDs + */ + final public static function deleteAll(array $objectIDs): void + { + static::beforeDeleteAll($objectIDs); + + $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. + */ + final public static function forCreate(): static + { + return new (static::class)(); + } + + /** + * Returns a builder instance for updating an existing database object. + * + * @param TDatabaseObject $object + */ + final 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 + */ + 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."); + } + + $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. + */ + final public function setCustomProperty(string $name, string|int|float|null $value): static + { + $this->customProperties[$name] = $value; + + 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. + * You SHOULD NOT modify any properties in this method. + */ + 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. + * + * @param TDatabaseObject $object + */ + protected function afterCreate(DatabaseObject $object): 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. + * + * @param TDatabaseObject $object + */ + protected function afterUpdate(DatabaseObject $object): 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 non-empty-list|non-empty-list $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; + } + + final public function isUpdate(): bool + { + return $this->object !== null; + } + + /** + * @return TDatabaseObject + */ + 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; + } +} diff --git a/wcfsetup/install/files/lib/data/TCollectionCoverPhotos.class.php b/wcfsetup/install/files/lib/data/TCollectionCoverPhotos.class.php index bf9042952d9..6d91899d7e9 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/data/article/ArticleAction.class.php b/wcfsetup/install/files/lib/data/article/ArticleAction.class.php index 16ef4b7e039..b29e1680a7e 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 new file mode 100644 index 00000000000..dd99f7b68a1 --- /dev/null +++ b/wcfsetup/install/files/lib/data/article/ArticleBuilder.class.php @@ -0,0 +1,225 @@ + + * @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']; + } + + /** + * 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 7eafa20c3a5..83a87b83fe2 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 c308ed3d67e..8ed0fe01f28 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/data/article/content/ArticleContent.class.php b/wcfsetup/install/files/lib/data/article/content/ArticleContent.class.php index 504a31e813b..11fd17f15bb 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 37af2212c13..e328115fd6d 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 new file mode 100644 index 00000000000..3b097742cbc --- /dev/null +++ b/wcfsetup/install/files/lib/data/article/content/ArticleContentBuilder.class.php @@ -0,0 +1,233 @@ + + * @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; + } + + public function incrementComments(int $comments): static + { + $this->incrementProperties['comments'] = $comments; + + 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/data/article/content/ArticleContentEditor.class.php b/wcfsetup/install/files/lib/data/article/content/ArticleContentEditor.class.php index e3e5cc22add..3ca750e9ba5 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/data/tag/TagBuilder.class.php b/wcfsetup/install/files/lib/data/tag/TagBuilder.class.php new file mode 100644 index 00000000000..57db6c7be7e --- /dev/null +++ b/wcfsetup/install/files/lib/data/tag/TagBuilder.class.php @@ -0,0 +1,119 @@ + + * @since 6.3 + * + * @extends DatabaseObjectBuilder + */ +final class TagBuilder extends DatabaseObjectBuilder +{ + /** + * @var ?list + */ + private ?array $synonyms = null; + + 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; + $this->synonyms = []; + + return $this; + } + + /** + * @param list $synonyms + */ + public function setSynonyms(array $synonyms): static + { + $this->synonyms = $synonyms; + if ($synonyms !== []) { + $this->properties['synonymFor'] = null; + } + + return $this; + } + + #[\Override] + protected function afterCreate(DatabaseObject $object): void + { + if ($this->synonyms !== null && $this->synonyms !== []) { + $this->saveSynonyms($object, $this->synonyms); + } + } + + #[\Override] + protected function afterUpdate(DatabaseObject $object): void + { + if ($this->synonyms !== null) { + $this->removeSynonyms($object); + + if ($this->synonyms !== []) { + $this->saveSynonyms($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) + ->create(); + } else { + TagBuilder::forUpdate($synonymObj) + ->setSynonymFor($tag) + ->update(); + } + } + } + + #[\Override] + protected function getRequiredProperties(): array + { + return ['name']; + } +} 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 00000000000..a5e17116b34 --- /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/event/tag/TagCreated.class.php b/wcfsetup/install/files/lib/event/tag/TagCreated.class.php new file mode 100644 index 00000000000..d8b26513c92 --- /dev/null +++ b/wcfsetup/install/files/lib/event/tag/TagCreated.class.php @@ -0,0 +1,23 @@ + + * @since 6.3 + */ +final class TagCreated implements IPsr14Event +{ + public function __construct( + 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 new file mode 100644 index 00000000000..75c7a5c09e7 --- /dev/null +++ b/wcfsetup/install/files/lib/event/tag/TagUpdated.class.php @@ -0,0 +1,23 @@ + + * @since 6.3 + */ +final class TagUpdated implements IPsr14Event +{ + public function __construct( + public readonly Tag $tag, + public readonly TagBuilder $builder, + ) {} +} 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 00000000000..0896888f204 --- /dev/null +++ b/wcfsetup/install/files/lib/form/AbstractDatabaseObjectBuilderForm.class.php @@ -0,0 +1,276 @@ + + * @since 6.3 + * + * @template TDatabaseObject of DatabaseObject|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 ?TDatabaseObject + */ + public ?DatabaseObject $formObject = null; + + /** + * name of the controller for the link to the edit form + */ + public string $objectEditLinkController = ''; + + /** + * object persisted by the most recent `save()` call + * @var ?TDatabaseObject + */ + 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 function () use ($builder) { + if ($builder->isUpdate()) { + return $builder->update(); + } + + return $builder->create(); + }; + } + + #[\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(), + ]) + ); + } + + $this->afterSave(); + } + + #[\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; + $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, $_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 + } + + /** + * 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 + } +} diff --git a/wcfsetup/install/files/lib/form/ArticleAddForm.class.php b/wcfsetup/install/files/lib/form/ArticleAddForm.class.php index 2221e5071f8..0a4d5b0c24f 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)); } } } diff --git a/wcfsetup/install/files/lib/page/ArticlePage.class.php b/wcfsetup/install/files/lib/page/ArticlePage.class.php index 5aaa8169e2d..6df0e036e11 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/article/discussion/CommentArticleDiscussionProvider.class.php b/wcfsetup/install/files/lib/system/article/discussion/CommentArticleDiscussionProvider.class.php index 9ccd33400f8..9b38ce81099 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/category/ArticleCategoryType.class.php b/wcfsetup/install/files/lib/system/category/ArticleCategoryType.class.php index e9fdcf45f80..287c09cd523 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/comment/manager/ArticleCommentManager.class.php b/wcfsetup/install/files/lib/system/comment/manager/ArticleCommentManager.class.php index 3562555fc73..8c67df6fc02 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/cronjob/ArticlePublicationCronjob.class.php b/wcfsetup/install/files/lib/system/cronjob/ArticlePublicationCronjob.class.php index d7ee1848537..28a81570695 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 1f66f8ce9e0..fe7c008ecb9 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 2de9e8c268f..a64e80d2aa8 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/form/builder/DatabaseObjectBuilderFormDocument.class.php b/wcfsetup/install/files/lib/system/form/builder/DatabaseObjectBuilderFormDocument.class.php new file mode 100644 index 00000000000..ce417cd17cb --- /dev/null +++ b/wcfsetup/install/files/lib/system/form/builder/DatabaseObjectBuilderFormDocument.class.php @@ -0,0 +1,71 @@ +saveValueCallback( + * static function (DatabaseObjectBuilder $builder, IFormField $formField) { + * return $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 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/Psr15BuilderDialogForm.class.php b/wcfsetup/install/files/lib/system/form/builder/Psr15BuilderDialogForm.class.php new file mode 100644 index 00000000000..64f42542460 --- /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; + } +} 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 003e9a890ba..07aa2a3589b 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(); 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 e2c9c458d56..354701fb061 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<*>, static): void + * @since 6.3 + */ + protected ?\Closure $saveValueCallback = null; + + /** + * callback loading this field's value from an `IStorableObject` + * @var ?\Closure(\wcf\data\IStorableObject, static): void + * @since 6.3 + */ + protected ?\Closure $loadValueCallback = null; + /** * @return static */ @@ -290,14 +305,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 +397,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; @@ -502,7 +511,11 @@ public function updatedObject(array $data, IStorableObject $object, bool $loadVa $this->setAttachmentHandler(); - return parent::updatedObject($data, $object); + if ($this->loadValueCallback !== null) { + ($this->loadValueCallback)($object, $this); + } + + return parent::updatedObject($data, $object, $loadValues); } /** @@ -536,7 +549,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 +562,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); @@ -740,4 +753,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 f0a99d71149..a1499f7d91a 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,59 @@ 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 []; + } + + $id = $this->wysiwygId . 'poll'; + + $pollData = []; + foreach ($this->children() as $child) { + \assert($child instanceof AbstractFormField); + $name = \lcfirst( + \substr( + $child->getId(), + \strlen($id), + ) + ); + $pollData[$name] = $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; + } + + /** + * 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); + } } 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 badb8b8a35b..fdde9fdc197 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,20 @@ abstract class AbstractFormField implements IFormField */ protected $value; + /** + * callback transferring this field's save value into a `DatabaseObjectBuilder` + * @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, static): void + * @since 6.3 + */ + protected ?\Closure $loadValueCallback = null; + #[\Override] public function addValidationError(IFormFieldValidationError $error) { @@ -165,6 +179,34 @@ 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 loadValueCallback(\Closure $callback): static + { + $this->loadValueCallback = $callback; + + return $this; + } + + #[\Override] + public function getLoadValueCallback(): ?\Closure + { + return $this->loadValueCallback; + } + #[\Override] public function hasValidator(string $validatorId) { @@ -192,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 4d00dbea849..d31c55ea235 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,48 @@ public function getValue() return $this->field->getValue(); } + /** + * @template TBuilder of DatabaseObjectBuilder + * @param \Closure(TBuilder, T): void $callback + */ + #[\Override] + public function saveValueCallback(\Closure $callback): static + { + $this->field->saveValueCallback($callback); + + 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 + { + $this->field->loadValueCallback($callback); + + return $this; + } + + /** + * @return ?\Closure(IStorableObject, T): void + */ + #[\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/AbstractNumericFormField.class.php b/wcfsetup/install/files/lib/system/form/builder/field/AbstractNumericFormField.class.php index 4960b783766..4cc1b19623c 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 d2c004ae983..68c0b6bc9b7 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 e1e11e835e4..65b14885bac 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 bac6aa0b95a..cc1ff708d54 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 3813a205da5..694f883310c 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 85d3c15e685..f114d37d413 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 312f29dfbf3..5d8ab7ab951 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 00000000000..45da7e6ff03 --- /dev/null +++ b/wcfsetup/install/files/lib/system/form/builder/field/IBuilderNode.class.php @@ -0,0 +1,95 @@ + + * @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<*>, static): 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 + * @param \Closure(TObject, static): 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, static): 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 89330390b22..859965917b1 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; @@ -16,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. 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 7ca864e4178..c6573e6452a 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 f322716dfc0..a28b390e0e9 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 662f56fdf26..74453dacbfa 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 d5a2746eb78..a6874065e20 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 0495859715c..f8fd48e9f4a 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 474ac413760..e8a18c9230e 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/TI18nFormField.class.php b/wcfsetup/install/files/lib/system/form/builder/field/TI18nFormField.class.php index cbec854598f..0523a612f9b 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; + } } } 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 d57691ccb19..e24bf8fd438 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, 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 295fca4207f..bd6c1fe61aa 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()}; 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 4be0c94a242..fc84fa3653b 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')); @@ -393,4 +400,27 @@ 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; + } + + /** + * @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); + } } 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 3fa2e44ff48..22153906531 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 356724e0c15..81cf8957ed7 100644 --- a/wcfsetup/install/files/lib/system/worker/ArticleRebuildDataWorker.class.php +++ b/wcfsetup/install/files/lib/system/worker/ArticleRebuildDataWorker.class.php @@ -3,9 +3,9 @@ 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\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 @@ -148,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(); } }