Passed
Push — master ( 9287df...ac1a9d )
by MusikAnimal
06:03
created

EditCounter::averageRevisionsPerDay()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 6
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 6

Importance

Changes 0
Metric Value
cc 2
eloc 3
nc 2
nop 0
dl 0
loc 6
ccs 0
cts 4
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
declare(strict_types = 1);
7
8
namespace AppBundle\Model;
9
10
use AppBundle\Helper\I18nHelper;
11
use DateInterval;
12
use DatePeriod;
13
use DateTime;
14
15
/**
16
 * An EditCounter provides statistics about a user's edits on a project.
17
 */
18
class EditCounter extends UserRights
19
{
20
    /** @var int[] Revision and page counts etc. */
21
    protected $pairData;
22
23
    /** @var string[] The IDs and timestamps of first/latest edit and logged action. */
24
    protected $firstAndLatestActions;
25
26
    /** @var int[] The total page counts. */
27
    protected $pageCounts;
28
29
    /** @var int[] The lot totals. */
30
    protected $logCounts;
31
32
    /** @var mixed[] Total numbers of edits per month */
33
    protected $monthCounts;
34
35
    /** @var mixed[] Total numbers of edits per year */
36
    protected $yearCounts;
37
38
    /** @var int[] Keys are project DB names. */
39
    protected $globalEditCounts;
40
41
    /** @var array Block data, with keys 'set' and 'received'. */
42
    protected $blocks;
43
44
    /** @var integer[] Array keys are namespace IDs, values are the edit counts. */
45
    protected $namespaceTotals;
46
47
    /** @var int Number of semi-automated edits. */
48
    protected $autoEditCount;
49
50
    /** @var string[] Data needed for time card chart. */
51
    protected $timeCardData;
52
53
    /** @var array Most recent revisions across all projects. */
54
    protected $globalEdits;
55
56
    /**
57
     * Revision size data, with keys 'average_size', 'large_edits' and 'small_edits'.
58
     * @var string[] As returned by the DB, unconverted to int or float
59
     */
60
    protected $editSizeData;
61
62
    /**
63
     * Duration of the longest block in seconds; -1 if indefinite,
64
     *   or false if could not be parsed from log params
65
     * @var int|bool
66
     */
67
    protected $longestBlockSeconds;
68
69
    /**
70
     * EditCounter constructor.
71
     * @param Project $project The base project to count edits
72
     * @param User $user
73
     * @param I18nHelper $i18n
74
     */
75 19
    public function __construct(Project $project, User $user, I18nHelper $i18n)
76
    {
77 19
        $this->project = $project;
78 19
        $this->user = $user;
79 19
        $this->i18n = $i18n;
80 19
    }
81
82
    /**
83
     * Get revision and page counts etc.
84
     * @return int[]
85
     */
86 2
    public function getPairData(): array
87
    {
88 2
        if (!is_array($this->pairData)) {
0 ignored issues
show
introduced by
The condition is_array($this->pairData) is always true.
Loading history...
89 2
            $this->pairData = $this->getRepository()
90 2
                ->getPairData($this->project, $this->user);
91
        }
92 2
        return $this->pairData;
93
    }
94
95
    /**
96
     * Get revision dates.
97
     * @return array
98
     */
99
    public function getLogCounts(): array
100
    {
101
        if (!is_array($this->logCounts)) {
0 ignored issues
show
introduced by
The condition is_array($this->logCounts) is always true.
Loading history...
102
            $this->logCounts = $this->getRepository()
103
                ->getLogCounts($this->project, $this->user);
104
        }
105
        return $this->logCounts;
106
    }
107
108
    /**
109
     * Get the IDs and timestamps of the latest edit and logged action.
110
     * @return string[] With keys 'rev_first', 'rev_latest', 'log_latest', each with 'id' and 'timestamp'.
111
     */
112 1
    public function getFirstAndLatestActions(): array
113
    {
114 1
        if (!isset($this->firstAndLatestActions)) {
115 1
            $this->firstAndLatestActions = $this->getRepository()->getFirstAndLatestActions(
0 ignored issues
show
Bug introduced by
The method getFirstAndLatestActions() does not exist on AppBundle\Repository\Repository. It seems like you code against a sub-type of AppBundle\Repository\Repository such as AppBundle\Repository\EditCounterRepository. ( Ignorable by Annotation )

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

115
            $this->firstAndLatestActions = $this->getRepository()->/** @scrutinizer ignore-call */ getFirstAndLatestActions(
Loading history...
116 1
                $this->project,
117 1
                $this->user
118
            );
119
        }
120 1
        return $this->firstAndLatestActions;
121
    }
122
123
    /**
124
     * Get block data.
125
     * @param string $type Either 'set', 'received'
126
     * @param bool $blocksOnly Whether to include only blocks, and not reblocks and unblocks.
127
     * @return array
128
     */
129 6
    protected function getBlocks(string $type, bool $blocksOnly = true): array
130
    {
131 6
        if (isset($this->blocks[$type]) && is_array($this->blocks[$type])) {
132
            return $this->blocks[$type];
133
        }
134 6
        $method = "getBlocks".ucfirst($type);
135 6
        $blocks = $this->getRepository()->$method($this->project, $this->user);
136 6
        $this->blocks[$type] = $blocks;
137
138
        // Filter out unblocks unless requested.
139 6
        if ($blocksOnly) {
140
            $blocks = array_filter($blocks, function ($block) {
141
                return 'block' === $block['log_action'];
142
            });
143
        }
144
145 6
        return $blocks;
146
    }
147
148
    /**
149
     * Get the total number of currently-live revisions.
150
     * @return int
151
     */
152 1
    public function countLiveRevisions(): int
153
    {
154 1
        $revCounts = $this->getPairData();
155 1
        return isset($revCounts['live']) ? (int)$revCounts['live'] : 0;
156
    }
157
158
    /**
159
     * Get the total number of the user's revisions that have been deleted.
160
     * @return int
161
     */
162 1
    public function countDeletedRevisions(): int
163
    {
164 1
        $revCounts = $this->getPairData();
165 1
        return isset($revCounts['deleted']) ? (int)$revCounts['deleted'] : 0;
166
    }
167
168
    /**
169
     * Get the total edit count (live + deleted).
170
     * @return int
171
     */
172 1
    public function countAllRevisions(): int
173
    {
174 1
        return $this->countLiveRevisions() + $this->countDeletedRevisions();
175
    }
176
177
    /**
178
     * Get the total number of revisions marked as 'minor' by the user.
179
     * @return int
180
     */
181
    public function countMinorRevisions(): int
182
    {
183
        $revCounts = $this->getPairData();
184
        return isset($revCounts['minor']) ? (int)$revCounts['minor'] : 0;
185
    }
186
187
    /**
188
     * Get the total number of non-deleted pages edited by the user.
189
     * @return int
190
     */
191 1
    public function countLivePagesEdited(): int
192
    {
193 1
        $pageCounts = $this->getPairData();
194 1
        return isset($pageCounts['edited-live']) ? (int)$pageCounts['edited-live'] : 0;
195
    }
196
197
    /**
198
     * Get the total number of deleted pages ever edited by the user.
199
     * @return int
200
     */
201 1
    public function countDeletedPagesEdited(): int
202
    {
203 1
        $pageCounts = $this->getPairData();
204 1
        return isset($pageCounts['edited-deleted']) ? (int)$pageCounts['edited-deleted'] : 0;
205
    }
206
207
    /**
208
     * Get the total number of pages ever edited by this user (both live and deleted).
209
     * @return int
210
     */
211 1
    public function countAllPagesEdited(): int
212
    {
213 1
        return $this->countLivePagesEdited() + $this->countDeletedPagesEdited();
214
    }
215
216
    /**
217
     * Get the total number of pages (both still live and those that have been deleted) created
218
     * by the user.
219
     * @return int
220
     */
221 1
    public function countPagesCreated(): int
222
    {
223 1
        return $this->countCreatedPagesLive() + $this->countPagesCreatedDeleted();
224
    }
225
226
    /**
227
     * Get the total number of pages created by the user, that have not been deleted.
228
     * @return int
229
     */
230 1
    public function countCreatedPagesLive(): int
231
    {
232 1
        $pageCounts = $this->getPairData();
233 1
        return isset($pageCounts['created-live']) ? (int)$pageCounts['created-live'] : 0;
234
    }
235
236
    /**
237
     * Get the total number of pages created by the user, that have since been deleted.
238
     * @return int
239
     */
240 1
    public function countPagesCreatedDeleted(): int
241
    {
242 1
        $pageCounts = $this->getPairData();
243 1
        return isset($pageCounts['created-deleted']) ? (int)$pageCounts['created-deleted'] : 0;
244
    }
245
246
    /**
247
     * Get the total number of pages that have been deleted by the user.
248
     * @return int
249
     */
250
    public function countPagesDeleted(): int
251
    {
252
        $logCounts = $this->getLogCounts();
253
        return isset($logCounts['delete-delete']) ? (int)$logCounts['delete-delete'] : 0;
254
    }
255
256
    /**
257
     * Get the total number of pages moved by the user.
258
     * @return int
259
     */
260
    public function countPagesMoved(): int
261
    {
262
        $logCounts = $this->getLogCounts();
263
        return isset($logCounts['move-move']) ? (int)$logCounts['move-move'] : 0;
264
    }
265
266
    /**
267
     * Get the total number of times the user has blocked a user.
268
     * @return int
269
     */
270
    public function countBlocksSet(): int
271
    {
272
        $logCounts = $this->getLogCounts();
273
        $reBlock = isset($logCounts['block-block']) ? (int)$logCounts['block-block'] : 0;
274
        return $reBlock;
275
    }
276
277
    /**
278
     * Get the total number of times the user has re-blocked a user.
279
     * @return int
280
     */
281
    public function countReblocksSet(): int
282
    {
283
        $logCounts = $this->getLogCounts();
284
        $reBlock = isset($logCounts['block-reblock']) ? (int)$logCounts['block-reblock'] : 0;
285
        return $reBlock;
286
    }
287
288
    /**
289
     * Get the total number of times the user has unblocked a user.
290
     * @return int
291
     */
292
    public function countUnblocksSet(): int
293
    {
294
        $logCounts = $this->getLogCounts();
295
        return isset($logCounts['block-unblock']) ? (int)$logCounts['block-unblock'] : 0;
296
    }
297
298
    /**
299
     * Get the total number of blocks that have been lifted (i.e. unblocks) by this user.
300
     * @return int
301
     */
302
    public function countBlocksLifted(): int
303
    {
304
        $logCounts = $this->getLogCounts();
305
        return isset($logCounts['block-unblock']) ? (int)$logCounts['block-unblock'] : 0;
306
    }
307
308
    /**
309
     * Get the total number of times the user has been blocked.
310
     * @return int
311
     */
312
    public function countBlocksReceived(): int
313
    {
314
        $blocks = $this->getBlocks('received');
315
        return count($blocks);
316
    }
317
318
    /**
319
     * Get the length of the longest block the user received, in seconds.
320
     * If the user is blocked, the time since the block is returned. If the block is
321
     * indefinite, -1 is returned. 0 if there was never a block.
322
     * @return int|false Number of seconds or false if it could not be determined.
323
     */
324 6
    public function getLongestBlockSeconds()
325
    {
326 6
        if (isset($this->longestBlockSeconds)) {
327
            return $this->longestBlockSeconds;
328
        }
329
330 6
        $blocks = $this->getBlocks('received', false);
331 6
        $this->longestBlockSeconds = false;
332
333
        // If there was never a block, the longest was zero seconds.
334 6
        if (empty($blocks)) {
335
            return 0;
336
        }
337
338
        /**
339
         * Keep track of the last block so we can determine the duration
340
         * if the current block in the loop is an unblock.
341
         * @var int[] [
342
         *              Unix timestamp,
343
         *              Duration in seconds (-1 if indefinite)
344
         *            ]
345
         */
346 6
        $lastBlock = [null, null];
347
348 6
        foreach (array_values($blocks) as $block) {
349 6
            [$timestamp, $duration] = $this->parseBlockLogEntry($block);
350
351 6
            if ('block' === $block['log_action']) {
352
                // This is a new block, so first see if the duration of the last
353
                // block exceeded our longest duration. -1 duration means indefinite.
354 6
                if ($lastBlock[1] > $this->longestBlockSeconds || -1 === $lastBlock[1]) {
355 2
                    $this->longestBlockSeconds = $lastBlock[1];
356
                }
357
358
                // Now set this as the last block.
359 6
                $lastBlock = [$timestamp, $duration];
360 3
            } elseif ('unblock' === $block['log_action']) {
361
                // The last block was lifted. So the duration will be the time from when the
362
                // last block was set to the time of the unblock.
363 1
                $timeSinceLastBlock = $timestamp - $lastBlock[0];
364 1
                if ($timeSinceLastBlock > $this->longestBlockSeconds) {
365 1
                    $this->longestBlockSeconds = $timeSinceLastBlock;
366
367
                    // Reset the last block, as it has now been accounted for.
368 1
                    $lastBlock = null;
369
                }
370 2
            } elseif ('reblock' === $block['log_action'] && -1 !== $lastBlock[1]) {
371
                // The last block was modified. So we will adjust $lastBlock to include
372
                // the difference of the duration of the new reblock, and time since the last block.
373
                // $lastBlock is left unchanged if its duration was indefinite.
374 1
                $timeSinceLastBlock = $timestamp - $lastBlock[0];
375 6
                $lastBlock[1] = $timeSinceLastBlock + $duration;
376
            }
377
        }
378
379
        // If the last block was indefinite, we'll return that as the longest duration.
380 6
        if (-1 === $lastBlock[1]) {
381 2
            return -1;
382
        }
383
384
        // Test if the last block is still active, and if so use the expiry as the duration.
385 4
        $lastBlockExpiry = $lastBlock[0] + $lastBlock[1];
386 4
        if ($lastBlockExpiry > time() && $lastBlockExpiry > $this->longestBlockSeconds) {
387 1
            $this->longestBlockSeconds = $lastBlock[1];
388
        // Otherwise, test if the duration of the last block is now the longest overall.
389 3
        } elseif ($lastBlock[1] > $this->longestBlockSeconds) {
390 2
            $this->longestBlockSeconds = $lastBlock[1];
391
        }
392
393 4
        return $this->longestBlockSeconds;
394
    }
395
396
    /**
397
     * Given a block log entry from the database, get the timestamp and duration in seconds.
398
     * @param  mixed[] $block Block log entry as fetched via self::getBlocks()
399
     * @return int[] [
400
     *                 Unix timestamp,
401
     *                 Duration in seconds (-1 if indefinite, null if unparsable or unblock)
402
     *               ]
403
     */
404 11
    public function parseBlockLogEntry(array $block): array
405
    {
406 11
        $timestamp = strtotime($block['log_timestamp']);
407 11
        $duration = null;
408
409
        // log_params may be null, but we need to treat it like a string.
410 11
        $block['log_params'] = (string)$block['log_params'];
411
412
        // First check if the string is serialized, and if so parse it to get the block duration.
413 11
        if (false !== @unserialize($block['log_params'])) {
414 8
            $parsedParams = unserialize($block['log_params']);
415 8
            $durationStr = $parsedParams['5::duration'] ?? '';
416
        } else {
417
            // Old format, the duration in English + block options separated by new lines.
418 4
            $durationStr = explode("\n", $block['log_params'])[0];
419
        }
420
421 11
        if (in_array($durationStr, ['indefinite', 'infinity', 'infinite'])) {
422 3
            $duration = -1;
423
        }
424
425
        // Make sure $durationStr is valid just in case it is in an older, unpredictable format.
426
        // If invalid, $duration is left as null.
427 11
        if (strtotime($durationStr)) {
428 8
            $expiry = strtotime($durationStr, $timestamp);
429 8
            $duration = $expiry - $timestamp;
430
        }
431
432 11
        return [$timestamp, $duration];
433
    }
434
435
    /**
436
     * Get the total number of pages protected by the user.
437
     * @return int
438
     */
439
    public function countPagesProtected(): int
440
    {
441
        $logCounts = $this->getLogCounts();
442
        return isset($logCounts['protect-protect']) ? (int)$logCounts['protect-protect'] : 0;
443
    }
444
445
    /**
446
     * Get the total number of pages reprotected by the user.
447
     * @return int
448
     */
449
    public function countPagesReprotected(): int
450
    {
451
        $logCounts = $this->getLogCounts();
452
        return isset($logCounts['protect-modify']) ? (int)$logCounts['protect-modify'] : 0;
453
    }
454
455
    /**
456
     * Get the total number of pages unprotected by the user.
457
     * @return int
458
     */
459
    public function countPagesUnprotected(): int
460
    {
461
        $logCounts = $this->getLogCounts();
462
        return isset($logCounts['protect-unprotect']) ? (int)$logCounts['protect-unprotect'] : 0;
463
    }
464
465
    /**
466
     * Get the total number of edits deleted by the user.
467
     * @return int
468
     */
469
    public function countEditsDeleted(): int
470
    {
471
        $logCounts = $this->getLogCounts();
472
        return isset($logCounts['delete-revision']) ? (int)$logCounts['delete-revision'] : 0;
473
    }
474
475
    /**
476
     * Get the total number of log entries deleted by the user.
477
     * @return int
478
     */
479
    public function countLogsDeleted(): int
480
    {
481
        $revCounts = $this->getLogCounts();
482
        return isset($revCounts['delete-event']) ? (int)$revCounts['delete-event'] : 0;
483
    }
484
485
    /**
486
     * Get the total number of pages restored by the user.
487
     * @return int
488
     */
489
    public function countPagesRestored(): int
490
    {
491
        $logCounts = $this->getLogCounts();
492
        return isset($logCounts['delete-restore']) ? (int)$logCounts['delete-restore'] : 0;
493
    }
494
495
    /**
496
     * Get the total number of times the user has modified the rights of a user.
497
     * @return int
498
     */
499
    public function countRightsModified(): int
500
    {
501
        $logCounts = $this->getLogCounts();
502
        return isset($logCounts['rights-rights']) ? (int)$logCounts['rights-rights'] : 0;
503
    }
504
505
    /**
506
     * Get the total number of pages imported by the user (through any import mechanism:
507
     * interwiki, or XML upload).
508
     * @return int
509
     */
510
    public function countPagesImported(): int
511
    {
512
        $logCounts = $this->getLogCounts();
513
        $import = isset($logCounts['import-import']) ? (int)$logCounts['import-import'] : 0;
514
        $interwiki = isset($logCounts['import-interwiki']) ? (int)$logCounts['import-interwiki'] : 0;
515
        $upload = isset($logCounts['import-upload']) ? (int)$logCounts['import-upload'] : 0;
516
        return $import + $interwiki + $upload;
517
    }
518
519
    public function countAbuseFilterchanges(): int
520
    {
521
        $logCounts = $this->getLogCounts();
522
        return isset($logCounts['abusefilter-modify']) ? (int)$logCounts['abusefilter-modify'] : 0;
523
    }
524
525
    /**
526
     * Get the average number of edits per page (including deleted revisions and pages).
527
     * @return float
528
     */
529
    public function averageRevisionsPerPage(): float
530
    {
531
        if (0 == $this->countAllPagesEdited()) {
532
            return 0;
533
        }
534
        return round($this->countAllRevisions() / $this->countAllPagesEdited(), 3);
535
    }
536
537
    /**
538
     * Average number of edits made per day.
539
     * @return float
540
     */
541
    public function averageRevisionsPerDay(): float
542
    {
543
        if (0 == $this->getDays()) {
544
            return 0;
545
        }
546
        return round($this->countAllRevisions() / $this->getDays(), 3);
547
    }
548
549
    /**
550
     * Get the total number of edits made by the user with semi-automating tools.
551
     */
552
    public function countAutomatedEdits(): int
553
    {
554
        if ($this->autoEditCount) {
555
            return $this->autoEditCount;
556
        }
557
        $this->autoEditCount = $this->getRepository()->countAutomatedEdits(
0 ignored issues
show
Bug introduced by
The method countAutomatedEdits() does not exist on AppBundle\Repository\Repository. It seems like you code against a sub-type of AppBundle\Repository\Repository such as AppBundle\Repository\AutoEditsRepository or AppBundle\Repository\EditCounterRepository. ( Ignorable by Annotation )

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

557
        $this->autoEditCount = $this->getRepository()->/** @scrutinizer ignore-call */ countAutomatedEdits(
Loading history...
558
            $this->project,
559
            $this->user
560
        );
561
        return $this->autoEditCount;
562
    }
563
564
    /**
565
     * Get the count of (non-deleted) edits made in the given timeframe to now.
566
     * @param string $time One of 'day', 'week', 'month', or 'year'.
567
     * @return int The total number of live edits.
568
     */
569
    public function countRevisionsInLast(string $time): int
570
    {
571
        $revCounts = $this->getPairData();
572
        return $revCounts[$time] ?? 0;
573
    }
574
575
    /**
576
     * Get the number of days between the first and last edits.
577
     * If there's only one edit, this is counted as one day.
578
     * @return int
579
     */
580 1
    public function getDays(): int
581
    {
582 1
        $first = isset($this->getFirstAndLatestActions()['rev_first']['timestamp'])
583 1
            ? new DateTime($this->getFirstAndLatestActions()['rev_first']['timestamp'])
584 1
            : false;
585 1
        $latest = isset($this->getFirstAndLatestActions()['rev_latest']['timestamp'])
586 1
            ? new DateTime($this->getFirstAndLatestActions()['rev_latest']['timestamp'])
587 1
            : false;
588
589 1
        if (false === $first || false === $latest) {
590
            return 0;
591
        }
592
593 1
        $days = $latest->diff($first)->days;
594
595 1
        return $days > 0 ? $days : 1;
596
    }
597
598
    /**
599
     * Get the total number of files uploaded (including those now deleted).
600
     * @return int
601
     */
602
    public function countFilesUploaded(): int
603
    {
604
        $logCounts = $this->getLogCounts();
605
        return $logCounts['upload-upload'] ?: 0;
606
    }
607
608
    /**
609
     * Get the total number of files uploaded to Commons (including those now deleted).
610
     * This is only applicable for WMF labs installations.
611
     * @return int
612
     */
613
    public function countFilesUploadedCommons(): int
614
    {
615
        $logCounts = $this->getLogCounts();
616
        return $logCounts['files_uploaded_commons'] ?: 0;
617
    }
618
619
    /**
620
     * Get the total number of revisions the user has sent thanks for.
621
     * @return int
622
     */
623
    public function thanks(): int
624
    {
625
        $logCounts = $this->getLogCounts();
626
        return $logCounts['thanks-thank'] ?: 0;
627
    }
628
629
    /**
630
     * Get the total number of approvals
631
     * @return int
632
     */
633
    public function approvals(): int
634
    {
635
        $logCounts = $this->getLogCounts();
636
        $total = (!empty($logCounts['review-approve']) ? $logCounts['review-approve'] : 0) +
637
            (!empty($logCounts['review-approve2']) ? $logCounts['review-approve2'] : 0) +
638
            (!empty($logCounts['review-approve-i']) ? $logCounts['review-approve-i'] : 0) +
639
            (!empty($logCounts['review-approve2-i']) ? $logCounts['review2-approve-i'] : 0);
640
        return $total;
641
    }
642
643
    /**
644
     * Get the total number of patrols performed by the user.
645
     * @return int
646
     */
647
    public function patrols(): int
648
    {
649
        $logCounts = $this->getLogCounts();
650
        return $logCounts['patrol-patrol'] ?: 0;
651
    }
652
653
    /**
654
     * Get the total number of accounts created by the user.
655
     * @return int
656
     */
657
    public function accountsCreated(): int
658
    {
659
        $logCounts = $this->getLogCounts();
660
        $create2 = $logCounts['newusers-create2'] ?: 0;
661
        $byemail = $logCounts['newusers-byemail'] ?: 0;
662
        return $create2 + $byemail;
663
    }
664
665
    /**
666
     * Get the number of history merges performed by the user.
667
     * @return int
668
     */
669
    public function merges(): int
670
    {
671
        $logCounts = $this->getLogCounts();
672
        return $logCounts['merge-merge'];
673
    }
674
675
    /**
676
     * Get the given user's total edit counts per namespace.
677
     * @return array Array keys are namespace IDs, values are the edit counts.
678
     */
679 1
    public function namespaceTotals(): array
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);
0 ignored issues
show
Bug introduced by
The method getNamespaceTotals() does not exist on AppBundle\Repository\Repository. It seems like you code against a sub-type of AppBundle\Repository\Repository such as AppBundle\Repository\EditCounterRepository. ( Ignorable by Annotation )

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

684
        $counts = $this->getRepository()->/** @scrutinizer ignore-call */ getNamespaceTotals($this->project, $this->user);
Loading history...
685 1
        arsort($counts);
686 1
        $this->namespaceTotals = $counts;
687 1
        return $counts;
688
    }
689
690
    /**
691
     * Get the total number of live edits by summing the namespace totals.
692
     * This is used in the view for namespace totals so we don't unnecessarily run the self::getPairData() query.
693
     * @return int
694
     */
695
    public function liveRevisionsFromNamespaces(): int
696
    {
697
        return array_sum($this->namespaceTotals());
698
    }
699
700
    /**
701
     * Get a summary of the times of day and the days of the week that the user has edited.
702
     * @return string[]
703
     */
704
    public function timeCard(): array
705
    {
706
        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...
707
            return $this->timeCardData;
708
        }
709
        $totals = $this->getRepository()->getTimeCard($this->project, $this->user);
0 ignored issues
show
Bug introduced by
The method getTimeCard() does not exist on AppBundle\Repository\Repository. It seems like you code against a sub-type of AppBundle\Repository\Repository such as AppBundle\Repository\EditCounterRepository. ( Ignorable by Annotation )

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

709
        $totals = $this->getRepository()->/** @scrutinizer ignore-call */ getTimeCard($this->project, $this->user);
Loading history...
710
711
        // Scale the radii: get the max, then scale each radius.
712
        // This looks inefficient, but there's a max of 72 elements in this array.
713
        $max = 0;
714
        foreach ($totals as $total) {
715
            $max = max($max, $total['value']);
716
        }
717
        foreach ($totals as &$total) {
718
            $total['value'] = round($total['value'] / $max * 100);
719
        }
720
721
        // Fill in zeros for timeslots that have no values.
722
        $sortedTotals = [];
723
        $index = 0;
724
        $sortedIndex = 0;
725
        foreach (range(1, 7) as $day) {
726
            foreach (range(0, 24, 2) as $hour) {
727
                if (isset($totals[$index]) && (int)$totals[$index]['hour'] === $hour) {
728
                    $sortedTotals[$sortedIndex] = $totals[$index];
729
                    $index++;
730
                } else {
731
                    $sortedTotals[$sortedIndex] = [
732
                        'day_of_week' => $day,
733
                        'hour' => $hour,
734
                        'value' => 0,
735
                    ];
736
                }
737
                $sortedIndex++;
738
            }
739
        }
740
741
        $this->timeCardData = $sortedTotals;
742
        return $sortedTotals;
743
    }
744
745
    /**
746
     * Get the total numbers of edits per month.
747
     * @param null|DateTime $currentTime - *USED ONLY FOR UNIT TESTING* so we can mock the current DateTime.
748
     * @return mixed[] With keys 'yearLabels', 'monthLabels' and 'totals',
749
     *   the latter keyed by namespace, year and then month.
750
     */
751 2
    public function monthCounts(?DateTime $currentTime = null): array
752
    {
753 2
        if (isset($this->monthCounts)) {
754 1
            return $this->monthCounts;
755
        }
756
757
        // Set to current month if we're not unit-testing
758 2
        if (!($currentTime instanceof DateTime)) {
759
            $currentTime = new DateTime('last day of this month');
760
        }
761
762 2
        $totals = $this->getRepository()->getMonthCounts($this->project, $this->user);
0 ignored issues
show
Bug introduced by
The method getMonthCounts() does not exist on AppBundle\Repository\Repository. It seems like you code against a sub-type of AppBundle\Repository\Repository such as AppBundle\Repository\EditCounterRepository. ( Ignorable by Annotation )

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

762
        $totals = $this->getRepository()->/** @scrutinizer ignore-call */ getMonthCounts($this->project, $this->user);
Loading history...
763
        $out = [
764 2
            'yearLabels' => [],  // labels for years
765
            'monthLabels' => [], // labels for months
766
            'totals' => [], // actual totals, grouped by namespace, year and then month
767
        ];
768
769
        /** @var DateTime $firstEdit Keep track of the date of their first edit. */
770 2
        $firstEdit = new DateTime();
771
772 2
        [$out, $firstEdit] = $this->fillInMonthCounts($out, $totals, $firstEdit);
773
774 2
        $dateRange = new DatePeriod(
775 2
            $firstEdit,
776 2
            new DateInterval('P1M'),
777 2
            $currentTime->modify('first day of this month')
778
        );
779
780 2
        $out = $this->fillInMonthTotalsAndLabels($out, $dateRange);
781
782
        // One more set of loops to sort by year/month
783 2
        foreach (array_keys($out['totals']) as $nsId) {
784 2
            ksort($out['totals'][$nsId]);
785
786 2
            foreach ($out['totals'][$nsId] as &$yearData) {
787 2
                ksort($yearData);
788
            }
789
        }
790
791
        // Finally, sort the namespaces
792 2
        ksort($out['totals']);
793
794 2
        $this->monthCounts = $out;
795 2
        return $out;
796
    }
797
798
    /**
799
     * Get the counts keyed by month and then namespace.
800
     * Basically the opposite of self::monthCounts()['totals'].
801
     * @param null|DateTime $currentTime - *USED ONLY FOR UNIT TESTING*
802
     *   so we can mock the current DateTime.
803
     * @return array Months as keys, values are counts keyed by namesapce.
804
     * @fixme Create API for this!
805
     */
806 1
    public function monthCountsWithNamespaces(?DateTime $currentTime = null): array
807
    {
808 1
        $countsMonthNamespace = array_fill_keys(
809 1
            array_keys($this->monthTotals($currentTime)),
810 1
            []
811
        );
812
813 1
        foreach ($this->monthCounts($currentTime)['totals'] as $ns => $years) {
814 1
            foreach ($years as $year => $months) {
815 1
                foreach ($months as $month => $count) {
816 1
                    $monthKey = $year.'-'.sprintf('%02d', $month);
817 1
                    $countsMonthNamespace[$monthKey][$ns] = $count;
818
                }
819
            }
820
        }
821
822 1
        return $countsMonthNamespace;
823
    }
824
825
    /**
826
     * Loop through the database results and fill in the values
827
     * for the months that we have data for.
828
     * @param array $out
829
     * @param array $totals
830
     * @param DateTime $firstEdit
831
     * @return array [
832
     *           string[] - Modified $out filled with month stats,
833
     *           DateTime - timestamp of first edit
834
     *         ]
835
     * Tests covered in self::monthCounts().
836
     * @codeCoverageIgnore
837
     */
838
    private function fillInMonthCounts(array $out, array $totals, DateTime $firstEdit): array
839
    {
840
        foreach ($totals as $total) {
841
            // Keep track of first edit
842
            $date = new DateTime($total['year'].'-'.$total['month'].'-01');
843
            if ($date < $firstEdit) {
844
                $firstEdit = $date;
845
            }
846
847
            // Collate the counts by namespace, and then year and month.
848
            $ns = $total['page_namespace'];
849
            if (!isset($out['totals'][$ns])) {
850
                $out['totals'][$ns] = [];
851
            }
852
853
            // Start array for this year if not already present.
854
            if (!isset($out['totals'][$ns][$total['year']])) {
855
                $out['totals'][$ns][$total['year']] = [];
856
            }
857
858
            $out['totals'][$ns][$total['year']][$total['month']] = (int) $total['count'];
859
        }
860
861
        return [$out, $firstEdit];
862
    }
863
864
    /**
865
     * Given the output array, fill each month's totals and labels.
866
     * @param array $out
867
     * @param DatePeriod $dateRange From first edit to present.
868
     * @return array Modified $out filled with month stats.
869
     * Tests covered in self::monthCounts().
870
     * @codeCoverageIgnore
871
     */
872
    private function fillInMonthTotalsAndLabels(array $out, DatePeriod $dateRange): array
873
    {
874
        foreach ($dateRange as $monthObj) {
875
            $year = (int) $monthObj->format('Y');
876
            $yearLabel = $this->i18n->dateFormat($monthObj, 'yyyy');
877
            $month = (int) $monthObj->format('n');
878
            $monthLabel = $this->i18n->dateFormat($monthObj, 'yyyy-MM');
879
880
            // Fill in labels
881
            $out['monthLabels'][] = $monthLabel;
882
            if (!in_array($yearLabel, $out['yearLabels'])) {
883
                $out['yearLabels'][] = $yearLabel;
884
            }
885
886
            foreach (array_keys($out['totals']) as $nsId) {
887
                if (!isset($out['totals'][$nsId][$year])) {
888
                    $out['totals'][$nsId][$year] = [];
889
                }
890
891
                if (!isset($out['totals'][$nsId][$year][$month])) {
892
                    $out['totals'][$nsId][$year][$month] = 0;
893
                }
894
            }
895
        }
896
897
        return $out;
898
    }
899
900
    /**
901
     * Get total edits for each month. Used in wikitext export.
902
     * @param null|DateTime $currentTime *USED ONLY FOR UNIT TESTING*
903
     * @return array With the months as the keys, counts as the values.
904
     */
905 1
    public function monthTotals(?DateTime $currentTime = null): array
906
    {
907 1
        $months = [];
908
909 1
        foreach (array_values($this->monthCounts($currentTime)['totals']) as $nsData) {
910 1
            foreach ($nsData as $year => $monthData) {
911 1
                foreach ($monthData as $month => $count) {
912 1
                    $monthLabel = $year.'-'.sprintf('%02d', $month);
913 1
                    if (!isset($months[$monthLabel])) {
914 1
                        $months[$monthLabel] = 0;
915
                    }
916 1
                    $months[$monthLabel] += $count;
917
                }
918
            }
919
        }
920
921 1
        return $months;
922
    }
923
924
    /**
925
     * Get the total numbers of edits per year.
926
     * @param null|DateTime $currentTime - *USED ONLY FOR UNIT TESTING* so we can mock the current DateTime.
927
     * @return mixed[] With keys 'yearLabels' and 'totals', the latter keyed by namespace then year.
928
     */
929 2
    public function yearCounts(?DateTime $currentTime = null): array
930
    {
931 2
        if (isset($this->yearCounts)) {
932
            return $this->yearCounts;
933
        }
934
935 2
        $out = $this->monthCounts($currentTime);
936
937 2
        foreach ($out['totals'] as $nsId => $years) {
938 2
            foreach ($years as $year => $months) {
939 2
                $out['totals'][$nsId][$year] = array_sum(array_values($months));
940
            }
941
        }
942
943 2
        $this->yearCounts = $out;
944 2
        return $out;
945
    }
946
947
    /**
948
     * Get the counts keyed by year and then namespace.
949
     * Basically the opposite of self::yearCounts()['totals'].
950
     * @param null|DateTime $currentTime - *USED ONLY FOR UNIT TESTING*
951
     *   so we can mock the current DateTime.
952
     * @return array Years as keys, values are counts keyed by namesapce.
953
     */
954
    public function yearCountsWithNamespaces(?DateTime $currentTime = null): array
955
    {
956
        $countsYearNamespace = array_fill_keys(
957
            array_keys($this->yearTotals($currentTime)),
958
            []
959
        );
960
961
        foreach ($this->yearCounts($currentTime)['totals'] as $ns => $years) {
962
            foreach ($years as $year => $count) {
963
                $countsYearNamespace[$year][$ns] = $count;
964
            }
965
        }
966
967
        return $countsYearNamespace;
968
    }
969
970
    /**
971
     * Get total edits for each year. Used in wikitext export.
972
     * @param null|DateTime $currentTime *USED ONLY FOR UNIT TESTING*
973
     * @return array With the years as the keys, counts as the values.
974
     */
975 1
    public function yearTotals(?DateTime $currentTime = null): array
976
    {
977 1
        $years = [];
978
979 1
        foreach (array_values($this->yearCounts($currentTime)['totals']) as $nsData) {
980 1
            foreach ($nsData as $year => $count) {
981 1
                if (!isset($years[$year])) {
982 1
                    $years[$year] = 0;
983
                }
984 1
                $years[$year] += $count;
985
            }
986
        }
987
988 1
        return $years;
989
    }
990
991
    /**
992
     * Get the total edit counts for the top n projects of this user.
993
     * @param int $numProjects
994
     * @return mixed[] Each element has 'total' and 'project' keys.
995
     */
996 1
    public function globalEditCountsTopN(int $numProjects = 10): array
997
    {
998
        // Get counts.
999 1
        $editCounts = $this->globalEditCounts(true);
1000
        // Truncate, and return.
1001 1
        return array_slice($editCounts, 0, $numProjects);
1002
    }
1003
1004
    /**
1005
     * Get the total number of edits excluding the top n.
1006
     * @param int $numProjects
1007
     * @return int
1008
     */
1009 1
    public function globalEditCountWithoutTopN(int $numProjects = 10): int
1010
    {
1011 1
        $editCounts = $this->globalEditCounts(true);
1012 1
        $bottomM = array_slice($editCounts, $numProjects);
1013 1
        $total = 0;
1014 1
        foreach ($bottomM as $editCount) {
1015 1
            $total += $editCount['total'];
1016
        }
1017 1
        return $total;
1018
    }
1019
1020
    /**
1021
     * Get the grand total of all edits on all projects.
1022
     * @return int
1023
     */
1024 1
    public function globalEditCount(): int
1025
    {
1026 1
        $total = 0;
1027 1
        foreach ($this->globalEditCounts() as $editCount) {
1028 1
            $total += $editCount['total'];
1029
        }
1030 1
        return $total;
1031
    }
1032
1033
    /**
1034
     * Get the total revision counts for all projects for this user.
1035
     * @param bool $sorted Whether to sort the list by total, or not.
1036
     * @return mixed[] Each element has 'total' and 'project' keys.
1037
     */
1038 1
    public function globalEditCounts(bool $sorted = false): array
1039
    {
1040 1
        if (empty($this->globalEditCounts)) {
1041 1
            $this->globalEditCounts = $this->getRepository()
1042 1
                ->globalEditCounts($this->user, $this->project);
0 ignored issues
show
Bug introduced by
The method globalEditCounts() does not exist on AppBundle\Repository\Repository. It seems like you code against a sub-type of AppBundle\Repository\Repository such as AppBundle\Repository\EditCounterRepository. ( Ignorable by Annotation )

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

1042
                ->/** @scrutinizer ignore-call */ globalEditCounts($this->user, $this->project);
Loading history...
1043
        }
1044
1045 1
        if ($sorted) {
1046
            // Sort.
1047 1
            uasort($this->globalEditCounts, function ($a, $b) {
1048 1
                return $b['total'] - $a['total'];
1049 1
            });
1050
        }
1051
1052 1
        return $this->globalEditCounts;
1053
    }
1054
1055
    /**
1056
     * Get Projects on which the user has made at least one edit.
1057
     * @return Project[]
1058
     */
1059
    private function getProjectsWithEdits(): array
1060
    {
1061
        if ($this->user->isAnon()) {
1062
            return $this->getRepository()->getProjectsWithEdits($this->user);
0 ignored issues
show
Bug introduced by
The method getProjectsWithEdits() does not exist on AppBundle\Repository\Repository. It seems like you code against a sub-type of AppBundle\Repository\Repository such as AppBundle\Repository\EditCounterRepository. ( Ignorable by Annotation )

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

1062
            return $this->getRepository()->/** @scrutinizer ignore-call */ getProjectsWithEdits($this->user);
Loading history...
1063
        }
1064
1065
        // Registered accounts; for these we go by globalEditCounts() which uses CentralAuth.
1066
        $projects = [];
1067
        $globalCounts = array_column(array_filter($this->globalEditCounts(), function ($row) {
1068
            return $row['total'] > 0;
1069
        }), 'project');
1070
        foreach ($globalCounts as $globalCount) {
1071
            $projects[$globalCount->getDatabaseName()] = $globalCount;
1072
        }
1073
        return $projects;
1074
    }
1075
1076
    /**
1077
     * Get the most recent n revisions across all projects.
1078
     * @param int $max The maximum number of revisions to return.
1079
     * @param int $offset Offset results by this number of revisions.
1080
     * @return Edit[]
1081
     */
1082
    public function globalEdits(int $max, int $offset = 0): array
1083
    {
1084
        if (is_array($this->globalEdits)) {
0 ignored issues
show
introduced by
The condition is_array($this->globalEdits) is always true.
Loading history...
1085
            return $this->globalEdits;
1086
        }
1087
1088
        // Get projects with edits.
1089
        $projects = $this->getProjectsWithEdits();
1090
        if (0 === count($projects)) {
1091
            return [];
1092
        }
1093
1094
        // Get all revisions for those projects.
1095
        $globalRevisionsData = $this->getRepository()
1096
            ->getRevisions($projects, $this->user, $max, $offset);
1097
        $globalEdits = [];
1098
        foreach ($globalRevisionsData as $revision) {
1099
            /** @var Project $project */
1100
            $project = $projects[$revision['project_name']];
1101
1102
            // Can happen if the project is given from CentralAuth API but the database is not being replicated.
1103
            if (null === $project) {
1104
                continue;
1105
            }
1106
1107
            $nsName = '';
1108
            if ($revision['page_namespace']) {
1109
                $nsName = $project->getNamespaces()[$revision['page_namespace']];
1110
            }
1111
1112
            $page = $project->getRepository()
1113
                ->getPage($project, ltrim($nsName.':'.$revision['page_title'], ':'));
1114
            $edit = new Edit($page, $revision);
1115
            $globalEdits[$edit->getTimestamp()->getTimestamp().'-'.$edit->getId()] = $edit;
1116
        }
1117
1118
        // Sort and prune, before adding more.
1119
        krsort($globalEdits);
1120
        $this->globalEdits = array_slice($globalEdits, 0, $max);
1121
1122
        return $this->globalEdits;
1123
    }
1124
1125
    /**
1126
     * Get average edit size, and number of large and small edits.
1127
     * @return int[]
1128
     */
1129
    public function getEditSizeData(): array
1130
    {
1131
        if (!is_array($this->editSizeData)) {
0 ignored issues
show
introduced by
The condition is_array($this->editSizeData) is always true.
Loading history...
1132
            $this->editSizeData = $this->getRepository()
1133
                ->getEditSizeData($this->project, $this->user);
1134
        }
1135
        return $this->editSizeData;
1136
    }
1137
1138
    /**
1139
     * Get the total edit count of this user or 5,000 if they've made more than 5,000 edits.
1140
     * This is used to ensure percentages of small and large edits are computed properly.
1141
     * @return int
1142
     */
1143 1
    public function countLast5000(): int
1144
    {
1145 1
        return $this->countLiveRevisions() > 5000 ? 5000 : $this->countLiveRevisions();
1146
    }
1147
1148
    /**
1149
     * Get the number of edits under 20 bytes of the user's past 5000 edits.
1150
     * @return int
1151
     */
1152
    public function countSmallEdits(): int
1153
    {
1154
        $editSizeData = $this->getEditSizeData();
1155
        return isset($editSizeData['small_edits']) ? (int) $editSizeData['small_edits'] : 0;
1156
    }
1157
1158
    /**
1159
     * Get the total number of edits over 1000 bytes of the user's past 5000 edits.
1160
     * @return int
1161
     */
1162
    public function countLargeEdits(): int
1163
    {
1164
        $editSizeData = $this->getEditSizeData();
1165
        return isset($editSizeData['large_edits']) ? (int) $editSizeData['large_edits'] : 0;
1166
    }
1167
1168
    /**
1169
     * Get the average size of the user's past 5000 edits.
1170
     * @return float Size in bytes.
1171
     */
1172
    public function averageEditSize(): float
1173
    {
1174
        $editSizeData = $this->getEditSizeData();
1175
        if (isset($editSizeData['average_size'])) {
1176
            return round($editSizeData['average_size'], 3);
0 ignored issues
show
Bug introduced by
$editSizeData['average_size'] of type string is incompatible with the type double expected by parameter $val of round(). ( Ignorable by Annotation )

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

1176
            return round(/** @scrutinizer ignore-type */ $editSizeData['average_size'], 3);
Loading history...
1177
        } else {
1178
            return 0;
1179
        }
1180
    }
1181
}
1182