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