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