Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
38.50% covered (danger)
38.50%
87 / 226
0.00% covered (danger)
0.00%
0 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
TopicListBlock
38.50% covered (danger)
38.50%
87 / 226
0.00% covered (danger)
0.00%
0 / 11
1016.97
0.00% covered (danger)
0.00%
0 / 1
 validate
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
156
 create
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
6
 commit
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
30
 renderTocApi
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
30
 renderApi
67.65% covered (warning)
67.65%
23 / 34
0.00% covered (danger)
0.00%
0 / 1
8.66
 preloadTexts
21.43% covered (danger)
21.43%
3 / 14
0.00% covered (danger)
0.00%
0 / 1
23.46
 getName
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getLimit
50.00% covered (danger)
50.00%
3 / 6
0.00% covered (danger)
0.00%
0 / 1
2.50
 getFindOptions
90.74% covered (success)
90.74%
49 / 54
0.00% covered (danger)
0.00%
0 / 1
15.18
 getPage
33.33% covered (danger)
33.33%
9 / 27
0.00% covered (danger)
0.00%
0 / 1
21.52
 setPageTitle
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2
3namespace Flow\Block;
4
5use Flow\Container;
6use Flow\Data\Pager\Pager;
7use Flow\Data\Pager\PagerPage;
8use Flow\Exception\FailCommitException;
9use Flow\Exception\FlowException;
10use Flow\Formatter\TocTopicListFormatter;
11use Flow\Formatter\TopicListFormatter;
12use Flow\Formatter\TopicListQuery;
13use Flow\Model\PostRevision;
14use Flow\Model\TopicListEntry;
15use Flow\Model\UUID;
16use Flow\Model\Workflow;
17use MediaWiki\MediaWikiServices;
18use MediaWiki\Output\OutputPage;
19use MediaWiki\Revision\RevisionRecord;
20use MediaWiki\Title\Title;
21use UserOptionsUpdateJob;
22
23class 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( '&lt; ' . MediaWikiServices::getInstance()->getLinkRenderer()->makeLink( $title ) );
509    }
510}