Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 123
0.00% covered (danger)
0.00%
0 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
TopicSubscriptionsPager
0.00% covered (danger)
0.00%
0 / 123
0.00% covered (danger)
0.00%
0 / 13
1892
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 preprocessResults
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
20
 getFieldNames
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 formatValue
0.00% covered (danger)
0.00%
0 / 31
0.00% covered (danger)
0.00%
0 / 1
90
 maybeFormatAsList
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 formatValuePage
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
42
 formatValueTopic
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
132
 getCellAttrs
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 getQueryInfo
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
2
 getDefaultSort
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getDefaultDirections
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getIndexField
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isFieldSortable
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2
3namespace MediaWiki\Extension\DiscussionTools;
4
5use InvalidArgumentException;
6use MediaWiki\Cache\LinkBatchFactory;
7use MediaWiki\Context\IContextSource;
8use MediaWiki\Extension\DiscussionTools\ThreadItem\DatabaseThreadItem;
9use MediaWiki\Html\Html;
10use MediaWiki\Linker\Linker;
11use MediaWiki\Linker\LinkRenderer;
12use MediaWiki\Pager\TablePager;
13use MediaWiki\Title\Title;
14use OOUI;
15
16class TopicSubscriptionsPager extends TablePager {
17
18    /**
19     * Map of our field names (see ::getFieldNames()) to the column names actually used for
20     * pagination. This is needed to ensure that the values are unique, and that pagination
21     * won't get "stuck" when e.g. 50 subscriptions are all created within a second.
22     */
23    private const INDEX_FIELDS = [
24        // The auto-increment ID will almost always have the same order as sub_created
25        // and the field already has an index.
26        '_topic' => [ 'sub_id' ],
27        'sub_created' => [ 'sub_id' ],
28        // TODO Add indexes that cover these fields to enable sorting by them
29        // 'sub_state' => [ 'sub_state', 'sub_item' ],
30        // 'sub_created' => [ 'sub_created', 'sub_item' ],
31        // 'sub_notified' => [ 'sub_notified', 'sub_item' ],
32    ];
33
34    private LinkBatchFactory $linkBatchFactory;
35    private ThreadItemStore $threadItemStore;
36    private ThreadItemFormatter $threadItemFormatter;
37
38    /** @var array<string,DatabaseThreadItem[]> */
39    private array $threadItemsByName = [];
40
41    public function __construct(
42        IContextSource $context,
43        LinkRenderer $linkRenderer,
44        LinkBatchFactory $linkBatchFactory,
45        ThreadItemStore $threadItemStore,
46        ThreadItemFormatter $threadItemFormatter
47    ) {
48        parent::__construct( $context, $linkRenderer );
49        $this->linkBatchFactory = $linkBatchFactory;
50        $this->threadItemStore = $threadItemStore;
51        $this->threadItemFormatter = $threadItemFormatter;
52    }
53
54    /**
55     * @inheritDoc
56     */
57    public function preprocessResults( $result ) {
58        if ( !$result->numRows() ) {
59            return;
60        }
61        $lb = $this->linkBatchFactory->newLinkBatch();
62        $itemNames = [];
63        foreach ( $result as $row ) {
64            $lb->add( $row->sub_namespace, $row->sub_title );
65            $itemNames[] = $row->sub_item;
66        }
67        $lb->execute();
68
69        // Increased limit to allow finding and skipping over some bad permalinks
70        $threadItems = $this->threadItemStore->findNewestRevisionsByName( $itemNames, $this->mLimit * 5 );
71        foreach ( $threadItems as $threadItem ) {
72            $this->threadItemsByName[ $threadItem->getName() ][] = $threadItem;
73        }
74    }
75
76    /**
77     * @inheritDoc
78     */
79    protected function getFieldNames() {
80        return [
81            '_topic' => $this->msg( 'discussiontools-topicsubscription-pager-topic' )->text(),
82            '_page' => $this->msg( 'discussiontools-topicsubscription-pager-page' )->text(),
83            'sub_created' => $this->msg( 'discussiontools-topicsubscription-pager-created' )->text(),
84            'sub_notified' => $this->msg( 'discussiontools-topicsubscription-pager-notified' )->text(),
85            '_unsubscribe' => $this->msg( 'discussiontools-topicsubscription-pager-actions' )->text(),
86        ];
87    }
88
89    /**
90     * @inheritDoc
91     */
92    public function formatValue( $field, $value ) {
93        /** @var stdClass $row */
94        $row = $this->mCurrentRow;
95
96        switch ( $field ) {
97            case '_topic':
98                return $this->formatValueTopic( $row );
99
100            case '_page':
101                return $this->formatValuePage( $row );
102
103            case 'sub_created':
104                return htmlspecialchars( $this->getLanguage()->userTimeAndDate( $value, $this->getUser() ) );
105
106            case 'sub_notified':
107                return $value ?
108                    htmlspecialchars( $this->getLanguage()->userTimeAndDate( $value, $this->getUser() ) ) :
109                    $this->msg( 'discussiontools-topicsubscription-pager-notified-never' )->escaped();
110
111            case '_unsubscribe':
112                $title = Title::makeTitleSafe( $row->sub_namespace, $row->sub_title );
113                if ( !$title ) {
114                    // Handle invalid titles (T345648)
115                    // The title isn't checked when unsubscribing, as long as it's a valid title,
116                    // so specify something to make it possible to unsubscribe from the buggy entries.
117                    $title = Title::newMainPage();
118                }
119                return (string)new OOUI\ButtonWidget( [
120                    'label' => $this->msg( 'discussiontools-topicsubscription-pager-unsubscribe-button' )->text(),
121                    'classes' => [ 'ext-discussiontools-special-unsubscribe-button' ],
122                    'framed' => false,
123                    'flags' => [ 'destructive' ],
124                    'data' => [
125                        'item' => $row->sub_item,
126                        'title' => $title->getPrefixedText(),
127                    ],
128                    'href' => $title->getLinkURL( [
129                        'action' => 'dtunsubscribe',
130                        'commentname' => $row->sub_item,
131                    ] ),
132                    'infusable' => true,
133                ] );
134
135            default:
136                throw new InvalidArgumentException( "Unknown field '$field'" );
137        }
138    }
139
140    /**
141     * Format items as a HTML list, unless there's just one item, in which case return it unwrapped.
142     * @param string[] $list HTML
143     * @return string HTML
144     */
145    private function maybeFormatAsList( array $list ): string {
146        if ( count( $list ) === 1 ) {
147            return $list[0];
148        } else {
149            foreach ( $list as &$item ) {
150                $item = Html::rawElement( 'li', [], $item );
151            }
152            return Html::rawElement( 'ul', [], implode( '', $list ) );
153        }
154    }
155
156    private function formatValuePage( \stdClass $row ): string {
157        $linkRenderer = $this->getLinkRenderer();
158
159        if ( isset( $this->threadItemsByName[ $row->sub_item ] ) ) {
160            $items = [];
161            foreach ( $this->threadItemsByName[ $row->sub_item ] as $threadItem ) {
162                if ( $threadItem->isCanonicalPermalink() ) {
163                    $items[] = $this->threadItemFormatter->formatLine( $threadItem, $this );
164                }
165            }
166            if ( $items ) {
167                return $this->maybeFormatAsList( $items );
168            }
169
170            // Found items in the permalink database, but they're not good permalinks.
171            // TODO: We could link to the full list on Special:FindComment here
172            // (but we don't link it from the mw.notify message either, at the moment).
173        }
174
175        // Permalink not available - display a plain link to the page title at the time of subscription
176        $title = Title::makeTitleSafe( $row->sub_namespace, $row->sub_title );
177        if ( !$title ) {
178            // Handle invalid titles (T345648)
179            return Html::element( 'span', [ 'class' => 'mw-invalidtitle' ],
180                Linker::getInvalidTitleDescription(
181                    $this->getContext(), $row->sub_namespace, $row->sub_title )
182                );
183        }
184        return $linkRenderer->makeLink( $title );
185    }
186
187    private function formatValueTopic( \stdClass $row ): string {
188        $linkRenderer = $this->getLinkRenderer();
189
190        $sectionText = $row->sub_section;
191        $sectionLink = $row->sub_section;
192        // Detect truncated section titles: either intentionally truncated by SubscriptionStore,
193        // or incorrect multibyte truncation of old entries (T345648).
194        $last = mb_substr( $sectionText, -1 );
195        if ( $last !== '' && ( $last === "\x1f" || mb_ord( $last ) === false ) ) {
196            $sectionText = substr( $sectionText, 0, -strlen( $last ) ) . $this->msg( 'ellipsis' )->text();
197            $sectionLink = null;
198        }
199
200        if ( str_starts_with( $row->sub_item, 'p-topics-' ) ) {
201            return '<em>' .
202                $this->msg( 'discussiontools-topicsubscription-pager-newtopics-label' )->escaped() .
203            '</em>';
204        }
205
206        if ( isset( $this->threadItemsByName[ $row->sub_item ] ) ) {
207            $items = [];
208            foreach ( $this->threadItemsByName[ $row->sub_item ] as $threadItem ) {
209                if ( $threadItem->isCanonicalPermalink() ) {
210                    // TODO: Can we extract the current topic title out of $threadItem->getId() sometimes,
211                    // instead of always using the topic title at the time of subscription? (T295431)
212                    $items[] = $this->threadItemFormatter->makeLink( $threadItem, $sectionText );
213                }
214            }
215            if ( $items ) {
216                return $this->maybeFormatAsList( $items );
217            }
218
219            // Found items in the permalink database, but they're not good permalinks.
220            // TODO: We could link to the full list on Special:FindComment here
221            // (but we don't link it from the mw.notify message either, at the moment).
222        }
223
224        // Permalink not available - display a plain link to the section at the time of subscription
225        if ( !$sectionLink ) {
226            // We can't link to the section correctly, since the only link we have is truncated
227            return htmlspecialchars( $sectionText );
228        }
229        $titleSection = Title::makeTitleSafe( $row->sub_namespace, $row->sub_title, $sectionLink );
230        if ( !$titleSection ) {
231            // Handle invalid titles of any other kind, just in case
232            return htmlspecialchars( $sectionText );
233        }
234        return $linkRenderer->makeLink( $titleSection, $sectionText );
235    }
236
237    /**
238     * @inheritDoc
239     */
240    protected function getCellAttrs( $field, $value ) {
241        $attrs = parent::getCellAttrs( $field, $value );
242        if ( $field === '_unsubscribe' ) {
243            $attrs['style'] = 'text-align: center;';
244        }
245        return $attrs;
246    }
247
248    /**
249     * @inheritDoc
250     */
251    public function getQueryInfo() {
252        return [
253            'tables' => [
254                'discussiontools_subscription',
255            ],
256            'fields' => [
257                'sub_id',
258                'sub_item',
259                'sub_namespace',
260                'sub_title',
261                'sub_section',
262                'sub_created',
263                'sub_notified',
264            ],
265            'conds' => [
266                'sub_user' => $this->getUser()->getId(),
267                $this->getDatabase()->expr( 'sub_state', '!=', SubscriptionStore::STATE_UNSUBSCRIBED ),
268            ],
269        ];
270    }
271
272    /**
273     * @inheritDoc
274     */
275    public function getDefaultSort() {
276        return 'sub_created';
277    }
278
279    /**
280     * @inheritDoc
281     */
282    public function getDefaultDirections() {
283        return static::DIR_DESCENDING;
284    }
285
286    /**
287     * @inheritDoc
288     */
289    public function getIndexField() {
290        return [ static::INDEX_FIELDS[$this->mSort] ];
291    }
292
293    /**
294     * @inheritDoc
295     */
296    protected function isFieldSortable( $field ) {
297        // Hide the sort button for "Topic" as it is more accurately shown as "Created"
298        return isset( static::INDEX_FIELDS[$field] ) && $field !== '_topic';
299    }
300}