Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
31.89% |
192 / 602 |
|
20.00% |
6 / 30 |
CRAP | |
0.00% |
0 / 1 |
CollaborationListContent | |
31.89% |
192 / 602 |
|
20.00% |
6 / 30 |
12083.55 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
isValid | |
91.30% |
21 / 23 |
|
0.00% |
0 / 1 |
13.11 | |||
validateOption | |
91.67% |
11 / 12 |
|
0.00% |
0 / 1 |
5.01 | |||
beautifyJSON | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
2 | |||
decode | |
100.00% |
17 / 17 |
|
100.00% |
1 / 1 |
4 | |||
getDescription | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
fillParserOutput | |
0.00% |
0 / 26 |
|
0.00% |
0 / 1 |
20 | |||
getFullRenderListOptions | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
2 | |||
convertToWikitext | |
0.00% |
0 / 118 |
|
0.00% |
0 / 1 |
1482 | |||
generateImage | |
0.00% |
0 / 32 |
|
0.00% |
0 / 1 |
132 | |||
convert | |
58.33% |
7 / 12 |
|
0.00% |
0 / 1 |
5.16 | |||
getDefaultOptions | |
0.00% |
0 / 11 |
|
0.00% |
0 / 1 |
2 | |||
sortList | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
20 | |||
filterColumns | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
20 | |||
sortRandomly | |
0.00% |
0 / 14 |
|
0.00% |
0 / 1 |
42 | |||
matchesTag | |
92.31% |
12 / 13 |
|
0.00% |
0 / 1 |
6.02 | |||
convertToHumanEditable | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
1 | |||
getHumanOptions | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
2 | |||
getHumanEditableList | |
77.14% |
27 / 35 |
|
0.00% |
0 / 1 |
15.02 | |||
parseHumanOptions | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
4 | |||
convertFromHumanEditable | |
89.66% |
26 / 29 |
|
0.00% |
0 / 1 |
6.04 | |||
convertFromHumanEditableColumn | |
72.73% |
24 / 33 |
|
0.00% |
0 / 1 |
13.45 | |||
convertFromHumanEditableItemLine | |
53.85% |
21 / 39 |
|
0.00% |
0 / 1 |
26.16 | |||
transcludeHook | |
0.00% |
0 / 64 |
|
0.00% |
0 / 1 |
506 | |||
sortUsersIntoColumns | |
0.00% |
0 / 27 |
|
0.00% |
0 / 1 |
30 | |||
filterActiveUsers | |
0.00% |
0 / 19 |
|
0.00% |
0 / 1 |
12 | |||
loadStyles | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
2 | |||
onArticleViewHeader | |
0.00% |
0 / 17 |
|
0.00% |
0 / 1 |
42 | |||
onBeforePageDisplay | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
onCustomEditor | |
0.00% |
0 / 6 |
|
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 | |
18 | use MediaWiki\Extension\EventLogging\EventLogging; |
19 | use MediaWiki\MediaWikiServices; |
20 | use PageImages\PageImages; |
21 | |
22 | class 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 | } |