Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 101
0.00% covered (danger)
0.00%
0 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
Hooks
0.00% covered (danger)
0.00%
0 / 101
0.00% covered (danger)
0.00%
0 / 7
812
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 onBeforePageDisplay
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
20
 onWikiPageDeletionUpdates
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 onRevisionFromEditComplete
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
30
 onAPIQuerySiteInfoGeneralInfo
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 onInfoAction
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
20
 onParserLogLinterData
0.00% covered (danger)
0.00%
0 / 37
0.00% covered (danger)
0.00%
0 / 1
156
1<?php
2/**
3 * This program is free software; you can redistribute it and/or modify
4 * it under the terms of the GNU General Public License as published by
5 * the Free Software Foundation; either version 2 of the License, or
6 * (at your option) any later version.
7 *
8 * This program is distributed in the hope that it will be useful,
9 * but WITHOUT ANY WARRANTY; without even the implied warranty of
10 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 * GNU General Public License for more details.
12 *
13 * You should have received a copy of the GNU General Public License along
14 * with this program; if not, write to the Free Software Foundation, Inc.,
15 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 * http://www.gnu.org/copyleft/gpl.html
17 *
18 * @file
19 */
20
21namespace MediaWiki\Linter;
22
23use ApiQuerySiteinfo;
24use Content;
25use IContextSource;
26use JobQueueGroup;
27use MediaWiki\Api\Hook\APIQuerySiteInfoGeneralInfoHook;
28use MediaWiki\Deferred\MWCallableUpdate;
29use MediaWiki\Hook\BeforePageDisplayHook;
30use MediaWiki\Hook\InfoActionHook;
31use MediaWiki\Hook\ParserLogLinterDataHook;
32use MediaWiki\Linker\LinkRenderer;
33use MediaWiki\Logger\LoggerFactory;
34use MediaWiki\Output\OutputPage;
35use MediaWiki\Page\Hook\RevisionFromEditCompleteHook;
36use MediaWiki\Page\Hook\WikiPageDeletionUpdatesHook;
37use MediaWiki\Revision\RevisionRecord;
38use MediaWiki\SpecialPage\SpecialPage;
39use MediaWiki\Title\Title;
40use MediaWiki\User\UserIdentity;
41use Skin;
42use WikiPage;
43
44class Hooks implements
45    APIQuerySiteInfoGeneralInfoHook,
46    BeforePageDisplayHook,
47    InfoActionHook,
48    ParserLogLinterDataHook,
49    RevisionFromEditCompleteHook,
50    WikiPageDeletionUpdatesHook
51{
52    private LinkRenderer $linkRenderer;
53    private JobQueueGroup $jobQueueGroup;
54    private CategoryManager $categoryManager;
55    private TotalsLookup $totalsLookup;
56    private Database $database;
57
58    /**
59     * @param LinkRenderer $linkRenderer
60     * @param JobQueueGroup $jobQueueGroup
61     * @param CategoryManager $categoryManager
62     * @param TotalsLookup $totalsLookup
63     * @param Database $database
64     */
65    public function __construct(
66        LinkRenderer $linkRenderer,
67        JobQueueGroup $jobQueueGroup,
68        CategoryManager $categoryManager,
69        TotalsLookup $totalsLookup,
70        Database $database
71    ) {
72        $this->linkRenderer = $linkRenderer;
73        $this->jobQueueGroup = $jobQueueGroup;
74        $this->categoryManager = $categoryManager;
75        $this->totalsLookup = $totalsLookup;
76        $this->database = $database;
77    }
78
79    /**
80     * Hook: BeforePageDisplay
81     *
82     * If there is a lintid parameter, look up that error in the database
83     * and setup and output our client-side helpers
84     *
85     * @param OutputPage $out
86     * @param Skin $skin
87     */
88    public function onBeforePageDisplay( $out, $skin ): void {
89        $request = $out->getRequest();
90        $lintId = $request->getInt( 'lintid' );
91        if ( !$lintId ) {
92            return;
93        }
94        $title = $out->getTitle();
95        if ( !$title ) {
96            return;
97        }
98
99        $lintError = $this->database->getFromId( $lintId );
100        if ( !$lintError ) {
101            // Already fixed or bogus URL parameter?
102            return;
103        }
104
105        $out->addJsConfigVars( [
106            'wgLinterErrorCategory' => $lintError->category,
107            'wgLinterErrorLocation' => $lintError->location,
108        ] );
109        $out->addModules( 'ext.linter.edit' );
110    }
111
112    /**
113     * Hook: WikiPageDeletionUpdates
114     *
115     * Remove entries from the linter table upon page deletion
116     *
117     * @param WikiPage $wikiPage
118     * @param Content $content
119     * @param array &$updates
120     */
121    public function onWikiPageDeletionUpdates( $wikiPage, $content, &$updates ) {
122        $updates[] = new MWCallableUpdate( function () use ( $wikiPage ) {
123            $this->totalsLookup->updateStats(
124                $this->database->setForPage(
125                    $wikiPage->getId(), $wikiPage->getNamespace(), []
126                )
127            );
128        }, __METHOD__ );
129    }
130
131    /**
132     * This should match Parsoid's PageConfig::hasLintableContentModel()
133     */
134    public const LINTABLE_CONTENT_MODELS = [ CONTENT_MODEL_WIKITEXT, 'proofread-page' ];
135
136    /**
137     * Hook: RevisionFromEditComplete
138     *
139     * Remove entries from the linter table upon page content model change away from wikitext
140     *
141     * @param WikiPage $wikiPage
142     * @param RevisionRecord $newRevisionRecord
143     * @param bool|int $originalRevId
144     * @param UserIdentity $user
145     * @param string[] &$tags
146     */
147    public function onRevisionFromEditComplete(
148        $wikiPage, $newRevisionRecord, $originalRevId, $user, &$tags
149    ) {
150        // This is just a stop-gap to deal with callers that aren't complying
151        // with the advertised hook signature.
152        if ( !is_array( $tags ) ) {
153            return;
154        }
155
156        if (
157            in_array( "mw-blank", $tags ) ||
158            ( in_array( "mw-contentmodelchange", $tags ) &&
159            !in_array( $wikiPage->getContentModel(), self::LINTABLE_CONTENT_MODELS ) )
160        ) {
161            $this->totalsLookup->updateStats(
162                $this->database->setForPage(
163                    $wikiPage->getId(), $wikiPage->getNamespace(), []
164                )
165            );
166        }
167    }
168
169    /**
170     * Hook: APIQuerySiteInfoGeneralInfo
171     *
172     * Expose categories via action=query&meta=siteinfo
173     *
174     * @param ApiQuerySiteInfo $api
175     * @param array &$data
176     */
177    public function onAPIQuerySiteInfoGeneralInfo( $api, &$data ) {
178        $data['linter'] = [
179            'high' => $this->categoryManager->getHighPriority(),
180            'medium' => $this->categoryManager->getMediumPriority(),
181            'low' => $this->categoryManager->getLowPriority(),
182        ];
183    }
184
185    /**
186     * Hook: InfoAction
187     *
188     * Display quick summary of errors for this page on ?action=info
189     *
190     * @param IContextSource $context
191     * @param array &$pageInfo
192     */
193    public function onInfoAction( $context, &$pageInfo ) {
194        $title = $context->getTitle();
195        $pageId = $title->getArticleID();
196        if ( !$pageId ) {
197            return;
198        }
199        $totals = array_filter( $this->database->getTotalsForPage( $pageId ) );
200        if ( !$totals ) {
201            // No errors, yay!
202            return;
203        }
204
205        foreach ( $totals as $name => $count ) {
206            $pageInfo['linter'][] = [
207                $context->msg( "linter-category-$name" ),
208                htmlspecialchars( (string)$count )
209            ];
210        }
211
212        $pageInfo['linter'][] = [
213            'below',
214            $this->linkRenderer->makeKnownLink(
215                SpecialPage::getTitleFor( 'LintErrors' ),
216                $context->msg( 'pageinfo-linter-moreinfo' )->text(),
217                [],
218                [ 'wpNamespaceRestrictions' => $title->getNamespace(),
219                    'titlesearch' => $title->getText(), 'exactmatch' => 1 ]
220            ),
221        ];
222    }
223
224    /**
225     * Hook: ParserLogLinterData
226     *
227     * To record a lint errors.
228     *
229     * @param string $page
230     * @param int $revision
231     * @param array[] $data
232     * @return bool
233     */
234    public function onParserLogLinterData(
235        string $page, int $revision, array $data
236    ): bool {
237        $errors = [];
238        $title = Title::newFromText( $page );
239        if (
240            !$title || !$title->getArticleID() ||
241            $title->getLatestRevID() != $revision
242        ) {
243            return false;
244        }
245        $catCounts = [];
246        foreach ( $data as $info ) {
247            if ( $this->categoryManager->isKnownCategory( $info['type'] ) ) {
248                $info[ 'dbid' ] = null;
249            } elseif ( !isset( $info[ 'dbid' ] ) ) {
250                continue;
251            }
252            $count = $catCounts[$info['type']] ?? 0;
253            if ( $count > Database::MAX_PER_CAT ) {
254                // Drop
255                continue;
256            }
257            $catCounts[$info['type']] = $count + 1;
258            if ( !isset( $info['dsr'] ) ) {
259                LoggerFactory::getInstance( 'Linter' )->warning(
260                    'dsr for {page} @ rev {revid}, for lint: {lint} is missing',
261                    [
262                        'page' => $page,
263                        'revid' => $revision,
264                        'lint' => $info['type'],
265                    ]
266                );
267                continue;
268            }
269            $info['location'] = array_slice( $info['dsr'], 0, 2 );
270            if ( !isset( $info['params'] ) ) {
271                $info['params'] = [];
272            }
273            if ( isset( $info['templateInfo'] ) && $info['templateInfo'] ) {
274                $info['params']['templateInfo'] = $info['templateInfo'];
275            }
276            $errors[] = $info;
277        }
278        $job = new RecordLintJob( $title, [
279            'errors' => $errors,
280            'revision' => $revision,
281        ], $this->totalsLookup, $this->database );
282        $this->jobQueueGroup->push( $job );
283        return true;
284    }
285}