Merge branch '5.3' into 5.4
authorTim Düsterhus <duesterhus@woltlab.com>
Wed, 19 Apr 2023 11:51:31 +0000 (13:51 +0200)
committerTim Düsterhus <duesterhus@woltlab.com>
Wed, 19 Apr 2023 11:51:31 +0000 (13:51 +0200)
1  2 
ts/WoltLabSuite/Core/StringUtil.ts
wcfsetup/install/files/js/WoltLabSuite/Core/StringUtil.js
wcfsetup/install/files/lib/data/article/ArticleAction.class.php
wcfsetup/install/files/lib/system/clipboard/action/ArticleClipboardAction.class.php

index 9c7c3b438bf93532d0b63c26af0daf1890c4e974,0000000000000000000000000000000000000000..52e4896f9de299901016c4eadd3af22de408bc75
mode 100644,000000..100644
--- /dev/null
@@@ -1,143 -1,0 +1,143 @@@
-     .replace(/&amp;/g, "&")
 +/**
 + * Provides helper functions for String handling.
 + *
 + * @author  Tim Duesterhus, Joshua Ruesweg
 + * @copyright  2001-2019 WoltLab GmbH
 + * @license  GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
 + * @module  StringUtil (alias)
 + * @module  WoltLabSuite/Core/StringUtil
 + */
 +
 +import * as NumberUtil from "./NumberUtil";
 +
 +let _decimalPoint = ".";
 +let _thousandsSeparator = ",";
 +
 +/**
 + * Adds thousands separators to a given number.
 + *
 + * @see    http://stackoverflow.com/a/6502556/782822
 + */
 +export function addThousandsSeparator(number: number): string {
 +  return String(number).replace(/(^-?\d{1,3}|\d{3})(?=(?:\d{3})+(?:$|\.))/g, "$1" + _thousandsSeparator);
 +}
 +
 +/**
 + * Escapes special HTML-characters within a string
 + */
 +export function escapeHTML(string: string): string {
 +  return String(string).replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
 +}
 +
 +/**
 + * Escapes a String to work with RegExp.
 + *
 + * @see    https://github.com/sstephenson/prototype/blob/master/src/prototype/lang/regexp.js#L25
 + */
 +export function escapeRegExp(string: string): string {
 +  return String(string).replace(/([.*+?^=!:${}()|[\]/\\])/g, "\\$1");
 +}
 +
 +/**
 + * Rounds number to given count of floating point digits, localizes decimal-point and inserts thousands separators.
 + */
 +export function formatNumeric(number: number, decimalPlaces?: number): string {
 +  let tmp = NumberUtil.round(number, decimalPlaces || -2).toString();
 +  const numberParts = tmp.split(".");
 +
 +  tmp = addThousandsSeparator(+numberParts[0]);
 +  if (numberParts.length > 1) {
 +    tmp += _decimalPoint + numberParts[1];
 +  }
 +
 +  tmp = tmp.replace("-", "\u2212");
 +
 +  return tmp;
 +}
 +
 +/**
 + * Makes a string's first character lowercase.
 + */
 +export function lcfirst(string: string): string {
 +  return String(string).substring(0, 1).toLowerCase() + string.substring(1);
 +}
 +
 +/**
 + * Makes a string's first character uppercase.
 + */
 +export function ucfirst(string: string): string {
 +  return String(string).substring(0, 1).toUpperCase() + string.substring(1);
 +}
 +
 +/**
 + * Unescapes special HTML-characters within a string.
 + */
 +export function unescapeHTML(string: string): string {
 +  return String(string)
-     .replace(/&gt;/g, ">");
 +    .replace(/&quot;/g, '"')
 +    .replace(/&lt;/g, "<")
++    .replace(/&gt;/g, ">")
++    .replace(/&amp;/g, "&");
 +}
 +
 +/**
 + * Shortens numbers larger than 1000 by using unit suffixes.
 + */
 +export function shortUnit(number: number): string {
 +  let unitSuffix = "";
 +
 +  if (number >= 1000000) {
 +    number /= 1000000;
 +
 +    if (number > 10) {
 +      number = Math.floor(number);
 +    } else {
 +      number = NumberUtil.round(number, -1);
 +    }
 +
 +    unitSuffix = "M";
 +  } else if (number >= 1000) {
 +    number /= 1000;
 +
 +    if (number > 10) {
 +      number = Math.floor(number);
 +    } else {
 +      number = NumberUtil.round(number, -1);
 +    }
 +
 +    unitSuffix = "k";
 +  }
 +
 +  return formatNumeric(number) + unitSuffix;
 +}
 +
 +/**
 + * Converts a lower-case string containing dashed to camelCase for use
 + * with the `dataset` property.
 + */
 +export function toCamelCase(value: string): string {
 +  if (!value.includes("-")) {
 +    return value;
 +  }
 +
 +  return value
 +    .split("-")
 +    .map((part, index) => {
 +      if (index > 0) {
 +        part = ucfirst(part);
 +      }
 +
 +      return part;
 +    })
 +    .join("");
 +}
 +
 +interface I18nValues {
 +  decimalPoint: string;
 +  thousandsSeparator: string;
 +}
 +
 +export function setupI18n(values: I18nValues): void {
 +  _decimalPoint = values.decimalPoint;
 +  _thousandsSeparator = values.thousandsSeparator;
 +}
index 28e090fadb148d9f64ffb267443ea0e1c2687a91,37f4754e718d7e76bfde559fb74a592664074c1d..dee8ef2a53d856e66c6ca83adcbc5ae04e2ab417
  /**
   * Provides helper functions for String handling.
 - * 
 - * @author    Tim Duesterhus, Joshua Ruesweg
 - * @copyright 2001-2019 WoltLab GmbH
 - * @license   GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
 - * @module    StringUtil (alias)
 - * @module    WoltLabSuite/Core/StringUtil
 + *
 + * @author  Tim Duesterhus, Joshua Ruesweg
 + * @copyright  2001-2019 WoltLab GmbH
 + * @license  GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
 + * @module  StringUtil (alias)
 + * @module  WoltLabSuite/Core/StringUtil
   */
 -define(['Language', './NumberUtil'], function(Language, NumberUtil) {
 -      "use strict";
 -      
 -      /**
 -       * @exports     WoltLabSuite/Core/StringUtil
 -       */
 -      return {
 -              /**
 -               * Adds thousands separators to a given number.
 -               * 
 -               * @see         http://stackoverflow.com/a/6502556/782822
 -               * @param       {?}     number
 -               * @return      {String}
 -               */
 -              addThousandsSeparator: function(number) {
 -                      // Fetch Language, as it cannot be provided because of a circular dependency
 -                      if (Language === undefined) Language = require('Language');
 -                      
 -                      return String(number).replace(/(^-?\d{1,3}|\d{3})(?=(?:\d{3})+(?:$|\.))/g, '$1' + Language.get('wcf.global.thousandsSeparator'));
 -              },
 -              
 -              /**
 -               * Escapes special HTML-characters within a string
 -               * 
 -               * @param       {?}     string
 -               * @return      {String}
 -               */
 -              escapeHTML: function (string) {
 -                      return String(string).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
 -              },
 -              
 -              /**
 -               * Escapes a String to work with RegExp.
 -               * 
 -               * @see         https://github.com/sstephenson/prototype/blob/master/src/prototype/lang/regexp.js#L25
 -               * @param       {?}     string
 -               * @return      {String}
 -               */
 -              escapeRegExp: function(string) {
 -                      return String(string).replace(/([.*+?^=!:${}()|[\]\/\\])/g, '\\$1');
 -              },
 -              
 -              /**
 -               * Rounds number to given count of floating point digits, localizes decimal-point and inserts thousands separators.
 -               * 
 -               * @param       {?}             number
 -               * @param       {int}           decimalPlaces   The number of decimal places to leave after rounding.
 -               * @return      {String}
 -               */
 -              formatNumeric: function(number, decimalPlaces) {
 -                      // Fetch Language, as it cannot be provided because of a circular dependency
 -                      if (Language === undefined) Language = require('Language');
 -                      
 -                      number = String(NumberUtil.round(number, decimalPlaces || -2));
 -                      var numberParts = number.split('.');
 -                      
 -                      number = this.addThousandsSeparator(numberParts[0]);
 -                      if (numberParts.length > 1) number += Language.get('wcf.global.decimalPoint') + numberParts[1];
 -                      
 -                      number = number.replace('-', '\u2212');
 -                      
 -                      return number;
 -              },
 -              
 -              /**
 -               * Makes a string's first character lowercase.
 -               * 
 -               * @param       {?}             string
 -               * @return      {String}
 -               */
 -              lcfirst: function(string) {
 -                      return String(string).substring(0, 1).toLowerCase() + string.substring(1);
 -              },
 -              
 -              /**
 -               * Makes a string's first character uppercase.
 -               * 
 -               * @param       {?}             string
 -               * @return      {String}
 -               */
 -              ucfirst: function(string) {
 -                      return String(string).substring(0, 1).toUpperCase() + string.substring(1);
 -              },
 -              
 -              /**
 -               * Unescapes special HTML-characters within a string.
 -               * 
 -               * @param       {?}             string
 -               * @return      {String}
 -               */
 -              unescapeHTML: function(string) {
 -                      return String(string).replace(/&quot;/g, '"').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&');
 -              },
 -              
 -              /**
 -               * Shortens numbers larger than 1000 by using unit suffixes.
 -               *
 -               * @param       {?}             number
 -               * @return      {String}
 -               */
 -              shortUnit: function(number) {
 -                      var unitSuffix = '';
 -                      
 -                      if (number >= 1000000) {
 -                              number /= 1000000;
 -                              
 -                              if (number > 10) {
 -                                      number = Math.floor(number);
 -                              }
 -                              else {
 -                                      number = NumberUtil.round(number, -1);
 -                              }
 -                              
 -                              unitSuffix = 'M';
 -                      }
 -                      else if (number >= 1000) {
 -                              number /= 1000;
 -                              
 -                              if (number > 10) {
 -                                      number = Math.floor(number);
 -                              }
 -                              else {
 -                                      number = NumberUtil.round(number, -1);
 -                              }
 -                              
 -                              unitSuffix = 'k';
 -                      }
 -                      
 -                      return this.formatNumeric(number) + unitSuffix;
 -              }
 -      };
 +define(["require", "exports", "tslib", "./NumberUtil"], function (require, exports, tslib_1, NumberUtil) {
 +    "use strict";
 +    Object.defineProperty(exports, "__esModule", { value: true });
 +    exports.setupI18n = exports.toCamelCase = exports.shortUnit = exports.unescapeHTML = exports.ucfirst = exports.lcfirst = exports.formatNumeric = exports.escapeRegExp = exports.escapeHTML = exports.addThousandsSeparator = void 0;
 +    NumberUtil = tslib_1.__importStar(NumberUtil);
 +    let _decimalPoint = ".";
 +    let _thousandsSeparator = ",";
 +    /**
 +     * Adds thousands separators to a given number.
 +     *
 +     * @see    http://stackoverflow.com/a/6502556/782822
 +     */
 +    function addThousandsSeparator(number) {
 +        return String(number).replace(/(^-?\d{1,3}|\d{3})(?=(?:\d{3})+(?:$|\.))/g, "$1" + _thousandsSeparator);
 +    }
 +    exports.addThousandsSeparator = addThousandsSeparator;
 +    /**
 +     * Escapes special HTML-characters within a string
 +     */
 +    function escapeHTML(string) {
 +        return String(string).replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
 +    }
 +    exports.escapeHTML = escapeHTML;
 +    /**
 +     * Escapes a String to work with RegExp.
 +     *
 +     * @see    https://github.com/sstephenson/prototype/blob/master/src/prototype/lang/regexp.js#L25
 +     */
 +    function escapeRegExp(string) {
 +        return String(string).replace(/([.*+?^=!:${}()|[\]/\\])/g, "\\$1");
 +    }
 +    exports.escapeRegExp = escapeRegExp;
 +    /**
 +     * Rounds number to given count of floating point digits, localizes decimal-point and inserts thousands separators.
 +     */
 +    function formatNumeric(number, decimalPlaces) {
 +        let tmp = NumberUtil.round(number, decimalPlaces || -2).toString();
 +        const numberParts = tmp.split(".");
 +        tmp = addThousandsSeparator(+numberParts[0]);
 +        if (numberParts.length > 1) {
 +            tmp += _decimalPoint + numberParts[1];
 +        }
 +        tmp = tmp.replace("-", "\u2212");
 +        return tmp;
 +    }
 +    exports.formatNumeric = formatNumeric;
 +    /**
 +     * Makes a string's first character lowercase.
 +     */
 +    function lcfirst(string) {
 +        return String(string).substring(0, 1).toLowerCase() + string.substring(1);
 +    }
 +    exports.lcfirst = lcfirst;
 +    /**
 +     * Makes a string's first character uppercase.
 +     */
 +    function ucfirst(string) {
 +        return String(string).substring(0, 1).toUpperCase() + string.substring(1);
 +    }
 +    exports.ucfirst = ucfirst;
 +    /**
 +     * Unescapes special HTML-characters within a string.
 +     */
 +    function unescapeHTML(string) {
 +        return String(string)
-             .replace(/&amp;/g, "&")
 +            .replace(/&quot;/g, '"')
 +            .replace(/&lt;/g, "<")
-             .replace(/&gt;/g, ">");
++            .replace(/&gt;/g, ">")
++            .replace(/&amp;/g, "&");
 +    }
 +    exports.unescapeHTML = unescapeHTML;
 +    /**
 +     * Shortens numbers larger than 1000 by using unit suffixes.
 +     */
 +    function shortUnit(number) {
 +        let unitSuffix = "";
 +        if (number >= 1000000) {
 +            number /= 1000000;
 +            if (number > 10) {
 +                number = Math.floor(number);
 +            }
 +            else {
 +                number = NumberUtil.round(number, -1);
 +            }
 +            unitSuffix = "M";
 +        }
 +        else if (number >= 1000) {
 +            number /= 1000;
 +            if (number > 10) {
 +                number = Math.floor(number);
 +            }
 +            else {
 +                number = NumberUtil.round(number, -1);
 +            }
 +            unitSuffix = "k";
 +        }
 +        return formatNumeric(number) + unitSuffix;
 +    }
 +    exports.shortUnit = shortUnit;
 +    /**
 +     * Converts a lower-case string containing dashed to camelCase for use
 +     * with the `dataset` property.
 +     */
 +    function toCamelCase(value) {
 +        if (!value.includes("-")) {
 +            return value;
 +        }
 +        return value
 +            .split("-")
 +            .map((part, index) => {
 +            if (index > 0) {
 +                part = ucfirst(part);
 +            }
 +            return part;
 +        })
 +            .join("");
 +    }
 +    exports.toCamelCase = toCamelCase;
 +    function setupI18n(values) {
 +        _decimalPoint = values.decimalPoint;
 +        _thousandsSeparator = values.thousandsSeparator;
 +    }
 +    exports.setupI18n = setupI18n;
  });
index 2e47b398ca197a47b8dce418093b5340566ee854,172b265c16c6393f5973640b9607f8140b37ea30..b3756bf04e73c64aad954248676d64460c18c1ee
@@@ -29,889 -27,814 +29,893 @@@ 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');
 -              $category = ArticleCategory::getCategory($this->parameters['categoryID']);
 -              if ($category === null) {
 -                      throw new UserInputException('categoryID');
 -              }
 -              if (!$category->isAccessible()) {
 -                      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,
 +                        $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,
 +                            $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) {
++        $category = ArticleCategory::getCategory($this->parameters['categoryID']);
++        if ($category === null) {
++            throw new UserInputException('categoryID');
++        }
++        if (!$category->isAccessible()) {
 +            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 39eb6f81b85252a3fd2985ac0cb310fcf6eb9ea5,4502f6f5237e89554f1212759adc8e53a8c6162c..a7cbb62eedcc9e9916707753601e1f423a799f1e
@@@ -10,198 -8,185 +10,207 @@@ use wcf\system\WCF
  
  /**
   * Clipboard action implementation for articles.
 - * 
 - * @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\Clipboard\Action
 - * @since     3.1
 + *
 + * @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\Clipboard\Action
 + * @since   3.1
   */
 -class ArticleClipboardAction extends AbstractClipboardAction {
 -      /**
 -       * @inheritDoc
 -       */
 -      protected $actionClassActions = [
 -              'delete',
 -              'publish',
 -              'restore',
 -              'trash',
 -              'unpublish'
 -      ];
 -      
 -      /**
 -       * @inheritDoc
 -       */
 -      protected $supportedActions = [
 -              'delete',
 -              'publish',
 -              'restore',
 -              'setCategory',
 -              'trash',
 -              'unpublish'
 -      ];
 -      
 -      /**
 -       * @inheritDoc
 -       */
 -      public function execute(array $objects, ClipboardAction $action) {
 -              $item = parent::execute($objects, $action);
 -              
 -              if ($item === null) {
 -                      return null;
 -              }
 -              
 -              // handle actions
 -              switch ($action->actionName) {
 -                      case 'delete':
 -                              $item->addInternalData('confirmMessage', WCF::getLanguage()->getDynamicVariable('wcf.clipboard.item.com.woltlab.wcf.article.delete.confirmMessage', [
 -                                      'count' => $item->getCount()
 -                              ]));
 -                              break;
 -                              
 -                      case 'setCategory':
 -                              $item->addInternalData('template', WCF::getTPL()->fetch('articleCategoryDialog', 'wcf', [
 -                                      'categoryNodeList' => (new CategoryNodeTree('com.woltlab.wcf.article.category'))->getIterator()
 -                              ]));
 -                              break;
 -                      
 -                      case 'trash':
 -                              $item->addInternalData('confirmMessage', WCF::getLanguage()->getDynamicVariable('wcf.clipboard.item.com.woltlab.wcf.article.trash.confirmMessage', [
 -                                      'count' => $item->getCount()
 -                              ]));
 -                              break;
 -              }
 -              
 -              return $item;
 -      }
 -      
 -      /**
 -       * @inheritDoc
 -       */
 -      public function getClassName() {
 -              return ArticleAction::class;
 -      }
 -      
 -      /**
 -       * @inheritDoc
 -       */
 -      public function getTypeName() {
 -              return 'com.woltlab.wcf.article';
 -      }
 -      
 -      /**
 -       * Returns the ids of the articles that can be deleted.
 -       *
 -       * @return      integer[]
 -       */
 -      public function validateDelete() {
 -              $objectIDs = [];
 -              
 -              /** @var Article $article */
 -              foreach ($this->objects as $article) {
 -                      if ($article->canDelete() && $article->isDeleted) {
 -                              $objectIDs[] = $article->articleID;
 -                      }
 -              }
 -              
 -              return $objectIDs;
 -      }
 -      
 -      /**
 -       * Returns the ids of the articles that can be published.
 -       * 
 -       * @return      integer[]
 -       */
 -      public function validatePublish() {
 -              $objectIDs = [];
 -              
 -              /** @var Article $article */
 -              foreach ($this->objects as $article) {
 -                      if ($article->canPublish() && $article->publicationStatus == Article::UNPUBLISHED) {
 -                              $objectIDs[] = $article->articleID;
 -                      }
 -              }
 -              
 -              return $objectIDs;
 -      }
 -      
 -      /**
 -       * Returns the ids of the articles that can be restored.
 -       *
 -       * @return      integer[]
 -       */
 -      public function validateRestore() {
 -              return $this->validateDelete();
 -      }
 -      
 -      /**
 -       * Returns the ids of the articles whose category can be set.
 -       * 
 -       * @return      integer[]
 -       */
 -      public function validateSetCategory() {
 -              if (!WCF::getSession()->getPermission('admin.content.article.canManageArticle')) {
 -                      return [];
 -              }
 -              
 -              $objectIDs = [];
 -              
 -              /** @var Article $article */
 -              foreach ($this->objects as $article) {
 -                      if ($article->canEdit()) {
 -                              $objectIDs[] = $article->articleID;
 -                      }
 -              }
 -              
 -              return $objectIDs;
 -      }
 -      
 -      /**
 -       * Returns the ids of the articles that can be trashed.
 -       * 
 -       * @return      integer[]
 -       */
 -      public function validateTrash() {
 -              $objectIDs = [];
 -              
 -              /** @var Article $article */
 -              foreach ($this->objects as $article) {
 -                      if ($article->canDelete() && !$article->isDeleted) {
 -                              $objectIDs[] = $article->articleID;
 -                      }
 -              }
 -              
 -              return $objectIDs;
 -      }
 -      
 -      /**
 -       * Returns the ids of the articles that can be unpublished.
 -       *
 -       * @return      integer[]
 -       */
 -      public function validateUnpublish() {
 -              $objectIDs = [];
 -              
 -              /** @var Article $article */
 -              foreach ($this->objects as $article) {
 -                      if ($article->canPublish() && $article->publicationStatus == Article::PUBLISHED) {
 -                              $objectIDs[] = $article->articleID;
 -                      }
 -              }
 -              
 -              return $objectIDs;
 -      }
 +class ArticleClipboardAction extends AbstractClipboardAction
 +{
 +    /**
 +     * @inheritDoc
 +     */
 +    protected $actionClassActions = [
 +        'delete',
 +        'publish',
 +        'restore',
 +        'trash',
 +        'unpublish',
 +    ];
 +
 +    /**
 +     * @inheritDoc
 +     */
 +    protected $supportedActions = [
 +        'delete',
 +        'publish',
 +        'restore',
 +        'setCategory',
 +        'trash',
 +        'unpublish',
 +    ];
 +
 +    /**
 +     * @inheritDoc
 +     */
 +    public function execute(array $objects, ClipboardAction $action)
 +    {
 +        $item = parent::execute($objects, $action);
 +
 +        if ($item === null) {
 +            return;
 +        }
 +
 +        // handle actions
 +        switch ($action->actionName) {
 +            case 'delete':
 +                $item->addInternalData(
 +                    'confirmMessage',
 +                    WCF::getLanguage()->getDynamicVariable(
 +                        'wcf.clipboard.item.com.woltlab.wcf.article.delete.confirmMessage',
 +                        [
 +                            'count' => $item->getCount(),
 +                        ]
 +                    )
 +                );
 +                break;
 +
 +            case 'setCategory':
 +                $item->addInternalData('template', WCF::getTPL()->fetch('articleCategoryDialog', 'wcf', [
 +                    'categoryNodeList' => (new CategoryNodeTree('com.woltlab.wcf.article.category'))->getIterator(),
 +                ]));
 +                break;
 +
 +            case 'trash':
 +                $item->addInternalData(
 +                    'confirmMessage',
 +                    WCF::getLanguage()->getDynamicVariable(
 +                        'wcf.clipboard.item.com.woltlab.wcf.article.trash.confirmMessage',
 +                        [
 +                            'count' => $item->getCount(),
 +                        ]
 +                    )
 +                );
 +                break;
 +        }
 +
 +        return $item;
 +    }
 +
 +    /**
 +     * @inheritDoc
 +     */
 +    public function getClassName()
 +    {
 +        return ArticleAction::class;
 +    }
 +
 +    /**
 +     * @inheritDoc
 +     */
 +    public function getTypeName()
 +    {
 +        return 'com.woltlab.wcf.article';
 +    }
 +
 +    /**
 +     * Returns the ids of the articles that can be deleted.
 +     *
 +     * @return  int[]
 +     */
 +    public function validateDelete()
 +    {
 +        $objectIDs = [];
 +
 +        /** @var Article $article */
 +        foreach ($this->objects as $article) {
 +            if ($article->canDelete() && $article->isDeleted) {
 +                $objectIDs[] = $article->articleID;
 +            }
 +        }
 +
 +        return $objectIDs;
 +    }
 +
 +    /**
 +     * Returns the ids of the articles that can be published.
 +     *
 +     * @return  int[]
 +     */
 +    public function validatePublish()
 +    {
 +        $objectIDs = [];
 +
 +        /** @var Article $article */
 +        foreach ($this->objects as $article) {
 +            if ($article->canPublish() && $article->publicationStatus == Article::UNPUBLISHED) {
 +                $objectIDs[] = $article->articleID;
 +            }
 +        }
 +
 +        return $objectIDs;
 +    }
 +
 +    /**
 +     * Returns the ids of the articles that can be restored.
 +     *
 +     * @return  int[]
 +     */
 +    public function validateRestore()
 +    {
 +        return $this->validateDelete();
 +    }
 +
 +    /**
 +     * Returns the ids of the articles whose category can be set.
 +     *
 +     * @return  int[]
 +     */
 +    public function validateSetCategory()
 +    {
 +        if (!WCF::getSession()->getPermission('admin.content.article.canManageArticle')) {
 +            return [];
 +        }
 +
-         return \array_keys($this->objects);
++        $objectIDs = [];
++
++        /** @var Article $article */
++        foreach ($this->objects as $article) {
++            if ($article->canEdit()) {
++                $objectIDs[] = $article->articleID;
++            }
++        }
++
++        return $objectIDs;
 +    }
 +
 +    /**
 +     * Returns the ids of the articles that can be trashed.
 +     *
 +     * @return  int[]
 +     */
 +    public function validateTrash()
 +    {
 +        $objectIDs = [];
 +
 +        /** @var Article $article */
 +        foreach ($this->objects as $article) {
 +            if ($article->canDelete() && !$article->isDeleted) {
 +                $objectIDs[] = $article->articleID;
 +            }
 +        }
 +
 +        return $objectIDs;
 +    }
 +
 +    /**
 +     * Returns the ids of the articles that can be unpublished.
 +     *
 +     * @return  int[]
 +     */
 +    public function validateUnpublish()
 +    {
 +        $objectIDs = [];
 +
 +        /** @var Article $article */
 +        foreach ($this->objects as $article) {
 +            if ($article->canPublish() && $article->publicationStatus == Article::PUBLISHED) {
 +                $objectIDs[] = $article->articleID;
 +            }
 +        }
 +
 +        return $objectIDs;
 +    }
  }