Merge branch '3.0'
[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 return $article;
141 }
142
143 /**
144 * @inheritDoc
145 */
146 public function update() {
147 parent::update();
148
149 $isRevert = (!empty($this->parameters['isRevert']));
150
151 // update article content
152 if (!empty($this->parameters['content'])) {
153 foreach ($this->getObjects() as $article) {
154 $versionData = [];
155 $hasChanges = false;
156
157 foreach ($this->parameters['content'] as $languageID => $content) {
158 if (!empty($content['htmlInputProcessor'])) {
159 /** @noinspection PhpUndefinedMethodInspection */
160 $content['content'] = $content['htmlInputProcessor']->getHtml();
161 }
162
163 $articleContent = ArticleContent::getArticleContent($article->articleID, ($languageID ?: null));
164 $articleContentEditor = null;
165 if ($articleContent !== null) {
166 // update
167 $articleContentEditor = new ArticleContentEditor($articleContent);
168 $articleContentEditor->update([
169 'title' => $content['title'],
170 'teaser' => $content['teaser'],
171 'content' => $content['content'],
172 'imageID' => ($isRevert) ? $articleContent->imageID : $content['imageID'],
173 'teaserImageID' => ($isRevert) ? $articleContent->teaserImageID : $content['teaserImageID']
174 ]);
175
176 $versionData[] = $articleContent;
177 if ($articleContent->content != $content['content'] || $articleContent->teaser != $content['teaser'] || $articleContent->title != $content['title']) {
178 $hasChanges = true;
179 }
180
181 // delete tags
182 if (!$isRevert && empty($content['tags'])) {
183 TagEngine::getInstance()->deleteObjectTags('com.woltlab.wcf.article', $articleContent->articleContentID, ($languageID ?: null));
184 }
185 }
186 else {
187 /** @var ArticleContent $articleContent */
188 $articleContent = ArticleContentEditor::create([
189 'articleID' => $article->articleID,
190 'languageID' => $languageID ?: null,
191 'title' => $content['title'],
192 'teaser' => $content['teaser'],
193 'content' => $content['content'],
194 'imageID' => ($isRevert) ? null : $content['imageID'],
195 'teaserImageID' => ($isRevert) ? null : $content['teaserImageID']
196 ]);
197 $articleContentEditor = new ArticleContentEditor($articleContent);
198
199 $versionData[] = $articleContent;
200 $hasChanges = true;
201 }
202
203 // save tags
204 if (!$isRevert && !empty($content['tags'])) {
205 TagEngine::getInstance()->addObjectTags('com.woltlab.wcf.article', $articleContent->articleContentID, $content['tags'], ($languageID ?: LanguageFactory::getInstance()->getDefaultLanguageID()));
206 }
207
208 // update search index
209 SearchIndexManager::getInstance()->set(
210 'com.woltlab.wcf.article',
211 $articleContent->articleContentID,
212 $articleContent->content,
213 $articleContent->title,
214 $article->time,
215 $article->userID,
216 $article->username,
217 $languageID ?: null,
218 $articleContent->teaser
219 );
220
221 // save embedded objects
222 if (!empty($content['htmlInputProcessor'])) {
223 /** @noinspection PhpUndefinedMethodInspection */
224 $content['htmlInputProcessor']->setObjectID($articleContent->articleContentID);
225 if ($articleContent->hasEmbeddedObjects != MessageEmbeddedObjectManager::getInstance()->registerObjects($content['htmlInputProcessor'])) {
226 $articleContentEditor->update(['hasEmbeddedObjects' => $articleContent->hasEmbeddedObjects ? 0 : 1]);
227 }
228 }
229 }
230
231 if ($hasChanges) {
232 $articleObj = new ArticleVersionTracker($article->getDecoratedObject());
233 $articleObj->setContent($versionData);
234 VersionTracker::getInstance()->add('com.woltlab.wcf.article', $articleObj);
235 }
236 }
237 }
238
239 // reset storage
240 if (ARTICLE_ENABLE_VISIT_TRACKING) {
241 UserStorageHandler::getInstance()->resetAll('unreadArticles');
242 }
243 }
244
245 /**
246 * Validates parameters to delete articles.
247 *
248 * @throws UserInputException
249 */
250 public function validateDelete() {
251 WCF::getSession()->checkPermissions(['admin.content.article.canManageArticle']);
252
253 if (empty($this->objects)) {
254 $this->readObjects();
255
256 if (empty($this->objects)) {
257 throw new UserInputException('objectIDs');
258 }
259 }
260
261 foreach ($this->getObjects() as $article) {
262 if (!$article->isDeleted) {
263 throw new UserInputException('objectIDs');
264 }
265 }
266 }
267
268 /**
269 * @inheritDoc
270 */
271 public function delete() {
272 $articleIDs = $articleContentIDs = [];
273 foreach ($this->getObjects() as $article) {
274 $articleIDs[] = $article->articleID;
275 foreach ($article->getArticleContents() as $articleContent) {
276 $articleContentIDs[] = $articleContent->articleContentID;
277 }
278 }
279
280 // delete articles
281 parent::delete();
282
283 if (!empty($articleIDs)) {
284 // delete like data
285 LikeHandler::getInstance()->removeLikes('com.woltlab.wcf.likeableArticle', $articleIDs);
286 // delete comments
287 CommentHandler::getInstance()->deleteObjects('com.woltlab.wcf.articleComment', $articleContentIDs);
288 // delete tag to object entries
289 TagEngine::getInstance()->deleteObjects('com.woltlab.wcf.article', $articleContentIDs);
290 // delete entry from search index
291 SearchIndexManager::getInstance()->delete('com.woltlab.wcf.article', $articleContentIDs);
292 }
293
294 $this->unmarkItems();
295
296 return [
297 'objectIDs' => $this->objectIDs,
298 'redirectURL' => LinkHandler::getInstance()->getLink('ArticleList', ['isACP' => true])
299 ];
300 }
301
302 /**
303 * Validates parameters to move articles to the trash bin.
304 *
305 * @throws UserInputException
306 */
307 public function validateTrash() {
308 WCF::getSession()->checkPermissions(['admin.content.article.canManageArticle']);
309
310 if (empty($this->objects)) {
311 $this->readObjects();
312
313 if (empty($this->objects)) {
314 throw new UserInputException('objectIDs');
315 }
316 }
317
318 foreach ($this->getObjects() as $article) {
319 if ($article->isDeleted) {
320 throw new UserInputException('objectIDs');
321 }
322 }
323 }
324
325 /**
326 * Moves articles to the trash bin.
327 */
328 public function trash() {
329 foreach ($this->getObjects() as $articleEditor) {
330 $articleEditor->update(['isDeleted' => 1]);
331 }
332
333 $this->unmarkItems();
334
335 // reset storage
336 if (ARTICLE_ENABLE_VISIT_TRACKING) {
337 UserStorageHandler::getInstance()->resetAll('unreadArticles');
338 }
339
340 return ['objectIDs' => $this->objectIDs];
341 }
342
343 /**
344 * Validates parameters to restore articles.
345 *
346 * @throws UserInputException
347 */
348 public function validateRestore() {
349 $this->validateDelete();
350 }
351
352 /**
353 * Restores articles.
354 */
355 public function restore() {
356 foreach ($this->getObjects() as $articleEditor) {
357 $articleEditor->update(['isDeleted' => 0]);
358 }
359
360 $this->unmarkItems();
361
362 // reset storage
363 if (ARTICLE_ENABLE_VISIT_TRACKING) {
364 UserStorageHandler::getInstance()->resetAll('unreadArticles');
365 }
366
367 return ['objectIDs' => $this->objectIDs];
368 }
369
370 /**
371 * Validates parameters to toggle between i18n and monolingual mode.
372 *
373 * @throws UserInputException
374 */
375 public function validateToggleI18n() {
376 WCF::getSession()->checkPermissions(['admin.content.article.canManageArticle']);
377
378 $this->articleEditor = $this->getSingleObject();
379 if ($this->articleEditor->getDecoratedObject()->isMultilingual) {
380 $this->readInteger('languageID');
381 $this->language = LanguageFactory::getInstance()->getLanguage($this->parameters['languageID']);
382 if ($this->language === null) {
383 throw new UserInputException('languageID');
384 }
385
386 $contents = $this->articleEditor->getArticleContents();
387 if (!isset($contents[$this->language->languageID])) {
388 // there is no content
389 throw new UserInputException('languageID');
390 }
391 }
392 }
393
394 /**
395 * Toggles between i18n and monolingual mode.
396 */
397 public function toggleI18n() {
398 $removeContent = [];
399
400 // i18n -> monolingual
401 if ($this->articleEditor->getDecoratedObject()->isMultilingual) {
402 foreach ($this->articleEditor->getArticleContents() as $articleContent) {
403 if ($articleContent->languageID == $this->language->languageID) {
404 $articleContentEditor = new ArticleContentEditor($articleContent);
405 $articleContentEditor->update(['languageID' => null]);
406 }
407 else {
408 $removeContent[] = $articleContent;
409 }
410 }
411 }
412 else {
413 // monolingual -> i18n
414 $articleContent = $this->articleEditor->getArticleContent();
415 $data = [];
416 foreach (LanguageFactory::getInstance()->getLanguages() as $language) {
417 $data[$language->languageID] = [
418 'title' => $articleContent->title,
419 'teaser' => $articleContent->teaser,
420 'content' => $articleContent->content,
421 'imageID' => $articleContent->imageID ?: null,
422 'teaserImageID' => $articleContent->teaserImageID ?: null
423 ];
424 }
425
426 $action = new ArticleAction([$this->articleEditor], 'update', ['content' => $data]);
427 $action->executeAction();
428
429 $removeContent[] = $articleContent;
430 }
431
432 if (!empty($removeContent)) {
433 $action = new ArticleContentAction($removeContent, 'delete');
434 $action->executeAction();
435 }
436
437 // flush edit history
438 VersionTracker::getInstance()->reset('com.woltlab.wcf.article', $this->articleEditor->getDecoratedObject()->articleID);
439
440 // update article's i18n state
441 $this->articleEditor->update([
442 'isMultilingual' => ($this->articleEditor->getDecoratedObject()->isMultilingual) ? 0 : 1
443 ]);
444 }
445
446 /**
447 * Marks articles as read.
448 */
449 public function markAsRead() {
450 if (empty($this->parameters['visitTime'])) {
451 $this->parameters['visitTime'] = TIME_NOW;
452 }
453
454 if (empty($this->objects)) {
455 $this->readObjects();
456 }
457
458 foreach ($this->getObjects() as $article) {
459 VisitTracker::getInstance()->trackObjectVisit('com.woltlab.wcf.article', $article->articleID, $this->parameters['visitTime']);
460 }
461
462 // reset storage
463 if (WCF::getUser()->userID) {
464 UserStorageHandler::getInstance()->reset([WCF::getUser()->userID], 'unreadArticles');
465 }
466 }
467
468 /**
469 * Marks all articles as read.
470 */
471 public function markAllAsRead() {
472 VisitTracker::getInstance()->trackTypeVisit('com.woltlab.wcf.article');
473
474 // reset storage
475 if (WCF::getUser()->userID) {
476 UserStorageHandler::getInstance()->reset([WCF::getUser()->userID], 'unreadArticles');
477 }
478 }
479
480 /**
481 * Validates the mark all as read action.
482 */
483 public function validateMarkAllAsRead() {
484 // does nothing
485 }
486
487 /**
488 * Validates the `setCategory` action.
489 *
490 * @throws UserInputException
491 */
492 public function validateSetCategory() {
493 WCF::getSession()->checkPermissions(['admin.content.article.canManageArticle']);
494
495 $this->readBoolean('useMarkedArticles', true);
496
497 // if no object ids are given, use clipboard handler
498 if (empty($this->objectIDs) && $this->parameters['useMarkedArticles']) {
499 $this->objectIDs = array_keys(ClipboardHandler::getInstance()->getMarkedItems(ClipboardHandler::getInstance()->getObjectTypeID('com.woltlab.wcf.article')));
500 }
501
502 if (empty($this->objects)) {
503 $this->readObjects();
504
505 if (empty($this->objects)) {
506 throw new UserInputException('objectIDs');
507 }
508 }
509
510 $this->readInteger('categoryID');
511 if (ArticleCategory::getCategory($this->parameters['categoryID']) === null) {
512 throw new UserInputException('categoryID');
513 }
514 }
515
516 /**
517 * Sets the category of articles.
518 */
519 public function setCategory() {
520 foreach ($this->getObjects() as $articleEditor) {
521 $articleEditor->update(['categoryID' => $this->parameters['categoryID']]);
522 }
523
524 $this->unmarkItems();
525 }
526
527 /**
528 * Validates the `publish` action.
529 *
530 * @throws UserInputException
531 */
532 public function validatePublish() {
533 WCF::getSession()->checkPermissions(['admin.content.article.canManageArticle']);
534
535 if (empty($this->objects)) {
536 $this->readObjects();
537
538 if (empty($this->objects)) {
539 throw new UserInputException('objectIDs');
540 }
541 }
542
543 foreach ($this->getObjects() as $article) {
544 if ($article->publicationStatus == Article::PUBLISHED) {
545 throw new UserInputException('objectIDs');
546 }
547 }
548 }
549
550 /**
551 * Publishes articles.
552 */
553 public function publish() {
554 foreach ($this->getObjects() as $articleEditor) {
555 $articleEditor->update([
556 'time' => TIME_NOW,
557 'publicationStatus' => Article::PUBLISHED,
558 'publicationDate' => 0
559 ]);
560 }
561
562 $this->unmarkItems();
563 }
564
565 /**
566 * Validates the `unpublish` action.
567 *
568 * @throws UserInputException
569 */
570 public function validateUnpublish() {
571 WCF::getSession()->checkPermissions(['admin.content.article.canManageArticle']);
572
573 if (empty($this->objects)) {
574 $this->readObjects();
575
576 if (empty($this->objects)) {
577 throw new UserInputException('objectIDs');
578 }
579 }
580
581 foreach ($this->getObjects() as $article) {
582 if ($article->publicationStatus != Article::PUBLISHED) {
583 throw new UserInputException('objectIDs');
584 }
585 }
586 }
587
588 /**
589 * Unpublishes articles.
590 */
591 public function unpublish() {
592 foreach ($this->getObjects() as $articleEditor) {
593 $articleEditor->update(['publicationStatus' => Article::UNPUBLISHED]);
594 }
595
596 $this->unmarkItems();
597 }
598
599 /**
600 * Unmarks articles.
601 *
602 * @param integer[] $articleIDs
603 */
604 protected function unmarkItems(array $articleIDs = []) {
605 if (empty($articleIDs)) {
606 foreach ($this->getObjects() as $article) {
607 $articleIDs[] = $article->articleID;
608 }
609 }
610
611 if (!empty($articleIDs)) {
612 ClipboardHandler::getInstance()->unmark($articleIDs, ClipboardHandler::getInstance()->getObjectTypeID('com.woltlab.wcf.article'));
613 }
614 }
615 }