Passed
Push — master ( 782005...8964b9 )
by MusikAnimal
06:51
created

EditCounter::setI18nHelper()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 2
CRAP Score 1

Importance

Changes 0
Metric Value
cc 1
eloc 1
nc 1
nop 1
dl 0
loc 3
ccs 2
cts 2
cp 1
crap 1
rs 10
c 0
b 0
f 0
1
<?php
2
/**
3
 * This file contains only the EditCounter class.
4
 */
5
6
namespace Xtools;
7
8
use AppBundle\Helper\I18nHelper;
9
use DateInterval;
10
use DatePeriod;
11
use DateTime;
12
use Exception;
13
use GuzzleHttp;
14
use GuzzleHttp\Promise\Promise;
15
use Xtools\Edit;
16
17
/**
18
 * An EditCounter provides statistics about a user's edits on a project.
19
 */
20
class EditCounter extends Model
21
{
22
23
    /** @var Project The project. */
24
    protected $project;
25
26
    /** @var User The user. */
27
    protected $user;
28
29
    /** @var I18nHelper For i18n and l10n. */
30
    protected $i18n;
31
32
    /** @var int[] Revision and page counts etc. */
33
    protected $pairData;
34
35
    /** @var string[] The start and end dates of revisions. */
36
    protected $revisionDates;
37
38
    /** @var int[] The total page counts. */
39
    protected $pageCounts;
40
41
    /** @var int[] The lot totals. */
42
    protected $logCounts;
43
44
    /** @var mixed[] Total numbers of edits per month */
45
    protected $monthCounts;
46
47
    /** @var mixed[] Total numbers of edits per year */
48
    protected $yearCounts;
49
50
    /** @var string[] Rights changes, keyed by timestamp then 'added' and 'removed'. */
51
    protected $rightsChanges;
52
53
    /** @var string[] Global rights changes, keyed by timestamp then 'added' and 'removed'. */
54
    protected $globalRightsChanges;
55
56
    /** @var int[] Keys are project DB names. */
57
    protected $globalEditCounts;
58
59
    /** @var array Block data, with keys 'set' and 'received'. */
60
    protected $blocks;
61
62
    /** @var integer[] Array keys are namespace IDs, values are the edit counts. */
63
    protected $namespaceTotals;
64
65
    /** @var int Number of semi-automated edits. */
66
    protected $autoEditCount;
67
68
    /** @var string[] Data needed for time card chart. */
69
    protected $timeCardData;
70
71
    /** @var array Most recent revisions across all projects. */
72
    protected $globalEdits;
73
74
    /**
75
     * Revision size data, with keys 'average_size', 'large_edits' and 'small_edits'.
76
     * @var string[] As returned by the DB, unconverted to int or float
77
     */
78
    protected $editSizeData;
79
80
    /**
81
     * Duration of the longest block in seconds; -1 if indefinite,
82
     *   or false if could not be parsed from log params
83
     * @var int|bool
84
     */
85
    protected $longestBlockSeconds;
86
87
    /**
88
     * EditCounter constructor.
89
     * @param Project $project The base project to count edits
90
     * @param User $user
91
     */
92 20
    public function __construct(Project $project, User $user)
93
    {
94 20
        $this->project = $project;
95 20
        $this->user = $user;
96 20
    }
97
98
    /**
99
     * Make the I18nHelper accessible to EditCounter.
100
     * @param I18nHelper $i18n
101
     */
102 20
    public function setI18nHelper(I18nHelper $i18n)
103
    {
104 20
        $this->i18n = $i18n;
105 20
    }
106
107
    /**
108
     * This method asynchronously fetches all the expensive data, waits
109
     * for each request to finish, and copies the values to the class instance.
110
     * @return null
111
     */
112
    public function prepareData()
113
    {
114
        $project = $this->project->getDomain();
115
        $username = $this->user->getUsername();
116
117
        /**
118
         * The URL of each endpoint, keyed by the name of the corresponding class-level
119
         * instance variable.
120
         * @var array[]
121
         */
122
        $endpoints = [
123
            "pairData" => "ec/pairdata/$project/$username",
124
            "logCounts" => "ec/logcounts/$project/$username",
125
            "namespaceTotals" => "ec/namespacetotals/$project/$username",
126
            "editSizeData" => "ec/editsizes/$project/$username",
127
            "monthCounts" => "ec/monthcounts/$project/$username",
128
            // "globalEditCounts" => "ec-globaleditcounts/$project/$username",
129
            "autoEditCount" => "user/automated_editcount/$project/$username",
130
        ];
131
132
        /**
133
         * Keep track of all promises so we can wait for all of them to complete.
134
         * @var GuzzleHttp\Promise\Promise[]
135
         */
136
        $promises = [];
137
138
        foreach ($endpoints as $key => $endpoint) {
139
            $promise = $this->getRepository()->queryXToolsApi($endpoint, true);
140
            $promises[] = $promise;
141
142
            // Handle response of $promise asynchronously.
143
            $promise->then(function ($response) use ($key, $endpoint) {
144
                $result = (array) json_decode($response->getBody()->getContents());
145
146
                $this->getRepository()
147
                    ->getLog()
148
                    ->debug("$key promise resolved successfully.");
149
150
                if (isset($result)) {
151
                    // Copy result to the class class instance. From here any subsequent
152
                    // calls to the getters (e.g. getPairData()) will return these cached values.
153
                    $this->{$key} = $result;
154
                } else {
155
                    // The API should *always* return something, so if $result is not set,
156
                    // something went wrong, so we simply won't set it and the getters will in
157
                    // turn re-attempt to get the data synchronously.
158
                    // We'll log this to see how often it happens.
159
                    $this->getRepository()
160
                        ->getLog()
161
                        ->error("Failed to fetch data for $endpoint via async, " .
162
                            "re-attempting synchoronously.");
163
                }
164
            });
165
        }
166
167
        // Wait for all promises to complete, even if some of them fail.
168
        GuzzleHttp\Promise\settle($promises)->wait();
0 ignored issues
show
Bug introduced by
The function settle was not found. Maybe you did not declare it correctly or list all dependencies? ( Ignorable by Annotation )

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

168
        /** @scrutinizer ignore-call */ 
169
        GuzzleHttp\Promise\settle($promises)->wait();
Loading history...
169
170
        // Everything we need now lives on the class instance, so we're done.
171
        return;
172
    }
173
174
    /**
175
     * Get revision and page counts etc.
176
     * @return int[]
177
     */
178 4
    public function getPairData()
179
    {
180 4
        if (!is_array($this->pairData)) {
0 ignored issues
show
introduced by
The condition is_array($this->pairData) is always true.
Loading history...
181 4
            $this->pairData = $this->getRepository()
182 4
                ->getPairData($this->project, $this->user);
183
        }
184 4
        return $this->pairData;
185
    }
186
187
    /**
188
     * Get revision dates.
189
     * @return int[]
190
     */
191
    public function getLogCounts()
192
    {
193
        if (!is_array($this->logCounts)) {
0 ignored issues
show
introduced by
The condition is_array($this->logCounts) is always true.
Loading history...
194
            $this->logCounts = $this->getRepository()
195
                ->getLogCounts($this->project, $this->user);
196
        }
197
        return $this->logCounts;
198
    }
199
200
    /**
201
     * Get block data.
202
     * @param string $type Either 'set', 'received'
203
     * @param bool $blocksOnly Whether to include only blocks, and not reblocks and unblocks.
204
     * @return array
205
     */
206 6
    protected function getBlocks($type, $blocksOnly = true)
207
    {
208 6
        if (isset($this->blocks[$type]) && is_array($this->blocks[$type])) {
209
            return $this->blocks[$type];
210
        }
211 6
        $method = "getBlocks".ucfirst($type);
212 6
        $blocks = $this->getRepository()->$method($this->project, $this->user);
213 6
        $this->blocks[$type] = $blocks;
214
215
        // Filter out unblocks unless requested.
216 6
        if ($blocksOnly) {
217
            $blocks = array_filter($blocks, function ($block) {
218
                return $block['log_action'] === 'block';
219
            });
220
        }
221
222 6
        return $blocks;
223
    }
224
225
    /**
226
     * Get user rights changes of the given user.
227
     * @param Project $project
228
     * @param User $user
229
     * @return string[] Keyed by timestamp then 'added' and 'removed'.
230
     */
231 1
    public function getRightsChanges()
232
    {
233 1
        if (isset($this->rightsChanges)) {
234
            return $this->rightsChanges;
235
        }
236
237 1
        $logData = $this->getRepository()
238 1
            ->getRightsChanges($this->project, $this->user);
239
240 1
        $this->rightsChanges = $this->processRightsChanges($logData);
241
242 1
        return $this->rightsChanges;
243
    }
244
245
    /**
246
     * Get global user rights changes of the given user.
247
     * @param Project $project
248
     * @param User $user
249
     * @return string[] Keyed by timestamp then 'added' and 'removed'.
250
     */
251 1
    public function getGlobalRightsChanges()
252
    {
253 1
        if (isset($this->globalRightsChanges)) {
254
            return $this->globalRightsChanges;
255
        }
256
257 1
        $logData = $this->getRepository()
258 1
            ->getGlobalRightsChanges($this->project, $this->user);
259
260 1
        $this->globalRightsChanges = $this->processRightsChanges($logData);
261
262 1
        return $this->globalRightsChanges;
263
    }
264
265
    /**
266
     * Process the given rights changes, sorting an putting in a human-readable format.
267
     * @param  array $logData As fetched with EditCounterRepository::getRightsChanges.
268
     * @return array
269
     */
270 1
    private function processRightsChanges($logData)
271
    {
272 1
        $rightsChanges = [];
273
274 1
        foreach ($logData as $row) {
275 1
            $unserialized = @unserialize($row['log_params']);
276 1
            if ($unserialized !== false) {
277 1
                $old = $unserialized['4::oldgroups'];
278 1
                $new = $unserialized['5::newgroups'];
279 1
                $added = array_diff($new, $old);
280 1
                $removed = array_diff($old, $new);
281
282 1
                $rightsChanges = $this->setAutoRemovals($rightsChanges, $row, $unserialized, $added);
283
            } else {
284
                // This is the old school format the most likely contains
285
                // the list of rights additions in as a comma-separated list.
286
                try {
287 1
                    list($old, $new) = explode("\n", $row['log_params']);
288 1
                    $old = array_filter(array_map('trim', explode(',', $old)));
289 1
                    $new = array_filter(array_map('trim', explode(',', $new)));
290 1
                    $added = array_diff($new, $old);
291 1
                    $removed = array_diff($old, $new);
292
                } catch (Exception $e) {
293
                    // Really really old school format that may be missing metadata
294
                    // altogether. Here we'll just leave $added and $removed blank.
295
                    $added = [];
296
                    $removed = [];
297
                }
298
            }
299
300
            // Remove '(none)'.
301 1
            if (in_array('(none)', $added)) {
302
                array_splice($added, array_search('(none)', $added), 1);
0 ignored issues
show
Bug introduced by
It seems like array_search('(none)', $added) can also be of type string and false; however, parameter $offset of array_splice() does only seem to accept integer, maybe add an additional type check? ( Ignorable by Annotation )

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

302
                array_splice($added, /** @scrutinizer ignore-type */ array_search('(none)', $added), 1);
Loading history...
303
            }
304 1
            if (in_array('(none)', $removed)) {
305
                array_splice($removed, array_search('(none)', $removed), 1);
306
            }
307
308 1
            $rightsChanges[$row['log_timestamp']] = [
309 1
                'logId' => $row['log_id'],
310 1
                'admin' => $row['log_user_text'],
311 1
                'comment' => $row['log_comment'],
312 1
                'added' => array_values($added),
313 1
                'removed' => array_values($removed),
314 1
                'automatic' => $row['log_action'] === 'autopromote',
315 1
                'type' => $row['type'],
316
            ];
317
        }
318
319 1
        krsort($rightsChanges);
320
321 1
        return $rightsChanges;
322
    }
323
324
    /**
325
     * Check the given log entry for rights changes that are set to automatically expire,
326
     * and add entries to $rightsChanges accordingly.
327
     * @param array $rightsChanges
328
     * @param array $row Log entry row from database.
329
     * @param array $params Unserialized log params.
330
     * @param string[] $added List of added user rights.
331
     * @return array Modified $rightsChanges.
332
     */
333 1
    private function setAutoRemovals($rightsChanges, $row, $params, $added)
334
    {
335 1
        foreach ($added as $index => $entry) {
336 1
            if (!isset($params['newmetadata'][$index]) ||
337 1
                !array_key_exists('expiry', $params['newmetadata'][$index]) ||
338 1
                empty($params['newmetadata'][$index]['expiry'])
339
            ) {
340 1
                continue;
341
            }
342
343 1
            $expiry = $params['newmetadata'][$index]['expiry'];
344
345 1
            if (isset($rightsChanges[$expiry]) && !in_array($entry, $rightsChanges[$expiry]['removed'])) {
346 1
                $rightsChanges[$expiry]['removed'][] = $entry;
347
            } else {
348 1
                $rightsChanges[$expiry] = [
349 1
                    'logId' => $row['log_id'],
350 1
                    'admin' => $row['log_user_text'],
351
                    'comment' => null,
352
                    'added' => [],
353 1
                    'removed' => [$entry],
354
                    'automatic' => true,
355 1
                    'type' => $row['type'],
356
                ];
357
            }
358
        }
359
360 1
        return $rightsChanges;
361
    }
362
363
    /**
364
     * Get the total number of currently-live revisions.
365
     * @return int
366
     */
367 1
    public function countLiveRevisions()
368
    {
369 1
        $revCounts = $this->getPairData();
370 1
        return isset($revCounts['live']) ? (int)$revCounts['live'] : 0;
371
    }
372
373
    /**
374
     * Get the total number of the user's revisions that have been deleted.
375
     * @return int
376
     */
377 1
    public function countDeletedRevisions()
378
    {
379 1
        $revCounts = $this->getPairData();
380 1
        return isset($revCounts['deleted']) ? (int)$revCounts['deleted'] : 0;
381
    }
382
383
    /**
384
     * Get the total edit count (live + deleted).
385
     * @return int
386
     */
387 1
    public function countAllRevisions()
388
    {
389 1
        return $this->countLiveRevisions() + $this->countDeletedRevisions();
390
    }
391
392
    /**
393
     * Get the total number of live revisions with comments.
394
     * @return int
395
     */
396 1
    public function countRevisionsWithComments()
397
    {
398 1
        $revCounts = $this->getPairData();
399 1
        return isset($revCounts['with_comments']) ? (int)$revCounts['with_comments'] : 0;
400
    }
401
402
    /**
403
     * Get the total number of live revisions without comments.
404
     * @return int
405
     */
406 1
    public function countRevisionsWithoutComments()
407
    {
408 1
        return $this->countLiveRevisions() - $this->countRevisionsWithComments();
409
    }
410
411
    /**
412
     * Get the total number of revisions marked as 'minor' by the user.
413
     * @return int
414
     */
415
    public function countMinorRevisions()
416
    {
417
        $revCounts = $this->getPairData();
418
        return isset($revCounts['minor']) ? (int)$revCounts['minor'] : 0;
419
    }
420
421
    /**
422
     * Get the total number of non-deleted pages edited by the user.
423
     * @return int
424
     */
425 1
    public function countLivePagesEdited()
426
    {
427 1
        $pageCounts = $this->getPairData();
428 1
        return isset($pageCounts['edited-live']) ? (int)$pageCounts['edited-live'] : 0;
429
    }
430
431
    /**
432
     * Get the total number of deleted pages ever edited by the user.
433
     * @return int
434
     */
435 1
    public function countDeletedPagesEdited()
436
    {
437 1
        $pageCounts = $this->getPairData();
438 1
        return isset($pageCounts['edited-deleted']) ? (int)$pageCounts['edited-deleted'] : 0;
439
    }
440
441
    /**
442
     * Get the total number of pages ever edited by this user (both live and deleted).
443
     * @return int
444
     */
445 1
    public function countAllPagesEdited()
446
    {
447 1
        return $this->countLivePagesEdited() + $this->countDeletedPagesEdited();
448
    }
449
450
    /**
451
     * Get the total number of pages (both still live and those that have been deleted) created
452
     * by the user.
453
     * @return int
454
     */
455 1
    public function countPagesCreated()
456
    {
457 1
        return $this->countCreatedPagesLive() + $this->countPagesCreatedDeleted();
458
    }
459
460
    /**
461
     * Get the total number of pages created by the user, that have not been deleted.
462
     * @return int
463
     */
464 1
    public function countCreatedPagesLive()
465
    {
466 1
        $pageCounts = $this->getPairData();
467 1
        return isset($pageCounts['created-live']) ? (int)$pageCounts['created-live'] : 0;
468
    }
469
470
    /**
471
     * Get the total number of pages created by the user, that have since been deleted.
472
     * @return int
473
     */
474 1
    public function countPagesCreatedDeleted()
475
    {
476 1
        $pageCounts = $this->getPairData();
477 1
        return isset($pageCounts['created-deleted']) ? (int)$pageCounts['created-deleted'] : 0;
478
    }
479
480
    /**
481
     * Get the total number of pages that have been deleted by the user.
482
     * @return int
483
     */
484
    public function countPagesDeleted()
485
    {
486
        $logCounts = $this->getLogCounts();
487
        return isset($logCounts['delete-delete']) ? (int)$logCounts['delete-delete'] : 0;
488
    }
489
490
    /**
491
     * Get the total number of pages moved by the user.
492
     * @return int
493
     */
494
    public function countPagesMoved()
495
    {
496
        $logCounts = $this->getLogCounts();
497
        return isset($logCounts['move-move']) ? (int)$logCounts['move-move'] : 0;
498
    }
499
500
    /**
501
     * Get the total number of times the user has blocked a user.
502
     * @return int
503
     */
504
    public function countBlocksSet()
505
    {
506
        $logCounts = $this->getLogCounts();
507
        $reBlock = isset($logCounts['block-block']) ? (int)$logCounts['block-block'] : 0;
508
        return $reBlock;
509
    }
510
511
    /**
512
     * Get the total number of times the user has re-blocked a user.
513
     * @return int
514
     */
515
    public function countReblocksSet()
516
    {
517
        $logCounts = $this->getLogCounts();
518
        $reBlock = isset($logCounts['block-reblock']) ? (int)$logCounts['block-reblock'] : 0;
519
        return $reBlock;
520
    }
521
522
    /**
523
     * Get the total number of times the user has unblocked a user.
524
     * @return int
525
     */
526
    public function countUnblocksSet()
527
    {
528
        $logCounts = $this->getLogCounts();
529
        return isset($logCounts['block-unblock']) ? (int)$logCounts['block-unblock'] : 0;
530
    }
531
532
    /**
533
     * Get the total number of blocks that have been lifted (i.e. unblocks) by this user.
534
     * @return int
535
     */
536
    public function countBlocksLifted()
537
    {
538
        $logCounts = $this->getLogCounts();
539
        return isset($logCounts['block-unblock']) ? (int)$logCounts['block-unblock'] : 0;
540
    }
541
542
    /**
543
     * Get the total number of times the user has been blocked.
544
     * @return int
545
     */
546
    public function countBlocksReceived()
547
    {
548
        $blocks = $this->getBlocks('received');
549
        return count($blocks);
550
    }
551
552
    /**
553
     * Get the length of the longest block the user received, in seconds.
554
     * @return int Number of seconds or false if it could not be determined.
555
     *   If the user is blocked, the time since the block is returned. If the block is
556
     *   indefinite, -1 is returned. 0 if there was never a block.
557
     */
558 6
    public function getLongestBlockSeconds()
559
    {
560 6
        if (isset($this->longestBlockSeconds)) {
561
            return $this->longestBlockSeconds;
562
        }
563
564 6
        $blocks = $this->getBlocks('received', false);
565 6
        $this->longestBlockSeconds = false;
566
567
        // If there was never a block, the longest was zero seconds.
568 6
        if (empty($blocks)) {
569
            return 0;
570
        }
571
572
        /**
573
         * Keep track of the last block so we can determine the duration
574
         * if the current block in the loop is an unblock.
575
         * @var int[] [
576
         *              Unix timestamp,
577
         *              Duration in seconds (-1 if indefinite)
578
         *            ]
579
         */
580 6
        $lastBlock = [null, null];
581
582 6
        foreach ($blocks as $index => $block) {
583 6
            list($timestamp, $duration) = $this->parseBlockLogEntry($block);
584
585 6
            if ($block['log_action'] === 'block') {
586
                // This is a new block, so first see if the duration of the last
587
                // block exceeded our longest duration. -1 duration means indefinite.
588 6
                if ($lastBlock[1] > $this->longestBlockSeconds || $lastBlock[1] === -1) {
589 2
                    $this->longestBlockSeconds = $lastBlock[1];
590
                }
591
592
                // Now set this as the last block.
593 6
                $lastBlock = [$timestamp, $duration];
594 3
            } elseif ($block['log_action'] === 'unblock') {
595
                // The last block was lifted. So the duration will be the time from when the
596
                // last block was set to the time of the unblock.
597 1
                $timeSinceLastBlock = $timestamp - $lastBlock[0];
598 1
                if ($timeSinceLastBlock > $this->longestBlockSeconds) {
599 1
                    $this->longestBlockSeconds = $timeSinceLastBlock;
600
601
                    // Reset the last block, as it has now been accounted for.
602 1
                    $lastBlock = null;
603
                }
604 2
            } elseif ($block['log_action'] === 'reblock' && $lastBlock[1] !== -1) {
605
                // The last block was modified. So we will adjust $lastBlock to include
606
                // the difference of the duration of the new reblock, and time since the last block.
607
                // $lastBlock is left unchanged if its duration was indefinite.
608 1
                $timeSinceLastBlock = $timestamp - $lastBlock[0];
609 6
                $lastBlock[1] = $timeSinceLastBlock + $duration;
610
            }
611
        }
612
613
        // If the last block was indefinite, we'll return that as the longest duration.
614 6
        if ($lastBlock[1] === -1) {
615 2
            return -1;
616
        }
617
618
        // Test if the last block is still active, and if so use the expiry as the duration.
619 4
        $lastBlockExpiry = $lastBlock[0] + $lastBlock[1];
620 4
        if ($lastBlockExpiry > time() && $lastBlockExpiry > $this->longestBlockSeconds) {
621 1
            $this->longestBlockSeconds = $lastBlock[1];
622
        // Otherwise, test if the duration of the last block is now the longest overall.
623 3
        } elseif ($lastBlock[1] > $this->longestBlockSeconds) {
624 2
            $this->longestBlockSeconds = $lastBlock[1];
625
        }
626
627 4
        return $this->longestBlockSeconds;
628
    }
629
630
    /**
631
     * Given a block log entry from the database, get the timestamp and duration in seconds.
632
     * @param  mixed[] $block Block log entry as fetched via self::getBlocks()
633
     * @return int[] [
634
     *                 Unix timestamp,
635
     *                 Duration in seconds (-1 if indefinite, null if unparsable or unblock)
636
     *               ]
637
     */
638 11
    public function parseBlockLogEntry($block)
639
    {
640 11
        $timestamp = strtotime($block['log_timestamp']);
641 11
        $duration = null;
642
643
        // First check if the string is serialized, and if so parse it to get the block duration.
644 11
        if (@unserialize($block['log_params']) !== false) {
645 8
            $parsedParams = unserialize($block['log_params']);
646 8
            $durationStr = isset($parsedParams['5::duration']) ? $parsedParams['5::duration'] : null;
647
        } else {
648
            // Old format, the duration in English + block options separated by new lines.
649 4
            $durationStr = explode("\n", $block['log_params'])[0];
650
        }
651
652 11
        if (in_array($durationStr, ['indefinite', 'infinity', 'infinite'])) {
653 3
            $duration = -1;
654
        }
655
656
        // Make sure $durationStr is valid just in case it is in an older, unpredictable format.
657
        // If invalid, $duration is left as null.
658 11
        if (strtotime($durationStr)) {
659 8
            $expiry = strtotime($durationStr, $timestamp);
660 8
            $duration = $expiry - $timestamp;
661
        }
662
663 11
        return [$timestamp, $duration];
664
    }
665
666
    /**
667
     * Get the total number of pages protected by the user.
668
     * @return int
669
     */
670
    public function countPagesProtected()
671
    {
672
        $logCounts = $this->getLogCounts();
673
        return isset($logCounts['protect-protect']) ? (int)$logCounts['protect-protect'] : 0;
674
    }
675
676
    /**
677
     * Get the total number of pages reprotected by the user.
678
     * @return int
679
     */
680
    public function countPagesReprotected()
681
    {
682
        $logCounts = $this->getLogCounts();
683
        return isset($logCounts['protect-modify']) ? (int)$logCounts['protect-modify'] : 0;
684
    }
685
686
    /**
687
     * Get the total number of pages unprotected by the user.
688
     * @return int
689
     */
690
    public function countPagesUnprotected()
691
    {
692
        $logCounts = $this->getLogCounts();
693
        return isset($logCounts['protect-unprotect']) ? (int)$logCounts['protect-unprotect'] : 0;
694
    }
695
696
    /**
697
     * Get the total number of edits deleted by the user.
698
     * @return int
699
     */
700
    public function countEditsDeleted()
701
    {
702
        $logCounts = $this->getLogCounts();
703
        return isset($logCounts['delete-revision']) ? (int)$logCounts['delete-revision'] : 0;
704
    }
705
706
    /**
707
     * Get the total number of pages restored by the user.
708
     * @return int
709
     */
710
    public function countPagesRestored()
711
    {
712
        $logCounts = $this->getLogCounts();
713
        return isset($logCounts['delete-restore']) ? (int)$logCounts['delete-restore'] : 0;
714
    }
715
716
    /**
717
     * Get the total number of times the user has modified the rights of a user.
718
     * @return int
719
     */
720
    public function countRightsModified()
721
    {
722
        $logCounts = $this->getLogCounts();
723
        return isset($logCounts['rights-rights']) ? (int)$logCounts['rights-rights'] : 0;
724
    }
725
726
    /**
727
     * Get the total number of pages imported by the user (through any import mechanism:
728
     * interwiki, or XML upload).
729
     * @return int
730
     */
731
    public function countPagesImported()
732
    {
733
        $logCounts = $this->getLogCounts();
734
        $import = isset($logCounts['import-import']) ? (int)$logCounts['import-import'] : 0;
735
        $interwiki = isset($logCounts['import-interwiki']) ? (int)$logCounts['import-interwiki'] : 0;
736
        $upload = isset($logCounts['import-upload']) ? (int)$logCounts['import-upload'] : 0;
737
        return $import + $interwiki + $upload;
738
    }
739
740
    /**
741
     * Get the average number of edits per page (including deleted revisions and pages).
742
     * @return float
743
     */
744
    public function averageRevisionsPerPage()
745
    {
746
        if ($this->countAllPagesEdited() == 0) {
747
            return 0;
748
        }
749
        return round($this->countAllRevisions() / $this->countAllPagesEdited(), 3);
750
    }
751
752
    /**
753
     * Average number of edits made per day.
754
     * @return float
755
     */
756
    public function averageRevisionsPerDay()
757
    {
758
        if ($this->getDays() == 0) {
759
            return 0;
760
        }
761
        return round($this->countAllRevisions() / $this->getDays(), 3);
762
    }
763
764
    /**
765
     * Get the total number of edits made by the user with semi-automating tools.
766
     */
767
    public function countAutomatedEdits()
768
    {
769
        if ($this->autoEditCount) {
770
            return $this->autoEditCount;
771
        }
772
        $this->autoEditCount = $this->getRepository()->countAutomatedEdits(
773
            $this->project,
774
            $this->user
775
        );
776
        return $this->autoEditCount;
777
    }
778
779
    /**
780
     * Get the count of (non-deleted) edits made in the given timeframe to now.
781
     * @param string $time One of 'day', 'week', 'month', or 'year'.
782
     * @return int The total number of live edits.
783
     */
784
    public function countRevisionsInLast($time)
785
    {
786
        $revCounts = $this->getPairData();
787
        return isset($revCounts[$time]) ? $revCounts[$time] : 0;
788
    }
789
790
    /**
791
     * Get the date and time of the user's first edit.
792
     * @return DateTime|bool The time of the first revision, or false.
793
     */
794 2
    public function datetimeFirstRevision()
795
    {
796 2
        $revDates = $this->getPairData();
797 2
        return isset($revDates['first']) ? new DateTime($revDates['first']) : false;
798
    }
799
800
    /**
801
     * Get the date and time of the user's first edit.
802
     * @return DateTime|bool The time of the last revision, or false.
803
     */
804 2
    public function datetimeLastRevision()
805
    {
806 2
        $revDates = $this->getPairData();
807 2
        return isset($revDates['last']) ? new DateTime($revDates['last']) : false;
808
    }
809
810
    /**
811
     * Get the number of days between the first and last edits.
812
     * If there's only one edit, this is counted as one day.
813
     * @return int
814
     */
815 2
    public function getDays()
816
    {
817 2
        $first = $this->datetimeFirstRevision();
818 2
        $last = $this->datetimeLastRevision();
819 2
        if ($first === false || $last === false) {
820
            return 0;
821
        }
822 2
        $days = $last->diff($first)->days;
823 2
        return $days > 0 ? $days : 1;
824
    }
825
826
    /**
827
     * Get the total number of files uploaded (including those now deleted).
828
     * @return int
829
     */
830
    public function countFilesUploaded()
831
    {
832
        $logCounts = $this->getLogCounts();
833
        return $logCounts['upload-upload'] ?: 0;
834
    }
835
836
    /**
837
     * Get the total number of files uploaded to Commons (including those now deleted).
838
     * This is only applicable for WMF labs installations.
839
     * @return int
840
     */
841
    public function countFilesUploadedCommons()
842
    {
843
        $logCounts = $this->getLogCounts();
844
        return $logCounts['files_uploaded_commons'] ?: 0;
845
    }
846
847
    /**
848
     * Get the total number of revisions the user has sent thanks for.
849
     * @return int
850
     */
851
    public function thanks()
852
    {
853
        $logCounts = $this->getLogCounts();
854
        return $logCounts['thanks-thank'] ?: 0;
855
    }
856
857
    /**
858
     * Get the total number of approvals
859
     * @return int
860
     */
861
    public function approvals()
862
    {
863
        $logCounts = $this->getLogCounts();
864
        $total = $logCounts['review-approve'] +
865
        (!empty($logCounts['review-approve-a']) ? $logCounts['review-approve-a'] : 0) +
866
        (!empty($logCounts['review-approve-i']) ? $logCounts['review-approve-i'] : 0) +
867
        (!empty($logCounts['review-approve-ia']) ? $logCounts['review-approve-ia'] : 0);
868
        return $total;
869
    }
870
871
    /**
872
     * Get the total number of patrols performed by the user.
873
     * @return int
874
     */
875
    public function patrols()
876
    {
877
        $logCounts = $this->getLogCounts();
878
        return $logCounts['patrol-patrol'] ?: 0;
879
    }
880
881
    /**
882
     * Get the total number of accounts created by the user.
883
     * @return int
884
     */
885
    public function accountsCreated()
886
    {
887
        $logCounts = $this->getLogCounts();
888
        $create2 = $logCounts['newusers-create2'] ?: 0;
889
        $byemail = $logCounts['newusers-byemail'] ?: 0;
890
        return $create2 + $byemail;
891
    }
892
893
    /**
894
     * Get the given user's total edit counts per namespace.
895
     * @return integer[] Array keys are namespace IDs, values are the edit counts.
896
     */
897 1
    public function namespaceTotals()
898
    {
899 1
        if ($this->namespaceTotals) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->namespaceTotals of type integer[] is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
900
            return $this->namespaceTotals;
901
        }
902 1
        $counts = $this->getRepository()->getNamespaceTotals($this->project, $this->user);
903 1
        arsort($counts);
904 1
        $this->namespaceTotals = $counts;
905 1
        return $counts;
906
    }
907
908
    /**
909
     * Get a summary of the times of day and the days of the week that the user has edited.
910
     * @return string[]
911
     */
912
    public function timeCard()
913
    {
914
        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...
915
            return $this->timeCardData;
916
        }
917
        $totals = $this->getRepository()->getTimeCard($this->project, $this->user);
918
919
        // Scale the radii: get the max, then scale each radius.
920
        // This looks inefficient, but there's a max of 72 elements in this array.
921
        $max = 0;
922
        foreach ($totals as $total) {
923
            $max = max($max, $total['value']);
924
        }
925
        foreach ($totals as &$total) {
926
            $total['value'] = round($total['value'] / $max * 100);
927
        }
928
929
        // Fill in zeros for timeslots that have no values.
930
        $sortedTotals = [];
931
        $index = 0;
932
        $sortedIndex = 0;
933
        foreach (range(1, 7) as $day) {
934
            foreach (range(0, 24, 2) as $hour) {
935
                if (isset($totals[$index]) && (int)$totals[$index]['x'] === $hour) {
936
                    $sortedTotals[$sortedIndex] = $totals[$index];
937
                    $index++;
938
                } else {
939
                    $sortedTotals[$sortedIndex] = [
940
                        'y' => $day,
941
                        'x' => $hour,
942
                        'value' => 0,
943
                    ];
944
                }
945
                $sortedIndex++;
946
            }
947
        }
948
949
        $this->timeCardData = $sortedTotals;
0 ignored issues
show
Documentation Bug introduced by
$sortedTotals is of type array<mixed,array<string,mixed|integer>|mixed>, but the property $timeCardData was declared to be of type string[]. Are you sure that you always receive this specific sub-class here, or does it make sense to add an instanceof 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 given class or a super-class is assigned to a property that is type hinted more strictly.

Either this assignment is in error or an instanceof check should be added for that assignment.

class Alien {}

class Dalek extends Alien {}

class Plot
{
    /** @var  Dalek */
    public $villain;
}

$alien = new Alien();
$plot = new Plot();
if ($alien instanceof Dalek) {
    $plot->villain = $alien;
}
Loading history...
950
        return $sortedTotals;
951
    }
952
953
    /**
954
     * Get the total numbers of edits per month.
955
     * @param null|DateTime $currentTime - *USED ONLY FOR UNIT TESTING*
956
     *   so we can mock the current DateTime.
957
     * @return mixed[] With keys 'yearLabels', 'monthLabels' and 'totals',
958
     *   the latter keyed by namespace, year and then month.
959
     */
960 2
    public function monthCounts($currentTime = null)
961
    {
962 2
        if (isset($this->monthCounts)) {
963 1
            return $this->monthCounts;
964
        }
965
966
        // Set to current month if we're not unit-testing
967 2
        if (!($currentTime instanceof DateTime)) {
968
            $currentTime = new DateTime('last day of this month');
969
        }
970
971 2
        $totals = $this->getRepository()->getMonthCounts($this->project, $this->user);
972
        $out = [
973 2
            'yearLabels' => [],  // labels for years
974
            'monthLabels' => [], // labels for months
975
            'totals' => [], // actual totals, grouped by namespace, year and then month
976
        ];
977
978
        /** @var DateTime Keep track of the date of their first edit. */
979 2
        $firstEdit = new DateTime();
980
981 2
        list($out, $firstEdit) = $this->fillInMonthCounts($out, $totals, $firstEdit);
982
983 2
        $dateRange = new DatePeriod(
984 2
            $firstEdit,
985 2
            new DateInterval('P1M'),
986 2
            $currentTime->modify('first day of this month')
987
        );
988
989 2
        $out = $this->fillInMonthTotalsAndLabels($out, $dateRange);
990
991
        // One more set of loops to sort by year/month
992 2
        foreach (array_keys($out['totals']) as $nsId) {
0 ignored issues
show
Bug introduced by
$out['totals'] of type string is incompatible with the type array expected by parameter $input of array_keys(). ( Ignorable by Annotation )

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

992
        foreach (array_keys(/** @scrutinizer ignore-type */ $out['totals']) as $nsId) {
Loading history...
993 2
            ksort($out['totals'][$nsId]);
994
995 2
            foreach ($out['totals'][$nsId] as &$yearData) {
996 2
                ksort($yearData);
997
            }
998
        }
999
1000
        // Finally, sort the namespaces
1001 2
        ksort($out['totals']);
0 ignored issues
show
Bug introduced by
$out['totals'] of type string is incompatible with the type array expected by parameter $array of ksort(). ( Ignorable by Annotation )

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

1001
        ksort(/** @scrutinizer ignore-type */ $out['totals']);
Loading history...
1002
1003 2
        $this->monthCounts = $out;
1004 2
        return $out;
1005
    }
1006
1007
    /**
1008
     * Loop through the database results and fill in the values
1009
     * for the months that we have data for.
1010
     * @param array $out
1011
     * @param string[] $totals
1012
     * @param DateTime $firstEdit
1013
     * @return array [
1014
     *           string[] - Modified $out filled with month stats,
1015
     *           DateTime - timestamp of first edit
1016
     *         ]
1017
     * Tests covered in self::monthCounts().
1018
     * @codeCoverageIgnore
1019
     */
1020
    private function fillInMonthCounts($out, $totals, $firstEdit)
1021
    {
1022
        foreach ($totals as $total) {
1023
            // Keep track of first edit
1024
            $date = new DateTime($total['year'].'-'.$total['month'].'-01');
1025
            if ($date < $firstEdit) {
1026
                $firstEdit = $date;
1027
            }
1028
1029
            // Collate the counts by namespace, and then year and month.
1030
            $ns = $total['page_namespace'];
1031
            if (!isset($out['totals'][$ns])) {
1032
                $out['totals'][$ns] = [];
1033
            }
1034
1035
            // Start array for this year if not already present.
1036
            if (!isset($out['totals'][$ns][$total['year']])) {
1037
                $out['totals'][$ns][$total['year']] = [];
1038
            }
1039
1040
            $out['totals'][$ns][$total['year']][$total['month']] = (int) $total['count'];
1041
        }
1042
1043
        return [$out, $firstEdit];
1044
    }
1045
1046
    /**
1047
     * Given the output array, fill each month's totals and labels.
1048
     * @param array $out
1049
     * @param DatePeriod $dateRange From first edit to present.
1050
     * @return string[] - Modified $out filled with month stats.
1051
     * Tests covered in self::monthCounts().
1052
     * @codeCoverageIgnore
1053
     */
1054
    private function fillInMonthTotalsAndLabels($out, DatePeriod $dateRange)
1055
    {
1056
        foreach ($dateRange as $monthObj) {
1057
            $year = (int) $monthObj->format('Y');
1058
            $yearLabel = $this->i18n->dateFormat($monthObj, 'yyyy');
1059
            $month = (int) $monthObj->format('n');
1060
            $monthLabel = $this->i18n->dateFormat($monthObj, 'yyyy-MM');
1061
1062
            // Fill in labels
1063
            $out['monthLabels'][] = $monthLabel;
1064
            if (!in_array($yearLabel, $out['yearLabels'])) {
1065
                $out['yearLabels'][] = $yearLabel;
1066
            }
1067
1068
            foreach (array_keys($out['totals']) as $nsId) {
1069
                if (!isset($out['totals'][$nsId][$year])) {
1070
                    $out['totals'][$nsId][$year] = [];
1071
                }
1072
1073
                if (!isset($out['totals'][$nsId][$year][$month])) {
1074
                    $out['totals'][$nsId][$year][$month] = 0;
1075
                }
1076
            }
1077
        }
1078
1079
        return $out;
1080
    }
1081
1082
    /**
1083
     * Get total edits for each month. Used in wikitext export.
1084
     * @param  null|DateTime $currentTime *USED ONLY FOR UNIT TESTING*
1085
     * @return array With the months as the keys, counts as the values.
1086
     */
1087 1
    public function monthTotals($currentTime = null)
1088
    {
1089 1
        $months = [];
1090
1091 1
        foreach ($this->monthCounts($currentTime)['totals'] as $nsId => $nsData) {
1092 1
            foreach ($nsData as $year => $monthData) {
1093 1
                foreach ($monthData as $month => $count) {
1094 1
                    $monthLabel = $year.'-'.sprintf('%02d', $month);
1095 1
                    if (!isset($months[$monthLabel])) {
1096 1
                        $months[$monthLabel] = 0;
1097
                    }
1098 1
                    $months[$monthLabel] += $count;
1099
                }
1100
            }
1101
        }
1102
1103 1
        return $months;
1104
    }
1105
1106
    /**
1107
     * Get the total numbers of edits per year.
1108
     * @param null|DateTime $currentTime - *USED ONLY FOR UNIT TESTING*
1109
     *   so we can mock the current DateTime.
1110
     * @return mixed[] With keys 'yearLabels' and 'totals', the latter
1111
     *   keyed by namespace then year.
1112
     */
1113 2
    public function yearCounts($currentTime = null)
1114
    {
1115 2
        if (isset($this->yearCounts)) {
1116
            return $this->yearCounts;
1117
        }
1118
1119 2
        $out = $this->monthCounts($currentTime);
1120
1121 2
        foreach ($out['totals'] as $nsId => $years) {
1122 2
            foreach ($years as $year => $months) {
1123 2
                $out['totals'][$nsId][$year] = array_sum(array_values($months));
1124
            }
1125
        }
1126
1127 2
        $this->yearCounts = $out;
1128 2
        return $out;
1129
    }
1130
1131
    /**
1132
     * Get total edits for each year. Used in wikitext export.
1133
     * @param  null|DateTime $currentTime *USED ONLY FOR UNIT TESTING*
1134
     * @return array With the years as the keys, counts as the values.
1135
     */
1136 1
    public function yearTotals($currentTime = null)
1137
    {
1138 1
        $years = [];
1139
1140 1
        foreach ($this->yearCounts($currentTime)['totals'] as $nsId => $nsData) {
1141 1
            foreach ($nsData as $year => $count) {
1142 1
                if (!isset($years[$year])) {
1143 1
                    $years[$year] = 0;
1144
                }
1145 1
                $years[$year] += $count;
1146
            }
1147
        }
1148
1149 1
        return $years;
1150
    }
1151
1152
    /**
1153
     * Get the total edit counts for the top n projects of this user.
1154
     * @param int $numProjects
1155
     * @return mixed[] Each element has 'total' and 'project' keys.
1156
     */
1157 1
    public function globalEditCountsTopN($numProjects = 10)
1158
    {
1159
        // Get counts.
1160 1
        $editCounts = $this->globalEditCounts(true);
1161
        // Truncate, and return.
1162 1
        return array_slice($editCounts, 0, $numProjects);
1163
    }
1164
1165
    /**
1166
     * Get the total number of edits excluding the top n.
1167
     * @param int $numProjects
1168
     * @return int
1169
     */
1170 1
    public function globalEditCountWithoutTopN($numProjects = 10)
1171
    {
1172 1
        $editCounts = $this->globalEditCounts(true);
1173 1
        $bottomM = array_slice($editCounts, $numProjects);
1174 1
        $total = 0;
1175 1
        foreach ($bottomM as $editCount) {
1176 1
            $total += $editCount['total'];
1177
        }
1178 1
        return $total;
1179
    }
1180
1181
    /**
1182
     * Get the grand total of all edits on all projects.
1183
     * @return int
1184
     */
1185 1
    public function globalEditCount()
1186
    {
1187 1
        $total = 0;
1188 1
        foreach ($this->globalEditCounts() as $editCount) {
1189 1
            $total += $editCount['total'];
1190
        }
1191 1
        return $total;
1192
    }
1193
1194
    /**
1195
     * Get the total revision counts for all projects for this user.
1196
     * @param bool $sorted Whether to sort the list by total, or not.
1197
     * @return mixed[] Each element has 'total' and 'project' keys.
1198
     */
1199 1
    public function globalEditCounts($sorted = false)
1200
    {
1201 1
        if (empty($this->globalEditCounts)) {
1202 1
            $this->globalEditCounts = $this->getRepository()
1203 1
                ->globalEditCounts($this->user, $this->project);
1204
        }
1205
1206 1
        if ($sorted) {
1207
            // Sort.
1208 1
            uasort($this->globalEditCounts, function ($a, $b) {
1209 1
                return $b['total'] - $a['total'];
1210 1
            });
1211
        }
1212
1213 1
        return $this->globalEditCounts;
1214
    }
1215
1216
    /**
1217
     * Get the most recent n revisions across all projects.
1218
     * @param int $max The maximum number of revisions to return.
1219
     * @return Edit[]
1220
     */
1221
    public function globalEdits($max)
1222
    {
1223
        if (is_array($this->globalEdits)) {
0 ignored issues
show
introduced by
The condition is_array($this->globalEdits) is always true.
Loading history...
1224
            return $this->globalEdits;
1225
        }
1226
1227
        // Collect all projects with any edits.
1228
        $projects = [];
1229
        foreach ($this->globalEditCounts() as $editCount) {
1230
            // Don't query revisions if there aren't any.
1231
            if ($editCount['total'] == 0) {
1232
                continue;
1233
            }
1234
            $projects[$editCount['project']->getDatabaseName()] = $editCount['project'];
1235
        }
1236
1237
        if (count($projects) === 0) {
1238
            return [];
1239
        }
1240
1241
        // Get all revisions for those projects.
1242
        $globalRevisionsData = $this->getRepository()
1243
            ->getRevisions($projects, $this->user, $max);
1244
        $globalEdits = [];
1245
        foreach ($globalRevisionsData as $revision) {
1246
            /** @var Project $project */
1247
            $project = $projects[$revision['project_name']];
1248
            $nsName = '';
1249
            if ($revision['page_namespace']) {
1250
                $nsName = $project->getNamespaces()[$revision['page_namespace']];
1251
            }
1252
            $page = $project->getRepository()
1253
                ->getPage($project, $nsName . ':' . $revision['page_title']);
1254
            $edit = new Edit($page, $revision);
1255
            $globalEdits[$edit->getTimestamp()->getTimestamp().'-'.$edit->getId()] = $edit;
1256
        }
1257
1258
        // Sort and prune, before adding more.
1259
        krsort($globalEdits);
1260
        $this->globalEdits = array_slice($globalEdits, 0, $max);
1261
1262
        return $this->globalEdits;
1263
    }
1264
1265
    /**
1266
     * Get average edit size, and number of large and small edits.
1267
     * @return int[]
1268
     */
1269
    public function getEditSizeData()
1270
    {
1271
        if (!is_array($this->editSizeData)) {
0 ignored issues
show
introduced by
The condition is_array($this->editSizeData) is always true.
Loading history...
1272
            $this->editSizeData = $this->getRepository()
1273
                ->getEditSizeData($this->project, $this->user);
1274
        }
1275
        return $this->editSizeData;
1276
    }
1277
1278
    /**
1279
     * Get the total edit count of this user or 5,000 if they've made more than 5,000 edits.
1280
     * This is used to ensure percentages of small and large edits are computed properly.
1281
     * @return int
1282
     */
1283 1
    public function countLast5000()
1284
    {
1285 1
        return $this->countLiveRevisions() > 5000 ? 5000 : $this->countLiveRevisions();
1286
    }
1287
1288
    /**
1289
     * Get the number of edits under 20 bytes of the user's past 5000 edits.
1290
     * @return int
1291
     */
1292
    public function countSmallEdits()
1293
    {
1294
        $editSizeData = $this->getEditSizeData();
1295
        return isset($editSizeData['small_edits']) ? (int) $editSizeData['small_edits'] : 0;
1296
    }
1297
1298
    /**
1299
     * Get the total number of edits over 1000 bytes of the user's past 5000 edits.
1300
     * @return int
1301
     */
1302
    public function countLargeEdits()
1303
    {
1304
        $editSizeData = $this->getEditSizeData();
1305
        return isset($editSizeData['large_edits']) ? (int) $editSizeData['large_edits'] : 0;
1306
    }
1307
1308
    /**
1309
     * Get the average size of the user's past 5000 edits.
1310
     * @return float Size in bytes.
1311
     */
1312
    public function averageEditSize()
1313
    {
1314
        $editSizeData = $this->getEditSizeData();
1315
        if (isset($editSizeData['average_size'])) {
1316
            return round($editSizeData['average_size'], 3);
1317
        } else {
1318
            return 0;
1319
        }
1320
    }
1321
}
1322