Passed
Push — master ( 16bc58...e2c4be )
by MusikAnimal
05:53
created

ArticleInfoController::textsharesIndexAction()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 7
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 6

Importance

Changes 0
Metric Value
cc 2
eloc 3
nc 2
nop 0
dl 0
loc 7
ccs 0
cts 4
cp 0
crap 6
rs 10
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\Authorship;
14
use AppBundle\Model\Page;
15
use AppBundle\Model\Project;
16
use AppBundle\Repository\ArticleInfoRepository;
17
use DateTime;
18
use Symfony\Component\HttpFoundation\JsonResponse;
19
use Symfony\Component\HttpFoundation\Request;
20
use Symfony\Component\HttpFoundation\Response;
21
use Symfony\Component\Process\Process;
22
use Symfony\Component\Routing\Annotation\Route;
23
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
24
25
/**
26
 * This controller serves the search form and results for the ArticleInfo tool
27
 */
28
class ArticleInfoController extends XtoolsController
29
{
30
    /**
31
     * Get the name of the tool's index route. This is also the name of the associated model.
32
     * @return string
33
     * @codeCoverageIgnore
34
     */
35
    public function getIndexRoute(): string
36
    {
37
        return 'ArticleInfo';
38
    }
39
40
    /**
41
     * The search form.
42
     * @Route("/articleinfo", name="ArticleInfo")
43
     * @Route("/articleinfo/index.php", name="articleInfoIndexPhp")
44
     * @Route("/articleinfo/{project}", name="ArticleInfoProject")
45
     * @return Response
46
     */
47 1
    public function indexAction(): Response
48
    {
49 1
        if (isset($this->params['project']) && isset($this->params['page'])) {
50
            return $this->redirectToRoute('ArticleInfoResult', $this->params);
51
        }
52
53 1
        return $this->render('articleInfo/index.html.twig', array_merge([
54 1
            'xtPage' => 'ArticleInfo',
55 1
            'xtPageTitle' => 'tool-articleinfo',
56 1
            'xtSubtitle' => 'tool-articleinfo-desc',
57 1
            'project' => $this->project,
58
59
            // Defaults that will get overridden if in $params.
60 1
            'start' => '',
61 1
            'end' => '',
62 1
            'page' => '',
63 1
        ], $this->params, ['project' => $this->project]));
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): Response
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')->getProjectDir().
91
                "/node_modules/uglify-es/bin/uglifyjs $tmpFile --mangle " .
92
                "&& rm $tmpFile >/dev/null";
93
            $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

93
            $process = new Process(/** @scrutinizer ignore-type */ $script);
Loading history...
94
            $process->run();
95
96
            // Check for errors.
97
            $errorOutput = $process->getErrorOutput();
98
            if ('' != $errorOutput) {
99
                $response = new 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 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}/{page}/{start}/{end}", name="ArticleInfoResult",
125
     *     requirements={
126
     *         "page"="(.+?)(?!\/(?:|\d{4}-\d{2}-\d{2})(?:\/(|\d{4}-\d{2}-\d{2}))?)?$",
127
     *         "start"="|\d{4}-\d{2}-\d{2}",
128
     *         "end"="|\d{4}-\d{2}-\d{2}",
129
     *     },
130
     *     defaults={
131
     *         "start"=false,
132
     *         "end"=false,
133
     *     }
134
     * )
135
     * @return Response
136
     * @codeCoverageIgnore
137
     */
138
    public function resultAction(I18nHelper $i18n): Response
139
    {
140
        if (!$this->isDateRangeValid($this->page, $this->start, $this->end)) {
141
            $this->addFlashMessage('notice', 'date-range-outside-revisions');
142
143
            return $this->redirectToRoute('ArticleInfo', [
144
                'project' => $this->request->get('project'),
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
            'showAuthorship' => Authorship::isSupportedPage($this->page),
181
        ];
182
183
        // Output the relevant format template.
184
        return $this->getFormattedResponse('articleInfo/result', $ret);
185
    }
186
187
    /**
188
     * Check if there were any revisions of given page in given date range.
189
     * @param Page $page
190
     * @param false|int $start
191
     * @param false|int $end
192
     * @return bool
193
     */
194
    private function isDateRangeValid(Page $page, $start, $end): bool
195
    {
196
        return $page->getNumRevisions(null, $start, $end) > 0;
197
    }
198
199
    /************************ API endpoints ************************/
200
201
    /**
202
     * Get basic info on a given article.
203
     * @Route(
204
     *     "/api/articleinfo/{project}/{page}",
205
     *     name="ArticleInfoApiAction",
206
     *     requirements={"page"=".+"}
207
     * )
208
     * @Route("/api/page/articleinfo/{project}/{page}", requirements={"page"=".+"})
209
     * @return Response|JsonResponse
210
     * See ArticleInfoControllerTest::testArticleInfoApi()
211
     * @codeCoverageIgnore
212
     */
213
    public function articleInfoApiAction(): Response
214
    {
215
        $data = $this->getArticleInfoApiData($this->project, $this->page);
216
217
        if ('html' === $this->request->query->get('format')) {
218
            return $this->getApiHtmlResponse($this->project, $this->page, $data);
219
        }
220
221
        return $this->getFormattedApiResponse($data);
222
    }
223
224
    /**
225
     * Generate the data structure that will used in the ArticleInfo API response.
226
     * @param Project $project
227
     * @param Page $page
228
     * @return array
229
     * @codeCoverageIgnore
230
     */
231
    private function getArticleInfoApiData(Project $project, Page $page): array
232
    {
233
        /** @var int $pageviewsOffset Number of days to query for pageviews */
234
        $pageviewsOffset = 30;
235
236
        $data = [
237
            'project' => $project->getDomain(),
238
            'page' => $page->getTitle(),
239
            'watchers' => (int) $page->getWatchers(),
240
            'pageviews' => $page->getLastPageviews($pageviewsOffset),
241
            'pageviews_offset' => $pageviewsOffset,
242
        ];
243
244
        $info = false;
0 ignored issues
show
Unused Code introduced by
The assignment to $info is dead and can be removed.
Loading history...
245
246
        try {
247
            $info = $page->getBasicEditingInfo();
248
        } catch (\Symfony\Component\HttpKernel\Exception\ServiceUnavailableHttpException $e) {
249
            // No more open database connections.
250
            $data['error'] = 'Unable to fetch revision data. Please try again later.';
251
        } catch (\Symfony\Component\HttpKernel\Exception\HttpException $e) {
252
            /**
253
             * The query most likely exceeded the maximum query time,
254
             * so we'll abort and give only info retrieved by the API.
255
             */
256
            $data['error'] = 'Unable to fetch revision data. The query may have timed out.';
257
        }
258
259
        if (false !== $info) {
260
            $creationDateTime = DateTime::createFromFormat('YmdHis', $info['created_at']);
261
            $modifiedDateTime = DateTime::createFromFormat('YmdHis', $info['modified_at']);
262
            $secsSinceLastEdit = (new DateTime)->getTimestamp() - $modifiedDateTime->getTimestamp();
263
264
            // Some wikis (such foundation.wikimedia.org) may be missing the creation date.
265
            $creationDateTime = false === $creationDateTime
266
                ? null
267
                : $creationDateTime->format('Y-m-d');
268
269
            $assessment = $page->getProject()
270
                ->getPageAssessments()
271
                ->getAssessment($page);
272
273
            $data = array_merge($data, [
274
                'revisions' => (int) $info['num_edits'],
275
                'editors' => (int) $info['num_editors'],
276
                'author' => $info['author'],
277
                'author_editcount' => (int) $info['author_editcount'],
278
                'created_at' => $creationDateTime,
279
                'created_rev_id' => $info['created_rev_id'],
280
                'modified_at' => $modifiedDateTime->format('Y-m-d H:i'),
281
                'secs_since_last_edit' => $secsSinceLastEdit,
282
                'last_edit_id' => (int) $info['modified_rev_id'],
283
                'assessment' => $assessment,
284
            ]);
285
        }
286
287
        return $data;
288
    }
289
290
    /**
291
     * Get the Response for the HTML output of the ArticleInfo API action.
292
     * @param Project $project
293
     * @param Page $page
294
     * @param string[] $data The pre-fetched data.
295
     * @return Response
296
     * @codeCoverageIgnore
297
     */
298
    private function getApiHtmlResponse(Project $project, Page $page, array $data): Response
299
    {
300
        $response = $this->render('articleInfo/api.html.twig', [
301
            'project' => $project,
302
            'page' => $page,
303
            'data' => $data,
304
        ]);
305
306
        // All /api routes by default respond with a JSON content type.
307
        $response->headers->set('Content-Type', 'text/html');
308
309
        // This endpoint is hit constantly and user could be browsing the same page over
310
        // and over (popular noticeboard, for instance), so offload brief caching to browser.
311
        $response->setClientTtl(350);
312
313
        return $response;
314
    }
315
316
    /**
317
     * Get prose statistics for the given article.
318
     * @Route(
319
     *     "/api/page/prose/{project}/{page}",
320
     *     name="PageApiProse",
321
     *     requirements={"page"=".+"}
322
     * )
323
     * @return JsonResponse
324
     * @codeCoverageIgnore
325
     */
326
    public function proseStatsApiAction(): JsonResponse
327
    {
328
        $this->recordApiUsage('page/prose');
329
330
        $articleInfoRepo = new ArticleInfoRepository();
331
        $articleInfoRepo->setContainer($this->container);
332
        $articleInfo = new ArticleInfo($this->page, $this->container);
333
        $articleInfo->setRepository($articleInfoRepo);
334
335
        return $this->getFormattedApiResponse($articleInfo->getProseStats());
336
    }
337
338
    /**
339
     * Get the page assessments of one or more pages, along with various related metadata.
340
     * @Route(
341
     *     "/api/page/assessments/{project}/{pages}",
342
     *     name="PageApiAssessments",
343
     *     requirements={"pages"=".+"}
344
     * )
345
     * @param string $pages May be multiple pages separated by pipes, e.g. Foo|Bar|Baz
346
     * @return JsonResponse
347
     * @codeCoverageIgnore
348
     */
349
    public function assessmentsApiAction(string $pages): JsonResponse
350
    {
351
        $this->recordApiUsage('page/assessments');
352
353
        $pages = explode('|', $pages);
354
        $out = [];
355
356
        foreach ($pages as $pageTitle) {
357
            try {
358
                $page = $this->validatePage($pageTitle);
359
                $assessments = $page->getProject()
360
                    ->getPageAssessments()
361
                    ->getAssessments($page);
362
363
                $out[$page->getTitle()] = $this->request->get('classonly')
364
                    ? $assessments['assessment']
365
                    : $assessments;
366
            } catch (XtoolsHttpException $e) {
367
                $out[$pageTitle] = false;
368
            }
369
        }
370
371
        return $this->getFormattedApiResponse($out);
372
    }
373
374
    /**
375
     * Get number of in and outgoing links and redirects to the given page.
376
     * @Route(
377
     *     "/api/page/links/{project}/{page}",
378
     *     name="PageApiLinks",
379
     *     requirements={"page"=".+"}
380
     * )
381
     * @return JsonResponse
382
     * @codeCoverageIgnore
383
     */
384
    public function linksApiAction(): JsonResponse
385
    {
386
        $this->recordApiUsage('page/links');
387
388
        return $this->getFormattedApiResponse($this->page->countLinksAndRedirects());
389
    }
390
391
    /**
392
     * Get the top editors to a page.
393
     * @Route(
394
     *     "/api/page/top_editors/{project}/{page}/{start}/{end}/{limit}", name="PageApiTopEditors",
395
     *     requirements={
396
     *         "page"="(.+?)(?!\/(?:|\d{4}-\d{2}-\d{2})(?:\/(|\d{4}-\d{2}-\d{2}))?(?:\/(\d+))?)?$",
397
     *         "start"="|\d{4}-\d{2}-\d{2}",
398
     *         "end"="|\d{4}-\d{2}-\d{2}",
399
     *         "limit"="|\d+"
400
     *     },
401
     *     defaults={
402
     *         "start"=false,
403
     *         "end"=false,
404
     *         "limit"=20,
405
     *     }
406
     * )
407
     * @return JsonResponse
408
     * @codeCoverageIgnore
409
     */
410
    public function topEditorsApiAction(): JsonResponse
411
    {
412
        $this->recordApiUsage('page/top_editors');
413
414
        $articleInfoRepo = new ArticleInfoRepository();
415
        $articleInfoRepo->setContainer($this->container);
416
        $articleInfo = new ArticleInfo($this->page, $this->container, $this->start, $this->end);
417
        $articleInfo->setRepository($articleInfoRepo);
418
419
        $topEditors = $articleInfo->getTopEditorsByEditCount(
420
            (int)$this->limit,
421
            '' != $this->request->query->get('nobots')
422
        );
423
424
        return $this->getFormattedApiResponse([
425
            'top_editors' => $topEditors,
426
        ]);
427
    }
428
}
429