Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
75.42% covered (warning)
75.42%
178 / 236
57.14% covered (warning)
57.14%
16 / 28
CRAP
0.00% covered (danger)
0.00%
0 / 1
IndexContentHandler
75.42% covered (warning)
75.42%
178 / 236
57.14% covered (warning)
57.14%
16 / 28
165.01
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 getContentClass
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getParser
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 buildParser
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 canBeUsedOn
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 serializeContent
58.82% covered (warning)
58.82%
10 / 17
0.00% covered (danger)
0.00%
0 / 1
10.42
 unserializeContent
63.64% covered (warning)
63.64%
7 / 11
0.00% covered (danger)
0.00%
0 / 1
6.20
 serializeContentInWikitext
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 unserializeContentInWikitext
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
1 / 1
8
 serializeContentInJson
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 unserializeContentInJson
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
8
 getActionOverrides
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 getSlotDiffRendererWithOptions
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 makeEmptyContent
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 merge3
96.15% covered (success)
96.15%
25 / 26
0.00% covered (danger)
0.00%
0 / 1
8
 arrayMerge3
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 makeRedirectContent
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 supportsRedirects
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isParserCacheSupported
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 validateSave
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
5.01
 getSecondaryDataUpdates
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 getDeletionUpdates
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 preSaveTransform
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
3.01
 supportsPreloadContent
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 preloadTransform
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
3.01
 fillParserOutput
0.00% covered (danger)
0.00%
0 / 35
0.00% covered (danger)
0.00%
0 / 1
30
 buildIndexQualityStatsUpdate
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 buildIndexQualityStatsDelete
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3namespace ProofreadPage\Index;
4
5use Content;
6use IContextSource;
7use MediaWiki\Content\Renderer\ContentParseParams;
8use MediaWiki\Content\Transform\PreloadTransformParams;
9use MediaWiki\Content\Transform\PreSaveTransformParams;
10use MediaWiki\Content\ValidationParams;
11use MediaWiki\MediaWikiServices;
12use MediaWiki\Parser\ParserOutput;
13use MediaWiki\Revision\SlotRenderingProvider;
14use MediaWiki\Title\Title;
15use MWContentSerializationException;
16use Parser;
17use ParserOptions;
18use PPFrame;
19use ProofreadPage\Context;
20use ProofreadPage\Link;
21use ProofreadPage\MultiFormatSerializerUtils;
22use StatusValue;
23use TextContentHandler;
24use UnexpectedValueException;
25use WikitextContent;
26use WikitextContentHandler;
27
28/**
29 * @license GPL-2.0-or-later
30 *
31 * Content handler for a Index: pages
32 */
33class IndexContentHandler extends TextContentHandler {
34
35    use MultiFormatSerializerUtils;
36
37    /**
38     * @var WikitextContentHandler
39     */
40    private $wikitextContentHandler;
41
42    /**
43     * @var Parser
44     */
45    private $parser;
46
47    /**
48     * @var WikitextLinksExtractor
49     */
50    private $wikitextLinksExtractor;
51
52    /**
53     * @inheritDoc
54     */
55    public function __construct( $modelId = CONTENT_MODEL_PROOFREAD_INDEX ) {
56        $this->wikitextContentHandler = MediaWikiServices::getInstance()
57            ->getContentHandlerFactory()
58            ->getContentHandler( CONTENT_MODEL_WIKITEXT );
59        $this->parser = $this->buildParser();
60        $this->wikitextLinksExtractor = new WikitextLinksExtractor();
61
62        parent::__construct( $modelId, [ CONTENT_FORMAT_WIKITEXT, CONTENT_FORMAT_JSON ] );
63    }
64
65    /**
66     * @return string
67     */
68    protected function getContentClass() {
69        return IndexContent::class;
70    }
71
72    /**
73     * Warning: should not be used outside of IndexContent
74     * @return Parser
75     */
76    public function getParser() {
77        return $this->parser;
78    }
79
80    private function buildParser() {
81        $parser = MediaWikiServices::getInstance()->getParserFactory()->create();
82        $parser->startExternalParse(
83            null, ParserOptions::newFromAnon(), Parser::OT_PLAIN
84        );
85        return $parser;
86    }
87
88    /**
89     * @inheritDoc
90     */
91    public function canBeUsedOn( Title $title ) {
92        return parent::canBeUsedOn( $title ) &&
93            $title->getNamespace() === Context::getDefaultContext()->getIndexNamespaceId();
94    }
95
96    /**
97     * @inheritDoc
98     */
99    public function serializeContent( Content $content, $format = null ) {
100        // if not given, default is Wikitext
101        $format = $format ?: CONTENT_FORMAT_WIKITEXT;
102
103        $this->checkFormat( $format );
104
105        // redirects can only be wikitext
106        if ( $content instanceof IndexRedirectContent ) {
107            self::assertFormatSuitableForRedirect( $format );
108            return '#REDIRECT [[' . $content->getRedirectTarget()->getFullText() . ']]';
109        }
110
111        if ( !( $content instanceof IndexContent ) ) {
112            throw new MWContentSerializationException(
113                'IndexContentHandler could only serialize IndexContent'
114            );
115        }
116
117        switch ( $format ) {
118            case CONTENT_FORMAT_JSON:
119                return $this->serializeContentInJson( $content );
120            case CONTENT_FORMAT_WIKITEXT:
121                return $this->serializeContentInWikitext( $content );
122            default:
123                throw new MWContentSerializationException(
124                    "Format '$format' is not supported for serialization of content model " .
125                        $this->getModelID()
126                );
127        }
128    }
129
130    /**
131     * @inheritDoc
132     */
133    public function unserializeContent( $text, $format = null ) {
134        $this->checkFormat( $format );
135
136        if ( $format === null ) {
137            $format = self::guessDataFormat( $text, false );
138        }
139
140        switch ( $format ) {
141            case CONTENT_FORMAT_JSON:
142                return $this->unserializeContentInJson( $text );
143            case CONTENT_FORMAT_WIKITEXT:
144                return $this->unserializeContentInWikitext( $text );
145            default:
146                throw new UnexpectedValueException(
147                    "Format '$format' is not supported for unserialization of content model " .
148                        $this->getModelID()
149                );
150        }
151    }
152
153    /**
154     * @param IndexContent $content
155     * @throws MWContentSerializationException
156     * @return string
157     */
158    private function serializeContentInWikitext( IndexContent $content ): string {
159        $text = '{{:MediaWiki:Proofreadpage_index_template';
160        /** @var WikitextContent $value */
161        foreach ( $content->getFields() as $key => $value ) {
162            $text .= "\n|" . $key . '=' . $value->serialize();
163        }
164        $text .= "\n}}";
165
166        foreach ( $content->getCategories() as $category ) {
167            $text .= "\n[[" . $category->getFullText() . ']]';
168        }
169
170        return $text;
171    }
172
173    /**
174     * @param string $text
175     * @return IndexRedirectContent|IndexContent
176     */
177    private function unserializeContentInWikitext( $text ) {
178        $fullWikitext = new WikitextContent( $text );
179        if ( $fullWikitext->isRedirect() ) {
180            return new IndexRedirectContent( $fullWikitext->getRedirectTarget() );
181        }
182
183        $dom = $this->parser->preprocessToDom( $text );
184        $customFieldsValues = [];
185        $categories = [];
186        // We iterate on the main components of the Wikitext serialization
187        for ( $child = $dom->getFirstChild(); $child; $child = $child->getNextSibling() ) {
188            if ( $child->getName() === 'template' ) {
189                // It's a template call, we extract the fields
190                $frame = $this->parser->getPreprocessor()->newFrame();
191                $childFrame = $frame->newChild( $child->getChildrenOfType( 'part' ) );
192                // @phan-suppress-next-line PhanUndeclaredProperty
193                foreach ( $childFrame->namedArgs as $varName => $value ) {
194                    $value = $this->parser->getStripState()->unstripBoth(
195                        $frame->expand( $value, PPFrame::RECOVER_ORIG )
196                    );
197
198                    if ( substr( $value, -1 ) === "\n" ) {
199                        // We strip one "\n"
200                        $value = substr( $value, 0, -1 );
201                    }
202                    $customFieldsValues[$varName] = new WikitextContent( $value );
203                }
204            } elseif ( $child->getName() === '#text' ) {
205                // It's some text, we look for category links
206                $text = $this->parser->getStripState()->unstripBoth( strval( $child ) );
207                $categoryLinks = $this->wikitextLinksExtractor->getLinksToNamespace( $text, NS_CATEGORY );
208                /** @var Link $categoryLink */
209                foreach ( $categoryLinks as $categoryLink ) {
210                    $categories[] = $categoryLink->getTarget();
211                }
212            }
213        }
214        return new IndexContent( $customFieldsValues, $categories );
215    }
216
217    /**
218     * @param IndexContent $content
219     * @throws MWContentSerializationException
220     * @return string
221     */
222    public function serializeContentInJson( IndexContent $content ): string {
223        $data = [
224            'fields' => [],
225            'categories' => [],
226        ];
227
228        /** @var WikitextContent $value */
229        foreach ( $content->getFields() as $key => $value ) {
230            $data['fields'][ $key ] = $value->serialize();
231        }
232
233        foreach ( $content->getCategories() as $category ) {
234            $data['categories'][] = $category->getText();
235        }
236
237        return json_encode( $data, JSON_UNESCAPED_UNICODE );
238    }
239
240    /**
241     * @param string $text
242     * @return IndexContent
243     * @throws MWContentSerializationException
244     */
245    private function unserializeContentInJson( $text ): IndexContent {
246        $array = json_decode( $text, true );
247
248        if ( !is_array( $array ) ) {
249            throw new MWContentSerializationException(
250                'The serialization is an invalid JSON array.'
251            );
252        }
253
254        $customFieldsValues = [];
255        $categories = [];
256
257        if ( isset( $array['categories'] ) ) {
258
259            self::assertArrayValueIsArray( $array, 'categories' );
260            self::assertArrayIsSequential( $array['categories'], 'categories' );
261            self::assertContainsOnlyStrings( $array['categories'], false, 'categories' );
262
263            /** @var string $category */
264            foreach ( $array['categories'] as $category ) {
265                $title = Title::makeTitleSafe( NS_CATEGORY, $category );
266
267                if ( $title ) {
268                    $categories[] = $title;
269                } else {
270                    throw new MWContentSerializationException(
271                        "The category title '$category' is invalid."
272                    );
273                }
274            }
275        }
276
277        if ( isset( $array['fields'] ) ) {
278            self::assertArrayValueIsArray( $array, 'fields' );
279            // for now, all supported 'type' values are encoded as strings
280            // we may relax this in future if we accept object-type date in fields
281            self::assertContainsOnlyStrings( $array['fields'], true, 'fields' );
282
283            /** @var string $fieldValue */
284            foreach ( $array['fields'] as $fieldKey => $fieldValue ) {
285                if ( $fieldValue !== null ) {
286                    $customFieldsValues[$fieldKey] = new WikitextContent( $fieldValue );
287                }
288            }
289        }
290
291        return new IndexContent( $customFieldsValues, $categories );
292    }
293
294    /**
295     * @inheritDoc
296     */
297    public function getActionOverrides() {
298        return [
299            'edit' => IndexEditAction::class,
300            'submit' => IndexSubmitAction::class
301        ];
302    }
303
304    /**
305     * @inheritDoc
306     */
307    protected function getSlotDiffRendererWithOptions( IContextSource $context, $options = [] ) {
308        return new IndexSlotDiffRenderer(
309            $context,
310            Context::getDefaultContext()->getCustomIndexFieldsParser(),
311            $this->wikitextContentHandler->getSlotDiffRenderer( $context )
312        );
313    }
314
315    /**
316     * @inheritDoc
317     */
318    public function makeEmptyContent() {
319        return new IndexContent( [] );
320    }
321
322    /**
323     * @inheritDoc
324     */
325    public function merge3( Content $oldContent, Content $myContent, Content $yourContent ) {
326        $this->checkModelID( $oldContent->getModel() );
327        $this->checkModelID( $myContent->getModel() );
328        $this->checkModelID( $yourContent->getModel() );
329
330        if ( !( $oldContent instanceof IndexContent && $myContent instanceof IndexContent &&
331            $yourContent instanceof IndexContent )
332        ) {
333            return false;
334        }
335
336        $oldFields = $oldContent->getFields();
337        $myFields = $myContent->getFields();
338        $yourFields = $yourContent->getFields();
339
340        // We adds yourFields to myFields
341        foreach ( $yourFields as $key => $yourValue ) {
342            if ( array_key_exists( $key, $myFields ) ) {
343                $oldValue  = array_key_exists( $key, $oldFields )
344                    ? $oldFields[$key]
345                    : $this->wikitextContentHandler->makeEmptyContent();
346                $myFields[$key] = $this->wikitextContentHandler->merge3(
347                    $oldValue, $myFields[$key], $yourValue
348                );
349
350                if ( $myFields[$key] === false ) {
351                    return false;
352                }
353            } else {
354                $myFields[$key] = $yourValue;
355            }
356        }
357
358        // Categories
359        $categories = $this->arrayMerge3(
360            $oldContent->getCategories(),
361            $myContent->getCategories(),
362            $yourContent->getCategories()
363        );
364
365        return new IndexContent( $myFields, $categories );
366    }
367
368    /**
369     * @param array $old
370     * @param array $my
371     * @param array $your
372     * @return array
373     */
374    private function arrayMerge3( array $old, array $my, array $your ) {
375        // TODO: detection of deletions
376        return array_unique( array_merge( $my, $your ) );
377    }
378
379    /**
380     * @inheritDoc
381     */
382    public function makeRedirectContent( Title $destination, $text = '' ) {
383        return new IndexRedirectContent( $destination );
384    }
385
386    /**
387     * @inheritDoc
388     */
389    public function supportsRedirects() {
390        return true;
391    }
392
393    /**
394     * @inheritDoc
395     */
396    public function isParserCacheSupported() {
397        return true;
398    }
399
400    /**
401     * @inheritDoc
402     */
403    public function validateSave(
404        Content $content,
405        ValidationParams $validationParams
406    ) {
407        if ( $content instanceof IndexRedirectContent ) {
408            return StatusValue::newGood();
409        } else {
410            '@phan-var IndexContent $content';
411            if ( !$content->isValid() ) {
412                return StatusValue::newFatal( 'invalid-content-data' );
413            }
414
415            // Get list of pages titles
416            $links = $content->getLinksToNamespace(
417                Context::getDefaultContext()->getPageNamespaceId()
418            );
419            $linksTitle = [];
420            foreach ( $links as $link ) {
421                $linksTitle[] = $link->getTarget();
422            }
423
424            if ( count( $linksTitle ) !== count( array_unique( $linksTitle ) ) ) {
425                return StatusValue::newFatal( 'proofreadpage_indexdupetext' );
426            }
427
428            return StatusValue::newGood();
429        }
430    }
431
432    /**
433     * @inheritDoc
434     */
435    public function getSecondaryDataUpdates(
436        Title $title,
437        Content $content,
438        $role,
439        SlotRenderingProvider $slotOutput
440    ) {
441        $updates = parent::getSecondaryDataUpdates( $title, $content, $role, $slotOutput );
442        $updates[] = ( $content instanceof IndexContent )
443            ? $this->buildIndexQualityStatsUpdate( $title, $content )
444            : $this->buildIndexQualityStatsDelete( $title );
445        return $updates;
446    }
447
448    /**
449     * @inheritDoc
450     */
451    public function getDeletionUpdates( Title $title, $role ) {
452        $updates = parent::getDeletionUpdates( $title, $role );
453        $updates[] = $this->buildIndexQualityStatsDelete( $title );
454        return $updates;
455    }
456
457    /**
458     * @inheritDoc
459     */
460    public function preSaveTransform(
461        Content $content,
462        PreSaveTransformParams $pstParams
463    ): Content {
464        $contentHandlerFactory = MediaWikiServices::getInstance()->getContentHandlerFactory();
465
466        if ( $content instanceof IndexRedirectContent ) {
467            return $content;
468        }
469
470        '@phan-var IndexContent $content';
471        $fields = [];
472        foreach ( $content->getFields() as $key => $value ) {
473            $contentHandler = $contentHandlerFactory->getContentHandler( $value->getModel() );
474            $fields[$key] = $contentHandler->preSaveTransform(
475                $value,
476                $pstParams
477            );
478        }
479
480        $contentClass = $this->getContentClass();
481        return new $contentClass( $fields, $content->getCategories() );
482    }
483
484    /**
485     * @inheritDoc
486     */
487    public function supportsPreloadContent(): bool {
488        return true;
489    }
490
491    /**
492     * @inheritDoc
493     */
494    public function preloadTransform(
495        Content $content,
496        PreloadTransformParams $pltParams
497    ): Content {
498        $contentHandlerFactory = MediaWikiServices::getInstance()->getContentHandlerFactory();
499
500        if ( $content instanceof IndexRedirectContent ) {
501            return $content;
502        }
503
504        '@phan-var IndexContent $content';
505        $fields = [];
506
507        foreach ( $content->getFields() as $key => $value ) {
508            $contentHandler = $contentHandlerFactory->getContentHandler( $value->getModel() );
509            $fields[$key] = $contentHandler->preloadTransform(
510                $value,
511                $pltParams
512            );
513        }
514
515        $contentClass = $this->getContentClass();
516        return new $contentClass( $fields, $content->getCategories() );
517    }
518
519    /**
520     * @inheritDoc
521     */
522    protected function fillParserOutput(
523        Content $content,
524        ContentParseParams $cpoParams,
525        ParserOutput &$parserOutput
526    ) {
527        $title = Title::castFromPageReference( $cpoParams->getPage() );
528        $parserOptions = $cpoParams->getParserOptions();
529
530        if ( $content instanceof IndexRedirectContent ) {
531            $parserOutput->addLink( $content->getRedirectTarget() );
532            if ( $cpoParams->getGenerateHtml() ) {
533                $parserOutput->setText( '' );
534                $parserOutput->setRedirectHeader(
535                    MediaWikiServices::getInstance()->getLinkRenderer()->makeRedirectHeader(
536                        $title->getPageLanguage(), $content->getRedirectTarget()
537                    ) );
538                $parserOutput->addModuleStyles( [ 'mediawiki.action.view.redirectPage' ] );
539            }
540        } else {
541            '@phan-var IndexContent $content';
542            $parserHelper = new ParserHelper( $title, $parserOptions );
543
544            // Start with the default index styles
545            // @phan-suppress-next-line PhanTypeMismatchArgument
546            $indexTs = new IndexTemplateStyles( $title );
547            $text = $indexTs->getIndexTemplateStyles( null );
548
549            // make sure the template starts on a new line in case it starts
550            // with something like '{|'
551            if ( $text ) {
552                $text .= "\n";
553            }
554
555            // We retrieve the view template
556            [ $templateText, $templateTitle ] = $parserHelper->fetchTemplateTextAndTitle(
557                Title::makeTitle( NS_MEDIAWIKI, 'Proofreadpage index template' )
558            );
559
560            // We replace the arguments calls by their values
561            $text .= $parserHelper->expandTemplateArgs(
562                $templateText,
563                array_map( static function ( Content $content ) {
564                    return $content->serialize( CONTENT_FORMAT_WIKITEXT );
565                }, $content->getFields() )
566            );
567
568            // Force no section edit links
569            $text = '__NOEDITSECTION__' . $text;
570
571            // We do the final rendering
572            $parserOutput = MediaWikiServices::getInstance()->getParser()
573                // @phan-suppress-next-line PhanTypeMismatchArgument
574                ->parse( $text, $title, $parserOptions, true, true, $cpoParams->getRevId() );
575            $parserOutput->addTemplate( $templateTitle,
576                $templateTitle->getArticleID(),
577                $templateTitle->getLatestRevID()
578            );
579
580            foreach ( $content->getCategories() as $category ) {
581                $parserOutput->addCategory( $category->getDBkey(), $category->getText() );
582            }
583        }
584    }
585
586    /**
587     * @param Title $title
588     * @param IndexContent $content
589     * @return UpdateIndexQualityStats
590     */
591    private function buildIndexQualityStatsUpdate( Title $title, IndexContent $content ): UpdateIndexQualityStats {
592        $context = Context::getDefaultContext();
593        return new UpdateIndexQualityStats(
594            MediaWikiServices::getInstance()->getDBLoadBalancer(),
595            $context->getPageQualityLevelLookup(),
596            $context->getPaginationFactory()->buildPaginationForIndexContent( $title, $content ),
597            $title
598        );
599    }
600
601    /**
602     * @param Title $title
603     * @return DeleteIndexQualityStats
604     */
605    private function buildIndexQualityStatsDelete( Title $title ): DeleteIndexQualityStats {
606        return new DeleteIndexQualityStats( MediaWikiServices::getInstance()->getDBLoadBalancer(), $title );
607    }
608}