Passed
Push — master ( a1ed3f...a64a0e )
by
unknown
05:21
created

EditCounter::countAllPagesEdited()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 2
CRAP Score 1

Importance

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

774
        foreach (array_keys(/** @scrutinizer ignore-type */ $out['totals']) as $nsId) {
Loading history...
775 2
            ksort($out['totals'][$nsId]);
776
777 2
            foreach ($out['totals'][$nsId] as &$yearData) {
778 2
                ksort($yearData);
779
            }
780
        }
781
782
        // Finally, sort the namespaces
783 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

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