Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
52.35% |
468 / 894 |
|
16.67% |
6 / 36 |
CRAP | |
0.00% |
0 / 1 |
| SkinTemplate | |
52.41% |
468 / 893 |
|
16.67% |
6 / 36 |
6344.13 | |
0.00% |
0 / 1 |
| setupTemplate | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| setupTemplateForOutput | |
55.56% |
5 / 9 |
|
0.00% |
0 / 1 |
2.35 | |||
| setupTemplateContext | |
94.12% |
16 / 17 |
|
0.00% |
0 / 1 |
3.00 | |||
| generateHTML | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
2 | |||
| outputPage | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
2 | |||
| getTemplateData | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| prepareQuickTemplate | |
99.02% |
101 / 102 |
|
0.00% |
0 / 1 |
7 | |||
| setFooterTemplateVars | |
100.00% |
18 / 18 |
|
100.00% |
1 / 1 |
4 | |||
| makePersonalToolsList | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
6 | |||
| getStructuredPersonalTools | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
2 | |||
| buildPersonalUrls | |
24.64% |
17 / 69 |
|
0.00% |
0 / 1 |
191.21 | |||
| addAccountLinks | |
93.75% |
15 / 16 |
|
0.00% |
0 / 1 |
6.01 | |||
| useCombinedLoginLink | |
83.33% |
5 / 6 |
|
0.00% |
0 / 1 |
3.04 | |||
| buildLoginData | |
100.00% |
12 / 12 |
|
100.00% |
1 / 1 |
5 | |||
| getCategoryPortletsData | |
30.00% |
3 / 10 |
|
0.00% |
0 / 1 |
6.09 | |||
| getCategoryLinks | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
| getPortletsTemplateData | |
72.92% |
35 / 48 |
|
0.00% |
0 / 1 |
16.36 | |||
| buildLogoutLinkData | |
0.00% |
0 / 14 |
|
0.00% |
0 / 1 |
6 | |||
| buildCreateAccountData | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
1 | |||
| addPersonalPageItem | |
50.00% |
1 / 2 |
|
0.00% |
0 / 1 |
2.50 | |||
| buildPersonalPageItem | |
0.00% |
0 / 15 |
|
0.00% |
0 / 1 |
20 | |||
| buildWatchlistData | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
2 | |||
| tabAction | |
61.36% |
27 / 44 |
|
0.00% |
0 / 1 |
27.98 | |||
| getSkinNavOverrideableLabel | |
70.00% |
7 / 10 |
|
0.00% |
0 / 1 |
3.24 | |||
| makeTalkUrlDetails | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
2 | |||
| getWatchLinkAttrs | |
0.00% |
0 / 32 |
|
0.00% |
0 / 1 |
72 | |||
| runOnSkinTemplateNavigationHooks | |
80.65% |
25 / 31 |
|
0.00% |
0 / 1 |
13.04 | |||
| buildContentNavigationUrlsInternal | |
55.43% |
143 / 258 |
|
0.00% |
0 / 1 |
693.09 | |||
| getSpecialPageAssociatedNavigationLinks | |
0.00% |
0 / 21 |
|
0.00% |
0 / 1 |
42 | |||
| buildContentActionUrls | |
69.23% |
9 / 13 |
|
0.00% |
0 / 1 |
9.86 | |||
| injectLegacyMenusIntoPersonalTools | |
84.21% |
16 / 19 |
|
0.00% |
0 / 1 |
5.10 | |||
| makeSkinTemplatePersonalUrls | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
6 | |||
| makeSearchInput | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
2 | |||
| makeSearchButton | |
0.00% |
0 / 40 |
|
0.00% |
0 / 1 |
30 | |||
| isSpecialContributeShowable | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
2 | |||
| makeContributionsLink | |
0.00% |
0 / 25 |
|
0.00% |
0 / 1 |
20 | |||
| 1 | <?php |
| 2 | /** |
| 3 | * Copyright © Gabriel Wicke -- http://www.aulinx.de/ |
| 4 | * |
| 5 | * @license GPL-2.0-or-later |
| 6 | * @file |
| 7 | */ |
| 8 | |
| 9 | namespace MediaWiki\Skin; |
| 10 | |
| 11 | use InvalidArgumentException; |
| 12 | use MediaWiki\Debug\MWDebug; |
| 13 | use MediaWiki\Exception\MWException; |
| 14 | use MediaWiki\Html\Html; |
| 15 | use MediaWiki\Language\LanguageCode; |
| 16 | use MediaWiki\Linker\Linker; |
| 17 | use MediaWiki\MainConfigNames; |
| 18 | use MediaWiki\MediaWikiServices; |
| 19 | use MediaWiki\Message\Message; |
| 20 | use MediaWiki\Parser\ParserOutputFlags; |
| 21 | use MediaWiki\Permissions\Authority; |
| 22 | use MediaWiki\Profiler\Profiler; |
| 23 | use MediaWiki\ResourceLoader as RL; |
| 24 | use MediaWiki\Skin\Components\SkinComponentFooter; |
| 25 | use MediaWiki\Skin\Components\SkinComponentUtils; |
| 26 | use MediaWiki\SpecialPage\SpecialPage; |
| 27 | use MediaWiki\Specials\Contribute\ContributeFactory; |
| 28 | use MediaWiki\Title\Title; |
| 29 | use RuntimeException; |
| 30 | use Wikimedia\ArrayUtils\ArrayUtils; |
| 31 | use Wikimedia\Message\MessageParam; |
| 32 | use Wikimedia\Message\MessageSpecifier; |
| 33 | use Wikimedia\Timestamp\TimestampFormat as TS; |
| 34 | |
| 35 | /** |
| 36 | * Base class for QuickTemplate-based skins. |
| 37 | * |
| 38 | * The template data is filled in SkinTemplate::prepareQuickTemplate. |
| 39 | * |
| 40 | * @stable to extend |
| 41 | * @ingroup Skins |
| 42 | */ |
| 43 | class SkinTemplate extends Skin { |
| 44 | /** |
| 45 | * @var string For QuickTemplate, the name of the subclass which will |
| 46 | * actually fill the template. |
| 47 | */ |
| 48 | public $template; |
| 49 | |
| 50 | /** @var string */ |
| 51 | public $thispage; |
| 52 | /** @var string */ |
| 53 | public $titletxt; |
| 54 | /** @var string */ |
| 55 | public $userpage; |
| 56 | /** @var bool TODO: Rename this to $isRegistered (but that's a breaking change) */ |
| 57 | public $loggedin; |
| 58 | /** @var string */ |
| 59 | public $username; |
| 60 | /** @var array */ |
| 61 | public $userpageUrlDetails; |
| 62 | |
| 63 | /** @var bool */ |
| 64 | private $isTempUser; |
| 65 | |
| 66 | /** @var bool */ |
| 67 | private $isAnonUser; |
| 68 | |
| 69 | /** @var bool */ |
| 70 | private $templateContextSet = false; |
| 71 | /** @var array|null */ |
| 72 | private $contentNavigationCached; |
| 73 | /** @var array|null */ |
| 74 | private $portletsCached; |
| 75 | |
| 76 | /** |
| 77 | * Create the template engine object; we feed it a bunch of data |
| 78 | * and eventually it spits out some HTML. Should have interface |
| 79 | * roughly equivalent to PHPTAL 0.7. |
| 80 | * |
| 81 | * @param class-string<QuickTemplate> $classname |
| 82 | * @return QuickTemplate |
| 83 | */ |
| 84 | protected function setupTemplate( $classname ) { |
| 85 | return new $classname( $this->getConfig() ); |
| 86 | } |
| 87 | |
| 88 | /** |
| 89 | * @return QuickTemplate |
| 90 | */ |
| 91 | protected function setupTemplateForOutput() { |
| 92 | $this->setupTemplateContext(); |
| 93 | $template = $this->options['template'] ?? $this->template; |
| 94 | if ( !$template ) { |
| 95 | throw new RuntimeException( |
| 96 | 'SkinTemplate skins must define a `template` either as a public' |
| 97 | . ' property of by passing in a`template` option to the constructor.' |
| 98 | ); |
| 99 | } |
| 100 | $tpl = $this->setupTemplate( $template ); |
| 101 | return $tpl; |
| 102 | } |
| 103 | |
| 104 | /** |
| 105 | * Setup class properties that are necessary prior to calling |
| 106 | * setupTemplateForOutput. It must be called inside |
| 107 | * prepareQuickTemplate. |
| 108 | * This function may set local class properties that will be used |
| 109 | * by other methods, but should not make assumptions about the |
| 110 | * implementation of setupTemplateForOutput |
| 111 | * @since 1.35 |
| 112 | */ |
| 113 | final protected function setupTemplateContext() { |
| 114 | if ( $this->templateContextSet ) { |
| 115 | return; |
| 116 | } |
| 117 | |
| 118 | $request = $this->getRequest(); |
| 119 | $user = $this->getUser(); |
| 120 | $title = $this->getTitle(); |
| 121 | $this->thispage = $title->getPrefixedDBkey(); |
| 122 | $this->titletxt = $title->getPrefixedText(); |
| 123 | $userpageTitle = $user->getUserPage(); |
| 124 | $this->userpage = $userpageTitle->getPrefixedText(); |
| 125 | $this->loggedin = $user->isRegistered(); |
| 126 | $this->username = $user->getName(); |
| 127 | $this->isTempUser = $user->isTemp(); |
| 128 | $this->isAnonUser = $user->isAnon(); |
| 129 | |
| 130 | if ( $this->loggedin ) { |
| 131 | $this->userpageUrlDetails = self::makeUrlDetails( $userpageTitle ); |
| 132 | } else { |
| 133 | # This won't be used in the standard skins, but we define it to preserve the interface |
| 134 | # To save time, we check for existence |
| 135 | $this->userpageUrlDetails = self::makeKnownUrlDetails( $userpageTitle ); |
| 136 | } |
| 137 | |
| 138 | $this->templateContextSet = true; |
| 139 | } |
| 140 | |
| 141 | /** |
| 142 | * Subclasses not wishing to use the QuickTemplate |
| 143 | * render method can rewrite this method, for example to use |
| 144 | * TemplateParser::processTemplate |
| 145 | * @since 1.35 |
| 146 | * @return string HTML is the contents of the body tag e.g. <body>...</body> |
| 147 | */ |
| 148 | public function generateHTML() { |
| 149 | $tpl = $this->prepareQuickTemplate(); |
| 150 | $options = $this->getOptions(); |
| 151 | $out = $this->getOutput(); |
| 152 | // execute template |
| 153 | ob_start(); |
| 154 | $tpl->execute(); |
| 155 | $html = ob_get_contents(); |
| 156 | ob_end_clean(); |
| 157 | |
| 158 | return $html; |
| 159 | } |
| 160 | |
| 161 | /** |
| 162 | * Initialize various variables and generate the template |
| 163 | * @stable to override |
| 164 | */ |
| 165 | public function outputPage() { |
| 166 | Profiler::instance()->setAllowOutput(); |
| 167 | $out = $this->getOutput(); |
| 168 | |
| 169 | $this->initPage( $out ); |
| 170 | $out->addJsConfigVars( $this->getJsConfigVars() ); |
| 171 | |
| 172 | // result may be an error |
| 173 | echo $this->generateHTML(); |
| 174 | } |
| 175 | |
| 176 | /** |
| 177 | * @inheritDoc |
| 178 | */ |
| 179 | public function getTemplateData() { |
| 180 | return parent::getTemplateData() + $this->getPortletsTemplateData(); |
| 181 | } |
| 182 | |
| 183 | /** |
| 184 | * initialize various variables and generate the template |
| 185 | * |
| 186 | * @since 1.23 |
| 187 | * @return QuickTemplate The template to be executed by outputPage |
| 188 | */ |
| 189 | protected function prepareQuickTemplate() { |
| 190 | $title = $this->getTitle(); |
| 191 | $request = $this->getRequest(); |
| 192 | $out = $this->getOutput(); |
| 193 | $config = $this->getConfig(); |
| 194 | $tpl = $this->setupTemplateForOutput(); |
| 195 | |
| 196 | $tpl->set( 'title', $out->getPageTitle() ); |
| 197 | $tpl->set( 'pagetitle', $out->getHTMLTitle() ); |
| 198 | |
| 199 | $tpl->set( 'thispage', $this->thispage ); |
| 200 | $tpl->set( 'titleprefixeddbkey', $this->thispage ); |
| 201 | $tpl->set( 'titletext', $title->getText() ); |
| 202 | $tpl->set( 'articleid', $title->getArticleID() ); |
| 203 | |
| 204 | $tpl->set( 'isarticle', $out->isArticle() ); |
| 205 | |
| 206 | $tpl->set( 'subtitle', $this->prepareSubtitle() ); |
| 207 | $tpl->set( 'undelete', $this->prepareUndeleteLink() ); |
| 208 | |
| 209 | $tpl->set( 'catlinks', $this->getCategories() ); |
| 210 | $feeds = $this->buildFeedUrls(); |
| 211 | $tpl->set( 'feeds', count( $feeds ) ? $feeds : false ); |
| 212 | |
| 213 | $tpl->set( 'mimetype', $config->get( MainConfigNames::MimeType ) ); |
| 214 | $tpl->set( 'charset', 'UTF-8' ); |
| 215 | $tpl->set( 'wgScript', $config->get( MainConfigNames::Script ) ); |
| 216 | $tpl->set( 'skinname', $this->skinname ); |
| 217 | $tpl->set( 'skinclass', static::class ); |
| 218 | $tpl->set( 'skin', $this ); |
| 219 | $tpl->set( 'printable', $out->isPrintable() ); |
| 220 | $tpl->set( 'handheld', $request->getBool( 'handheld' ) ); |
| 221 | $tpl->set( 'loggedin', $this->loggedin ); |
| 222 | $tpl->set( 'notspecialpage', !$title->isSpecialPage() ); |
| 223 | |
| 224 | $searchTitle = SpecialPage::newSearchPage( $this->getUser() ); |
| 225 | $searchLink = $searchTitle->getLocalURL(); |
| 226 | $tpl->set( 'searchaction', $searchLink ); |
| 227 | $tpl->deprecate( 'searchaction', '1.36' ); |
| 228 | |
| 229 | $tpl->set( 'searchtitle', $searchTitle->getPrefixedDBkey() ); |
| 230 | $tpl->set( 'search', trim( $request->getVal( 'search', '' ) ) ); |
| 231 | $tpl->set( 'stylepath', $config->get( MainConfigNames::StylePath ) ); |
| 232 | $tpl->set( 'articlepath', $config->get( MainConfigNames::ArticlePath ) ); |
| 233 | $tpl->set( 'scriptpath', $config->get( MainConfigNames::ScriptPath ) ); |
| 234 | $tpl->set( 'serverurl', $config->get( MainConfigNames::Server ) ); |
| 235 | $tpl->set( 'sitename', $config->get( MainConfigNames::Sitename ) ); |
| 236 | |
| 237 | $userLang = $this->getLanguage(); |
| 238 | $userLangCode = $userLang->getHtmlCode(); |
| 239 | $userLangDir = $userLang->getDir(); |
| 240 | |
| 241 | $tpl->set( 'lang', $userLangCode ); |
| 242 | $tpl->set( 'dir', $userLangDir ); |
| 243 | $tpl->set( 'rtl', $userLang->isRTL() ); |
| 244 | |
| 245 | $logos = RL\SkinModule::getAvailableLogos( $config, $userLangCode ); |
| 246 | $tpl->set( 'logopath', $logos['1x'] ); |
| 247 | |
| 248 | $tpl->set( 'showjumplinks', true ); // showjumplinks preference has been removed |
| 249 | $tpl->set( 'username', $this->loggedin ? $this->username : null ); |
| 250 | $tpl->set( 'userpage', $this->userpage ); |
| 251 | $tpl->set( 'userpageurl', $this->userpageUrlDetails['href'] ); |
| 252 | $tpl->set( 'userlang', $userLangCode ); |
| 253 | |
| 254 | // Users can have their language set differently than the |
| 255 | // content of the wiki. For these users, tell the web browser |
| 256 | // that interface elements are in a different language. |
| 257 | $tpl->set( 'userlangattributes', $this->prepareUserLanguageAttributes() ); |
| 258 | $tpl->set( 'specialpageattributes', '' ); # obsolete |
| 259 | // Used by VectorBeta to insert HTML before content but after the |
| 260 | // heading for the page title. Defaults to empty string. |
| 261 | $tpl->set( 'prebodyhtml', '' ); |
| 262 | |
| 263 | $tpl->set( 'newtalk', $this->getNewtalks() ); |
| 264 | $tpl->set( 'logo', $this->logoText() ); |
| 265 | |
| 266 | $tpl->set( 'indicators', $out->getIndicators() ); |
| 267 | |
| 268 | $tpl->set( 'sitenotice', $this->getSiteNotice() ); |
| 269 | $tpl->set( 'printfooter', $this->printSource() ); |
| 270 | // Wrap the bodyText with #mw-content-text element |
| 271 | $tpl->set( 'bodytext', $this->wrapHTML( $title, $out->getHTML() ) ); |
| 272 | |
| 273 | $tpl->set( 'language_urls', $this->getLanguages() ?: false ); |
| 274 | |
| 275 | $content_navigation = $this->buildContentNavigationUrlsInternal(); |
| 276 | $requestedMenus = $this->getOptions()['menus']; |
| 277 | # Personal toolbar |
| 278 | if ( in_array( 'personal', $requestedMenus ) ) { |
| 279 | $tpl->set( 'personal_urls', $this->makeSkinTemplatePersonalUrls( $content_navigation ) ); |
| 280 | } |
| 281 | $tpl->deprecate( 'personal_urls', 'Use content_navigation instead' ); |
| 282 | // The user-menu, notifications, and user-interface-preferences are new content navigation entries which aren't |
| 283 | // expected to be part of content_navigation or content_actions. Adding them in there breaks skins that do not |
| 284 | // expect it. (See T316196) |
| 285 | $optInKeys = [ |
| 286 | 'user-menu', |
| 287 | 'notifications', |
| 288 | 'user-page', |
| 289 | 'user-interface-preferences', |
| 290 | 'category-normal', |
| 291 | 'category-hidden', |
| 292 | 'associated-pages', |
| 293 | // Footer menus are handled by SkinComponentFooter, not content_navigation (T318376) |
| 294 | 'footer-icons', |
| 295 | 'footer-info', |
| 296 | 'footer-places', |
| 297 | // All historic menus are covered by requested menus so can be unset |
| 298 | // This should match the default for Skin::getOptions()['menus'] |
| 299 | 'views', |
| 300 | 'actions', |
| 301 | 'variants' |
| 302 | ]; |
| 303 | // We could iterate on keys of $requestedMenus but that might break skins making use of their own custom menus |
| 304 | // This is safer for backwards compatibility! |
| 305 | foreach ( $optInKeys as $key ) { |
| 306 | if ( !in_array( $key, $requestedMenus ) ) { |
| 307 | unset( $content_navigation[ $key ] ); |
| 308 | } |
| 309 | } |
| 310 | $content_actions = $this->buildContentActionUrls( $content_navigation ); |
| 311 | $tpl->set( 'content_navigation', $content_navigation ); |
| 312 | $tpl->set( 'content_actions', $content_actions ); |
| 313 | $tpl->deprecate( 'content_actions', '1.46' ); |
| 314 | |
| 315 | // Read footer data AFTER content navigation hooks have run. |
| 316 | // This ensures items added via the Universal hook are captured |
| 317 | // in the footer template variables for legacy skins (T426358). |
| 318 | $this->setFooterTemplateVars( $tpl ); |
| 319 | |
| 320 | $tpl->set( 'sidebar', $this->buildSidebar() ); |
| 321 | $tpl->set( 'nav_urls', $this->buildNavUrls() ); |
| 322 | |
| 323 | $tpl->set( 'debug', '' ); |
| 324 | $tpl->set( 'debughtml', MWDebug::getHTMLDebugLog() ); |
| 325 | |
| 326 | // Set the bodytext to another key so that skins can just output it on its own |
| 327 | // and output printfooter and debughtml separately |
| 328 | $tpl->set( 'bodycontent', $tpl->data['bodytext'] ); |
| 329 | |
| 330 | // Append printfooter and debughtml onto bodytext so that skins that |
| 331 | // were already using bodytext before they were split out don't suddenly |
| 332 | // start not outputting information. |
| 333 | $tpl->data['bodytext'] .= Html::rawElement( |
| 334 | 'div', |
| 335 | [ 'class' => 'printfooter' ], |
| 336 | "\n{$tpl->data['printfooter']}" |
| 337 | ) . "\n"; |
| 338 | $tpl->data['bodytext'] .= $tpl->data['debughtml']; |
| 339 | |
| 340 | // allow extensions adding stuff after the page content. |
| 341 | // See Skin::afterContentHook() for further documentation. |
| 342 | $tpl->set( 'dataAfterContent', $this->afterContentHook() ); |
| 343 | |
| 344 | return $tpl; |
| 345 | } |
| 346 | |
| 347 | /** |
| 348 | * Populate footer-related template variables on the given QuickTemplate. |
| 349 | * |
| 350 | * Reads footer data from SkinComponentFooter and sets individual |
| 351 | * template keys (copyright, lastmod, etc.) as well as the flattened |
| 352 | * 'footerlinks' array for legacy QuickTemplate-based skins. |
| 353 | * |
| 354 | * @param QuickTemplate $tpl |
| 355 | */ |
| 356 | private function setFooterTemplateVars( QuickTemplate $tpl ): void { |
| 357 | $footerData = $this->getComponent( 'footer' )->getTemplateData(); |
| 358 | $tpl->set( 'copyright', $footerData['info']['copyright'] ?? false ); |
| 359 | // No longer used |
| 360 | $tpl->set( 'viewcount', false ); |
| 361 | $tpl->set( 'lastmod', $footerData['info']['lastmod'] ?? false ); |
| 362 | $tpl->set( 'credits', $footerData['info']['credits'] ?? false ); |
| 363 | $tpl->set( 'numberofwatchingusers', false ); |
| 364 | |
| 365 | $tpl->set( 'disclaimer', $footerData['places']['disclaimer'] ?? false ); |
| 366 | $tpl->set( 'privacy', $footerData['places']['privacy'] ?? false ); |
| 367 | $tpl->set( 'about', $footerData['places']['about'] ?? false ); |
| 368 | |
| 369 | // Flatten for compat with the 'footerlinks' key in QuickTemplate-based skins. |
| 370 | $flattenedfooterlinks = []; |
| 371 | foreach ( $footerData as $category => $data ) { |
| 372 | if ( $category !== 'data-icons' ) { |
| 373 | foreach ( $data['array-items'] as $item ) { |
| 374 | $key = str_replace( 'data-', '', $category ); |
| 375 | $flattenedfooterlinks[$key][] = $item['name']; |
| 376 | // For full support with BaseTemplate we also need to |
| 377 | // copy over the keys. |
| 378 | $tpl->set( $item['name'], $item['html'] ); |
| 379 | } |
| 380 | } |
| 381 | } |
| 382 | $tpl->set( 'footerlinks', $flattenedfooterlinks ); |
| 383 | $tpl->set( 'footericons', $this->getFooterIcons() ); |
| 384 | } |
| 385 | |
| 386 | /** |
| 387 | * Get the HTML for the personal tools list |
| 388 | * @since 1.31 |
| 389 | * |
| 390 | * @param array|null $personalTools |
| 391 | * @param array $options |
| 392 | * @return string |
| 393 | */ |
| 394 | public function makePersonalToolsList( $personalTools = null, $options = [] ) { |
| 395 | $personalTools ??= $this->getPersonalToolsForMakeListItem( |
| 396 | $this->buildPersonalUrls() |
| 397 | ); |
| 398 | |
| 399 | $html = ''; |
| 400 | foreach ( $personalTools as $key => $item ) { |
| 401 | $html .= $this->makeListItem( $key, $item, $options ); |
| 402 | } |
| 403 | return $html; |
| 404 | } |
| 405 | |
| 406 | /** |
| 407 | * Get personal tools for the user |
| 408 | * |
| 409 | * @since 1.31 |
| 410 | * |
| 411 | * @return array[] |
| 412 | */ |
| 413 | public function getStructuredPersonalTools() { |
| 414 | return $this->getPersonalToolsForMakeListItem( |
| 415 | $this->buildPersonalUrls() |
| 416 | ); |
| 417 | } |
| 418 | |
| 419 | /** |
| 420 | * Build array of urls for personal toolbar |
| 421 | * |
| 422 | * @param bool $includeNotifications Since 1.36, notifications are optional, |
| 423 | * since 1.46 sending this flag will also add user page. |
| 424 | * @return array |
| 425 | */ |
| 426 | protected function buildPersonalUrls( bool $includeNotifications = true ) { |
| 427 | $this->setupTemplateContext(); |
| 428 | $title = $this->getTitle(); |
| 429 | $authority = $this->getAuthority(); |
| 430 | $request = $this->getRequest(); |
| 431 | $pageurl = $title->getLocalURL(); |
| 432 | $services = MediaWikiServices::getInstance(); |
| 433 | $authManager = $services->getAuthManager(); |
| 434 | $groupPermissionsLookup = $services->getGroupPermissionsLookup(); |
| 435 | $tempUserConfig = $services->getTempUserConfig(); |
| 436 | $returnto = SkinComponentUtils::getReturnToParam( $title, $request, $authority ); |
| 437 | $shouldHideUserLinks = $this->isAnonUser && $tempUserConfig->isKnown(); |
| 438 | |
| 439 | /* set up the default links for the personal toolbar */ |
| 440 | $personal_urls = []; |
| 441 | |
| 442 | if ( $this->loggedin ) { |
| 443 | $this->addPersonalPageItem( $personal_urls, '' ); |
| 444 | if ( $this->isTempUser ) { |
| 445 | $this->addAccountLinks( $personal_urls, $returnto ); |
| 446 | } |
| 447 | // Merge notifications into the personal menu for older skins. |
| 448 | if ( $includeNotifications ) { |
| 449 | $contentNavigation = $this->buildContentNavigationUrlsInternal(); |
| 450 | |
| 451 | if ( isset( $contentNavigation['notifications'] ) ) { |
| 452 | $personal_urls += $contentNavigation['notifications']; |
| 453 | } |
| 454 | } |
| 455 | |
| 456 | $usertalkUrlDetails = $this->makeTalkUrlDetails( $this->userpage ); |
| 457 | $personal_urls['mytalk'] = [ |
| 458 | 'text' => $this->msg( 'mytalk' )->text(), |
| 459 | 'href' => &$usertalkUrlDetails['href'], |
| 460 | 'class' => $usertalkUrlDetails['exists'] ? null : 'new', |
| 461 | 'exists' => $usertalkUrlDetails['exists'], |
| 462 | 'active' => ( $usertalkUrlDetails['href'] == $pageurl ), |
| 463 | 'icon' => 'userTalk' |
| 464 | ]; |
| 465 | if ( !$this->isTempUser ) { |
| 466 | $href = SkinComponentUtils::makeSpecialUrl( 'Preferences' ); |
| 467 | $personal_urls['preferences'] = [ |
| 468 | 'text' => $this->msg( 'mypreferences' )->text(), |
| 469 | 'href' => $href, |
| 470 | 'active' => ( $href == $pageurl ), |
| 471 | 'icon' => 'settings', |
| 472 | ]; |
| 473 | } |
| 474 | |
| 475 | if ( $authority->isAllowed( 'viewmywatchlist' ) ) { |
| 476 | $personal_urls['watchlist'] = self::buildWatchlistData(); |
| 477 | } |
| 478 | |
| 479 | # We need to do an explicit check for Special:Contributions, as we |
| 480 | # have to match both the title, and the target, which could come |
| 481 | # from request values (Special:Contributions?target=Jimbo_Wales) |
| 482 | # or be specified in "subpage" form |
| 483 | # (Special:Contributions/Jimbo_Wales). The plot |
| 484 | # thickens, because the Title object is altered for special pages, |
| 485 | # so it doesn't contain the original alias-with-subpage. |
| 486 | $origTitle = Title::newFromText( $request->getText( 'title' ) ); |
| 487 | if ( $origTitle instanceof Title && $origTitle->isSpecialPage() ) { |
| 488 | [ $spName, $spPar ] = |
| 489 | MediaWikiServices::getInstance()->getSpecialPageFactory()-> |
| 490 | resolveAlias( $origTitle->getText() ); |
| 491 | $active = $spName == 'Contributions' |
| 492 | && ( ( $spPar && $spPar == $this->username ) |
| 493 | || $request->getText( 'target' ) == $this->username ); |
| 494 | } else { |
| 495 | $active = false; |
| 496 | } |
| 497 | |
| 498 | $personal_urls = $this->makeContributionsLink( $personal_urls, 'mycontris', $this->username, $active ); |
| 499 | |
| 500 | // if we can't set the user, we can't unset it either |
| 501 | if ( $request->getSession()->canSetUser() ) { |
| 502 | $personal_urls['logout'] = $this->buildLogoutLinkData(); |
| 503 | } |
| 504 | } elseif ( !$shouldHideUserLinks ) { |
| 505 | $canEdit = $authority->isAllowed( 'edit' ); |
| 506 | $canEditWithTemp = $tempUserConfig->isAutoCreateAction( 'edit' ); |
| 507 | // No need to show Talk and Contributions to anons if they can't contribute! |
| 508 | if ( $canEdit || $canEditWithTemp ) { |
| 509 | // Non interactive placeholder for anonymous users. |
| 510 | // It's unstyled by default (black color). Skin that |
| 511 | // needs it, can style it using the 'pt-anonuserpage' id. |
| 512 | // Skin that does not need it should unset it. |
| 513 | $personal_urls['anonuserpage'] = [ |
| 514 | 'text' => $this->msg( 'notloggedin' )->text(), |
| 515 | ]; |
| 516 | } |
| 517 | if ( $canEdit ) { |
| 518 | // Because of caching, we can't link directly to the IP talk and |
| 519 | // contributions pages. Instead we use the special page shortcuts |
| 520 | // (which work correctly regardless of caching). This means we can't |
| 521 | // determine whether these links are active or not, but since major |
| 522 | // skins (MonoBook, Vector) don't use this information, it's not a |
| 523 | // huge loss. |
| 524 | $personal_urls['anontalk'] = [ |
| 525 | 'text' => $this->msg( 'anontalk' )->text(), |
| 526 | 'href' => SkinComponentUtils::makeSpecialUrlSubpage( 'Mytalk', false ), |
| 527 | 'active' => false, |
| 528 | 'icon' => 'userTalk', |
| 529 | ]; |
| 530 | $personal_urls = $this->makeContributionsLink( $personal_urls, 'anoncontribs', null, false ); |
| 531 | } |
| 532 | } |
| 533 | |
| 534 | if ( !$this->loggedin ) { |
| 535 | $this->addAccountLinks( $personal_urls, $returnto ); |
| 536 | } |
| 537 | |
| 538 | return $personal_urls; |
| 539 | } |
| 540 | |
| 541 | /** |
| 542 | * Add links like create account/log in based on user type |
| 543 | */ |
| 544 | private function addAccountLinks( array &$personal_urls, array $returnto ): void { |
| 545 | $services = MediaWikiServices::getInstance(); |
| 546 | $authority = $this->getAuthority(); |
| 547 | $authManager = $services->getAuthManager(); |
| 548 | $groupPermissionsLookup = $services->getGroupPermissionsLookup(); |
| 549 | $useCombinedLoginLink = $this->useCombinedLoginLink(); |
| 550 | $login_url = $this->buildLoginData( $returnto, $useCombinedLoginLink ); |
| 551 | $createaccount_url = $this->buildCreateAccountData( $returnto ); |
| 552 | |
| 553 | if ( |
| 554 | $authManager->canCreateAccounts() |
| 555 | && $authority->isAllowed( 'createaccount' ) |
| 556 | && !$useCombinedLoginLink |
| 557 | ) { |
| 558 | $personal_urls['createaccount'] = $createaccount_url; |
| 559 | } |
| 560 | |
| 561 | if ( $authManager->canAuthenticateNow() ) { |
| 562 | // TODO: easy way to get anon authority |
| 563 | $key = $groupPermissionsLookup->groupHasPermission( '*', 'read' ) |
| 564 | ? 'login' |
| 565 | : 'login-private'; |
| 566 | $personal_urls[$key] = $login_url; |
| 567 | } |
| 568 | } |
| 569 | |
| 570 | /** |
| 571 | * Returns if a combined login/signup link will be used |
| 572 | * @unstable |
| 573 | * |
| 574 | * @return bool |
| 575 | */ |
| 576 | protected function useCombinedLoginLink() { |
| 577 | $services = MediaWikiServices::getInstance(); |
| 578 | $authManager = $services->getAuthManager(); |
| 579 | $useCombinedLoginLink = $this->getConfig()->get( MainConfigNames::UseCombinedLoginLink ); |
| 580 | if ( !$authManager->canCreateAccounts() || !$authManager->canAuthenticateNow() ) { |
| 581 | // don't show combined login/signup link if one of those is actually not available |
| 582 | $useCombinedLoginLink = false; |
| 583 | } |
| 584 | |
| 585 | return $useCombinedLoginLink; |
| 586 | } |
| 587 | |
| 588 | /** |
| 589 | * Build "Login" link |
| 590 | * @unstable |
| 591 | * |
| 592 | * @param string[] $returnto query params for the page to return to |
| 593 | * @param bool $useCombinedLoginLink when set a single link to login form will be created |
| 594 | * with alternative label. |
| 595 | * @return array |
| 596 | */ |
| 597 | protected function buildLoginData( $returnto, $useCombinedLoginLink ) { |
| 598 | $title = $this->getTitle(); |
| 599 | |
| 600 | $loginlink = $this->getAuthority()->isAllowed( 'createaccount' ) |
| 601 | && $useCombinedLoginLink ? 'nav-login-createaccount' : 'pt-login'; |
| 602 | |
| 603 | $login_url = [ |
| 604 | 'single-id' => 'pt-login', |
| 605 | 'text' => $this->msg( $loginlink )->text(), |
| 606 | 'href' => SkinComponentUtils::makeSpecialUrl( 'Userlogin', $returnto ), |
| 607 | 'active' => $title->isSpecial( 'Userlogin' ) |
| 608 | || ( $title->isSpecial( 'CreateAccount' ) && $useCombinedLoginLink ), |
| 609 | 'icon' => 'logIn' |
| 610 | ]; |
| 611 | |
| 612 | return $login_url; |
| 613 | } |
| 614 | |
| 615 | /** |
| 616 | * @param array $links return value from OutputPage::getCategoryLinks |
| 617 | * @return array of data |
| 618 | */ |
| 619 | private function getCategoryPortletsData( array $links ): array { |
| 620 | $categories = []; |
| 621 | foreach ( $links as $group => $groupLinks ) { |
| 622 | $allLinks = []; |
| 623 | $groupName = 'category-' . $group; |
| 624 | foreach ( $groupLinks as $i => $link ) { |
| 625 | $allLinks[$groupName . '-' . $i] = [ |
| 626 | 'html' => $link, |
| 627 | ]; |
| 628 | } |
| 629 | $categories[ $groupName ] = $allLinks; |
| 630 | } |
| 631 | return $categories; |
| 632 | } |
| 633 | |
| 634 | /** |
| 635 | * Extends category links with Skin::getAfterPortlet functionality. |
| 636 | * @return string HTML |
| 637 | */ |
| 638 | public function getCategoryLinks() { |
| 639 | $afterPortlet = $this->getPortletsTemplateData()['data-portlets']['data-category-normal']['html-after-portal'] |
| 640 | ?? ''; |
| 641 | return parent::getCategoryLinks() . $afterPortlet; |
| 642 | } |
| 643 | |
| 644 | /** |
| 645 | * @return array of portlet data for all portlets |
| 646 | */ |
| 647 | private function getPortletsTemplateData() { |
| 648 | if ( $this->portletsCached ) { |
| 649 | return $this->portletsCached; |
| 650 | } |
| 651 | $portlets = []; |
| 652 | $contentNavigation = $this->buildContentNavigationUrlsInternal(); |
| 653 | $sidebar = []; |
| 654 | $sidebarData = $this->buildSidebar(); |
| 655 | foreach ( $sidebarData as $name => $items ) { |
| 656 | if ( is_array( $items ) ) { |
| 657 | // Numeric strings gets an integer when set as key, cast back - T73639 |
| 658 | $name = (string)$name; |
| 659 | switch ( $name ) { |
| 660 | // ignore search |
| 661 | case 'SEARCH': |
| 662 | break; |
| 663 | // Map toolbox to `tb` id. |
| 664 | case 'TOOLBOX': |
| 665 | $sidebar[] = $this->getPortletData( 'tb', $items ); |
| 666 | break; |
| 667 | // Languages is no longer be tied to sidebar |
| 668 | case 'LANGUAGES': |
| 669 | // The language portal will be added provided either |
| 670 | // languages exist or there is a value in html-after-portal |
| 671 | // for example to show the add language wikidata link (T252800) |
| 672 | $portal = $this->getPortletData( 'lang', $items ); |
| 673 | if ( count( $items ) || $portal['html-after-portal'] ) { |
| 674 | $portlets['data-languages'] = $portal; |
| 675 | } |
| 676 | break; |
| 677 | default: |
| 678 | $sidebar[] = $this->getPortletData( $name, $items ); |
| 679 | break; |
| 680 | } |
| 681 | } |
| 682 | } |
| 683 | |
| 684 | foreach ( $contentNavigation as $name => $items ) { |
| 685 | if ( $name === 'user-menu' ) { |
| 686 | $items = $this->getPersonalToolsForMakeListItem( $items, true ); |
| 687 | } |
| 688 | |
| 689 | $portlets['data-' . $name] = $this->getPortletData( $name, $items ); |
| 690 | } |
| 691 | |
| 692 | $menuOptions = $this->getOptions()['menus']; |
| 693 | $intro = 'Skins must now pass `menus` key to skin definition in skin.json. Default value is: ' |
| 694 | . "['associated-pages', 'views', 'actions', 'variants', 'personal']. <br>"; |
| 695 | |
| 696 | // A menu that includes the notifications. Now deprecated. |
| 697 | if ( in_array( 'personal', $menuOptions ) ) { |
| 698 | wfDeprecatedMsg( |
| 699 | $intro . 'Menu "personal" is deprecated. Replace with "user-page", "user-interface-preferences",' |
| 700 | . '"notifications" and "user-menu".', |
| 701 | '1.46' |
| 702 | ); |
| 703 | $portlets['data-personal'] = $this->getPortletData( |
| 704 | 'personal', |
| 705 | $this->getPersonalToolsForMakeListItem( |
| 706 | $this->injectLegacyMenusIntoPersonalTools( $contentNavigation ) |
| 707 | ) |
| 708 | ); |
| 709 | } |
| 710 | |
| 711 | $this->portletsCached = [ |
| 712 | 'data-portlets' => $portlets, |
| 713 | 'data-portlets-sidebar' => [ |
| 714 | 'data-portlets-first' => $sidebar[0] ?? null, |
| 715 | 'array-portlets-rest' => array_slice( $sidebar, 1 ), |
| 716 | ], |
| 717 | ]; |
| 718 | return $this->portletsCached; |
| 719 | } |
| 720 | |
| 721 | /** |
| 722 | * Build data required for "Logout" link. |
| 723 | * |
| 724 | * @unstable |
| 725 | * |
| 726 | * @since 1.37 |
| 727 | * |
| 728 | * @return array Array of data required to create a logout link. |
| 729 | */ |
| 730 | final protected function buildLogoutLinkData() { |
| 731 | $title = $this->getTitle(); |
| 732 | $request = $this->getRequest(); |
| 733 | $authority = $this->getAuthority(); |
| 734 | $returnto = SkinComponentUtils::getReturnToParam( $title, $request, $authority ); |
| 735 | $isTemp = $this->isTempUser; |
| 736 | $msg = $isTemp ? 'templogout' : 'pt-userlogout'; |
| 737 | |
| 738 | return [ |
| 739 | 'single-id' => 'pt-logout', |
| 740 | 'text' => $this->msg( $msg )->text(), |
| 741 | 'data-mw-interface' => '1', |
| 742 | 'href' => SkinComponentUtils::makeSpecialUrl( 'Userlogout', $returnto ), |
| 743 | 'active' => false, |
| 744 | 'icon' => 'logOut' |
| 745 | ]; |
| 746 | } |
| 747 | |
| 748 | /** |
| 749 | * Build "Create Account" link data. |
| 750 | * @unstable |
| 751 | * |
| 752 | * @param string[] $returnto query params for the page to return to |
| 753 | * @return array |
| 754 | */ |
| 755 | protected function buildCreateAccountData( $returnto ) { |
| 756 | $title = $this->getTitle(); |
| 757 | |
| 758 | return [ |
| 759 | 'single-id' => 'pt-createaccount', |
| 760 | 'text' => $this->msg( 'pt-createaccount' )->text(), |
| 761 | 'href' => SkinComponentUtils::makeSpecialUrl( 'CreateAccount', $returnto ), |
| 762 | 'active' => $title->isSpecial( 'CreateAccount' ), |
| 763 | 'icon' => 'userAvatar' |
| 764 | ]; |
| 765 | } |
| 766 | |
| 767 | /** |
| 768 | * Add the userpage link to the array |
| 769 | * |
| 770 | * @param array &$links Links array to append to |
| 771 | * @param string $idSuffix Something to add to the IDs to make them unique |
| 772 | */ |
| 773 | private function addPersonalPageItem( &$links, $idSuffix ) { |
| 774 | if ( $this->loggedin ) { |
| 775 | $links['userpage'] = $this->buildPersonalPageItem( 'pt-userpage' . $idSuffix ); |
| 776 | } |
| 777 | } |
| 778 | |
| 779 | /** |
| 780 | * Build a user page link data. |
| 781 | * |
| 782 | * @param string $id of user page item to be output in HTML attribute (optional) |
| 783 | * @return array |
| 784 | */ |
| 785 | protected function buildPersonalPageItem( $id = 'pt-userpage' ): array { |
| 786 | $linkClasses = $this->userpageUrlDetails['exists'] ? [] : [ 'new' ]; |
| 787 | $icon = $this->isTempUser ? 'userTemporary' : 'userAvatar'; |
| 788 | if ( $this->isTempUser ) { |
| 789 | $linkClasses[] = 'mw-temp-user-link'; |
| 790 | } |
| 791 | $href = &$this->userpageUrlDetails['href']; |
| 792 | return [ |
| 793 | 'id' => $id, |
| 794 | 'single-id' => 'pt-userpage', |
| 795 | 'text' => $this->username, |
| 796 | 'href' => $href, |
| 797 | 'link-class' => $linkClasses, |
| 798 | 'exists' => $this->userpageUrlDetails['exists'], |
| 799 | 'active' => ( $this->userpageUrlDetails['href'] == $this->getTitle()->getLocalURL() ), |
| 800 | 'icon' => $icon, |
| 801 | ]; |
| 802 | } |
| 803 | |
| 804 | /** |
| 805 | * Build a watchlist link data. |
| 806 | * |
| 807 | * @return array Array of data required to create a watchlist link. |
| 808 | */ |
| 809 | private function buildWatchlistData() { |
| 810 | $href = SkinComponentUtils::makeSpecialUrl( 'Watchlist' ); |
| 811 | $pageurl = $this->getTitle()->getLocalURL(); |
| 812 | |
| 813 | return [ |
| 814 | 'single-id' => 'pt-watchlist', |
| 815 | 'text' => $this->msg( 'mywatchlist' )->text(), |
| 816 | 'href' => $href, |
| 817 | 'active' => ( $href == $pageurl ), |
| 818 | 'icon' => 'watchlist' |
| 819 | ]; |
| 820 | } |
| 821 | |
| 822 | /** |
| 823 | * Builds an array with tab definition |
| 824 | * |
| 825 | * @param Title $title Page Where the tab links to |
| 826 | * @param string|string[]|MessageSpecifier $message Message or an array of message keys |
| 827 | * (will fall back) |
| 828 | * @param bool $selected Display the tab as selected |
| 829 | * @param string $query Query string attached to tab URL |
| 830 | * @param bool $checkEdit Check if $title exists and mark with .new if one doesn't |
| 831 | * |
| 832 | * @return array |
| 833 | * @param-taint $message tainted |
| 834 | */ |
| 835 | public function tabAction( $title, $message, $selected, $query = '', $checkEdit = false ) { |
| 836 | $classes = []; |
| 837 | if ( $selected ) { |
| 838 | $classes[] = 'selected'; |
| 839 | } |
| 840 | $exists = true; |
| 841 | $services = MediaWikiServices::getInstance(); |
| 842 | $linkClass = $services->getLinkRenderer()->getLinkClasses( $title ); |
| 843 | if ( $checkEdit && !$title->isKnown() ) { |
| 844 | // Selected tabs should not show as red link. It doesn't make sense |
| 845 | // to show a red link on a page the user has already navigated to. |
| 846 | // https://phabricator.wikimedia.org/T294129#7451549 |
| 847 | if ( !$selected ) { |
| 848 | // For historic reasons we add to the LI element |
| 849 | $classes[] = 'new'; |
| 850 | // but adding the class to the A element is more appropriate. |
| 851 | $linkClass .= ' new'; |
| 852 | } |
| 853 | $exists = false; |
| 854 | if ( $query !== '' ) { |
| 855 | $query = 'action=edit&redlink=1&' . $query; |
| 856 | } else { |
| 857 | $query = 'action=edit&redlink=1'; |
| 858 | } |
| 859 | } elseif ( $title->isRedirect() ) { |
| 860 | // Do not redirect on redirect pages, see T5324 |
| 861 | $origTitle = $this->getRelevantTitle(); |
| 862 | // FIXME: If T385340 is resolved, this check can be removed |
| 863 | $action = $this->getContext()->getActionName(); |
| 864 | $out = $this->getOutput(); |
| 865 | $notCurrentActionView = $action !== 'view' || !$out->isRevisionCurrent(); |
| 866 | |
| 867 | if ( $origTitle instanceof Title && $title->isSamePageAs( $origTitle ) && $notCurrentActionView ) { |
| 868 | if ( $query !== '' ) { |
| 869 | $query .= '&redirect=no'; |
| 870 | } else { |
| 871 | $query = 'redirect=no'; |
| 872 | } |
| 873 | } |
| 874 | } |
| 875 | |
| 876 | if ( $message instanceof MessageSpecifier ) { |
| 877 | $msg = new Message( $message ); |
| 878 | } else { |
| 879 | // wfMessageFallback will nicely accept $message as an array of fallbacks |
| 880 | // or just a single key |
| 881 | $msg = wfMessageFallback( $message ); |
| 882 | } |
| 883 | $msg->setContext( $this->getContext() ); |
| 884 | if ( !$msg->isDisabled() ) { |
| 885 | $text = $msg->text(); |
| 886 | } else { |
| 887 | $text = $services->getLanguageConverterFactory() |
| 888 | ->getLanguageConverter( $services->getContentLanguage() ) |
| 889 | ->convertNamespace( |
| 890 | $services->getNamespaceInfo() |
| 891 | ->getSubject( $title->getNamespace() ) |
| 892 | ); |
| 893 | } |
| 894 | |
| 895 | $result = [ |
| 896 | // Use a string instead of a class list for hook compatibility (T393504) |
| 897 | 'class' => implode( ' ', $classes ), |
| 898 | 'text' => $text, |
| 899 | 'href' => $title->getLocalURL( $query ), |
| 900 | 'exists' => $exists, |
| 901 | 'primary' => true ]; |
| 902 | if ( $linkClass !== '' ) { |
| 903 | $result['link-class'] = trim( $linkClass ); |
| 904 | } |
| 905 | |
| 906 | return $result; |
| 907 | } |
| 908 | |
| 909 | /** |
| 910 | * Get a message label that skins can override. |
| 911 | * |
| 912 | * @param string $labelMessageKey |
| 913 | * @param MessageParam|MessageSpecifier|string|int|float|null $param for the message |
| 914 | * @return string |
| 915 | */ |
| 916 | private function getSkinNavOverrideableLabel( $labelMessageKey, $param = null ) { |
| 917 | $skname = $this->skinname; |
| 918 | // The following messages can be used here: |
| 919 | // * skin-action-addsection |
| 920 | // * skin-action-delete |
| 921 | // * skin-action-move |
| 922 | // * skin-action-protect |
| 923 | // * skin-action-undelete |
| 924 | // * skin-action-unprotect |
| 925 | // * skin-action-viewdeleted |
| 926 | // * skin-action-viewsource |
| 927 | // * skin-view-create |
| 928 | // * skin-view-create-local |
| 929 | // * skin-view-edit |
| 930 | // * skin-view-edit-local |
| 931 | // * skin-view-foreign |
| 932 | // * skin-view-history |
| 933 | // * skin-view-view |
| 934 | $msg = wfMessageFallback( |
| 935 | "$skname-$labelMessageKey", |
| 936 | "skin-$labelMessageKey" |
| 937 | )->setContext( $this->getContext() ); |
| 938 | |
| 939 | if ( $param ) { |
| 940 | if ( is_numeric( $param ) ) { |
| 941 | $msg->numParams( $param ); |
| 942 | } else { |
| 943 | $msg->params( $param ); |
| 944 | } |
| 945 | } |
| 946 | return $msg->text(); |
| 947 | } |
| 948 | |
| 949 | /** |
| 950 | * @param string $name |
| 951 | * @param string|array $urlaction |
| 952 | * @return array |
| 953 | */ |
| 954 | private function makeTalkUrlDetails( $name, $urlaction = '' ) { |
| 955 | $title = Title::newFromTextThrow( $name )->getTalkPage(); |
| 956 | return [ |
| 957 | 'href' => $title->getLocalURL( $urlaction ), |
| 958 | 'exists' => $title->isKnown(), |
| 959 | ]; |
| 960 | } |
| 961 | |
| 962 | /** |
| 963 | * Get the attributes for the watch link. |
| 964 | * @param string $mode Either 'watch' or 'unwatch' |
| 965 | * @param Authority $performer |
| 966 | * @param Title $title |
| 967 | * @param string|null $action |
| 968 | * @param bool $onPage |
| 969 | * @return array |
| 970 | */ |
| 971 | private function getWatchLinkAttrs( |
| 972 | string $mode, Authority $performer, Title $title, ?string $action, bool $onPage |
| 973 | ): array { |
| 974 | $isWatchMode = $action == 'watch'; |
| 975 | $class = 'mw-watchlink ' . ( |
| 976 | $onPage && ( $isWatchMode || $action == 'unwatch' ) ? 'selected' : '' |
| 977 | ); |
| 978 | |
| 979 | $services = MediaWikiServices::getInstance(); |
| 980 | $watchlistManager = $services->getWatchlistManager(); |
| 981 | $watchIcon = $mode === 'unwatch' ? 'unStar' : 'star'; |
| 982 | $watchExpiry = null; |
| 983 | // Modify tooltip and add class identifying the page is temporarily watched, if applicable. |
| 984 | if ( $this->getConfig()->get( MainConfigNames::WatchlistExpiry ) && |
| 985 | $watchlistManager->isTempWatched( $performer, $title ) |
| 986 | ) { |
| 987 | $class .= ' mw-watchlink-temp'; |
| 988 | $watchIcon = 'halfStar'; |
| 989 | |
| 990 | $watchStore = $services->getWatchedItemStore(); |
| 991 | $watchedItem = $watchStore->getWatchedItem( $performer->getUser(), $title ); |
| 992 | $diffInDays = $watchedItem->getExpiryInDays(); |
| 993 | $watchExpiry = $watchedItem->getExpiry( TS::ISO_8601 ); |
| 994 | if ( $diffInDays ) { |
| 995 | $msgParams = [ $diffInDays ]; |
| 996 | // Resolves to tooltip-ca-unwatch-expiring message |
| 997 | $tooltip = 'ca-unwatch-expiring'; |
| 998 | } else { |
| 999 | // Resolves to tooltip-ca-unwatch-expiring-hours message |
| 1000 | $tooltip = 'ca-unwatch-expiring-hours'; |
| 1001 | } |
| 1002 | } |
| 1003 | |
| 1004 | return [ |
| 1005 | 'class' => $class, |
| 1006 | 'icon' => $watchIcon, |
| 1007 | // uses 'watch' or 'unwatch' message |
| 1008 | 'text' => $this->msg( $mode )->text(), |
| 1009 | 'single-id' => $tooltip ?? null, |
| 1010 | 'tooltip-params' => $msgParams ?? null, |
| 1011 | 'href' => $title->getLocalURL( [ 'action' => $mode ] ), |
| 1012 | // Set a data-mw-interface attribute, which the mediawiki.page.ajax |
| 1013 | // module will look for to make sure it's a trusted link |
| 1014 | 'data' => [ |
| 1015 | 'mw-interface' => '1', |
| 1016 | 'mw-expiry' => $watchExpiry, |
| 1017 | ], |
| 1018 | ]; |
| 1019 | } |
| 1020 | |
| 1021 | /** |
| 1022 | * Run hooks relating to navigation menu data. |
| 1023 | * Skins should extend this if they want to run opinionated transformations to the data after all |
| 1024 | * hooks have been run. Note hooks are run in an arbitrary order. |
| 1025 | * |
| 1026 | * @param SkinTemplate $skin |
| 1027 | * @param array &$content_navigation representing all menus. |
| 1028 | * @since 1.37 |
| 1029 | */ |
| 1030 | protected function runOnSkinTemplateNavigationHooks( SkinTemplate $skin, &$content_navigation ) { |
| 1031 | // Snapshot footer menu keys before the hook runs, so we can detect |
| 1032 | // new items added by extensions (T426358). |
| 1033 | $beforeHookFooter = [ |
| 1034 | 'footer-info' => array_keys( $content_navigation['footer-info'] ?? [] ), |
| 1035 | 'footer-places' => array_keys( $content_navigation['footer-places'] ?? [] ), |
| 1036 | 'footer-icons' => array_keys( $content_navigation['footer-icons'] ?? [] ), |
| 1037 | ]; |
| 1038 | |
| 1039 | // Equiv to SkinTemplateContentActions, run |
| 1040 | $this->getHookRunner()->onSkinTemplateNavigation__Universal( |
| 1041 | $skin, $content_navigation ); |
| 1042 | // check all items have been added with the required fields |
| 1043 | foreach ( $content_navigation as $menuName => $menuItems ) { |
| 1044 | foreach ( $menuItems as $itemName => $itemData ) { |
| 1045 | if ( !isset( $itemData['text'] ) && !isset( $itemData['html'] ) ) { |
| 1046 | throw new InvalidArgumentException( |
| 1047 | "Menu item '$itemName' in menu '$menuName' is missing required `text` or `html` key. " |
| 1048 | ); |
| 1049 | } |
| 1050 | if ( isset( $itemData['icon'] ) && !isset( $itemData['href'] ) ) { |
| 1051 | throw new InvalidArgumentException( |
| 1052 | "Menu items with 'icon' must also have href key." |
| 1053 | ); |
| 1054 | } |
| 1055 | } |
| 1056 | } |
| 1057 | |
| 1058 | // Detect new footer items added by hooks for skins that have not |
| 1059 | // opted into footer menus via getOptions()['menus'] (T426358). |
| 1060 | $supportedMenus = $this->getOptions()['menus']; |
| 1061 | $footer = $this->getComponent( 'footer' ); |
| 1062 | foreach ( [ 'footer-info', 'footer-places', 'footer-icons' ] as $navKey ) { |
| 1063 | if ( in_array( $navKey, $supportedMenus ) ) { |
| 1064 | continue; |
| 1065 | } |
| 1066 | $afterKeys = array_keys( $content_navigation[$navKey] ?? [] ); |
| 1067 | $newKeys = array_diff( $afterKeys, $beforeHookFooter[$navKey] ); |
| 1068 | if ( $newKeys ) { |
| 1069 | $section = str_replace( 'footer-', '', $navKey ); |
| 1070 | $newItems = []; |
| 1071 | foreach ( $newKeys as $key ) { |
| 1072 | $newItems[$key] = $content_navigation[$navKey][$key]; |
| 1073 | } |
| 1074 | if ( $footer instanceof SkinComponentFooter ) { |
| 1075 | $footer->addItem( $section, $newItems ); |
| 1076 | } |
| 1077 | } |
| 1078 | } |
| 1079 | } |
| 1080 | |
| 1081 | /** |
| 1082 | * a structured array of links usually used for the tabs in a skin |
| 1083 | * |
| 1084 | * There are 4 standard sections |
| 1085 | * namespaces: Used for namespace tabs like special, page, and talk namespaces |
| 1086 | * views: Used for primary page views like read, edit, history |
| 1087 | * actions: Used for most extra page actions like deletion, protection, etc... |
| 1088 | * variants: Used to list the language variants for the page |
| 1089 | * |
| 1090 | * Each section's value is a key/value array of links for that section. |
| 1091 | * The links themselves have these common keys: |
| 1092 | * - class: The css classes to apply to the tab |
| 1093 | * - text: The text to display on the tab |
| 1094 | * - href: The href for the tab to point to |
| 1095 | * - rel: An optional rel= for the tab's link |
| 1096 | * - redundant: If true the tab will be dropped in skins using content_actions |
| 1097 | * this is useful for tabs like "Read" which only have meaning in skins that |
| 1098 | * take special meaning from the grouped structure of content_navigation |
| 1099 | * |
| 1100 | * Views also have an extra key which can be used: |
| 1101 | * - primary: If this is not true skins like vector may try to hide the tab |
| 1102 | * when the user has limited space in their browser window |
| 1103 | * |
| 1104 | * content_navigation using code also expects these ids to be present on the |
| 1105 | * links, however these are usually automatically generated by SkinTemplate |
| 1106 | * itself and are not necessary when using a hook. The only things these may |
| 1107 | * matter to are people modifying content_navigation after it's initial creation: |
| 1108 | * - id: A "preferred" id, most skins are best off outputting this preferred |
| 1109 | * id for best compatibility. |
| 1110 | * - tooltiponly: This is set to true for some tabs in cases where the system |
| 1111 | * believes that the accesskey should not be added to the tab. |
| 1112 | * |
| 1113 | * @return array |
| 1114 | */ |
| 1115 | private function buildContentNavigationUrlsInternal() { |
| 1116 | if ( $this->contentNavigationCached ) { |
| 1117 | return $this->contentNavigationCached; |
| 1118 | } |
| 1119 | // Display tabs for the relevant title rather than always the title itself |
| 1120 | $title = $this->getRelevantTitle(); |
| 1121 | $onPage = $title->equals( $this->getTitle() ); |
| 1122 | |
| 1123 | $out = $this->getOutput(); |
| 1124 | $request = $this->getRequest(); |
| 1125 | $performer = $this->getAuthority(); |
| 1126 | $action = $this->getContext()->getActionName(); |
| 1127 | $services = MediaWikiServices::getInstance(); |
| 1128 | $permissionManager = $services->getPermissionManager(); |
| 1129 | $shadowPageLoader = $services->getShadowPageLoader(); |
| 1130 | $categoriesData = $this->getCategoryPortletsData( $this->getOutput()->getCategoryLinks() ); |
| 1131 | $userPageLink = []; |
| 1132 | $this->addPersonalPageItem( $userPageLink, '-2' ); |
| 1133 | |
| 1134 | $userMenu = $this->buildPersonalUrls( false ); |
| 1135 | $requestedMenus = $this->getOptions()['menus']; |
| 1136 | if ( in_array( 'user-page', $requestedMenus ) ) { |
| 1137 | unset( $userMenu['userpage' ] ); |
| 1138 | } |
| 1139 | $legacyUserMenuSupport = in_array( 'personal', $requestedMenus ); |
| 1140 | $content_navigation = $categoriesData + [ |
| 1141 | // Modern keys: Please ensure these get unset inside Skin::prepareQuickTemplate |
| 1142 | 'user-interface-preferences' => [], |
| 1143 | 'user-page' => $userPageLink, |
| 1144 | 'user-menu' => $userMenu, |
| 1145 | 'notifications' => [], |
| 1146 | 'associated-pages' => [], |
| 1147 | // Added in 1.44: a fixed position menu at bottom of page |
| 1148 | 'dock-bottom' => [], |
| 1149 | // Added in 1.47: footer menus, allowing modification via |
| 1150 | // SkinTemplateNavigation::Universal hook (T318376) |
| 1151 | 'footer-icons' => [], |
| 1152 | 'footer-info' => [], |
| 1153 | 'footer-places' => [], |
| 1154 | // Legacy keys |
| 1155 | 'views' => [], |
| 1156 | 'actions' => [], |
| 1157 | 'variants' => [], |
| 1158 | ]; |
| 1159 | |
| 1160 | $associatedPages = []; |
| 1161 | $userCanRead = $this->getAuthority()->probablyCan( 'read', $title ); |
| 1162 | |
| 1163 | // Checks if page is some kind of content |
| 1164 | if ( $title->canExist() ) { |
| 1165 | // Gets page objects for the associatedPages namespaces |
| 1166 | $subjectPage = $title->getSubjectPage(); |
| 1167 | $talkPage = $title->getTalkPage(); |
| 1168 | |
| 1169 | // Determines if this is a talk page |
| 1170 | $isTalk = $title->isTalkPage(); |
| 1171 | |
| 1172 | // Generates XML IDs from namespace names |
| 1173 | $subjectId = $title->getNamespaceKey( '' ); |
| 1174 | |
| 1175 | if ( $subjectId == 'main' ) { |
| 1176 | $talkId = 'talk'; |
| 1177 | } else { |
| 1178 | $talkId = "{$subjectId}_talk"; |
| 1179 | } |
| 1180 | |
| 1181 | // Adds namespace links |
| 1182 | if ( $subjectId === 'user' ) { |
| 1183 | $subjectMsg = $this->msg( 'nstab-user', $subjectPage->getRootText() ); |
| 1184 | } else { |
| 1185 | // The following messages are used here: |
| 1186 | // * nstab-main |
| 1187 | // * nstab-media |
| 1188 | // * nstab-special |
| 1189 | // * nstab-project |
| 1190 | // * nstab-image |
| 1191 | // * nstab-mediawiki |
| 1192 | // * nstab-template |
| 1193 | // * nstab-help |
| 1194 | // * nstab-category |
| 1195 | // * nstab-<subject namespace key> |
| 1196 | $subjectMsg = [ "nstab-$subjectId" ]; |
| 1197 | |
| 1198 | if ( $subjectPage->isMainPage() ) { |
| 1199 | array_unshift( $subjectMsg, 'nstab-mainpage' ); |
| 1200 | } |
| 1201 | } |
| 1202 | |
| 1203 | $associatedPages[$subjectId] = $this->tabAction( |
| 1204 | $subjectPage, $subjectMsg, !$isTalk, '', $userCanRead |
| 1205 | ); |
| 1206 | $associatedPages[$subjectId]['context'] = 'subject'; |
| 1207 | // Use the following messages if defined or talk if not: |
| 1208 | // * nstab-talk, nstab-user_talk, nstab-media_talk, nstab-project_talk |
| 1209 | // * nstab-image_talk, nstab-mediawiki_talk, nstab-template_talk |
| 1210 | // * nstab-help_talk, nstab-category_talk, |
| 1211 | // * nstab-<subject namespace key>_talk |
| 1212 | $associatedPages[$talkId] = $this->tabAction( |
| 1213 | $talkPage, [ "nstab-$talkId", "talk" ], $isTalk, '', $userCanRead |
| 1214 | ); |
| 1215 | $associatedPages[$talkId]['context'] = 'talk'; |
| 1216 | |
| 1217 | if ( $userCanRead ) { |
| 1218 | // Adds "view" view link |
| 1219 | if ( $title->isKnown() ) { |
| 1220 | $content_navigation['views']['view'] = $this->tabAction( |
| 1221 | $isTalk ? $talkPage : $subjectPage, |
| 1222 | 'view-view', |
| 1223 | ( $onPage && ( $action == 'view' || $action == 'purge' ) ), '', true |
| 1224 | ); |
| 1225 | $content_navigation['views']['view']['text'] = $this->getSkinNavOverrideableLabel( |
| 1226 | 'view-view' |
| 1227 | ); |
| 1228 | $content_navigation['views']['view']['icon'] = 'eye'; |
| 1229 | // signal to hide this from simple content_actions |
| 1230 | $content_navigation['views']['view']['redundant'] = true; |
| 1231 | } |
| 1232 | |
| 1233 | $page = $this->canUseWikiPage() ? $this->getWikiPage() : false; |
| 1234 | $isRemoteContent = $page && !$page->isLocal(); |
| 1235 | $shadowPage = $shadowPageLoader->get( $title ); |
| 1236 | |
| 1237 | // If it is a non-local file, show a link to the file in its own repository |
| 1238 | // @todo abstract this for remote content that isn't a file |
| 1239 | if ( $isRemoteContent ) { |
| 1240 | $content_navigation['views']['view-foreign'] = [ |
| 1241 | 'text' => $this->getSkinNavOverrideableLabel( |
| 1242 | 'view-foreign', $page->getWikiDisplayName() |
| 1243 | ), |
| 1244 | 'icon' => 'eye', |
| 1245 | 'href' => $page->getSourceURL(), |
| 1246 | 'primary' => false, |
| 1247 | ]; |
| 1248 | } |
| 1249 | |
| 1250 | // Checks if user can edit the current page if it exists or create it otherwise |
| 1251 | if ( $this->getAuthority()->probablyCan( 'edit', $title ) ) { |
| 1252 | // Builds CSS class for talk page links |
| 1253 | $isTalkClass = $isTalk ? ' istalk' : ''; |
| 1254 | // Whether the user is editing the page |
| 1255 | $isEditing = $onPage && ( $action == 'edit' || $action == 'submit' ); |
| 1256 | $isRedirect = $page && $page->isRedirect(); |
| 1257 | // Whether to show the "Add a new section" tab |
| 1258 | $showNewSection = !$out->getOutputFlag( ParserOutputFlags::HIDE_NEW_SECTION ) && ( |
| 1259 | ( |
| 1260 | $isTalk && !$isRedirect && $out->isRevisionCurrent() |
| 1261 | ) || |
| 1262 | $out->getOutputFlag( ParserOutputFlags::NEW_SECTION ) |
| 1263 | ); |
| 1264 | $section = $request->getVal( 'section' ); |
| 1265 | |
| 1266 | if ( $title->exists() || $shadowPage?->existsForEdit() ) { |
| 1267 | $msgKey = $isRemoteContent ? 'edit-local' : 'edit'; |
| 1268 | } else { |
| 1269 | $msgKey = $isRemoteContent ? 'create-local' : 'create'; |
| 1270 | } |
| 1271 | $content_navigation['views']['edit'] = [ |
| 1272 | 'class' => ( $isEditing && ( $section !== 'new' || !$showNewSection ) |
| 1273 | ? 'selected' |
| 1274 | : null |
| 1275 | ) . $isTalkClass, |
| 1276 | 'icon' => 'edit', |
| 1277 | 'text' => $this->getSkinNavOverrideableLabel( |
| 1278 | "view-$msgKey" |
| 1279 | ), |
| 1280 | 'single-id' => "ca-$msgKey", |
| 1281 | 'href' => $title->getLocalURL( $this->editUrlOptions() ), |
| 1282 | 'primary' => !$isRemoteContent, // don't collapse this in vector |
| 1283 | ]; |
| 1284 | |
| 1285 | // section link |
| 1286 | if ( $showNewSection ) { |
| 1287 | // Adds new section link |
| 1288 | // $content_navigation['actions']['addsection'] |
| 1289 | $content_navigation['views']['addsection'] = [ |
| 1290 | 'class' => ( $isEditing && $section == 'new' ) ? 'selected' : null, |
| 1291 | 'text' => $this->getSkinNavOverrideableLabel( |
| 1292 | "action-addsection" |
| 1293 | ), |
| 1294 | 'icon' => 'speechBubbleAdd', |
| 1295 | 'href' => $title->getLocalURL( 'action=edit§ion=new' ) |
| 1296 | ]; |
| 1297 | } |
| 1298 | // Checks if the page has some kind of viewable source content |
| 1299 | } elseif ( $title->exists() || $shadowPage?->hasPreloadContent() ) { |
| 1300 | // Adds view source view link |
| 1301 | $content_navigation['views']['viewsource'] = [ |
| 1302 | 'class' => ( $onPage && $action == 'edit' ) ? 'selected' : null, |
| 1303 | 'text' => $this->getSkinNavOverrideableLabel( |
| 1304 | "action-viewsource" |
| 1305 | ), |
| 1306 | 'icon' => 'editLock', |
| 1307 | 'href' => $title->getLocalURL( $this->editUrlOptions() ), |
| 1308 | 'primary' => true, // don't collapse this in vector |
| 1309 | ]; |
| 1310 | } |
| 1311 | |
| 1312 | // Checks if the page exists |
| 1313 | if ( $title->exists() ) { |
| 1314 | // Adds history view link |
| 1315 | $content_navigation['views']['history'] = [ |
| 1316 | 'class' => ( $onPage && $action == 'history' ) ? 'selected' : null, |
| 1317 | 'text' => $this->getSkinNavOverrideableLabel( |
| 1318 | 'view-history' |
| 1319 | ), |
| 1320 | 'icon' => 'history', |
| 1321 | 'href' => $title->getLocalURL( 'action=history' ), |
| 1322 | ]; |
| 1323 | |
| 1324 | if ( $this->getAuthority()->probablyCan( 'delete', $title ) ) { |
| 1325 | $content_navigation['actions']['delete'] = [ |
| 1326 | 'icon' => 'trash', |
| 1327 | 'class' => ( $onPage && $action == 'delete' ) ? 'selected' : null, |
| 1328 | 'text' => $this->getSkinNavOverrideableLabel( |
| 1329 | 'action-delete' |
| 1330 | ), |
| 1331 | 'href' => $title->getLocalURL( [ |
| 1332 | 'action' => 'delete', |
| 1333 | 'oldid' => $title->getLatestRevID(), |
| 1334 | ] ) |
| 1335 | ]; |
| 1336 | } |
| 1337 | |
| 1338 | if ( $this->getAuthority()->probablyCan( 'move', $title ) ) { |
| 1339 | $moveTitle = SpecialPage::getTitleFor( 'Movepage', $title->getPrefixedDBkey() ); |
| 1340 | $content_navigation['actions']['move'] = [ |
| 1341 | 'class' => $this->getTitle()->isSpecial( 'Movepage' ) ? 'selected' : null, |
| 1342 | 'text' => $this->getSkinNavOverrideableLabel( |
| 1343 | 'action-move' |
| 1344 | ), |
| 1345 | 'icon' => 'move', |
| 1346 | 'href' => $moveTitle->getLocalURL() |
| 1347 | ]; |
| 1348 | } |
| 1349 | } else { |
| 1350 | // article doesn't exist or is deleted |
| 1351 | if ( $this->getAuthority()->probablyCan( 'deletedhistory', $title ) ) { |
| 1352 | $n = $title->getDeletedEditsCount(); |
| 1353 | if ( $n ) { |
| 1354 | $undelTitle = SpecialPage::getTitleFor( 'Undelete', $title->getPrefixedDBkey() ); |
| 1355 | // If the user can't undelete but can view deleted |
| 1356 | // history show them a "View .. deleted" tab instead. |
| 1357 | $msgKey = $this->getAuthority()->probablyCan( 'undelete', $title ) ? |
| 1358 | 'undelete' : 'viewdeleted'; |
| 1359 | $content_navigation['actions']['undelete'] = [ |
| 1360 | 'class' => $this->getTitle()->isSpecial( 'Undelete' ) ? 'selected' : null, |
| 1361 | 'text' => $this->getSkinNavOverrideableLabel( |
| 1362 | "action-$msgKey", $n |
| 1363 | ), |
| 1364 | 'icon' => 'trash', |
| 1365 | 'href' => $undelTitle->getLocalURL() |
| 1366 | ]; |
| 1367 | } |
| 1368 | } |
| 1369 | } |
| 1370 | |
| 1371 | $restrictionStore = $services->getRestrictionStore(); |
| 1372 | if ( $this->getAuthority()->probablyCan( 'protect', $title ) && |
| 1373 | $restrictionStore->listApplicableRestrictionTypes( $title ) && |
| 1374 | $permissionManager->getNamespaceRestrictionLevels( |
| 1375 | $title->getNamespace(), |
| 1376 | $performer->getUser() |
| 1377 | ) !== [ '' ] |
| 1378 | ) { |
| 1379 | $isProtected = $restrictionStore->isProtected( $title ); |
| 1380 | $mode = $isProtected ? 'unprotect' : 'protect'; |
| 1381 | $content_navigation['actions'][$mode] = [ |
| 1382 | 'class' => ( $onPage && $action == $mode ) ? 'selected' : null, |
| 1383 | 'text' => $this->getSkinNavOverrideableLabel( |
| 1384 | "action-$mode" |
| 1385 | ), |
| 1386 | 'icon' => $isProtected ? 'unLock' : 'lock', |
| 1387 | 'href' => $title->getLocalURL( "action=$mode" ) |
| 1388 | ]; |
| 1389 | } |
| 1390 | |
| 1391 | if ( $this->loggedin && $this->getAuthority() |
| 1392 | ->isAllowedAll( 'viewmywatchlist', 'editmywatchlist' ) |
| 1393 | ) { |
| 1394 | /** |
| 1395 | * The following actions use messages which, if made particular to |
| 1396 | * the any specific skins, would break the Ajax code which makes this |
| 1397 | * action happen entirely inline. OutputPage::getJSVars |
| 1398 | * defines a set of messages in a javascript object - and these |
| 1399 | * messages are assumed to be global for all skins. Without making |
| 1400 | * a change to that procedure these messages will have to remain as |
| 1401 | * the global versions. |
| 1402 | */ |
| 1403 | $mode = MediaWikiServices::getInstance()->getWatchlistManager() |
| 1404 | ->isWatched( $performer, $title ) ? 'unwatch' : 'watch'; |
| 1405 | |
| 1406 | // Add the watch/unwatch link. |
| 1407 | $content_navigation['actions'][$mode] = $this->getWatchLinkAttrs( |
| 1408 | $mode, |
| 1409 | $performer, |
| 1410 | $title, |
| 1411 | $action, |
| 1412 | $onPage |
| 1413 | ); |
| 1414 | } |
| 1415 | } |
| 1416 | |
| 1417 | // Add language variants |
| 1418 | $languageConverterFactory = MediaWikiServices::getInstance()->getLanguageConverterFactory(); |
| 1419 | |
| 1420 | if ( $userCanRead && !$languageConverterFactory->isConversionDisabled() ) { |
| 1421 | $pageLang = $title->getPageLanguage(); |
| 1422 | $converter = $languageConverterFactory |
| 1423 | ->getLanguageConverter( $pageLang ); |
| 1424 | // Checks that language conversion is enabled and variants exist |
| 1425 | // And if it is not in the special namespace |
| 1426 | if ( $converter->hasVariants() ) { |
| 1427 | // Gets list of language variants |
| 1428 | $variants = $converter->getVariants(); |
| 1429 | // Gets preferred variant (note that user preference is |
| 1430 | // only possible for wiki content language variant) |
| 1431 | $preferred = $converter->getPreferredVariant(); |
| 1432 | if ( $action === 'view' ) { |
| 1433 | $params = $request->getQueryValues(); |
| 1434 | unset( $params['title'] ); |
| 1435 | } else { |
| 1436 | $params = []; |
| 1437 | } |
| 1438 | // Loops over each variant |
| 1439 | foreach ( $variants as $code ) { |
| 1440 | // Gets variant name from language code |
| 1441 | $varname = $pageLang->getVariantname( $code ); |
| 1442 | // Appends variant link |
| 1443 | $content_navigation['variants'][] = [ |
| 1444 | 'class' => ( $code == $preferred ) ? 'selected' : null, |
| 1445 | 'text' => $varname, |
| 1446 | 'href' => $title->getLocalURL( [ 'variant' => $code ] + $params ), |
| 1447 | 'lang' => LanguageCode::bcp47( $code ), |
| 1448 | 'hreflang' => LanguageCode::bcp47( $code ), |
| 1449 | ]; |
| 1450 | } |
| 1451 | } |
| 1452 | } |
| 1453 | } else { |
| 1454 | // If it's not content, and a request URL is set it's got to be a special page |
| 1455 | try { |
| 1456 | $url = $request->getRequestURL(); |
| 1457 | } catch ( MWException ) { |
| 1458 | $url = false; |
| 1459 | } |
| 1460 | $associatedPages += $this->getSpecialPageAssociatedNavigationLinks( $title ); |
| 1461 | } |
| 1462 | |
| 1463 | $content_navigation['associated-pages'] = $associatedPages; |
| 1464 | // Populate footer menus from SkinComponentFooter so that extensions |
| 1465 | // can modify them via the Universal hook (T318376). |
| 1466 | $footer = $this->getComponent( 'footer' )->getTemplateData(); |
| 1467 | foreach ( $footer as $key => $data ) { |
| 1468 | $portletId = $data['id'] ?? null; |
| 1469 | if ( $portletId ) { |
| 1470 | $items = []; |
| 1471 | foreach ( $data['array-items'] ?? [] as $i => $item ) { |
| 1472 | $name = $item['name'] ?? $i; |
| 1473 | $items[ $name ] = $item; |
| 1474 | } |
| 1475 | $content_navigation[ $portletId ] = $items; |
| 1476 | } |
| 1477 | } |
| 1478 | $this->runOnSkinTemplateNavigationHooks( $this, $content_navigation ); |
| 1479 | |
| 1480 | // Setup xml ids and tooltip info |
| 1481 | foreach ( $content_navigation as $section => &$links ) { |
| 1482 | foreach ( $links as $key => &$link ) { |
| 1483 | // Allow links to set their own id for backwards compatibility reasons. |
| 1484 | if ( isset( $link['id'] ) || isset( $link['html' ] ) ) { |
| 1485 | continue; |
| 1486 | } |
| 1487 | $xmlID = $key; |
| 1488 | if ( isset( $link['context'] ) && $link['context'] == 'subject' ) { |
| 1489 | $xmlID = 'ca-nstab-' . $xmlID; |
| 1490 | } elseif ( isset( $link['context'] ) && $link['context'] == 'talk' ) { |
| 1491 | $xmlID = 'ca-talk'; |
| 1492 | $link['rel'] = 'discussion'; |
| 1493 | } elseif ( $section == 'variants' ) { |
| 1494 | $xmlID = 'ca-varlang-' . $xmlID; |
| 1495 | $link['class'] .= ' ca-variants-' . $link['lang']; |
| 1496 | } else { |
| 1497 | $xmlID = 'ca-' . $xmlID; |
| 1498 | } |
| 1499 | $link['id'] = $xmlID; |
| 1500 | } |
| 1501 | } |
| 1502 | |
| 1503 | # We don't want to give the watch tab an accesskey if the |
| 1504 | # page is being edited, because that conflicts with the |
| 1505 | # accesskey on the watch checkbox. We also don't want to |
| 1506 | # give the edit tab an accesskey, because that's fairly |
| 1507 | # superfluous and conflicts with an accesskey (Ctrl-E) often |
| 1508 | # used for editing in Safari. |
| 1509 | if ( in_array( $action, [ 'edit', 'submit' ] ) ) { |
| 1510 | if ( isset( $content_navigation['views']['edit'] ) ) { |
| 1511 | $content_navigation['views']['edit']['tooltiponly'] = true; |
| 1512 | } |
| 1513 | if ( isset( $content_navigation['actions']['watch'] ) ) { |
| 1514 | $content_navigation['actions']['watch']['tooltiponly'] = true; |
| 1515 | } |
| 1516 | if ( isset( $content_navigation['actions']['unwatch'] ) ) { |
| 1517 | $content_navigation['actions']['unwatch']['tooltiponly'] = true; |
| 1518 | } |
| 1519 | } |
| 1520 | |
| 1521 | $this->contentNavigationCached = $content_navigation; |
| 1522 | return $content_navigation; |
| 1523 | } |
| 1524 | |
| 1525 | /** |
| 1526 | * Return a list of pages that have been marked as related to/associated with |
| 1527 | * the special page for display. |
| 1528 | * |
| 1529 | * @param Title $title |
| 1530 | * @return array |
| 1531 | */ |
| 1532 | private function getSpecialPageAssociatedNavigationLinks( Title $title ): array { |
| 1533 | $specialAssociatedNavigationLinks = []; |
| 1534 | $specialFactory = MediaWikiServices::getInstance()->getSpecialPageFactory(); |
| 1535 | $special = $specialFactory->getPage( $title->getText() ); |
| 1536 | if ( $special === null ) { |
| 1537 | // not a valid special page |
| 1538 | return []; |
| 1539 | } |
| 1540 | $special->setContext( $this ); |
| 1541 | $associatedNavigationLinks = $special->getAssociatedNavigationLinks(); |
| 1542 | // If there are no subpages, we should not render |
| 1543 | if ( count( $associatedNavigationLinks ) === 0 ) { |
| 1544 | return []; |
| 1545 | } |
| 1546 | |
| 1547 | foreach ( $associatedNavigationLinks as $i => $relatedTitleText ) { |
| 1548 | $relatedTitle = Title::newFromText( $relatedTitleText ); |
| 1549 | $special = $specialFactory->getPage( $relatedTitle->getText() ); |
| 1550 | if ( $special === null ) { |
| 1551 | $text = $relatedTitle->getText(); |
| 1552 | } else { |
| 1553 | $text = $special->getShortDescription( $relatedTitle->getSubpageText() ); |
| 1554 | } |
| 1555 | $specialAssociatedNavigationLinks['special-specialAssociatedNavigationLinks-link-' . $i ] = [ |
| 1556 | 'text' => $text, |
| 1557 | 'href' => $relatedTitle->fixSpecialName()->getLocalURL(), |
| 1558 | 'class' => $relatedTitle->fixSpecialName()->equals( $title->fixSpecialName() ) ? 'selected' : null, |
| 1559 | ]; |
| 1560 | } |
| 1561 | return $specialAssociatedNavigationLinks; |
| 1562 | } |
| 1563 | |
| 1564 | /** |
| 1565 | * an array of edit links by default used for the tabs |
| 1566 | * @param array $content_navigation |
| 1567 | * @return array |
| 1568 | */ |
| 1569 | private function buildContentActionUrls( $content_navigation ) { |
| 1570 | // content_actions has been replaced with content_navigation for backwards |
| 1571 | // compatibility and also for skins that just want simple tabs content_actions |
| 1572 | // is now built by flattening the content_navigation arrays into one |
| 1573 | |
| 1574 | $content_actions = []; |
| 1575 | |
| 1576 | foreach ( $content_navigation as $links ) { |
| 1577 | foreach ( $links as $key => $value ) { |
| 1578 | if ( isset( $value['redundant'] ) && $value['redundant'] ) { |
| 1579 | // Redundant tabs are dropped from content_actions |
| 1580 | continue; |
| 1581 | } |
| 1582 | |
| 1583 | // content_actions used to have ids built using the "ca-$key" pattern |
| 1584 | // so the xmlID based id is much closer to the actual $key that we want |
| 1585 | // for that reason we'll just strip out the ca- if present and use |
| 1586 | // the latter potion of the "id" as the $key |
| 1587 | if ( isset( $value['id'] ) && str_starts_with( $value['id'], 'ca-' ) ) { |
| 1588 | $key = substr( $value['id'], 3 ); |
| 1589 | } |
| 1590 | |
| 1591 | if ( isset( $content_actions[$key] ) ) { |
| 1592 | wfDebug( __METHOD__ . ": Found a duplicate key for $key while flattening " . |
| 1593 | "content_navigation into content_actions." ); |
| 1594 | continue; |
| 1595 | } |
| 1596 | |
| 1597 | $content_actions[$key] = $value; |
| 1598 | } |
| 1599 | } |
| 1600 | |
| 1601 | return $content_actions; |
| 1602 | } |
| 1603 | |
| 1604 | /** |
| 1605 | * Insert legacy menu items from content navigation into the personal toolbar. |
| 1606 | * |
| 1607 | * @internal |
| 1608 | * |
| 1609 | * @deprecated 1.46 Update your skin options to request the menu "notification" and "user-menu" instead. |
| 1610 | * @param array $contentNavigation |
| 1611 | * @return array |
| 1612 | */ |
| 1613 | final protected function injectLegacyMenusIntoPersonalTools( |
| 1614 | array $contentNavigation |
| 1615 | ): array { |
| 1616 | wfDeprecated( __METHOD__, '1.46', 'Please make sure Skin option menus contains `user-menu` ' |
| 1617 | . '(and possibly `notifications`, `user-interface-preferences`, `user-page`)' ); |
| 1618 | $userMenu = $contentNavigation['user-menu'] ?? []; |
| 1619 | if ( isset( $contentNavigation['user-page']['userpage'] ) ) { |
| 1620 | $userMenu = [ |
| 1621 | 'userpage' => $contentNavigation['user-page']['userpage'], |
| 1622 | ] + $userMenu; |
| 1623 | } |
| 1624 | |
| 1625 | // userpage is only defined for logged-in users, and ArrayUtils::insertAfter requires the |
| 1626 | // $after parameter to be a known key in the array. |
| 1627 | if ( isset( $userMenu['userpage'] ) && isset( $contentNavigation['notifications'] ) ) { |
| 1628 | $userMenu = ArrayUtils::insertAfter( |
| 1629 | $userMenu, |
| 1630 | $contentNavigation['notifications'], |
| 1631 | 'userpage' |
| 1632 | ); |
| 1633 | } |
| 1634 | if ( isset( $contentNavigation['user-interface-preferences'] ) ) { |
| 1635 | return array_merge( |
| 1636 | $contentNavigation['user-interface-preferences'], |
| 1637 | $userMenu |
| 1638 | ); |
| 1639 | } |
| 1640 | return $userMenu; |
| 1641 | } |
| 1642 | |
| 1643 | /** |
| 1644 | * Build the personal urls array. |
| 1645 | * |
| 1646 | * @internal |
| 1647 | * |
| 1648 | * @param array $contentNavigation |
| 1649 | * @return array |
| 1650 | */ |
| 1651 | private function makeSkinTemplatePersonalUrls( |
| 1652 | array $contentNavigation |
| 1653 | ): array { |
| 1654 | if ( isset( $contentNavigation['user-menu'] ) ) { |
| 1655 | return $this->injectLegacyMenusIntoPersonalTools( $contentNavigation ); |
| 1656 | } |
| 1657 | return []; |
| 1658 | } |
| 1659 | |
| 1660 | /** |
| 1661 | * @since 1.35 |
| 1662 | * @param array $attrs (optional) will be passed to tooltipAndAccesskeyAttribs |
| 1663 | * and decorate the resulting input |
| 1664 | * @return string of HTML input |
| 1665 | */ |
| 1666 | public function makeSearchInput( $attrs = [] ) { |
| 1667 | // It's possible that getTemplateData might be calling |
| 1668 | // Skin::makeSearchInput. To avoid infinite recursion create a |
| 1669 | // new instance of the search component here. |
| 1670 | $searchBox = $this->getComponent( 'search-box' ); |
| 1671 | $data = $searchBox->getTemplateData(); |
| 1672 | |
| 1673 | return Html::element( 'input', |
| 1674 | $data[ 'array-input-attributes' ] + $attrs |
| 1675 | ); |
| 1676 | } |
| 1677 | |
| 1678 | /** |
| 1679 | * @since 1.35 |
| 1680 | * @param string $mode representing the type of button wanted |
| 1681 | * either `go`, `fulltext` or `image` |
| 1682 | * @param array $attrs (optional) |
| 1683 | * @return string of HTML button |
| 1684 | */ |
| 1685 | final public function makeSearchButton( $mode, $attrs = [] ) { |
| 1686 | // It's possible that getTemplateData might be calling |
| 1687 | // Skin::makeSearchInput. To avoid infinite recursion create a |
| 1688 | // new instance of the search component here. |
| 1689 | $searchBox = $this->getComponent( 'search-box' ); |
| 1690 | $searchData = $searchBox->getTemplateData(); |
| 1691 | |
| 1692 | switch ( $mode ) { |
| 1693 | case 'go': |
| 1694 | $attrs['value'] ??= $this->msg( 'searcharticle' )->text(); |
| 1695 | return Html::element( |
| 1696 | 'input', |
| 1697 | array_merge( |
| 1698 | $searchData[ 'array-button-go-attributes' ], $attrs |
| 1699 | ) |
| 1700 | ); |
| 1701 | case 'fulltext': |
| 1702 | $attrs['value'] ??= $this->msg( 'searchbutton' )->text(); |
| 1703 | return Html::element( |
| 1704 | 'input', |
| 1705 | array_merge( |
| 1706 | $searchData[ 'array-button-fulltext-attributes' ], $attrs |
| 1707 | ) |
| 1708 | ); |
| 1709 | case 'image': |
| 1710 | $buttonAttrs = [ |
| 1711 | 'type' => 'submit', |
| 1712 | 'name' => 'button', |
| 1713 | ]; |
| 1714 | $buttonAttrs = array_merge( |
| 1715 | $buttonAttrs, |
| 1716 | Linker::tooltipAndAccesskeyAttribs( 'search-fulltext' ), |
| 1717 | $attrs |
| 1718 | ); |
| 1719 | unset( $buttonAttrs['src'] ); |
| 1720 | unset( $buttonAttrs['alt'] ); |
| 1721 | unset( $buttonAttrs['width'] ); |
| 1722 | unset( $buttonAttrs['height'] ); |
| 1723 | $imgAttrs = [ |
| 1724 | 'src' => $attrs['src'], |
| 1725 | 'alt' => $attrs['alt'] ?? $this->msg( 'searchbutton' )->text(), |
| 1726 | 'width' => $attrs['width'] ?? null, |
| 1727 | 'height' => $attrs['height'] ?? null, |
| 1728 | ]; |
| 1729 | return Html::rawElement( 'button', $buttonAttrs, Html::element( 'img', $imgAttrs ) ); |
| 1730 | default: |
| 1731 | throw new InvalidArgumentException( 'Unknown mode passed to ' . __METHOD__ ); |
| 1732 | } |
| 1733 | } |
| 1734 | |
| 1735 | private function isSpecialContributeShowable(): bool { |
| 1736 | return ContributeFactory::isEnabledOnCurrentSkin( |
| 1737 | $this, |
| 1738 | $this->getConfig()->get( MainConfigNames::SpecialContributeSkinsEnabled ) |
| 1739 | ); |
| 1740 | } |
| 1741 | |
| 1742 | /** |
| 1743 | * @param array &$personal_urls |
| 1744 | * @param string $key |
| 1745 | * @param string|null $userName |
| 1746 | * @param bool $active |
| 1747 | * |
| 1748 | * @return array |
| 1749 | */ |
| 1750 | private function makeContributionsLink( |
| 1751 | array &$personal_urls, |
| 1752 | string $key, |
| 1753 | ?string $userName = null, |
| 1754 | bool $active = false |
| 1755 | ): array { |
| 1756 | $isSpecialContributeShowable = $this->isSpecialContributeShowable(); |
| 1757 | $subpage = $userName ?? false; |
| 1758 | $user = $this->getUser(); |
| 1759 | // If the "Contribute" page is showable and the user is anon. or has no edit count, |
| 1760 | // direct them to the "Contribute" page instead of the "Contributions" or "Mycontributions" pages. |
| 1761 | // Explanation: |
| 1762 | // a. For logged-in users: In wikis where the "Contribute" page is enabled, we only want |
| 1763 | // to navigate logged-in users to the "Contribute", when they have done no edits. Otherwise, we |
| 1764 | // want to navigate them to the "Mycontributions" page to easily access their edits/contributions. |
| 1765 | // Currently, the "Contribute" page is used as target for all logged-in users. |
| 1766 | // b. For anon. users: In wikis where the "Contribute" page is enabled, we still navigate the |
| 1767 | // anonymous users to the "Contribute" page. |
| 1768 | // Task: T369041 |
| 1769 | if ( $isSpecialContributeShowable && (int)$user->getEditCount() === 0 ) { |
| 1770 | $href = SkinComponentUtils::makeSpecialUrlSubpage( |
| 1771 | 'Contribute', |
| 1772 | false |
| 1773 | ); |
| 1774 | $personal_urls['contribute'] = [ |
| 1775 | 'text' => $this->msg( 'contribute' )->text(), |
| 1776 | 'href' => $href, |
| 1777 | 'active' => $href == $this->getTitle()->getLocalURL(), |
| 1778 | 'icon' => 'edit' |
| 1779 | ]; |
| 1780 | } else { |
| 1781 | $href = SkinComponentUtils::makeSpecialUrlSubpage( |
| 1782 | $subpage !== false ? 'Contributions' : 'Mycontributions', |
| 1783 | $subpage |
| 1784 | ); |
| 1785 | $personal_urls[$key] = [ |
| 1786 | 'text' => $this->msg( $key )->text(), |
| 1787 | 'href' => $href, |
| 1788 | 'active' => $active, |
| 1789 | 'icon' => 'userContributions' |
| 1790 | ]; |
| 1791 | } |
| 1792 | return $personal_urls; |
| 1793 | } |
| 1794 | |
| 1795 | } |
| 1796 | |
| 1797 | /** @deprecated class alias since 1.44 */ |
| 1798 | class_alias( SkinTemplate::class, 'SkinTemplate' ); |