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