Passed
Push — master ( e9dfe5...fa6a28 )
by MusikAnimal
05:21
created

EditCounter::countLast5000()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 2
CRAP Score 2

Importance

Changes 0
Metric Value
cc 2
eloc 1
nc 2
nop 0
dl 0
loc 3
ccs 2
cts 2
cp 1
crap 2
rs 10
c 0
b 0
f 0
1
<?php
2
/**
3
 * This file contains only the EditCounter class.
4
 */
5
6
namespace Xtools;
7
8
use AppBundle\Helper\I18nHelper;
9
use DateInterval;
10
use DatePeriod;
11
use DateTime;
12
use Exception;
13
14
/**
15
 * An EditCounter provides statistics about a user's edits on a project.
16
 */
17
class EditCounter extends UserRights
18
{
19
20
    /** @var Project The project. */
21
    protected $project;
22
23
    /** @var User The user. */
24
    protected $user;
25
26
    /** @var I18nHelper For i18n and l10n. */
27
    protected $i18n;
28
29
    /** @var int[] Revision and page counts etc. */
30
    protected $pairData;
31
32
    /** @var string[] The start and end dates of revisions. */
33
    protected $revisionDates;
34
35
    /** @var int[] The total page counts. */
36
    protected $pageCounts;
37
38
    /** @var int[] The lot totals. */
39
    protected $logCounts;
40
41
    /** @var mixed[] Total numbers of edits per month */
42
    protected $monthCounts;
43
44
    /** @var mixed[] Total numbers of edits per year */
45
    protected $yearCounts;
46
47
    /** @var int[] Keys are project DB names. */
48
    protected $globalEditCounts;
49
50
    /** @var array Block data, with keys 'set' and 'received'. */
51
    protected $blocks;
52
53
    /** @var integer[] Array keys are namespace IDs, values are the edit counts. */
54
    protected $namespaceTotals;
55
56
    /** @var int Number of semi-automated edits. */
57
    protected $autoEditCount;
58
59
    /** @var string[] Data needed for time card chart. */
60
    protected $timeCardData;
61
62
    /** @var array Most recent revisions across all projects. */
63
    protected $globalEdits;
64
65
    /**
66
     * Revision size data, with keys 'average_size', 'large_edits' and 'small_edits'.
67
     * @var string[] As returned by the DB, unconverted to int or float
68
     */
69
    protected $editSizeData;
70
71
    /**
72
     * Duration of the longest block in seconds; -1 if indefinite,
73
     *   or false if could not be parsed from log params
74
     * @var int|bool
75
     */
76
    protected $longestBlockSeconds;
77
78
    /**
79
     * EditCounter constructor.
80
     * @param Project $project The base project to count edits
81
     * @param User $user
82
     * @param I18nHelper $i18n
83
     */
84 20
    public function __construct(Project $project, User $user, I18nHelper $i18n)
85
    {
86 20
        $this->project = $project;
87 20
        $this->user = $user;
88 20
        $this->i18n = $i18n;
89 20
    }
90
91
    /**
92
     * Get revision and page counts etc.
93
     * @return int[]
94
     */
95 4
    public function getPairData()
96
    {
97 4
        if (!is_array($this->pairData)) {
0 ignored issues
show
introduced by
The condition is_array($this->pairData) is always true.
Loading history...
98 4
            $this->pairData = $this->getRepository()
99 4
                ->getPairData($this->project, $this->user);
100
        }
101 4
        return $this->pairData;
102
    }
103
104
    /**
105
     * Get revision dates.
106
     * @return int[]
107
     */
108
    public function getLogCounts()
109
    {
110
        if (!is_array($this->logCounts)) {
0 ignored issues
show
introduced by
The condition is_array($this->logCounts) is always true.
Loading history...
111
            $this->logCounts = $this->getRepository()
112
                ->getLogCounts($this->project, $this->user);
113
        }
114
        return $this->logCounts;
115
    }
116
117
    /**
118
     * Get block data.
119
     * @param string $type Either 'set', 'received'
120
     * @param bool $blocksOnly Whether to include only blocks, and not reblocks and unblocks.
121
     * @return array
122
     */
123 6
    protected function getBlocks($type, $blocksOnly = true)
124
    {
125 6
        if (isset($this->blocks[$type]) && is_array($this->blocks[$type])) {
126
            return $this->blocks[$type];
127
        }
128 6
        $method = "getBlocks".ucfirst($type);
129 6
        $blocks = $this->getRepository()->$method($this->project, $this->user);
130 6
        $this->blocks[$type] = $blocks;
131
132
        // Filter out unblocks unless requested.
133 6
        if ($blocksOnly) {
134
            $blocks = array_filter($blocks, function ($block) {
135
                return $block['log_action'] === 'block';
136
            });
137
        }
138
139 6
        return $blocks;
140
    }
141
142
    /**
143
     * Get the total number of currently-live revisions.
144
     * @return int
145
     */
146 1
    public function countLiveRevisions()
147
    {
148 1
        $revCounts = $this->getPairData();
149 1
        return isset($revCounts['live']) ? (int)$revCounts['live'] : 0;
150
    }
151
152
    /**
153
     * Get the total number of the user's revisions that have been deleted.
154
     * @return int
155
     */
156 1
    public function countDeletedRevisions()
157
    {
158 1
        $revCounts = $this->getPairData();
159 1
        return isset($revCounts['deleted']) ? (int)$revCounts['deleted'] : 0;
160
    }
161
162
    /**
163
     * Get the total edit count (live + deleted).
164
     * @return int
165
     */
166 1
    public function countAllRevisions()
167
    {
168 1
        return $this->countLiveRevisions() + $this->countDeletedRevisions();
169
    }
170
171
    /**
172
     * Get the total number of live revisions with comments.
173
     * @return int
174
     */
175 1
    public function countRevisionsWithComments()
176
    {
177 1
        $revCounts = $this->getPairData();
178 1
        return isset($revCounts['with_comments']) ? (int)$revCounts['with_comments'] : 0;
179
    }
180
181
    /**
182
     * Get the total number of live revisions without comments.
183
     * @return int
184
     */
185 1
    public function countRevisionsWithoutComments()
186
    {
187 1
        return $this->countLiveRevisions() - $this->countRevisionsWithComments();
188
    }
189
190
    /**
191
     * Get the total number of revisions marked as 'minor' by the user.
192
     * @return int
193
     */
194
    public function countMinorRevisions()
195
    {
196
        $revCounts = $this->getPairData();
197
        return isset($revCounts['minor']) ? (int)$revCounts['minor'] : 0;
198
    }
199
200
    /**
201
     * Get the total number of non-deleted pages edited by the user.
202
     * @return int
203
     */
204 1
    public function countLivePagesEdited()
205
    {
206 1
        $pageCounts = $this->getPairData();
207 1
        return isset($pageCounts['edited-live']) ? (int)$pageCounts['edited-live'] : 0;
208
    }
209
210
    /**
211
     * Get the total number of deleted pages ever edited by the user.
212
     * @return int
213
     */
214 1
    public function countDeletedPagesEdited()
215
    {
216 1
        $pageCounts = $this->getPairData();
217 1
        return isset($pageCounts['edited-deleted']) ? (int)$pageCounts['edited-deleted'] : 0;
218
    }
219
220
    /**
221
     * Get the total number of pages ever edited by this user (both live and deleted).
222
     * @return int
223
     */
224 1
    public function countAllPagesEdited()
225
    {
226 1
        return $this->countLivePagesEdited() + $this->countDeletedPagesEdited();
227
    }
228
229
    /**
230
     * Get the total number of pages (both still live and those that have been deleted) created
231
     * by the user.
232
     * @return int
233
     */
234 1
    public function countPagesCreated()
235
    {
236 1
        return $this->countCreatedPagesLive() + $this->countPagesCreatedDeleted();
237
    }
238
239
    /**
240
     * Get the total number of pages created by the user, that have not been deleted.
241
     * @return int
242
     */
243 1
    public function countCreatedPagesLive()
244
    {
245 1
        $pageCounts = $this->getPairData();
246 1
        return isset($pageCounts['created-live']) ? (int)$pageCounts['created-live'] : 0;
247
    }
248
249
    /**
250
     * Get the total number of pages created by the user, that have since been deleted.
251
     * @return int
252
     */
253 1
    public function countPagesCreatedDeleted()
254
    {
255 1
        $pageCounts = $this->getPairData();
256 1
        return isset($pageCounts['created-deleted']) ? (int)$pageCounts['created-deleted'] : 0;
257
    }
258
259
    /**
260
     * Get the total number of pages that have been deleted by the user.
261
     * @return int
262
     */
263
    public function countPagesDeleted()
264
    {
265
        $logCounts = $this->getLogCounts();
266
        return isset($logCounts['delete-delete']) ? (int)$logCounts['delete-delete'] : 0;
267
    }
268
269
    /**
270
     * Get the total number of pages moved by the user.
271
     * @return int
272
     */
273
    public function countPagesMoved()
274
    {
275
        $logCounts = $this->getLogCounts();
276
        return isset($logCounts['move-move']) ? (int)$logCounts['move-move'] : 0;
277
    }
278
279
    /**
280
     * Get the total number of times the user has blocked a user.
281
     * @return int
282
     */
283
    public function countBlocksSet()
284
    {
285
        $logCounts = $this->getLogCounts();
286
        $reBlock = isset($logCounts['block-block']) ? (int)$logCounts['block-block'] : 0;
287
        return $reBlock;
288
    }
289
290
    /**
291
     * Get the total number of times the user has re-blocked a user.
292
     * @return int
293
     */
294
    public function countReblocksSet()
295
    {
296
        $logCounts = $this->getLogCounts();
297
        $reBlock = isset($logCounts['block-reblock']) ? (int)$logCounts['block-reblock'] : 0;
298
        return $reBlock;
299
    }
300
301
    /**
302
     * Get the total number of times the user has unblocked a user.
303
     * @return int
304
     */
305
    public function countUnblocksSet()
306
    {
307
        $logCounts = $this->getLogCounts();
308
        return isset($logCounts['block-unblock']) ? (int)$logCounts['block-unblock'] : 0;
309
    }
310
311
    /**
312
     * Get the total number of blocks that have been lifted (i.e. unblocks) by this user.
313
     * @return int
314
     */
315
    public function countBlocksLifted()
316
    {
317
        $logCounts = $this->getLogCounts();
318
        return isset($logCounts['block-unblock']) ? (int)$logCounts['block-unblock'] : 0;
319
    }
320
321
    /**
322
     * Get the total number of times the user has been blocked.
323
     * @return int
324
     */
325
    public function countBlocksReceived()
326
    {
327
        $blocks = $this->getBlocks('received');
328
        return count($blocks);
329
    }
330
331
    /**
332
     * Get the length of the longest block the user received, in seconds.
333
     * @return int Number of seconds or false if it could not be determined.
334
     *   If the user is blocked, the time since the block is returned. If the block is
335
     *   indefinite, -1 is returned. 0 if there was never a block.
336
     */
337 6
    public function getLongestBlockSeconds()
338
    {
339 6
        if (isset($this->longestBlockSeconds)) {
340
            return $this->longestBlockSeconds;
341
        }
342
343 6
        $blocks = $this->getBlocks('received', false);
344 6
        $this->longestBlockSeconds = false;
345
346
        // If there was never a block, the longest was zero seconds.
347 6
        if (empty($blocks)) {
348
            return 0;
349
        }
350
351
        /**
352
         * Keep track of the last block so we can determine the duration
353
         * if the current block in the loop is an unblock.
354
         * @var int[] [
355
         *              Unix timestamp,
356
         *              Duration in seconds (-1 if indefinite)
357
         *            ]
358
         */
359 6
        $lastBlock = [null, null];
360
361 6
        foreach ($blocks as $index => $block) {
362 6
            list($timestamp, $duration) = $this->parseBlockLogEntry($block);
363
364 6
            if ($block['log_action'] === 'block') {
365
                // This is a new block, so first see if the duration of the last
366
                // block exceeded our longest duration. -1 duration means indefinite.
367 6
                if ($lastBlock[1] > $this->longestBlockSeconds || $lastBlock[1] === -1) {
368 2
                    $this->longestBlockSeconds = $lastBlock[1];
369
                }
370
371
                // Now set this as the last block.
372 6
                $lastBlock = [$timestamp, $duration];
373 3
            } elseif ($block['log_action'] === 'unblock') {
374
                // The last block was lifted. So the duration will be the time from when the
375
                // last block was set to the time of the unblock.
376 1
                $timeSinceLastBlock = $timestamp - $lastBlock[0];
377 1
                if ($timeSinceLastBlock > $this->longestBlockSeconds) {
378 1
                    $this->longestBlockSeconds = $timeSinceLastBlock;
379
380
                    // Reset the last block, as it has now been accounted for.
381 1
                    $lastBlock = null;
382
                }
383 2
            } elseif ($block['log_action'] === 'reblock' && $lastBlock[1] !== -1) {
384
                // The last block was modified. So we will adjust $lastBlock to include
385
                // the difference of the duration of the new reblock, and time since the last block.
386
                // $lastBlock is left unchanged if its duration was indefinite.
387 1
                $timeSinceLastBlock = $timestamp - $lastBlock[0];
388 6
                $lastBlock[1] = $timeSinceLastBlock + $duration;
389
            }
390
        }
391
392
        // If the last block was indefinite, we'll return that as the longest duration.
393 6
        if ($lastBlock[1] === -1) {
394 2
            return -1;
395
        }
396
397
        // Test if the last block is still active, and if so use the expiry as the duration.
398 4
        $lastBlockExpiry = $lastBlock[0] + $lastBlock[1];
399 4
        if ($lastBlockExpiry > time() && $lastBlockExpiry > $this->longestBlockSeconds) {
400 1
            $this->longestBlockSeconds = $lastBlock[1];
401
        // Otherwise, test if the duration of the last block is now the longest overall.
402 3
        } elseif ($lastBlock[1] > $this->longestBlockSeconds) {
403 2
            $this->longestBlockSeconds = $lastBlock[1];
404
        }
405
406 4
        return $this->longestBlockSeconds;
407
    }
408
409
    /**
410
     * Given a block log entry from the database, get the timestamp and duration in seconds.
411
     * @param  mixed[] $block Block log entry as fetched via self::getBlocks()
412
     * @return int[] [
413
     *                 Unix timestamp,
414
     *                 Duration in seconds (-1 if indefinite, null if unparsable or unblock)
415
     *               ]
416
     */
417 11
    public function parseBlockLogEntry($block)
418
    {
419 11
        $timestamp = strtotime($block['log_timestamp']);
420 11
        $duration = null;
421
422
        // First check if the string is serialized, and if so parse it to get the block duration.
423 11
        if (@unserialize($block['log_params']) !== false) {
424 8
            $parsedParams = unserialize($block['log_params']);
425 8
            $durationStr = isset($parsedParams['5::duration']) ? $parsedParams['5::duration'] : null;
426
        } else {
427
            // Old format, the duration in English + block options separated by new lines.
428 4
            $durationStr = explode("\n", $block['log_params'])[0];
429
        }
430
431 11
        if (in_array($durationStr, ['indefinite', 'infinity', 'infinite'])) {
432 3
            $duration = -1;
433
        }
434
435
        // Make sure $durationStr is valid just in case it is in an older, unpredictable format.
436
        // If invalid, $duration is left as null.
437 11
        if (strtotime($durationStr)) {
438 8
            $expiry = strtotime($durationStr, $timestamp);
439 8
            $duration = $expiry - $timestamp;
440
        }
441
442 11
        return [$timestamp, $duration];
443
    }
444
445
    /**
446
     * Get the total number of pages protected by the user.
447
     * @return int
448
     */
449
    public function countPagesProtected()
450
    {
451
        $logCounts = $this->getLogCounts();
452
        return isset($logCounts['protect-protect']) ? (int)$logCounts['protect-protect'] : 0;
453
    }
454
455
    /**
456
     * Get the total number of pages reprotected by the user.
457
     * @return int
458
     */
459
    public function countPagesReprotected()
460
    {
461
        $logCounts = $this->getLogCounts();
462
        return isset($logCounts['protect-modify']) ? (int)$logCounts['protect-modify'] : 0;
463
    }
464
465
    /**
466
     * Get the total number of pages unprotected by the user.
467
     * @return int
468
     */
469
    public function countPagesUnprotected()
470
    {
471
        $logCounts = $this->getLogCounts();
472
        return isset($logCounts['protect-unprotect']) ? (int)$logCounts['protect-unprotect'] : 0;
473
    }
474
475
    /**
476
     * Get the total number of edits deleted by the user.
477
     * @return int
478
     */
479
    public function countEditsDeleted()
480
    {
481
        $logCounts = $this->getLogCounts();
482
        return isset($logCounts['delete-revision']) ? (int)$logCounts['delete-revision'] : 0;
483
    }
484
485
    /**
486
     * Get the total number of pages restored by the user.
487
     * @return int
488
     */
489
    public function countPagesRestored()
490
    {
491
        $logCounts = $this->getLogCounts();
492
        return isset($logCounts['delete-restore']) ? (int)$logCounts['delete-restore'] : 0;
493
    }
494
495
    /**
496
     * Get the total number of times the user has modified the rights of a user.
497
     * @return int
498
     */
499
    public function countRightsModified()
500
    {
501
        $logCounts = $this->getLogCounts();
502
        return isset($logCounts['rights-rights']) ? (int)$logCounts['rights-rights'] : 0;
503
    }
504
505
    /**
506
     * Get the total number of pages imported by the user (through any import mechanism:
507
     * interwiki, or XML upload).
508
     * @return int
509
     */
510
    public function countPagesImported()
511
    {
512
        $logCounts = $this->getLogCounts();
513
        $import = isset($logCounts['import-import']) ? (int)$logCounts['import-import'] : 0;
514
        $interwiki = isset($logCounts['import-interwiki']) ? (int)$logCounts['import-interwiki'] : 0;
515
        $upload = isset($logCounts['import-upload']) ? (int)$logCounts['import-upload'] : 0;
516
        return $import + $interwiki + $upload;
517
    }
518
519
    /**
520
     * Get the average number of edits per page (including deleted revisions and pages).
521
     * @return float
522
     */
523
    public function averageRevisionsPerPage()
524
    {
525
        if ($this->countAllPagesEdited() == 0) {
526
            return 0;
527
        }
528
        return round($this->countAllRevisions() / $this->countAllPagesEdited(), 3);
529
    }
530
531
    /**
532
     * Average number of edits made per day.
533
     * @return float
534
     */
535
    public function averageRevisionsPerDay()
536
    {
537
        if ($this->getDays() == 0) {
538
            return 0;
539
        }
540
        return round($this->countAllRevisions() / $this->getDays(), 3);
541
    }
542
543
    /**
544
     * Get the total number of edits made by the user with semi-automating tools.
545
     */
546
    public function countAutomatedEdits()
547
    {
548
        if ($this->autoEditCount) {
549
            return $this->autoEditCount;
550
        }
551
        $this->autoEditCount = $this->getRepository()->countAutomatedEdits(
552
            $this->project,
553
            $this->user
554
        );
555
        return $this->autoEditCount;
556
    }
557
558
    /**
559
     * Get the count of (non-deleted) edits made in the given timeframe to now.
560
     * @param string $time One of 'day', 'week', 'month', or 'year'.
561
     * @return int The total number of live edits.
562
     */
563
    public function countRevisionsInLast($time)
564
    {
565
        $revCounts = $this->getPairData();
566
        return isset($revCounts[$time]) ? $revCounts[$time] : 0;
567
    }
568
569
    /**
570
     * Get the date and time of the user's first edit.
571
     * @return DateTime|bool The time of the first revision, or false.
572
     */
573 2
    public function datetimeFirstRevision()
574
    {
575 2
        $revDates = $this->getPairData();
576 2
        return isset($revDates['first']) ? new DateTime($revDates['first']) : false;
577
    }
578
579
    /**
580
     * Get the date and time of the user's first edit.
581
     * @return DateTime|bool The time of the last revision, or false.
582
     */
583 2
    public function datetimeLastRevision()
584
    {
585 2
        $revDates = $this->getPairData();
586 2
        return isset($revDates['last']) ? new DateTime($revDates['last']) : false;
587
    }
588
589
    /**
590
     * Get the number of days between the first and last edits.
591
     * If there's only one edit, this is counted as one day.
592
     * @return int
593
     */
594 2
    public function getDays()
595
    {
596 2
        $first = $this->datetimeFirstRevision();
597 2
        $last = $this->datetimeLastRevision();
598 2
        if ($first === false || $last === false) {
599
            return 0;
600
        }
601 2
        $days = $last->diff($first)->days;
602 2
        return $days > 0 ? $days : 1;
603
    }
604
605
    /**
606
     * Get the total number of files uploaded (including those now deleted).
607
     * @return int
608
     */
609
    public function countFilesUploaded()
610
    {
611
        $logCounts = $this->getLogCounts();
612
        return $logCounts['upload-upload'] ?: 0;
613
    }
614
615
    /**
616
     * Get the total number of files uploaded to Commons (including those now deleted).
617
     * This is only applicable for WMF labs installations.
618
     * @return int
619
     */
620
    public function countFilesUploadedCommons()
621
    {
622
        $logCounts = $this->getLogCounts();
623
        return $logCounts['files_uploaded_commons'] ?: 0;
624
    }
625
626
    /**
627
     * Get the total number of revisions the user has sent thanks for.
628
     * @return int
629
     */
630
    public function thanks()
631
    {
632
        $logCounts = $this->getLogCounts();
633
        return $logCounts['thanks-thank'] ?: 0;
634
    }
635
636
    /**
637
     * Get the total number of approvals
638
     * @return int
639
     */
640
    public function approvals()
641
    {
642
        $logCounts = $this->getLogCounts();
643
        $total = $logCounts['review-approve'] +
644
        (!empty($logCounts['review-approve-a']) ? $logCounts['review-approve-a'] : 0) +
645
        (!empty($logCounts['review-approve-i']) ? $logCounts['review-approve-i'] : 0) +
646
        (!empty($logCounts['review-approve-ia']) ? $logCounts['review-approve-ia'] : 0);
647
        return $total;
648
    }
649
650
    /**
651
     * Get the total number of patrols performed by the user.
652
     * @return int
653
     */
654
    public function patrols()
655
    {
656
        $logCounts = $this->getLogCounts();
657
        return $logCounts['patrol-patrol'] ?: 0;
658
    }
659
660
    /**
661
     * Get the total number of accounts created by the user.
662
     * @return int
663
     */
664
    public function accountsCreated()
665
    {
666
        $logCounts = $this->getLogCounts();
667
        $create2 = $logCounts['newusers-create2'] ?: 0;
668
        $byemail = $logCounts['newusers-byemail'] ?: 0;
669
        return $create2 + $byemail;
670
    }
671
672
    /**
673
     * Get the given user's total edit counts per namespace.
674
     * @return integer[] Array keys are namespace IDs, values are the edit counts.
675
     */
676 1
    public function namespaceTotals()
677
    {
678 1
        if ($this->namespaceTotals) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->namespaceTotals of type integer[] is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
679
            return $this->namespaceTotals;
680
        }
681 1
        $counts = $this->getRepository()->getNamespaceTotals($this->project, $this->user);
682 1
        arsort($counts);
683 1
        $this->namespaceTotals = $counts;
684 1
        return $counts;
685
    }
686
687
    /**
688
     * Get a summary of the times of day and the days of the week that the user has edited.
689
     * @return string[]
690
     */
691
    public function timeCard()
692
    {
693
        if ($this->timeCardData) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->timeCardData of type string[] is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
694
            return $this->timeCardData;
695
        }
696
        $totals = $this->getRepository()->getTimeCard($this->project, $this->user);
697
698
        // Scale the radii: get the max, then scale each radius.
699
        // This looks inefficient, but there's a max of 72 elements in this array.
700
        $max = 0;
701
        foreach ($totals as $total) {
702
            $max = max($max, $total['value']);
703
        }
704
        foreach ($totals as &$total) {
705
            $total['value'] = round($total['value'] / $max * 100);
706
        }
707
708
        // Fill in zeros for timeslots that have no values.
709
        $sortedTotals = [];
710
        $index = 0;
711
        $sortedIndex = 0;
712
        foreach (range(1, 7) as $day) {
713
            foreach (range(0, 24, 2) as $hour) {
714
                if (isset($totals[$index]) && (int)$totals[$index]['x'] === $hour) {
715
                    $sortedTotals[$sortedIndex] = $totals[$index];
716
                    $index++;
717
                } else {
718
                    $sortedTotals[$sortedIndex] = [
719
                        'y' => $day,
720
                        'x' => $hour,
721
                        'value' => 0,
722
                    ];
723
                }
724
                $sortedIndex++;
725
            }
726
        }
727
728
        $this->timeCardData = $sortedTotals;
0 ignored issues
show
Documentation Bug introduced by
$sortedTotals is of type array<mixed,array<string,mixed|integer>|mixed>, but the property $timeCardData was declared to be of type string[]. Are you sure that you always receive this specific sub-class here, or does it make sense to add an instanceof check?

Our type inference engine has found a suspicous assignment of a value to a property. This check raises an issue when a value that can be of a given class or a super-class is assigned to a property that is type hinted more strictly.

Either this assignment is in error or an instanceof check should be added for that assignment.

class Alien {}

class Dalek extends Alien {}

class Plot
{
    /** @var  Dalek */
    public $villain;
}

$alien = new Alien();
$plot = new Plot();
if ($alien instanceof Dalek) {
    $plot->villain = $alien;
}
Loading history...
729
        return $sortedTotals;
730
    }
731
732
    /**
733
     * Get the total numbers of edits per month.
734
     * @param null|DateTime $currentTime - *USED ONLY FOR UNIT TESTING*
735
     *   so we can mock the current DateTime.
736
     * @return mixed[] With keys 'yearLabels', 'monthLabels' and 'totals',
737
     *   the latter keyed by namespace, year and then month.
738
     */
739 2
    public function monthCounts($currentTime = null)
740
    {
741 2
        if (isset($this->monthCounts)) {
742 1
            return $this->monthCounts;
743
        }
744
745
        // Set to current month if we're not unit-testing
746 2
        if (!($currentTime instanceof DateTime)) {
747
            $currentTime = new DateTime('last day of this month');
748
        }
749
750 2
        $totals = $this->getRepository()->getMonthCounts($this->project, $this->user);
751
        $out = [
752 2
            'yearLabels' => [],  // labels for years
753
            'monthLabels' => [], // labels for months
754
            'totals' => [], // actual totals, grouped by namespace, year and then month
755
        ];
756
757
        /** @var DateTime Keep track of the date of their first edit. */
758 2
        $firstEdit = new DateTime();
759
760 2
        list($out, $firstEdit) = $this->fillInMonthCounts($out, $totals, $firstEdit);
761
762 2
        $dateRange = new DatePeriod(
763 2
            $firstEdit,
764 2
            new DateInterval('P1M'),
765 2
            $currentTime->modify('first day of this month')
766
        );
767
768 2
        $out = $this->fillInMonthTotalsAndLabels($out, $dateRange);
769
770
        // One more set of loops to sort by year/month
771 2
        foreach (array_keys($out['totals']) as $nsId) {
0 ignored issues
show
Bug introduced by
$out['totals'] of type string is incompatible with the type array expected by parameter $input of array_keys(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

771
        foreach (array_keys(/** @scrutinizer ignore-type */ $out['totals']) as $nsId) {
Loading history...
772 2
            ksort($out['totals'][$nsId]);
773
774 2
            foreach ($out['totals'][$nsId] as &$yearData) {
775 2
                ksort($yearData);
776
            }
777
        }
778
779
        // Finally, sort the namespaces
780 2
        ksort($out['totals']);
0 ignored issues
show
Bug introduced by
$out['totals'] of type string is incompatible with the type array expected by parameter $array of ksort(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

780
        ksort(/** @scrutinizer ignore-type */ $out['totals']);
Loading history...
781
782 2
        $this->monthCounts = $out;
783 2
        return $out;
784
    }
785
786
    /**
787
     * Loop through the database results and fill in the values
788
     * for the months that we have data for.
789
     * @param array $out
790
     * @param string[] $totals
791
     * @param DateTime $firstEdit
792
     * @return array [
793
     *           string[] - Modified $out filled with month stats,
794
     *           DateTime - timestamp of first edit
795
     *         ]
796
     * Tests covered in self::monthCounts().
797
     * @codeCoverageIgnore
798
     */
799
    private function fillInMonthCounts($out, $totals, $firstEdit)
800
    {
801
        foreach ($totals as $total) {
802
            // Keep track of first edit
803
            $date = new DateTime($total['year'].'-'.$total['month'].'-01');
804
            if ($date < $firstEdit) {
805
                $firstEdit = $date;
806
            }
807
808
            // Collate the counts by namespace, and then year and month.
809
            $ns = $total['page_namespace'];
810
            if (!isset($out['totals'][$ns])) {
811
                $out['totals'][$ns] = [];
812
            }
813
814
            // Start array for this year if not already present.
815
            if (!isset($out['totals'][$ns][$total['year']])) {
816
                $out['totals'][$ns][$total['year']] = [];
817
            }
818
819
            $out['totals'][$ns][$total['year']][$total['month']] = (int) $total['count'];
820
        }
821
822
        return [$out, $firstEdit];
823
    }
824
825
    /**
826
     * Given the output array, fill each month's totals and labels.
827
     * @param array $out
828
     * @param DatePeriod $dateRange From first edit to present.
829
     * @return string[] - Modified $out filled with month stats.
830
     * Tests covered in self::monthCounts().
831
     * @codeCoverageIgnore
832
     */
833
    private function fillInMonthTotalsAndLabels($out, DatePeriod $dateRange)
834
    {
835
        foreach ($dateRange as $monthObj) {
836
            $year = (int) $monthObj->format('Y');
837
            $yearLabel = $this->i18n->dateFormat($monthObj, 'yyyy');
838
            $month = (int) $monthObj->format('n');
839
            $monthLabel = $this->i18n->dateFormat($monthObj, 'yyyy-MM');
840
841
            // Fill in labels
842
            $out['monthLabels'][] = $monthLabel;
843
            if (!in_array($yearLabel, $out['yearLabels'])) {
844
                $out['yearLabels'][] = $yearLabel;
845
            }
846
847
            foreach (array_keys($out['totals']) as $nsId) {
848
                if (!isset($out['totals'][$nsId][$year])) {
849
                    $out['totals'][$nsId][$year] = [];
850
                }
851
852
                if (!isset($out['totals'][$nsId][$year][$month])) {
853
                    $out['totals'][$nsId][$year][$month] = 0;
854
                }
855
            }
856
        }
857
858
        return $out;
859
    }
860
861
    /**
862
     * Get total edits for each month. Used in wikitext export.
863
     * @param  null|DateTime $currentTime *USED ONLY FOR UNIT TESTING*
864
     * @return array With the months as the keys, counts as the values.
865
     */
866 1
    public function monthTotals($currentTime = null)
867
    {
868 1
        $months = [];
869
870 1
        foreach ($this->monthCounts($currentTime)['totals'] as $nsId => $nsData) {
871 1
            foreach ($nsData as $year => $monthData) {
872 1
                foreach ($monthData as $month => $count) {
873 1
                    $monthLabel = $year.'-'.sprintf('%02d', $month);
874 1
                    if (!isset($months[$monthLabel])) {
875 1
                        $months[$monthLabel] = 0;
876
                    }
877 1
                    $months[$monthLabel] += $count;
878
                }
879
            }
880
        }
881
882 1
        return $months;
883
    }
884
885
    /**
886
     * Get the total numbers of edits per year.
887
     * @param null|DateTime $currentTime - *USED ONLY FOR UNIT TESTING*
888
     *   so we can mock the current DateTime.
889
     * @return mixed[] With keys 'yearLabels' and 'totals', the latter
890
     *   keyed by namespace then year.
891
     */
892 2
    public function yearCounts($currentTime = null)
893
    {
894 2
        if (isset($this->yearCounts)) {
895
            return $this->yearCounts;
896
        }
897
898 2
        $out = $this->monthCounts($currentTime);
899
900 2
        foreach ($out['totals'] as $nsId => $years) {
901 2
            foreach ($years as $year => $months) {
902 2
                $out['totals'][$nsId][$year] = array_sum(array_values($months));
903
            }
904
        }
905
906 2
        $this->yearCounts = $out;
907 2
        return $out;
908
    }
909
910
    /**
911
     * Get total edits for each year. Used in wikitext export.
912
     * @param  null|DateTime $currentTime *USED ONLY FOR UNIT TESTING*
913
     * @return array With the years as the keys, counts as the values.
914
     */
915 1
    public function yearTotals($currentTime = null)
916
    {
917 1
        $years = [];
918
919 1
        foreach ($this->yearCounts($currentTime)['totals'] as $nsId => $nsData) {
920 1
            foreach ($nsData as $year => $count) {
921 1
                if (!isset($years[$year])) {
922 1
                    $years[$year] = 0;
923
                }
924 1
                $years[$year] += $count;
925
            }
926
        }
927
928 1
        return $years;
929
    }
930
931
    /**
932
     * Get the total edit counts for the top n projects of this user.
933
     * @param int $numProjects
934
     * @return mixed[] Each element has 'total' and 'project' keys.
935
     */
936 1
    public function globalEditCountsTopN($numProjects = 10)
937
    {
938
        // Get counts.
939 1
        $editCounts = $this->globalEditCounts(true);
940
        // Truncate, and return.
941 1
        return array_slice($editCounts, 0, $numProjects);
942
    }
943
944
    /**
945
     * Get the total number of edits excluding the top n.
946
     * @param int $numProjects
947
     * @return int
948
     */
949 1
    public function globalEditCountWithoutTopN($numProjects = 10)
950
    {
951 1
        $editCounts = $this->globalEditCounts(true);
952 1
        $bottomM = array_slice($editCounts, $numProjects);
953 1
        $total = 0;
954 1
        foreach ($bottomM as $editCount) {
955 1
            $total += $editCount['total'];
956
        }
957 1
        return $total;
958
    }
959
960
    /**
961
     * Get the grand total of all edits on all projects.
962
     * @return int
963
     */
964 1
    public function globalEditCount()
965
    {
966 1
        $total = 0;
967 1
        foreach ($this->globalEditCounts() as $editCount) {
968 1
            $total += $editCount['total'];
969
        }
970 1
        return $total;
971
    }
972
973
    /**
974
     * Get the total revision counts for all projects for this user.
975
     * @param bool $sorted Whether to sort the list by total, or not.
976
     * @return mixed[] Each element has 'total' and 'project' keys.
977
     */
978 1
    public function globalEditCounts($sorted = false)
979
    {
980 1
        if (empty($this->globalEditCounts)) {
981 1
            $this->globalEditCounts = $this->getRepository()
982 1
                ->globalEditCounts($this->user, $this->project);
983
        }
984
985 1
        if ($sorted) {
986
            // Sort.
987 1
            uasort($this->globalEditCounts, function ($a, $b) {
988 1
                return $b['total'] - $a['total'];
989 1
            });
990
        }
991
992 1
        return $this->globalEditCounts;
993
    }
994
995
    /**
996
     * Get the most recent n revisions across all projects.
997
     * @param int $max The maximum number of revisions to return.
998
     * @return Edit[]
999
     */
1000
    public function globalEdits($max)
1001
    {
1002
        if (is_array($this->globalEdits)) {
0 ignored issues
show
introduced by
The condition is_array($this->globalEdits) is always true.
Loading history...
1003
            return $this->globalEdits;
1004
        }
1005
1006
        // Collect all projects with any edits.
1007
        $projects = [];
1008
        foreach ($this->globalEditCounts() as $editCount) {
1009
            // Don't query revisions if there aren't any.
1010
            if ($editCount['total'] == 0) {
1011
                continue;
1012
            }
1013
            $projects[$editCount['project']->getDatabaseName()] = $editCount['project'];
1014
        }
1015
1016
        if (count($projects) === 0) {
1017
            return [];
1018
        }
1019
1020
        // Get all revisions for those projects.
1021
        $globalRevisionsData = $this->getRepository()
1022
            ->getRevisions($projects, $this->user, $max);
1023
        $globalEdits = [];
1024
        foreach ($globalRevisionsData as $revision) {
1025
            /** @var Project $project */
1026
            $project = $projects[$revision['project_name']];
1027
            $nsName = '';
1028
            if ($revision['page_namespace']) {
1029
                $nsName = $project->getNamespaces()[$revision['page_namespace']];
1030
            }
1031
            $page = $project->getRepository()
1032
                ->getPage($project, $nsName . ':' . $revision['page_title']);
1033
            $edit = new Edit($page, $revision);
1034
            $globalEdits[$edit->getTimestamp()->getTimestamp().'-'.$edit->getId()] = $edit;
1035
        }
1036
1037
        // Sort and prune, before adding more.
1038
        krsort($globalEdits);
1039
        $this->globalEdits = array_slice($globalEdits, 0, $max);
1040
1041
        return $this->globalEdits;
1042
    }
1043
1044
    /**
1045
     * Get average edit size, and number of large and small edits.
1046
     * @return int[]
1047
     */
1048
    public function getEditSizeData()
1049
    {
1050
        if (!is_array($this->editSizeData)) {
0 ignored issues
show
introduced by
The condition is_array($this->editSizeData) is always true.
Loading history...
1051
            $this->editSizeData = $this->getRepository()
1052
                ->getEditSizeData($this->project, $this->user);
1053
        }
1054
        return $this->editSizeData;
1055
    }
1056
1057
    /**
1058
     * Get the total edit count of this user or 5,000 if they've made more than 5,000 edits.
1059
     * This is used to ensure percentages of small and large edits are computed properly.
1060
     * @return int
1061
     */
1062 1
    public function countLast5000()
1063
    {
1064 1
        return $this->countLiveRevisions() > 5000 ? 5000 : $this->countLiveRevisions();
1065
    }
1066
1067
    /**
1068
     * Get the number of edits under 20 bytes of the user's past 5000 edits.
1069
     * @return int
1070
     */
1071
    public function countSmallEdits()
1072
    {
1073
        $editSizeData = $this->getEditSizeData();
1074
        return isset($editSizeData['small_edits']) ? (int) $editSizeData['small_edits'] : 0;
1075
    }
1076
1077
    /**
1078
     * Get the total number of edits over 1000 bytes of the user's past 5000 edits.
1079
     * @return int
1080
     */
1081
    public function countLargeEdits()
1082
    {
1083
        $editSizeData = $this->getEditSizeData();
1084
        return isset($editSizeData['large_edits']) ? (int) $editSizeData['large_edits'] : 0;
1085
    }
1086
1087
    /**
1088
     * Get the average size of the user's past 5000 edits.
1089
     * @return float Size in bytes.
1090
     */
1091
    public function averageEditSize()
1092
    {
1093
        $editSizeData = $this->getEditSizeData();
1094
        if (isset($editSizeData['average_size'])) {
1095
            return round($editSizeData['average_size'], 3);
1096
        } else {
1097
            return 0;
1098
        }
1099
    }
1100
}
1101