Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
71.67% covered (warning)
71.67%
172 / 240
72.22% covered (warning)
72.22%
13 / 18
CRAP
0.00% covered (danger)
0.00%
0 / 1
ZObjectContentHandler
71.67% covered (warning)
71.67%
172 / 240
72.22% covered (warning)
72.22%
13 / 18
76.39
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%
23 / 23
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%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 getExternalRepresentation
85.00% covered (warning)
85.00%
34 / 40
0.00% covered (danger)
0.00%
0 / 1
6.12
 getSecondaryDataUpdates
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 getDeletionUpdates
100.00% covered (success)
100.00%
4 / 4
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
5.00% covered (danger)
5.00%
3 / 60
0.00% covered (danger)
0.00%
0 / 1
26.43
 createZObjectViewHeader
98.67% covered (success)
98.67%
74 / 75
0.00% covered (danger)
0.00%
0 / 1
9
 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
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
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;
12
13use Content;
14use ContentHandler;
15use FormatJson;
16use InvalidArgumentException;
17use Language;
18use MediaWiki\Content\Renderer\ContentParseParams;
19use MediaWiki\Content\Transform\PreSaveTransformParams;
20use MediaWiki\Content\ValidationParams;
21use MediaWiki\Context\IContextSource;
22use MediaWiki\Context\RequestContext;
23use MediaWiki\Extension\WikiLambda\Diff\ZObjectContentDifferenceEngine;
24use MediaWiki\Extension\WikiLambda\Registry\ZErrorTypeRegistry;
25use MediaWiki\Extension\WikiLambda\Registry\ZLangRegistry;
26use MediaWiki\Extension\WikiLambda\Registry\ZTypeRegistry;
27use MediaWiki\Html\Html;
28use MediaWiki\MediaWikiServices;
29use MediaWiki\Revision\SlotRenderingProvider;
30use MediaWiki\Title\Title;
31use ParserOutput;
32use StatusValue;
33
34class ZObjectContentHandler extends ContentHandler {
35    use ZObjectEditingPageTrait;
36
37    /**
38     * @param string $modelId
39     */
40    public function __construct( $modelId ) {
41        if ( $modelId !== CONTENT_MODEL_ZOBJECT ) {
42            throw new InvalidArgumentException( __CLASS__ . " initialised for invalid content model" );
43        }
44
45        // Triggers use of message content-model-zobject
46        parent::__construct( CONTENT_MODEL_ZOBJECT, [ CONTENT_FORMAT_TEXT ] );
47    }
48
49    /**
50     * @param Title $title Page to check
51     * @return bool
52     */
53    public function canBeUsedOn( Title $title ) {
54        return $title->inNamespace( NS_MAIN );
55    }
56
57    /**
58     * @return ZObjectContent
59     */
60    public function makeEmptyContent() {
61        $class = $this->getContentClass();
62        return new $class(
63            '{' . "\n"
64            . '"' . ZTypeRegistry::Z_OBJECT_TYPE . '": "' . ZTypeRegistry::Z_PERSISTENTOBJECT . '",' . "\n"
65            . '"' . ZTypeRegistry::Z_PERSISTENTOBJECT_ID . '": "' . ZTypeRegistry::Z_NULL_REFERENCE . '",' . "\n"
66            . '"' . ZTypeRegistry::Z_PERSISTENTOBJECT_VALUE . '": "",' . "\n"
67            . '"' . ZTypeRegistry::Z_PERSISTENTOBJECT_LABEL . '": {' . "\n"
68            . '"' . ZTypeRegistry::Z_OBJECT_TYPE . '": "' . ZTypeRegistry::Z_MULTILINGUALSTRING . '",' . "\n"
69            . '"' . ZTypeRegistry::Z_MULTILINGUALSTRING_VALUE . '":'
70            . '["' . ZTypeRegistry::Z_MONOLINGUALSTRING . '"]' . "\n"
71            . '},' . "\n"
72            . '"' . ZTypeRegistry::Z_PERSISTENTOBJECT_ALIASES . '": {' . "\n"
73            . '"' . ZTypeRegistry::Z_OBJECT_TYPE . '": "' . ZTypeRegistry::Z_MULTILINGUALSTRINGSET . '",' . "\n"
74            . '"' . ZTypeRegistry::Z_MULTILINGUALSTRINGSET_VALUE . '":'
75            . '["' . ZTypeRegistry::Z_MONOLINGUALSTRINGSET . '"]' . "\n"
76            . '},' . "\n"
77            . '"' . ZTypeRegistry::Z_PERSISTENTOBJECT_DESCRIPTION . '": {' . "\n"
78            . '"' . ZTypeRegistry::Z_OBJECT_TYPE . '": "' . ZTypeRegistry::Z_MULTILINGUALSTRING . '",' . "\n"
79            . '"' . ZTypeRegistry::Z_MULTILINGUALSTRING_VALUE . '":'
80            . '["' . ZTypeRegistry::Z_MONOLINGUALSTRING . '"]' . "\n"
81            . '}' . "\n"
82            . '}'
83        );
84    }
85
86    /**
87     * @param string $data
88     * @param Title|null $title
89     * @param string|null $modelId
90     * @param string|null $format
91     * @return ZObjectContent
92     */
93    public static function makeContent( $data, Title $title = null, $modelId = null, $format = null ) {
94        // @phan-suppress-next-line PhanTypeMismatchReturnSuperType
95        return parent::makeContent( $data, $title, $modelId, $format );
96    }
97
98    /**
99     * @return string
100     */
101    protected function getContentClass() {
102        return ZObjectContent::class;
103    }
104
105    /**
106     * @param Content $content
107     * @param string|null $format
108     * @return string
109     */
110    public function serializeContent( Content $content, $format = null ) {
111        $this->checkFormat( $format );
112
113        if ( !( $content instanceof ZObjectContent ) ) {
114            // Throw?
115            return '';
116        }
117
118        return $content->getText();
119    }
120
121    /**
122     * @param string $text
123     * @param string|null $format
124     * @return ZObjectContent
125     */
126    public function unserializeContent( $text, $format = null ) {
127        $this->checkFormat( $format );
128
129        $class = $this->getContentClass();
130        return new $class( $text );
131    }
132
133    /**
134     * @param Title $zObjectTitle The page to fetch.
135     * @param string|null $languageCode The language in which to return results. If unset, all results are returned.
136     * @param int|null $revision The revision ID of the page to fetch. If unset, the latest is returned.
137     * @return string The external JSON form of the given title.
138     * @throws ZErrorException
139     */
140    public static function getExternalRepresentation(
141        Title $zObjectTitle, ?string $languageCode = null, ?int $revision = null
142    ): string {
143        if ( $zObjectTitle->getNamespace() !== NS_MAIN ) {
144            throw new ZErrorException(
145                ZErrorFactory::createZErrorInstance(
146                    ZErrorTypeRegistry::Z_ERROR_WRONG_NAMESPACE,
147                    [ 'title' => (string)$zObjectTitle ]
148                )
149            );
150        }
151
152        if ( $zObjectTitle->getContentModel() !== CONTENT_MODEL_ZOBJECT ) {
153            throw new ZErrorException(
154                ZErrorFactory::createZErrorInstance(
155                    ZErrorTypeRegistry::Z_ERROR_WRONG_CONTENT_TYPE,
156                    [ 'title' => (string)$zObjectTitle ]
157                )
158            );
159        }
160
161        $zObjectStore = WikiLambdaServices::getZObjectStore();
162        $zObject = $zObjectStore->fetchZObjectByTitle( $zObjectTitle, $revision );
163
164        if ( $zObject === false ) {
165            throw new ZErrorException(
166                ZErrorFactory::createZErrorInstance(
167                    ZErrorTypeRegistry::Z_ERROR_ZID_NOT_FOUND,
168                    [ 'data' => (string)$zObjectTitle ]
169                )
170            );
171        }
172
173        $object = ZObjectUtils::canonicalize( $zObject->getObject() );
174
175        if ( $languageCode ) {
176            // TODO (T362246): Dependency-inject
177            $services = MediaWikiServices::getInstance();
178
179            // If language code is not valid, throws ZErrorException of Z540/Invalid language code
180            if ( !$services->getLanguageNameUtils()->isValidCode( $languageCode ) ) {
181                throw new ZErrorException(
182                    ZErrorFactory::createZErrorInstance(
183                        ZErrorTypeRegistry::Z_ERROR_INVALID_LANG_CODE,
184                        [ 'lang' => $languageCode ]
185                    )
186                );
187            }
188
189            // If language doesn't have a Zid, throws ZErrorException of Z541/Language code not found
190            $languageZid = ZLangRegistry::singleton()->getLanguageZidFromCode( $languageCode );
191
192            // Filter all Multilingual Strings and Stringsets if language is present and valid
193            $object = ZObjectUtils::filterZMultilingualStringsToLanguage( $object, [ $languageZid ] );
194        }
195
196        // Replace Z2K1: Z0 with the actual page ID.
197        $object->{ ZTypeRegistry::Z_PERSISTENTOBJECT_ID } = [
198            ZTypeRegistry::Z_OBJECT_TYPE => ZTypeRegistry::Z_STRING,
199            ZTypeRegistry::Z_STRING_VALUE => $zObjectTitle->getDBkey()
200        ];
201
202        return FormatJson::encode( $object, true, FormatJson::UTF8_OK );
203    }
204
205    /**
206     * @inheritDoc
207     */
208    public function getSecondaryDataUpdates(
209        Title $title,
210        Content $content,
211        $role,
212        SlotRenderingProvider $slotOutput
213    ) {
214        return array_merge(
215            parent::getSecondaryDataUpdates( $title, $content, $role, $slotOutput ),
216            [ new ZObjectSecondaryDataUpdate( $title, $content ) ]
217        );
218    }
219
220    /**
221     * @inheritDoc
222     */
223    public function getDeletionUpdates( Title $title, $role ) {
224        return array_merge(
225            parent::getDeletionUpdates( $title, $role ),
226            [ new ZObjectSecondaryDataRemoval( $title ) ]
227        );
228    }
229
230    /**
231     * @inheritDoc
232     */
233    public function supportsDirectEditing() {
234        return false;
235    }
236
237    /**
238     * @inheritDoc
239     */
240    public function getActionOverrides() {
241        return [
242            'edit' => ZObjectEditAction::class,
243            'history' => ZObjectHistoryAction::class
244        ];
245    }
246
247    /**
248     * Do not render HTML on edit (T285987)
249     *
250     * @return bool
251     */
252    public function generateHTMLOnEdit(): bool {
253        return false;
254    }
255
256    /**
257     * Set the HTML and add the appropriate styles.
258     *
259     * @inheritDoc
260     * @param Content $content
261     * @param ContentParseParams $cpoParams
262     * @param ParserOutput &$parserOutput The output object to fill (reference).
263     */
264    protected function fillParserOutput(
265        Content $content,
266        ContentParseParams $cpoParams,
267        ParserOutput &$parserOutput
268    ) {
269        if ( !$cpoParams->getGenerateHtml() ) {
270            $parserOutput->setText( '' );
271            return;
272        }
273
274        $userLang = RequestContext::getMain()->getLanguage();
275
276        // Ensure the stored content is a valid ZObject; this also populates $this->getZObject() for us
277        if ( !( $content instanceof ZObjectContent ) || !$content->isValid() ) {
278            $parserOutput->setText(
279                Html::element(
280                    'div',
281                    [
282                        'class' => [ 'ext-wikilambda-view-invalidcontent', 'warning' ],
283                    ],
284                    wfMessage( 'wikilambda-invalidzobject' )->inLanguage( $userLang )->text()
285                )
286            );
287            // Exit early, as the rest of the code relies on the stored content being well-formed and valid.
288            return;
289        }
290
291        $pageIdentity = $cpoParams->getPage();
292
293        // TODO (T362245): Re-work our code to use PageReferences rather than Titles
294        $title = Title::castFromPageReference( $pageIdentity );
295        '@phan-var Title $title';
296
297        $header = static::createZObjectViewHeader( $content, $title, $userLang );
298        $parserOutput->setTitleText( $header );
299
300        $parserOutput->addModuleStyles( [ 'ext.wikilambda.viewpage.styles' ] );
301
302        $parserOutput->addModules( [ 'ext.wikilambda.edit' ] );
303
304        // $zObjectStore = WikiLambdaServices::getZObjectStore();
305        // $zObject = $zObjectStore->fetchZObjectByTitle( $title );
306
307        $zLangRegistry = ZLangRegistry::singleton();
308        $userLangCode = $userLang->getCode();
309
310        // If the userLang isn't recognised (e.g. it's qqx, or a language we don't support yet, or it's
311        // nonsense), then fall back to English.
312        $userLangZid = $zLangRegistry->getLanguageZidFromCode( $userLangCode, true );
313        // Normalise our used language code from what the Language object says
314        $userLangCode = $zLangRegistry->getLanguageCodeFromZid( $userLangZid );
315
316        // Add the canonical page link to /view/<lang>/<zid>
317        $output = RequestContext::getMain()->getOutput();
318        $output->addLink( [
319                'rel' => 'canonical',
320                'hreflang' => $userLangCode,
321                'href' => "/view/$userLangCode/" . $title->getDBkey(),
322            ] );
323
324        $editingData = [
325            // The following paramether may be the same now,
326            // but will surely change in the future as we remove the Zds from the UI
327            'title' => $title->getBaseText(),
328            'zId' => $title->getBaseText(),
329            'page' => $title->getPrefixedDBkey(),
330            'zlang' => $userLangCode,
331            'zlangZid' => $userLangZid,
332            'createNewPage' => false,
333            'viewmode' => true
334        ];
335
336        $parserOutput->setJsConfigVar( 'wgWikiLambda', $editingData );
337
338        $parserOutput->setText(
339            // Placeholder div for the Vue template.
340            Html::element( 'div', [ 'id' => 'ext-wikilambda-app' ] )
341            // Fallback div for the warning.
342            . Html::rawElement(
343                'div',
344                [
345                    'class' => [ 'ext-wikilambda-view-nojsfallback', 'client-nojs' ],
346                ],
347                Html::element(
348                    'div',
349                    [
350                        'class' => [ 'ext-wikilambda-view-nojswarning', 'warning' ],
351                    ],
352                    wfMessage( 'wikilambda-viewmode-nojs' )->inLanguage( $userLang )->text()
353                )
354            )
355        );
356
357        // Add links to other ZObjects
358        foreach ( $content->getInnerZObject()->getLinkedZObjects() as $link ) {
359            $parserOutput->addLink( Title::newFromText( $link, NS_MAIN ) );
360        }
361    }
362
363    /**
364     * Generate the special "title" shown on view pages
365     *
366     * <span class="ext-wikilambda-viewpage-header" lang="es">
367     *     <span class="ext-wikilambda-viewpage-header--bcp47-code">en</span>
368     *     &#20;
369     *     <span class="ext-wikilambda-viewpage-header-label">multiply</span>
370     *     <span class="ext-wikilambda-viewpage-header-zid">Z12345</span>
371     *     <div class="ext-wikilambda-viewpage-header-type">
372     *         <span class="ext-wikilambda-viewpage-header--bcp47-code">en</span>
373     *         &#20;
374     *         <span class="ext-wikilambda-viewpage-header-type-label">Function</span>
375     *     </div>
376     * </span>
377     *
378     * @param ZObjectContent $content
379     * @param Title $title
380     * @param Language $userLang
381     * @return string
382     */
383    public static function createZObjectViewHeader(
384        ZObjectContent $content, Title $title, Language $userLang
385    ): string {
386        // TODO (T362246): Dependency-inject
387        $services = MediaWikiServices::getInstance();
388
389        $zobject = $content->getZObject();
390
391        if ( !$zobject || !$zobject->isValid() ) {
392            // Soemthing's bad, let's give up.
393            return '';
394        }
395
396        // Get best-available label (and its language code) for the target object's type, given the request language.
397        [
398            'title' => $targetTypeLabel,
399            'languageCode' => $targetTypeLabelLanguage
400        ] = $content->getTypeStringAndLanguage( $userLang );
401
402        // OBJECT TYPE Language code, which is usually a BCP47 code (e.g. 'en') but sometimes tests inject it as a
403        // Language object(!)
404        $targetTypeDisplayCode = gettype( $targetTypeLabelLanguage ) === 'string'
405            ? $targetTypeLabelLanguage : $targetTypeLabelLanguage->getCode();
406        // OBJECT TYPE language label (e.g. 'Function') of the language currently being rendered
407        $targetTypeDisplayLabelLanguageName = $services->getLanguageNameUtils()->getLanguageName(
408            $targetTypeDisplayCode
409        );
410
411        // Get best-available label (and its language code) for the target object's name, given the request language.
412
413        // OBJECT NAME label (e.g. 'My function' or 'Unknown') and language code (e.g. 'en')
414        [
415            'title' => $targetLabel,
416            'languageCode' => $targetLabelLanguageCode
417        ] = $zobject->getLabels()->buildStringForLanguage( $userLang )
418            ->fallbackWithEnglish()
419            ->placeholderForTitle()
420            ->getStringAndLanguageCode();
421
422        // OBJECT NAME language label (e.g. 'English') of the language currently being rendered
423        $targetDisplayLabelLanguageName = $services->getLanguageNameUtils()->getLanguageName(
424            $targetLabelLanguageCode
425        );
426
427        $bcp47CodeClassName = 'ext-wikilambda-viewpage-header--bcp47-code';
428
429        $targetDisplayLabelWidget = '';
430        // If the object type label (e.g. 'Function') is not in the user's language, show a BCP47 code widget
431        // for the language used instead
432        if ( $targetLabelLanguageCode !== $userLang->getCode() ) {
433            $targetDisplayLabelWidget = ZObjectUtils::wrapBCP47CodeInFakeCodexChip(
434                $targetLabelLanguageCode, $targetDisplayLabelLanguageName, $bcp47CodeClassName
435            );
436        }
437
438        $targetDisplayTypeWidget = '';
439        // If the object label (e.g. 'Echo') is not in the user's language, show a BCP47 code widget
440        // for the language used instead
441        if ( $targetTypeDisplayCode !== $userLang->getCode() ) {
442            $targetDisplayTypeWidget = ZObjectUtils::wrapBCP47CodeInFakeCodexChip(
443                $targetTypeDisplayCode, $targetTypeDisplayLabelLanguageName, $bcp47CodeClassName
444            );
445        }
446
447        $untitledStyle = $targetLabel === wfMessage( 'wikilambda-editor-default-name' )->text() ?
448            'ext-wikilambda-viewpage-header--title-untitled' : null;
449
450        $labelSpan = Html::element(
451            'span',
452            [
453                'class' => [
454                    'ext-wikilambda-viewpage-header-title',
455                    'ext-wikilambda-viewpage-header-title--function-name',
456                    $untitledStyle
457                ]
458            ],
459            $targetLabel
460        );
461
462        $zidSpan = Html::element(
463            'span',
464            [
465                'class' => 'ext-wikilambda-viewpage-header-zid'
466            ],
467            $title->getText()
468        );
469
470        $labelTitle =
471            // (T356731) When $targetDisplayLabelWidget is an empty string, colon-separator already
472            // adds/removes the needed/unneeded whitespace for languages. Always adding a
473            // space would unexpectedly add unneeded extra whitespace for languages including
474            // zh-hans, zh-hant, etc.
475            ( $targetDisplayLabelWidget === '' ? '' : $targetDisplayLabelWidget . ' ' )
476                . $labelSpan . ' ' . $zidSpan;
477
478        $typeSubtitle = Html::rawElement(
479            'div', [ 'class' => 'ext-wikilambda-viewpage-header-type' ],
480            $targetDisplayTypeWidget . ' ' . Html::element(
481                'span',
482                [
483                    'class' => 'ext-wikilambda-viewpage-header-type-label'
484                ],
485                $targetTypeLabel
486            )
487        );
488
489        return Html::rawElement(
490            'span',
491            [
492                // Mark the header in the correct language, regardless of the rest of the page
493                // … but mark it back into their requested language if it's actually untitled
494                'lang' => ( $untitledStyle === null ? $userLang->getCode() : $targetTypeDisplayCode ),
495                'class' => 'ext-wikilambda-viewpage-header'
496            ],
497            $labelTitle . $typeSubtitle
498        );
499    }
500
501    /**
502     * @param Content $content
503     * @param ValidationParams $validationParams
504     * @return StatusValue
505     */
506    public function validateSave( $content, $validationParams ) {
507        if ( $content->isValid() ) {
508            return StatusValue::newGood();
509        }
510        return StatusValue::newFatal( "wikilambda-invalidzobject" );
511    }
512
513    /**
514     * @inheritDoc
515     */
516    public function preSaveTransform( Content $content, PreSaveTransformParams $pstParams ): Content {
517        '@phan-var ZObjectContent $content';
518
519        if ( !$content->isValid() ) {
520            return $content;
521        }
522
523        $json = ZObjectUtils::canonicalize( $content->getObject() );
524        $encoded = FormatJson::encode( $json, true, FormatJson::UTF8_OK );
525        $encodedCleanedWhitespace = str_replace( [ "\r\n", "\r" ], "\n", rtrim( $encoded ) );
526
527        if ( $content->getText() !== $encodedCleanedWhitespace ) {
528            $contentClass = $this->getContentClass();
529            return new $contentClass( $encodedCleanedWhitespace );
530        }
531
532        return $content;
533    }
534
535    /**
536     * @inheritDoc
537     */
538    public function createDifferenceEngine(
539        IContextSource $context,
540        $oldContentRevisionId = 0,
541        $newContentRevisionId = 0,
542        $recentChangesId = 0,
543        $refreshCache = false,
544        $unhide = false
545    ) {
546        return new ZObjectContentDifferenceEngine(
547            $context, $oldContentRevisionId, $newContentRevisionId, $recentChangesId, $refreshCache, $unhide
548        );
549    }
550}