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