Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
0.00% |
0 / 123 |
|
0.00% |
0 / 13 |
CRAP | |
0.00% |
0 / 1 |
TopicSubscriptionsPager | |
0.00% |
0 / 123 |
|
0.00% |
0 / 13 |
1892 | |
0.00% |
0 / 1 |
__construct | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
2 | |||
preprocessResults | |
0.00% |
0 / 11 |
|
0.00% |
0 / 1 |
20 | |||
getFieldNames | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
2 | |||
formatValue | |
0.00% |
0 / 31 |
|
0.00% |
0 / 1 |
90 | |||
maybeFormatAsList | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
12 | |||
formatValuePage | |
0.00% |
0 / 15 |
|
0.00% |
0 / 1 |
42 | |||
formatValueTopic | |
0.00% |
0 / 24 |
|
0.00% |
0 / 1 |
132 | |||
getCellAttrs | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
6 | |||
getQueryInfo | |
0.00% |
0 / 18 |
|
0.00% |
0 / 1 |
2 | |||
getDefaultSort | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getDefaultDirections | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getIndexField | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
isFieldSortable | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
6 |
1 | <?php |
2 | |
3 | namespace MediaWiki\Extension\DiscussionTools; |
4 | |
5 | use InvalidArgumentException; |
6 | use MediaWiki\Cache\LinkBatchFactory; |
7 | use MediaWiki\Context\IContextSource; |
8 | use MediaWiki\Extension\DiscussionTools\ThreadItem\DatabaseThreadItem; |
9 | use MediaWiki\Html\Html; |
10 | use MediaWiki\Linker\Linker; |
11 | use MediaWiki\Linker\LinkRenderer; |
12 | use MediaWiki\Pager\TablePager; |
13 | use MediaWiki\Title\Title; |
14 | use OOUI; |
15 | |
16 | class 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 | } |