Passed
Push — master ( 9b6f1b...510c7f )
by MusikAnimal
04:53
created

ArticleInfoController::proseStatsApiAction()   B

Complexity

Conditions 3
Paths 3

Size

Total Lines 36
Code Lines 22

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 12

Importance

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