Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
90.84% covered (success)
90.84%
119 / 131
77.78% covered (warning)
77.78%
14 / 18
CRAP
0.00% covered (danger)
0.00%
0 / 1
AbstractWikiContentHandler
90.84% covered (success)
90.84%
119 / 131
77.78% covered (warning)
77.78%
14 / 18
38.05
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%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 makeEmptyContent
100.00% covered (success)
100.00%
1 / 1
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
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 unserializeContent
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 validateSave
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
5
 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%
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
 supportsRedirects
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getActionOverrides
100.00% covered (success)
100.00%
10 / 10
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
100.00% covered (success)
100.00%
59 / 59
100.00% covered (success)
100.00%
1 / 1
5
 createDifferenceEngine
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 getSlotDiffRendererWithOptions
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 getAbstractContentForTitle
76.92% covered (warning)
76.92%
10 / 13
0.00% covered (danger)
0.00%
0 / 1
6.44
1<?php
2/**
3 * WikiLambda content handler for Abstract Wiki content objects
4 *
5 * @file
6 * @ingroup Extensions
7 * @copyright 2020– Abstract Wikipedia team; see AUTHORS.txt
8 * @license MIT
9 */
10
11namespace MediaWiki\Extension\WikiLambda\AbstractContent;
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\ValidationParams;
20use MediaWiki\Context\IContextSource;
21use MediaWiki\Context\RequestContext;
22use MediaWiki\Diff\TextSlotDiffRenderer;
23use MediaWiki\Extension\WikiLambda\AWStorage\AWArticleStore;
24use MediaWiki\Extension\WikiLambda\PageTitle\PageTitleBuilder;
25use MediaWiki\Extension\WikiLambda\UIUtils;
26use MediaWiki\Extension\WikiLambda\WikidataEntityLookup;
27use MediaWiki\Extension\WikiLambda\WikiLambdaServices;
28use MediaWiki\Html\Html;
29use MediaWiki\Logger\LoggerFactory;
30use MediaWiki\MediaWikiServices;
31use MediaWiki\Parser\ParserOutput;
32use MediaWiki\Permissions\Authority;
33use MediaWiki\Revision\RevisionRecord;
34use MediaWiki\Revision\RevisionStore;
35use MediaWiki\Revision\SlotRecord;
36use MediaWiki\Revision\SlotRenderingProvider;
37use MediaWiki\Title\Title;
38use MediaWiki\WikiMap\WikiMap;
39use StatusValue;
40
41class AbstractWikiContentHandler extends ContentHandler {
42
43    // private const ABSTRACTCONTENT_TYPE_WIKIPEDIA = 'Q50081413';
44    // private const ABSTRACTCONTENT_TYPE_DRAFT = 'Q560361';
45    // // XXX: There is not yet a QID for Wiktionary content pages; this is used here as a placeholder
46    // private const ABSTRACTCONTENT_TYPE_WIKTIONARY = 'Q15138389';
47
48    /**
49     * @param string $modelId
50     * @param Config $config
51     * @param AWArticleStore $articleStore
52     * @param WikidataEntityLookup $entityLookup
53     */
54    public function __construct(
55        $modelId,
56        private readonly Config $config,
57        private readonly AWArticleStore $articleStore,
58        private readonly WikidataEntityLookup $entityLookup
59    ) {
60        if ( $modelId !== CONTENT_MODEL_ABSTRACT ) {
61            throw new InvalidArgumentException( __CLASS__ . " initialised for invalid content model" );
62        }
63
64        // Triggers use of message content-model-abstractcontent
65        parent::__construct( CONTENT_MODEL_ABSTRACT, [ CONTENT_FORMAT_TEXT ] );
66    }
67
68    /**
69     * @param Title $title Page to check
70     * @return bool
71     */
72    public function canBeUsedOn( Title $title ) {
73        if ( !WikiLambdaServices::getMode()->isAbstract() ) {
74            return false;
75        }
76
77        $enabledNamespace = $this->config->get( 'WikiLambdaAbstractNamespaces' );
78        if ( array_key_exists( $title->getNamespace(), $enabledNamespace ) ) {
79            return true;
80        }
81
82        return false;
83    }
84
85    /**
86     * @return AbstractWikiContent
87     */
88    public function makeEmptyContent() {
89        return AbstractWikiContent::makeEmptyContent();
90    }
91
92    /**
93     * @param string $data
94     * @param Title|null $title
95     * @param string|null $modelId
96     * @param string|null $format
97     * @return AbstractWikiContent
98     */
99    public static function makeContent( $data, ?Title $title = null, $modelId = null, $format = null ) {
100        // @phan-suppress-next-line PhanTypeMismatchReturnSuperType
101        return parent::makeContent( $data, $title, $modelId, $format );
102    }
103
104    /**
105     * @return string
106     */
107    protected function getContentClass() {
108        return AbstractWikiContent::class;
109    }
110
111    /**
112     * @param Content $content
113     * @param string|null $format
114     * @return string
115     */
116    public function serializeContent( Content $content, $format = null ) {
117        $this->checkFormat( $format );
118
119        if ( !( $content instanceof AbstractWikiContent ) ) {
120            // Throw?
121            return '';
122        }
123
124        return $content->getText();
125    }
126
127    /**
128     * @param string $text
129     * @param string|null $format
130     * @return AbstractWikiContent
131     * @throws ContentSerializationException if input causes an error
132     */
133    public function unserializeContent( $text, $format = null ) {
134        $class = $this->getContentClass();
135        try {
136            return new $class( $text );
137        } catch ( InvalidArgumentException $error ) {
138            // (T381115) If the passed user input isn't valid, we're expected to throw this particular MW error
139            throw new ContentSerializationException( $error->getMessage() );
140        }
141    }
142
143    /**
144     * @inheritDoc
145     */
146    public function validateSave( Content $content, ValidationParams $validationParams ) {
147        /** @var AbstractWikiContent $content */
148        '@phan-var AbstractWikiContent $content';
149
150        $title = Title::newFromPageIdentity( $validationParams->getPageIdentity() );
151
152        if ( !$content->isValidForTitle( $title ) ) {
153            return $content->getStatus();
154        }
155
156        $qid = $content->getTopicQid();
157        // Check if the QID is null or a null Wikidata item reference (Q0)
158        if ( $qid === null || AbstractContentUtils::isNullWikidataItemReference( $qid ) ) {
159            return StatusValue::newFatal( 'wikilambda-abstract-error-bad-qid', $qid ?? '' );
160        }
161        // Check if the QID exists on Wikidata
162        // If WikibaseClient is not loaded, we skip this check and let the save proceed.
163        if ( $this->entityLookup->wikidataItemExists( $qid ) === false ) {
164            return StatusValue::newFatal( 'wikilambda-abstract-error-nonexistent-qid', $qid );
165        }
166
167        return StatusValue::newGood();
168    }
169
170    /**
171     * @inheritDoc
172     */
173    public function getSecondaryDataUpdates(
174        Title $title,
175        Content $content,
176        $role,
177        SlotRenderingProvider $slotOutput
178    ) {
179        $updates = parent::getSecondaryDataUpdates( $title, $content, $role, $slotOutput );
180        if ( $content instanceof AbstractWikiContent ) {
181            $updates[] = new AbstractContentDataUpdate( $title, $content, $this->articleStore );
182            // (T390557) Record which Functions this Abstract article calls into the shared cross-wiki usage table.
183            $updates[] = new AbstractWikiUsageUpdate( $title, $content );
184        }
185
186        return $updates;
187    }
188
189    /**
190     * @inheritDoc
191     */
192    public function getDeletionUpdates( Title $title, $role ) {
193        $updates = parent::getDeletionUpdates( $title, $role );
194
195        $updates[] = new AbstractContentDataRemoval( $title, $this->articleStore );
196
197        // (T390557) Clear this article's rows from the shared usage table.
198        $updates[] = new AbstractWikiUsageRemoval( WikiMap::getCurrentWikiId(), $title->getArticleID() );
199
200        return $updates;
201    }
202
203    /**
204     * @inheritDoc
205     */
206    public function supportsDirectEditing() {
207        return true;
208    }
209
210    /**
211     * @inheritDoc
212     */
213    public function supportsRedirects() {
214        return true;
215    }
216
217    /**
218     * @inheritDoc
219     */
220    public function getActionOverrides() {
221        return [
222            'edit' => [
223                'class' => AbstractContentEditAction::class,
224                'services' => [
225                    'RevisionStore',
226                    'ContentHandlerFactory'
227                ]
228            ],
229            'history' => AbstractContentHistoryAction::class
230        ];
231    }
232
233    /**
234     * Do not render HTML on edit
235     *
236     * @return bool
237     */
238    public function generateHTMLOnEdit(): bool {
239        return false;
240    }
241
242    /**
243     * Set the HTML and add the appropriate styles.
244     *
245     * @inheritDoc
246     * @param Content $content
247     * @param ContentParseParams $cpoParams
248     * @param ParserOutput &$parserOutput The output object to fill (reference).
249     */
250    protected function fillParserOutput(
251        Content $content,
252        ContentParseParams $cpoParams,
253        ParserOutput &$parserOutput
254    ) {
255        $userLang = RequestContext::getMain()->getLanguage();
256        $logger = LoggerFactory::getInstance( 'WikiLambdaAbstract' );
257
258        // Ensure the stored content is a valid AbstractWikiContent
259        if ( !( $content instanceof AbstractWikiContent ) || !$content->isValid() ) {
260            $parserOutput->setContentHolderText(
261                Html::element(
262                    'div',
263                    [
264                        'class' => [ 'ext-wikilambda-view-invalidcontent', 'warning' ],
265                    ],
266                    wfMessage( 'wikilambda-abstract-invalidcontent' )->inLanguage( $userLang )->text()
267                )
268            );
269            // Exit early, as the rest of the code relies on the stored content being ours.
270            return;
271        }
272
273        // Don't do further work if the requester doesn't want the HTML version generated.
274        if ( !$cpoParams->getGenerateHtml() ) {
275            $parserOutput->setContentHolderText( '' );
276            return;
277        }
278
279        // TODO (T362245): Re-work our code to use PageReferences rather than Titles
280        $pageIdentity = $cpoParams->getPage();
281        $title = Title::castFromPageReference( $pageIdentity );
282        '@phan-var Title $title';
283
284        // Set display title to show Wikibase label if available
285        $qid = $title->getBaseText();
286        $langCode = $userLang->getCode();
287        $label = $this->entityLookup->resolveAbstractLabel( $qid, $langCode );
288        if ( $label !== null ) {
289            $parserOutput->setDisplayTitle(
290                PageTitleBuilder::createAbstractViewPageTitle(
291                    $label,
292                    $langCode,
293                    $userLang->getDir(),
294                    $qid,
295                )
296            );
297        }
298
299        // (T426833) Set the browser <title> directly on the OutputPage ("Label (QID) -
300        // {{SITENAME}}" or "QID - {{SITENAME}}"), mirroring ZObjectContentHandler (T360169),
301        // so the "/wiki/Q42" and "?title=…" views match the Special:ViewAbstract, edit and
302        // history variants. Set unconditionally so the no-label case drops the namespace too.
303        RequestContext::getMain()->getOutput()->setHTMLTitle(
304            PageTitleBuilder::createAbstractViewPageHtmlTitle( $label, $qid, $langCode )
305        );
306
307        // Set config variables
308        $wikilambdaConfig = [
309            'abstractContent' => true,
310            'content' => $content->getText(),
311            'createNewPage' => false,
312            'title' => $title->getBaseText(),
313            'page' => $title->getPrefixedDBkey(),
314            'zlang' => $userLang->getCode(),
315            'viewmode' => true
316        ];
317        $parserOutput->setJsConfigVar( 'wgWikiLambda', $wikilambdaConfig );
318
319        // Load styles and Vue app modules
320        $parserOutput->addModuleStyles( [ 'ext.wikilambda.viewpage.styles' ] );
321        $parserOutput->addModules( [ 'ext.wikilambda.app' ] );
322
323        // Build HTML fragments to load Vue app
324        $loadingMessage = wfMessage( 'wikilambda-loading' )->inLanguage( $userLang )->text();
325        $parserOutput->setContentHolderText(
326            // Placeholder div for the Vue template with Codex progress indicator.
327            Html::rawElement(
328                'div',
329                [ 'id' => 'ext-wikilambda-app' ],
330                UIUtils::createCodexProgressIndicator( $loadingMessage )
331            )
332            // Fallback message for users without JavaScript.
333            . Html::rawElement(
334                'noscript',
335                [],
336                wfMessage( 'wikilambda-nojs' )->inLanguage( $userLang )->parse()
337            )
338        );
339    }
340
341    /**
342     * @inheritDoc
343     */
344    public function createDifferenceEngine(
345        IContextSource $context,
346        $oldContentRevisionId = 0,
347        $newContentRevisionId = 0,
348        $recentChangesId = 0,
349        $refreshCache = false,
350        $unhide = false
351    ) {
352        return new AbstractContentDifferenceEngine(
353            $context, $oldContentRevisionId, $newContentRevisionId, $recentChangesId, $refreshCache, $unhide
354        );
355    }
356
357    /**
358     * @inheritDoc
359     *
360     * Access level widened to public for use in AbstractContentDifferenceEngine
361     */
362    public function getSlotDiffRendererWithOptions( IContextSource $context, $options = [] ) {
363        // NOTE: We intentionally avoid injecting ContentHandlerFactory here.
364        // Accessing MediaWikiServices during early service construction can
365        // trigger premature initialization of ContentHandlerFactory, which may
366        // prevent other extensions (e.g. Wikibase) from registering their
367        // content models correctly.
368        $slotDiffRenderer = MediaWikiServices::getInstance()
369            ->getContentHandlerFactory()
370            ->getContentHandler( CONTENT_MODEL_TEXT )
371            ->getSlotDiffRenderer( $context );
372        '@phan-var TextSlotDiffRenderer $slotDiffRenderer';
373        return $slotDiffRenderer;
374    }
375
376    /**
377     * Return the AbstractWikiContent object for a title and revisionId
378     * or the latest revision if revisionId is null.
379     *
380     * Returns false if the revision is not found, the content model of the
381     * retrieved revision doesn't match AbstractContent model, or the given
382     * performer may not see the (deleted/suppressed) revision.
383     *
384     * When a performer is supplied the content is read for that user's audience
385     * (RevisionDelete/suppression honoured); when it is null the content is read
386     * at the public audience, so a deleted/suppressed revision is excluded. The
387     * only null-performer caller is the public article-store rebuild.
388     *
389     * TODO: Once the security fix is deployed, make the visibility audience
390     * explicit — an $audience parameter mirroring RevisionRecord::getContent() —
391     * rather than inferring it from whether a performer is passed. Deferred to
392     * keep this patch minimal; do it under regular code review.
393     *
394     * @param RevisionStore $revisionStore
395     * @param Title $title
396     * @param int|null $revisionId
397     * @param Authority|null $performer
398     * @return AbstractWikiContent|false
399     */
400    public function getAbstractContentForTitle(
401        RevisionStore $revisionStore,
402        Title $title,
403        ?int $revisionId = null,
404        ?Authority $performer = null
405    ) {
406        // Get requested or current revision
407        $revision = $revisionId ?
408            $revisionStore->getRevisionByTitle( $title, $revisionId, 0 ) :
409            $revisionStore->getKnownLatestRevision( $title );
410
411        // Check revision exists
412        if ( !$revision ) {
413            return false;
414        }
415
416        // Check content model
417        $contentModel = $revision->getMainContentModel();
418        if ( $contentModel !== CONTENT_MODEL_ABSTRACT ) {
419            return false;
420        }
421
422        // Honour deletion/suppression. With a performer, use their audience; without
423        // one, fail closed at the public audience (the only no-performer caller is the
424        // public article-store rebuild, which must not bake in hidden content).
425        $audience = $performer ? RevisionRecord::FOR_THIS_USER : RevisionRecord::FOR_PUBLIC;
426        $content = $revision->getContent( SlotRecord::MAIN, $audience, $performer );
427        if ( $content === null ) {
428            return false;
429        }
430        '@phan-var AbstractWikiContent $content';
431        return $content;
432    }
433}