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