Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
90.53% covered (success)
90.53%
153 / 169
50.00% covered (danger)
50.00%
6 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
SkinComponentFooter
90.53% covered (success)
90.53%
153 / 169
50.00% covered (danger)
50.00%
6 / 12
55.38
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getTemplateDataFooter
73.33% covered (warning)
73.33%
11 / 15
0.00% covered (danger)
0.00%
0 / 1
3.17
 getTemplateData
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
1
 getFooterInfoData
87.50% covered (warning)
87.50%
21 / 24
0.00% covered (danger)
0.00%
0 / 1
11.24
 getCopyright
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 formatFooterInfoData
50.00% covered (danger)
50.00%
4 / 8
0.00% covered (danger)
0.00%
0 / 1
4.12
 getSiteFooterLinks
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
4
 makeFooterIconHTML
86.67% covered (warning)
86.67%
13 / 15
0.00% covered (danger)
0.00%
0 / 1
5.06
 getFooterIconsData
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
10
 getFooterIcons
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
100.00%
1 / 1
7
 formatFooterDataForCurrentSpec
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
4
 lastModified
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
3.01
1<?php
2
3namespace MediaWiki\Skin;
4
5use Action;
6use Article;
7use CreditsAction;
8use MediaWiki\Config\Config;
9use MediaWiki\Html\Html;
10use MediaWiki\MainConfigNames;
11use MediaWiki\MediaWikiServices;
12use MediaWiki\Title\Title;
13
14class SkinComponentFooter implements SkinComponent {
15    /** @var SkinComponentRegistryContext */
16    private $skinContext;
17
18    /**
19     * @param SkinComponentRegistryContext $skinContext
20     */
21    public function __construct( SkinComponentRegistryContext $skinContext ) {
22        $this->skinContext = $skinContext;
23    }
24
25    /**
26     * Run SkinAddFooterLinks hook on menu data to insert additional menu items specifically in footer.
27     *
28     * @return array
29     */
30    private function getTemplateDataFooter(): array {
31        $data = [
32            'info' => $this->formatFooterInfoData(
33                $this->getFooterInfoData()
34            ),
35            'places' => $this->getSiteFooterLinks(),
36        ];
37        foreach ( $data as $key => $existingItems ) {
38            $newItems = [];
39            $this->skinContext->runHook( 'onSkinAddFooterLinks', [ $key, &$newItems ] );
40            // @phan-suppress-next-line PhanEmptyForeach False positive as hooks modify
41            foreach ( $newItems as $index => $linkHTML ) {
42                $data[ $key ][ $index ] = [
43                    'id' => 'footer-' . $key . '-' . $index,
44                    'html' => $linkHTML,
45                ];
46            }
47        }
48        return $data;
49    }
50
51    /**
52     * @inheritDoc
53     */
54    public function getTemplateData(): array {
55        $footerData = $this->getTemplateDataFooter();
56
57        // Create the menu components from the footer data.
58        $footerInfoMenuData = new SkinComponentMenu(
59            'footer-info',
60            $footerData['info'],
61            $this->skinContext->getMessageLocalizer()
62        );
63        $footerSiteMenuData = new SkinComponentMenu(
64            'footer-places',
65            $footerData['places'],
66            $this->skinContext->getMessageLocalizer()
67        );
68
69        // To conform the footer menu data to the current SkinMustache specification,
70        // run the derived data through a cleanup function to unset unexpected data properties
71        // until the spec is updated to reflect the new properties introduced by the menu component.
72        // See https://www.mediawiki.org/wiki/Manual:SkinMustache.php#DataFooter
73        $footerMenuData = [];
74        $footerMenuData['data-info'] = $footerInfoMenuData->getTemplateData();
75        $footerMenuData['data-places'] = $footerSiteMenuData->getTemplateData();
76        $footerMenuData['data-icons'] = $this->getFooterIcons();
77        $footerMenuData = $this->formatFooterDataForCurrentSpec( $footerMenuData );
78
79        return [
80            'data-info' => $footerMenuData['data-info'],
81            'data-places' => $footerMenuData['data-places'],
82            'data-icons' => $footerMenuData['data-icons']
83        ];
84    }
85
86    /**
87     * Get the footer data containing standard footer links.
88     *
89     * All values are resolved and can be added to by the
90     * SkinAddFooterLinks hook.
91     *
92     * @since 1.40
93     * @internal
94     * @return array
95     */
96    private function getFooterInfoData(): array {
97        $action = null;
98        $skinContext = $this->skinContext;
99        $out = $skinContext->getOutput();
100        $ctx = $skinContext->getContextSource();
101        // This needs to be the relevant Title rather than just the raw Title for e.g. special pages that render content
102        $title = $skinContext->getRelevantTitle();
103        $titleExists = $title && $title->exists();
104        $config = $skinContext->getConfig();
105        $maxCredits = $config->get( MainConfigNames::MaxCredits );
106        $showCreditsIfMax = $config->get( MainConfigNames::ShowCreditsIfMax );
107        $useCredits = $titleExists
108            && $out->isArticle()
109            && $out->isRevisionCurrent()
110            && $maxCredits !== 0;
111
112        /** @var CreditsAction $action */
113        if ( $useCredits ) {
114            $article = Article::newFromWikiPage( $skinContext->getWikiPage(), $ctx );
115            $action = Action::factory( 'credits', $article, $ctx );
116        }
117
118        '@phan-var CreditsAction $action';
119        return [
120            'lastmod' => !$useCredits ? $this->lastModified() : null,
121            'numberofwatchingusers' => null,
122            'credits' => $useCredits && $action ?
123                $action->getCredits( $maxCredits, $showCreditsIfMax ) : null,
124            'copyright' => $titleExists &&
125            $out->showsCopyright() ? $this->getCopyright() : null,
126        ];
127    }
128
129    /**
130     * @return string
131     */
132    private function getCopyright() {
133        $copyright = new SkinComponentCopyright( $this->skinContext );
134        return $copyright->getTemplateData()[ 'html' ];
135    }
136
137    /**
138     * Format the footer data containing standard footer links for passing
139     * into SkinComponentMenu.
140     *
141     * @since 1.40
142     * @internal
143     * @param array $data raw footer data
144     * @return array
145     */
146    private function formatFooterInfoData( array $data ): array {
147        $formattedData = [];
148        foreach ( $data as $key => $item ) {
149            if ( $item ) {
150                $formattedData[ $key ] = [
151                    'id' => 'footer-info-' . $key,
152                    'html' => $item
153                ];
154            }
155        }
156        return $formattedData;
157    }
158
159    /**
160     * Gets the link to the wiki's privacy policy, about page, and disclaimer page
161     *
162     * @internal
163     * @return array data array for 'privacy', 'about', 'disclaimer'
164     */
165    private function getSiteFooterLinks(): array {
166        $siteLinksData = [];
167        $siteLinks = [
168            'privacy' => [ 'privacy', 'privacypage' ],
169            'about' => [ 'aboutsite', 'aboutpage' ],
170            'disclaimers' => [ 'disclaimers', 'disclaimerpage' ]
171        ];
172        $localizer = $this->skinContext->getMessageLocalizer();
173
174        foreach ( $siteLinks as $key => $siteLink ) {
175            // Check if the link description has been disabled in the default language.
176            // If disabled, it is disabled for all languages.
177            if ( !$localizer->msg( $siteLink[0] )->inContentLanguage()->isDisabled() ) {
178                // Display the link for the user, described in their language (which may or may not be the same as the
179                // default language), but make the link target be the one site-wide page.
180                $title = Title::newFromText( $localizer->msg( $siteLink[1] )->inContentLanguage()->text() );
181                if ( $title !== null ) {
182                    $siteLinksData[$key] = [
183                        'id' => "footer-places-$key",
184                        'text' => $localizer->msg( $siteLink[0] )->text(),
185                        'href' => $title->fixSpecialName()->getLinkURL()
186                    ];
187                }
188            }
189        }
190        return $siteLinksData;
191    }
192
193    /**
194     * Renders a $wgFooterIcons icon according to the method's arguments
195     *
196     * @param Config $config
197     * @param array|string $icon The icon to build the html for, see $wgFooterIcons
198     *   for the format of this array.
199     * @param string $withImage Whether to use the icon's image or output
200     *   a text-only footer icon.
201     * @return string HTML
202     * @internal for use in Skin only
203     */
204    public static function makeFooterIconHTML( Config $config, $icon, string $withImage = 'withImage' ): string {
205        if ( is_string( $icon ) ) {
206            $html = $icon;
207        } else { // Assuming array
208            $url = $icon['url'] ?? null;
209            unset( $icon['url'] );
210            if ( isset( $icon['src'] ) && $withImage === 'withImage' ) {
211                // Lazy-load footer icons, since they're not part of the printed view.
212                $icon['loading'] = 'lazy';
213                // do this the lazy way, just pass icon data as an attribute array
214                $html = Html::element( 'img', $icon );
215            } else {
216                $html = htmlspecialchars( $icon['alt'] ?? '' );
217            }
218            if ( $url ) {
219                $html = Html::rawElement( 'a', [
220                    'href' => $url,
221                    'target' => $config->get( MainConfigNames::ExternalLinkTarget ),
222                ],
223                $html );
224            }
225        }
226        return $html;
227    }
228
229    /**
230     * Get data representation of icons
231     *
232     * @internal for use in Skin only
233     * @param Config $config
234     * @return array
235     */
236    public static function getFooterIconsData( Config $config ) {
237        $footericons = [];
238        foreach (
239            $config->get( MainConfigNames::FooterIcons ) as $footerIconsKey => &$footerIconsBlock
240        ) {
241            if ( count( $footerIconsBlock ) > 0 ) {
242                $footericons[$footerIconsKey] = [];
243                foreach ( $footerIconsBlock as &$footerIcon ) {
244                    if ( isset( $footerIcon['src'] ) ) {
245                        if ( !isset( $footerIcon['width'] ) ) {
246                            $footerIcon['width'] = 88;
247                        }
248                        if ( !isset( $footerIcon['height'] ) ) {
249                            $footerIcon['height'] = 31;
250                        }
251                    }
252
253                    // Only output icons which have an image.
254                    // For historic reasons this mimics the `icononly` option
255                    // for BaseTemplate::getFooterIcons.
256                    // In some cases the icon may be an empty array.
257                    // Filter these out. (See T269776)
258                    if ( is_string( $footerIcon ) || isset( $footerIcon['src'] ) ) {
259                        $footericons[$footerIconsKey][] = $footerIcon;
260                    }
261                }
262
263                // If no valid icons with images were added, unset the parent array
264                // Should also prevent empty arrays from when no copyright is set.
265                if ( !count( $footericons[$footerIconsKey] ) ) {
266                    unset( $footericons[$footerIconsKey] );
267                }
268            }
269        }
270        return $footericons;
271    }
272
273    /**
274     * Gets the link to the wiki's privacy policy, about page, and disclaimer page
275     *
276     * @internal
277     * @return array data array for 'privacy', 'about', 'disclaimer'
278     * @suppress SecurityCheck-DoubleEscaped
279     */
280    private function getFooterIcons(): array {
281        $dataIcons = [];
282        $skinContext = $this->skinContext;
283        // If footer icons are enabled append to the end of the rows
284        $footerIcons = $skinContext->getFooterIcons();
285
286        if ( count( $footerIcons ) > 0 ) {
287            $icons = [];
288            foreach ( $footerIcons as $blockName => $blockIcons ) {
289                $html = '';
290                foreach ( $blockIcons as $icon ) {
291                    $html .= $skinContext->makeFooterIcon( $icon );
292                }
293                // For historic reasons this mimics the `icononly` option
294                // for BaseTemplate::getFooterIcons. Empty rows should not be output.
295                if ( $html ) {
296                    $block = htmlspecialchars( $blockName );
297                    $icons[$block] = [
298                        'name' => $block,
299                        'id' => 'footer-' . $block . 'ico',
300                        'html' => $html,
301                        'class' => [ 'noprint' ],
302                    ];
303                }
304            }
305
306            // Empty rows should not be output.
307            // This is how Vector has behaved historically but we can revisit later if necessary.
308            if ( count( $icons ) > 0 ) {
309                $dataIcons = new SkinComponentMenu(
310                    'footer-icons',
311                    $icons,
312                    $this->skinContext->getMessageLocalizer(),
313                    '',
314                    []
315                );
316            }
317        }
318
319        return $dataIcons ? $dataIcons->getTemplateData() : [];
320    }
321
322    /**
323     * Get finalized footer menu data and reformat to fit current specification.
324     *
325     * See https://www.mediawiki.org/wiki/Manual:SkinMustache.php#DataFooter
326     * This method should be removed once the specification is updated and
327     * new data properties provided by the menu component are ok to output.
328     *
329     * @internal
330     * @param array $data
331     * @return array
332     */
333    private function formatFooterDataForCurrentSpec( array $data ): array {
334        $formattedData = [];
335        foreach ( $data as $key => $item ) {
336            unset( $item['html-tooltip'] );
337            unset( $item['html-items'] );
338            unset( $item['html-after-portal'] );
339            unset( $item['html-before-portal'] );
340            unset( $item['label'] );
341            unset( $item['class'] );
342            foreach ( $item['array-items'] ?? [] as $index => $arrayItem ) {
343                unset( $item['array-items'][$index]['html-item'] );
344            }
345            $formattedData[$key] = $item;
346            $formattedData[$key]['className'] = $key === 'data-icons' ? 'noprint' : null;
347        }
348        return $formattedData;
349    }
350
351    /**
352     * Get the timestamp of the latest revision, formatted in user language
353     *
354     * @internal for use in Skin.php only
355     * @return string
356     */
357    private function lastModified() {
358        $skinContext = $this->skinContext;
359        $out = $skinContext->getOutput();
360        $timestamp = $out->getRevisionTimestamp();
361
362        // No cached timestamp, load it from the database
363        // TODO: This code shouldn't be necessary, revision ID should always be available
364        // Move this logic to OutputPage::getRevisionTimestamp if needed.
365        if ( $timestamp === null ) {
366            $revId = $out->getRevisionId();
367            if ( $revId !== null ) {
368                $timestamp = MediaWikiServices::getInstance()->getRevisionLookup()->getTimestampFromId( $revId );
369            }
370        }
371
372        $lastModified = new SkinComponentLastModified(
373            $skinContext,
374            $timestamp
375        );
376
377        return $lastModified->getTemplateData()['text'];
378    }
379}