Passed
Push — master ( 48da0f...5f527e )
by MusikAnimal
04:29
created

EditCounter::timeCard()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 8
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 6

Importance

Changes 0
Metric Value
cc 2
eloc 5
nc 2
nop 0
dl 0
loc 8
ccs 0
cts 6
cp 0
crap 6
rs 9.4285
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
15
/**
16
 * An EditCounter provides statistics about a user's edits on a project.
17
 */
18
class EditCounter extends Model
19
{
20
21
    /** @var Project The project. */
22
    protected $project;
23
24
    /** @var User The user. */
25
    protected $user;
26
27
    /** @var int[] Revision and page counts etc. */
28
    protected $pairData;
29
30
    /** @var string[] The start and end dates of revisions. */
31
    protected $revisionDates;
32
33
    /** @var int[] The total page counts. */
34
    protected $pageCounts;
35
36
    /** @var int[] The lot totals. */
37
    protected $logCounts;
38
39
    /** @var mixed[] Total numbers of edits per month */
40
    protected $monthCounts;
41
42
    /** @var mixed[] Total numbers of edits per year */
43
    protected $yearCounts;
44
45
    /** @var int[] Keys are project DB names. */
46
    protected $globalEditCounts;
47
48
    /** @var array Block data, with keys 'set' and 'received'. */
49
    protected $blocks;
50
51
    /** @var integer[] Array keys are namespace IDs, values are the edit counts */
52
    protected $namespaceTotals;
53
54
    /** @var int Number of semi-automated edits */
55
    protected $autoEditCount;
56
57
    /** @var string[] Data needed for time card chart */
58
    protected $timeCardData;
59
60
    /**
61
     * Revision size data, with keys 'average_size', 'large_edits' and 'small_edits'.
62
     * @var string[] As returned by the DB, unconverted to int or float
63
     */
64
    protected $editSizeData;
65
66
    /**
67
     * Duration of the longest block in seconds; -1 if indefinite,
68
     *   or false if could not be parsed from log params
69
     * @var int|bool
70
     */
71
    protected $longestBlockSeconds;
72
73
    /**
74
     * EditCounter constructor.
75
     * @param Project $project The base project to count edits
76
     * @param User $user
77
     */
78 19
    public function __construct(Project $project, User $user)
79
    {
80 19
        $this->project = $project;
81 19
        $this->user = $user;
82 19
    }
83
84
    /**
85
     * This method asynchronously fetches all the expensive data, waits
86
     * for each request to finish, and copies the values to the class instance.
87
     * @return null
88
     */
89
    public function prepareData()
90
    {
91
        $project = $this->project->getDomain();
92
        $username = $this->user->getUsername();
93
94
        /**
95
         * The URL of each endpoint, keyed by the name of the corresponding class-level
96
         * instance variable.
97
         * @var array[]
98
         */
99
        $endpoints = [
100
            "pairData" => "ec/pairdata/$project/$username",
101
            "logCounts" => "ec/logcounts/$project/$username",
102
            "namespaceTotals" => "ec/namespacetotals/$project/$username",
103
            "editSizeData" => "ec/editsizes/$project/$username",
104
            "monthCounts" => "ec/monthcounts/$project/$username",
105
            // "globalEditCounts" => "ec-globaleditcounts/$project/$username",
106
            "autoEditCount" => "user/automated_editcount/$project/$username",
107
        ];
108
109
        /**
110
         * Keep track of all promises so we can wait for all of them to complete.
111
         * @var GuzzleHttp\Promise\Promise[]
112
         */
113
        $promises = [];
114
115
        foreach ($endpoints as $key => $endpoint) {
116
            $promise = $this->getRepository()->queryXToolsApi($endpoint, true);
117
            $promises[] = $promise;
118
119
            // Handle response of $promise asynchronously.
120
            $promise->then(function ($response) use ($key, $endpoint) {
121
                $result = (array) json_decode($response->getBody()->getContents());
122
123
                $this->getRepository()
124
                    ->getLog()
125
                    ->debug("$key promise resolved successfully.");
126
127
                if (isset($result)) {
128
                    // Copy result to the class class instance. From here any subsequent
129
                    // calls to the getters (e.g. getPairData()) will return these cached values.
130
                    $this->{$key} = $result;
131
                } else {
132
                    // The API should *always* return something, so if $result is not set,
133
                    // something went wrong, so we simply won't set it and the getters will in
134
                    // turn re-attempt to get the data synchronously.
135
                    // We'll log this to see how often it happens.
136
                    $this->getRepository()
137
                        ->getLog()
138
                        ->error("Failed to fetch data for $endpoint via async, " .
139
                            "re-attempting synchoronously.");
140
                }
141
            });
142
        }
143
144
        // Wait for all promises to complete, even if some of them fail.
145
        GuzzleHttp\Promise\settle($promises)->wait();
146
147
        // Everything we need now lives on the class instance, so we're done.
148
        return;
149
    }
150
151
    /**
152
     * Get revision and page counts etc.
153
     * @return int[]
154
     */
155 4
    public function getPairData()
156
    {
157 4
        if (!is_array($this->pairData)) {
158 4
            $this->pairData = $this->getRepository()
159 4
                ->getPairData($this->project, $this->user);
160
        }
161 4
        return $this->pairData;
162
    }
163
164
    /**
165
     * Get revision dates.
166
     * @return int[]
167
     */
168
    public function getLogCounts()
169
    {
170
        if (!is_array($this->logCounts)) {
171
            $this->logCounts = $this->getRepository()
172
                ->getLogCounts($this->project, $this->user);
173
        }
174
        return $this->logCounts;
175
    }
176
177
    /**
178
     * Get block data.
179
     * @param string $type Either 'set', 'received'
180
     * @param bool $blocksOnly Whether to include only blocks, and not reblocks and unblocks.
181
     * @return array
182
     */
183 6
    protected function getBlocks($type, $blocksOnly = true)
184
    {
185 6
        if (isset($this->blocks[$type]) && is_array($this->blocks[$type])) {
186
            return $this->blocks[$type];
187
        }
188 6
        $method = "getBlocks".ucfirst($type);
189 6
        $blocks = $this->getRepository()->$method($this->project, $this->user);
190 6
        $this->blocks[$type] = $blocks;
191
192
        // Filter out unblocks unless requested.
193 6
        if ($blocksOnly) {
194
            $blocks = array_filter($blocks, function ($block) {
195
                return $block['log_action'] === 'block';
196
            });
197
        }
198
199 6
        return $blocks;
200
    }
201
202
    /**
203
     * Get the total number of currently-live revisions.
204
     * @return int
205
     */
206 1
    public function countLiveRevisions()
207
    {
208 1
        $revCounts = $this->getPairData();
209 1
        return isset($revCounts['live']) ? (int)$revCounts['live'] : 0;
210
    }
211
212
    /**
213
     * Get the total number of the user's revisions that have been deleted.
214
     * @return int
215
     */
216 1
    public function countDeletedRevisions()
217
    {
218 1
        $revCounts = $this->getPairData();
219 1
        return isset($revCounts['deleted']) ? (int)$revCounts['deleted'] : 0;
220
    }
221
222
    /**
223
     * Get the total edit count (live + deleted).
224
     * @return int
225
     */
226 1
    public function countAllRevisions()
227
    {
228 1
        return $this->countLiveRevisions() + $this->countDeletedRevisions();
229
    }
230
231
    /**
232
     * Get the total number of live revisions with comments.
233
     * @return int
234
     */
235 1
    public function countRevisionsWithComments()
236
    {
237 1
        $revCounts = $this->getPairData();
238 1
        return isset($revCounts['with_comments']) ? (int)$revCounts['with_comments'] : 0;
239
    }
240
241
    /**
242
     * Get the total number of live revisions without comments.
243
     * @return int
244
     */
245 1
    public function countRevisionsWithoutComments()
246
    {
247 1
        return $this->countLiveRevisions() - $this->countRevisionsWithComments();
248
    }
249
250
    /**
251
     * Get the total number of revisions marked as 'minor' by the user.
252
     * @return int
253
     */
254
    public function countMinorRevisions()
255
    {
256
        $revCounts = $this->getPairData();
257
        return isset($revCounts['minor']) ? (int)$revCounts['minor'] : 0;
258
    }
259
260
    /**
261
     * Get the total number of non-deleted pages edited by the user.
262
     * @return int
263
     */
264 1
    public function countLivePagesEdited()
265
    {
266 1
        $pageCounts = $this->getPairData();
267 1
        return isset($pageCounts['edited-live']) ? (int)$pageCounts['edited-live'] : 0;
268
    }
269
270
    /**
271
     * Get the total number of deleted pages ever edited by the user.
272
     * @return int
273
     */
274 1
    public function countDeletedPagesEdited()
275
    {
276 1
        $pageCounts = $this->getPairData();
277 1
        return isset($pageCounts['edited-deleted']) ? (int)$pageCounts['edited-deleted'] : 0;
278
    }
279
280
    /**
281
     * Get the total number of pages ever edited by this user (both live and deleted).
282
     * @return int
283
     */
284 1
    public function countAllPagesEdited()
285
    {
286 1
        return $this->countLivePagesEdited() + $this->countDeletedPagesEdited();
287
    }
288
289
    /**
290
     * Get the total number of pages (both still live and those that have been deleted) created
291
     * by the user.
292
     * @return int
293
     */
294 1
    public function countPagesCreated()
295
    {
296 1
        return $this->countCreatedPagesLive() + $this->countPagesCreatedDeleted();
297
    }
298
299
    /**
300
     * Get the total number of pages created by the user, that have not been deleted.
301
     * @return int
302
     */
303 1
    public function countCreatedPagesLive()
304
    {
305 1
        $pageCounts = $this->getPairData();
306 1
        return isset($pageCounts['created-live']) ? (int)$pageCounts['created-live'] : 0;
307
    }
308
309
    /**
310
     * Get the total number of pages created by the user, that have since been deleted.
311
     * @return int
312
     */
313 1
    public function countPagesCreatedDeleted()
314
    {
315 1
        $pageCounts = $this->getPairData();
316 1
        return isset($pageCounts['created-deleted']) ? (int)$pageCounts['created-deleted'] : 0;
317
    }
318
319
    /**
320
     * Get the total number of pages that have been deleted by the user.
321
     * @return int
322
     */
323
    public function countPagesDeleted()
324
    {
325
        $logCounts = $this->getLogCounts();
326
        return isset($logCounts['delete-delete']) ? (int)$logCounts['delete-delete'] : 0;
327
    }
328
329
    /**
330
     * Get the total number of pages moved by the user.
331
     * @return int
332
     */
333
    public function countPagesMoved()
334
    {
335
        $logCounts = $this->getLogCounts();
336
        return isset($logCounts['move-move']) ? (int)$logCounts['move-move'] : 0;
337
    }
338
339
    /**
340
     * Get the total number of times the user has blocked a user.
341
     * @return int
342
     */
343
    public function countBlocksSet()
344
    {
345
        $logCounts = $this->getLogCounts();
346
        $reBlock = isset($logCounts['block-block']) ? (int)$logCounts['block-block'] : 0;
347
        return $reBlock;
348
    }
349
350
    /**
351
     * Get the total number of times the user has re-blocked a user.
352
     * @return int
353
     */
354
    public function countReblocksSet()
355
    {
356
        $logCounts = $this->getLogCounts();
357
        $reBlock = isset($logCounts['block-reblock']) ? (int)$logCounts['block-reblock'] : 0;
358
        return $reBlock;
359
    }
360
361
    /**
362
     * Get the total number of times the user has unblocked a user.
363
     * @return int
364
     */
365
    public function countUnblocksSet()
366
    {
367
        $logCounts = $this->getLogCounts();
368
        return isset($logCounts['block-unblock']) ? (int)$logCounts['block-unblock'] : 0;
369
    }
370
371
    /**
372
     * Get the total number of blocks that have been lifted (i.e. unblocks) by this user.
373
     * @return int
374
     */
375
    public function countBlocksLifted()
376
    {
377
        $logCounts = $this->getLogCounts();
378
        return isset($logCounts['block-unblock']) ? (int)$logCounts['block-unblock'] : 0;
379
    }
380
381
    /**
382
     * Get the total number of times the user has been blocked.
383
     * @return int
384
     */
385
    public function countBlocksReceived()
386
    {
387
        $blocks = $this->getBlocks('received');
388
        return count($blocks);
389
    }
390
391
    /**
392
     * Get the length of the longest block the user received, in seconds.
393
     * @return int Number of seconds or false if it could not be determined.
394
     *   If the user is blocked, the time since the block is returned. If the block is
395
     *   indefinite, -1 is returned. 0 if there was never a block.
396
     */
397 6
    public function getLongestBlockSeconds()
398
    {
399 6
        if (isset($this->longestBlockSeconds)) {
400
            return $this->longestBlockSeconds;
401
        }
402
403 6
        $blocks = $this->getBlocks('received', false);
404 6
        $this->longestBlockSeconds = false;
405
406
        // If there was never a block, the longest was zero seconds.
407 6
        if (empty($blocks)) {
408
            return 0;
409
        }
410
411
        /**
412
         * Keep track of the last block so we can determine the duration
413
         * if the current block in the loop is an unblock.
414
         * @var int[] [
415
         *              Unix timestamp,
416
         *              Duration in seconds (-1 if indefinite)
417
         *            ]
418
         */
419 6
        $lastBlock = [null, null];
420
421 6
        foreach ($blocks as $index => $block) {
422 6
            list($timestamp, $duration) = $this->parseBlockLogEntry($block);
423
424 6
            if ($block['log_action'] === 'block') {
425
                // This is a new block, so first see if the duration of the last
426
                // block exceeded our longest duration. -1 duration means indefinite.
427 6
                if ($lastBlock[1] > $this->longestBlockSeconds || $lastBlock[1] === -1) {
428 2
                    $this->longestBlockSeconds = $lastBlock[1];
429
                }
430
431
                // Now set this as the last block.
432 6
                $lastBlock = [$timestamp, $duration];
433 3
            } elseif ($block['log_action'] === 'unblock') {
434
                // The last block was lifted. So the duration will be the time from when the
435
                // last block was set to the time of the unblock.
436 1
                $timeSinceLastBlock = $timestamp - $lastBlock[0];
437 1
                if ($timeSinceLastBlock > $this->longestBlockSeconds) {
438 1
                    $this->longestBlockSeconds = $timeSinceLastBlock;
439
440
                    // Reset the last block, as it has now been accounted for.
441 1
                    $lastBlock = null;
442
                }
443 2
            } elseif ($block['log_action'] === 'reblock' && $lastBlock[1] !== -1) {
444
                // The last block was modified. So we will adjust $lastBlock to include
445
                // the difference of the duration of the new reblock, and time since the last block.
446
                // $lastBlock is left unchanged if its duration was indefinite.
447 1
                $timeSinceLastBlock = $timestamp - $lastBlock[0];
448 6
                $lastBlock[1] = $timeSinceLastBlock + $duration;
449
            }
450
        }
451
452
        // If the last block was indefinite, we'll return that as the longest duration.
453 6
        if ($lastBlock[1] === -1) {
454 2
            return -1;
455
        }
456
457
        // Test if the last block is still active, and if so use the expiry as the duration.
458 4
        $lastBlockExpiry = $lastBlock[0] + $lastBlock[1];
459 4
        if ($lastBlockExpiry > time() && $lastBlockExpiry > $this->longestBlockSeconds) {
460 1
            $this->longestBlockSeconds = $lastBlock[1];
461
        // Otherwise, test if the duration of the last block is now the longest overall.
462 3
        } elseif ($lastBlock[1] > $this->longestBlockSeconds) {
463 2
            $this->longestBlockSeconds = $lastBlock[1];
464
        }
465
466 4
        return $this->longestBlockSeconds;
467
    }
468
469
    /**
470
     * Given a block log entry from the database, get the timestamp and duration in seconds.
471
     * @param  mixed[] $block Block log entry as fetched via self::getBlocks()
472
     * @return int[] [
473
     *                 Unix timestamp,
474
     *                 Duration in seconds (-1 if indefinite, null if unparsable or unblock)
475
     *               ]
476
     */
477 11
    public function parseBlockLogEntry($block)
478
    {
479 11
        $timestamp = strtotime($block['log_timestamp']);
480 11
        $duration = null;
481
482
        // First check if the string is serialized, and if so parse it to get the block duration.
483 11
        if (@unserialize($block['log_params']) !== false) {
484 8
            $parsedParams = unserialize($block['log_params']);
485 8
            $durationStr = isset($parsedParams['5::duration']) ? $parsedParams['5::duration'] : null;
486
        } else {
487
            // Old format, the duration in English + block options separated by new lines.
488 4
            $durationStr = explode("\n", $block['log_params'])[0];
489
        }
490
491 11
        if (in_array($durationStr, ['indefinite', 'infinity', 'infinite'])) {
492 3
            $duration = -1;
493
        }
494
495
        // Make sure $durationStr is valid just in case it is in an older, unpredictable format.
496
        // If invalid, $duration is left as null.
497 11
        if (strtotime($durationStr)) {
498 8
            $expiry = strtotime($durationStr, $timestamp);
0 ignored issues
show
Bug introduced by
It seems like $timestamp can also be of type false; however, parameter $now of strtotime() 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

498
            $expiry = strtotime($durationStr, /** @scrutinizer ignore-type */ $timestamp);
Loading history...
499 8
            $duration = $expiry - $timestamp;
500
        }
501
502 11
        return [$timestamp, $duration];
503
    }
504
505
    /**
506
     * Get the total number of pages protected by the user.
507
     * @return int
508
     */
509
    public function countPagesProtected()
510
    {
511
        $logCounts = $this->getLogCounts();
512
        return isset($logCounts['protect-protect']) ? (int)$logCounts['protect-protect'] : 0;
513
    }
514
515
    /**
516
     * Get the total number of pages reprotected by the user.
517
     * @return int
518
     */
519
    public function countPagesReprotected()
520
    {
521
        $logCounts = $this->getLogCounts();
522
        return isset($logCounts['protect-modify']) ? (int)$logCounts['protect-modify'] : 0;
523
    }
524
525
    /**
526
     * Get the total number of pages unprotected by the user.
527
     * @return int
528
     */
529
    public function countPagesUnprotected()
530
    {
531
        $logCounts = $this->getLogCounts();
532
        return isset($logCounts['protect-unprotect']) ? (int)$logCounts['protect-unprotect'] : 0;
533
    }
534
535
    /**
536
     * Get the total number of edits deleted by the user.
537
     * @return int
538
     */
539
    public function countEditsDeleted()
540
    {
541
        $logCounts = $this->getLogCounts();
542
        return isset($logCounts['delete-revision']) ? (int)$logCounts['delete-revision'] : 0;
543
    }
544
545
    /**
546
     * Get the total number of pages restored by the user.
547
     * @return int
548
     */
549
    public function countPagesRestored()
550
    {
551
        $logCounts = $this->getLogCounts();
552
        return isset($logCounts['delete-restore']) ? (int)$logCounts['delete-restore'] : 0;
553
    }
554
555
    /**
556
     * Get the total number of times the user has modified the rights of a user.
557
     * @return int
558
     */
559
    public function countRightsModified()
560
    {
561
        $logCounts = $this->getLogCounts();
562
        return isset($logCounts['rights-rights']) ? (int)$logCounts['rights-rights'] : 0;
563
    }
564
565
    /**
566
     * Get the total number of pages imported by the user (through any import mechanism:
567
     * interwiki, or XML upload).
568
     * @return int
569
     */
570
    public function countPagesImported()
571
    {
572
        $logCounts = $this->getLogCounts();
573
        $import = isset($logCounts['import-import']) ? (int)$logCounts['import-import'] : 0;
574
        $interwiki = isset($logCounts['import-interwiki']) ? (int)$logCounts['import-interwiki'] : 0;
575
        $upload = isset($logCounts['import-upload']) ? (int)$logCounts['import-upload'] : 0;
576
        return $import + $interwiki + $upload;
577
    }
578
579
    /**
580
     * Get the average number of edits per page (including deleted revisions and pages).
581
     * @return float
582
     */
583
    public function averageRevisionsPerPage()
584
    {
585
        if ($this->countAllPagesEdited() == 0) {
586
            return 0;
587
        }
588
        return round($this->countAllRevisions() / $this->countAllPagesEdited(), 3);
589
    }
590
591
    /**
592
     * Average number of edits made per day.
593
     * @return float
594
     */
595
    public function averageRevisionsPerDay()
596
    {
597
        if ($this->getDays() == 0) {
598
            return 0;
599
        }
600
        return round($this->countAllRevisions() / $this->getDays(), 3);
601
    }
602
603
    /**
604
     * Get the total number of edits made by the user with semi-automating tools.
605
     */
606
    public function countAutomatedEdits()
607
    {
608
        if ($this->autoEditCount) {
609
            return $this->autoEditCount;
610
        }
611
        $this->autoEditCount = $this->getRepository()->countAutomatedEdits(
1 ignored issue
show
Bug introduced by
The method countAutomatedEdits() does not exist on Xtools\Repository. It seems like you code against a sub-type of Xtools\Repository such as Xtools\EditCounterRepository or Xtools\AutoEditsRepository. ( Ignorable by Annotation )

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

611
        $this->autoEditCount = $this->getRepository()->/** @scrutinizer ignore-call */ countAutomatedEdits(
Loading history...
612
            $this->project,
613
            $this->user
614
        );
615
        return $this->autoEditCount;
616
    }
617
618
    /**
619
     * Get the count of (non-deleted) edits made in the given timeframe to now.
620
     * @param string $time One of 'day', 'week', 'month', or 'year'.
621
     * @return int The total number of live edits.
622
     */
623
    public function countRevisionsInLast($time)
624
    {
625
        $revCounts = $this->getPairData();
626
        return isset($revCounts[$time]) ? $revCounts[$time] : 0;
627
    }
628
629
    /**
630
     * Get the date and time of the user's first edit.
631
     * @return DateTime|bool The time of the first revision, or false.
632
     */
633 2
    public function datetimeFirstRevision()
634
    {
635 2
        $revDates = $this->getPairData();
636 2
        return isset($revDates['first']) ? new DateTime($revDates['first']) : false;
637
    }
638
639
    /**
640
     * Get the date and time of the user's first edit.
641
     * @return DateTime|bool The time of the last revision, or false.
642
     */
643 2
    public function datetimeLastRevision()
644
    {
645 2
        $revDates = $this->getPairData();
646 2
        return isset($revDates['last']) ? new DateTime($revDates['last']) : false;
647
    }
648
649
    /**
650
     * Get the number of days between the first and last edits.
651
     * If there's only one edit, this is counted as one day.
652
     * @return int
653
     */
654 2
    public function getDays()
655
    {
656 2
        $first = $this->datetimeFirstRevision();
657 2
        $last = $this->datetimeLastRevision();
658 2
        if ($first === false || $last === false) {
659
            return 0;
660
        }
661 2
        $days = $last->diff($first)->days;
662 2
        return $days > 0 ? $days : 1;
663
    }
664
665
    /**
666
     * Get the total number of files uploaded (including those now deleted).
667
     * @return int
668
     */
669
    public function countFilesUploaded()
670
    {
671
        $logCounts = $this->getLogCounts();
672
        return $logCounts['upload-upload'] ?: 0;
673
    }
674
675
    /**
676
     * Get the total number of files uploaded to Commons (including those now deleted).
677
     * This is only applicable for WMF labs installations.
678
     * @return int
679
     */
680
    public function countFilesUploadedCommons()
681
    {
682
        $logCounts = $this->getLogCounts();
683
        return $logCounts['files_uploaded_commons'] ?: 0;
684
    }
685
686
    /**
687
     * Get the total number of revisions the user has sent thanks for.
688
     * @return int
689
     */
690
    public function thanks()
691
    {
692
        $logCounts = $this->getLogCounts();
693
        return $logCounts['thanks-thank'] ?: 0;
694
    }
695
696
    /**
697
     * Get the total number of approvals
698
     * @return int
699
     */
700
    public function approvals()
701
    {
702
        $logCounts = $this->getLogCounts();
703
        $total = $logCounts['review-approve'] +
704
        (!empty($logCounts['review-approve-a']) ? $logCounts['review-approve-a'] : 0) +
705
        (!empty($logCounts['review-approve-i']) ? $logCounts['review-approve-i'] : 0) +
706
        (!empty($logCounts['review-approve-ia']) ? $logCounts['review-approve-ia'] : 0);
707
        return $total;
708
    }
709
710
    /**
711
     * Get the total number of patrols performed by the user.
712
     * @return int
713
     */
714
    public function patrols()
715
    {
716
        $logCounts = $this->getLogCounts();
717
        return $logCounts['patrol-patrol'] ?: 0;
718
    }
719
720
    /**
721
     * Get the total number of accounts created by the user.
722
     * @return int
723
     */
724
    public function accountsCreated()
725
    {
726
        $logCounts = $this->getLogCounts();
727
        $create2 = $logCounts['newusers-create2'] ?: 0;
728
        $byemail = $logCounts['newusers-byemail'] ?: 0;
729
        return $create2 + $byemail;
730
    }
731
732
    /**
733
     * Get the given user's total edit counts per namespace.
734
     * @return integer[] Array keys are namespace IDs, values are the edit counts.
735
     */
736 1
    public function namespaceTotals()
737
    {
738 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...
739
            return $this->namespaceTotals;
740
        }
741 1
        $counts = $this->getRepository()->getNamespaceTotals($this->project, $this->user);
1 ignored issue
show
Bug introduced by
The method getNamespaceTotals() does not exist on Xtools\Repository. It seems like you code against a sub-type of Xtools\Repository such as Xtools\EditCounterRepository. ( Ignorable by Annotation )

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

741
        $counts = $this->getRepository()->/** @scrutinizer ignore-call */ getNamespaceTotals($this->project, $this->user);
Loading history...
742 1
        arsort($counts);
743 1
        $this->namespaceTotals = $counts;
744 1
        return $counts;
745
    }
746
747
    /**
748
     * Get a summary of the times of day and the days of the week that the user has edited.
749
     * @return string[]
750
     */
751
    public function timeCard()
752
    {
753
        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...
754
            return $this->timeCardData;
755
        }
756
        $totals = $this->getRepository()->getTimeCard($this->project, $this->user);
1 ignored issue
show
Bug introduced by
The method getTimeCard() does not exist on Xtools\Repository. It seems like you code against a sub-type of Xtools\Repository such as Xtools\EditCounterRepository. ( Ignorable by Annotation )

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

756
        $totals = $this->getRepository()->/** @scrutinizer ignore-call */ getTimeCard($this->project, $this->user);
Loading history...
757
        $this->timeCardData = $totals;
758
        return $totals;
759
    }
760
761
    /**
762
     * Get the total numbers of edits per month.
763
     * @param null|DateTime $currentTime - *USED ONLY FOR UNIT TESTING*
764
     *   so we can mock the current DateTime.
765
     * @return mixed[] With keys 'yearLabels', 'monthLabels' and 'totals',
766
     *   the latter keyed by namespace, year and then month.
767
     */
768 2
    public function monthCounts($currentTime = null)
769
    {
770 2
        if (isset($this->monthCounts)) {
771
            return $this->monthCounts;
772
        }
773
774
        // Set to current month if we're not unit-testing
775 2
        if (!($currentTime instanceof DateTime)) {
776
            $currentTime = new DateTime('last day of this month');
777
        }
778
779 2
        $totals = $this->getRepository()->getMonthCounts($this->project, $this->user);
1 ignored issue
show
Bug introduced by
The method getMonthCounts() does not exist on Xtools\Repository. It seems like you code against a sub-type of Xtools\Repository such as Xtools\EditCounterRepository. ( Ignorable by Annotation )

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

779
        $totals = $this->getRepository()->/** @scrutinizer ignore-call */ getMonthCounts($this->project, $this->user);
Loading history...
780
        $out = [
781 2
            'yearLabels' => [],  // labels for years
782
            'monthLabels' => [], // labels for months
783
            'totals' => [], // actual totals, grouped by namespace, year and then month
784
        ];
785
786
        /** @var DateTime Keep track of the date of their first edit. */
787 2
        $firstEdit = new DateTime();
788
789 2
        list($out, $firstEdit) = $this->fillInMonthCounts($out, $totals, $firstEdit);
790
791 2
        $dateRange = new DatePeriod(
792 2
            $firstEdit,
793 2
            new DateInterval('P1M'),
794 2
            $currentTime->modify('first day of this month')
795
        );
796
797 2
        $out = $this->fillInMonthTotalsAndLabels($out, $dateRange);
798
799
        // One more set of loops to sort by year/month
800 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

800
        foreach (array_keys(/** @scrutinizer ignore-type */ $out['totals']) as $nsId) {
Loading history...
801 2
            ksort($out['totals'][$nsId]);
802
803 2
            foreach ($out['totals'][$nsId] as &$yearData) {
804 2
                ksort($yearData);
805
            }
806
        }
807
808
        // Finally, sort the namespaces
809 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

809
        ksort(/** @scrutinizer ignore-type */ $out['totals']);
Loading history...
810
811 2
        $this->monthCounts = $out;
812 2
        return $out;
813
    }
814
815
    /**
816
     * Loop through the database results and fill in the values
817
     * for the months that we have data for.
818
     * @param array $out
819
     * @param string[] $totals
820
     * @param DateTime $firstEdit
821
     * @return array [
822
     *           string[] - Modified $out filled with month stats,
823
     *           DateTime - timestamp of first edit
824
     *         ]
825
     * Tests covered in self::monthCounts().
826
     * @codeCoverageIgnore
827
     */
828
    private function fillInMonthCounts($out, $totals, $firstEdit)
829
    {
830
        foreach ($totals as $total) {
831
            // Keep track of first edit
832
            $date = new DateTime($total['year'].'-'.$total['month'].'-01');
833
            if ($date < $firstEdit) {
834
                $firstEdit = $date;
835
            }
836
837
            // Collate the counts by namespace, and then year and month.
838
            $ns = $total['page_namespace'];
839
            if (!isset($out['totals'][$ns])) {
840
                $out['totals'][$ns] = [];
841
            }
842
843
            // Start array for this year if not already present.
844
            if (!isset($out['totals'][$ns][$total['year']])) {
845
                $out['totals'][$ns][$total['year']] = [];
846
            }
847
848
            $out['totals'][$ns][$total['year']][$total['month']] = (int) $total['count'];
849
        }
850
851
        return [$out, $firstEdit];
852
    }
853
854
    /**
855
     * Given the output array, fill each month's totals and labels.
856
     * @param array $out
857
     * @param DatePeriod $dateRange From first edit to present.
858
     * @return string[] - Modified $out filled with month stats.
859
     * Tests covered in self::monthCounts().
860
     * @codeCoverageIgnore
861
     */
862
    private function fillInMonthTotalsAndLabels($out, DatePeriod $dateRange)
863
    {
864
        foreach ($dateRange as $monthObj) {
865
            $year = (int) $monthObj->format('Y');
866
            $month = (int) $monthObj->format('n');
867
868
            // Fill in labels
869
            $out['monthLabels'][] = $monthObj->format('Y-m');
870
            if (!in_array($year, $out['yearLabels'])) {
871
                $out['yearLabels'][] = $year;
872
            }
873
874
            foreach (array_keys($out['totals']) as $nsId) {
875
                if (!isset($out['totals'][$nsId][$year])) {
876
                    $out['totals'][$nsId][$year] = [];
877
                }
878
879
                if (!isset($out['totals'][$nsId][$year][$month])) {
880
                    $out['totals'][$nsId][$year][$month] = 0;
881
                }
882
            }
883
        }
884
885
        return $out;
886
    }
887
888
    /**
889
     * Get the total numbers of edits per year.
890
     * @param null|DateTime [$currentTime] - *USED ONLY FOR UNIT TESTING*
891
     *   so we can mock the current DateTime.
892
     * @return mixed[] With keys 'yearLabels' and 'totals', the latter
893
     *   keyed by namespace then year.
894
     */
0 ignored issues
show
Documentation Bug introduced by
The doc comment [$currentTime] at position 0 could not be parsed: Unknown type name '[' at position 0 in [$currentTime].
Loading history...
895 1
    public function yearCounts($currentTime = null)
896
    {
897 1
        if (isset($this->yearCounts)) {
898
            return $this->yearCounts;
899
        }
900
901 1
        $out = $this->monthCounts($currentTime);
902
903 1
        foreach ($out['totals'] as $nsId => $years) {
904 1
            foreach ($years as $year => $months) {
905 1
                $out['totals'][$nsId][$year] = array_sum(array_values($months));
906
            }
907
        }
908
909 1
        $this->yearCounts = $out;
910 1
        return $out;
911
    }
912
913
    /**
914
     * Get the total edit counts for the top n projects of this user.
915
     * @param int $numProjects
916
     * @return mixed[] Each element has 'total' and 'project' keys.
917
     */
918 1
    public function globalEditCountsTopN($numProjects = 10)
919
    {
920
        // Get counts.
921 1
        $editCounts = $this->globalEditCounts(true);
922
        // Truncate, and return.
923 1
        return array_slice($editCounts, 0, $numProjects);
924
    }
925
926
    /**
927
     * Get the total number of edits excluding the top n.
928
     * @param int $numProjects
929
     * @return int
930
     */
931 1
    public function globalEditCountWithoutTopN($numProjects = 10)
932
    {
933 1
        $editCounts = $this->globalEditCounts(true);
934 1
        $bottomM = array_slice($editCounts, $numProjects);
935 1
        $total = 0;
936 1
        foreach ($bottomM as $editCount) {
937 1
            $total += $editCount['total'];
938
        }
939 1
        return $total;
940
    }
941
942
    /**
943
     * Get the grand total of all edits on all projects.
944
     * @return int
945
     */
946 1
    public function globalEditCount()
947
    {
948 1
        $total = 0;
949 1
        foreach ($this->globalEditCounts() as $editCount) {
950 1
            $total += $editCount['total'];
951
        }
952 1
        return $total;
953
    }
954
955
    /**
956
     * Get the total revision counts for all projects for this user.
957
     * @param bool $sorted Whether to sort the list by total, or not.
958
     * @return mixed[] Each element has 'total' and 'project' keys.
959
     */
960 1
    public function globalEditCounts($sorted = false)
961
    {
962 1
        if (empty($this->globalEditCounts)) {
963 1
            $this->globalEditCounts = $this->getRepository()
964 1
                ->globalEditCounts($this->user, $this->project);
1 ignored issue
show
Bug introduced by
The method globalEditCounts() does not exist on Xtools\Repository. It seems like you code against a sub-type of Xtools\Repository such as Xtools\EditCounterRepository. ( Ignorable by Annotation )

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

964
                ->/** @scrutinizer ignore-call */ globalEditCounts($this->user, $this->project);
Loading history...
965
        }
966
967 1
        if ($sorted) {
968
            // Sort.
969 1
            uasort($this->globalEditCounts, function ($a, $b) {
970 1
                return $b['total'] - $a['total'];
971 1
            });
972
        }
973
974 1
        return $this->globalEditCounts;
975
    }
976
977
    /**
978
     * Get the most recent n revisions across all projects.
979
     * @param int $max The maximum number of revisions to return.
980
     * @return Edit[]
981
     */
982
    public function globalEdits($max)
983
    {
984
        // Collect all projects with any edits.
985
        $projects = [];
986
        foreach ($this->globalEditCounts() as $editCount) {
987
            // Don't query revisions if there aren't any.
988
            if ($editCount['total'] == 0) {
989
                continue;
990
            }
991
            $projects[$editCount['project']->getDatabaseName()] = $editCount['project'];
992
        }
993
994
        // Get all revisions for those projects.
995
        $globalRevisionsData = $this->getRepository()
996
            ->getRevisions($projects, $this->user, $max);
1 ignored issue
show
Bug introduced by
The method getRevisions() does not exist on Xtools\Repository. It seems like you code against a sub-type of Xtools\Repository such as Xtools\EditCounterRepository or Xtools\PageRepository. ( Ignorable by Annotation )

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

996
            ->/** @scrutinizer ignore-call */ getRevisions($projects, $this->user, $max);
Loading history...
997
        $globalEdits = [];
998
        foreach ($globalRevisionsData as $revision) {
999
            /** @var Project $project */
1000
            $project = $projects[$revision['project_name']];
1001
            $nsName = '';
1002
            if ($revision['page_namespace']) {
1003
                $nsName = $project->getNamespaces()[$revision['page_namespace']];
1004
            }
1005
            $page = $project->getRepository()
1006
                ->getPage($project, $nsName . ':' . $revision['page_title']);
1 ignored issue
show
Bug introduced by
The method getPage() does not exist on Xtools\Repository. It seems like you code against a sub-type of Xtools\Repository such as Xtools\ProjectRepository. ( Ignorable by Annotation )

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

1006
                ->/** @scrutinizer ignore-call */ getPage($project, $nsName . ':' . $revision['page_title']);
Loading history...
1007
            $edit = new Edit($page, $revision);
1008
            $globalEdits[$edit->getTimestamp()->getTimestamp().'-'.$edit->getId()] = $edit;
1009
        }
1010
1011
        // Sort and prune, before adding more.
1012
        krsort($globalEdits);
1013
        $globalEdits = array_slice($globalEdits, 0, $max);
1014
        return $globalEdits;
1015
    }
1016
1017
    /**
1018
     * Get average edit size, and number of large and small edits.
1019
     * @return int[]
1020
     */
1021
    public function getEditSizeData()
1022
    {
1023
        if (!is_array($this->editSizeData)) {
1024
            $this->editSizeData = $this->getRepository()
1025
                ->getEditSizeData($this->project, $this->user);
1026
        }
1027
        return $this->editSizeData;
1028
    }
1029
1030
    /**
1031
     * Get the total edit count of this user or 5,000 if they've made more than 5,000 edits.
1032
     * This is used to ensure percentages of small and large edits are computed properly.
1033
     * @return int
1034
     */
1035 1
    public function countLast5000()
1036
    {
1037 1
        return $this->countLiveRevisions() > 5000 ? 5000 : $this->countLiveRevisions();
1038
    }
1039
1040
    /**
1041
     * Get the number of edits under 20 bytes of the user's past 5000 edits.
1042
     * @return int
1043
     */
1044
    public function countSmallEdits()
1045
    {
1046
        $editSizeData = $this->getEditSizeData();
1047
        return isset($editSizeData['small_edits']) ? (int) $editSizeData['small_edits'] : 0;
1048
    }
1049
1050
    /**
1051
     * Get the total number of edits over 1000 bytes of the user's past 5000 edits.
1052
     * @return int
1053
     */
1054
    public function countLargeEdits()
1055
    {
1056
        $editSizeData = $this->getEditSizeData();
1057
        return isset($editSizeData['large_edits']) ? (int) $editSizeData['large_edits'] : 0;
1058
    }
1059
1060
    /**
1061
     * Get the average size of the user's past 5000 edits.
1062
     * @return float Size in bytes.
1063
     */
1064
    public function averageEditSize()
1065
    {
1066
        $editSizeData = $this->getEditSizeData();
1067
        if (isset($editSizeData['average_size'])) {
1068
            return round($editSizeData['average_size'], 3);
1069
        } else {
1070
            return 0;
1071
        }
1072
    }
1073
}
1074