Passed
Push — master ( 4e5b74...9a5c5d )
by MusikAnimal
05:54
created

EditCounter::getBlocks()   A

Complexity

Conditions 4
Paths 3

Size

Total Lines 17
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 7
CRAP Score 4.7691

Importance

Changes 0
Metric Value
cc 4
eloc 9
nc 3
nop 2
dl 0
loc 17
ccs 7
cts 11
cp 0.6364
crap 4.7691
rs 9.9666
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 array Block data, with keys 'set' and 'received'. */
39
    protected $blocks;
40
41
    /** @var integer[] Array keys are namespace IDs, values are the edit counts. */
42
    protected $namespaceTotals;
43
44
    /** @var int Number of semi-automated edits. */
45
    protected $autoEditCount;
46
47
    /** @var string[] Data needed for time card chart. */
48
    protected $timeCardData;
49
50
    /**
51
     * Revision size data, with keys 'average_size', 'large_edits' and 'small_edits'.
52
     * @var string[] As returned by the DB, unconverted to int or float
53
     */
54
    protected $editSizeData;
55
56
    /**
57
     * Duration of the longest block in seconds; -1 if indefinite,
58
     *   or false if could not be parsed from log params
59
     * @var int|bool
60
     */
61
    protected $longestBlockSeconds;
62
63
    /**
64
     * EditCounter constructor.
65
     * @param Project $project The base project to count edits
66
     * @param User $user
67
     * @param I18nHelper $i18n
68
     */
69 18
    public function __construct(Project $project, User $user, I18nHelper $i18n)
70
    {
71 18
        $this->project = $project;
72 18
        $this->user = $user;
73 18
        $this->i18n = $i18n;
74 18
    }
75
76
    /**
77
     * Get revision and page counts etc.
78
     * @return int[]
79
     */
80 2
    public function getPairData(): array
81
    {
82 2
        if (!is_array($this->pairData)) {
0 ignored issues
show
introduced by
The condition is_array($this->pairData) is always true.
Loading history...
83 2
            $this->pairData = $this->getRepository()
84 2
                ->getPairData($this->project, $this->user);
85
        }
86 2
        return $this->pairData;
87
    }
88
89
    /**
90
     * Get revision dates.
91
     * @return array
92
     */
93
    public function getLogCounts(): array
94
    {
95
        if (!is_array($this->logCounts)) {
0 ignored issues
show
introduced by
The condition is_array($this->logCounts) is always true.
Loading history...
96
            $this->logCounts = $this->getRepository()
97
                ->getLogCounts($this->project, $this->user);
98
        }
99
        return $this->logCounts;
100
    }
101
102
    /**
103
     * Get the IDs and timestamps of the latest edit and logged action.
104
     * @return string[] With keys 'rev_first', 'rev_latest', 'log_latest', each with 'id' and 'timestamp'.
105
     */
106 1
    public function getFirstAndLatestActions(): array
107
    {
108 1
        if (!isset($this->firstAndLatestActions)) {
109 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

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

551
        $this->autoEditCount = $this->getRepository()->/** @scrutinizer ignore-call */ countAutomatedEdits(
Loading history...
552
            $this->project,
553
            $this->user
554
        );
555
        return $this->autoEditCount;
556
    }
557
558
    /**
559
     * Get the count of (non-deleted) edits made in the given timeframe to now.
560
     * @param string $time One of 'day', 'week', 'month', or 'year'.
561
     * @return int The total number of live edits.
562
     */
563
    public function countRevisionsInLast(string $time): int
564
    {
565
        $revCounts = $this->getPairData();
566
        return $revCounts[$time] ?? 0;
567
    }
568
569
    /**
570
     * Get the number of days between the first and last edits.
571
     * If there's only one edit, this is counted as one day.
572
     * @return int
573
     */
574 1
    public function getDays(): int
575
    {
576 1
        $first = isset($this->getFirstAndLatestActions()['rev_first']['timestamp'])
577 1
            ? new DateTime($this->getFirstAndLatestActions()['rev_first']['timestamp'])
578 1
            : false;
579 1
        $latest = isset($this->getFirstAndLatestActions()['rev_latest']['timestamp'])
580 1
            ? new DateTime($this->getFirstAndLatestActions()['rev_latest']['timestamp'])
581 1
            : false;
582
583 1
        if (false === $first || false === $latest) {
584
            return 0;
585
        }
586
587 1
        $days = $latest->diff($first)->days;
588
589 1
        return $days > 0 ? $days : 1;
590
    }
591
592
    /**
593
     * Get the total number of files uploaded (including those now deleted).
594
     * @return int
595
     */
596
    public function countFilesUploaded(): int
597
    {
598
        $logCounts = $this->getLogCounts();
599
        return $logCounts['upload-upload'] ?: 0;
600
    }
601
602
    /**
603
     * Get the total number of files uploaded to Commons (including those now deleted).
604
     * This is only applicable for WMF labs installations.
605
     * @return int
606
     */
607
    public function countFilesUploadedCommons(): int
608
    {
609
        $fileCounts = $this->getRepository()->getFileCounts($this->project, $this->user);
0 ignored issues
show
Bug introduced by
The method getFileCounts() 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

609
        $fileCounts = $this->getRepository()->/** @scrutinizer ignore-call */ getFileCounts($this->project, $this->user);
Loading history...
610
        return $fileCounts['files_uploaded_commons'] ?? 0;
611
    }
612
613
    /**
614
     * Get the total number of files that were renamed (including those now deleted).
615
     */
616
    public function countFilesMoved(): int
617
    {
618
        $fileCounts = $this->getRepository()->getFileCounts($this->project, $this->user);
619
        return $fileCounts['files_moved'] ?? 0;
620
    }
621
622
    /**
623
     * Get the total number of files that were renamed on Commons (including those now deleted).
624
     */
625
    public function countFilesMovedCommons(): int
626
    {
627
        $fileCounts = $this->getRepository()->getFileCounts($this->project, $this->user);
628
        return $fileCounts['files_moved_commons'] ?? 0;
629
    }
630
631
    /**
632
     * Get the total number of revisions the user has sent thanks for.
633
     * @return int
634
     */
635
    public function thanks(): int
636
    {
637
        $logCounts = $this->getLogCounts();
638
        return $logCounts['thanks-thank'] ?: 0;
639
    }
640
641
    /**
642
     * Get the total number of approvals
643
     * @return int
644
     */
645
    public function approvals(): int
646
    {
647
        $logCounts = $this->getLogCounts();
648
        $total = (!empty($logCounts['review-approve']) ? $logCounts['review-approve'] : 0) +
649
            (!empty($logCounts['review-approve2']) ? $logCounts['review-approve2'] : 0) +
650
            (!empty($logCounts['review-approve-i']) ? $logCounts['review-approve-i'] : 0) +
651
            (!empty($logCounts['review-approve2-i']) ? $logCounts['review2-approve-i'] : 0);
652
        return $total;
653
    }
654
655
    /**
656
     * Get the total number of patrols performed by the user.
657
     * @return int
658
     */
659
    public function patrols(): int
660
    {
661
        $logCounts = $this->getLogCounts();
662
        return $logCounts['patrol-patrol'] ?: 0;
663
    }
664
665
    /**
666
     * Get the total number of accounts created by the user.
667
     * @return int
668
     */
669
    public function accountsCreated(): int
670
    {
671
        $logCounts = $this->getLogCounts();
672
        $create2 = $logCounts['newusers-create2'] ?: 0;
673
        $byemail = $logCounts['newusers-byemail'] ?: 0;
674
        return $create2 + $byemail;
675
    }
676
677
    /**
678
     * Get the number of history merges performed by the user.
679
     * @return int
680
     */
681
    public function merges(): int
682
    {
683
        $logCounts = $this->getLogCounts();
684
        return $logCounts['merge-merge'];
685
    }
686
687
    /**
688
     * Get the given user's total edit counts per namespace.
689
     * @return array Array keys are namespace IDs, values are the edit counts.
690
     */
691 1
    public function namespaceTotals(): array
692
    {
693 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...
694
            return $this->namespaceTotals;
695
        }
696 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

696
        $counts = $this->getRepository()->/** @scrutinizer ignore-call */ getNamespaceTotals($this->project, $this->user);
Loading history...
697 1
        arsort($counts);
698 1
        $this->namespaceTotals = $counts;
699 1
        return $counts;
700
    }
701
702
    /**
703
     * Get the total number of live edits by summing the namespace totals.
704
     * This is used in the view for namespace totals so we don't unnecessarily run the self::getPairData() query.
705
     * @return int
706
     */
707
    public function liveRevisionsFromNamespaces(): int
708
    {
709
        return array_sum($this->namespaceTotals());
710
    }
711
712
    /**
713
     * Get a summary of the times of day and the days of the week that the user has edited.
714
     * @return string[]
715
     */
716
    public function timeCard(): array
717
    {
718
        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...
719
            return $this->timeCardData;
720
        }
721
        $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

721
        $totals = $this->getRepository()->/** @scrutinizer ignore-call */ getTimeCard($this->project, $this->user);
Loading history...
722
723
        // Scale the radii: get the max, then scale each radius.
724
        // This looks inefficient, but there's a max of 72 elements in this array.
725
        $max = 0;
726
        foreach ($totals as $total) {
727
            $max = max($max, $total['value']);
728
        }
729
        foreach ($totals as &$total) {
730
            $total['value'] = round($total['value'] / $max * 100);
731
        }
732
733
        // Fill in zeros for timeslots that have no values.
734
        $sortedTotals = [];
735
        $index = 0;
736
        $sortedIndex = 0;
737
        foreach (range(1, 7) as $day) {
738
            foreach (range(0, 24, 2) as $hour) {
739
                if (isset($totals[$index]) && (int)$totals[$index]['hour'] === $hour) {
740
                    $sortedTotals[$sortedIndex] = $totals[$index];
741
                    $index++;
742
                } else {
743
                    $sortedTotals[$sortedIndex] = [
744
                        'day_of_week' => $day,
745
                        'hour' => $hour,
746
                        'value' => 0,
747
                    ];
748
                }
749
                $sortedIndex++;
750
            }
751
        }
752
753
        $this->timeCardData = $sortedTotals;
754
        return $sortedTotals;
755
    }
756
757
    /**
758
     * Get the total numbers of edits per month.
759
     * @param null|DateTime $currentTime - *USED ONLY FOR UNIT TESTING* so we can mock the current DateTime.
760
     * @return mixed[] With keys 'yearLabels', 'monthLabels' and 'totals',
761
     *   the latter keyed by namespace, year and then month.
762
     */
763 2
    public function monthCounts(?DateTime $currentTime = null): array
764
    {
765 2
        if (isset($this->monthCounts)) {
766 1
            return $this->monthCounts;
767
        }
768
769
        // Set to current month if we're not unit-testing
770 2
        if (!($currentTime instanceof DateTime)) {
771
            $currentTime = new DateTime('last day of this month');
772
        }
773
774 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

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

1054
            return round(/** @scrutinizer ignore-type */ $editSizeData['average_size'], 3);
Loading history...
1055
        } else {
1056
            return 0;
1057
        }
1058
    }
1059
}
1060