Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 324
0.00% covered (danger)
0.00%
0 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
SpecialEditChecks
0.00% covered (danger)
0.00%
0 / 324
0.00% covered (danger)
0.00%
0 / 14
5256
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 getGroupName
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isListed
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 execute
0.00% covered (danger)
0.00%
0 / 85
0.00% covered (danger)
0.00%
0 / 1
182
 outputSection
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 collectChecks
0.00% covered (danger)
0.00%
0 / 41
0.00% covered (danger)
0.00%
0 / 1
132
 buildTableHtml
0.00% covered (danger)
0.00%
0 / 41
0.00% covered (danger)
0.00%
0 / 1
42
 buildRowHtml
0.00% covered (danger)
0.00%
0 / 44
0.00% covered (danger)
0.00%
0 / 1
110
 buildEditCheckActionWidget
0.00% covered (danger)
0.00%
0 / 62
0.00% covered (danger)
0.00%
0 / 1
210
 getConfigValueFromData
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
56
 configDetails
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
12
 matchItemDetails
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
2
 jsonTableFromObjectString
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 jsonTable
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2/**
3 * Special page listing Edit Checks and their configuration.
4 */
5
6namespace MediaWiki\Extension\VisualEditor;
7
8use MediaWiki\Config\Config;
9use MediaWiki\Config\ConfigFactory;
10use MediaWiki\Content\JsonContent;
11use MediaWiki\Extension\VisualEditor\EditCheck\ResourceLoaderData;
12use MediaWiki\Html\Html;
13use MediaWiki\Html\TocGeneratorTrait;
14use MediaWiki\Language\RawMessage;
15use MediaWiki\MediaWikiServices;
16use MediaWiki\Parser\Sanitizer;
17use MediaWiki\SpecialPage\SpecialPage;
18use MediaWiki\Title\Title;
19use OOUI\MessageWidget;
20
21class SpecialEditChecks extends SpecialPage {
22    use TocGeneratorTrait;
23
24    private readonly Config $config;
25    private readonly EditCheckFileParser $parser;
26
27    /**
28     * @inheritDoc
29     */
30    public function __construct(
31        private readonly Config $coreConfig,
32        ConfigFactory $configFactory
33    ) {
34        parent::__construct( 'EditChecks' );
35
36        $this->config = $configFactory->makeConfig( 'visualeditor' );
37        $this->parser = new EditCheckFileParser( $this->getContext() );
38    }
39
40    /**
41     * @inheritDoc
42     */
43    protected function getGroupName() {
44        return 'wiki';
45    }
46
47    /**
48     * @inheritDoc
49     */
50    public function isListed() {
51        return (bool)$this->getConfig()->get( 'VisualEditorEditCheck' );
52    }
53
54    /**
55     * @inheritDoc
56     */
57    public function execute( $par ) {
58        $this->setHeaders();
59        $out = $this->getOutput();
60        if ( !$this->getConfig()->get( 'VisualEditorEditCheck' ) ) {
61            $out->addHTML( Html::element( 'p', [], $this->msg( 'editcheck-specialeditchecks-disabled' )->text() ) );
62            return;
63        }
64        $out->enableOOUI();
65        $out->addModuleStyles( [
66            'oojs-ui.styles.icons-content',
67            'oojs-ui.styles.icons-interactions',
68            'ext.visualEditor.editCheck.special',
69            'mediawiki.content.json'
70        ] );
71
72        $contentLang = $this->getContext()->getLanguage()->getCode();
73        $dir = dirname( __DIR__ );
74        $baseDir = $dir . '/editcheck/modules/editchecks';
75        $checksDir = $baseDir . '/checks';
76        $abstractClasses = [
77            'BaseEditCheck.js',
78            'AsyncTextCheck.js',
79        ];
80        $onWikiConfig = ResourceLoaderData::getConfig( $this->getContext() );
81
82        $out->addHtml( $this->msg( 'editcheck-specialeditchecks-info' )->parseAsBlock() );
83
84        $abChecks = [];
85        $unsupportedChecks = [];
86        $defaultChecks = $this->collectChecks(
87            $checksDir . '/*.js', $abstractClasses, false, true, null, $onWikiConfig
88        );
89        $disabledChecks = $this->collectChecks(
90            $checksDir . '/*.js', $abstractClasses, false, false, false, $onWikiConfig
91        );
92        $experimentalEnabledChecks = $this->collectChecks(
93            $checksDir . '/*.js', [], false, false, true, $onWikiConfig
94        );
95        $abTest = MediaWikiServices::getInstance()->getMainConfig()->get( 'VisualEditorEditCheckABTest' );
96        if ( $abTest !== null ) {
97            foreach ( [ &$defaultChecks, &$disabledChecks, &$experimentalEnabledChecks ] as &$checks ) {
98                foreach ( $checks as $i => $check ) {
99                    // Extract AB test check
100                    if ( $check['name'] === (string)$abTest ) {
101                        $abChecks[] = $check;
102                        unset( $checks[$i] );
103                    }
104                    // Extract unsupported checks (not in allowedContentLanguages)
105                    if ( $check['allowedContentLanguages'] ) {
106                        if ( !in_array( $contentLang, $check['allowedContentLanguages'], true ) ) {
107                            $unsupportedChecks[] = $check;
108                            unset( $checks[$i] );
109                        }
110                    }
111                }
112            }
113        }
114
115        $this->outputSection( 'default-checks', $this->msg( 'editcheck-specialeditchecks-header-default' )->text() );
116        $out->addHTML( $this->buildTableHtml( $defaultChecks, $onWikiConfig ) );
117
118        if ( $abChecks ) {
119            $this->outputSection( 'abtest-checks', $this->msg( 'editcheck-specialeditchecks-header-abtest' )->text() );
120            $out->addHTML( $this->buildTableHtml( $abChecks, $onWikiConfig ) );
121        }
122
123        if ( $this->coreConfig->get( 'VisualEditorEnableEditCheckSuggestionsBeta' ) ) {
124            // Split beta and experimental checks based on if they are enabled by default
125            $out->addHTML( Html::rawElement( 'h2', [],
126                Html::element( 'a', [
127                    'href' => $this->getTitleFor( 'Preferences' )->getLocalURL() . '#mw-prefsection-betafeatures',
128                ],
129                $this->msg( 'editcheck-specialeditchecks-header-betafeatures' )->text() ) )
130            );
131            $out->addHTML( $this->buildTableHtml( $experimentalEnabledChecks, $onWikiConfig, true ) );
132
133            $this->outputSection(
134                'experimental-checks',
135                $this->msg( 'editcheck-specialeditchecks-header-experimental' )->text()
136            );
137            $out->addHTML( $this->buildTableHtml( $disabledChecks, $onWikiConfig, true ) );
138        } else {
139            $allExperimentalChecks = array_merge( $experimentalEnabledChecks, $disabledChecks );
140            // Sort checks by 'name' property
141            usort( $allExperimentalChecks, static function ( $a, $b ) {
142                return strcmp( $a['name'], $b['name'] );
143            } );
144            $this->outputSection(
145                'experimental-checks',
146                $this->msg( 'editcheck-specialeditchecks-header-experimental' )->text()
147            );
148            $out->addHTML( $this->buildTableHtml( $allExperimentalChecks, $onWikiConfig, true ) );
149        }
150
151        if ( $unsupportedChecks ) {
152            $this->outputSection(
153                'unsupported-checks',
154                $this->msg( 'editcheck-specialeditchecks-header-unsupported' )->text()
155            );
156            $out->addHTML( $this->buildTableHtml( $unsupportedChecks, $onWikiConfig ) );
157        }
158
159        $baseCheck = $this->collectChecks( $baseDir . '/BaseEditCheck.js', [], true );
160        if ( isset( $baseCheck[0]['defaultConfig'] ) ) {
161            $this->outputSection( 'base-check', $this->msg( 'editcheck-specialeditchecks-header-base' )->text() );
162            $out->addHTML( $this->configDetails(
163                $this->jsonTableFromObjectString( $baseCheck[ 0 ]['defaultConfig'] ),
164                isset( $onWikiConfig['*'] ) ? $this->jsonTable( $onWikiConfig['*'] ) : ''
165            ) );
166        }
167
168        $out->addTOCPlaceholder( $this->getTocData() );
169    }
170
171    /**
172     * Output a section header and add it to the TOC.
173     *
174     * @param string $id Section ID
175     * @param string $label Section label
176     */
177    private function outputSection( string $id, string $label ): void {
178        $out = $this->getOutput();
179        $out->addHTML( Html::element( 'h2', [ 'id' => Sanitizer::escapeIdForLink( $id ) ], $label ) );
180        $this->addTocSection( $id, 'rawmessage', $label );
181    }
182
183    /**
184     * Collect edit checks from the given directory.
185     *
186     * @param string $glob Glob pattern for files
187     * @param array $excludeFiles List of filenames to exclude
188     * @param bool $includeAbstract Whether to include abstract classes
189     * @param bool|null $showAsCheck If a boolean, only checks whose 'showAsCheck' value matches this
190     * @param bool|null $showAsSuggestion If a boolean, only checks whose 'showAsSuggestion' value matches this
191     * @return array List of edit checks with metadata
192     */
193    private function collectChecks(
194        string $glob, array $excludeFiles = [], bool $includeAbstract = false,
195        ?bool $showAsCheck = null, ?bool $showAsSuggestion = null, array $onWikiConfig = []
196    ): array {
197        $checks = [];
198        $files = glob( $glob ) ?: [];
199        foreach ( $files as $file ) {
200            if ( in_array( basename( $file ), $excludeFiles, true ) ) {
201                continue;
202            }
203            $src = file_get_contents( $file );
204            if ( $src === false ) {
205                continue;
206            }
207
208            $name = $this->parser->extractStaticValue( $src, 'name' );
209
210            // Skip abstract classes (those without a name)
211            if ( !$includeAbstract && $name === '' ) {
212                continue;
213            }
214
215            $checkData = [
216                'file' => $file,
217                'name' => $name,
218                'mode' => '',
219                'title' => $this->parser->extractStaticValue( $src, 'title' ),
220                'description' => $this->parser->extractStaticValue( $src, 'description' ),
221                'prompt' => $this->parser->extractStaticValue( $src, 'prompt' ),
222                'footer' => $this->parser->extractStaticValue( $src, 'footer' ),
223                'footerIcon' => $this->parser->extractStaticValue( $src, 'footerIcon' ),
224                'choices' => $this->parser->extractStaticValue( $src, 'choices' ),
225                'allowedContentLanguages' => $this->parser->extractStaticValue( $src, 'allowedContentLanguages' ),
226                'defaultConfig' => $this->parser->extractDefaultConfig( $src ),
227            ];
228
229            // Filter by showAsCheck value if requested
230            if ( $showAsCheck !== null ) {
231                $showAsCheckValue = $this->getConfigValueFromData( $checkData, $onWikiConfig, 'showAsCheck' ) ?? true;
232                if ( $showAsCheckValue !== $showAsCheck ) {
233                    continue;
234                }
235            }
236
237            // Filter by showAsSuggestion value if requested
238            if ( $showAsSuggestion !== null ) {
239                $showAsSuggestionValue =
240                    $this->getConfigValueFromData( $checkData, $onWikiConfig, 'showAsSuggestion' ) ?? true;
241                if ( $showAsSuggestionValue !== $showAsSuggestion ) {
242                    continue;
243                }
244            }
245
246            $checkData['extraModes'] = $this->parser->findExtraActionModes( $src, $checkData );
247            $checks[] = $checkData;
248        }
249        usort( $checks, static function ( $a, $b ) {
250            return strcmp( basename( $a['file'] ), basename( $b['file'] ) );
251        } );
252        return $checks;
253    }
254
255    /**
256     * Build HTML table listing the given edit checks.
257     *
258     * @param array $checks List of edit checks
259     * @param array $onWikiConfig On-wiki configuration overrides
260     * @param bool $suggestions
261     * @return string
262     */
263    private function buildTableHtml(
264        array $checks, array $onWikiConfig, bool $suggestions = false
265    ): string {
266        if ( !$checks ) {
267            return Html::element( 'p', [], $this->msg( 'table_pager_empty' )->text() );
268        }
269        $html = Html::openElement( 'table', [ 'class' => 'wikitable mw-editchecks' ] );
270        $html .= Html::rawElement( 'tr', [ 'class' => 'mw-editchecks-header' ],
271            Html::element( 'th', [ 'class' => 'mw-editchecks-name' ],
272                $this->msg( 'editcheck-specialeditchecks-col-name' )->text() ) .
273            Html::element( 'th', [ 'class' => 'mw-editchecks-appearance' ],
274                $this->msg( 'editcheck-specialeditchecks-col-appearance' )->text() ) .
275            Html::element( 'th', [ 'class' => 'mw-editchecks-config' ],
276                $this->msg( 'editcheck-specialeditchecks-config-summary' )->text() )
277        );
278        foreach ( $checks as $checkData ) {
279            $html .= $this->buildRowHtml( $checkData, $onWikiConfig, $suggestions );
280            if ( $checkData['name'] === 'textMatch' ) {
281                $matchRules = $this->getConfigValueFromData( $checkData, $onWikiConfig, 'matchRules' )
282                    // In T424678 we renamed matchItems to matchRules, but allow 'matchItems'
283                    // for backwards compatibility temporarily
284                    ?? $this->getConfigValueFromData( $checkData, $onWikiConfig, 'matchItems' )
285                    ?? [];
286                foreach ( $matchRules as $name => $item ) {
287                    $importTitle = null;
288                    if ( isset( $item['import'] ) ) {
289                        $importTitle = Title::newFromText( $item['import'] );
290                        $item = json_decode( $this->msg( $importTitle->getText() )->inContentLanguage()->text(), true );
291                    }
292                    $matchCheckData = [
293                        'file' => '',
294                        'name' => $checkData['name'] . " ($name)",
295                        'mode' => $item['mode'] ?? '',
296                        'title' => $item['title'] ?? '',
297                        'description' => new \OOUI\HtmlSnippet( ( new RawMessage( $item['message'] ?? '' ) )->parse() ),
298                        'prompt' => $item['prompt'] ?? '',
299                        'footer' => $item['footer'] ?? '',
300                        'choices' => $checkData['choices'] ?? [],
301                        'allowedContentLanguages' => '',
302                        'defaultConfig' => json_encode( $item['config'] ?? '' ),
303                        'matchItem' => $item,
304                        'importTitle' => $importTitle,
305                    ];
306                    $html .= $this->buildRowHtml( $matchCheckData, $onWikiConfig, $suggestions );
307                }
308            }
309        }
310        $html .= Html::closeElement( 'table' );
311        return $html;
312    }
313
314    /**
315     * Build HTML for a single edit check row.
316     *
317     * @param array $checkData Edit check data
318     * @param array $onWikiConfig On-wiki configuration overrides
319     * @param bool $suggestions
320     * @return string Row HTML or empty string if filtered out
321     */
322    private function buildRowHtml(
323        array $checkData, array $onWikiConfig, bool $suggestions = false
324    ): string {
325        $html = '';
326        $override = '';
327        if ( isset( $onWikiConfig[$checkData['name']] ) ) {
328            $override = $this->jsonTable( $onWikiConfig[$checkData['name']] );
329        }
330        $defaultConfig = '';
331        if ( $checkData['defaultConfig'] ) {
332            $defaultConfig = $this->jsonTableFromObjectString( $checkData['defaultConfig'] );
333        }
334
335        if ( empty( $checkData['title'] ) && empty( $checkData['description'] ) ) {
336            $widget = '';
337        } else {
338            $widget = $this->buildEditCheckActionWidget( $checkData, $suggestions );
339        }
340        $this->addTocSubSection( $checkData['name'], 'rawmessage', $checkData['name'] );
341
342        foreach ( $checkData['extraModes'] ?? [] as $modeCheckData ) {
343            $widget .= $this->buildEditCheckActionWidget( $modeCheckData, $suggestions );
344        }
345
346        $html .= Html::rawElement( 'tr', [],
347            Html::rawElement( 'td', [],
348                Html::element( 'strong', [
349                    'id' => Sanitizer::escapeIdForLink( $checkData['name'] )
350                ], $checkData['name'] ) .
351                Html::element( 'div', [], basename( $checkData['file'] ) )
352            ) .
353            Html::rawElement( 'td', [], $widget ) .
354            Html::rawElement( 'td', [],
355                ( isset( $checkData['importTitle'] ) ?
356                    Html::rawElement( 'p', [],
357                        $this->msg( 'editcheck-specialeditchecks-imported-configs' )
358                            ->rawParams(
359                                Html::element(
360                                    'a',
361                                    [ 'href' => $checkData['importTitle']->getLocalURL() ],
362                                    $checkData['importTitle']->getPrefixedText()
363                                )
364                            )
365                            ->parse()
366                    ) : ''
367                ) .
368                ( $defaultConfig !== '' || $override !== '' ?
369                    $this->configDetails( $defaultConfig, $override ) : ''
370                ) .
371                ( !empty( $checkData['matchItem'] ) ?
372                    $this->matchItemDetails( $checkData['matchItem'] ) : ''
373                )
374            )
375        );
376        return $html;
377    }
378
379    private function buildEditCheckActionWidget( array $checkData, bool $suggestion ): string {
380        $widget = new MessageWidget(
381            [
382                'type' => $suggestion ? 'progressive' : 'warning',
383                'icon' => $suggestion ? 'lightbulb' : null,
384                'label' => $checkData['title'] ?: "\u{00A0}",
385                'classes' => [ 've-ui-editCheckActionWidget' ]
386            ]
387        );
388        if ( $suggestion ) {
389            $widget->clearFlags()->setFlags( [ 'progressive' ] );
390        }
391        if ( $suggestion ) {
392            $widget->addClasses( [ 've-ui-editCheckActionWidget-suggestion' ] );
393        }
394        $actions = new \OOUI\Tag( 'div' );
395        $actions->addClasses( [ 've-ui-editCheckActionWidget-actions' ]    );
396        if ( $checkData['prompt'] ) {
397            $actions
398                ->addClasses( [ 've-ui-editCheckActionWidget-actions-prompted' ] )
399                ->appendContent(
400                    new \OOUI\LabelWidget( [
401                        'label' => $checkData['prompt'],
402                        'classes' => [ 've-ui-editCheckActionWidget-prompt' ]
403                    ] ),
404                );
405        }
406        $body = ( new \OOUI\Tag( 'div' ) )->addClasses( [ 've-ui-editCheckActionWidget-body' ] );
407        $widget->appendContent(
408            $body
409                ->appendContent( new \OOUI\LabelWidget( [ 'label' => $checkData['description'] ] ) )
410                ->appendContent( $actions )
411        );
412        if ( $checkData['footer'] ) {
413            if ( $checkData['footerIcon'] ) {
414                $body->appendContent(
415                    new \OOUI\MessageWidget( [
416                        'icon' => $checkData['footerIcon'],
417                        'label' => $checkData['footer'],
418                        'inline' => true,
419                        'classes' => [ 've-ui-editCheckActionWidget-footer' ]
420                    ] )
421                );
422            } else {
423                $body->appendContent(
424                    new \OOUI\LabelWidget( [
425                        'label' => $checkData['footer'],
426                        'classes' => [ 've-ui-editCheckActionWidget-footer' ]
427                    ] ),
428                );
429            }
430        }
431
432        if ( !empty( $checkData['choices'] ) ) {
433            foreach ( $checkData['choices'] as $choice ) {
434                // Filter by mode
435                if (
436                    isset( $choice['modes'] ) && is_array( $choice['modes'] ) &&
437                    !in_array( $checkData['mode'], $choice['modes'], true )
438                ) {
439                    continue;
440                }
441                $actionButton = new \OOUI\ButtonWidget( [
442                    'label' => $choice[ 'label' ],
443                    'flags' => $choice['flags'] ?? [],
444                    'icon' => $choice['icon'] ?? null,
445                    'classes' => [ 'oo-ui-actionWidget' ],
446                ] );
447                $actions->appendContent( $actionButton );
448            }
449        }
450        return Html::rawElement(
451            'div',
452            [ 'class' => 've-ui-editCheckDialog' ],
453            (string)$widget
454        );
455    }
456
457    /**
458     * Get a configuration value for a given check from on-wiki config or default config.
459     *
460     * @param array $checkData Check metadata
461     * @param array $onWikiConfig On-wiki configuration overrides
462     * @param string $key Configuration key to retrieve
463     * @return mixed|null JSON encoded value or null if not found
464     */
465    private function getConfigValueFromData( array $checkData, array $onWikiConfig, string $key ) {
466        // Check on-wiki config first
467        if ( isset( $onWikiConfig[$checkData['name']] ) &&
468            is_array( $onWikiConfig[$checkData['name']] ) &&
469            array_key_exists( $key, $onWikiConfig[$checkData['name']] )
470        ) {
471            return $onWikiConfig[$checkData['name']][$key];
472        } elseif ( $checkData['defaultConfig'] !== '' ) {
473            // Fallback to default config
474            $defaultConfig = $this->parser->tryJsonDecodeObjectString( $checkData['defaultConfig'] );
475            if ( is_array( $defaultConfig ) && array_key_exists( $key, $defaultConfig ) ) {
476                return $defaultConfig[$key];
477            }
478        }
479        return null;
480    }
481
482    /**
483     * Build the details element showing default and on-wiki configuration.
484     *
485     * @param string $defaultConfig Default configuration display
486     * @param string $override On-wiki override display
487     * @return string
488     */
489    private function configDetails( string $defaultConfig, string $override ): string {
490        return ( $defaultConfig !== '' ?
491            Html::element( 'strong', [ 'class' => 'mw-editchecks-config-header' ],
492                $this->msg( 'editcheck-specialeditchecks-config-default' )->text() ) .
493            $defaultConfig
494        : '' ) .
495        ( $override !== '' ?
496            Html::rawElement( 'details', [],
497                Html::rawElement( 'summary', [],
498                    Html::element( 'strong', [ 'class' => 'mw-editchecks-config-header' ],
499                        $this->msg( 'editcheck-specialeditchecks-config-onwiki' )->text() )
500                ) .
501                $override
502            )
503            : ''
504        );
505    }
506
507    /**
508     * Build the details element showing a textMatch matchItem configuration.
509     *
510     * @param array $matchItem Match item data
511     * @return string
512     */
513    private function matchItemDetails( array $matchItem ): string {
514        // Skip already displayed fields
515        $matchItemFiltered = array_filter(
516            $matchItem,
517            static function ( $key ) {
518                return !in_array( $key, [ 'config', 'title', 'message', 'prompt', 'footer' ], true );
519            },
520            ARRAY_FILTER_USE_KEY
521        );
522
523        return Html::rawElement( 'details', [],
524            Html::rawElement( 'summary', [],
525                Html::element( 'strong', [ 'class' => 'mw-editchecks-config-header' ],
526                    $this->msg( 'editcheck-specialeditchecks-config-matchitem' )->text() )
527            ) .
528            $this->jsonTable( $matchItemFiltered )
529        );
530    }
531
532    /**
533     * Attempt to convert a JS object literal to valid JSON for display.
534     * Returns pretty-printed JSON string or null on failure.
535     *
536     * @param string $js JS object literal
537     * @return string
538     */
539    private function jsonTableFromObjectString( string $js ): string {
540        $data = $this->parser->tryJsonDecodeObjectString( $js );
541
542        return $data === null ? $js : $this->jsonTable( $data );
543    }
544
545    /**
546     * Format a JSON value as a table.
547     *
548     * @param mixed $value
549     * @return string
550     */
551    private function jsonTable( $value ): string {
552        $json = json_encode( $value );
553        $content = new JsonContent( $json );
554        return $content->rootValueTable( $content->getData()->getValue() );
555    }
556}