Passed
Push — master ( 526c52...06ff74 )
by MusikAnimal
08:41 queued 03:03
created

EditCounter::countPagesReprotected()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 4
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 6

Importance

Changes 0
Metric Value
cc 2
eloc 2
nc 2
nop 0
dl 0
loc 4
ccs 0
cts 3
cp 0
crap 6
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 Model
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 string[] Rights changes, keyed by timestamp then 'added' and 'removed'. */
48
    protected $rightsChanges;
49
50
    /** @var string[] Global rights changes, keyed by timestamp then 'added' and 'removed'. */
51
    protected $globalRightsChanges;
52
53
    /** @var int[] Keys are project DB names. */
54
    protected $globalEditCounts;
55
56
    /** @var array Block data, with keys 'set' and 'received'. */
57
    protected $blocks;
58
59
    /** @var integer[] Array keys are namespace IDs, values are the edit counts. */
60
    protected $namespaceTotals;
61
62
    /** @var int Number of semi-automated edits. */
63
    protected $autoEditCount;
64
65
    /** @var string[] Data needed for time card chart. */
66
    protected $timeCardData;
67
68
    /** @var array Most recent revisions across all projects. */
69
    protected $globalEdits;
70
71
    /**
72
     * Revision size data, with keys 'average_size', 'large_edits' and 'small_edits'.
73
     * @var string[] As returned by the DB, unconverted to int or float
74
     */
75
    protected $editSizeData;
76
77
    /**
78
     * Duration of the longest block in seconds; -1 if indefinite,
79
     *   or false if could not be parsed from log params
80
     * @var int|bool
81
     */
82
    protected $longestBlockSeconds;
83
84
    /**
85
     * EditCounter constructor.
86
     * @param Project $project The base project to count edits
87
     * @param User $user
88
     */
89 20
    public function __construct(Project $project, User $user)
90
    {
91 20
        $this->project = $project;
92 20
        $this->user = $user;
93 20
    }
94
95
    /**
96
     * Make the I18nHelper accessible to EditCounter.
97
     * @param I18nHelper $i18n
98
     */
99 20
    public function setI18nHelper(I18nHelper $i18n)
100
    {
101 20
        $this->i18n = $i18n;
102 20
    }
103
104
    /**
105
     * Get revision and page counts etc.
106
     * @return int[]
107
     */
108 4
    public function getPairData()
109
    {
110 4
        if (!is_array($this->pairData)) {
0 ignored issues
show
introduced by
The condition is_array($this->pairData) is always true.
Loading history...
111 4
            $this->pairData = $this->getRepository()
112 4
                ->getPairData($this->project, $this->user);
113
        }
114 4
        return $this->pairData;
115
    }
116
117
    /**
118
     * Get revision dates.
119
     * @return int[]
120
     */
121
    public function getLogCounts()
122
    {
123
        if (!is_array($this->logCounts)) {
0 ignored issues
show
introduced by
The condition is_array($this->logCounts) is always true.
Loading history...
124
            $this->logCounts = $this->getRepository()
125
                ->getLogCounts($this->project, $this->user);
126
        }
127
        return $this->logCounts;
128
    }
129
130
    /**
131
     * Get block data.
132
     * @param string $type Either 'set', 'received'
133
     * @param bool $blocksOnly Whether to include only blocks, and not reblocks and unblocks.
134
     * @return array
135
     */
136 6
    protected function getBlocks($type, $blocksOnly = true)
137
    {
138 6
        if (isset($this->blocks[$type]) && is_array($this->blocks[$type])) {
139
            return $this->blocks[$type];
140
        }
141 6
        $method = "getBlocks".ucfirst($type);
142 6
        $blocks = $this->getRepository()->$method($this->project, $this->user);
143 6
        $this->blocks[$type] = $blocks;
144
145
        // Filter out unblocks unless requested.
146 6
        if ($blocksOnly) {
147
            $blocks = array_filter($blocks, function ($block) {
148
                return $block['log_action'] === 'block';
149
            });
150
        }
151
152 6
        return $blocks;
153
    }
154
155
    /**
156
     * Get user rights changes of the given user.
157
     * @param Project $project
158
     * @param User $user
159
     * @return string[] Keyed by timestamp then 'added' and 'removed'.
160
     */
161 1
    public function getRightsChanges()
162
    {
163 1
        if (isset($this->rightsChanges)) {
164 1
            return $this->rightsChanges;
165
        }
166
167 1
        $logData = $this->getRepository()
168 1
            ->getRightsChanges($this->project, $this->user);
169
170 1
        $this->rightsChanges = $this->processRightsChanges($logData);
171
172 1
        return $this->rightsChanges;
173
    }
174
175
    /**
176
     * Checks the user rights log to see whether the user is an admin
177
     * or used to be one.
178
     * @return string|false One of false (never an admin), 'current' or 'former'.
179
     */
180
    public function getAdminStatus()
181
    {
182
        $rightsStates = $this->getRightsStates();
183
184
        if (in_array('sysop', $rightsStates['current'])) {
185
            return 'current';
186
        } elseif (in_array('sysop', $rightsStates['former'])) {
187
            return 'former';
188
        } else {
189
            return false;
190
        }
191
    }
192
193
    /**
194
     * Get a list of the current and former rights of the user.
195
     * @return array With keys 'current' and 'former'.
196
     */
197 1
    public function getRightsStates()
198
    {
199 1
        $current = [];
200 1
        $former = [];
201
202 1
        foreach (array_reverse($this->getRightsChanges()) as $change) {
203 1
            $current = array_diff(
204 1
                array_unique(array_merge($current, $change['added'])),
205 1
                $change['removed']
206
            );
207 1
            $former = array_diff(
208 1
                array_unique(array_merge($former, $change['removed'])),
209 1
                $change['added']
210
            );
211
        }
212
213
        return [
214 1
            'current' => $current,
215 1
            'former' => $former,
216
        ];
217
    }
218
219
    /**
220
     * Get global user rights changes of the given user.
221
     * @param Project $project
222
     * @param User $user
223
     * @return string[] Keyed by timestamp then 'added' and 'removed'.
224
     */
225 1
    public function getGlobalRightsChanges()
226
    {
227 1
        if (isset($this->globalRightsChanges)) {
228
            return $this->globalRightsChanges;
229
        }
230
231 1
        $logData = $this->getRepository()
232 1
            ->getGlobalRightsChanges($this->project, $this->user);
233
234 1
        $this->globalRightsChanges = $this->processRightsChanges($logData);
235
236 1
        return $this->globalRightsChanges;
237
    }
238
239
    /**
240
     * Process the given rights changes, sorting an putting in a human-readable format.
241
     * @param  array $logData As fetched with EditCounterRepository::getRightsChanges.
242
     * @return array
243
     */
244 1
    private function processRightsChanges($logData)
245
    {
246 1
        $rightsChanges = [];
247
248 1
        foreach ($logData as $row) {
249 1
            $unserialized = @unserialize($row['log_params']);
250 1
            if ($unserialized !== false) {
251 1
                $old = $unserialized['4::oldgroups'];
252 1
                $new = $unserialized['5::newgroups'];
253 1
                $added = array_diff($new, $old);
254 1
                $removed = array_diff($old, $new);
255
256 1
                $rightsChanges = $this->setAutoRemovals($rightsChanges, $row, $unserialized, $added);
257
            } else {
258
                // This is the old school format the most likely contains
259
                // the list of rights additions in as a comma-separated list.
260
                try {
261 1
                    list($old, $new) = explode("\n", $row['log_params']);
262 1
                    $old = array_filter(array_map('trim', explode(',', $old)));
263 1
                    $new = array_filter(array_map('trim', explode(',', $new)));
264 1
                    $added = array_diff($new, $old);
265 1
                    $removed = array_diff($old, $new);
266
                } catch (Exception $e) {
267
                    // Really really old school format that may be missing metadata
268
                    // altogether. Here we'll just leave $added and $removed blank.
269
                    $added = [];
270
                    $removed = [];
271
                }
272
            }
273
274
            // Remove '(none)'.
275 1
            if (in_array('(none)', $added)) {
276
                array_splice($added, array_search('(none)', $added), 1);
0 ignored issues
show
Bug introduced by
It seems like array_search('(none)', $added) can also be of type string and false; however, parameter $offset of array_splice() does only seem to accept integer, maybe add an additional type check? ( Ignorable by Annotation )

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

276
                array_splice($added, /** @scrutinizer ignore-type */ array_search('(none)', $added), 1);
Loading history...
277
            }
278 1
            if (in_array('(none)', $removed)) {
279
                array_splice($removed, array_search('(none)', $removed), 1);
280
            }
281
282 1
            $rightsChanges[$row['log_timestamp']] = [
283 1
                'logId' => $row['log_id'],
284 1
                'admin' => $row['log_user_text'],
285 1
                'comment' => $row['log_comment'],
286 1
                'added' => array_values($added),
287 1
                'removed' => array_values($removed),
288 1
                'automatic' => $row['log_action'] === 'autopromote',
289 1
                'type' => $row['type'],
290
            ];
291
        }
292
293 1
        krsort($rightsChanges);
294
295 1
        return $rightsChanges;
296
    }
297
298
    /**
299
     * Check the given log entry for rights changes that are set to automatically expire,
300
     * and add entries to $rightsChanges accordingly.
301
     * @param array $rightsChanges
302
     * @param array $row Log entry row from database.
303
     * @param array $params Unserialized log params.
304
     * @param string[] $added List of added user rights.
305
     * @return array Modified $rightsChanges.
306
     */
307 1
    private function setAutoRemovals($rightsChanges, $row, $params, $added)
308
    {
309 1
        foreach ($added as $index => $entry) {
310 1
            if (!isset($params['newmetadata'][$index]) ||
311 1
                !array_key_exists('expiry', $params['newmetadata'][$index]) ||
312 1
                empty($params['newmetadata'][$index]['expiry'])
313
            ) {
314 1
                continue;
315
            }
316
317 1
            $expiry = $params['newmetadata'][$index]['expiry'];
318
319 1
            if (isset($rightsChanges[$expiry]) && !in_array($entry, $rightsChanges[$expiry]['removed'])) {
320 1
                $rightsChanges[$expiry]['removed'][] = $entry;
321
            } else {
322 1
                $rightsChanges[$expiry] = [
323 1
                    'logId' => $row['log_id'],
324 1
                    'admin' => $row['log_user_text'],
325
                    'comment' => null,
326
                    'added' => [],
327 1
                    'removed' => [$entry],
328
                    'automatic' => true,
329 1
                    'type' => $row['type'],
330
                ];
331
            }
332
        }
333
334 1
        return $rightsChanges;
335
    }
336
337
    /**
338
     * Get the total number of currently-live revisions.
339
     * @return int
340
     */
341 1
    public function countLiveRevisions()
342
    {
343 1
        $revCounts = $this->getPairData();
344 1
        return isset($revCounts['live']) ? (int)$revCounts['live'] : 0;
345
    }
346
347
    /**
348
     * Get the total number of the user's revisions that have been deleted.
349
     * @return int
350
     */
351 1
    public function countDeletedRevisions()
352
    {
353 1
        $revCounts = $this->getPairData();
354 1
        return isset($revCounts['deleted']) ? (int)$revCounts['deleted'] : 0;
355
    }
356
357
    /**
358
     * Get the total edit count (live + deleted).
359
     * @return int
360
     */
361 1
    public function countAllRevisions()
362
    {
363 1
        return $this->countLiveRevisions() + $this->countDeletedRevisions();
364
    }
365
366
    /**
367
     * Get the total number of live revisions with comments.
368
     * @return int
369
     */
370 1
    public function countRevisionsWithComments()
371
    {
372 1
        $revCounts = $this->getPairData();
373 1
        return isset($revCounts['with_comments']) ? (int)$revCounts['with_comments'] : 0;
374
    }
375
376
    /**
377
     * Get the total number of live revisions without comments.
378
     * @return int
379
     */
380 1
    public function countRevisionsWithoutComments()
381
    {
382 1
        return $this->countLiveRevisions() - $this->countRevisionsWithComments();
383
    }
384
385
    /**
386
     * Get the total number of revisions marked as 'minor' by the user.
387
     * @return int
388
     */
389
    public function countMinorRevisions()
390
    {
391
        $revCounts = $this->getPairData();
392
        return isset($revCounts['minor']) ? (int)$revCounts['minor'] : 0;
393
    }
394
395
    /**
396
     * Get the total number of non-deleted pages edited by the user.
397
     * @return int
398
     */
399 1
    public function countLivePagesEdited()
400
    {
401 1
        $pageCounts = $this->getPairData();
402 1
        return isset($pageCounts['edited-live']) ? (int)$pageCounts['edited-live'] : 0;
403
    }
404
405
    /**
406
     * Get the total number of deleted pages ever edited by the user.
407
     * @return int
408
     */
409 1
    public function countDeletedPagesEdited()
410
    {
411 1
        $pageCounts = $this->getPairData();
412 1
        return isset($pageCounts['edited-deleted']) ? (int)$pageCounts['edited-deleted'] : 0;
413
    }
414
415
    /**
416
     * Get the total number of pages ever edited by this user (both live and deleted).
417
     * @return int
418
     */
419 1
    public function countAllPagesEdited()
420
    {
421 1
        return $this->countLivePagesEdited() + $this->countDeletedPagesEdited();
422
    }
423
424
    /**
425
     * Get the total number of pages (both still live and those that have been deleted) created
426
     * by the user.
427
     * @return int
428
     */
429 1
    public function countPagesCreated()
430
    {
431 1
        return $this->countCreatedPagesLive() + $this->countPagesCreatedDeleted();
432
    }
433
434
    /**
435
     * Get the total number of pages created by the user, that have not been deleted.
436
     * @return int
437
     */
438 1
    public function countCreatedPagesLive()
439
    {
440 1
        $pageCounts = $this->getPairData();
441 1
        return isset($pageCounts['created-live']) ? (int)$pageCounts['created-live'] : 0;
442
    }
443
444
    /**
445
     * Get the total number of pages created by the user, that have since been deleted.
446
     * @return int
447
     */
448 1
    public function countPagesCreatedDeleted()
449
    {
450 1
        $pageCounts = $this->getPairData();
451 1
        return isset($pageCounts['created-deleted']) ? (int)$pageCounts['created-deleted'] : 0;
452
    }
453
454
    /**
455
     * Get the total number of pages that have been deleted by the user.
456
     * @return int
457
     */
458
    public function countPagesDeleted()
459
    {
460
        $logCounts = $this->getLogCounts();
461
        return isset($logCounts['delete-delete']) ? (int)$logCounts['delete-delete'] : 0;
462
    }
463
464
    /**
465
     * Get the total number of pages moved by the user.
466
     * @return int
467
     */
468
    public function countPagesMoved()
469
    {
470
        $logCounts = $this->getLogCounts();
471
        return isset($logCounts['move-move']) ? (int)$logCounts['move-move'] : 0;
472
    }
473
474
    /**
475
     * Get the total number of times the user has blocked a user.
476
     * @return int
477
     */
478
    public function countBlocksSet()
479
    {
480
        $logCounts = $this->getLogCounts();
481
        $reBlock = isset($logCounts['block-block']) ? (int)$logCounts['block-block'] : 0;
482
        return $reBlock;
483
    }
484
485
    /**
486
     * Get the total number of times the user has re-blocked a user.
487
     * @return int
488
     */
489
    public function countReblocksSet()
490
    {
491
        $logCounts = $this->getLogCounts();
492
        $reBlock = isset($logCounts['block-reblock']) ? (int)$logCounts['block-reblock'] : 0;
493
        return $reBlock;
494
    }
495
496
    /**
497
     * Get the total number of times the user has unblocked a user.
498
     * @return int
499
     */
500
    public function countUnblocksSet()
501
    {
502
        $logCounts = $this->getLogCounts();
503
        return isset($logCounts['block-unblock']) ? (int)$logCounts['block-unblock'] : 0;
504
    }
505
506
    /**
507
     * Get the total number of blocks that have been lifted (i.e. unblocks) by this user.
508
     * @return int
509
     */
510
    public function countBlocksLifted()
511
    {
512
        $logCounts = $this->getLogCounts();
513
        return isset($logCounts['block-unblock']) ? (int)$logCounts['block-unblock'] : 0;
514
    }
515
516
    /**
517
     * Get the total number of times the user has been blocked.
518
     * @return int
519
     */
520
    public function countBlocksReceived()
521
    {
522
        $blocks = $this->getBlocks('received');
523
        return count($blocks);
524
    }
525
526
    /**
527
     * Get the length of the longest block the user received, in seconds.
528
     * @return int Number of seconds or false if it could not be determined.
529
     *   If the user is blocked, the time since the block is returned. If the block is
530
     *   indefinite, -1 is returned. 0 if there was never a block.
531
     */
532 6
    public function getLongestBlockSeconds()
533
    {
534 6
        if (isset($this->longestBlockSeconds)) {
535
            return $this->longestBlockSeconds;
536
        }
537
538 6
        $blocks = $this->getBlocks('received', false);
539 6
        $this->longestBlockSeconds = false;
540
541
        // If there was never a block, the longest was zero seconds.
542 6
        if (empty($blocks)) {
543
            return 0;
544
        }
545
546
        /**
547
         * Keep track of the last block so we can determine the duration
548
         * if the current block in the loop is an unblock.
549
         * @var int[] [
550
         *              Unix timestamp,
551
         *              Duration in seconds (-1 if indefinite)
552
         *            ]
553
         */
554 6
        $lastBlock = [null, null];
555
556 6
        foreach ($blocks as $index => $block) {
557 6
            list($timestamp, $duration) = $this->parseBlockLogEntry($block);
558
559 6
            if ($block['log_action'] === 'block') {
560
                // This is a new block, so first see if the duration of the last
561
                // block exceeded our longest duration. -1 duration means indefinite.
562 6
                if ($lastBlock[1] > $this->longestBlockSeconds || $lastBlock[1] === -1) {
563 2
                    $this->longestBlockSeconds = $lastBlock[1];
564
                }
565
566
                // Now set this as the last block.
567 6
                $lastBlock = [$timestamp, $duration];
568 3
            } elseif ($block['log_action'] === 'unblock') {
569
                // The last block was lifted. So the duration will be the time from when the
570
                // last block was set to the time of the unblock.
571 1
                $timeSinceLastBlock = $timestamp - $lastBlock[0];
572 1
                if ($timeSinceLastBlock > $this->longestBlockSeconds) {
573 1
                    $this->longestBlockSeconds = $timeSinceLastBlock;
574
575
                    // Reset the last block, as it has now been accounted for.
576 1
                    $lastBlock = null;
577
                }
578 2
            } elseif ($block['log_action'] === 'reblock' && $lastBlock[1] !== -1) {
579
                // The last block was modified. So we will adjust $lastBlock to include
580
                // the difference of the duration of the new reblock, and time since the last block.
581
                // $lastBlock is left unchanged if its duration was indefinite.
582 1
                $timeSinceLastBlock = $timestamp - $lastBlock[0];
583 6
                $lastBlock[1] = $timeSinceLastBlock + $duration;
584
            }
585
        }
586
587
        // If the last block was indefinite, we'll return that as the longest duration.
588 6
        if ($lastBlock[1] === -1) {
589 2
            return -1;
590
        }
591
592
        // Test if the last block is still active, and if so use the expiry as the duration.
593 4
        $lastBlockExpiry = $lastBlock[0] + $lastBlock[1];
594 4
        if ($lastBlockExpiry > time() && $lastBlockExpiry > $this->longestBlockSeconds) {
595 1
            $this->longestBlockSeconds = $lastBlock[1];
596
        // Otherwise, test if the duration of the last block is now the longest overall.
597 3
        } elseif ($lastBlock[1] > $this->longestBlockSeconds) {
598 2
            $this->longestBlockSeconds = $lastBlock[1];
599
        }
600
601 4
        return $this->longestBlockSeconds;
602
    }
603
604
    /**
605
     * Given a block log entry from the database, get the timestamp and duration in seconds.
606
     * @param  mixed[] $block Block log entry as fetched via self::getBlocks()
607
     * @return int[] [
608
     *                 Unix timestamp,
609
     *                 Duration in seconds (-1 if indefinite, null if unparsable or unblock)
610
     *               ]
611
     */
612 11
    public function parseBlockLogEntry($block)
613
    {
614 11
        $timestamp = strtotime($block['log_timestamp']);
615 11
        $duration = null;
616
617
        // First check if the string is serialized, and if so parse it to get the block duration.
618 11
        if (@unserialize($block['log_params']) !== false) {
619 8
            $parsedParams = unserialize($block['log_params']);
620 8
            $durationStr = isset($parsedParams['5::duration']) ? $parsedParams['5::duration'] : null;
621
        } else {
622
            // Old format, the duration in English + block options separated by new lines.
623 4
            $durationStr = explode("\n", $block['log_params'])[0];
624
        }
625
626 11
        if (in_array($durationStr, ['indefinite', 'infinity', 'infinite'])) {
627 3
            $duration = -1;
628
        }
629
630
        // Make sure $durationStr is valid just in case it is in an older, unpredictable format.
631
        // If invalid, $duration is left as null.
632 11
        if (strtotime($durationStr)) {
633 8
            $expiry = strtotime($durationStr, $timestamp);
634 8
            $duration = $expiry - $timestamp;
635
        }
636
637 11
        return [$timestamp, $duration];
638
    }
639
640
    /**
641
     * Get the total number of pages protected by the user.
642
     * @return int
643
     */
644
    public function countPagesProtected()
645
    {
646
        $logCounts = $this->getLogCounts();
647
        return isset($logCounts['protect-protect']) ? (int)$logCounts['protect-protect'] : 0;
648
    }
649
650
    /**
651
     * Get the total number of pages reprotected by the user.
652
     * @return int
653
     */
654
    public function countPagesReprotected()
655
    {
656
        $logCounts = $this->getLogCounts();
657
        return isset($logCounts['protect-modify']) ? (int)$logCounts['protect-modify'] : 0;
658
    }
659
660
    /**
661
     * Get the total number of pages unprotected by the user.
662
     * @return int
663
     */
664
    public function countPagesUnprotected()
665
    {
666
        $logCounts = $this->getLogCounts();
667
        return isset($logCounts['protect-unprotect']) ? (int)$logCounts['protect-unprotect'] : 0;
668
    }
669
670
    /**
671
     * Get the total number of edits deleted by the user.
672
     * @return int
673
     */
674
    public function countEditsDeleted()
675
    {
676
        $logCounts = $this->getLogCounts();
677
        return isset($logCounts['delete-revision']) ? (int)$logCounts['delete-revision'] : 0;
678
    }
679
680
    /**
681
     * Get the total number of pages restored by the user.
682
     * @return int
683
     */
684
    public function countPagesRestored()
685
    {
686
        $logCounts = $this->getLogCounts();
687
        return isset($logCounts['delete-restore']) ? (int)$logCounts['delete-restore'] : 0;
688
    }
689
690
    /**
691
     * Get the total number of times the user has modified the rights of a user.
692
     * @return int
693
     */
694
    public function countRightsModified()
695
    {
696
        $logCounts = $this->getLogCounts();
697
        return isset($logCounts['rights-rights']) ? (int)$logCounts['rights-rights'] : 0;
698
    }
699
700
    /**
701
     * Get the total number of pages imported by the user (through any import mechanism:
702
     * interwiki, or XML upload).
703
     * @return int
704
     */
705
    public function countPagesImported()
706
    {
707
        $logCounts = $this->getLogCounts();
708
        $import = isset($logCounts['import-import']) ? (int)$logCounts['import-import'] : 0;
709
        $interwiki = isset($logCounts['import-interwiki']) ? (int)$logCounts['import-interwiki'] : 0;
710
        $upload = isset($logCounts['import-upload']) ? (int)$logCounts['import-upload'] : 0;
711
        return $import + $interwiki + $upload;
712
    }
713
714
    /**
715
     * Get the average number of edits per page (including deleted revisions and pages).
716
     * @return float
717
     */
718
    public function averageRevisionsPerPage()
719
    {
720
        if ($this->countAllPagesEdited() == 0) {
721
            return 0;
722
        }
723
        return round($this->countAllRevisions() / $this->countAllPagesEdited(), 3);
724
    }
725
726
    /**
727
     * Average number of edits made per day.
728
     * @return float
729
     */
730
    public function averageRevisionsPerDay()
731
    {
732
        if ($this->getDays() == 0) {
733
            return 0;
734
        }
735
        return round($this->countAllRevisions() / $this->getDays(), 3);
736
    }
737
738
    /**
739
     * Get the total number of edits made by the user with semi-automating tools.
740
     */
741
    public function countAutomatedEdits()
742
    {
743
        if ($this->autoEditCount) {
744
            return $this->autoEditCount;
745
        }
746
        $this->autoEditCount = $this->getRepository()->countAutomatedEdits(
747
            $this->project,
748
            $this->user
749
        );
750
        return $this->autoEditCount;
751
    }
752
753
    /**
754
     * Get the count of (non-deleted) edits made in the given timeframe to now.
755
     * @param string $time One of 'day', 'week', 'month', or 'year'.
756
     * @return int The total number of live edits.
757
     */
758
    public function countRevisionsInLast($time)
759
    {
760
        $revCounts = $this->getPairData();
761
        return isset($revCounts[$time]) ? $revCounts[$time] : 0;
762
    }
763
764
    /**
765
     * Get the date and time of the user's first edit.
766
     * @return DateTime|bool The time of the first revision, or false.
767
     */
768 2
    public function datetimeFirstRevision()
769
    {
770 2
        $revDates = $this->getPairData();
771 2
        return isset($revDates['first']) ? new DateTime($revDates['first']) : false;
772
    }
773
774
    /**
775
     * Get the date and time of the user's first edit.
776
     * @return DateTime|bool The time of the last revision, or false.
777
     */
778 2
    public function datetimeLastRevision()
779
    {
780 2
        $revDates = $this->getPairData();
781 2
        return isset($revDates['last']) ? new DateTime($revDates['last']) : false;
782
    }
783
784
    /**
785
     * Get the number of days between the first and last edits.
786
     * If there's only one edit, this is counted as one day.
787
     * @return int
788
     */
789 2
    public function getDays()
790
    {
791 2
        $first = $this->datetimeFirstRevision();
792 2
        $last = $this->datetimeLastRevision();
793 2
        if ($first === false || $last === false) {
794
            return 0;
795
        }
796 2
        $days = $last->diff($first)->days;
797 2
        return $days > 0 ? $days : 1;
798
    }
799
800
    /**
801
     * Get the total number of files uploaded (including those now deleted).
802
     * @return int
803
     */
804
    public function countFilesUploaded()
805
    {
806
        $logCounts = $this->getLogCounts();
807
        return $logCounts['upload-upload'] ?: 0;
808
    }
809
810
    /**
811
     * Get the total number of files uploaded to Commons (including those now deleted).
812
     * This is only applicable for WMF labs installations.
813
     * @return int
814
     */
815
    public function countFilesUploadedCommons()
816
    {
817
        $logCounts = $this->getLogCounts();
818
        return $logCounts['files_uploaded_commons'] ?: 0;
819
    }
820
821
    /**
822
     * Get the total number of revisions the user has sent thanks for.
823
     * @return int
824
     */
825
    public function thanks()
826
    {
827
        $logCounts = $this->getLogCounts();
828
        return $logCounts['thanks-thank'] ?: 0;
829
    }
830
831
    /**
832
     * Get the total number of approvals
833
     * @return int
834
     */
835
    public function approvals()
836
    {
837
        $logCounts = $this->getLogCounts();
838
        $total = $logCounts['review-approve'] +
839
        (!empty($logCounts['review-approve-a']) ? $logCounts['review-approve-a'] : 0) +
840
        (!empty($logCounts['review-approve-i']) ? $logCounts['review-approve-i'] : 0) +
841
        (!empty($logCounts['review-approve-ia']) ? $logCounts['review-approve-ia'] : 0);
842
        return $total;
843
    }
844
845
    /**
846
     * Get the total number of patrols performed by the user.
847
     * @return int
848
     */
849
    public function patrols()
850
    {
851
        $logCounts = $this->getLogCounts();
852
        return $logCounts['patrol-patrol'] ?: 0;
853
    }
854
855
    /**
856
     * Get the total number of accounts created by the user.
857
     * @return int
858
     */
859
    public function accountsCreated()
860
    {
861
        $logCounts = $this->getLogCounts();
862
        $create2 = $logCounts['newusers-create2'] ?: 0;
863
        $byemail = $logCounts['newusers-byemail'] ?: 0;
864
        return $create2 + $byemail;
865
    }
866
867
    /**
868
     * Get the given user's total edit counts per namespace.
869
     * @return integer[] Array keys are namespace IDs, values are the edit counts.
870
     */
871 1
    public function namespaceTotals()
872
    {
873 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...
874
            return $this->namespaceTotals;
875
        }
876 1
        $counts = $this->getRepository()->getNamespaceTotals($this->project, $this->user);
877 1
        arsort($counts);
878 1
        $this->namespaceTotals = $counts;
879 1
        return $counts;
880
    }
881
882
    /**
883
     * Get a summary of the times of day and the days of the week that the user has edited.
884
     * @return string[]
885
     */
886
    public function timeCard()
887
    {
888
        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...
889
            return $this->timeCardData;
890
        }
891
        $totals = $this->getRepository()->getTimeCard($this->project, $this->user);
892
893
        // Scale the radii: get the max, then scale each radius.
894
        // This looks inefficient, but there's a max of 72 elements in this array.
895
        $max = 0;
896
        foreach ($totals as $total) {
897
            $max = max($max, $total['value']);
898
        }
899
        foreach ($totals as &$total) {
900
            $total['value'] = round($total['value'] / $max * 100);
901
        }
902
903
        // Fill in zeros for timeslots that have no values.
904
        $sortedTotals = [];
905
        $index = 0;
906
        $sortedIndex = 0;
907
        foreach (range(1, 7) as $day) {
908
            foreach (range(0, 24, 2) as $hour) {
909
                if (isset($totals[$index]) && (int)$totals[$index]['x'] === $hour) {
910
                    $sortedTotals[$sortedIndex] = $totals[$index];
911
                    $index++;
912
                } else {
913
                    $sortedTotals[$sortedIndex] = [
914
                        'y' => $day,
915
                        'x' => $hour,
916
                        'value' => 0,
917
                    ];
918
                }
919
                $sortedIndex++;
920
            }
921
        }
922
923
        $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...
924
        return $sortedTotals;
925
    }
926
927
    /**
928
     * Get the total numbers of edits per month.
929
     * @param null|DateTime $currentTime - *USED ONLY FOR UNIT TESTING*
930
     *   so we can mock the current DateTime.
931
     * @return mixed[] With keys 'yearLabels', 'monthLabels' and 'totals',
932
     *   the latter keyed by namespace, year and then month.
933
     */
934 2
    public function monthCounts($currentTime = null)
935
    {
936 2
        if (isset($this->monthCounts)) {
937 1
            return $this->monthCounts;
938
        }
939
940
        // Set to current month if we're not unit-testing
941 2
        if (!($currentTime instanceof DateTime)) {
942
            $currentTime = new DateTime('last day of this month');
943
        }
944
945 2
        $totals = $this->getRepository()->getMonthCounts($this->project, $this->user);
946
        $out = [
947 2
            'yearLabels' => [],  // labels for years
948
            'monthLabels' => [], // labels for months
949
            'totals' => [], // actual totals, grouped by namespace, year and then month
950
        ];
951
952
        /** @var DateTime Keep track of the date of their first edit. */
953 2
        $firstEdit = new DateTime();
954
955 2
        list($out, $firstEdit) = $this->fillInMonthCounts($out, $totals, $firstEdit);
956
957 2
        $dateRange = new DatePeriod(
958 2
            $firstEdit,
959 2
            new DateInterval('P1M'),
960 2
            $currentTime->modify('first day of this month')
961
        );
962
963 2
        $out = $this->fillInMonthTotalsAndLabels($out, $dateRange);
964
965
        // One more set of loops to sort by year/month
966 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

966
        foreach (array_keys(/** @scrutinizer ignore-type */ $out['totals']) as $nsId) {
Loading history...
967 2
            ksort($out['totals'][$nsId]);
968
969 2
            foreach ($out['totals'][$nsId] as &$yearData) {
970 2
                ksort($yearData);
971
            }
972
        }
973
974
        // Finally, sort the namespaces
975 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

975
        ksort(/** @scrutinizer ignore-type */ $out['totals']);
Loading history...
976
977 2
        $this->monthCounts = $out;
978 2
        return $out;
979
    }
980
981
    /**
982
     * Loop through the database results and fill in the values
983
     * for the months that we have data for.
984
     * @param array $out
985
     * @param string[] $totals
986
     * @param DateTime $firstEdit
987
     * @return array [
988
     *           string[] - Modified $out filled with month stats,
989
     *           DateTime - timestamp of first edit
990
     *         ]
991
     * Tests covered in self::monthCounts().
992
     * @codeCoverageIgnore
993
     */
994
    private function fillInMonthCounts($out, $totals, $firstEdit)
995
    {
996
        foreach ($totals as $total) {
997
            // Keep track of first edit
998
            $date = new DateTime($total['year'].'-'.$total['month'].'-01');
999
            if ($date < $firstEdit) {
1000
                $firstEdit = $date;
1001
            }
1002
1003
            // Collate the counts by namespace, and then year and month.
1004
            $ns = $total['page_namespace'];
1005
            if (!isset($out['totals'][$ns])) {
1006
                $out['totals'][$ns] = [];
1007
            }
1008
1009
            // Start array for this year if not already present.
1010
            if (!isset($out['totals'][$ns][$total['year']])) {
1011
                $out['totals'][$ns][$total['year']] = [];
1012
            }
1013
1014
            $out['totals'][$ns][$total['year']][$total['month']] = (int) $total['count'];
1015
        }
1016
1017
        return [$out, $firstEdit];
1018
    }
1019
1020
    /**
1021
     * Given the output array, fill each month's totals and labels.
1022
     * @param array $out
1023
     * @param DatePeriod $dateRange From first edit to present.
1024
     * @return string[] - Modified $out filled with month stats.
1025
     * Tests covered in self::monthCounts().
1026
     * @codeCoverageIgnore
1027
     */
1028
    private function fillInMonthTotalsAndLabels($out, DatePeriod $dateRange)
1029
    {
1030
        foreach ($dateRange as $monthObj) {
1031
            $year = (int) $monthObj->format('Y');
1032
            $yearLabel = $this->i18n->dateFormat($monthObj, 'yyyy');
1033
            $month = (int) $monthObj->format('n');
1034
            $monthLabel = $this->i18n->dateFormat($monthObj, 'yyyy-MM');
1035
1036
            // Fill in labels
1037
            $out['monthLabels'][] = $monthLabel;
1038
            if (!in_array($yearLabel, $out['yearLabels'])) {
1039
                $out['yearLabels'][] = $yearLabel;
1040
            }
1041
1042
            foreach (array_keys($out['totals']) as $nsId) {
1043
                if (!isset($out['totals'][$nsId][$year])) {
1044
                    $out['totals'][$nsId][$year] = [];
1045
                }
1046
1047
                if (!isset($out['totals'][$nsId][$year][$month])) {
1048
                    $out['totals'][$nsId][$year][$month] = 0;
1049
                }
1050
            }
1051
        }
1052
1053
        return $out;
1054
    }
1055
1056
    /**
1057
     * Get total edits for each month. Used in wikitext export.
1058
     * @param  null|DateTime $currentTime *USED ONLY FOR UNIT TESTING*
1059
     * @return array With the months as the keys, counts as the values.
1060
     */
1061 1
    public function monthTotals($currentTime = null)
1062
    {
1063 1
        $months = [];
1064
1065 1
        foreach ($this->monthCounts($currentTime)['totals'] as $nsId => $nsData) {
1066 1
            foreach ($nsData as $year => $monthData) {
1067 1
                foreach ($monthData as $month => $count) {
1068 1
                    $monthLabel = $year.'-'.sprintf('%02d', $month);
1069 1
                    if (!isset($months[$monthLabel])) {
1070 1
                        $months[$monthLabel] = 0;
1071
                    }
1072 1
                    $months[$monthLabel] += $count;
1073
                }
1074
            }
1075
        }
1076
1077 1
        return $months;
1078
    }
1079
1080
    /**
1081
     * Get the total numbers of edits per year.
1082
     * @param null|DateTime $currentTime - *USED ONLY FOR UNIT TESTING*
1083
     *   so we can mock the current DateTime.
1084
     * @return mixed[] With keys 'yearLabels' and 'totals', the latter
1085
     *   keyed by namespace then year.
1086
     */
1087 2
    public function yearCounts($currentTime = null)
1088
    {
1089 2
        if (isset($this->yearCounts)) {
1090
            return $this->yearCounts;
1091
        }
1092
1093 2
        $out = $this->monthCounts($currentTime);
1094
1095 2
        foreach ($out['totals'] as $nsId => $years) {
1096 2
            foreach ($years as $year => $months) {
1097 2
                $out['totals'][$nsId][$year] = array_sum(array_values($months));
1098
            }
1099
        }
1100
1101 2
        $this->yearCounts = $out;
1102 2
        return $out;
1103
    }
1104
1105
    /**
1106
     * Get total edits for each year. Used in wikitext export.
1107
     * @param  null|DateTime $currentTime *USED ONLY FOR UNIT TESTING*
1108
     * @return array With the years as the keys, counts as the values.
1109
     */
1110 1
    public function yearTotals($currentTime = null)
1111
    {
1112 1
        $years = [];
1113
1114 1
        foreach ($this->yearCounts($currentTime)['totals'] as $nsId => $nsData) {
1115 1
            foreach ($nsData as $year => $count) {
1116 1
                if (!isset($years[$year])) {
1117 1
                    $years[$year] = 0;
1118
                }
1119 1
                $years[$year] += $count;
1120
            }
1121
        }
1122
1123 1
        return $years;
1124
    }
1125
1126
    /**
1127
     * Get the total edit counts for the top n projects of this user.
1128
     * @param int $numProjects
1129
     * @return mixed[] Each element has 'total' and 'project' keys.
1130
     */
1131 1
    public function globalEditCountsTopN($numProjects = 10)
1132
    {
1133
        // Get counts.
1134 1
        $editCounts = $this->globalEditCounts(true);
1135
        // Truncate, and return.
1136 1
        return array_slice($editCounts, 0, $numProjects);
1137
    }
1138
1139
    /**
1140
     * Get the total number of edits excluding the top n.
1141
     * @param int $numProjects
1142
     * @return int
1143
     */
1144 1
    public function globalEditCountWithoutTopN($numProjects = 10)
1145
    {
1146 1
        $editCounts = $this->globalEditCounts(true);
1147 1
        $bottomM = array_slice($editCounts, $numProjects);
1148 1
        $total = 0;
1149 1
        foreach ($bottomM as $editCount) {
1150 1
            $total += $editCount['total'];
1151
        }
1152 1
        return $total;
1153
    }
1154
1155
    /**
1156
     * Get the grand total of all edits on all projects.
1157
     * @return int
1158
     */
1159 1
    public function globalEditCount()
1160
    {
1161 1
        $total = 0;
1162 1
        foreach ($this->globalEditCounts() as $editCount) {
1163 1
            $total += $editCount['total'];
1164
        }
1165 1
        return $total;
1166
    }
1167
1168
    /**
1169
     * Get the total revision counts for all projects for this user.
1170
     * @param bool $sorted Whether to sort the list by total, or not.
1171
     * @return mixed[] Each element has 'total' and 'project' keys.
1172
     */
1173 1
    public function globalEditCounts($sorted = false)
1174
    {
1175 1
        if (empty($this->globalEditCounts)) {
1176 1
            $this->globalEditCounts = $this->getRepository()
1177 1
                ->globalEditCounts($this->user, $this->project);
1178
        }
1179
1180 1
        if ($sorted) {
1181
            // Sort.
1182 1
            uasort($this->globalEditCounts, function ($a, $b) {
1183 1
                return $b['total'] - $a['total'];
1184 1
            });
1185
        }
1186
1187 1
        return $this->globalEditCounts;
1188
    }
1189
1190
    /**
1191
     * Get the most recent n revisions across all projects.
1192
     * @param int $max The maximum number of revisions to return.
1193
     * @return Edit[]
1194
     */
1195
    public function globalEdits($max)
1196
    {
1197
        if (is_array($this->globalEdits)) {
0 ignored issues
show
introduced by
The condition is_array($this->globalEdits) is always true.
Loading history...
1198
            return $this->globalEdits;
1199
        }
1200
1201
        // Collect all projects with any edits.
1202
        $projects = [];
1203
        foreach ($this->globalEditCounts() as $editCount) {
1204
            // Don't query revisions if there aren't any.
1205
            if ($editCount['total'] == 0) {
1206
                continue;
1207
            }
1208
            $projects[$editCount['project']->getDatabaseName()] = $editCount['project'];
1209
        }
1210
1211
        if (count($projects) === 0) {
1212
            return [];
1213
        }
1214
1215
        // Get all revisions for those projects.
1216
        $globalRevisionsData = $this->getRepository()
1217
            ->getRevisions($projects, $this->user, $max);
1218
        $globalEdits = [];
1219
        foreach ($globalRevisionsData as $revision) {
1220
            /** @var Project $project */
1221
            $project = $projects[$revision['project_name']];
1222
            $nsName = '';
1223
            if ($revision['page_namespace']) {
1224
                $nsName = $project->getNamespaces()[$revision['page_namespace']];
1225
            }
1226
            $page = $project->getRepository()
1227
                ->getPage($project, $nsName . ':' . $revision['page_title']);
1228
            $edit = new Edit($page, $revision);
1229
            $globalEdits[$edit->getTimestamp()->getTimestamp().'-'.$edit->getId()] = $edit;
1230
        }
1231
1232
        // Sort and prune, before adding more.
1233
        krsort($globalEdits);
1234
        $this->globalEdits = array_slice($globalEdits, 0, $max);
1235
1236
        return $this->globalEdits;
1237
    }
1238
1239
    /**
1240
     * Get average edit size, and number of large and small edits.
1241
     * @return int[]
1242
     */
1243
    public function getEditSizeData()
1244
    {
1245
        if (!is_array($this->editSizeData)) {
0 ignored issues
show
introduced by
The condition is_array($this->editSizeData) is always true.
Loading history...
1246
            $this->editSizeData = $this->getRepository()
1247
                ->getEditSizeData($this->project, $this->user);
1248
        }
1249
        return $this->editSizeData;
1250
    }
1251
1252
    /**
1253
     * Get the total edit count of this user or 5,000 if they've made more than 5,000 edits.
1254
     * This is used to ensure percentages of small and large edits are computed properly.
1255
     * @return int
1256
     */
1257 1
    public function countLast5000()
1258
    {
1259 1
        return $this->countLiveRevisions() > 5000 ? 5000 : $this->countLiveRevisions();
1260
    }
1261
1262
    /**
1263
     * Get the number of edits under 20 bytes of the user's past 5000 edits.
1264
     * @return int
1265
     */
1266
    public function countSmallEdits()
1267
    {
1268
        $editSizeData = $this->getEditSizeData();
1269
        return isset($editSizeData['small_edits']) ? (int) $editSizeData['small_edits'] : 0;
1270
    }
1271
1272
    /**
1273
     * Get the total number of edits over 1000 bytes of the user's past 5000 edits.
1274
     * @return int
1275
     */
1276
    public function countLargeEdits()
1277
    {
1278
        $editSizeData = $this->getEditSizeData();
1279
        return isset($editSizeData['large_edits']) ? (int) $editSizeData['large_edits'] : 0;
1280
    }
1281
1282
    /**
1283
     * Get the average size of the user's past 5000 edits.
1284
     * @return float Size in bytes.
1285
     */
1286
    public function averageEditSize()
1287
    {
1288
        $editSizeData = $this->getEditSizeData();
1289
        if (isset($editSizeData['average_size'])) {
1290
            return round($editSizeData['average_size'], 3);
1291
        } else {
1292
            return 0;
1293
        }
1294
    }
1295
}
1296