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