Passed
Pull Request — main (#415)
by MusikAnimal
12:40 queued 08:14
created

EditCounter::getLogCounts()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 7
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 6

Importance

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

106
            $this->firstAndLatestActions = $this->getRepository()->/** @scrutinizer ignore-call */ getFirstAndLatestActions(
Loading history...
107 1
                $this->project,
108 1
                $this->user
109
            );
110
        }
111 1
        return $this->firstAndLatestActions;
112
    }
113
114
    /**
115
     * Get the number of times the user was thanked.
116
     * @return int
117
     */
118
    public function getThanksReceived(): int
119
    {
120 6
        if (!isset($this->thanksReceived)) {
121
            $this->thanksReceived = $this->getRepository()->getThanksReceived($this->project, $this->user);
0 ignored issues
show
Bug Best Practice introduced by
The property thanksReceived does not exist. Although not strictly required by PHP, it is generally a best practice to declare properties explicitly.
Loading history...
Bug introduced by
The method getThanksReceived() 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

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

560
        $this->autoEditCount = $this->getRepository()->/** @scrutinizer ignore-call */ countAutomatedEdits(
Loading history...
561
            $this->project,
562
            $this->user
563
        );
564
        return $this->autoEditCount;
565
    }
566
567
    /**
568
     * Get the count of (non-deleted) edits made in the given timeframe to now.
569
     * @param string $time One of 'day', 'week', 'month', or 'year'.
570
     * @return int The total number of live edits.
571 1
     */
572
    public function countRevisionsInLast(string $time): int
573 1
    {
574 1
        $revCounts = $this->getPairData();
575 1
        return $revCounts[$time] ?? 0;
576 1
    }
577 1
578 1
    /**
579
     * Get the number of days between the first and last edits.
580 1
     * If there's only one edit, this is counted as one day.
581
     * @return int
582
     */
583
    public function getDays(): int
584 1
    {
585
        $first = isset($this->getFirstAndLatestActions()['rev_first']['timestamp'])
586 1
            ? new DateTime($this->getFirstAndLatestActions()['rev_first']['timestamp'])
587
            : false;
588
        $latest = isset($this->getFirstAndLatestActions()['rev_latest']['timestamp'])
589
            ? new DateTime($this->getFirstAndLatestActions()['rev_latest']['timestamp'])
590
            : false;
591
592
        if (false === $first || false === $latest) {
593
            return 0;
594
        }
595
596
        $days = $latest->diff($first)->days;
0 ignored issues
show
Documentation Bug introduced by
It seems like $latest->diff($first)->days can also be of type boolean. However, the property $days is declared as type false|integer. Maybe add an additional type check?

Our type inference engine has found a suspicous assignment of a value to a property. This check raises an issue when a value that can be of a mixed type is assigned to a property that is type hinted more strictly.

For example, imagine you have a variable $accountId that can either hold an Id object or false (if there is no account id yet). Your code now assigns that value to the id property of an instance of the Account class. This class holds a proper account, so the id value must no longer be false.

Either this assignment is in error or a type check should be added for that assignment.

class Id
{
    public $id;

    public function __construct($id)
    {
        $this->id = $id;
    }

}

class Account
{
    /** @var  Id $id */
    public $id;
}

$account_id = false;

if (starsAreRight()) {
    $account_id = new Id(42);
}

$account = new Account();
if ($account instanceof Id)
{
    $account->id = $account_id;
}
Loading history...
597
598
        return $days > 0 ? $days : 1;
599
    }
600
601
    /**
602
     * Get the total number of files uploaded (including those now deleted).
603
     * @return int
604
     */
605
    public function countFilesUploaded(): int
606
    {
607
        $logCounts = $this->getLogCounts();
608
        return $logCounts['upload-upload'] ?: 0;
609
    }
610
611
    /**
612
     * Get the total number of files uploaded to Commons (including those now deleted).
613
     * This is only applicable for WMF labs installations.
614
     * @return int
615
     */
616
    public function countFilesUploadedCommons(): int
617
    {
618
        $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

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

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

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

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

1063
            return round(/** @scrutinizer ignore-type */ $editSizeData['average_size'], 3);
Loading history...
1064
        } else {
1065
            return 0;
1066
        }
1067
    }
1068
}
1069