Passed
Push — master ( 738dcd...d9f1a8 )
by MusikAnimal
04:23
created

ArticleInfoController::gadgetAction()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 42
Code Lines 25

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 12

Importance

Changes 0
Metric Value
cc 3
eloc 25
nc 3
nop 1
dl 0
loc 42
ccs 0
cts 0
cp 0
crap 12
rs 9.52
c 0
b 0
f 0
1
<?php
2
/**
3
 * This file contains only the ArticleInfoController class.
4
 */
5
6
namespace AppBundle\Controller;
7
8
use DateTime;
9
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
10
use Symfony\Component\HttpFoundation\JsonResponse;
11
use Symfony\Component\HttpFoundation\RedirectResponse;
12
use Symfony\Component\HttpFoundation\Request;
13
use Symfony\Component\HttpFoundation\Response;
14
use Symfony\Component\Process\Process;
15
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
16
use Xtools\ArticleInfo;
17
use Xtools\ArticleInfoRepository;
18
use Xtools\Page;
19
use Xtools\Project;
20
use Xtools\ProjectRepository;
21
22
/**
23
 * This controller serves the search form and results for the ArticleInfo tool
24
 */
25
class ArticleInfoController extends XtoolsController
26
{
27
    /**
28
     * Get the name of the tool's index route.
29
     * This is also the name of the associated model.
30
     * @return string
31
     * @codeCoverageIgnore
32
     */
33
    public function getIndexRoute()
34
    {
35
        return 'ArticleInfo';
36
    }
37
38
    /**
39
     * The search form.
40
     * @Route("/articleinfo", name="ArticleInfo")
41
     * @Route("/articleinfo/", name="articleInfoSlash")
42
     * @Route("/articleinfo/index.php", name="articleInfoIndexPhp")
43
     * @Route("/articleinfo/{project}", name="ArticleInfoProject")
44
     * @param Request $request The HTTP request.
45
     * @return Response
46
     */
47 1
    public function indexAction(Request $request)
48
    {
49 1
        $params = $this->parseQueryParams($request);
50
51 1
        if (isset($params['project']) && isset($params['article'])) {
52
            return $this->redirectToRoute('ArticleInfoResult', $params);
53
        }
54
55
        // Convert the given project (or default project) into a Project instance.
56 1
        $params['project'] = $this->getProjectFromQuery($params);
57
58 1
        return $this->render('articleInfo/index.html.twig', [
59 1
            'xtPage' => 'articleinfo',
60 1
            'xtPageTitle' => 'tool-articleinfo',
61 1
            'xtSubtitle' => 'tool-articleinfo-desc',
62 1
            'project' => $params['project'],
63
        ]);
64
    }
65
66
    /**
67
     * Generate ArticleInfo gadget script for use on-wiki. This automatically points the
68
     * script to this installation's API. Pass ?uglify=1 to uglify the code.
69
     *
70
     * @Route("/articleinfo-gadget.js", name="ArticleInfoGadget")
71
     * @link https://www.mediawiki.org/wiki/XTools#ArticleInfo_gadget
72
     *
73
     * @param Request $request The HTTP request
74
     * @return Response
75
     * @codeCoverageIgnore
76
     */
77
    public function gadgetAction(Request $request)
78
    {
79
        $rendered = $this->renderView('articleInfo/articleinfo.js.twig');
80
81
        // SUPER hacky, but it works and is safe.
82
        if ($request->query->get('uglify') != '') {
83
            // $ and " need to be escaped.
84
            $rendered = str_replace('$', '\$', trim($rendered));
85
            $rendered = str_replace('"', '\"', trim($rendered));
86
87
            // Uglify temporary file.
88
            $tmpFile = sys_get_temp_dir() . '/xtools_articleinfo_gadget.js';
89
            $script = "echo \"$rendered\" | tee $tmpFile >/dev/null && ";
90
            $script .= $this->get('kernel')->getRootDir() .
91
                "/Resources/node_modules/uglify-es/bin/uglifyjs $tmpFile --mangle " .
92
                "&& rm $tmpFile >/dev/null";
93
            $process = new Process($script);
94
            $process->run();
95
96
            // Check for errors.
97
            $errorOutput = $process->getErrorOutput();
98
            if ($errorOutput != '') {
99
                $response = new \Symfony\Component\HttpFoundation\Response(
100
                    "Error generating uglified JS. The server said:\n\n$errorOutput"
101
                );
102
                return $response;
103
            }
104
105
            // Remove escaping.
106
            $rendered = str_replace('\$', '$', trim($process->getOutput()));
107
            $rendered = str_replace('\"', '"', trim($rendered));
108
109
            // Add comment after uglifying since it removes comments.
110
            $rendered = "/**\n * This code was automatically generated and should not " .
111
                "be manually edited.\n * For updates, please copy and paste from " .
112
                $this->generateUrl('ArticleInfoGadget', ['uglify' => 1], UrlGeneratorInterface::ABSOLUTE_URL) .
113
                "\n * Released under GPL v3 license.\n */\n" . $rendered;
114
        }
115
116
        $response = new \Symfony\Component\HttpFoundation\Response($rendered);
117
        $response->headers->set('Content-Type', 'text/javascript');
118
        return $response;
119
    }
120
121
    /**
122
     * Display the results in given date range.
123
     * @Route(
124
     *    "/articleinfo/{project}/{article}/{start}/{end}", name="ArticleInfoResult",
125
     *     requirements={
126
     *         "article"=".+",
127
     *         "start"="|\d{4}-\d{2}-\d{2}",
128
     *         "end"="|\d{4}-\d{2}-\d{2}",
129
     *     }
130
     * )
131
     * @param Request $request
132
     * @param $article
133
     * @param null|string $start
134
     * @param null|string $end
135
     * @return Response
136
     * @codeCoverageIgnore
137
     */
138
    public function resultAction(Request $request, $article, $start = null, $end = null)
139
    {
140
        list($article, $start, $end) = $this->extractDatesFromParams($article, $start, $end);
141
142
        // In this case only the project is validated.
143
        $ret = $this->validateProjectAndUser($request);
144
        if ($ret instanceof RedirectResponse) {
0 ignored issues
show
introduced by
$ret is always a sub-type of Symfony\Component\HttpFoundation\RedirectResponse.
Loading history...
145
            return $ret;
146
        } else {
147
            $project = $ret[0];
148
        }
149
150
        $page = $this->getAndValidatePage($project, $article);
151
        if ($page instanceof RedirectResponse) {
152
            return $page;
153
        }
154
155
        if (!$this->isDateRangeValid($page, $start, $end)) {
156
            $this->addFlash('notice', ['date-range-outside-revisions']);
157
158
            return $this->redirectToRoute('ArticleInfoResult', [
159
                'project' => $request->get('project'),
160
                'article' => $article
161
            ]);
162
        }
163
164
        $articleInfoRepo = new ArticleInfoRepository();
165
        $articleInfoRepo->setContainer($this->container);
166
        $articleInfo = new ArticleInfo($page, $this->container, $start, $end);
167
        $articleInfo->setRepository($articleInfoRepo);
168
        $articleInfo->setI18nHelper($this->container->get('app.i18n_helper'));
169
170
        $articleInfo->prepareData();
171
172
        $maxRevisions = $this->container->getParameter('app.max_page_revisions');
173
174
        // Show message if we hit the max revisions.
175
        if ($articleInfo->tooManyRevisions()) {
176
            // FIXME: i18n number_format?
177
            $this->addFlash('notice', ['too-many-revisions', number_format($maxRevisions), $maxRevisions]);
178
        }
179
180
        // For when there is very old data (2001 era) which may cause miscalculations.
181
        if ($articleInfo->getFirstEdit()->getYear() < 2003) {
182
            $this->addFlash('warning', ['old-page-notice']);
183
        }
184
185
        $ret = [
186
            'xtPage' => 'articleinfo',
187
            'xtTitle' => $page->getTitle(),
188
            'project' => $project,
189
            'editorlimit' => $request->query->get('editorlimit', 20),
190
            'botlimit' => $request->query->get('botlimit', 10),
191
            'pageviewsOffset' => 60,
192
            'ai' => $articleInfo,
193
            'page' => $page,
194
        ];
195
196
        // Output the relevant format template.
197
        $format = $request->query->get('format', 'html');
198
        if ($format == '') {
199
            // The default above doesn't work when the 'format' parameter is blank.
200
            $format = 'html';
201
        }
202
        $response = $this->render("articleInfo/result.$format.twig", $ret);
203
        if ($format == 'wikitext') {
204
            $response->headers->set('Content-Type', 'text/plain');
205
        }
206
207
        return $response;
208
    }
209
210
    /**
211
     * Check if there were any revisions of given page in given date range.
212
     * @param Page $page
213
     * @param false|int $start
214
     * @param false|int $end
215
     * @return bool
216
     */
217
    private function isDateRangeValid(Page $page, $start, $end)
218
    {
219
        return $page->getNumRevisions(null, $start, $end) > 0;
220
    }
221
222
    /**
223
     * Get textshares information about the article.
224
     * @Route(
225
     *     "/articleinfo-authorship/{project}/{article}",
226
     *     name="ArticleInfoAuthorshipResult",
227
     *     requirements={"article"=".+"}
228
     * )
229
     * @param Request $request The HTTP request.
230
     * @param string $article
231
     * @return Response
232
     * @codeCoverageIgnore
233
     */
234
    public function textsharesResultAction(Request $request, $article)
235
    {
236
        // In this case only the project is validated.
237
        $ret = $this->validateProjectAndUser($request);
238
        if ($ret instanceof RedirectResponse) {
0 ignored issues
show
introduced by
$ret is always a sub-type of Symfony\Component\HttpFoundation\RedirectResponse.
Loading history...
239
            return $ret;
240
        } else {
241
            $project = $ret[0];
242
        }
243
244
        $page = $this->getAndValidatePage($project, $article);
245
        if ($page instanceof RedirectResponse) {
246
            return $page;
247
        }
248
249
        $articleInfoRepo = new ArticleInfoRepository();
250
        $articleInfoRepo->setContainer($this->container);
251
        $articleInfo = new ArticleInfo($page, $this->container);
252
        $articleInfo->setRepository($articleInfoRepo);
253
254
        $isSubRequest = $request->get('htmlonly')
255
            || $this->get('request_stack')->getParentRequest() !== null;
256
257
        $limit = $isSubRequest ? 10 : null;
258
259
        return $this->render('articleInfo/textshares.html.twig', [
260
            'xtPage' => 'articleinfo',
261
            'xtTitle' => $page->getTitle(),
262
            'project' => $project,
263
            'page' => $page,
264
            'textshares' => $articleInfo->getTextshares($limit),
265
            'is_sub_request' => $isSubRequest,
266
        ]);
267
    }
268
269
    /************************ API endpoints ************************/
270
271
    /**
272
     * Get basic info on a given article.
273
     * @Route(
274
     *     "/api/articleinfo/{project}/{article}",
275
     *     name="ArticleInfoApiAction",
276
     *     requirements={"article"=".+"}
277
     * )
278
     * @Route("/api/page/articleinfo/{project}/{article}", requirements={"article"=".+"})
279
     * @param Request $request The HTTP request.
280
     * @param string $project
281
     * @param string $article
282
     * @return Response|JsonResponse
283
     * See ArticleInfoControllerTest::testArticleInfoApi()
284
     * @codeCoverageIgnore
285
     */
286
    public function articleInfoApiAction(Request $request, $project, $article)
287
    {
288
        $projectData = ProjectRepository::getProject($project, $this->container);
289
        if (!$projectData->exists()) {
290
            return new JsonResponse(
291
                ['error' => "$project is not a valid project"],
292
                Response::HTTP_NOT_FOUND
293
            );
294
        }
295
296
        $page = $this->getAndValidatePage($projectData, $article);
297
        if ($page instanceof RedirectResponse) {
298
            return new JsonResponse(
299
                ['error' => "$article was not found"],
300
                Response::HTTP_NOT_FOUND
301
            );
302
        }
303
304
        $data = $this->getArticleInfoApiData($projectData, $page);
305
306
        if ($request->query->get('format') === 'html') {
307
            return $this->getApiHtmlResponse($projectData, $page, $data);
308
        }
309
310
        $body = array_merge([
311
            'project' => $projectData->getDomain(),
312
            'page' => $page->getTitle(),
313
        ], $data);
314
315
        return new JsonResponse(
316
            $body,
317
            Response::HTTP_OK
318
        );
319
    }
320
321
    /**
322
     * Generate the data structure that will used in the ArticleInfo API response.
323
     * @param Project $project
324
     * @param Page $page
325
     * @return array
326
     * @codeCoverageIgnore
327
     */
328
    private function getArticleInfoApiData(Project $project, Page $page)
329
    {
330
        /** @var integer Number of days to query for pageviews */
331
        $pageviewsOffset = 30;
332
333
        $data = [
334
            'project' => $project->getDomain(),
335
            'page' => $page->getTitle(),
336
            'watchers' => (int) $page->getWatchers(),
337
            'pageviews' => $page->getLastPageviews($pageviewsOffset),
338
            'pageviews_offset' => $pageviewsOffset,
339
        ];
340
341
        $info = false;
0 ignored issues
show
Unused Code introduced by
The assignment to $info is dead and can be removed.
Loading history...
342
343
        try {
344
            $info = $page->getBasicEditingInfo();
345
        } catch (\Symfony\Component\HttpKernel\Exception\ServiceUnavailableHttpException $e) {
346
            // No more open database connections.
347
            $data['error'] = 'Unable to fetch revision data. Please try again later.';
348
        } catch (\Symfony\Component\HttpKernel\Exception\HttpException $e) {
349
            /**
350
             * The query most likely exceeded the maximum query time,
351
             * so we'll abort and give only info retrieved by the API.
352
             */
353
            $data['error'] = 'Unable to fetch revision data. The query may have timed out.';
354
        }
355
356
        if ($info != false) {
357
            $creationDateTime = DateTime::createFromFormat('YmdHis', $info['created_at']);
358
            $modifiedDateTime = DateTime::createFromFormat('YmdHis', $info['modified_at']);
359
            $secsSinceLastEdit = (new DateTime)->getTimestamp() - $modifiedDateTime->getTimestamp();
360
361
            $assessment = $page->getProject()
362
                ->getPageAssessments()
363
                ->getAssessment($page);
364
365
            $data = array_merge($data, [
366
                'revisions' => (int) $info['num_edits'],
367
                'editors' => (int) $info['num_editors'],
368
                'author' => $info['author'],
369
                'author_editcount' => (int) $info['author_editcount'],
370
                'created_at' => $creationDateTime->format('Y-m-d'),
371
                'created_rev_id' => $info['created_rev_id'],
372
                'modified_at' => $modifiedDateTime->format('Y-m-d H:i'),
373
                'secs_since_last_edit' => $secsSinceLastEdit,
374
                'last_edit_id' => (int) $info['modified_rev_id'],
375
                'assessment' => $assessment,
376
            ]);
377
        }
378
379
        return $data;
380
    }
381
382
    /**
383
     * Get the Response for the HTML output of the ArticleInfo API action.
384
     * @param Project $project
385
     * @param Page $page
386
     * @param string[] $data The pre-fetched data.
387
     * @return Response
388
     * @codeCoverageIgnore
389
     */
390
    private function getApiHtmlResponse(Project $project, Page $page, $data)
391
    {
392
        $response = $this->render('articleInfo/api.html.twig', [
393
            'data' => $data,
394
            'project' => $project,
395
            'page' => $page,
396
        ]);
397
398
        // All /api routes by default respond with a JSON content type.
399
        $response->headers->set('Content-Type', 'text/html');
400
401
        // This endpoint is hit constantly and user could be browsing the same page over
402
        // and over (popular noticeboard, for instance), so offload brief caching to browser.
403
        $response->setClientTtl(350);
404
405
        return $response;
406
    }
407
408
    /**
409
     * Get prose statistics for the given article.
410
     * @Route(
411
     *     "/api/page/prose/{project}/{article}",
412
     *     name="PageApiProse",
413
     *     requirements={"article"=".+"}
414
     * )
415
     * @param Request $request The HTTP request.
416
     * @param string $article
417
     * @return JsonResponse|RedirectResponse
418
     * @codeCoverageIgnore
419
     */
420
    public function proseStatsApiAction(Request $request, $article)
421
    {
422
        $this->recordApiUsage('page/prose');
423
424
        // In this case only the project is validated.
425
        $ret = $this->validateProjectAndUser($request);
426
        if ($ret instanceof RedirectResponse) {
0 ignored issues
show
introduced by
$ret is always a sub-type of Symfony\Component\HttpFoundation\RedirectResponse.
Loading history...
427
            return $ret;
428
        } else {
429
            /** @var Project $project */
430
            $project = $ret[0];
431
        }
432
433
        $page = $this->getAndValidatePage($project, $article);
434
        if ($page instanceof RedirectResponse) {
435
            return new JsonResponse(
436
                ['error' => "$article was not found"],
437
                Response::HTTP_NOT_FOUND
438
            );
439
        }
440
441
        $articleInfoRepo = new ArticleInfoRepository();
442
        $articleInfoRepo->setContainer($this->container);
443
        $articleInfo = new ArticleInfo($page, $this->container);
444
        $articleInfo->setRepository($articleInfoRepo);
445
446
        $ret = array_merge(
447
            [
448
                'project' => $project->getDomain(),
449
                'page' => $page->getTitle(),
450
            ],
451
            $articleInfo->getProseStats()
452
        );
453
454
        return new JsonResponse(
455
            $ret,
456
            Response::HTTP_OK
457
        );
458
    }
459
460
    /**
461
     * Get the page assessments of a page, along with various related metadata.
462
     * @Route(
463
     *     "/api/page/assessments/{project}/{articles}",
464
     *     name="PageApiAssessments",
465
     *     requirements={"article"=".+"}
466
     * )
467
     * @param Request $request
468
     * @param string $articles May be multiple pages separated by pipes, e.g. Foo|Bar|Baz
469
     * @return JsonResponse
470
     * @codeCoverageIgnore
471
     */
472
    public function assessmentsApiAction(Request $request, $articles)
473
    {
474
        $this->recordApiUsage('page/assessments');
475
476
        // First validate project.
477
        $ret = $this->validateProjectAndUser($request);
478
        if ($ret instanceof RedirectResponse) {
0 ignored issues
show
introduced by
$ret is always a sub-type of Symfony\Component\HttpFoundation\RedirectResponse.
Loading history...
479
            return new JsonResponse(
480
                ['error' => 'Invalid project'],
481
                Response::HTTP_NOT_FOUND
482
            );
483
        } else {
484
            $project = $ret[0];
485
        }
486
487
        $pages = explode('|', $articles);
488
        $out = [];
489
490
        foreach ($pages as $page) {
491
            $page = $this->getAndValidatePage($project, $page);
492
            if ($page instanceof RedirectResponse) {
493
                $out[$page->getTitle()] = false;
494
            } else {
495
                $assessments = $page->getProject()
496
                    ->getPageAssessments()
497
                    ->getAssessments($page);
498
499
                $out[$page->getTitle()] = $request->get('classonly')
500
                    ? $assessments['assessment']
501
                    : $assessments;
502
            }
503
        }
504
505
        return new JsonResponse(
506
            $out,
507
            Response::HTTP_OK
508
        );
509
    }
510
511
    /**
512
     * Get number of in and outgoing links and redirects to the given page.
513
     * @Route(
514
     *     "/api/page/links/{project}/{article}",
515
     *     name="PageApiLinks",
516
     *     requirements={"article"=".+"}
517
     * )
518
     * @param Request $request The HTTP request.
519
     * @param string $article
520
     * @return JsonResponse
521
     * @codeCoverageIgnore
522
     */
523
    public function linksApiAction(Request $request, $article)
524
    {
525
        $this->recordApiUsage('page/links');
526
527
        // First validate project.
528
        $ret = $this->validateProjectAndUser($request);
529
        if ($ret instanceof RedirectResponse) {
0 ignored issues
show
introduced by
$ret is always a sub-type of Symfony\Component\HttpFoundation\RedirectResponse.
Loading history...
530
            return new JsonResponse(
531
                ['error' => 'Invalid project'],
532
                Response::HTTP_NOT_FOUND
533
            );
534
        } else {
535
            $project = $ret[0];
536
        }
537
538
        $page = $this->getAndValidatePage($project, $article);
539
540
        $response = new JsonResponse(
541
            $page->countLinksAndRedirects(),
542
            Response::HTTP_OK
543
        );
544
        $response->setEncodingOptions(JSON_NUMERIC_CHECK);
545
546
        return $response;
547
    }
548
549
    /**
550
     * Get the top editors to a page.
551
     * @Route(
552
     *     "/api/page/top_editors/{project}/{article}/{start}/{end}/{limit}", name="PageApiTopEditors",
553
     *     requirements={
554
     *         "article"=".+",
555
     *         "start"="|\d{4}-\d{2}-\d{2}",
556
     *         "end"="|\d{4}-\d{2}-\d{2}",
557
     *         "limit"="|\d+"
558
     *     }
559
     * )
560
     * @param Request $request
561
     * @param $article
562
     * @param null|string $start
563
     * @param null|string $end
564
     * @param $limit
565
     * @return JsonResponse
566
     * @codeCoverageIgnore
567
     */
568
    public function topEditorsApiAction(Request $request, $article, $start = null, $end = null, $limit = 20)
569
    {
570
        $this->recordApiUsage('page/topeditors');
571
572
        list($article, $start, $end) = $this->extractDatesFromParams($article, $start, $end);
573
574
        // First validate project.
575
        $ret = $this->validateProjectAndUser($request);
576
        if ($ret instanceof RedirectResponse) {
0 ignored issues
show
introduced by
$ret is always a sub-type of Symfony\Component\HttpFoundation\RedirectResponse.
Loading history...
577
            return new JsonResponse(
578
                ['error' => 'Invalid project'],
579
                Response::HTTP_NOT_FOUND
580
            );
581
        } else {
582
            /** @var Project $project */
583
            $project = $ret[0];
584
        }
585
586
        $page = $this->getAndValidatePage($project, $article);
587
588
        $articleInfoRepo = new ArticleInfoRepository();
589
        $articleInfoRepo->setContainer($this->container);
590
        $articleInfo = new ArticleInfo($page, $this->container, $start, $end);
591
        $articleInfo->setRepository($articleInfoRepo);
592
593
        $topEditors = $articleInfo->getTopEditorsByEditCount(
594
            $limit,
595
            $request->query->get('nobots') != ''
596
        );
597
598
        $ret = [
599
            'project' => $project->getDomain(),
600
            'page' => $page->getTitle(),
601
        ];
602
603
        if ($start !== false) {
604
            $ret['start'] = date('Y-m-d', $start);
605
        }
606
        if ($end !== false) {
607
            $ret['end'] = date('Y-m-d', $end);
608
        }
609
610
        $ret['top_editors'] = $topEditors;
611
612
        $response = new JsonResponse($ret, Response::HTTP_OK);
613
        $response->setEncodingOptions(JSON_NUMERIC_CHECK);
614
615
        return $response;
616
    }
617
618
    /**
619
     * Parse the given article, start, and end parameters. This is needed because it's possible
620
     * that the article parameter could contain /YYYY-MM-DD.
621
     * @param $article
622
     * @param $start
623
     * @param $end
624
     * @return array
625
     * @codeCoverageIgnore
626
     */
627
    private function extractDatesFromParams($article, $start, $end)
628
    {
629
        // This is some complicated stuff here. We pass $start and $end to method signature
630
        // for router regex parser to parse `article` with those parameters and then
631
        // manually retrieve what we want. It's done this way because programmatic way
632
        // is much easier (or maybe even only existing) solution for that.
633
634
        // Does path have `start` and `end` parameters (even empty ones)?
635
        if (1 === preg_match('/(.+?)\/(|\d{4}-\d{2}-\d{2})(?:\/(|\d{4}-\d{2}-\d{2}))?$/', $article, $matches)) {
636
            $article = $matches[1];
637
            $start = $matches[2];
638
            $end = isset($matches[3]) ? $matches[3] : null;
639
        }
640
641
        return array_merge(
642
            [$article],
643
            $this->getUTCFromDateParams($start, $end, false)
644
        );
645
    }
646
}
647