Passed
Push — master ( e6ce91...429907 )
by MusikAnimal
04:46
created

ArticleInfoController   A

Complexity

Total Complexity 31

Size/Duplication

Total Lines 434
Duplicated Lines 0 %

Test Coverage

Coverage 78.56%

Importance

Changes 0
Metric Value
eloc 162
dl 0
loc 434
ccs 11
cts 14
cp 0.7856
rs 9.92
c 0
b 0
f 0
wmc 31

13 Methods

Rating   Name   Duplication   Size   Complexity  
A assessmentsApiAction() 0 23 4
A getIndexRoute() 0 3 1
A getApiHtmlResponse() 0 16 1
A topEditorsApiAction() 0 16 1
B resultAction() 0 55 6
A gadgetAction() 0 42 3
A proseStatsApiAction() 0 10 1
A getArticleInfoApiData() 0 52 4
A linksApiAction() 0 5 1
A articleInfoApiAction() 0 9 2
A isDateRangeValid() 0 3 1
A textsharesResultAction() 0 19 3
A indexAction() 0 17 3
1
<?php
2
/**
3
 * This file contains only the ArticleInfoController class.
4
 */
5
6
namespace AppBundle\Controller;
7
8
use AppBundle\Exception\XtoolsHttpException;
9
use DateTime;
10
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
11
use Symfony\Component\HttpFoundation\JsonResponse;
12
use Symfony\Component\HttpFoundation\RedirectResponse;
13
use Symfony\Component\HttpFoundation\Request;
14
use Symfony\Component\HttpFoundation\Response;
15
use Symfony\Component\Process\Process;
16
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
17
use Xtools\ArticleInfo;
18
use Xtools\ArticleInfoRepository;
19
use Xtools\Page;
20
use Xtools\Project;
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 name of the tool's index route. This is also the name of the associated model.
29
     * @return string
30
     * @codeCoverageIgnore
31
     */
32
    public function getIndexRoute()
33
    {
34
        return 'ArticleInfo';
35
    }
36
37
    /**
38
     * The search form.
39
     * @Route("/articleinfo", name="ArticleInfo")
40
     * @Route("/articleinfo/", name="articleInfoSlash")
41
     * @Route("/articleinfo/index.php", name="articleInfoIndexPhp")
42
     * @Route("/articleinfo/{project}", name="ArticleInfoProject")
43
     * @return Response
44
     */
45 1
    public function indexAction()
46
    {
47 1
        if (isset($this->params['project']) && isset($this->params['page'])) {
48
            return $this->redirectToRoute('ArticleInfoResult', $this->params);
49
        }
50
51 1
        return $this->render('articleInfo/index.html.twig', array_merge([
52 1
            'xtPage' => 'articleinfo',
53 1
            'xtPageTitle' => 'tool-articleinfo',
54 1
            'xtSubtitle' => 'tool-articleinfo-desc',
55 1
            'project' => $this->project,
56
57
            // Defaults that will get overridden if in $params.
58 1
            'start' => '',
59 1
            'end' => '',
60 1
            'page' => '',
61 1
        ], $this->params, ['project' => $this->project]));
62
    }
63
64
    /**
65
     * Generate ArticleInfo gadget script for use on-wiki. This automatically points the
66
     * script to this installation's API. Pass ?uglify=1 to uglify the code.
67
     *
68
     * @Route("/articleinfo-gadget.js", name="ArticleInfoGadget")
69
     * @link https://www.mediawiki.org/wiki/XTools#ArticleInfo_gadget
70
     *
71
     * @param Request $request The HTTP request
72
     * @return Response
73
     * @codeCoverageIgnore
74
     */
75
    public function gadgetAction(Request $request)
76
    {
77
        $rendered = $this->renderView('articleInfo/articleinfo.js.twig');
78
79
        // SUPER hacky, but it works and is safe.
80
        if ($request->query->get('uglify') != '') {
81
            // $ and " need to be escaped.
82
            $rendered = str_replace('$', '\$', trim($rendered));
83
            $rendered = str_replace('"', '\"', trim($rendered));
84
85
            // Uglify temporary file.
86
            $tmpFile = sys_get_temp_dir() . '/xtools_articleinfo_gadget.js';
87
            $script = "echo \"$rendered\" | tee $tmpFile >/dev/null && ";
88
            $script .= $this->get('kernel')->getRootDir() .
89
                "/Resources/node_modules/uglify-es/bin/uglifyjs $tmpFile --mangle " .
90
                "&& rm $tmpFile >/dev/null";
91
            $process = new Process($script);
92
            $process->run();
93
94
            // Check for errors.
95
            $errorOutput = $process->getErrorOutput();
96
            if ($errorOutput != '') {
97
                $response = new Response(
98
                    "Error generating uglified JS. The server said:\n\n$errorOutput"
99
                );
100
                return $response;
101
            }
102
103
            // Remove escaping.
104
            $rendered = str_replace('\$', '$', trim($process->getOutput()));
105
            $rendered = str_replace('\"', '"', trim($rendered));
106
107
            // Add comment after uglifying since it removes comments.
108
            $rendered = "/**\n * This code was automatically generated and should not " .
109
                "be manually edited.\n * For updates, please copy and paste from " .
110
                $this->generateUrl('ArticleInfoGadget', ['uglify' => 1], UrlGeneratorInterface::ABSOLUTE_URL) .
111
                "\n * Released under GPL v3 license.\n */\n" . $rendered;
112
        }
113
114
        $response = new Response($rendered);
115
        $response->headers->set('Content-Type', 'text/javascript');
116
        return $response;
117
    }
118
119
    /**
120
     * Display the results in given date range.
121
     * @Route(
122
     *    "/articleinfo/{project}/{page}/{start}/{end}", name="ArticleInfoResult",
123
     *     requirements={
124
     *         "page"="(.+?)(?!\/(?:|\d{4}-\d{2}-\d{2})(?:\/(|\d{4}-\d{2}-\d{2}))?)?$",
125
     *         "start"="|\d{4}-\d{2}-\d{2}",
126
     *         "end"="|\d{4}-\d{2}-\d{2}",
127
     *     },
128
     *     defaults={
129
     *         "start"=false,
130
     *         "end"=false,
131
     *     }
132
     * )
133
     * @return Response
134
     * @codeCoverageIgnore
135
     */
136
    public function resultAction()
137
    {
138
        if (!$this->isDateRangeValid($this->page, $this->start, $this->end)) {
139
            $this->addFlash('notice', ['date-range-outside-revisions']);
0 ignored issues
show
Bug introduced by
array('date-range-outside-revisions') of type array<integer,string> is incompatible with the type string expected by parameter $message of Symfony\Bundle\Framework...\Controller::addFlash(). ( Ignorable by Annotation )

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

139
            $this->addFlash('notice', /** @scrutinizer ignore-type */ ['date-range-outside-revisions']);
Loading history...
140
141
            return $this->redirectToRoute('ArticleInfoResult', [
142
                'project' => $this->request->get('project'),
143
                'page' => $this->page->getTitle(true),
144
            ]);
145
        }
146
147
        $articleInfoRepo = new ArticleInfoRepository();
148
        $articleInfoRepo->setContainer($this->container);
149
        $articleInfo = new ArticleInfo($this->page, $this->container, $this->start, $this->end);
150
        $articleInfo->setRepository($articleInfoRepo);
151
        $articleInfo->setI18nHelper($this->container->get('app.i18n_helper'));
152
153
        $articleInfo->prepareData();
154
155
        $maxRevisions = $this->container->getParameter('app.max_page_revisions');
156
157
        // Show message if we hit the max revisions.
158
        if ($articleInfo->tooManyRevisions()) {
159
            // FIXME: i18n number_format?
160
            $this->addFlash('notice', ['too-many-revisions', number_format($maxRevisions), $maxRevisions]);
0 ignored issues
show
Bug introduced by
array('too-many-revision...isions), $maxRevisions) of type array<integer,string|mixed> is incompatible with the type string expected by parameter $message of Symfony\Bundle\Framework...\Controller::addFlash(). ( Ignorable by Annotation )

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

160
            $this->addFlash('notice', /** @scrutinizer ignore-type */ ['too-many-revisions', number_format($maxRevisions), $maxRevisions]);
Loading history...
161
        }
162
163
        // For when there is very old data (2001 era) which may cause miscalculations.
164
        if ($articleInfo->getFirstEdit()->getYear() < 2003) {
165
            $this->addFlash('warning', ['old-page-notice']);
166
        }
167
168
        $ret = [
169
            'xtPage' => 'articleinfo',
170
            'xtTitle' => $this->page->getTitle(),
171
            'project' => $this->project,
172
            'editorlimit' => $this->request->query->get('editorlimit', 20),
173
            'botlimit' => $this->request->query->get('botlimit', 10),
174
            'pageviewsOffset' => 60,
175
            'ai' => $articleInfo,
176
            'page' => $this->page,
177
        ];
178
179
        // Output the relevant format template.
180
        $format = $this->request->query->get('format', 'html');
181
        if ($format == '') {
182
            // The default above doesn't work when the 'format' parameter is blank.
183
            $format = 'html';
184
        }
185
        $response = $this->render("articleInfo/result.$format.twig", $ret);
186
        if ($format == 'wikitext') {
187
            $response->headers->set('Content-Type', 'text/plain');
188
        }
189
190
        return $response;
191
    }
192
193
    /**
194
     * Check if there were any revisions of given page in given date range.
195
     * @param Page $page
196
     * @param false|int $start
197
     * @param false|int $end
198
     * @return bool
199
     */
200
    private function isDateRangeValid(Page $page, $start, $end)
201
    {
202
        return $page->getNumRevisions(null, $start, $end) > 0;
203
    }
204
205
    /**
206
     * Get textshares information about the article.
207
     * @Route(
208
     *     "/articleinfo-authorship/{project}/{page}",
209
     *     name="ArticleInfoAuthorshipResult",
210
     *     requirements={"page"=".+"}
211
     * )
212
     * @return Response
213
     * @codeCoverageIgnore
214
     */
215
    public function textsharesResultAction()
216
    {
217
        $articleInfoRepo = new ArticleInfoRepository();
218
        $articleInfoRepo->setContainer($this->container);
219
        $articleInfo = new ArticleInfo($this->page, $this->container);
220
        $articleInfo->setRepository($articleInfoRepo);
221
222
        $isSubRequest = $this->request->get('htmlonly')
223
            || $this->get('request_stack')->getParentRequest() !== null;
224
225
        $limit = $isSubRequest ? 10 : null;
226
227
        return $this->render('articleInfo/textshares.html.twig', [
228
            'xtPage' => 'articleinfo',
229
            'xtTitle' => $this->page->getTitle(),
230
            'project' => $this->project,
231
            'page' => $this->page,
232
            'textshares' => $articleInfo->getTextshares($limit),
233
            'is_sub_request' => $isSubRequest,
234
        ]);
235
    }
236
237
    /************************ API endpoints ************************/
238
239
    /**
240
     * Get basic info on a given article.
241
     * @Route(
242
     *     "/api/articleinfo/{project}/{page}",
243
     *     name="ArticleInfoApiAction",
244
     *     requirements={"page"=".+"}
245
     * )
246
     * @Route("/api/page/articleinfo/{project}/{page}", requirements={"page"=".+"})
247
     * @return Response|JsonResponse
248
     * See ArticleInfoControllerTest::testArticleInfoApi()
249
     * @codeCoverageIgnore
250
     */
251
    public function articleInfoApiAction()
252
    {
253
        $data = $this->getArticleInfoApiData($this->project, $this->page);
254
255
        if ($this->request->query->get('format') === 'html') {
256
            return $this->getApiHtmlResponse($this->project, $this->page, $data);
257
        }
258
259
        return $this->getFormattedApiResponse($data);
260
    }
261
262
    /**
263
     * Generate the data structure that will used in the ArticleInfo API response.
264
     * @param Project $project
265
     * @param Page $page
266
     * @return array
267
     * @codeCoverageIgnore
268
     */
269
    private function getArticleInfoApiData(Project $project, Page $page)
270
    {
271
        /** @var integer Number of days to query for pageviews */
272
        $pageviewsOffset = 30;
273
274
        $data = [
275
            'project' => $project->getDomain(),
276
            'page' => $page->getTitle(),
277
            'watchers' => (int) $page->getWatchers(),
278
            'pageviews' => $page->getLastPageviews($pageviewsOffset),
279
            'pageviews_offset' => $pageviewsOffset,
280
        ];
281
282
        $info = false;
0 ignored issues
show
Unused Code introduced by
The assignment to $info is dead and can be removed.
Loading history...
283
284
        try {
285
            $info = $page->getBasicEditingInfo();
286
        } catch (\Symfony\Component\HttpKernel\Exception\ServiceUnavailableHttpException $e) {
287
            // No more open database connections.
288
            $data['error'] = 'Unable to fetch revision data. Please try again later.';
289
        } catch (\Symfony\Component\HttpKernel\Exception\HttpException $e) {
290
            /**
291
             * The query most likely exceeded the maximum query time,
292
             * so we'll abort and give only info retrieved by the API.
293
             */
294
            $data['error'] = 'Unable to fetch revision data. The query may have timed out.';
295
        }
296
297
        if ($info != false) {
298
            $creationDateTime = DateTime::createFromFormat('YmdHis', $info['created_at']);
299
            $modifiedDateTime = DateTime::createFromFormat('YmdHis', $info['modified_at']);
300
            $secsSinceLastEdit = (new DateTime)->getTimestamp() - $modifiedDateTime->getTimestamp();
301
302
            $assessment = $page->getProject()
303
                ->getPageAssessments()
304
                ->getAssessment($page);
305
306
            $data = array_merge($data, [
307
                'revisions' => (int) $info['num_edits'],
308
                'editors' => (int) $info['num_editors'],
309
                'author' => $info['author'],
310
                'author_editcount' => (int) $info['author_editcount'],
311
                'created_at' => $creationDateTime->format('Y-m-d'),
312
                'created_rev_id' => $info['created_rev_id'],
313
                'modified_at' => $modifiedDateTime->format('Y-m-d H:i'),
314
                'secs_since_last_edit' => $secsSinceLastEdit,
315
                'last_edit_id' => (int) $info['modified_rev_id'],
316
                'assessment' => $assessment,
317
            ]);
318
        }
319
320
        return $data;
321
    }
322
323
    /**
324
     * Get the Response for the HTML output of the ArticleInfo API action.
325
     * @param Project $project
326
     * @param Page $page
327
     * @param string[] $data The pre-fetched data.
328
     * @return Response
329
     * @codeCoverageIgnore
330
     */
331
    private function getApiHtmlResponse(Project $project, Page $page, $data)
332
    {
333
        $response = $this->render('articleInfo/api.html.twig', [
334
            'project' => $project,
335
            'page' => $page,
336
            'data' => $data,
337
        ]);
338
339
        // All /api routes by default respond with a JSON content type.
340
        $response->headers->set('Content-Type', 'text/html');
341
342
        // This endpoint is hit constantly and user could be browsing the same page over
343
        // and over (popular noticeboard, for instance), so offload brief caching to browser.
344
        $response->setClientTtl(350);
345
346
        return $response;
347
    }
348
349
    /**
350
     * Get prose statistics for the given article.
351
     * @Route(
352
     *     "/api/page/prose/{project}/{page}",
353
     *     name="PageApiProse",
354
     *     requirements={"page"=".+"}
355
     * )
356
     * @return JsonResponse
357
     * @codeCoverageIgnore
358
     */
359
    public function proseStatsApiAction()
360
    {
361
        $this->recordApiUsage('page/prose');
362
363
        $articleInfoRepo = new ArticleInfoRepository();
364
        $articleInfoRepo->setContainer($this->container);
365
        $articleInfo = new ArticleInfo($this->page, $this->container);
366
        $articleInfo->setRepository($articleInfoRepo);
367
368
        return $this->getFormattedApiResponse($articleInfo->getProseStats());
369
    }
370
371
    /**
372
     * Get the page assessments of one or more pages, along with various related metadata.
373
     * @Route(
374
     *     "/api/page/assessments/{project}/{pages}",
375
     *     name="PageApiAssessments",
376
     *     requirements={"pages"=".+"}
377
     * )
378
     * @param string $pages May be multiple pages separated by pipes, e.g. Foo|Bar|Baz
379
     * @return JsonResponse
380
     * @codeCoverageIgnore
381
     */
382
    public function assessmentsApiAction($pages)
383
    {
384
        $this->recordApiUsage('page/assessments');
385
386
        $pages = explode('|', $pages);
387
        $out = [];
388
389
        foreach ($pages as $pageTitle) {
390
            try {
391
                $page = $this->validatePage($pageTitle);
392
                $assessments = $page->getProject()
393
                    ->getPageAssessments()
394
                    ->getAssessments($page);
395
396
                $out[$page->getTitle()] = $this->request->get('classonly')
397
                    ? $assessments['assessment']
398
                    : $assessments;
399
            } catch (XtoolsHttpException $e) {
400
                $out[$pageTitle] = false;
401
            }
402
        }
403
404
        return $this->getFormattedApiResponse($out);
405
    }
406
407
    /**
408
     * Get number of in and outgoing links and redirects to the given page.
409
     * @Route(
410
     *     "/api/page/links/{project}/{page}",
411
     *     name="PageApiLinks",
412
     *     requirements={"page"=".+"}
413
     * )
414
     * @return JsonResponse
415
     * @codeCoverageIgnore
416
     */
417
    public function linksApiAction()
418
    {
419
        $this->recordApiUsage('page/links');
420
421
        return $this->getFormattedApiResponse($this->page->countLinksAndRedirects());
422
    }
423
424
    /**
425
     * Get the top editors to a page.
426
     * @Route(
427
     *     "/api/page/top_editors/{project}/{page}/{start}/{end}/{limit}", name="PageApiTopEditors",
428
     *     requirements={
429
     *         "page"="(.+?)(?!\/(?:|\d{4}-\d{2}-\d{2})(?:\/(|\d{4}-\d{2}-\d{2}))?(?:\/(\d+))?)?$",
430
     *         "start"="|\d{4}-\d{2}-\d{2}",
431
     *         "end"="|\d{4}-\d{2}-\d{2}",
432
     *         "limit"="|\d+"
433
     *     },
434
     *     defaults={
435
     *         "start"=false,
436
     *         "end"=false,
437
     *         "limit"=20,
438
     *     }
439
     * )
440
     * @return JsonResponse
441
     * @codeCoverageIgnore
442
     */
443
    public function topEditorsApiAction()
444
    {
445
        $this->recordApiUsage('page/top_editors');
446
447
        $articleInfoRepo = new ArticleInfoRepository();
448
        $articleInfoRepo->setContainer($this->container);
449
        $articleInfo = new ArticleInfo($this->page, $this->container, $this->start, $this->end);
450
        $articleInfo->setRepository($articleInfoRepo);
451
452
        $topEditors = $articleInfo->getTopEditorsByEditCount(
453
            $this->limit,
454
            $this->request->query->get('nobots') != ''
455
        );
456
457
        return $this->getFormattedApiResponse([
458
            'top_editors' => $topEditors,
459
        ]);
460
    }
461
}
462