Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
42.00% covered (danger)
42.00%
63 / 150
0.00% covered (danger)
0.00%
0 / 15
CRAP
0.00% covered (danger)
0.00%
0 / 1
Hooks
42.00% covered (danger)
42.00%
63 / 150
0.00% covered (danger)
0.00%
0 / 15
738.18
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 expandIconTemplateOptions
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 isSiteNoticeSkin
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 isBannerPermitted
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 addBannerToSkinOutput
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
20
 onSiteNoticeAfter
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
20
 onBeforePageDisplay
90.62% covered (success)
90.62%
29 / 32
0.00% covered (danger)
0.00%
0 / 1
13.14
 getBannerOptions
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 onParserOutputPostCacheTransform
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 getSectionsDataInternal
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
20
 getRecursiveTocData
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
12
 onOutputPageParserOutput
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
20
 addBadParserFunctionArgsWarning
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
30
 addCustomBanner
89.47% covered (warning)
89.47%
34 / 38
0.00% covered (danger)
0.00%
0 / 1
10.12
 onParserFirstCallInit
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3namespace MediaWiki\Extension\WikidataPageBanner;
4
5use MediaWiki\Hook\BeforePageDisplayHook;
6use MediaWiki\Hook\OutputPageParserOutputHook;
7use MediaWiki\Hook\ParserFirstCallInitHook;
8use MediaWiki\Hook\ParserOutputPostCacheTransformHook;
9use MediaWiki\Hook\SiteNoticeAfterHook;
10use MediaWiki\MediaWikiServices;
11use MediaWiki\Output\OutputPage;
12use MediaWiki\Parser\ParserOutput;
13use MediaWiki\Title\Title;
14use Message;
15use OOUI\IconWidget;
16use Parser;
17use Skin;
18
19/**
20 * This class implements the hookhandlers for WikidataPageBanner
21 *
22 * TODO: It also currently handles a lot of the actual construction of the Banner.
23 * and this should be moved into Banner, WikidataBanner, BannerFactory classes
24 */
25class Hooks implements
26    BeforePageDisplayHook,
27    OutputPageParserOutputHook,
28    ParserFirstCallInitHook,
29    ParserOutputPostCacheTransformHook,
30    SiteNoticeAfterHook
31{
32
33    /**
34     * Singleton instance for helper class functions
35     * This variable holds the class name for helper functions and is used to make calls to those
36     * functions
37     * Note that this variable is also used by tests to store a mock classname of helper functions
38     * in it externally
39     * @var string
40     */
41    public static $wpbBannerClass = Banner::class;
42
43    /**
44     * Holds an array of valid parameters for PAGEBANNER hook.
45     */
46    private static $allowedParameters = [
47        'pgname',
48        'tooltip',
49        'toc',
50        'bottomtoc',
51        'origin',
52        'icon-*',
53        'extraClass',
54        'link',
55    ];
56
57    public function __construct() {
58    }
59
60    /**
61     * Expands icons for rendering via template
62     *
63     * @param array[] $icons of options for IconWidget
64     * @return array[]
65     */
66    protected function expandIconTemplateOptions( array $icons ) {
67        foreach ( $icons as $key => $iconData ) {
68            $widget = new IconWidget( $iconData );
69            $iconData['html'] = $widget->toString();
70            $icons[$key] = $iconData;
71        }
72
73        return $icons;
74    }
75
76    /**
77     * Checks if the skin should output the wikidata banner before the
78     * site subtitle, in which case it should use the sitenotice container.
79     * @param Skin $skin
80     * @return bool
81     */
82    private function isSiteNoticeSkin( Skin $skin ) {
83        $currentSkin = $skin->getSkinName();
84        $skins = $skin->getConfig()->get( 'WPBDisplaySubtitleAfterBannerSkins' );
85        return in_array( $currentSkin, $skins );
86    }
87
88    /**
89     * Determine whether a banner should be shown on the given page.
90     * @param Title $title
91     * @return bool
92     */
93    private function isBannerPermitted( Title $title ) {
94        $config = Banner::getWPBConfig();
95        $ns = $title->getNamespace();
96        $enabledMainPage = $title->isMainPage() ? $config->get( 'WPBEnableMainPage' ) : true;
97        return self::$wpbBannerClass::validateNamespace( $ns ) && $enabledMainPage;
98    }
99
100    /**
101     * Modifies the template to add the banner html for rendering by the skin to the subtitle
102     * if a banner exists and the skin is configured via WPBDisplaySubtitleAfterBannerSkins;
103     * Any existing subtitle is made part of the banner and the subtitle is reset.
104     * @see https://www.mediawiki.org/wiki/Manual:Hooks/BeforePageDisplay
105     *
106     * @param OutputPage $out
107     * @return bool indicating whether it was added or not
108     */
109    public function addBannerToSkinOutput( OutputPage $out ) {
110        $skin = $out->getSkin();
111        $isSkinDisabled = self::$wpbBannerClass::isSkinDisabled( $skin );
112
113        // If the skin is using SiteNoticeAfter abort.
114        if ( $isSkinDisabled || $this->isSiteNoticeSkin( $skin ) ) {
115            return false;
116        }
117        $banner = $out->getProperty( 'articlebanner' );
118        if ( $banner ) {
119            // Insert banner
120            $out->addSubtitle( $banner );
121        }
122
123        return true;
124    }
125
126    /**
127     * Add banner to skins which output banners into the site notice area.
128     * @param string|bool &$siteNotice of the page.
129     * @param Skin $skin being used.
130     */
131    public function onSiteNoticeAfter( &$siteNotice, $skin ) {
132        if ( !self::$wpbBannerClass::isSkinDisabled( $skin ) &&
133            $this->isSiteNoticeSkin( $skin )
134        ) {
135            $out = $skin->getOutput();
136            $banner = $out->getProperty( 'articlebanner' );
137
138            if ( $siteNotice ) {
139                $siteNotice .= $banner;
140            } else {
141                $siteNotice = $banner;
142            }
143        }
144    }
145
146    /**
147     * Hooks::addBanner Generates banner from given options and adds it and its styles
148     * to Output Page. If no options defined through {{PAGEBANNER}}, tries to add a wikidata banner
149     * or an image as defined by the PageImages extension or a default one
150     * dependent on how extension is configured.
151     *
152     * @param OutputPage $out
153     * @param Skin $skin Skin object being rendered
154     */
155    public function onBeforePageDisplay( $out, $skin ): void {
156        $config = Banner::getWPBConfig();
157        $title = $out->getTitle();
158        $isDiff = $out->getRequest()->getCheck( 'diff' );
159        $wpbBannerClass = self::$wpbBannerClass;
160
161        // if banner-options are set and not a diff page, add banner anyway
162        if ( $out->getProperty( 'wpb-banner-options' ) !== null && !$isDiff ) {
163            $params = $out->getProperty( 'wpb-banner-options' );
164            $bannername = $params['name'];
165            if ( isset( $params['icons'] ) ) {
166                $out->enableOOUI();
167                $params['icons'] = $this->expandIconTemplateOptions( $params['icons'] );
168            }
169            $banner = $wpbBannerClass::getBannerHtml( $bannername, $params );
170            // attempt to get an automatic banner
171            if ( $banner === null ) {
172                $params['isAutomatic'] = true;
173                $bannername = $wpbBannerClass::getAutomaticBanner( $title );
174                $banner = $wpbBannerClass::getBannerHtml( $bannername, $params );
175            }
176            // only add banner and styling if valid banner generated
177            if ( $banner !== null ) {
178                if ( isset( $params['data-toc'] ) ) {
179                    $out->addModuleStyles( 'ext.WikidataPageBanner.toc.styles' );
180                }
181                $wpbBannerClass::setOutputPageProperties( $out, $banner );
182
183                // FIXME: This is currently only needed to support testing
184                $out->setProperty( 'articlebanner-name', $bannername );
185            }
186        } elseif (
187            $title->isKnown() &&
188            $out->isArticle() &&
189            $this->isBannerPermitted( $title ) &&
190            $config->get( 'WPBEnableDefaultBanner' ) &&
191            !$isDiff
192        ) {
193            // if the page uses no 'PAGEBANNER' invocation and if article page, insert default banner
194            // first try to obtain bannername from Wikidata
195            $bannername = $wpbBannerClass::getAutomaticBanner( $title );
196            // add title and whether the banner is auto generated to template parameters
197            $paramsForBannerTemplate = [ 'title' => $title, 'isAutomatic' => true ];
198            $banner = $wpbBannerClass::getBannerHtml( $bannername, $paramsForBannerTemplate );
199            // only add banner and styling if valid banner generated
200            if ( $banner !== null ) {
201                $wpbBannerClass::setOutputPageProperties( $out, $banner );
202
203                // set articlebanner property on OutputPage
204                // FIXME: This is currently only needed to support testing
205                $out->setProperty( 'articlebanner-name', $bannername );
206            }
207        }
208        $this->addBannerToSkinOutput( $out );
209    }
210
211    /**
212     * @param ParserOutput $parserOutput
213     * @return array|null
214     */
215    private function getBannerOptions( ParserOutput $parserOutput ) {
216        return $parserOutput->getExtensionData( 'wpb-banner-options' );
217    }
218
219    /**
220     * Disables the primary table of contents in the article.
221     * @param ParserOutput $parserOutput
222     * @param string &$text
223     * @param array &$options
224     */
225    public function onParserOutputPostCacheTransform( $parserOutput, &$text, &$options ): void {
226        // Disable table of contents in article.
227        $bannerOptions = $this->getBannerOptions( $parserOutput );
228        if ( $bannerOptions !== null && isset( $bannerOptions['enable-toc'] ) ) {
229            $enableTocInBanner = $bannerOptions['enable-toc'];
230            $options['injectTOC'] = !$enableTocInBanner;
231        }
232    }
233
234    /**
235     * Nests child sections within their parent sections.
236     * Based on code in SkinComponentTableOfContents.
237     *
238     * @param array $sections
239     * @param int $toclevel
240     * @return array
241     */
242    private function getSectionsDataInternal( array $sections, int $toclevel = 1 ): array {
243        $data = [];
244        foreach ( $sections as $i => $section ) {
245            // Child section belongs to a higher parent.
246            if ( $section->tocLevel < $toclevel ) {
247                return $data;
248            }
249
250            // Set all the parent sections at the current top level.
251            if ( $section->tocLevel === $toclevel ) {
252                $childSections = $this->getSectionsDataInternal(
253                    array_slice( $sections, $i + 1 ),
254                    $toclevel + 1
255                );
256                $data[] = $section->toLegacy() + [
257                    'array-sections' => $childSections,
258                ];
259            }
260        }
261        return $data;
262    }
263
264    /**
265     * @param OutputPage $out
266     * @return array
267     */
268    private function getRecursiveTocData( OutputPage $out ) {
269        $tocData = $out->getTOCData();
270        $sections = $this->getSectionsDataInternal(
271            $tocData ? $tocData->getSections() : []
272        );
273        // Since the banner is outside of #mw-content-text, it
274        // will be in the 'user language' (set on the root <html>
275        // tag) not the 'content language'.  Record the proper
276        // page language so that we can reset lang/dir in HTML.
277        $title = $out->getTitle();
278        // Note that OutputPage::getLanguage() returns user language
279        // *not* the page/content language -- but title should always be
280        // set for pages we're interested in.
281        $lang = $title ? $title->getPageLanguage() : $out->getLanguage();
282        return [
283            'array-sections' => $sections,
284            'lang' => $lang->getHtmlCode(),
285            'dir' => $lang->getDir(),
286        ];
287    }
288
289    /**
290     * Hooks::onOutputPageParserOutput add banner parameters from ParserOutput to
291     * Output page
292     *
293     * @param OutputPage $outputPage
294     * @param ParserOutput $parserOutput
295     */
296    public function onOutputPageParserOutput( $outputPage, $parserOutput ): void {
297        $options = $this->getBannerOptions( $parserOutput );
298        if ( $options !== null ) {
299            // if toc parameter set, then remove original classes and add banner class
300            if ( isset( $options['enable-toc'] ) ) {
301                $tocData = $outputPage->getTOCData();
302                if ( $tocData ) {
303                    $options['data-toc'] = $this->getRecursiveTocData( $outputPage );
304                }
305                $options['msg-toc'] = $outputPage->msg( 'toc' )->text();
306            }
307
308            // set banner properties as an OutputPage property
309            $outputPage->setProperty( 'wpb-banner-options', $options );
310        }
311    }
312
313    /**
314     * Validates a given array of parameters against a set of allowed parameters and adds a
315     * warning message with a list of unknown parameters and a tracking category, if there are any.
316     *
317     * @param array $args Array of parameters to check
318     * @param Parser $parser ParserOutput object to add the warning message
319     */
320    public function addBadParserFunctionArgsWarning( array $args, Parser $parser ) {
321        $badParams = [];
322        $allowedParams = array_flip( self::$allowedParameters );
323        foreach ( $args as $param => $value ) {
324            // manually check for icons, they can have any name with the "icon-" prefix
325            if ( !isset( $allowedParams[$param] ) && substr( $param, 0, 5 ) !== 'icon-' ) {
326                $badParams[] = $param;
327            }
328        }
329
330        if ( $badParams ) {
331            // if there are unknown parameters, add a tracking category
332            $parser->addTrackingCategory( 'wikidatapagebanner-invalid-arguments-cat' );
333
334            // this message will be visible when the page preview button is used, but not when the page is
335            // saved. It contains a list of unknown parameters.
336            $parser->getOutput()->addWarningMsg(
337                'wikidatapagebanner-invalid-arguments',
338                Message::listParam( $badParams, 'comma' )
339            );
340        }
341    }
342
343    /**
344     * Hooks::addCustomBanner
345     * Parser function hooked to 'PAGEBANNER' magic word, to define a custom banner and options to
346     * customize banner such as icons,horizontal TOC,etc. The method does not return any content but
347     * sets the banner parameters in ParserOutput object for use at a later stage to generate banner
348     *
349     * @param Parser $parser
350     * @param string $bannername Name of custom banner
351     * @param string ...$args
352     */
353    public function addCustomBanner( Parser $parser, $bannername, ...$args ) {
354        // @var array to hold parameters to be passed to banner template
355        $paramsForBannerTemplate = [];
356        // Convert $argumentsFromParserFunction into an associative array
357        $wpbFunctionsClass = self::$wpbBannerClass;
358        $argumentsFromParserFunction = $wpbFunctionsClass::extractOptions( $parser, $args );
359        // if given banner does not exist, return
360        $title = $parser->getTitle();
361
362        if ( $this->isBannerPermitted( $title ) ) {
363            // check for unknown parameters used in the parser hook and add a warning if there is any
364            $this->addBadParserFunctionArgsWarning( $argumentsFromParserFunction, $parser );
365
366            // set title and tooltip attribute to default title
367            // convert title to preferred language variant as done in core Parser.php
368            $langConv = MediaWikiServices::getInstance()->getLanguageConverterFactory()
369                ->getLanguageConverter( $parser->getTargetLanguage() );
370            $paramsForBannerTemplate['tooltip'] = $langConv->convert( $title->getText() );
371            $paramsForBannerTemplate['title'] = $langConv->convert( $title->getText() );
372            if ( isset( $argumentsFromParserFunction['pgname'] ) ) {
373                // set tooltip attribute to  parameter 'pgname', if set
374                $paramsForBannerTemplate['tooltip'] = $argumentsFromParserFunction['pgname'];
375                // set title attribute to 'pgname' if set
376                $paramsForBannerTemplate['title'] = $argumentsFromParserFunction['pgname'];
377            }
378            // set extra CSS classes added with extraClass attribute
379            $wpbFunctionsClass::addCssClasses( $paramsForBannerTemplate,
380                    $argumentsFromParserFunction );
381            // set tooltip attribute to  parameter 'tooltip', if set, which takes highest preference
382            if ( isset( $argumentsFromParserFunction['tooltip'] ) ) {
383                $paramsForBannerTemplate['tooltip'] = $argumentsFromParserFunction['tooltip'];
384            }
385            // Add link attribute, to change the target of the banner link.
386            if ( isset( $argumentsFromParserFunction['link'] ) ) {
387                $paramsForBannerTemplate['link'] = $argumentsFromParserFunction['link'];
388            }
389            // set 'bottomtoc' parameter to allow TOC completely below the banner
390            if ( isset( $argumentsFromParserFunction['bottomtoc'] ) &&
391                    $argumentsFromParserFunction['bottomtoc'] === 'yes' ) {
392                $paramsForBannerTemplate['bottomtoc'] = true;
393            }
394            Banner::addToc( $paramsForBannerTemplate,
395                    $argumentsFromParserFunction );
396            Banner::addIcons( $paramsForBannerTemplate,
397                    $argumentsFromParserFunction );
398            Banner::addFocus( $paramsForBannerTemplate,
399                    $argumentsFromParserFunction );
400            $paramsForBannerTemplate['name'] = $bannername;
401
402            // add the valid banner to image links
403            // @FIXME:Since bannernames which are to be added are generated here, getBannerHtml can
404            // be cleaned to only accept a valid title object pointing to a banner file
405            // Default banner is not added to imagelinks as that is the property of this extension
406            // and is uniform across all pages
407            $bannerTitle = null;
408            if ( $wpbFunctionsClass::getImageUrl( $paramsForBannerTemplate['name'] ) !== null ) {
409                $bannerTitle = Title::makeTitleSafe( NS_FILE, $paramsForBannerTemplate['name'] );
410            } else {
411                $fallbackBanner = $wpbFunctionsClass::getAutomaticBanner( $title );
412                if ( $wpbFunctionsClass::getImageUrl( $fallbackBanner ) !== null ) {
413                    $bannerTitle = Title::makeTitleSafe( NS_FILE, $fallbackBanner );
414                }
415            }
416            // add custom or wikidata banner properties to page_props table if a valid banner exists
417            // in, checking for custom banner first, then wikidata banner
418            if ( $bannerTitle !== null ) {
419                $parser->getOutput()->setExtensionData( 'wpb-banner-options', $paramsForBannerTemplate );
420                $parser->fetchFileAndTitle( $bannerTitle );
421            }
422        }
423    }
424
425    /**
426     * Hooks::onParserFirstCallInit
427     * Hooks the parser function addCustomBanner to the magic word 'PAGEBANNER'
428     *
429     * @param Parser $parser
430     * @return bool
431     */
432    public function onParserFirstCallInit( $parser ) {
433        $parser->setFunctionHook(
434            'PAGEBANNER', [ $this, 'addCustomBanner' ], Parser::SFH_NO_HASH
435        );
436        return true;
437    }
438
439}