Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
12.02% covered (danger)
12.02%
47 / 391
0.00% covered (danger)
0.00%
0 / 15
CRAP
0.00% covered (danger)
0.00%
0 / 1
ChangesListHooksHandler
12.02% covered (danger)
12.02%
47 / 391
0.00% covered (danger)
0.00%
0 / 15
4549.02
0.00% covered (danger)
0.00%
0 / 1
 onChangesListSpecialPageStructuredFilters
60.00% covered (warning)
60.00%
9 / 15
0.00% covered (danger)
0.00%
0 / 1
8.30
 handleDamaging
0.00% covered (danger)
0.00%
0 / 103
0.00% covered (danger)
0.00%
0 / 1
272
 handleGoodFaith
0.00% covered (danger)
0.00%
0 / 52
0.00% covered (danger)
0.00%
0 / 1
110
 handleRevertrisk
0.00% covered (danger)
0.00%
0 / 48
0.00% covered (danger)
0.00%
0 / 1
20
 shouldStraightJoin
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getDamagingStructuredFiltersOnChangesList
0.00% covered (danger)
0.00%
0 / 49
0.00% covered (danger)
0.00%
0 / 1
42
 getGoodFaithStructuredFiltersOnChangesList
0.00% covered (danger)
0.00%
0 / 49
0.00% covered (danger)
0.00%
0 / 1
42
 getRevertriskStructuredFiltersOnChangesList
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
6
 onChangesListSpecialPageQuery
76.92% covered (warning)
76.92%
10 / 13
0.00% covered (danger)
0.00%
0 / 1
6.44
 onEnhancedChangesListModifyLineData
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 onEnhancedChangesListModifyBlockLineData
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
2.03
 processRecentChangesList
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 onOldChangesListRecentChangesLine
85.71% covered (warning)
85.71%
12 / 14
0.00% covered (danger)
0.00%
0 / 1
6.10
 getScoreRecentChangesList
66.67% covered (warning)
66.67%
10 / 15
0.00% covered (danger)
0.00%
0 / 1
8.81
 makeApplicableCallback
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
20
1<?php
2/**
3 * This program is free software: you can redistribute it and/or modify
4 * it under the terms of the GNU General Public License as published by
5 * the Free Software Foundation, either version 3 of the License, or
6 * (at your option) any later version.
7 *
8 * This program is distributed in the hope that it will be useful,
9 * but WITHOUT ANY WARRANTY; without even the implied warranty of
10 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
11 * GNU General Public License for more details.
12 *
13 * You should have received a copy of the GNU General Public License
14 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
15 */
16
17namespace ORES\Hooks;
18
19use ChangesList;
20use ChangesListBooleanFilterGroup;
21use ChangesListFilter;
22use ChangesListStringOptionsFilterGroup;
23use EnhancedChangesList;
24use Exception;
25use IContextSource;
26use MediaWiki\Hook\EnhancedChangesListModifyBlockLineDataHook;
27use MediaWiki\Hook\EnhancedChangesListModifyLineDataHook;
28use MediaWiki\Hook\OldChangesListRecentChangesLineHook;
29use MediaWiki\MediaWikiServices;
30use MediaWiki\SpecialPage\ChangesListSpecialPage;
31use MediaWiki\SpecialPage\Hook\ChangesListSpecialPageQueryHook;
32use MediaWiki\SpecialPage\Hook\ChangesListSpecialPageStructuredFiltersHook;
33use MediaWiki\Specials\SpecialRecentChanges;
34use MediaWiki\Specials\SpecialWatchlist;
35use ORES\Services\ORESServices;
36use ORES\Storage\ThresholdLookup;
37use RecentChange;
38use Wikimedia\Rdbms\IDatabase;
39
40class ChangesListHooksHandler implements
41    ChangesListSpecialPageStructuredFiltersHook,
42    ChangesListSpecialPageQueryHook,
43    EnhancedChangesListModifyBlockLineDataHook,
44    EnhancedChangesListModifyLineDataHook,
45    OldChangesListRecentChangesLineHook
46{
47
48    public function onChangesListSpecialPageStructuredFilters(
49        $clsp
50    ) {
51        if ( !Helpers::oresUiEnabled() ) {
52            return;
53        }
54
55        $thresholdLookup = ORESServices::getThresholdLookup();
56        $changeTypeGroup = $clsp->getFilterGroup( 'changeType' );
57        $logFilter = $changeTypeGroup->getFilter( 'hidelog' );
58        try {
59            if ( Helpers::isModelEnabled( 'revertrisklanguageagnostic' ) ) {
60                self::handleRevertrisk( $clsp, $thresholdLookup, $logFilter );
61            }
62            if ( Helpers::isModelEnabled( 'damaging' ) ) {
63                self::handleDamaging( $clsp, $thresholdLookup, $logFilter );
64            }
65            if ( Helpers::isModelEnabled( 'goodfaith' ) ) {
66                self::handleGoodFaith( $clsp, $thresholdLookup, $logFilter );
67            }
68        } catch ( Exception $exception ) {
69            ORESServices::getLogger()->error(
70                'Error in ChangesListHookHandler: ' . $exception->getMessage()
71            );
72        }
73    }
74
75    /**
76     * @param ChangesListSpecialPage $clsp
77     * @param ThresholdLookup $thresholdLookup
78     * @param ChangesListFilter $logFilter
79     */
80    private static function handleDamaging(
81        ChangesListSpecialPage $clsp,
82        ThresholdLookup $thresholdLookup,
83        ChangesListFilter $logFilter
84
85    ) {
86        $userOptionsLookup = MediaWikiServices::getInstance()->getUserOptionsLookup();
87        if ( $clsp instanceof SpecialRecentChanges ) {
88            $damagingDefault = $userOptionsLookup->getOption( $clsp->getUser(), 'oresRCHideNonDamaging' );
89            $highlightDefault = $userOptionsLookup->getBoolOption( $clsp->getUser(), 'ores-damaging-flag-rc' );
90        } elseif ( $clsp instanceof SpecialWatchlist ) {
91            $damagingDefault = $userOptionsLookup->getOption( $clsp->getUser(), 'oresWatchlistHideNonDamaging' );
92            $highlightDefault = $userOptionsLookup->getBoolOption( $clsp->getUser(), 'oresHighlight' );
93        } else {
94            $damagingDefault = false;
95            $highlightDefault = false;
96        }
97
98        $filters = self::getDamagingStructuredFiltersOnChangesList(
99            $thresholdLookup->getThresholds( 'damaging' )
100        );
101
102        if ( $filters ) {
103            $newDamagingGroup = new ChangesListStringOptionsFilterGroup( [
104                'name' => 'damaging',
105                'title' => 'ores-rcfilters-damaging-title',
106                'whatsThisHeader' => 'ores-rcfilters-damaging-whats-this-header',
107                'whatsThisBody' => 'ores-rcfilters-damaging-whats-this-body',
108                'whatsThisUrl' => 'https://www.mediawiki.org/wiki/' .
109                    'Special:MyLanguage/Help:New_filters_for_edit_review/Quality_and_Intent_Filters',
110                'whatsThisLinkText' => 'ores-rcfilters-whats-this-link-text',
111                'priority' => 2,
112                'filters' => array_values( $filters ),
113                'default' => ChangesListStringOptionsFilterGroup::NONE,
114                'isFullCoverage' => false,
115                'queryCallable' => function ( $specialClassName, $ctx, IDatabase $dbr, &$tables,
116                        &$fields, &$conds, &$query_options, &$join_conds, $selectedValues ) {
117                    $databaseQueryBuilder = ORESServices::getDatabaseQueryBuilder();
118                    $condition = $databaseQueryBuilder->buildQuery(
119                        'damaging',
120                        $selectedValues
121                    );
122                    if ( $condition ) {
123                        $conds[] = $condition;
124
125                        // Filter out incompatible types; log actions and external rows are not scorable
126                        $conds[] = $dbr->expr( 'rc_type', '!=', [ RC_LOG, RC_EXTERNAL ] );
127                        // Make the joins INNER JOINs instead of LEFT JOINs
128                        $join_conds['ores_damaging_mdl'][0] = 'INNER JOIN';
129                        $join_conds['ores_damaging_cls'][0] = 'INNER JOIN';
130                        if ( self::shouldStraightJoin( $specialClassName ) ) {
131                            $query_options[] = 'STRAIGHT_JOIN';
132                        }
133                    }
134                },
135            ] );
136
137            $newDamagingGroup->conflictsWith(
138                $logFilter,
139                'ores-rcfilters-ores-conflicts-logactions-global',
140                'ores-rcfilters-damaging-conflicts-logactions',
141                'ores-rcfilters-logactions-conflicts-ores'
142            );
143
144            if ( isset( $filters[ 'maybebad' ] ) && isset( $filters[ 'likelybad' ] ) ) {
145                $newDamagingGroup->getFilter( 'maybebad' )->setAsSupersetOf(
146                    $newDamagingGroup->getFilter( 'likelybad' )
147                );
148            }
149
150            if ( isset( $filters[ 'likelybad' ] ) && isset( $filters[ 'verylikelybad' ] ) ) {
151                $newDamagingGroup->getFilter( 'likelybad' )->setAsSupersetOf(
152                    $newDamagingGroup->getFilter( 'verylikelybad' )
153                );
154            }
155
156            // Transitive closure
157            if ( isset( $filters[ 'maybebad' ] ) && isset( $filters[ 'verylikelybad' ] ) ) {
158                $newDamagingGroup->getFilter( 'maybebad' )->setAsSupersetOf(
159                    $newDamagingGroup->getFilter( 'verylikelybad' )
160                );
161            }
162
163            if ( $damagingDefault ) {
164                $newDamagingGroup->setDefault( Helpers::getDamagingLevelPreference( $clsp->getUser(),
165                    $clsp->getPageTitle() ) );
166            }
167
168            if ( $highlightDefault ) {
169                $levelsColors = [
170                    'maybebad' => 'c3',
171                    'likelybad' => 'c4',
172                    'verylikelybad' => 'c5',
173                ];
174
175                $prefLevel = Helpers::getDamagingLevelPreference(
176                    $clsp->getUser(),
177                    $clsp->getPageTitle()
178                );
179                $allLevels = array_keys( $levelsColors );
180                $applicableLevels = array_slice( $allLevels, array_search( $prefLevel, $allLevels ) );
181                $applicableLevels = array_intersect( $applicableLevels, array_keys( $filters ) );
182
183                foreach ( $applicableLevels as $level ) {
184                    $newDamagingGroup
185                        ->getFilter( $level )
186                        ->setDefaultHighlightColor( $levelsColors[$level] );
187                }
188            }
189
190            $clsp->registerFilterGroup( $newDamagingGroup );
191        }
192
193        // I don't think we need to register a conflict here, since
194        // if we're showing non-damaging, that won't conflict with
195        // anything.
196        $legacyDamagingGroup = new ChangesListBooleanFilterGroup( [
197            'name' => 'ores',
198            'filters' => [
199                [
200                    'name' => 'hidenondamaging',
201                    'showHide' => 'ores-damaging-filter',
202                    'isReplacedInStructuredUi' => true,
203                    'default' => $damagingDefault,
204                    'queryCallable' => function ( $specialClassName, IContextSource $ctx, IDatabase $dbr, &$tables,
205                                                  &$fields, &$conds, &$query_options, &$join_conds ) {
206                        Helpers::hideNonDamagingFilter( $fields, $conds, true, $ctx->getUser(),
207                            $ctx->getTitle() );
208                        // Filter out incompatible types; log actions and external rows are not scorable
209                        $conds[] = $dbr->expr( 'rc_type', '!=', [ RC_LOG, RC_EXTERNAL ] );
210                        // Filter out patrolled edits: the 'r' doesn't appear for them
211                        $conds['rc_patrolled'] = RecentChange::PRC_UNPATROLLED;
212                        // Make the joins INNER JOINs instead of LEFT JOINs
213                        $join_conds['ores_damaging_mdl'][0] = 'INNER JOIN';
214                        $join_conds['ores_damaging_cls'][0] = 'INNER JOIN';
215                        if ( self::shouldStraightJoin( $specialClassName ) ) {
216                            $query_options[] = 'STRAIGHT_JOIN';
217                        }
218                    },
219                ]
220            ],
221
222        ] );
223
224        $clsp->registerFilterGroup( $legacyDamagingGroup );
225    }
226
227    /**
228     * @param ChangesListSpecialPage $clsp
229     * @param ThresholdLookup $thresholdLookup
230     * @param ChangesListFilter $logFilter
231     */
232    private static function handleGoodFaith(
233        ChangesListSpecialPage $clsp,
234        ThresholdLookup $thresholdLookup,
235        ChangesListFilter $logFilter
236    ) {
237        $filters = self::getGoodFaithStructuredFiltersOnChangesList(
238            $thresholdLookup->getThresholds( 'goodfaith' )
239        );
240
241        if ( !$filters ) {
242            return;
243        }
244        $goodfaithGroup = new ChangesListStringOptionsFilterGroup( [
245            'name' => 'goodfaith',
246            'title' => 'ores-rcfilters-goodfaith-title',
247            'whatsThisHeader' => 'ores-rcfilters-goodfaith-whats-this-header',
248            'whatsThisBody' => 'ores-rcfilters-goodfaith-whats-this-body',
249            'whatsThisUrl' => 'https://www.mediawiki.org/wiki/' .
250                'Special:MyLanguage/Help:New_filters_for_edit_review/Quality_and_Intent_Filters',
251            'whatsThisLinkText' => 'ores-rcfilters-whats-this-link-text',
252            'priority' => 1,
253            'filters' => array_values( $filters ),
254            'default' => ChangesListStringOptionsFilterGroup::NONE,
255            'isFullCoverage' => false,
256            'queryCallable' => function ( $specialClassName, $ctx, IDatabase $dbr, &$tables, &$fields,
257                                         &$conds, &$query_options, &$join_conds, $selectedValues ) {
258                $databaseQueryBuilder = ORESServices::getDatabaseQueryBuilder();
259                $condition = $databaseQueryBuilder->buildQuery(
260                    'goodfaith',
261                    $selectedValues
262                );
263                if ( $condition ) {
264                    $conds[] = $condition;
265
266                    // Filter out incompatible types; log actions and external rows are not scorable
267                    $conds[] = $dbr->expr( 'rc_type', '!=', [ RC_LOG, RC_EXTERNAL ] );
268                    // Make the joins INNER JOINs instead of LEFT JOINs
269                    $join_conds['ores_goodfaith_mdl'][0] = 'INNER JOIN';
270                    $join_conds['ores_goodfaith_cls'][0] = 'INNER JOIN';
271                    if ( self::shouldStraightJoin( $specialClassName ) ) {
272                        $query_options[] = 'STRAIGHT_JOIN';
273                    }
274                }
275            },
276        ] );
277
278        if ( isset( $filters['maybebad'] ) && isset( $filters['likelybad'] ) ) {
279            $goodfaithGroup->getFilter( 'maybebad' )->setAsSupersetOf(
280                $goodfaithGroup->getFilter( 'likelybad' )
281            );
282        }
283
284        if ( isset( $filters['likelybad'] ) && isset( $filters['verylikelybad'] ) ) {
285            $goodfaithGroup->getFilter( 'likelybad' )->setAsSupersetOf(
286                $goodfaithGroup->getFilter( 'verylikelybad' )
287            );
288        }
289
290        if ( isset( $filters['maybebad'] ) && isset( $filters['verylikelybad'] ) ) {
291            $goodfaithGroup->getFilter( 'maybebad' )->setAsSupersetOf(
292                $goodfaithGroup->getFilter( 'verylikelybad' )
293            );
294        }
295
296        $goodfaithGroup->conflictsWith(
297            $logFilter,
298            'ores-rcfilters-ores-conflicts-logactions-global',
299            'ores-rcfilters-goodfaith-conflicts-logactions',
300            'ores-rcfilters-logactions-conflicts-ores'
301        );
302
303        $clsp->registerFilterGroup( $goodfaithGroup );
304    }
305
306    private static function handleRevertrisk(
307        ChangesListSpecialPage $clsp,
308        $thresholdLookup,
309        ChangesListFilter $logFilter
310    ) {
311        $filters = self::getRevertriskStructuredFiltersOnChangesList(
312            $thresholdLookup->getThresholds( 'revertrisklanguageagnostic' )
313        );
314
315        if ( !$filters ) {
316            return;
317        }
318        $revertriskGroup = new ChangesListStringOptionsFilterGroup( [
319            'name' => 'revertrisklanguageagnostic',
320            'title' => 'ores-rcfilters-revertrisklanguageagnostic-title',
321            'whatsThisHeader' => 'ores-rcfilters-revertrisklanguageagnostic-whats-this-header',
322            'whatsThisBody' => 'ores-rcfilters-revertrisklanguageagnostic-whats-this-body',
323            'whatsThisUrl' => 'https://www.mediawiki.org/wiki/' .
324                'Special:MyLanguage/Help:New_filters_for_edit_review/Quality_and_Intent_Filters#Revert_risk',
325            'whatsThisLinkText' => 'ores-rcfilters-whats-this-link-text',
326            'priority' => 1,
327            'filters' => array_values( $filters ),
328            'default' => ChangesListStringOptionsFilterGroup::NONE,
329            'isFullCoverage' => false,
330            'queryCallable' => function (
331                $specialClassName,
332                $ctx,
333                IDatabase $dbr,
334                &$tables,
335                &$fields,
336                &$conds,
337                &$query_options,
338                &$join_conds,
339                $selectedValues ) {
340                $databaseQueryBuilder = ORESServices::getDatabaseQueryBuilder();
341                $condition = $databaseQueryBuilder->buildQuery(
342                    'revertrisklanguageagnostic',
343                    $selectedValues
344                );
345                if ( $condition ) {
346                    $conds[] = $condition;
347
348                    // Filter out incompatible types; log actions and external rows are not scorable
349                    $conds[] = $dbr->expr( 'rc_type', '!=', [ RC_LOG, RC_EXTERNAL ] );
350                    // Make the joins INNER JOINs instead of LEFT JOINs
351                    $join_conds['ores_revertrisklanguageagnostic_mdl'][0] = 'INNER JOIN';
352                    $join_conds['ores_revertrisklanguageagnostic_cls'][0] = 'INNER JOIN';
353                    if ( self::shouldStraightJoin( $specialClassName ) ) {
354                        $query_options[] = 'STRAIGHT_JOIN';
355                    }
356                }
357            },
358        ] );
359
360        $revertriskGroup->conflictsWith(
361            $logFilter,
362            'ores-rcfilters-ores-conflicts-logactions-global',
363            'ores-rcfilters-revertrisklanguageagnosticconflicts-logactions',
364            'ores-rcfilters-logactions-conflicts-ores'
365        );
366
367        $clsp->registerFilterGroup( $revertriskGroup );
368    }
369
370    private static function shouldStraightJoin( $specialClassName ) {
371        // Performance hack: add STRAIGHT_JOIN (T146111) but not for Watchlist (T176456 / T164796)
372        // New theory is that STRAIGHT JOIN should be used for unfiltered queries (RecentChanges)
373        // but not for filtered queries (Watchlist and RecentChangesLinked) (T179718)
374        return $specialClassName === 'SpecialRecentChanges';
375    }
376
377    private static function getDamagingStructuredFiltersOnChangesList( array $damagingLevels ) {
378        $filters = [];
379        if ( isset( $damagingLevels[ 'likelygood' ] ) ) {
380            $filters[ 'likelygood' ] = [
381                'name' => 'likelygood',
382                'label' => 'ores-rcfilters-damaging-likelygood-label',
383                'description' => 'ores-rcfilters-damaging-likelygood-desc',
384                'cssClassSuffix' => 'damaging-likelygood',
385                'isRowApplicableCallable' => self::makeApplicableCallback(
386                    'damaging',
387                    $damagingLevels['likelygood']
388                ),
389            ];
390        }
391        if ( isset( $damagingLevels[ 'maybebad' ] ) ) {
392            $filters[ 'maybebad' ] = [
393                'name' => 'maybebad',
394                'label' => 'ores-rcfilters-damaging-maybebad-label',
395                'description' => 'ores-rcfilters-damaging-maybebad-desc',
396                'cssClassSuffix' => 'damaging-maybebad',
397                'isRowApplicableCallable' => self::makeApplicableCallback(
398                    'damaging',
399                    $damagingLevels['maybebad']
400                ),
401            ];
402        }
403        if ( isset( $damagingLevels[ 'likelybad' ] ) ) {
404            $descMsg = isset( $filters[ 'maybebad' ] ) ?
405                'ores-rcfilters-damaging-likelybad-desc-low' :
406                'ores-rcfilters-damaging-likelybad-desc-high';
407            $filters[ 'likelybad' ] = [
408                'name' => 'likelybad',
409                'label' => 'ores-rcfilters-damaging-likelybad-label',
410                'description' => $descMsg,
411                'cssClassSuffix' => 'damaging-likelybad',
412                'isRowApplicableCallable' => self::makeApplicableCallback(
413                    'damaging',
414                    $damagingLevels['likelybad']
415                ),
416            ];
417        }
418        if ( isset( $damagingLevels[ 'verylikelybad' ] ) ) {
419            $filters[ 'verylikelybad' ] = [
420                'name' => 'verylikelybad',
421                'label' => 'ores-rcfilters-damaging-verylikelybad-label',
422                'description' => 'ores-rcfilters-damaging-verylikelybad-desc',
423                'cssClassSuffix' => 'damaging-verylikelybad',
424                'isRowApplicableCallable' => self::makeApplicableCallback(
425                    'damaging',
426                    $damagingLevels['verylikelybad']
427                ),
428            ];
429        }
430        return $filters;
431    }
432
433    private static function getGoodFaithStructuredFiltersOnChangesList( array $goodfaithLevels ) {
434        $filters = [];
435        if ( isset( $goodfaithLevels['likelygood'] ) ) {
436            $filters[ 'likelygood' ] = [
437                'name' => 'likelygood',
438                'label' => 'ores-rcfilters-goodfaith-good-label',
439                'description' => 'ores-rcfilters-goodfaith-good-desc',
440                'cssClassSuffix' => 'goodfaith-good',
441                'isRowApplicableCallable' => self::makeApplicableCallback(
442                    'goodfaith',
443                    $goodfaithLevels['likelygood']
444                ),
445            ];
446        }
447        if ( isset( $goodfaithLevels['maybebad'] ) ) {
448            $filters[ 'maybebad' ] = [
449                'name' => 'maybebad',
450                'label' => 'ores-rcfilters-goodfaith-maybebad-label',
451                'description' => 'ores-rcfilters-goodfaith-maybebad-desc',
452                'cssClassSuffix' => 'goodfaith-maybebad',
453                'isRowApplicableCallable' => self::makeApplicableCallback(
454                    'goodfaith',
455                    $goodfaithLevels['maybebad']
456                ),
457            ];
458        }
459        if ( isset( $goodfaithLevels['likelybad'] ) ) {
460            $descMsg = isset( $filters[ 'maybebad' ] ) ?
461                'ores-rcfilters-goodfaith-bad-desc-low' :
462                'ores-rcfilters-goodfaith-bad-desc-high';
463            $filters[ 'likelybad' ] = [
464                'name' => 'likelybad',
465                'label' => 'ores-rcfilters-goodfaith-bad-label',
466                'description' => $descMsg,
467                'cssClassSuffix' => 'goodfaith-bad',
468                'isRowApplicableCallable' => self::makeApplicableCallback(
469                    'goodfaith',
470                    $goodfaithLevels['likelybad']
471                ),
472            ];
473        }
474        if ( isset( $goodfaithLevels['verylikelybad'] ) ) {
475            $filters[ 'verylikelybad' ] = [
476                'name' => 'verylikelybad',
477                'label' => 'ores-rcfilters-goodfaith-verylikelybad-label',
478                'description' => 'ores-rcfilters-goodfaith-verylikelybad-desc',
479                'cssClassSuffix' => 'goodfaith-verylikelybad',
480                'isRowApplicableCallable' => self::makeApplicableCallback(
481                    'goodfaith',
482                    $goodfaithLevels['verylikelybad']
483                ),
484            ];
485        }
486
487        return $filters;
488    }
489
490    private static function getRevertriskStructuredFiltersOnChangesList( array $revertriskLevels ) {
491        $filters = [];
492        if ( isset( $revertriskLevels['revertrisk'] ) ) {
493            $filters[ 'revertrisk' ] = [
494                'name' => 'revertrisk',
495                'label' => 'ores-rcfilters-revertrisklanguageagnostic-revertrisk-label',
496                'description' => 'ores-rcfilters-revertrisklanguageagnostic-revertrisk-desc',
497                'cssClassSuffix' => 'revertrisklanguageagnostic-revertrisk',
498                'isRowApplicableCallable' => self::makeApplicableCallback(
499                    'revertrisk',
500                    $revertriskLevels['revertrisk']
501                ),
502            ];
503        }
504
505        return $filters;
506    }
507
508    public function onChangesListSpecialPageQuery(
509        $name, &$tables, &$fields,
510        &$conds, &$query_options, &$join_conds, $opts
511    ) {
512        if ( !Helpers::oresUiEnabled() ) {
513            return;
514        }
515        try {
516            if ( Helpers::isModelEnabled( 'damaging' ) ) {
517                Helpers::joinWithOresTables( 'damaging', 'rc_this_oldid', $tables, $fields,
518                    $join_conds );
519            }
520            if ( Helpers::isModelEnabled( 'goodfaith' ) ) {
521                Helpers::joinWithOresTables( 'goodfaith', 'rc_this_oldid', $tables, $fields,
522                    $join_conds );
523            }
524            if ( Helpers::isModelEnabled( 'revertrisklanguageagnostic' ) ) {
525                Helpers::joinWithOresTables( 'revertrisklanguageagnostic', 'rc_this_oldid', $tables, $fields,
526                    $join_conds );
527            }
528        } catch ( Exception $exception ) {
529            return;
530        }
531    }
532
533    /**
534     * Label recent changes with ORES scores (for each change in an expanded group)
535     *
536     * @param EnhancedChangesList $ecl
537     * @param array &$data
538     * @param RecentChange[] $block
539     * @param RecentChange $rcObj
540     * @param string[] &$classes
541     * @param string[] &$attribs
542     */
543    public function onEnhancedChangesListModifyLineData(
544        $ecl,
545        &$data,
546        $block,
547        $rcObj,
548        &$classes,
549        &$attribs
550    ) {
551        if ( !Helpers::oresUiEnabled() ) {
552            return;
553        }
554
555        self::processRecentChangesList( $rcObj, $data, $classes, $ecl->getContext() );
556    }
557
558    /**
559     * Label recent changes with ORES scores (for top-level ungrouped lines)
560     *
561     * @param EnhancedChangesList $ecl
562     * @param array &$data
563     * @param RecentChange $rcObj
564     */
565    public function onEnhancedChangesListModifyBlockLineData(
566        $ecl,
567        &$data,
568        $rcObj
569    ) {
570        if ( !Helpers::oresUiEnabled() ) {
571            return;
572        }
573
574        $classes = [];
575        self::processRecentChangesList( $rcObj, $data, $classes, $ecl->getContext() );
576        $data['attribs']['class'] = array_merge( $data['attribs']['class'], $classes );
577    }
578
579    /**
580     * Internal helper to label matching rows
581     *
582     * @param RecentChange $rcObj
583     * @param string[] &$data
584     * @param string[] &$classes
585     * @param IContextSource $context
586     */
587    protected static function processRecentChangesList(
588        RecentChange $rcObj,
589        array &$data,
590        array &$classes,
591        IContextSource $context
592    ) {
593        $damaging = self::getScoreRecentChangesList( $rcObj, $context );
594
595        if ( $damaging && Helpers::isDamagingFlagEnabled( $context ) ) {
596            $classes[] = 'damaging';
597            $data['recentChangesFlags']['damaging'] = true;
598        }
599    }
600
601    /**
602     * Hook for formatting recent changes links
603     * @see https://www.mediawiki.org/wiki/Manual:Hooks/OldChangesListRecentChangesLine
604     *
605     * @param ChangesList $changesList
606     * @param string &$s
607     * @param RecentChange $rc
608     * @param string[] &$classes
609     * @param string[] &$attribs
610     * @return bool|void
611     */
612    public function onOldChangesListRecentChangesLine(
613        $changesList,
614        &$s,
615        $rc,
616        &$classes,
617        &$attribs
618    ) {
619        if ( !Helpers::oresUiEnabled() ) {
620            return;
621        }
622
623        $damaging = self::getScoreRecentChangesList( $rc, $changesList->getContext() );
624        if ( $damaging ) {
625            // Add highlight class
626            if ( Helpers::isHighlightEnabled( $changesList ) ) {
627                $classes[] = 'ores-highlight';
628            }
629
630            // Add damaging class and flag
631            if ( Helpers::isDamagingFlagEnabled( $changesList ) ) {
632                $classes[] = 'damaging';
633
634                $separator = ' <span class="mw-changeslist-separator"></span> ';
635                if ( strpos( $s, $separator ) === false ) {
636                    return;
637                }
638
639                $parts = explode( $separator, $s );
640                $parts[1] = ChangesList::flag( 'damaging' ) . $parts[1];
641                $s = implode( $separator, $parts );
642            }
643        }
644    }
645
646    /**
647     * Check if we should flag a row. As a side effect, also adds score data for this row.
648     * @param RecentChange $rcObj
649     * @param IContextSource $context
650     * @return bool
651     */
652    public static function getScoreRecentChangesList( RecentChange $rcObj, IContextSource $context ) {
653        $threshold = $rcObj->getAttribute( 'ores_damaging_threshold' );
654        if ( $threshold === null ) {
655            try {
656                $threshold =
657                    Helpers::getThreshold( 'damaging', $context->getUser(), $context->getTitle() );
658            } catch ( Exception $exception ) {
659                return false;
660            }
661
662        }
663        $score = $rcObj->getAttribute( 'ores_damaging_score' );
664        $patrolled = $rcObj->getAttribute( 'rc_patrolled' );
665        $type = $rcObj->getAttribute( 'rc_type' );
666
667        // Log actions and external rows are not scorable; if such a row does have a score, ignore it
668        if ( !$score || $threshold === null || in_array( $type, [ RC_LOG, RC_EXTERNAL ] ) ) {
669            // Shorten out
670            return false;
671        }
672
673        $score = (float)$score;
674        Helpers::addRowData( $context, $rcObj->getAttribute( 'rc_this_oldid' ), $score,
675            'damaging' );
676
677        return $score >= $threshold && !$patrolled;
678    }
679
680    private static function makeApplicableCallback( $model, array $levelData ) {
681        return static function ( $ctx, RecentChange $rc ) use ( $model, $levelData ) {
682            $score = $rc->getAttribute( "ores_{$model}_score" );
683            $type = $rc->getAttribute( 'rc_type' );
684            // Log actions and external rows are not scorable; if such a row does have a score, ignore it
685            if ( $score === null || in_array( $type, [ RC_LOG, RC_EXTERNAL ] ) ) {
686                return false;
687            }
688            return $levelData['min'] <= $score && $score <= $levelData['max'];
689        };
690    }
691
692}