Passed
Push — master ( 9a481f...a0a42c )
by MusikAnimal
05:51
created

ArticleInfoController::indexAction()   A

Complexity

Conditions 3
Paths 2

Size

Total Lines 14
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 8
CRAP Score 3.0123

Importance

Changes 0
Metric Value
cc 3
eloc 8
nc 2
nop 0
dl 0
loc 14
ccs 8
cts 9
cp 0.8889
crap 3.0123
rs 9.4285
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 Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
9
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
10
use Symfony\Component\HttpFoundation\Response;
11
use Symfony\Component\HttpFoundation\JsonResponse;
12
use Symfony\Component\HttpFoundation\RedirectResponse;
13
use Symfony\Component\Process\Process;
14
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
15
use Xtools\ProjectRepository;
16
use Xtools\ArticleInfo;
17
use Xtools\Project;
18
use Xtools\Page;
19
use DateTime;
20
use Xtools\ArticleInfoRepository;
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 tool's shortname.
29
     * @return string
30
     * @codeCoverageIgnore
31
     */
32
    public function getToolShortname()
33
    {
34
        return 'articleinfo';
35
    }
36
37
    /**
38
     * The search form.
39
     * @Route("/articleinfo", name="articleinfo")
40
     * @Route("/articleinfo", name="articleInfo")
41
     * @Route("/articleinfo/", name="articleInfoSlash")
42
     * @Route("/articleinfo/index.php", name="articleInfoIndexPhp")
43
     * @Route("/articleinfo/{project}", name="ArticleInfoProject")
44
     * @return Response
45
     */
46 1
    public function indexAction()
47
    {
48 1
        if (isset($this->params['project']) && isset($this->params['article'])) {
49
            return $this->redirectToRoute('ArticleInfoResult', $this->params);
50
        }
51
52
        // Convert the given project (or default project) into a Project instance.
53 1
        $this->params['project'] = $this->getProjectFromQuery($this->params);
54
55 1
        return $this->render('articleInfo/index.html.twig', [
56 1
            'xtPage' => 'articleinfo',
57 1
            'xtPageTitle' => 'tool-articleinfo',
58 1
            'xtSubtitle' => 'tool-articleinfo-desc',
59 1
            'project' => $this->params['project'],
60
        ]);
61
    }
62
63
    /**
64
     * Generate ArticleInfo gadget script for use on-wiki. This automatically points the
65
     * script to this installation's API. Pass ?uglify=1 to uglify the code.
66
     * @Route("/articleinfo-gadget.js", name="ArticleInfoGadget")
67
     * @link https://www.mediawiki.org/wiki/XTools#ArticleInfo_gadget
68
     * @return Response
69
     * @codeCoverageIgnore
70
     */
71
    public function gadgetAction()
72
    {
73
        $rendered = $this->renderView('articleInfo/articleinfo.js.twig');
74
75
        // SUPER hacky, but it works and is safe.
76
        if ($this->request->query->get('uglify') != '') {
77
            // $ and " need to be escaped.
78
            $rendered = str_replace('$', '\$', trim($rendered));
79
            $rendered = str_replace('"', '\"', trim($rendered));
80
81
            // Uglify temporary file.
82
            $tmpFile = sys_get_temp_dir() . '/xtools_articleinfo_gadget.js';
83
            $script = "echo \"$rendered\" | tee $tmpFile >/dev/null && ";
84
            $script .= $this->get('kernel')->getRootDir() .
85
                "/Resources/node_modules/uglify-es/bin/uglifyjs $tmpFile --mangle " .
86
                "&& rm $tmpFile >/dev/null";
87
            $process = new Process($script);
88
            $process->run();
89
90
            // Check for errors.
91
            $errorOutput = $process->getErrorOutput();
92
            if ($errorOutput != '') {
93
                $response = new \Symfony\Component\HttpFoundation\Response(
94
                    "Error generating uglified JS. The server said:\n\n$errorOutput"
95
                );
96
                return $response;
97
            }
98
99
            // Remove escaping.
100
            $rendered = str_replace('\$', '$', trim($process->getOutput()));
101
            $rendered = str_replace('\"', '"', trim($rendered));
102
103
            // Add comment after uglifying since it removes comments.
104
            $rendered = "/**\n * This code was automatically generated and should not " .
105
                "be manually edited.\n * For updates, please copy and paste from " .
106
                $this->generateUrl('ArticleInfoGadget', ['uglify' => 1], UrlGeneratorInterface::ABSOLUTE_URL) .
107
                "\n * Released under GPL v3 license.\n */\n" . $rendered;
108
        }
109
110
        $response = new \Symfony\Component\HttpFoundation\Response($rendered);
111
        $response->headers->set('Content-Type', 'text/javascript');
112
        return $response;
113
    }
114
115
    /**
116
     * Display the results in given date range.
117
     * @Route(
118
     *    "/articleinfo/{project}/{article}/{start}/{end}", name="ArticleInfoResult",
119
     *     requirements={
120
     *         "article"=".+",
121
     *         "start"="|\d{4}-\d{2}-\d{2}",
122
     *         "end"="|\d{4}-\d{2}-\d{2}",
123
     *     }
124
     * )
125
     * @param $article
126
     * @param null|string $start
127
     * @param null|string $end
128
     * @return Response
129
     * @codeCoverageIgnore
130
     */
131
    public function resultAction($article, $start = null, $end = null)
132
    {
133
        // This is some complicated stuff here. We pass $start and $end to method signature
134
        // for router regex parser to parse `article` with those parameters and then
135
        // manually retrieve what we want. It's done this way because programmatical way
136
        // is much easier (or maybe even only existing) solution for that.
137
138
        // Does path have `start` and `end` parameters (even empty ones)?
139
        if (1 === preg_match('/(.+?)\/(|\d{4}-\d{2}-\d{2})(?:\/(|\d{4}-\d{2}-\d{2}))?$/', $article, $matches)) {
140
            $article = $matches[1];
141
            $start = $matches[2];
142
            $end = isset($matches[3]) ? $matches[3] : null;
143
        }
144
145
        list($start, $end) = $this->getUTCFromDateParams($start, $end, false);
146
147
        // In this case only the project is validated.
148
        $ret = $this->validateProjectAndUser();
149
        if ($ret instanceof RedirectResponse) {
0 ignored issues
show
introduced by
The condition $ret instanceof Symfony\...dation\RedirectResponse can never be false since $ret is always a sub-type of Symfony\Component\HttpFoundation\RedirectResponse.
Loading history...
150
            return $ret;
151
        } else {
152
            $project = $ret[0];
153
        }
154
155
        $page = $this->getAndValidatePage($project, $article);
156
        if ($page instanceof RedirectResponse) {
157
            return $page;
158
        }
159
160
        if (!$this->isDateRangeValid($page, $start, $end)) {
161
            $this->addFlash('notice', ['date-range-outside-revisions']);
162
163
            return $this->redirectToRoute('ArticleInfoResult', [
164
                'project' => $this->request->get('project'),
165
                'article' => $article
166
            ]);
167
        }
168
169
        $articleInfoRepo = new ArticleInfoRepository();
170
        $articleInfoRepo->setContainer($this->container);
171
        $articleInfo = new ArticleInfo($page, $this->container, $start, $end);
172
        $articleInfo->setRepository($articleInfoRepo);
173
174
        $articleInfo->prepareData();
175
176
        $maxRevisions = $this->container->getParameter('app.max_page_revisions');
177
178
        // Show message if we hit the max revisions.
179
        if ($articleInfo->tooManyRevisions()) {
180
            // FIXME: i18n number_format?
181
            $this->addFlash('notice', ['too-many-revisions', number_format($maxRevisions), $maxRevisions]);
182
        }
183
184
        // For when there is very old data (2001 era) which may cause miscalculations.
185
        if ($articleInfo->getFirstEdit()->getYear() < 2003) {
186
            $this->addFlash('warning', ['old-page-notice']);
187
        }
188
189
        $ret = [
190
            'xtPage' => 'articleinfo',
191
            'xtTitle' => $page->getTitle(),
192
            'project' => $project,
193
            'editorlimit' => $this->request->query->get('editorlimit', 20),
194
            'botlimit' => $this->request->query->get('botlimit', 10),
195
            'pageviewsOffset' => 60,
196
            'ai' => $articleInfo,
197
            'page' => $page,
198
        ];
199
200
        // Output the relevant format template.
201
        $format = $this->request->query->get('format', 'html');
202
        if ($format == '') {
203
            // The default above doesn't work when the 'format' parameter is blank.
204
            $format = 'html';
205
        }
206
        $response = $this->render("articleInfo/result.$format.twig", $ret);
207
        if ($format == 'wikitext') {
208
            $response->headers->set('Content-Type', 'text/plain');
209
        }
210
211
        return $response;
212
    }
213
214
    /**
215
     * Check if there were any revisions of given page in given date range.
216
     * @param Page $page
217
     * @param false|int $start
218
     * @param false|int $end
219
     * @return bool
220
     */
221
    private function isDateRangeValid(Page $page, $start, $end)
222
    {
223
        return $page->getNumRevisions(null, $start, $end) > 0;
224
    }
225
226
    /**
227
     * Get textshares information about the article.
228
     * @Route(
229
     *     "/articleinfo-authorship/{project}/{article}",
230
     *     name="ArticleInfoAuthorshipResult",
231
     *     requirements={"article"=".+"}
232
     * )
233
     * @param string $article
234
     * @return Response
235
     * @codeCoverageIgnore
236
     */
237
    public function textsharesResultAction($article)
238
    {
239
        // In this case only the project is validated.
240
        $ret = $this->validateProjectAndUser();
241
        if ($ret instanceof RedirectResponse) {
0 ignored issues
show
introduced by
The condition $ret instanceof Symfony\...dation\RedirectResponse can never be false since $ret is always a sub-type of Symfony\Component\HttpFoundation\RedirectResponse.
Loading history...
242
            return $ret;
243
        } else {
244
            $project = $ret[0];
245
        }
246
247
        $page = $this->getAndValidatePage($project, $article);
248
        if ($page instanceof RedirectResponse) {
249
            return $page;
250
        }
251
252
        $articleInfoRepo = new ArticleInfoRepository();
253
        $articleInfoRepo->setContainer($this->container);
254
        $articleInfo = new ArticleInfo($page, $this->container);
255
        $articleInfo->setRepository($articleInfoRepo);
256
257
        $limit = $this->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' => $this->isSubRequest,
266
        ]);
267
    }
268
269
    /************************ API endpoints ************************/
270
271
    /**
272
     * Get basic info on a given article.
273
     * @Route("/api/articleinfo/{project}/{article}", requirements={"article"=".+"})
274
     * @Route("/api/page/articleinfo/{project}/{article}", requirements={"article"=".+"})
275
     * @param string $project
276
     * @param string $article
277
     * @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...
278
     * See ArticleInfoControllerTest::testArticleInfoApi()
279
     * @codeCoverageIgnore
280
     */
281
    public function articleInfoApiAction($project, $article)
282
    {
283
        $projectData = ProjectRepository::getProject($project, $this->container);
284
        if (!$projectData->exists()) {
285
            return new JsonResponse(
286
                ['error' => "$project is not a valid project"],
287
                Response::HTTP_NOT_FOUND
288
            );
289
        }
290
291
        $page = $this->getAndValidatePage($projectData, $article);
292
        if ($page instanceof RedirectResponse) {
293
            return new JsonResponse(
294
                ['error' => "$article was not found"],
295
                Response::HTTP_NOT_FOUND
296
            );
297
        }
298
299
        $data = $this->getArticleInfoApiData($projectData, $page);
300
301
        if ($this->request->query->get('format') === 'html') {
302
            return $this->getApiHtmlResponse($projectData, $page, $data);
303
        }
304
305
        $body = array_merge([
306
            'project' => $projectData->getDomain(),
307
            'page' => $page->getTitle(),
308
        ], $data);
309
310
        return new JsonResponse(
311
            $body,
312
            Response::HTTP_OK
313
        );
314
    }
315
316
    /**
317
     * Generate the data structure that will used in the ArticleInfo API response.
318
     * @param  Project $project
319
     * @param  Page    $page
320
     * @return array
321
     * @codeCoverageIgnore
322
     */
323
    private function getArticleInfoApiData(Project $project, Page $page)
324
    {
325
        /** @var integer Number of days to query for pageviews */
326
        $pageviewsOffset = 30;
327
328
        $data = [
329
            'project' => $project->getDomain(),
330
            'page' => $page->getTitle(),
331
            'watchers' => (int) $page->getWatchers(),
332
            'pageviews' => $page->getLastPageviews($pageviewsOffset),
333
            'pageviews_offset' => $pageviewsOffset,
334
        ];
335
336
        try {
337
            $info = $page->getBasicEditingInfo();
338
        } catch (\Symfony\Component\HttpKernel\Exception\ServiceUnavailableHttpException $e) {
339
            // No more open database connections.
340
            $data['error'] = 'Unable to fetch revision data. Please try again later.';
341
        } catch (\Symfony\Component\HttpKernel\Exception\HttpException $e) {
342
            /**
343
             * The query most likely exceeded the maximum query time,
344
             * so we'll abort and give only info retrived by the API.
345
             */
346
            $data['error'] = 'Unable to fetch revision data. The query may have timed out.';
347
        }
348
349
        if ($info != false) {
350
            $creationDateTime = DateTime::createFromFormat('YmdHis', $info['created_at']);
351
            $modifiedDateTime = DateTime::createFromFormat('YmdHis', $info['modified_at']);
352
            $secsSinceLastEdit = (new DateTime)->getTimestamp() - $modifiedDateTime->getTimestamp();
353
354
            $data = array_merge($data, [
355
                'revisions' => (int) $info['num_edits'],
356
                'editors' => (int) $info['num_editors'],
357
                'author' => $info['author'],
358
                'author_editcount' => (int) $info['author_editcount'],
359
                'created_at' => $creationDateTime->format('Y-m-d'),
360
                'created_rev_id' => $info['created_rev_id'],
361
                'modified_at' => $modifiedDateTime->format('Y-m-d H:i'),
362
                'secs_since_last_edit' => $secsSinceLastEdit,
363
                'last_edit_id' => (int) $info['modified_rev_id'],
364
            ]);
365
        }
366
367
        return $data;
368
    }
369
370
    /**
371
     * Get the Response for the HTML output of the ArticleInfo API action.
372
     * @param  Project  $project
373
     * @param  Page     $page
374
     * @param  string[] $data The pre-fetched data.
375
     * @return Response
376
     * @codeCoverageIgnore
377
     */
378
    private function getApiHtmlResponse(Project $project, Page $page, $data)
379
    {
380
        $response = $this->render('articleInfo/api.html.twig', [
381
            'data' => $data,
382
            'project' => $project,
383
            'page' => $page,
384
        ]);
385
386
        // All /api routes by default respond with a JSON content type.
387
        $response->headers->set('Content-Type', 'text/html');
388
389
        // This endpoint is hit constantly and user could be browsing the same page over
390
        // and over (popular noticeboard, for instance), so offload brief caching to browser.
391
        $response->setClientTtl(350);
392
393
        return $response;
394
    }
395
396
    /**
397
     * Get prose statistics for the given article.
398
     * @Route("/api/page/prose/{project}/{article}", requirements={"article"=".+"})
399
     * @param string $article
400
     * @return JsonResponse
401
     * @codeCoverageIgnore
402
     */
403
    public function proseStatsApiAction($article)
404
    {
405
        $this->recordApiUsage('page/prose');
406
407
        // In this case only the project is validated.
408
        $ret = $this->validateProjectAndUser();
409
        if ($ret instanceof RedirectResponse) {
0 ignored issues
show
introduced by
The condition $ret instanceof Symfony\...dation\RedirectResponse can never be false since $ret is always a sub-type of Symfony\Component\HttpFoundation\RedirectResponse.
Loading history...
410
            return $ret;
411
        } else {
412
            $project = $ret[0];
413
        }
414
415
        $page = $this->getAndValidatePage($project, $article);
416
        if ($page instanceof RedirectResponse) {
417
            return new JsonResponse(
418
                ['error' => "$article was not found"],
419
                Response::HTTP_NOT_FOUND
420
            );
421
        }
422
423
        $articleInfoRepo = new ArticleInfoRepository();
424
        $articleInfoRepo->setContainer($this->container);
425
        $articleInfo = new ArticleInfo($page, $this->container);
426
        $articleInfo->setRepository($articleInfoRepo);
427
428
        $ret = array_merge(
429
            [
430
                'project' => $project->getDomain(),
431
                'page' => $page->getTitle(),
432
            ],
433
            $articleInfo->getProseStats()
434
        );
435
436
        return new JsonResponse(
437
            $ret,
438
            Response::HTTP_OK
439
        );
440
    }
441
}
442