Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
90.80% covered (success)
90.80%
227 / 250
68.75% covered (warning)
68.75%
11 / 16
CRAP
0.00% covered (danger)
0.00%
0 / 1
SpecialConstraintReport
90.80% covered (success)
90.80%
227 / 250
68.75% covered (warning)
68.75%
11 / 16
33.85
0.00% covered (danger)
0.00%
0 / 1
 factory
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
2
 __construct
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
1
 getModules
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 getGroupName
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getDescription
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 execute
84.78% covered (warning)
84.78%
39 / 46
0.00% covered (danger)
0.00%
0 / 1
9.29
 buildEntityIdForm
94.44% covered (success)
94.44%
17 / 18
0.00% covered (danger)
0.00%
0 / 1
1.00
 buildNotice
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
2
 getExplanationText
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
1
 buildResultTable
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
1 / 1
2
 appendToResultTable
94.59% covered (success)
94.59%
35 / 37
0.00% covered (danger)
0.00%
0 / 1
3.00
 buildResultHeader
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 buildSummary
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
5
 formatStatus
100.00% covered (success)
100.00%
33 / 33
100.00% covered (success)
100.00%
1 / 1
2
 getClaimLink
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 getClaimUrl
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare( strict_types = 1 );
4
5namespace WikibaseQuality\ConstraintReport\Specials;
6
7use HtmlArmor;
8use InvalidArgumentException;
9use MediaWiki\Config\Config;
10use MediaWiki\Html\Html;
11use MediaWiki\HTMLForm\Field\HTMLTextField;
12use MediaWiki\HTMLForm\HTMLForm;
13use MediaWiki\SpecialPage\SpecialPage;
14use OOUI\IconWidget;
15use OOUI\LabelWidget;
16use UnexpectedValueException;
17use Wikibase\DataModel\Entity\EntityId;
18use Wikibase\DataModel\Entity\EntityIdParser;
19use Wikibase\DataModel\Entity\EntityIdParsingException;
20use Wikibase\DataModel\Entity\ItemId;
21use Wikibase\DataModel\Entity\NumericPropertyId;
22use Wikibase\DataModel\Services\EntityId\EntityIdFormatter;
23use Wikibase\DataModel\Services\Lookup\EntityLookup;
24use Wikibase\Lib\LanguageFallbackChainFactory;
25use Wikibase\Lib\Store\EntityTitleLookup;
26use Wikibase\Repo\EntityIdLabelFormatterFactory;
27use Wikibase\View\EntityIdFormatterFactory;
28use WikibaseQuality\ConstraintReport\ConstraintCheck\DelegatingConstraintChecker;
29use WikibaseQuality\ConstraintReport\ConstraintCheck\Message\ViolationMessageRenderer;
30use WikibaseQuality\ConstraintReport\ConstraintCheck\Message\ViolationMessageRendererFactory;
31use WikibaseQuality\ConstraintReport\ConstraintCheck\Result\CheckResult;
32use WikibaseQuality\ConstraintReport\Html\HtmlTableBuilder;
33use WikibaseQuality\ConstraintReport\Html\HtmlTableCellBuilder;
34use WikibaseQuality\ConstraintReport\Html\HtmlTableHeaderBuilder;
35use Wikimedia\Stats\IBufferingStatsdDataFactory;
36
37/**
38 * Special page that displays all constraints that are defined on an Entity with additional information
39 * (whether it complied or was a violation, which parameters the constraint has etc.).
40 *
41 * @author BP2014N1
42 * @license GPL-2.0-or-later
43 */
44class SpecialConstraintReport extends SpecialPage {
45
46    private EntityIdParser $entityIdParser;
47    private EntityLookup $entityLookup;
48    private EntityTitleLookup $entityTitleLookup;
49    private EntityIdFormatter $entityIdLabelFormatter;
50    private EntityIdFormatter $entityIdLinkFormatter;
51    private DelegatingConstraintChecker $constraintChecker;
52    private ViolationMessageRenderer $violationMessageRenderer;
53    private Config $config;
54    private IBufferingStatsdDataFactory $dataFactory;
55
56    public static function factory(
57        Config $config,
58        IBufferingStatsdDataFactory $dataFactory,
59        EntityIdFormatterFactory $entityIdHtmlLinkFormatterFactory,
60        EntityIdLabelFormatterFactory $entityIdLabelFormatterFactory,
61        EntityIdParser $entityIdParser,
62        EntityTitleLookup $entityTitleLookup,
63        LanguageFallbackChainFactory $languageFallbackChainFactory,
64        EntityLookup $entityLookup,
65        DelegatingConstraintChecker $delegatingConstraintChecker,
66        ViolationMessageRendererFactory $violationMessageRendererFactory
67    ): self {
68        return new self(
69            $entityLookup,
70            $entityTitleLookup,
71            $entityIdLabelFormatterFactory,
72            $entityIdHtmlLinkFormatterFactory,
73            $entityIdParser,
74            $languageFallbackChainFactory,
75            $delegatingConstraintChecker,
76            $violationMessageRendererFactory,
77            $config,
78            $dataFactory
79        );
80    }
81
82    public function __construct(
83        EntityLookup $entityLookup,
84        EntityTitleLookup $entityTitleLookup,
85        EntityIdLabelFormatterFactory $entityIdLabelFormatterFactory,
86        EntityIdFormatterFactory $entityIdHtmlLinkFormatterFactory,
87        EntityIdParser $entityIdParser,
88        LanguageFallbackChainFactory $languageFallbackChainFactory,
89        DelegatingConstraintChecker $constraintChecker,
90        ViolationMessageRendererFactory $violationMessageRendererFactory,
91        Config $config,
92        IBufferingStatsdDataFactory $dataFactory
93    ) {
94        parent::__construct( 'ConstraintReport' );
95
96        $this->entityLookup = $entityLookup;
97        $this->entityTitleLookup = $entityTitleLookup;
98        $this->entityIdParser = $entityIdParser;
99
100        $language = $this->getLanguage();
101
102        $this->entityIdLabelFormatter = $entityIdLabelFormatterFactory->getEntityIdFormatter(
103            $language
104        );
105
106        $this->entityIdLinkFormatter = $entityIdHtmlLinkFormatterFactory->getEntityIdFormatter(
107            $language
108        );
109
110        $this->constraintChecker = $constraintChecker;
111
112        $this->violationMessageRenderer = $violationMessageRendererFactory->getViolationMessageRenderer(
113            $language,
114            $languageFallbackChainFactory->newFromLanguage( $language ),
115            $this->getContext()
116        );
117
118        $this->config = $config;
119        $this->dataFactory = $dataFactory;
120    }
121
122    /**
123     * Returns array of modules that should be added
124     *
125     * @return string[]
126     */
127    private function getModules(): array {
128        return [
129            'SpecialConstraintReportPage',
130            'wikibase.quality.constraints.icon',
131            'wikibase.alltargets',
132        ];
133    }
134
135    /**
136     * @see SpecialPage::getGroupName
137     *
138     * @return string
139     */
140    protected function getGroupName() {
141        return 'wikibase';
142    }
143
144    /**
145     * @inheritDoc
146     */
147    public function getDescription() {
148        return $this->msg( 'wbqc-constraintreport' );
149    }
150
151    /**
152     * @see SpecialPage::execute
153     *
154     * @param string|null $subPage
155     *
156     * @throws InvalidArgumentException
157     * @throws EntityIdParsingException
158     * @throws UnexpectedValueException
159     */
160    public function execute( $subPage ) {
161        $out = $this->getOutput();
162
163        $postRequest = $this->getContext()->getRequest()->getVal( 'entityid' );
164        if ( $postRequest ) {
165            try {
166                $entityId = $this->entityIdParser->parse( $postRequest );
167                $out->redirect( $this->getPageTitle( $entityId->getSerialization() )->getLocalURL() );
168                return;
169            } catch ( EntityIdParsingException $e ) {
170                // fall through, error is shown later
171            }
172        }
173
174        $out->enableOOUI();
175        $out->addModules( $this->getModules() );
176
177        $this->setHeaders();
178
179        $out->addHTML( $this->getExplanationText() );
180        $this->buildEntityIdForm();
181
182        if ( $postRequest ) {
183            // must be an invalid entity ID (otherwise we would have redirected and returned above)
184            $out->addHTML(
185                $this->buildNotice( 'wbqc-constraintreport-invalid-entity-id', true )
186            );
187            return;
188        }
189
190        if ( !$subPage ) {
191            return;
192        }
193
194        if ( !is_string( $subPage ) ) {
195            throw new InvalidArgumentException( '$subPage must be string.' );
196        }
197
198        try {
199            $entityId = $this->entityIdParser->parse( $subPage );
200        } catch ( EntityIdParsingException $e ) {
201            $out->addHTML(
202                $this->buildNotice( 'wbqc-constraintreport-invalid-entity-id', true )
203            );
204            return;
205        }
206
207        if ( !$this->entityLookup->hasEntity( $entityId ) ) {
208            $out->addHTML(
209                $this->buildNotice( 'wbqc-constraintreport-not-existent-entity', true )
210            );
211            return;
212        }
213
214        $this->dataFactory->increment(
215            'wikibase.quality.constraints.specials.specialConstraintReport.executeCheck'
216        );
217        $results = $this->constraintChecker->checkAgainstConstraintsOnEntityId( $entityId );
218
219        if ( $results !== [] ) {
220            $out->addHTML(
221                $this->buildResultHeader( $entityId )
222                . $this->buildSummary( $results )
223                . $this->buildResultTable( $entityId, $results )
224            );
225        } else {
226            $out->addHTML(
227                $this->buildResultHeader( $entityId )
228                . $this->buildNotice( 'wbqc-constraintreport-empty-result' )
229            );
230        }
231    }
232
233    /**
234     * Builds html form for entity id input
235     */
236    private function buildEntityIdForm(): void {
237        $formDescriptor = [
238            'entityid' => [
239                'class' => HTMLTextField::class,
240                'section' => 'section',
241                'name' => 'entityid',
242                'label-message' => 'wbqc-constraintreport-form-entityid-label',
243                'cssclass' => 'wbqc-constraintreport-form-entity-id',
244                'placeholder' => $this->msg( 'wbqc-constraintreport-form-entityid-placeholder' )->escaped(),
245                'required' => true,
246            ],
247        ];
248        $htmlForm = HTMLForm::factory( 'ooui', $formDescriptor, $this->getContext(), 'wbqc-constraintreport-form' );
249        $htmlForm->setSubmitText( $this->msg( 'wbqc-constraintreport-form-submit-label' )->escaped() );
250        $htmlForm->setSubmitCallback( static function () {
251            return false;
252        } );
253        $htmlForm->setMethod( 'post' );
254        $htmlForm->show();
255    }
256
257    /**
258     * Builds notice with given message. Optionally notice can be handles as error by settings $error to true
259     *
260     * @param string $messageKey
261     * @param bool $error
262     *
263     * @throws InvalidArgumentException
264     *
265     * @return string HTML
266     */
267    private function buildNotice( string $messageKey, bool $error = false ): string {
268        $cssClasses = 'wbqc-constraintreport-notice';
269        if ( $error ) {
270            $cssClasses .= ' wbqc-constraintreport-notice-error';
271        }
272
273        return Html::rawElement(
274                'p',
275                [
276                    'class' => $cssClasses,
277                ],
278                $this->msg( $messageKey )->escaped()
279            );
280    }
281
282    /**
283     * @return string HTML
284     */
285    private function getExplanationText(): string {
286        return Html::rawElement(
287            'div',
288            [ 'class' => 'wbqc-explanation' ],
289            Html::rawElement(
290                'p',
291                [],
292                $this->msg( 'wbqc-constraintreport-explanation-part-one' )->escaped()
293            )
294            . Html::rawElement(
295                'p',
296                [],
297                $this->msg( 'wbqc-constraintreport-explanation-part-two' )->escaped()
298            )
299        );
300    }
301
302    /**
303     * @param EntityId $entityId
304     * @param CheckResult[] $results
305     *
306     * @return string HTML
307     */
308    private function buildResultTable( EntityId $entityId, array $results ): string {
309        // Set table headers
310        $table = new HtmlTableBuilder(
311            [
312                new HtmlTableHeaderBuilder(
313                    $this->msg( 'wbqc-constraintreport-result-table-header-status' )->text(),
314                    true
315                ),
316                new HtmlTableHeaderBuilder(
317                    $this->msg( 'wbqc-constraintreport-result-table-header-property' )->text(),
318                    true
319                ),
320                new HtmlTableHeaderBuilder(
321                    $this->msg( 'wbqc-constraintreport-result-table-header-message' )->text(),
322                    true
323                ),
324                new HtmlTableHeaderBuilder(
325                    $this->msg( 'wbqc-constraintreport-result-table-header-constraint' )->text(),
326                    true
327                ),
328            ]
329        );
330
331        foreach ( $results as $result ) {
332            $table = $this->appendToResultTable( $table, $entityId, $result );
333        }
334
335        return $table->toHtml();
336    }
337
338    private function appendToResultTable(
339        HtmlTableBuilder $table,
340        EntityId $entityId,
341        CheckResult $result
342    ): HtmlTableBuilder {
343        $message = $result->getMessage();
344        if ( $message === null ) {
345            // no row for this result
346            return $table;
347        }
348
349        // Status column
350        $statusColumn = $this->formatStatus( $result->getStatus() );
351
352        // Property column
353        $propertyId = new NumericPropertyId( $result->getContextCursor()->getSnakPropertyId() );
354        $propertyColumn = $this->getClaimLink(
355            $entityId,
356            $propertyId,
357            $this->entityIdLabelFormatter->formatEntityId( $propertyId )
358        );
359
360        // Message column
361        $messageColumn = $this->violationMessageRenderer->render( $message );
362
363        // Constraint column
364        $constraintTypeItemId = $result->getConstraint()->getConstraintTypeItemId();
365        try {
366            $constraintTypeLabel = $this->entityIdLabelFormatter->formatEntityId( new ItemId( $constraintTypeItemId ) );
367        } catch ( InvalidArgumentException $e ) {
368            $constraintTypeLabel = htmlspecialchars( $constraintTypeItemId );
369        }
370        $constraintColumn = $this->getClaimLink(
371            $propertyId,
372            new NumericPropertyId( $this->config->get( 'WBQualityConstraintsPropertyConstraintId' ) ),
373            $constraintTypeLabel
374        );
375
376        // Append cells
377        $table->appendRow(
378            [
379                new HtmlTableCellBuilder(
380                    new HtmlArmor( $statusColumn )
381                ),
382                new HtmlTableCellBuilder(
383                    new HtmlArmor( $propertyColumn )
384                ),
385                new HtmlTableCellBuilder(
386                    new HtmlArmor( $messageColumn )
387                ),
388                new HtmlTableCellBuilder(
389                    new HtmlArmor( $constraintColumn )
390                ),
391            ]
392        );
393
394        return $table;
395    }
396
397    /**
398     * Returns html text of the result header
399     *
400     * @param EntityId $entityId
401     *
402     * @return string HTML
403     */
404    protected function buildResultHeader( EntityId $entityId ): string {
405        $entityLink = sprintf( '%s (%s)',
406                               $this->entityIdLinkFormatter->formatEntityId( $entityId ),
407                               htmlspecialchars( $entityId->getSerialization() ) );
408
409        return Html::rawElement(
410            'h3',
411            [],
412            sprintf( '%s %s', $this->msg( 'wbqc-constraintreport-result-headline' )->escaped(), $entityLink )
413        );
414    }
415
416    /**
417     * Builds summary from given results
418     *
419     * @param CheckResult[] $results
420     *
421     * @return string HTML
422     */
423    protected function buildSummary( array $results ): string {
424        $statuses = [];
425        foreach ( $results as $result ) {
426            $status = strtolower( $result->getStatus() );
427            $statuses[$status] = isset( $statuses[$status] ) ? $statuses[$status] + 1 : 1;
428        }
429
430        $statusElements = [];
431        foreach ( $statuses as $status => $count ) {
432            if ( $count > 0 ) {
433                $statusElements[] =
434                    $this->formatStatus( $status )
435                    . ': '
436                    . $count;
437            }
438        }
439
440        return Html::rawElement( 'p', [], implode( ', ', $statusElements ) );
441    }
442
443    /**
444     * Formats given status to html
445     *
446     * @param string $status
447     *
448     * @throws InvalidArgumentException
449     *
450     * @return string HTML
451     */
452    private function formatStatus( string $status ): string {
453        $messageName = "wbqc-constraintreport-status-" . strtolower( $status );
454        $statusIcons = [
455            CheckResult::STATUS_SUGGESTION => [
456                'icon' => 'suggestion-constraint-violation',
457            ],
458            CheckResult::STATUS_WARNING => [
459                'icon' => 'non-mandatory-constraint-violation',
460            ],
461            CheckResult::STATUS_VIOLATION => [
462                'icon' => 'mandatory-constraint-violation',
463            ],
464            CheckResult::STATUS_BAD_PARAMETERS => [
465                'icon' => 'alert',
466                'flags' => 'warning',
467            ],
468        ];
469
470        if ( array_key_exists( $status, $statusIcons ) ) {
471            $iconWidget = new IconWidget( $statusIcons[$status] );
472            $iconHtml = $iconWidget->toString() . ' ';
473        } else {
474            $iconHtml = '';
475        }
476
477        $labelWidget = new LabelWidget( [
478            'label' => $this->msg( $messageName )->text(),
479        ] );
480        $labelHtml = $labelWidget->toString();
481
482        $formattedStatus =
483            Html::rawElement(
484                'span',
485                [
486                    'class' => 'wbqc-status wbqc-status-' . $status,
487                ],
488                $iconHtml . $labelHtml
489            );
490
491        return $formattedStatus;
492    }
493
494    /**
495     * Returns html link to given entity with anchor to specified property.
496     *
497     * @param EntityId $entityId
498     * @param NumericPropertyId $propertyId
499     * @param string $text HTML
500     *
501     * @return string HTML
502     */
503    private function getClaimLink(
504        EntityId $entityId,
505        NumericPropertyId $propertyId,
506        string $text
507    ): string {
508        return Html::rawElement(
509            'a',
510            [
511                'href' => $this->getClaimUrl( $entityId, $propertyId ),
512                'target' => '_blank',
513            ],
514            $text
515        );
516    }
517
518    /**
519     * Returns url of given entity with anchor to specified property.
520     */
521    private function getClaimUrl(
522        EntityId $entityId,
523        NumericPropertyId $propertyId
524    ): string {
525        $title = $this->entityTitleLookup->getTitleForId( $entityId );
526        $entityUrl = sprintf( '%s#%s', $title->getLocalURL(), $propertyId->getSerialization() );
527
528        return $entityUrl;
529    }
530
531}