Passed
Pull Request — main (#463)
by MusikAnimal
06:12 queued 03:18
created

ArticleInfoController::proseStatsApiAction()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 16
Code Lines 11

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 2
eloc 11
nc 2
nop 2
dl 0
loc 16
rs 9.9
c 1
b 0
f 0
1
<?php
2
3
declare(strict_types=1);
4
5
namespace App\Controller;
6
7
use App\Exception\XtoolsHttpException;
8
use App\Helper\AutomatedEditsHelper;
9
use App\Model\ArticleInfo;
10
use App\Model\Authorship;
11
use App\Model\Page;
12
use App\Model\Project;
13
use App\Repository\ArticleInfoRepository;
14
use GuzzleHttp\Exception\ServerException;
15
use Symfony\Component\HttpFoundation\JsonResponse;
16
use Symfony\Component\HttpFoundation\Response;
17
use Symfony\Component\Routing\Annotation\Route;
18
use Twig\Markup;
19
20
/**
21
 * This controller serves the search form and results for the ArticleInfo tool
22
 */
23
class ArticleInfoController extends XtoolsController
24
{
25
    protected ArticleInfo $articleInfo;
26
27
    /**
28
     * @inheritDoc
29
     * @codeCoverageIgnore
30
     */
31
    public function getIndexRoute(): string
32
    {
33
        return 'ArticleInfo';
34
    }
35
36
    /**
37
     * The search form.
38
     * @Route("/articleinfo", name="ArticleInfo")
39
     * @Route("/articleinfo/index.php", name="articleInfoIndexPhp")
40
     * @Route("/articleinfo/{project}", name="ArticleInfoProject")
41
     * @return Response
42
     */
43
    public function indexAction(): Response
44
    {
45
        if (isset($this->params['project']) && isset($this->params['page'])) {
46
            return $this->redirectToRoute('ArticleInfoResult', $this->params);
47
        }
48
49
        return $this->render('articleInfo/index.html.twig', array_merge([
50
            'xtPage' => 'ArticleInfo',
51
            'xtPageTitle' => 'tool-articleinfo',
52
            'xtSubtitle' => 'tool-articleinfo-desc',
53
54
            // Defaults that will get overridden if in $params.
55
            'start' => '',
56
            'end' => '',
57
            'page' => '',
58
        ], $this->params, ['project' => $this->project]));
59
    }
60
61
    /**
62
     * Setup the ArticleInfo instance and its Repository.
63
     * @param ArticleInfoRepository $articleInfoRepo
64
     * @param AutomatedEditsHelper $autoEditsHelper
65
     * @codeCoverageIgnore
66
     */
67
    private function setupArticleInfo(
68
        ArticleInfoRepository $articleInfoRepo,
69
        AutomatedEditsHelper $autoEditsHelper
70
    ): void {
71
        if (isset($this->articleInfo)) {
72
            return;
73
        }
74
75
        $this->articleInfo = new ArticleInfo(
76
            $articleInfoRepo,
77
            $this->i18n,
78
            $autoEditsHelper,
79
            $this->page,
0 ignored issues
show
Bug introduced by
It seems like $this->page can also be of type null; however, parameter $page of App\Model\ArticleInfo::__construct() does only seem to accept App\Model\Page, maybe add an additional type check? ( Ignorable by Annotation )

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

79
            /** @scrutinizer ignore-type */ $this->page,
Loading history...
80
            $this->start,
81
            $this->end
82
        );
83
    }
84
85
    /**
86
     * Generate ArticleInfo gadget script for use on-wiki. This automatically points the
87
     * script to this installation's API.
88
     *
89
     * @Route("/articleinfo-gadget.js", name="ArticleInfoGadget")
90
     * @link https://www.mediawiki.org/wiki/XTools/ArticleInfo_gadget
91
     *
92
     * @return Response
93
     * @codeCoverageIgnore
94
     */
95
    public function gadgetAction(): Response
96
    {
97
        $rendered = $this->renderView('articleInfo/articleinfo.js.twig');
98
        $response = new Response($rendered);
99
        $response->headers->set('Content-Type', 'text/javascript');
100
        return $response;
101
    }
102
103
    /**
104
     * Display the results in given date range.
105
     * @Route(
106
     *    "/articleinfo/{project}/{page}/{start}/{end}", name="ArticleInfoResult",
107
     *     requirements={
108
     *         "page"="(.+?)(?!\/(?:|\d{4}-\d{2}-\d{2})(?:\/(|\d{4}-\d{2}-\d{2}))?)?$",
109
     *         "start"="|\d{4}-\d{2}-\d{2}",
110
     *         "end"="|\d{4}-\d{2}-\d{2}",
111
     *     },
112
     *     defaults={
113
     *         "start"=false,
114
     *         "end"=false,
115
     *     }
116
     * )
117
     * @param ArticleInfoRepository $articleInfoRepo
118
     * @param AutomatedEditsHelper $autoEditsHelper
119
     * @return Response
120
     * @codeCoverageIgnore
121
     */
122
    public function resultAction(
123
        ArticleInfoRepository $articleInfoRepo,
124
        AutomatedEditsHelper $autoEditsHelper
125
    ): Response {
126
        if (!$this->isDateRangeValid($this->page, $this->start, $this->end)) {
0 ignored issues
show
Bug introduced by
It seems like $this->page can also be of type null; however, parameter $page of App\Controller\ArticleIn...ler::isDateRangeValid() does only seem to accept App\Model\Page, maybe add an additional type check? ( Ignorable by Annotation )

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

126
        if (!$this->isDateRangeValid(/** @scrutinizer ignore-type */ $this->page, $this->start, $this->end)) {
Loading history...
127
            $this->addFlashMessage('notice', 'date-range-outside-revisions');
128
129
            return $this->redirectToRoute('ArticleInfo', [
130
                'project' => $this->request->get('project'),
131
            ]);
132
        }
133
134
        $this->setupArticleInfo($articleInfoRepo, $autoEditsHelper);
135
        $this->articleInfo->prepareData();
136
137
        $maxRevisions = $this->getParameter('app.max_page_revisions');
138
139
        // Show message if we hit the max revisions.
140
        if ($this->articleInfo->tooManyRevisions()) {
141
            $this->addFlashMessage('notice', 'too-many-revisions', [
142
                $this->i18n->numberFormat($maxRevisions),
143
                $maxRevisions,
144
            ]);
145
        }
146
147
        // For when there is very old data (2001 era) which may cause miscalculations.
148
        if ($this->articleInfo->getFirstEdit()->getYear() < 2003) {
149
            $this->addFlashMessage('warning', 'old-page-notice');
150
        }
151
152
        // When all username info has been hidden (see T303724).
153
        if (0 === $this->articleInfo->getNumEditors()) {
154
            $this->addFlashMessage('warning', 'error-usernames-missing');
155
        } elseif ($this->articleInfo->numDeletedRevisions()) {
156
            $link = new Markup(
157
                $this->renderView('flashes/deleted_data.html.twig', [
158
                    'numRevs' => $this->articleInfo->numDeletedRevisions(),
159
                ]),
160
                'UTF-8'
161
            );
162
            $this->addFlashMessage(
163
                'warning',
164
                $link,
165
                [$this->articleInfo->numDeletedRevisions(), $link]
166
            );
167
        }
168
169
        $ret = [
170
            'xtPage' => 'ArticleInfo',
171
            'xtTitle' => $this->page->getTitle(),
0 ignored issues
show
Bug introduced by
The method getTitle() does not exist on null. ( Ignorable by Annotation )

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

171
            'xtTitle' => $this->page->/** @scrutinizer ignore-call */ getTitle(),

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
172
            'project' => $this->project,
173
            'editorlimit' => (int)$this->request->query->get('editorlimit', 20),
174
            'botlimit' => $this->request->query->get('botlimit', 10),
175
            'pageviewsOffset' => 60,
176
            'ai' => $this->articleInfo,
177
            'showAuthorship' => Authorship::isSupportedPage($this->page) && $this->articleInfo->getNumEditors() > 0,
0 ignored issues
show
Bug introduced by
It seems like $this->page can also be of type null; however, parameter $page of App\Model\Authorship::isSupportedPage() does only seem to accept App\Model\Page, maybe add an additional type check? ( Ignorable by Annotation )

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

177
            'showAuthorship' => Authorship::isSupportedPage(/** @scrutinizer ignore-type */ $this->page) && $this->articleInfo->getNumEditors() > 0,
Loading history...
178
        ];
179
180
        // Output the relevant format template.
181
        return $this->getFormattedResponse('articleInfo/result', $ret);
182
    }
183
184
    /**
185
     * Check if there were any revisions of given page in given date range.
186
     * @param Page $page
187
     * @param false|int $start
188
     * @param false|int $end
189
     * @return bool
190
     */
191
    private function isDateRangeValid(Page $page, $start, $end): bool
192
    {
193
        return $page->getNumRevisions(null, $start, $end) > 0;
194
    }
195
196
    /************************ API endpoints ************************/
197
198
    /**
199
     * Get basic info on a given article.
200
     * @Route(
201
     *     "/api/articleinfo/{project}/{page}",
202
     *     name="ArticleInfoApiAction",
203
     *     requirements={"page"=".+"}
204
     * )
205
     * @Route("/api/page/articleinfo/{project}/{page}", requirements={"page"=".+"})
206
     * @param ArticleInfoRepository $articleInfoRepo
207
     * @param AutomatedEditsHelper $autoEditsHelper
208
     * @return Response|JsonResponse
209
     * See ArticleInfoControllerTest::testArticleInfoApi()
210
     * @codeCoverageIgnore
211
     */
212
    public function articleInfoApiAction(
213
        ArticleInfoRepository $articleInfoRepo,
214
        AutomatedEditsHelper $autoEditsHelper
215
    ): Response {
216
        $this->recordApiUsage('page/articleinfo');
217
218
        $this->setupArticleInfo($articleInfoRepo, $autoEditsHelper);
219
        $data = [];
0 ignored issues
show
Unused Code introduced by
The assignment to $data is dead and can be removed.
Loading history...
220
221
        try {
222
            $data = $this->articleInfo->getArticleInfoApiData($this->project, $this->page);
0 ignored issues
show
Bug introduced by
It seems like $this->page can also be of type null; however, parameter $page of App\Model\ArticleInfoApi::getArticleInfoApiData() does only seem to accept App\Model\Page, maybe add an additional type check? ( Ignorable by Annotation )

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

222
            $data = $this->articleInfo->getArticleInfoApiData($this->project, /** @scrutinizer ignore-type */ $this->page);
Loading history...
223
        } catch (ServerException $e) {
224
            // The Wikimedia action API can fail for any number of reasons. To our users
225
            // any ServerException means the data could not be fetched, so we capture it here
226
            // to avoid the flood of automated emails when the API goes down, etc.
227
            $data['error'] = $this->i18n->msg('api-error', [$this->project->getDomain()]);
228
        }
229
230
        if ('html' === $this->request->query->get('format')) {
231
            return $this->getApiHtmlResponse($this->project, $this->page, $data);
0 ignored issues
show
Bug introduced by
It seems like $this->page can also be of type null; however, parameter $page of App\Controller\ArticleIn...r::getApiHtmlResponse() does only seem to accept App\Model\Page, maybe add an additional type check? ( Ignorable by Annotation )

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

231
            return $this->getApiHtmlResponse($this->project, /** @scrutinizer ignore-type */ $this->page, $data);
Loading history...
232
        }
233
234
        return $this->getFormattedApiResponse($data);
235
    }
236
237
    /**
238
     * Get the Response for the HTML output of the ArticleInfo API action.
239
     * @param Project $project
240
     * @param Page $page
241
     * @param string[] $data The pre-fetched data.
242
     * @return Response
243
     * @codeCoverageIgnore
244
     */
245
    private function getApiHtmlResponse(Project $project, Page $page, array $data): Response
246
    {
247
        $response = $this->render('articleInfo/api.html.twig', [
248
            'project' => $project,
249
            'page' => $page,
250
            'data' => $data,
251
        ]);
252
253
        // All /api routes by default respond with a JSON content type.
254
        $response->headers->set('Content-Type', 'text/html');
255
256
        // This endpoint is hit constantly and user could be browsing the same page over
257
        // and over (popular noticeboard, for instance), so offload brief caching to browser.
258
        $response->setClientTtl(350);
259
260
        return $response;
261
    }
262
263
    /**
264
     * Get prose statistics for the given article.
265
     * @Route(
266
     *     "/api/page/prose/{project}/{page}",
267
     *     name="PageApiProse",
268
     *     requirements={"page"=".+"}
269
     * )
270
     * @param ArticleInfoRepository $articleInfoRepo
271
     * @param AutomatedEditsHelper $autoEditsHelper
272
     * @return JsonResponse
273
     * @codeCoverageIgnore
274
     */
275
    public function proseStatsApiAction(
276
        ArticleInfoRepository $articleInfoRepo,
277
        AutomatedEditsHelper $autoEditsHelper
278
    ): JsonResponse {
279
        $responseCode = Response::HTTP_OK;
280
        $this->recordApiUsage('page/prose');
281
        $this->setupArticleInfo($articleInfoRepo, $autoEditsHelper);
282
        $this->addFlash('info', 'The algorithm used by this API has recently changed. ' .
283
            'See https://www.mediawiki.org/wiki/XTools/Page_History#Prose for details.');
284
        $ret = $this->articleInfo->getProseStats();
285
        if (null === $ret) {
286
            $this->addFlashMessage('error', 'api-error-wikimedia');
287
            $responseCode = Response::HTTP_BAD_GATEWAY;
288
            $ret = [];
289
        }
290
        return $this->getFormattedApiResponse($ret, $responseCode);
291
    }
292
293
    /**
294
     * Get the page assessments of one or more pages, along with various related metadata.
295
     * @Route(
296
     *     "/api/page/assessments/{project}/{pages}",
297
     *     name="PageApiAssessments",
298
     *     requirements={"pages"=".+"}
299
     * )
300
     * @param string $pages May be multiple pages separated by pipes, e.g. Foo|Bar|Baz
301
     * @return JsonResponse
302
     * @codeCoverageIgnore
303
     */
304
    public function assessmentsApiAction(string $pages): JsonResponse
305
    {
306
        $this->recordApiUsage('page/assessments');
307
308
        $pages = explode('|', $pages);
309
        $out = [];
310
311
        foreach ($pages as $pageTitle) {
312
            try {
313
                $page = $this->validatePage($pageTitle);
314
                $assessments = $page->getProject()
315
                    ->getPageAssessments()
316
                    ->getAssessments($page);
317
318
                $out[$page->getTitle()] = $this->request->get('classonly')
319
                    ? $assessments['assessment']
320
                    : $assessments;
321
            } catch (XtoolsHttpException $e) {
322
                $out[$pageTitle] = false;
323
            }
324
        }
325
326
        return $this->getFormattedApiResponse($out);
327
    }
328
329
    /**
330
     * Get number of in and outgoing links and redirects to the given page.
331
     * @Route(
332
     *     "/api/page/links/{project}/{page}",
333
     *     name="PageApiLinks",
334
     *     requirements={"page"=".+"}
335
     * )
336
     * @return JsonResponse
337
     * @codeCoverageIgnore
338
     */
339
    public function linksApiAction(): JsonResponse
340
    {
341
        $this->recordApiUsage('page/links');
342
        return $this->getFormattedApiResponse($this->page->countLinksAndRedirects());
343
    }
344
345
    /**
346
     * Get the top editors to a page.
347
     * @Route(
348
     *     "/api/page/top_editors/{project}/{page}/{start}/{end}/{limit}", name="PageApiTopEditors",
349
     *     requirements={
350
     *         "page"="(.+?)(?!\/(?:|\d{4}-\d{2}-\d{2})(?:\/(|\d{4}-\d{2}-\d{2}))?(?:\/(\d+))?)?$",
351
     *         "start"="|\d{4}-\d{2}-\d{2}",
352
     *         "end"="|\d{4}-\d{2}-\d{2}",
353
     *         "limit"="|\d+"
354
     *     },
355
     *     defaults={
356
     *         "start"=false,
357
     *         "end"=false,
358
     *         "limit"=20,
359
     *     }
360
     * )
361
     * @param ArticleInfoRepository $articleInfoRepo
362
     * @param AutomatedEditsHelper $autoEditsHelper
363
     * @return JsonResponse
364
     * @codeCoverageIgnore
365
     */
366
    public function topEditorsApiAction(
367
        ArticleInfoRepository $articleInfoRepo,
368
        AutomatedEditsHelper $autoEditsHelper
369
    ): JsonResponse {
370
        $this->recordApiUsage('page/top_editors');
371
372
        $this->setupArticleInfo($articleInfoRepo, $autoEditsHelper);
373
        $topEditors = $this->articleInfo->getTopEditorsByEditCount(
374
            (int)$this->limit,
375
            '' != $this->request->query->get('nobots')
376
        );
377
378
        return $this->getFormattedApiResponse([
379
            'top_editors' => $topEditors,
380
        ]);
381
    }
382
383
    /**
384
     * Get data about bots that have edited a page.
385
     * @Route(
386
     *     "/api/page/bot_data/{project}/{page}/{start}/{end}", name="PageApiBotData",
387
     *     requirements={
388
     *         "page"="(.+?)(?!\/(?:|\d{4}-\d{2}-\d{2})(?:\/(|\d{4}-\d{2}-\d{2}))?)?$",
389
     *         "start"="|\d{4}-\d{2}-\d{2}",
390
     *         "end"="|\d{4}-\d{2}-\d{2}",
391
     *     },
392
     *     defaults={
393
     *         "start"=false,
394
     *         "end"=false,
395
     *     }
396
     * )
397
     * @param ArticleInfoRepository $articleInfoRepo
398
     * @param AutomatedEditsHelper $autoEditsHelper
399
     * @return JsonResponse
400
     * @codeCoverageIgnore
401
     */
402
    public function botDataApiAction(
403
        ArticleInfoRepository $articleInfoRepo,
404
        AutomatedEditsHelper $autoEditsHelper
405
    ): JsonResponse {
406
        $this->recordApiUsage('page/bot_data');
407
408
        $this->setupArticleInfo($articleInfoRepo, $autoEditsHelper);
409
        $bots = $this->articleInfo->getBots();
410
411
        return $this->getFormattedApiResponse([
412
            'bots' => $bots,
413
        ]);
414
    }
415
416
    /**
417
     * Get counts of (semi-)automated tools that were used to edit the page.
418
     * @Route(
419
     *     "/api/page/automated_edits/{project}/{page}/{start}/{end}", name="PageApiAutoEdits",
420
     *     requirements={
421
     *         "page"="(.+?)(?!\/(?:|\d{4}-\d{2}-\d{2})(?:\/(|\d{4}-\d{2}-\d{2}))?)?$",
422
     *         "start"="|\d{4}-\d{2}-\d{2}",
423
     *         "end"="|\d{4}-\d{2}-\d{2}",
424
     *     },
425
     *     defaults={
426
     *         "start"=false,
427
     *         "end"=false,
428
     *     }
429
     * )
430
     * @param ArticleInfoRepository $articleInfoRepo
431
     * @param AutomatedEditsHelper $autoEditsHelper
432
     * @return JsonResponse
433
     * @codeCoverageIgnore
434
     */
435
    public function getAutoEdits(
436
        ArticleInfoRepository $articleInfoRepo,
437
        AutomatedEditsHelper $autoEditsHelper
438
    ): JsonResponse {
439
        $this->recordApiUsage('page/auto_edits');
440
441
        $this->setupArticleInfo($articleInfoRepo, $autoEditsHelper);
442
        return $this->getFormattedApiResponse([
443
            'auto_edits' => $this->articleInfo->getAutoEditsCounts(),
444
        ]);
445
    }
446
}
447