Merge branch 'master' into next
[GitHub/WoltLab/WCF.git] / wcfsetup / install / files / lib / data / article / ArticleAction.class.php
1 <?php
2 namespace wcf\data\article;
3 use wcf\data\article\category\ArticleCategory;
4 use wcf\data\article\content\ArticleContent;
5 use wcf\data\article\content\ArticleContentAction;
6 use wcf\data\article\content\ArticleContentEditor;
7 use wcf\data\language\Language;
8 use wcf\data\AbstractDatabaseObjectAction;
9 use wcf\system\clipboard\ClipboardHandler;
10 use wcf\system\comment\CommentHandler;
11 use wcf\system\exception\UserInputException;
12 use wcf\system\language\LanguageFactory;
13 use wcf\system\like\LikeHandler;
14 use wcf\system\message\embedded\object\MessageEmbeddedObjectManager;
15 use wcf\system\request\LinkHandler;
16 use wcf\system\search\SearchIndexManager;
17 use wcf\system\tagging\TagEngine;
18 use wcf\system\user\storage\UserStorageHandler;
19 use wcf\system\version\VersionTracker;
20 use wcf\system\visitTracker\VisitTracker;
21 use wcf\system\WCF;
22
23 /**
24 * Executes article related actions.
25 *
26 * @author Marcel Werk
27 * @copyright 2001-2018 WoltLab GmbH
28 * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
29 * @package WoltLabSuite\Core\Data\Article
30 * @since 3.0
31 *
32 * @method ArticleEditor[] getObjects()
33 * @method ArticleEditor getSingleObject()
34 */
35 class ArticleAction extends AbstractDatabaseObjectAction {
36 /**
37 * article editor instance
38 * @var ArticleEditor
39 */
40 public $articleEditor;
41
42 /**
43 * language object
44 * @var Language
45 */
46 public $language;
47
48 /**
49 * @inheritDoc
50 */
51 protected $className = ArticleEditor::class;
52
53 /**
54 * @inheritDoc
55 */
56 protected $permissionsCreate = ['admin.content.article.canManageArticle'];
57
58 /**
59 * @inheritDoc
60 */
61 protected $permissionsDelete = ['admin.content.article.canManageArticle'];
62
63 /**
64 * @inheritDoc
65 */
66 protected $permissionsUpdate = ['admin.content.article.canManageArticle'];
67
68 /**
69 * @inheritDoc
70 */
71 protected $requireACP = ['create', 'delete', 'restore', 'toggleI18n', 'trash', 'update'];
72
73 /**
74 * @inheritDoc
75 */
76 protected $allowGuestAccess = ['markAllAsRead'];
77
78 /**
79 * @inheritDoc
80 * @return Article
81 */
82 public function create() {
83 /** @var Article $article */
84 $article = parent::create();
85
86 // save article content
87 if (!empty($this->parameters['content'])) {
88 foreach ($this->parameters['content'] as $languageID => $content) {
89 if (!empty($content['htmlInputProcessor'])) {
90 /** @noinspection PhpUndefinedMethodInspection */
91 $content['content'] = $content['htmlInputProcessor']->getHtml();
92 }
93
94 /** @var ArticleContent $articleContent */
95 $articleContent = ArticleContentEditor::create([
96 'articleID' => $article->articleID,
97 'languageID' => $languageID ?: null,
98 'title' => $content['title'],
99 'teaser' => $content['teaser'],
100 'content' => $content['content'],
101 'imageID' => $content['imageID'],
102 'teaserImageID' => $content['teaserImageID']
103 ]);
104 $articleContentEditor = new ArticleContentEditor($articleContent);
105
106 // save tags
107 if (!empty($content['tags'])) {
108 TagEngine::getInstance()->addObjectTags('com.woltlab.wcf.article', $articleContent->articleContentID, $content['tags'], ($languageID ?: LanguageFactory::getInstance()->getDefaultLanguageID()));
109 }
110
111 // update search index
112 SearchIndexManager::getInstance()->set(
113 'com.woltlab.wcf.article',
114 $articleContent->articleContentID,
115 $articleContent->content,
116 $articleContent->title,
117 $article->time,
118 $article->userID,
119 $article->username,
120 $languageID ?: null,
121 $articleContent->teaser
122 );
123
124 // save embedded objects
125 if (!empty($content['htmlInputProcessor'])) {
126 /** @noinspection PhpUndefinedMethodInspection */
127 $content['htmlInputProcessor']->setObjectID($articleContent->articleContentID);
128 if (MessageEmbeddedObjectManager::getInstance()->registerObjects($content['htmlInputProcessor'])) {
129 $articleContentEditor->update(['hasEmbeddedObjects' => 1]);
130 }
131 }
132 }
133 }
134
135 // reset storage
136 if (ARTICLE_ENABLE_VISIT_TRACKING) {
137 UserStorageHandler::getInstance()->resetAll('unreadArticles');
138 }
139
140 if ($article->publicationStatus == Article::PUBLISHED) {
141 ArticleEditor::updateArticleCounter([$article->userID => 1]);
142 }
143
144 return $article;
145 }
146
147 /**
148 * @inheritDoc
149 */
150 public function update() {
151 parent::update();
152
153 $isRevert = (!empty($this->parameters['isRevert']));
154
155 // update article content
156 if (!empty($this->parameters['content'])) {
157 foreach ($this->getObjects() as $article) {
158 $versionData = [];
159 $hasChanges = false;
160
161 foreach ($this->parameters['content'] as $languageID => $content) {
162 if (!empty($content['htmlInputProcessor'])) {
163 /** @noinspection PhpUndefinedMethodInspection */
164 $content['content'] = $content['htmlInputProcessor']->getHtml();
165 }
166
167 $articleContent = ArticleContent::getArticleContent($article->articleID, ($languageID ?: null));
168 $articleContentEditor = null;
169 if ($articleContent !== null) {
170 // update
171 $articleContentEditor = new ArticleContentEditor($articleContent);
172 $articleContentEditor->update([
173 'title' => $content['title'],
174 'teaser' => $content['teaser'],
175 'content' => $content['content'],
176 'imageID' => ($isRevert) ? $articleContent->imageID : $content['imageID'],
177 'teaserImageID' => ($isRevert) ? $articleContent->teaserImageID : $content['teaserImageID']
178 ]);
179
180 $versionData[] = $articleContent;
181 if ($articleContent->content != $content['content'] || $articleContent->teaser != $content['teaser'] || $articleContent->title != $content['title']) {
182 $hasChanges = true;
183 }
184
185 // delete tags
186 if (!$isRevert && empty($content['tags'])) {
187 TagEngine::getInstance()->deleteObjectTags('com.woltlab.wcf.article', $articleContent->articleContentID, ($languageID ?: null));
188 }
189 }
190 else {
191 /** @var ArticleContent $articleContent */
192 $articleContent = ArticleContentEditor::create([
193 'articleID' => $article->articleID,
194 'languageID' => $languageID ?: null,
195 'title' => $content['title'],
196 'teaser' => $content['teaser'],
197 'content' => $content['content'],
198 'imageID' => ($isRevert) ? null : $content['imageID'],
199 'teaserImageID' => ($isRevert) ? null : $content['teaserImageID']
200 ]);
201 $articleContentEditor = new ArticleContentEditor($articleContent);
202
203 $versionData[] = $articleContent;
204 $hasChanges = true;
205 }
206
207 // save tags
208 if (!$isRevert && !empty($content['tags'])) {
209 TagEngine::getInstance()->addObjectTags('com.woltlab.wcf.article', $articleContent->articleContentID, $content['tags'], ($languageID ?: LanguageFactory::getInstance()->getDefaultLanguageID()));
210 }
211
212 // update search index
213 SearchIndexManager::getInstance()->set(
214 'com.woltlab.wcf.article',
215 $articleContent->articleContentID,
216 $articleContent->content,
217 $articleContent->title,
218 $article->time,
219 $article->userID,
220 $article->username,
221 $languageID ?: null,
222 $articleContent->teaser
223 );
224
225 // save embedded objects
226 if (!empty($content['htmlInputProcessor'])) {
227 /** @noinspection PhpUndefinedMethodInspection */
228 $content['htmlInputProcessor']->setObjectID($articleContent->articleContentID);
229 if ($articleContent->hasEmbeddedObjects != MessageEmbeddedObjectManager::getInstance()->registerObjects($content['htmlInputProcessor'])) {
230 $articleContentEditor->update(['hasEmbeddedObjects' => $articleContent->hasEmbeddedObjects ? 0 : 1]);
231 }
232 }
233 }
234
235 if ($hasChanges) {
236 $articleObj = new ArticleVersionTracker($article->getDecoratedObject());
237 $articleObj->setContent($versionData);
238 VersionTracker::getInstance()->add('com.woltlab.wcf.article', $articleObj);
239 }
240 }
241 }
242
243 // reset storage
244 if (ARTICLE_ENABLE_VISIT_TRACKING) {
245 UserStorageHandler::getInstance()->resetAll('unreadArticles');
246 }
247
248 $publicationStatus = (isset($this->parameters['data']['publicationStatus'])) ? $this->parameters['data']['publicationStatus'] : null;
249 if ($publicationStatus !== null) {
250 $usersToArticles = [];
251 /** @var ArticleEditor $articleEditor */
252 foreach ($this->objects as $articleEditor) {
253 if ($publicationStatus != $articleEditor->publicationStatus) {
254 // The article was published before or was now published.
255 if ($publicationStatus == Article::PUBLISHED || $articleEditor->publicationStatus == Article::PUBLISHED) {
256 if (!isset($usersToArticles[$articleEditor->userID])) {
257 $usersToArticles[$articleEditor->userID] = 0;
258 }
259
260 $usersToArticles[$articleEditor->userID] += ($publicationStatus == Article::PUBLISHED) ? 1 : -1;
261 }
262 }
263 }
264
265 if (!empty($usersToArticles)) {
266 ArticleEditor::updateArticleCounter($usersToArticles);
267 }
268 }
269 }
270
271 /**
272 * Validates parameters to delete articles.
273 *
274 * @throws UserInputException
275 */
276 public function validateDelete() {
277 WCF::getSession()->checkPermissions(['admin.content.article.canManageArticle']);
278
279 if (empty($this->objects)) {
280 $this->readObjects();
281
282 if (empty($this->objects)) {
283 throw new UserInputException('objectIDs');
284 }
285 }
286
287 foreach ($this->getObjects() as $article) {
288 if (!$article->isDeleted) {
289 throw new UserInputException('objectIDs');
290 }
291 }
292 }
293
294 /**
295 * @inheritDoc
296 */
297 public function delete() {
298 $articleIDs = $articleContentIDs = [];
299 foreach ($this->getObjects() as $article) {
300 $articleIDs[] = $article->articleID;
301 foreach ($article->getArticleContents() as $articleContent) {
302 $articleContentIDs[] = $articleContent->articleContentID;
303 }
304 }
305
306 // delete articles
307 parent::delete();
308
309 if (!empty($articleIDs)) {
310 // delete like data
311 LikeHandler::getInstance()->removeLikes('com.woltlab.wcf.likeableArticle', $articleIDs);
312 // delete comments
313 CommentHandler::getInstance()->deleteObjects('com.woltlab.wcf.articleComment', $articleContentIDs);
314 // delete tag to object entries
315 TagEngine::getInstance()->deleteObjects('com.woltlab.wcf.article', $articleContentIDs);
316 // delete entry from search index
317 SearchIndexManager::getInstance()->delete('com.woltlab.wcf.article', $articleContentIDs);
318 }
319
320 $this->unmarkItems();
321
322 return [
323 'objectIDs' => $this->objectIDs,
324 'redirectURL' => LinkHandler::getInstance()->getLink('ArticleList', ['isACP' => true])
325 ];
326 }
327
328 /**
329 * Validates parameters to move articles to the trash bin.
330 *
331 * @throws UserInputException
332 */
333 public function validateTrash() {
334 WCF::getSession()->checkPermissions(['admin.content.article.canManageArticle']);
335
336 if (empty($this->objects)) {
337 $this->readObjects();
338
339 if (empty($this->objects)) {
340 throw new UserInputException('objectIDs');
341 }
342 }
343
344 foreach ($this->getObjects() as $article) {
345 if ($article->isDeleted) {
346 throw new UserInputException('objectIDs');
347 }
348 }
349 }
350
351 /**
352 * Moves articles to the trash bin.
353 */
354 public function trash() {
355 foreach ($this->getObjects() as $articleEditor) {
356 $articleEditor->update(['isDeleted' => 1]);
357 }
358
359 $this->unmarkItems();
360
361 // reset storage
362 if (ARTICLE_ENABLE_VISIT_TRACKING) {
363 UserStorageHandler::getInstance()->resetAll('unreadArticles');
364 }
365
366 return ['objectIDs' => $this->objectIDs];
367 }
368
369 /**
370 * Validates parameters to restore articles.
371 *
372 * @throws UserInputException
373 */
374 public function validateRestore() {
375 $this->validateDelete();
376 }
377
378 /**
379 * Restores articles.
380 */
381 public function restore() {
382 foreach ($this->getObjects() as $articleEditor) {
383 $articleEditor->update(['isDeleted' => 0]);
384 }
385
386 $this->unmarkItems();
387
388 // reset storage
389 if (ARTICLE_ENABLE_VISIT_TRACKING) {
390 UserStorageHandler::getInstance()->resetAll('unreadArticles');
391 }
392
393 return ['objectIDs' => $this->objectIDs];
394 }
395
396 /**
397 * Validates parameters to toggle between i18n and monolingual mode.
398 *
399 * @throws UserInputException
400 */
401 public function validateToggleI18n() {
402 WCF::getSession()->checkPermissions(['admin.content.article.canManageArticle']);
403
404 $this->articleEditor = $this->getSingleObject();
405 if ($this->articleEditor->getDecoratedObject()->isMultilingual) {
406 $this->readInteger('languageID');
407 $this->language = LanguageFactory::getInstance()->getLanguage($this->parameters['languageID']);
408 if ($this->language === null) {
409 throw new UserInputException('languageID');
410 }
411
412 $contents = $this->articleEditor->getArticleContents();
413 if (!isset($contents[$this->language->languageID])) {
414 // there is no content
415 throw new UserInputException('languageID');
416 }
417 }
418 }
419
420 /**
421 * Toggles between i18n and monolingual mode.
422 */
423 public function toggleI18n() {
424 $removeContent = [];
425
426 // i18n -> monolingual
427 if ($this->articleEditor->getDecoratedObject()->isMultilingual) {
428 foreach ($this->articleEditor->getArticleContents() as $articleContent) {
429 if ($articleContent->languageID == $this->language->languageID) {
430 $articleContentEditor = new ArticleContentEditor($articleContent);
431 $articleContentEditor->update(['languageID' => null]);
432 }
433 else {
434 $removeContent[] = $articleContent;
435 }
436 }
437 }
438 else {
439 // monolingual -> i18n
440 $articleContent = $this->articleEditor->getArticleContent();
441 $data = [];
442 foreach (LanguageFactory::getInstance()->getLanguages() as $language) {
443 $data[$language->languageID] = [
444 'title' => $articleContent->title,
445 'teaser' => $articleContent->teaser,
446 'content' => $articleContent->content,
447 'imageID' => $articleContent->imageID ?: null,
448 'teaserImageID' => $articleContent->teaserImageID ?: null
449 ];
450 }
451
452 $action = new ArticleAction([$this->articleEditor], 'update', ['content' => $data]);
453 $action->executeAction();
454
455 $removeContent[] = $articleContent;
456 }
457
458 if (!empty($removeContent)) {
459 $action = new ArticleContentAction($removeContent, 'delete');
460 $action->executeAction();
461 }
462
463 // flush edit history
464 VersionTracker::getInstance()->reset('com.woltlab.wcf.article', $this->articleEditor->getDecoratedObject()->articleID);
465
466 // update article's i18n state
467 $this->articleEditor->update([
468 'isMultilingual' => ($this->articleEditor->getDecoratedObject()->isMultilingual) ? 0 : 1
469 ]);
470 }
471
472 /**
473 * Marks articles as read.
474 */
475 public function markAsRead() {
476 if (empty($this->parameters['visitTime'])) {
477 $this->parameters['visitTime'] = TIME_NOW;
478 }
479
480 if (empty($this->objects)) {
481 $this->readObjects();
482 }
483
484 foreach ($this->getObjects() as $article) {
485 VisitTracker::getInstance()->trackObjectVisit('com.woltlab.wcf.article', $article->articleID, $this->parameters['visitTime']);
486 }
487
488 // reset storage
489 if (WCF::getUser()->userID) {
490 UserStorageHandler::getInstance()->reset([WCF::getUser()->userID], 'unreadArticles');
491 }
492 }
493
494 /**
495 * Marks all articles as read.
496 */
497 public function markAllAsRead() {
498 VisitTracker::getInstance()->trackTypeVisit('com.woltlab.wcf.article');
499
500 // reset storage
501 if (WCF::getUser()->userID) {
502 UserStorageHandler::getInstance()->reset([WCF::getUser()->userID], 'unreadArticles');
503 }
504 }
505
506 /**
507 * Validates the mark all as read action.
508 */
509 public function validateMarkAllAsRead() {
510 // does nothing
511 }
512
513 /**
514 * Validates the `setCategory` action.
515 *
516 * @throws UserInputException
517 */
518 public function validateSetCategory() {
519 WCF::getSession()->checkPermissions(['admin.content.article.canManageArticle']);
520
521 $this->readBoolean('useMarkedArticles', true);
522
523 // if no object ids are given, use clipboard handler
524 if (empty($this->objectIDs) && $this->parameters['useMarkedArticles']) {
525 $this->objectIDs = array_keys(ClipboardHandler::getInstance()->getMarkedItems(ClipboardHandler::getInstance()->getObjectTypeID('com.woltlab.wcf.article')));
526 }
527
528 if (empty($this->objects)) {
529 $this->readObjects();
530
531 if (empty($this->objects)) {
532 throw new UserInputException('objectIDs');
533 }
534 }
535
536 $this->readInteger('categoryID');
537 if (ArticleCategory::getCategory($this->parameters['categoryID']) === null) {
538 throw new UserInputException('categoryID');
539 }
540 }
541
542 /**
543 * Sets the category of articles.
544 */
545 public function setCategory() {
546 foreach ($this->getObjects() as $articleEditor) {
547 $articleEditor->update(['categoryID' => $this->parameters['categoryID']]);
548 }
549
550 $this->unmarkItems();
551 }
552
553 /**
554 * Validates the `publish` action.
555 *
556 * @throws UserInputException
557 */
558 public function validatePublish() {
559 WCF::getSession()->checkPermissions(['admin.content.article.canManageArticle']);
560
561 if (empty($this->objects)) {
562 $this->readObjects();
563
564 if (empty($this->objects)) {
565 throw new UserInputException('objectIDs');
566 }
567 }
568
569 foreach ($this->getObjects() as $article) {
570 if ($article->publicationStatus == Article::PUBLISHED) {
571 throw new UserInputException('objectIDs');
572 }
573 }
574 }
575
576 /**
577 * Publishes articles.
578 */
579 public function publish() {
580 $usersToArticles = [];
581 foreach ($this->getObjects() as $articleEditor) {
582 $articleEditor->update([
583 'time' => TIME_NOW,
584 'publicationStatus' => Article::PUBLISHED,
585 'publicationDate' => 0
586 ]);
587
588 if (!isset($usersToArticles[$articleEditor->userID])) {
589 $usersToArticles[$articleEditor->userID] = 0;
590 }
591
592 $usersToArticles[$articleEditor->userID]++;
593 }
594
595 ArticleEditor::updateArticleCounter($usersToArticles);
596
597 $this->unmarkItems();
598 }
599
600 /**
601 * Validates the `unpublish` action.
602 *
603 * @throws UserInputException
604 */
605 public function validateUnpublish() {
606 WCF::getSession()->checkPermissions(['admin.content.article.canManageArticle']);
607
608 if (empty($this->objects)) {
609 $this->readObjects();
610
611 if (empty($this->objects)) {
612 throw new UserInputException('objectIDs');
613 }
614 }
615
616 foreach ($this->getObjects() as $article) {
617 if ($article->publicationStatus != Article::PUBLISHED) {
618 throw new UserInputException('objectIDs');
619 }
620 }
621 }
622
623 /**
624 * Unpublishes articles.
625 */
626 public function unpublish() {
627 $usersToArticles = [];
628 foreach ($this->getObjects() as $articleEditor) {
629 $articleEditor->update(['publicationStatus' => Article::UNPUBLISHED]);
630
631 if (!isset($usersToArticles[$articleEditor->userID])) {
632 $usersToArticles[$articleEditor->userID] = 0;
633 }
634
635 $usersToArticles[$articleEditor->userID]--;
636 }
637
638 ArticleEditor::updateArticleCounter($usersToArticles);
639
640 $this->unmarkItems();
641 }
642
643 /**
644 * Unmarks articles.
645 *
646 * @param integer[] $articleIDs
647 */
648 protected function unmarkItems(array $articleIDs = []) {
649 if (empty($articleIDs)) {
650 foreach ($this->getObjects() as $article) {
651 $articleIDs[] = $article->articleID;
652 }
653 }
654
655 if (!empty($articleIDs)) {
656 ClipboardHandler::getInstance()->unmark($articleIDs, ClipboardHandler::getInstance()->getObjectTypeID('com.woltlab.wcf.article'));
657 }
658 }
659 }