PageInfo::getTopTenCount()   A
last analyzed

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 1
nc 1
nop 0
dl 0
loc 3
rs 10
c 0
b 0
f 0
1
<?php
2
3
declare(strict_types = 1);
4
5
namespace App\Model;
6
7
use DateTime;
8
9
/**
10
 * A PageInfo provides statistics about a page on a project.
11
 */
12
class PageInfo extends PageInfoApi
13
{
14
    /** @var int Number of revisions that were actually processed. */
15
    protected int $numRevisionsProcessed;
16
17
    /**
18
     * Various statistics about editors to the page. These are not User objects
19
     * so as to preserve memory.
20
     * @var array
21
     */
22
    protected array $editors = [];
23
24
    /** @var array The top 10 editors to the page by number of edits. */
25
    protected array $topTenEditorsByEdits;
26
27
    /** @var array The top 10 editors to the page by added text. */
28
    protected array $topTenEditorsByAdded;
29
30
    /** @var int Number of edits made by the top 10 editors. */
31
    protected int $topTenCount;
32
33
    /** @var array Various counts about each individual year and month of the page's history. */
34
    protected array $yearMonthCounts;
35
36
    /** @var string[] Localized labels for the years, to be used in the 'Year counts' chart. */
37
    protected array $yearLabels = [];
38
39
    /** @var string[] Localized labels for the months, to be used in the 'Month counts' chart. */
40
    protected array $monthLabels = [];
41
42
    /** @var Edit|null The first edit to the page. */
43
    protected ?Edit $firstEdit = null;
44
45
    /** @var Edit|null The last edit to the page. */
46
    protected ?Edit $lastEdit = null;
47
48
    /** @var Edit|null Edit that made the largest addition by number of bytes. */
49
    protected ?Edit $maxAddition = null;
50
51
    /** @var Edit|null Edit that made the largest deletion by number of bytes. */
52
    protected ?Edit $maxDeletion = null;
53
54
    /**
55
     * Maximum number of edits that were created across all months. This is used as a comparison
56
     * for the bar charts in the months section.
57
     * @var int
58
     */
59
    protected int $maxEditsPerMonth = 0;
60
61
    /** @var string[][] List of (semi-)automated tools that were used to edit the page. */
62
    protected array $tools = [];
63
64
    /**
65
     * Total number of bytes added throughout the page's history. This is used as a comparison
66
     * when computing the top 10 editors by added text.
67
     * @var int
68
     */
69
    protected int $addedBytes = 0;
70
71
    /** @var int Number of days between first and last edit. */
72
    protected int $totalDays;
73
74
    /** @var int Number of minor edits to the page. */
75
    protected int $minorCount = 0;
76
77
    /** @var int Number of anonymous edits to the page. */
78
    protected int $anonCount = 0;
79
80
    /** @var int Number of automated edits to the page. */
81
    protected int $automatedCount = 0;
82
83
    /** @var int Number of edits to the page that were reverted with the subsequent edit. */
84
    protected int $revertCount = 0;
85
86
    /** @var int[] The "edits per <time>" counts. */
87
    protected array $countHistory = [
88
        'day' => 0,
89
        'week' => 0,
90
        'month' => 0,
91
        'year' => 0,
92
    ];
93
94
    /** @var int Number of revisions with deleted information that could effect accuracy of the stats. */
95
    protected int $numDeletedRevisions = 0;
96
97
    /**
98
     * Get the day of last date we should show in the month/year sections,
99
     * based on $this->end or the current date.
100
     * @return int As Unix timestamp.
101
     */
102
    private function getLastDay(): int
103
    {
104
        if (is_int($this->end)) {
105
            return (new DateTime("@$this->end"))
106
                ->modify('last day of this month')
107
                ->getTimestamp();
108
        } else {
109
            return strtotime('last day of this month');
110
        }
111
    }
112
113
    /**
114
     * Return the start/end date values as associative array, with YYYY-MM-DD as the date format.
115
     * This is used mainly as a helper to pass to the pageviews Twig macros.
116
     * @return array
117
     */
118
    public function getDateParams(): array
119
    {
120
        if (!$this->hasDateRange()) {
121
            return [];
122
        }
123
124
        $ret = [
125
            'start' => $this->firstEdit->getTimestamp()->format('Y-m-d'),
0 ignored issues
show
Bug introduced by
The method getTimestamp() does not exist on null. ( Ignorable by Annotation )

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

125
            'start' => $this->firstEdit->/** @scrutinizer ignore-call */ getTimestamp()->format('Y-m-d'),

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
126
            'end' => $this->lastEdit->getTimestamp()->format('Y-m-d'),
0 ignored issues
show
Bug introduced by
The method getTimestamp() does not exist on null. ( Ignorable by Annotation )

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

126
            'end' => $this->lastEdit->/** @scrutinizer ignore-call */ getTimestamp()->format('Y-m-d'),

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
127
        ];
128
129
        if (is_int($this->start)) {
130
            $ret['start'] = date('Y-m-d', $this->start);
131
        }
132
        if (is_int($this->end)) {
133
            $ret['end'] = date('Y-m-d', $this->end);
134
        }
135
136
        return $ret;
137
    }
138
139
    /**
140
     * Get the number of revisions that are actually getting processed. This goes by the APP_MAX_PAGE_REVISIONS
141
     * env variable, or the actual number of revisions, whichever is smaller.
142
     * @return int
143
     */
144
    public function getNumRevisionsProcessed(): int
145
    {
146
        if (isset($this->numRevisionsProcessed)) {
147
            return $this->numRevisionsProcessed;
148
        }
149
150
        if ($this->tooManyRevisions()) {
151
            $this->numRevisionsProcessed = $this->repository->getMaxPageRevisions();
0 ignored issues
show
Bug introduced by
The method getMaxPageRevisions() does not exist on App\Repository\Repository. It seems like you code against a sub-type of App\Repository\Repository such as App\Repository\PageInfoRepository. ( Ignorable by Annotation )

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

151
            /** @scrutinizer ignore-call */ 
152
            $this->numRevisionsProcessed = $this->repository->getMaxPageRevisions();
Loading history...
152
        } else {
153
            $this->numRevisionsProcessed = $this->getNumRevisions();
154
        }
155
156
        return $this->numRevisionsProcessed;
157
    }
158
159
    /**
160
     * Fetch and store all the data we need to show the PageInfo view.
161
     * @codeCoverageIgnore
162
     */
163
    public function prepareData(): void
164
    {
165
        $this->parseHistory();
166
        $this->setLogsEvents();
167
168
        // Bots need to be set before setting top 10 counts.
169
        $this->bots = $this->getBots();
170
171
        $this->doPostPrecessing();
172
    }
173
174
    /**
175
     * Get the number of editors that edited the page.
176
     * @return int
177
     */
178
    public function getNumEditors(): int
179
    {
180
        return count($this->editors);
181
    }
182
183
    /**
184
     * Get the number of days between the first and last edit.
185
     * @return int
186
     */
187
    public function getTotalDays(): int
188
    {
189
        if (isset($this->totalDays)) {
190
            return $this->totalDays;
191
        }
192
        $dateFirst = $this->firstEdit->getTimestamp();
193
        $dateLast = $this->lastEdit->getTimestamp();
194
        $interval = date_diff($dateLast, $dateFirst, true);
195
        $this->totalDays = (int)$interval->format('%a');
196
        return $this->totalDays;
197
    }
198
199
    /**
200
     * Returns length of the page.
201
     * @return int|null
202
     */
203
    public function getLength(): ?int
204
    {
205
        if ($this->hasDateRange()) {
206
            return $this->lastEdit->getLength();
207
        }
208
209
        return $this->page->getLength();
0 ignored issues
show
Bug introduced by
The method getLength() does not exist on null. ( Ignorable by Annotation )

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

209
        return $this->page->/** @scrutinizer ignore-call */ getLength();

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
210
    }
211
212
    /**
213
     * Get the average number of days between edits to the page.
214
     * @return float
215
     */
216
    public function averageDaysPerEdit(): float
217
    {
218
        return round($this->getTotalDays() / $this->getNumRevisionsProcessed(), 1);
219
    }
220
221
    /**
222
     * Get the average number of edits per day to the page.
223
     * @return float
224
     */
225
    public function editsPerDay(): float
226
    {
227
        $editsPerDay = $this->getTotalDays()
228
            ? $this->getNumRevisionsProcessed() / ($this->getTotalDays() / (365 / 12 / 24))
229
            : 0;
230
        return round($editsPerDay, 1);
231
    }
232
233
    /**
234
     * Get the average number of edits per month to the page.
235
     * @return float
236
     */
237
    public function editsPerMonth(): float
238
    {
239
        $editsPerMonth = $this->getTotalDays()
240
            ? $this->getNumRevisionsProcessed() / ($this->getTotalDays() / (365 / 12))
241
            : 0;
242
        return min($this->getNumRevisionsProcessed(), round($editsPerMonth, 1));
243
    }
244
245
    /**
246
     * Get the average number of edits per year to the page.
247
     * @return float
248
     */
249
    public function editsPerYear(): float
250
    {
251
        $editsPerYear = $this->getTotalDays()
252
            ? $this->getNumRevisionsProcessed() / ($this->getTotalDays() / 365)
253
            : 0;
254
        return min($this->getNumRevisionsProcessed(), round($editsPerYear, 1));
255
    }
256
257
    /**
258
     * Get the average number of edits per editor.
259
     * @return float
260
     */
261
    public function editsPerEditor(): float
262
    {
263
        if (count($this->editors) > 0) {
264
            return round($this->getNumRevisionsProcessed() / count($this->editors), 1);
265
        }
266
267
        // To prevent division by zero error; can happen if all usernames are removed (see T303724).
268
        return 0;
269
    }
270
271
    /**
272
     * Get the percentage of minor edits to the page.
273
     * @return float
274
     */
275
    public function minorPercentage(): float
276
    {
277
        return round(
278
            ($this->minorCount / $this->getNumRevisionsProcessed()) * 100,
279
            1
280
        );
281
    }
282
283
    /**
284
     * Get the percentage of anonymous edits to the page.
285
     * @return float
286
     */
287
    public function anonPercentage(): float
288
    {
289
        return round(
290
            ($this->anonCount / $this->getNumRevisionsProcessed()) * 100,
291
            1
292
        );
293
    }
294
295
    /**
296
     * Get the percentage of edits made by the top 10 editors.
297
     * @return float
298
     */
299
    public function topTenPercentage(): float
300
    {
301
        return round(($this->topTenCount / $this->getNumRevisionsProcessed()) * 100, 1);
302
    }
303
304
    /**
305
     * Get the number of automated edits made to the page.
306
     * @return int
307
     */
308
    public function getAutomatedCount(): int
309
    {
310
        return $this->automatedCount;
311
    }
312
313
    /**
314
     * Get the number of edits to the page that were reverted with the subsequent edit.
315
     * @return int
316
     */
317
    public function getRevertCount(): int
318
    {
319
        return $this->revertCount;
320
    }
321
322
    /**
323
     * Get the number of edits to the page made by logged out users.
324
     * @return int
325
     */
326
    public function getAnonCount(): int
327
    {
328
        return $this->anonCount;
329
    }
330
331
    /**
332
     * Get the number of minor edits to the page.
333
     * @return int
334
     */
335
    public function getMinorCount(): int
336
    {
337
        return $this->minorCount;
338
    }
339
340
    /**
341
     * Get the number of edits to the page made in the past day, week, month and year.
342
     * @return int[] With keys 'day', 'week', 'month' and 'year'.
343
     */
344
    public function getCountHistory(): array
345
    {
346
        return $this->countHistory;
347
    }
348
349
    /**
350
     * Get the number of edits to the page made by the top 10 editors.
351
     * @return int
352
     */
353
    public function getTopTenCount(): int
354
    {
355
        return $this->topTenCount;
356
    }
357
358
    /**
359
     * Get the first edit to the page.
360
     * @return Edit
361
     */
362
    public function getFirstEdit(): Edit
363
    {
364
        return $this->firstEdit;
365
    }
366
367
    /**
368
     * Get the last edit to the page.
369
     * @return Edit
370
     */
371
    public function getLastEdit(): Edit
372
    {
373
        return $this->lastEdit;
374
    }
375
376
    /**
377
     * Get the edit that made the largest addition to the page (by number of bytes).
378
     * @return Edit|null
379
     */
380
    public function getMaxAddition(): ?Edit
381
    {
382
        return $this->maxAddition;
383
    }
384
385
    /**
386
     * Get the edit that made the largest removal to the page (by number of bytes).
387
     * @return Edit|null
388
     */
389
    public function getMaxDeletion(): ?Edit
390
    {
391
        return $this->maxDeletion;
392
    }
393
394
    /**
395
     * Get the list of editors to the page, including various statistics.
396
     * @return array
397
     */
398
    public function getEditors(): array
399
    {
400
        return $this->editors;
401
    }
402
403
    /**
404
     * Get usernames of human editors (not bots).
405
     * @param int|null $limit
406
     * @return string[]
407
     */
408
    public function getHumans(?int $limit = null): array
409
    {
410
        return array_slice(array_diff(array_keys($this->getEditors()), array_keys($this->getBots())), 0, $limit);
411
    }
412
413
    /**
414
     * Get the list of the top editors to the page (by edits), including various statistics.
415
     * @return array
416
     */
417
    public function topTenEditorsByEdits(): array
418
    {
419
        return $this->topTenEditorsByEdits;
420
    }
421
422
    /**
423
     * Get the list of the top editors to the page (by added text), including various statistics.
424
     * @return array
425
     */
426
    public function topTenEditorsByAdded(): array
427
    {
428
        return $this->topTenEditorsByAdded;
429
    }
430
431
    /**
432
     * Get various counts about each individual year and month of the page's history.
433
     * @return array
434
     */
435
    public function getYearMonthCounts(): array
436
    {
437
        return $this->yearMonthCounts;
438
    }
439
440
    /**
441
     * Get the localized labels for the 'Year counts' chart.
442
     * @return string[]
443
     */
444
    public function getYearLabels(): array
445
    {
446
        return $this->yearLabels;
447
    }
448
449
    /**
450
     * Get the localized labels for the 'Month counts' chart.
451
     * @return string[]
452
     */
453
    public function getMonthLabels(): array
454
    {
455
        return $this->monthLabels;
456
    }
457
458
    /**
459
     * Get the maximum number of edits that were created across all months. This is used as a
460
     * comparison for the bar charts in the months section.
461
     * @return int
462
     */
463
    public function getMaxEditsPerMonth(): int
464
    {
465
        return $this->maxEditsPerMonth;
466
    }
467
468
    /**
469
     * Get a list of (semi-)automated tools that were used to edit the page, including
470
     * the number of times they were used, and a link to the tool's homepage.
471
     * @return string[]
472
     */
473
    public function getTools(): array
474
    {
475
        return $this->tools;
476
    }
477
478
    /**
479
     * Parse the revision history, collecting our core statistics.
480
     *
481
     * Untestable because it relies on getting a PDO statement. All the important
482
     * logic lives in other methods which are tested.
483
     * @codeCoverageIgnore
484
     */
485
    private function parseHistory(): void
486
    {
487
        $limit = $this->tooManyRevisions() ? $this->repository->getMaxPageRevisions() : null;
488
489
        // Third parameter is ignored if $limit is null.
490
        $revStmt = $this->page->getRevisionsStmt(
491
            null,
492
            $limit,
493
            $this->getNumRevisions(),
494
            $this->start,
495
            $this->end
496
        );
497
        $revCount = 0;
498
499
        /**
500
         * Data about previous edits so that we can use them as a basis for comparison.
501
         * @var Edit[] $prevEdits
502
         */
503
        $prevEdits = [
504
            // The previous Edit, used to discount content that was reverted.
505
            'prev' => null,
506
507
            // The SHA-1 of the edit *before* the previous edit. Used for more
508
            // accurate revert detection.
509
            'prevSha' => null,
510
511
            // The last edit deemed to be the max addition of content. This is kept track of
512
            // in case we find out the next edit was reverted (and was also a max edit),
513
            // in which case we'll want to discount it and use this one instead.
514
            'maxAddition' => null,
515
516
            // Same as with maxAddition, except the maximum amount of content deleted.
517
            // This is used to discount content that was reverted.
518
            'maxDeletion' => null,
519
        ];
520
521
        while ($rev = $revStmt->fetchAssociative()) {
0 ignored issues
show
Bug introduced by
The method fetchAssociative() does not exist on Doctrine\DBAL\Driver\ResultStatement. It seems like you code against a sub-type of said class. However, the method does not exist in Doctrine\DBAL\Driver\Statement. Are you sure you never get one of those? ( Ignorable by Annotation )

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

521
        while ($rev = $revStmt->/** @scrutinizer ignore-call */ fetchAssociative()) {
Loading history...
522
            /** @var Edit $edit */
523
            $edit = $this->repository->getEdit($this->page, $rev);
0 ignored issues
show
Bug introduced by
The method getEdit() does not exist on App\Repository\Repository. It seems like you code against a sub-type of App\Repository\Repository such as App\Repository\TopEditsRepository or App\Repository\PageInfoRepository. ( Ignorable by Annotation )

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

523
            /** @scrutinizer ignore-call */ 
524
            $edit = $this->repository->getEdit($this->page, $rev);
Loading history...
524
525
            if (0 !== $edit->getDeleted()) {
526
                $this->numDeletedRevisions++;
527
            }
528
529
            if (0 === $revCount) {
530
                $this->firstEdit = $edit;
531
            }
532
533
            // Sometimes, with old revisions (2001 era), the revisions from 2002 come before 2001
534
            if ($edit->getTimestamp() < $this->firstEdit->getTimestamp()) {
535
                $this->firstEdit = $edit;
536
            }
537
538
            $prevEdits = $this->updateCounts($edit, $prevEdits);
539
540
            $revCount++;
541
        }
542
543
        $this->numRevisionsProcessed = $revCount;
544
545
        // Various sorts
546
        arsort($this->editors);
547
        ksort($this->yearMonthCounts);
548
        if ($this->tools) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->tools of type array 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...
549
            arsort($this->tools);
550
        }
551
    }
552
553
    /**
554
     * Update various counts based on the current edit.
555
     * @param Edit $edit
556
     * @param Edit[] $prevEdits With 'prev', 'prevSha', 'maxAddition' and 'maxDeletion'
557
     * @return Edit[] Updated version of $prevEdits.
558
     */
559
    private function updateCounts(Edit $edit, array $prevEdits): array
560
    {
561
        // Update the counts for the year and month of the current edit.
562
        $this->updateYearMonthCounts($edit);
563
564
        // Update counts for the user who made the edit.
565
        $this->updateUserCounts($edit);
566
567
        // Update the year/month/user counts of anon and minor edits.
568
        $this->updateAnonMinorCounts($edit);
569
570
        // Update counts for automated tool usage, if applicable.
571
        $this->updateToolCounts($edit);
572
573
        // Increment "edits per <time>" counts
574
        $this->updateCountHistory($edit);
575
576
        // Update figures regarding content addition/removal, and the revert count.
577
        $prevEdits = $this->updateContentSizes($edit, $prevEdits);
578
579
        // Now that we've updated all the counts, we can reset
580
        // the prev and last edits, which are used for tracking.
581
        // But first, let's copy over the SHA of the actual previous edit
582
        // and put it in our $prevEdits['prev'], so that we'll know
583
        // that content added after $prevEdit['prev'] was reverted.
584
        if (null !== $prevEdits['prev']) {
585
            $prevEdits['prevSha'] = $prevEdits['prev']->getSha();
586
        }
587
        $prevEdits['prev'] = $edit;
588
        $this->lastEdit = $edit;
589
590
        return $prevEdits;
591
    }
592
593
    /**
594
     * Update various figures about content sizes based on the given edit.
595
     * @param Edit $edit
596
     * @param Edit[] $prevEdits With 'prev', 'prevSha', 'maxAddition' and 'maxDeletion'.
597
     * @return Edit[] Updated version of $prevEdits.
598
     */
599
    private function updateContentSizes(Edit $edit, array $prevEdits): array
600
    {
601
        // Check if it was a revert
602
        if ($this->isRevert($edit, $prevEdits)) {
603
            $edit->setReverted(true);
604
            return $this->updateContentSizesRevert($prevEdits);
605
        } else {
606
            return $this->updateContentSizesNonRevert($edit, $prevEdits);
607
        }
608
    }
609
610
    /**
611
     * Is the given Edit a revert?
612
     * @param Edit $edit
613
     * @param Edit[] $prevEdits With 'prev', 'prevSha', 'maxAddition' and 'maxDeletion'.
614
     * @return bool
615
     */
616
    private function isRevert(Edit $edit, array $prevEdits): bool
617
    {
618
        return $edit->getSha() === $prevEdits['prevSha'] || $edit->isRevert();
619
    }
620
621
    /**
622
     * Updates the figures on content sizes assuming the given edit was a revert of the previous one.
623
     * In such a case, we don't want to treat the previous edit as legit content addition or removal.
624
     * @param Edit[] $prevEdits With 'prev', 'prevSha', 'maxAddition' and 'maxDeletion'.
625
     * @return Edit[] Updated version of $prevEdits, for tracking.
626
     */
627
    private function updateContentSizesRevert(array $prevEdits): array
628
    {
629
        $this->revertCount++;
630
631
        // Adjust addedBytes given this edit was a revert of the previous one.
632
        if ($prevEdits['prev'] && false === $prevEdits['prev']->isReverted() && $prevEdits['prev']->getSize() > 0) {
633
            $this->addedBytes -= $prevEdits['prev']->getSize();
634
635
            // Also deduct from the user's individual added byte count.
636
            // We don't do this if the previous edit was reverted, since that would make the net bytes zero.
637
            if ($prevEdits['prev']->getUser()) {
638
                $username = $prevEdits['prev']->getUser()->getUsername();
639
                $this->editors[$username]['added'] -= $prevEdits['prev']->getSize();
640
            }
641
        }
642
643
        // @TODO: Test this against an edit war (use your sandbox).
644
        // Also remove as max added or deleted, if applicable.
645
        if ($this->maxAddition && $prevEdits['prev']->getId() === $this->maxAddition->getId()) {
646
            $this->maxAddition = $prevEdits['maxAddition'];
647
            $prevEdits['maxAddition'] = $prevEdits['prev']; // In the event of edit wars.
648
        } elseif ($this->maxDeletion && $prevEdits['prev']->getId() === $this->maxDeletion->getId()) {
649
            $this->maxDeletion = $prevEdits['maxDeletion'];
650
            $prevEdits['maxDeletion'] = $prevEdits['prev']; // In the event of edit wars.
651
        }
652
653
        return $prevEdits;
654
    }
655
656
    /**
657
     * Updates the figures on content sizes assuming the given edit was NOT a revert of the previous edit.
658
     * @param Edit $edit
659
     * @param Edit[] $prevEdits With 'prev', 'prevSha', 'maxAddition' and 'maxDeletion'.
660
     * @return Edit[] Updated version of $prevEdits, for tracking.
661
     */
662
    private function updateContentSizesNonRevert(Edit $edit, array $prevEdits): array
663
    {
664
        $editSize = $this->getEditSize($edit, $prevEdits);
665
666
        // Edit was not a revert, so treat size > 0 as content added.
667
        if ($editSize > 0) {
668
            $this->addedBytes += $editSize;
669
670
            if ($edit->getUser()) {
671
                $this->editors[$edit->getUser()->getUsername()]['added'] += $editSize;
672
            }
673
674
            // Keep track of edit with max addition.
675
            if (!$this->maxAddition || $editSize > $this->maxAddition->getSize()) {
676
                // Keep track of old maxAddition in case we find out the next $edit was reverted
677
                // (and was also a max edit), in which case we'll want to use this one ($edit).
678
                $prevEdits['maxAddition'] = $this->maxAddition;
679
680
                $this->maxAddition = $edit;
681
            }
682
        } elseif ($editSize < 0 && (!$this->maxDeletion || $editSize < $this->maxDeletion->getSize())) {
683
            // Keep track of old maxDeletion in case we find out the next edit was reverted
684
            // (and was also a max deletion), in which case we'll want to use this one.
685
            $prevEdits['maxDeletion'] = $this->maxDeletion;
686
687
            $this->maxDeletion = $edit;
688
        }
689
690
        return $prevEdits;
691
    }
692
693
    /**
694
     * Get the size of the given edit, based on the previous edit (if present).
695
     * We also don't return the actual edit size if last revision had a length of null.
696
     * This happens when the edit follows other edits that were revision-deleted.
697
     * @see T148857 for more information.
698
     * @todo Remove once T101631 is resolved.
699
     * @param Edit $edit
700
     * @param Edit[] $prevEdits With 'prev', 'prevSha', 'maxAddition' and 'maxDeletion'.
701
     * @return int|null
702
     */
703
    private function getEditSize(Edit $edit, array $prevEdits): ?int
704
    {
705
        if ($prevEdits['prev'] && null === $prevEdits['prev']->getLength()) {
706
            return 0;
707
        } else {
708
            return $edit->getSize();
709
        }
710
    }
711
712
    /**
713
     * Update counts of automated tool usage for the given edit.
714
     * @param Edit $edit
715
     */
716
    private function updateToolCounts(Edit $edit): void
717
    {
718
        $automatedTool = $edit->getTool();
719
720
        if (!$automatedTool) {
721
            // Nothing to do.
722
            return;
723
        }
724
725
        $editYear = $edit->getYear();
726
        $editMonth = $edit->getMonth();
727
728
        $this->automatedCount++;
729
        $this->yearMonthCounts[$editYear]['automated']++;
730
        $this->yearMonthCounts[$editYear]['months'][$editMonth]['automated']++;
731
732
        if (!isset($this->tools[$automatedTool['name']])) {
733
            $this->tools[$automatedTool['name']] = [
734
                'count' => 1,
735
                'link' => $automatedTool['link'],
736
            ];
737
        } else {
738
            $this->tools[$automatedTool['name']]['count']++;
739
        }
740
    }
741
742
    /**
743
     * Update various counts for the year and month of the given edit.
744
     * @param Edit $edit
745
     */
746
    private function updateYearMonthCounts(Edit $edit): void
747
    {
748
        $editYear = $edit->getYear();
749
        $editMonth = $edit->getMonth();
750
751
        // Fill in the blank arrays for the year and 12 months if needed.
752
        if (!isset($this->yearMonthCounts[$editYear])) {
753
            $this->addYearMonthCountEntry($edit);
754
        }
755
756
        // Increment year and month counts for all edits
757
        $this->yearMonthCounts[$editYear]['all']++;
758
        $this->yearMonthCounts[$editYear]['months'][$editMonth]['all']++;
759
        // This will ultimately be the size of the page by the end of the year
760
        $this->yearMonthCounts[$editYear]['size'] = $edit->getLength();
761
762
        // Keep track of which month had the most edits
763
        $editsThisMonth = $this->yearMonthCounts[$editYear]['months'][$editMonth]['all'];
764
        if ($editsThisMonth > $this->maxEditsPerMonth) {
765
            $this->maxEditsPerMonth = $editsThisMonth;
766
        }
767
    }
768
769
    /**
770
     * Add a new entry to $this->yearMonthCounts for the given year,
771
     * with blank values for each month. This called during self::parseHistory().
772
     * @param Edit $edit
773
     */
774
    private function addYearMonthCountEntry(Edit $edit): void
775
    {
776
        $this->yearLabels[] = $this->i18n->dateFormat($edit->getTimestamp(), 'yyyy');
777
        $editYear = $edit->getYear();
778
779
        // Beginning of the month at 00:00:00.
780
        $firstEditTime = mktime(0, 0, 0, (int)$this->firstEdit->getMonth(), 1, (int)$this->firstEdit->getYear());
781
782
        $this->yearMonthCounts[$editYear] = [
783
            'all' => 0,
784
            'minor' => 0,
785
            'anon' => 0,
786
            'automated' => 0,
787
            'size' => 0, // Keep track of the size by the end of the year.
788
            'events' => [],
789
            'months' => [],
790
        ];
791
792
        for ($i = 1; $i <= 12; $i++) {
793
            $timeObj = mktime(0, 0, 0, $i, 1, (int)$editYear);
794
795
            // Don't show zeros for months before the first edit or after the current month.
796
            if ($timeObj < $firstEditTime || $timeObj > $this->getLastDay()) {
797
                continue;
798
            }
799
800
            $this->monthLabels[] = $this->i18n->dateFormat($timeObj, 'yyyy-MM');
801
            $this->yearMonthCounts[$editYear]['months'][sprintf('%02d', $i)] = [
802
                'all' => 0,
803
                'minor' => 0,
804
                'anon' => 0,
805
                'automated' => 0,
806
            ];
807
        }
808
    }
809
810
    /**
811
     * Update the counts of anon and minor edits for year, month, and user of the given edit.
812
     * @param Edit $edit
813
     */
814
    private function updateAnonMinorCounts(Edit $edit): void
815
    {
816
        $editYear = $edit->getYear();
817
        $editMonth = $edit->getMonth();
818
819
        // If anonymous, increase counts
820
        if ($edit->isAnon()) {
821
            $this->anonCount++;
822
            $this->yearMonthCounts[$editYear]['anon']++;
823
            $this->yearMonthCounts[$editYear]['months'][$editMonth]['anon']++;
824
        }
825
826
        // If minor edit, increase counts
827
        if ($edit->isMinor()) {
828
            $this->minorCount++;
829
            $this->yearMonthCounts[$editYear]['minor']++;
830
            $this->yearMonthCounts[$editYear]['months'][$editMonth]['minor']++;
831
        }
832
    }
833
834
    /**
835
     * Update various counts for the user of the given edit.
836
     * @param Edit $edit
837
     */
838
    private function updateUserCounts(Edit $edit): void
839
    {
840
        if (!$edit->getUser()) {
841
            return;
842
        }
843
844
        $username = $edit->getUser()->getUsername();
845
846
        // Initialize various user stats if needed.
847
        if (!isset($this->editors[$username])) {
848
            $this->editors[$username] = [
849
                'all' => 0,
850
                'minor' => 0,
851
                'minorPercentage' => 0,
852
                'first' => $edit->getTimestamp(),
853
                'firstId' => $edit->getId(),
854
                'last' => null,
855
                'atbe' => null,
856
                'added' => 0,
857
            ];
858
        }
859
860
        // Increment user counts
861
        $this->editors[$username]['all']++;
862
        $this->editors[$username]['last'] = $edit->getTimestamp();
863
        $this->editors[$username]['lastId'] = $edit->getId();
864
865
        // Increment minor counts for this user
866
        if ($edit->isMinor()) {
867
            $this->editors[$username]['minor']++;
868
        }
869
    }
870
871
    /**
872
     * Increment "edits per <time>" counts based on the given edit.
873
     * @param Edit $edit
874
     */
875
    private function updateCountHistory(Edit $edit): void
876
    {
877
        $editTimestamp = $edit->getTimestamp();
878
879
        if ($editTimestamp > new DateTime('-1 day')) {
880
            $this->countHistory['day']++;
881
        }
882
        if ($editTimestamp > new DateTime('-1 week')) {
883
            $this->countHistory['week']++;
884
        }
885
        if ($editTimestamp > new DateTime('-1 month')) {
886
            $this->countHistory['month']++;
887
        }
888
        if ($editTimestamp > new DateTime('-1 year')) {
889
            $this->countHistory['year']++;
890
        }
891
    }
892
893
    /**
894
     * Query for log events during each year of the page's history, and set the results in $this->yearMonthCounts.
895
     */
896
    private function setLogsEvents(): void
897
    {
898
        $logData = $this->repository->getLogEvents(
0 ignored issues
show
Bug introduced by
The method getLogEvents() does not exist on App\Repository\Repository. It seems like you code against a sub-type of App\Repository\Repository such as App\Repository\PageInfoRepository. ( Ignorable by Annotation )

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

898
        /** @scrutinizer ignore-call */ 
899
        $logData = $this->repository->getLogEvents(
Loading history...
899
            $this->page,
900
            $this->start,
901
            $this->end
902
        );
903
904
        foreach ($logData as $event) {
905
            $time = strtotime($event['timestamp']);
906
            $year = date('Y', $time);
907
908
            if (!isset($this->yearMonthCounts[$year])) {
909
                break;
910
            }
911
912
            $yearEvents = $this->yearMonthCounts[$year]['events'];
913
914
            // Convert log type value to i18n key.
915
            switch ($event['log_type']) {
916
                // count pending-changes protections along with normal protections.
917
                case 'stable':
918
                case 'protect':
919
                    $action = 'protections';
920
                    break;
921
                case 'delete':
922
                    $action = 'deletions';
923
                    break;
924
                case 'move':
925
                    $action = 'moves';
926
                    break;
927
            }
928
929
            if (empty($yearEvents[$action])) {
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $action does not seem to be defined for all execution paths leading up to this point.
Loading history...
930
                $yearEvents[$action] = 1;
931
            } else {
932
                $yearEvents[$action]++;
933
            }
934
935
            $this->yearMonthCounts[$year]['events'] = $yearEvents;
936
        }
937
    }
938
939
    /**
940
     * Set statistics about the top 10 editors by added text and number of edits.
941
     * This is ran *after* parseHistory() since we need the grand totals first.
942
     * Various stats are also set for each editor in $this->editors to be used in the charts.
943
     */
944
    private function doPostPrecessing(): void
945
    {
946
        $topTenCount = $counter = 0;
947
        $topTenEditorsByEdits = [];
948
949
        foreach ($this->editors as $editor => $info) {
950
            // Count how many users are in the top 10% by number of edits, excluding bots.
951
            if ($counter < 10 && !in_array($editor, array_keys($this->bots))) {
952
                $topTenCount += $info['all'];
953
                $counter++;
954
955
                // To be used in the Top Ten charts.
956
                $topTenEditorsByEdits[] = [
957
                    'label' => $editor,
958
                    'value' => $info['all'],
959
                ];
960
            }
961
962
            // Compute the percentage of minor edits the user made.
963
            $this->editors[$editor]['minorPercentage'] = $info['all']
964
                ? ($info['minor'] / $info['all']) * 100
965
                : 0;
966
967
            if ($info['all'] > 1) {
968
                // Number of seconds/days between first and last edit.
969
                $secs = $info['last']->getTimestamp() - $info['first']->getTimestamp();
970
                $days = $secs / (60 * 60 * 24);
971
972
                // Average time between edits (in days).
973
                $this->editors[$editor]['atbe'] = round($days / ($info['all'] - 1), 1);
974
            }
975
        }
976
977
        // Loop through again and add percentages.
978
        $this->topTenEditorsByEdits = array_map(function ($editor) use ($topTenCount) {
979
            $editor['percentage'] = 100 * ($editor['value'] / $topTenCount);
980
            return $editor;
981
        }, $topTenEditorsByEdits);
982
983
        $this->topTenEditorsByAdded = $this->getTopTenByAdded();
984
985
        $this->topTenCount = $topTenCount;
986
    }
987
988
    /**
989
     * Get the top ten editors by added text.
990
     * @return array With keys 'label', 'value' and 'percentage', ready to be used by the pieChart Twig helper.
991
     */
992
    private function getTopTenByAdded(): array
993
    {
994
        // First sort editors array by the amount of text they added.
995
        $topTenEditorsByAdded = $this->editors;
996
        uasort($topTenEditorsByAdded, function ($a, $b) {
997
            if ($a['added'] === $b['added']) {
998
                return 0;
999
            }
1000
            return $a['added'] > $b['added'] ? -1 : 1;
1001
        });
1002
1003
        // Slice to the top 10.
1004
        $topTenEditorsByAdded = array_keys(array_slice($topTenEditorsByAdded, 0, 10, true));
1005
1006
         // Get the sum of added text so that we can add in percentages.
1007
         $topTenTotalAdded = array_sum(array_map(function ($editor) {
1008
             return $this->editors[$editor]['added'];
1009
         }, $topTenEditorsByAdded));
1010
1011
        // Then build a new array of top 10 editors by added text in the data structure needed for the chart.
1012
        return array_map(function ($editor) use ($topTenTotalAdded) {
1013
            $added = $this->editors[$editor]['added'];
1014
            return [
1015
                'label' => $editor,
1016
                'value' => $added,
1017
                'percentage' => 0 === $this->addedBytes
1018
                    ? 0
1019
                    : 100 * ($added / $topTenTotalAdded),
1020
            ];
1021
        }, $topTenEditorsByAdded);
1022
    }
1023
1024
    /**
1025
     * Get the number of times the page has been viewed in the last PageInfoApi::PAGEVIEWS_OFFSET days.
1026
     * If the PageInfo instance has a date range, it is used instead of the last N days.
1027
     * To reduce logic in the view, this method returns an array also containing the localized string
1028
     * for the pageviews count, as well as the tooltip to be used on the link to the Pageviews tool.
1029
     * @return array With keys 'count'<int>, 'formatted'<string> and 'tooltip'<string>
1030
     *@see PageInfoApi::PAGEVIEWS_OFFSET
1031
     */
1032
    public function getPageviews(): ?array
1033
    {
1034
        if (!$this->hasDateRange()) {
1035
            $pageviews = $this->page->getLatestPageviews();
1036
        } else {
1037
            $dateRange = $this->getDateParams();
1038
            $pageviews = $this->page->getPageviews($dateRange['start'], $dateRange['end']);
1039
        }
1040
1041
        return [
1042
            'count' => $pageviews,
1043
            'formatted' => $this->getPageviewsFormatted($pageviews),
1044
            'tooltip' => $this->getPageviewsTooltip($pageviews),
1045
        ];
1046
    }
1047
1048
    /**
1049
     * Convenience method for the view to get the value of the offset constant.
1050
     * (Twig code like `ai.PAGEVIEWS_OFFSET` just looks odd!)
1051
     * @return int
1052
     *@see PageInfoApi::PAGEVIEWS_OFFSET
1053
     */
1054
    public function getPageviewsOffset(): int
1055
    {
1056
        return PageInfoApi::PAGEVIEWS_OFFSET;
1057
    }
1058
1059
    /**
1060
     * Used to avoid putting too much logic in the view.
1061
     * @param int|null $pageviews
1062
     * @return string Formatted number or "Data unavailable".
1063
     */
1064
    private function getPageviewsFormatted(?int $pageviews): string
1065
    {
1066
        return null !== $pageviews
1067
            ? $this->i18n->numberFormat($pageviews)
1068
            : $this->i18n->msg('data-unavailable');
1069
    }
1070
1071
    /**
1072
     * Another convenience method for the view. Simply checks if there's data available,
1073
     * and if not, provides an informative message to be used in the tooltip.
1074
     * @param int|null $pageviews
1075
     * @return string
1076
     */
1077
    private function getPageviewsTooltip(?int $pageviews): string
1078
    {
1079
        return $pageviews ? '' : $this->i18n->msg('api-error-wikimedia', ['Pageviews']);
1080
    }
1081
1082
    /**
1083
     * Number of revisions with deleted information that could effect accuracy of the stats.
1084
     * @return int
1085
     */
1086
    public function numDeletedRevisions(): int
1087
    {
1088
        return $this->numDeletedRevisions;
1089
    }
1090
}
1091