Passed
Push — master ( 4600de...b3841b )
by MusikAnimal
11:32 queued 01:15
created

ArticleInfoController::indexAction()   A

Complexity

Conditions 3
Paths 2

Size

Total Lines 17
Code Lines 11

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 11
CRAP Score 3.0052

Importance

Changes 0
Metric Value
cc 3
eloc 11
nc 2
nop 0
dl 0
loc 17
ccs 11
cts 12
cp 0.9167
crap 3.0052
rs 9.9
c 0
b 0
f 0
1
<?php
2
/**
3
 * This file contains only the ArticleInfoController class.
4
 */
5
6
declare(strict_types=1);
7
8
namespace AppBundle\Controller;
9
10
use AppBundle\Exception\XtoolsHttpException;
11
use AppBundle\Helper\I18nHelper;
12
use AppBundle\Model\ArticleInfo;
13
use AppBundle\Model\Page;
14
use AppBundle\Model\Project;
15
use AppBundle\Repository\ArticleInfoRepository;
16
use DateTime;
17
use Symfony\Component\HttpFoundation\JsonResponse;
18
use Symfony\Component\HttpFoundation\Request;
19
use Symfony\Component\HttpFoundation\Response;
20
use Symfony\Component\Process\Process;
21
use Symfony\Component\Routing\Annotation\Route;
22
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
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. This is also the name of the associated model.
31
     * @return string
32
     * @codeCoverageIgnore
33
     */
34
    public function getIndexRoute(): string
35
    {
36
        return 'ArticleInfo';
37
    }
38
39
    /**
40
     * The search form.
41
     * @Route("/articleinfo", name="ArticleInfo")
42
     * @Route("/articleinfo/index.php", name="articleInfoIndexPhp")
43
     * @Route("/articleinfo/{project}", name="ArticleInfoProject")
44
     * @return Response
45
     */
46 1
    public function indexAction(): Response
47
    {
48 1
        if (isset($this->params['project']) && isset($this->params['page'])) {
49
            return $this->redirectToRoute('ArticleInfoResult', $this->params);
50
        }
51
52 1
        return $this->render('articleInfo/index.html.twig', array_merge([
53 1
            'xtPage' => 'ArticleInfo',
54 1
            'xtPageTitle' => 'tool-articleinfo',
55 1
            'xtSubtitle' => 'tool-articleinfo-desc',
56 1
            'project' => $this->project,
57
58
            // Defaults that will get overridden if in $params.
59 1
            'start' => '',
60 1
            'end' => '',
61 1
            'page' => '',
62 1
        ], $this->params, ['project' => $this->project]));
63
    }
64
65
    /**
66
     * Generate ArticleInfo gadget script for use on-wiki. This automatically points the
67
     * script to this installation's API. Pass ?uglify=1 to uglify the code.
68
     *
69
     * @Route("/articleinfo-gadget.js", name="ArticleInfoGadget")
70
     * @link https://www.mediawiki.org/wiki/XTools#ArticleInfo_gadget
71
     *
72
     * @param Request $request The HTTP request
73
     * @return Response
74
     * @codeCoverageIgnore
75
     */
76
    public function gadgetAction(Request $request): Response
77
    {
78
        $rendered = $this->renderView('articleInfo/articleinfo.js.twig');
79
80
        // SUPER hacky, but it works and is safe.
81
        if ('' != $request->query->get('uglify')) {
82
            // $ and " need to be escaped.
83
            $rendered = str_replace('$', '\$', trim($rendered));
84
            $rendered = str_replace('"', '\"', trim($rendered));
85
86
            // Uglify temporary file.
87
            $tmpFile = sys_get_temp_dir() . '/xtools_articleinfo_gadget.js';
88
            $script = "echo \"$rendered\" | tee $tmpFile >/dev/null && ";
89
            $script .= $this->get('kernel')->getProjectDir().
90
                "/node_modules/uglify-es/bin/uglifyjs $tmpFile --mangle " .
91
                "&& rm $tmpFile >/dev/null";
92
            $process = new Process($script);
0 ignored issues
show
Bug introduced by
$script of type string is incompatible with the type array expected by parameter $command of Symfony\Component\Process\Process::__construct(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

92
            $process = new Process(/** @scrutinizer ignore-type */ $script);
Loading history...
93
            $process->run();
94
95
            // Check for errors.
96
            $errorOutput = $process->getErrorOutput();
97
            if ('' != $errorOutput) {
98
                $response = new Response(
99
                    "Error generating uglified JS. The server said:\n\n$errorOutput"
100
                );
101
                return $response;
102
            }
103
104
            // Remove escaping.
105
            $rendered = str_replace('\$', '$', trim($process->getOutput()));
106
            $rendered = str_replace('\"', '"', trim($rendered));
107
108
            // Add comment after uglifying since it removes comments.
109
            $rendered = "/**\n * This code was automatically generated and should not " .
110
                "be manually edited.\n * For updates, please copy and paste from " .
111
                $this->generateUrl('ArticleInfoGadget', ['uglify' => 1], UrlGeneratorInterface::ABSOLUTE_URL) .
112
                "\n * Released under GPL v3 license.\n */\n" . $rendered;
113
        }
114
115
        $response = new Response($rendered);
116
        $response->headers->set('Content-Type', 'text/javascript');
117
        return $response;
118
    }
119
120
    /**
121
     * Display the results in given date range.
122
     * @Route(
123
     *    "/articleinfo/{project}/{page}/{start}/{end}", name="ArticleInfoResult",
124
     *     requirements={
125
     *         "page"="(.+?)(?!\/(?:|\d{4}-\d{2}-\d{2})(?:\/(|\d{4}-\d{2}-\d{2}))?)?$",
126
     *         "start"="|\d{4}-\d{2}-\d{2}",
127
     *         "end"="|\d{4}-\d{2}-\d{2}",
128
     *     },
129
     *     defaults={
130
     *         "start"=false,
131
     *         "end"=false,
132
     *     }
133
     * )
134
     * @return Response
135
     * @codeCoverageIgnore
136
     */
137
    public function resultAction(I18nHelper $i18n): Response
138
    {
139
        if (!$this->isDateRangeValid($this->page, $this->start, $this->end)) {
140
            $this->addFlashMessage('notice', 'date-range-outside-revisions');
141
142
            return $this->redirectToRoute('ArticleInfoResult', [
143
                'project' => $this->request->get('project'),
144
                'page' => $this->page->getTitle(true),
145
            ]);
146
        }
147
148
        $articleInfoRepo = new ArticleInfoRepository();
149
        $articleInfoRepo->setContainer($this->container);
150
        $articleInfo = new ArticleInfo($this->page, $this->container, $this->start, $this->end);
151
        $articleInfo->setRepository($articleInfoRepo);
152
        $articleInfo->setI18nHelper($this->container->get('app.i18n_helper'));
153
154
        $articleInfo->prepareData();
155
156
        $maxRevisions = $this->container->getParameter('app.max_page_revisions');
157
158
        // Show message if we hit the max revisions.
159
        if ($articleInfo->tooManyRevisions()) {
160
            // FIXME: i18n number_format?
161
            $this->addFlashMessage('notice', 'too-many-revisions', [
162
                $i18n->numberFormat($maxRevisions),
163
                $maxRevisions,
164
            ]);
165
        }
166
167
        // For when there is very old data (2001 era) which may cause miscalculations.
168
        if ($articleInfo->getFirstEdit()->getYear() < 2003) {
169
            $this->addFlashMessage('warning', 'old-page-notice');
170
        }
171
172
        $ret = [
173
            'xtPage' => 'ArticleInfo',
174
            'xtTitle' => $this->page->getTitle(),
175
            'project' => $this->project,
176
            'editorlimit' => $this->request->query->get('editorlimit', 20),
177
            'botlimit' => $this->request->query->get('botlimit', 10),
178
            'pageviewsOffset' => 60,
179
            'ai' => $articleInfo,
180
        ];
181
182
        // Output the relevant format template.
183
        return $this->getFormattedResponse('articleInfo/result', $ret);
184
    }
185
186
    /**
187
     * Check if there were any revisions of given page in given date range.
188
     * @param Page $page
189
     * @param false|int $start
190
     * @param false|int $end
191
     * @return bool
192
     */
193
    private function isDateRangeValid(Page $page, $start, $end): bool
194
    {
195
        return $page->getNumRevisions(null, $start, $end) > 0;
196
    }
197
198
    /**
199
     * Wrapper for ArticleInfoAuthorshipResult action to allow you to pass in the page via query string.
200
     * If no page title is given, it redirects to ArticleInfo (index page).
201
     * @Route(
202
     *     "/articleinfo-authorship/{project}",
203
     *     name="ArticleInfoAuthorshipIndex"
204
     * )
205
     * @return Response
206
     */
207
    public function textsharesIndexAction(): Response
208
    {
209
        if (isset($this->params['page'])) {
210
            return $this->redirectToRoute('ArticleInfoAuthorshipResult', $this->params);
211
        }
212
213
        return $this->redirectToRoute('ArticleInfo', $this->params);
214
    }
215
216
    /**
217
     * Get authorship (aka textshares) information about the article.
218
     * @Route(
219
     *     "/articleinfo-authorship/{project}/{page}",
220
     *     name="ArticleInfoAuthorshipResult",
221
     *     requirements={"page"=".+"}
222
     * )
223
     * @return Response
224
     * @codeCoverageIgnore
225
     */
226
    public function textsharesResultAction(): Response
227
    {
228
        $articleInfoRepo = new ArticleInfoRepository();
229
        $articleInfoRepo->setContainer($this->container);
230
        $articleInfo = new ArticleInfo($this->page, $this->container);
231
        $articleInfo->setRepository($articleInfoRepo);
232
233
        $isSubRequest = $this->request->get('htmlonly')
234
            || null !== $this->get('request_stack')->getParentRequest();
235
        $limit = $isSubRequest ? 10 : ($this->limit ?? 500);
236
237
        return $this->getFormattedResponse('articleInfo/textshares', [
238
            'xtPage' => 'ArticleInfo',
239
            'xtTitle' => $this->page->getTitle(),
240
            'textshares' => $articleInfo->getTextshares($limit),
241
            'limit' => $limit,
242
            'is_sub_request' => $isSubRequest,
243
        ]);
244
    }
245
246
    /************************ API endpoints ************************/
247
248
    /**
249
     * Get basic info on a given article.
250
     * @Route(
251
     *     "/api/articleinfo/{project}/{page}",
252
     *     name="ArticleInfoApiAction",
253
     *     requirements={"page"=".+"}
254
     * )
255
     * @Route("/api/page/articleinfo/{project}/{page}", requirements={"page"=".+"})
256
     * @return Response|JsonResponse
257
     * See ArticleInfoControllerTest::testArticleInfoApi()
258
     * @codeCoverageIgnore
259
     */
260
    public function articleInfoApiAction(): Response
261
    {
262
        $data = $this->getArticleInfoApiData($this->project, $this->page);
263
264
        if ('html' === $this->request->query->get('format')) {
265
            return $this->getApiHtmlResponse($this->project, $this->page, $data);
266
        }
267
268
        return $this->getFormattedApiResponse($data);
269
    }
270
271
    /**
272
     * Generate the data structure that will used in the ArticleInfo API response.
273
     * @param Project $project
274
     * @param Page $page
275
     * @return array
276
     * @codeCoverageIgnore
277
     */
278
    private function getArticleInfoApiData(Project $project, Page $page): array
279
    {
280
        /** @var int $pageviewsOffset Number of days to query for pageviews */
281
        $pageviewsOffset = 30;
282
283
        $data = [
284
            'project' => $project->getDomain(),
285
            'page' => $page->getTitle(),
286
            'watchers' => (int) $page->getWatchers(),
287
            'pageviews' => $page->getLastPageviews($pageviewsOffset),
288
            'pageviews_offset' => $pageviewsOffset,
289
        ];
290
291
        $info = false;
0 ignored issues
show
Unused Code introduced by
The assignment to $info is dead and can be removed.
Loading history...
292
293
        try {
294
            $info = $page->getBasicEditingInfo();
295
        } catch (\Symfony\Component\HttpKernel\Exception\ServiceUnavailableHttpException $e) {
296
            // No more open database connections.
297
            $data['error'] = 'Unable to fetch revision data. Please try again later.';
298
        } catch (\Symfony\Component\HttpKernel\Exception\HttpException $e) {
299
            /**
300
             * The query most likely exceeded the maximum query time,
301
             * so we'll abort and give only info retrieved by the API.
302
             */
303
            $data['error'] = 'Unable to fetch revision data. The query may have timed out.';
304
        }
305
306
        if (false !== $info) {
307
            $creationDateTime = DateTime::createFromFormat('YmdHis', $info['created_at']);
308
            $modifiedDateTime = DateTime::createFromFormat('YmdHis', $info['modified_at']);
309
            $secsSinceLastEdit = (new DateTime)->getTimestamp() - $modifiedDateTime->getTimestamp();
310
311
            $assessment = $page->getProject()
312
                ->getPageAssessments()
313
                ->getAssessment($page);
314
315
            $data = array_merge($data, [
316
                'revisions' => (int) $info['num_edits'],
317
                'editors' => (int) $info['num_editors'],
318
                'author' => $info['author'],
319
                'author_editcount' => (int) $info['author_editcount'],
320
                'created_at' => $creationDateTime->format('Y-m-d'),
321
                'created_rev_id' => $info['created_rev_id'],
322
                'modified_at' => $modifiedDateTime->format('Y-m-d H:i'),
323
                'secs_since_last_edit' => $secsSinceLastEdit,
324
                'last_edit_id' => (int) $info['modified_rev_id'],
325
                'assessment' => $assessment,
326
            ]);
327
        }
328
329
        return $data;
330
    }
331
332
    /**
333
     * Get the Response for the HTML output of the ArticleInfo API action.
334
     * @param Project $project
335
     * @param Page $page
336
     * @param string[] $data The pre-fetched data.
337
     * @return Response
338
     * @codeCoverageIgnore
339
     */
340
    private function getApiHtmlResponse(Project $project, Page $page, array $data): Response
341
    {
342
        $response = $this->render('articleInfo/api.html.twig', [
343
            'project' => $project,
344
            'page' => $page,
345
            'data' => $data,
346
        ]);
347
348
        // All /api routes by default respond with a JSON content type.
349
        $response->headers->set('Content-Type', 'text/html');
350
351
        // This endpoint is hit constantly and user could be browsing the same page over
352
        // and over (popular noticeboard, for instance), so offload brief caching to browser.
353
        $response->setClientTtl(350);
354
355
        return $response;
356
    }
357
358
    /**
359
     * Get prose statistics for the given article.
360
     * @Route(
361
     *     "/api/page/prose/{project}/{page}",
362
     *     name="PageApiProse",
363
     *     requirements={"page"=".+"}
364
     * )
365
     * @return JsonResponse
366
     * @codeCoverageIgnore
367
     */
368
    public function proseStatsApiAction(): JsonResponse
369
    {
370
        $this->recordApiUsage('page/prose');
371
372
        $articleInfoRepo = new ArticleInfoRepository();
373
        $articleInfoRepo->setContainer($this->container);
374
        $articleInfo = new ArticleInfo($this->page, $this->container);
375
        $articleInfo->setRepository($articleInfoRepo);
376
377
        return $this->getFormattedApiResponse($articleInfo->getProseStats());
378
    }
379
380
    /**
381
     * Get the page assessments of one or more pages, along with various related metadata.
382
     * @Route(
383
     *     "/api/page/assessments/{project}/{pages}",
384
     *     name="PageApiAssessments",
385
     *     requirements={"pages"=".+"}
386
     * )
387
     * @param string $pages May be multiple pages separated by pipes, e.g. Foo|Bar|Baz
388
     * @return JsonResponse
389
     * @codeCoverageIgnore
390
     */
391
    public function assessmentsApiAction(string $pages): JsonResponse
392
    {
393
        $this->recordApiUsage('page/assessments');
394
395
        $pages = explode('|', $pages);
396
        $out = [];
397
398
        foreach ($pages as $pageTitle) {
399
            try {
400
                $page = $this->validatePage($pageTitle);
401
                $assessments = $page->getProject()
402
                    ->getPageAssessments()
403
                    ->getAssessments($page);
404
405
                $out[$page->getTitle()] = $this->request->get('classonly')
406
                    ? $assessments['assessment']
407
                    : $assessments;
408
            } catch (XtoolsHttpException $e) {
409
                $out[$pageTitle] = false;
410
            }
411
        }
412
413
        return $this->getFormattedApiResponse($out);
414
    }
415
416
    /**
417
     * Get number of in and outgoing links and redirects to the given page.
418
     * @Route(
419
     *     "/api/page/links/{project}/{page}",
420
     *     name="PageApiLinks",
421
     *     requirements={"page"=".+"}
422
     * )
423
     * @return JsonResponse
424
     * @codeCoverageIgnore
425
     */
426
    public function linksApiAction(): JsonResponse
427
    {
428
        $this->recordApiUsage('page/links');
429
430
        return $this->getFormattedApiResponse($this->page->countLinksAndRedirects());
431
    }
432
433
    /**
434
     * Get the top editors to a page.
435
     * @Route(
436
     *     "/api/page/top_editors/{project}/{page}/{start}/{end}/{limit}", name="PageApiTopEditors",
437
     *     requirements={
438
     *         "page"="(.+?)(?!\/(?:|\d{4}-\d{2}-\d{2})(?:\/(|\d{4}-\d{2}-\d{2}))?(?:\/(\d+))?)?$",
439
     *         "start"="|\d{4}-\d{2}-\d{2}",
440
     *         "end"="|\d{4}-\d{2}-\d{2}",
441
     *         "limit"="|\d+"
442
     *     },
443
     *     defaults={
444
     *         "start"=false,
445
     *         "end"=false,
446
     *         "limit"=20,
447
     *     }
448
     * )
449
     * @return JsonResponse
450
     * @codeCoverageIgnore
451
     */
452
    public function topEditorsApiAction(): JsonResponse
453
    {
454
        $this->recordApiUsage('page/top_editors');
455
456
        $articleInfoRepo = new ArticleInfoRepository();
457
        $articleInfoRepo->setContainer($this->container);
458
        $articleInfo = new ArticleInfo($this->page, $this->container, $this->start, $this->end);
459
        $articleInfo->setRepository($articleInfoRepo);
460
461
        $topEditors = $articleInfo->getTopEditorsByEditCount(
462
            (int)$this->limit,
463
            '' != $this->request->query->get('nobots')
464
        );
465
466
        return $this->getFormattedApiResponse([
467
            'top_editors' => $topEditors,
468
        ]);
469
    }
470
}
471