Completed
Pull Request — master (#147)
by MusikAnimal
15:47
created

EditCounter::averageRevisionsPerPage()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 6
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 6

Importance

Changes 0
Metric Value
cc 2
eloc 3
nc 2
nop 0
dl 0
loc 6
ccs 0
cts 4
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
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();
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)) {
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)) {
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);
1 ignored issue
show
Bug introduced by
The method getRightsChanges() 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

220
            ->/** @scrutinizer ignore-call */ getRightsChanges($this->project, $this->user);
Loading history...
221
222 1
        foreach ($logData as $row) {
223
            try {
224 1
                $unserialized = unserialize($row['log_params']);
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 1
            } catch (Exception $e) {
232
                // This is the old school format the most likely contains
233
                // the list of rights additions in as a comma-separated list.
234 1
                list($old, $new) = explode("\n", $row['log_params']);
235 1
                $old = array_filter(array_map('trim', explode(',', $old)));
0 ignored issues
show
Bug introduced by
It seems like $old can also be of type array; however, parameter $string of explode() does only seem to accept string, 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

235
                $old = array_filter(array_map('trim', explode(',', /** @scrutinizer ignore-type */ $old)));
Loading history...
236 1
                $new = array_filter(array_map('trim', explode(',', $new)));
237 1
                $added = array_diff($new, $old);
238 1
                $removed = array_diff($old, $new);
239
            }
240
241 1
            $this->rightsChanges[$row['log_timestamp']] = [
242 1
                'logId' => $row['log_id'],
243 1
                'admin' => $row['log_user_text'],
244 1
                'comment' => Edit::wikifyString($row['log_comment'], $this->project),
245 1
                'added' => array_values($added),
246 1
                'removed' => array_values($removed),
247 1
                'automatic' => $row['log_action'] === 'autopromote'
248
            ];
249
        }
250
251 1
        krsort($this->rightsChanges);
252
253 1
        return $this->rightsChanges;
254
    }
255
256
    /**
257
     * Check the given log entry for rights changes that are set to automatically expire,
258
     * and add entries to $this->rightsChanges accordingly.
259
     * @param array $row Log entry row from database.
260
     * @param array $params Unserialized log params.
261
     * @param string[] $added List of added user rights.
262
     */
263 1
    private function setAutoRemovals($row, $params, $added)
264
    {
265 1
        foreach ($added as $index => $entry) {
266 1
            if (!isset($params['newmetadata'][$index]) ||
267 1
                !array_key_exists('expiry', $params['newmetadata'][$index]) ||
268 1
                empty($params['newmetadata'][$index]['expiry'])
269
            ) {
270 1
                continue;
271
            }
272
273 1
            $expiry = $params['newmetadata'][$index]['expiry'];
274
275 1
            if (isset($this->rightsChanges[$expiry])) {
276 1
                $this->rightsChanges[$expiry]['removed'][] = $entry;
277
            } else {
278 1
                $this->rightsChanges[$expiry] = [
279 1
                    'logId' => $row['log_id'],
280 1
                    'admin' => $row['log_user_text'],
281
                    'comment' => null,
282
                    'added' => [],
283 1
                    'removed' => [$entry],
284
                    'automatic' => true,
285
                ];
286
            }
287
        }
288 1
    }
289
290
    /**
291
     * Get the total number of currently-live revisions.
292
     * @return int
293
     */
294 1
    public function countLiveRevisions()
295
    {
296 1
        $revCounts = $this->getPairData();
297 1
        return isset($revCounts['live']) ? (int)$revCounts['live'] : 0;
298
    }
299
300
    /**
301
     * Get the total number of the user's revisions that have been deleted.
302
     * @return int
303
     */
304 1
    public function countDeletedRevisions()
305
    {
306 1
        $revCounts = $this->getPairData();
307 1
        return isset($revCounts['deleted']) ? (int)$revCounts['deleted'] : 0;
308
    }
309
310
    /**
311
     * Get the total edit count (live + deleted).
312
     * @return int
313
     */
314 1
    public function countAllRevisions()
315
    {
316 1
        return $this->countLiveRevisions() + $this->countDeletedRevisions();
317
    }
318
319
    /**
320
     * Get the total number of live revisions with comments.
321
     * @return int
322
     */
323 1
    public function countRevisionsWithComments()
324
    {
325 1
        $revCounts = $this->getPairData();
326 1
        return isset($revCounts['with_comments']) ? (int)$revCounts['with_comments'] : 0;
327
    }
328
329
    /**
330
     * Get the total number of live revisions without comments.
331
     * @return int
332
     */
333 1
    public function countRevisionsWithoutComments()
334
    {
335 1
        return $this->countLiveRevisions() - $this->countRevisionsWithComments();
336
    }
337
338
    /**
339
     * Get the total number of revisions marked as 'minor' by the user.
340
     * @return int
341
     */
342
    public function countMinorRevisions()
343
    {
344
        $revCounts = $this->getPairData();
345
        return isset($revCounts['minor']) ? (int)$revCounts['minor'] : 0;
346
    }
347
348
    /**
349
     * Get the total number of non-deleted pages edited by the user.
350
     * @return int
351
     */
352 1
    public function countLivePagesEdited()
353
    {
354 1
        $pageCounts = $this->getPairData();
355 1
        return isset($pageCounts['edited-live']) ? (int)$pageCounts['edited-live'] : 0;
356
    }
357
358
    /**
359
     * Get the total number of deleted pages ever edited by the user.
360
     * @return int
361
     */
362 1
    public function countDeletedPagesEdited()
363
    {
364 1
        $pageCounts = $this->getPairData();
365 1
        return isset($pageCounts['edited-deleted']) ? (int)$pageCounts['edited-deleted'] : 0;
366
    }
367
368
    /**
369
     * Get the total number of pages ever edited by this user (both live and deleted).
370
     * @return int
371
     */
372 1
    public function countAllPagesEdited()
373
    {
374 1
        return $this->countLivePagesEdited() + $this->countDeletedPagesEdited();
375
    }
376
377
    /**
378
     * Get the total number of pages (both still live and those that have been deleted) created
379
     * by the user.
380
     * @return int
381
     */
382 1
    public function countPagesCreated()
383
    {
384 1
        return $this->countCreatedPagesLive() + $this->countPagesCreatedDeleted();
385
    }
386
387
    /**
388
     * Get the total number of pages created by the user, that have not been deleted.
389
     * @return int
390
     */
391 1
    public function countCreatedPagesLive()
392
    {
393 1
        $pageCounts = $this->getPairData();
394 1
        return isset($pageCounts['created-live']) ? (int)$pageCounts['created-live'] : 0;
395
    }
396
397
    /**
398
     * Get the total number of pages created by the user, that have since been deleted.
399
     * @return int
400
     */
401 1
    public function countPagesCreatedDeleted()
402
    {
403 1
        $pageCounts = $this->getPairData();
404 1
        return isset($pageCounts['created-deleted']) ? (int)$pageCounts['created-deleted'] : 0;
405
    }
406
407
    /**
408
     * Get the total number of pages that have been deleted by the user.
409
     * @return int
410
     */
411
    public function countPagesDeleted()
412
    {
413
        $logCounts = $this->getLogCounts();
414
        return isset($logCounts['delete-delete']) ? (int)$logCounts['delete-delete'] : 0;
415
    }
416
417
    /**
418
     * Get the total number of pages moved by the user.
419
     * @return int
420
     */
421
    public function countPagesMoved()
422
    {
423
        $logCounts = $this->getLogCounts();
424
        return isset($logCounts['move-move']) ? (int)$logCounts['move-move'] : 0;
425
    }
426
427
    /**
428
     * Get the total number of times the user has blocked a user.
429
     * @return int
430
     */
431
    public function countBlocksSet()
432
    {
433
        $logCounts = $this->getLogCounts();
434
        $reBlock = isset($logCounts['block-block']) ? (int)$logCounts['block-block'] : 0;
435
        return $reBlock;
436
    }
437
438
    /**
439
     * Get the total number of times the user has re-blocked a user.
440
     * @return int
441
     */
442
    public function countReblocksSet()
443
    {
444
        $logCounts = $this->getLogCounts();
445
        $reBlock = isset($logCounts['block-reblock']) ? (int)$logCounts['block-reblock'] : 0;
446
        return $reBlock;
447
    }
448
449
    /**
450
     * Get the total number of times the user has unblocked a user.
451
     * @return int
452
     */
453
    public function countUnblocksSet()
454
    {
455
        $logCounts = $this->getLogCounts();
456
        return isset($logCounts['block-unblock']) ? (int)$logCounts['block-unblock'] : 0;
457
    }
458
459
    /**
460
     * Get the total number of blocks that have been lifted (i.e. unblocks) by this user.
461
     * @return int
462
     */
463
    public function countBlocksLifted()
464
    {
465
        $logCounts = $this->getLogCounts();
466
        return isset($logCounts['block-unblock']) ? (int)$logCounts['block-unblock'] : 0;
467
    }
468
469
    /**
470
     * Get the total number of times the user has been blocked.
471
     * @return int
472
     */
473
    public function countBlocksReceived()
474
    {
475
        $blocks = $this->getBlocks('received');
476
        return count($blocks);
477
    }
478
479
    /**
480
     * Get the length of the longest block the user received, in seconds.
481
     * @return int Number of seconds or false if it could not be determined.
482
     *   If the user is blocked, the time since the block is returned. If the block is
483
     *   indefinite, -1 is returned. 0 if there was never a block.
484
     */
485 6
    public function getLongestBlockSeconds()
486
    {
487 6
        if (isset($this->longestBlockSeconds)) {
488
            return $this->longestBlockSeconds;
489
        }
490
491 6
        $blocks = $this->getBlocks('received', false);
492 6
        $this->longestBlockSeconds = false;
493
494
        // If there was never a block, the longest was zero seconds.
495 6
        if (empty($blocks)) {
496
            return 0;
497
        }
498
499
        /**
500
         * Keep track of the last block so we can determine the duration
501
         * if the current block in the loop is an unblock.
502
         * @var int[] [
503
         *              Unix timestamp,
504
         *              Duration in seconds (-1 if indefinite)
505
         *            ]
506
         */
507 6
        $lastBlock = [null, null];
508
509 6
        foreach ($blocks as $index => $block) {
510 6
            list($timestamp, $duration) = $this->parseBlockLogEntry($block);
511
512 6
            if ($block['log_action'] === 'block') {
513
                // This is a new block, so first see if the duration of the last
514
                // block exceeded our longest duration. -1 duration means indefinite.
515 6
                if ($lastBlock[1] > $this->longestBlockSeconds || $lastBlock[1] === -1) {
516 2
                    $this->longestBlockSeconds = $lastBlock[1];
517
                }
518
519
                // Now set this as the last block.
520 6
                $lastBlock = [$timestamp, $duration];
521 3
            } elseif ($block['log_action'] === 'unblock') {
522
                // The last block was lifted. So the duration will be the time from when the
523
                // last block was set to the time of the unblock.
524 1
                $timeSinceLastBlock = $timestamp - $lastBlock[0];
525 1
                if ($timeSinceLastBlock > $this->longestBlockSeconds) {
526 1
                    $this->longestBlockSeconds = $timeSinceLastBlock;
527
528
                    // Reset the last block, as it has now been accounted for.
529 1
                    $lastBlock = null;
530
                }
531 2
            } elseif ($block['log_action'] === 'reblock' && $lastBlock[1] !== -1) {
532
                // The last block was modified. So we will adjust $lastBlock to include
533
                // the difference of the duration of the new reblock, and time since the last block.
534
                // $lastBlock is left unchanged if its duration was indefinite.
535 1
                $timeSinceLastBlock = $timestamp - $lastBlock[0];
536 6
                $lastBlock[1] = $timeSinceLastBlock + $duration;
537
            }
538
        }
539
540
        // If the last block was indefinite, we'll return that as the longest duration.
541 6
        if ($lastBlock[1] === -1) {
542 2
            return -1;
543
        }
544
545
        // Test if the last block is still active, and if so use the expiry as the duration.
546 4
        $lastBlockExpiry = $lastBlock[0] + $lastBlock[1];
547 4
        if ($lastBlockExpiry > time() && $lastBlockExpiry > $this->longestBlockSeconds) {
548 1
            $this->longestBlockSeconds = $lastBlock[1];
549
        // Otherwise, test if the duration of the last block is now the longest overall.
550 3
        } elseif ($lastBlock[1] > $this->longestBlockSeconds) {
551 2
            $this->longestBlockSeconds = $lastBlock[1];
552
        }
553
554 4
        return $this->longestBlockSeconds;
555
    }
556
557
    /**
558
     * Given a block log entry from the database, get the timestamp and duration in seconds.
559
     * @param  mixed[] $block Block log entry as fetched via self::getBlocks()
560
     * @return int[] [
561
     *                 Unix timestamp,
562
     *                 Duration in seconds (-1 if indefinite, null if unparsable or unblock)
563
     *               ]
564
     */
565 11
    public function parseBlockLogEntry($block)
566
    {
567 11
        $timestamp = strtotime($block['log_timestamp']);
568 11
        $duration = null;
569
570
        // First check if the string is serialized, and if so parse it to get the block duration.
571 11
        if (@unserialize($block['log_params']) !== false) {
572 8
            $parsedParams = unserialize($block['log_params']);
573 8
            $durationStr = isset($parsedParams['5::duration']) ? $parsedParams['5::duration'] : null;
574
        } else {
575
            // Old format, the duration in English + block options separated by new lines.
576 4
            $durationStr = explode("\n", $block['log_params'])[0];
577
        }
578
579 11
        if (in_array($durationStr, ['indefinite', 'infinity', 'infinite'])) {
580 3
            $duration = -1;
581
        }
582
583
        // Make sure $durationStr is valid just in case it is in an older, unpredictable format.
584
        // If invalid, $duration is left as null.
585 11
        if (strtotime($durationStr)) {
586 8
            $expiry = strtotime($durationStr, $timestamp);
587 8
            $duration = $expiry - $timestamp;
588
        }
589
590 11
        return [$timestamp, $duration];
591
    }
592
593
    /**
594
     * Get the total number of pages protected by the user.
595
     * @return int
596
     */
597
    public function countPagesProtected()
598
    {
599
        $logCounts = $this->getLogCounts();
600
        return isset($logCounts['protect-protect']) ? (int)$logCounts['protect-protect'] : 0;
601
    }
602
603
    /**
604
     * Get the total number of pages reprotected by the user.
605
     * @return int
606
     */
607
    public function countPagesReprotected()
608
    {
609
        $logCounts = $this->getLogCounts();
610
        return isset($logCounts['protect-modify']) ? (int)$logCounts['protect-modify'] : 0;
611
    }
612
613
    /**
614
     * Get the total number of pages unprotected by the user.
615
     * @return int
616
     */
617
    public function countPagesUnprotected()
618
    {
619
        $logCounts = $this->getLogCounts();
620
        return isset($logCounts['protect-unprotect']) ? (int)$logCounts['protect-unprotect'] : 0;
621
    }
622
623
    /**
624
     * Get the total number of edits deleted by the user.
625
     * @return int
626
     */
627
    public function countEditsDeleted()
628
    {
629
        $logCounts = $this->getLogCounts();
630
        return isset($logCounts['delete-revision']) ? (int)$logCounts['delete-revision'] : 0;
631
    }
632
633
    /**
634
     * Get the total number of pages restored by the user.
635
     * @return int
636
     */
637
    public function countPagesRestored()
638
    {
639
        $logCounts = $this->getLogCounts();
640
        return isset($logCounts['delete-restore']) ? (int)$logCounts['delete-restore'] : 0;
641
    }
642
643
    /**
644
     * Get the total number of times the user has modified the rights of a user.
645
     * @return int
646
     */
647
    public function countRightsModified()
648
    {
649
        $logCounts = $this->getLogCounts();
650
        return isset($logCounts['rights-rights']) ? (int)$logCounts['rights-rights'] : 0;
651
    }
652
653
    /**
654
     * Get the total number of pages imported by the user (through any import mechanism:
655
     * interwiki, or XML upload).
656
     * @return int
657
     */
658
    public function countPagesImported()
659
    {
660
        $logCounts = $this->getLogCounts();
661
        $import = isset($logCounts['import-import']) ? (int)$logCounts['import-import'] : 0;
662
        $interwiki = isset($logCounts['import-interwiki']) ? (int)$logCounts['import-interwiki'] : 0;
663
        $upload = isset($logCounts['import-upload']) ? (int)$logCounts['import-upload'] : 0;
664
        return $import + $interwiki + $upload;
665
    }
666
667
    /**
668
     * Get the average number of edits per page (including deleted revisions and pages).
669
     * @return float
670
     */
671
    public function averageRevisionsPerPage()
672
    {
673
        if ($this->countAllPagesEdited() == 0) {
674
            return 0;
675
        }
676
        return round($this->countAllRevisions() / $this->countAllPagesEdited(), 3);
677
    }
678
679
    /**
680
     * Average number of edits made per day.
681
     * @return float
682
     */
683
    public function averageRevisionsPerDay()
684
    {
685
        if ($this->getDays() == 0) {
686
            return 0;
687
        }
688
        return round($this->countAllRevisions() / $this->getDays(), 3);
689
    }
690
691
    /**
692
     * Get the total number of edits made by the user with semi-automating tools.
693
     */
694
    public function countAutomatedEdits()
695
    {
696
        if ($this->autoEditCount) {
697
            return $this->autoEditCount;
698
        }
699
        $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

699
        $this->autoEditCount = $this->getRepository()->/** @scrutinizer ignore-call */ countAutomatedEdits(
Loading history...
700
            $this->project,
701
            $this->user
702
        );
703
        return $this->autoEditCount;
704
    }
705
706
    /**
707
     * Get the count of (non-deleted) edits made in the given timeframe to now.
708
     * @param string $time One of 'day', 'week', 'month', or 'year'.
709
     * @return int The total number of live edits.
710
     */
711
    public function countRevisionsInLast($time)
712
    {
713
        $revCounts = $this->getPairData();
714
        return isset($revCounts[$time]) ? $revCounts[$time] : 0;
715
    }
716
717
    /**
718
     * Get the date and time of the user's first edit.
719
     * @return DateTime|bool The time of the first revision, or false.
720
     */
721 2
    public function datetimeFirstRevision()
722
    {
723 2
        $revDates = $this->getPairData();
724 2
        return isset($revDates['first']) ? new DateTime($revDates['first']) : false;
725
    }
726
727
    /**
728
     * Get the date and time of the user's first edit.
729
     * @return DateTime|bool The time of the last revision, or false.
730
     */
731 2
    public function datetimeLastRevision()
732
    {
733 2
        $revDates = $this->getPairData();
734 2
        return isset($revDates['last']) ? new DateTime($revDates['last']) : false;
735
    }
736
737
    /**
738
     * Get the number of days between the first and last edits.
739
     * If there's only one edit, this is counted as one day.
740
     * @return int
741
     */
742 2
    public function getDays()
743
    {
744 2
        $first = $this->datetimeFirstRevision();
745 2
        $last = $this->datetimeLastRevision();
746 2
        if ($first === false || $last === false) {
747
            return 0;
748
        }
749 2
        $days = $last->diff($first)->days;
750 2
        return $days > 0 ? $days : 1;
751
    }
752
753
    /**
754
     * Get the total number of files uploaded (including those now deleted).
755
     * @return int
756
     */
757
    public function countFilesUploaded()
758
    {
759
        $logCounts = $this->getLogCounts();
760
        return $logCounts['upload-upload'] ?: 0;
761
    }
762
763
    /**
764
     * Get the total number of files uploaded to Commons (including those now deleted).
765
     * This is only applicable for WMF labs installations.
766
     * @return int
767
     */
768
    public function countFilesUploadedCommons()
769
    {
770
        $logCounts = $this->getLogCounts();
771
        return $logCounts['files_uploaded_commons'] ?: 0;
772
    }
773
774
    /**
775
     * Get the total number of revisions the user has sent thanks for.
776
     * @return int
777
     */
778
    public function thanks()
779
    {
780
        $logCounts = $this->getLogCounts();
781
        return $logCounts['thanks-thank'] ?: 0;
782
    }
783
784
    /**
785
     * Get the total number of approvals
786
     * @return int
787
     */
788
    public function approvals()
789
    {
790
        $logCounts = $this->getLogCounts();
791
        $total = $logCounts['review-approve'] +
792
        (!empty($logCounts['review-approve-a']) ? $logCounts['review-approve-a'] : 0) +
793
        (!empty($logCounts['review-approve-i']) ? $logCounts['review-approve-i'] : 0) +
794
        (!empty($logCounts['review-approve-ia']) ? $logCounts['review-approve-ia'] : 0);
795
        return $total;
796
    }
797
798
    /**
799
     * Get the total number of patrols performed by the user.
800
     * @return int
801
     */
802
    public function patrols()
803
    {
804
        $logCounts = $this->getLogCounts();
805
        return $logCounts['patrol-patrol'] ?: 0;
806
    }
807
808
    /**
809
     * Get the total number of accounts created by the user.
810
     * @return int
811
     */
812
    public function accountsCreated()
813
    {
814
        $logCounts = $this->getLogCounts();
815
        $create2 = $logCounts['newusers-create2'] ?: 0;
816
        $byemail = $logCounts['newusers-byemail'] ?: 0;
817
        return $create2 + $byemail;
818
    }
819
820
    /**
821
     * Get the given user's total edit counts per namespace.
822
     * @return integer[] Array keys are namespace IDs, values are the edit counts.
823
     */
824 1
    public function namespaceTotals()
825
    {
826 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...
827
            return $this->namespaceTotals;
828
        }
829 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

829
        $counts = $this->getRepository()->/** @scrutinizer ignore-call */ getNamespaceTotals($this->project, $this->user);
Loading history...
830 1
        arsort($counts);
831 1
        $this->namespaceTotals = $counts;
832 1
        return $counts;
833
    }
834
835
    /**
836
     * Get a summary of the times of day and the days of the week that the user has edited.
837
     * @return string[]
838
     */
839
    public function timeCard()
840
    {
841
        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...
842
            return $this->timeCardData;
843
        }
844
        $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

844
        $totals = $this->getRepository()->/** @scrutinizer ignore-call */ getTimeCard($this->project, $this->user);
Loading history...
845
        $this->timeCardData = $totals;
846
        return $totals;
847
    }
848
849
    /**
850
     * Get the total numbers of edits per month.
851
     * @param null|DateTime $currentTime - *USED ONLY FOR UNIT TESTING*
852
     *   so we can mock the current DateTime.
853
     * @return mixed[] With keys 'yearLabels', 'monthLabels' and 'totals',
854
     *   the latter keyed by namespace, year and then month.
855
     */
856 2
    public function monthCounts($currentTime = null)
857
    {
858 2
        if (isset($this->monthCounts)) {
859
            return $this->monthCounts;
860
        }
861
862
        // Set to current month if we're not unit-testing
863 2
        if (!($currentTime instanceof DateTime)) {
864
            $currentTime = new DateTime('last day of this month');
865
        }
866
867 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

867
        $totals = $this->getRepository()->/** @scrutinizer ignore-call */ getMonthCounts($this->project, $this->user);
Loading history...
868
        $out = [
869 2
            'yearLabels' => [],  // labels for years
870
            'monthLabels' => [], // labels for months
871
            'totals' => [], // actual totals, grouped by namespace, year and then month
872
        ];
873
874
        /** @var DateTime Keep track of the date of their first edit. */
875 2
        $firstEdit = new DateTime();
876
877 2
        list($out, $firstEdit) = $this->fillInMonthCounts($out, $totals, $firstEdit);
878
879 2
        $dateRange = new DatePeriod(
880 2
            $firstEdit,
881 2
            new DateInterval('P1M'),
882 2
            $currentTime->modify('first day of this month')
883
        );
884
885 2
        $out = $this->fillInMonthTotalsAndLabels($out, $dateRange);
886
887
        // One more set of loops to sort by year/month
888 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

888
        foreach (array_keys(/** @scrutinizer ignore-type */ $out['totals']) as $nsId) {
Loading history...
889 2
            ksort($out['totals'][$nsId]);
890
891 2
            foreach ($out['totals'][$nsId] as &$yearData) {
892 2
                ksort($yearData);
893
            }
894
        }
895
896
        // Finally, sort the namespaces
897 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

897
        ksort(/** @scrutinizer ignore-type */ $out['totals']);
Loading history...
898
899 2
        $this->monthCounts = $out;
900 2
        return $out;
901
    }
902
903
    /**
904
     * Loop through the database results and fill in the values
905
     * for the months that we have data for.
906
     * @param array $out
907
     * @param string[] $totals
908
     * @param DateTime $firstEdit
909
     * @return array [
910
     *           string[] - Modified $out filled with month stats,
911
     *           DateTime - timestamp of first edit
912
     *         ]
913
     * Tests covered in self::monthCounts().
914
     * @codeCoverageIgnore
915
     */
916
    private function fillInMonthCounts($out, $totals, $firstEdit)
917
    {
918
        foreach ($totals as $total) {
919
            // Keep track of first edit
920
            $date = new DateTime($total['year'].'-'.$total['month'].'-01');
921
            if ($date < $firstEdit) {
922
                $firstEdit = $date;
923
            }
924
925
            // Collate the counts by namespace, and then year and month.
926
            $ns = $total['page_namespace'];
927
            if (!isset($out['totals'][$ns])) {
928
                $out['totals'][$ns] = [];
929
            }
930
931
            // Start array for this year if not already present.
932
            if (!isset($out['totals'][$ns][$total['year']])) {
933
                $out['totals'][$ns][$total['year']] = [];
934
            }
935
936
            $out['totals'][$ns][$total['year']][$total['month']] = (int) $total['count'];
937
        }
938
939
        return [$out, $firstEdit];
940
    }
941
942
    /**
943
     * Given the output array, fill each month's totals and labels.
944
     * @param array $out
945
     * @param DatePeriod $dateRange From first edit to present.
946
     * @return string[] - Modified $out filled with month stats.
947
     * Tests covered in self::monthCounts().
948
     * @codeCoverageIgnore
949
     */
950
    private function fillInMonthTotalsAndLabels($out, DatePeriod $dateRange)
951
    {
952
        foreach ($dateRange as $monthObj) {
953
            $year = (int) $monthObj->format('Y');
954
            $month = (int) $monthObj->format('n');
955
956
            // Fill in labels
957
            $out['monthLabels'][] = $monthObj->format('Y-m');
958
            if (!in_array($year, $out['yearLabels'])) {
959
                $out['yearLabels'][] = $year;
960
            }
961
962
            foreach (array_keys($out['totals']) as $nsId) {
963
                if (!isset($out['totals'][$nsId][$year])) {
964
                    $out['totals'][$nsId][$year] = [];
965
                }
966
967
                if (!isset($out['totals'][$nsId][$year][$month])) {
968
                    $out['totals'][$nsId][$year][$month] = 0;
969
                }
970
            }
971
        }
972
973
        return $out;
974
    }
975
976
    /**
977
     * Get the total numbers of edits per year.
978
     * @param null|DateTime $currentTime - *USED ONLY FOR UNIT TESTING*
979
     *   so we can mock the current DateTime.
980
     * @return mixed[] With keys 'yearLabels' and 'totals', the latter
981
     *   keyed by namespace then year.
982
     */
983 1
    public function yearCounts($currentTime = null)
984
    {
985 1
        if (isset($this->yearCounts)) {
986
            return $this->yearCounts;
987
        }
988
989 1
        $out = $this->monthCounts($currentTime);
990
991 1
        foreach ($out['totals'] as $nsId => $years) {
992 1
            foreach ($years as $year => $months) {
993 1
                $out['totals'][$nsId][$year] = array_sum(array_values($months));
994
            }
995
        }
996
997 1
        $this->yearCounts = $out;
998 1
        return $out;
999
    }
1000
1001
    /**
1002
     * Get the total edit counts for the top n projects of this user.
1003
     * @param int $numProjects
1004
     * @return mixed[] Each element has 'total' and 'project' keys.
1005
     */
1006 1
    public function globalEditCountsTopN($numProjects = 10)
1007
    {
1008
        // Get counts.
1009 1
        $editCounts = $this->globalEditCounts(true);
1010
        // Truncate, and return.
1011 1
        return array_slice($editCounts, 0, $numProjects);
1012
    }
1013
1014
    /**
1015
     * Get the total number of edits excluding the top n.
1016
     * @param int $numProjects
1017
     * @return int
1018
     */
1019 1
    public function globalEditCountWithoutTopN($numProjects = 10)
1020
    {
1021 1
        $editCounts = $this->globalEditCounts(true);
1022 1
        $bottomM = array_slice($editCounts, $numProjects);
1023 1
        $total = 0;
1024 1
        foreach ($bottomM as $editCount) {
1025 1
            $total += $editCount['total'];
1026
        }
1027 1
        return $total;
1028
    }
1029
1030
    /**
1031
     * Get the grand total of all edits on all projects.
1032
     * @return int
1033
     */
1034 1
    public function globalEditCount()
1035
    {
1036 1
        $total = 0;
1037 1
        foreach ($this->globalEditCounts() as $editCount) {
1038 1
            $total += $editCount['total'];
1039
        }
1040 1
        return $total;
1041
    }
1042
1043
    /**
1044
     * Get the total revision counts for all projects for this user.
1045
     * @param bool $sorted Whether to sort the list by total, or not.
1046
     * @return mixed[] Each element has 'total' and 'project' keys.
1047
     */
1048 1
    public function globalEditCounts($sorted = false)
1049
    {
1050 1
        if (empty($this->globalEditCounts)) {
1051 1
            $this->globalEditCounts = $this->getRepository()
1052 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

1052
                ->/** @scrutinizer ignore-call */ globalEditCounts($this->user, $this->project);
Loading history...
1053
        }
1054
1055 1
        if ($sorted) {
1056
            // Sort.
1057 1
            uasort($this->globalEditCounts, function ($a, $b) {
1058 1
                return $b['total'] - $a['total'];
1059 1
            });
1060
        }
1061
1062 1
        return $this->globalEditCounts;
1063
    }
1064
1065
    /**
1066
     * Get the most recent n revisions across all projects.
1067
     * @param int $max The maximum number of revisions to return.
1068
     * @return Edit[]
1069
     */
1070
    public function globalEdits($max)
1071
    {
1072
        // Collect all projects with any edits.
1073
        $projects = [];
1074
        foreach ($this->globalEditCounts() as $editCount) {
1075
            // Don't query revisions if there aren't any.
1076
            if ($editCount['total'] == 0) {
1077
                continue;
1078
            }
1079
            $projects[$editCount['project']->getDatabaseName()] = $editCount['project'];
1080
        }
1081
1082
        // Get all revisions for those projects.
1083
        $globalRevisionsData = $this->getRepository()
1084
            ->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

1084
            ->/** @scrutinizer ignore-call */ getRevisions($projects, $this->user, $max);
Loading history...
1085
        $globalEdits = [];
1086
        foreach ($globalRevisionsData as $revision) {
1087
            /** @var Project $project */
1088
            $project = $projects[$revision['project_name']];
1089
            $nsName = '';
1090
            if ($revision['page_namespace']) {
1091
                $nsName = $project->getNamespaces()[$revision['page_namespace']];
1092
            }
1093
            $page = $project->getRepository()
1094
                ->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

1094
                ->/** @scrutinizer ignore-call */ getPage($project, $nsName . ':' . $revision['page_title']);
Loading history...
1095
            $edit = new Edit($page, $revision);
1096
            $globalEdits[$edit->getTimestamp()->getTimestamp().'-'.$edit->getId()] = $edit;
1097
        }
1098
1099
        // Sort and prune, before adding more.
1100
        krsort($globalEdits);
1101
        $globalEdits = array_slice($globalEdits, 0, $max);
1102
        return $globalEdits;
1103
    }
1104
1105
    /**
1106
     * Get average edit size, and number of large and small edits.
1107
     * @return int[]
1108
     */
1109
    public function getEditSizeData()
1110
    {
1111
        if (!is_array($this->editSizeData)) {
1112
            $this->editSizeData = $this->getRepository()
1113
                ->getEditSizeData($this->project, $this->user);
1114
        }
1115
        return $this->editSizeData;
1116
    }
1117
1118
    /**
1119
     * Get the total edit count of this user or 5,000 if they've made more than 5,000 edits.
1120
     * This is used to ensure percentages of small and large edits are computed properly.
1121
     * @return int
1122
     */
1123 1
    public function countLast5000()
1124
    {
1125 1
        return $this->countLiveRevisions() > 5000 ? 5000 : $this->countLiveRevisions();
1126
    }
1127
1128
    /**
1129
     * Get the number of edits under 20 bytes of the user's past 5000 edits.
1130
     * @return int
1131
     */
1132
    public function countSmallEdits()
1133
    {
1134
        $editSizeData = $this->getEditSizeData();
1135
        return isset($editSizeData['small_edits']) ? (int) $editSizeData['small_edits'] : 0;
1136
    }
1137
1138
    /**
1139
     * Get the total number of edits over 1000 bytes of the user's past 5000 edits.
1140
     * @return int
1141
     */
1142
    public function countLargeEdits()
1143
    {
1144
        $editSizeData = $this->getEditSizeData();
1145
        return isset($editSizeData['large_edits']) ? (int) $editSizeData['large_edits'] : 0;
1146
    }
1147
1148
    /**
1149
     * Get the average size of the user's past 5000 edits.
1150
     * @return float Size in bytes.
1151
     */
1152
    public function averageEditSize()
1153
    {
1154
        $editSizeData = $this->getEditSizeData();
1155
        if (isset($editSizeData['average_size'])) {
1156
            return round($editSizeData['average_size'], 3);
1157
        } else {
1158
            return 0;
1159
        }
1160
    }
1161
}
1162