Passed
Push — master ( 0904dc...f47cd2 )
by MusikAnimal
04:59
created

EditCounter::countCreatedPagesLive()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 4
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 3
CRAP Score 2

Importance

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

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

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

979
        foreach (array_keys(/** @scrutinizer ignore-type */ $out['totals']) as $nsId) {
Loading history...
980 2
            ksort($out['totals'][$nsId]);
981
982 2
            foreach ($out['totals'][$nsId] as &$yearData) {
983 2
                ksort($yearData);
984
            }
985
        }
986
987
        // Finally, sort the namespaces
988 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

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