Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
87.37% covered (warning)
87.37%
83 / 95
77.78% covered (warning)
77.78%
14 / 18
CRAP
0.00% covered (danger)
0.00%
0 / 1
AbstractWikiContent
87.37% covered (warning)
87.37%
83 / 95
77.78% covered (warning)
77.78%
14 / 18
47.90
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 makeEmptyContent
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 getText
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getObject
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getTopicQid
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getSections
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getSectionByQid
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getTextForSearchIndex
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getWikitextForTransclusion
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getTextForSummary
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 getSize
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 copy
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 convert
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 isCountable
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 isValid
95.74% covered (success)
95.74%
45 / 47
0.00% covered (danger)
0.00%
0 / 1
18
 isValidForTitle
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 isEmpty
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 getStatus
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2/**
3 * WikiLambda AbstractWikiContent
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\Content\AbstractContent;
15use MediaWiki\Content\TextContent;
16use MediaWiki\Extension\WikiLambda\Registry\ZTypeRegistry;
17use MediaWiki\Json\FormatJson;
18use MediaWiki\MediaWikiServices;
19use MediaWiki\Title\Title;
20use StatusValue;
21
22/**
23 * This class represents the wrapper for an Abstract Wiki content object, as stored in MediaWiki.
24 */
25class AbstractWikiContent extends AbstractContent {
26
27    private ?array $object;
28    private ?StatusValue $status = null;
29
30    public const ABSTRACTCONTENT_SECTION_LEDE = 'Q8776414';
31
32    /**
33     * Builds the Content object that can be saved in the Wiki
34     *
35     * @param string $text
36     * @throws InvalidArgumentException If the input string isn't valid JSON
37     */
38    public function __construct( private readonly string $text ) {
39        // Some unit tests somehow don't load our constant by this point, so defensively provide it as needed.
40        $ourModel = defined( 'CONTENT_MODEL_ABSTRACT' ) ? CONTENT_MODEL_ABSTRACT : 'abstractwiki';
41        parent::__construct( $ourModel );
42
43        // Check that the input is a valid JSON
44        $parseStatus = FormatJson::parse( $text, FormatJson::FORCE_ASSOC );
45        if ( !$parseStatus->isGood() ) {
46            $errorMessage = wfMessage( $parseStatus->getErrors()[0]['message'] )->inContentLanguage()->text();
47            throw new InvalidArgumentException( $errorMessage );
48        }
49
50        $this->object = $parseStatus->getValue();
51    }
52
53    /**
54     * @return AbstractWikiContent
55     */
56    public static function makeEmptyContent(): AbstractWikiContent {
57        // Note: This is not the specification in Z25, but an intentional deviation from for simplicity.
58        $blankContent = <<<EOD
59{
60    "qid": "Q0",
61    "sections": {
62        "Q8776414": {
63            "index": 0,
64            "fragments": [ "Z89" ]
65        }
66    }
67}
68EOD;
69
70        return new self( $blankContent );
71    }
72
73    /**
74     * @return string
75     */
76    public function getText() {
77        return $this->text;
78    }
79
80    /**
81     * @return ?array
82     */
83    public function getObject() {
84        return $this->object;
85    }
86
87    /**
88     * @return ?string
89     */
90    public function getTopicQid() {
91        return $this->object[ 'qid' ] ?? null;
92    }
93
94    /**
95     * @return ?array
96     */
97    public function getSections() {
98        return $this->object['sections'] ?? null;
99    }
100
101    /**
102     * @param string $sectionQid
103     * @return ?array
104     */
105    public function getSectionByQid( string $sectionQid ): ?array {
106        return $this->object['sections'][$sectionQid] ?? null;
107    }
108
109    /**
110     * @inheritDoc
111     */
112    public function getTextForSearchIndex() {
113        // TODO (T271963): We'll probably want to inject something special for search with facets/etc.
114        return $this->getText();
115    }
116
117    /**
118     * @inheritDoc
119     */
120    public function getWikitextForTransclusion() {
121        // Abstract Wiki pages are not transcludable.
122        return false;
123    }
124
125    /**
126     * @inheritDoc
127     */
128    public function getTextForSummary( $maxLength = 250 ) {
129        // TODO (T362246): Dependency-inject
130        $contentLanguage = MediaWikiServices::getInstance()->getContentLanguage();
131        // Splice out newlines from content.
132        $textWithoutNewlines = preg_replace( "/[\n\r]/", ' ', $this->getText() );
133        $trimLength = max( 0, $maxLength );
134        return $contentLanguage->truncateForDatabase( $textWithoutNewlines, $trimLength );
135    }
136
137    /**
138     * @inheritDoc
139     */
140    public function getSize() {
141        return strlen( $this->getText() );
142    }
143
144    /**
145     * @inheritDoc
146     */
147    public function copy() {
148        // We're immutable.
149        return $this;
150    }
151
152    /**
153     * @inheritDoc
154     */
155    public function convert( $toModel, $lossy = '' ) {
156        if ( $toModel === CONTENT_MODEL_ABSTRACT ) {
157            return $this;
158        }
159
160        if ( $toModel === CONTENT_MODEL_TEXT ) {
161            return new TextContent( $this->text );
162        }
163
164        return false;
165    }
166
167    /**
168     * @inheritDoc
169     */
170    public function isCountable( $hasLinks = null ) {
171        // TODO (T362246): Dependency-inject
172        $config = MediaWikiServices::getInstance()->getMainConfig();
173
174        return (
175            !$this->isRedirect()
176            && ( $config->get( 'ArticleCountMethod' ) === 'any' )
177        );
178    }
179
180    /**
181     * @inheritDoc
182     */
183    public function isValid() {
184        // Content exists and is the right type
185        if ( !isset( $this->object ) ) {
186            $this->status = StatusValue::newFatal( 'wikilambda-abstract-error-invalid-json' );
187            return false;
188        }
189
190        // Qid exists, is a string, and has the shape of a qid.
191        // Also consider null (Q0) qid as valid, as empty content objects must pass validation
192        $topicQid = $this->getTopicQid();
193        if ( !is_string( $topicQid ) ||
194            ( !AbstractContentUtils::isValidWikidataItemReference( $topicQid ) &&
195            !AbstractContentUtils::isNullWikidataItemReference( $topicQid ) )
196        ) {
197            $badQid = $topicQid ?? (string)null;
198            $badQid = is_string( $badQid ) ? $badQid : var_export( $badQid, true );
199            $this->status = StatusValue::newFatal( 'wikilambda-abstract-error-bad-qid', $badQid );
200            return false;
201        }
202
203        // Sections exists and is an object
204        $sections = $this->getSections();
205        if ( !is_array( $sections ) ) {
206            $this->status = StatusValue::newFatal( 'wikilambda-abstract-error-missing-sections' );
207            return false;
208        }
209
210        // Sections must contain a lede section
211        if ( !array_key_exists( self::ABSTRACTCONTENT_SECTION_LEDE, $sections ) ) {
212            $this->status = StatusValue::newFatal( 'wikilambda-abstract-error-missing-lede-section' );
213            return false;
214        }
215
216        // For each section:
217        foreach ( $sections as $sectionQid => $section ) {
218            // Section key must be a valid qid
219            if ( !AbstractContentUtils::isValidWikidataItemReference( $sectionQid ) ) {
220                $this->status = StatusValue::newFatal( 'wikilambda-abstract-error-bad-section-qid', $sectionQid );
221                return false;
222            }
223
224            // Section must be an object
225            if ( !is_array( $section ) ) {
226                $this->status = StatusValue::newFatal( 'wikilambda-abstract-error-bad-section-content', $sectionQid );
227                return false;
228            }
229
230            $sectionIndex = $section['index'] ?? null;
231            // Section index must have a positive integer
232            if ( !is_int( $sectionIndex ) || $sectionIndex < 0 ) {
233                $badIndex = $sectionIndex ?? (string)null;
234                $badIndex = is_string( $badIndex ) ? $badIndex : var_export( $badIndex, true );
235                $this->status = StatusValue::newFatal(
236                    'wikilambda-abstract-error-bad-section-index',
237                    $sectionQid,
238                    $badIndex
239                );
240                return false;
241            }
242
243            $fragments = $section['fragments'] ?? null;
244            // Section fragments must exist and contain an array
245            if ( !is_array( $fragments ) || !array_is_list( $fragments ) ) {
246                $this->status = StatusValue::newFatal(
247                    'wikilambda-abstract-error-missing-section-fragments',
248                    $sectionQid
249                );
250                return false;
251            }
252
253            // Section fragments must be a benjamin array of HTML/Z89 objects
254            if ( count( $fragments ) === 0 || $fragments[0] !== ZTypeRegistry::Z_HTML_FRAGMENT ) {
255                $this->status = StatusValue::newFatal( 'wikilambda-abstract-error-bad-fragments-type', $sectionQid );
256                return false;
257            }
258        }
259
260        // Passed all checks
261        $this->status = StatusValue::newGood();
262        return true;
263    }
264
265    /**
266     * Whether the content is valid for a given title.
267     *
268     * This method only checks whether a given title matches the internal
269     * internal qid property of the content. If that passes, then we
270     * call isValid(), which checks for content validity, independently
271     * from the page identity.
272     *
273     * @param Title $title
274     * @return bool
275     */
276    public function isValidForTitle( Title $title ): bool {
277        // title is the same as the value of the object key 'qid'
278        $innerQid = $this->getTopicQid() ?? '';
279        $titleQid = $title->getBaseText();
280        if ( $innerQid !== $titleQid ) {
281            $this->status = StatusValue::newFatal( 'wikilambda-abstract-error-unmatching-qid', $innerQid, $titleQid );
282            return false;
283        }
284
285        return $this->isValid();
286    }
287
288    /**
289     * Whether this content has no substantive fragments in any section.
290     *
291     * An article is empty when no section contains substantive fragments — i.e. every
292     * section's fragment list holds only the Z89 type indicator with nothing after it.
293     * Content in any section, not just the lede, makes the article non-empty.
294     *
295     * @return bool
296     */
297    public function isEmpty(): bool {
298        $sections = $this->getSections();
299        if ( !is_array( $sections ) ) {
300            return true;
301        }
302        foreach ( $sections as $section ) {
303            $fragments = $section['fragments'] ?? [];
304            // The first element is always the Z89 type indicator; count > 1 means real content exists
305            if ( count( $fragments ) > 1 ) {
306                return false;
307            }
308        }
309        return true;
310    }
311
312    /**
313     * @return StatusValue|null
314     */
315    public function getStatus() {
316        return $this->status;
317    }
318}