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