Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
31.89% covered (danger)
31.89%
192 / 602
20.00% covered (danger)
20.00%
6 / 30
CRAP
0.00% covered (danger)
0.00%
0 / 1
CollaborationListContent
31.89% covered (danger)
31.89%
192 / 602
20.00% covered (danger)
20.00%
6 / 30
12083.55
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isValid
91.30% covered (success)
91.30%
21 / 23
0.00% covered (danger)
0.00%
0 / 1
13.11
 validateOption
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
5.01
 beautifyJSON
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 decode
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
4
 getDescription
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 / 26
0.00% covered (danger)
0.00%
0 / 1
20
 getFullRenderListOptions
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 convertToWikitext
0.00% covered (danger)
0.00%
0 / 118
0.00% covered (danger)
0.00%
0 / 1
1482
 generateImage
0.00% covered (danger)
0.00%
0 / 32
0.00% covered (danger)
0.00%
0 / 1
132
 convert
58.33% covered (warning)
58.33%
7 / 12
0.00% covered (danger)
0.00%
0 / 1
5.16
 getDefaultOptions
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
2
 sortList
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
20
 filterColumns
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
20
 sortRandomly
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
42
 matchesTag
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
6.02
 convertToHumanEditable
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 getHumanOptions
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 getHumanEditableList
77.14% covered (warning)
77.14%
27 / 35
0.00% covered (danger)
0.00%
0 / 1
15.02
 parseHumanOptions
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
4
 convertFromHumanEditable
89.66% covered (warning)
89.66%
26 / 29
0.00% covered (danger)
0.00%
0 / 1
6.04
 convertFromHumanEditableColumn
72.73% covered (warning)
72.73%
24 / 33
0.00% covered (danger)
0.00%
0 / 1
13.45
 convertFromHumanEditableItemLine
53.85% covered (warning)
53.85%
21 / 39
0.00% covered (danger)
0.00%
0 / 1
26.16
 transcludeHook
0.00% covered (danger)
0.00%
0 / 64
0.00% covered (danger)
0.00%
0 / 1
506
 sortUsersIntoColumns
0.00% covered (danger)
0.00%
0 / 27
0.00% covered (danger)
0.00%
0 / 1
30
 filterActiveUsers
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
12
 loadStyles
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 onArticleViewHeader
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
42
 onBeforePageDisplay
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 onCustomEditor
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
1<?php
2
3/**
4 * A content model for representing lists of wiki pages in JSON.
5 *
6 * This content model is used to prepare lists of pages (i.e., of
7 * members' user pages or of wiki pages to work on) and associated
8 * metadata while separating content from presentation.
9 * Features associated JavaScript modules allowing for quick
10 * manipulation of list contents.
11 * Important design assumption: This class assumes lists are small
12 * (e.g. Average case < 500 items, outliers < 2000)
13 * Schema is found in CollaborationListContentSchema.php.
14 *
15 * @file
16 */
17
18use MediaWiki\Extension\EventLogging\EventLogging;
19use MediaWiki\MediaWikiServices;
20use PageImages\PageImages;
21
22class CollaborationListContent extends JsonContent {
23
24    const MAX_LIST_SIZE = 2000; // Maybe should be a config option.
25    const RANDOM_CACHE_EXPIRY = 28800; // 8 hours
26    const MAX_TAGS = 50;
27
28    // Splitter denoting the beginning of a list column
29    const HUMAN_COLUMN_SPLIT = "\n---------~-~---------\n";
30    // Splitter denoting the beginning of the list itself within the column
31    const HUMAN_COLUMN_SPLIT2 = "\n---------------------\n";
32
33    /** @var bool Have we decoded the data yet */
34    private $decoded = false;
35    /** @var string Descripton wikitext */
36    protected $description;
37    /** @var StdClass Options for page */
38    protected $options;
39    /** @var $items array List of columns */
40    protected $columns;
41    /** @var string The variety of list */
42    protected $displaymode;
43
44    /** @var string Error message text */
45    protected $errortext;
46
47    /**
48     * @param string $text
49     * @param string $type
50     */
51    public function __construct( $text, $type = 'CollaborationListContent' ) {
52        parent::__construct( $text, $type );
53    }
54
55    /**
56     * Decode and validate the contents.
57     *
58     * @return bool Whether the contents are valid
59     */
60    public function isValid() {
61        $listSchema = include __DIR__ . '/CollaborationListContentSchema.php';
62        if ( !parent::isValid() ) {
63            return false;
64        }
65        $status = $this->getData();
66        if ( !is_object( $status ) || !$status->isOK() ) {
67            return false;
68        }
69        $data = $status->value;
70        // FIXME: The schema should be checking for required fields but for some
71        // reason that doesn't work. This may be an issue with EventLogging
72        if (
73            !isset( $data->description )
74            || !isset( $data->columns )
75            || !isset( $data->options )
76            || !isset( $data->displaymode )
77        ) {
78            return false;
79        }
80
81        $jsonAsArray = json_decode( json_encode( $data ), true );
82
83        try {
84            EventLogging::schemaValidate( $jsonAsArray, $listSchema );
85            // FIXME: The schema should be enforcing data type requirements, but
86            // it isn't. Again, this is probably EventLogging.
87            // @phan-suppress-next-line PhanTypeArraySuspiciousNullable
88            foreach ( $jsonAsArray['columns'] as $column ) {
89                if ( !is_array( $column ) ) {
90                    return false;
91                } else {
92                    foreach ( $column['items'] as $item ) {
93                        if ( !is_array( $item ) ) {
94                            return false;
95                        }
96                    }
97                }
98            }
99            return true;
100        } catch ( JsonSchemaException $e ) {
101            return false;
102        }
103    }
104
105    /**
106     * Validates a list configuration option against the schema.
107     *
108     * @param string $name The name of the parameter
109     * @param mixed &$value The value of the parameter
110     * @return bool Whether the configuration option is valid.
111     */
112    private static function validateOption( $name, &$value ) {
113        $listSchema = include __DIR__ . '/CollaborationListContentSchema.php';
114
115        // Special handling for DISPLAYMODE
116        if ( $name == 'DISPLAYMODE' ) {
117            return ( $value == 'members' || $value == 'normal' || $value == 'error' );
118        }
119
120        // Force intrepretation as boolean for certain options
121        if ( $name == 'includedesc' ) {
122            $value = (bool)$value;
123        }
124
125        // Set up a dummy CollaborationListContent array featuring the options
126        // being validated.
127        $toValidate = [
128            'displaymode' => 'normal',
129            'description' => '',
130            'columns' => [],
131            'options' => [ $name => $value ]
132        ];
133        return EventLogging::schemaValidate( $toValidate, $listSchema );
134    }
135
136    /**
137     * Format JSON
138     *
139     * Do not escape < and > it's unnecessary and ugly
140     * @return string
141     */
142    public function beautifyJSON() {
143        return FormatJson::encode(
144            $this->getData()->getValue(),
145            true,
146            FormatJson::ALL_OK
147        );
148    }
149
150    /**
151     * Decode the JSON contents and populate protected variables.
152     */
153    protected function decode() {
154        if ( $this->decoded ) {
155            return;
156        }
157        $data = $this->getData()->value;
158        if ( !$this->isValid() ) {
159            $this->displaymode = 'error';
160            if ( !parent::isValid() ) {
161                // It's not even valid json
162                $this->errortext = htmlspecialchars( $this->getText() );
163            } else {
164                $this->errortext = FormatJson::encode(
165                    $data,
166                    true,
167                    FormatJson::ALL_OK
168                );
169            }
170        } else {
171            $this->displaymode = $data->displaymode;
172            $this->description = $data->description;
173            $this->options = $data->options;
174            $this->columns = $data->columns;
175        }
176        $this->decoded = true;
177    }
178
179    /**
180     * @return string
181     */
182    public function getDescription() {
183        $this->decode();
184        return $this->description;
185    }
186
187    /**
188     * Fill $output with information derived from the content.
189     *
190     * @param Title $title
191     * @param int $revId Revision ID
192     * @param ParserOptions $options
193     * @param bool $generateHtml
194     * @param ParserOutput &$output
195     */
196    protected function fillParserOutput( Title $title, $revId,
197        ParserOptions $options, $generateHtml, ParserOutput &$output
198    ) {
199        $parser = MediaWikiServices::getInstance()->getParser();
200        $this->decode();
201
202        $lang = $options->getTargetLanguage();
203        if ( !$lang ) {
204            $lang = $title->getPageLanguage();
205        }
206
207        // If this is an error-type list (i.e. a schema-violating blob),
208        // just return the plain JSON.
209        if ( $this->displaymode == 'error' ) {
210            $errorText = '<div class=errorbox>' .
211                wfMessage( 'collaborationkit-list-invalid' )
212                    ->inLanguage( $lang )
213                    ->plain() .
214                "</div>\n<pre>" .
215                $this->errortext .
216                '</pre>';
217            $output = $parser->parse( $errorText, $title, $options, true, true,
218                $revId );
219            return;
220        }
221
222        $listOptions = $this->getFullRenderListOptions()
223            + (array)$this->options
224            + $this->getDefaultOptions();
225
226        // Preparing page contents
227        $text = $this->convertToWikitext( $lang, $listOptions );
228        $output = $parser->parse( $text, $title, $options, true, true, $revId );
229
230        $parser->addTrackingCategory( 'collaborationkit-list-tracker' );
231
232        // Special JS variable if this is a member list
233        if ( $this->displaymode == 'members' ) {
234            $isMemberList = true;
235        } else {
236            $isMemberList = false;
237        }
238        $output->addJsConfigVars( 'wgCollaborationKitIsMemberList', $isMemberList );
239    }
240
241    /**
242     * Get rendering options to use when directly viewing the list page
243     *
244     * These are used on direct page views, and plain wikitext
245     * transclusions. They are not used when using the parser function.
246     *
247     * @todo FIXME These should maybe not be used during transclusion.
248     * @return array Options
249     */
250    private function getFullRenderListOptions() {
251        return [
252            'includeDesc' => true,
253            'maxItems' => false,
254            'defaultSort' => 'natural',
255        ];
256    }
257
258    /**
259     * Convert the JSON to wikitext.
260     *
261     * @param Language $lang The (content) language to render the page in.
262     * @param array $options Options to override the default transclude options
263     * @return string The wikitext
264     */
265    public function convertToWikitext( Language $lang, $options = [] ) {
266        $this->decode();
267        $options += $this->getDefaultOptions();
268        $maxItems = $options['maxItems'];
269        $includeDesc = $options['includeDesc'];
270        $iconWidth = (int)$options['iconWidth'];
271
272        // Hack to force style loading even when we don't have a Parser reference.
273        $text = '<collaborationkitloadliststyles/>';
274
275        // Ugly way to prevent unexpected column header TOCs and editsection
276        // links from showing up
277        $text .= "__NOTOC__ __NOEDITSECTION__\n";
278
279        if ( $includeDesc ) {
280            $text .= $this->getDescription() . "\n";
281        }
282        if ( $this->columns === null || count( $this->columns ) === 0 ) {
283            $text .= "\n{{mediawiki:collaborationkit-list-isempty}}\n";
284            return $text;
285        }
286
287        $columns = $this->columns;
288
289        // Assign a UID to each list entry.
290        $uidCounter = 0;
291        foreach ( $columns as $colId => $column ) {
292            foreach ( $column->items as $rowId => $row ) {
293                $columns[$colId]->items[$rowId]->uid = $uidCounter;
294                $uidCounter++;
295            }
296        }
297
298        if ( $this->displaymode === 'members' && count( $this->columns ) === 1 ) {
299            $columns = $this->sortUsersIntoColumns( $columns[0] );
300        }
301
302        $columns = $this->filterColumns( $columns, $options['columns'] );
303
304        $listclass = count( $columns ) > 1 ? 'mw-ck-multilist' : 'mw-ck-singlelist';
305        $text .= '<div class="mw-ck-list ' . $listclass . '">' . "\n";
306        $offsetRule = $options['defaultSort'] === 'random' ? 0 : $options['offset'];
307        foreach ( $columns as $colId => $column ) {
308            $offset = $offsetRule;  // Resetting value after each column is processed
309            $text .= Html::openElement( 'div', [
310                'class' => 'mw-ck-list-column',
311                'data-collabkit-column-id' => $colId
312            ] ) . "\n";
313            $text .= '<div class="mw-ck-list-column-header">' . "\n";
314            if ( $options['showColumnHeaders'] && isset( $column->label )
315                && $column->label !== ''
316            ) {
317                $text .= "=== {$column->label} ===\n";
318            }
319            if (
320                isset( $column->notes ) &&
321                $column->notes !== '' &&
322                $options['showColumnHeaders']
323            ) {
324                $text .= "<div class=\"mw-ck-list-notes\">{$column->notes}</div>\n";
325            }
326            $text .= "</div>\n";
327
328            if ( count( $column->items ) === 0 ) {
329                $text .= "\n<div class=\"mw-ck-list-item\">";
330                $text .= "{{mediawiki:collaborationkit-list-emptycolumn}}</div>\n";
331            } else {
332                $curItem = 0;
333
334                $sortedItems = $column->items;
335                $this->sortList( $sortedItems, $options['defaultSort'] );
336
337                $itemCounter = 0;
338                foreach ( $sortedItems as $item ) {
339                    if ( $offset !== 0 ) {
340                        $offset--;
341                        continue;
342                    }
343                    $curItem++;
344                    if ( $maxItems !== false && $maxItems < $curItem ) {
345                        break;
346                    }
347
348                    $itemTags = $item->tags ?? [];
349                    if ( !$this->matchesTag( $options['tags'], $itemTags ) ) {
350                        continue;
351                    }
352
353                    $titleForItem = null;
354                    if ( !isset( $item->link ) ) {
355                        $titleForItem = Title::newFromText( $item->title );
356                    } elseif ( $item->link !== false ) {
357                        $titleForItem = Title::newFromText( $item->link );
358                    }
359                    $adjustedIconWidth = $iconWidth * 1.3;
360                    $text .= Html::openElement( 'div', [
361                        'class' => 'mw-ck-list-item',
362                        'data-collabkit-item-title' => $item->title,
363                        'data-collabkit-item-id' => $colId . '-' . $itemCounter,
364                        'data-collabkit-item-uid' => $item->uid
365                    ] );
366                    $itemCounter++;
367                    if ( $options['mode'] !== 'no-img' ) {
368                        if ( isset( $item->image ) ) {
369                            $text .= static::generateImage(
370                                $item->image,
371                                $this->displaymode,
372                                $titleForItem,
373                                $iconWidth
374                            );
375                        } else {
376                            // Use fallback mechanisms
377                            $text .= static::generateImage(
378                                null,
379                                $this->displaymode,
380                                $titleForItem,
381                                $iconWidth
382                            );
383                        }
384                    }
385
386                    $text .= '<div class="mw-ck-list-container">';
387                    // Question: Arguably it would be more semantically correct to
388                    // use an <Hn> element for this. Would that be better? Unclear.
389                    $text .= '<div class="mw-ck-list-title">';
390                    if ( $titleForItem ) {
391                        if ( $this->displaymode == 'members'
392                            && !isset( $item->link )
393                            && $titleForItem->inNamespace( NS_USER )
394                        ) {
395                            $titleText = $titleForItem->getText();
396                        } else {
397                            $titleText = $item->title;
398                        }
399                        $text .= '[[:' . $titleForItem->getPrefixedDBkey() . '|'
400                            . wfEscapeWikiText( $titleText ) . ']]';
401                    } else {
402                        $text .= wfEscapeWikiText( $item->title );
403                    }
404                    $text .= "</div>\n";
405                    $text .= '<div class="mw-ck-list-notes">' . "\n";
406                    if ( isset( $item->notes ) && is_string( $item->notes ) ) {
407                        $text .= $item->notes . "\n";
408                    }
409
410                    if ( isset( $item->tags ) && is_array( $item->tags )
411                        && count( $item->tags )
412                    ) {
413                        $text .= "\n<div class='toccolours mw-ck-list-tags'>" .
414                            wfMessage( 'collaborationkit-list-taglist' )
415                                ->inLanguage( $lang )
416                                ->params(
417                                    $lang->commaList(
418                                        array_map( 'wfEscapeWikiText', $item->tags )
419                                    )
420                                )->numParams( count( $item->tags ) )
421                                ->text() .
422                            "</div>\n";
423                    }
424                    $text .= '</div></div></div>' . "\n";
425                }
426            }
427            if ( $this->displaymode != 'members' ) {
428                $text .= "\n<div class=\"mw-ck-list-additem-container\"></div>";
429            }
430            $text .= "\n</div>";
431        }
432        $text .= "\n</div>";
433        if ( $this->displaymode == 'members' ) {
434            $text .= "\n<div class=\"mw-ck-list-additem-container\"></div>";
435        }
436        return $text;
437    }
438
439    /**
440     * Invokes CollaborationKitImage::makeImage with fallback criteria
441     *
442     * @param string|null $definedImage The filename given in the list item
443     * @param string $displayMode Type of list (members or otherwise)
444     * @param Title|null $title Title object of the list item
445     * @param int $size The width of the icon image. Default is 32px;
446     * @return string HTML
447     */
448    protected static function generateImage( $definedImage, $displayMode, $title,
449        $size = 32
450    ) {
451        $size = (int)$size;  // Just in case
452        $image = null;
453        $iconColour = '';
454        $linkOrNot = true;
455
456        // Step 1: Use the defined image, assuming it's valid
457        if ( $definedImage !== null && is_string( $definedImage ) ) {
458            $imageTitle = Title::newFromText( $definedImage, NS_FILE );
459            if ( $imageTitle ) {
460                $imageObj = MediaWikiServices::getInstance()->getRepoGroup()->findFile( $imageTitle );
461                if ( $imageObj ) {
462                    $image = $imageObj->getName();
463                }
464            }
465        }
466
467        // Step 2: No defined image / invalid defined image? Use PageImages if possible.
468        if ( $image === null && $title && ExtensionRegistry::getInstance()->isLoaded( 'PageImages' ) ) {
469            $queryPageImages = PageImages::getPageImage( $title );
470            if ( $queryPageImages !== false ) {
471                $image = $queryPageImages->getName();
472            }
473        }
474
475        // Step 3: None of the above work? Time for fallback icons.
476        if ( $image === null ) {
477            $iconColour = 'lightgrey';
478            $linkOrNot = false;
479            if ( $displayMode == 'members' ) {
480                $image = 'user';
481            } else {
482                $image = 'page';
483            }
484        }
485
486        return CollaborationKitImage::makeImage(
487            $image,
488            $size,
489            [
490                'css' => "width:{$size}px; height:{$size}px;",
491                'classes' => [ 'mw-ck-list-image', 'thumbinner' ],
492                'colour' => $iconColour,
493                'link' => $linkOrNot,
494                'renderAsWikitext' => true,
495                'optimizeForSquare' => true
496            ]
497        );
498    }
499
500    /**
501     * Converts between different text-based content models
502     *
503     * @param string $toModel The desired content model, use the
504     *  CONTENT_MODEL_XXX flags.
505     * @param string $lossy Flag, set to "lossy" to allow lossy conversion.
506     *  If lossy conversion is not allowed, full round-trip conversion is expected
507     *  to work without losing information.
508     * @return Content|bool A content object with the content model $toModel.
509     */
510    public function convert( $toModel, $lossy = '' ) {
511        if ( $toModel === CONTENT_MODEL_WIKITEXT && $lossy === 'lossy' ) {
512            $contLang = MediaWikiServices::getInstance()->getContentLanguage();
513            // Maybe we should transclude from MediaWiki namespace, or give
514            // up on not splitting the parser cache and just use {{int:... (?)
515            $renderOpts = $this->getFullRenderListOptions();
516            $text = $this->convertToWikitext( $contLang, $renderOpts );
517            return ContentHandler::makeContent( $text, null, $toModel );
518        } elseif ( $toModel === CONTENT_MODEL_JSON ) {
519            return ContentHandler::makeContent(
520                $this->getText(),
521                null,
522                $toModel
523            );
524        }
525        return parent::convert( $toModel, $lossy );
526    }
527
528    /**
529     * Default rendering options.
530     *
531     * These are used when rendering a list and no option
532     * value was specified. getFullRenderListOptions() will
533     * override some of these values when a list page is directly
534     * viewed.
535     *
536     * Any new option must be added to this list.
537     *
538     * @return array default rendering options to use.
539     */
540    public function getDefaultOptions() {
541        // FIXME implement
542        // FIXME use defaults from schema instead of hardcoded values
543        return [
544            'includeDesc' => false,
545            'maxItems' => 5,
546            'defaultSort' => 'random',
547            'offset' => 0,
548            'tags' => [],
549            'mode' => 'normal',
550            'columns' => [],
551            'showColumnHeaders' => true,
552            'iconWidth' => 32
553        ];
554    }
555
556    /**
557     * Sort a list
558     *
559     * @param array &$items List to sort (sorted in-place)
560     * @param string $mode sort method
561     * @return array sorted list
562     * @throws UnexpectedValueException on unrecognized mode
563     */
564    private function sortList( &$items, $mode ) {
565        switch ( $mode ) {
566            case 'random':
567                return $this->sortRandomly( $items );
568            case 'natural':
569                return $items;
570            default:
571                throw new UnexpectedValueException( 'invalid sort mode' );
572        }
573    }
574
575    /**
576     * Filter displayed columns
577     *
578     * @note Array keys may not be consecutive after filtering.
579     * @param array $columns
580     * @param array $allowedColumns List of columns to allow. [] for all columns.
581     * @return array The list of columns after filtering is applied
582     */
583    private function filterColumns( array $columns, array $allowedColumns ) {
584        if ( count( $allowedColumns ) === 0 ) {
585            return $columns;
586        }
587
588        $finalColumns = [];
589        foreach ( $columns as $colId => $col ) {
590            if ( in_array( $col->label, $allowedColumns ) ) {
591                $finalColumns[$colId] = $col;
592            }
593        }
594        return $finalColumns;
595    }
596
597    /**
598     * Sort an array pseudo-randomly using an affine transform
599     *
600     * @param array &$items Stuff to sort (sorted in-place)
601     * @return array
602     */
603    private function sortRandomly( &$items ) {
604        $totItems = count( $items );
605        // No point in randomizing if only one item
606        if ( count( $items ) > 1 ) {
607            $rand1 = mt_rand( 1, $totItems - 1 );
608            $rand2 = mt_rand( 0, $totItems - 1 );
609
610            while ( $rand1 < $totItems - 1 && $rand1 % $totItems === 0 ) {
611                // Make rand1 relatively prime to $totItems.
612                $rand1++;
613            }
614            uksort( $items, static function ( $a, $b ) use( $rand1, $rand2, $totItems ) {
615                $a2 = ( $a * $rand1 + $rand2 ) % $totItems;
616                $b2 = ( $b * $rand1 + $rand2 ) % $totItems;
617                if ( $a2 === $b2 ) {
618                    // Really should not happen
619                    return 0;
620                }
621                return $a2 > $b2 ? 1 : -1;
622            } );
623        }
624        return $items;
625    }
626
627    /**
628     * Determine if an item matches a set of tags
629     *
630     * $tagSpecifier is a 2D array describing an AND of OR conditions
631     * e.g. $tagSpecifier = [ [ 'a', 'b' ], ['b', 'd'] ]
632     * means that any item must have the tags (a&&b) || (b&&d).
633     *
634     * @param array $tagSpecifier What tags to check (aka $options['tags'])
635     * @param array $itemTags What tags is this item tagged with
636     * @return bool If the item matches
637     */
638    private function matchesTag( array $tagSpecifier, array $itemTags ) {
639        if ( !$tagSpecifier ) {
640            // We want the empty case to be considered a match.
641            return true;
642        }
643        // We first want to find if there exists a group that matches.
644        $matchesOneGroup = false;
645        foreach ( $tagSpecifier as $tagGroups ) {
646            // Inside the group, we want to verify for all group
647            // members, the group matches
648            $matchesAllAlternatives = true;
649            foreach ( $tagGroups as $tagAlt ) {
650                if ( !in_array( $tagAlt, $itemTags ) ) {
651                    $matchesAllAlternatives = false;
652                    break;
653                }
654            }
655            if ( $matchesAllAlternatives ) {
656                $matchesOneGroup = true;
657                break;
658            }
659        }
660        return $matchesOneGroup;
661    }
662
663    /**
664     * Convert JSON to markup that's easier for humans.
665     * @return string
666     */
667    public function convertToHumanEditable() {
668        $this->decode();
669        return CollaborationKitSerialization::getSerialization( [
670            $this->description,
671            $this->getHumanOptions(),
672            $this->getHumanEditableList()
673        ] );
674    }
675
676    /**
677     * Output the options in human readable form
678     *
679     * @return string key=value pairs separated by newlines.
680     */
681    private function getHumanOptions() {
682        $this->decode();
683        $ret = '';
684        foreach ( $this->options as $opt => $value ) {
685            $ret .= $opt . '=' . $value . "\n";
686        }
687        // This might be a bad idea, but displaymode (not considered
688        // an "option" but a separate attribute) is going to piggy-
689        // back off of this method. Allcaps is to signify its specialness.
690        $ret .= 'DISPLAYMODE=' . $this->displaymode . "\n";
691        return $ret;
692    }
693
694    /**
695     * Get the list of items in human editable form.
696     *
697     * @return string
698     * @todo i18n-ize
699     */
700    public function getHumanEditableList() {
701        $this->decode();
702
703        $out = '';
704        foreach ( $this->columns as $column ) {
705            // Use two to separate columns
706            $out .= self::HUMAN_COLUMN_SPLIT;
707            if ( isset( $column->label ) ) {
708                $out .= CollaborationHubContent::escapeForHumanEditable( $column->label );
709            } else {
710                $out .= 'column';
711            }
712            if ( isset( $column->notes ) ) {
713                $out .= '|notes='
714                    . CollaborationHubContent::escapeForHumanEditable( $column->notes );
715            }
716            $out .= self::HUMAN_COLUMN_SPLIT2;
717
718            foreach ( $column->items as $item ) {
719                $out .= CollaborationHubContent::escapeForHumanEditable( $item->title );
720                if ( isset( $item->notes ) ) {
721                    $out .= '|'
722                    . CollaborationHubContent::escapeForHumanEditable( $item->notes );
723                } else {
724                    $out .= '|';
725                }
726                if ( isset( $item->link ) ) {
727                    if ( $item->link === false ) {
728                        $out .= '|nolink';
729                    } else {
730                        $out .= "|link="
731                            . CollaborationHubContent::escapeForHumanEditable( $item->link );
732                    }
733                }
734                if ( isset( $item->image ) ) {
735                    if ( $item->image === false ) {
736                        $out .= '|noimage';
737                    } else {
738                        $out .= '|image='
739                            . CollaborationHubContent::escapeForHumanEditable( $item->image );
740                    }
741                }
742                if ( isset( $item->tags ) ) {
743                    foreach ( (array)$item->tags as $tag ) {
744                        $out .= '|tag='
745                            . CollaborationHubContent::escapeForHumanEditable( $tag );
746                    }
747                }
748                if ( substr( $out, -1 ) === '|' ) {
749                    $out = substr( $out, 0, strlen( $out ) - 1 );
750                }
751                // Not doing sortkey as that's not really implemented.
752                $out .= "\n";
753            }
754        }
755        return $out;
756    }
757
758    /**
759     * @param string $options Human readable options
760     * @return stdClass
761     */
762    private static function parseHumanOptions( $options ) {
763        $finalList = [];
764        $optList = explode( "\n", $options );
765        foreach ( $optList as $line ) {
766            $splitLine = explode( '=', $line, 2 );
767            if ( count( $splitLine ) !== 2 ) {
768                continue;
769            }
770            $name = trim( $splitLine[0] );
771            $value = trim( $splitLine[1] );
772            if ( self::validateOption( $name, $value ) ) {
773                $finalList[$name] = $value;
774            }
775        }
776        return (object)$finalList;
777    }
778
779    /**
780     * Convert from human editable form into a (php) array
781     *
782     * @param string $text text to convert
783     * @return array Result of converting it to native form
784     * @throws MWContentSerializationException
785     */
786    public static function convertFromHumanEditable( $text ) {
787        $res = [ 'columns' => [] ];
788
789        $split2 = strrpos(
790            $text,
791            CollaborationKitSerialization::SERIALIZATION_SPLIT
792        );
793        if ( $split2 === false ) {
794            throw new MWContentSerializationException( 'Missing list description' );
795        }
796
797        $split1 = strrpos(
798            $text, CollaborationKitSerialization::SERIALIZATION_SPLIT,
799            -strlen( $text ) + $split2 - 1
800        );
801        if ( $split1 === false ) {
802            throw new MWContentSerializationException( 'Missing list description' );
803        }
804        $dividerLength = strlen( CollaborationKitSerialization::SERIALIZATION_SPLIT );
805
806        $optionLength = $split2 - ( $split1 + $dividerLength );
807        $optionString = substr( $text, $split1 + $dividerLength, $optionLength );
808        $res['options'] = self::parseHumanOptions( $optionString );
809
810        if ( isset( $res['options']->DISPLAYMODE ) ) {
811            $res['displaymode'] = $res['options']->DISPLAYMODE;
812            unset( $res['options']->DISPLAYMODE );
813        } else {
814            throw new MWContentSerializationException( 'Missing list displaymode' );
815        }
816
817        $res['description'] = substr( $text, 0, $split1 );
818        $columnText = substr( $text, $split2 + $dividerLength );
819
820        // Put \n back on beginning so it still explodes properly after general trim
821        $columnText = "\n" . $columnText;
822        $columns = explode( self::HUMAN_COLUMN_SPLIT, $columnText );
823        foreach ( $columns as $column ) {
824            // Skip empty lines
825            if ( trim( $column ) !== '' ) {
826                $res['columns'][] = self::convertFromHumanEditableColumn( $column );
827            }
828        }
829
830        return $res;
831    }
832
833    /**
834     * @param string $column
835     * @return array
836     * @throws MWContentSerializationException
837     */
838    private static function convertFromHumanEditableColumn( $column ) {
839        // Adding newline so that HUMAN_COLUMN_SPLIT2 correctly triggers
840        $column .= "\n";
841
842        $columnItem = [ 'items' => [] ];
843
844        $columnContent = explode( self::HUMAN_COLUMN_SPLIT2, $column );
845
846        $parts = explode( '|', $columnContent[0] );
847
848        $parts = array_map(
849            [ 'CollaborationHubContent', 'unescapeForHumanEditable' ],
850            $parts
851        );
852
853        if ( $parts[0] != 'column' ) {
854            $columnItem['label'] = $parts[0];
855        }
856
857        $parts = array_slice( $parts, 1 );
858
859        if ( count( $parts ) > 0 ) {
860            foreach ( $parts as $part ) {
861                if ( $part == 'column' ) {
862                    continue;
863                }
864                [ $key, $value ] = explode( '=', $part );
865
866                switch ( $key ) {
867                    case 'notes':
868                        $columnItem[$key] = $value;
869                        break;
870                    default:
871                        $context = wfEscapeWikiText( substr( $part, 30 ) );
872                        if ( strlen( $context ) === 30 ) {
873                            $context .= '...';
874                        }
875                        throw new MWContentSerializationException(
876                            'Unrecognized option for column:' .
877                            wfEscapeWikiText( $key )
878                        );
879                }
880            }
881        }
882
883        if ( count( $columnContent ) == 1 ) {
884            return $columnItem;
885        } else {
886            $listLines = explode( "\n", $columnContent[1] );
887            foreach ( $listLines as $line ) {
888                // Skip empty lines
889                if ( trim( $line ) !== '' ) {
890                    $columnItem['items'][] = self::convertFromHumanEditableItemLine( $line );
891                }
892            }
893        }
894
895        return $columnItem;
896    }
897
898    /**
899     * @param string $line
900     * @return array
901     * @throws MWContentSerializationException
902     */
903    private static function convertFromHumanEditableItemLine( $line ) {
904        $parts = explode( '|', $line );
905        $parts = array_map(
906            [ 'CollaborationHubContent', 'unescapeForHumanEditable' ],
907            $parts
908        );
909        $itemRes = [ 'title' => $parts[0] ];
910        if ( count( $parts ) > 1 ) {
911            // If people are using batch editor, they might define an image etc.
912            // despite lack of a note. This is to catch that and prevent weirdness.
913            $testExplosion = explode( '=', $parts[1] );
914            if ( in_array( $testExplosion[0], [ 'image', 'link', 'tags', 'sortkey' ] ) ) {
915                $itemRes[$testExplosion[0]] = $testExplosion[1];
916                $itemRes['notes'] = '';
917            } else {
918                $itemRes['notes'] = $parts[1];
919            }
920            $parts = array_slice( $parts, 2 );
921            foreach ( $parts as $part ) {
922                [ $key, $value ] = explode( '=', $part );
923                switch ( $key ) {
924                    case 'nolink':
925                        $itemRes['link'] = false;
926                        break;
927                    case 'noimage':
928                        $itemRes['image'] = false;
929                        break;
930                    case 'tag':
931                        if ( !isset( $itemRes['tags'] ) ) {
932                            $itemRes['tags'] = [];
933                        }
934                        $itemRes['tags'][] = $value;
935                        break;
936                    case 'image':
937                    case 'link':
938                        $itemRes[$key] = $value;
939                        break;
940                    default:
941                        $context = wfEscapeWikiText( substr( $part, 30 ) );
942                        if ( strlen( $context ) === 30 ) {
943                            $context .= '...';
944                        }
945                        throw new MWContentSerializationException(
946                            'Unrecognized option for list item:' .
947                            wfEscapeWikiText( $key )
948                        );
949                }
950            }
951        } else {
952            $itemRes['notes'] = '';
953        }
954        return $itemRes;
955    }
956
957    /**
958     * Function to handle {{#trancludelist:Page name|options...}} calls
959     *
960     * @param Parser $parser
961     * @param string $pageName
962     * @param string ...$args
963     * @return string|array HTML string or an array [ string, 'noparse' => false ]
964     */
965    public static function transcludeHook( $parser, $pageName = '', ...$args ) {
966        $options = [];
967        $title = Title::newFromText( $pageName );
968        $lang = $parser->getTargetLanguage();
969
970        if ( !$title || $title->getContentModel() !== __CLASS__ ) {
971            // This is interpreted as wikitext, so is safe.
972            return Html::rawElement( 'div', [ 'class' => 'error' ],
973                wfMessage( 'collaborationkit-list-notlist' )
974                    ->params( $title->getPrefixedText() )
975                    ->inLanguage( $lang )
976                    ->text()
977            );
978        }
979        $tagCount = 0;
980        foreach ( $args as $argument ) {
981            if ( strpos( $argument, '=' ) === false ) {
982                continue;
983            }
984            // If we need everything i18n-ized, this could be
985            // replaced with magic words.
986            [ $name, $value ] = explode( '=', $argument, 2 );
987            if ( $name === 'tags' ) {
988                $tagList = explode( '+', $value );
989                if ( !isset( $options['tags'] ) ) {
990                    $options['tags'] = [];
991                }
992                $options['tags'][] = $tagList;
993                $tagCount += count( $tagList );
994            } elseif ( $name === 'column' ) {
995                $options['columns'][] = $value;
996            } elseif ( $name === 'columns' ) {
997                // No-op. The preferred parameter name is the singular "column"
998                // but "columns" is still a valid option name. But without special
999                // handling, it causes an exception since all of these parameters
1000                // are coming in as strings even though "columns" is an array.
1001                continue;
1002            } elseif (
1003                $name === 'offset' ||
1004                $name === 'iconWidth'
1005            ) {
1006                $options[$name] = (int)$value;
1007            } elseif (
1008                $name === 'includeDesc' ||
1009                $name === 'showColumnHeaders'
1010            ) {
1011                // (bool)'false' evaluates to true? What a country!
1012                if (
1013                    $value === 'false' ||
1014                    $value === 'no' ||
1015                    $value === 0
1016                ) {
1017                    $options[$name] = false;
1018                } else {
1019                    $options[$name] = (bool)$value;
1020                }
1021            } elseif ( self::validateOption( $name, $value ) ) {
1022                $options[$name] = $value;
1023            }
1024        }
1025
1026        if ( $tagCount > self::MAX_TAGS ) {
1027            return Html::rawElement( 'div', [ 'class' => 'error' ],
1028                wfMessage( 'collaborationkit-list-toomanytags' )
1029                    ->numParams( self::MAX_TAGS, $tagCount )
1030                    ->inLanguage( $lang )
1031                    ->text()
1032            );
1033        }
1034
1035        $wikipage = MediaWikiServices::getInstance()->getWikiPageFactory()->newFromTitle( $title );
1036        $content = $wikipage->getContent();
1037        if ( !$content instanceof CollaborationListContent ) {
1038            // We already checked this, so this should not happen...
1039            return Html::rawElement( 'div', [ 'class' => 'error' ],
1040                wfMessage( 'collaborationkit-list-notlist' )
1041                    ->params( $title->getPrefixedText() )
1042                    ->inLanguage( $lang )
1043                    ->text()
1044            );
1045        }
1046
1047        if ( ( isset( $options['defaultSort'] )
1048            && $options['defaultSort'] === 'random' )
1049            || $content->getDefaultOptions()['defaultSort'] === 'random'
1050        ) {
1051            $parser->getOutput()->updateCacheExpiry( self::RANDOM_CACHE_EXPIRY );
1052        }
1053        $parser->getOutput()->addTemplate(
1054            $title,
1055            $wikipage->getId(),
1056            $wikipage->getLatest()
1057        );
1058        $res = $content->convertToWikitext( $lang, $options );
1059        return [ $res, 'noparse' => false ];
1060    }
1061
1062    /**
1063     * Sort users into active/inactive column
1064     *
1065     * @param stdClass $column An object representing the one column containing
1066     *  the list of members of a given project. The object contains an attribute
1067     *  "items" with a value of an array of objects representing individual list
1068     *  items. Each of these has a key named title which contains a user name
1069     *  (including namespace). May have non-users too.
1070     * @return array Two column structure sorted active/inactive.
1071     * @todo Should link property be taken into account as actual name?
1072     */
1073    private function sortUsersIntoColumns( $column ) {
1074        $nonUserItems = [];
1075        $userItems = [];
1076        foreach ( $column->items as $item ) {
1077            $title = Title::newFromText( $item->title );
1078            if ( !$title ||
1079                !$title->inNamespace( NS_USER ) ||
1080                $title->isSubpage()
1081            ) {
1082                $nonUserItems[] = $item;
1083            } else {
1084                $userItems[$title->getText()] = $item;
1085            }
1086        }
1087        $res = $this->filterActiveUsers( $userItems );
1088        $inactiveFlatList = array_merge(
1089            array_values( $res['inactive'] ),
1090            $nonUserItems
1091        );
1092
1093        // So currently, columns can be selected based on their names,
1094        // which is based on a Message object, which is not the nicest
1095        // for the autogenerated active/inactive columns.
1096        $activeColumn = (object)[
1097            'items' => array_values( $res['active'] ),
1098            'label' => wfMessage( 'collaborationkit-column-active' )
1099                ->inContentLanguage()
1100                ->plain(),
1101        ];
1102        $inactiveColumn = (object)[
1103            'items' => $inactiveFlatList,
1104            'label' => wfMessage( 'collaborationkit-column-inactive' )
1105                ->inContentLanguage()
1106                ->plain(),
1107        ];
1108
1109        return [ $activeColumn, $inactiveColumn ];
1110    }
1111
1112    /**
1113     * Filter users into active and inactive.
1114     *
1115     * @note The results of this function get stored in parser cache.
1116     * @param array $userList Array of usernames => stdClass
1117     * @return array [ 'active' => [..], 'inactive' => '[..]' ]
1118     */
1119    private function filterActiveUsers( $userList ) {
1120        if ( count( $userList ) > 0 ) {
1121            $users = array_keys( $userList );
1122            $dbr = wfGetDB( DB_REPLICA );
1123            $res = $dbr->select(
1124                'querycachetwo',
1125                'qcc_title',
1126                [
1127                    'qcc_namespace' => NS_USER,
1128                    // TODO: Perhaps should use batching.
1129                    'qcc_title' => $users,
1130                    'qcc_type' => 'activeusers'
1131                ],
1132                __METHOD__
1133            );
1134
1135            $active = [];
1136            foreach ( $res as $row ) {
1137                $active[$row->qcc_title] = $userList[$row->qcc_title];
1138                unset( $userList[$row->qcc_title] );
1139            }
1140            return [ 'active' => $active, 'inactive' => $userList ];
1141        } else {
1142            return [ 'active' => [], 'inactive' => [] ];
1143        }
1144    }
1145
1146    /**
1147     * Hack to allow styles to be loaded from plain transclusion.
1148     *
1149     * We don't have access to a parser object in getWikitextForTransclusion().
1150     * So instead we put <collaborationkitloadliststyles/> on the page, which
1151     * calls this.
1152     *
1153     * @param string $content Input to parser hook
1154     * @param array $attributes
1155     * @param Parser $parser
1156     * @return string Empty string
1157     */
1158    public static function loadStyles( $content, array $attributes, Parser $parser ) {
1159        $parser->getOutput()->addModuleStyles( [
1160            'ext.CollaborationKit.list.styles',
1161            'ext.CollaborationKit.icons'
1162        ] );
1163        return '';
1164    }
1165
1166    /**
1167     * Hook used to determine if current user should be given the edit interface
1168     * for a page.
1169     *
1170     * @todo Not clear if this is the best hook to use. onBeforePageDisplay
1171     *  doesn't have easy access to oldid
1172     *
1173     * @param Article $article
1174     */
1175    public static function onArticleViewHeader( Article $article ) {
1176        $title = $article->getTitle();
1177        $context = $article->getContext();
1178        $user = $context->getUser();
1179        $output = $context->getOutput();
1180        $action = $context->getRequest()->getVal( 'action', 'view' );
1181        $permissionManager = MediaWiki\MediaWikiServices::getInstance()->getPermissionManager();
1182
1183        // @todo Does not trigger on perma-link to current revision
1184        // not sure if that's a desired behaviour or not.
1185        if ( $title->getContentModel() === __CLASS__
1186            && $action === 'view'
1187            && $title->getArticleID() !== 0
1188            && $article->getOldID() === 0 /* current rev */
1189            && $permissionManager->userCan( 'edit', $user, $title )
1190        ) {
1191            $output->addJsConfigVars( 'wgEnableCollaborationKitListEdit', true );
1192
1193            // FIXME: only load .list.members if the list is a member list
1194            // (displaymode = members)
1195            $output->addModules( [
1196                'ext.CollaborationKit.list.ui',
1197                'ext.CollaborationKit.list.members'
1198            ] );
1199            $output->preventClickjacking();
1200        }
1201    }
1202
1203    /**
1204     * Hook to add timestamp for edit conflict detection
1205     *
1206     * @param OutputPage $out
1207     * @param Skin $skin
1208     */
1209    public static function onBeforePageDisplay( OutputPage $out, $skin ) {
1210        // Used for edit conflict detection in lists.
1211        $revTS = (int)$out->getRevisionTimestamp();
1212        $out->addJsConfigVars( 'wgCollabkitLastEdit', $revTS );
1213    }
1214
1215    /**
1216     * Hook to use custom edit page for lists
1217     *
1218     * @param Article|Page $page
1219     * @param User $user (Not used)
1220     * @return bool|null
1221     */
1222    public static function onCustomEditor( Page $page, User $user ) {
1223        if (
1224            $page instanceof Article
1225            && $page->getPage()->getContentModel() === __CLASS__
1226        ) {
1227            $editor = new CollaborationListContentEditor( $page );
1228            $editor->setContextTitle( $page->getTitle() );
1229            $editor->edit();
1230            return false;
1231        }
1232    }
1233}