Passed
Push — master ( 35c320...7deb12 )
by MusikAnimal
04:42
created

ArticleInfoController::getArticleInfoApiData()   B

Complexity

Conditions 4
Paths 6

Size

Total Lines 45
Code Lines 28

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 20

Importance

Changes 0
Metric Value
cc 4
eloc 28
nc 6
nop 2
dl 0
loc 45
ccs 0
cts 0
cp 0
crap 20
rs 8.5806
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 AppBundle\Helper\I18nHelper;
9
use DateTime;
10
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
11
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
12
use Symfony\Component\HttpFoundation\JsonResponse;
13
use Symfony\Component\HttpFoundation\RedirectResponse;
14
use Symfony\Component\HttpFoundation\Request;
15
use Symfony\Component\HttpFoundation\Response;
16
use Symfony\Component\Process\Process;
17
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
18
use Xtools\ArticleInfo;
19
use Xtools\ArticleInfoRepository;
20
use Xtools\Page;
21
use Xtools\Project;
22
use Xtools\ProjectRepository;
23
24
/**
25
 * This controller serves the search form and results for the ArticleInfo tool
26
 */
27
class ArticleInfoController extends XtoolsController
28
{
29
    /**
30
     * Get the name of the tool's index route.
31
     * This is also the name of the associated model.
32
     * @return string
33
     * @codeCoverageIgnore
34
     */
35
    public function getIndexRoute()
36
    {
37
        return 'ArticleInfo';
38
    }
39
40
    /**
41
     * The search form.
42
     * @Route("/articleinfo", name="ArticleInfo")
43
     * @Route("/articleinfo/", name="articleInfoSlash")
44
     * @Route("/articleinfo/index.php", name="articleInfoIndexPhp")
45
     * @Route("/articleinfo/{project}", name="ArticleInfoProject")
46
     * @param Request $request The HTTP request.
47
     * @return Response
48
     */
49 1
    public function indexAction(Request $request)
50
    {
51 1
        $params = $this->parseQueryParams($request);
52
53 1
        if (isset($params['project']) && isset($params['article'])) {
54
            return $this->redirectToRoute('ArticleInfoResult', $params);
55
        }
56
57
        // Convert the given project (or default project) into a Project instance.
58 1
        $params['project'] = $this->getProjectFromQuery($params);
59
60 1
        return $this->render('articleInfo/index.html.twig', [
61 1
            'xtPage' => 'articleinfo',
62 1
            'xtPageTitle' => 'tool-articleinfo',
63 1
            'xtSubtitle' => 'tool-articleinfo-desc',
64 1
            'project' => $params['project'],
65
        ]);
66
    }
67
68
    /**
69
     * Generate ArticleInfo gadget script for use on-wiki. This automatically points the
70
     * script to this installation's API. Pass ?uglify=1 to uglify the code.
71
     *
72
     * @Route("/articleinfo-gadget.js", name="ArticleInfoGadget")
73
     * @link https://www.mediawiki.org/wiki/XTools#ArticleInfo_gadget
74
     *
75
     * @param Request $request The HTTP request
76
     * @return Response
77
     * @codeCoverageIgnore
78
     */
79
    public function gadgetAction(Request $request)
80
    {
81
        $rendered = $this->renderView('articleInfo/articleinfo.js.twig');
82
83
        // SUPER hacky, but it works and is safe.
84
        if ($request->query->get('uglify') != '') {
85
            // $ and " need to be escaped.
86
            $rendered = str_replace('$', '\$', trim($rendered));
87
            $rendered = str_replace('"', '\"', trim($rendered));
88
89
            // Uglify temporary file.
90
            $tmpFile = sys_get_temp_dir() . '/xtools_articleinfo_gadget.js';
91
            $script = "echo \"$rendered\" | tee $tmpFile >/dev/null && ";
92
            $script .= $this->get('kernel')->getRootDir() .
93
                "/Resources/node_modules/uglify-es/bin/uglifyjs $tmpFile --mangle " .
94
                "&& rm $tmpFile >/dev/null";
95
            $process = new Process($script);
96
            $process->run();
97
98
            // Check for errors.
99
            $errorOutput = $process->getErrorOutput();
100
            if ($errorOutput != '') {
101
                $response = new \Symfony\Component\HttpFoundation\Response(
102
                    "Error generating uglified JS. The server said:\n\n$errorOutput"
103
                );
104
                return $response;
105
            }
106
107
            // Remove escaping.
108
            $rendered = str_replace('\$', '$', trim($process->getOutput()));
109
            $rendered = str_replace('\"', '"', trim($rendered));
110
111
            // Add comment after uglifying since it removes comments.
112
            $rendered = "/**\n * This code was automatically generated and should not " .
113
                "be manually edited.\n * For updates, please copy and paste from " .
114
                $this->generateUrl('ArticleInfoGadget', ['uglify' => 1], UrlGeneratorInterface::ABSOLUTE_URL) .
115
                "\n * Released under GPL v3 license.\n */\n" . $rendered;
116
        }
117
118
        $response = new \Symfony\Component\HttpFoundation\Response($rendered);
119
        $response->headers->set('Content-Type', 'text/javascript');
120
        return $response;
121
    }
122
123
    /**
124
     * Display the results in given date range.
125
     * @Route(
126
     *    "/articleinfo/{project}/{article}/{start}/{end}", name="ArticleInfoResult",
127
     *     requirements={
128
     *         "article"=".+",
129
     *         "start"="|\d{4}-\d{2}-\d{2}",
130
     *         "end"="|\d{4}-\d{2}-\d{2}",
131
     *     }
132
     * )
133
     * @param Request $request
134
     * @param $article
135
     * @param null|string $start
136
     * @param null|string $end
137
     * @return Response
138
     * @codeCoverageIgnore
139
     */
140
    public function resultAction(Request $request, $article, $start = null, $end = null)
141
    {
142
        // This is some complicated stuff here. We pass $start and $end to method signature
143
        // for router regex parser to parse `article` with those parameters and then
144
        // manually retrieve what we want. It's done this way because programmatical way
145
        // is much easier (or maybe even only existing) solution for that.
146
147
        // Does path have `start` and `end` parameters (even empty ones)?
148
        if (1 === preg_match('/(.+?)\/(|\d{4}-\d{2}-\d{2})(?:\/(|\d{4}-\d{2}-\d{2}))?$/', $article, $matches)) {
149
            $article = $matches[1];
150
            $start = $matches[2];
151
            $end = isset($matches[3]) ? $matches[3] : null;
152
        }
153
154
        list($start, $end) = $this->getUTCFromDateParams($start, $end, false);
155
156
        // In this case only the project is validated.
157
        $ret = $this->validateProjectAndUser($request);
158
        if ($ret instanceof RedirectResponse) {
0 ignored issues
show
introduced by
$ret is always a sub-type of Symfony\Component\HttpFoundation\RedirectResponse.
Loading history...
159
            return $ret;
160
        } else {
161
            $project = $ret[0];
162
        }
163
164
        $page = $this->getAndValidatePage($project, $article);
165
        if ($page instanceof RedirectResponse) {
166
            return $page;
167
        }
168
169
        if (!$this->isDateRangeValid($page, $start, $end)) {
170
            $this->addFlash('notice', ['date-range-outside-revisions']);
171
172
            return $this->redirectToRoute('ArticleInfoResult', [
173
                'project' => $request->get('project'),
174
                'article' => $article
175
            ]);
176
        }
177
178
        $articleInfoRepo = new ArticleInfoRepository();
179
        $articleInfoRepo->setContainer($this->container);
180
        $articleInfo = new ArticleInfo($page, $this->container, $start, $end);
181
        $articleInfo->setRepository($articleInfoRepo);
182
        $articleInfo->setI18nHelper($this->container->get('app.i18n_helper'));
183
184
        $articleInfo->prepareData();
185
186
        $maxRevisions = $this->container->getParameter('app.max_page_revisions');
187
188
        // Show message if we hit the max revisions.
189
        if ($articleInfo->tooManyRevisions()) {
190
            // FIXME: i18n number_format?
191
            $this->addFlash('notice', ['too-many-revisions', number_format($maxRevisions), $maxRevisions]);
192
        }
193
194
        // For when there is very old data (2001 era) which may cause miscalculations.
195
        if ($articleInfo->getFirstEdit()->getYear() < 2003) {
196
            $this->addFlash('warning', ['old-page-notice']);
197
        }
198
199
        $ret = [
200
            'xtPage' => 'articleinfo',
201
            'xtTitle' => $page->getTitle(),
202
            'project' => $project,
203
            'editorlimit' => $request->query->get('editorlimit', 20),
204
            'botlimit' => $request->query->get('botlimit', 10),
205
            'pageviewsOffset' => 60,
206
            'ai' => $articleInfo,
207
            'page' => $page,
208
        ];
209
210
        // Output the relevant format template.
211
        $format = $request->query->get('format', 'html');
212
        if ($format == '') {
213
            // The default above doesn't work when the 'format' parameter is blank.
214
            $format = 'html';
215
        }
216
        $response = $this->render("articleInfo/result.$format.twig", $ret);
217
        if ($format == 'wikitext') {
218
            $response->headers->set('Content-Type', 'text/plain');
219
        }
220
221
        return $response;
222
    }
223
224
    /**
225
     * Check if there were any revisions of given page in given date range.
226
     * @param Page $page
227
     * @param false|int $start
228
     * @param false|int $end
229
     * @return bool
230
     */
231
    private function isDateRangeValid(Page $page, $start, $end)
232
    {
233
        return $page->getNumRevisions(null, $start, $end) > 0;
234
    }
235
236
    /**
237
     * Get textshares information about the article.
238
     * @Route(
239
     *     "/articleinfo-authorship/{project}/{article}",
240
     *     name="ArticleInfoAuthorshipResult",
241
     *     requirements={"article"=".+"}
242
     * )
243
     * @param Request $request The HTTP request.
244
     * @param string $article
245
     * @return Response
246
     * @codeCoverageIgnore
247
     */
248
    public function textsharesResultAction(Request $request, $article)
249
    {
250
        // In this case only the project is validated.
251
        $ret = $this->validateProjectAndUser($request);
252
        if ($ret instanceof RedirectResponse) {
0 ignored issues
show
introduced by
$ret is always a sub-type of Symfony\Component\HttpFoundation\RedirectResponse.
Loading history...
253
            return $ret;
254
        } else {
255
            $project = $ret[0];
256
        }
257
258
        $page = $this->getAndValidatePage($project, $article);
259
        if ($page instanceof RedirectResponse) {
260
            return $page;
261
        }
262
263
        $articleInfoRepo = new ArticleInfoRepository();
264
        $articleInfoRepo->setContainer($this->container);
265
        $articleInfo = new ArticleInfo($page, $this->container);
266
        $articleInfo->setRepository($articleInfoRepo);
267
268
        $isSubRequest = $request->get('htmlonly')
269
            || $this->get('request_stack')->getParentRequest() !== null;
270
271
        $limit = $isSubRequest ? 10 : null;
272
273
        return $this->render('articleInfo/textshares.html.twig', [
274
            'xtPage' => 'articleinfo',
275
            'xtTitle' => $page->getTitle(),
276
            'project' => $project,
277
            'page' => $page,
278
            'textshares' => $articleInfo->getTextshares($limit),
279
            'is_sub_request' => $isSubRequest,
280
        ]);
281
    }
282
283
    /************************ API endpoints ************************/
284
285
    /**
286
     * Get basic info on a given article.
287
     * @Route(
288
     *     "/api/articleinfo/{project}/{article}",
289
     *     name="ArticleInfoApiAction",
290
     *     requirements={"article"=".+"}
291
     * )
292
     * @Route("/api/page/articleinfo/{project}/{article}", requirements={"article"=".+"})
293
     * @param Request $request The HTTP request.
294
     * @param string $project
295
     * @param string $article
296
     * @return View
0 ignored issues
show
Bug introduced by
The type AppBundle\Controller\View was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
297
     * See ArticleInfoControllerTest::testArticleInfoApi()
298
     * @codeCoverageIgnore
299
     */
300
    public function articleInfoApiAction(Request $request, $project, $article)
301
    {
302
        $projectData = ProjectRepository::getProject($project, $this->container);
303
        if (!$projectData->exists()) {
304
            return new JsonResponse(
305
                ['error' => "$project is not a valid project"],
306
                Response::HTTP_NOT_FOUND
307
            );
308
        }
309
310
        $page = $this->getAndValidatePage($projectData, $article);
311
        if ($page instanceof RedirectResponse) {
312
            return new JsonResponse(
313
                ['error' => "$article was not found"],
314
                Response::HTTP_NOT_FOUND
315
            );
316
        }
317
318
        $data = $this->getArticleInfoApiData($projectData, $page);
319
320
        if ($request->query->get('format') === 'html') {
321
            return $this->getApiHtmlResponse($projectData, $page, $data);
322
        }
323
324
        $body = array_merge([
325
            'project' => $projectData->getDomain(),
326
            'page' => $page->getTitle(),
327
        ], $data);
328
329
        return new JsonResponse(
330
            $body,
331
            Response::HTTP_OK
332
        );
333
    }
334
335
    /**
336
     * Generate the data structure that will used in the ArticleInfo API response.
337
     * @param  Project $project
338
     * @param  Page    $page
339
     * @return array
340
     * @codeCoverageIgnore
341
     */
342
    private function getArticleInfoApiData(Project $project, Page $page)
343
    {
344
        /** @var integer Number of days to query for pageviews */
345
        $pageviewsOffset = 30;
346
347
        $data = [
348
            'project' => $project->getDomain(),
349
            'page' => $page->getTitle(),
350
            'watchers' => (int) $page->getWatchers(),
351
            'pageviews' => $page->getLastPageviews($pageviewsOffset),
352
            'pageviews_offset' => $pageviewsOffset,
353
        ];
354
355
        try {
356
            $info = $page->getBasicEditingInfo();
357
        } catch (\Symfony\Component\HttpKernel\Exception\ServiceUnavailableHttpException $e) {
358
            // No more open database connections.
359
            $data['error'] = 'Unable to fetch revision data. Please try again later.';
360
        } catch (\Symfony\Component\HttpKernel\Exception\HttpException $e) {
361
            /**
362
             * The query most likely exceeded the maximum query time,
363
             * so we'll abort and give only info retrived by the API.
364
             */
365
            $data['error'] = 'Unable to fetch revision data. The query may have timed out.';
366
        }
367
368
        if ($info != false) {
369
            $creationDateTime = DateTime::createFromFormat('YmdHis', $info['created_at']);
370
            $modifiedDateTime = DateTime::createFromFormat('YmdHis', $info['modified_at']);
371
            $secsSinceLastEdit = (new DateTime)->getTimestamp() - $modifiedDateTime->getTimestamp();
372
373
            $data = array_merge($data, [
374
                'revisions' => (int) $info['num_edits'],
375
                'editors' => (int) $info['num_editors'],
376
                'author' => $info['author'],
377
                'author_editcount' => (int) $info['author_editcount'],
378
                'created_at' => $creationDateTime->format('Y-m-d'),
379
                'created_rev_id' => $info['created_rev_id'],
380
                'modified_at' => $modifiedDateTime->format('Y-m-d H:i'),
381
                'secs_since_last_edit' => $secsSinceLastEdit,
382
                'last_edit_id' => (int) $info['modified_rev_id'],
383
            ]);
384
        }
385
386
        return $data;
387
    }
388
389
    /**
390
     * Get the Response for the HTML output of the ArticleInfo API action.
391
     * @param  Project  $project
392
     * @param  Page     $page
393
     * @param  string[] $data The pre-fetched data.
394
     * @return Response
395
     * @codeCoverageIgnore
396
     */
397
    private function getApiHtmlResponse(Project $project, Page $page, $data)
398
    {
399
        $response = $this->render('articleInfo/api.html.twig', [
400
            'data' => $data,
401
            'project' => $project,
402
            'page' => $page,
403
        ]);
404
405
        // All /api routes by default respond with a JSON content type.
406
        $response->headers->set('Content-Type', 'text/html');
407
408
        // This endpoint is hit constantly and user could be browsing the same page over
409
        // and over (popular noticeboard, for instance), so offload brief caching to browser.
410
        $response->setClientTtl(350);
411
412
        return $response;
413
    }
414
415
    /**
416
     * Get prose statistics for the given article.
417
     * @Route(
418
     *     "/api/page/prose/{project}/{article}",
419
     *     name="PageApiProse",
420
     *     requirements={"article"=".+"}
421
     * )
422
     * @param Request $request The HTTP request.
423
     * @param string $article
424
     * @return JsonResponse
425
     * @codeCoverageIgnore
426
     */
427
    public function proseStatsApiAction(Request $request, $article)
428
    {
429
        $this->recordApiUsage('page/prose');
430
431
        // In this case only the project is validated.
432
        $ret = $this->validateProjectAndUser($request);
433
        if ($ret instanceof RedirectResponse) {
0 ignored issues
show
introduced by
$ret is always a sub-type of Symfony\Component\HttpFoundation\RedirectResponse.
Loading history...
434
            return $ret;
435
        } else {
436
            $project = $ret[0];
437
        }
438
439
        $page = $this->getAndValidatePage($project, $article);
440
        if ($page instanceof RedirectResponse) {
441
            return new JsonResponse(
442
                ['error' => "$article was not found"],
443
                Response::HTTP_NOT_FOUND
444
            );
445
        }
446
447
        $articleInfoRepo = new ArticleInfoRepository();
448
        $articleInfoRepo->setContainer($this->container);
449
        $articleInfo = new ArticleInfo($page, $this->container);
450
        $articleInfo->setRepository($articleInfoRepo);
451
452
        $ret = array_merge(
453
            [
454
                'project' => $project->getDomain(),
455
                'page' => $page->getTitle(),
456
            ],
457
            $articleInfo->getProseStats()
458
        );
459
460
        return new JsonResponse(
461
            $ret,
462
            Response::HTTP_OK
463
        );
464
    }
465
466
    /**
467
     * Get the page assessments of a page, along with various related metadata.
468
     * @Route(
469
     *     "/api/page/assessments/{project}/{articles}",
470
     *     name="PageApiAssessments",
471
     *     requirements={"article"=".+"}
472
     * )
473
     * @param  Request $request
474
     * @param  string $articles May be multiple pages separated by pipes, e.g. Foo|Bar|Baz
475
     * @return JsonResponse
476
     * @codeCoverageIgnore
477
     */
478
    public function assessmentsApiAction(Request $request, $articles)
479
    {
480
        $this->recordApiUsage('page/assessments');
481
482
        // First validate project.
483
        $ret = $this->validateProjectAndUser($request);
484
        if ($ret instanceof RedirectResponse) {
0 ignored issues
show
introduced by
$ret is always a sub-type of Symfony\Component\HttpFoundation\RedirectResponse.
Loading history...
485
            return new JsonResponse(
486
                ['error' => 'Invalid project'],
487
                Response::HTTP_NOT_FOUND
488
            );
489
        } else {
490
            $project = $ret[0];
491
        }
492
493
        $pageAssessments = [];
494
        $pages = explode('|', $articles);
495
        $out = [];
496
497
        foreach ($pages as $page) {
498
            $page = $this->getAndValidatePage($project, $page);
499
            if ($page instanceof RedirectResponse) {
500
                $out[$page->getTitle()] = false;
501
            } else {
502
                $assessments = $page->getProject()
503
                    ->getPageAssessments()
504
                    ->getAssessments($page);
505
506
                $out[$page->getTitle()] = $request->get('classonly')
507
                    ? $assessments['assessment']
508
                    : $assessments;
509
            }
510
        }
511
512
        return new JsonResponse(
513
            $out,
514
            Response::HTTP_OK
515
        );
516
    }
517
}
518