Merge branch '5.3'
authorMatthias Schmidt <gravatronics@live.com>
Fri, 23 Apr 2021 06:14:28 +0000 (08:14 +0200)
committerMatthias Schmidt <gravatronics@live.com>
Fri, 23 Apr 2021 06:14:28 +0000 (08:14 +0200)
1  2 
wcfsetup/install/files/lib/data/article/ArticleAction.class.php
wcfsetup/install/files/lib/system/form/builder/field/TextFormField.class.php
wcfsetup/install/files/lib/system/form/builder/field/wysiwyg/WysiwygFormField.class.php

index 9ff32064d3a3a542344ea6e6f25158584826cb2a,d44c02325cfa5d675090e9b210a0edc12d4b3a98..2e47b398ca197a47b8dce418093b5340566ee854
@@@ -29,889 -27,810 +29,889 @@@ use wcf\system\WCF
  
  /**
   * Executes article related actions.
 - * 
 - * @author    Marcel Werk
 - * @copyright 2001-2019 WoltLab GmbH
 - * @license   GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
 - * @package   WoltLabSuite\Core\Data\Article
 - * @since     3.0
 - * 
 - * @method    ArticleEditor[] getObjects()
 - * @method    ArticleEditor   getSingleObject()
 + *
 + * @author  Marcel Werk
 + * @copyright   2001-2019 WoltLab GmbH
 + * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
 + * @package WoltLabSuite\Core\Data\Article
 + * @since   3.0
 + *
 + * @method  ArticleEditor[] getObjects()
 + * @method  ArticleEditor   getSingleObject()
   */
 -class ArticleAction extends AbstractDatabaseObjectAction {
 -      /**
 -       * article editor instance
 -       * @var ArticleEditor
 -       */
 -      public $articleEditor;
 -      
 -      /**
 -       * language object
 -       * @var Language
 -       */
 -      public $language;
 -      
 -      /**
 -       * @inheritDoc
 -       */
 -      protected $className = ArticleEditor::class;
 -      
 -      /**
 -       * @inheritDoc
 -       */
 -      protected $permissionsCreate = ['admin.content.article.canManageArticle'];
 -      
 -      /**
 -       * @inheritDoc
 -       */
 -      protected $permissionsDelete = ['admin.content.article.canManageArticle'];
 -      
 -      /**
 -       * @inheritDoc
 -       */
 -      protected $permissionsUpdate = ['admin.content.article.canManageArticle'];
 -      
 -      /**
 -       * @inheritDoc
 -       */
 -      protected $requireACP = ['create', 'update'];
 -      
 -      /**
 -       * @inheritDoc
 -       */
 -      protected $allowGuestAccess = ['markAllAsRead'];
 -      
 -      /**
 -       * @inheritDoc
 -       * @return      Article
 -       */
 -      public function create() {
 -              /** @var Article $article */
 -              $article = parent::create();
 -              
 -              // save article content
 -              if (!empty($this->parameters['content'])) {
 -                      foreach ($this->parameters['content'] as $languageID => $content) {
 -                              if (!empty($content['htmlInputProcessor'])) {
 -                                      /** @noinspection PhpUndefinedMethodInspection */
 -                                      $content['content'] = $content['htmlInputProcessor']->getHtml();
 -                              }
 -                              
 -                              /** @var ArticleContent $articleContent */
 -                              $articleContent = ArticleContentEditor::create([
 -                                      'articleID' => $article->articleID,
 -                                      'languageID' => $languageID ?: null,
 -                                      'title' => $content['title'],
 -                                      'teaser' => $content['teaser'],
 -                                      'content' => $content['content'],
 -                                      'imageID' => $content['imageID'],
 -                                      'teaserImageID' => $content['teaserImageID'],
 -                                      'metaTitle' => $content['metaTitle'] ?? '',
 -                                      'metaDescription' => $content['metaDescription'] ?? '',
 -                              ]);
 -                              $articleContentEditor = new ArticleContentEditor($articleContent);
 -                              
 -                              // save tags
 -                              if (!empty($content['tags'])) {
 -                                      TagEngine::getInstance()->addObjectTags('com.woltlab.wcf.article', $articleContent->articleContentID, $content['tags'], ($languageID ?: LanguageFactory::getInstance()->getDefaultLanguageID()));
 -                              }
 -                              
 -                              // update search index
 -                              SearchIndexManager::getInstance()->set(
 -                                      'com.woltlab.wcf.article',
 -                                      $articleContent->articleContentID,
 -                                      $articleContent->content,
 -                                      $articleContent->title,
 -                                      $article->time,
 -                                      $article->userID,
 -                                      $article->username,
 -                                      $languageID ?: null,
 -                                      $articleContent->teaser
 -                              );
 -                              
 -                              // save embedded objects
 -                              if (!empty($content['htmlInputProcessor'])) {
 -                                      /** @noinspection PhpUndefinedMethodInspection */
 -                                      $content['htmlInputProcessor']->setObjectID($articleContent->articleContentID);
 -                                      if (MessageEmbeddedObjectManager::getInstance()->registerObjects($content['htmlInputProcessor'])) {
 -                                              $articleContentEditor->update(['hasEmbeddedObjects' => 1]);
 -                                      }
 -                              }
 -                      }
 -              }
 -              
 -              // reset storage
 -              if (ARTICLE_ENABLE_VISIT_TRACKING) {
 -                      UserStorageHandler::getInstance()->resetAll('unreadArticles');
 -                      UserStorageHandler::getInstance()->resetAll('unreadWatchedArticles');
 -                      UserStorageHandler::getInstance()->resetAll('unreadArticlesByCategory');
 -              }
 -              
 -              if ($article->publicationStatus == Article::PUBLISHED) {
 -                      ArticleEditor::updateArticleCounter([$article->userID => 1]);
 -                      
 -                      UserObjectWatchHandler::getInstance()->updateObject(
 -                              'com.woltlab.wcf.article.category',
 -                              $article->getCategory()->categoryID,
 -                              'article',
 -                              'com.woltlab.wcf.article.notification',
 -                              new ArticleUserNotificationObject($article)
 -                      );
 -                      
 -                      UserActivityEventHandler::getInstance()->fireEvent('com.woltlab.wcf.article.recentActivityEvent', $article->articleID, null, $article->userID, $article->time);
 -              }
 -              
 -              return $article;
 -      }
 -      
 -      /**
 -       * @inheritDoc
 -       */
 -      public function update() {
 -              parent::update();
 -              
 -              $isRevert = (!empty($this->parameters['isRevert']));
 -              
 -              // update article content
 -              if (!empty($this->parameters['content'])) {
 -                      foreach ($this->getObjects() as $article) {
 -                              $versionData = [];
 -                              $hasChanges = false;
 -                              
 -                              foreach ($this->parameters['content'] as $languageID => $content) {
 -                                      if (!empty($content['htmlInputProcessor'])) {
 -                                              /** @noinspection PhpUndefinedMethodInspection */
 -                                              $content['content'] = $content['htmlInputProcessor']->getHtml();
 -                                      }
 -                                      
 -                                      $articleContent = ArticleContent::getArticleContent($article->articleID, ($languageID ?: null));
 -                                      $articleContentEditor = null;
 -                                      if ($articleContent !== null) {
 -                                              // update
 -                                              $articleContentEditor = new ArticleContentEditor($articleContent);
 -                                              $articleContentEditor->update([
 -                                                      'title' => $content['title'],
 -                                                      'teaser' => $content['teaser'],
 -                                                      'content' => $content['content'],
 -                                                      'imageID' => ($isRevert) ? $articleContent->imageID : $content['imageID'],
 -                                                      'teaserImageID' => ($isRevert) ? $articleContent->teaserImageID : $content['teaserImageID'],
 -                                                      'metaTitle' => $content['metaTitle'] ?? '',
 -                                                      'metaDescription' => $content['metaDescription'] ?? '',
 -                                              ]);
 -                                              
 -                                              $versionData[] = $articleContent;
 -                                              if ($articleContent->content != $content['content'] || $articleContent->teaser != $content['teaser'] || $articleContent->title != $content['title']) {
 -                                                      $hasChanges = true;
 -                                              }
 -                                              
 -                                              // delete tags
 -                                              if (!$isRevert && empty($content['tags'])) {
 -                                                      TagEngine::getInstance()->deleteObjectTags('com.woltlab.wcf.article', $articleContent->articleContentID, ($languageID ?: null));
 -                                              }
 -                                      }
 -                                      else {
 -                                              /** @var ArticleContent $articleContent */
 -                                              $articleContent = ArticleContentEditor::create([
 -                                                      'articleID' => $article->articleID,
 -                                                      'languageID' => $languageID ?: null,
 -                                                      'title' => $content['title'],
 -                                                      'teaser' => $content['teaser'],
 -                                                      'content' => $content['content'],
 -                                                      'imageID' => ($isRevert) ? null : $content['imageID'],
 -                                                      'teaserImageID' => ($isRevert) ? null : $content['teaserImageID'],
 -                                                      'metaTitle' => $content['metaTitle'] ?? '',
 -                                                      'metaDescription' => $content['metaDescription'] ?? '',
 -                                              ]);
 -                                              $articleContentEditor = new ArticleContentEditor($articleContent);
 -                                              
 -                                              $versionData[] = $articleContent;
 -                                              $hasChanges = true;
 -                                      }
 -                                      
 -                                      // save tags
 -                                      if (!$isRevert && !empty($content['tags'])) {
 -                                              TagEngine::getInstance()->addObjectTags('com.woltlab.wcf.article', $articleContent->articleContentID, $content['tags'], ($languageID ?: LanguageFactory::getInstance()->getDefaultLanguageID()));
 -                                      }
 -                                      
 -                                      // update search index
 -                                      SearchIndexManager::getInstance()->set(
 -                                              'com.woltlab.wcf.article',
 -                                              $articleContent->articleContentID,
 -                                              isset($content['content']) ? $content['content'] : $articleContent->content,
 -                                              isset($content['title']) ? $content['title'] : $articleContent->title,
 -                                              $this->parameters['data']['time'] ?? $article->time,
 -                                              $this->parameters['data']['userID'] ?? $article->userID,
 -                                              $this->parameters['data']['username'] ?? $article->username,
 -                                              $languageID ?: null,
 -                                              isset($content['teaser']) ? $content['teaser'] : $articleContent->teaser
 -                                      );
 -                                      
 -                                      // save embedded objects
 -                                      if (!empty($content['htmlInputProcessor'])) {
 -                                              /** @noinspection PhpUndefinedMethodInspection */
 -                                              $content['htmlInputProcessor']->setObjectID($articleContent->articleContentID);
 -                                              if ($articleContent->hasEmbeddedObjects != MessageEmbeddedObjectManager::getInstance()->registerObjects($content['htmlInputProcessor'])) {
 -                                                      $articleContentEditor->update(['hasEmbeddedObjects' => $articleContent->hasEmbeddedObjects ? 0 : 1]);
 -                                              }
 -                                      }
 -                              }
 -                              
 -                              if ($hasChanges) {
 -                                      $articleObj = new ArticleVersionTracker($article->getDecoratedObject());
 -                                      $articleObj->setContent($versionData);
 -                                      VersionTracker::getInstance()->add('com.woltlab.wcf.article', $articleObj);
 -                              }
 -                      }
 -              }
 -              
 -              // reset storage
 -              if (ARTICLE_ENABLE_VISIT_TRACKING) {
 -                      UserStorageHandler::getInstance()->resetAll('unreadArticles');
 -                      UserStorageHandler::getInstance()->resetAll('unreadWatchedArticles');
 -                      UserStorageHandler::getInstance()->resetAll('unreadArticlesByCategory');
 -              }
 -              
 -              $publicationStatus = (isset($this->parameters['data']['publicationStatus'])) ? $this->parameters['data']['publicationStatus'] : null;
 -              if ($publicationStatus !== null) {
 -                      $usersToArticles = $resetArticleIDs = [];
 -                      /** @var ArticleEditor $articleEditor */
 -                      foreach ($this->objects as $articleEditor) {
 -                              if ($publicationStatus != $articleEditor->publicationStatus) {
 -                                      // The article was published before or was now published.
 -                                      if ($publicationStatus == Article::PUBLISHED || $articleEditor->publicationStatus == Article::PUBLISHED) {
 -                                              if (!isset($usersToArticles[$articleEditor->userID])) {
 -                                                      $usersToArticles[$articleEditor->userID] = 0;
 -                                              }
 -                                              
 -                                              $usersToArticles[$articleEditor->userID] += ($publicationStatus == Article::PUBLISHED) ? 1 : -1;
 -                                      }
 -                                      
 -                                      if ($publicationStatus == Article::PUBLISHED) {
 -                                              UserObjectWatchHandler::getInstance()->updateObject(
 -                                                      'com.woltlab.wcf.article.category',
 -                                                      $articleEditor->getCategory()->categoryID,
 -                                                      'article',
 -                                                      'com.woltlab.wcf.article.notification',
 -                                                      new ArticleUserNotificationObject($articleEditor->getDecoratedObject())
 -                                              );
 -                                              
 -                                              UserActivityEventHandler::getInstance()->fireEvent(
 -                                                      'com.woltlab.wcf.article.recentActivityEvent',
 -                                                      $articleEditor->articleID,
 -                                                      null,
 -                                                      $this->parameters['data']['userID'] ?? $articleEditor->userID,
 -                                                      $this->parameters['data']['time'] ?? $articleEditor->time
 -                                              );
 -                                      }
 -                                      else {
 -                                              $resetArticleIDs[] = $articleEditor->articleID;
 -                                      }
 -                              }
 -                      }
 -                      
 -                      if (!empty($resetArticleIDs)) {
 -                              // delete user notifications
 -                              UserNotificationHandler::getInstance()->removeNotifications('com.woltlab.wcf.article.notification', $resetArticleIDs);
 -                              // delete recent activity events
 -                              UserActivityEventHandler::getInstance()->removeEvents('com.woltlab.wcf.article.recentActivityEvent', $resetArticleIDs);
 -                      }
 -                      
 -                      if (!empty($usersToArticles)) {
 -                              ArticleEditor::updateArticleCounter($usersToArticles);
 -                      }
 -              }
 -              
 -              // update author in recent activities
 -              if (isset($this->parameters['data']['userID'])) {
 -                      $sql = "UPDATE wcf".WCF_N."_user_activity_event SET userID = ? WHERE objectTypeID = ? AND objectID = ?";
 -                      $statement = WCF::getDB()->prepareStatement($sql);
 -                      
 -                      foreach ($this->objects as $articleEditor) {
 -                              if ($articleEditor->userID != $this->parameters['data']['userID']) {
 -                                      $statement->execute([
 -                                              $this->parameters['data']['userID'],
 -                                              UserActivityEventHandler::getInstance()->getObjectTypeID('com.woltlab.wcf.article.recentActivityEvent'),
 -                                              $articleEditor->articleID,
 -                                      ]);
 -                              }
 -                      }
 -              }
 -      }
 -      
 -      /**
 -       * Validates parameters to delete articles.
 -       *
 -       * @throws      PermissionDeniedException
 -       * @throws      UserInputException
 -       */
 -      public function validateDelete() {
 -              if (empty($this->objects)) {
 -                      $this->readObjects();
 -                      
 -                      if (empty($this->objects)) {
 -                              throw new UserInputException('objectIDs');
 -                      }
 -              }
 -              
 -              foreach ($this->getObjects() as $article) {
 -                      if (!$article->canDelete()) {
 -                              throw new PermissionDeniedException();
 -                      }
 -                      
 -                      if (!$article->isDeleted) {
 -                              throw new UserInputException('objectIDs');
 -                      }
 -              }
 -      }
 -      
 -      /**
 -       * @inheritDoc
 -       */
 -      public function delete() {
 -              $usersToArticles = $articleIDs = $articleContentIDs = [];
 -              foreach ($this->getObjects() as $article) {
 -                      $articleIDs[] = $article->articleID;
 -                      foreach ($article->getArticleContents() as $articleContent) {
 -                              $articleContentIDs[] = $articleContent->articleContentID;
 -                      }
 -                      
 -                      if ($article->publicationStatus == Article::PUBLISHED) {
 -                              if (!isset($usersToArticles[$article->userID])) {
 -                                      $usersToArticles[$article->userID] = 0;
 -                              }
 -                              $usersToArticles[$article->userID]--;
 -                      }
 -              }
 -              
 -              // delete articles
 -              parent::delete();
 -              
 -              if (!empty($articleIDs)) {
 -                      // delete like data
 -                      ReactionHandler::getInstance()->removeReactions('com.woltlab.wcf.likeableArticle', $articleIDs);
 -                      // 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', $articleIDs);
 -                      // delete recent activity events
 -                      UserActivityEventHandler::getInstance()->removeEvents('com.woltlab.wcf.article.recentActivityEvent', $articleIDs);
 -                      // delete embedded object references
 -                      MessageEmbeddedObjectManager::getInstance()->removeObjects('com.woltlab.wcf.article.content', $articleContentIDs);
 -                      // update wcf1_user.articles
 -                      ArticleEditor::updateArticleCounter($usersToArticles);
 -              }
 -              
 -              $this->unmarkItems();
 -              
 -              return [
 -                      'objectIDs' => $this->objectIDs,
 -                      'redirectURL' => LinkHandler::getInstance()->getLink('ArticleList', ['isACP' => true])
 -              ];
 -      }
 -      
 -      /**
 -       * Validates parameters to move articles to the trash bin.
 -       * 
 -       * @throws      PermissionDeniedException
 -       * @throws      UserInputException
 -       */
 -      public function validateTrash() {
 -              if (empty($this->objects)) {
 -                      $this->readObjects();
 -                      
 -                      if (empty($this->objects)) {
 -                              throw new UserInputException('objectIDs');
 -                      }
 -              }
 -              
 -              foreach ($this->getObjects() as $article) {
 -                      if (!$article->canDelete()) {
 -                              throw new PermissionDeniedException();
 -                      }
 -                      
 -                      if ($article->isDeleted) {
 -                              throw new UserInputException('objectIDs');
 -                      }
 -              }
 -      }
 -      
 -      /**
 -       * Moves articles to the trash bin.
 -       */
 -      public function trash() {
 -              foreach ($this->getObjects() as $articleEditor) {
 -                      $articleEditor->update(['isDeleted' => 1]);
 -              }
 -              
 -              $this->unmarkItems();
 -              
 -              // reset storage
 -              if (ARTICLE_ENABLE_VISIT_TRACKING) {
 -                      UserStorageHandler::getInstance()->resetAll('unreadArticles');
 -                      UserStorageHandler::getInstance()->resetAll('unreadWatchedArticles');
 -                      UserStorageHandler::getInstance()->resetAll('unreadArticlesByCategory');
 -              }
 -              
 -              return ['objectIDs' => $this->objectIDs];
 -      }
 -      
 -      /**
 -       * Validates parameters to restore articles.
 -       * 
 -       * @throws      UserInputException
 -       */
 -      public function validateRestore() {
 -              $this->validateDelete();
 -      }
 -      
 -      /**
 -       * Restores articles.
 -       */
 -      public function restore() {
 -              foreach ($this->getObjects() as $articleEditor) {
 -                      $articleEditor->update(['isDeleted' => 0]);
 -              }
 -              
 -              $this->unmarkItems();
 -              
 -              // reset storage
 -              if (ARTICLE_ENABLE_VISIT_TRACKING) {
 -                      UserStorageHandler::getInstance()->resetAll('unreadArticles');
 -                      UserStorageHandler::getInstance()->resetAll('unreadWatchedArticles');
 -                      UserStorageHandler::getInstance()->resetAll('unreadArticlesByCategory');
 -              }
 -              
 -              return ['objectIDs' => $this->objectIDs];
 -      }
 -      
 -      /**
 -       * Validates parameters to toggle between i18n and monolingual mode.
 -       * 
 -       * @throws      UserInputException
 -       */
 -      public function validateToggleI18n() {
 -              WCF::getSession()->checkPermissions(['admin.content.article.canManageArticle']);
 -              
 -              $this->articleEditor = $this->getSingleObject();
 -              if ($this->articleEditor->getDecoratedObject()->isMultilingual) {
 -                      $this->readInteger('languageID');
 -                      $this->language = LanguageFactory::getInstance()->getLanguage($this->parameters['languageID']);
 -                      if ($this->language === null) {
 -                              throw new UserInputException('languageID');
 -                      }
 -                      
 -                      $contents = $this->articleEditor->getArticleContents();
 -                      if (!isset($contents[$this->language->languageID])) {
 -                              // there is no content
 -                              throw new UserInputException('languageID');
 -                      }
 -              }
 -      }
 -      
 -      /**
 -       * Toggles between i18n and monolingual mode.
 -       */
 -      public function toggleI18n() {
 -              $removeContent = [];
 -              
 -              // i18n -> monolingual
 -              if ($this->articleEditor->getDecoratedObject()->isMultilingual) {
 -                      foreach ($this->articleEditor->getArticleContents() as $articleContent) {
 -                              if ($articleContent->languageID == $this->language->languageID) {
 -                                      $articleContentEditor = new ArticleContentEditor($articleContent);
 -                                      $articleContentEditor->update(['languageID' => null]);
 -                              }
 -                              else {
 -                                      $removeContent[] = $articleContent;
 -                              }
 -                      }
 -              }
 -              else {
 -                      // monolingual -> i18n
 -                      $articleContent = $this->articleEditor->getArticleContent();
 -                      $data = [];
 -                      foreach (LanguageFactory::getInstance()->getLanguages() as $language) {
 -                              $data[$language->languageID] = [
 -                                      'title' => $articleContent->title,
 -                                      'teaser' => $articleContent->teaser,
 -                                      'content' => $articleContent->content,
 -                                      'imageID' => $articleContent->imageID ?: null,
 -                                      'teaserImageID' => $articleContent->teaserImageID ?: null
 -                              ];
 -                      }
 -                      
 -                      $action = new ArticleAction([$this->articleEditor], 'update', ['content' => $data]);
 -                      $action->executeAction();
 -                      
 -                      $removeContent[] = $articleContent;
 -              }
 -              
 -              if (!empty($removeContent)) {
 -                      $action = new ArticleContentAction($removeContent, 'delete');
 -                      $action->executeAction();
 -              }
 -              
 -              // flush edit history
 -              VersionTracker::getInstance()->reset('com.woltlab.wcf.article', $this->articleEditor->getDecoratedObject()->articleID);
 -              
 -              // update article's i18n state
 -              $this->articleEditor->update([
 -                      'isMultilingual' => ($this->articleEditor->getDecoratedObject()->isMultilingual) ? 0 : 1
 -              ]);
 -      }
 -      
 -      /**
 -       * Marks articles as read.
 -       */
 -      public function markAsRead() {
 -              if (empty($this->parameters['visitTime'])) {
 -                      $this->parameters['visitTime'] = TIME_NOW;
 -              }
 -              
 -              if (empty($this->objects)) {
 -                      $this->readObjects();
 -              }
 -              
 -              foreach ($this->getObjects() as $article) {
 -                      VisitTracker::getInstance()->trackObjectVisit('com.woltlab.wcf.article', $article->articleID, $this->parameters['visitTime']);
 -              }
 -              
 -              // reset storage
 -              if (WCF::getUser()->userID) {
 -                      UserStorageHandler::getInstance()->reset([WCF::getUser()->userID], 'unreadArticles');
 -                      UserStorageHandler::getInstance()->reset([WCF::getUser()->userID], 'unreadWatchedArticles');
 -                      UserStorageHandler::getInstance()->reset([WCF::getUser()->userID], 'unreadArticlesByCategory');
 -              }
 -      }
 -      
 -      /**
 -       * Marks all articles as read.
 -       */
 -      public function markAllAsRead() {
 -              VisitTracker::getInstance()->trackTypeVisit('com.woltlab.wcf.article');
 -              
 -              // reset storage
 -              if (WCF::getUser()->userID) {
 -                      UserStorageHandler::getInstance()->reset([WCF::getUser()->userID], 'unreadArticles');
 -                      UserStorageHandler::getInstance()->reset([WCF::getUser()->userID], 'unreadWatchedArticles');
 -                      UserStorageHandler::getInstance()->reset([WCF::getUser()->userID], 'unreadArticlesByCategory');
 -              }
 -      }
 -      
 -      /**
 -       * Validates the mark all as read action.
 -       */
 -      public function validateMarkAllAsRead() {
 -              // does nothing
 -      }
 -      
 -      /**
 -       * Validates the `setCategory` action.
 -       * 
 -       * @throws      UserInputException
 -       */
 -      public function validateSetCategory() {
 -              WCF::getSession()->checkPermissions(['admin.content.article.canManageArticle']);
 -              
 -              $this->readBoolean('useMarkedArticles', true);
 -              
 -              // if no object ids are given, use clipboard handler
 -              if (empty($this->objectIDs) && $this->parameters['useMarkedArticles']) {
 -                      $this->objectIDs = array_keys(ClipboardHandler::getInstance()->getMarkedItems(ClipboardHandler::getInstance()->getObjectTypeID('com.woltlab.wcf.article')));
 -              }
 -              
 -              if (empty($this->objects)) {
 -                      $this->readObjects();
 -                      
 -                      if (empty($this->objects)) {
 -                              throw new UserInputException('objectIDs');
 -                      }
 -              }
 -              
 -              $this->readInteger('categoryID');
 -              if (ArticleCategory::getCategory($this->parameters['categoryID']) === null) {
 -                      throw new UserInputException('categoryID');
 -              }
 -      }
 -      
 -      /**
 -       * Sets the category of articles.
 -       */
 -      public function setCategory() {
 -              foreach ($this->getObjects() as $articleEditor) {
 -                      $articleEditor->update(['categoryID' => $this->parameters['categoryID']]);
 -              }
 -              
 -              $this->unmarkItems();
 -      }
 -      
 -      /**
 -       * Validates the `publish` action.
 -       * 
 -       * @throws      PermissionDeniedException
 -       * @throws      UserInputException
 -       */
 -      public function validatePublish() {
 -              if (empty($this->objects)) {
 -                      $this->readObjects();
 -                      
 -                      if (empty($this->objects)) {
 -                              throw new UserInputException('objectIDs');
 -                      }
 -              }
 -              
 -              foreach ($this->getObjects() as $article) {
 -                      if (!$article->canPublish()) {
 -                              throw new PermissionDeniedException();  
 -                      }
 -                      
 -                      if ($article->publicationStatus == Article::PUBLISHED) {
 -                              throw new UserInputException('objectIDs');
 -                      }
 -              }
 -      }
 -      
 -      /**
 -       * Publishes articles.
 -       */
 -      public function publish() {
 -              $usersToArticles = [];
 -              foreach ($this->getObjects() as $articleEditor) {
 -                      $articleEditor->update([
 -                              'time' => TIME_NOW,
 -                              'publicationStatus' => Article::PUBLISHED,
 -                              'publicationDate' => 0
 -                      ]);
 -                      
 -                      if (!isset($usersToArticles[$articleEditor->userID])) {
 -                              $usersToArticles[$articleEditor->userID] = 0;
 -                      }
 -                      
 -                      $usersToArticles[$articleEditor->userID]++;
 -                      
 -                      UserObjectWatchHandler::getInstance()->updateObject(
 -                              'com.woltlab.wcf.article.category',
 -                              $articleEditor->getCategory()->categoryID,
 -                              'article',
 -                              'com.woltlab.wcf.article.notification',
 -                              new ArticleUserNotificationObject($articleEditor->getDecoratedObject())
 -                      );
 -                      
 -                      UserActivityEventHandler::getInstance()->fireEvent('com.woltlab.wcf.article.recentActivityEvent', $articleEditor->articleID, null, $articleEditor->userID, TIME_NOW);
 -              }
 -              
 -              ArticleEditor::updateArticleCounter($usersToArticles);
 -              
 -              // reset storage
 -              if (ARTICLE_ENABLE_VISIT_TRACKING) {
 -                      UserStorageHandler::getInstance()->resetAll('unreadArticles');
 -                      UserStorageHandler::getInstance()->resetAll('unreadWatchedArticles');
 -                      UserStorageHandler::getInstance()->resetAll('unreadArticlesByCategory');
 -              }
 -              
 -              $this->unmarkItems();
 -      }
 -      
 -      /**
 -       * Validates the `unpublish` action.
 -       * 
 -       * @throws      PermissionDeniedException
 -       * @throws      UserInputException
 -       */
 -      public function validateUnpublish() {
 -              if (empty($this->objects)) {
 -                      $this->readObjects();
 -                      
 -                      if (empty($this->objects)) {
 -                              throw new UserInputException('objectIDs');
 -                      }
 -              }
 -              
 -              foreach ($this->getObjects() as $article) {
 -                      if (!$article->canPublish()) {
 -                              throw new PermissionDeniedException();
 -                      }
 -                      
 -                      if ($article->publicationStatus != Article::PUBLISHED) {
 -                              throw new UserInputException('objectIDs');
 -                      }
 -              }
 -      }
 -      
 -      /**
 -       * Unpublishes articles.
 -       */
 -      public function unpublish() {
 -              $usersToArticles = $articleIDs = [];
 -              foreach ($this->getObjects() as $articleEditor) {
 -                      $articleEditor->update(['publicationStatus' => Article::UNPUBLISHED]);
 -                      
 -                      if (!isset($usersToArticles[$articleEditor->userID])) {
 -                              $usersToArticles[$articleEditor->userID] = 0;
 -                      }
 -                      
 -                      $usersToArticles[$articleEditor->userID]--;
 -                      
 -                      $articleIDs[] = $articleEditor->articleID;
 -              }
 -              
 -              // delete user notifications
 -              UserNotificationHandler::getInstance()->removeNotifications('com.woltlab.wcf.article.notification', $articleIDs);
 -              
 -              // delete recent activity events
 -              UserActivityEventHandler::getInstance()->removeEvents('com.woltlab.wcf.article.recentActivityEvent', $articleIDs);
 -              
 -              ArticleEditor::updateArticleCounter($usersToArticles);
 -              
 -              $this->unmarkItems();
 -      }
 -      
 -      /**
 -       * Validates parameters to search for an article by its localized title.
 -       */
 -      public function validateSearch() {
 -              $this->readString('searchString');
 -      }
 -      
 -      /**
 -       * Searches for an article by its localized title.
 -       * 
 -       * @return      array   list of matching articles
 -       */
 -      public function search() {
 -              $sql = "SELECT          articleID
 -                      FROM            wcf".WCF_N."_article_content
 -                      WHERE           title LIKE ?
 -                                      AND (
 -                                              languageID = ?
 -                                              OR languageID IS NULL
 -                                      )
 -                      ORDER BY        title";
 -              $statement = WCF::getDB()->prepareStatement($sql, 5);
 -              $statement->execute([
 -                      '%' . $this->parameters['searchString'] . '%',
 -                      WCF::getLanguage()->languageID,
 -              ]);
 -              
 -              $articleIDs = [];
 -              while ($articleID = $statement->fetchColumn()) {
 -                      $articleIDs[] = $articleID;
 -              }
 -              
 -              $articleList = new ArticleList();
 -              $articleList->setObjectIDs($articleIDs);
 -              $articleList->readObjects();
 -              
 -              $articles = [];
 -              foreach ($articleList as $article) {
 -                      $articles[] = [
 -                              'displayLink' => $article->getLink(),
 -                              'name' => $article->getTitle(),
 -                              'articleID' => $article->articleID,     
 -                      ];
 -              }
 -              
 -              return $articles;
 -      }
 -      
 -      /**
 -       * Unmarks articles.
 -       * 
 -       * @param       integer[]       $articleIDs
 -       */
 -      protected function unmarkItems(array $articleIDs = []) {
 -              if (empty($articleIDs)) {
 -                      foreach ($this->getObjects() as $article) {
 -                              $articleIDs[] = $article->articleID;
 -                      }
 -              }
 -              
 -              if (!empty($articleIDs)) {
 -                      ClipboardHandler::getInstance()->unmark($articleIDs, ClipboardHandler::getInstance()->getObjectTypeID('com.woltlab.wcf.article'));
 -              }
 -      }
 +class ArticleAction extends AbstractDatabaseObjectAction
 +{
 +    /**
 +     * article editor instance
 +     * @var ArticleEditor
 +     */
 +    public $articleEditor;
 +
 +    /**
 +     * language object
 +     * @var Language
 +     */
 +    public $language;
 +
 +    /**
 +     * @inheritDoc
 +     */
 +    protected $className = ArticleEditor::class;
 +
 +    /**
 +     * @inheritDoc
 +     */
 +    protected $permissionsCreate = ['admin.content.article.canManageArticle'];
 +
 +    /**
 +     * @inheritDoc
 +     */
 +    protected $permissionsDelete = ['admin.content.article.canManageArticle'];
 +
 +    /**
 +     * @inheritDoc
 +     */
 +    protected $permissionsUpdate = ['admin.content.article.canManageArticle'];
 +
 +    /**
 +     * @inheritDoc
 +     */
 +    protected $requireACP = ['create', 'update'];
 +
 +    /**
 +     * @inheritDoc
 +     */
 +    protected $allowGuestAccess = ['markAllAsRead'];
 +
 +    /**
 +     * @inheritDoc
 +     * @return  Article
 +     */
 +    public function create()
 +    {
 +        /** @var Article $article */
 +        $article = parent::create();
 +
 +        // save article content
 +        if (!empty($this->parameters['content'])) {
 +            foreach ($this->parameters['content'] as $languageID => $content) {
 +                if (!empty($content['htmlInputProcessor'])) {
 +                    /** @noinspection PhpUndefinedMethodInspection */
 +                    $content['content'] = $content['htmlInputProcessor']->getHtml();
 +                }
 +
 +                /** @var ArticleContent $articleContent */
 +                $articleContent = ArticleContentEditor::create([
 +                    'articleID' => $article->articleID,
 +                    'languageID' => $languageID ?: null,
 +                    'title' => $content['title'],
 +                    'teaser' => $content['teaser'],
 +                    'content' => $content['content'],
 +                    'imageID' => $content['imageID'],
 +                    'teaserImageID' => $content['teaserImageID'],
 +                    'metaTitle' => $content['metaTitle'] ?? '',
 +                    'metaDescription' => $content['metaDescription'] ?? '',
 +                ]);
 +                $articleContentEditor = new ArticleContentEditor($articleContent);
 +
 +                // save tags
 +                if (!empty($content['tags'])) {
 +                    TagEngine::getInstance()->addObjectTags(
 +                        'com.woltlab.wcf.article',
 +                        $articleContent->articleContentID,
 +                        $content['tags'],
 +                        ($languageID ?: LanguageFactory::getInstance()->getDefaultLanguageID())
 +                    );
 +                }
 +
 +                // update search index
 +                SearchIndexManager::getInstance()->set(
 +                    'com.woltlab.wcf.article',
 +                    $articleContent->articleContentID,
 +                    $articleContent->content,
 +                    $articleContent->title,
 +                    $article->time,
 +                    $article->userID,
 +                    $article->username,
 +                    $languageID ?: null,
 +                    $articleContent->teaser
 +                );
 +
 +                // save embedded objects
 +                if (!empty($content['htmlInputProcessor'])) {
 +                    /** @noinspection PhpUndefinedMethodInspection */
 +                    $content['htmlInputProcessor']->setObjectID($articleContent->articleContentID);
 +                    if (MessageEmbeddedObjectManager::getInstance()->registerObjects($content['htmlInputProcessor'])) {
 +                        $articleContentEditor->update(['hasEmbeddedObjects' => 1]);
 +                    }
 +                }
 +            }
 +        }
 +
 +        // reset storage
 +        if (ARTICLE_ENABLE_VISIT_TRACKING) {
 +            UserStorageHandler::getInstance()->resetAll('unreadArticles');
 +            UserStorageHandler::getInstance()->resetAll('unreadWatchedArticles');
 +            UserStorageHandler::getInstance()->resetAll('unreadArticlesByCategory');
 +        }
 +
 +        if ($article->publicationStatus == Article::PUBLISHED) {
 +            ArticleEditor::updateArticleCounter([$article->userID => 1]);
 +
 +            UserObjectWatchHandler::getInstance()->updateObject(
 +                'com.woltlab.wcf.article.category',
 +                $article->getCategory()->categoryID,
 +                'article',
 +                'com.woltlab.wcf.article.notification',
 +                new ArticleUserNotificationObject($article)
 +            );
 +
 +            UserActivityEventHandler::getInstance()->fireEvent(
 +                'com.woltlab.wcf.article.recentActivityEvent',
 +                $article->articleID,
 +                null,
 +                $article->userID,
 +                $article->time
 +            );
 +        }
 +
 +        return $article;
 +    }
 +
 +    /**
 +     * @inheritDoc
 +     */
 +    public function update()
 +    {
 +        parent::update();
 +
 +        $isRevert = (!empty($this->parameters['isRevert']));
 +
 +        // update article content
 +        if (!empty($this->parameters['content'])) {
 +            foreach ($this->getObjects() as $article) {
 +                $versionData = [];
 +                $hasChanges = false;
 +
 +                foreach ($this->parameters['content'] as $languageID => $content) {
 +                    if (!empty($content['htmlInputProcessor'])) {
 +                        /** @noinspection PhpUndefinedMethodInspection */
 +                        $content['content'] = $content['htmlInputProcessor']->getHtml();
 +                    }
 +
 +                    $articleContent = ArticleContent::getArticleContent($article->articleID, ($languageID ?: null));
 +                    $articleContentEditor = null;
 +                    if ($articleContent !== null) {
 +                        // update
 +                        $articleContentEditor = new ArticleContentEditor($articleContent);
 +                        $articleContentEditor->update([
 +                            'title' => $content['title'],
 +                            'teaser' => $content['teaser'],
 +                            'content' => $content['content'],
 +                            'imageID' => ($isRevert) ? $articleContent->imageID : $content['imageID'],
 +                            'teaserImageID' => ($isRevert) ? $articleContent->teaserImageID : $content['teaserImageID'],
 +                            'metaTitle' => $content['metaTitle'] ?? '',
 +                            'metaDescription' => $content['metaDescription'] ?? '',
 +                        ]);
 +
 +                        $versionData[] = $articleContent;
 +                        if ($articleContent->content != $content['content'] || $articleContent->teaser != $content['teaser'] || $articleContent->title != $content['title']) {
 +                            $hasChanges = true;
 +                        }
 +
 +                        // delete tags
 +                        if (!$isRevert && empty($content['tags'])) {
 +                            TagEngine::getInstance()->deleteObjectTags(
 +                                'com.woltlab.wcf.article',
 +                                $articleContent->articleContentID,
 +                                ($languageID ?: null)
 +                            );
 +                        }
 +                    } else {
 +                        /** @var ArticleContent $articleContent */
 +                        $articleContent = ArticleContentEditor::create([
 +                            'articleID' => $article->articleID,
 +                            'languageID' => $languageID ?: null,
 +                            'title' => $content['title'],
 +                            'teaser' => $content['teaser'],
 +                            'content' => $content['content'],
 +                            'imageID' => ($isRevert) ? null : $content['imageID'],
 +                            'teaserImageID' => ($isRevert) ? null : $content['teaserImageID'],
 +                            'metaTitle' => $content['metaTitle'] ?? '',
 +                            'metaDescription' => $content['metaDescription'] ?? '',
 +                        ]);
 +                        $articleContentEditor = new ArticleContentEditor($articleContent);
 +
 +                        $versionData[] = $articleContent;
 +                        $hasChanges = true;
 +                    }
 +
 +                    // save tags
 +                    if (!$isRevert && !empty($content['tags'])) {
 +                        TagEngine::getInstance()->addObjectTags(
 +                            'com.woltlab.wcf.article',
 +                            $articleContent->articleContentID,
 +                            $content['tags'],
 +                            ($languageID ?: LanguageFactory::getInstance()->getDefaultLanguageID())
 +                        );
 +                    }
 +
 +                    // update search index
 +                    SearchIndexManager::getInstance()->set(
 +                        'com.woltlab.wcf.article',
 +                        $articleContent->articleContentID,
 +                        $content['content'] ?? $articleContent->content,
 +                        $content['title'] ?? $articleContent->title,
-                         $article->time,
-                         $article->userID,
-                         $article->username,
++                        $this->parameters['data']['time'] ?? $article->time,
++                        $this->parameters['data']['userID'] ?? $article->userID,
++                        $this->parameters['data']['username'] ?? $article->username,
 +                        $languageID ?: null,
 +                        $content['teaser'] ?? $articleContent->teaser
 +                    );
 +
 +                    // save embedded objects
 +                    if (!empty($content['htmlInputProcessor'])) {
 +                        /** @noinspection PhpUndefinedMethodInspection */
 +                        $content['htmlInputProcessor']->setObjectID($articleContent->articleContentID);
 +                        if ($articleContent->hasEmbeddedObjects != MessageEmbeddedObjectManager::getInstance()->registerObjects($content['htmlInputProcessor'])) {
 +                            $articleContentEditor->update(['hasEmbeddedObjects' => $articleContent->hasEmbeddedObjects ? 0 : 1]);
 +                        }
 +                    }
 +                }
 +
 +                if ($hasChanges) {
 +                    $articleObj = new ArticleVersionTracker($article->getDecoratedObject());
 +                    $articleObj->setContent($versionData);
 +                    VersionTracker::getInstance()->add('com.woltlab.wcf.article', $articleObj);
 +                }
 +            }
 +        }
 +
 +        // reset storage
 +        if (ARTICLE_ENABLE_VISIT_TRACKING) {
 +            UserStorageHandler::getInstance()->resetAll('unreadArticles');
 +            UserStorageHandler::getInstance()->resetAll('unreadWatchedArticles');
 +            UserStorageHandler::getInstance()->resetAll('unreadArticlesByCategory');
 +        }
 +
 +        $publicationStatus = (isset($this->parameters['data']['publicationStatus'])) ? $this->parameters['data']['publicationStatus'] : null;
 +        if ($publicationStatus !== null) {
 +            $usersToArticles = $resetArticleIDs = [];
 +            /** @var ArticleEditor $articleEditor */
 +            foreach ($this->objects as $articleEditor) {
 +                if ($publicationStatus != $articleEditor->publicationStatus) {
 +                    // The article was published before or was now published.
 +                    if ($publicationStatus == Article::PUBLISHED || $articleEditor->publicationStatus == Article::PUBLISHED) {
 +                        if (!isset($usersToArticles[$articleEditor->userID])) {
 +                            $usersToArticles[$articleEditor->userID] = 0;
 +                        }
 +
 +                        $usersToArticles[$articleEditor->userID] += ($publicationStatus == Article::PUBLISHED) ? 1 : -1;
 +                    }
 +
 +                    if ($publicationStatus == Article::PUBLISHED) {
 +                        UserObjectWatchHandler::getInstance()->updateObject(
 +                            'com.woltlab.wcf.article.category',
 +                            $articleEditor->getCategory()->categoryID,
 +                            'article',
 +                            'com.woltlab.wcf.article.notification',
 +                            new ArticleUserNotificationObject($articleEditor->getDecoratedObject())
 +                        );
 +
 +                        UserActivityEventHandler::getInstance()->fireEvent(
 +                            'com.woltlab.wcf.article.recentActivityEvent',
 +                            $articleEditor->articleID,
 +                            null,
-                             $articleEditor->userID,
-                             $articleEditor->time
++                            $this->parameters['data']['userID'] ?? $articleEditor->userID,
++                            $this->parameters['data']['time'] ?? $articleEditor->time
 +                        );
 +                    } else {
 +                        $resetArticleIDs[] = $articleEditor->articleID;
 +                    }
 +                }
 +            }
 +
 +            if (!empty($resetArticleIDs)) {
 +                // delete user notifications
 +                UserNotificationHandler::getInstance()->removeNotifications(
 +                    'com.woltlab.wcf.article.notification',
 +                    $resetArticleIDs
 +                );
 +                // delete recent activity events
 +                UserActivityEventHandler::getInstance()->removeEvents(
 +                    'com.woltlab.wcf.article.recentActivityEvent',
 +                    $resetArticleIDs
 +                );
 +            }
 +
 +            if (!empty($usersToArticles)) {
 +                ArticleEditor::updateArticleCounter($usersToArticles);
 +            }
 +        }
 +
 +        // update author in recent activities
 +        if (isset($this->parameters['data']['userID'])) {
 +            $sql = "UPDATE  wcf" . WCF_N . "_user_activity_event
 +                    SET     userID = ?
 +                    WHERE   objectTypeID = ?
 +                        AND objectID = ?";
 +            $statement = WCF::getDB()->prepareStatement($sql);
 +
 +            foreach ($this->objects as $articleEditor) {
 +                if ($articleEditor->userID != $this->parameters['data']['userID']) {
 +                    $statement->execute([
 +                        $this->parameters['data']['userID'],
 +                        UserActivityEventHandler::getInstance()->getObjectTypeID('com.woltlab.wcf.article.recentActivityEvent'),
 +                        $articleEditor->articleID,
 +                    ]);
 +                }
 +            }
 +        }
 +    }
 +
 +    /**
 +     * Validates parameters to delete articles.
 +     *
 +     * @throws  PermissionDeniedException
 +     * @throws  UserInputException
 +     */
 +    public function validateDelete()
 +    {
 +        if (empty($this->objects)) {
 +            $this->readObjects();
 +
 +            if (empty($this->objects)) {
 +                throw new UserInputException('objectIDs');
 +            }
 +        }
 +
 +        foreach ($this->getObjects() as $article) {
 +            if (!$article->canDelete()) {
 +                throw new PermissionDeniedException();
 +            }
 +
 +            if (!$article->isDeleted) {
 +                throw new UserInputException('objectIDs');
 +            }
 +        }
 +    }
 +
 +    /**
 +     * @inheritDoc
 +     */
 +    public function delete()
 +    {
 +        $usersToArticles = $articleIDs = $articleContentIDs = [];
 +        foreach ($this->getObjects() as $article) {
 +            $articleIDs[] = $article->articleID;
 +            foreach ($article->getArticleContents() as $articleContent) {
 +                $articleContentIDs[] = $articleContent->articleContentID;
 +            }
 +
 +            if ($article->publicationStatus == Article::PUBLISHED) {
 +                if (!isset($usersToArticles[$article->userID])) {
 +                    $usersToArticles[$article->userID] = 0;
 +                }
 +                $usersToArticles[$article->userID]--;
 +            }
 +        }
 +
 +        // delete articles
 +        parent::delete();
 +
 +        if (!empty($articleIDs)) {
 +            // delete like data
 +            ReactionHandler::getInstance()->removeReactions('com.woltlab.wcf.likeableArticle', $articleIDs);
 +            // 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',
 +                $articleIDs
 +            );
 +            // delete recent activity events
 +            UserActivityEventHandler::getInstance()->removeEvents(
 +                'com.woltlab.wcf.article.recentActivityEvent',
 +                $articleIDs
 +            );
 +            // delete embedded object references
 +            MessageEmbeddedObjectManager::getInstance()->removeObjects(
 +                'com.woltlab.wcf.article.content',
 +                $articleContentIDs
 +            );
 +            // update wcf1_user.articles
 +            ArticleEditor::updateArticleCounter($usersToArticles);
 +        }
 +
 +        $this->unmarkItems();
 +
 +        return [
 +            'objectIDs' => $this->objectIDs,
 +            'redirectURL' => LinkHandler::getInstance()->getLink('ArticleList', ['isACP' => true]),
 +        ];
 +    }
 +
 +    /**
 +     * Validates parameters to move articles to the trash bin.
 +     *
 +     * @throws  PermissionDeniedException
 +     * @throws  UserInputException
 +     */
 +    public function validateTrash()
 +    {
 +        if (empty($this->objects)) {
 +            $this->readObjects();
 +
 +            if (empty($this->objects)) {
 +                throw new UserInputException('objectIDs');
 +            }
 +        }
 +
 +        foreach ($this->getObjects() as $article) {
 +            if (!$article->canDelete()) {
 +                throw new PermissionDeniedException();
 +            }
 +
 +            if ($article->isDeleted) {
 +                throw new UserInputException('objectIDs');
 +            }
 +        }
 +    }
 +
 +    /**
 +     * Moves articles to the trash bin.
 +     */
 +    public function trash()
 +    {
 +        foreach ($this->getObjects() as $articleEditor) {
 +            $articleEditor->update(['isDeleted' => 1]);
 +        }
 +
 +        $this->unmarkItems();
 +
 +        // reset storage
 +        if (ARTICLE_ENABLE_VISIT_TRACKING) {
 +            UserStorageHandler::getInstance()->resetAll('unreadArticles');
 +            UserStorageHandler::getInstance()->resetAll('unreadWatchedArticles');
 +            UserStorageHandler::getInstance()->resetAll('unreadArticlesByCategory');
 +        }
 +
 +        return ['objectIDs' => $this->objectIDs];
 +    }
 +
 +    /**
 +     * Validates parameters to restore articles.
 +     *
 +     * @throws  UserInputException
 +     */
 +    public function validateRestore()
 +    {
 +        $this->validateDelete();
 +    }
 +
 +    /**
 +     * Restores articles.
 +     */
 +    public function restore()
 +    {
 +        foreach ($this->getObjects() as $articleEditor) {
 +            $articleEditor->update(['isDeleted' => 0]);
 +        }
 +
 +        $this->unmarkItems();
 +
 +        // reset storage
 +        if (ARTICLE_ENABLE_VISIT_TRACKING) {
 +            UserStorageHandler::getInstance()->resetAll('unreadArticles');
 +            UserStorageHandler::getInstance()->resetAll('unreadWatchedArticles');
 +            UserStorageHandler::getInstance()->resetAll('unreadArticlesByCategory');
 +        }
 +
 +        return ['objectIDs' => $this->objectIDs];
 +    }
 +
 +    /**
 +     * Validates parameters to toggle between i18n and monolingual mode.
 +     *
 +     * @throws      UserInputException
 +     */
 +    public function validateToggleI18n()
 +    {
 +        WCF::getSession()->checkPermissions(['admin.content.article.canManageArticle']);
 +
 +        $this->articleEditor = $this->getSingleObject();
 +        if ($this->articleEditor->getDecoratedObject()->isMultilingual) {
 +            $this->readInteger('languageID');
 +            $this->language = LanguageFactory::getInstance()->getLanguage($this->parameters['languageID']);
 +            if ($this->language === null) {
 +                throw new UserInputException('languageID');
 +            }
 +
 +            $contents = $this->articleEditor->getArticleContents();
 +            if (!isset($contents[$this->language->languageID])) {
 +                // there is no content
 +                throw new UserInputException('languageID');
 +            }
 +        }
 +    }
 +
 +    /**
 +     * Toggles between i18n and monolingual mode.
 +     */
 +    public function toggleI18n()
 +    {
 +        $removeContent = [];
 +
 +        // i18n -> monolingual
 +        if ($this->articleEditor->getDecoratedObject()->isMultilingual) {
 +            foreach ($this->articleEditor->getArticleContents() as $articleContent) {
 +                if ($articleContent->languageID == $this->language->languageID) {
 +                    $articleContentEditor = new ArticleContentEditor($articleContent);
 +                    $articleContentEditor->update(['languageID' => null]);
 +                } else {
 +                    $removeContent[] = $articleContent;
 +                }
 +            }
 +        } else {
 +            // monolingual -> i18n
 +            $articleContent = $this->articleEditor->getArticleContent();
 +            $data = [];
 +            foreach (LanguageFactory::getInstance()->getLanguages() as $language) {
 +                $data[$language->languageID] = [
 +                    'title' => $articleContent->title,
 +                    'teaser' => $articleContent->teaser,
 +                    'content' => $articleContent->content,
 +                    'imageID' => $articleContent->imageID ?: null,
 +                    'teaserImageID' => $articleContent->teaserImageID ?: null,
 +                ];
 +            }
 +
 +            $action = new self([$this->articleEditor], 'update', ['content' => $data]);
 +            $action->executeAction();
 +
 +            $removeContent[] = $articleContent;
 +        }
 +
 +        if (!empty($removeContent)) {
 +            $action = new ArticleContentAction($removeContent, 'delete');
 +            $action->executeAction();
 +        }
 +
 +        // flush edit history
 +        VersionTracker::getInstance()->reset(
 +            'com.woltlab.wcf.article',
 +            $this->articleEditor->getDecoratedObject()->articleID
 +        );
 +
 +        // update article's i18n state
 +        $this->articleEditor->update([
 +            'isMultilingual' => ($this->articleEditor->getDecoratedObject()->isMultilingual) ? 0 : 1,
 +        ]);
 +    }
 +
 +    /**
 +     * Marks articles as read.
 +     */
 +    public function markAsRead()
 +    {
 +        if (empty($this->parameters['visitTime'])) {
 +            $this->parameters['visitTime'] = TIME_NOW;
 +        }
 +
 +        if (empty($this->objects)) {
 +            $this->readObjects();
 +        }
 +
 +        foreach ($this->getObjects() as $article) {
 +            VisitTracker::getInstance()->trackObjectVisit(
 +                'com.woltlab.wcf.article',
 +                $article->articleID,
 +                $this->parameters['visitTime']
 +            );
 +        }
 +
 +        // reset storage
 +        if (WCF::getUser()->userID) {
 +            UserStorageHandler::getInstance()->reset([WCF::getUser()->userID], 'unreadArticles');
 +            UserStorageHandler::getInstance()->reset([WCF::getUser()->userID], 'unreadWatchedArticles');
 +            UserStorageHandler::getInstance()->reset([WCF::getUser()->userID], 'unreadArticlesByCategory');
 +        }
 +    }
 +
 +    /**
 +     * Marks all articles as read.
 +     */
 +    public function markAllAsRead()
 +    {
 +        VisitTracker::getInstance()->trackTypeVisit('com.woltlab.wcf.article');
 +
 +        // reset storage
 +        if (WCF::getUser()->userID) {
 +            UserStorageHandler::getInstance()->reset([WCF::getUser()->userID], 'unreadArticles');
 +            UserStorageHandler::getInstance()->reset([WCF::getUser()->userID], 'unreadWatchedArticles');
 +            UserStorageHandler::getInstance()->reset([WCF::getUser()->userID], 'unreadArticlesByCategory');
 +        }
 +    }
 +
 +    /**
 +     * Validates the mark all as read action.
 +     */
 +    public function validateMarkAllAsRead()
 +    {
 +        // does nothing
 +    }
 +
 +    /**
 +     * Validates the `setCategory` action.
 +     *
 +     * @throws  UserInputException
 +     */
 +    public function validateSetCategory()
 +    {
 +        WCF::getSession()->checkPermissions(['admin.content.article.canManageArticle']);
 +
 +        $this->readBoolean('useMarkedArticles', true);
 +
 +        // if no object ids are given, use clipboard handler
 +        if (empty($this->objectIDs) && $this->parameters['useMarkedArticles']) {
 +            $this->objectIDs = \array_keys(ClipboardHandler::getInstance()->getMarkedItems(ClipboardHandler::getInstance()->getObjectTypeID('com.woltlab.wcf.article')));
 +        }
 +
 +        if (empty($this->objects)) {
 +            $this->readObjects();
 +
 +            if (empty($this->objects)) {
 +                throw new UserInputException('objectIDs');
 +            }
 +        }
 +
 +        $this->readInteger('categoryID');
 +        if (ArticleCategory::getCategory($this->parameters['categoryID']) === null) {
 +            throw new UserInputException('categoryID');
 +        }
 +    }
 +
 +    /**
 +     * Sets the category of articles.
 +     */
 +    public function setCategory()
 +    {
 +        foreach ($this->getObjects() as $articleEditor) {
 +            $articleEditor->update(['categoryID' => $this->parameters['categoryID']]);
 +        }
 +
 +        $this->unmarkItems();
 +    }
 +
 +    /**
 +     * Validates the `publish` action.
 +     *
 +     * @throws  PermissionDeniedException
 +     * @throws  UserInputException
 +     */
 +    public function validatePublish()
 +    {
 +        if (empty($this->objects)) {
 +            $this->readObjects();
 +
 +            if (empty($this->objects)) {
 +                throw new UserInputException('objectIDs');
 +            }
 +        }
 +
 +        foreach ($this->getObjects() as $article) {
 +            if (!$article->canPublish()) {
 +                throw new PermissionDeniedException();
 +            }
 +
 +            if ($article->publicationStatus == Article::PUBLISHED) {
 +                throw new UserInputException('objectIDs');
 +            }
 +        }
 +    }
 +
 +    /**
 +     * Publishes articles.
 +     */
 +    public function publish()
 +    {
 +        $usersToArticles = [];
 +        foreach ($this->getObjects() as $articleEditor) {
 +            $articleEditor->update([
 +                'time' => TIME_NOW,
 +                'publicationStatus' => Article::PUBLISHED,
 +                'publicationDate' => 0,
 +            ]);
 +
 +            if (!isset($usersToArticles[$articleEditor->userID])) {
 +                $usersToArticles[$articleEditor->userID] = 0;
 +            }
 +
 +            $usersToArticles[$articleEditor->userID]++;
 +
 +            UserObjectWatchHandler::getInstance()->updateObject(
 +                'com.woltlab.wcf.article.category',
 +                $articleEditor->getCategory()->categoryID,
 +                'article',
 +                'com.woltlab.wcf.article.notification',
 +                new ArticleUserNotificationObject($articleEditor->getDecoratedObject())
 +            );
 +
 +            UserActivityEventHandler::getInstance()->fireEvent(
 +                'com.woltlab.wcf.article.recentActivityEvent',
 +                $articleEditor->articleID,
 +                null,
 +                $articleEditor->userID,
 +                TIME_NOW
 +            );
 +        }
 +
 +        ArticleEditor::updateArticleCounter($usersToArticles);
 +
 +        // reset storage
 +        if (ARTICLE_ENABLE_VISIT_TRACKING) {
 +            UserStorageHandler::getInstance()->resetAll('unreadArticles');
 +            UserStorageHandler::getInstance()->resetAll('unreadWatchedArticles');
 +            UserStorageHandler::getInstance()->resetAll('unreadArticlesByCategory');
 +        }
 +
 +        $this->unmarkItems();
 +    }
 +
 +    /**
 +     * Validates the `unpublish` action.
 +     *
 +     * @throws  PermissionDeniedException
 +     * @throws  UserInputException
 +     */
 +    public function validateUnpublish()
 +    {
 +        if (empty($this->objects)) {
 +            $this->readObjects();
 +
 +            if (empty($this->objects)) {
 +                throw new UserInputException('objectIDs');
 +            }
 +        }
 +
 +        foreach ($this->getObjects() as $article) {
 +            if (!$article->canPublish()) {
 +                throw new PermissionDeniedException();
 +            }
 +
 +            if ($article->publicationStatus != Article::PUBLISHED) {
 +                throw new UserInputException('objectIDs');
 +            }
 +        }
 +    }
 +
 +    /**
 +     * Unpublishes articles.
 +     */
 +    public function unpublish()
 +    {
 +        $usersToArticles = $articleIDs = [];
 +        foreach ($this->getObjects() as $articleEditor) {
 +            $articleEditor->update(['publicationStatus' => Article::UNPUBLISHED]);
 +
 +            if (!isset($usersToArticles[$articleEditor->userID])) {
 +                $usersToArticles[$articleEditor->userID] = 0;
 +            }
 +
 +            $usersToArticles[$articleEditor->userID]--;
 +
 +            $articleIDs[] = $articleEditor->articleID;
 +        }
 +
 +        // delete user notifications
 +        UserNotificationHandler::getInstance()->removeNotifications(
 +            'com.woltlab.wcf.article.notification',
 +            $articleIDs
 +        );
 +
 +        // delete recent activity events
 +        UserActivityEventHandler::getInstance()->removeEvents(
 +            'com.woltlab.wcf.article.recentActivityEvent',
 +            $articleIDs
 +        );
 +
 +        ArticleEditor::updateArticleCounter($usersToArticles);
 +
 +        $this->unmarkItems();
 +    }
 +
 +    /**
 +     * Validates parameters to search for an article by its localized title.
 +     */
 +    public function validateSearch()
 +    {
 +        $this->readString('searchString');
 +    }
 +
 +    /**
 +     * Searches for an article by its localized title.
 +     *
 +     * @return      array   list of matching articles
 +     */
 +    public function search()
 +    {
 +        $sql = "SELECT      articleID
 +                FROM        wcf" . WCF_N . "_article_content
 +                WHERE       title LIKE ?
 +                        AND (
 +                                languageID = ?
 +                                OR languageID IS NULL
 +                            )
 +                ORDER BY    title";
 +        $statement = WCF::getDB()->prepareStatement($sql, 5);
 +        $statement->execute([
 +            '%' . $this->parameters['searchString'] . '%',
 +            WCF::getLanguage()->languageID,
 +        ]);
 +
 +        $articleIDs = [];
 +        while ($articleID = $statement->fetchColumn()) {
 +            $articleIDs[] = $articleID;
 +        }
 +
 +        $articleList = new ArticleList();
 +        $articleList->setObjectIDs($articleIDs);
 +        $articleList->readObjects();
 +
 +        $articles = [];
 +        foreach ($articleList as $article) {
 +            $articles[] = [
 +                'displayLink' => $article->getLink(),
 +                'name' => $article->getTitle(),
 +                'articleID' => $article->articleID,
 +            ];
 +        }
 +
 +        return $articles;
 +    }
 +
 +    /**
 +     * Unmarks articles.
 +     *
 +     * @param int[] $articleIDs
 +     */
 +    protected function unmarkItems(array $articleIDs = [])
 +    {
 +        if (empty($articleIDs)) {
 +            foreach ($this->getObjects() as $article) {
 +                $articleIDs[] = $article->articleID;
 +            }
 +        }
 +
 +        if (!empty($articleIDs)) {
 +            ClipboardHandler::getInstance()->unmark(
 +                $articleIDs,
 +                ClipboardHandler::getInstance()->getObjectTypeID('com.woltlab.wcf.article')
 +            );
 +        }
 +    }
  }
index 5cb6d94c3fabe61c72cf3d194315ff661dd05571,dd5708573f56612773eb7bad1c30f87ead46edd4..5985f451bab0ac1829cba9d48fa638656a9d45bf
@@@ -8,112 -6,72 +8,112 @@@ use wcf\system\language\LanguageFactory
  
  /**
   * Implementation of a form field for single-line text values.
 - * 
 - * @author    Matthias Schmidt
 - * @copyright 2001-2019 WoltLab GmbH
 - * @license   GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
 - * @package   WoltLabSuite\Core\System\Form\Builder\Field
 - * @since     5.2
 + *
 + * @author  Matthias Schmidt
 + * @copyright   2001-2019 WoltLab GmbH
 + * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
 + * @package WoltLabSuite\Core\System\Form\Builder\Field
 + * @since   5.2
   */
 -class TextFormField extends AbstractFormField implements IAutoFocusFormField, II18nFormField, IImmutableFormField, IMaximumLengthFormField, IMinimumLengthFormField, IPlaceholderFormField {
 -      use TAutoFocusFormField;
 -      use TImmutableFormField;
 -      use TI18nFormField {
 -              validate as protected i18nValidate;
 -      }
 -      use TMaximumLengthFormField;
 -      use TMinimumLengthFormField;
 -      use TPlaceholderFormField;
 -      
 -      /**
 -       * @inheritDoc
 -       */
 -      protected $javaScriptDataHandlerModule = 'WoltLabSuite/Core/Form/Builder/Field/Value';
 -      
 -      /**
 -       * @inheritDoc
 -       */
 -      protected $templateName = '__textFormField';
 -      
 -      /**
 -       * @inheritDoc
 -       */
 -      public function validate() {
 -              if ($this->isI18n()) {
 -                      $this->i18nValidate();
 -                      
 -                      if (empty($this->getValidationErrors())) {
 -                              $value = $this->getValue();
 -                              if ($this->hasPlainValue()) {
 -                                      $this->validateText($value);
 -                              }
 -                              else {
 -                                      foreach ($value as $languageID => $languageValue) {
 -                                              $this->validateText($languageValue, LanguageFactory::getInstance()->getLanguage($languageID));
 -                                      }
 -                              }
 -                      }
 -              }
 -              else {
 -                      if ($this->isRequired() && ($this->getValue() === null || $this->getValue() === '')) {
 -                              $this->addValidationError(new FormFieldValidationError('empty'));
 -                      }
 -                      else if ($this->getValue() !== null && $this->getValue() !== '') {
 -                              $this->validateText($this->getValue());
 -                      }
 -              }
 -              
 -              parent::validate();
 -      }
 -      
 -      /**
 -       * Checks the length of the given text with the given language.
 -       * 
 -       * @param       string          $text           validated text
 -       * @param       null|Language   $language       language of validated text or `null` for monolingual text
 -       */
 -      protected function validateText($text, Language $language = null) {
 -              $this->validateMinimumLength($text, $language);
 -              $this->validateMaximumLength($text, $language);
 -      }
 +class TextFormField extends AbstractFormField implements
 +    IAttributeFormField,
 +    IAutoCompleteFormField,
 +    IAutoFocusFormField,
 +    ICssClassFormField,
 +    II18nFormField,
 +    IImmutableFormField,
 +    IInputModeFormField,
 +    IMaximumLengthFormField,
 +    IMinimumLengthFormField,
 +    IPatternFormField,
 +    IPlaceholderFormField
 +{
 +    use TInputAttributeFormField;
 +    use TTextAutoCompleteFormField;
 +    use TAutoFocusFormField;
 +    use TCssClassFormField;
 +    use TImmutableFormField;
 +    use TInputModeFormField;
 +    use TI18nFormField {
 +        validate as protected i18nValidate;
 +    }
 +    use TMaximumLengthFormField;
 +    use TMinimumLengthFormField;
 +    use TPatternFormField;
 +    use TPlaceholderFormField;
 +
 +    /**
 +     * @inheritDoc
 +     */
 +    protected $javaScriptDataHandlerModule = 'WoltLabSuite/Core/Form/Builder/Field/Value';
 +
 +    /**
 +     * @inheritDoc
 +     */
 +    protected $templateName = '__textFormField';
 +
 +    /**
 +     * Creates a new instance of `TextFormField`.
 +     */
 +    public function __construct()
 +    {
 +        $this->addFieldClass('long');
 +    }
 +
 +    /**
 +     * @inheritDoc
 +     */
 +    protected function getValidInputModes(): array
 +    {
 +        return [
 +            'text',
 +            'tel',
 +            'url',
 +            'email',
 +            'numeric',
 +            'decimal',
 +            'search',
 +        ];
 +    }
 +
 +    /**
 +     * @inheritDoc
 +     */
 +    public function validate()
 +    {
 +        if ($this->isI18n()) {
 +            $this->i18nValidate();
 +
 +            if (empty($this->getValidationErrors())) {
 +                $value = $this->getValue();
 +                if ($this->hasPlainValue()) {
 +                    $this->validateText($value);
 +                } else {
 +                    foreach ($value as $languageID => $languageValue) {
 +                        $this->validateText($languageValue, LanguageFactory::getInstance()->getLanguage($languageID));
 +                    }
 +                }
 +            }
 +        } else {
 +            if ($this->isRequired() && ($this->getValue() === null || $this->getValue() === '')) {
 +                $this->addValidationError(new FormFieldValidationError('empty'));
-             } else {
++            } elseif ($this->getValue() !== null && $this->getValue() !== '') {
 +                $this->validateText($this->getValue());
 +            }
 +        }
 +
 +        parent::validate();
 +    }
 +
 +    /**
 +     * Checks the length of the given text with the given language.
 +     *
 +     * @param string $text validated text
 +     * @param null|Language $language language of validated text or `null` for monolingual text
 +     */
 +    protected function validateText($text, ?Language $language = null)
 +    {
 +        $this->validateMinimumLength($text, $language);
 +        $this->validateMaximumLength($text, $language);
 +    }
  }
index dd130ac90c4a8e120574420546411aff2fa3a65e,d9fb6109716e90ed642a6c5e08ea74d7dd56eed4..c1a3bf5a57abf3b8b6861cbc0451e502f4153a98
@@@ -25,443 -21,394 +25,445 @@@ use wcf\util\StringUtil
  
  /**
   * Implementation of a form field for wysiwyg editors.
 - * 
 - * @author    Matthias Schmidt
 - * @copyright 2001-2019 WoltLab GmbH
 - * @license   GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
 - * @package   WoltLabSuite\Core\System\Form\Builder\Field
 - * @since     5.2
 + *
 + * @author  Matthias Schmidt
 + * @copyright   2001-2019 WoltLab GmbH
 + * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
 + * @package WoltLabSuite\Core\System\Form\Builder\Field
 + * @since   5.2
   */
 -class WysiwygFormField extends AbstractFormField implements IMaximumLengthFormField, IMinimumLengthFormField, IObjectTypeFormNode {
 -      use TMaximumLengthFormField;
 -      use TMinimumLengthFormField;
 -      use TObjectTypeFormNode;
 -      
 -      /**
 -       * identifier used to autosave the field value; if empty, autosave is disabled
 -       * @var string
 -       */
 -      protected $autosaveId = '';
 -      
 -      /**
 -       * input processor containing the wysiwyg text
 -       * @var HtmlInputProcessor
 -       */
 -      protected $htmlInputProcessor;
 -      
 -      /**
 -       * last time the field has been edited; if `0`, the last edit time is unknown
 -       * @var int
 -       */
 -      protected $lastEditTime = 0;
 -      
 -      /**
 -       * quote-related data used to create the JavaScript quote manager
 -       * @var null|array
 -       */
 -      protected $quoteData;
 -      
 -      /**
 -       * is `true` if this form field supports attachments, otherwise `false`
 -       * @var boolean 
 -       */
 -      protected $supportAttachments = false;
 -      
 -      /**
 -       * is `true` if this form field supports mentions, otherwise `false`
 -       * @var boolean
 -       */
 -      protected $supportMentions = false;
 -      
 -      /**
 -       * is `true` if this form field supports quotes, otherwise `false`
 -       * @var boolean
 -       */
 -      protected $supportQuotes = false;
 -      
 -      /**
 -       * @inheritDoc
 -       */
 -      protected $javaScriptDataHandlerModule = 'WoltLabSuite/Core/Form/Builder/Field/Value';
 -      
 -      /**
 -       * @inheritDoc
 -       */
 -      protected $templateName = '__wysiwygFormField';
 -      
 -      /**
 -       * Sets the identifier used to autosave the field value and returns this field.
 -       * 
 -       * @param       string          $autosaveId     identifier used to autosave field value
 -       * @return      WysiwygFormField                this field
 -       */
 -      public function autosaveId($autosaveId) {
 -              $this->autosaveId = $autosaveId;
 -              
 -              return $this;
 -      }
 -      
 -      /**
 -       * @inheritDoc
 -       */
 -      public function cleanup() {
 -              MessageQuoteManager::getInstance()->saved();
 -      }
 -      
 -      /**
 -       * Returns the identifier used to autosave the field value. If autosave is disabled,
 -       * an empty string is returned.
 -       * 
 -       * @return      string
 -       */
 -      public function getAutosaveId() {
 -              return $this->autosaveId;
 -      }
 -      
 -      /**
 -       * @inheritDoc
 -       */
 -      public function getFieldHtml() {
 -              if ($this->supportsQuotes()) {
 -                      MessageQuoteManager::getInstance()->assignVariables();
 -              }
 -              
 -              /** @noinspection PhpUndefinedFieldInspection */
 -              $disallowedBBCodesPermission = $this->getObjectType()->disallowedBBCodesPermission;
 -              if ($disallowedBBCodesPermission === null) {
 -                      $disallowedBBCodesPermission = 'user.message.disallowedBBCodes';
 -              }
 -              
 -              BBCodeHandler::getInstance()->setDisallowedBBCodes(explode(
 -                      ',',
 -                      WCF::getSession()->getPermission($disallowedBBCodesPermission)
 -              ));
 -              
 -              return parent::getFieldHtml();
 -      }
 -      
 -      /**
 -       * @inheritDoc
 -       */
 -      public function getObjectTypeDefinition() {
 -              return 'com.woltlab.wcf.message';
 -      }
 -      
 -      /**
 -       * Returns the last time the field has been edited. If no last edit time has
 -       * been set, `0` is returned.
 -       * 
 -       * @return      int
 -       */
 -      public function getLastEditTime() {
 -              return $this->lastEditTime;
 -      }
 -      
 -      /**
 -       * Returns all quote data or specific quote data if an argument is given.
 -       * 
 -       * @param       null|string     $index          quote data index
 -       * @return      string[]|string
 -       * 
 -       * @throws      \BadMethodCallException         if quotes are not supported for this field
 -       * @throws      \InvalidArgumentException       if unknown quote data is requested
 -       */
 -      public function getQuoteData($index = null) {
 -              if (!$this->supportQuotes()) {
 -                      throw new \BadMethodCallException("Quotes are not supported.");
 -              }
 -              
 -              if ($index === null) {
 -                      return $this->quoteData;
 -              }
 -              
 -              if (!isset($this->quoteData[$index])) {
 -                      throw new \InvalidArgumentException("Unknown quote data '{$index}'.");
 -              }
 -              
 -              return $this->quoteData[$index];
 -      }
 -      
 -      /**
 -       * @inheritDoc
 -       */
 -      public function hasSaveValue() {
 -              return false;
 -      }
 -      
 -      /**
 -       * Sets the last time this field has been edited and returns this field.
 -       * 
 -       * @param       int     $lastEditTime   last time field has been edited
 -       * @return      WysiwygFormField        this field
 -       */
 -      public function lastEditTime($lastEditTime) {
 -              $this->lastEditTime = $lastEditTime;
 -              
 -              return $this;
 -      }
 -      
 -      /**
 -       * @inheritDoc
 -       */
 -      public function populate() {
 -              parent::populate();
 -              
 -              $this->getDocument()->getDataHandler()->addProcessor(new CustomFormDataProcessor('wysiwyg', function(IFormDocument $document, array $parameters) {
 -                      if ($this->checkDependencies()) {
 -                              $parameters[$this->getObjectProperty() . '_htmlInputProcessor'] = $this->htmlInputProcessor;
 -                      }
 -                      
 -                      return $parameters;
 -              }));
 -              
 -              return $this;
 -      }
 -
 -      /**
 -       * Sets the data required for advanced quote support for when quotable content is present
 -       * on the active page and returns this field.
 -       * 
 -       * Calling this method automatically enables quote support for this field.
 -       * 
 -       * @param       string          $objectType     name of the relevant `com.woltlab.wcf.message.quote` object type
 -       * @param       string          $actionClass    action class implementing `wcf\data\IMessageQuoteAction`
 -       * @param       string[]        $selectors      selectors for the quotable content (required keys: `container`, `messageBody`, and `messageContent`)
 -       * @return      static
 -       * 
 -       * @throws      \InvalidArgumentException       if any of the given arguments is invalid
 -       */
 -      public function quoteData($objectType, $actionClass, array $selectors = []) {
 -              if (ObjectTypeCache::getInstance()->getObjectTypeByName('com.woltlab.wcf.message.quote', $objectType) === null) {
 -                      throw new \InvalidArgumentException("Unknown message quote object type '{$objectType}'.");
 -              }
 -              
 -              if (!class_exists($actionClass)) {
 -                      throw new \InvalidArgumentException("Unknown class '{$actionClass}'");
 -              }
 -              if (!is_subclass_of($actionClass, IMessageQuoteAction::class)) {
 -                      throw new \InvalidArgumentException("'{$actionClass}' does not implement '" . IMessageQuoteAction::class . "'.");
 -              }
 -              
 -              if (!empty($selectors)) {
 -                      foreach (['container', 'messageBody', 'messageContent'] as $selector) {
 -                              if (!isset($selectors[$selector])) {
 -                                      throw new \InvalidArgumentException("Missing selector '{$selector}'.");
 -                              }
 -                      }
 -              }
 -              
 -              $this->supportQuotes();
 -              
 -              $this->quoteData = [
 -                      'actionClass' => $actionClass,
 -                      'objectType' => $objectType,
 -                      'selectors' => $selectors
 -              ];
 -              
 -              return $this;
 -      }
 -      
 -      /**
 -       * @inheritDoc
 -       */
 -      public function readValue() {
 -              if ($this->getDocument()->hasRequestData($this->getPrefixedId())) {
 -                      $value = $this->getDocument()->getRequestData($this->getPrefixedId());
 -                      
 -                      if (is_string($value)) {
 -                              $this->value = StringUtil::trim($value);
 -                      }
 -              }
 -              
 -              if ($this->supportsQuotes()) {
 -                      MessageQuoteManager::getInstance()->readFormParameters();
 -              }
 -              
 -              return $this;
 -      }
 -      
 -      /**
 -       * Sets if the form field supports attachments and returns this field.
 -       * 
 -       * @param       boolean         $supportAttachments
 -       * @return      WysiwygFormField                this field
 -       */
 -      public function supportAttachments($supportAttachments = true) {
 -              $this->supportAttachments = $supportAttachments;
 -              
 -              return $this;
 -      }
 -      
 -      /**
 -       * Sets if the form field supports mentions and returns this field.
 -       * 
 -       * @param       boolean         $supportMentions
 -       * @return      WysiwygFormField                this field
 -       */
 -      public function supportMentions($supportMentions = true) {
 -              $this->supportMentions = $supportMentions;
 -              
 -              return $this;
 -      }
 -      
 -      /**
 -       * Sets if the form field supports quotes and returns this field.
 -       * 
 -       * @param       boolean         $supportQuotes
 -       * @return      WysiwygFormField                this field
 -       */
 -      public function supportQuotes($supportQuotes = true) {
 -              $this->supportQuotes = $supportQuotes;
 -              
 -              if (!$this->supportsQuotes()) {
 -                      // unset previously set quote data
 -                      $this->quoteData = null;
 -              }
 -              else {
 -                      MessageQuoteManager::getInstance()->readParameters();
 -              }
 -              
 -              return $this;
 -      }
 -      
 -      /**
 -       * Returns `true` if the form field supports attachments and returns `false` otherwise.
 -       * 
 -       * Important: If this method returns `true`, it does not necessarily mean that attachment
 -       * support will also work as that is the task of `WysiwygAttachmentFormField`. This method
 -       * is primarily relevant to inform the JavaScript API that the field supports attachments
 -       * so that the relevant editor plugin is loaded.
 -       * 
 -       * By default, attachments are not supported.
 -       * 
 -       * @return      boolean
 -       */
 -      public function supportsAttachments() {
 -              return $this->supportAttachments;
 -      }
 -      
 -      /**
 -       * Returns `true` if the form field supports mentions and returns `false` otherwise.
 -       * 
 -       * By default, mentions are not supported.
 -       * 
 -       * @return      boolean
 -       */
 -      public function supportsMentions() {
 -              return $this->supportMentions;
 -      }
 -      
 -      /**
 -       * Returns `true` if the form field supports quotes and returns `false` otherwise.
 -       * 
 -       * By default, quotes are not supported.
 -       * 
 -       * @return      boolean
 -       */
 -      public function supportsQuotes() {
 -              return $this->supportQuotes;
 -      }
 -      
 -      /**
 -       * @inheritDoc
 -       */
 -      public function validate() {
 -              /** @noinspection PhpUndefinedFieldInspection */
 -              $disallowedBBCodesPermission = $this->getObjectType()->disallowedBBCodesPermission;
 -              if ($disallowedBBCodesPermission === null) {
 -                      $disallowedBBCodesPermission = 'user.message.disallowedBBCodes';
 -              }
 -              
 -              BBCodeHandler::getInstance()->setDisallowedBBCodes(explode(
 -                      ',',
 -                      WCF::getSession()->getPermission($disallowedBBCodesPermission)
 -              ));
 -              
 -              $this->htmlInputProcessor = new HtmlInputProcessor();
 -              $this->htmlInputProcessor->process($this->getValue(), $this->getObjectType()->objectType);
 -              
 -              if ($this->isRequired() && $this->htmlInputProcessor->appearsToBeEmpty()) {
 -                      $this->addValidationError(new FormFieldValidationError('empty'));
 -              }
 -              else {
 -                      $disallowedBBCodes = $this->htmlInputProcessor->validate();
 -                      if (!empty($disallowedBBCodes)) {
 -                              $this->addValidationError(new FormFieldValidationError(
 -                                      'disallowedBBCodes',
 -                                      'wcf.message.error.disallowedBBCodes',
 -                                      ['disallowedBBCodes' => $disallowedBBCodes]
 -                              ));
 -                      }
 -                      else {
 -                              $message = $this->htmlInputProcessor->getTextContent();
 -                              if ($message !== '') {
 -                                      $this->validateMinimumLength($message);
 -                                      $this->validateMaximumLength($message);
 -                                      
 -                                      if (empty($this->getValidationErrors()) && ENABLE_CENSORSHIP) {
 -                                              $result = Censorship::getInstance()->test($message);
 -                                              if ($result) {
 -                                                      $this->addValidationError(new FormFieldValidationError(
 -                                                              'censoredWords',
 -                                                              'wcf.message.error.censoredWordsFound',
 -                                                              ['censoredWords' => $result]
 -                                                      ));
 -                                              }
 -                                      }
 -                              }
 -                      }
 -              }
 -              
 -              parent::validate();
 -      }
 +class WysiwygFormField extends AbstractFormField implements
 +    IAttributeFormField,
 +    IMaximumLengthFormField,
 +    IMinimumLengthFormField,
 +    IObjectTypeFormNode
 +{
 +    use TInputAttributeFormField {
 +        getReservedFieldAttributes as private inputGetReservedFieldAttributes;
 +    }
 +    use TMaximumLengthFormField;
 +    use TMinimumLengthFormField;
 +    use TObjectTypeFormNode;
 +
 +    /**
 +     * identifier used to autosave the field value; if empty, autosave is disabled
 +     * @var string
 +     */
 +    protected $autosaveId = '';
 +
 +    /**
 +     * input processor containing the wysiwyg text
 +     * @var HtmlInputProcessor
 +     */
 +    protected $htmlInputProcessor;
 +
 +    /**
 +     * last time the field has been edited; if `0`, the last edit time is unknown
 +     * @var int
 +     */
 +    protected $lastEditTime = 0;
 +
 +    /**
 +     * quote-related data used to create the JavaScript quote manager
 +     * @var null|array
 +     */
 +    protected $quoteData;
 +
 +    /**
 +     * is `true` if this form field supports attachments, otherwise `false`
 +     * @var bool
 +     */
 +    protected $supportAttachments = false;
 +
 +    /**
 +     * is `true` if this form field supports mentions, otherwise `false`
 +     * @var bool
 +     */
 +    protected $supportMentions = false;
 +
 +    /**
 +     * is `true` if this form field supports quotes, otherwise `false`
 +     * @var bool
 +     */
 +    protected $supportQuotes = false;
 +
 +    /**
 +     * @inheritDoc
 +     */
 +    protected $javaScriptDataHandlerModule = 'WoltLabSuite/Core/Form/Builder/Field/Value';
 +
 +    /**
 +     * @inheritDoc
 +     */
 +    protected $templateName = '__wysiwygFormField';
 +
 +    /**
 +     * Sets the identifier used to autosave the field value and returns this field.
 +     *
 +     * @param string $autosaveId identifier used to autosave field value
 +     * @return  WysiwygFormField        this field
 +     */
 +    public function autosaveId($autosaveId)
 +    {
 +        $this->autosaveId = $autosaveId;
 +
 +        return $this;
 +    }
 +
 +    /**
 +     * @inheritDoc
 +     */
 +    public function cleanup()
 +    {
 +        MessageQuoteManager::getInstance()->saved();
 +    }
 +
 +    /**
 +     * Returns the identifier used to autosave the field value. If autosave is disabled,
 +     * an empty string is returned.
 +     *
 +     * @return  string
 +     */
 +    public function getAutosaveId()
 +    {
 +        return $this->autosaveId;
 +    }
 +
 +    /**
 +     * @inheritDoc
 +     */
 +    public function getFieldHtml()
 +    {
 +        if ($this->supportsQuotes()) {
 +            MessageQuoteManager::getInstance()->assignVariables();
 +        }
 +
 +        /** @noinspection PhpUndefinedFieldInspection */
 +        $disallowedBBCodesPermission = $this->getObjectType()->disallowedBBCodesPermission;
 +        if ($disallowedBBCodesPermission === null) {
 +            $disallowedBBCodesPermission = 'user.message.disallowedBBCodes';
 +        }
 +
 +        BBCodeHandler::getInstance()->setDisallowedBBCodes(\explode(
 +            ',',
 +            WCF::getSession()->getPermission($disallowedBBCodesPermission)
 +        ));
 +
 +        return parent::getFieldHtml();
 +    }
 +
 +    /**
 +     * @inheritDoc
 +     */
 +    public function getObjectTypeDefinition()
 +    {
 +        return 'com.woltlab.wcf.message';
 +    }
 +
 +    /**
 +     * Returns the last time the field has been edited. If no last edit time has
 +     * been set, `0` is returned.
 +     *
 +     * @return  int
 +     */
 +    public function getLastEditTime()
 +    {
 +        return $this->lastEditTime;
 +    }
 +
 +    /**
 +     * Returns all quote data or specific quote data if an argument is given.
 +     *
 +     * @param null|string $index quote data index
 +     * @return  string[]|string
 +     *
 +     * @throws  \BadMethodCallException     if quotes are not supported for this field
 +     * @throws  \InvalidArgumentException   if unknown quote data is requested
 +     */
 +    public function getQuoteData($index = null)
 +    {
 +        if (!$this->supportQuotes()) {
 +            throw new \BadMethodCallException("Quotes are not supported.");
 +        }
 +
 +        if ($index === null) {
 +            return $this->quoteData;
 +        }
 +
 +        if (!isset($this->quoteData[$index])) {
 +            throw new \InvalidArgumentException("Unknown quote data '{$index}'.");
 +        }
 +
 +        return $this->quoteData[$index];
 +    }
 +
 +    /**
 +     * @inheritDoc
 +     */
 +    public function hasSaveValue()
 +    {
 +        return false;
 +    }
 +
 +    /**
 +     * Sets the last time this field has been edited and returns this field.
 +     *
 +     * @param int $lastEditTime last time field has been edited
 +     * @return  WysiwygFormField    this field
 +     */
 +    public function lastEditTime($lastEditTime)
 +    {
 +        $this->lastEditTime = $lastEditTime;
 +
 +        return $this;
 +    }
 +
 +    /**
 +     * @inheritDoc
 +     */
 +    public function populate()
 +    {
 +        parent::populate();
 +
 +        $this->getDocument()->getDataHandler()->addProcessor(new CustomFormDataProcessor(
 +            'wysiwyg',
 +            function (IFormDocument $document, array $parameters) {
 +                if ($this->checkDependencies()) {
 +                    $parameters[$this->getObjectProperty() . '_htmlInputProcessor'] = $this->htmlInputProcessor;
 +                }
 +
 +                return $parameters;
 +            }
 +        ));
 +
 +        return $this;
 +    }
 +
 +    /**
 +     * Sets the data required for advanced quote support for when quotable content is present
 +     * on the active page and returns this field.
 +     *
 +     * Calling this method automatically enables quote support for this field.
 +     *
 +     * @param string $objectType name of the relevant `com.woltlab.wcf.message.quote` object type
 +     * @param string $actionClass action class implementing `wcf\data\IMessageQuoteAction`
 +     * @param string[] $selectors selectors for the quotable content (required keys: `container`, `messageBody`, and `messageContent`)
 +     * @return  static
 +     *
 +     * @throws  \InvalidArgumentException   if any of the given arguments is invalid
 +     */
 +    public function quoteData($objectType, $actionClass, array $selectors = [])
 +    {
 +        if (
 +            ObjectTypeCache::getInstance()->getObjectTypeByName(
 +                'com.woltlab.wcf.message.quote',
 +                $objectType
 +            ) === null
 +        ) {
 +            throw new \InvalidArgumentException("Unknown message quote object type '{$objectType}'.");
 +        }
 +
 +        if (!\class_exists($actionClass)) {
 +            throw new \InvalidArgumentException("Unknown class '{$actionClass}'");
 +        }
 +        if (!\is_subclass_of($actionClass, IMessageQuoteAction::class)) {
 +            throw new \InvalidArgumentException(
 +                "'{$actionClass}' does not implement '" . IMessageQuoteAction::class . "'."
 +            );
 +        }
 +
 +        if (!empty($selectors)) {
 +            foreach (['container', 'messageBody', 'messageContent'] as $selector) {
 +                if (!isset($selectors[$selector])) {
 +                    throw new \InvalidArgumentException("Missing selector '{$selector}'.");
 +                }
 +            }
 +        }
 +
 +        $this->supportQuotes();
 +
 +        $this->quoteData = [
 +            'actionClass' => $actionClass,
 +            'objectType' => $objectType,
 +            'selectors' => $selectors,
 +        ];
 +
 +        return $this;
 +    }
 +
 +    /**
 +     * @inheritDoc
 +     */
 +    public function readValue()
 +    {
 +        if ($this->getDocument()->hasRequestData($this->getPrefixedId())) {
 +            $value = $this->getDocument()->getRequestData($this->getPrefixedId());
 +
 +            if (\is_string($value)) {
 +                $this->value = StringUtil::trim($value);
 +            }
 +        }
 +
 +        if ($this->supportsQuotes()) {
 +            MessageQuoteManager::getInstance()->readFormParameters();
 +        }
 +
 +        return $this;
 +    }
 +
 +    /**
 +     * Sets if the form field supports attachments and returns this field.
 +     *
 +     * @param bool $supportAttachments
 +     * @return  WysiwygFormField        this field
 +     */
 +    public function supportAttachments($supportAttachments = true)
 +    {
 +        $this->supportAttachments = $supportAttachments;
 +
 +        return $this;
 +    }
 +
 +    /**
 +     * Sets if the form field supports mentions and returns this field.
 +     *
 +     * @param bool $supportMentions
 +     * @return  WysiwygFormField        this field
 +     */
 +    public function supportMentions($supportMentions = true)
 +    {
 +        $this->supportMentions = $supportMentions;
 +
 +        return $this;
 +    }
 +
 +    /**
 +     * Sets if the form field supports quotes and returns this field.
 +     *
 +     * @param bool $supportQuotes
 +     * @return  WysiwygFormField        this field
 +     */
 +    public function supportQuotes($supportQuotes = true)
 +    {
 +        $this->supportQuotes = $supportQuotes;
 +
 +        if (!$this->supportsQuotes()) {
 +            // unset previously set quote data
 +            $this->quoteData = null;
 +        } else {
 +            MessageQuoteManager::getInstance()->readParameters();
 +        }
 +
 +        return $this;
 +    }
 +
 +    /**
 +     * Returns `true` if the form field supports attachments and returns `false` otherwise.
 +     *
 +     * Important: If this method returns `true`, it does not necessarily mean that attachment
 +     * support will also work as that is the task of `WysiwygAttachmentFormField`. This method
 +     * is primarily relevant to inform the JavaScript API that the field supports attachments
 +     * so that the relevant editor plugin is loaded.
 +     *
 +     * By default, attachments are not supported.
 +     *
 +     * @return  bool
 +     */
 +    public function supportsAttachments()
 +    {
 +        return $this->supportAttachments;
 +    }
 +
 +    /**
 +     * Returns `true` if the form field supports mentions and returns `false` otherwise.
 +     *
 +     * By default, mentions are not supported.
 +     *
 +     * @return  bool
 +     */
 +    public function supportsMentions()
 +    {
 +        return $this->supportMentions;
 +    }
 +
 +    /**
 +     * Returns `true` if the form field supports quotes and returns `false` otherwise.
 +     *
 +     * By default, quotes are not supported.
 +     *
 +     * @return  bool
 +     */
 +    public function supportsQuotes()
 +    {
 +        return $this->supportQuotes;
 +    }
 +
 +    /**
 +     * @inheritDoc
 +     */
 +    public function validate()
 +    {
 +        /** @noinspection PhpUndefinedFieldInspection */
 +        $disallowedBBCodesPermission = $this->getObjectType()->disallowedBBCodesPermission;
 +        if ($disallowedBBCodesPermission === null) {
 +            $disallowedBBCodesPermission = 'user.message.disallowedBBCodes';
 +        }
 +
 +        BBCodeHandler::getInstance()->setDisallowedBBCodes(\explode(
 +            ',',
 +            WCF::getSession()->getPermission($disallowedBBCodesPermission)
 +        ));
 +
 +        $this->htmlInputProcessor = new HtmlInputProcessor();
 +        $this->htmlInputProcessor->process($this->getValue(), $this->getObjectType()->objectType);
 +
 +        if ($this->isRequired() && $this->htmlInputProcessor->appearsToBeEmpty()) {
 +            $this->addValidationError(new FormFieldValidationError('empty'));
 +        } else {
 +            $disallowedBBCodes = $this->htmlInputProcessor->validate();
 +            if (!empty($disallowedBBCodes)) {
 +                $this->addValidationError(new FormFieldValidationError(
 +                    'disallowedBBCodes',
 +                    'wcf.message.error.disallowedBBCodes',
 +                    ['disallowedBBCodes' => $disallowedBBCodes]
 +                ));
 +            } else {
 +                $message = $this->htmlInputProcessor->getTextContent();
-                 $this->validateMinimumLength($message);
-                 $this->validateMaximumLength($message);
-                 if (empty($this->getValidationErrors()) && ENABLE_CENSORSHIP) {
-                     $result = Censorship::getInstance()->test($message);
-                     if ($result) {
-                         $this->addValidationError(new FormFieldValidationError(
-                             'censoredWords',
-                             'wcf.message.error.censoredWordsFound',
-                             ['censoredWords' => $result]
-                         ));
++                if ($message !== '') {
++                    $this->validateMinimumLength($message);
++                    $this->validateMaximumLength($message);
++
++                    if (empty($this->getValidationErrors()) && ENABLE_CENSORSHIP) {
++                        $result = Censorship::getInstance()->test($message);
++                        if ($result) {
++                            $this->addValidationError(new FormFieldValidationError(
++                                'censoredWords',
++                                'wcf.message.error.censoredWordsFound',
++                                ['censoredWords' => $result]
++                            ));
++                        }
 +                    }
 +                }
 +            }
 +        }
 +
 +        parent::validate();
 +    }
 +
 +    /**
 +     * @inheritDoc
 +     * @since       5.4
 +     */
 +    protected static function getReservedFieldAttributes(): array
 +    {
 +        return \array_merge(
 +            static::inputGetReservedFieldAttributes(),
 +            [
 +                'data-autosave',
 +                'data-autosave-last-edit-time',
 +                'data-disable-attachments',
 +                'data-support-mention',
 +            ]
 +        );
 +    }
  }