Test Failed
Pull Request — main (#442)
by MusikAnimal
07:37 queued 03:16
created

EditCounter::getUserRights()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

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

101
            /** @scrutinizer ignore-call */ 
102
            $this->pairData = $this->repository->getPairData($this->project, $this->user);
Loading history...
102
        }
103
        return $this->pairData;
104
    }
105
106
    /**
107
     * Get revision dates.
108
     * @return array
109
     */
110
    public function getLogCounts(): array
111
    {
112
        if (!isset($this->logCounts)) {
113
            $this->logCounts = $this->repository->getLogCounts($this->project, $this->user);
0 ignored issues
show
Bug introduced by
The method getLogCounts() does not exist on App\Repository\Repository. It seems like you code against a sub-type of App\Repository\Repository such as App\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

113
            /** @scrutinizer ignore-call */ 
114
            $this->logCounts = $this->repository->getLogCounts($this->project, $this->user);
Loading history...
114
        }
115
        return $this->logCounts;
116
    }
117
118
    /**
119
     * Get the IDs and timestamps of the latest edit and logged action.
120
     * @return string[] With keys 'rev_first', 'rev_latest', 'log_latest', each with 'id' and 'timestamp'.
121
     */
122
    public function getFirstAndLatestActions(): array
123
    {
124
        if (!isset($this->firstAndLatestActions)) {
125
            $this->firstAndLatestActions = $this->repository->getFirstAndLatestActions(
0 ignored issues
show
Bug introduced by
The method getFirstAndLatestActions() does not exist on App\Repository\Repository. It seems like you code against a sub-type of App\Repository\Repository such as App\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

125
            /** @scrutinizer ignore-call */ 
126
            $this->firstAndLatestActions = $this->repository->getFirstAndLatestActions(
Loading history...
126
                $this->project,
127
                $this->user
128
            );
129
        }
130
        return $this->firstAndLatestActions;
131
    }
132
133
    /**
134
     * Get the number of times the user was thanked.
135
     * @return int
136
     * @codeCoverageIgnore Simply returns the result of an SQL query.
137
     */
138
    public function getThanksReceived(): int
139
    {
140
        if (!isset($this->thanksReceived)) {
141
            $this->thanksReceived = $this->repository->getThanksReceived($this->project, $this->user);
0 ignored issues
show
Bug introduced by
The method getThanksReceived() does not exist on App\Repository\Repository. It seems like you code against a sub-type of App\Repository\Repository such as App\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

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

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

595
        /** @scrutinizer ignore-call */ 
596
        $this->autoEditCount = $this->repository->countAutomatedEdits($this->project, $this->user);
Loading history...
596
        return $this->autoEditCount;
597
    }
598
599
    /**
600
     * Get the count of (non-deleted) edits made in the given timeframe to now.
601
     * @param string $time One of 'day', 'week', 'month', or 'year'.
602
     * @return int The total number of live edits.
603
     */
604
    public function countRevisionsInLast(string $time): int
605
    {
606
        $revCounts = $this->getPairData();
607
        return $revCounts[$time] ?? 0;
608
    }
609
610
    /**
611
     * Get the number of days between the first and last edits.
612
     * If there's only one edit, this is counted as one day.
613
     * @return int
614
     */
615
    public function getDays(): int
616
    {
617
        $first = isset($this->getFirstAndLatestActions()['rev_first']['timestamp'])
618
            ? new DateTime($this->getFirstAndLatestActions()['rev_first']['timestamp'])
619
            : false;
620
        $latest = isset($this->getFirstAndLatestActions()['rev_latest']['timestamp'])
621
            ? new DateTime($this->getFirstAndLatestActions()['rev_latest']['timestamp'])
622
            : false;
623
624
        if (false === $first || false === $latest) {
625
            return 0;
626
        }
627
628
        $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...
629
630
        return $days > 0 ? $days : 1;
631
    }
632
633
    /**
634
     * Get the total number of files uploaded (including those now deleted).
635
     * @return int
636
     */
637
    public function countFilesUploaded(): int
638
    {
639
        $logCounts = $this->getLogCounts();
640
        return $logCounts['upload-upload'] ?: 0;
641
    }
642
643
    /**
644
     * Get the total number of files uploaded to Commons (including those now deleted).
645
     * This is only applicable for WMF labs installations.
646
     * @return int
647
     */
648
    public function countFilesUploadedCommons(): int
649
    {
650
        $fileCounts = $this->repository->getFileCounts($this->project, $this->user);
0 ignored issues
show
Bug introduced by
The method getFileCounts() does not exist on App\Repository\Repository. It seems like you code against a sub-type of App\Repository\Repository such as App\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

650
        /** @scrutinizer ignore-call */ 
651
        $fileCounts = $this->repository->getFileCounts($this->project, $this->user);
Loading history...
651
        return $fileCounts['files_uploaded_commons'] ?? 0;
652
    }
653
654
    /**
655
     * Get the total number of files that were renamed (including those now deleted).
656
     */
657
    public function countFilesMoved(): int
658
    {
659
        $fileCounts = $this->repository->getFileCounts($this->project, $this->user);
660
        return $fileCounts['files_moved'] ?? 0;
661
    }
662
663
    /**
664
     * Get the total number of files that were renamed on Commons (including those now deleted).
665
     */
666
    public function countFilesMovedCommons(): int
667
    {
668
        $fileCounts = $this->repository->getFileCounts($this->project, $this->user);
669
        return $fileCounts['files_moved_commons'] ?? 0;
670
    }
671
672
    /**
673
     * Get the total number of revisions the user has sent thanks for.
674
     * @return int
675
     */
676
    public function thanks(): int
677
    {
678
        $logCounts = $this->getLogCounts();
679
        return $logCounts['thanks-thank'] ?: 0;
680
    }
681
682
    /**
683
     * Get the total number of approvals
684
     * @return int
685
     */
686
    public function approvals(): int
687
    {
688
        $logCounts = $this->getLogCounts();
689
        return (!empty($logCounts['review-approve']) ? $logCounts['review-approve'] : 0) +
690
            (!empty($logCounts['review-approve2']) ? $logCounts['review-approve2'] : 0) +
691
            (!empty($logCounts['review-approve-i']) ? $logCounts['review-approve-i'] : 0) +
692
            (!empty($logCounts['review-approve2-i']) ? $logCounts['review2-approve-i'] : 0);
693
    }
694
695
    /**
696
     * Get the total number of patrols performed by the user.
697
     * @return int
698
     */
699
    public function patrols(): int
700
    {
701
        $logCounts = $this->getLogCounts();
702
        return $logCounts['patrol-patrol'] ?: 0;
703
    }
704
705
    /**
706
     * Get the total number of accounts created by the user.
707
     * @return int
708
     */
709
    public function accountsCreated(): int
710
    {
711
        $logCounts = $this->getLogCounts();
712
        $create2 = $logCounts['newusers-create2'] ?: 0;
713
        $byemail = $logCounts['newusers-byemail'] ?: 0;
714
        return $create2 + $byemail;
715
    }
716
717
    /**
718
     * Get the number of history merges performed by the user.
719
     * @return int
720
     */
721
    public function merges(): int
722
    {
723
        $logCounts = $this->getLogCounts();
724
        return $logCounts['merge-merge'];
725
    }
726
727
    /**
728
     * Get the given user's total edit counts per namespace.
729
     * @return array Array keys are namespace IDs, values are the edit counts.
730
     */
731
    public function namespaceTotals(): array
732
    {
733
        if (isset($this->namespaceTotals)) {
734
            return $this->namespaceTotals;
735
        }
736
        $counts = $this->repository->getNamespaceTotals($this->project, $this->user);
0 ignored issues
show
Bug introduced by
The method getNamespaceTotals() does not exist on App\Repository\Repository. It seems like you code against a sub-type of App\Repository\Repository such as App\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

736
        /** @scrutinizer ignore-call */ 
737
        $counts = $this->repository->getNamespaceTotals($this->project, $this->user);
Loading history...
737
        arsort($counts);
738
        $this->namespaceTotals = $counts;
739
        return $counts;
740
    }
741
742
    /**
743
     * Get the total number of live edits by summing the namespace totals.
744
     * This is used in the view for namespace totals so we don't unnecessarily run the self::getPairData() query.
745
     * @return int
746
     */
747
    public function liveRevisionsFromNamespaces(): int
748
    {
749
        return array_sum($this->namespaceTotals());
750
    }
751
752
    /**
753
     * Get a summary of the times of day and the days of the week that the user has edited.
754
     * @return string[]
755
     */
756
    public function timeCard(): array
757
    {
758
        if (isset($this->timeCardData)) {
759
            return $this->timeCardData;
760
        }
761
        $totals = $this->repository->getTimeCard($this->project, $this->user);
0 ignored issues
show
Bug introduced by
The method getTimeCard() does not exist on App\Repository\Repository. It seems like you code against a sub-type of App\Repository\Repository such as App\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

761
        /** @scrutinizer ignore-call */ 
762
        $totals = $this->repository->getTimeCard($this->project, $this->user);
Loading history...
762
763
        // Scale the radii: get the max, then scale each radius.
764
        // This looks inefficient, but there's a max of 72 elements in this array.
765
        $max = 0;
766
        foreach ($totals as $total) {
767
            $max = max($max, $total['value']);
768
        }
769
        foreach ($totals as &$total) {
770
            $total['scale'] = round(($total['value'] / $max) * 20);
771
        }
772
773
        // Fill in zeros for timeslots that have no values.
774
        $sortedTotals = [];
775
        $index = 0;
776
        $sortedIndex = 0;
777
        foreach (range(1, 7) as $day) {
778
            foreach (range(0, 23) as $hour) {
779
                if (isset($totals[$index]) && (int)$totals[$index]['hour'] === $hour) {
780
                    $sortedTotals[$sortedIndex] = $totals[$index];
781
                    $index++;
782
                } else {
783
                    $sortedTotals[$sortedIndex] = [
784
                        'day_of_week' => $day,
785
                        'hour' => $hour,
786
                        'value' => 0,
787
                    ];
788
                }
789
                $sortedIndex++;
790
            }
791
        }
792
793
        $this->timeCardData = $sortedTotals;
794
        return $sortedTotals;
795
    }
796
797
    /**
798
     * Get the total numbers of edits per month.
799
     * @param null|DateTime $currentTime - *USED ONLY FOR UNIT TESTING* so we can mock the current DateTime.
800
     * @return array With keys 'yearLabels', 'monthLabels' and 'totals',
801
     *   the latter keyed by namespace, year and then month.
802
     */
803
    public function monthCounts(?DateTime $currentTime = null): array
804
    {
805
        if (isset($this->monthCounts)) {
806
            return $this->monthCounts;
807
        }
808
809
        // Set to current month if we're not unit-testing
810
        if (!($currentTime instanceof DateTime)) {
811
            $currentTime = new DateTime('last day of this month');
812
        }
813
814
        $totals = $this->repository->getMonthCounts($this->project, $this->user);
0 ignored issues
show
Bug introduced by
The method getMonthCounts() does not exist on App\Repository\Repository. It seems like you code against a sub-type of App\Repository\Repository such as App\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

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

1051
            /** @scrutinizer ignore-call */ 
1052
            $this->editSizeData = $this->repository
Loading history...
1052
        }
1053
        return $this->editSizeData;
1054
    }
1055
1056
    /**
1057
     * Get the total edit count of this user or 5,000 if they've made more than 5,000 edits.
1058
     * This is used to ensure percentages of small and large edits are computed properly.
1059
     * @return int
1060
     */
1061
    public function countLast5000(): int
1062
    {
1063
        return $this->countLiveRevisions() > 5000 ? 5000 : $this->countLiveRevisions();
1064
    }
1065
1066
    /**
1067
     * Get the number of edits under 20 bytes of the user's past 5000 edits.
1068
     * @return int
1069
     */
1070
    public function countSmallEdits(): int
1071
    {
1072
        $editSizeData = $this->getEditSizeData();
1073
        return isset($editSizeData['small_edits']) ? (int) $editSizeData['small_edits'] : 0;
1074
    }
1075
1076
    /**
1077
     * Get the total number of edits over 1000 bytes of the user's past 5000 edits.
1078
     * @return int
1079
     */
1080
    public function countLargeEdits(): int
1081
    {
1082
        $editSizeData = $this->getEditSizeData();
1083
        return isset($editSizeData['large_edits']) ? (int) $editSizeData['large_edits'] : 0;
1084
    }
1085
1086
    /**
1087
     * Get the average size of the user's past 5000 edits.
1088
     * @return float Size in bytes.
1089
     */
1090
    public function averageEditSize(): float
1091
    {
1092
        $editSizeData = $this->getEditSizeData();
1093
        if (isset($editSizeData['average_size'])) {
1094
            return round($editSizeData['average_size'], 3);
1095
        } else {
1096
            return 0;
1097
        }
1098
    }
1099
}
1100