Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
87.07% covered (warning)
87.07%
202 / 232
73.68% covered (warning)
73.68%
14 / 19
CRAP
0.00% covered (danger)
0.00%
0 / 1
ZObjectContentHandler
87.07% covered (warning)
87.07%
202 / 232
73.68% covered (warning)
73.68%
14 / 19
48.19
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 canBeUsedOn
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 makeEmptyContent
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
1
 makeContent
100.00% covered (success)
100.00%
1 / 1
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
 serializeContent
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 unserializeContent
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 getExternalRepresentation
83.33% covered (warning)
83.33%
30 / 36
0.00% covered (danger)
0.00%
0 / 1
6.17
 getSecondaryDataUpdates
93.75% covered (success)
93.75%
15 / 16
0.00% covered (danger)
0.00%
0 / 1
3.00
 getDeletionUpdates
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 supportsDirectEditing
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getActionOverrides
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 generateHTMLOnEdit
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 fillParserOutput
78.35% covered (warning)
78.35%
76 / 97
0.00% covered (danger)
0.00%
0 / 1
11.01
 getZObjectViewPageHTMLTitle
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
4.02
 validateSave
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 preSaveTransform
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 createDifferenceEngine
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 getSlotDiffRendererWithOptions
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2/**
3 * WikiLambda content handler for ZObjects
4 *
5 * @file
6 * @ingroup Extensions
7 * @copyright 2020– Abstract Wikipedia team; see AUTHORS.txt
8 * @license MIT
9 */
10
11namespace MediaWiki\Extension\WikiLambda\ZObjectContent;
12
13use InvalidArgumentException;
14use MediaWiki\Config\Config;
15use MediaWiki\Content\Content;
16use MediaWiki\Content\ContentHandler;
17use MediaWiki\Content\ContentSerializationException;
18use MediaWiki\Content\Renderer\ContentParseParams;
19use MediaWiki\Content\Transform\PreSaveTransformParams;
20use MediaWiki\Content\ValidationParams;
21use MediaWiki\Context\IContextSource;
22use MediaWiki\Context\RequestContext;
23use MediaWiki\Diff\TextSlotDiffRenderer;
24use MediaWiki\Extension\WikiLambda\Cache\MemcachedWrapper;
25use MediaWiki\Extension\WikiLambda\PageTitle\PageTitleBuilder;
26use MediaWiki\Extension\WikiLambda\Registry\ZErrorTypeRegistry;
27use MediaWiki\Extension\WikiLambda\Registry\ZLangRegistry;
28use MediaWiki\Extension\WikiLambda\Registry\ZTypeRegistry;
29use MediaWiki\Extension\WikiLambda\UIUtils;
30use MediaWiki\Extension\WikiLambda\WikiLambdaServices;
31use MediaWiki\Extension\WikiLambda\ZErrorException;
32use MediaWiki\Extension\WikiLambda\ZErrorFactory;
33use MediaWiki\Extension\WikiLambda\ZObjectStore;
34use MediaWiki\Extension\WikiLambda\ZObjectUtils;
35use MediaWiki\Html\Html;
36use MediaWiki\Json\FormatJson;
37use MediaWiki\Language\Language;
38use MediaWiki\Logger\LoggerFactory;
39use MediaWiki\MainConfigNames;
40use MediaWiki\MediaWikiServices;
41use MediaWiki\Parser\ParserOutput;
42use MediaWiki\Revision\SlotRenderingProvider;
43use MediaWiki\Title\Title;
44use StatusValue;
45
46class ZObjectContentHandler extends ContentHandler {
47    use ZObjectEditingPageTrait;
48
49    /**
50     * @param string $modelId
51     * @param Config $config
52     * @param ZObjectStore $zObjectStore
53     * @param MemcachedWrapper $zObjectCache
54     */
55    public function __construct(
56        $modelId,
57        private readonly Config $config,
58        private readonly ZObjectStore $zObjectStore,
59        private readonly MemcachedWrapper $zObjectCache
60    ) {
61        if ( $modelId !== CONTENT_MODEL_ZOBJECT ) {
62            throw new InvalidArgumentException( __CLASS__ . " initialised for invalid content model" );
63        }
64
65        // Triggers use of message content-model-zobject
66        parent::__construct( CONTENT_MODEL_ZOBJECT, [ CONTENT_FORMAT_TEXT ] );
67    }
68
69    /**
70     * @param Title $title Page to check
71     * @return bool
72     */
73    public function canBeUsedOn( Title $title ) {
74        return $title->inNamespace( NS_MAIN );
75    }
76
77    /**
78     * @return ZObjectContent
79     */
80    public function makeEmptyContent() {
81        $class = $this->getContentClass();
82        return new $class(
83            '{' . "\n"
84            . '"' . ZTypeRegistry::Z_OBJECT_TYPE . '": "' . ZTypeRegistry::Z_PERSISTENTOBJECT . '",' . "\n"
85            . '"' . ZTypeRegistry::Z_PERSISTENTOBJECT_ID . '": {' . "\n"
86            . '"' . ZTypeRegistry::Z_OBJECT_TYPE . '": "' . ZTypeRegistry::Z_STRING . '",' . "\n"
87            . '"' . ZTypeRegistry::Z_STRING_VALUE . '": "' . ZTypeRegistry::Z_NULL_REFERENCE . '"},' . "\n"
88            . '"' . ZTypeRegistry::Z_PERSISTENTOBJECT_VALUE . '": "",' . "\n"
89            . '"' . ZTypeRegistry::Z_PERSISTENTOBJECT_LABEL . '": {' . "\n"
90            . '"' . ZTypeRegistry::Z_OBJECT_TYPE . '": "' . ZTypeRegistry::Z_MULTILINGUALSTRING . '",' . "\n"
91            . '"' . ZTypeRegistry::Z_MULTILINGUALSTRING_VALUE . '":'
92            . '["' . ZTypeRegistry::Z_MONOLINGUALSTRING . '"]' . "\n"
93            . '},' . "\n"
94            . '"' . ZTypeRegistry::Z_PERSISTENTOBJECT_ALIASES . '": {' . "\n"
95            . '"' . ZTypeRegistry::Z_OBJECT_TYPE . '": "' . ZTypeRegistry::Z_MULTILINGUALSTRINGSET . '",' . "\n"
96            . '"' . ZTypeRegistry::Z_MULTILINGUALSTRINGSET_VALUE . '":'
97            . '["' . ZTypeRegistry::Z_MONOLINGUALSTRINGSET . '"]' . "\n"
98            . '},' . "\n"
99            . '"' . ZTypeRegistry::Z_PERSISTENTOBJECT_DESCRIPTION . '": {' . "\n"
100            . '"' . ZTypeRegistry::Z_OBJECT_TYPE . '": "' . ZTypeRegistry::Z_MULTILINGUALSTRING . '",' . "\n"
101            . '"' . ZTypeRegistry::Z_MULTILINGUALSTRING_VALUE . '":'
102            . '["' . ZTypeRegistry::Z_MONOLINGUALSTRING . '"]' . "\n"
103            . '}' . "\n"
104            . '}'
105        );
106    }
107
108    /**
109     * @param string $data
110     * @param Title|null $title
111     * @param string|null $modelId
112     * @param string|null $format
113     * @return ZObjectContent
114     */
115    public static function makeContent( $data, ?Title $title = null, $modelId = null, $format = null ) {
116        // @phan-suppress-next-line PhanTypeMismatchReturnSuperType
117        return parent::makeContent( $data, $title, $modelId, $format );
118    }
119
120    /**
121     * @return string
122     */
123    protected function getContentClass() {
124        return ZObjectContent::class;
125    }
126
127    /**
128     * @param Content $content
129     * @param string|null $format
130     * @return string
131     */
132    public function serializeContent( Content $content, $format = null ) {
133        $this->checkFormat( $format );
134
135        if ( !( $content instanceof ZObjectContent ) ) {
136            // Throw?
137            return '';
138        }
139
140        return $content->getText();
141    }
142
143    /**
144     * @param string $text
145     * @param string|null $format
146     * @return ZObjectContent
147     * @throws ContentSerializationException if input causes an error
148     */
149    public function unserializeContent( $text, $format = null ) {
150        $this->checkFormat( $format );
151
152        $class = $this->getContentClass();
153        try {
154            return new $class( $text );
155        } catch ( ZErrorException $zerror ) {
156            // (T381115) If the passed user input isn't valid, we're expected to throw this particular MW error
157            throw new ContentSerializationException( $zerror->getZError()->getMessage() );
158        }
159    }
160
161    /**
162     * @param Title $zObjectTitle The page to fetch.
163     * @param string|null $languageCode The language in which to return results. If unset, all results are returned.
164     * @param int|null $revision The revision ID of the page to fetch. If unset, the latest is returned.
165     * @return string The external JSON form of the given title.
166     * @throws ZErrorException
167     */
168    public static function getExternalRepresentation(
169        Title $zObjectTitle, ?string $languageCode = null, ?int $revision = null
170    ): string {
171        if ( $zObjectTitle->getNamespace() !== NS_MAIN ) {
172            throw new ZErrorException(
173                ZErrorFactory::createZErrorInstance(
174                    ZErrorTypeRegistry::Z_ERROR_WRONG_NAMESPACE,
175                    [ 'title' => (string)$zObjectTitle ]
176                )
177            );
178        }
179
180        if ( $zObjectTitle->getContentModel() !== CONTENT_MODEL_ZOBJECT ) {
181            throw new ZErrorException(
182                ZErrorFactory::createZErrorInstance(
183                    ZErrorTypeRegistry::Z_ERROR_WRONG_CONTENT_TYPE,
184                    [ 'title' => (string)$zObjectTitle ]
185                )
186            );
187        }
188
189        $zObjectStore = WikiLambdaServices::getZObjectStore();
190        $zObject = $zObjectStore->fetchZObjectByTitle( $zObjectTitle, $revision );
191
192        if ( $zObject === false ) {
193            throw new ZErrorException(
194                ZErrorFactory::createZErrorInstance(
195                    ZErrorTypeRegistry::Z_ERROR_ZID_NOT_FOUND,
196                    [ 'data' => (string)$zObjectTitle ]
197                )
198            );
199        }
200
201        // Cautionary canonicalization: all objects should be fully canonicalized in the DB
202        $object = ZObjectUtils::canonicalize( $zObject->getObject() );
203
204        if ( $languageCode ) {
205            // TODO (T362246): Dependency-inject
206            $services = MediaWikiServices::getInstance();
207
208            // If language code is not valid, throws ZErrorException of Z540/Invalid language code
209            if ( !$services->getLanguageNameUtils()->isValidCode( $languageCode ) ) {
210                throw new ZErrorException(
211                    ZErrorFactory::createZErrorInstance(
212                        ZErrorTypeRegistry::Z_ERROR_INVALID_LANG_CODE,
213                        [ 'lang' => $languageCode ]
214                    )
215                );
216            }
217
218            // If language doesn't have a Zid, throws ZErrorException of Z541/Language code not found
219            $languageZid = ZLangRegistry::singleton()->getLanguageZidFromCode( $languageCode );
220
221            // Filter all Multilingual Strings and Stringsets if language is present and valid
222            $object = ZObjectUtils::filterZMultilingualStringsToLanguage( $object, [ $languageZid ] );
223        }
224
225        return FormatJson::encode( $object, true, FormatJson::UTF8_OK );
226    }
227
228    /**
229     * @inheritDoc
230     */
231    public function getSecondaryDataUpdates(
232        Title $title,
233        Content $content,
234        $role,
235        SlotRenderingProvider $slotOutput
236    ) {
237        $orchestrator = null;
238
239        if ( $this->config->get( 'WikiLambdaPersistBackendCache' ) ) {
240            $orchestrator = WikiLambdaServices::getOrchestratorRequest();
241        }
242
243        $ourUpdate = [];
244        if ( ( $content instanceof ZObjectContent ) ) {
245            $ourUpdate[] = new ZObjectSecondaryDataUpdate(
246                $title,
247                $content,
248                $this->zObjectStore,
249                $this->zObjectCache,
250                $orchestrator
251            );
252        }
253
254        return array_merge(
255            parent::getSecondaryDataUpdates( $title, $content, $role, $slotOutput ),
256            $ourUpdate
257        );
258    }
259
260    /**
261     * @inheritDoc
262     */
263    public function getDeletionUpdates( Title $title, $role ) {
264        return array_merge(
265            parent::getDeletionUpdates( $title, $role ),
266            [ new ZObjectSecondaryDataRemoval(
267                $title,
268                $this->zObjectStore,
269                $this->zObjectCache
270            ) ]
271        );
272    }
273
274    /**
275     * @inheritDoc
276     */
277    public function supportsDirectEditing() {
278        return false;
279    }
280
281    /**
282     * @inheritDoc
283     */
284    public function getActionOverrides() {
285        return [
286            'edit' => ZObjectEditAction::class,
287            'history' => ZObjectHistoryAction::class
288        ];
289    }
290
291    /**
292     * Do not render HTML on edit (T285987)
293     *
294     * @return bool
295     */
296    public function generateHTMLOnEdit(): bool {
297        return false;
298    }
299
300    /**
301     * Set the HTML and add the appropriate styles.
302     *
303     * @inheritDoc
304     * @param Content $content
305     * @param ContentParseParams $cpoParams
306     * @param ParserOutput &$parserOutput The output object to fill (reference).
307     */
308    protected function fillParserOutput(
309        Content $content,
310        ContentParseParams $cpoParams,
311        ParserOutput &$parserOutput
312    ) {
313        $userLang = RequestContext::getMain()->getLanguage();
314        $logger = LoggerFactory::getInstance( 'WikiLambda' );
315
316        // Ensure the stored content is a valid ZObject; this also populates $this->getZObject() for us
317        if ( !( $content instanceof ZObjectContent ) || !$content->isValid() ) {
318            $parserOutput->setContentHolderText(
319                Html::element(
320                    'div',
321                    [
322                        'class' => [ 'ext-wikilambda-view-invalidcontent', 'warning' ],
323                    ],
324                    wfMessage( 'wikilambda-invalidzobject' )->inLanguage( $userLang )->text()
325                )
326            );
327            // Exit early, as the rest of the code relies on the stored content being well-formed and valid.
328            return;
329        }
330
331        // Add the ZObject's labels in each language as a page property, for cheaper reading
332        $zLangRegistry = ZLangRegistry::singleton();
333        $labels = $content->getLabels()->getValueAsList();
334        foreach ( $labels as $langZid => $label ) {
335            if ( !$langZid ) {
336                // (T402670) Something's wrong with this label entry; skip it
337                $logger->debug(
338                    'Skipping setting page property for label in blank language ZID when displaying "{page}"',
339                    [
340                        'page' => $content->getZObject()->getZid()
341                    ]
342                );
343                continue;
344            }
345
346            try {
347                $lang = $zLangRegistry->getLanguageCodeFromZid( $langZid );
348                $parserOutput->setPageProperty( "wikilambda-label-$lang", $label );
349            } catch ( ZErrorException ) {
350                // The language code is somehow not recognised; don't set a property, but log it for review
351                $logger->warning(
352                    'Skipping setting page property for label in unknown language "{langZid}" when displaying "{page}"',
353                    [
354                        'langZid' => $langZid,
355                        'page' => $content->getZObject()->getZid()
356                    ]
357                );
358            }
359        }
360
361        // Add links to other ZObjects
362        // (T361701) Do this ahead of the early return, as LinkUpdater asks for the non-HTML version
363        foreach ( $content->getInnerZObject()->getLinkedZObjects() as $link ) {
364            if ( !$link ) {
365                // (T402670) Something's wrong with this link entry; skip it
366                $logger->debug(
367                    'Skipping setting page property for link to blank page when displaying "{page}"',
368                    [
369                        'page' => $content->getZObject()->getZid()
370                    ]
371                );
372                continue;
373            }
374
375            $title = Title::newFromText( $link, NS_MAIN );
376
377            // (T400521) Don't try to write a null Title, but log that it happened
378            if ( !$title ) {
379                $logger->warning(
380                    'Skipping setting page property for link to unknown page "{link}" when displaying "{page}"',
381                    [
382                        'link' => $link,
383                        'page' => $content->getZObject()->getZid(),
384                    ]
385                );
386            } else {
387                $parserOutput->addLink( $title, $title->getArticleID() );
388            }
389        }
390
391        // Don't do further work if the requester doesn't want the HTML version generated.
392        if ( !$cpoParams->getGenerateHtml() ) {
393            $parserOutput->setContentHolderText( '' );
394            return;
395        }
396
397        $pageIdentity = $cpoParams->getPage();
398
399        // TODO (T362245): Re-work our code to use PageReferences rather than Titles
400        $title = Title::castFromPageReference( $pageIdentity );
401        '@phan-var Title $title';
402
403        $parserOutput->addModuleStyles( [ 'ext.wikilambda.viewpage.styles' ] );
404        $parserOutput->addModules( [ 'ext.wikilambda.app' ] );
405
406        $userLangCode = $userLang->getCode();
407
408        // If the userLang isn't recognised (e.g. it's qqx, or a language we don't support yet, or it's
409        // nonsense), then fall back to English.
410        $userLangZid = $zLangRegistry->getLanguageZidFromCode( $userLangCode, true );
411        // Normalise our used language code from what the Language object says
412        $userLangCode = $zLangRegistry->getLanguageCodeFromZid( $userLangZid );
413
414        // Add the canonical page link to /view/<lang>/<zid>
415        $output = RequestContext::getMain()->getOutput();
416
417        // Set page header
418        $pageTitle = PageTitleBuilder::createZObjectViewPageTitle( $content, $title, $userLang );
419        $output->setPageTitle( $pageTitle );
420
421        // (T360169) Set page title meta tag
422        $htmlTitle = static::getZObjectViewPageHTMLTitle( $content, $title, $userLang );
423        $output->setHTMLTitle( $htmlTitle );
424
425        $output->addLink( [
426                'rel' => 'canonical',
427                'hreflang' => $userLangCode,
428                'href' => "/view/$userLangCode/" . $title->getDBkey(),
429            ] );
430
431        $editingData = [
432            // The following paramether may be the same now,
433            // but will surely change in the future as we remove the Zds from the UI
434            'title' => $title->getBaseText(),
435            'zId' => $title->getBaseText(),
436            'page' => $title->getPrefixedDBkey(),
437            'zlang' => $userLangCode,
438            'zlangZid' => $userLangZid,
439            'createNewPage' => false,
440            'viewmode' => true
441        ];
442
443        $parserOutput->setJsConfigVar( 'wgWikiLambda', $editingData );
444
445        $loadingMessage = wfMessage( 'wikilambda-loading' )->inLanguage( $userLang )->text();
446        $parserOutput->setContentHolderText(
447            // Placeholder div for the Vue template with Codex progress indicator.
448            Html::rawElement(
449                'div',
450                [ 'id' => 'ext-wikilambda-app' ],
451                UIUtils::createCodexProgressIndicator( $loadingMessage )
452            )
453            // Fallback message for users without JavaScript.
454            . Html::rawElement(
455                'noscript',
456                [],
457                wfMessage( 'wikilambda-nojs' )->inLanguage( $userLang )->parse()
458            )
459        );
460    }
461
462    /**
463     * Generate the HTML "title" tag for the view page
464     *
465     * @param ZObjectContent $content
466     * @param Title $title
467     * @param Language $userLang
468     * @return string
469     */
470    public static function getZObjectViewPageHTMLTitle(
471        ZObjectContent $content, Title $title, Language $userLang
472    ): string {
473        $zobject = $content->getZObject();
474        if ( !$zobject || !$zobject->isValid() ) {
475            // Something's bad, let's give up.
476            return '';
477        }
478
479        $sitename = MediaWikiServices::getInstance()->getMainConfig()->get( MainConfigNames::Sitename );
480
481        // Get label, english fallback, or zid if no available option
482        $label = $zobject->getLabels()
483            ->buildStringForLanguage( $userLang )
484            ->fallbackWithEnglish()
485            ->getString();
486
487        // Return Label/Zid - Sitename
488        return ( $label ?: $title->getBaseText() ) . ' - ' . $sitename;
489    }
490
491    /**
492     * @param Content $content
493     * @param ValidationParams $validationParams
494     * @return StatusValue
495     */
496    public function validateSave( $content, $validationParams ) {
497        if ( $content->isValid() ) {
498            return StatusValue::newGood();
499        }
500        return StatusValue::newFatal( "wikilambda-invalidzobject" );
501    }
502
503    /**
504     * @inheritDoc
505     */
506    public function preSaveTransform( Content $content, PreSaveTransformParams $pstParams ): Content {
507        '@phan-var ZObjectContent $content';
508
509        if ( !$content->isValid() ) {
510            return $content;
511        }
512
513        $json = ZObjectUtils::canonicalize( $content->getObject() );
514        $encoded = FormatJson::encode( $json, true, FormatJson::UTF8_OK );
515        $encodedCleanedWhitespace = str_replace( [ "\r\n", "\r" ], "\n", rtrim( $encoded ) );
516
517        if ( $content->getText() !== $encodedCleanedWhitespace ) {
518            $contentClass = $this->getContentClass();
519            return new $contentClass( $encodedCleanedWhitespace );
520        }
521
522        return $content;
523    }
524
525    /**
526     * @inheritDoc
527     */
528    public function createDifferenceEngine(
529        IContextSource $context,
530        $oldContentRevisionId = 0,
531        $newContentRevisionId = 0,
532        $recentChangesId = 0,
533        $refreshCache = false,
534        $unhide = false
535    ) {
536        return new ZObjectContentDifferenceEngine(
537            $context, $oldContentRevisionId, $newContentRevisionId, $recentChangesId, $refreshCache, $unhide
538        );
539    }
540
541    /**
542     * @inheritDoc
543     *
544     * Access level widened to public for use in ZObjectContentDifferenceEngine
545     */
546    public function getSlotDiffRendererWithOptions( IContextSource $context, $options = [] ) {
547        // NOTE: We intentionally avoid injecting ContentHandlerFactory here.
548        // Accessing MediaWikiServices during early service construction can
549        // trigger premature initialization of ContentHandlerFactory, which may
550        // prevent other extensions (e.g. Wikibase) from registering their
551        // content models correctly.
552        $slotDiffRenderer = MediaWikiServices::getInstance()
553            ->getContentHandlerFactory()
554            ->getContentHandler( CONTENT_MODEL_TEXT )
555            ->getSlotDiffRenderer( $context );
556        '@phan-var TextSlotDiffRenderer $slotDiffRenderer';
557        return $slotDiffRenderer;
558    }
559
560}