Test Setup Failed
Pull Request — main (#426)
by MusikAnimal
17:10 queued 11:44
created

EditCounter::countFilesMoved()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 4
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 2
nc 1
nop 0
dl 0
loc 4
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 App\Model;
9
10
use App\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
    /** @var int Number of times the user has been thanked. */
61
    protected $thanksReceived;
62
63
    /**
64
     * EditCounter constructor.
65
     * @param Project $project The base project to count edits
66
     * @param User $user
67
     * @param I18nHelper $i18n
68
     */
69
    public function __construct(Project $project, User $user, I18nHelper $i18n)
70
    {
71
        $this->project = $project;
72
        $this->user = $user;
73
        $this->i18n = $i18n;
74
    }
75
76
    /**
77
     * Get revision and page counts etc.
78
     * @return int[]
79
     */
80
    public function getPairData(): array
81
    {
82
        if (!is_array($this->pairData)) {
0 ignored issues
show
introduced by
The condition is_array($this->pairData) is always true.
Loading history...
83
            $this->pairData = $this->getRepository()
84
                ->getPairData($this->project, $this->user);
85
        }
86
        return $this->pairData;
87
    }
88
89
    /**
90
     * Get revision dates.
91
     * @return array
92
     */
93
    public function getLogCounts(): array
94
    {
95
        if (!is_array($this->logCounts)) {
0 ignored issues
show
introduced by
The condition is_array($this->logCounts) is always true.
Loading history...
96
            $this->logCounts = $this->getRepository()
97
                ->getLogCounts($this->project, $this->user);
98
        }
99
        return $this->logCounts;
100
    }
101
102
    /**
103
     * Get the IDs and timestamps of the latest edit and logged action.
104
     * @return string[] With keys 'rev_first', 'rev_latest', 'log_latest', each with 'id' and 'timestamp'.
105
     */
106
    public function getFirstAndLatestActions(): array
107
    {
108
        if (!isset($this->firstAndLatestActions)) {
109
            $this->firstAndLatestActions = $this->getRepository()->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

109
            $this->firstAndLatestActions = $this->getRepository()->/** @scrutinizer ignore-call */ getFirstAndLatestActions(
Loading history...
110
                $this->project,
111
                $this->user
112
            );
113
        }
114
        return $this->firstAndLatestActions;
115
    }
116
117
    /**
118
     * Get the number of times the user was thanked.
119
     * @return int
120
     * @codeCoverageIgnore Simply returns the result of an SQL query.
121
     */
122
    public function getThanksReceived(): int
123
    {
124
        if (!isset($this->thanksReceived)) {
125
            $this->thanksReceived = $this->getRepository()->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

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

580
        $this->autoEditCount = $this->getRepository()->/** @scrutinizer ignore-call */ countAutomatedEdits(
Loading history...
581
            $this->project,
582
            $this->user
583
        );
584
        return $this->autoEditCount;
585
    }
586
587
    /**
588
     * Get the count of (non-deleted) edits made in the given timeframe to now.
589
     * @param string $time One of 'day', 'week', 'month', or 'year'.
590
     * @return int The total number of live edits.
591
     */
592
    public function countRevisionsInLast(string $time): int
593
    {
594
        $revCounts = $this->getPairData();
595
        return $revCounts[$time] ?? 0;
596
    }
597
598
    /**
599
     * Get the number of days between the first and last edits.
600
     * If there's only one edit, this is counted as one day.
601
     * @return int
602
     */
603
    public function getDays(): int
604
    {
605
        $first = isset($this->getFirstAndLatestActions()['rev_first']['timestamp'])
606
            ? new DateTime($this->getFirstAndLatestActions()['rev_first']['timestamp'])
607
            : false;
608
        $latest = isset($this->getFirstAndLatestActions()['rev_latest']['timestamp'])
609
            ? new DateTime($this->getFirstAndLatestActions()['rev_latest']['timestamp'])
610
            : false;
611
612
        if (false === $first || false === $latest) {
613
            return 0;
614
        }
615
616
        $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...
617
618
        return $days > 0 ? $days : 1;
619
    }
620
621
    /**
622
     * Get the total number of files uploaded (including those now deleted).
623
     * @return int
624
     */
625
    public function countFilesUploaded(): int
626
    {
627
        $logCounts = $this->getLogCounts();
628
        return $logCounts['upload-upload'] ?: 0;
629
    }
630
631
    /**
632
     * Get the total number of files uploaded to Commons (including those now deleted).
633
     * This is only applicable for WMF labs installations.
634
     * @return int
635
     */
636
    public function countFilesUploadedCommons(): int
637
    {
638
        $fileCounts = $this->getRepository()->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

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

725
        $counts = $this->getRepository()->/** @scrutinizer ignore-call */ getNamespaceTotals($this->project, $this->user);
Loading history...
726
        arsort($counts);
727
        $this->namespaceTotals = $counts;
728
        return $counts;
729
    }
730
731
    /**
732
     * Get the total number of live edits by summing the namespace totals.
733
     * This is used in the view for namespace totals so we don't unnecessarily run the self::getPairData() query.
734
     * @return int
735
     */
736
    public function liveRevisionsFromNamespaces(): int
737
    {
738
        return array_sum($this->namespaceTotals());
739
    }
740
741
    /**
742
     * Get a summary of the times of day and the days of the week that the user has edited.
743
     * @return string[]
744
     */
745
    public function timeCard(): array
746
    {
747
        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...
748
            return $this->timeCardData;
749
        }
750
        $totals = $this->getRepository()->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

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

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

1083
            return round(/** @scrutinizer ignore-type */ $editSizeData['average_size'], 3);
Loading history...
1084
        } else {
1085
            return 0;
1086
        }
1087
    }
1088
}
1089