Passed
Push — master ( f43d54...b6518a )
by MusikAnimal
01:39
created

ArticleInfoController::getToolShortname()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 2

Importance

Changes 0
Metric Value
cc 1
eloc 1
nc 1
nop 0
dl 0
loc 3
ccs 0
cts 0
cp 0
crap 2
rs 10
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
        /** @var integer Number of days to query for pageviews */
292
        $pageviewsOffset = 30;
293
294
        $projectData = ProjectRepository::getProject($project, $this->container);
295
        if (!$projectData->exists()) {
296
            return new JsonResponse(
297
                ['error' => "$project is not a valid project"],
298
                Response::HTTP_NOT_FOUND
299
            );
300
        }
301
302
        $page = $this->getAndValidatePage($projectData, $article);
303
        if ($page instanceof RedirectResponse) {
304
            return new JsonResponse(
305
                ['error' => "$article was not found"],
306
                Response::HTTP_NOT_FOUND
307
            );
308
        }
309
310
        $data = [
311
            'project' => $projectData->getDomain(),
312
            'page' => $page->getTitle(),
313
            'watchers' => (int) $page->getWatchers(),
314
            'pageviews' => $page->getLastPageviews($pageviewsOffset),
315
            'pageviews_offset' => $pageviewsOffset,
316
        ];
317
318
        try {
319
            $info = $page->getBasicEditingInfo();
320
        } catch (\Doctrine\DBAL\Exception\DriverException $e) {
321
            /**
322
             * The query most likely exceeded the maximum query time,
323
             * so we'll abort and give only info retrived by the API.
324
             */
325
            $data['error'] = 'Unable to fetch revision data. The query may have timed out.';
326
        }
327
328
        if (isset($info)) {
329
            $creationDateTime = DateTime::createFromFormat('YmdHis', $info['created_at']);
330
            $modifiedDateTime = DateTime::createFromFormat('YmdHis', $info['modified_at']);
331
            $secsSinceLastEdit = (new DateTime)->getTimestamp() - $modifiedDateTime->getTimestamp();
332
333
            $data = array_merge($data, [
334
                'revisions' => (int) $info['num_edits'],
335
                'editors' => (int) $info['num_editors'],
336
                'author' => $info['author'],
337
                'author_editcount' => (int) $info['author_editcount'],
338
                'created_at' => $creationDateTime->format('Y-m-d'),
339
                'created_rev_id' => $info['created_rev_id'],
340
                'modified_at' => $modifiedDateTime->format('Y-m-d H:i'),
341
                'secs_since_last_edit' => $secsSinceLastEdit,
342
                'last_edit_id' => (int) $info['modified_rev_id'],
343
            ]);
344
        }
345
346
        if ($request->query->get('format') === 'html') {
347
            return $this->getApiHtmlResponse($projectData, $page, $data);
348
        }
349
350
        $body = array_merge([
351
            'project' => $projectData->getDomain(),
352
            'page' => $page->getTitle(),
353
        ], $data);
354
355
        return new JsonResponse(
356
            $body,
357
            Response::HTTP_OK
358
        );
359
    }
360
361
    /**
362
     * Get the Response for the HTML output of the ArticleInfo API action.
363
     * @param  Project  $project
364
     * @param  Page     $page
365
     * @param  string[] $data The pre-fetched data.
366
     * @return Response
367
     */
368
    private function getApiHtmlResponse(Project $project, Page $page, $data)
369
    {
370
        $response = $this->render('articleInfo/api.html.twig', [
371
            'data' => $data,
372
            'project' => $project,
373
            'page' => $page,
374
        ]);
375
376
        // All /api routes by default respond with a JSON content type.
377
        $response->headers->set('Content-Type', 'text/html');
378
379
        // This endpoint is hit constantly and user could be browsing the same page over
380
        // and over (popular noticeboard, for instance), so offload brief caching to browser.
381
        $response->setClientTtl(350);
382
383
        return $response;
384
    }
385
386
    /**
387
     * Get prose statistics for the given article.
388
     * @Route("/api/page/prose/{project}/{article}", requirements={"article"=".+"})
389
     * @param Request $request The HTTP request.
390
     * @param string $article
391
     * @return JsonResponse
392
     * @codeCoverageIgnore
393
     */
394
    public function proseStatsApiAction(Request $request, $article)
395
    {
396
        // In this case only the project is validated.
397
        $ret = $this->validateProjectAndUser($request);
398
        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...
399
            return $ret;
400
        } else {
401
            $project = $ret[0];
402
        }
403
404
        $page = $this->getAndValidatePage($project, $article);
405
        if ($page instanceof RedirectResponse) {
406
            return new JsonResponse(
407
                ['error' => "$article was not found"],
408
                Response::HTTP_NOT_FOUND
409
            );
410
        }
411
412
        $articleInfoRepo = new ArticleInfoRepository();
413
        $articleInfoRepo->setContainer($this->container);
414
        $articleInfo = new ArticleInfo($page, $this->container);
415
        $articleInfo->setRepository($articleInfoRepo);
416
417
        $ret = array_merge(
418
            [
419
                'project' => $project->getDomain(),
420
                'page' => $page->getTitle(),
421
            ],
422
            $articleInfo->getProseStats()
423
        );
424
425
        return new JsonResponse(
426
            $ret,
427
            Response::HTTP_OK
428
        );
429
    }
430
}
431