Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
27.79% covered (danger)
27.79%
172 / 619
27.27% covered (danger)
27.27%
9 / 33
CRAP
0.00% covered (danger)
0.00%
0 / 1
CollaborationHubContent
27.79% covered (danger)
27.79%
172 / 619
27.27% covered (danger)
27.27%
9 / 33
4500.36
0.00% covered (danger)
0.00%
0 / 1
 getThemeColours
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
2
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isValid
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
4.01
 decode
65.71% covered (warning)
65.71%
23 / 35
0.00% covered (danger)
0.00%
0 / 1
17.80
 redirectProof
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 getIntroduction
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getFooter
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getImage
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getContent
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getDisplayName
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getThemeColour
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 fillParserOutput
0.00% covered (danger)
0.00%
0 / 85
0.00% covered (danger)
0.00%
0 / 1
30
 getHubClasses
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
2.01
 getMembersBlock
0.00% covered (danger)
0.00%
0 / 71
0.00% covered (danger)
0.00%
0 / 1
90
 getParsedIntroduction
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 getParsedAnnouncements
0.00% covered (danger)
0.00%
0 / 32
0.00% covered (danger)
0.00%
0 / 1
20
 getParsedFooter
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 getSecondFooter
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
1
 getParsedContent
0.00% covered (danger)
0.00%
0 / 86
0.00% covered (danger)
0.00%
0 / 1
90
 makeHeader
0.00% covered (danger)
0.00%
0 / 75
0.00% covered (danger)
0.00%
0 / 1
30
 makeActionButton
65.85% covered (warning)
65.85%
27 / 41
0.00% covered (danger)
0.00%
0 / 1
12.22
 getTableOfContents
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getParsedImage
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 getParentHub
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
56
 convert
50.00% covered (danger)
50.00%
7 / 14
0.00% covered (danger)
0.00%
0 / 1
6.00
 convertToHumanEditable
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 getHumanEditableContent
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
5.01
 escapeForHumanEditable
80.00% covered (warning)
80.00%
8 / 10
0.00% covered (danger)
0.00%
0 / 1
3.07
 unescapeForHumanEditable
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 convertFromHumanEditable
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
3
 convertFromHumanEditableItemLine
75.00% covered (warning)
75.00%
12 / 16
0.00% covered (danger)
0.00%
0 / 1
6.56
 onCustomEditor
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 getTrimmedText
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2/**
3 * A content model for group collaboration pages.
4 *
5 * The principle behind CollaborationHubContent is to facilitate
6 * the development of "WikiProjects," called "Portals" on other
7 * wikis. CollaborationHubContent facilitates the development
8 * of these nodes of activity, consisting of header content, a
9 * table of contents, and several transcluded pages.
10 * Schema is found in CollaborationHubContentSchema.php.
11 *
12 * @file
13 */
14
15use MediaWiki\Extension\EventLogging\EventLogging;
16use MediaWiki\MediaWikiServices;
17use MediaWiki\Revision\SlotRecord;
18
19/**
20 * @class CollaborationHubContent
21 */
22class CollaborationHubContent extends JsonContent {
23
24    /** @var string */
25    protected $displayName;
26
27    /** @var string */
28    protected $image;
29
30    /** @var string */
31    protected $introduction;
32
33    /** @var array|null pages included in the hub */
34    protected $content;
35
36    /** @var string */
37    protected $footer;
38
39    /** @var string */
40    protected $themeColour;
41
42    /** @var string How to display contents */
43    protected $displaymode;
44
45    /** @var bool Whether contents have been populated */
46    protected $decoded = false;
47
48    /** @var string Error message text */
49    protected $errortext;
50
51    /**
52     * 10 preset colours; actual colour values are set in the extension.json and
53     * less modules
54     *
55     * @return array
56     */
57    public static function getThemeColours() {
58        return [
59            'lightgrey',
60            'red',
61            'skyblue',
62            'bluegrey',
63            'aquamarine',
64            'violet',
65            'salmon',
66            'yellow',
67            'gold',
68            'brightgreen',
69        ];
70    }
71
72    /**
73     * @param string $text
74     */
75    public function __construct( $text ) {
76        parent::__construct( $text, 'CollaborationHubContent' );
77    }
78
79    /**
80     * Decode and validate the contents
81     * @return bool Whether the contents are valid
82     */
83    public function isValid() {
84        $hubSchema = include __DIR__ . '/CollaborationHubContentSchema.php';
85        $jsonParse = $this->getData();
86        if ( $jsonParse->isGood() ) {
87            // TODO: The schema should be checking for required fields but for
88            // some reason that doesn't work
89            if ( !isset( $jsonParse->value->content ) ) {
90                return false;
91            }
92            // Forcing the object to become an array
93            $jsonAsArray = json_decode(
94                json_encode( $jsonParse->getValue() ), true );
95            try {
96                EventLogging::schemaValidate( $jsonAsArray, $hubSchema );
97                return true;
98            } catch ( JsonSchemaException $e ) {
99                return false;
100            }
101        }
102        return false;
103    }
104
105    /**
106     * Decode the JSON contents and populate protected variables
107     */
108    protected function decode() {
109        if ( $this->decoded ) {
110            return;
111        }
112        $jsonParse = $this->getData();
113        $data = $jsonParse->isGood() ? $jsonParse->getValue() : null;
114        if ( $data ) {
115            if ( !$this->isValid() ) {
116                $this->displaymode = 'error';
117                if ( !parent::isValid() ) {
118                    // It's not even valid json
119                    $this->errortext = htmlspecialchars(
120                        $this->getText()
121                    );
122                } else {
123                    $this->errortext = FormatJson::encode(
124                        $data,
125                        true,
126                        FormatJson::ALL_OK
127                    );
128                }
129            } else {
130                $this->displayName = $data->display_name ?? '';
131                $this->introduction = $data->introduction ?? '';
132                $this->footer = $data->footer ?? '';
133                $this->image = $data->image ?? 'none';
134
135                // Set colour to default if empty or missing
136                if ( !isset( $data->colour ) || $data->colour == '' ) {
137                    $this->themeColour = 'lightgrey';
138                } else {
139                    $this->themeColour = $data->colour;
140                }
141
142                if ( isset( $data->content ) && is_array( $data->content ) ) {
143                    $this->content = [];
144                    foreach ( $data->content as $itemObject ) {
145                        if ( !is_object( $itemObject ) ) { // Malformed item
146                            $this->content = null;
147                            break;
148                        }
149                        $item = [];
150                        $item['title'] = $itemObject->title ?? null;
151                        $item['image'] = $itemObject->image ?? null;
152                        $item['displayTitle'] = $itemObject->display_title ?? null;
153
154                        $this->content[] = $item;
155                    }
156                }
157            }
158        }
159        $this->decoded = true;
160    }
161
162    /**
163     * Resolves the redirect of a Title if it is in fact a redirect.
164     *
165     * Consistent with general MediaWiki behavior, this function does
166     * not resolve double redirects.
167     *
168     * @param Title $title Title which may or may not be a redirect
169     * @return Title
170     */
171    public function redirectProof( Title $title ) {
172        if ( $title->isRedirect() ) {
173            $articleID = $title->getArticleID();
174            $wikipage = MediaWikiServices::getInstance()->getWikiPageFactory()->newFromID( $articleID );
175            return $wikipage->getRedirectTarget();
176        }
177        return $title;
178    }
179
180    /**
181     * @return string
182     */
183    public function getIntroduction() {
184        $this->decode();
185        return $this->introduction;
186    }
187
188    /**
189     * @return string
190     */
191    public function getFooter() {
192        $this->decode();
193        return $this->footer;
194    }
195
196    /**
197     * @return string
198     */
199    public function getImage() {
200        $this->decode();
201        return $this->image;
202    }
203
204    /**
205     * @return array
206     */
207    public function getContent() {
208        $this->decode();
209        return $this->content;
210    }
211
212    /**
213     * @return string
214     */
215    public function getDisplayName() {
216        $this->decode();
217        return $this->displayName;
218    }
219
220    /**
221     * @return string
222     */
223    public function getThemeColour() {
224        $this->decode();
225        return $this->themeColour;
226    }
227
228    /**
229     * Fill $output with information derived from the content.
230     *
231     * @param Title $title
232     * @param int $revId
233     * @param ParserOptions $options
234     * @param bool $generateHtml
235     * @param ParserOutput &$output
236     */
237    protected function fillParserOutput( Title $title, $revId,
238        ParserOptions $options, $generateHtml, ParserOutput &$output
239    ) {
240        $parser = MediaWikiServices::getInstance()->getParser();
241        $this->decode();
242
243        OutputPage::setupOOUI();
244
245        // Dummy parse intro and footer to get categories and page info for the actual
246        // content of *this* page, essentially setting up our real ParserOutput
247        $output = $parser->parse(
248            $this->getIntroduction() . $this->getFooter(),
249            $title,
250            $options,
251            true,
252            true,
253            $revId
254        );
255
256        $parser->addTrackingCategory( 'collaborationkit-hub-tracker' );
257
258        // Let's just assume we'll probably need this...
259        // (tells our ParserOutputPostCacheTransform hook to look for post-cache buttons etc)
260        $output->setExtensionData( 'ck-editmarkers', true );
261
262        // Change $options a bit for the rest of this
263        // We may or may not want limit reporting for every piece; we can put this back on
264        // later if it turns out we actually do (and only disable it for the header/footer,
265        // where it should already be included per the above, I think?)
266        $options->enableLimitReport( false );
267
268        $html = '';
269
270        // If error, then bypass all this and just show the offending JSON
271
272        if ( $this->displaymode == 'error' ) {
273            $html = '<div class=errorbox>'
274            . wfMessage( 'collaborationkit-hub-invalid' )->escaped()
275            . "</div>\n<pre>"
276            . $this->errortext
277            . '</pre>';
278            $output->setText( $html );
279        } else {
280            // set up hub with theme stuff
281            $html .= Html::openElement(
282                'div',
283                [ 'class' => $this->getHubClasses() ]
284            );
285            // get page image
286            $html .= Html::rawElement(
287                'div',
288                [ 'class' => 'mw-ck-hub-image' ],
289                $this->getParsedImage( $this->getImage(), 200 )
290            );
291            // get members list
292            $html .= Html::rawElement(
293                'div',
294                [ 'class' => 'mw-ck-hub-members' ],
295                $this->getMembersBlock( $title, $options, $output )
296            );
297            // get parsed intro
298            $html .= Html::rawElement(
299                'div',
300                [ 'class' => 'mw-ck-hub-intro' ],
301                $this->getParsedIntroduction( $title, $options )
302            );
303            // get announcements
304            $html .= Html::rawElement(
305                'div',
306                [ 'class' => 'mw-ck-hub-announcements' ],
307                $this->getParsedAnnouncements( $title, $options )
308            );
309            // get table of contents
310            if ( count( $this->getContent() ) > 0 ) {
311                $html .= Html::rawElement(
312                    'div',
313                    [ 'class' => [ 'mw-ck-hub-toc', 'toc' ] ],
314                    $this->getTableOfContents( $title, $options )
315                );
316            }
317
318            $html .= Html::element( 'div', [ 'style' => 'clear:both' ] );
319
320            // get transcluded content
321            $html .= Html::rawElement(
322                'div',
323                [ 'class' => 'mw-ck-hub-content' ],
324                $this->getParsedContent( $title, $options, $output )
325            );
326
327            $html .= Html::element( 'div', [ 'style' => 'clear:both' ] );
328
329            // get main footer: bottom text under the content
330            $footerText = $this->getParsedFooter( $title, $options );
331            // only show if it's likely to contain anything visible
332            if ( strlen( trim( $footerText ) ) > 0 ) {
333                $html .= Html::rawElement(
334                    'div',
335                    [ 'class' => 'mw-ck-hub-footer' ],
336                    $footerText
337                );
338            }
339
340            if ( !$options->getIsPreview() ) {
341                $html .= Html::rawElement(
342                    'div',
343                    [ 'class' => 'mw-ck-hub-footer-actions' ],
344                    $this->getSecondFooter( $title )
345                );
346            }
347
348            $html .= Html::closeElement( 'div' );
349
350            $output->setText( $html );
351
352            // Add some style stuff
353            $output->addModuleStyles( [
354                'ext.CollaborationKit.hub.styles',
355                'oojs-ui.styles.icons-editing-core',
356                'ext.CollaborationKit.icons',
357                'ext.CollaborationKit.blots',
358                'ext.CollaborationKit.list.styles'
359            ] );
360            $output->addModules( [
361                'ext.CollaborationKit.list.members'
362            ] );
363            $output->setEnableOOUI( true );
364        }
365    }
366
367    /**
368     * Helper function for fillParserOutput to get all the css classes for the
369     * page content
370     *
371     * @return array
372     */
373    protected function getHubClasses() {
374        $colour = $this->getThemeColour();
375
376        $classes = [
377            'mw-ck-collaborationhub',
378            'mw-ck-list-square'
379        ];
380        if ( $colour == 'black' ) {
381            $classes = array_merge( $classes, [ 'mw-ck-theme' ] );
382        } else {
383            $classes = array_merge( $classes, [ 'mw-ck-theme-' . $colour ] );
384        }
385
386        return $classes;
387    }
388
389    /**
390     * Helper function for fillParserOutput
391     *
392     * @param Title $title
393     * @param ParserOptions $options
394     * @param ParserOutput $output
395     * @param CollaborationListContent|null $membersContent Member list Content
396     *  for testing purposes
397     * @return string
398     */
399    protected function getMembersBlock( Title $title, ParserOptions $options,
400        ParserOutput $output, $membersContent = null
401    ) {
402        $services = MediaWikiServices::getInstance();
403        $parser = $services->getParser();
404
405        $html = '';
406
407        $lang = $options->getTargetLanguage();
408        if ( !$lang ) {
409            $lang = $title->getPageLanguage();
410        }
411
412        $membersPageName = $title->getFullText()
413            . '/'
414            . wfMessage( 'collaborationkit-hub-pagetitle-members' )
415                ->inContentLanguage()
416                ->text();
417        $membersTitle = Title::newFromText( $membersPageName );
418        $membersTitle = $this->redirectProof( $membersTitle );
419        if ( ( $membersTitle->exists()
420            && $membersTitle->getContentModel() == 'CollaborationListContent' )
421            || $membersContent !== null
422        ) {
423            $membersPageID = $membersTitle->getArticleID();
424            $output->addJsConfigVars(
425                'wgCollaborationKitAssociatedMemberList',
426                $membersPageID
427            );
428
429            // rawElement is used because we don't want [edit] links or usual
430            // header behavior
431            $html .= Html::rawElement(
432                'h3',
433                [],
434                wfMessage( 'collaborationkit-hub-members-header' )->escaped()
435            );
436
437            if ( $membersContent === null ) {
438                $membersRevision = MediaWikiServices::getInstance()
439                    ->getRevisionLookup()
440                    ->getRevisionByTitle( $membersTitle, 0, IDBAccessObject::READ_LATEST );
441                if ( $membersRevision ) {
442                    $membersContent = $membersRevision->getContent( SlotRecord::MAIN );
443                }
444            }
445            if ( $membersContent && $membersContent instanceof CollaborationListContent ) {
446                $activeCol = wfMessage( 'collaborationkit-column-active' )
447                    ->inContentLanguage()
448                    ->plain();
449                $wikitext = $membersContent->convertToWikitext(
450                    $lang,
451                    [
452                        'includeDesc' => false,
453                        'maxItems' => 3,
454                        'defaultSort' => 'random',
455                        'columns' => [ $activeCol ],
456                        'showColumnHeaders' => false,
457                        'iconWidth' => 32
458                    ]
459                );
460            } else {
461                // Some sort of error occurred. Probably
462                // a race condition.
463                // No i18n for this error message, since
464                // it should never happen.
465                $wikitext = '<span class="error">Cannot include member list</span>';
466            }
467
468            $titleParse = $parser->parse( $wikitext, $membersTitle, $options );
469            $html .= $this->getTrimmedText( $titleParse );
470
471            $membersViewButton = $this->makeActionButton(
472                $membersTitle,
473                'collaborationkit-hub-members-view',
474                [ 'framed' => true ]
475            );
476
477            $membersJoinButton = $this->makeActionButton(
478                $membersTitle,
479                'collaborationkit-hub-members-signup',
480                [
481                    'action' => 'edit',
482                    'framed' => true,
483                    'flags' => [ 'primary', 'progressive' ],
484                    'classes' => [ 'mw-ck-members-join' ]
485                ]
486            );
487
488            $html .= Html::rawElement(
489                'div',
490                [ 'class' => 'mw-ck-members-buttons' ],
491                $membersViewButton . $membersJoinButton
492            );
493        }
494
495        return $html;
496    }
497
498    /**
499     * Helper function for fillParserOutput
500     * @param Title $title
501     * @param ParserOptions $options
502     * @return string
503     */
504    protected function getParsedIntroduction( Title $title, ParserOptions $options ) {
505        $parser = MediaWikiServices::getInstance()->getParser();
506        $tempOutput = $parser->parse( $this->getIntroduction(), $title, $options );
507
508        return $this->getTrimmedText( $tempOutput );
509    }
510
511    /**
512     * Helper function for fillParserOutput
513     *
514     * @param Title $title
515     * @param ParserOptions $options
516     * @param string|null $announcementsText Force-fed announcements HTML for testing purposes
517     * @return string
518     */
519    protected function getParsedAnnouncements( Title $title, ParserOptions $options,
520        $announcementsText = null
521    ) {
522        $announcementsSubpageName = wfMessage( 'collaborationkit-hub-pagetitle-announcements' )
523            ->inContentLanguage()
524            ->text();
525        $announcementsTitle = Title::newFromText(
526            $title->getFullText()
527            . '/'
528            . $announcementsSubpageName
529        );
530        $announcementsTitle = $this->redirectProof( $announcementsTitle );
531
532        if ( $announcementsTitle->exists() || $announcementsText !== null ) {
533            if ( $announcementsText === null ) {
534                $announcementsWikiPage = MediaWikiServices::getInstance()->getWikiPageFactory()
535                    ->newFromTitle( $announcementsTitle );
536                $announcementsOutput = $announcementsWikiPage
537                    ->getContent()
538                    ->getParserOutput( $announcementsTitle );
539                $announcementsText = $this->getTrimmedText( $announcementsOutput );
540            }
541
542            $announcementsEditButton = $this->makeActionButton(
543                $announcementsTitle,
544                'edit',
545                [
546                    'icon' => 'edit',
547                    'action' => 'edit',
548                    'classes' => [ 'mw-ck-hub-section-button mw-editsection-like' ]
549                ]
550            );
551
552            $announcementsHeader = Html::rawElement(
553                'h3',
554                [],
555                $announcementsSubpageName . $announcementsEditButton
556            );
557
558            return $announcementsHeader . $announcementsText;
559        }
560    }
561
562    /**
563     * Helper function for fillParserOutput
564     *
565     * @param Title $title
566     * @param ParserOptions $options
567     * @return string
568     */
569    protected function getParsedFooter( Title $title, ParserOptions $options ) {
570        $parser = MediaWikiServices::getInstance()->getParser();
571        $tempOutput = $parser->parse( $this->getFooter(), $title, $options );
572
573        return $this->getTrimmedText( $tempOutput );
574    }
575
576    /**
577     * Get some extra buttons for another footer
578     *
579     * @param Title $title
580     * @return string
581     */
582    protected function getSecondFooter( Title $title ) {
583        $html = '';
584
585        $html .= $this->makeActionButton(
586            $title,
587            'collaborationkit-hub-manage',
588            [
589                'icon' => 'edit',
590                'framed' => true,
591                'action' => 'edit'
592            ]
593        );
594
595        // use stupid dummy subpage to make sure they probably have create permissions
596        $dummysubpage = 'SUPERSECRETDUMMYSUBPAGEISUREHOPEDOESNTACTUALLYEXIST!';
597        $html .= $this->makeActionButton(
598            Title::newFromText( $title->getFullText() . '/' . $dummysubpage ),
599            'collaborationkit-hub-addpage',
600            [
601                'icon' => 'add',
602                'framed' => true,
603                'action' => 'create',
604                'title' => $title->getFullText(),
605                'scarylink' => SpecialPage::getTitleFor( 'CreateHubFeature' )->getFullURL(
606                    [ 'collaborationhub' => $title->getFullText() ]
607                )
608            ]
609        );
610
611        return $html;
612    }
613
614    /**
615     * Helper function for fillParserOutput; the main body of the page
616     *
617     * @param Title $title
618     * @param ParserOptions $options
619     * @param ParserOutput $output
620     * @return string
621     */
622    protected function getParsedContent( Title $title, ParserOptions $options,
623        ParserOutput $output
624    ) {
625        $parser = MediaWikiServices::getInstance()->getParser();
626
627        $lang = $options->getTargetLanguage();
628        if ( !$lang ) {
629            $lang = $title->getPageLanguage();
630        }
631
632        $html = '';
633
634        foreach ( $this->getContent() as $item ) {
635            if ( !isset( $item['title'] ) || $item['title'] == '' ) {
636                continue;
637            }
638            $spTitle = $this->redirectProof( Title::newFromText( $item['title'] ) );
639            $spRev = MediaWikiServices::getInstance()
640                ->getRevisionLookup()
641                ->getRevisionByTitle( $spTitle );
642
643            // open element and do header
644            $html .= $this->makeHeader( $title, $item );
645
646            if ( isset( $spRev ) ) {
647                // DO CONTENT FROM PAGE
648                /** @var CollaborationHubContent $spContent */
649                $spContent = $spRev->getContent( SlotRecord::MAIN );
650                $spContentModel = $spRev->getSlot( SlotRecord::MAIN )->getModel();
651
652                if ( $spContentModel == 'CollaborationHubContent' ) {
653                    // this is dumb, but we'll just rebuild the intro here for now
654                    $text = Html::rawElement(
655                        'div',
656                        [ 'class' => 'mw-ck-hub-image' ],
657                        $spContent->getParsedImage( $spContent->getImage(), 100 )
658                    );
659                    $text .= $spContent->getParsedIntroduction( $spTitle, $options );
660                } elseif ( $spContentModel == 'CollaborationListContent' ) {
661                    // convert to wikitext with maxItems limit in place
662                    /** @var CollaborationListContent $spContent */
663                    $wikitext = $spContent->convertToWikitext(
664                        $lang,
665                        [
666                            'includeDesc' => false,
667                            'maxItems' => 4,
668                            // TODO use a sort according to options in the item line
669                            'defaultSort' => 'random'
670                        ]
671                    );
672                    $text = $parser->parse( $wikitext, $title, $options );
673                    $text = $this->getTrimmedText( $text );
674                } elseif ( $spContentModel == 'wikitext' ) {
675                    // to grab first section only
676                    $spContent = $spContent->getSection( 0 );
677
678                    // Do template preproccessing magic
679                    // ... parse, get text into $text
680                    $rawText = $spContent->serialize();
681                    // Get rid of all <noinclude>'s.
682                    $parser->startExternalParse( $title, $options, Parser::OT_WIKI );
683                    $frame = $parser->getPreprocessor()->newFrame()->newChild( [], $spTitle );
684                    $node = $parser->preprocessToDom( $rawText, Parser::PTD_FOR_INCLUSION );
685                    $processedText = $frame->expand(
686                        $node,
687                        PPFrame::RECOVER_ORIG & ( ~PPFrame::NO_IGNORE )
688                    );
689                    $parsedWikitext = $parser->parse( $processedText, $title, $options );
690                    $text = $this->getTrimmedText( $parsedWikitext );
691                    $output->addModuleStyles( $parsedWikitext->getModuleStyles() );
692                } else {
693                    // Parse whatever (else) as whatever
694                    $contentOutput = $spContent->getParserOutput( $spTitle, $spRev->getId(), $options );
695                    $output->addModuleStyles( $contentOutput->getModuleStyles() );
696                    $text = $contentOutput->getRawText();
697                }
698
699                $html .= Html::rawElement(
700                    'div',
701                    [ 'class' => 'mw-ck-hub-section-main' ],
702                    $text
703                );
704
705                // register as template for stuff
706                $output->addTemplate(
707                    $spTitle,
708                    $spTitle->getArticleID(),
709                    $spRev->getId()
710                );
711            } else {
712                // DO CONTENT FOR NOT YET MADE PAGE
713
714                // lol we use a different message depending on whether they
715                // even can create it, so we can't even parse that here
716                $html .= Html::rawElement(
717                    'p',
718                    [ 'class' => 'mw-ck-hub-missingfeature-note' ],
719                    '<ext:ck:missingfeature-note target="' . htmlspecialchars( $spTitle->getFullText() ) . '"/>'
720                );
721
722                $html .= $this->makeActionButton(
723                    $spTitle,
724                    'collaborationkit-hub-missingpage-create',
725                    [
726                        'action' => 'create',
727                        'framed' => true,
728                        'scarylink' => SpecialPage::getTitleFor( 'CreateHubFeature' )
729                            ->getFullURL( [
730                                'collaborationhub' => $title->getFullText(),
731                                'feature' => $spTitle->getSubpageText()
732                            ] )
733                    ]
734                );
735
736                $html .= $this->makeActionButton(
737                    $title,
738                    'collaborationkit-hub-missingpage-purgecache',
739                    [ 'action' => 'purge', 'framed' => true ]
740                );
741
742                // register as template for stuff
743                // @phan-suppress-next-line PhanTypeMismatchArgumentProbablyReal
744                $output->addTemplate( $spTitle, $spTitle->getArticleID(), null );
745            }
746
747            $html .= Html::closeElement( 'div' );
748        }
749
750        return $html;
751    }
752
753    /**
754     * Helper function for getParsedContent for making subpage section headers
755     *
756     * @param Title $title
757     * @param array $contentItem Data for the content item we're generating the
758     *  header for
759     * @return string html (NOTE THIS IS AN OPEN DIV)
760     */
761    protected function makeHeader( Title $title, array $contentItem ) {
762        static $tocLinks = []; // All used ids for the sections for the toc
763
764        $spTitle = Title::newFromText( $contentItem['title'] );
765        $spTitle = $this->redirectProof( $spTitle );
766        $spRev = MediaWikiServices::getInstance()
767            ->getRevisionLookup()
768            ->getRevisionByTitle( $spTitle );
769
770        // Get display name
771        if ( isset( $contentItem['displayTitle'] ) ) {
772            $spPage = $contentItem['displayTitle'];
773        } else {
774            $spPage = $spTitle->getSubpageText();
775        }
776
777        // Get icon
778        $image = $contentItem['image'] ?? null;
779        $imageHtml = CollaborationKitImage::makeImage(
780            $image,
781            35,
782            [
783                'link' => $spTitle->getText(),
784                'fallback' => $spPage,
785                'classes' => [ 'mw-ck-section-image' ]
786            ]
787        );
788
789        // Generate an id for the section for anchors
790        // Make sure this matches the ToC anchor generation
791        $spPageLink = Sanitizer::escapeIdForLink( htmlspecialchars( $spPage ) );
792        $spPageLink2 = $spPageLink;
793        $spPageLinkCounter = 1;
794        while ( in_array( $spPageLink2, $tocLinks ) ) {
795            $spPageLink2 = $spPageLink . $spPageLinkCounter;
796            $spPageLinkCounter++;
797        }
798        $tocLinks[] = $spPageLink2;
799
800        // Get editsection-style links for the subpage
801        $sectionLinks = [];
802        $sectionLinksText = '';
803        if ( isset( $spRev ) ) {
804            // view
805            $sectionLinksText .= $this->makeActionButton(
806                $spTitle,
807                'collaborationkit-hub-subpage-view',
808                [ 'classes' => [ 'mw-ck-hub-section-button mw-editsection-like' ] ]
809            );
810
811            // edit
812            $sectionLinksText .= $this->makeActionButton(
813                $spTitle,
814                'edit',
815                [
816                    'icon' => 'edit',
817                    'action' => 'edit',
818                    'classes' => [ 'mw-ck-hub-section-button mw-editsection-like' ]
819                ]
820            );
821        }
822
823        $sectionButtons = '';
824        if ( $sectionLinksText !== '' ) {
825            $sectionButtons = Html::rawElement(
826                'div',
827                [ 'class' => 'mw-ck-hub-section-buttons' ],
828                $sectionLinksText
829            );
830        }
831
832        // Assemble header
833        // Open general section here since we have the id here
834        $html = Html::openElement(
835            'div',
836            [
837                'class' => 'mw-ck-hub-section',
838                'id' => $spPageLink2
839            ]
840        );
841        $html .= Html::rawElement(
842            'div',
843            [
844                'class' => 'mw-ck-hub-section-header'
845            ],
846            Html::rawElement(
847                'h2',
848                [],
849                $imageHtml .
850                Html::element(
851                    'span',
852                    [ 'class' => 'mw-headline' ],
853                    $spPage
854                ) . $sectionButtons
855            )
856        );
857
858        OutputPage::setupOOUI();
859        return $html;
860    }
861
862    /**
863     * Helper function for fillParserOutput for making various action links
864     * (editsection links, purge cache buttons, whatever)
865     *
866     * @param Title $title Target page
867     * @param string $message Message to display
868     * @param array $setOptions of a bunch of options, mostly to forward to the OOUI button
869     * (see defaults below)
870     * @return string either an OOUI\ButtonWidget effectively tostringed, or a ck:editsection marker
871     * which will get replaced with an OOUI\ButtonWidget later in
872     * CollaborationHubContentHandler::onParserOutputPostCacheTransform
873     */
874    protected function makeActionButton( $title, $message, $setOptions = [] ) {
875        // Set options and fill in defaults
876        $options = $setOptions + [
877            'title' => $title->getFullText(),
878            'action' => 'view',
879            'framed' => false, // whether to display it as a *button* or not
880            'icon' => null,
881            'flags' => [],
882            'classes' => [],
883            'scarylink' => false // for weird create links, because I give up
884        ];
885
886        if ( !$options['framed'] ) {
887            // If it's not displaying as a button (framed), we'll want it to be
888            // link-coloured regardless so it's clear it's interactable (a link)
889            $options['flags'][] = 'progressive';
890        }
891
892        $html = '';
893
894        if ( $options['action'] == 'create' || $options['action'] == 'edit' ) {
895            // can't cache this here, gotta generate a marker to handle later
896
897            if ( $options['action'] == 'create' ) {
898                // I'm not sure how to deal with this, so scary link time
899                $link = $options['scarylink'];
900            } else {
901                // whoohoo straight edit! I know what to do!
902                $link = $title->getEditURL();
903            }
904
905            $html .= '<ext:ck:editmarker page="' . htmlspecialchars( $options['title'] ) . '"'
906                . 'target="' . htmlspecialchars( $title->getFullText() ) . '"'
907                . 'message="' . htmlspecialchars( $message ) . '"'
908                . 'link="' . $link . '"'
909                . 'classes="' . implode( ' ', $options['classes'] ) . '"';
910
911            // Forward some other random options...
912            if ( $options['icon'] !== null ) {
913                $html .= 'icon="' . htmlspecialchars( $options['icon'] ) . '"';
914            } else {
915                $html .= 'icon="0"';
916            }
917
918            $html .= $options['framed'] ? 'framed="1"' : 'framed="0"';
919
920            if ( in_array( 'primary', $options['flags'] ) ) {
921                $html .= 'primary="1"';
922            } else {
923                $html .= 'primary="0"';
924            }
925
926            $html .= '/>';
927        } else {
928            // we can go ahead and just cache it here!
929            if ( $options['action'] == 'purge' ) {
930                // is it possible they may not have this permission? I DON'T CARE!
931                $link = $title->getFullURL( [ 'action' => 'purge' ] );
932            } else {
933                // only other thing we'll cache is 'view', currently,
934                // so no need to even bother checking at this point
935                $link = $title->getLinkURL();
936            }
937
938            $html .= new OOUI\ButtonWidget( [
939                'label' => wfMessage( $message )->inContentLanguage()->text(),
940                'href' => $link,
941                'framed' => $options['framed'],
942                'icon' => $options['icon'],
943                'flags' => $options['flags'],
944                'classes' => $options['classes']
945            ] );
946        }
947
948        return $html;
949    }
950
951    /**
952     * Helper function for fillParserOutput: the table of contents
953     *
954     * @param Title $title
955     * @param ParserOptions $options
956     * @return string
957     */
958    protected function getTableOfContents( Title $title, ParserOptions $options ) {
959        $toc = new CollaborationHubTOC();
960        return $toc->renderToC( $this->content );
961    }
962
963    /**
964     * Generate an image based on what's in 'image', be it an icon or a file
965     *
966     * @param string $image Filename or icon name
967     * @param int $size int for non-icon images
968     * @return string HTML
969     */
970    public function getParsedImage( $image, $size = 200 ) {
971        return CollaborationKitImage::makeImage(
972            $image,
973            $size,
974            [ 'fallback' => 'puzzlepiece' ]
975        );
976    }
977
978    /**
979     * Find the parent hub, if any.
980     *
981     * Returns the first CollaborationHub Title found, even if more are higher
982     * up, or null if none
983     *
984     * @param Title $title Title to start looking from
985     * @return Title|null Title of parent hub or null if none was found
986     */
987    public static function getParentHub( Title $title ) {
988        global $wgCollaborationHubAllowedNamespaces;
989
990        $namespace = $title->getNamespace();
991        $namespaceInfo = MediaWikiServices::getInstance()->getNamespaceInfo();
992        if ( $namespaceInfo->hasSubpages( $namespace ) &&
993            isset( $wgCollaborationHubAllowedNamespaces[$namespace] ) &&
994            $wgCollaborationHubAllowedNamespaces[$namespace]
995        ) {
996            $parentTitle = $title->getBaseTitle();
997            while ( !$title->equals( $parentTitle ) ) {
998                $parentRev = MediaWikiServices::getInstance()
999                    ->getRevisionLookup()
1000                    ->getRevisionByTitle( $parentTitle );
1001                if ( $parentTitle->getContentModel() == 'CollaborationHubContent'
1002                    && isset( $parentRev )
1003                ) {
1004                    return $parentTitle;
1005                }
1006
1007                // keep looking
1008                $title = $parentTitle;
1009            }
1010        }
1011
1012        // Nothing was found
1013        return null;
1014    }
1015
1016    /**
1017     * Converts content between wikitext and JSON.
1018     *
1019     * @param string $toModel
1020     * @param string $lossy Flag, set to "lossy" to allow lossy conversion.
1021     *  If lossy conversion is not allowed, full round-trip conversion is
1022     *  expected to work without losing information.
1023     * @return Content
1024     */
1025    public function convert( $toModel, $lossy = '' ) {
1026        if ( $toModel === CONTENT_MODEL_WIKITEXT && $lossy === 'lossy' ) {
1027            // Not ideal at all, but without access to the name of the page
1028            // being transcluded, we can't embed the rest of the page. This is
1029            // just a holdover to prevent the thing from throwing an exception.
1030            $this->decode();
1031            $image = $this->getImage();
1032            $intro = $this->getIntroduction();
1033            $text = "<div style='margin:0 2em 2em 0;'>[[File:$image|200px|left]]</div>
1034                \n<div style='font-size:115%;'>$intro</div>";
1035            return ContentHandler::makeContent( $text, null, $toModel );
1036        } elseif ( $toModel === CONTENT_MODEL_JSON ) {
1037            return ContentHandler::makeContent(
1038                $this->getText(),
1039                null,
1040                $toModel
1041            );
1042        }
1043        return parent::convert( $toModel, $lossy );
1044    }
1045
1046    /**
1047     * Convert JSON to markup that's easier for humans.
1048     *
1049     * @return string
1050     */
1051    public function convertToHumanEditable() {
1052        $this->decode();
1053        return CollaborationKitSerialization::getSerialization( [
1054            $this->displayName,
1055            $this->introduction,
1056            $this->footer,
1057            $this->image,
1058            $this->themeColour,
1059            $this->getHumanEditableContent()
1060        ] );
1061    }
1062
1063    /**
1064     * Get the list of items in human editable form.
1065     *
1066     * @return string
1067     * @todo Should this be i18n-ized?
1068     */
1069    public function getHumanEditableContent() {
1070        $this->decode();
1071
1072        $out = '';
1073        foreach ( $this->content as $item ) {
1074            $out .= self::escapeForHumanEditable( $item['title'] );
1075            if ( isset( $item['image'] ) ) {
1076                $out .= '|image='
1077                    . self::escapeForHumanEditable( $item['image'] );
1078            }
1079            if ( isset( $item['displayTitle'] ) ) {
1080                $out .= '|display_title='
1081                    . self::escapeForHumanEditable( $item['displayTitle'] );
1082            }
1083            if ( substr( $out, -1 ) === '|' ) {
1084                $out = substr( $out, 0, strlen( $out ) - 1 );
1085            }
1086            $out .= "\n";
1087        }
1088        return $out;
1089    }
1090
1091    /**
1092     * Escape characters used as separators in human editable mode.
1093     *
1094     * @param string $text
1095     * @return string Escaped text
1096     * @throws MWContentSerializationException
1097     * @todo Unclear if this is best approach. Alternative might be
1098     *  to use &#xA; Or an obscure unicode character like ␊ (U+240A).
1099     */
1100    public static function escapeForHumanEditable( $text ) {
1101        if ( strpos( $text, '{{!}}' ) !== false ) {
1102            // Maybe we should use \| too, but that's not MW like.
1103            throw new MWContentSerializationException( "{{!}} in content" );
1104        }
1105        if ( strpos( $text, "\\\n" ) !== false ) {
1106            // @todo We don't currently handle this properly.
1107            throw new MWContentSerializationException( "Line ending with a \\" );
1108        }
1109        $text = strtr( $text, [
1110            "\n" => '\n',
1111            '\n' => '\\\\n',
1112            '|' => '{{!}}'
1113        ] );
1114        return $text;
1115    }
1116
1117    /**
1118     * Removes escape characters inserted in human editable mode.
1119     *
1120     * @param string $text
1121     * @return string Unescaped text
1122     */
1123    public static function unescapeForHumanEditable( $text ) {
1124        $text = strtr( $text, [
1125            '\\\\n' => "\\n",
1126            '\n' => "\n",
1127            '{{!}}' => '|'
1128        ] );
1129        return $text;
1130    }
1131
1132    /**
1133     * Convert from human editable form into a (php) array
1134     *
1135     * @param string $text Text to convert
1136     * @return array Result of converting it to native form
1137     */
1138    public static function convertFromHumanEditable( $text ) {
1139        $res = [];
1140        $split = explode( CollaborationKitSerialization::SERIALIZATION_SPLIT, $text );
1141
1142        $res['display_name'] = $split[0];
1143        $res['introduction'] = $split[1];
1144        $res['footer'] = $split[2];
1145        $res['image'] = $split[3];
1146        $res['colour'] = $split[4];
1147        $content = $split[5];
1148        if ( trim( $content ) == '' ) {
1149            $res['content'] = [];
1150        } else {
1151            $listLines = explode( "\n", $content );
1152            foreach ( $listLines as $line ) {
1153                $res['content'][] = self::convertFromHumanEditableItemLine( $line );
1154            }
1155        }
1156        return $res;
1157    }
1158
1159    /**
1160     * Helper function that converts individual lines from convertFromHumanEditable.
1161     *
1162     * @param string $line
1163     * @return array
1164     * @throws MWContentSerializationException
1165     */
1166    private static function convertFromHumanEditableItemLine( $line ) {
1167        $parts = explode( '|', $line );
1168        $parts = array_map( [ __CLASS__, 'unescapeForHumanEditable' ], $parts );
1169        $itemRes = [ 'title' => $parts[0] ];
1170        if ( count( $parts ) > 1 ) {
1171            $parts = array_slice( $parts, 1 );
1172            foreach ( $parts as $part ) {
1173                [ $key, $value ] = explode( '=', $part );
1174                switch ( $key ) {
1175                    case 'image':
1176                    case 'display_title':
1177                        $itemRes[$key] = $value;
1178                        break;
1179                    default:
1180                        throw new MWContentSerializationException(
1181                            'Unrecognized option for list item:' .
1182                            wfEscapeWikiText( $key )
1183                        );
1184                }
1185            }
1186        }
1187        return $itemRes;
1188    }
1189
1190    /**
1191     * Hook to use custom edit page for lists
1192     *
1193     * @param Article|Page $page
1194     * @param User $user (Not used)
1195     * @return bool|null
1196     */
1197    public static function onCustomEditor( Page $page, User $user ) {
1198        if (
1199            $page instanceof Article
1200            && $page->getPage()->getContentModel() === __CLASS__
1201        ) {
1202            $editor = new CollaborationHubContentEditor( $page );
1203            $editor->setContextTitle( $page->getTitle() );
1204            $editor->edit();
1205            return false;
1206        }
1207    }
1208
1209    /**
1210     * Helper function to return only the specific text from a ParserOutput object
1211     * so we don't fill the page with unnecessary wrappers and stuff
1212     *
1213     * @param ParserOutput $tempOutput
1214     * @return string
1215     */
1216    private function getTrimmedText( $tempOutput ) {
1217        return $tempOutput->getText( [ 'unwrap' => true ] );
1218    }
1219}