Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
57.93% covered (warning)
57.93%
325 / 561
37.50% covered (danger)
37.50%
6 / 16
CRAP
0.00% covered (danger)
0.00%
0 / 1
RevisionFormatter
57.93% covered (warning)
57.93%
325 / 561
37.50% covered (danger)
37.50%
6 / 16
2536.78
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 setIncludeHistoryProperties
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setIncludeContent
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setContentFormat
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 formatApi
74.81% covered (warning)
74.81%
98 / 131
0.00% covered (danger)
0.00%
0 / 1
23.18
 serializeUserLinks
83.72% covered (warning)
83.72%
36 / 43
0.00% covered (danger)
0.00%
0 / 1
5.11
 serializeUser
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
4
 getDateFormats
20.00% covered (danger)
20.00%
2 / 10
0.00% covered (danger)
0.00%
0 / 1
4.05
 buildActions
50.38% covered (warning)
50.38%
66 / 131
0.00% covered (danger)
0.00%
0 / 1
453.90
 buildLinks
45.45% covered (danger)
45.45%
40 / 88
0.00% covered (danger)
0.00%
0 / 1
135.70
 buildProperties
15.38% covered (danger)
15.38%
2 / 13
0.00% covered (danger)
0.00%
0 / 1
13.69
 processParam
30.49% covered (danger)
30.49%
25 / 82
0.00% covered (danger)
0.00%
0 / 1
471.30
 msg
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 decideContentFormat
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
4
 decideTopicTitleContentFormat
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
7
 decideNonTopicTitleContentFormat
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
6
1<?php
2
3namespace Flow\Formatter;
4
5use ApiResult;
6use ExtensionRegistry;
7use Flow\Collection\PostCollection;
8use Flow\Conversion\Utils;
9use Flow\Exception\FlowException;
10use Flow\Exception\InvalidInputException;
11use Flow\Exception\PermissionException;
12use Flow\Model\AbstractRevision;
13use Flow\Model\Anchor;
14use Flow\Model\PostRevision;
15use Flow\Model\PostSummary;
16use Flow\Model\UUID;
17use Flow\Repository\UserNameBatch;
18use Flow\RevisionActionPermissions;
19use Flow\Templating;
20use Flow\UrlGenerator;
21use MediaWiki\Cache\GenderCache;
22use MediaWiki\Context\IContextSource;
23use MediaWiki\Logger\LoggerFactory;
24use MediaWiki\MediaWikiServices;
25use MediaWiki\Message\Message;
26use MediaWiki\SpecialPage\SpecialPage;
27use MediaWiki\Title\Title;
28use MediaWiki\User\User;
29use MediaWiki\User\UserGroupManager;
30use MediaWiki\WikiMap\WikiMap;
31use Wikimedia\Timestamp\TimestampException;
32
33/**
34 * This implements a serializer for converting revision objects
35 * into an array of localized and sanitized data ready for user
36 * consumption.
37 *
38 * The formatApi method is the primary method of interacting with
39 * this serializer. The results of formatApi can be passed on to
40 * html formatting or emitted directly as an api response.
41 *
42 * For performance considerations of special purpose formatters like
43 * CheckUser methods that build pieces of the api response are also
44 * public.
45 *
46 * @todo can't output as api yet, Message instances are returned
47 *  for the various strings.
48 *
49 * @todo this needs a better name, RevisionSerializer? not sure yet
50 */
51class RevisionFormatter {
52
53    /**
54     * @var RevisionActionPermissions
55     */
56    protected $permissions;
57
58    /**
59     * @var Templating
60     */
61    protected $templating;
62
63    /**
64     * @var UrlGenerator
65     */
66    protected $urlGenerator;
67
68    /**
69     * @var bool
70     */
71    protected $includeProperties = false;
72
73    /**
74     * @var bool
75     */
76    protected $includeContent = true;
77
78    /**
79     * @var string[] Allowed content formats
80     *
81     *  See setContentFormat.
82     */
83    protected $allowedContentFormats = [ 'html', 'wikitext', 'fixed-html',
84        'topic-title-html', 'topic-title-wikitext' ];
85
86    /**
87     * @var string Default content format for revision output
88     */
89    protected $contentFormat = 'fixed-html';
90
91    /**
92     * @var array Map from alphadecimal revision id to content format override
93     */
94    protected $revisionContentFormat = [];
95
96    /**
97     * @var int
98     */
99    protected $maxThreadingDepth;
100
101    /**
102     * @var Message[]
103     */
104    protected $messages = [];
105
106    /**
107     * @var array
108     */
109    protected $userLinks = [];
110
111    /**
112     * @var UserNameBatch
113     */
114    protected $usernames;
115
116    /**
117     * @var GenderCache
118     */
119    protected $genderCache;
120
121    /**
122     * @var UserGroupManager
123     */
124    protected $userGroupManager;
125
126    /**
127     * @param RevisionActionPermissions $permissions
128     * @param Templating $templating
129     * @param UrlGenerator $urlGenerator
130     * @param UserNameBatch $usernames
131     * @param int $maxThreadingDepth
132     */
133    public function __construct(
134        RevisionActionPermissions $permissions,
135        Templating $templating,
136        UrlGenerator $urlGenerator,
137        UserNameBatch $usernames,
138        $maxThreadingDepth
139    ) {
140        $this->permissions = $permissions;
141        $this->templating = $templating;
142        $this->urlGenerator = $urlGenerator;
143        $this->usernames = $usernames;
144        $this->genderCache = MediaWikiServices::getInstance()->getGenderCache();
145        $this->userGroupManager = MediaWikiServices::getInstance()->getUserGroupManager();
146        $this->maxThreadingDepth = $maxThreadingDepth;
147    }
148
149    /**
150     * The self::buildProperties method is fairly expensive and only used for rendering
151     * history entries.  As such it is optimistically disabled unless requested
152     * here
153     *
154     * @param bool $shouldInclude
155     */
156    public function setIncludeHistoryProperties( $shouldInclude ) {
157        $this->includeProperties = (bool)$shouldInclude;
158    }
159
160    /**
161     * Outputing content can be somehwat expensive, as most of the content is loaded
162     * into DOMDocuemnts for processing of relidlinks and badimages.  Set this to false
163     * if the content will not be used such as for recent changes.
164     * @param bool $shouldInclude
165     */
166    public function setIncludeContent( $shouldInclude ) {
167        $this->includeContent = (bool)$shouldInclude;
168    }
169
170    /**
171     * Sets the content format for all revisions formatted by this formatter, or a
172     * particular revision.
173     *
174     * @param string $format Format to use for revision content.  If no revision ID is
175     *  given, this is a default format, and the allowed formats are 'html', 'wikitext',
176     *  and 'fixed-html'.
177     *
178     *  For the default format, 'fixed-html' will be converted to 'topic-title-html'
179     *  when formatting a topic title.  'html' and 'wikitext' will be converted to
180     *  'topic-title-wikitext' for topic titles (because 'html' and 'wikitext' are
181     *  editable, and 'topic-title-html' is not editable).
182     *
183     *  If a revision ID is given, the allowed formats are 'html', 'wikitext',
184     *  'fixed-html', 'topic-title-html', and 'topic-title-wikitext'.  However, the
185     *  format will not be converted, and must be valid for the given revision ('html',
186     *  'wikitext', and 'fixed-html' are valid only for non-topic titles.
187     *  'topic-title-html' and 'topic-title-wikitext' are only valid for topic titles.
188     *  Otherwise, an exception will be thrown later.
189     * @param UUID|null $revisionId Revision ID this format applies for.
190     * @throws FlowException
191     * @throws InvalidInputException
192     */
193    public function setContentFormat( $format, UUID $revisionId = null ) {
194        if ( !in_array( $format, $this->allowedContentFormats ) ) {
195            throw new InvalidInputException( "Unknown content format: $format" );
196        }
197        if ( $revisionId === null ) {
198            // set default content format
199            $this->contentFormat = $format;
200        } else {
201            // set per-revision content format
202            $this->revisionContentFormat[$revisionId->getAlphadecimal()] = $format;
203        }
204    }
205
206    /**
207     * @param FormatterRow $row
208     * @param IContextSource $ctx
209     * @param string $action action from FlowActions
210     * @return array|bool
211     * @throws FlowException
212     * @throws PermissionException
213     * @throws \Exception
214     * @throws \Flow\Exception\InvalidInputException
215     * @throws TimestampException
216     * @suppress PhanUndeclaredMethod Phan doesn't infer types from the instanceofs
217     */
218    public function formatApi( FormatterRow $row, IContextSource $ctx, $action = 'view' ) {
219        $this->permissions->setUser( $ctx->getUser() );
220
221        if ( !$this->permissions->isAllowed( $row->revision, $action ) ) {
222            LoggerFactory::getInstance( 'Flow' )->debug(
223                __METHOD__ . ': Permission denied for user on action {action}',
224                [
225                    'action' => $action,
226                    'revision_id' => $row->revision->getRevisionId()->getAlphadecimal(),
227                    'user_id' => $ctx->getUser()->getId(),
228                ]
229            );
230            return false;
231        }
232
233        $moderatedRevision = $this->templating->getModeratedRevision( $row->revision );
234        $ts = $row->revision->getRevisionId()->getTimestampObj();
235        $res = [
236            ApiResult::META_BC_BOOLS => [
237                'isOriginalContent',
238                'isModerated',
239                'isLocked',
240                'isModeratedNotLocked',
241            ],
242            'workflowId' => $row->workflow->getId()->getAlphadecimal(),
243            'articleTitle' => $row->workflow->getArticleTitle()->getPrefixedText(),
244            'revisionId' => $row->revision->getRevisionId()->getAlphadecimal(),
245            'timestamp' => $ts->getTimestamp( TS_MW ),
246            'changeType' => $row->revision->getChangeType(),
247            // @todo push all date formatting to the render side?
248            'dateFormats' => $this->getDateFormats( $row->revision, $ctx ),
249            'properties' => $this->buildProperties( $row->workflow->getId(), $row->revision, $ctx, $row ),
250            'isOriginalContent' => $row->revision->isOriginalContent(),
251            'isModerated' => $moderatedRevision->isModerated(),
252            // These are read urls
253            'links' => $this->buildLinks( $row ),
254            // These are write urls
255            'actions' => $this->buildActions( $row ),
256            'size' => [
257                'old' => $row->revision->getPreviousContentLength(),
258                'new' => $row->revision->getContentLength(),
259            ],
260            'author' => $this->serializeUser(
261                $row->revision->getUserWiki(),
262                $row->revision->getUserId(),
263                $row->revision->getUserIp()
264            ),
265            'lastEditUser' => $this->serializeUser(
266                $row->revision->getLastContentEditUserWiki(),
267                $row->revision->getLastContentEditUserId(),
268                $row->revision->getLastContentEditUserIp()
269            ),
270            'lastEditId' => $row->revision->isOriginalContent()
271                ? null : $row->revision->getLastContentEditId()->getAlphadecimal(),
272            'previousRevisionId' => $row->revision->isFirstRevision()
273                ? null
274                : $row->revision->getPrevRevisionId()->getAlphadecimal(),
275        ];
276
277        if ( $res['isModerated'] ) {
278            $res['moderator'] = $this->serializeUser(
279                $moderatedRevision->getModeratedByUserWiki(),
280                $moderatedRevision->getModeratedByUserId(),
281                $moderatedRevision->getModeratedByUserIp()
282            );
283            // @todo why moderate instead of moderated or something else?
284            $res['moderateState'] = $moderatedRevision->getModerationState();
285            $res['moderateReason'] = [
286                'content' => $moderatedRevision->getModeratedReason(),
287                'format' => 'plaintext',
288            ];
289            $res['isLocked'] = $moderatedRevision->isLocked();
290        } else {
291            $res['isLocked'] = false;
292        }
293        // to avoid doing this check in handlebars
294        $res['isModeratedNotLocked'] = $moderatedRevision->isModerated() && !$moderatedRevision->isLocked();
295
296        if ( $this->includeContent ) {
297            $contentFormat = $this->decideContentFormat( $row->revision );
298
299            // @todo better name?
300            $res['content'] = [
301                'content' => $this->templating->getContent( $row->revision, $contentFormat ),
302                'format' => $contentFormat
303            ];
304        }
305
306        if ( $row instanceof TopicRow ) {
307            $res[ApiResult::META_BC_BOOLS] = array_merge(
308                $res[ApiResult::META_BC_BOOLS],
309                [
310                    'isWatched',
311                    'watchable',
312                ]
313            );
314            if ( $row->summary ) {
315                $summary = $this->formatApi( $row->summary, $ctx, $action );
316                if ( $summary ) {
317                    $res['summary'] = [
318                        'revision' => $summary,
319                    ];
320                }
321            }
322
323            // Only non-anon users can watch/unwatch a flow topic
324            // isWatched - the topic is watched by current user
325            // watchable - the user could watch the topic, eg, anon-user can't watch a topic
326            if ( $ctx->getUser()->isRegistered() ) {
327                // default topic is not watched and topic is not always watched
328                $res['isWatched'] = (bool)$row->isWatched;
329                $res['watchable'] = true;
330            } else {
331                $res['watchable'] = false;
332            }
333        }
334
335        if ( $row->revision instanceof PostRevision ) {
336            $res[ApiResult::META_BC_BOOLS] = array_merge(
337                $res[ApiResult::META_BC_BOOLS],
338                [
339                    'isMaxThreadingDepth',
340                    'isNewPage',
341                ]
342            );
343
344            $replyTo = $row->revision->getReplyToId();
345            $res['replyToId'] = $replyTo ? $replyTo->getAlphadecimal() : null;
346            $res['postId'] = $row->revision->getPostId()->getAlphadecimal();
347            $res['isMaxThreadingDepth'] = $row->revision->getDepth() >= $this->maxThreadingDepth;
348            $res['creator'] = $this->serializeUser(
349                $row->revision->getCreatorWiki(),
350                $row->revision->getCreatorId(),
351                $row->revision->getCreatorIp()
352            );
353
354            // Always output this along with topic titles so they
355            // have a safe parameter to use within l10n for content
356            // output.
357            if ( $row->revision->isTopicTitle() && !isset( $res['properties']['topic-of-post'] ) ) {
358                $res['properties']['topic-of-post'] = $this->processParam(
359                    'topic-of-post',
360                    $row->revision,
361                    $row->workflow->getId(),
362                    $ctx,
363                    $row
364                );
365
366                $res['properties']['topic-of-post-text-from-html'] = $this->processParam(
367                    'topic-of-post-text-from-html',
368                    $row->revision,
369                    $row->workflow->getId(),
370                    $ctx,
371                    $row
372                );
373
374                // moderated posts won't have that property
375                if ( isset( $res['properties']['topic-of-post-text-from-html']['plaintext'] ) ) {
376                    $res['content']['plaintext'] =
377                        $res['properties']['topic-of-post-text-from-html']['plaintext'];
378                }
379            }
380
381            $res['isNewPage'] = $row->isFirstReply && $row->revision->isFirstRevision();
382
383        } elseif ( $row->revision instanceof PostSummary ) {
384            $res['creator'] = $this->serializeUser(
385                $row->revision->getCreatorWiki(),
386                $row->revision->getCreatorId(),
387                $row->revision->getCreatorIp()
388            );
389        }
390
391        return $res;
392    }
393
394    /**
395     * @param array $userData Contains `name`, `wiki`, and `gender` keys
396     * @return array
397     */
398    public function serializeUserLinks( $userData ) {
399        $name = $userData['name'];
400        if ( isset( $this->userLinks[$name] ) ) {
401            return $this->userLinks[$name];
402        }
403
404        $talkPageTitle = null;
405        $userTitle = Title::newFromText( $name, NS_USER );
406        if ( $userTitle ) {
407            $talkPageTitle = $userTitle->getTalkPage();
408        }
409
410        $blockTitle = SpecialPage::getTitleFor( 'Block', $name );
411
412        $userContribsTitle = SpecialPage::getTitleFor( 'Contributions', $name );
413        $userLinksBCBools = [
414            '_BC_bools' => [
415                'exists',
416            ],
417        ];
418        $links = [
419            'contribs' => [
420                'url' => $userContribsTitle->getLinkURL(),
421                'title' => $userContribsTitle->getText(),
422                'exists' => true,
423            ] + $userLinksBCBools,
424            'userpage' => [
425                'url' => $userTitle->getLinkURL(),
426                'title' => $userTitle->getText(),
427                'exists' => $userTitle->isKnown(),
428            ] + $userLinksBCBools,
429        ];
430
431        if ( $talkPageTitle ) {
432            $links['talk'] = [
433                'url' => $talkPageTitle->getLinkURL(),
434                'title' => $talkPageTitle->getPrefixedText(),
435                'exists' => $talkPageTitle->isKnown()
436            ] + $userLinksBCBools;
437        }
438        // is this right permissions? typically this would
439        // be sourced from Linker::userToolLinks, but that
440        // only undertands html strings.
441        if ( MediaWikiServices::getInstance()->getPermissionManager()
442                ->userHasRight( $this->permissions->getUser(), 'block' )
443        ) {
444            // only is the user has blocking rights
445            $links += [
446                "block" => [
447                    'url' => $blockTitle->getLinkURL(),
448                    'title' => wfMessage( 'blocklink' ),
449                    'exists' => true
450                ] + $userLinksBCBools,
451            ];
452        }
453
454        $this->userLinks[$name] = $links;
455        return $this->userLinks[$name];
456    }
457
458    public function serializeUser( $userWiki, $userId, $userIp ) {
459        $res = [
460            'name' => $this->usernames->get( $userWiki, $userId, $userIp ),
461            'wiki' => $userWiki,
462            'gender' => 'unknown',
463            'links' => [],
464            'id' => $userId
465        ];
466        // Only works for the local wiki
467        if ( $res['name'] && WikiMap::getCurrentWikiId() === $userWiki ) {
468            $res['gender'] = $this->genderCache->getGenderOf( $res['name'], __METHOD__ );
469        }
470        if ( $res['name'] ) {
471            $res['links'] = $this->serializeUserLinks( $res );
472        }
473
474        return $res;
475    }
476
477    /**
478     * @param AbstractRevision $revision
479     * @param IContextSource $ctx
480     * @return string[] Contains [timeAndDate, date, time]
481     */
482    public function getDateFormats( AbstractRevision $revision, IContextSource $ctx ) {
483        // also restricted to history
484        if ( $this->includeProperties === false ) {
485            return [];
486        }
487
488        $timestamp = $revision->getRevisionId()->getTimestampObj()->getTimestamp( TS_MW );
489        $user = $ctx->getUser();
490        $lang = $ctx->getLanguage();
491
492        return [
493            'timeAndDate' => $lang->userTimeAndDate( $timestamp, $user ),
494            'date' => $lang->userDate( $timestamp, $user ),
495            'time' => $lang->userTime( $timestamp, $user ),
496        ];
497    }
498
499    /**
500     * @param FormatterRow $row
501     * @return array
502     * @throws FlowException
503     */
504    public function buildActions( FormatterRow $row ) {
505        global $wgThanksSendToBots;
506
507        $user = $this->permissions->getUser();
508        $workflow = $row->workflow;
509        $title = $workflow->getArticleTitle();
510
511        // If a user does not have rights to perform actions on this page return
512        // an empty array of actions.
513        if ( !$workflow->userCan( 'edit', $user ) ) {
514            return [];
515        }
516
517        $revision = $row->revision;
518        $action = $revision->getChangeType();
519        $workflowId = $workflow->getId();
520        $revId = $revision->getRevisionId();
521        // @phan-suppress-next-line PhanUndeclaredMethod Checks method_exists
522        $postId = method_exists( $revision, 'getPostId' ) ? $revision->getPostId() : null;
523        $actionTypes = $this->permissions->getActions()->getValue( $action, 'actions' );
524        if ( $actionTypes === null ) {
525            wfDebugLog( 'Flow', __METHOD__ . ": No actions defined for action: $action" );
526            return [];
527        }
528
529        // actions primarily vary by revision type...
530        $links = [];
531        foreach ( $actionTypes as $type ) {
532            if ( !$this->permissions->isAllowed( $revision, $type ) ) {
533                continue;
534            }
535            switch ( $type ) {
536                case 'thank':
537                    $targetedUser = User::newFromId( $revision->getCreatorId() );
538                    if (
539                        // thanks extension must be available
540                        ExtensionRegistry::getInstance()->isLoaded( 'Thanks' ) &&
541                        // anons can't give a thank
542                        $user->isRegistered() &&
543                        // can only thank for PostRevisions
544                        // (other revision objects have no getCreator* methods)
545                        $revision instanceof PostRevision &&
546                        // only thank a logged in user
547                        $targetedUser->isRegistered() &&
548                        // can't thank self
549                        $user->getId() !== $revision->getCreatorId() &&
550                        // can't thank bots
551                        !( !$wgThanksSendToBots && in_array( 'bot', $this->userGroupManager->getUserGroups( $targetedUser ) ) )
552                    ) {
553                        $links['thank'] = $this->urlGenerator->thankAction( $postId );
554                    }
555                    break;
556
557                case 'reply':
558                    if ( !$postId ) {
559                        throw new FlowException( "$type called without \$postId" );
560                    } elseif ( !$revision instanceof PostRevision ) {
561                        throw new FlowException( "$type called without PostRevision object" );
562                    }
563
564                    /*
565                     * If the post being replied to is the most recent post
566                     * of its depth, the reply link should point to parent
567                     */
568                    $replyToId = $postId;
569                    $replyToRevision = $revision;
570                    if ( $row->isLastReply ) {
571                        $replyToId = $replyToRevision->getReplyToId();
572                        $replyToRevision = PostCollection::newFromId( $replyToId )->getLastRevision();
573                    }
574
575                    /*
576                     * If the post being replied to is at or exceeds the max
577                     * threading depth, the reply link should point to parent.
578                     */
579                    while ( $replyToRevision->getDepth() >= $this->maxThreadingDepth ) {
580                        $replyToId = $replyToRevision->getReplyToId();
581                        $replyToRevision = PostCollection::newFromId( $replyToId )->getLastRevision();
582                    }
583
584                    $links['reply'] = $this->urlGenerator->replyAction(
585                        $title,
586                        $workflowId,
587                        $replyToId,
588                        $revision->isTopicTitle()
589                    );
590                    break;
591
592                case 'edit-header':
593                    $links['edit'] = $this->urlGenerator->editHeaderAction( $title, $workflowId, $revId );
594                    break;
595
596                case 'edit-title':
597                    if ( !$postId ) {
598                        throw new FlowException( "$type called without \$postId" );
599                    }
600                    $links['edit'] = $this->urlGenerator
601                        ->editTitleAction( $title, $workflowId, $postId, $revId );
602                    break;
603
604                case 'edit-post':
605                    if ( !$postId ) {
606                        throw new FlowException( "$type called without \$postId" );
607                    }
608                    $links['edit'] = $this->urlGenerator
609                        ->editPostAction( $title, $workflowId, $postId, $revId );
610                    break;
611
612                case 'undo-edit-header':
613                case 'undo-edit-post':
614                case 'undo-edit-topic-summary':
615                    if ( !$revision->isFirstRevision() ) {
616                        $links['undo'] = $this->urlGenerator->undoAction( $revision, $title, $workflowId );
617                    }
618                    break;
619
620                case 'hide-post':
621                    if ( !$postId ) {
622                        throw new FlowException( "$type called without \$postId" );
623                    }
624                    $links['hide'] = $this->urlGenerator->hidePostAction( $title, $workflowId, $postId );
625                    break;
626
627                case 'delete-topic':
628                    $links['delete'] = $this->urlGenerator->deleteTopicAction( $title, $workflowId );
629                    break;
630
631                case 'delete-post':
632                    if ( !$postId ) {
633                        throw new FlowException( "$type called without \$postId" );
634                    }
635                    $links['delete'] = $this->urlGenerator->deletePostAction( $title, $workflowId, $postId );
636                    break;
637
638                case 'suppress-topic':
639                    $links['suppress'] = $this->urlGenerator->suppressTopicAction( $title, $workflowId );
640                    break;
641
642                case 'suppress-post':
643                    if ( !$postId ) {
644                        throw new FlowException( "$type called without \$postId" );
645                    }
646                    $links['suppress'] = $this->urlGenerator->suppressPostAction( $title, $workflowId, $postId );
647                    break;
648
649                case 'lock-topic':
650                    // lock topic link is only available to topics
651                    if ( !$revision instanceof PostRevision || !$revision->isTopicTitle() ) {
652                        break;
653                    }
654
655                    $links['lock'] = $this->urlGenerator->lockTopicAction( $title, $workflowId );
656                    break;
657
658                case 'restore-topic':
659                    $moderateAction = $flowAction = null;
660                    switch ( $revision->getModerationState() ) {
661                        case AbstractRevision::MODERATED_LOCKED:
662                            $moderateAction = 'unlock';
663                            $flowAction = 'lock-topic';
664                            break;
665                        case AbstractRevision::MODERATED_HIDDEN:
666                        case AbstractRevision::MODERATED_DELETED:
667                        case AbstractRevision::MODERATED_SUPPRESSED:
668                            $moderateAction = 'un' . $revision->getModerationState();
669                            $flowAction = 'moderate-topic';
670                            break;
671                    }
672                    if ( $moderateAction && $flowAction ) {
673                        $links[$moderateAction] = $this->urlGenerator->restoreTopicAction(
674                            $title, $workflowId, $moderateAction, $flowAction );
675                    }
676                    break;
677
678                case 'restore-post':
679                    if ( !$postId ) {
680                        throw new FlowException( "$type called without \$postId" );
681                    }
682                    $moderateAction = $flowAction = null;
683                    switch ( $revision->getModerationState() ) {
684                        case AbstractRevision::MODERATED_HIDDEN:
685                        case AbstractRevision::MODERATED_DELETED:
686                        case AbstractRevision::MODERATED_SUPPRESSED:
687                            $moderateAction = 'un' . $revision->getModerationState();
688                            $flowAction = 'moderate-post';
689                            break;
690                    }
691                    if ( $moderateAction && $flowAction ) {
692                        $links[$moderateAction] = $this->urlGenerator->restorePostAction(
693                            $title, $workflowId, $postId, $moderateAction, $flowAction );
694                    }
695                    break;
696
697                case 'hide-topic':
698                    $links['hide'] = $this->urlGenerator->hideTopicAction( $title, $workflowId );
699                    break;
700
701                // Need to use 'edit-topic-summary' to match FlowActions
702                case 'edit-topic-summary':
703                    // summarize link is only available to topic workflow
704                    if ( !in_array( $workflow->getType(), [ 'topic', 'topicsummary' ] ) ) {
705                        break;
706                    }
707                    $links['summarize'] = $this->urlGenerator->editTopicSummaryAction( $title, $workflowId );
708                    break;
709
710                default:
711                    wfDebugLog( 'Flow', __METHOD__ . ': unkown action link type: ' . $type );
712                    break;
713            }
714        }
715
716        return $links;
717    }
718
719    /**
720     * @param FormatterRow $row
721     * @return Anchor[]
722     * @throws FlowException
723     */
724    public function buildLinks( FormatterRow $row ) {
725        $workflow = $row->workflow;
726        $revision = $row->revision;
727        $title = $workflow->getArticleTitle();
728        $action = $revision->getChangeType();
729        $workflowId = $workflow->getId();
730        $revId = $revision->getRevisionId();
731        // @phan-suppress-next-line PhanUndeclaredMethod Checks method_exists
732        $postId = method_exists( $revision, 'getPostId' ) ? $revision->getPostId() : null;
733
734        $linkTypes = $this->permissions->getActions()->getValue( $action, 'links' );
735        if ( $linkTypes === null ) {
736            wfDebugLog( 'Flow', __METHOD__ . ": No links defined for action: $action" );
737            return [];
738        }
739
740        $links = [];
741        $diffCallback = null;
742        foreach ( $linkTypes as $type ) {
743            switch ( $type ) {
744                case 'watch-topic':
745                    $links['watch-topic'] = $this->urlGenerator->watchTopicLink( $title, $workflowId );
746                    break;
747
748                case 'unwatch-topic':
749                    $links['unwatch-topic'] = $this->urlGenerator->unwatchTopicLink( $title, $workflowId );
750                    break;
751
752                case 'topic':
753                    $links['topic'] = $this->urlGenerator->topicLink( $title, $workflowId );
754                    break;
755
756                case 'post':
757                    if ( !$postId ) {
758                        wfDebugLog( 'Flow', __METHOD__ . ': No postId available to render post link' );
759                        break;
760                    }
761                    $links['post'] = $this->urlGenerator->postLink( $title, $workflowId, $postId );
762                    break;
763
764                case 'header-revision':
765                    $links['header-revision'] = $this->urlGenerator
766                        ->headerRevisionLink( $title, $workflowId, $revId );
767                    break;
768
769                case 'topic-revision':
770                    if ( !$postId ) {
771                        wfDebugLog( 'Flow', __METHOD__ . ': No postId available to render revision link' );
772                        break;
773                    }
774
775                    $links['topic-revision'] = $this->urlGenerator
776                        ->topicRevisionLink( $title, $workflowId, $revId );
777                    break;
778
779                case 'post-revision':
780                    if ( !$postId ) {
781                        wfDebugLog( 'Flow', __METHOD__ . ': No postId available to render revision link' );
782                        break;
783                    }
784
785                    $links['post-revision'] = $this->urlGenerator
786                        ->postRevisionLink( $title, $workflowId, $postId, $revId );
787                    break;
788
789                case 'summary-revision':
790                    $links['summary-revision'] = $this->urlGenerator
791                        ->summaryRevisionLink( $title, $workflowId, $revId );
792                    break;
793
794                case 'post-history':
795                    if ( !$postId ) {
796                        wfDebugLog( 'Flow', __METHOD__ . ': No postId available to render post-history link' );
797                        break;
798                    }
799                    $links['post-history'] = $this->urlGenerator->postHistoryLink( $title, $workflowId, $postId );
800                    break;
801
802                case 'topic-history':
803                    $links['topic-history'] = $this->urlGenerator->workflowHistoryLink( $title, $workflowId );
804                    break;
805
806                case 'board-history':
807                    $links['board-history'] = $this->urlGenerator->boardHistoryLink( $title );
808                    break;
809
810                case 'diff-header':
811                    $diffCallback ??= [ $this->urlGenerator, 'diffHeaderLink' ];
812                    // don't break, diff links are rendered below
813                case 'diff-post':
814                    $diffCallback ??= [ $this->urlGenerator, 'diffPostLink' ];
815                    // don't break, diff links are rendered below
816                case 'diff-post-summary':
817                    $diffCallback ??= [ $this->urlGenerator, 'diffSummaryLink' ];
818
819                    /*
820                     * To diff against previous revision, we don't really need that
821                     * revision id; if no particular diff id is specified, it will
822                     * assume a diff against previous revision. However, we do want
823                     * to make sure that a previous revision actually exists to diff
824                     * against. This could result in a network request (fetching the
825                     * current revision), but it's likely being loaded anyways.
826                     */
827                    if ( $revision->getPrevRevisionId() !== null ) {
828                        $links['diff'] = $diffCallback( $title, $workflowId, $revId );
829
830                        /*
831                         * Different formatters have different terminology for the link
832                         * that diffs a certain revision to the previous revision.
833                         *
834                         * E.g.: Special:Contributions has "diff" ($links['diff']),
835                         * ?action=history has "prev" ($links['prev']).
836                         */
837                        $links['diff-prev'] = clone $links['diff'];
838                        $lastMsg = new Message( 'last' );
839                        $links['diff-prev']->setTitleMessage( $lastMsg );
840                        $links['diff-prev']->setMessage( $lastMsg );
841                    }
842
843                    /*
844                     * To diff against the current revision, we need to know the id
845                     * of this last revision. This could be an additional network
846                     * request, though anything using formatter likely already needs
847                     * to request the most current revision (e.g. to check
848                     * permissions) so we should be able to get it from local cache.
849                     */
850                    $cur = $row->currentRevision;
851                    if ( !$revId->equals( $cur->getRevisionId() ) ) {
852                        $links['diff-cur'] = $diffCallback( $title, $workflowId, $cur->getRevisionId(), $revId );
853                        $curMsg = new Message( 'cur' );
854                        $links['diff-cur']->setTitleMessage( $curMsg );
855                        $links['diff-cur']->setMessage( $curMsg );
856                    }
857                    break;
858
859                case 'workflow':
860                    $links['workflow'] = $this->urlGenerator->workflowLink( $title, $workflowId );
861                    break;
862
863                default:
864                    wfDebugLog( 'Flow', __METHOD__ . ': unkown action link type: ' . $type );
865                    break;
866            }
867        }
868
869        return $links;
870    }
871
872    /**
873     * Build api properties defined in FlowActions for this change type
874     *
875     * This is a fairly expensive function(compared to the other methods in this class).
876     * As such its only output when specifically requested
877     *
878     * @param UUID $workflowId
879     * @param AbstractRevision $revision
880     * @param IContextSource $ctx
881     * @param FormatterRow|null $row
882     * @return array
883     */
884    public function buildProperties(
885        UUID $workflowId,
886        AbstractRevision $revision,
887        IContextSource $ctx,
888        FormatterRow $row = null
889    ) {
890        if ( $this->includeProperties === false ) {
891            return [];
892        }
893
894        $changeType = $revision->getChangeType();
895        $actions = $this->permissions->getActions();
896        $params = $actions->getValue( $changeType, 'history', 'i18n-params' );
897        if ( !$params ) {
898            // should we have a sigil for i18n with no parameters?
899            wfDebugLog( 'Flow', __METHOD__ . ": No i18n params for changeType $changeType on " .
900                $revision->getRevisionId()->getAlphadecimal() );
901            return [];
902        }
903
904        $res = [ '_key' => $actions->getValue( $changeType, 'history', 'i18n-message' ) ];
905        foreach ( $params as $param ) {
906            $res[$param] = $this->processParam( $param, $revision, $workflowId, $ctx, $row );
907        }
908
909        return $res;
910    }
911
912    /**
913     * Mimic Echo parameter formatting
914     *
915     * @param string $param The requested i18n parameter
916     * @param AbstractRevision|AbstractRevision[] $revision The revision or
917     *  revisions to format or an array of revisions
918     * @param UUID $workflowId The UUID of the workflow $revision belongs tow
919     * @param IContextSource $ctx
920     * @param FormatterRow|null $row
921     * @return mixed A valid parameter for a core Message instance. These
922     *  parameters will be used with Message::parse
923     * @throws FlowException
924     */
925    public function processParam(
926        $param,
927        $revision,
928        UUID $workflowId,
929        IContextSource $ctx,
930        FormatterRow $row = null
931    ) {
932        $isWikiText = str_ends_with( $param, 'wikitext' );
933        $format = $isWikiText ? $revision->getWikitextFormat() : $revision->getHtmlFormat();
934
935        switch ( $param ) {
936            case 'creator-text':
937                if ( $revision instanceof PostRevision ) {
938                    return $this->usernames->getFromTuple( $revision->getCreatorTuple() );
939                } else {
940                    return '';
941                }
942
943            case 'user-text':
944                return $this->usernames->getFromTuple( $revision->getUserTuple() );
945
946            case 'user-links':
947                return Message::rawParam( $this->templating->getUserLinks( $revision ) );
948
949            case 'summary':
950                if ( !$this->permissions->isAllowed( $revision, 'view' ) ) {
951                    return '';
952                }
953
954                /*
955                 * Fetch in HTML; unparsed wikitext in summary is pointless.
956                 * Larger-scale wikis will likely also store content in html, so no
957                 * Parsoid roundtrip is needed then (and if it *is*, it'll already
958                 * be needed to render Flow discussions, so this is manageable)
959                 */
960                $content = $this->templating->getContent( $revision, 'fixed-html' );
961                // strip html tags and decode to plaintext
962                $content = Utils::htmlToPlaintext( $content, 140, $ctx->getLanguage() );
963                return Message::plaintextParam( $content );
964
965            case 'wikitext':
966            case 'plaintext':
967                if ( !$this->permissions->isAllowed( $revision, 'view' ) ) {
968                    return '';
969                }
970
971                $content = $this->templating->getContent( $revision, $format );
972                if ( !$isWikiText ) {
973                    $content = Utils::htmlToPlaintext( $content );
974                }
975                // This must be escaped and marked raw to prevent special chars in
976                // content, like $1, from changing the i18n result
977                return Message::plaintextParam( $content );
978
979            // This is potentially two networked round trips, much too expensive for
980            // the rendering loop
981            case 'prev-wikitext':
982            case 'prev-plaintext':
983                if ( $revision->isFirstRevision() ) {
984                    return '';
985                }
986                if ( $row === null ) {
987                    $previousRevision = $revision->getCollection()->getPrevRevision( $revision );
988                } else {
989                    $previousRevision = $row->previousRevision;
990                }
991                if ( !$previousRevision ) {
992                    return '';
993                }
994                if ( !$this->permissions->isAllowed( $previousRevision, 'view' ) ) {
995                    return '';
996                }
997
998                $content = $this->templating->getContent( $previousRevision, $format );
999                if ( !$isWikiText ) {
1000                    $content = Utils::htmlToPlaintext( $content );
1001                }
1002                return Message::plaintextParam( $content );
1003
1004            case 'workflow-url':
1005                return $this->urlGenerator
1006                    ->workflowLink( null, $workflowId )
1007                    ->getFullURL();
1008
1009            case 'post-url':
1010                if ( !$revision instanceof PostRevision ) {
1011                    throw new FlowException( 'Expected PostRevision but received' . get_class( $revision ) );
1012                }
1013                return $this->urlGenerator
1014                    ->postLink( null, $workflowId, $revision->getPostId() )
1015                    ->getFullURL();
1016
1017            case 'moderated-reason':
1018                // don't parse wikitext in the moderation reason
1019                return Message::plaintextParam( $revision->getModeratedReason() ?? '' );
1020
1021            case 'topic-of-post':
1022                if ( !$revision instanceof PostRevision ) {
1023                    throw new FlowException( 'Expected PostRevision but received ' . get_class( $revision ) );
1024                }
1025
1026                $root = $revision->getRootPost();
1027                if ( !$this->permissions->isAllowed( $root, 'view-topic-title' ) ) {
1028                    return '';
1029                }
1030
1031                $content = $this->templating->getContent( $root, 'topic-title-wikitext' );
1032
1033                // TODO: We need to use plaintextParam or similar to avoid parsing,
1034                // but the API output says "plaintext", which is confusing and
1035                // should be fixed.  From the API consumer's perspective, it's
1036                // topic-title-wikitext.
1037                return Message::plaintextParam( $content );
1038
1039            // Strip the tags from the HTML version to produce text:
1040            // [[Red link 3]], [[Adrines]], [[Media:Earth.jpg]], http://example.com =>
1041            // Red link 3, Adrines, Media:Earth.jpg, http://example.com
1042            case 'topic-of-post-text-from-html':
1043                if ( !$revision instanceof PostRevision ) {
1044                    throw new FlowException( 'Expected PostRevision but received ' . get_class( $revision ) );
1045                }
1046
1047                $root = $revision->getRootPost();
1048                if ( !$this->permissions->isAllowed( $root, 'view-topic-title' ) ) {
1049                    return '';
1050                }
1051
1052                $content = $this->templating->getContent( $root, 'topic-title-plaintext' );
1053
1054                return Message::plaintextParam( $content );
1055
1056            case 'post-of-summary':
1057                if ( !$revision instanceof PostSummary ) {
1058                    throw new FlowException( 'Expected PostSummary but received ' . get_class( $revision ) );
1059                }
1060
1061                /** @var PostRevision $post */
1062                $post = $revision->getCollection()->getPost()->getLastRevision();
1063                // @phan-suppress-next-line PhanUndeclaredMethod Type not correctly inferred
1064                $permissionAction = $post->isTopicTitle() ? 'view-topic-title' : 'view';
1065                if ( !$this->permissions->isAllowed( $post, $permissionAction ) ) {
1066                    return '';
1067                }
1068
1069                // @phan-suppress-next-line PhanUndeclaredMethod Type not correctly inferred
1070                if ( $post->isTopicTitle() ) {
1071                    return Message::plaintextParam( $this->templating->getContent(
1072                        $post, 'topic-title-plaintext' ) );
1073                } else {
1074                    return Message::rawParam( $this->templating->getContent( $post, 'fixed-html' ) );
1075                }
1076
1077            case 'bundle-count':
1078                return Message::numParam( count( $revision ) );
1079
1080            default:
1081                wfWarn( __METHOD__ . ': Unknown formatter parameter: ' . $param );
1082                return '';
1083        }
1084    }
1085
1086    protected function msg( $key, ...$params ) {
1087        if ( $params ) {
1088            return wfMessage( $key, ...$params );
1089        }
1090        if ( !isset( $this->messages[$key] ) ) {
1091            $this->messages[$key] = new Message( $key );
1092        }
1093        return $this->messages[$key];
1094    }
1095
1096    /**
1097     * Determines the exact output content format, given the requested content format
1098     * and the revision type.
1099     *
1100     * @param AbstractRevision $revision
1101     * @return string Content format
1102     * @throws FlowException If a per-revision format was given and it is
1103     *  invalid for the revision type (topic title/non-topic title).
1104     */
1105    public function decideContentFormat( AbstractRevision $revision ) {
1106        $requestedRevFormat = null;
1107        $requestedDefaultFormat = null;
1108
1109        $alpha = $revision->getRevisionId()->getAlphadecimal();
1110        if ( isset( $this->revisionContentFormat[$alpha] ) ) {
1111            $requestedRevFormat = $this->revisionContentFormat[$alpha];
1112        } else {
1113            $requestedDefaultFormat = $this->contentFormat;
1114        }
1115
1116        if ( $revision instanceof PostRevision && $revision->isTopicTitle() ) {
1117            return $this->decideTopicTitleContentFormat(
1118                $revision, $requestedRevFormat, $requestedDefaultFormat );
1119        } else {
1120            return $this->decideNonTopicTitleContentFormat(
1121                $revision, $requestedRevFormat, $requestedDefaultFormat );
1122        }
1123    }
1124
1125    /**
1126     * Decide the content format for a topic title
1127     *
1128     * @param PostRevision $topicTitle Topic title revision
1129     * @param string|null $requestedRevFormat Format requested for this specific revision
1130     * @param string|null $requestedDefaultFormat Default format requested
1131     * @return string
1132     * @throws FlowException If a per-revision format was given and it is
1133     *  invalid for topic titles.
1134     */
1135    protected function decideTopicTitleContentFormat(
1136        PostRevision $topicTitle,
1137        $requestedRevFormat,
1138        $requestedDefaultFormat
1139    ) {
1140        if ( $requestedRevFormat !== null ) {
1141            if ( $requestedRevFormat !== 'topic-title-html' &&
1142                $requestedRevFormat !== 'topic-title-wikitext'
1143            ) {
1144                throw new FlowException( 'Per-revision format for a topic title must be ' .
1145                    '\'topic-title-html\' or \'topic-title-wikitext\'' );
1146            }
1147            return $requestedRevFormat;
1148        } else {
1149            // Since this is a default format, we'll canonicalize it.
1150
1151            // Because these are both editable formats, and this is the only
1152            // editable topic title format.
1153            if ( $requestedDefaultFormat === 'topic-title-wikitext' || $requestedDefaultFormat === 'html' ||
1154                $requestedDefaultFormat === 'wikitext'
1155            ) {
1156                return 'topic-title-wikitext';
1157            } else {
1158                return 'topic-title-html';
1159            }
1160        }
1161    }
1162
1163    /**
1164     * Decide the content format for revisions other than topic titles
1165     *
1166     * @param AbstractRevision $revision Revision to decide format for
1167     * @param string|null $requestedRevFormat Format requested for this specific revision
1168     * @param string|null $requestedDefaultFormat Default format requested
1169     * @return string
1170     * @throws FlowException If a per-revision format was given and it is
1171     *  invalid for this type
1172     */
1173    protected function decideNonTopicTitleContentFormat(
1174        AbstractRevision $revision,
1175        $requestedRevFormat,
1176        $requestedDefaultFormat
1177    ) {
1178        if ( $requestedRevFormat !== null ) {
1179            if ( $requestedRevFormat === 'topic-title-html' ||
1180                $requestedRevFormat === 'topic-title-wikitext'
1181            ) {
1182                throw new FlowException( 'Invalid per-revision format.  Only topic titles can use  ' .
1183                    '\'topic-title-html\' and \'topic-title-wikitext\'' );
1184            }
1185            return $requestedRevFormat;
1186        } else {
1187            if ( $requestedDefaultFormat === 'topic-title-html' ||
1188                $requestedDefaultFormat === 'topic-title-wikitext'
1189            ) {
1190                throw new FlowException( 'Default format of \'topic-title-html\' or ' .
1191                    '\'topic-title-wikitext\' can only be used to format topic titles.' );
1192            }
1193
1194            return $requestedDefaultFormat;
1195        }
1196    }
1197}