Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
17.21% covered (danger)
17.21%
42 / 244
5.88% covered (danger)
5.88%
1 / 17
CRAP
0.00% covered (danger)
0.00%
0 / 1
RecentChangesPropagationHooks
17.21% covered (danger)
17.21%
42 / 244
5.88% covered (danger)
5.88%
1 / 17
576.27
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 makeRecentChangesEntry
100.00% covered (success)
100.00%
42 / 42
100.00% covered (success)
100.00%
1 / 1
2
 getWordSep
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 isWikiStoriesRelatedChange
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 makeStoryLink
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
6
 makeArticleLink
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 getStoryId
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 makeDiffLink
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
2
 makeHistLink
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
2
 makeDiffHistLinks
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 makeTimestampLink
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
2
 makeUserLinks
0.00% covered (danger)
0.00%
0 / 41
0.00% covered (danger)
0.00%
0 / 1
12
 makeComment
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
20
 onEnhancedChangesListModifyBlockLineData
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
6
 onEnhancedChangesListModifyLineData
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
6
 onOldChangesListRecentChangesLine
0.00% covered (danger)
0.00%
0 / 37
0.00% covered (danger)
0.00%
0 / 1
30
 onChangesListSpecialPageStructuredFilters
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3namespace MediaWiki\Extension\Wikistories\Hooks;
4
5use ChangesListBooleanFilter;
6use HtmlArmor;
7use MediaWiki\CommentStore\CommentStoreComment;
8use MediaWiki\Config\Config;
9use MediaWiki\Context\IContextSource;
10use MediaWiki\Hook\EnhancedChangesListModifyBlockLineDataHook;
11use MediaWiki\Hook\EnhancedChangesListModifyLineDataHook;
12use MediaWiki\Hook\OldChangesListRecentChangesLineHook;
13use MediaWiki\Html\Html;
14use MediaWiki\Language\Language;
15use MediaWiki\Linker\LinkRenderer;
16use MediaWiki\MediaWikiServices;
17use MediaWiki\Page\PageIdentity;
18use MediaWiki\Page\PageReference;
19use MediaWiki\Page\PageReferenceValue;
20use MediaWiki\Revision\RevisionRecord;
21use MediaWiki\Revision\RevisionStore;
22use MediaWiki\SpecialPage\Hook\ChangesListSpecialPageStructuredFiltersHook;
23use MediaWiki\SpecialPage\SpecialPage;
24use MediaWiki\Storage\EditResult;
25use MediaWiki\User\User;
26use MediaWiki\User\UserFactory;
27use MediaWiki\User\UserIdentity;
28use RecentChange;
29use Wikimedia\Rdbms\ILoadBalancer;
30
31class RecentChangesPropagationHooks implements
32    EnhancedChangesListModifyBlockLineDataHook,
33    EnhancedChangesListModifyLineDataHook,
34    OldChangesListRecentChangesLineHook,
35    ChangesListSpecialPageStructuredFiltersHook
36{
37    public const SRC_WIKISTORIES = 'src_wikistories';
38
39    /** @var RevisionStore */
40    private $revisionStore;
41
42    /** @var Config */
43    private $config;
44
45    /** @var LinkRenderer */
46    private $linkRenderer;
47
48    /** @var ILoadBalancer */
49    private $loadBalancer;
50
51    /** @var string */
52    private $sep;
53
54    /** @var string */
55    private $wordSep = null;
56
57    /** @var UserFactory */
58    private $userFactory;
59
60    /**
61     * @param RevisionStore $revisionStore
62     * @param Config $config
63     * @param LinkRenderer $linkRenderer
64     * @param ILoadBalancer $loadBalancer
65     * @param UserFactory $userFactory
66     */
67    public function __construct(
68        RevisionStore $revisionStore,
69        Config $config,
70        LinkRenderer $linkRenderer,
71        ILoadBalancer $loadBalancer,
72        UserFactory $userFactory
73    ) {
74        $this->revisionStore = $revisionStore;
75        $this->config = $config;
76        $this->linkRenderer = $linkRenderer;
77        $this->loadBalancer = $loadBalancer;
78        $this->userFactory = $userFactory;
79
80        $this->sep = ' ' . Html::element( 'span', [ 'class' => 'mw-changeslist-separator' ], '' ) . ' ';
81    }
82
83    /**
84     * When a story is saved (created or edited), we create a recent changes
85     * entry for the related article so that watchers of that article can
86     * be aware of the story change.
87     *
88     * @note The logic for creating the fake RecentChanges entry is in this class
89     * because this is where we define how that entry is later visualized.
90     * The actual insertion of the fake RC entry is left to EventIngress, which
91     * handles core events triggered by page changes.
92     */
93    public static function makeRecentChangesEntry(
94        PageIdentity $article,
95        RevisionRecord $revisionRecord,
96        UserIdentity $user,
97        string $summary,
98        string $requestIP,
99        bool $minor,
100        bool $bot,
101        int $patrolled,
102        ?EditResult $editResult
103    ): RecentChange {
104        // NOTE: $revisionRecord does not belong to $article!
105
106        $rc = new RecentChange;
107        $rc->mAttribs = [
108            'rc_timestamp' => $revisionRecord->getTimestamp(),
109            'rc_namespace' => $article->getNamespace(),
110            'rc_title' => $article->getDBkey(),
111            'rc_type' => RC_EXTERNAL,
112            'rc_source' => self::SRC_WIKISTORIES,
113            'rc_minor' => $minor,
114            'rc_cur_id' => $article->getId(),
115            'rc_user' => $user->getId(),
116            'rc_user_text' => $user->getName(),
117            'rc_comment' => $summary,
118            'rc_comment_text' => $summary,
119            'rc_comment_data' => null,
120            'rc_this_oldid' => (int)$revisionRecord->getId(),
121            'rc_last_oldid' => (int)$revisionRecord->getParentId(),
122            'rc_bot' => $bot,
123            'rc_ip' => $requestIP,
124            'rc_patrolled' => $patrolled,
125            'rc_new' => 0,
126            'rc_old_len' => 0,
127            'rc_new_len' => 0,
128            'rc_deleted' => 0,
129            'rc_logid' => 0,
130            'rc_log_type' => null,
131            'rc_log_action' => '',
132            'rc_params' => serialize( [
133                'story_title' => $revisionRecord->getPage()->getDBkey(),
134                'story_id' => $revisionRecord->getPage()->getId(),
135            ] )
136        ];
137
138        // TODO: deprecate the 'prefixedDBkey' entry, let callers do the formatting.
139        $formatter = MediaWikiServices::getInstance()->getTitleFormatter();
140
141        $rc->mExtra = [
142            'prefixedDBkey' => $formatter->getPrefixedDBkey( $article ),
143            'lastTimestamp' => 0,
144            'oldSize' => 0,
145            'newSize' => 0,
146            'pageStatus' => 'changed'
147        ];
148
149        if ( $editResult ) {
150            $rc->setEditResult( $editResult );
151        }
152
153        return $rc;
154    }
155
156    /**
157     * @param IContextSource $context
158     * @return string Word separator
159     */
160    private function getWordSep( IContextSource $context ): string {
161        if ( $this->wordSep === null ) {
162            $this->wordSep = $context->msg( 'word-separator' )->plain();
163        }
164        return $this->wordSep;
165    }
166
167    /**
168     * @param RecentChange $rc
169     * @return bool
170     */
171    private function isWikiStoriesRelatedChange( RecentChange $rc ): bool {
172        return $rc->getAttribute( 'rc_source' ) === self::SRC_WIKISTORIES;
173    }
174
175    /**
176     * @param IContextSource $context
177     * @param PageReference $story
178     * @param bool $parens
179     * @return string
180     */
181    private function makeStoryLink( IContextSource $context, PageReference $story, $parens = false ): string {
182        $storyLink = $this->linkRenderer->makeKnownLink( $story );
183        $formattedLink = $parens ?
184            $context->msg( 'parentheses' )->rawParams( $storyLink )->text() :
185            $storyLink;
186        return Html::rawElement(
187            'span',
188            [],
189            $formattedLink
190        );
191    }
192
193    /**
194     * @param PageReference $article
195     * @return string
196     */
197    private function makeArticleLink( PageReference $article ): string {
198        return Html::rawElement(
199            'span',
200            [ 'class' => 'mw-title' ],
201            $this->linkRenderer->makeKnownLink( $article )
202        );
203    }
204
205    /**
206     * @param RecentChange $rc
207     * @return int
208     */
209    private function getStoryId( RecentChange $rc ): int {
210        $params = $rc->parseParams();
211        return $params[ 'story_id' ];
212    }
213
214    /**
215     * @param IContextSource $context
216     * @param PageReference $story
217     * @param RecentChange $rc
218     * @return string
219     */
220    private function makeDiffLink( IContextSource $context, PageReference $story, RecentChange $rc ): string {
221        return Html::rawElement(
222            'span',
223            [],
224            $this->linkRenderer->makeKnownLink(
225                $story,
226                new HtmlArmor( $context->msg( 'diff' )->escaped() ),
227                [ 'class' => 'mw-changeslist-diff' ],
228                [
229                    'curid' => $this->getStoryId( $rc ),
230                    'diff' => $rc->getAttribute( 'rc_this_oldid' ),
231                    'oldid' => $rc->getAttribute( 'rc_last_oldid' ),
232                ]
233            )
234        );
235    }
236
237    /**
238     * @param IContextSource $context
239     * @param PageReference $story
240     * @param RecentChange $rc
241     * @return string
242     */
243    private function makeHistLink( IContextSource $context, PageReference $story, RecentChange $rc ): string {
244        return Html::rawElement(
245            'span',
246            [],
247            $this->linkRenderer->makeKnownLink(
248                $story,
249                new HtmlArmor( $context->msg( 'hist' )->escaped() ),
250                [ 'class' => 'mw-changeslist-history' ],
251                [
252                    'curid' => $this->getStoryId( $rc ),
253                    'action' => 'history',
254                ]
255            )
256        );
257    }
258
259    /**
260     * @param IContextSource $context
261     * @param PageReference $story
262     * @param RecentChange $rc
263     * @return string
264     */
265    private function makeDiffHistLinks(
266        IContextSource $context,
267        PageReference $story,
268        RecentChange $rc
269    ): string {
270        $diffLink = $this->makeDiffLink( $context, $story, $rc );
271        $histLink = $this->makeHistLink( $context, $story, $rc );
272        return Html::rawElement(
273            'span',
274            [ 'class' => 'mw-changeslist-links' ],
275            $diffLink . $histLink
276        );
277    }
278
279    /**
280     * @param PageReference $story
281     * @param RecentChange $rc
282     * @param Language $lang
283     * @return string
284     */
285    private function makeTimestampLink( PageReference $story, RecentChange $rc, Language $lang ): string {
286        $user = $rc->getPerformerIdentity();
287        return $this->linkRenderer->makeKnownLink(
288            $story,
289            $lang->userTime( $rc->getAttribute( 'rc_timestamp' ), $user ),
290            [ 'class' => 'mw-changeslist-date' ],
291            [
292                'title' => $story->getDBkey(),
293                'curid' => $this->getStoryId( $rc ),
294                'oldid' => $rc->getAttribute( 'rc_this_oldid' ),
295            ]
296        );
297    }
298
299    /**
300     * @param IContextSource $context
301     * @param int $visibility
302     * @param User $user
303     * @return string
304     */
305    private function makeUserLinks( IContextSource $context, int $visibility, User $user ) {
306        if ( !RevisionRecord::userCanBitfield(
307            $visibility,
308            RevisionRecord::DELETED_USER,
309            $user )
310        ) {
311            // The username has been moderated and cannot be seen by the current user
312            return Html::rawElement(
313                'span',
314                [ 'class' => 'history-deleted' ],
315                $context->msg( 'rev-deleted-user' )->escaped()
316            );
317        }
318
319        $userLink = $this->linkRenderer->makeLink(
320            $user->getUserPage(),
321            $user->getName(),
322            [ 'class' => 'mw-userlink' ]
323        );
324
325        $links = [];
326
327        $links[] = Html::rawElement(
328            'span',
329            [],
330            $this->linkRenderer->makeLink(
331                $user->getTalkPage(),
332                $context->msg( 'talkpagelinktext' )->text(),
333                [ 'class' => 'mw-usertoollinks-talk' ]
334            )
335        );
336
337        if ( $user->isRegistered() ) {
338            $links[] = Html::rawElement(
339                'span',
340                [],
341                $this->linkRenderer->makeLink(
342                    SpecialPage::getTitleValueFor( 'Contributions', $user->getName() ),
343                    $context->msg( 'contribslink' )->text(),
344                    [ 'class' => 'mw-usertoollinks-contribs' ]
345                )
346            );
347        }
348        return $userLink .
349            $this->getWordSep( $context ) .
350            Html::rawElement(
351                'span',
352                [ 'class' => 'mw-usertoollinks mw-changeslist-links' ],
353                implode( '', $links )
354            );
355    }
356
357    /**
358     * @param IContextSource $context
359     * @param CommentStoreComment|null $comment
360     * @return string
361     */
362    private function makeComment( IContextSource $context, $comment ): string {
363        $text = $comment ? $comment->text : null;
364        if ( $text !== null && $text !== '' ) {
365            return Html::rawElement(
366                'span',
367                [ 'class' => 'comment' ],
368                $context->msg( 'parentheses', $text )->parse()
369            );
370
371        }
372        return '';
373    }
374
375    /**
376     * Use this hook to alter data used to build a non-grouped recent change line in
377     * EnhancedChangesList.
378     *
379     * @inheritDoc
380     */
381    public function onEnhancedChangesListModifyBlockLineData( $changesList, &$data, $rc ) {
382        if ( !$this->isWikiStoriesRelatedChange( $rc ) ) {
383            return;
384        }
385
386        $params = $rc->parseParams();
387        $story = PageReferenceValue::localReference( NS_STORY, $params[ 'story_title' ] );
388        $lang = $changesList->getLanguage();
389        $context = $changesList->getContext();
390
391        $data[ 'recentChangesFlags' ][ 'wikistories-edit' ] = true;
392
393        // Make timestamp link to specific revision
394        $data[ 'timestampLink' ] = $this->makeTimestampLink( $story, $rc, $lang );
395
396        // Append story link to article link
397        $data[ 'articleLink' ] .= $this->sep . $this->makeStoryLink( $context, $story, true );
398
399        // Remove character diff section
400        unset( $data['characterDiff'] );
401        unset( $data['separatorAftercharacterDiff'] );
402
403        // Make DIFF and HIST links for story instead of article
404        $data[ 'historyLink' ] = $this->getWordSep( $context ) .
405            $this->makeDiffHistLinks( $context, $story, $rc );
406    }
407
408    /**
409     * Use this hook to alter data used to build a grouped recent change inner line in
410     * EnhancedChangesList.
411     *
412     * @inheritDoc
413     */
414    public function onEnhancedChangesListModifyLineData( $changesList, &$data, $block, $rc, &$classes, &$attribs ) {
415        if ( !$this->isWikiStoriesRelatedChange( $rc ) ) {
416            return;
417        }
418
419        $params = $rc->parseParams();
420        $story = PageReferenceValue::localReference( NS_STORY, $params[ 'story_title' ] );
421        $lang = $changesList->getLanguage();
422        $context = $changesList->getContext();
423
424        $data[ 'recentChangesFlags' ][ 'wikistories-edit' ] = true;
425
426        // Make timestamp link to specific revision
427        $data[ 'timestampLink' ] = $this->makeTimestampLink( $story, $rc, $lang );
428
429        // Replace "(cur last)" links with "story (diff hist)" links
430        $data[ 'currentAndLastLinks' ] = $this->getWordSep( $context ) .
431            $this->makeStoryLink( $context, $story ) .
432            $this->getWordSep( $context ) .
433            $this->makeDiffHistLinks( $context, $story, $rc );
434
435        // Remove character diff section
436        unset( $data['characterDiff'] );
437        unset( $data['separatorAfterCharacterDiff'] );
438    }
439
440    /**
441     * Use this hook to customize a recent changes line.
442     *
443     * @inheritDoc
444     */
445    public function onOldChangesListRecentChangesLine( $changeslist, &$s, $rc, &$classes, &$attribs ) {
446        if ( !$this->isWikiStoriesRelatedChange( $rc ) ) {
447            return;
448        }
449
450        $params = $rc->parseParams();
451        $story = PageReferenceValue::localReference( NS_STORY, $params['story_title'] );
452        $rev = $this->revisionStore->getRevisionById( $rc->getAttribute( 'rc_this_oldid' ) );
453        $user = $this->userFactory->newFromUserIdentity( $rc->getPerformerIdentity() );
454        $lang = $changeslist->getLanguage();
455        $context = $changeslist->getContext();
456        $comment = $rev !== null ? $rev->getComment( RevisionRecord::FOR_PUBLIC, $user ) : null;
457
458        $flag = $changeslist->recentChangesFlags(
459            [
460                'wikistories-edit' => true,
461                'minor' => $rc->getAttribute( 'rc_minor' ),
462                'bot' => $rc->getAttribute( 'rc_bot' ),
463            ],
464            ''
465        );
466
467        $article = $rc->getPage();
468        if ( $article === null ) {
469            return;
470        }
471
472        $s = Html::rawElement(
473            'span',
474            [ 'class' => 'mw-changeslist-line-inner' ],
475            $this->makeDiffHistLinks( $context, $story, $rc ) .
476            $this->sep .
477            $flag .
478            $this->getWordSep( $context ) .
479            $this->makeArticleLink( $article ) .
480            $this->getWordSep( $context ) .
481            $this->makeStoryLink( $context, $story, true ) .
482            $this->getWordSep( $context ) .
483            $this->makeTimestampLink( $story, $rc, $lang ) .
484            $this->sep .
485            $this->makeUserLinks( $context, $rev !== null ? $rev->getVisibility() : 0, $user ) .
486            $this->getWordSep( $context ) .
487            $this->makeComment( $context, $comment )
488        );
489    }
490
491    /**
492     * @inheritDoc
493     */
494    public function onChangesListSpecialPageStructuredFilters( $special ) {
495        // @phan-suppress-next-line PhanNoopNew
496        new ChangesListBooleanFilter( [
497            'name' => 'hidewikistories',
498            'group' => $special->getFilterGroup( 'changeType' ),
499            'priority' => -4,
500            'label' => 'wikistories-rcfilters-hidewikistories-label',
501            'description' => 'wikistories-rcfilters-hidewikistories-description',
502            'showHide' => 'rcshowhidewikistories',
503            'default' => false,
504            'queryCallable' => static function (
505                $specialClassName, $ctx, $dbr, &$tables, &$fields, &$conds, &$query_options, &$join_conds
506            ) {
507                $conds[] = $dbr->expr( 'rc_source', '!=', self::SRC_WIKISTORIES );
508            },
509            'cssClassSuffix' => 'src-mw-wikistories',
510            'isRowApplicableCallable' => function ( $ctx, $rc ) {
511                return $this->isWikiStoriesRelatedChange( $rc );
512            }
513        ] );
514    }
515}