Passed
Push — master ( 75cc42...9c63a6 )
by MusikAnimal
05:38
created

EditCounter::yearTotals()   A

Complexity

Conditions 4
Paths 4

Size

Total Lines 14
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 8
CRAP Score 4

Importance

Changes 0
Metric Value
cc 4
eloc 7
nc 4
nop 1
dl 0
loc 14
ccs 8
cts 8
cp 1
crap 4
rs 9.2
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 int[] Keys are project DB names. */
50
    protected $globalEditCounts;
51
52
    /** @var array Block data, with keys 'set' and 'received'. */
53
    protected $blocks;
54
55
    /** @var integer[] Array keys are namespace IDs, values are the edit counts */
56
    protected $namespaceTotals;
57
58
    /** @var int Number of semi-automated edits */
59
    protected $autoEditCount;
60
61
    /** @var string[] Data needed for time card chart */
62
    protected $timeCardData;
63
64
    /**
65
     * Revision size data, with keys 'average_size', 'large_edits' and 'small_edits'.
66
     * @var string[] As returned by the DB, unconverted to int or float
67
     */
68
    protected $editSizeData;
69
70
    /**
71
     * Duration of the longest block in seconds; -1 if indefinite,
72
     *   or false if could not be parsed from log params
73
     * @var int|bool
74
     */
75
    protected $longestBlockSeconds;
76
77
    /**
78
     * EditCounter constructor.
79
     * @param Project $project The base project to count edits
80
     * @param User $user
81
     */
82 20
    public function __construct(Project $project, User $user)
83
    {
84 20
        $this->project = $project;
85 20
        $this->user = $user;
86 20
    }
87
88
    /**
89
     * This method asynchronously fetches all the expensive data, waits
90
     * for each request to finish, and copies the values to the class instance.
91
     * @return null
92
     */
93
    public function prepareData()
94
    {
95
        $project = $this->project->getDomain();
96
        $username = $this->user->getUsername();
97
98
        /**
99
         * The URL of each endpoint, keyed by the name of the corresponding class-level
100
         * instance variable.
101
         * @var array[]
102
         */
103
        $endpoints = [
104
            "pairData" => "ec/pairdata/$project/$username",
105
            "logCounts" => "ec/logcounts/$project/$username",
106
            "namespaceTotals" => "ec/namespacetotals/$project/$username",
107
            "editSizeData" => "ec/editsizes/$project/$username",
108
            "monthCounts" => "ec/monthcounts/$project/$username",
109
            // "globalEditCounts" => "ec-globaleditcounts/$project/$username",
110
            "autoEditCount" => "user/automated_editcount/$project/$username",
111
        ];
112
113
        /**
114
         * Keep track of all promises so we can wait for all of them to complete.
115
         * @var GuzzleHttp\Promise\Promise[]
116
         */
117
        $promises = [];
118
119
        foreach ($endpoints as $key => $endpoint) {
120
            $promise = $this->getRepository()->queryXToolsApi($endpoint, true);
121
            $promises[] = $promise;
122
123
            // Handle response of $promise asynchronously.
124
            $promise->then(function ($response) use ($key, $endpoint) {
125
                $result = (array) json_decode($response->getBody()->getContents());
126
127
                $this->getRepository()
128
                    ->getLog()
129
                    ->debug("$key promise resolved successfully.");
130
131
                if (isset($result)) {
132
                    // Copy result to the class class instance. From here any subsequent
133
                    // calls to the getters (e.g. getPairData()) will return these cached values.
134
                    $this->{$key} = $result;
135
                } else {
136
                    // The API should *always* return something, so if $result is not set,
137
                    // something went wrong, so we simply won't set it and the getters will in
138
                    // turn re-attempt to get the data synchronously.
139
                    // We'll log this to see how often it happens.
140
                    $this->getRepository()
141
                        ->getLog()
142
                        ->error("Failed to fetch data for $endpoint via async, " .
143
                            "re-attempting synchoronously.");
144
                }
145
            });
146
        }
147
148
        // Wait for all promises to complete, even if some of them fail.
149
        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

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

926
        foreach (array_keys(/** @scrutinizer ignore-type */ $out['totals']) as $nsId) {
Loading history...
927 2
            ksort($out['totals'][$nsId]);
928
929 2
            foreach ($out['totals'][$nsId] as &$yearData) {
930 2
                ksort($yearData);
931
            }
932
        }
933
934
        // Finally, sort the namespaces
935 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

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