Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
0.00% |
0 / 433 |
|
0.00% |
0 / 32 |
CRAP | |
0.00% |
0 / 1 |
Impact | |
0.00% |
0 / 433 |
|
0.00% |
0 / 32 |
4970 | |
0.00% |
0 / 1 |
__construct | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
2 | |||
canRender | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getModuleStyles | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
2 | |||
getHeaderText | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
2 | |||
getBody | |
0.00% |
0 / 15 |
|
0.00% |
0 / 1 |
20 | |||
getMobileSummaryBody | |
0.00% |
0 / 53 |
|
0.00% |
0 / 1 |
12 | |||
generateEditsTable | |
0.00% |
0 / 84 |
|
0.00% |
0 / 1 |
20 | |||
getTotalViewsElement | |
0.00% |
0 / 14 |
|
0.00% |
0 / 1 |
20 | |||
getSubheaderText | |
0.00% |
0 / 11 |
|
0.00% |
0 / 1 |
6 | |||
getSubheaderSubtext | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
6 | |||
getUnactivatedModuleCssClass | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getUnactivatedModuleSubheader | |
0.00% |
0 / 20 |
|
0.00% |
0 / 1 |
2 | |||
getUnactivatedModuleSuggestedEditsButton | |
0.00% |
0 / 22 |
|
0.00% |
0 / 1 |
12 | |||
getUnactivatedModuleBody | |
0.00% |
0 / 12 |
|
0.00% |
0 / 1 |
6 | |||
getSubheader | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
6 | |||
getSubheaderTag | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getFooter | |
0.00% |
0 / 21 |
|
0.00% |
0 / 1 |
12 | |||
getCssClasses | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
12 | |||
getState | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
12 | |||
isActivated | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
isUnactivatedWithSuggestedEdits | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
6 | |||
getArticleContributions | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
12 | |||
getTotalPageViews | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
6 | |||
queryArticleEdits | |
0.00% |
0 / 38 |
|
0.00% |
0 / 1 |
6 | |||
getArticleEditCount | |
0.00% |
0 / 15 |
|
0.00% |
0 / 1 |
2 | |||
getArticleOrTotalEditCountText | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
6 | |||
addPageViews | |
0.00% |
0 / 26 |
|
0.00% |
0 / 1 |
42 | |||
getImage | |
0.00% |
0 / 14 |
|
0.00% |
0 / 1 |
30 | |||
daysSince | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
2 | |||
getPageViewToolsUrl | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
2 | |||
getHeaderIconName | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getEditsTable | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 |
1 | <?php |
2 | |
3 | namespace GrowthExperiments\HomepageModules; |
4 | |
5 | use DateTime; |
6 | use Exception; |
7 | use ExtensionRegistry; |
8 | use GrowthExperiments\ExperimentUserManager; |
9 | use IContextSource; |
10 | use MediaWiki\Config\Config; |
11 | use MediaWiki\Extension\PageViewInfo\PageViewService; |
12 | use MediaWiki\Html\Html; |
13 | use MediaWiki\MediaWikiServices; |
14 | use MediaWiki\SpecialPage\SpecialPage; |
15 | use MediaWiki\Title\Title; |
16 | use MediaWiki\Title\TitleFactory; |
17 | use MediaWiki\User\ActorMigration; |
18 | use MediaWiki\Utils\MWTimestamp; |
19 | use OOUI\ButtonWidget; |
20 | use OOUI\IconWidget; |
21 | use PageImages\PageImages; |
22 | use Wikimedia\Rdbms\IConnectionProvider; |
23 | use Wikimedia\Rdbms\SelectQueryBuilder; |
24 | |
25 | /** |
26 | * This is the "Impact" module. It shows the page views information |
27 | * of recently edited pages. |
28 | * |
29 | * All timestamps in this file are in UTC. That's also what |
30 | * the pageviews tool expects. |
31 | * |
32 | * @package GrowthExperiments\HomepageModules |
33 | */ |
34 | class Impact extends BaseModule { |
35 | |
36 | private const THUMBNAIL_SIZE = 40; |
37 | |
38 | /** |
39 | * @var array |
40 | */ |
41 | private $contribs = null; |
42 | |
43 | /** |
44 | * @var string |
45 | */ |
46 | private $body = null; |
47 | |
48 | /** |
49 | * @var bool |
50 | */ |
51 | private $pageViewsDataExists = false; |
52 | |
53 | /** |
54 | * @var string|null |
55 | */ |
56 | private $editsTable = null; |
57 | |
58 | /** @var IConnectionProvider */ |
59 | private $connectionProvider; |
60 | |
61 | /** |
62 | * @var bool |
63 | */ |
64 | private $isSuggestedEditsEnabledForUser; |
65 | |
66 | /** |
67 | * @var bool |
68 | */ |
69 | private $isSuggestedEditsActivatedForUser; |
70 | |
71 | /** |
72 | * @var TitleFactory |
73 | */ |
74 | private $titleFactory; |
75 | |
76 | /** |
77 | * @var PageViewService|null |
78 | */ |
79 | private $pageViewService; |
80 | |
81 | /** |
82 | * @param IContextSource $context |
83 | * @param Config $wikiConfig |
84 | * @param IConnectionProvider $connectionProvider |
85 | * @param ExperimentUserManager $experimentUserManager |
86 | * @param array $suggestedEditsConfig |
87 | * @param TitleFactory $titleFactory |
88 | * @param PageViewService|null $pageViewService |
89 | */ |
90 | public function __construct( |
91 | IContextSource $context, |
92 | Config $wikiConfig, |
93 | IConnectionProvider $connectionProvider, |
94 | ExperimentUserManager $experimentUserManager, |
95 | array $suggestedEditsConfig, |
96 | TitleFactory $titleFactory, |
97 | PageViewService $pageViewService = null |
98 | ) { |
99 | parent::__construct( 'impact', $context, $wikiConfig, $experimentUserManager ); |
100 | $this->connectionProvider = $connectionProvider; |
101 | $this->isSuggestedEditsEnabledForUser = $suggestedEditsConfig['isSuggestedEditsEnabled']; |
102 | $this->isSuggestedEditsActivatedForUser = $suggestedEditsConfig['isSuggestedEditsActivated']; |
103 | $this->titleFactory = $titleFactory; |
104 | $this->pageViewService = $pageViewService; |
105 | } |
106 | |
107 | /** @inheritDoc */ |
108 | public function canRender() { |
109 | return $this->pageViewService !== null; |
110 | } |
111 | |
112 | /** |
113 | * @inheritDoc |
114 | */ |
115 | protected function getModuleStyles() { |
116 | return array_merge( |
117 | parent::getModuleStyles(), |
118 | [ 'oojs-ui.styles.icons-media', 'oojs-ui.styles.icons-interactions' ] |
119 | ); |
120 | } |
121 | |
122 | /** |
123 | * @inheritDoc |
124 | */ |
125 | protected function getHeaderText() { |
126 | return $this->getContext() |
127 | ->msg( 'growthexperiments-homepage-impact-header' ) |
128 | ->params( $this->getContext()->getUser()->getName() ) |
129 | ->text(); |
130 | } |
131 | |
132 | /** |
133 | * @inheritDoc |
134 | */ |
135 | protected function getBody() { |
136 | if ( $this->body !== null ) { |
137 | return $this->body; |
138 | } |
139 | if ( $this->isActivated() ) { |
140 | $this->body = $this->getEditsTable(); |
141 | } elseif ( $this->isUnactivatedWithSuggestedEdits() ) { |
142 | $this->body = $this->getUnactivatedModuleBody(); |
143 | |
144 | } else { |
145 | $this->body = Html::rawElement( |
146 | 'div', |
147 | [], |
148 | $this->getContext() |
149 | ->msg( 'growthexperiments-homepage-impact-body-no-edit' ) |
150 | ->params( $this->getContext()->getUser()->getName() ) |
151 | ->parse() |
152 | ); |
153 | } |
154 | return $this->body; |
155 | } |
156 | |
157 | /** |
158 | * @inheritDoc |
159 | */ |
160 | protected function getMobileSummaryBody() { |
161 | if ( $this->isActivated() ) { |
162 | $purposeElement = ''; |
163 | $articleEditsElement = Html::rawElement( |
164 | 'div', |
165 | [ 'class' => 'growthexperiments-homepage-impact-subheader-text' ], |
166 | $this->getArticleOrTotalEditCountText() |
167 | ); |
168 | } elseif ( $this->isUnactivatedWithSuggestedEdits() ) { |
169 | return $this->getUnactivatedModuleBody() . Html::element( |
170 | 'div', |
171 | [ 'class' => $this->getUnactivatedModuleCssClass() . '-description' ], |
172 | $this->getContext() |
173 | ->msg( 'growthexperiments-homepage-impact-unactivated-description' ) |
174 | ->params( $this->getContext()->getUser()->getName() ) |
175 | ->text() |
176 | ); |
177 | } else { |
178 | $purposeElement = Html::element( |
179 | 'div', |
180 | [ 'class' => 'growthexperiments-homepage-module-text-light' ], |
181 | $this->getContext() |
182 | ->msg( 'growthexperiments-homepage-impact-mobilesummarybody-monitor' ) |
183 | ->text() |
184 | ); |
185 | $articleEditsElement = Html::element( |
186 | 'div', |
187 | [ 'class' => [ |
188 | 'growthexperiments-homepage-module-text-normal', |
189 | 'growthexperiments-homepage-impact-subheader-text', |
190 | ] ], |
191 | $this->getContext() |
192 | ->msg( 'growthexperiments-homepage-impact-subheader-text-no-edit' ) |
193 | ->text() |
194 | ); |
195 | } |
196 | |
197 | $totalViewsElement = $this->getTotalViewsElement( $this->isActivated() ); |
198 | $pageViewsElement = Html::element( |
199 | 'div', |
200 | [ 'class' => [ |
201 | 'growthexperiments-homepage-impact-subheader-subtext', |
202 | 'growthexperiments-homepage-module-text-light' |
203 | ] ], |
204 | $this->getContext()->msg( 'growthexperiments-homepage-impact-mobilebody-pageviews' ) |
205 | ->numParams( $this->getTotalPageViews() ) |
206 | ->text() |
207 | ); |
208 | |
209 | return Html::rawElement( |
210 | 'div', |
211 | [ 'class' => 'growthexperiments-homepage-impact-column-text' ], |
212 | $purposeElement . $articleEditsElement . $pageViewsElement |
213 | ) . Html::rawElement( |
214 | 'div', |
215 | [ 'class' => 'growthexperiments-homepage-impact-column-pageviews' ], |
216 | $totalViewsElement |
217 | ); |
218 | } |
219 | |
220 | /** |
221 | * Generate the HTML for the edits table. |
222 | */ |
223 | private function generateEditsTable() { |
224 | $articleLinkTooltip = $this->getContext() |
225 | ->msg( 'growthexperiments-homepage-impact-article-link-tooltip' ) |
226 | ->text(); |
227 | $pageviewsTooltip = $this->getContext() |
228 | ->msg( 'growthexperiments-homepage-impact-pageviews-link-tooltip' ) |
229 | ->text(); |
230 | $emptyImage = new IconWidget( [ |
231 | 'icon' => 'image', |
232 | 'classes' => [ 'placeholder-image' ], |
233 | ] ); |
234 | $emptyViewsWidget = new ButtonWidget( [ |
235 | 'classes' => [ 'empty-pageviews' ], |
236 | 'framed' => false, |
237 | 'icon' => 'clock', |
238 | 'title' => $this->getContext() |
239 | ->msg( 'growthexperiments-homepage-impact-empty-pageviews-tooltip' ) |
240 | ->text(), |
241 | 'infusable' => true, |
242 | 'flags' => [ 'progressive' ], |
243 | ] ); |
244 | $this->editsTable = implode( "\n", array_map( |
245 | function ( $contrib ) use ( |
246 | $articleLinkTooltip, $pageviewsTooltip, $emptyImage, $emptyViewsWidget |
247 | ) { |
248 | $titleText = $contrib['title']->getText(); |
249 | $titlePrefixedText = $contrib['title']->getPrefixedText(); |
250 | $titleUrl = $contrib['title']->getLinkUrl(); |
251 | |
252 | $imageUrl = $this->getImage( $contrib['title'] ); |
253 | $image = $imageUrl ? |
254 | Html::element( |
255 | 'div', |
256 | [ |
257 | 'alt' => $titleText, |
258 | 'title' => $titlePrefixedText, |
259 | 'class' => 'real-image mw-no-invert', |
260 | 'style' => 'background-image: url(' . $imageUrl . ');', |
261 | ] |
262 | ) : $emptyImage; |
263 | $imageElement = Html::rawElement( |
264 | 'a', |
265 | [ |
266 | 'class' => 'article-image', |
267 | 'href' => $titleUrl, |
268 | 'title' => $articleLinkTooltip, |
269 | 'data-link-id' => 'impact-article-image', |
270 | ], |
271 | $image |
272 | ); |
273 | |
274 | $titleElement = Html::rawElement( |
275 | 'span', |
276 | [ 'class' => 'article-title' ], |
277 | Html::element( |
278 | 'a', |
279 | [ |
280 | 'href' => $titleUrl, |
281 | 'title' => $articleLinkTooltip, |
282 | 'data-link-id' => 'impact-article-title', |
283 | ], |
284 | $titlePrefixedText |
285 | ) |
286 | ); |
287 | |
288 | // Set this flag to check if page views data exists for at least |
289 | // one article. This is used to determine if the mobile summary |
290 | // should show the clock icon if all article edits have no page view |
291 | // data yet. Once the flag is set to true, don't set it again. |
292 | if ( !$this->pageViewsDataExists ) { |
293 | // 'views' is null if no data exists. |
294 | $this->pageViewsDataExists = isset( $contrib['views'] ); |
295 | } |
296 | $viewsElement = isset( $contrib['views'] ) ? |
297 | Html::element( |
298 | 'a', |
299 | [ |
300 | 'class' => 'pageviews', |
301 | 'href' => $this->getPageViewToolsUrl( |
302 | $contrib['title'], $contrib['ts'] |
303 | ), |
304 | 'title' => $pageviewsTooltip, |
305 | 'data-link-id' => 'impact-pageviews', |
306 | ], |
307 | $this->getContext()->getLanguage()->formatNum( $contrib['views'] ) |
308 | ) : $emptyViewsWidget; |
309 | |
310 | return Html::rawElement( |
311 | 'div', |
312 | [ 'class' => 'impact-row' ], |
313 | $imageElement . $titleElement . $viewsElement |
314 | ); |
315 | }, |
316 | array_slice( $this->getArticleContributions(), 0, 5 ) |
317 | ) ); |
318 | } |
319 | |
320 | private function getTotalViewsElement( $showPendingIcon = false ) { |
321 | $views = $this->getTotalPageViews(); |
322 | if ( $views === 0 && !$this->pageViewsDataExists && $showPendingIcon ) { |
323 | $views = new IconWidget( [ |
324 | 'icon' => 'clock', |
325 | 'flags' => [ 'progressive' ], |
326 | 'framed' => false, |
327 | 'classes' => [ 'empty-pageviews-summary' ] |
328 | ] ); |
329 | } else { |
330 | $views = htmlspecialchars( $this->getContext()->getLanguage()->formatNum( $views ) ); |
331 | } |
332 | return Html::rawElement( |
333 | 'span', |
334 | [ 'class' => 'growthexperiments-homepage-impact-mobile-totalviews' ], |
335 | $views |
336 | ); |
337 | } |
338 | |
339 | /** @inheritDoc */ |
340 | protected function getSubheaderText() { |
341 | $textMsgKey = $this->getTotalPageViews() ? |
342 | 'growthexperiments-homepage-impact-subheader-text' : |
343 | 'growthexperiments-homepage-impact-subheader-text-no-pageviews'; |
344 | return Html::element( |
345 | 'p', |
346 | [ 'class' => 'growthexperiments-homepage-module-text-normal' ], |
347 | $this->getContext() |
348 | ->msg( $textMsgKey ) |
349 | ->params( $this->getContext()->getUser()->getName() ) |
350 | ->text() |
351 | ); |
352 | } |
353 | |
354 | private function getSubheaderSubtext() { |
355 | if ( $this->isActivated() ) { |
356 | return Html::element( |
357 | 'p', |
358 | [ 'class' => 'growthexperiments-homepage-module-text-light' ], |
359 | $this->getContext() |
360 | ->msg( 'growthexperiments-homepage-impact-subheader-subtext' ) |
361 | ->params( $this->getContext()->getUser()->getName() ) |
362 | ->text() |
363 | ); |
364 | } |
365 | return ''; |
366 | } |
367 | |
368 | /** |
369 | * @return string |
370 | */ |
371 | private function getUnactivatedModuleCssClass() { |
372 | // The following classes are used here: |
373 | // * growthexperiments-homepage-module-impact-unactivated-desktop |
374 | // * growthexperiments-homepage-module-impact-unactivated-mobile-details |
375 | // * growthexperiments-homepage-module-impact-unactivated-mobile-overlay |
376 | // * growthexperiments-homepage-module-impact-unactivated-mobile-summary |
377 | return 'growthexperiments-homepage-module-impact-unactivated-' . $this->getMode(); |
378 | } |
379 | |
380 | /** |
381 | * @return string |
382 | */ |
383 | private function getUnactivatedModuleSubheader() { |
384 | $subheader = Html::element( |
385 | 'h3', |
386 | [ 'class' => $this->getUnactivatedModuleCssClass() . '-subheader' ], |
387 | $this->getContext() |
388 | ->msg( 'growthexperiments-homepage-impact-unactivated-subheader-text' ) |
389 | ->text() |
390 | ); |
391 | $subheaderSubtext = Html::element( |
392 | 'h4', |
393 | [ 'class' => $this->getUnactivatedModuleCssClass() . '-subheader-subtext' ], |
394 | $this->getContext() |
395 | ->msg( 'growthexperiments-homepage-impact-unactivated-subheader-subtext' ) |
396 | ->params( $this->getContext()->getUser()->getName() ) |
397 | ->text() |
398 | ); |
399 | return Html::rawElement( |
400 | 'div', |
401 | [ 'class' => $this->getUnactivatedModuleCssClass() . '-subheader-container' ], |
402 | $subheader . $subheaderSubtext |
403 | ); |
404 | } |
405 | |
406 | /** |
407 | * @return string |
408 | */ |
409 | private function getUnactivatedModuleSuggestedEditsButton() { |
410 | if ( in_array( $this->getMode(), [ self::RENDER_MOBILE_DETAILS, self::RENDER_MOBILE_DETAILS_OVERLAY ] ) ) { |
411 | if ( $this->isSuggestedEditsActivatedForUser ) { |
412 | $linkPath = 'Special:Homepage/suggested-edits'; |
413 | $linkModulePath = '#/homepage/suggested-edits'; |
414 | } else { |
415 | $linkPath = 'Special:Homepage'; |
416 | // HACK: We use this to indicate to the client-side to use launchCta() to open the |
417 | // start editing onboarding dialog for suggested edits. |
418 | $linkModulePath = 'launchCta'; |
419 | } |
420 | $button = new ButtonWidget( [ |
421 | 'label' => $this->getContext() |
422 | ->msg( 'growthexperiments-homepage-impact-unactivated-suggested-edits-link' ) |
423 | ->text(), |
424 | 'href' => $this->titleFactory->newFromText( $linkPath )->getLinkURL(), |
425 | 'classes' => [ |
426 | $this->getUnactivatedModuleCssClass() . '-suggested-edits-button', |
427 | 'see-suggested-edits-button', |
428 | ], |
429 | ] ); |
430 | $button->setAttributes( [ |
431 | 'data-link-id' => 'impact-see-suggested-edits', |
432 | 'data-link-module-path' => $linkModulePath |
433 | ] ); |
434 | return $button; |
435 | } |
436 | return ''; |
437 | } |
438 | |
439 | /** |
440 | * @return string |
441 | */ |
442 | private function getUnactivatedModuleBody() { |
443 | if ( $this->isUnactivatedWithSuggestedEdits() ) { |
444 | return Html::rawElement( |
445 | 'div', |
446 | [ 'class' => $this->getUnactivatedModuleCssClass() . '-body' ], |
447 | Html::element( |
448 | 'div', |
449 | [ 'class' => $this->getUnactivatedModuleCssClass() . '-image' ] |
450 | ) . |
451 | $this->getUnactivatedModuleSubheader() . |
452 | $this->getUnactivatedModuleSuggestedEditsButton() |
453 | ); |
454 | } |
455 | return ''; |
456 | } |
457 | |
458 | /** |
459 | * @inheritDoc |
460 | */ |
461 | protected function getSubheader() { |
462 | if ( $this->isUnactivatedWithSuggestedEdits() ) { |
463 | return ''; |
464 | } |
465 | return $this->getSubheaderText() . $this->getSubheaderSubtext(); |
466 | } |
467 | |
468 | /** |
469 | * @inheritDoc |
470 | */ |
471 | protected function getSubheaderTag() { |
472 | return 'div'; |
473 | } |
474 | |
475 | /** |
476 | * @inheritDoc |
477 | */ |
478 | protected function getFooter() { |
479 | if ( $this->isUnactivatedWithSuggestedEdits() ) { |
480 | return $this->getContext() |
481 | ->msg( 'growthexperiments-homepage-impact-unactivated-suggested-edits-footer' ) |
482 | ->params( $this->getContext()->getUser()->getName() ) |
483 | ->parse(); |
484 | } |
485 | |
486 | $user = $this->getContext()->getUser(); |
487 | $msgKey = $this->isActivated() ? |
488 | 'growthexperiments-homepage-impact-contributions-link' : |
489 | 'growthexperiments-homepage-impact-contributions-link-no-edit'; |
490 | return Html::rawElement( |
491 | 'a', |
492 | [ |
493 | 'href' => SpecialPage::getTitleFor( 'Contributions', $user->getName() )->getLinkURL(), |
494 | 'data-link-id' => 'impact-contributions', |
495 | ], |
496 | $this->getContext() |
497 | ->msg( $msgKey ) |
498 | ->numParams( $user->getEditCount() ) |
499 | ->params( $user->getName() ) |
500 | ->parse() |
501 | ); |
502 | } |
503 | |
504 | /** |
505 | * @inheritDoc |
506 | */ |
507 | protected function getCssClasses() { |
508 | $unactivatedClasses = $this->isUnactivatedWithSuggestedEdits() ? |
509 | [ $this->getUnactivatedModuleCssClass() ] : |
510 | []; |
511 | return array_merge( |
512 | parent::getCssClasses(), |
513 | $this->isActivated() ? |
514 | [ 'growthexperiments-homepage-impact-activated' ] : |
515 | $unactivatedClasses |
516 | ); |
517 | } |
518 | |
519 | /** |
520 | * @inheritDoc |
521 | */ |
522 | public function getState() { |
523 | if ( $this->canRender() ) { |
524 | return (bool)$this->getArticleContributions() ? |
525 | self::MODULE_STATE_ACTIVATED : |
526 | self::MODULE_STATE_UNACTIVATED; |
527 | } |
528 | return self::MODULE_STATE_NOTRENDERED; |
529 | } |
530 | |
531 | private function isActivated() { |
532 | return $this->getState() === self::MODULE_STATE_ACTIVATED; |
533 | } |
534 | |
535 | /** |
536 | * Check if impact module is unactivated and suggested edits module is enabled |
537 | * |
538 | * @return bool |
539 | */ |
540 | private function isUnactivatedWithSuggestedEdits() { |
541 | return $this->getState() === self::MODULE_STATE_UNACTIVATED && $this->isSuggestedEditsEnabledForUser; |
542 | } |
543 | |
544 | /** |
545 | * @return array Top 10 recently edited articles with pageviews |
546 | */ |
547 | public function getArticleContributions() { |
548 | if ( $this->contribs === null ) { |
549 | $this->contribs = $this->queryArticleEdits(); |
550 | if ( count( $this->contribs ) ) { |
551 | // Add pageviews data |
552 | $this->addPageViews( $this->contribs ); |
553 | |
554 | // Sort by pageviews DESC |
555 | usort( $this->contribs, static function ( $a, $b ) { |
556 | return ( $b['views'] ?? -1 ) <=> ( $a['views'] ?? -1 ); |
557 | } ); |
558 | // Generate the edits table for later use. |
559 | $this->generateEditsTable(); |
560 | } |
561 | } |
562 | return $this->contribs; |
563 | } |
564 | |
565 | private function getTotalPageViews() { |
566 | if ( !$this->isActivated() ) { |
567 | return 0; |
568 | } |
569 | $views = array_reduce( |
570 | $this->getArticleContributions(), |
571 | static function ( $subTotal, $contrib ) { |
572 | return $subTotal + ( $contrib['views'] ?? 0 ); |
573 | }, |
574 | 0 |
575 | ); |
576 | return $views; |
577 | } |
578 | |
579 | /** |
580 | * Query the last 10 edited pages and the timestamp of the first edit for those pages. |
581 | * |
582 | * @return array[] like [ 'title' => <Title object>, 'ts' => <DateTime object> ] |
583 | * @throws Exception |
584 | */ |
585 | private function queryArticleEdits() { |
586 | $actorMigration = ActorMigration::newMigration(); |
587 | $dbr = $this->connectionProvider->getReplicaDatabase(); |
588 | $actorQuery = $actorMigration->getWhere( $dbr, 'rev_user', $this->getContext()->getUser() ); |
589 | $subquery = $dbr->newSelectQueryBuilder() |
590 | ->select( [ 'rev_page', 'page_title', 'page_namespace', 'rev_timestamp' ] ) |
591 | ->from( 'revision' ) |
592 | ->tables( $actorQuery[ 'tables' ] ) |
593 | ->joinConds( $actorQuery[ 'joins' ] ) |
594 | ->join( 'page', null, 'rev_page = page_id' ) |
595 | ->where( [ |
596 | $actorQuery[ 'conds' ], |
597 | 'rev_deleted' => 0, |
598 | 'page_namespace' => 0, |
599 | ] ) |
600 | ->orderBy( 'rev_timestamp', SelectQueryBuilder::SORT_DESC ) |
601 | ->limit( 1000 ) |
602 | ->caller( __METHOD__ ); |
603 | $result = $dbr->newSelectQueryBuilder() |
604 | ->select( [ |
605 | 'rev_page', |
606 | 'page_title', |
607 | 'page_namespace', |
608 | 'max_ts' => 'MAX(rev_timestamp)', |
609 | 'min_ts' => 'MIN(rev_timestamp)', |
610 | ] ) |
611 | ->from( $subquery, 'latest_edits' ) |
612 | ->groupBy( 'rev_page' ) |
613 | ->orderBy( 'max_ts', SelectQueryBuilder::SORT_DESC ) |
614 | ->limit( 10 ) |
615 | ->caller( __METHOD__ ) |
616 | ->fetchResultSet(); |
617 | $contribs = []; |
618 | foreach ( $result as $row ) { |
619 | $contribs[] = [ |
620 | 'title' => Title::newFromRow( $row ), |
621 | 'ts' => new DateTime( $row->min_ts ), |
622 | ]; |
623 | } |
624 | return $contribs; |
625 | } |
626 | |
627 | /** |
628 | * Get the total number of article edits made by the current user. |
629 | * |
630 | * @return int |
631 | * @throws Exception |
632 | */ |
633 | private function getArticleEditCount() { |
634 | $dbr = $this->connectionProvider->getReplicaDatabase(); |
635 | return $dbr->newSelectQueryBuilder() |
636 | ->select( 'rev_id' ) |
637 | ->from( 'revision' ) |
638 | ->join( 'page', null, [ 'rev_page = page_id' ] ) |
639 | ->where( [ |
640 | 'rev_actor' => MediaWikiServices::getInstance()->getActorNormalization()->findActorId( |
641 | $this->getUser(), |
642 | $dbr |
643 | ), |
644 | 'rev_deleted' => 0, |
645 | 'page_namespace' => NS_MAIN, |
646 | ] ) |
647 | ->caller( __METHOD__ ) |
648 | ->fetchRowCount(); |
649 | } |
650 | |
651 | private function getArticleOrTotalEditCountText() { |
652 | $user = $this->getContext()->getUser(); |
653 | if ( $user->getEditCount() < 1000 ) { |
654 | $msgKey = 'growthexperiments-homepage-impact-mobilebody-articleedits'; |
655 | $count = $this->getArticleEditCount(); |
656 | } else { |
657 | $msgKey = 'growthexperiments-homepage-impact-mobilebody-totaledits'; |
658 | $count = $user->getEditCount(); |
659 | } |
660 | return $this->getContext()->msg( $msgKey ) |
661 | ->numParams( $count ) |
662 | ->parse(); |
663 | } |
664 | |
665 | /** |
666 | * Add pageviews information to the array of recent contributions. |
667 | * |
668 | * @param array[] &$contribs Recent contributions |
669 | */ |
670 | private function addPageViews( &$contribs ) { |
671 | $titles = array_column( $contribs, 'title' ); |
672 | $days = min( 60, $this->daysSince( end( $contribs )[ 'ts' ] ) ); |
673 | $data = $this->pageViewService->getPageData( $titles, $days ); |
674 | if ( $data->isGood() ) { |
675 | foreach ( $contribs as &$contrib ) { |
676 | $viewsByDay = $data->getValue()[ $contrib[ 'title' ]->getPrefixedDBkey() ] ?? []; |
677 | if ( $viewsByDay ) { |
678 | $editDate = $contrib[ 'ts' ]; |
679 | // go back to the beginning of the day of the edit |
680 | $editDate->setTime( 0, 0 ); |
681 | $viewsByDaySinceEdit = array_filter( |
682 | $viewsByDay, |
683 | static function ( $views, $date ) use ( $editDate ) { |
684 | return new DateTime( $date ) >= $editDate; |
685 | }, |
686 | ARRAY_FILTER_USE_BOTH |
687 | ); |
688 | if ( $viewsByDaySinceEdit ) { |
689 | $contrib['views'] = array_reduce( |
690 | $viewsByDaySinceEdit, |
691 | static function ( $total, $views ) { |
692 | return $total + ( is_numeric( $views ) ? $views : 0 ); |
693 | }, |
694 | 0 |
695 | ); |
696 | } else { |
697 | $contrib[ 'views' ] = null; |
698 | } |
699 | } else { |
700 | $contrib[ 'views' ] = null; |
701 | } |
702 | } |
703 | } |
704 | } |
705 | |
706 | /** |
707 | * Get image URL for a page |
708 | * Depends on the PageImages extension. |
709 | * |
710 | * @param Title $title |
711 | * @return bool|string |
712 | */ |
713 | private function getImage( Title $title ) { |
714 | if ( !ExtensionRegistry::getInstance()->isLoaded( 'PageImages' ) ) { |
715 | return false; |
716 | } |
717 | |
718 | $imageFile = PageImages::getPageImage( $title ); |
719 | if ( $imageFile ) { |
720 | $ratio = $imageFile->getWidth() / $imageFile->getHeight(); |
721 | $options = [ |
722 | 'width' => $ratio > 1 ? |
723 | self::THUMBNAIL_SIZE / $imageFile->getHeight() * $imageFile->getWidth() : |
724 | self::THUMBNAIL_SIZE |
725 | ]; |
726 | $thumb = $imageFile->transform( $options ); |
727 | if ( $thumb ) { |
728 | return $thumb->getUrl(); |
729 | } |
730 | } |
731 | |
732 | return false; |
733 | } |
734 | |
735 | /** |
736 | * @param DateTime $timestamp |
737 | * @return int Number of days since, and including, the given timestamp |
738 | * @throws Exception |
739 | */ |
740 | private function daysSince( DateTime $timestamp ) { |
741 | $now = MWTimestamp::getInstance(); |
742 | $diff = $now->timestamp->diff( $timestamp ); |
743 | return $diff->days; |
744 | } |
745 | |
746 | /** |
747 | * @param Title $title |
748 | * @param DateTime $start |
749 | * @return string Full URL for the PageViews tool for the given title and start date |
750 | * @throws Exception |
751 | */ |
752 | private function getPageViewToolsUrl( $title, $start ) { |
753 | $baseUrl = 'https://pageviews.wmcloud.org/'; |
754 | $format = 'Y-m-d'; |
755 | return wfAppendQuery( $baseUrl, [ |
756 | 'project' => $this->getContext()->getConfig()->get( 'ServerName' ), |
757 | 'userlang' => $this->getContext()->getLanguage()->getCode(), |
758 | 'start' => $start->format( $format ), |
759 | 'end' => 'latest', |
760 | 'pages' => $title->getPrefixedDBkey(), |
761 | ] ); |
762 | } |
763 | |
764 | /** |
765 | * @inheritDoc |
766 | */ |
767 | protected function getHeaderIconName() { |
768 | return 'chart'; |
769 | } |
770 | |
771 | /** |
772 | * @return string|null |
773 | */ |
774 | protected function getEditsTable() { |
775 | return $this->editsTable; |
776 | } |
777 | } |