Passed
Push — master ( ff8e4b...88fd0b )
by MusikAnimal
05:59
created

ArticleInfoController::textsharesResultAction()   B

Complexity

Conditions 5
Paths 6

Size

Total Lines 32
Code Lines 22

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 30

Importance

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