Passed
Push — master ( 3ba3e3...4b1164 )
by MusikAnimal
06:55
created

EditCounter::yearCountsWithNamespaces()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 14
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 12

Importance

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

763
        foreach (array_keys(/** @scrutinizer ignore-type */ $out['totals']) as $nsId) {
Loading history...
764 2
            ksort($out['totals'][$nsId]);
765
766 2
            foreach ($out['totals'][$nsId] as &$yearData) {
767 2
                ksort($yearData);
768
            }
769
        }
770
771
        // Finally, sort the namespaces
772 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

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