Passed
Push — master ( 0815a5...5e3306 )
by MusikAnimal
03:29
created

EditCounter::fillInMonthTotalsAndLabels()   B

Complexity

Conditions 6
Paths 11

Size

Total Lines 25
Code Lines 13

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 42

Importance

Changes 0
Metric Value
dl 0
loc 25
ccs 0
cts 0
cp 0
rs 8.439
c 0
b 0
f 0
cc 6
eloc 13
nc 11
nop 2
crap 42
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()
0 ignored issues
show
Bug introduced by
The method getLogCounts() does not exist on Xtools\Repository. Did you maybe mean getLog()?

This check marks calls to methods that do not seem to exist on an object.

This is most likely the result of a method being renamed without all references to it being renamed likewise.

Loading history...
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;
0 ignored issues
show
Bug Compatibility introduced by
The expression $this->longestBlockSeconds; of type integer|boolean adds the type boolean to the return on line 400 which is incompatible with the return type documented by Xtools\EditCounter::getLongestBlockSeconds of type integer.
Loading history...
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;
0 ignored issues
show
Documentation Bug introduced by
It seems like $timeSinceLastBlock can also be of type double. However, the property $longestBlockSeconds is declared as type integer|boolean. Maybe add an additional type check?

Our type inference engine has found a suspicous assignment of a value to a property. This check raises an issue when a value that can be of a mixed type is assigned to a property that is type hinted more strictly.

For example, imagine you have a variable $accountId that can either hold an Id object or false (if there is no account id yet). Your code now assigns that value to the id property of an instance of the Account class. This class holds a proper account, so the id value must no longer be false.

Either this assignment is in error or a type check should be added for that assignment.

class Id
{
    public $id;

    public function __construct($id)
    {
        $this->id = $id;
    }

}

class Account
{
    /** @var  Id $id */
    public $id;
}

$account_id = false;

if (starsAreRight()) {
    $account_id = new Id(42);
}

$account = new Account();
if ($account instanceof Id)
{
    $account->id = $account_id;
}
Loading history...
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);
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->user->countAutomatedEdits($this->project);
612
        return $this->autoEditCount;
613
    }
614
615
    /**
616
     * Get the count of (non-deleted) edits made in the given timeframe to now.
617
     * @param string $time One of 'day', 'week', 'month', or 'year'.
618
     * @return int The total number of live edits.
619
     */
620
    public function countRevisionsInLast($time)
621
    {
622
        $revCounts = $this->getPairData();
623
        return isset($revCounts[$time]) ? $revCounts[$time] : 0;
624
    }
625
626
    /**
627
     * Get the date and time of the user's first edit.
628
     * @return DateTime|bool The time of the first revision, or false.
629
     */
630 2
    public function datetimeFirstRevision()
631
    {
632 2
        $revDates = $this->getPairData();
633 2
        return isset($revDates['first']) ? new DateTime($revDates['first']) : false;
634
    }
635
636
    /**
637
     * Get the date and time of the user's first edit.
638
     * @return DateTime|bool The time of the last revision, or false.
639
     */
640 2
    public function datetimeLastRevision()
641
    {
642 2
        $revDates = $this->getPairData();
643 2
        return isset($revDates['last']) ? new DateTime($revDates['last']) : false;
644
    }
645
646
    /**
647
     * Get the number of days between the first and last edits.
648
     * If there's only one edit, this is counted as one day.
649
     * @return int
650
     */
651 2
    public function getDays()
652
    {
653 2
        $first = $this->datetimeFirstRevision();
654 2
        $last = $this->datetimeLastRevision();
655 2
        if ($first === false || $last === false) {
656
            return 0;
657
        }
658 2
        $days = $last->diff($first)->days;
659 2
        return $days > 0 ? $days : 1;
660
    }
661
662
    /**
663
     * Get the total number of files uploaded (including those now deleted).
664
     * @return int
665
     */
666
    public function countFilesUploaded()
667
    {
668
        $logCounts = $this->getLogCounts();
669
        return $logCounts['upload-upload'] ?: 0;
670
    }
671
672
    /**
673
     * Get the total number of files uploaded to Commons (including those now deleted).
674
     * This is only applicable for WMF labs installations.
675
     * @return int
676
     */
677
    public function countFilesUploadedCommons()
678
    {
679
        $logCounts = $this->getLogCounts();
680
        return $logCounts['files_uploaded_commons'] ?: 0;
681
    }
682
683
    /**
684
     * Get the total number of revisions the user has sent thanks for.
685
     * @return int
686
     */
687
    public function thanks()
688
    {
689
        $logCounts = $this->getLogCounts();
690
        return $logCounts['thanks-thank'] ?: 0;
691
    }
692
693
    /**
694
     * Get the total number of approvals
695
     * @return int
696
     */
697
    public function approvals()
698
    {
699
        $logCounts = $this->getLogCounts();
700
        $total = $logCounts['review-approve'] +
701
        (!empty($logCounts['review-approve-a']) ? $logCounts['review-approve-a'] : 0) +
702
        (!empty($logCounts['review-approve-i']) ? $logCounts['review-approve-i'] : 0) +
703
        (!empty($logCounts['review-approve-ia']) ? $logCounts['review-approve-ia'] : 0);
704
        return $total;
705
    }
706
707
    /**
708
     * Get the total number of patrols performed by the user.
709
     * @return int
710
     */
711
    public function patrols()
712
    {
713
        $logCounts = $this->getLogCounts();
714
        return $logCounts['patrol-patrol'] ?: 0;
715
    }
716
717
    /**
718
     * Get the total number of accounts created by the user.
719
     * @return int
720
     */
721
    public function accountsCreated()
722
    {
723
        $logCounts = $this->getLogCounts();
724
        $create2 = $logCounts['newusers-create2'] ?: 0;
725
        $byemail = $logCounts['newusers-byemail'] ?: 0;
726
        return $create2 + $byemail;
727
    }
728
729
    /**
730
     * Get the given user's total edit counts per namespace.
731
     * @return integer[] Array keys are namespace IDs, values are the edit counts.
732
     */
733 1
    public function namespaceTotals()
734
    {
735 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...
736
            return $this->namespaceTotals;
737
        }
738 1
        $counts = $this->getRepository()->getNamespaceTotals($this->project, $this->user);
739 1
        arsort($counts);
740 1
        $this->namespaceTotals = $counts;
741 1
        return $counts;
742
    }
743
744
    /**
745
     * Get a summary of the times of day and the days of the week that the user has edited.
746
     * @return string[]
747
     */
748
    public function timeCard()
749
    {
750
        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...
751
            return $this->timeCardData;
752
        }
753
        $totals = $this->getRepository()->getTimeCard($this->project, $this->user);
754
        $this->timeCardData = $totals;
755
        return $totals;
756
    }
757
758
    /**
759
     * Get the total numbers of edits per month.
760
     * @param null|DateTime $currentTime - *USED ONLY FOR UNIT TESTING*
761
     *   so we can mock the current DateTime.
762
     * @return mixed[] With keys 'yearLabels', 'monthLabels' and 'totals',
763
     *   the latter keyed by namespace, year and then month.
764
     */
765 2
    public function monthCounts($currentTime = null)
766
    {
767 2
        if (isset($this->monthCounts)) {
768
            return $this->monthCounts;
769
        }
770
771
        // Set to current month if we're not unit-testing
772 2
        if (!($currentTime instanceof DateTime)) {
773
            $currentTime = new DateTime('last day of this month');
774
        }
775
776 2
        $totals = $this->getRepository()->getMonthCounts($this->project, $this->user);
777
        $out = [
778 2
            'yearLabels' => [],  // labels for years
779
            'monthLabels' => [], // labels for months
780
            'totals' => [], // actual totals, grouped by namespace, year and then month
781
        ];
782
783
        /** @var DateTime Keep track of the date of their first edit. */
784 2
        $firstEdit = new DateTime();
785
786 2
        list($out, $firstEdit) = $this->fillInMonthCounts($out, $totals, $firstEdit);
787
788 2
        $dateRange = new DatePeriod(
789 2
            $firstEdit,
790 2
            new DateInterval('P1M'),
791 2
            $currentTime->modify('first day of this month')
792
        );
793
794 2
        $out = $this->fillInMonthTotalsAndLabels($out, $dateRange);
795
796
        // One more set of loops to sort by year/month
797 2
        foreach (array_keys($out['totals']) as $nsId) {
798 2
            ksort($out['totals'][$nsId]);
799
800 2
            foreach ($out['totals'][$nsId] as &$yearData) {
0 ignored issues
show
Bug introduced by
The expression $out['totals'][$nsId] of type string is not traversable.
Loading history...
801 2
                ksort($yearData);
802
            }
803
        }
804
805
        // Finally, sort the namespaces
806 2
        ksort($out['totals']);
807
808 2
        $this->monthCounts = $out;
809 2
        return $out;
810
    }
811
812
    /**
813
     * Loop through the database results and fill in the values
814
     * for the months that we have data for.
815
     * @param array $out
816
     * @param string[] $totals
817
     * @param DateTime $firstEdit
818
     * @return array [
819
     *           string[] - Modified $out filled with month stats,
820
     *           DateTime - timestamp of first edit
821
     *         ]
822
     * Tests covered in self::monthCounts().
823
     * @codeCoverageIgnore
824
     */
825
    private function fillInMonthCounts($out, $totals, $firstEdit)
826
    {
827
        foreach ($totals as $total) {
828
            // Keep track of first edit
829
            $date = new DateTime($total['year'].'-'.$total['month'].'-01');
830
            if ($date < $firstEdit) {
831
                $firstEdit = $date;
832
            }
833
834
            // Collate the counts by namespace, and then year and month.
835
            $ns = $total['page_namespace'];
836
            if (!isset($out['totals'][$ns])) {
837
                $out['totals'][$ns] = [];
838
            }
839
840
            // Start array for this year if not already present.
841
            if (!isset($out['totals'][$ns][$total['year']])) {
842
                $out['totals'][$ns][$total['year']] = [];
843
            }
844
845
            $out['totals'][$ns][$total['year']][$total['month']] = (int) $total['count'];
846
        }
847
848
        return [$out, $firstEdit];
849
    }
850
851
    /**
852
     * Given the output array, fill each month's totals and labels.
853
     * @param array $out
854
     * @param DatePeriod $dateRange From first edit to present.
855
     * @return string[] - Modified $out filled with month stats.
856
     * Tests covered in self::monthCounts().
857
     * @codeCoverageIgnore
858
     */
859
    private function fillInMonthTotalsAndLabels($out, DatePeriod $dateRange)
860
    {
861
        foreach ($dateRange as $monthObj) {
862
            $year = (int) $monthObj->format('Y');
863
            $month = (int) $monthObj->format('n');
864
865
            // Fill in labels
866
            $out['monthLabels'][] = $monthObj->format('Y-m');
867
            if (!in_array($year, $out['yearLabels'])) {
868
                $out['yearLabels'][] = $year;
869
            }
870
871
            foreach (array_keys($out['totals']) as $nsId) {
872
                if (!isset($out['totals'][$nsId][$year])) {
873
                    $out['totals'][$nsId][$year] = [];
874
                }
875
876
                if (!isset($out['totals'][$nsId][$year][$month])) {
877
                    $out['totals'][$nsId][$year][$month] = 0;
878
                }
879
            }
880
        }
881
882
        return $out;
883
    }
884
885
    /**
886
     * Get the total numbers of edits per year.
887
     * @param null|DateTime [$currentTime] - *USED ONLY FOR UNIT TESTING*
888
     *   so we can mock the current DateTime.
889
     * @return mixed[] With keys 'yearLabels' and 'totals', the latter
890
     *   keyed by namespace then year.
891
     */
892 1
    public function yearCounts($currentTime = null)
893
    {
894 1
        if (isset($this->yearCounts)) {
895
            return $this->yearCounts;
896
        }
897
898 1
        $out = $this->monthCounts($currentTime);
899
900 1
        foreach ($out['totals'] as $nsId => $years) {
901 1
            foreach ($years as $year => $months) {
902 1
                $out['totals'][$nsId][$year] = array_sum(array_values($months));
903
            }
904
        }
905
906 1
        $this->yearCounts = $out;
907 1
        return $out;
908
    }
909
910
    /**
911
     * Get the total edit counts for the top n projects of this user.
912
     * @param int $numProjects
913
     * @return mixed[] Each element has 'total' and 'project' keys.
914
     */
915 1
    public function globalEditCountsTopN($numProjects = 10)
916
    {
917
        // Get counts.
918 1
        $editCounts = $this->globalEditCounts(true);
919
        // Truncate, and return.
920 1
        return array_slice($editCounts, 0, $numProjects);
921
    }
922
923
    /**
924
     * Get the total number of edits excluding the top n.
925
     * @param int $numProjects
926
     * @return int
927
     */
928 1
    public function globalEditCountWithoutTopN($numProjects = 10)
929
    {
930 1
        $editCounts = $this->globalEditCounts(true);
931 1
        $bottomM = array_slice($editCounts, $numProjects);
932 1
        $total = 0;
933 1
        foreach ($bottomM as $editCount) {
934 1
            $total += $editCount['total'];
935
        }
936 1
        return $total;
937
    }
938
939
    /**
940
     * Get the grand total of all edits on all projects.
941
     * @return int
942
     */
943 1
    public function globalEditCount()
944
    {
945 1
        $total = 0;
946 1
        foreach ($this->globalEditCounts() as $editCount) {
947 1
            $total += $editCount['total'];
948
        }
949 1
        return $total;
950
    }
951
952
    /**
953
     * Get the total revision counts for all projects for this user.
954
     * @param bool $sorted Whether to sort the list by total, or not.
955
     * @return mixed[] Each element has 'total' and 'project' keys.
956
     */
957 1
    public function globalEditCounts($sorted = false)
958
    {
959 1
        if (empty($this->globalEditCounts)) {
960 1
            $this->globalEditCounts = $this->getRepository()
961 1
                ->globalEditCounts($this->user, $this->project);
962
        }
963
964 1
        if ($sorted) {
965
            // Sort.
966 1
            uasort($this->globalEditCounts, function ($a, $b) {
967 1
                return $b['total'] - $a['total'];
968 1
            });
969
        }
970
971 1
        return $this->globalEditCounts;
972
    }
973
974
    /**
975
     * Get the most recent n revisions across all projects.
976
     * @param int $max The maximum number of revisions to return.
977
     * @return Edit[]
978
     */
979
    public function globalEdits($max)
980
    {
981
        // Collect all projects with any edits.
982
        $projects = [];
983
        foreach ($this->globalEditCounts() as $editCount) {
984
            // Don't query revisions if there aren't any.
985
            if ($editCount['total'] == 0) {
986
                continue;
987
            }
988
            $projects[$editCount['project']->getDatabaseName()] = $editCount['project'];
989
        }
990
991
        // Get all revisions for those projects.
992
        $globalRevisionsData = $this->getRepository()
993
            ->getRevisions($projects, $this->user, $max);
994
        $globalEdits = [];
995
        foreach ($globalRevisionsData as $revision) {
996
            /** @var Project $project */
997
            $project = $projects[$revision['project_name']];
998
            $nsName = '';
999
            if ($revision['page_namespace']) {
1000
                $nsName = $project->getNamespaces()[$revision['page_namespace']];
1001
            }
1002
            $page = $project->getRepository()
1003
                ->getPage($project, $nsName . ':' . $revision['page_title']);
1004
            $edit = new Edit($page, $revision);
1005
            $globalEdits[$edit->getTimestamp()->getTimestamp().'-'.$edit->getId()] = $edit;
1006
        }
1007
1008
        // Sort and prune, before adding more.
1009
        krsort($globalEdits);
1010
        $globalEdits = array_slice($globalEdits, 0, $max);
1011
        return $globalEdits;
1012
    }
1013
1014
    /**
1015
     * Get average edit size, and number of large and small edits.
1016
     * @return int[]
1017
     */
1018
    public function getEditSizeData()
1019
    {
1020
        if (!is_array($this->editSizeData)) {
1021
            $this->editSizeData = $this->getRepository()
1022
                ->getEditSizeData($this->project, $this->user);
1023
        }
1024
        return $this->editSizeData;
1025
    }
1026
1027
    /**
1028
     * Get the total edit count of this user or 5,000 if they've made more than 5,000 edits.
1029
     * This is used to ensure percentages of small and large edits are computed properly.
1030
     * @return int
1031
     */
1032 1
    public function countLast5000()
1033
    {
1034 1
        return $this->countLiveRevisions() > 5000 ? 5000 : $this->countLiveRevisions();
1035
    }
1036
1037
    /**
1038
     * Get the number of edits under 20 bytes of the user's past 5000 edits.
1039
     * @return int
1040
     */
1041
    public function countSmallEdits()
1042
    {
1043
        $editSizeData = $this->getEditSizeData();
1044
        return isset($editSizeData['small_edits']) ? (int) $editSizeData['small_edits'] : 0;
1045
    }
1046
1047
    /**
1048
     * Get the total number of edits over 1000 bytes of the user's past 5000 edits.
1049
     * @return int
1050
     */
1051
    public function countLargeEdits()
1052
    {
1053
        $editSizeData = $this->getEditSizeData();
1054
        return isset($editSizeData['large_edits']) ? (int) $editSizeData['large_edits'] : 0;
1055
    }
1056
1057
    /**
1058
     * Get the average size of the user's past 5000 edits.
1059
     * @return float Size in bytes.
1060
     */
1061
    public function averageEditSize()
1062
    {
1063
        $editSizeData = $this->getEditSizeData();
1064
        if (isset($editSizeData['average_size'])) {
1065
            return round($editSizeData['average_size'], 3);
1066
        } else {
1067
            return 0;
1068
        }
1069
    }
1070
}
1071