Completed
Push — master ( dc7f10...440bb1 )
by Sam
16s
created

ArticleInfoController::gadgetAction()   B

Complexity

Conditions 3
Paths 3

Size

Total Lines 43
Code Lines 26

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 43
rs 8.8571
c 0
b 0
f 0
cc 3
eloc 26
nc 3
nop 1
1
<?php
2
/**
3
 * This file contains only the ArticleInfoController class.
4
 */
5
6
namespace AppBundle\Controller;
7
8
use AppBundle\Helper\AutomatedEditsHelper;
9
use Doctrine\DBAL\Connection;
10
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
11
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
12
use Symfony\Component\HttpFoundation\Request;
13
use Symfony\Component\DependencyInjection\ContainerInterface;
14
use Symfony\Component\HttpFoundation\Response;
15
use Symfony\Component\Process\Process;
16
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
17
use Xtools\ProjectRepository;
18
use Xtools\Page;
19
use Xtools\PagesRepository;
20
use Xtools\Edit;
21
use DateTime;
22
23
/**
24
 * This controller serves the search form and results for the ArticleInfo tool
25
 */
26
class ArticleInfoController extends Controller
27
{
28
    /** @var mixed[] Information about the page in question. */
29
    private $pageInfo;
30
    /** @var ProjectRepository Shared Project repository for use of getting table names, etc. */
31
    private $projectRepo;
32
    /** @var string Database name, for us of getting table names, etc. */
33
    private $dbName;
34
    /** @var Connection The projects' database connection. */
35
    protected $conn;
36
    /** @var AutomatedEditsHelper The semi-automated edits helper. */
37
    protected $aeh;
38
39
    /**
40
     * Get the tool's shortname.
41
     * @return string
42
     */
43
    public function getToolShortname()
44
    {
45
        return 'articleinfo';
46
    }
47
48
    /**
49
     * Override method to call ArticleInfoController::containerInitialized() when container set.
50
     * @param ContainerInterface|null $container A ContainerInterface instance or null
51
     */
52
    public function setContainer(ContainerInterface $container = null)
53
    {
54
        parent::setContainer($container);
55
        $this->containerInitialized();
56
    }
57
58
    /**
59
     * Perform some operations after controller initialized and container set.
60
     */
61
    private function containerInitialized()
62
    {
63
        $this->conn = $this->getDoctrine()->getManager('replicas')->getConnection();
0 ignored issues
show
Bug introduced by
It seems like you code against a concrete implementation and not the interface Doctrine\Common\Persistence\ObjectManager as the method getConnection() does only exist in the following implementations of said interface: Doctrine\ORM\Decorator\EntityManagerDecorator, Doctrine\ORM\EntityManager.

Let’s take a look at an example:

interface User
{
    /** @return string */
    public function getPassword();
}

class MyUser implements User
{
    public function getPassword()
    {
        // return something
    }

    public function getDisplayName()
    {
        // return some name.
    }
}

class AuthSystem
{
    public function authenticate(User $user)
    {
        $this->logger->info(sprintf('Authenticating %s.', $user->getDisplayName()));
        // do something.
    }
}

In the above example, the authenticate() method works fine as long as you just pass instances of MyUser. However, if you now also want to pass a different implementation of User which does not have a getDisplayName() method, the code will break.

Available Fixes

  1. Change the type-hint for the parameter:

    class AuthSystem
    {
        public function authenticate(MyUser $user) { /* ... */ }
    }
    
  2. Add an additional type-check:

    class AuthSystem
    {
        public function authenticate(User $user)
        {
            if ($user instanceof MyUser) {
                $this->logger->info(/** ... */);
            }
    
            // or alternatively
            if ( ! $user instanceof MyUser) {
                throw new \LogicException(
                    '$user must be an instance of MyUser, '
                   .'other instances are not supported.'
                );
            }
    
        }
    }
    
Note: PHP Analyzer uses reverse abstract interpretation to narrow down the types inside the if block in such a case.
  1. Add the method to the interface:

    interface User
    {
        /** @return string */
        public function getPassword();
    
        /** @return string */
        public function getDisplayName();
    }
    
Loading history...
64
        $this->aeh = $this->get('app.automated_edits_helper');
65
    }
66
67
    /**
68
     * The search form.
69
     * @Route("/articleinfo", name="articleinfo")
70
     * @Route("/articleinfo", name="articleInfo")
71
     * @Route("/articleinfo/", name="articleInfoSlash")
72
     * @Route("/articleinfo/index.php", name="articleInfoIndexPhp")
73
     * @Route("/articleinfo/{project}", name="ArticleInfoProject")
74
     * @param Request $request The HTTP request.
75
     * @return Response
76
     */
77
    public function indexAction(Request $request)
78
    {
79
        $projectQuery = $request->query->get('project');
80
        $article = $request->query->get('article');
81
82
        if ($projectQuery != '' && $article != '') {
83
            return $this->redirectToRoute('ArticleInfoResult', [ 'project'=>$projectQuery, 'article' => $article ]);
84
        } elseif ($article != '') {
85
            return $this->redirectToRoute('ArticleInfoProject', [ 'project'=>$projectQuery ]);
86
        }
87
88
        if ($projectQuery == '') {
89
            $projectQuery = $this->container->getParameter('default_project');
90
        }
91
92
        $project = ProjectRepository::getProject($projectQuery, $this->container);
93
94
        return $this->render('articleInfo/index.html.twig', [
95
            'xtPage' => 'articleinfo',
96
            'xtPageTitle' => 'tool-articleinfo',
97
            'xtSubtitle' => 'tool-articleinfo-desc',
98
            'project' => $project,
99
        ]);
100
    }
101
102
    /**
103
     * Generate ArticleInfo gadget script for use on-wiki. This automatically points the
104
     * script to this installation's API. Pass ?uglify=1 to uglify the code.
105
     *
106
     * @Route("/articleinfo-gadget.js", name="ArticleInfoGadget")
107
     * @link https://www.mediawiki.org/wiki/XTools#ArticleInfo_gadget
108
     *
109
     * @param Request $request The HTTP request
110
     * @return Response
111
     */
112
    public function gadgetAction(Request $request)
113
    {
114
        $rendered = $this->renderView('articleInfo/articleinfo.js.twig');
115
116
        // SUPER hacky, but it works and is safe.
117
        if ($request->query->get('uglify') != '') {
118
            // $ and " need to be escaped.
119
            $rendered = str_replace('$', '\$', trim($rendered));
120
            $rendered = str_replace('"', '\"', trim($rendered));
121
122
            // Uglify temporary file.
123
            $tmpFile = sys_get_temp_dir() . '/xtools_articleinfo_gadget.js';
124
            $script = "echo \"$rendered\" | tee $tmpFile >/dev/null && ";
125
            $script .= $this->get('kernel')->getRootDir() .
126
                "/Resources/node_modules/uglify-es/bin/uglifyjs $tmpFile --mangle " .
127
                "&& rm $tmpFile >/dev/null";
128
            $process = new Process($script);
129
            $process->run();
130
131
            // Check for errors.
132
            $errorOutput = $process->getErrorOutput();
133
            if ($errorOutput != '') {
134
                $response = new \Symfony\Component\HttpFoundation\Response(
135
                    "Error generating uglified JS. The server said:\n\n$errorOutput"
136
                );
137
                return $response;
138
            }
139
140
            // Remove escaping.
141
            $rendered = str_replace('\$', '$', trim($process->getOutput()));
142
            $rendered = str_replace('\"', '"', trim($rendered));
143
144
            // Add comment after uglifying since it removes comments.
145
            $rendered = "/**\n * This code was automatically generated and should not " .
146
                "be manually edited.\n * For updates, please copy and paste from " .
147
                $this->generateUrl('ArticleInfoGadget', ['uglify' => 1], UrlGeneratorInterface::ABSOLUTE_URL) .
148
                "\n * Released under GPL v3 license.\n */\n" . $rendered;
149
        }
150
151
        $response = new \Symfony\Component\HttpFoundation\Response($rendered);
152
        $response->headers->set('Content-Type', 'text/javascript');
153
        return $response;
154
    }
155
156
    /**
157
     * Display the results.
158
     * @Route("/articleinfo/{project}/{article}", name="ArticleInfoResult", requirements={"article"=".+"})
159
     * @param Request $request The HTTP request.
160
     * @return Response
161
     */
162
    public function resultAction(Request $request)
163
    {
164
        $projectQuery = $request->attributes->get('project');
165
        $project = ProjectRepository::getProject($projectQuery, $this->container);
166
        $this->projectRepo = $project->getRepository();
167 View Code Duplication
        if (!$project->exists()) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
168
            $this->addFlash('notice', ['invalid-project', $projectQuery]);
0 ignored issues
show
Documentation introduced by
array('invalid-project', $projectQuery) is of type array<integer,*,{"0":"string","1":"*"}>, but the function expects a string.

It seems like the type of the argument is not accepted by the function/method which you are calling.

In some cases, in particular if PHP’s automatic type-juggling kicks in this might be fine. In other cases, however this might be a bug.

We suggest to add an explicit type cast like in the following example:

function acceptsInteger($int) { }

$x = '123'; // string "123"

// Instead of
acceptsInteger($x);

// we recommend to use
acceptsInteger((integer) $x);
Loading history...
169
            return $this->redirectToRoute('articleInfo');
170
        }
171
        $this->dbName = $project->getDatabaseName();
172
173
        $pageQuery = $request->attributes->get('article');
174
        $page = new Page($project, $pageQuery);
175
        $pageRepo = new PagesRepository();
176
        $pageRepo->setContainer($this->container);
1 ignored issue
show
Compatibility introduced by
$this->container of type object<Symfony\Component...ion\ContainerInterface> is not a sub-type of object<Symfony\Component...ncyInjection\Container>. It seems like you assume a concrete implementation of the interface Symfony\Component\Depend...tion\ContainerInterface to be always present.

This check looks for parameters that are defined as one type in their type hint or doc comment but seem to be used as a narrower type, i.e an implementation of an interface or a subclass.

Consider changing the type of the parameter or doing an instanceof check before assuming your parameter is of the expected type.

Loading history...
177
        $page->setRepository($pageRepo);
178
179
        if (!$page->exists()) {
180
            $this->addFlash('notice', ['no-exist', str_replace('_', ' ', $pageQuery)]);
0 ignored issues
show
Documentation introduced by
array('no-exist', str_re...('_', ' ', $pageQuery)) is of type array<integer,*,{"0":"string","1":"*"}>, but the function expects a string.

It seems like the type of the argument is not accepted by the function/method which you are calling.

In some cases, in particular if PHP’s automatic type-juggling kicks in this might be fine. In other cases, however this might be a bug.

We suggest to add an explicit type cast like in the following example:

function acceptsInteger($int) { }

$x = '123'; // string "123"

// Instead of
acceptsInteger($x);

// we recommend to use
acceptsInteger((integer) $x);
Loading history...
181
            return $this->redirectToRoute('articleInfo');
182
        }
183
184
        $this->pageInfo = [
0 ignored issues
show
Documentation Bug introduced by
It seems like array('project' => $proj...=> $project->getLang()) of type array<string,object<Xtoo...age>","lang":"string"}> is incompatible with the declared type array<integer,*> of property $pageInfo.

Our type inference engine has found an assignment to a property that is incompatible with the declared type of that property.

Either this assignment is in error or the assigned type should be added to the documentation/type hint for that property..

Loading history...
185
            'project' => $project,
186
            'page' => $page,
187
            'lang' => $project->getLang(),
188
        ];
189
190
        // TODO: Adapted from legacy code; may be used to indicate how many dead ext links there are
191
        // if ( isset( $basicInfo->extlinks ) ){
1 ignored issue
show
Unused Code Comprehensibility introduced by
57% of this comment could be valid code. Did you maybe forget this after debugging?

Sometimes obsolete code just ends up commented out instead of removed. In this case it is better to remove the code once you have checked you do not need it.

The code might also have been commented out for debugging purposes. In this case it is vital that someone uncomments it again or your project may behave in very unexpected ways in production.

This check looks for comments that seem to be mostly valid code and reports them.

Loading history...
192
        //     foreach ( $basicInfo->extlinks as $i => $link ){
1 ignored issue
show
Unused Code Comprehensibility introduced by
53% of this comment could be valid code. Did you maybe forget this after debugging?

Sometimes obsolete code just ends up commented out instead of removed. In this case it is better to remove the code once you have checked you do not need it.

The code might also have been commented out for debugging purposes. In this case it is vital that someone uncomments it again or your project may behave in very unexpected ways in production.

This check looks for comments that seem to be mostly valid code and reports them.

Loading history...
193
        //         $this->extLinks[] = array("link" => $link->{'*'}, "status" => "unchecked" );
1 ignored issue
show
Unused Code Comprehensibility introduced by
64% of this comment could be valid code. Did you maybe forget this after debugging?

Sometimes obsolete code just ends up commented out instead of removed. In this case it is better to remove the code once you have checked you do not need it.

The code might also have been commented out for debugging purposes. In this case it is vital that someone uncomments it again or your project may behave in very unexpected ways in production.

This check looks for comments that seem to be mostly valid code and reports them.

Loading history...
194
        //     }
195
        // }
196
197
        $this->pageInfo = array_merge($this->pageInfo, $this->parseHistory($page));
0 ignored issues
show
Documentation Bug introduced by
It seems like array_merge($this->pageI...s->parseHistory($page)) of type array is incompatible with the declared type array<integer,*> of property $pageInfo.

Our type inference engine has found an assignment to a property that is incompatible with the declared type of that property.

Either this assignment is in error or the assigned type should be added to the documentation/type hint for that property..

Loading history...
198
        $this->pageInfo['bots'] = $this->getBotData();
199
        $this->pageInfo['general']['bot_count'] = count($this->pageInfo['bots']);
200
        $this->pageInfo['general']['top_ten_count'] = $this->getTopTenCount();
201
        $this->pageInfo['general']['top_ten_percentage'] = round(
202
            ($this->pageInfo['general']['top_ten_count'] / $page->getNumRevisions()) * 100,
203
            1
204
        );
205
        $this->pageInfo = array_merge($this->pageInfo, $page->countLinksAndRedirects());
0 ignored issues
show
Documentation Bug introduced by
It seems like array_merge($this->pageI...untLinksAndRedirects()) of type array is incompatible with the declared type array<integer,*> of property $pageInfo.

Our type inference engine has found an assignment to a property that is incompatible with the declared type of that property.

Either this assignment is in error or the assigned type should be added to the documentation/type hint for that property..

Loading history...
206
        $this->pageInfo['general']['pageviews_offset'] = 60;
207
        $this->pageInfo['general']['pageviews'] = $page->getLastPageviews(60);
208
209
        $assessments = $page->getAssessments();
210
        if ($assessments) {
211
            $this->pageInfo['assessments'] = $assessments;
212
        }
213
        $this->setLogsEvents($page);
214
215
        $bugs = $page->getErrors();
216
        if (!empty($bugs)) {
217
            $this->pageInfo['bugs'] = $bugs;
218
        }
219
220
        $this->pageInfo['xtPage'] = 'articleinfo';
221
        $this->pageInfo['xtTitle'] = $page->getTitle();
222
        $this->pageInfo['editorlimit'] = $request->query->get('editorlimit', 20);
223
224
        // Output the relevant format template.
225
        $format = $request->query->get('format', 'html');
226
        if ($format == '') {
227
            // The default above doesn't work when the 'format' parameter is blank.
228
            $format = 'html';
229
        }
230
        $response = $this->render("articleInfo/result.$format.twig", $this->pageInfo);
231
        if ($format == 'wikitext') {
232
            $response->headers->set('Content-Type', 'text/plain');
233
        }
234
        return $response;
235
    }
236
237
    /**
238
     * Get info about bots that edited the page
239
     * This also sets $this->pageInfo['bot_revision_count'] and $this->pageInfo['bot_percentage']
240
     * @return array Associative array containing the bot's username, edit count to the page
241
     *               and whether or not they are currently a bot
242
     */
243
    private function getBotData()
244
    {
245
        $userGroupsTable = $this->projectRepo->getTableName($this->dbName, 'user_groups');
246
        $userFromerGroupsTable = $this->projectRepo->getTableName($this->dbName, 'user_former_groups');
247
        $query = "SELECT COUNT(rev_user_text) AS count, rev_user_text AS username, ug_group AS current
248
                  FROM " . $this->projectRepo->getTableName($this->dbName, 'revision') . "
249
                  LEFT JOIN $userGroupsTable ON rev_user = ug_user
250
                  LEFT JOIN $userFromerGroupsTable ON rev_user = ufg_user
251
                  WHERE rev_page = " . $this->pageInfo['page']->getId() . " AND (ug_group = 'bot' OR ufg_group = 'bot')
252
                  GROUP BY rev_user_text";
253
        $res = $this->conn->query($query)->fetchAll();
254
255
        // Parse the botedits
256
        $bots = [];
257
        $sum = 0;
258
        foreach ($res as $bot) {
259
            $bots[$bot['username']] = [
260
                'count' => (int) $bot['count'],
261
                'current' => $bot['current'] === 'bot'
262
            ];
263
            $sum += $bot['count'];
264
        }
265
266
        uasort($bots, function ($a, $b) {
267
            return $b['count'] - $a['count'];
268
        });
269
270
        $this->pageInfo['general']['bot_revision_count'] = $sum;
271
        $this->pageInfo['general']['bot_percentage'] = round(
272
            ($sum / $this->pageInfo['page']->getNumRevisions()) * 100,
273
            1
274
        );
275
276
        return $bots;
277
    }
278
279
    /**
280
     * Get the number of edits made to the page by the top 10% of editors
281
     * This is ran *after* parseHistory() since we need the grand totals first.
282
     * Various stats are also set for each editor in $this->pageInfo['editors']
283
     *   and top ten editors are stored in $this->pageInfo['general']['top_ten']
284
     *   to be used in the charts
285
     * @return integer Number of edits
286
     */
287
    private function getTopTenCount()
288
    {
289
        $topTenCount = $counter = 0;
290
        $topTenEditors = [];
291
292
        foreach ($this->pageInfo['editors'] as $editor => $info) {
293
            // Count how many users are in the top 10% by number of edits
294
            if ($counter < 10) {
295
                $topTenCount += $info['all'];
296
                $counter++;
297
298
                // To be used in the Top Ten charts
299
                $topTenEditors[] = [
300
                    'label' => $editor,
301
                    'value' => $info['all'],
302
                    'percentage' => (
303
                        100 * ($info['all'] / $this->pageInfo['page']->getNumRevisions())
304
                    )
305
                ];
306
            }
307
308
            // Compute the percentage of minor edits the user made
309
            $this->pageInfo['editors'][$editor]['minor_percentage'] = $info['all']
310
                ? ($info['minor'] / $info['all']) * 100
311
                : 0;
312
313
            if ($info['all'] > 1) {
314
                // Number of seconds between first and last edit
315
                $secs = $info['last']->getTimestamp() - $info['first']->getTimestamp();
316
317
                // Average time between edits (in days)
318
                $this->pageInfo['editors'][$editor]['atbe'] = $secs / ( 60 * 60 * 24 );
319
            }
320
321
            if (count($info['sizes'])) {
322
                // Average Total KB divided by number of stored sizes (user's edit count to this page)
323
                $this->pageInfo['editors'][$editor]['size'] = array_sum($info['sizes']) / count($info['sizes']);
324
            } else {
325
                $this->pageInfo['editors'][$editor]['size'] = 0;
326
            }
327
        }
328
329
        $this->pageInfo['topTenEditors'] = $topTenEditors;
330
331
        // First sort editors array by the amount of text they added
332
        $topTenEditorsByAdded = $this->pageInfo['editors'];
333
        uasort($topTenEditorsByAdded, function ($a, $b) {
334
            if ($a['added'] === $b['added']) {
335
                return 0;
336
            }
337
            return $a['added'] > $b['added'] ? -1 : 1;
338
        });
339
340
        // Then build a new array of top 10 editors by added text,
341
        //   in the data structure needed for the chart
342
        $this->pageInfo['topTenEditorsByAdded'] = array_map(function ($editor) {
343
            $added = $this->pageInfo['editors'][$editor]['added'];
344
            return [
345
                'label' => $editor,
346
                'value' => $added,
347
                'percentage' => (
348
                    100 * ($added / $this->pageInfo['general']['added'])
349
                )
350
            ];
351
        }, array_keys(array_slice($topTenEditorsByAdded, 0, 10)));
352
353
        return $topTenCount;
354
    }
355
356
    /**
357
     * Query for log events during each year of the article's history,
358
     *   and set the results in $this->pageInfo['year_count']
359
     * @param Page $page
360
     */
361
    private function setLogsEvents($page)
362
    {
363
        $loggingTable = $this->projectRepo->getTableName($this->dbName, 'logging', 'logindex');
364
        $title = str_replace(' ', '_', $page->getTitle());
365
        $sql = "SELECT log_action, log_type, log_timestamp AS timestamp
366
                FROM $loggingTable
367
                WHERE log_namespace = '" . $page->getNamespace() . "'
368
                AND log_title = :title AND log_timestamp > 1
369
                AND log_type IN ('delete', 'move', 'protect', 'stable')";
370
        $resultQuery = $this->conn->prepare($sql);
371
        $resultQuery->bindParam(':title', $title);
372
        $resultQuery->execute();
373
        $events = $resultQuery->fetchAll();
374
375
        foreach ($events as $event) {
376
            $time = strtotime($event['timestamp']);
377
            $year = date('Y', $time);
378
            if (isset($this->pageInfo['year_count'][$year])) {
379
                $yearEvents = $this->pageInfo['year_count'][$year]['events'];
380
381
                // Convert log type value to i18n key
382
                switch ($event['log_type']) {
383
                    case 'protect':
384
                        $action = 'protections';
385
                        break;
386
                    case 'delete':
387
                        $action = 'deletions';
388
                        break;
389
                    case 'move':
390
                        $action = 'moves';
391
                        break;
392
                    // count pending-changes protections along with normal protections
393
                    case 'stable':
394
                        $action = 'protections';
395
                        break;
396
                }
397
398
                if (empty($yearEvents[$action])) {
399
                    $yearEvents[$action] = 1;
0 ignored issues
show
Bug introduced by
The variable $action does not seem to be defined for all execution paths leading up to this point.

If you define a variable conditionally, it can happen that it is not defined for all execution paths.

Let’s take a look at an example:

function myFunction($a) {
    switch ($a) {
        case 'foo':
            $x = 1;
            break;

        case 'bar':
            $x = 2;
            break;
    }

    // $x is potentially undefined here.
    echo $x;
}

In the above example, the variable $x is defined if you pass “foo” or “bar” as argument for $a. However, since the switch statement has no default case statement, if you pass any other value, the variable $x would be undefined.

Available Fixes

  1. Check for existence of the variable explicitly:

    function myFunction($a) {
        switch ($a) {
            case 'foo':
                $x = 1;
                break;
    
            case 'bar':
                $x = 2;
                break;
        }
    
        if (isset($x)) { // Make sure it's always set.
            echo $x;
        }
    }
    
  2. Define a default value for the variable:

    function myFunction($a) {
        $x = ''; // Set a default which gets overridden for certain paths.
        switch ($a) {
            case 'foo':
                $x = 1;
                break;
    
            case 'bar':
                $x = 2;
                break;
        }
    
        echo $x;
    }
    
  3. Add a value for the missing path:

    function myFunction($a) {
        switch ($a) {
            case 'foo':
                $x = 1;
                break;
    
            case 'bar':
                $x = 2;
                break;
    
            // We add support for the missing case.
            default:
                $x = '';
                break;
        }
    
        echo $x;
    }
    
Loading history...
400
                } else {
401
                    $yearEvents[$action]++;
402
                }
403
404
                $this->pageInfo['year_count'][$year]['events'] = $yearEvents;
405
            }
406
        }
407
    }
408
409
    /**
410
     * Parse the revision history. This also sets some $this->pageInfo vars
411
     *   like 'firstEdit' and 'lastEdit'
412
     * @param Page $page Page to parse
413
     * @return array Associative "master" array of metadata about the page
414
     */
415
    private function parseHistory(Page $page)
416
    {
417
        $revStmt = $page->getRevisionsStmt();
418
        $revCount = 0;
419
420
        /** @var string[] Master array containing all the data we need */
421
        $data = [
422
            'general' => [
423
                'max_add' => null, // Edit
424
                'max_del' => null, // Edit
425
                'editor_count' => 0,
426
                'anon_count' => 0,
427
                'minor_count' => 0,
428
                'count_history' => ['day' => 0, 'week' => 0, 'month' => 0, 'year' => 0],
429
                'current_size' => null,
430
                'textshares' => [],
431
                'textshare_total' => 0,
432
                'automated_count' => 0,
433
                'revert_count' => 0,
434
                'added' => 0,
435
            ],
436
            'max_edits_per_month' => 0, // for bar chart in "Month counts" section
437
            'editors' => [],
438
            'anons' => [],
439
            'year_count' => [],
440
            'tools' => [],
441
        ];
442
443
        /** @var Edit|null */
444
        $firstEdit = null;
445
446
        /** @var Edit|null The previous edit, used to discount content that was reverted */
447
        $prevEdit = null;
448
449
        /**
450
         * The edit previously deemed as having the maximum amount of content added.
451
         * This is used to discount content that was reverted.
452
         * @var Edit|null
453
        */
454
        $prevMaxAddEdit = null;
455
456
        /**
457
         * The edit previously deemed as having the maximum amount of content deleted.
458
         * This is used to discount content that was reverted
459
         * @var Edit|null
460
         */
461
        $prevMaxDelEdit = null;
462
463
        /**
464
         * The last edit made to the article.
465
         * @var Edit|null
466
         */
467
        $lastEdit = null;
468
469
        /** @var Time|null Time of first revision, used as a comparison for month counts */
470
        $firstEditMonth = null;
471
472
        while ($rev = $revStmt->fetch()) {
473
            $edit = new Edit($this->pageInfo['page'], $rev);
474
475
            // Some shorthands
476
            $editYear = $edit->getYear();
477
            $editMonth = $edit->getMonth();
478
            $editTimestamp = $edit->getTimestamp();
479
480
            // Don't return actual edit size if last revision had a length of null.
481
            // This happens when the edit follows other edits that were revision-deleted.
482
            // See T148857 for more information.
483
            // @TODO: Remove once T101631 is resolved
484
            if ($prevEdit && $prevEdit->getLength() === null) {
485
                $editSize = 0;
486
            } else {
487
                $editSize = $edit->getSize();
488
            }
489
490
            if ($revCount === 0) {
491
                $firstEdit = $edit;
492
                $firstEditMonth = mktime(0, 0, 0, (int) $firstEdit->getMonth(), 1, $firstEdit->getYear());
493
            }
494
495
            $username = $edit->getUser()->getUsername();
496
497
            // Sometimes, with old revisions (2001 era), the revisions from 2002 come before 2001
498
            if ($editTimestamp < $firstEdit->getTimestamp()) {
499
                $firstEdit = $edit;
500
            }
501
502
            // Fill in the blank arrays for the year and 12 months
503
            if (!isset($data['year_count'][$editYear])) {
504
                $data['year_count'][$editYear] = [
505
                    'all' => 0,
506
                    'minor' => 0,
507
                    'anon' => 0,
508
                    'automated' => 0,
509
                    'size' => 0, // keep track of the size by the end of the year
510
                    'events' => [],
511
                    'months' => [],
512
                ];
513
514
                for ($i = 1; $i <= 12; $i++) {
515
                    $timeObj = mktime(0, 0, 0, $i, 1, $editYear);
516
517
                    // don't show zeros for months before the first edit or after the current month
518
                    if ($timeObj < $firstEditMonth || $timeObj > strtotime('last day of this month')) {
519
                        continue;
520
                    }
521
522
                    $data['year_count'][$editYear]['months'][sprintf('%02d', $i)] = [
523
                        'all' => 0,
524
                        'minor' => 0,
525
                        'anon' => 0,
526
                        'automated' => 0,
527
                    ];
528
                }
529
            }
530
531
            // Increment year and month counts for all edits
532
            $data['year_count'][$editYear]['all']++;
533
            $data['year_count'][$editYear]['months'][$editMonth]['all']++;
534
            // This will ultimately be the size of the page by the end of the year
535
            $data['year_count'][$editYear]['size'] = $edit->getLength();
536
537
            // Keep track of which month had the most edits
538
            $editsThisMonth = $data['year_count'][$editYear]['months'][$editMonth]['all'];
539
            if ($editsThisMonth > $data['max_edits_per_month']) {
540
                $data['max_edits_per_month'] = $editsThisMonth;
541
            }
542
543
            // Initialize various user stats
544
            if (!isset($data['editors'][$username])) {
545
                $data['general']['editor_count']++;
546
                $data['editors'][$username] = [
547
                    'all' => 0,
548
                    'minor' => 0,
549
                    'minor_percentage' => 0,
550
                    'first' => $editTimestamp,
551
                    'first_id' => $edit->getId(),
552
                    'last' => null,
553
                    'atbe' => null,
554
                    'added' => 0,
555
                    'sizes' => [],
556
                ];
557
            }
558
559
            // Increment user counts
560
            $data['editors'][$username]['all']++;
561
            $data['editors'][$username]['last'] = $editTimestamp;
562
            $data['editors'][$username]['last_id'] = $edit->getId();
563
564
            // Store number of KB added with this edit
565
            $data['editors'][$username]['sizes'][] = $edit->getLength() / 1024;
566
567
            // Check if it was a revert
568
            if ($edit->isRevert($this->container)) {
1 ignored issue
show
Compatibility introduced by
$this->container of type object<Symfony\Component...ion\ContainerInterface> is not a sub-type of object<Symfony\Component...ncyInjection\Container>. It seems like you assume a concrete implementation of the interface Symfony\Component\Depend...tion\ContainerInterface to be always present.

This check looks for parameters that are defined as one type in their type hint or doc comment but seem to be used as a narrower type, i.e an implementation of an interface or a subclass.

Consider changing the type of the parameter or doing an instanceof check before assuming your parameter is of the expected type.

Loading history...
569
                $data['general']['revert_count']++;
570
571
                // Since this was a revert, we don't want to treat the previous
572
                //   edit as legit content addition or removal
573
                if ($prevEdit && $prevEdit->getSize() > 0) {
574
                    $data['general']['added'] -= $prevEdit->getSize();
575
                }
576
577
                // @TODO: Test this against an edit war (use your sandbox)
578
                // Also remove as max added or deleted, if applicable
579
                if ($data['general']['max_add'] &&
580
                    $prevEdit->getId() === $data['general']['max_add']->getId()
581
                ) {
582
                    $data['general']['max_add'] = $prevMaxAddEdit;
583
                    $prevMaxAddEdit = $prevEdit; // in the event of edit wars
584
                } elseif ($data['general']['max_del'] &&
585
                    $prevEdit->getId() === $data['general']['max_del']->getId()
586
                ) {
587
                    $data['general']['max_del'] = $prevMaxDelEdit;
588
                    $prevMaxDelEdit = $prevEdit; // in the event of edit wars
589
                }
590
            } else {
591
                // Edit was not a revert, so treat size > 0 as content added
592
                if ($editSize > 0) {
593
                    $data['general']['added'] += $editSize;
594
                    $data['editors'][$username]['added'] += $editSize;
595
596
                    // Keep track of edit with max addition
597
                    if (!$data['general']['max_add'] || $editSize > $data['general']['max_add']->getSize()) {
598
                        // Keep track of old max_add in case we find out the next $edit was reverted
599
                        //   (and was also a max edit), in which case we'll want to use this one ($edit)
600
                        $prevMaxAddEdit = $data['general']['max_add'];
601
602
                        $data['general']['max_add'] = $edit;
603
                    }
604
                } elseif ($editSize < 0 && (
605
                    !$data['general']['max_del'] || $editSize < $data['general']['max_del']->getSize()
606
                )) {
607
                    $data['general']['max_del'] = $edit;
608
                }
609
            }
610
611
            // If anonymous, increase counts
612
            if ($edit->isAnon()) {
613
                $data['general']['anon_count']++;
614
                $data['year_count'][$editYear]['anon']++;
615
                $data['year_count'][$editYear]['months'][$editMonth]['anon']++;
616
            }
617
618
            // If minor edit, increase counts
619
            if ($edit->isMinor()) {
620
                $data['general']['minor_count']++;
621
                $data['year_count'][$editYear]['minor']++;
622
                $data['year_count'][$editYear]['months'][$editMonth]['minor']++;
623
624
                // Increment minor counts for this user
625
                $data['editors'][$username]['minor']++;
626
            }
627
628
            $automatedTool = $edit->getTool($this->container);
1 ignored issue
show
Compatibility introduced by
$this->container of type object<Symfony\Component...ion\ContainerInterface> is not a sub-type of object<Symfony\Component...ncyInjection\Container>. It seems like you assume a concrete implementation of the interface Symfony\Component\Depend...tion\ContainerInterface to be always present.

This check looks for parameters that are defined as one type in their type hint or doc comment but seem to be used as a narrower type, i.e an implementation of an interface or a subclass.

Consider changing the type of the parameter or doing an instanceof check before assuming your parameter is of the expected type.

Loading history...
629
            if ($automatedTool !== false) {
630
                $data['general']['automated_count']++;
631
                $data['year_count'][$editYear]['automated']++;
632
                $data['year_count'][$editYear]['months'][$editMonth]['automated']++;
633
634
                if (!isset($data['tools'][$automatedTool['name']])) {
635
                    $data['tools'][$automatedTool['name']] = [
636
                        'count' => 1,
637
                        'link' => $automatedTool['link'],
638
                    ];
639
                } else {
640
                    $data['tools'][$automatedTool['name']]['count']++;
641
                }
642
            }
643
644
            // Increment "edits per <time>" counts
645
            if ($editTimestamp > new DateTime('-1 day')) {
646
                $data['general']['count_history']['day']++;
647
            }
648
            if ($editTimestamp > new DateTime('-1 week')) {
649
                $data['general']['count_history']['week']++;
650
            }
651
            if ($editTimestamp > new DateTime('-1 month')) {
652
                $data['general']['count_history']['month']++;
653
            }
654
            if ($editTimestamp > new DateTime('-1 year')) {
655
                $data['general']['count_history']['year']++;
656
            }
657
658
            $revCount++;
659
            $prevEdit = $edit;
660
            $lastEdit = $edit;
661
        }
662
663
        // add percentages
664
        $data['general']['minor_percentage'] = round(
665
            ($data['general']['minor_count'] / $revCount) * 100,
666
            1
667
        );
668
        $data['general']['anon_percentage'] = round(
669
            ($data['general']['anon_count'] / $revCount) * 100,
670
            1
671
        );
672
673
        // other general statistics
674
        $dateFirst = $firstEdit->getTimestamp();
675
        $dateLast = $lastEdit->getTimestamp();
676
        $data['general']['datetime_first_edit'] = $dateFirst;
677
        $data['general']['datetime_last_edit'] = $dateLast;
678
        $interval = date_diff($dateLast, $dateFirst, true);
679
680
        $data['totaldays'] = $interval->format('%a');
681
        $data['general']['average_days_per_edit'] = round($data['totaldays'] / $revCount, 1);
682
        $editsPerDay = $data['totaldays']
683
            ? $revCount / ($data['totaldays'] / (365 / 12 / 24))
684
            : 0;
685
        $data['general']['edits_per_day'] = round($editsPerDay, 1);
686
        $editsPerMonth = $data['totaldays']
687
            ? $revCount / ($data['totaldays'] / (365 / 12))
688
            : 0;
689
        $data['general']['edits_per_month'] = round($editsPerMonth, 1);
690
        $editsPerYear = $data['totaldays']
691
            ? $revCount / ($data['totaldays'] / 365)
692
            : 0;
693
        $data['general']['edits_per_year'] = round($editsPerYear, 1);
694
        $data['general']['edits_per_editor'] = round($revCount / count($data['editors']), 1);
695
696
        $data['firstEdit'] = $firstEdit;
697
        $data['lastEdit'] = $lastEdit;
698
699
        // Various sorts
700
        arsort($data['editors']);
701
        arsort($data['tools']);
702
        ksort($data['year_count']);
703
704
        return $data;
705
    }
706
}
707