Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
38.50% |
87 / 226 |
|
0.00% |
0 / 11 |
CRAP | |
0.00% |
0 / 1 |
TopicListBlock | |
38.50% |
87 / 226 |
|
0.00% |
0 / 11 |
1016.97 | |
0.00% |
0 / 1 |
validate | |
0.00% |
0 / 23 |
|
0.00% |
0 / 1 |
156 | |||
create | |
0.00% |
0 / 19 |
|
0.00% |
0 / 1 |
6 | |||
commit | |
0.00% |
0 / 24 |
|
0.00% |
0 / 1 |
30 | |||
renderTocApi | |
0.00% |
0 / 16 |
|
0.00% |
0 / 1 |
30 | |||
renderApi | |
67.65% |
23 / 34 |
|
0.00% |
0 / 1 |
8.66 | |||
preloadTexts | |
21.43% |
3 / 14 |
|
0.00% |
0 / 1 |
23.46 | |||
getName | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getLimit | |
50.00% |
3 / 6 |
|
0.00% |
0 / 1 |
2.50 | |||
getFindOptions | |
90.74% |
49 / 54 |
|
0.00% |
0 / 1 |
15.18 | |||
getPage | |
33.33% |
9 / 27 |
|
0.00% |
0 / 1 |
21.52 | |||
setPageTitle | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
6 |
1 | <?php |
2 | |
3 | namespace Flow\Block; |
4 | |
5 | use Flow\Container; |
6 | use Flow\Data\Pager\Pager; |
7 | use Flow\Data\Pager\PagerPage; |
8 | use Flow\Exception\FailCommitException; |
9 | use Flow\Exception\FlowException; |
10 | use Flow\Formatter\TocTopicListFormatter; |
11 | use Flow\Formatter\TopicListFormatter; |
12 | use Flow\Formatter\TopicListQuery; |
13 | use Flow\Model\PostRevision; |
14 | use Flow\Model\TopicListEntry; |
15 | use Flow\Model\UUID; |
16 | use Flow\Model\Workflow; |
17 | use MediaWiki\MediaWikiServices; |
18 | use MediaWiki\Output\OutputPage; |
19 | use MediaWiki\Revision\RevisionRecord; |
20 | use MediaWiki\Title\Title; |
21 | use UserOptionsUpdateJob; |
22 | |
23 | class TopicListBlock extends AbstractBlock { |
24 | |
25 | /** |
26 | * @var array |
27 | */ |
28 | protected $supportedPostActions = [ 'new-topic' ]; |
29 | |
30 | /** |
31 | * @var array |
32 | */ |
33 | protected $supportedGetActions = [ 'view', 'view-topiclist' ]; |
34 | |
35 | /** |
36 | * @var string[] |
37 | * @todo Fill in the template names |
38 | */ |
39 | protected $templates = [ |
40 | 'view' => '', |
41 | 'new-topic' => 'newtopic', |
42 | ]; |
43 | |
44 | /** |
45 | * @var Workflow|null |
46 | */ |
47 | protected $topicWorkflow; |
48 | |
49 | /** |
50 | * @var TopicListEntry|null |
51 | */ |
52 | protected $topicListEntry; |
53 | |
54 | /** |
55 | * @var PostRevision|null |
56 | */ |
57 | protected $topicTitle; |
58 | |
59 | /** |
60 | * @var PostRevision|null |
61 | */ |
62 | protected $firstPost; |
63 | |
64 | /** |
65 | * @var array |
66 | * |
67 | * Associative array mapping topic ID (in alphadecimal form) to PostRevision for the topic root. |
68 | */ |
69 | protected $topicRootRevisionCache = []; |
70 | |
71 | /** |
72 | * The limit of Table of Contents topics that are rendered per request |
73 | */ |
74 | private const TOCLIMIT = 50; |
75 | |
76 | protected function validate() { |
77 | // for now, new topic is considered a new post; perhaps some day topic creation should get it's own permissions? |
78 | if ( |
79 | !$this->permissions->isRevisionAllowed( null, 'new-post' ) || |
80 | !$this->permissions->isBoardAllowed( $this->workflow, 'new-post' ) |
81 | ) { |
82 | $this->addError( 'permissions', $this->context->msg( 'flow-error-not-allowed' ) ); |
83 | return; |
84 | } |
85 | if ( !isset( $this->submitted['topic'] ) || !is_string( $this->submitted['topic'] ) ) { |
86 | unset( $this->submitted['topic'] ); |
87 | $this->addError( 'topic', $this->context->msg( 'flow-error-missing-title' ) ); |
88 | return; |
89 | } |
90 | $this->submitted['topic'] = trim( $this->submitted['topic'] ); |
91 | if ( strlen( $this->submitted['topic'] ) === 0 ) { |
92 | $this->addError( 'topic', $this->context->msg( 'flow-error-missing-title' ) ); |
93 | return; |
94 | } |
95 | if ( mb_strlen( $this->submitted['topic'] ) > PostRevision::MAX_TOPIC_LENGTH ) { |
96 | $this->addError( 'topic', $this->context->msg( 'flow-error-title-too-long', PostRevision::MAX_TOPIC_LENGTH ) ); |
97 | return; |
98 | } |
99 | |
100 | if ( !isset( $this->submitted['content'] ) || trim( $this->submitted['content'] ) === '' ) { |
101 | $this->addError( 'content', $this->context->msg( 'flow-error-missing-content' ) ); |
102 | return; |
103 | } |
104 | |
105 | // creates Workflow, Revision & TopicListEntry objects to be inserted into storage |
106 | [ $this->topicWorkflow, $this->topicListEntry, $this->topicTitle, $this->firstPost ] = $this->create(); |
107 | |
108 | if ( !$this->checkSpamFilters( null, $this->topicTitle ) ) { |
109 | return; |
110 | } |
111 | if ( $this->firstPost && !$this->checkSpamFilters( null, $this->firstPost ) ) { |
112 | return; |
113 | } |
114 | } |
115 | |
116 | /** |
117 | * Creates the objects about to be inserted into storage: |
118 | * * $this->topicWorkflow |
119 | * * $this->topicListEntry |
120 | * * $this->topicTitle |
121 | * * $this->firstPost |
122 | * |
123 | * @return array Array of [$topicWorkflow, $topicListEntry, $topicTitle, $firstPost] |
124 | */ |
125 | protected function create() { |
126 | $title = $this->workflow->getArticleTitle(); |
127 | $user = $this->context->getUser(); |
128 | $topicWorkflow = Workflow::create( 'topic', $title ); |
129 | $topicListEntry = TopicListEntry::create( $this->workflow, $topicWorkflow ); |
130 | $topicTitle = PostRevision::createTopicPost( |
131 | $topicWorkflow, |
132 | $user, |
133 | // @phan-suppress-next-line PhanTypeArraySuspiciousNullable |
134 | $this->submitted['topic'] |
135 | ); |
136 | |
137 | $firstPost = null; |
138 | if ( !empty( $this->submitted['content'] ) ) { |
139 | $firstPost = $topicTitle->reply( |
140 | $topicWorkflow, |
141 | $user, |
142 | // @phan-suppress-next-line PhanTypeArraySuspiciousNullable |
143 | $this->submitted['content'], |
144 | // default to wikitext when not specified, for old API requests |
145 | $this->submitted['format'] ?? 'wikitext' |
146 | ); |
147 | $topicTitle->setChildren( [ $firstPost ] ); |
148 | } |
149 | |
150 | return [ $topicWorkflow, $topicListEntry, $topicTitle, $firstPost ]; |
151 | } |
152 | |
153 | public function commit() { |
154 | if ( $this->action !== 'new-topic' ) { |
155 | throw new FailCommitException( 'Unknown commit action', 'fail-commit' ); |
156 | } |
157 | |
158 | $metadata = [ |
159 | 'workflow' => $this->topicWorkflow, |
160 | 'board-workflow' => $this->workflow, |
161 | 'topic-title' => $this->topicTitle, |
162 | 'first-post' => $this->firstPost, |
163 | ]; |
164 | |
165 | /* |
166 | * Order of storage is important! We've been changing when we stored |
167 | * workflow a couple of times. For now, it needs to be stored first: |
168 | * * TopicPageCreationListener.php (post listener) must first create the |
169 | * Topic:Xyz page before NotificationListener.php (topic/post |
170 | * listeners) creates notifications (& mails) that link to it |
171 | * * ReferenceExtractor.php (run from ReferenceRecorder.php, a post |
172 | * listener) needs to parse content with Parsoid & for that it needs |
173 | * the board title. AbstractRevision::getContent() will figure out |
174 | * the title from the workflow: $this->getCollection()->getTitle() |
175 | * If you even feel the need to change the order, make sure you come |
176 | * up with a fix for the above things ;) |
177 | */ |
178 | $this->storage->put( $this->workflow, [] ); // 'discussion' workflow |
179 | $this->storage->put( $this->topicWorkflow, $metadata ); // 'topic' workflow |
180 | $this->storage->put( $this->topicListEntry, $metadata ); |
181 | $this->storage->put( $this->topicTitle, $metadata ); |
182 | if ( $this->firstPost !== null ) { |
183 | $this->storage->put( $this->firstPost, $metadata + [ |
184 | 'reply-to' => $this->topicTitle |
185 | ] ); |
186 | } |
187 | |
188 | $output = [ |
189 | 'topic-page' => $this->topicWorkflow->getArticleTitle()->getPrefixedText(), |
190 | 'topic-id' => $this->topicTitle->getPostId(), |
191 | 'topic-revision-id' => $this->topicTitle->getRevisionId(), |
192 | 'post-id' => $this->firstPost ? $this->firstPost->getPostId() : null, |
193 | 'post-revision-id' => $this->firstPost ? $this->firstPost->getRevisionId() : null, |
194 | ]; |
195 | |
196 | return $output; |
197 | } |
198 | |
199 | public function renderTocApi( array $topicList, array $options ) { |
200 | global $wgFlowDefaultLimit; |
201 | |
202 | $tocApiParams = array_merge( |
203 | $options, |
204 | [ |
205 | 'toconly' => true, |
206 | 'limit' => self::TOCLIMIT |
207 | ] |
208 | ); |
209 | |
210 | $findOptions = $this->getFindOptions( $options ); |
211 | |
212 | // include the current sortby option. Note that when 'user' is either |
213 | // submitted or defaulted to this is the resulting sort. ex: newest |
214 | $tocApiParams['sortby'] = $findOptions['sortby']; |
215 | |
216 | // In the case of 'newest' sort, we could save ourselves trouble and only |
217 | // produce the necessary 40 topics that are missing from the ToC, by taking |
218 | // the latest UUID from the topic list. |
219 | // This is a bit harder for the case of 'updated' which requires a timestamp, |
220 | // so in that case, we can stick to having repeated topics and letting the |
221 | // data model sort through which ones it needs to update and which ones it |
222 | // may ignore. |
223 | if ( $tocApiParams['sortby'] === 'newest' ) { |
224 | // Make sure we found topiclist block |
225 | // and that it actually has roots in it |
226 | $existingRoots = isset( $topicList['roots'] ) && is_array( $topicList['roots'] ) ? |
227 | $topicList['roots'] : []; |
228 | |
229 | if ( count( $existingRoots ) > 0 ) { |
230 | // Add new offset-id and limit to the api parameters and change the limit |
231 | $tocApiParams['offset-id'] = end( $existingRoots ); |
232 | $tocApiParams['limit'] = self::TOCLIMIT - $wgFlowDefaultLimit; |
233 | } |
234 | } |
235 | |
236 | return $this->renderApi( $tocApiParams ); |
237 | } |
238 | |
239 | public function renderApi( array $options ) { |
240 | $options = $this->preloadTexts( $options ); |
241 | |
242 | $response = [ |
243 | 'submitted' => $this->wasSubmitted() ? $this->submitted : $options, |
244 | 'errors' => $this->errors, |
245 | ]; |
246 | |
247 | // Repeating the default until we use the API for everything (bug 72659) |
248 | // Also, if this is removed other APIs (i.e. ApiFlowNewTopic) may need |
249 | // to be adjusted if they trigger a rendering of this block. |
250 | $isTocOnly = $options['toconly'] ?? false; |
251 | |
252 | if ( $isTocOnly ) { |
253 | /** @var TocTopicListFormatter $serializer */ |
254 | $serializer = Container::get( 'formatter.topiclist.toc' ); |
255 | } else { |
256 | /** @var TopicListFormatter $serializer */ |
257 | $serializer = Container::get( 'formatter.topiclist' ); |
258 | $format = $options['format'] ?? 'fixed-html'; |
259 | $serializer->setContentFormat( $format ); |
260 | } |
261 | |
262 | // @todo remove the 'api' => true, its always api |
263 | $findOptions = $this->getFindOptions( $options + [ 'api' => true ] ); |
264 | |
265 | // include the current sortby option. Note that when 'user' is either |
266 | // submitted or defaulted to this is the resulting sort. ex: newest |
267 | $response['sortby'] = $findOptions['sortby']; |
268 | |
269 | if ( $this->workflow->isNew() ) { |
270 | return $response + $serializer->buildEmptyResult( $this->workflow ); |
271 | } |
272 | |
273 | $page = $this->getPage( $findOptions ); |
274 | $workflowIds = []; |
275 | /** @var TopicListEntry $topicListEntry */ |
276 | foreach ( $page->getResults() as $topicListEntry ) { |
277 | $workflowIds[] = $topicListEntry->getId(); |
278 | } |
279 | |
280 | $workflows = $this->storage->getMulti( 'Workflow', $workflowIds ); |
281 | |
282 | if ( $isTocOnly ) { |
283 | // We don't need any further data, so we skip the TopicListQuery. |
284 | |
285 | $topicRootRevisionsByWorkflowId = []; |
286 | $workflowsByWorkflowId = []; |
287 | |
288 | foreach ( $workflows as $workflow ) { |
289 | $alphaWorkflowId = $workflow->getId()->getAlphadecimal(); |
290 | $topicRootRevisionsByWorkflowId[$alphaWorkflowId] = $this->topicRootRevisionCache[$alphaWorkflowId]; |
291 | $workflowsByWorkflowId[$alphaWorkflowId] = $workflow; |
292 | } |
293 | |
294 | return $response + $serializer->formatApi( $this->workflow, $topicRootRevisionsByWorkflowId, $workflowsByWorkflowId, $page ); |
295 | } |
296 | |
297 | /** @var TopicListQuery $query */ |
298 | $query = Container::get( 'query.topiclist' ); |
299 | $found = $query->getResults( $page->getResults() ); |
300 | wfDebugLog( 'FlowDebug', 'Rendering topiclist for ids: ' . implode( ', ', array_map( static function ( UUID $id ) { |
301 | return $id->getAlphadecimal(); |
302 | }, $workflowIds ) ) ); |
303 | |
304 | return $response + $serializer->formatApi( $this->workflow, $workflows, $found, $page, $this->context ); |
305 | } |
306 | |
307 | /** |
308 | * Transforms preload params into proper options we can assign to template. |
309 | * |
310 | * @param array $options |
311 | * @return array |
312 | */ |
313 | protected function preloadTexts( $options ) { |
314 | if ( isset( $options['preload'] ) && !empty( $options['preload'] ) ) { |
315 | $title = Title::newFromText( $options['preload'] ); |
316 | $wikiPageFactory = MediaWikiServices::getInstance()->getWikiPageFactory(); |
317 | $page = $wikiPageFactory->newFromTitle( $title ); |
318 | if ( $page->isRedirect() ) { |
319 | $title = $page->getRedirectTarget(); |
320 | $page = $wikiPageFactory->newFromTitle( $title ); |
321 | } |
322 | |
323 | if ( $page->exists() ) { |
324 | $content = $page->getContent( RevisionRecord::RAW ); |
325 | $options['content'] = $content->serialize(); |
326 | $options['format'] = 'wikitext'; |
327 | } |
328 | } |
329 | |
330 | if ( isset( $options['preloadtitle'] ) ) { |
331 | $options['topic'] = $options['preloadtitle']; |
332 | } |
333 | |
334 | return $options; |
335 | } |
336 | |
337 | public function getName() { |
338 | return 'topiclist'; |
339 | } |
340 | |
341 | protected function getLimit( array $options ) { |
342 | global $wgFlowDefaultLimit, $wgFlowMaxLimit; |
343 | $limit = $wgFlowDefaultLimit; |
344 | if ( isset( $options['limit'] ) ) { |
345 | $requestedLimit = intval( $options['limit'] ); |
346 | $limit = min( $requestedLimit, $wgFlowMaxLimit ); |
347 | $limit = max( 0, $limit ); |
348 | } |
349 | |
350 | return $limit; |
351 | } |
352 | |
353 | protected function getFindOptions( array $requestOptions ) { |
354 | $findOptions = []; |
355 | $services = MediaWikiServices::getInstance(); |
356 | $userOptionsLookup = $services->getUserOptionsLookup(); |
357 | |
358 | // Compute offset/limit |
359 | $limit = $this->getLimit( $requestOptions ); |
360 | |
361 | // @todo Once we migrate View.php to use the API directly |
362 | // all defaults will be handled by API and not here. |
363 | $requestOptions += [ |
364 | 'include-offset' => false, |
365 | 'offset-id' => false, |
366 | 'offset-dir' => 'fwd', |
367 | 'offset' => false, |
368 | 'api' => true, |
369 | 'sortby' => 'user', |
370 | 'savesortby' => false, |
371 | ]; |
372 | |
373 | $user = $this->context->getUser(); |
374 | if ( strlen( $requestOptions['sortby'] ) === 0 ) { |
375 | $requestOptions['sortby'] = 'user'; |
376 | } |
377 | // the sortby option in $findOptions is not directly used for querying, |
378 | // but is needed by the pager to generate appropriate pagination links. |
379 | if ( $requestOptions['sortby'] === 'user' ) { |
380 | $requestOptions['sortby'] = $userOptionsLookup->getOption( $user, 'flow-topiclist-sortby' ); |
381 | } |
382 | switch ( $requestOptions['sortby'] ) { |
383 | case 'updated': |
384 | $findOptions = [ |
385 | // @phan-suppress-next-line PhanUselessBinaryAddRight |
386 | 'sortby' => 'updated', |
387 | 'sort' => 'workflow_last_update_timestamp', |
388 | 'order' => 'desc', |
389 | ] + $findOptions; |
390 | |
391 | if ( $requestOptions['offset-id'] ) { |
392 | throw new FlowException( 'The `updated` sort order does not allow the `offset-id` parameter. Please use `offset`.' ); |
393 | } |
394 | break; |
395 | |
396 | case 'newest': |
397 | default: |
398 | $findOptions = [ |
399 | // @phan-suppress-next-line PhanUselessBinaryAddRight |
400 | 'sortby' => 'newest', |
401 | 'sort' => 'topic_id', |
402 | 'order' => 'desc', |
403 | ] + $findOptions; |
404 | |
405 | if ( $requestOptions['offset'] ) { |
406 | throw new FlowException( 'The `newest` sort order does not allow the `offset` parameter. Please use `offset-id`.' ); |
407 | } |
408 | } |
409 | |
410 | if ( $requestOptions['offset-id'] ) { |
411 | $findOptions['pager-offset'] = UUID::create( $requestOptions['offset-id'] ); |
412 | } elseif ( $requestOptions['offset'] ) { |
413 | $findOptions['pager-offset'] = intval( $requestOptions['offset'] ); |
414 | } |
415 | |
416 | if ( $requestOptions['offset-dir'] ) { |
417 | $findOptions['pager-dir'] = $requestOptions['offset-dir']; |
418 | } |
419 | |
420 | if ( $requestOptions['include-offset'] ) { |
421 | $findOptions['pager-include-offset'] = $requestOptions['include-offset']; |
422 | } |
423 | |
424 | $findOptions['pager-limit'] = $limit; |
425 | |
426 | if ( |
427 | $requestOptions['savesortby'] |
428 | && $user->isRegistered() |
429 | && $userOptionsLookup->getOption( $user, 'flow-topiclist-sortby' ) != $findOptions['sortby'] |
430 | ) { |
431 | // Save the new sortby preference. |
432 | $job = new UserOptionsUpdateJob( [ |
433 | 'userId' => $user->getId(), |
434 | 'options' => [ 'flow-topiclist-sortby' => $findOptions['sortby'] ] |
435 | ] ); |
436 | $services->getJobQueueGroupFactory()->makeJobQueueGroup()->lazyPush( $job ); |
437 | } |
438 | |
439 | return $findOptions; |
440 | } |
441 | |
442 | /** |
443 | * Gets a set of workflow IDs |
444 | * This filters result to only include unmoderated and locked topics. |
445 | * |
446 | * Also populates topicRootRevisionCache with a mapping from topic ID to the |
447 | * PostRevision for the topic root. |
448 | * |
449 | * @param array $findOptions |
450 | * @return PagerPage |
451 | */ |
452 | protected function getPage( array $findOptions ) { |
453 | $pager = new Pager( |
454 | $this->storage->getStorage( 'TopicListEntry' ), |
455 | [ 'topic_list_id' => $this->workflow->getId() ], |
456 | $findOptions |
457 | ); |
458 | |
459 | $postStorage = $this->storage->getStorage( 'PostRevision' ); |
460 | |
461 | // Work around lack of $this in closures until we can use PHP 5.4+ features. |
462 | $topicRootRevisionCache =& $this->topicRootRevisionCache; |
463 | |
464 | return $pager->getPage( static function ( array $found ) use ( $postStorage, &$topicRootRevisionCache ) { |
465 | $queries = []; |
466 | /** @var TopicListEntry[] $found */ |
467 | foreach ( $found as $entry ) { |
468 | $queries[] = [ 'rev_type_id' => $entry->getId() ]; |
469 | } |
470 | $posts = $postStorage->findMulti( $queries, [ |
471 | 'sort' => 'rev_id', |
472 | 'order' => 'DESC', |
473 | 'limit' => 1, |
474 | ] ); |
475 | $allowed = []; |
476 | foreach ( $posts as $queryResult ) { |
477 | $post = reset( $queryResult ); |
478 | if ( !$post->isModerated() || $post->isLocked() ) { |
479 | $allowed[$post->getPostId()->getAlphadecimal()] = $post; |
480 | } |
481 | } |
482 | foreach ( $found as $idx => $entry ) { |
483 | if ( isset( $allowed[$entry->getId()->getAlphadecimal()] ) ) { |
484 | $topicRootRevisionCache[$entry->getId()->getAlphadecimal()] = $allowed[$entry->getId()->getAlphadecimal()]; |
485 | } else { |
486 | unset( $found[$idx] ); |
487 | } |
488 | } |
489 | |
490 | return $found; |
491 | } ); |
492 | } |
493 | |
494 | /** |
495 | * @param OutputPage $out |
496 | */ |
497 | public function setPageTitle( OutputPage $out ) { |
498 | if ( $this->action !== 'new-topic' ) { |
499 | // Only new-topic should override page title, rest should default |
500 | parent::setPageTitle( $out ); |
501 | return; |
502 | } |
503 | |
504 | $title = $this->workflow->getOwnerTitle(); |
505 | $message = $out->msg( 'flow-newtopic-first-heading', $title->getPrefixedText() ); |
506 | $out->setPageTitle( $message ); |
507 | $out->setHTMLTitle( $message ); |
508 | $out->setSubtitle( '< ' . MediaWikiServices::getInstance()->getLinkRenderer()->makeLink( $title ) ); |
509 | } |
510 | } |