Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.08% covered (success)
97.08%
233 / 240
78.57% covered (warning)
78.57%
11 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
ClientChangeHooks
97.08% covered (success)
97.08%
233 / 240
78.57% covered (warning)
78.57%
11 / 14
30
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 makeTitleForPossiblyRemoteZObject
66.67% covered (warning)
66.67%
4 / 6
0.00% covered (danger)
0.00%
0 / 1
3.33
 getDiffAndHistLinks
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
1
 getComment
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
2
 getFlags
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 getTimeStampLink
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 onEnhancedChangesListModifyLineData
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 onEnhancedChangesListModifyBlockLineData
100.00% covered (success)
100.00%
60 / 60
100.00% covered (success)
100.00%
1 / 1
6
 onOldChangesListRecentChangesLine
100.00% covered (success)
100.00%
68 / 68
100.00% covered (success)
100.00%
1 / 1
4
 isJobFromWikifunctions
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 onChangesListSpecialPageQuery
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 onChangesListSpecialPageStructuredFilters
91.67% covered (success)
91.67%
22 / 24
0.00% covered (danger)
0.00%
0 / 1
2.00
 onGetPreferences
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
2
 getOptionName
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2
3/**
4 * WikiLambda extension Parser-related ('client-mode') hooks
5 *
6 * @file
7 * @ingroup Extensions
8 * @copyright 2020– Abstract Wikipedia team; see AUTHORS.txt
9 * @license MIT
10 */
11
12namespace MediaWiki\Extension\WikiLambda\HookHandler;
13
14use MediaWiki\Config\Config;
15use MediaWiki\Config\ConfigException;
16use MediaWiki\Context\IContextSource;
17use MediaWiki\Extension\WikiLambda\Jobs\WikifunctionsRecentChangesInsertJob;
18use MediaWiki\Html\FormOptions;
19use MediaWiki\Html\Html;
20use MediaWiki\Linker\Linker;
21use MediaWiki\Linker\LinkRenderer;
22use MediaWiki\Logger\LoggerFactory;
23use MediaWiki\RecentChanges\ChangesList;
24use MediaWiki\RecentChanges\ChangesListBooleanFilter;
25use MediaWiki\RecentChanges\EnhancedChangesList;
26use MediaWiki\RecentChanges\OldChangesList;
27use MediaWiki\RecentChanges\RecentChange;
28use MediaWiki\SpecialPage\ChangesListSpecialPage;
29use MediaWiki\Title\Title;
30use MediaWiki\User\Options\UserOptionsLookup;
31use MediaWiki\User\User;
32use Psr\Log\LoggerInterface;
33use Wikimedia\HtmlArmor\HtmlArmor;
34use Wikimedia\Rdbms\IConnectionProvider;
35
36class ClientChangeHooks implements
37    \MediaWiki\RecentChanges\Hook\EnhancedChangesListModifyLineDataHook,
38    \MediaWiki\RecentChanges\Hook\EnhancedChangesListModifyBlockLineDataHook,
39    \MediaWiki\RecentChanges\Hook\OldChangesListRecentChangesLineHook,
40    \MediaWiki\SpecialPage\Hook\ChangesListSpecialPageQueryHook,
41    \MediaWiki\SpecialPage\Hook\ChangesListSpecialPageStructuredFiltersHook,
42    \MediaWiki\Preferences\Hook\GetPreferencesHook
43{
44    private UserOptionsLookup $userOptionsLookup;
45    private IConnectionProvider $dbProvider;
46    private LinkRenderer $linkRenderer;
47
48    private LoggerInterface $logger;
49
50    private bool $showWikifunctionsChanges = false;
51
52    public function __construct(
53        UserOptionsLookup $userOptionsLookup,
54        IConnectionProvider $dbProvider,
55        private readonly Config $config,
56        LinkRenderer $linkRenderer
57    ) {
58        $this->userOptionsLookup = $userOptionsLookup;
59        $this->dbProvider = $dbProvider;
60        $this->linkRenderer = $linkRenderer;
61
62        $this->showWikifunctionsChanges = $this->config->get( 'WikiLambdaClientDefaultShowChanges' );
63
64        // Non-injected items
65        $this->logger = LoggerFactory::getInstance( 'WikiLambdaClient' );
66    }
67
68    private function makeTitleForPossiblyRemoteZObject( string $zObject ): Title {
69        // If we're in "repo" mode, we don't want an interwiki Title
70        if ( $this->config->get( 'WikiLambdaEnableRepoMode' ) ) {
71            $title = Title::makeTitleSafe( 0, $zObject );
72        } else {
73            $title = Title::makeTitleSafe( 0, $zObject, '', 'wikifunctionswiki' );
74        }
75
76        if ( !$title ) {
77            throw new ConfigException( 'Could not create Title for ' . $zObject );
78        }
79
80        return $title;
81    }
82
83    private function getDiffAndHistLinks( Title $target, IContextSource $context, int $newId, int $oldId ): string {
84        // The 'diff' and 'hist' links; we can't use ChangesList->insertDiffHist as our targets are remote.
85        return Html::rawElement(
86            'span',
87            [ 'class' => 'mw-changeslist-links' ],
88            Html::rawElement(
89                'span',
90                [],
91                $this->linkRenderer->makeKnownLink(
92                    $target,
93                    new HtmlArmor( $context->msg( 'diff' )->escaped() ),
94                    [ 'class' => 'mw-changeslist-diff' ],
95                    [ 'diff' => $newId, 'oldid' => $oldId ]
96                )
97            ) .
98            Html::rawElement(
99                'span',
100                [],
101                $this->linkRenderer->makeKnownLink(
102                    $target,
103                    new HtmlArmor( $context->msg( 'hist' )->escaped() ),
104                    [ 'class' => 'mw-changeslist-history' ],
105                    [ 'action' => 'history' ]
106                )
107            )
108        );
109    }
110
111    private function getComment(
112        IContextSource $context, string $message, ?array $messageParams, string $humanComment
113    ): string {
114        // Use the supplied message, including parameters, to make a message in the current user's language
115        $commentString = Html::rawElement(
116            'span',
117            [ 'class' => 'ext-wikilambda-recentchange-autocomment' ],
118            $context->msg( $message, $messageParams ?? [] )->escaped()
119        );
120
121        // If there was also a human-written message, show that as well
122        if ( $humanComment ) {
123            $commentString .= Html::rawElement(
124                'span',
125                [ 'class' => 'ext-wikilambda-recentchange-editcomment' ],
126                $context->msg( 'colon-separator' )->plain() . $humanComment
127            );
128        }
129
130        return Html::rawElement( 'span', [ 'class' => 'comment' ], $context->msg(
131            'parentheses',
132            [ $commentString ]
133        )->parse() );
134    }
135
136    private function getFlags( RecentChange $recentChange ): array {
137        return [
138            'wikifunctions-edit' => true,
139            'minor' => $recentChange->getAttribute( 'rc_minor' ),
140            'bot' => $recentChange->getAttribute( 'rc_bot' ),
141        ];
142    }
143
144    private function getTimeStampLink(
145        Title $target, string $timestamp, ChangesList $changesList, User $user, int $newId, int $oldId
146    ): string {
147        // Time timestamp of the edit; we can't use ChangesList::revDateLink() as we don't have a local RevisionRecord
148        return $this->linkRenderer->makeKnownLink(
149            $target,
150            $changesList->getLanguage()->userTime( $timestamp, $user ),
151            [ 'class' => 'mw-changeslist-date' ],
152            [
153                'title' => $target->getDBkey(),
154                'diff' => $newId,
155                'oldid' => $oldId,
156            ]
157        );
158    }
159
160    /**
161     * @see https://www.mediawiki.org/wiki/Manual:Hooks/EnhancedChangesListModifyLineData
162     *
163     * @param EnhancedChangesList $changesList
164     * @param array &$data
165     * @param RecentChange[] $block
166     * @param RecentChange $rc
167     * @param string[] &$classes
168     * @param string[] &$attribs
169     */
170    public function onEnhancedChangesListModifyLineData( $changesList, &$data, $block, $rc, &$classes, &$attribs ) {
171        if ( !$this::isJobFromWikifunctions( $rc ) ) {
172            // Not one of ours.
173            $data['recentChangesFlags']['wikifunctions-edit'] = false;
174            return;
175        }
176
177        // We don't do anything special for runs of affected updates, so we can re-use our regular method
178        $this->onEnhancedChangesListModifyBlockLineData( $changesList, $data, $rc );
179    }
180
181    /**
182     * @see https://www.mediawiki.org/wiki/Manual:Hooks/EnhancedChangesListModifyBlockLineData
183     *
184     * @param EnhancedChangesList $changesList
185     * @param array &$data
186     * @param RecentChange $rc
187     */
188    public function onEnhancedChangesListModifyBlockLineData( $changesList, &$data, $rc ) {
189        if ( !$this::isJobFromWikifunctions( $rc ) ) {
190            // Not one of ours.
191            $data['recentChangesFlags']['wikifunctions-edit'] = false;
192            return;
193        }
194
195        if ( !$rc->getPage() ) {
196            $this->logger->warning(
197                __METHOD__ . ' called for a false page somehow on rc_id "{rcId}"',
198                [
199                    'rcId' => $rc->getAttribute( 'rc_id' )
200                ]
201            );
202            return;
203        }
204
205        $context = $changesList->getContext();
206
207        $decodedParams = json_decode( $rc->getAttribute( 'rc_params' ), true );
208        if ( !$decodedParams ) {
209            $this->logger->warning(
210                __METHOD__ . ' called for {page} but couldn\'t decode rc_params: "{params}"',
211                [
212                    'page' => $rc->getPage()->getDBkey(),
213                    'params' => $rc->getAttribute( 'rc_params' ),
214                ]
215            );
216            return;
217        }
218
219        $targetItem = $decodedParams['target'];
220        if ( !$targetItem ) {
221            $this->logger->warning(
222                __METHOD__ . ' called for {page} but couldn\'t decode target: "{params}"',
223                [
224                    'page' => $rc->getPage()->getDBkey(),
225                    'params' => var_export( $decodedParams, true ),
226                ]
227            );
228            return;
229        }
230        $targetItemTitleObject = $this->makeTitleForPossiblyRemoteZObject( $targetItem );
231
232        // Note that we're +='ing the flags, as the array might have flags we don't know about from other extensions
233        $data['recentChangesFlags'] += $this->getFlags( $rc );
234
235        $newId = $decodedParams['newId'];
236        if ( !$newId ) {
237            $this->logger->warning(
238                __METHOD__ . ' called for {page} but newId was not set: "{params}"',
239                [
240                    'page' => $rc->getPage()->getDBkey(),
241                    'params' => var_export( $decodedParams, true ),
242                ]
243            );
244            return;
245        }
246
247        $oldId = $decodedParams['oldId'] ?? 0;
248
249        $data['timestampLink'] = $this->getTimeStampLink(
250            $targetItemTitleObject,
251            $rc->getAttribute( 'rc_timestamp' ),
252            $changesList,
253            $context->getUser(),
254            $newId,
255            $oldId
256        );
257
258        $data['currentAndLastLinks'] = $this->getDiffAndHistLinks( $targetItemTitleObject, $context, $newId, $oldId );
259
260        $data['comment'] = $this->getComment(
261            $context,
262            $decodedParams['message'],
263            $decodedParams['messageParams'] ?? [],
264            $rc->getAttribute( 'rc_comment' )
265        );
266
267        // Value passed by reference, so this completes our work.
268    }
269
270    /**
271     * @see https://www.mediawiki.org/wiki/Manual:Hooks/OldChangesListRecentChangesLine
272     *
273     * @param OldChangesList $changesList
274     * @param string &$s
275     * @param RecentChange $rc
276     * @param string[] &$classes
277     * @param string[] &$attribs
278     * @return bool|void
279     */
280    public function onOldChangesListRecentChangesLine( $changesList, &$s, $rc, &$classes, &$attribs ) {
281        if ( !$this::isJobFromWikifunctions( $rc ) ) {
282            // Not one of ours.
283            return;
284        }
285
286        if ( !$rc->getPage() ) {
287            $this->logger->warning(
288                __METHOD__ . ' called for a false page somehow on rc_id "{rcId}"',
289                [
290                    'rcId' => $rc->getAttribute( 'rc_id' )
291                ]
292            );
293            return;
294        }
295
296        $context = $changesList->getContext();
297
298        $decodedParams = json_decode( $rc->getAttribute( 'rc_params' ), true );
299        if ( !$decodedParams ) {
300            $this->logger->warning(
301                __METHOD__ . ' called for {page} but couldn\'t decode rc_params: "{params}"',
302                [
303                    'page' => $rc->getPage()->getDBkey(),
304                    'params' => $rc->getAttribute( 'rc_params' ),
305                ]
306            );
307            return;
308        }
309
310        $targetItem = $decodedParams['target'];
311        $targetItemTitleObject = $this->makeTitleForPossiblyRemoteZObject( $targetItem );
312
313        $targetFunction = $decodedParams['function'];
314
315        $wordSeparator = $context->msg( 'word-separator' )->plain();
316        $conceptSeparator = ' ' . Html::element( 'span', [ 'class' => 'mw-changeslist-separator' ], '' ) . ' ';
317
318        $spanContents =
319            $this->getDiffAndHistLinks(
320                $targetItemTitleObject, $context, $decodedParams['newId'], $decodedParams['oldId']
321            ) .
322            $conceptSeparator .
323            // The appropriate Flags ('F' for us, 'm' or 'b' for minor/bot)
324            $changesList->recentChangesFlags( $this->getFlags( $rc ) ) .
325            $wordSeparator .
326            // Link to the article affected
327            Html::rawElement(
328                'span',
329                [ 'class' => 'mw-title' ],
330                // @phan-suppress-next-line PhanTypeMismatchArgumentNullable; we've checked $rc->getPage() is non-null
331                $this->linkRenderer->makeKnownLink( $rc->getPage() )
332            ) .
333            $wordSeparator .
334            // Link to the Function being used
335            Html::rawElement(
336                'span',
337                [ 'class' => 'mw-wikilambda-function' ],
338                $this->linkRenderer->makeKnownLink(
339                    $this->makeTitleForPossiblyRemoteZObject( $targetFunction ),
340                    // As a display title, we want "Function: Foo" rather than "Z12345"
341                    $context->msg( 'wikilambda-recentchanges-entry-function' )->plain()
342                        . $context->msg( 'colon-separator' )->plain()
343                        // FIXME: Change this to the name of the Function in this language, not the ZID
344                        . $targetFunction
345                )
346            ) .
347            $wordSeparator .
348            $this->getTimeStampLink(
349                $targetItemTitleObject,
350                $rc->getAttribute( 'rc_timestamp' ),
351                $changesList,
352                $context->getUser(),
353                $decodedParams['newId'], $decodedParams['oldId'] ?? 0
354            ) .
355            $conceptSeparator .
356            // Links to this user
357            Linker::userToolLinks( $rc->getPerformerIdentity()->getId(), $rc->getPerformerIdentity()->getName() ) .
358            $wordSeparator .
359            // Comments
360            $this->getComment(
361                $context,
362                $decodedParams['message'],
363                $decodedParams['messageParams'],
364                $rc->getAttribute( 'rc_comment' )
365            );
366
367        // Value passed by reference, so this completes our work.
368        $s = Html::rawElement( 'span', [ 'class' => 'mw-changeslist-line-inner' ], $spanContents );
369    }
370
371    /**
372     * Static so that it can be used in a callback.
373     *
374     * @param RecentChange $rc
375     * @return bool
376     */
377    public static function isJobFromWikifunctions( RecentChange $rc ): bool {
378        return ( $rc->getAttribute( 'rc_source' ) === WikifunctionsRecentChangesInsertJob::SRC_WIKIFUNCTIONS );
379    }
380
381    /**
382     * @see https://www.mediawiki.org/wiki/Manual:Hooks/OldChangesListRecentChangesLine
383     *
384     * @param string $name
385     * @param array &$tables
386     * @param array &$fields
387     * @param array &$conds
388     * @param array &$query_options
389     * @param array &$join_conds
390     * @param FormOptions $opts
391     */
392    public function onChangesListSpecialPageQuery(
393        $name, &$tables, &$fields, &$conds, &$query_options, &$join_conds, $opts
394    ) {
395        // Drop server-side our changes if the user hasn't opted to see them (as set below,
396        // in onChangesListSpecialPageStructuredFilters).
397        if ( !$this->showWikifunctionsChanges ) {
398            $dbr = $this->dbProvider->getReplicaDatabase();
399            $conds[] = $dbr->expr( 'rc_source', '!=', WikifunctionsRecentChangesInsertJob::SRC_WIKIFUNCTIONS );
400        }
401    }
402
403    /**
404     * @param ChangesListSpecialPage $specialPage
405     */
406    public function onChangesListSpecialPageStructuredFilters( $specialPage ) {
407        // @phan-suppress-next-line PhanNoopNew
408        new ChangesListBooleanFilter( [
409            'name' => 'hideWikifunctions',
410            'group' => $specialPage->getFilterGroup( 'changeType' ),
411            'priority' => -5,
412            'label' => 'wikilambda-recentchanges-filter-label',
413            'description' => 'wikilambda-recentchanges-filter-description',
414            'showHide' => 'wikilambda-rc-hide-wikifunctions',
415            'default' => !(
416                // True (i.e., show these) if the default is to show them …
417                $this->showWikifunctionsChanges &&
418                // … and the user preference isn't different.
419                (bool)$this->userOptionsLookup->getOption(
420                    $specialPage->getUser(),
421                    $this->getOptionName( $specialPage->getName() )
422                )
423            ),
424            'queryCallable' => static function (
425                // All these upstream parameters, and we only use two of them!
426                $specialClassName, $ctx, $dbr, &$tables, &$fields, &$conds, &$query_options, &$join_conds
427            ) {
428                $conds[] = $dbr->expr( 'rc_source', '!=', WikifunctionsRecentChangesInsertJob::SRC_WIKIFUNCTIONS );
429            },
430            'cssClassSuffix' => 'src-mw-wikifunctions',
431            'isRowApplicableCallable' => static function ( $ctx, $rc ) {
432                return ClientChangeHooks::isJobFromWikifunctions( $rc );
433            },
434        ] );
435    }
436
437    /**
438     * Adds a preference for showing or hiding Wikidata entries in recent changes
439     *
440     * @param User $user
441     * @param array[] &$prefs
442     */
443    public function onGetPreferences( $user, &$prefs ) {
444        if ( !$this->config->get( 'WikiLambdaEnableClientMode' ) ) {
445            return;
446        }
447
448        $prefs['rcshowwikifunctions'] = [
449            'type' => 'toggle',
450            'label-message' => 'wikilambda-recentchanges-filter-rc-pref',
451            'section' => 'rc/advancedrc',
452        ];
453
454        $prefs['wlshowwikifunctions'] = [
455            'type' => 'toggle',
456            'label-message' => 'wikilambda-recentchanges-filter-wl-pref',
457            'section' => 'watchlist/advancedwatchlist',
458        ];
459    }
460
461    private function getOptionName( string $pageName ): string {
462        if ( $pageName === 'Watchlist' ) {
463            return 'wlshowwikifunctions';
464        }
465
466        return 'rcshowwikifunctions';
467    }
468
469}