Whitelist existing violations, but enable the sniff to prevent
any new occurrences.
-->
- <exclude-pattern>*/includes/specials/SpecialActiveusers\.php</exclude-pattern>
- <exclude-pattern>*/includes/specials/SpecialBooksources\.php</exclude-pattern>
- <exclude-pattern>*/includes/specials/SpecialEmailuser\.php</exclude-pattern>
- <exclude-pattern>*/includes/specials/SpecialListfiles\.php</exclude-pattern>
- <exclude-pattern>*/includes/specials/SpecialListgrants\.php</exclude-pattern>
- <exclude-pattern>*/includes/specials/SpecialListgrouprights\.php</exclude-pattern>
- <exclude-pattern>*/includes/specials/SpecialListusers.php</exclude-pattern>
- <exclude-pattern>*/includes/specials/SpecialRecentchanges\.php</exclude-pattern>
- <exclude-pattern>*/includes/specials/SpecialRecentchangeslinked\.php</exclude-pattern>
- <exclude-pattern>*/includes/specials/SpecialRevisiondelete\.php</exclude-pattern>
- <exclude-pattern>*/includes/specials/SpecialWhatlinkshere\.php</exclude-pattern>
<exclude-pattern>*/maintenance/language/alltrans\.php</exclude-pattern>
<exclude-pattern>*/maintenance/language/digit2html\.php</exclude-pattern>
<exclude-pattern>*/maintenance/language/langmemusage\.php</exclude-pattern>
'SkinTemplate' => __DIR__ . '/includes/skins/SkinTemplate.php',
'SlideshowImageGallery' => __DIR__ . '/includes/gallery/SlideshowImageGallery.php',
'SlotDiffRenderer' => __DIR__ . '/includes/diff/SlotDiffRenderer.php',
- 'SpecialActiveUsers' => __DIR__ . '/includes/specials/SpecialActiveusers.php',
+ 'SpecialActiveUsers' => __DIR__ . '/includes/specials/SpecialActiveUsers.php',
'SpecialAllMessages' => __DIR__ . '/includes/specials/SpecialAllMessages.php',
'SpecialAllMyUploads' => __DIR__ . '/includes/specials/redirects/SpecialAllMyUploads.php',
'SpecialAllPages' => __DIR__ . '/includes/specials/SpecialAllPages.php',
'SpecialBlankpage' => __DIR__ . '/includes/specials/SpecialBlankpage.php',
'SpecialBlock' => __DIR__ . '/includes/specials/SpecialBlock.php',
'SpecialBlockList' => __DIR__ . '/includes/specials/SpecialBlockList.php',
- 'SpecialBookSources' => __DIR__ . '/includes/specials/SpecialBooksources.php',
+ 'SpecialBookSources' => __DIR__ . '/includes/specials/SpecialBookSources.php',
'SpecialBotPasswords' => __DIR__ . '/includes/specials/SpecialBotPasswords.php',
'SpecialCachedPage' => __DIR__ . '/includes/specials/SpecialCachedPage.php',
'SpecialCategories' => __DIR__ . '/includes/specials/SpecialCategories.php',
'SpecialDiff' => __DIR__ . '/includes/specials/SpecialDiff.php',
'SpecialEditTags' => __DIR__ . '/includes/specials/SpecialEditTags.php',
'SpecialEditWatchlist' => __DIR__ . '/includes/specials/SpecialEditWatchlist.php',
- 'SpecialEmailUser' => __DIR__ . '/includes/specials/SpecialEmailuser.php',
+ 'SpecialEmailUser' => __DIR__ . '/includes/specials/SpecialEmailUser.php',
'SpecialExpandTemplates' => __DIR__ . '/includes/specials/SpecialExpandTemplates.php',
'SpecialExport' => __DIR__ . '/includes/specials/SpecialExport.php',
'SpecialFilepath' => __DIR__ . '/includes/specials/SpecialFilepath.php',
'SpecialLinkAccounts' => __DIR__ . '/includes/specials/SpecialLinkAccounts.php',
'SpecialListAdmins' => __DIR__ . '/includes/specials/redirects/SpecialListAdmins.php',
'SpecialListBots' => __DIR__ . '/includes/specials/redirects/SpecialListBots.php',
- 'SpecialListFiles' => __DIR__ . '/includes/specials/SpecialListfiles.php',
- 'SpecialListGrants' => __DIR__ . '/includes/specials/SpecialListgrants.php',
- 'SpecialListGroupRights' => __DIR__ . '/includes/specials/SpecialListgrouprights.php',
- 'SpecialListUsers' => __DIR__ . '/includes/specials/SpecialListusers.php',
+ 'SpecialListFiles' => __DIR__ . '/includes/specials/SpecialListFiles.php',
+ 'SpecialListGrants' => __DIR__ . '/includes/specials/SpecialListGrants.php',
+ 'SpecialListGroupRights' => __DIR__ . '/includes/specials/SpecialListGroupRights.php',
+ 'SpecialListUsers' => __DIR__ . '/includes/specials/SpecialListUsers.php',
'SpecialLockdb' => __DIR__ . '/includes/specials/SpecialLockdb.php',
'SpecialLog' => __DIR__ . '/includes/specials/SpecialLog.php',
'SpecialMergeHistory' => __DIR__ . '/includes/specials/SpecialMergeHistory.php',
'SpecialRandomInCategory' => __DIR__ . '/includes/specials/SpecialRandomInCategory.php',
'SpecialRandomredirect' => __DIR__ . '/includes/specials/SpecialRandomredirect.php',
'SpecialRandomrootpage' => __DIR__ . '/includes/specials/SpecialRandomrootpage.php',
- 'SpecialRecentChanges' => __DIR__ . '/includes/specials/SpecialRecentchanges.php',
- 'SpecialRecentChangesLinked' => __DIR__ . '/includes/specials/SpecialRecentchangeslinked.php',
+ 'SpecialRecentChanges' => __DIR__ . '/includes/specials/SpecialRecentChanges.php',
+ 'SpecialRecentChangesLinked' => __DIR__ . '/includes/specials/SpecialRecentChangesLinked.php',
'SpecialRedirect' => __DIR__ . '/includes/specials/SpecialRedirect.php',
'SpecialRedirectToSpecial' => __DIR__ . '/includes/specialpage/SpecialRedirectToSpecial.php',
'SpecialRemoveCredentials' => __DIR__ . '/includes/specials/SpecialRemoveCredentials.php',
'SpecialResetTokens' => __DIR__ . '/includes/specials/SpecialResetTokens.php',
- 'SpecialRevisionDelete' => __DIR__ . '/includes/specials/SpecialRevisiondelete.php',
+ 'SpecialRevisionDelete' => __DIR__ . '/includes/specials/SpecialRevisionDelete.php',
'SpecialRunJobs' => __DIR__ . '/includes/specials/SpecialRunJobs.php',
'SpecialSearch' => __DIR__ . '/includes/specials/SpecialSearch.php',
'SpecialSpecialpages' => __DIR__ . '/includes/specials/SpecialSpecialpages.php',
'SpecialUserLogout' => __DIR__ . '/includes/specials/SpecialUserLogout.php',
'SpecialVersion' => __DIR__ . '/includes/specials/SpecialVersion.php',
'SpecialWatchlist' => __DIR__ . '/includes/specials/SpecialWatchlist.php',
- 'SpecialWhatLinksHere' => __DIR__ . '/includes/specials/SpecialWhatlinkshere.php',
+ 'SpecialWhatLinksHere' => __DIR__ . '/includes/specials/SpecialWhatLinksHere.php',
'SqlBagOStuff' => __DIR__ . '/includes/objectcache/SqlBagOStuff.php',
'SqlSearchResultSet' => __DIR__ . '/includes/search/SqlSearchResultSet.php',
'Sqlite' => __DIR__ . '/maintenance/sqlite.inc',
--- /dev/null
+<?php
+/**
+ * Implements Special:Activeusers
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+
+/**
+ * Implements Special:Activeusers
+ *
+ * @ingroup SpecialPage
+ */
+class SpecialActiveUsers extends SpecialPage {
+
+ public function __construct() {
+ parent::__construct( 'Activeusers' );
+ }
+
+ /**
+ * @param string|null $par Parameter passed to the page or null
+ */
+ public function execute( $par ) {
+ $out = $this->getOutput();
+
+ $this->setHeaders();
+ $this->outputHeader();
+
+ $opts = new FormOptions();
+
+ $opts->add( 'username', '' );
+ $opts->add( 'groups', [] );
+ $opts->add( 'excludegroups', [] );
+ // Backwards-compatibility with old URLs
+ $opts->add( 'hidebots', false, FormOptions::BOOL );
+ $opts->add( 'hidesysops', false, FormOptions::BOOL );
+
+ $opts->fetchValuesFromRequest( $this->getRequest() );
+
+ if ( $par !== null ) {
+ $opts->setValue( 'username', $par );
+ }
+
+ $pager = new ActiveUsersPager( $this->getContext(), $opts );
+ $usersBody = $pager->getBody();
+
+ $this->buildForm();
+
+ if ( $usersBody ) {
+ $out->addHTML(
+ $pager->getNavigationBar() .
+ Html::rawElement( 'ul', [], $usersBody ) .
+ $pager->getNavigationBar()
+ );
+ } else {
+ $out->addWikiMsg( 'activeusers-noresult' );
+ }
+ }
+
+ /**
+ * Generate and output the form
+ */
+ protected function buildForm() {
+ $groups = User::getAllGroups();
+
+ $options = [];
+ foreach ( $groups as $group ) {
+ $msg = htmlspecialchars( UserGroupMembership::getGroupName( $group ) );
+ $options[$msg] = $group;
+ }
+ asort( $options );
+
+ // Backwards-compatibility with old URLs
+ $req = $this->getRequest();
+ $excludeDefault = [];
+ if ( $req->getCheck( 'hidebots' ) ) {
+ $excludeDefault[] = 'bot';
+ }
+ if ( $req->getCheck( 'hidesysops' ) ) {
+ $excludeDefault[] = 'sysop';
+ }
+
+ $formDescriptor = [
+ 'username' => [
+ 'type' => 'user',
+ 'name' => 'username',
+ 'label-message' => 'activeusers-from',
+ ],
+ 'groups' => [
+ 'type' => 'multiselect',
+ 'dropdown' => true,
+ 'flatlist' => true,
+ 'name' => 'groups',
+ 'label-message' => 'activeusers-groups',
+ 'options' => $options,
+ ],
+ 'excludegroups' => [
+ 'type' => 'multiselect',
+ 'dropdown' => true,
+ 'flatlist' => true,
+ 'name' => 'excludegroups',
+ 'label-message' => 'activeusers-excludegroups',
+ 'options' => $options,
+ 'default' => $excludeDefault,
+ ],
+ ];
+
+ HTMLForm::factory( 'ooui', $formDescriptor, $this->getContext() )
+ // For the 'multiselect' field values to be preserved on submit
+ ->setFormIdentifier( 'specialactiveusers' )
+ ->setIntro( $this->getIntroText() )
+ ->setWrapperLegendMsg( 'activeusers' )
+ ->setSubmitTextMsg( 'activeusers-submit' )
+ // prevent setting subpage and 'username' parameter at the same time
+ ->setAction( $this->getPageTitle()->getLocalURL() )
+ ->setMethod( 'get' )
+ ->prepareForm()
+ ->displayForm( false );
+ }
+
+ /**
+ * Return introductory message.
+ * @return string
+ */
+ protected function getIntroText() {
+ $days = $this->getConfig()->get( 'ActiveUserDays' );
+
+ $intro = $this->msg( 'activeusers-intro' )->numParams( $days )->parse();
+
+ // Mention the level of cache staleness...
+ $dbr = wfGetDB( DB_REPLICA, 'recentchanges' );
+ $rcMax = $dbr->selectField( 'recentchanges', 'MAX(rc_timestamp)', '', __METHOD__ );
+ if ( $rcMax ) {
+ $cTime = $dbr->selectField( 'querycache_info',
+ 'qci_timestamp',
+ [ 'qci_type' => 'activeusers' ],
+ __METHOD__
+ );
+ if ( $cTime ) {
+ $secondsOld = wfTimestamp( TS_UNIX, $rcMax ) - wfTimestamp( TS_UNIX, $cTime );
+ } else {
+ $rcMin = $dbr->selectField( 'recentchanges', 'MIN(rc_timestamp)' );
+ $secondsOld = time() - wfTimestamp( TS_UNIX, $rcMin );
+ }
+ if ( $secondsOld > 0 ) {
+ $intro .= $this->msg( 'cachedspecial-viewing-cached-ttl' )
+ ->durationParams( $secondsOld )->parseAsBlock();
+ }
+ }
+
+ return $intro;
+ }
+
+ protected function getGroupName() {
+ return 'users';
+ }
+}
+++ /dev/null
-<?php
-/**
- * Implements Special:Activeusers
- *
- * This program is free software; you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation; either version 2 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License along
- * with this program; if not, write to the Free Software Foundation, Inc.,
- * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
- * http://www.gnu.org/copyleft/gpl.html
- *
- * @file
- * @ingroup SpecialPage
- */
-
-/**
- * Implements Special:Activeusers
- *
- * @ingroup SpecialPage
- */
-class SpecialActiveUsers extends SpecialPage {
-
- public function __construct() {
- parent::__construct( 'Activeusers' );
- }
-
- /**
- * @param string|null $par Parameter passed to the page or null
- */
- public function execute( $par ) {
- $out = $this->getOutput();
-
- $this->setHeaders();
- $this->outputHeader();
-
- $opts = new FormOptions();
-
- $opts->add( 'username', '' );
- $opts->add( 'groups', [] );
- $opts->add( 'excludegroups', [] );
- // Backwards-compatibility with old URLs
- $opts->add( 'hidebots', false, FormOptions::BOOL );
- $opts->add( 'hidesysops', false, FormOptions::BOOL );
-
- $opts->fetchValuesFromRequest( $this->getRequest() );
-
- if ( $par !== null ) {
- $opts->setValue( 'username', $par );
- }
-
- $pager = new ActiveUsersPager( $this->getContext(), $opts );
- $usersBody = $pager->getBody();
-
- $this->buildForm();
-
- if ( $usersBody ) {
- $out->addHTML(
- $pager->getNavigationBar() .
- Html::rawElement( 'ul', [], $usersBody ) .
- $pager->getNavigationBar()
- );
- } else {
- $out->addWikiMsg( 'activeusers-noresult' );
- }
- }
-
- /**
- * Generate and output the form
- */
- protected function buildForm() {
- $groups = User::getAllGroups();
-
- $options = [];
- foreach ( $groups as $group ) {
- $msg = htmlspecialchars( UserGroupMembership::getGroupName( $group ) );
- $options[$msg] = $group;
- }
- asort( $options );
-
- // Backwards-compatibility with old URLs
- $req = $this->getRequest();
- $excludeDefault = [];
- if ( $req->getCheck( 'hidebots' ) ) {
- $excludeDefault[] = 'bot';
- }
- if ( $req->getCheck( 'hidesysops' ) ) {
- $excludeDefault[] = 'sysop';
- }
-
- $formDescriptor = [
- 'username' => [
- 'type' => 'user',
- 'name' => 'username',
- 'label-message' => 'activeusers-from',
- ],
- 'groups' => [
- 'type' => 'multiselect',
- 'dropdown' => true,
- 'flatlist' => true,
- 'name' => 'groups',
- 'label-message' => 'activeusers-groups',
- 'options' => $options,
- ],
- 'excludegroups' => [
- 'type' => 'multiselect',
- 'dropdown' => true,
- 'flatlist' => true,
- 'name' => 'excludegroups',
- 'label-message' => 'activeusers-excludegroups',
- 'options' => $options,
- 'default' => $excludeDefault,
- ],
- ];
-
- HTMLForm::factory( 'ooui', $formDescriptor, $this->getContext() )
- // For the 'multiselect' field values to be preserved on submit
- ->setFormIdentifier( 'specialactiveusers' )
- ->setIntro( $this->getIntroText() )
- ->setWrapperLegendMsg( 'activeusers' )
- ->setSubmitTextMsg( 'activeusers-submit' )
- // prevent setting subpage and 'username' parameter at the same time
- ->setAction( $this->getPageTitle()->getLocalURL() )
- ->setMethod( 'get' )
- ->prepareForm()
- ->displayForm( false );
- }
-
- /**
- * Return introductory message.
- * @return string
- */
- protected function getIntroText() {
- $days = $this->getConfig()->get( 'ActiveUserDays' );
-
- $intro = $this->msg( 'activeusers-intro' )->numParams( $days )->parse();
-
- // Mention the level of cache staleness...
- $dbr = wfGetDB( DB_REPLICA, 'recentchanges' );
- $rcMax = $dbr->selectField( 'recentchanges', 'MAX(rc_timestamp)', '', __METHOD__ );
- if ( $rcMax ) {
- $cTime = $dbr->selectField( 'querycache_info',
- 'qci_timestamp',
- [ 'qci_type' => 'activeusers' ],
- __METHOD__
- );
- if ( $cTime ) {
- $secondsOld = wfTimestamp( TS_UNIX, $rcMax ) - wfTimestamp( TS_UNIX, $cTime );
- } else {
- $rcMin = $dbr->selectField( 'recentchanges', 'MIN(rc_timestamp)' );
- $secondsOld = time() - wfTimestamp( TS_UNIX, $rcMin );
- }
- if ( $secondsOld > 0 ) {
- $intro .= $this->msg( 'cachedspecial-viewing-cached-ttl' )
- ->durationParams( $secondsOld )->parseAsBlock();
- }
- }
-
- return $intro;
- }
-
- protected function getGroupName() {
- return 'users';
- }
-}
--- /dev/null
+<?php
+/**
+ * Implements Special:Booksources
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+
+use MediaWiki\MediaWikiServices;
+
+/**
+ * Special page outputs information on sourcing a book with a particular ISBN
+ * The parser creates links to this page when dealing with ISBNs in wikitext
+ *
+ * @author Rob Church <robchur@gmail.com>
+ * @ingroup SpecialPage
+ */
+class SpecialBookSources extends SpecialPage {
+ public function __construct() {
+ parent::__construct( 'Booksources' );
+ }
+
+ /**
+ * @param string|null $isbn ISBN passed as a subpage parameter
+ */
+ public function execute( $isbn ) {
+ $out = $this->getOutput();
+
+ $this->setHeaders();
+ $this->outputHeader();
+
+ // User provided ISBN
+ $isbn = $isbn ?: $this->getRequest()->getText( 'isbn' );
+ $isbn = trim( $isbn );
+
+ $this->buildForm( $isbn );
+
+ if ( $isbn !== '' ) {
+ if ( !self::isValidISBN( $isbn ) ) {
+ $out->wrapWikiMsg(
+ "<div class=\"error\">\n$1\n</div>",
+ 'booksources-invalid-isbn'
+ );
+ }
+
+ $this->showList( $isbn );
+ }
+ }
+
+ /**
+ * Return whether a given ISBN (10 or 13) is valid.
+ *
+ * @param string $isbn ISBN passed for check
+ * @return bool
+ */
+ public static function isValidISBN( $isbn ) {
+ $isbn = self::cleanIsbn( $isbn );
+ $sum = 0;
+ if ( strlen( $isbn ) == 13 ) {
+ for ( $i = 0; $i < 12; $i++ ) {
+ if ( $isbn[$i] === 'X' ) {
+ return false;
+ } elseif ( $i % 2 == 0 ) {
+ $sum += $isbn[$i];
+ } else {
+ $sum += 3 * $isbn[$i];
+ }
+ }
+
+ $check = ( 10 - ( $sum % 10 ) ) % 10;
+ if ( (string)$check === $isbn[12] ) {
+ return true;
+ }
+ } elseif ( strlen( $isbn ) == 10 ) {
+ for ( $i = 0; $i < 9; $i++ ) {
+ if ( $isbn[$i] === 'X' ) {
+ return false;
+ }
+ $sum += $isbn[$i] * ( $i + 1 );
+ }
+
+ $check = $sum % 11;
+ if ( $check == 10 ) {
+ $check = "X";
+ }
+ if ( (string)$check === $isbn[9] ) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Trim ISBN and remove characters which aren't required
+ *
+ * @param string $isbn Unclean ISBN
+ * @return string
+ */
+ private static function cleanIsbn( $isbn ) {
+ return trim( preg_replace( '![^0-9X]!', '', $isbn ) );
+ }
+
+ /**
+ * Generate a form to allow users to enter an ISBN
+ *
+ * @param string $isbn
+ */
+ private function buildForm( $isbn ) {
+ $formDescriptor = [
+ 'isbn' => [
+ 'type' => 'text',
+ 'name' => 'isbn',
+ 'label-message' => 'booksources-isbn',
+ 'default' => $isbn,
+ 'autofocus' => true,
+ 'required' => true,
+ ],
+ ];
+
+ $context = new DerivativeContext( $this->getContext() );
+ $context->setTitle( $this->getPageTitle() );
+ HTMLForm::factory( 'ooui', $formDescriptor, $context )
+ ->setWrapperLegendMsg( 'booksources-search-legend' )
+ ->setSubmitTextMsg( 'booksources-search' )
+ ->setMethod( 'get' )
+ ->prepareForm()
+ ->displayForm( false );
+ }
+
+ /**
+ * Determine where to get the list of book sources from,
+ * format and output them
+ *
+ * @param string $isbn
+ * @throws MWException
+ * @return bool
+ */
+ private function showList( $isbn ) {
+ $out = $this->getOutput();
+
+ $isbn = self::cleanIsbn( $isbn );
+ # Hook to allow extensions to insert additional HTML,
+ # e.g. for API-interacting plugins and so on
+ Hooks::run( 'BookInformation', [ $isbn, $out ] );
+
+ # Check for a local page such as Project:Book_sources and use that if available
+ $page = $this->msg( 'booksources' )->inContentLanguage()->text();
+ $title = Title::makeTitleSafe( NS_PROJECT, $page ); # Show list in content language
+ if ( is_object( $title ) && $title->exists() ) {
+ $rev = Revision::newFromTitle( $title, false, Revision::READ_NORMAL );
+ $content = $rev->getContent();
+
+ if ( $content instanceof TextContent ) {
+ // XXX: in the future, this could be stored as structured data, defining a list of book sources
+
+ $text = $content->getText();
+ $out->addWikiTextAsInterface( str_replace( 'MAGICNUMBER', $isbn, $text ) );
+
+ return true;
+ } else {
+ throw new MWException( "Unexpected content type for book sources: " . $content->getModel() );
+ }
+ }
+
+ # Fall back to the defaults given in the language file
+ $out->addWikiMsg( 'booksources-text' );
+ $out->addHTML( '<ul>' );
+ $items = MediaWikiServices::getInstance()->getContentLanguage()->getBookstoreList();
+ foreach ( $items as $label => $url ) {
+ $out->addHTML( $this->makeListItem( $isbn, $label, $url ) );
+ }
+ $out->addHTML( '</ul>' );
+
+ return true;
+ }
+
+ /**
+ * Format a book source list item
+ *
+ * @param string $isbn
+ * @param string $label Book source label
+ * @param string $url Book source URL
+ * @return string
+ */
+ private function makeListItem( $isbn, $label, $url ) {
+ $url = str_replace( '$1', $isbn, $url );
+
+ return Html::rawElement( 'li', [],
+ Html::element( 'a', [ 'href' => $url, 'class' => 'external' ], $label )
+ );
+ }
+
+ protected function getGroupName() {
+ return 'wiki';
+ }
+}
+++ /dev/null
-<?php
-/**
- * Implements Special:Booksources
- *
- * This program is free software; you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation; either version 2 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License along
- * with this program; if not, write to the Free Software Foundation, Inc.,
- * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
- * http://www.gnu.org/copyleft/gpl.html
- *
- * @file
- * @ingroup SpecialPage
- */
-
-use MediaWiki\MediaWikiServices;
-
-/**
- * Special page outputs information on sourcing a book with a particular ISBN
- * The parser creates links to this page when dealing with ISBNs in wikitext
- *
- * @author Rob Church <robchur@gmail.com>
- * @ingroup SpecialPage
- */
-class SpecialBookSources extends SpecialPage {
- public function __construct() {
- parent::__construct( 'Booksources' );
- }
-
- /**
- * @param string|null $isbn ISBN passed as a subpage parameter
- */
- public function execute( $isbn ) {
- $out = $this->getOutput();
-
- $this->setHeaders();
- $this->outputHeader();
-
- // User provided ISBN
- $isbn = $isbn ?: $this->getRequest()->getText( 'isbn' );
- $isbn = trim( $isbn );
-
- $this->buildForm( $isbn );
-
- if ( $isbn !== '' ) {
- if ( !self::isValidISBN( $isbn ) ) {
- $out->wrapWikiMsg(
- "<div class=\"error\">\n$1\n</div>",
- 'booksources-invalid-isbn'
- );
- }
-
- $this->showList( $isbn );
- }
- }
-
- /**
- * Return whether a given ISBN (10 or 13) is valid.
- *
- * @param string $isbn ISBN passed for check
- * @return bool
- */
- public static function isValidISBN( $isbn ) {
- $isbn = self::cleanIsbn( $isbn );
- $sum = 0;
- if ( strlen( $isbn ) == 13 ) {
- for ( $i = 0; $i < 12; $i++ ) {
- if ( $isbn[$i] === 'X' ) {
- return false;
- } elseif ( $i % 2 == 0 ) {
- $sum += $isbn[$i];
- } else {
- $sum += 3 * $isbn[$i];
- }
- }
-
- $check = ( 10 - ( $sum % 10 ) ) % 10;
- if ( (string)$check === $isbn[12] ) {
- return true;
- }
- } elseif ( strlen( $isbn ) == 10 ) {
- for ( $i = 0; $i < 9; $i++ ) {
- if ( $isbn[$i] === 'X' ) {
- return false;
- }
- $sum += $isbn[$i] * ( $i + 1 );
- }
-
- $check = $sum % 11;
- if ( $check == 10 ) {
- $check = "X";
- }
- if ( (string)$check === $isbn[9] ) {
- return true;
- }
- }
-
- return false;
- }
-
- /**
- * Trim ISBN and remove characters which aren't required
- *
- * @param string $isbn Unclean ISBN
- * @return string
- */
- private static function cleanIsbn( $isbn ) {
- return trim( preg_replace( '![^0-9X]!', '', $isbn ) );
- }
-
- /**
- * Generate a form to allow users to enter an ISBN
- *
- * @param string $isbn
- */
- private function buildForm( $isbn ) {
- $formDescriptor = [
- 'isbn' => [
- 'type' => 'text',
- 'name' => 'isbn',
- 'label-message' => 'booksources-isbn',
- 'default' => $isbn,
- 'autofocus' => true,
- 'required' => true,
- ],
- ];
-
- $context = new DerivativeContext( $this->getContext() );
- $context->setTitle( $this->getPageTitle() );
- HTMLForm::factory( 'ooui', $formDescriptor, $context )
- ->setWrapperLegendMsg( 'booksources-search-legend' )
- ->setSubmitTextMsg( 'booksources-search' )
- ->setMethod( 'get' )
- ->prepareForm()
- ->displayForm( false );
- }
-
- /**
- * Determine where to get the list of book sources from,
- * format and output them
- *
- * @param string $isbn
- * @throws MWException
- * @return bool
- */
- private function showList( $isbn ) {
- $out = $this->getOutput();
-
- $isbn = self::cleanIsbn( $isbn );
- # Hook to allow extensions to insert additional HTML,
- # e.g. for API-interacting plugins and so on
- Hooks::run( 'BookInformation', [ $isbn, $out ] );
-
- # Check for a local page such as Project:Book_sources and use that if available
- $page = $this->msg( 'booksources' )->inContentLanguage()->text();
- $title = Title::makeTitleSafe( NS_PROJECT, $page ); # Show list in content language
- if ( is_object( $title ) && $title->exists() ) {
- $rev = Revision::newFromTitle( $title, false, Revision::READ_NORMAL );
- $content = $rev->getContent();
-
- if ( $content instanceof TextContent ) {
- // XXX: in the future, this could be stored as structured data, defining a list of book sources
-
- $text = $content->getText();
- $out->addWikiTextAsInterface( str_replace( 'MAGICNUMBER', $isbn, $text ) );
-
- return true;
- } else {
- throw new MWException( "Unexpected content type for book sources: " . $content->getModel() );
- }
- }
-
- # Fall back to the defaults given in the language file
- $out->addWikiMsg( 'booksources-text' );
- $out->addHTML( '<ul>' );
- $items = MediaWikiServices::getInstance()->getContentLanguage()->getBookstoreList();
- foreach ( $items as $label => $url ) {
- $out->addHTML( $this->makeListItem( $isbn, $label, $url ) );
- }
- $out->addHTML( '</ul>' );
-
- return true;
- }
-
- /**
- * Format a book source list item
- *
- * @param string $isbn
- * @param string $label Book source label
- * @param string $url Book source URL
- * @return string
- */
- private function makeListItem( $isbn, $label, $url ) {
- $url = str_replace( '$1', $isbn, $url );
-
- return Html::rawElement( 'li', [],
- Html::element( 'a', [ 'href' => $url, 'class' => 'external' ], $label )
- );
- }
-
- protected function getGroupName() {
- return 'wiki';
- }
-}
--- /dev/null
+<?php
+/**
+ * Implements Special:Emailuser
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+use MediaWiki\MediaWikiServices;
+use MediaWiki\Preferences\MultiUsernameFilter;
+
+/**
+ * A special page that allows users to send e-mails to other users
+ *
+ * @ingroup SpecialPage
+ */
+class SpecialEmailUser extends UnlistedSpecialPage {
+ protected $mTarget;
+
+ /**
+ * @var User|string $mTargetObj
+ */
+ protected $mTargetObj;
+
+ public function __construct() {
+ parent::__construct( 'Emailuser' );
+ }
+
+ public function doesWrites() {
+ return true;
+ }
+
+ public function getDescription() {
+ $target = self::getTarget( $this->mTarget, $this->getUser() );
+ if ( !$target instanceof User ) {
+ return $this->msg( 'emailuser-title-notarget' )->text();
+ }
+
+ return $this->msg( 'emailuser-title-target', $target->getName() )->text();
+ }
+
+ protected function getFormFields() {
+ $linkRenderer = $this->getLinkRenderer();
+ return [
+ 'From' => [
+ 'type' => 'info',
+ 'raw' => 1,
+ 'default' => $linkRenderer->makeLink(
+ $this->getUser()->getUserPage(),
+ $this->getUser()->getName()
+ ),
+ 'label-message' => 'emailfrom',
+ 'id' => 'mw-emailuser-sender',
+ ],
+ 'To' => [
+ 'type' => 'info',
+ 'raw' => 1,
+ 'default' => $linkRenderer->makeLink(
+ $this->mTargetObj->getUserPage(),
+ $this->mTargetObj->getName()
+ ),
+ 'label-message' => 'emailto',
+ 'id' => 'mw-emailuser-recipient',
+ ],
+ 'Target' => [
+ 'type' => 'hidden',
+ 'default' => $this->mTargetObj->getName(),
+ ],
+ 'Subject' => [
+ 'type' => 'text',
+ 'default' => $this->msg( 'defemailsubject',
+ $this->getUser()->getName() )->inContentLanguage()->text(),
+ 'label-message' => 'emailsubject',
+ 'maxlength' => 200,
+ 'size' => 60,
+ 'required' => true,
+ ],
+ 'Text' => [
+ 'type' => 'textarea',
+ 'rows' => 20,
+ 'label-message' => 'emailmessage',
+ 'required' => true,
+ ],
+ 'CCMe' => [
+ 'type' => 'check',
+ 'label-message' => 'emailccme',
+ 'default' => $this->getUser()->getBoolOption( 'ccmeonemails' ),
+ ],
+ ];
+ }
+
+ public function execute( $par ) {
+ $out = $this->getOutput();
+ $request = $this->getRequest();
+ $out->addModuleStyles( 'mediawiki.special' );
+
+ $this->mTarget = $par ?? $request->getVal( 'wpTarget', $request->getVal( 'target', '' ) );
+
+ // Make sure, that HTMLForm uses the correct target.
+ $request->setVal( 'wpTarget', $this->mTarget );
+
+ // This needs to be below assignment of $this->mTarget because
+ // getDescription() needs it to determine the correct page title.
+ $this->setHeaders();
+ $this->outputHeader();
+
+ // error out if sending user cannot do this
+ $error = self::getPermissionsError(
+ $this->getUser(),
+ $this->getRequest()->getVal( 'wpEditToken' ),
+ $this->getConfig()
+ );
+
+ switch ( $error ) {
+ case null:
+ # Wahey!
+ break;
+ case 'badaccess':
+ throw new PermissionsError( 'sendemail' );
+ case 'blockedemailuser':
+ throw $this->getBlockedEmailError();
+ case 'actionthrottledtext':
+ throw new ThrottledError;
+ case 'mailnologin':
+ case 'usermaildisabled':
+ throw new ErrorPageError( $error, "{$error}text" );
+ default:
+ # It's a hook error
+ list( $title, $msg, $params ) = $error;
+ throw new ErrorPageError( $title, $msg, $params );
+ }
+
+ // Make sure, that a submitted form isn't submitted to a subpage (which could be
+ // a non-existing username)
+ $context = new DerivativeContext( $this->getContext() );
+ $context->setTitle( $this->getPageTitle() ); // Remove subpage
+ $this->setContext( $context );
+
+ // A little hack: HTMLForm will check $this->mTarget only, if the form was posted, not
+ // if the user opens Special:EmailUser/Florian (e.g.). So check, if the user did that
+ // and show the "Send email to user" form directly, if so. Show the "enter username"
+ // form, otherwise.
+ $this->mTargetObj = self::getTarget( $this->mTarget, $this->getUser() );
+ if ( !$this->mTargetObj instanceof User ) {
+ $this->userForm( $this->mTarget );
+ } else {
+ $this->sendEmailForm();
+ }
+ }
+
+ /**
+ * Validate target User
+ *
+ * @param string $target Target user name
+ * @param User|null $sender User sending the email
+ * @return User|string User object on success or a string on error
+ */
+ public static function getTarget( $target, User $sender = null ) {
+ if ( $sender === null ) {
+ wfDeprecated( __METHOD__ . ' without specifying the sending user', '1.30' );
+ }
+
+ if ( $target == '' ) {
+ wfDebug( "Target is empty.\n" );
+
+ return 'notarget';
+ }
+
+ $nu = User::newFromName( $target );
+ $error = self::validateTarget( $nu, $sender );
+
+ return $error ?: $nu;
+ }
+
+ /**
+ * Validate target User
+ *
+ * @param User $target Target user
+ * @param User|null $sender User sending the email
+ * @return string Error message or empty string if valid.
+ * @since 1.30
+ */
+ public static function validateTarget( $target, User $sender = null ) {
+ if ( $sender === null ) {
+ wfDeprecated( __METHOD__ . ' without specifying the sending user', '1.30' );
+ }
+
+ if ( !$target instanceof User || !$target->getId() ) {
+ wfDebug( "Target is invalid user.\n" );
+
+ return 'notarget';
+ }
+
+ if ( !$target->isEmailConfirmed() ) {
+ wfDebug( "User has no valid email.\n" );
+
+ return 'noemail';
+ }
+
+ if ( !$target->canReceiveEmail() ) {
+ wfDebug( "User does not allow user emails.\n" );
+
+ return 'nowikiemail';
+ }
+
+ if ( $sender !== null && !$target->getOption( 'email-allow-new-users' ) &&
+ $sender->isNewbie()
+ ) {
+ wfDebug( "User does not allow user emails from new users.\n" );
+
+ return 'nowikiemail';
+ }
+
+ if ( $sender !== null ) {
+ $blacklist = $target->getOption( 'email-blacklist', '' );
+ if ( $blacklist ) {
+ $blacklist = MultiUsernameFilter::splitIds( $blacklist );
+ $lookup = CentralIdLookup::factory();
+ $senderId = $lookup->centralIdFromLocalUser( $sender );
+ if ( $senderId !== 0 && in_array( $senderId, $blacklist ) ) {
+ wfDebug( "User does not allow user emails from this user.\n" );
+
+ return 'nowikiemail';
+ }
+ }
+ }
+
+ return '';
+ }
+
+ /**
+ * Check whether a user is allowed to send email
+ *
+ * @param User $user
+ * @param string $editToken Edit token
+ * @param Config|null $config optional for backwards compatibility
+ * @return null|string|array Null on success, string on error, or array on
+ * hook error
+ */
+ public static function getPermissionsError( $user, $editToken, Config $config = null ) {
+ if ( $config === null ) {
+ wfDebug( __METHOD__ . ' called without a Config instance passed to it' );
+ $config = MediaWikiServices::getInstance()->getMainConfig();
+ }
+ if ( !$config->get( 'EnableEmail' ) || !$config->get( 'EnableUserEmail' ) ) {
+ return 'usermaildisabled';
+ }
+
+ // Run this before $user->isAllowed, to show appropriate message to anons (T160309)
+ if ( !$user->isEmailConfirmed() ) {
+ return 'mailnologin';
+ }
+
+ if ( !$user->isAllowed( 'sendemail' ) ) {
+ return 'badaccess';
+ }
+
+ if ( $user->isBlockedFromEmailuser() ) {
+ wfDebug( "User is blocked from sending e-mail.\n" );
+
+ return "blockedemailuser";
+ }
+
+ // Check the ping limiter without incrementing it - we'll check it
+ // again later and increment it on a successful send
+ if ( $user->pingLimiter( 'emailuser', 0 ) ) {
+ wfDebug( "Ping limiter triggered.\n" );
+
+ return 'actionthrottledtext';
+ }
+
+ $hookErr = false;
+
+ Hooks::run( 'UserCanSendEmail', [ &$user, &$hookErr ] );
+ Hooks::run( 'EmailUserPermissionsErrors', [ $user, $editToken, &$hookErr ] );
+
+ if ( $hookErr ) {
+ return $hookErr;
+ }
+
+ return null;
+ }
+
+ /**
+ * Form to ask for target user name.
+ *
+ * @param string $name User name submitted.
+ */
+ protected function userForm( $name ) {
+ $htmlForm = HTMLForm::factory( 'ooui', [
+ 'Target' => [
+ 'type' => 'user',
+ 'exists' => true,
+ 'label' => $this->msg( 'emailusername' )->text(),
+ 'id' => 'emailusertarget',
+ 'autofocus' => true,
+ 'value' => $name,
+ ]
+ ], $this->getContext() );
+
+ $htmlForm
+ ->setMethod( 'post' )
+ ->setSubmitCallback( [ $this, 'sendEmailForm' ] )
+ ->setFormIdentifier( 'userForm' )
+ ->setId( 'askusername' )
+ ->setWrapperLegendMsg( 'emailtarget' )
+ ->setSubmitTextMsg( 'emailusernamesubmit' )
+ ->show();
+ }
+
+ public function sendEmailForm() {
+ $out = $this->getOutput();
+
+ $ret = $this->mTargetObj;
+ if ( !$ret instanceof User ) {
+ if ( $this->mTarget != '' ) {
+ // Messages used here: notargettext, noemailtext, nowikiemailtext
+ $ret = ( $ret == 'notarget' ) ? 'emailnotarget' : ( $ret . 'text' );
+ return Status::newFatal( $ret );
+ }
+ return false;
+ }
+
+ $htmlForm = HTMLForm::factory( 'ooui', $this->getFormFields(), $this->getContext() );
+ // By now we are supposed to be sure that $this->mTarget is a user name
+ $htmlForm
+ ->addPreText( $this->msg( 'emailpagetext', $this->mTarget )->parse() )
+ ->setSubmitTextMsg( 'emailsend' )
+ ->setSubmitCallback( [ __CLASS__, 'submit' ] )
+ ->setFormIdentifier( 'sendEmailForm' )
+ ->setWrapperLegendMsg( 'email-legend' )
+ ->loadData();
+
+ if ( !Hooks::run( 'EmailUserForm', [ &$htmlForm ] ) ) {
+ return false;
+ }
+
+ $result = $htmlForm->show();
+
+ if ( $result === true || ( $result instanceof Status && $result->isGood() ) ) {
+ $out->setPageTitle( $this->msg( 'emailsent' ) );
+ $out->addWikiMsg( 'emailsenttext', $this->mTarget );
+ $out->returnToMain( false, $ret->getUserPage() );
+ }
+ return true;
+ }
+
+ /**
+ * Really send a mail. Permissions should have been checked using
+ * getPermissionsError(). It is probably also a good
+ * idea to check the edit token and ping limiter in advance.
+ *
+ * @param array $data
+ * @param IContextSource $context
+ * @return Status|bool
+ */
+ public static function submit( array $data, IContextSource $context ) {
+ $config = $context->getConfig();
+
+ $target = self::getTarget( $data['Target'], $context->getUser() );
+ if ( !$target instanceof User ) {
+ // Messages used here: notargettext, noemailtext, nowikiemailtext
+ return Status::newFatal( $target . 'text' );
+ }
+
+ $to = MailAddress::newFromUser( $target );
+ $from = MailAddress::newFromUser( $context->getUser() );
+ $subject = $data['Subject'];
+ $text = $data['Text'];
+
+ // Add a standard footer and trim up trailing newlines
+ $text = rtrim( $text ) . "\n\n-- \n";
+ $text .= $context->msg( 'emailuserfooter',
+ $from->name, $to->name )->inContentLanguage()->text();
+
+ // Check and increment the rate limits
+ if ( $context->getUser()->pingLimiter( 'emailuser' ) ) {
+ throw new ThrottledError();
+ }
+
+ $error = false;
+ if ( !Hooks::run( 'EmailUser', [ &$to, &$from, &$subject, &$text, &$error ] ) ) {
+ if ( $error instanceof Status ) {
+ return $error;
+ } elseif ( $error === false || $error === '' || $error === [] ) {
+ // Possibly to tell HTMLForm to pretend there was no submission?
+ return false;
+ } elseif ( $error === true ) {
+ // Hook sent the mail itself and indicates success?
+ return Status::newGood();
+ } elseif ( is_array( $error ) ) {
+ $status = Status::newGood();
+ foreach ( $error as $e ) {
+ $status->fatal( $e );
+ }
+ return $status;
+ } elseif ( $error instanceof MessageSpecifier ) {
+ return Status::newFatal( $error );
+ } else {
+ // Ugh. Either a raw HTML string, or something that's supposed
+ // to be treated like one.
+ $type = is_object( $error ) ? get_class( $error ) : gettype( $error );
+ wfDeprecated( "EmailUser hook returning a $type as \$error", '1.29' );
+ return Status::newFatal( new ApiRawMessage(
+ [ '$1', Message::rawParam( (string)$error ) ], 'hookaborted'
+ ) );
+ }
+ }
+
+ if ( $config->get( 'UserEmailUseReplyTo' ) ) {
+ /**
+ * Put the generic wiki autogenerated address in the From:
+ * header and reserve the user for Reply-To.
+ *
+ * This is a bit ugly, but will serve to differentiate
+ * wiki-borne mails from direct mails and protects against
+ * SPF and bounce problems with some mailers (see below).
+ */
+ $mailFrom = new MailAddress( $config->get( 'PasswordSender' ),
+ $context->msg( 'emailsender' )->inContentLanguage()->text() );
+ $replyTo = $from;
+ } else {
+ /**
+ * Put the sending user's e-mail address in the From: header.
+ *
+ * This is clean-looking and convenient, but has issues.
+ * One is that it doesn't as clearly differentiate the wiki mail
+ * from "directly" sent mails.
+ *
+ * Another is that some mailers (like sSMTP) will use the From
+ * address as the envelope sender as well. For open sites this
+ * can cause mails to be flunked for SPF violations (since the
+ * wiki server isn't an authorized sender for various users'
+ * domains) as well as creating a privacy issue as bounces
+ * containing the recipient's e-mail address may get sent to
+ * the sending user.
+ */
+ $mailFrom = $from;
+ $replyTo = null;
+ }
+
+ $status = UserMailer::send( $to, $mailFrom, $subject, $text, [
+ 'replyTo' => $replyTo,
+ ] );
+
+ if ( !$status->isGood() ) {
+ return $status;
+ } else {
+ // if the user requested a copy of this mail, do this now,
+ // unless they are emailing themselves, in which case one
+ // copy of the message is sufficient.
+ if ( $data['CCMe'] && $to != $from ) {
+ $ccTo = $from;
+ $ccFrom = $from;
+ $ccSubject = $context->msg( 'emailccsubject' )->plaintextParams(
+ $target->getName(), $subject )->text();
+ $ccText = $text;
+
+ Hooks::run( 'EmailUserCC', [ &$ccTo, &$ccFrom, &$ccSubject, &$ccText ] );
+
+ if ( $config->get( 'UserEmailUseReplyTo' ) ) {
+ $mailFrom = new MailAddress(
+ $config->get( 'PasswordSender' ),
+ $context->msg( 'emailsender' )->inContentLanguage()->text()
+ );
+ $replyTo = $ccFrom;
+ } else {
+ $mailFrom = $ccFrom;
+ $replyTo = null;
+ }
+
+ $ccStatus = UserMailer::send(
+ $ccTo, $mailFrom, $ccSubject, $ccText, [
+ 'replyTo' => $replyTo,
+ ] );
+ $status->merge( $ccStatus );
+ }
+
+ Hooks::run( 'EmailUserComplete', [ $to, $from, $subject, $text ] );
+
+ return $status;
+ }
+ }
+
+ /**
+ * Return an array of subpages beginning with $search that this special page will accept.
+ *
+ * @param string $search Prefix to search for
+ * @param int $limit Maximum number of results to return (usually 10)
+ * @param int $offset Number of results to skip (usually 0)
+ * @return string[] Matching subpages
+ */
+ public function prefixSearchSubpages( $search, $limit, $offset ) {
+ $user = User::newFromName( $search );
+ if ( !$user ) {
+ // No prefix suggestion for invalid user
+ return [];
+ }
+ // Autocomplete subpage as user list - public to allow caching
+ return UserNamePrefixSearch::search( 'public', $search, $limit, $offset );
+ }
+
+ protected function getGroupName() {
+ return 'users';
+ }
+
+ /**
+ * Builds an error message based on the block params
+ *
+ * @return ErrorPageError
+ */
+ private function getBlockedEmailError() {
+ $block = $this->getUser()->mBlock;
+ $params = $block->getBlockErrorParams( $this->getContext() );
+
+ $msg = $block->isSitewide() ? 'blockedtext' : 'blocked-email-user';
+ return new ErrorPageError( 'blockedtitle', $msg, $params );
+ }
+}
+++ /dev/null
-<?php
-/**
- * Implements Special:Emailuser
- *
- * This program is free software; you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation; either version 2 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License along
- * with this program; if not, write to the Free Software Foundation, Inc.,
- * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
- * http://www.gnu.org/copyleft/gpl.html
- *
- * @file
- * @ingroup SpecialPage
- */
-use MediaWiki\MediaWikiServices;
-use MediaWiki\Preferences\MultiUsernameFilter;
-
-/**
- * A special page that allows users to send e-mails to other users
- *
- * @ingroup SpecialPage
- */
-class SpecialEmailUser extends UnlistedSpecialPage {
- protected $mTarget;
-
- /**
- * @var User|string $mTargetObj
- */
- protected $mTargetObj;
-
- public function __construct() {
- parent::__construct( 'Emailuser' );
- }
-
- public function doesWrites() {
- return true;
- }
-
- public function getDescription() {
- $target = self::getTarget( $this->mTarget, $this->getUser() );
- if ( !$target instanceof User ) {
- return $this->msg( 'emailuser-title-notarget' )->text();
- }
-
- return $this->msg( 'emailuser-title-target', $target->getName() )->text();
- }
-
- protected function getFormFields() {
- $linkRenderer = $this->getLinkRenderer();
- return [
- 'From' => [
- 'type' => 'info',
- 'raw' => 1,
- 'default' => $linkRenderer->makeLink(
- $this->getUser()->getUserPage(),
- $this->getUser()->getName()
- ),
- 'label-message' => 'emailfrom',
- 'id' => 'mw-emailuser-sender',
- ],
- 'To' => [
- 'type' => 'info',
- 'raw' => 1,
- 'default' => $linkRenderer->makeLink(
- $this->mTargetObj->getUserPage(),
- $this->mTargetObj->getName()
- ),
- 'label-message' => 'emailto',
- 'id' => 'mw-emailuser-recipient',
- ],
- 'Target' => [
- 'type' => 'hidden',
- 'default' => $this->mTargetObj->getName(),
- ],
- 'Subject' => [
- 'type' => 'text',
- 'default' => $this->msg( 'defemailsubject',
- $this->getUser()->getName() )->inContentLanguage()->text(),
- 'label-message' => 'emailsubject',
- 'maxlength' => 200,
- 'size' => 60,
- 'required' => true,
- ],
- 'Text' => [
- 'type' => 'textarea',
- 'rows' => 20,
- 'label-message' => 'emailmessage',
- 'required' => true,
- ],
- 'CCMe' => [
- 'type' => 'check',
- 'label-message' => 'emailccme',
- 'default' => $this->getUser()->getBoolOption( 'ccmeonemails' ),
- ],
- ];
- }
-
- public function execute( $par ) {
- $out = $this->getOutput();
- $request = $this->getRequest();
- $out->addModuleStyles( 'mediawiki.special' );
-
- $this->mTarget = $par ?? $request->getVal( 'wpTarget', $request->getVal( 'target', '' ) );
-
- // Make sure, that HTMLForm uses the correct target.
- $request->setVal( 'wpTarget', $this->mTarget );
-
- // This needs to be below assignment of $this->mTarget because
- // getDescription() needs it to determine the correct page title.
- $this->setHeaders();
- $this->outputHeader();
-
- // error out if sending user cannot do this
- $error = self::getPermissionsError(
- $this->getUser(),
- $this->getRequest()->getVal( 'wpEditToken' ),
- $this->getConfig()
- );
-
- switch ( $error ) {
- case null:
- # Wahey!
- break;
- case 'badaccess':
- throw new PermissionsError( 'sendemail' );
- case 'blockedemailuser':
- throw $this->getBlockedEmailError();
- case 'actionthrottledtext':
- throw new ThrottledError;
- case 'mailnologin':
- case 'usermaildisabled':
- throw new ErrorPageError( $error, "{$error}text" );
- default:
- # It's a hook error
- list( $title, $msg, $params ) = $error;
- throw new ErrorPageError( $title, $msg, $params );
- }
-
- // Make sure, that a submitted form isn't submitted to a subpage (which could be
- // a non-existing username)
- $context = new DerivativeContext( $this->getContext() );
- $context->setTitle( $this->getPageTitle() ); // Remove subpage
- $this->setContext( $context );
-
- // A little hack: HTMLForm will check $this->mTarget only, if the form was posted, not
- // if the user opens Special:EmailUser/Florian (e.g.). So check, if the user did that
- // and show the "Send email to user" form directly, if so. Show the "enter username"
- // form, otherwise.
- $this->mTargetObj = self::getTarget( $this->mTarget, $this->getUser() );
- if ( !$this->mTargetObj instanceof User ) {
- $this->userForm( $this->mTarget );
- } else {
- $this->sendEmailForm();
- }
- }
-
- /**
- * Validate target User
- *
- * @param string $target Target user name
- * @param User|null $sender User sending the email
- * @return User|string User object on success or a string on error
- */
- public static function getTarget( $target, User $sender = null ) {
- if ( $sender === null ) {
- wfDeprecated( __METHOD__ . ' without specifying the sending user', '1.30' );
- }
-
- if ( $target == '' ) {
- wfDebug( "Target is empty.\n" );
-
- return 'notarget';
- }
-
- $nu = User::newFromName( $target );
- $error = self::validateTarget( $nu, $sender );
-
- return $error ?: $nu;
- }
-
- /**
- * Validate target User
- *
- * @param User $target Target user
- * @param User|null $sender User sending the email
- * @return string Error message or empty string if valid.
- * @since 1.30
- */
- public static function validateTarget( $target, User $sender = null ) {
- if ( $sender === null ) {
- wfDeprecated( __METHOD__ . ' without specifying the sending user', '1.30' );
- }
-
- if ( !$target instanceof User || !$target->getId() ) {
- wfDebug( "Target is invalid user.\n" );
-
- return 'notarget';
- }
-
- if ( !$target->isEmailConfirmed() ) {
- wfDebug( "User has no valid email.\n" );
-
- return 'noemail';
- }
-
- if ( !$target->canReceiveEmail() ) {
- wfDebug( "User does not allow user emails.\n" );
-
- return 'nowikiemail';
- }
-
- if ( $sender !== null && !$target->getOption( 'email-allow-new-users' ) &&
- $sender->isNewbie()
- ) {
- wfDebug( "User does not allow user emails from new users.\n" );
-
- return 'nowikiemail';
- }
-
- if ( $sender !== null ) {
- $blacklist = $target->getOption( 'email-blacklist', '' );
- if ( $blacklist ) {
- $blacklist = MultiUsernameFilter::splitIds( $blacklist );
- $lookup = CentralIdLookup::factory();
- $senderId = $lookup->centralIdFromLocalUser( $sender );
- if ( $senderId !== 0 && in_array( $senderId, $blacklist ) ) {
- wfDebug( "User does not allow user emails from this user.\n" );
-
- return 'nowikiemail';
- }
- }
- }
-
- return '';
- }
-
- /**
- * Check whether a user is allowed to send email
- *
- * @param User $user
- * @param string $editToken Edit token
- * @param Config|null $config optional for backwards compatibility
- * @return null|string|array Null on success, string on error, or array on
- * hook error
- */
- public static function getPermissionsError( $user, $editToken, Config $config = null ) {
- if ( $config === null ) {
- wfDebug( __METHOD__ . ' called without a Config instance passed to it' );
- $config = MediaWikiServices::getInstance()->getMainConfig();
- }
- if ( !$config->get( 'EnableEmail' ) || !$config->get( 'EnableUserEmail' ) ) {
- return 'usermaildisabled';
- }
-
- // Run this before $user->isAllowed, to show appropriate message to anons (T160309)
- if ( !$user->isEmailConfirmed() ) {
- return 'mailnologin';
- }
-
- if ( !$user->isAllowed( 'sendemail' ) ) {
- return 'badaccess';
- }
-
- if ( $user->isBlockedFromEmailuser() ) {
- wfDebug( "User is blocked from sending e-mail.\n" );
-
- return "blockedemailuser";
- }
-
- // Check the ping limiter without incrementing it - we'll check it
- // again later and increment it on a successful send
- if ( $user->pingLimiter( 'emailuser', 0 ) ) {
- wfDebug( "Ping limiter triggered.\n" );
-
- return 'actionthrottledtext';
- }
-
- $hookErr = false;
-
- Hooks::run( 'UserCanSendEmail', [ &$user, &$hookErr ] );
- Hooks::run( 'EmailUserPermissionsErrors', [ $user, $editToken, &$hookErr ] );
-
- if ( $hookErr ) {
- return $hookErr;
- }
-
- return null;
- }
-
- /**
- * Form to ask for target user name.
- *
- * @param string $name User name submitted.
- */
- protected function userForm( $name ) {
- $htmlForm = HTMLForm::factory( 'ooui', [
- 'Target' => [
- 'type' => 'user',
- 'exists' => true,
- 'label' => $this->msg( 'emailusername' )->text(),
- 'id' => 'emailusertarget',
- 'autofocus' => true,
- 'value' => $name,
- ]
- ], $this->getContext() );
-
- $htmlForm
- ->setMethod( 'post' )
- ->setSubmitCallback( [ $this, 'sendEmailForm' ] )
- ->setFormIdentifier( 'userForm' )
- ->setId( 'askusername' )
- ->setWrapperLegendMsg( 'emailtarget' )
- ->setSubmitTextMsg( 'emailusernamesubmit' )
- ->show();
- }
-
- public function sendEmailForm() {
- $out = $this->getOutput();
-
- $ret = $this->mTargetObj;
- if ( !$ret instanceof User ) {
- if ( $this->mTarget != '' ) {
- // Messages used here: notargettext, noemailtext, nowikiemailtext
- $ret = ( $ret == 'notarget' ) ? 'emailnotarget' : ( $ret . 'text' );
- return Status::newFatal( $ret );
- }
- return false;
- }
-
- $htmlForm = HTMLForm::factory( 'ooui', $this->getFormFields(), $this->getContext() );
- // By now we are supposed to be sure that $this->mTarget is a user name
- $htmlForm
- ->addPreText( $this->msg( 'emailpagetext', $this->mTarget )->parse() )
- ->setSubmitTextMsg( 'emailsend' )
- ->setSubmitCallback( [ __CLASS__, 'submit' ] )
- ->setFormIdentifier( 'sendEmailForm' )
- ->setWrapperLegendMsg( 'email-legend' )
- ->loadData();
-
- if ( !Hooks::run( 'EmailUserForm', [ &$htmlForm ] ) ) {
- return false;
- }
-
- $result = $htmlForm->show();
-
- if ( $result === true || ( $result instanceof Status && $result->isGood() ) ) {
- $out->setPageTitle( $this->msg( 'emailsent' ) );
- $out->addWikiMsg( 'emailsenttext', $this->mTarget );
- $out->returnToMain( false, $ret->getUserPage() );
- }
- return true;
- }
-
- /**
- * Really send a mail. Permissions should have been checked using
- * getPermissionsError(). It is probably also a good
- * idea to check the edit token and ping limiter in advance.
- *
- * @param array $data
- * @param IContextSource $context
- * @return Status|bool
- */
- public static function submit( array $data, IContextSource $context ) {
- $config = $context->getConfig();
-
- $target = self::getTarget( $data['Target'], $context->getUser() );
- if ( !$target instanceof User ) {
- // Messages used here: notargettext, noemailtext, nowikiemailtext
- return Status::newFatal( $target . 'text' );
- }
-
- $to = MailAddress::newFromUser( $target );
- $from = MailAddress::newFromUser( $context->getUser() );
- $subject = $data['Subject'];
- $text = $data['Text'];
-
- // Add a standard footer and trim up trailing newlines
- $text = rtrim( $text ) . "\n\n-- \n";
- $text .= $context->msg( 'emailuserfooter',
- $from->name, $to->name )->inContentLanguage()->text();
-
- // Check and increment the rate limits
- if ( $context->getUser()->pingLimiter( 'emailuser' ) ) {
- throw new ThrottledError();
- }
-
- $error = false;
- if ( !Hooks::run( 'EmailUser', [ &$to, &$from, &$subject, &$text, &$error ] ) ) {
- if ( $error instanceof Status ) {
- return $error;
- } elseif ( $error === false || $error === '' || $error === [] ) {
- // Possibly to tell HTMLForm to pretend there was no submission?
- return false;
- } elseif ( $error === true ) {
- // Hook sent the mail itself and indicates success?
- return Status::newGood();
- } elseif ( is_array( $error ) ) {
- $status = Status::newGood();
- foreach ( $error as $e ) {
- $status->fatal( $e );
- }
- return $status;
- } elseif ( $error instanceof MessageSpecifier ) {
- return Status::newFatal( $error );
- } else {
- // Ugh. Either a raw HTML string, or something that's supposed
- // to be treated like one.
- $type = is_object( $error ) ? get_class( $error ) : gettype( $error );
- wfDeprecated( "EmailUser hook returning a $type as \$error", '1.29' );
- return Status::newFatal( new ApiRawMessage(
- [ '$1', Message::rawParam( (string)$error ) ], 'hookaborted'
- ) );
- }
- }
-
- if ( $config->get( 'UserEmailUseReplyTo' ) ) {
- /**
- * Put the generic wiki autogenerated address in the From:
- * header and reserve the user for Reply-To.
- *
- * This is a bit ugly, but will serve to differentiate
- * wiki-borne mails from direct mails and protects against
- * SPF and bounce problems with some mailers (see below).
- */
- $mailFrom = new MailAddress( $config->get( 'PasswordSender' ),
- $context->msg( 'emailsender' )->inContentLanguage()->text() );
- $replyTo = $from;
- } else {
- /**
- * Put the sending user's e-mail address in the From: header.
- *
- * This is clean-looking and convenient, but has issues.
- * One is that it doesn't as clearly differentiate the wiki mail
- * from "directly" sent mails.
- *
- * Another is that some mailers (like sSMTP) will use the From
- * address as the envelope sender as well. For open sites this
- * can cause mails to be flunked for SPF violations (since the
- * wiki server isn't an authorized sender for various users'
- * domains) as well as creating a privacy issue as bounces
- * containing the recipient's e-mail address may get sent to
- * the sending user.
- */
- $mailFrom = $from;
- $replyTo = null;
- }
-
- $status = UserMailer::send( $to, $mailFrom, $subject, $text, [
- 'replyTo' => $replyTo,
- ] );
-
- if ( !$status->isGood() ) {
- return $status;
- } else {
- // if the user requested a copy of this mail, do this now,
- // unless they are emailing themselves, in which case one
- // copy of the message is sufficient.
- if ( $data['CCMe'] && $to != $from ) {
- $ccTo = $from;
- $ccFrom = $from;
- $ccSubject = $context->msg( 'emailccsubject' )->plaintextParams(
- $target->getName(), $subject )->text();
- $ccText = $text;
-
- Hooks::run( 'EmailUserCC', [ &$ccTo, &$ccFrom, &$ccSubject, &$ccText ] );
-
- if ( $config->get( 'UserEmailUseReplyTo' ) ) {
- $mailFrom = new MailAddress(
- $config->get( 'PasswordSender' ),
- $context->msg( 'emailsender' )->inContentLanguage()->text()
- );
- $replyTo = $ccFrom;
- } else {
- $mailFrom = $ccFrom;
- $replyTo = null;
- }
-
- $ccStatus = UserMailer::send(
- $ccTo, $mailFrom, $ccSubject, $ccText, [
- 'replyTo' => $replyTo,
- ] );
- $status->merge( $ccStatus );
- }
-
- Hooks::run( 'EmailUserComplete', [ $to, $from, $subject, $text ] );
-
- return $status;
- }
- }
-
- /**
- * Return an array of subpages beginning with $search that this special page will accept.
- *
- * @param string $search Prefix to search for
- * @param int $limit Maximum number of results to return (usually 10)
- * @param int $offset Number of results to skip (usually 0)
- * @return string[] Matching subpages
- */
- public function prefixSearchSubpages( $search, $limit, $offset ) {
- $user = User::newFromName( $search );
- if ( !$user ) {
- // No prefix suggestion for invalid user
- return [];
- }
- // Autocomplete subpage as user list - public to allow caching
- return UserNamePrefixSearch::search( 'public', $search, $limit, $offset );
- }
-
- protected function getGroupName() {
- return 'users';
- }
-
- /**
- * Builds an error message based on the block params
- *
- * @return ErrorPageError
- */
- private function getBlockedEmailError() {
- $block = $this->getUser()->mBlock;
- $params = $block->getBlockErrorParams( $this->getContext() );
-
- $msg = $block->isSitewide() ? 'blockedtext' : 'blocked-email-user';
- return new ErrorPageError( 'blockedtitle', $msg, $params );
- }
-}
--- /dev/null
+<?php
+/**
+ * Implements Special:Listfiles
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+
+class SpecialListFiles extends IncludableSpecialPage {
+ public function __construct() {
+ parent::__construct( 'Listfiles' );
+ }
+
+ public function execute( $par ) {
+ $this->setHeaders();
+ $this->outputHeader();
+
+ if ( $this->including() ) {
+ $userName = $par;
+ $search = '';
+ $showAll = false;
+ } else {
+ $userName = $this->getRequest()->getText( 'user', $par );
+ $search = $this->getRequest()->getText( 'ilsearch', '' );
+ $showAll = $this->getRequest()->getBool( 'ilshowall', false );
+ }
+
+ $pager = new ImageListPager(
+ $this->getContext(),
+ $userName,
+ $search,
+ $this->including(),
+ $showAll
+ );
+
+ $out = $this->getOutput();
+ if ( $this->including() ) {
+ $out->addParserOutputContent( $pager->getBodyOutput() );
+ } else {
+ $user = $pager->getRelevantUser();
+ $this->getSkin()->setRelevantUser( $user );
+ $pager->getForm();
+ $out->addParserOutputContent( $pager->getFullOutput() );
+ }
+ }
+
+ /**
+ * Return an array of subpages beginning with $search that this special page will accept.
+ *
+ * @param string $search Prefix to search for
+ * @param int $limit Maximum number of results to return (usually 10)
+ * @param int $offset Number of results to skip (usually 0)
+ * @return string[] Matching subpages
+ */
+ public function prefixSearchSubpages( $search, $limit, $offset ) {
+ $user = User::newFromName( $search );
+ if ( !$user ) {
+ // No prefix suggestion for invalid user
+ return [];
+ }
+ // Autocomplete subpage as user list - public to allow caching
+ return UserNamePrefixSearch::search( 'public', $search, $limit, $offset );
+ }
+
+ protected function getGroupName() {
+ return 'media';
+ }
+}
--- /dev/null
+<?php
+/**
+ * Implements Special:Listgrants
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+
+/**
+ * This special page lists all defined rights grants and the associated rights.
+ * See also @ref $wgGrantPermissions and @ref $wgGrantPermissionGroups.
+ *
+ * @ingroup SpecialPage
+ */
+class SpecialListGrants extends SpecialPage {
+ function __construct() {
+ parent::__construct( 'Listgrants' );
+ }
+
+ /**
+ * Show the special page
+ * @param string|null $par
+ */
+ public function execute( $par ) {
+ $this->setHeaders();
+ $this->outputHeader();
+
+ $out = $this->getOutput();
+ $out->addModuleStyles( 'mediawiki.special' );
+
+ $out->addHTML(
+ \Html::openElement( 'table',
+ [ 'class' => 'wikitable mw-listgrouprights-table' ] ) .
+ '<tr>' .
+ \Html::element( 'th', null, $this->msg( 'listgrants-grant' )->text() ) .
+ \Html::element( 'th', null, $this->msg( 'listgrants-rights' )->text() ) .
+ '</tr>'
+ );
+
+ foreach ( $this->getConfig()->get( 'GrantPermissions' ) as $grant => $rights ) {
+ $descs = [];
+ $rights = array_filter( $rights ); // remove ones with 'false'
+ foreach ( $rights as $permission => $granted ) {
+ $descs[] = $this->msg(
+ 'listgrouprights-right-display',
+ \User::getRightDescription( $permission ),
+ '<span class="mw-listgrants-right-name">' . $permission . '</span>'
+ )->parse();
+ }
+ if ( $descs === [] ) {
+ $grantCellHtml = '';
+ } else {
+ sort( $descs );
+ $grantCellHtml = '<ul><li>' . implode( "</li>\n<li>", $descs ) . '</li></ul>';
+ }
+
+ $id = Sanitizer::escapeIdForAttribute( $grant );
+ $out->addHTML( \Html::rawElement( 'tr', [ 'id' => $id ],
+ "<td>" .
+ $this->msg(
+ "listgrants-grant-display",
+ \User::getGrantName( $grant ),
+ "<span class='mw-listgrants-grant-name'>" . $id . "</span>"
+ )->parse() .
+ "</td>" .
+ "<td>" . $grantCellHtml . "</td>"
+ ) );
+ }
+
+ $out->addHTML( \Html::closeElement( 'table' ) );
+ }
+
+ protected function getGroupName() {
+ return 'users';
+ }
+}
--- /dev/null
+<?php
+/**
+ * Implements Special:Listgrouprights
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+
+use MediaWiki\MediaWikiServices;
+
+/**
+ * This special page lists all defined user groups and the associated rights.
+ * See also @ref $wgGroupPermissions.
+ *
+ * @ingroup SpecialPage
+ * @author Petr Kadlec <mormegil@centrum.cz>
+ */
+class SpecialListGroupRights extends SpecialPage {
+ public function __construct() {
+ parent::__construct( 'Listgrouprights' );
+ }
+
+ /**
+ * Show the special page
+ * @param string|null $par
+ */
+ public function execute( $par ) {
+ $this->setHeaders();
+ $this->outputHeader();
+
+ $out = $this->getOutput();
+ $out->addModuleStyles( 'mediawiki.special' );
+
+ $out->wrapWikiMsg( "<div class=\"mw-listgrouprights-key\">\n$1\n</div>", 'listgrouprights-key' );
+
+ $out->addHTML(
+ Xml::openElement( 'table', [ 'class' => 'wikitable mw-listgrouprights-table' ] ) .
+ '<tr>' .
+ Xml::element( 'th', null, $this->msg( 'listgrouprights-group' )->text() ) .
+ Xml::element( 'th', null, $this->msg( 'listgrouprights-rights' )->text() ) .
+ '</tr>'
+ );
+
+ $config = $this->getConfig();
+ $groupPermissions = $config->get( 'GroupPermissions' );
+ $revokePermissions = $config->get( 'RevokePermissions' );
+ $addGroups = $config->get( 'AddGroups' );
+ $removeGroups = $config->get( 'RemoveGroups' );
+ $groupsAddToSelf = $config->get( 'GroupsAddToSelf' );
+ $groupsRemoveFromSelf = $config->get( 'GroupsRemoveFromSelf' );
+ $allGroups = array_unique( array_merge(
+ array_keys( $groupPermissions ),
+ array_keys( $revokePermissions ),
+ array_keys( $addGroups ),
+ array_keys( $removeGroups ),
+ array_keys( $groupsAddToSelf ),
+ array_keys( $groupsRemoveFromSelf )
+ ) );
+ asort( $allGroups );
+
+ $linkRenderer = $this->getLinkRenderer();
+
+ foreach ( $allGroups as $group ) {
+ $permissions = $groupPermissions[$group] ?? [];
+ $groupname = ( $group == '*' ) // Replace * with a more descriptive groupname
+ ? 'all'
+ : $group;
+
+ $groupnameLocalized = UserGroupMembership::getGroupName( $groupname );
+
+ $grouppageLocalizedTitle = UserGroupMembership::getGroupPage( $groupname )
+ ?: Title::newFromText( MWNamespace::getCanonicalName( NS_PROJECT ) . ':' . $groupname );
+
+ if ( $group == '*' || !$grouppageLocalizedTitle ) {
+ // Do not make a link for the generic * group or group with invalid group page
+ $grouppage = htmlspecialchars( $groupnameLocalized );
+ } else {
+ $grouppage = $linkRenderer->makeLink(
+ $grouppageLocalizedTitle,
+ $groupnameLocalized
+ );
+ }
+
+ if ( $group === 'user' ) {
+ // Link to Special:listusers for implicit group 'user'
+ $grouplink = '<br />' . $linkRenderer->makeKnownLink(
+ SpecialPage::getTitleFor( 'Listusers' ),
+ $this->msg( 'listgrouprights-members' )->text()
+ );
+ } elseif ( !in_array( $group, $config->get( 'ImplicitGroups' ) ) ) {
+ $grouplink = '<br />' . $linkRenderer->makeKnownLink(
+ SpecialPage::getTitleFor( 'Listusers' ),
+ $this->msg( 'listgrouprights-members' )->text(),
+ [],
+ [ 'group' => $group ]
+ );
+ } else {
+ // No link to Special:listusers for other implicit groups as they are unlistable
+ $grouplink = '';
+ }
+
+ $revoke = $revokePermissions[$group] ?? [];
+ $addgroups = $addGroups[$group] ?? [];
+ $removegroups = $removeGroups[$group] ?? [];
+ $addgroupsSelf = $groupsAddToSelf[$group] ?? [];
+ $removegroupsSelf = $groupsRemoveFromSelf[$group] ?? [];
+
+ $id = $group == '*' ? false : Sanitizer::escapeIdForAttribute( $group );
+ $out->addHTML( Html::rawElement( 'tr', [ 'id' => $id ], "
+ <td>$grouppage$grouplink</td>
+ <td>" .
+ $this->formatPermissions( $permissions, $revoke, $addgroups, $removegroups,
+ $addgroupsSelf, $removegroupsSelf ) .
+ '</td>
+ '
+ ) );
+ }
+ $out->addHTML( Xml::closeElement( 'table' ) );
+ $this->outputNamespaceProtectionInfo();
+ }
+
+ private function outputNamespaceProtectionInfo() {
+ $out = $this->getOutput();
+ $namespaceProtection = $this->getConfig()->get( 'NamespaceProtection' );
+
+ if ( count( $namespaceProtection ) == 0 ) {
+ return;
+ }
+
+ $header = $this->msg( 'listgrouprights-namespaceprotection-header' )->text();
+ $out->addHTML(
+ Html::rawElement( 'h2', [], Html::element( 'span', [
+ 'class' => 'mw-headline',
+ 'id' => substr( Parser::guessSectionNameFromStrippedText( $header ), 1 )
+ ], $header ) ) .
+ Xml::openElement( 'table', [ 'class' => 'wikitable' ] ) .
+ Html::element(
+ 'th',
+ [],
+ $this->msg( 'listgrouprights-namespaceprotection-namespace' )->text()
+ ) .
+ Html::element(
+ 'th',
+ [],
+ $this->msg( 'listgrouprights-namespaceprotection-restrictedto' )->text()
+ )
+ );
+ $linkRenderer = $this->getLinkRenderer();
+ ksort( $namespaceProtection );
+ $validNamespaces = MWNamespace::getValidNamespaces();
+ $contLang = MediaWikiServices::getInstance()->getContentLanguage();
+ foreach ( $namespaceProtection as $namespace => $rights ) {
+ if ( !in_array( $namespace, $validNamespaces ) ) {
+ continue;
+ }
+
+ if ( $namespace == NS_MAIN ) {
+ $namespaceText = $this->msg( 'blanknamespace' )->text();
+ } else {
+ $namespaceText = $contLang->convertNamespace( $namespace );
+ }
+
+ $out->addHTML(
+ Xml::openElement( 'tr' ) .
+ Html::rawElement(
+ 'td',
+ [],
+ $linkRenderer->makeLink(
+ SpecialPage::getTitleFor( 'Allpages' ),
+ $namespaceText,
+ [],
+ [ 'namespace' => $namespace ]
+ )
+ ) .
+ Xml::openElement( 'td' ) . Xml::openElement( 'ul' )
+ );
+
+ if ( !is_array( $rights ) ) {
+ $rights = [ $rights ];
+ }
+
+ foreach ( $rights as $right ) {
+ $out->addHTML(
+ Html::rawElement( 'li', [], $this->msg(
+ 'listgrouprights-right-display',
+ User::getRightDescription( $right ),
+ Html::element(
+ 'span',
+ [ 'class' => 'mw-listgrouprights-right-name' ],
+ $right
+ )
+ )->parse() )
+ );
+ }
+
+ $out->addHTML(
+ Xml::closeElement( 'ul' ) .
+ Xml::closeElement( 'td' ) .
+ Xml::closeElement( 'tr' )
+ );
+ }
+ $out->addHTML( Xml::closeElement( 'table' ) );
+ }
+
+ /**
+ * Create a user-readable list of permissions from the given array.
+ *
+ * @param array $permissions Array of permission => bool (from $wgGroupPermissions items)
+ * @param array $revoke Array of permission => bool (from $wgRevokePermissions items)
+ * @param array $add Array of groups this group is allowed to add or true
+ * @param array $remove Array of groups this group is allowed to remove or true
+ * @param array $addSelf Array of groups this group is allowed to add to self or true
+ * @param array $removeSelf Array of group this group is allowed to remove from self or true
+ * @return string HTML list of all granted permissions
+ */
+ private function formatPermissions( $permissions, $revoke, $add, $remove, $addSelf, $removeSelf ) {
+ $r = [];
+ foreach ( $permissions as $permission => $granted ) {
+ // show as granted only if it isn't revoked to prevent duplicate display of permissions
+ if ( $granted && ( !isset( $revoke[$permission] ) || !$revoke[$permission] ) ) {
+ $r[] = $this->msg( 'listgrouprights-right-display',
+ User::getRightDescription( $permission ),
+ '<span class="mw-listgrouprights-right-name">' . $permission . '</span>'
+ )->parse();
+ }
+ }
+ foreach ( $revoke as $permission => $revoked ) {
+ if ( $revoked ) {
+ $r[] = $this->msg( 'listgrouprights-right-revoked',
+ User::getRightDescription( $permission ),
+ '<span class="mw-listgrouprights-right-name">' . $permission . '</span>'
+ )->parse();
+ }
+ }
+
+ sort( $r );
+
+ $lang = $this->getLanguage();
+ $allGroups = User::getAllGroups();
+
+ $changeGroups = [
+ 'addgroup' => $add,
+ 'removegroup' => $remove,
+ 'addgroup-self' => $addSelf,
+ 'removegroup-self' => $removeSelf
+ ];
+
+ foreach ( $changeGroups as $messageKey => $changeGroup ) {
+ if ( $changeGroup === true ) {
+ // For grep: listgrouprights-addgroup-all, listgrouprights-removegroup-all,
+ // listgrouprights-addgroup-self-all, listgrouprights-removegroup-self-all
+ $r[] = $this->msg( 'listgrouprights-' . $messageKey . '-all' )->escaped();
+ } elseif ( is_array( $changeGroup ) ) {
+ $changeGroup = array_intersect( array_values( array_unique( $changeGroup ) ), $allGroups );
+ if ( count( $changeGroup ) ) {
+ $groupLinks = [];
+ foreach ( $changeGroup as $group ) {
+ $groupLinks[] = UserGroupMembership::getLink( $group, $this->getContext(), 'wiki' );
+ }
+ // For grep: listgrouprights-addgroup, listgrouprights-removegroup,
+ // listgrouprights-addgroup-self, listgrouprights-removegroup-self
+ $r[] = $this->msg( 'listgrouprights-' . $messageKey,
+ $lang->listToText( $groupLinks ), count( $changeGroup ) )->parse();
+ }
+ }
+ }
+
+ if ( empty( $r ) ) {
+ return '';
+ } else {
+ return '<ul><li>' . implode( "</li>\n<li>", $r ) . '</li></ul>';
+ }
+ }
+
+ protected function getGroupName() {
+ return 'users';
+ }
+}
--- /dev/null
+<?php
+/**
+ * Implements Special:Listusers
+ *
+ * Copyright © 2004 Brion Vibber, lcrocker, Tim Starling,
+ * Domas Mituzas, Antoine Musso, Jens Frank, Zhengzhu,
+ * 2006 Rob Church <robchur@gmail.com>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+
+/**
+ * @ingroup SpecialPage
+ */
+class SpecialListUsers extends IncludableSpecialPage {
+
+ public function __construct() {
+ parent::__construct( 'Listusers' );
+ }
+
+ /**
+ * @param string|null $par (optional) A group to list users from
+ */
+ public function execute( $par ) {
+ $this->setHeaders();
+ $this->outputHeader();
+
+ $up = new UsersPager( $this->getContext(), $par, $this->including() );
+
+ # getBody() first to check, if empty
+ $usersbody = $up->getBody();
+
+ $s = '';
+ if ( !$this->including() ) {
+ $s = $up->getPageHeader();
+ }
+
+ if ( $usersbody ) {
+ $s .= $up->getNavigationBar();
+ $s .= Html::rawElement( 'ul', [], $usersbody );
+ $s .= $up->getNavigationBar();
+ } else {
+ $s .= $this->msg( 'listusers-noresult' )->parseAsBlock();
+ }
+
+ $this->getOutput()->addHTML( $s );
+ }
+
+ /**
+ * Return an array of subpages that this special page will accept.
+ *
+ * @return string[] subpages
+ */
+ public function getSubpagesForPrefixSearch() {
+ return User::getAllGroups();
+ }
+
+ protected function getGroupName() {
+ return 'users';
+ }
+}
+++ /dev/null
-<?php
-/**
- * Implements Special:Listfiles
- *
- * This program is free software; you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation; either version 2 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License along
- * with this program; if not, write to the Free Software Foundation, Inc.,
- * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
- * http://www.gnu.org/copyleft/gpl.html
- *
- * @file
- * @ingroup SpecialPage
- */
-
-class SpecialListFiles extends IncludableSpecialPage {
- public function __construct() {
- parent::__construct( 'Listfiles' );
- }
-
- public function execute( $par ) {
- $this->setHeaders();
- $this->outputHeader();
-
- if ( $this->including() ) {
- $userName = $par;
- $search = '';
- $showAll = false;
- } else {
- $userName = $this->getRequest()->getText( 'user', $par );
- $search = $this->getRequest()->getText( 'ilsearch', '' );
- $showAll = $this->getRequest()->getBool( 'ilshowall', false );
- }
-
- $pager = new ImageListPager(
- $this->getContext(),
- $userName,
- $search,
- $this->including(),
- $showAll
- );
-
- $out = $this->getOutput();
- if ( $this->including() ) {
- $out->addParserOutputContent( $pager->getBodyOutput() );
- } else {
- $user = $pager->getRelevantUser();
- $this->getSkin()->setRelevantUser( $user );
- $pager->getForm();
- $out->addParserOutputContent( $pager->getFullOutput() );
- }
- }
-
- /**
- * Return an array of subpages beginning with $search that this special page will accept.
- *
- * @param string $search Prefix to search for
- * @param int $limit Maximum number of results to return (usually 10)
- * @param int $offset Number of results to skip (usually 0)
- * @return string[] Matching subpages
- */
- public function prefixSearchSubpages( $search, $limit, $offset ) {
- $user = User::newFromName( $search );
- if ( !$user ) {
- // No prefix suggestion for invalid user
- return [];
- }
- // Autocomplete subpage as user list - public to allow caching
- return UserNamePrefixSearch::search( 'public', $search, $limit, $offset );
- }
-
- protected function getGroupName() {
- return 'media';
- }
-}
+++ /dev/null
-<?php
-/**
- * Implements Special:Listgrants
- *
- * This program is free software; you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation; either version 2 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License along
- * with this program; if not, write to the Free Software Foundation, Inc.,
- * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
- * http://www.gnu.org/copyleft/gpl.html
- *
- * @file
- * @ingroup SpecialPage
- */
-
-/**
- * This special page lists all defined rights grants and the associated rights.
- * See also @ref $wgGrantPermissions and @ref $wgGrantPermissionGroups.
- *
- * @ingroup SpecialPage
- */
-class SpecialListGrants extends SpecialPage {
- function __construct() {
- parent::__construct( 'Listgrants' );
- }
-
- /**
- * Show the special page
- * @param string|null $par
- */
- public function execute( $par ) {
- $this->setHeaders();
- $this->outputHeader();
-
- $out = $this->getOutput();
- $out->addModuleStyles( 'mediawiki.special' );
-
- $out->addHTML(
- \Html::openElement( 'table',
- [ 'class' => 'wikitable mw-listgrouprights-table' ] ) .
- '<tr>' .
- \Html::element( 'th', null, $this->msg( 'listgrants-grant' )->text() ) .
- \Html::element( 'th', null, $this->msg( 'listgrants-rights' )->text() ) .
- '</tr>'
- );
-
- foreach ( $this->getConfig()->get( 'GrantPermissions' ) as $grant => $rights ) {
- $descs = [];
- $rights = array_filter( $rights ); // remove ones with 'false'
- foreach ( $rights as $permission => $granted ) {
- $descs[] = $this->msg(
- 'listgrouprights-right-display',
- \User::getRightDescription( $permission ),
- '<span class="mw-listgrants-right-name">' . $permission . '</span>'
- )->parse();
- }
- if ( $descs === [] ) {
- $grantCellHtml = '';
- } else {
- sort( $descs );
- $grantCellHtml = '<ul><li>' . implode( "</li>\n<li>", $descs ) . '</li></ul>';
- }
-
- $id = Sanitizer::escapeIdForAttribute( $grant );
- $out->addHTML( \Html::rawElement( 'tr', [ 'id' => $id ],
- "<td>" .
- $this->msg(
- "listgrants-grant-display",
- \User::getGrantName( $grant ),
- "<span class='mw-listgrants-grant-name'>" . $id . "</span>"
- )->parse() .
- "</td>" .
- "<td>" . $grantCellHtml . "</td>"
- ) );
- }
-
- $out->addHTML( \Html::closeElement( 'table' ) );
- }
-
- protected function getGroupName() {
- return 'users';
- }
-}
+++ /dev/null
-<?php
-/**
- * Implements Special:Listgrouprights
- *
- * This program is free software; you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation; either version 2 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License along
- * with this program; if not, write to the Free Software Foundation, Inc.,
- * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
- * http://www.gnu.org/copyleft/gpl.html
- *
- * @file
- * @ingroup SpecialPage
- */
-
-use MediaWiki\MediaWikiServices;
-
-/**
- * This special page lists all defined user groups and the associated rights.
- * See also @ref $wgGroupPermissions.
- *
- * @ingroup SpecialPage
- * @author Petr Kadlec <mormegil@centrum.cz>
- */
-class SpecialListGroupRights extends SpecialPage {
- public function __construct() {
- parent::__construct( 'Listgrouprights' );
- }
-
- /**
- * Show the special page
- * @param string|null $par
- */
- public function execute( $par ) {
- $this->setHeaders();
- $this->outputHeader();
-
- $out = $this->getOutput();
- $out->addModuleStyles( 'mediawiki.special' );
-
- $out->wrapWikiMsg( "<div class=\"mw-listgrouprights-key\">\n$1\n</div>", 'listgrouprights-key' );
-
- $out->addHTML(
- Xml::openElement( 'table', [ 'class' => 'wikitable mw-listgrouprights-table' ] ) .
- '<tr>' .
- Xml::element( 'th', null, $this->msg( 'listgrouprights-group' )->text() ) .
- Xml::element( 'th', null, $this->msg( 'listgrouprights-rights' )->text() ) .
- '</tr>'
- );
-
- $config = $this->getConfig();
- $groupPermissions = $config->get( 'GroupPermissions' );
- $revokePermissions = $config->get( 'RevokePermissions' );
- $addGroups = $config->get( 'AddGroups' );
- $removeGroups = $config->get( 'RemoveGroups' );
- $groupsAddToSelf = $config->get( 'GroupsAddToSelf' );
- $groupsRemoveFromSelf = $config->get( 'GroupsRemoveFromSelf' );
- $allGroups = array_unique( array_merge(
- array_keys( $groupPermissions ),
- array_keys( $revokePermissions ),
- array_keys( $addGroups ),
- array_keys( $removeGroups ),
- array_keys( $groupsAddToSelf ),
- array_keys( $groupsRemoveFromSelf )
- ) );
- asort( $allGroups );
-
- $linkRenderer = $this->getLinkRenderer();
-
- foreach ( $allGroups as $group ) {
- $permissions = $groupPermissions[$group] ?? [];
- $groupname = ( $group == '*' ) // Replace * with a more descriptive groupname
- ? 'all'
- : $group;
-
- $groupnameLocalized = UserGroupMembership::getGroupName( $groupname );
-
- $grouppageLocalizedTitle = UserGroupMembership::getGroupPage( $groupname )
- ?: Title::newFromText( MWNamespace::getCanonicalName( NS_PROJECT ) . ':' . $groupname );
-
- if ( $group == '*' || !$grouppageLocalizedTitle ) {
- // Do not make a link for the generic * group or group with invalid group page
- $grouppage = htmlspecialchars( $groupnameLocalized );
- } else {
- $grouppage = $linkRenderer->makeLink(
- $grouppageLocalizedTitle,
- $groupnameLocalized
- );
- }
-
- if ( $group === 'user' ) {
- // Link to Special:listusers for implicit group 'user'
- $grouplink = '<br />' . $linkRenderer->makeKnownLink(
- SpecialPage::getTitleFor( 'Listusers' ),
- $this->msg( 'listgrouprights-members' )->text()
- );
- } elseif ( !in_array( $group, $config->get( 'ImplicitGroups' ) ) ) {
- $grouplink = '<br />' . $linkRenderer->makeKnownLink(
- SpecialPage::getTitleFor( 'Listusers' ),
- $this->msg( 'listgrouprights-members' )->text(),
- [],
- [ 'group' => $group ]
- );
- } else {
- // No link to Special:listusers for other implicit groups as they are unlistable
- $grouplink = '';
- }
-
- $revoke = $revokePermissions[$group] ?? [];
- $addgroups = $addGroups[$group] ?? [];
- $removegroups = $removeGroups[$group] ?? [];
- $addgroupsSelf = $groupsAddToSelf[$group] ?? [];
- $removegroupsSelf = $groupsRemoveFromSelf[$group] ?? [];
-
- $id = $group == '*' ? false : Sanitizer::escapeIdForAttribute( $group );
- $out->addHTML( Html::rawElement( 'tr', [ 'id' => $id ], "
- <td>$grouppage$grouplink</td>
- <td>" .
- $this->formatPermissions( $permissions, $revoke, $addgroups, $removegroups,
- $addgroupsSelf, $removegroupsSelf ) .
- '</td>
- '
- ) );
- }
- $out->addHTML( Xml::closeElement( 'table' ) );
- $this->outputNamespaceProtectionInfo();
- }
-
- private function outputNamespaceProtectionInfo() {
- $out = $this->getOutput();
- $namespaceProtection = $this->getConfig()->get( 'NamespaceProtection' );
-
- if ( count( $namespaceProtection ) == 0 ) {
- return;
- }
-
- $header = $this->msg( 'listgrouprights-namespaceprotection-header' )->text();
- $out->addHTML(
- Html::rawElement( 'h2', [], Html::element( 'span', [
- 'class' => 'mw-headline',
- 'id' => substr( Parser::guessSectionNameFromStrippedText( $header ), 1 )
- ], $header ) ) .
- Xml::openElement( 'table', [ 'class' => 'wikitable' ] ) .
- Html::element(
- 'th',
- [],
- $this->msg( 'listgrouprights-namespaceprotection-namespace' )->text()
- ) .
- Html::element(
- 'th',
- [],
- $this->msg( 'listgrouprights-namespaceprotection-restrictedto' )->text()
- )
- );
- $linkRenderer = $this->getLinkRenderer();
- ksort( $namespaceProtection );
- $validNamespaces = MWNamespace::getValidNamespaces();
- $contLang = MediaWikiServices::getInstance()->getContentLanguage();
- foreach ( $namespaceProtection as $namespace => $rights ) {
- if ( !in_array( $namespace, $validNamespaces ) ) {
- continue;
- }
-
- if ( $namespace == NS_MAIN ) {
- $namespaceText = $this->msg( 'blanknamespace' )->text();
- } else {
- $namespaceText = $contLang->convertNamespace( $namespace );
- }
-
- $out->addHTML(
- Xml::openElement( 'tr' ) .
- Html::rawElement(
- 'td',
- [],
- $linkRenderer->makeLink(
- SpecialPage::getTitleFor( 'Allpages' ),
- $namespaceText,
- [],
- [ 'namespace' => $namespace ]
- )
- ) .
- Xml::openElement( 'td' ) . Xml::openElement( 'ul' )
- );
-
- if ( !is_array( $rights ) ) {
- $rights = [ $rights ];
- }
-
- foreach ( $rights as $right ) {
- $out->addHTML(
- Html::rawElement( 'li', [], $this->msg(
- 'listgrouprights-right-display',
- User::getRightDescription( $right ),
- Html::element(
- 'span',
- [ 'class' => 'mw-listgrouprights-right-name' ],
- $right
- )
- )->parse() )
- );
- }
-
- $out->addHTML(
- Xml::closeElement( 'ul' ) .
- Xml::closeElement( 'td' ) .
- Xml::closeElement( 'tr' )
- );
- }
- $out->addHTML( Xml::closeElement( 'table' ) );
- }
-
- /**
- * Create a user-readable list of permissions from the given array.
- *
- * @param array $permissions Array of permission => bool (from $wgGroupPermissions items)
- * @param array $revoke Array of permission => bool (from $wgRevokePermissions items)
- * @param array $add Array of groups this group is allowed to add or true
- * @param array $remove Array of groups this group is allowed to remove or true
- * @param array $addSelf Array of groups this group is allowed to add to self or true
- * @param array $removeSelf Array of group this group is allowed to remove from self or true
- * @return string HTML list of all granted permissions
- */
- private function formatPermissions( $permissions, $revoke, $add, $remove, $addSelf, $removeSelf ) {
- $r = [];
- foreach ( $permissions as $permission => $granted ) {
- // show as granted only if it isn't revoked to prevent duplicate display of permissions
- if ( $granted && ( !isset( $revoke[$permission] ) || !$revoke[$permission] ) ) {
- $r[] = $this->msg( 'listgrouprights-right-display',
- User::getRightDescription( $permission ),
- '<span class="mw-listgrouprights-right-name">' . $permission . '</span>'
- )->parse();
- }
- }
- foreach ( $revoke as $permission => $revoked ) {
- if ( $revoked ) {
- $r[] = $this->msg( 'listgrouprights-right-revoked',
- User::getRightDescription( $permission ),
- '<span class="mw-listgrouprights-right-name">' . $permission . '</span>'
- )->parse();
- }
- }
-
- sort( $r );
-
- $lang = $this->getLanguage();
- $allGroups = User::getAllGroups();
-
- $changeGroups = [
- 'addgroup' => $add,
- 'removegroup' => $remove,
- 'addgroup-self' => $addSelf,
- 'removegroup-self' => $removeSelf
- ];
-
- foreach ( $changeGroups as $messageKey => $changeGroup ) {
- if ( $changeGroup === true ) {
- // For grep: listgrouprights-addgroup-all, listgrouprights-removegroup-all,
- // listgrouprights-addgroup-self-all, listgrouprights-removegroup-self-all
- $r[] = $this->msg( 'listgrouprights-' . $messageKey . '-all' )->escaped();
- } elseif ( is_array( $changeGroup ) ) {
- $changeGroup = array_intersect( array_values( array_unique( $changeGroup ) ), $allGroups );
- if ( count( $changeGroup ) ) {
- $groupLinks = [];
- foreach ( $changeGroup as $group ) {
- $groupLinks[] = UserGroupMembership::getLink( $group, $this->getContext(), 'wiki' );
- }
- // For grep: listgrouprights-addgroup, listgrouprights-removegroup,
- // listgrouprights-addgroup-self, listgrouprights-removegroup-self
- $r[] = $this->msg( 'listgrouprights-' . $messageKey,
- $lang->listToText( $groupLinks ), count( $changeGroup ) )->parse();
- }
- }
- }
-
- if ( empty( $r ) ) {
- return '';
- } else {
- return '<ul><li>' . implode( "</li>\n<li>", $r ) . '</li></ul>';
- }
- }
-
- protected function getGroupName() {
- return 'users';
- }
-}
+++ /dev/null
-<?php
-/**
- * Implements Special:Listusers
- *
- * Copyright © 2004 Brion Vibber, lcrocker, Tim Starling,
- * Domas Mituzas, Antoine Musso, Jens Frank, Zhengzhu,
- * 2006 Rob Church <robchur@gmail.com>
- *
- * This program is free software; you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation; either version 2 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License along
- * with this program; if not, write to the Free Software Foundation, Inc.,
- * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
- * http://www.gnu.org/copyleft/gpl.html
- *
- * @file
- * @ingroup SpecialPage
- */
-
-/**
- * @ingroup SpecialPage
- */
-class SpecialListUsers extends IncludableSpecialPage {
-
- public function __construct() {
- parent::__construct( 'Listusers' );
- }
-
- /**
- * @param string|null $par (optional) A group to list users from
- */
- public function execute( $par ) {
- $this->setHeaders();
- $this->outputHeader();
-
- $up = new UsersPager( $this->getContext(), $par, $this->including() );
-
- # getBody() first to check, if empty
- $usersbody = $up->getBody();
-
- $s = '';
- if ( !$this->including() ) {
- $s = $up->getPageHeader();
- }
-
- if ( $usersbody ) {
- $s .= $up->getNavigationBar();
- $s .= Html::rawElement( 'ul', [], $usersbody );
- $s .= $up->getNavigationBar();
- } else {
- $s .= $this->msg( 'listusers-noresult' )->parseAsBlock();
- }
-
- $this->getOutput()->addHTML( $s );
- }
-
- /**
- * Return an array of subpages that this special page will accept.
- *
- * @return string[] subpages
- */
- public function getSubpagesForPrefixSearch() {
- return User::getAllGroups();
- }
-
- protected function getGroupName() {
- return 'users';
- }
-}
--- /dev/null
+<?php
+/**
+ * Implements Special:Recentchanges
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+
+use MediaWiki\MediaWikiServices;
+use Wikimedia\Rdbms\IResultWrapper;
+use Wikimedia\Rdbms\FakeResultWrapper;
+
+/**
+ * A special page that lists last changes made to the wiki
+ *
+ * @ingroup SpecialPage
+ */
+class SpecialRecentChanges extends ChangesListSpecialPage {
+
+ protected static $savedQueriesPreferenceName = 'rcfilters-saved-queries';
+ protected static $daysPreferenceName = 'rcdays'; // Use general RecentChanges preference
+ protected static $limitPreferenceName = 'rcfilters-limit'; // Use RCFilters-specific preference
+ protected static $collapsedPreferenceName = 'rcfilters-rc-collapsed';
+
+ private $watchlistFilterGroupDefinition;
+
+ public function __construct( $name = 'Recentchanges', $restriction = '' ) {
+ parent::__construct( $name, $restriction );
+
+ $this->watchlistFilterGroupDefinition = [
+ 'name' => 'watchlist',
+ 'title' => 'rcfilters-filtergroup-watchlist',
+ 'class' => ChangesListStringOptionsFilterGroup::class,
+ 'priority' => -9,
+ 'isFullCoverage' => true,
+ 'filters' => [
+ [
+ 'name' => 'watched',
+ 'label' => 'rcfilters-filter-watchlist-watched-label',
+ 'description' => 'rcfilters-filter-watchlist-watched-description',
+ 'cssClassSuffix' => 'watched',
+ 'isRowApplicableCallable' => function ( $ctx, $rc ) {
+ return $rc->getAttribute( 'wl_user' );
+ }
+ ],
+ [
+ 'name' => 'watchednew',
+ 'label' => 'rcfilters-filter-watchlist-watchednew-label',
+ 'description' => 'rcfilters-filter-watchlist-watchednew-description',
+ 'cssClassSuffix' => 'watchednew',
+ 'isRowApplicableCallable' => function ( $ctx, $rc ) {
+ return $rc->getAttribute( 'wl_user' ) &&
+ $rc->getAttribute( 'rc_timestamp' ) &&
+ $rc->getAttribute( 'wl_notificationtimestamp' ) &&
+ $rc->getAttribute( 'rc_timestamp' ) >= $rc->getAttribute( 'wl_notificationtimestamp' );
+ },
+ ],
+ [
+ 'name' => 'notwatched',
+ 'label' => 'rcfilters-filter-watchlist-notwatched-label',
+ 'description' => 'rcfilters-filter-watchlist-notwatched-description',
+ 'cssClassSuffix' => 'notwatched',
+ 'isRowApplicableCallable' => function ( $ctx, $rc ) {
+ return $rc->getAttribute( 'wl_user' ) === null;
+ },
+ ]
+ ],
+ 'default' => ChangesListStringOptionsFilterGroup::NONE,
+ 'queryCallable' => function ( $specialPageClassName, $context, $dbr,
+ &$tables, &$fields, &$conds, &$query_options, &$join_conds, $selectedValues ) {
+ sort( $selectedValues );
+ $notwatchedCond = 'wl_user IS NULL';
+ $watchedCond = 'wl_user IS NOT NULL';
+ $newCond = 'rc_timestamp >= wl_notificationtimestamp';
+
+ if ( $selectedValues === [ 'notwatched' ] ) {
+ $conds[] = $notwatchedCond;
+ return;
+ }
+
+ if ( $selectedValues === [ 'watched' ] ) {
+ $conds[] = $watchedCond;
+ return;
+ }
+
+ if ( $selectedValues === [ 'watchednew' ] ) {
+ $conds[] = $dbr->makeList( [
+ $watchedCond,
+ $newCond
+ ], LIST_AND );
+ return;
+ }
+
+ if ( $selectedValues === [ 'notwatched', 'watched' ] ) {
+ // no filters
+ return;
+ }
+
+ if ( $selectedValues === [ 'notwatched', 'watchednew' ] ) {
+ $conds[] = $dbr->makeList( [
+ $notwatchedCond,
+ $dbr->makeList( [
+ $watchedCond,
+ $newCond
+ ], LIST_AND )
+ ], LIST_OR );
+ return;
+ }
+
+ if ( $selectedValues === [ 'watched', 'watchednew' ] ) {
+ $conds[] = $watchedCond;
+ return;
+ }
+
+ if ( $selectedValues === [ 'notwatched', 'watched', 'watchednew' ] ) {
+ // no filters
+ return;
+ }
+ }
+ ];
+ }
+
+ /**
+ * @param string|null $subpage
+ */
+ public function execute( $subpage ) {
+ // Backwards-compatibility: redirect to new feed URLs
+ $feedFormat = $this->getRequest()->getVal( 'feed' );
+ if ( !$this->including() && $feedFormat ) {
+ $query = $this->getFeedQuery();
+ $query['feedformat'] = $feedFormat === 'atom' ? 'atom' : 'rss';
+ $this->getOutput()->redirect( wfAppendQuery( wfScript( 'api' ), $query ) );
+
+ return;
+ }
+
+ // 10 seconds server-side caching max
+ $out = $this->getOutput();
+ $out->setCdnMaxage( 10 );
+ // Check if the client has a cached version
+ $lastmod = $this->checkLastModified();
+ if ( $lastmod === false ) {
+ return;
+ }
+
+ $this->addHelpLink(
+ '//meta.wikimedia.org/wiki/Special:MyLanguage/Help:Recent_changes',
+ true
+ );
+ parent::execute( $subpage );
+ }
+
+ /**
+ * @inheritDoc
+ */
+ protected function transformFilterDefinition( array $filterDefinition ) {
+ if ( isset( $filterDefinition['showHideSuffix'] ) ) {
+ $filterDefinition['showHide'] = 'rc' . $filterDefinition['showHideSuffix'];
+ }
+
+ return $filterDefinition;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ protected function registerFilters() {
+ parent::registerFilters();
+
+ if (
+ !$this->including() &&
+ $this->getUser()->isLoggedIn() &&
+ $this->getUser()->isAllowed( 'viewmywatchlist' )
+ ) {
+ $this->registerFiltersFromDefinitions( [ $this->watchlistFilterGroupDefinition ] );
+ $watchlistGroup = $this->getFilterGroup( 'watchlist' );
+ $watchlistGroup->getFilter( 'watched' )->setAsSupersetOf(
+ $watchlistGroup->getFilter( 'watchednew' )
+ );
+ }
+
+ $user = $this->getUser();
+
+ $significance = $this->getFilterGroup( 'significance' );
+ $hideMinor = $significance->getFilter( 'hideminor' );
+ $hideMinor->setDefault( $user->getBoolOption( 'hideminor' ) );
+
+ $automated = $this->getFilterGroup( 'automated' );
+ $hideBots = $automated->getFilter( 'hidebots' );
+ $hideBots->setDefault( true );
+
+ $reviewStatus = $this->getFilterGroup( 'reviewStatus' );
+ if ( $reviewStatus !== null ) {
+ // Conditional on feature being available and rights
+ if ( $user->getBoolOption( 'hidepatrolled' ) ) {
+ $reviewStatus->setDefault( 'unpatrolled' );
+ $legacyReviewStatus = $this->getFilterGroup( 'legacyReviewStatus' );
+ $legacyHidePatrolled = $legacyReviewStatus->getFilter( 'hidepatrolled' );
+ $legacyHidePatrolled->setDefault( true );
+ }
+ }
+
+ $changeType = $this->getFilterGroup( 'changeType' );
+ $hideCategorization = $changeType->getFilter( 'hidecategorization' );
+ if ( $hideCategorization !== null ) {
+ // Conditional on feature being available
+ $hideCategorization->setDefault( $user->getBoolOption( 'hidecategorization' ) );
+ }
+ }
+
+ /**
+ * Process $par and put options found in $opts. Used when including the page.
+ *
+ * @param string $par
+ * @param FormOptions $opts
+ */
+ public function parseParameters( $par, FormOptions $opts ) {
+ parent::parseParameters( $par, $opts );
+
+ $bits = preg_split( '/\s*,\s*/', trim( $par ) );
+ foreach ( $bits as $bit ) {
+ if ( is_numeric( $bit ) ) {
+ $opts['limit'] = $bit;
+ }
+
+ $m = [];
+ if ( preg_match( '/^limit=(\d+)$/', $bit, $m ) ) {
+ $opts['limit'] = $m[1];
+ }
+ if ( preg_match( '/^days=(\d+(?:\.\d+)?)$/', $bit, $m ) ) {
+ $opts['days'] = $m[1];
+ }
+ if ( preg_match( '/^namespace=(.*)$/', $bit, $m ) ) {
+ $opts['namespace'] = $m[1];
+ }
+ if ( preg_match( '/^tagfilter=(.*)$/', $bit, $m ) ) {
+ $opts['tagfilter'] = $m[1];
+ }
+ }
+ }
+
+ /**
+ * @inheritDoc
+ */
+ protected function doMainQuery( $tables, $fields, $conds, $query_options,
+ $join_conds, FormOptions $opts
+ ) {
+ $dbr = $this->getDB();
+ $user = $this->getUser();
+
+ $rcQuery = RecentChange::getQueryInfo();
+ $tables = array_merge( $tables, $rcQuery['tables'] );
+ $fields = array_merge( $rcQuery['fields'], $fields );
+ $join_conds = array_merge( $join_conds, $rcQuery['joins'] );
+
+ // JOIN on watchlist for users
+ if ( $user->isLoggedIn() && $user->isAllowed( 'viewmywatchlist' ) ) {
+ $tables[] = 'watchlist';
+ $fields[] = 'wl_user';
+ $fields[] = 'wl_notificationtimestamp';
+ $join_conds['watchlist'] = [ 'LEFT JOIN', [
+ 'wl_user' => $user->getId(),
+ 'wl_title=rc_title',
+ 'wl_namespace=rc_namespace'
+ ] ];
+ }
+
+ // JOIN on page, used for 'last revision' filter highlight
+ $tables[] = 'page';
+ $fields[] = 'page_latest';
+ $join_conds['page'] = [ 'LEFT JOIN', 'rc_cur_id=page_id' ];
+
+ $tagFilter = $opts['tagfilter'] ? explode( '|', $opts['tagfilter'] ) : [];
+ ChangeTags::modifyDisplayQuery(
+ $tables,
+ $fields,
+ $conds,
+ $join_conds,
+ $query_options,
+ $tagFilter
+ );
+
+ if ( !$this->runMainQueryHook( $tables, $fields, $conds, $query_options, $join_conds,
+ $opts )
+ ) {
+ return false;
+ }
+
+ if ( $this->areFiltersInConflict() ) {
+ return false;
+ }
+
+ $orderByAndLimit = [
+ 'ORDER BY' => 'rc_timestamp DESC',
+ 'LIMIT' => $opts['limit']
+ ];
+ if ( in_array( 'DISTINCT', $query_options ) ) {
+ // ChangeTags::modifyDisplayQuery() adds DISTINCT when filtering on multiple tags.
+ // In order to prevent DISTINCT from causing query performance problems,
+ // we have to GROUP BY the primary key. This in turn requires us to add
+ // the primary key to the end of the ORDER BY, and the old ORDER BY to the
+ // start of the GROUP BY
+ $orderByAndLimit['ORDER BY'] = 'rc_timestamp DESC, rc_id DESC';
+ $orderByAndLimit['GROUP BY'] = 'rc_timestamp, rc_id';
+ }
+ // array_merge() is used intentionally here so that hooks can, should
+ // they so desire, override the ORDER BY / LIMIT condition(s); prior to
+ // MediaWiki 1.26 this used to use the plus operator instead, which meant
+ // that extensions weren't able to change these conditions
+ $query_options = array_merge( $orderByAndLimit, $query_options );
+ $rows = $dbr->select(
+ $tables,
+ $fields,
+ // rc_new is not an ENUM, but adding a redundant rc_new IN (0,1) gives mysql enough
+ // knowledge to use an index merge if it wants (it may use some other index though).
+ $conds + [ 'rc_new' => [ 0, 1 ] ],
+ __METHOD__,
+ $query_options,
+ $join_conds
+ );
+
+ return $rows;
+ }
+
+ protected function getDB() {
+ return wfGetDB( DB_REPLICA, 'recentchanges' );
+ }
+
+ public function outputFeedLinks() {
+ $this->addFeedLinks( $this->getFeedQuery() );
+ }
+
+ /**
+ * Get URL query parameters for action=feedrecentchanges API feed of current recent changes view.
+ *
+ * @return array
+ */
+ protected function getFeedQuery() {
+ $query = array_filter( $this->getOptions()->getAllValues(), function ( $value ) {
+ // API handles empty parameters in a different way
+ return $value !== '';
+ } );
+ $query['action'] = 'feedrecentchanges';
+ $feedLimit = $this->getConfig()->get( 'FeedLimit' );
+ if ( $query['limit'] > $feedLimit ) {
+ $query['limit'] = $feedLimit;
+ }
+
+ return $query;
+ }
+
+ /**
+ * Build and output the actual changes list.
+ *
+ * @param IResultWrapper $rows Database rows
+ * @param FormOptions $opts
+ */
+ public function outputChangesList( $rows, $opts ) {
+ $limit = $opts['limit'];
+
+ $showWatcherCount = $this->getConfig()->get( 'RCShowWatchingUsers' )
+ && $this->getUser()->getOption( 'shownumberswatching' );
+ $watcherCache = [];
+
+ $counter = 1;
+ $list = ChangesList::newFromContext( $this->getContext(), $this->filterGroups );
+ $list->initChangesListRows( $rows );
+
+ $userShowHiddenCats = $this->getUser()->getBoolOption( 'showhiddencats' );
+ $rclistOutput = $list->beginRecentChangesList();
+ if ( $this->isStructuredFilterUiEnabled() ) {
+ $rclistOutput .= $this->makeLegend();
+ }
+
+ foreach ( $rows as $obj ) {
+ if ( $limit == 0 ) {
+ break;
+ }
+ $rc = RecentChange::newFromRow( $obj );
+
+ # Skip CatWatch entries for hidden cats based on user preference
+ if (
+ $rc->getAttribute( 'rc_type' ) == RC_CATEGORIZE &&
+ !$userShowHiddenCats &&
+ $rc->getParam( 'hidden-cat' )
+ ) {
+ continue;
+ }
+
+ $rc->counter = $counter++;
+ # Check if the page has been updated since the last visit
+ if ( $this->getConfig()->get( 'ShowUpdatedMarker' )
+ && !empty( $obj->wl_notificationtimestamp )
+ ) {
+ $rc->notificationtimestamp = ( $obj->rc_timestamp >= $obj->wl_notificationtimestamp );
+ } else {
+ $rc->notificationtimestamp = false; // Default
+ }
+ # Check the number of users watching the page
+ $rc->numberofWatchingusers = 0; // Default
+ if ( $showWatcherCount && $obj->rc_namespace >= 0 ) {
+ if ( !isset( $watcherCache[$obj->rc_namespace][$obj->rc_title] ) ) {
+ $watcherCache[$obj->rc_namespace][$obj->rc_title] =
+ MediaWikiServices::getInstance()->getWatchedItemStore()->countWatchers(
+ new TitleValue( (int)$obj->rc_namespace, $obj->rc_title )
+ );
+ }
+ $rc->numberofWatchingusers = $watcherCache[$obj->rc_namespace][$obj->rc_title];
+ }
+
+ $changeLine = $list->recentChangesLine( $rc, !empty( $obj->wl_user ), $counter );
+ if ( $changeLine !== false ) {
+ $rclistOutput .= $changeLine;
+ --$limit;
+ }
+ }
+ $rclistOutput .= $list->endRecentChangesList();
+
+ if ( $rows->numRows() === 0 ) {
+ $this->outputNoResults();
+ if ( !$this->including() ) {
+ $this->getOutput()->setStatusCode( 404 );
+ }
+ } else {
+ $this->getOutput()->addHTML( $rclistOutput );
+ }
+ }
+
+ /**
+ * Set the text to be displayed above the changes
+ *
+ * @param FormOptions $opts
+ * @param int $numRows Number of rows in the result to show after this header
+ */
+ public function doHeader( $opts, $numRows ) {
+ $this->setTopText( $opts );
+
+ $defaults = $opts->getAllValues();
+ $nondefaults = $opts->getChangedValues();
+
+ $panel = [];
+ if ( !$this->isStructuredFilterUiEnabled() ) {
+ $panel[] = $this->makeLegend();
+ }
+ $panel[] = $this->optionsPanel( $defaults, $nondefaults, $numRows );
+ $panel[] = '<hr />';
+
+ $extraOpts = $this->getExtraOptions( $opts );
+ $extraOptsCount = count( $extraOpts );
+ $count = 0;
+ $submit = ' ' . Xml::submitButton( $this->msg( 'recentchanges-submit' )->text() );
+
+ $out = Xml::openElement( 'table', [ 'class' => 'mw-recentchanges-table' ] );
+ foreach ( $extraOpts as $name => $optionRow ) {
+ # Add submit button to the last row only
+ ++$count;
+ $addSubmit = ( $count === $extraOptsCount ) ? $submit : '';
+
+ $out .= Xml::openElement( 'tr', [ 'class' => $name . 'Form' ] );
+ if ( is_array( $optionRow ) ) {
+ $out .= Xml::tags(
+ 'td',
+ [ 'class' => 'mw-label mw-' . $name . '-label' ],
+ $optionRow[0]
+ );
+ $out .= Xml::tags(
+ 'td',
+ [ 'class' => 'mw-input' ],
+ $optionRow[1] . $addSubmit
+ );
+ } else {
+ $out .= Xml::tags(
+ 'td',
+ [ 'class' => 'mw-input', 'colspan' => 2 ],
+ $optionRow . $addSubmit
+ );
+ }
+ $out .= Xml::closeElement( 'tr' );
+ }
+ $out .= Xml::closeElement( 'table' );
+
+ $unconsumed = $opts->getUnconsumedValues();
+ foreach ( $unconsumed as $key => $value ) {
+ $out .= Html::hidden( $key, $value );
+ }
+
+ $t = $this->getPageTitle();
+ $out .= Html::hidden( 'title', $t->getPrefixedText() );
+ $form = Xml::tags( 'form', [ 'action' => wfScript() ], $out );
+ $panel[] = $form;
+ $panelString = implode( "\n", $panel );
+
+ $rcoptions = Xml::fieldset(
+ $this->msg( 'recentchanges-legend' )->text(),
+ $panelString,
+ [ 'class' => 'rcoptions cloptions' ]
+ );
+
+ // Insert a placeholder for RCFilters
+ if ( $this->isStructuredFilterUiEnabled() ) {
+ $rcfilterContainer = Html::element(
+ 'div',
+ [ 'class' => 'rcfilters-container' ]
+ );
+
+ $loadingContainer = Html::rawElement(
+ 'div',
+ [ 'class' => 'rcfilters-spinner' ],
+ Html::element(
+ 'div',
+ [ 'class' => 'rcfilters-spinner-bounce' ]
+ )
+ );
+
+ // Wrap both with rcfilters-head
+ $this->getOutput()->addHTML(
+ Html::rawElement(
+ 'div',
+ [ 'class' => 'rcfilters-head' ],
+ $rcfilterContainer . $rcoptions
+ )
+ );
+
+ // Add spinner
+ $this->getOutput()->addHTML( $loadingContainer );
+ } else {
+ $this->getOutput()->addHTML( $rcoptions );
+ }
+
+ $this->setBottomText( $opts );
+ }
+
+ /**
+ * Send the text to be displayed above the options
+ *
+ * @param FormOptions $opts Unused
+ */
+ function setTopText( FormOptions $opts ) {
+ $message = $this->msg( 'recentchangestext' )->inContentLanguage();
+ if ( !$message->isDisabled() ) {
+ $contLang = MediaWikiServices::getInstance()->getContentLanguage();
+ // Parse the message in this weird ugly way to preserve the ability to include interlanguage
+ // links in it (T172461). In the future when T66969 is resolved, perhaps we can just use
+ // $message->parse() instead. This code is copied from Message::parseText().
+ $parserOutput = MessageCache::singleton()->parse(
+ $message->plain(),
+ $this->getPageTitle(),
+ /*linestart*/true,
+ // Message class sets the interface flag to false when parsing in a language different than
+ // user language, and this is wiki content language
+ /*interface*/false,
+ $contLang
+ );
+ $content = $parserOutput->getText( [
+ 'enableSectionEditLinks' => false,
+ ] );
+ // Add only metadata here (including the language links), text is added below
+ $this->getOutput()->addParserOutputMetadata( $parserOutput );
+
+ $langAttributes = [
+ 'lang' => $contLang->getHtmlCode(),
+ 'dir' => $contLang->getDir(),
+ ];
+
+ $topLinksAttributes = [ 'class' => 'mw-recentchanges-toplinks' ];
+
+ if ( $this->isStructuredFilterUiEnabled() ) {
+ // Check whether the widget is already collapsed or expanded
+ $collapsedState = $this->getRequest()->getCookie( 'rcfilters-toplinks-collapsed-state' );
+ // Note that an empty/unset cookie means collapsed, so check for !== 'expanded'
+ $topLinksAttributes[ 'class' ] .= $collapsedState !== 'expanded' ?
+ ' mw-recentchanges-toplinks-collapsed' : '';
+
+ $this->getOutput()->enableOOUI();
+ $contentTitle = new OOUI\ButtonWidget( [
+ 'classes' => [ 'mw-recentchanges-toplinks-title' ],
+ 'label' => new OOUI\HtmlSnippet( $this->msg( 'rcfilters-other-review-tools' )->parse() ),
+ 'framed' => false,
+ 'indicator' => $collapsedState !== 'expanded' ? 'down' : 'up',
+ 'flags' => [ 'progressive' ],
+ ] );
+
+ $contentWrapper = Html::rawElement( 'div',
+ array_merge(
+ [ 'class' => 'mw-recentchanges-toplinks-content mw-collapsible-content' ],
+ $langAttributes
+ ),
+ $content
+ );
+ $content = $contentTitle . $contentWrapper;
+ } else {
+ // Language direction should be on the top div only
+ // if the title is not there. If it is there, it's
+ // interface direction, and the language/dir attributes
+ // should be on the content itself
+ $topLinksAttributes = array_merge( $topLinksAttributes, $langAttributes );
+ }
+
+ $this->getOutput()->addHTML(
+ Html::rawElement( 'div', $topLinksAttributes, $content )
+ );
+ }
+ }
+
+ /**
+ * Get options to be displayed in a form
+ *
+ * @param FormOptions $opts
+ * @return array
+ */
+ function getExtraOptions( $opts ) {
+ $opts->consumeValues( [
+ 'namespace', 'invert', 'associated', 'tagfilter'
+ ] );
+
+ $extraOpts = [];
+ $extraOpts['namespace'] = $this->namespaceFilterForm( $opts );
+
+ $tagFilter = ChangeTags::buildTagFilterSelector(
+ $opts['tagfilter'], false, $this->getContext() );
+ if ( count( $tagFilter ) ) {
+ $extraOpts['tagfilter'] = $tagFilter;
+ }
+
+ // Don't fire the hook for subclasses. (Or should we?)
+ if ( $this->getName() === 'Recentchanges' ) {
+ Hooks::run( 'SpecialRecentChangesPanel', [ &$extraOpts, $opts ] );
+ }
+
+ return $extraOpts;
+ }
+
+ /**
+ * Add page-specific modules.
+ */
+ protected function addModules() {
+ parent::addModules();
+ $out = $this->getOutput();
+ $out->addModules( 'mediawiki.special.recentchanges' );
+ }
+
+ /**
+ * Get last modified date, for client caching
+ * Don't use this if we are using the patrol feature, patrol changes don't
+ * update the timestamp
+ *
+ * @return string|bool
+ */
+ public function checkLastModified() {
+ $dbr = $this->getDB();
+ $lastmod = $dbr->selectField( 'recentchanges', 'MAX(rc_timestamp)', '', __METHOD__ );
+
+ return $lastmod;
+ }
+
+ /**
+ * Creates the choose namespace selection
+ *
+ * @param FormOptions $opts
+ * @return string[]
+ */
+ protected function namespaceFilterForm( FormOptions $opts ) {
+ $nsSelect = Html::namespaceSelector(
+ [ 'selected' => $opts['namespace'], 'all' => '', 'in-user-lang' => true ],
+ [ 'name' => 'namespace', 'id' => 'namespace' ]
+ );
+ $nsLabel = Xml::label( $this->msg( 'namespace' )->text(), 'namespace' );
+ $attribs = [ 'class' => [ 'mw-input-with-label' ] ];
+ // Hide the checkboxes when the namespace filter is set to 'all'.
+ if ( $opts['namespace'] === '' ) {
+ $attribs['class'][] = 'mw-input-hidden';
+ }
+ $invert = Html::rawElement( 'span', $attribs, Xml::checkLabel(
+ $this->msg( 'invert' )->text(), 'invert', 'nsinvert',
+ $opts['invert'],
+ [ 'title' => $this->msg( 'tooltip-invert' )->text() ]
+ ) );
+ $associated = Html::rawElement( 'span', $attribs, Xml::checkLabel(
+ $this->msg( 'namespace_association' )->text(), 'associated', 'nsassociated',
+ $opts['associated'],
+ [ 'title' => $this->msg( 'tooltip-namespace_association' )->text() ]
+ ) );
+
+ return [ $nsLabel, "$nsSelect $invert $associated" ];
+ }
+
+ /**
+ * Filter $rows by categories set in $opts
+ *
+ * @deprecated since 1.31
+ *
+ * @param IResultWrapper &$rows Database rows
+ * @param FormOptions $opts
+ */
+ function filterByCategories( &$rows, FormOptions $opts ) {
+ wfDeprecated( __METHOD__, '1.31' );
+
+ $categories = array_map( 'trim', explode( '|', $opts['categories'] ) );
+
+ if ( $categories === [] ) {
+ return;
+ }
+
+ # Filter categories
+ $cats = [];
+ foreach ( $categories as $cat ) {
+ $cat = trim( $cat );
+ if ( $cat == '' ) {
+ continue;
+ }
+ $cats[] = $cat;
+ }
+
+ # Filter articles
+ $articles = [];
+ $a2r = [];
+ $rowsarr = [];
+ foreach ( $rows as $k => $r ) {
+ $nt = Title::makeTitle( $r->rc_namespace, $r->rc_title );
+ $id = $nt->getArticleID();
+ if ( $id == 0 ) {
+ continue; # Page might have been deleted...
+ }
+ if ( !in_array( $id, $articles ) ) {
+ $articles[] = $id;
+ }
+ if ( !isset( $a2r[$id] ) ) {
+ $a2r[$id] = [];
+ }
+ $a2r[$id][] = $k;
+ $rowsarr[$k] = $r;
+ }
+
+ # Shortcut?
+ if ( $articles === [] || $cats === [] ) {
+ return;
+ }
+
+ # Look up
+ $catFind = new CategoryFinder;
+ $catFind->seed( $articles, $cats, $opts['categories_any'] ? 'OR' : 'AND' );
+ $match = $catFind->run();
+
+ # Filter
+ $newrows = [];
+ foreach ( $match as $id ) {
+ foreach ( $a2r[$id] as $rev ) {
+ $k = $rev;
+ $newrows[$k] = $rowsarr[$k];
+ }
+ }
+ $rows = new FakeResultWrapper( array_values( $newrows ) );
+ }
+
+ /**
+ * Makes change an option link which carries all the other options
+ *
+ * @param string $title
+ * @param array $override Options to override
+ * @param array $options Current options
+ * @param bool $active Whether to show the link in bold
+ * @return string
+ */
+ function makeOptionsLink( $title, $override, $options, $active = false ) {
+ $params = $this->convertParamsForLink( $override + $options );
+
+ if ( $active ) {
+ $title = new HtmlArmor( '<strong>' . htmlspecialchars( $title ) . '</strong>' );
+ }
+
+ return $this->getLinkRenderer()->makeKnownLink( $this->getPageTitle(), $title, [
+ 'data-params' => json_encode( $override ),
+ 'data-keys' => implode( ',', array_keys( $override ) ),
+ ], $params );
+ }
+
+ /**
+ * Creates the options panel.
+ *
+ * @param array $defaults
+ * @param array $nondefaults
+ * @param int $numRows Number of rows in the result to show after this header
+ * @return string
+ */
+ function optionsPanel( $defaults, $nondefaults, $numRows ) {
+ $options = $nondefaults + $defaults;
+
+ $note = '';
+ $msg = $this->msg( 'rclegend' );
+ if ( !$msg->isDisabled() ) {
+ $note .= Html::rawElement(
+ 'div',
+ [ 'class' => 'mw-rclegend' ],
+ $msg->parse()
+ );
+ }
+
+ $lang = $this->getLanguage();
+ $user = $this->getUser();
+ $config = $this->getConfig();
+ if ( $options['from'] ) {
+ $resetLink = $this->makeOptionsLink( $this->msg( 'rclistfromreset' ),
+ [ 'from' => '' ], $nondefaults );
+
+ $noteFromMsg = $this->msg( 'rcnotefrom' )
+ ->numParams( $options['limit'] )
+ ->params(
+ $lang->userTimeAndDate( $options['from'], $user ),
+ $lang->userDate( $options['from'], $user ),
+ $lang->userTime( $options['from'], $user )
+ )
+ ->numParams( $numRows );
+ $note .= Html::rawElement(
+ 'span',
+ [ 'class' => 'rcnotefrom' ],
+ $noteFromMsg->parse()
+ ) .
+ ' ' .
+ Html::rawElement(
+ 'span',
+ [ 'class' => 'rcoptions-listfromreset' ],
+ $this->msg( 'parentheses' )->rawParams( $resetLink )->parse()
+ ) .
+ '<br />';
+ }
+
+ # Sort data for display and make sure it's unique after we've added user data.
+ $linkLimits = $config->get( 'RCLinkLimits' );
+ $linkLimits[] = $options['limit'];
+ sort( $linkLimits );
+ $linkLimits = array_unique( $linkLimits );
+
+ $linkDays = $config->get( 'RCLinkDays' );
+ $linkDays[] = $options['days'];
+ sort( $linkDays );
+ $linkDays = array_unique( $linkDays );
+
+ // limit links
+ $cl = [];
+ foreach ( $linkLimits as $value ) {
+ $cl[] = $this->makeOptionsLink( $lang->formatNum( $value ),
+ [ 'limit' => $value ], $nondefaults, $value == $options['limit'] );
+ }
+ $cl = $lang->pipeList( $cl );
+
+ // day links, reset 'from' to none
+ $dl = [];
+ foreach ( $linkDays as $value ) {
+ $dl[] = $this->makeOptionsLink( $lang->formatNum( $value ),
+ [ 'days' => $value, 'from' => '' ], $nondefaults, $value == $options['days'] );
+ }
+ $dl = $lang->pipeList( $dl );
+
+ $showhide = [ 'show', 'hide' ];
+
+ $links = [];
+
+ foreach ( $this->getLegacyShowHideFilters() as $key => $filter ) {
+ $msg = $filter->getShowHide();
+ $linkMessage = $this->msg( $msg . '-' . $showhide[1 - $options[$key]] );
+ // Extensions can define additional filters, but don't need to define the corresponding
+ // messages. If they don't exist, just fall back to 'show' and 'hide'.
+ if ( !$linkMessage->exists() ) {
+ $linkMessage = $this->msg( $showhide[1 - $options[$key]] );
+ }
+
+ $link = $this->makeOptionsLink( $linkMessage->text(),
+ [ $key => 1 - $options[$key] ], $nondefaults );
+
+ $attribs = [
+ 'class' => "$msg rcshowhideoption clshowhideoption",
+ 'data-filter-name' => $filter->getName(),
+ ];
+
+ if ( $filter->isFeatureAvailableOnStructuredUi( $this ) ) {
+ $attribs['data-feature-in-structured-ui'] = true;
+ }
+
+ $links[] = Html::rawElement(
+ 'span',
+ $attribs,
+ $this->msg( $msg )->rawParams( $link )->parse()
+ );
+ }
+
+ // show from this onward link
+ $timestamp = wfTimestampNow();
+ $now = $lang->userTimeAndDate( $timestamp, $user );
+ $timenow = $lang->userTime( $timestamp, $user );
+ $datenow = $lang->userDate( $timestamp, $user );
+ $pipedLinks = '<span class="rcshowhide">' . $lang->pipeList( $links ) . '</span>';
+
+ $rclinks = Html::rawElement(
+ 'span',
+ [ 'class' => 'rclinks' ],
+ $this->msg( 'rclinks' )->rawParams( $cl, $dl, '' )->parse()
+ );
+
+ $rclistfrom = Html::rawElement(
+ 'span',
+ [ 'class' => 'rclistfrom' ],
+ $this->makeOptionsLink(
+ $this->msg( 'rclistfrom' )->plaintextParams( $now, $timenow, $datenow )->parse(),
+ [ 'from' => $timestamp ],
+ $nondefaults
+ )
+ );
+
+ return "{$note}$rclinks<br />$pipedLinks<br />$rclistfrom";
+ }
+
+ public function isIncludable() {
+ return true;
+ }
+
+ protected function getCacheTTL() {
+ return 60 * 5;
+ }
+
+ public function getDefaultLimit() {
+ $systemPrefValue = $this->getUser()->getIntOption( 'rclimit' );
+ // Prefer the RCFilters-specific preference if RCFilters is enabled
+ if ( $this->isStructuredFilterUiEnabled() ) {
+ return $this->getUser()->getIntOption( static::$limitPreferenceName, $systemPrefValue );
+ }
+
+ // Otherwise, use the system rclimit preference value
+ return $systemPrefValue;
+ }
+}
--- /dev/null
+<?php
+/**
+ * Implements Special:Recentchangeslinked
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+
+/**
+ * This is to display changes made to all articles linked in an article.
+ *
+ * @ingroup SpecialPage
+ */
+class SpecialRecentChangesLinked extends SpecialRecentChanges {
+ /** @var bool|Title */
+ protected $rclTargetTitle;
+
+ function __construct() {
+ parent::__construct( 'Recentchangeslinked' );
+ }
+
+ public function getDefaultOptions() {
+ $opts = parent::getDefaultOptions();
+ $opts->add( 'target', '' );
+ $opts->add( 'showlinkedto', false );
+
+ return $opts;
+ }
+
+ public function parseParameters( $par, FormOptions $opts ) {
+ $opts['target'] = $par;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ protected function doMainQuery( $tables, $select, $conds, $query_options,
+ $join_conds, FormOptions $opts
+ ) {
+ $target = $opts['target'];
+ $showlinkedto = $opts['showlinkedto'];
+ $limit = $opts['limit'];
+
+ if ( $target === '' ) {
+ return false;
+ }
+ $outputPage = $this->getOutput();
+ $title = Title::newFromText( $target );
+ if ( !$title || $title->isExternal() ) {
+ $outputPage->addHTML(
+ Html::errorBox( $this->msg( 'allpagesbadtitle' )->parse() )
+ );
+ return false;
+ }
+
+ $outputPage->setPageTitle( $this->msg( 'recentchangeslinked-title', $title->getPrefixedText() ) );
+
+ /*
+ * Ordinary links are in the pagelinks table, while transclusions are
+ * in the templatelinks table, categorizations in categorylinks and
+ * image use in imagelinks. We need to somehow combine all these.
+ * Special:Whatlinkshere does this by firing multiple queries and
+ * merging the results, but the code we inherit from our parent class
+ * expects only one result set so we use UNION instead.
+ */
+
+ $dbr = wfGetDB( DB_REPLICA, 'recentchangeslinked' );
+ $id = $title->getArticleID();
+ $ns = $title->getNamespace();
+ $dbkey = $title->getDBkey();
+
+ $rcQuery = RecentChange::getQueryInfo();
+ $tables = array_merge( $tables, $rcQuery['tables'] );
+ $select = array_merge( $rcQuery['fields'], $select );
+ $join_conds = array_merge( $join_conds, $rcQuery['joins'] );
+
+ // left join with watchlist table to highlight watched rows
+ $uid = $this->getUser()->getId();
+ if ( $uid && $this->getUser()->isAllowed( 'viewmywatchlist' ) ) {
+ $tables[] = 'watchlist';
+ $select[] = 'wl_user';
+ $join_conds['watchlist'] = [ 'LEFT JOIN', [
+ 'wl_user' => $uid,
+ 'wl_title=rc_title',
+ 'wl_namespace=rc_namespace'
+ ] ];
+ }
+
+ // JOIN on page, used for 'last revision' filter highlight
+ $tables[] = 'page';
+ $join_conds['page'] = [ 'LEFT JOIN', 'rc_cur_id=page_id' ];
+ $select[] = 'page_latest';
+
+ $tagFilter = $opts['tagfilter'] ? explode( '|', $opts['tagfilter'] ) : [];
+ ChangeTags::modifyDisplayQuery(
+ $tables,
+ $select,
+ $conds,
+ $join_conds,
+ $query_options,
+ $tagFilter
+ );
+
+ if ( $dbr->unionSupportsOrderAndLimit() ) {
+ if ( count( $tagFilter ) > 1 ) {
+ // ChangeTags::modifyDisplayQuery() will have added DISTINCT.
+ // To prevent this from causing query performance problems, we need to add
+ // a GROUP BY, and add rc_id to the ORDER BY.
+ $order = [
+ 'GROUP BY' => 'rc_timestamp, rc_id',
+ 'ORDER BY' => 'rc_timestamp DESC, rc_id DESC'
+ ];
+ } else {
+ $order = [ 'ORDER BY' => 'rc_timestamp DESC' ];
+ }
+ } else {
+ $order = [];
+ }
+
+ if ( !$this->runMainQueryHook( $tables, $select, $conds, $query_options, $join_conds,
+ $opts )
+ ) {
+ return false;
+ }
+
+ if ( $ns == NS_CATEGORY && !$showlinkedto ) {
+ // special handling for categories
+ // XXX: should try to make this less kludgy
+ $link_tables = [ 'categorylinks' ];
+ $showlinkedto = true;
+ } else {
+ // for now, always join on these tables; really should be configurable as in whatlinkshere
+ $link_tables = [ 'pagelinks', 'templatelinks' ];
+ // imagelinks only contains links to pages in NS_FILE
+ if ( $ns == NS_FILE || !$showlinkedto ) {
+ $link_tables[] = 'imagelinks';
+ }
+ }
+
+ if ( $id == 0 && !$showlinkedto ) {
+ return false; // nonexistent pages can't link to any pages
+ }
+
+ // field name prefixes for all the various tables we might want to join with
+ $prefix = [
+ 'pagelinks' => 'pl',
+ 'templatelinks' => 'tl',
+ 'categorylinks' => 'cl',
+ 'imagelinks' => 'il'
+ ];
+
+ $subsql = []; // SELECT statements to combine with UNION
+
+ foreach ( $link_tables as $link_table ) {
+ $pfx = $prefix[$link_table];
+
+ // imagelinks and categorylinks tables have no xx_namespace field,
+ // and have xx_to instead of xx_title
+ if ( $link_table == 'imagelinks' ) {
+ $link_ns = NS_FILE;
+ } elseif ( $link_table == 'categorylinks' ) {
+ $link_ns = NS_CATEGORY;
+ } else {
+ $link_ns = 0;
+ }
+
+ if ( $showlinkedto ) {
+ // find changes to pages linking to this page
+ if ( $link_ns ) {
+ if ( $ns != $link_ns ) {
+ continue;
+ } // should never happen, but check anyway
+ $subconds = [ "{$pfx}_to" => $dbkey ];
+ } else {
+ $subconds = [ "{$pfx}_namespace" => $ns, "{$pfx}_title" => $dbkey ];
+ }
+ $subjoin = "rc_cur_id = {$pfx}_from";
+ } else {
+ // find changes to pages linked from this page
+ $subconds = [ "{$pfx}_from" => $id ];
+ if ( $link_table == 'imagelinks' || $link_table == 'categorylinks' ) {
+ $subconds["rc_namespace"] = $link_ns;
+ $subjoin = "rc_title = {$pfx}_to";
+ } else {
+ $subjoin = [ "rc_namespace = {$pfx}_namespace", "rc_title = {$pfx}_title" ];
+ }
+ }
+
+ $query = $dbr->selectSQLText(
+ array_merge( $tables, [ $link_table ] ),
+ $select,
+ $conds + $subconds,
+ __METHOD__,
+ $order + $query_options,
+ $join_conds + [ $link_table => [ 'JOIN', $subjoin ] ]
+ );
+
+ if ( $dbr->unionSupportsOrderAndLimit() ) {
+ $query = $dbr->limitResult( $query, $limit );
+ }
+
+ $subsql[] = $query;
+ }
+
+ if ( count( $subsql ) == 0 ) {
+ return false; // should never happen
+ }
+ if ( count( $subsql ) == 1 && $dbr->unionSupportsOrderAndLimit() ) {
+ $sql = $subsql[0];
+ } else {
+ // need to resort and relimit after union
+ $sql = $dbr->unionQueries( $subsql, $dbr::UNION_DISTINCT ) .
+ ' ORDER BY rc_timestamp DESC';
+ $sql = $dbr->limitResult( $sql, $limit, false );
+ }
+
+ $res = $dbr->query( $sql, __METHOD__ );
+
+ if ( $res->numRows() == 0 ) {
+ $this->mResultEmpty = true;
+ }
+
+ return $res;
+ }
+
+ function setTopText( FormOptions $opts ) {
+ $target = $this->getTargetTitle();
+ if ( $target ) {
+ $this->getOutput()->addBacklinkSubtitle( $target );
+ $this->getSkin()->setRelevantTitle( $target );
+ }
+ }
+
+ /**
+ * Get options to be displayed in a form
+ *
+ * @param FormOptions $opts
+ * @return array
+ */
+ function getExtraOptions( $opts ) {
+ $extraOpts = parent::getExtraOptions( $opts );
+
+ $opts->consumeValues( [ 'showlinkedto', 'target' ] );
+
+ $extraOpts['target'] = [ $this->msg( 'recentchangeslinked-page' )->escaped(),
+ Xml::input( 'target', 40, str_replace( '_', ' ', $opts['target'] ) ) .
+ Xml::check( 'showlinkedto', $opts['showlinkedto'], [ 'id' => 'showlinkedto' ] ) . ' ' .
+ Xml::label( $this->msg( 'recentchangeslinked-to' )->text(), 'showlinkedto' ) ];
+
+ $this->addHelpLink( 'Help:Related changes' );
+ return $extraOpts;
+ }
+
+ /**
+ * @return Title
+ */
+ function getTargetTitle() {
+ if ( $this->rclTargetTitle === null ) {
+ $opts = $this->getOptions();
+ if ( isset( $opts['target'] ) && $opts['target'] !== '' ) {
+ $this->rclTargetTitle = Title::newFromText( $opts['target'] );
+ } else {
+ $this->rclTargetTitle = false;
+ }
+ }
+
+ return $this->rclTargetTitle;
+ }
+
+ /**
+ * Return an array of subpages beginning with $search that this special page will accept.
+ *
+ * @param string $search Prefix to search for
+ * @param int $limit Maximum number of results to return (usually 10)
+ * @param int $offset Number of results to skip (usually 0)
+ * @return string[] Matching subpages
+ */
+ public function prefixSearchSubpages( $search, $limit, $offset ) {
+ return $this->prefixSearchString( $search, $limit, $offset );
+ }
+
+ protected function outputNoResults() {
+ $targetTitle = $this->getTargetTitle();
+ if ( $targetTitle === false ) {
+ $this->getOutput()->addHTML(
+ Html::rawElement(
+ 'div',
+ [ 'class' => 'mw-changeslist-empty mw-changeslist-notargetpage' ],
+ $this->msg( 'recentchanges-notargetpage' )->parse()
+ )
+ );
+ } elseif ( !$targetTitle || $targetTitle->isExternal() ) {
+ $this->getOutput()->addHTML(
+ Html::rawElement(
+ 'div',
+ [ 'class' => 'mw-changeslist-empty mw-changeslist-invalidtargetpage' ],
+ $this->msg( 'allpagesbadtitle' )->parse()
+ )
+ );
+ } else {
+ parent::outputNoResults();
+ }
+ }
+}
+++ /dev/null
-<?php
-/**
- * Implements Special:Recentchanges
- *
- * This program is free software; you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation; either version 2 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License along
- * with this program; if not, write to the Free Software Foundation, Inc.,
- * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
- * http://www.gnu.org/copyleft/gpl.html
- *
- * @file
- * @ingroup SpecialPage
- */
-
-use MediaWiki\MediaWikiServices;
-use Wikimedia\Rdbms\IResultWrapper;
-use Wikimedia\Rdbms\FakeResultWrapper;
-
-/**
- * A special page that lists last changes made to the wiki
- *
- * @ingroup SpecialPage
- */
-class SpecialRecentChanges extends ChangesListSpecialPage {
-
- protected static $savedQueriesPreferenceName = 'rcfilters-saved-queries';
- protected static $daysPreferenceName = 'rcdays'; // Use general RecentChanges preference
- protected static $limitPreferenceName = 'rcfilters-limit'; // Use RCFilters-specific preference
- protected static $collapsedPreferenceName = 'rcfilters-rc-collapsed';
-
- private $watchlistFilterGroupDefinition;
-
- public function __construct( $name = 'Recentchanges', $restriction = '' ) {
- parent::__construct( $name, $restriction );
-
- $this->watchlistFilterGroupDefinition = [
- 'name' => 'watchlist',
- 'title' => 'rcfilters-filtergroup-watchlist',
- 'class' => ChangesListStringOptionsFilterGroup::class,
- 'priority' => -9,
- 'isFullCoverage' => true,
- 'filters' => [
- [
- 'name' => 'watched',
- 'label' => 'rcfilters-filter-watchlist-watched-label',
- 'description' => 'rcfilters-filter-watchlist-watched-description',
- 'cssClassSuffix' => 'watched',
- 'isRowApplicableCallable' => function ( $ctx, $rc ) {
- return $rc->getAttribute( 'wl_user' );
- }
- ],
- [
- 'name' => 'watchednew',
- 'label' => 'rcfilters-filter-watchlist-watchednew-label',
- 'description' => 'rcfilters-filter-watchlist-watchednew-description',
- 'cssClassSuffix' => 'watchednew',
- 'isRowApplicableCallable' => function ( $ctx, $rc ) {
- return $rc->getAttribute( 'wl_user' ) &&
- $rc->getAttribute( 'rc_timestamp' ) &&
- $rc->getAttribute( 'wl_notificationtimestamp' ) &&
- $rc->getAttribute( 'rc_timestamp' ) >= $rc->getAttribute( 'wl_notificationtimestamp' );
- },
- ],
- [
- 'name' => 'notwatched',
- 'label' => 'rcfilters-filter-watchlist-notwatched-label',
- 'description' => 'rcfilters-filter-watchlist-notwatched-description',
- 'cssClassSuffix' => 'notwatched',
- 'isRowApplicableCallable' => function ( $ctx, $rc ) {
- return $rc->getAttribute( 'wl_user' ) === null;
- },
- ]
- ],
- 'default' => ChangesListStringOptionsFilterGroup::NONE,
- 'queryCallable' => function ( $specialPageClassName, $context, $dbr,
- &$tables, &$fields, &$conds, &$query_options, &$join_conds, $selectedValues ) {
- sort( $selectedValues );
- $notwatchedCond = 'wl_user IS NULL';
- $watchedCond = 'wl_user IS NOT NULL';
- $newCond = 'rc_timestamp >= wl_notificationtimestamp';
-
- if ( $selectedValues === [ 'notwatched' ] ) {
- $conds[] = $notwatchedCond;
- return;
- }
-
- if ( $selectedValues === [ 'watched' ] ) {
- $conds[] = $watchedCond;
- return;
- }
-
- if ( $selectedValues === [ 'watchednew' ] ) {
- $conds[] = $dbr->makeList( [
- $watchedCond,
- $newCond
- ], LIST_AND );
- return;
- }
-
- if ( $selectedValues === [ 'notwatched', 'watched' ] ) {
- // no filters
- return;
- }
-
- if ( $selectedValues === [ 'notwatched', 'watchednew' ] ) {
- $conds[] = $dbr->makeList( [
- $notwatchedCond,
- $dbr->makeList( [
- $watchedCond,
- $newCond
- ], LIST_AND )
- ], LIST_OR );
- return;
- }
-
- if ( $selectedValues === [ 'watched', 'watchednew' ] ) {
- $conds[] = $watchedCond;
- return;
- }
-
- if ( $selectedValues === [ 'notwatched', 'watched', 'watchednew' ] ) {
- // no filters
- return;
- }
- }
- ];
- }
-
- /**
- * @param string|null $subpage
- */
- public function execute( $subpage ) {
- // Backwards-compatibility: redirect to new feed URLs
- $feedFormat = $this->getRequest()->getVal( 'feed' );
- if ( !$this->including() && $feedFormat ) {
- $query = $this->getFeedQuery();
- $query['feedformat'] = $feedFormat === 'atom' ? 'atom' : 'rss';
- $this->getOutput()->redirect( wfAppendQuery( wfScript( 'api' ), $query ) );
-
- return;
- }
-
- // 10 seconds server-side caching max
- $out = $this->getOutput();
- $out->setCdnMaxage( 10 );
- // Check if the client has a cached version
- $lastmod = $this->checkLastModified();
- if ( $lastmod === false ) {
- return;
- }
-
- $this->addHelpLink(
- '//meta.wikimedia.org/wiki/Special:MyLanguage/Help:Recent_changes',
- true
- );
- parent::execute( $subpage );
- }
-
- /**
- * @inheritDoc
- */
- protected function transformFilterDefinition( array $filterDefinition ) {
- if ( isset( $filterDefinition['showHideSuffix'] ) ) {
- $filterDefinition['showHide'] = 'rc' . $filterDefinition['showHideSuffix'];
- }
-
- return $filterDefinition;
- }
-
- /**
- * @inheritDoc
- */
- protected function registerFilters() {
- parent::registerFilters();
-
- if (
- !$this->including() &&
- $this->getUser()->isLoggedIn() &&
- $this->getUser()->isAllowed( 'viewmywatchlist' )
- ) {
- $this->registerFiltersFromDefinitions( [ $this->watchlistFilterGroupDefinition ] );
- $watchlistGroup = $this->getFilterGroup( 'watchlist' );
- $watchlistGroup->getFilter( 'watched' )->setAsSupersetOf(
- $watchlistGroup->getFilter( 'watchednew' )
- );
- }
-
- $user = $this->getUser();
-
- $significance = $this->getFilterGroup( 'significance' );
- $hideMinor = $significance->getFilter( 'hideminor' );
- $hideMinor->setDefault( $user->getBoolOption( 'hideminor' ) );
-
- $automated = $this->getFilterGroup( 'automated' );
- $hideBots = $automated->getFilter( 'hidebots' );
- $hideBots->setDefault( true );
-
- $reviewStatus = $this->getFilterGroup( 'reviewStatus' );
- if ( $reviewStatus !== null ) {
- // Conditional on feature being available and rights
- if ( $user->getBoolOption( 'hidepatrolled' ) ) {
- $reviewStatus->setDefault( 'unpatrolled' );
- $legacyReviewStatus = $this->getFilterGroup( 'legacyReviewStatus' );
- $legacyHidePatrolled = $legacyReviewStatus->getFilter( 'hidepatrolled' );
- $legacyHidePatrolled->setDefault( true );
- }
- }
-
- $changeType = $this->getFilterGroup( 'changeType' );
- $hideCategorization = $changeType->getFilter( 'hidecategorization' );
- if ( $hideCategorization !== null ) {
- // Conditional on feature being available
- $hideCategorization->setDefault( $user->getBoolOption( 'hidecategorization' ) );
- }
- }
-
- /**
- * Process $par and put options found in $opts. Used when including the page.
- *
- * @param string $par
- * @param FormOptions $opts
- */
- public function parseParameters( $par, FormOptions $opts ) {
- parent::parseParameters( $par, $opts );
-
- $bits = preg_split( '/\s*,\s*/', trim( $par ) );
- foreach ( $bits as $bit ) {
- if ( is_numeric( $bit ) ) {
- $opts['limit'] = $bit;
- }
-
- $m = [];
- if ( preg_match( '/^limit=(\d+)$/', $bit, $m ) ) {
- $opts['limit'] = $m[1];
- }
- if ( preg_match( '/^days=(\d+(?:\.\d+)?)$/', $bit, $m ) ) {
- $opts['days'] = $m[1];
- }
- if ( preg_match( '/^namespace=(.*)$/', $bit, $m ) ) {
- $opts['namespace'] = $m[1];
- }
- if ( preg_match( '/^tagfilter=(.*)$/', $bit, $m ) ) {
- $opts['tagfilter'] = $m[1];
- }
- }
- }
-
- /**
- * @inheritDoc
- */
- protected function doMainQuery( $tables, $fields, $conds, $query_options,
- $join_conds, FormOptions $opts
- ) {
- $dbr = $this->getDB();
- $user = $this->getUser();
-
- $rcQuery = RecentChange::getQueryInfo();
- $tables = array_merge( $tables, $rcQuery['tables'] );
- $fields = array_merge( $rcQuery['fields'], $fields );
- $join_conds = array_merge( $join_conds, $rcQuery['joins'] );
-
- // JOIN on watchlist for users
- if ( $user->isLoggedIn() && $user->isAllowed( 'viewmywatchlist' ) ) {
- $tables[] = 'watchlist';
- $fields[] = 'wl_user';
- $fields[] = 'wl_notificationtimestamp';
- $join_conds['watchlist'] = [ 'LEFT JOIN', [
- 'wl_user' => $user->getId(),
- 'wl_title=rc_title',
- 'wl_namespace=rc_namespace'
- ] ];
- }
-
- // JOIN on page, used for 'last revision' filter highlight
- $tables[] = 'page';
- $fields[] = 'page_latest';
- $join_conds['page'] = [ 'LEFT JOIN', 'rc_cur_id=page_id' ];
-
- $tagFilter = $opts['tagfilter'] ? explode( '|', $opts['tagfilter'] ) : [];
- ChangeTags::modifyDisplayQuery(
- $tables,
- $fields,
- $conds,
- $join_conds,
- $query_options,
- $tagFilter
- );
-
- if ( !$this->runMainQueryHook( $tables, $fields, $conds, $query_options, $join_conds,
- $opts )
- ) {
- return false;
- }
-
- if ( $this->areFiltersInConflict() ) {
- return false;
- }
-
- $orderByAndLimit = [
- 'ORDER BY' => 'rc_timestamp DESC',
- 'LIMIT' => $opts['limit']
- ];
- if ( in_array( 'DISTINCT', $query_options ) ) {
- // ChangeTags::modifyDisplayQuery() adds DISTINCT when filtering on multiple tags.
- // In order to prevent DISTINCT from causing query performance problems,
- // we have to GROUP BY the primary key. This in turn requires us to add
- // the primary key to the end of the ORDER BY, and the old ORDER BY to the
- // start of the GROUP BY
- $orderByAndLimit['ORDER BY'] = 'rc_timestamp DESC, rc_id DESC';
- $orderByAndLimit['GROUP BY'] = 'rc_timestamp, rc_id';
- }
- // array_merge() is used intentionally here so that hooks can, should
- // they so desire, override the ORDER BY / LIMIT condition(s); prior to
- // MediaWiki 1.26 this used to use the plus operator instead, which meant
- // that extensions weren't able to change these conditions
- $query_options = array_merge( $orderByAndLimit, $query_options );
- $rows = $dbr->select(
- $tables,
- $fields,
- // rc_new is not an ENUM, but adding a redundant rc_new IN (0,1) gives mysql enough
- // knowledge to use an index merge if it wants (it may use some other index though).
- $conds + [ 'rc_new' => [ 0, 1 ] ],
- __METHOD__,
- $query_options,
- $join_conds
- );
-
- return $rows;
- }
-
- protected function getDB() {
- return wfGetDB( DB_REPLICA, 'recentchanges' );
- }
-
- public function outputFeedLinks() {
- $this->addFeedLinks( $this->getFeedQuery() );
- }
-
- /**
- * Get URL query parameters for action=feedrecentchanges API feed of current recent changes view.
- *
- * @return array
- */
- protected function getFeedQuery() {
- $query = array_filter( $this->getOptions()->getAllValues(), function ( $value ) {
- // API handles empty parameters in a different way
- return $value !== '';
- } );
- $query['action'] = 'feedrecentchanges';
- $feedLimit = $this->getConfig()->get( 'FeedLimit' );
- if ( $query['limit'] > $feedLimit ) {
- $query['limit'] = $feedLimit;
- }
-
- return $query;
- }
-
- /**
- * Build and output the actual changes list.
- *
- * @param IResultWrapper $rows Database rows
- * @param FormOptions $opts
- */
- public function outputChangesList( $rows, $opts ) {
- $limit = $opts['limit'];
-
- $showWatcherCount = $this->getConfig()->get( 'RCShowWatchingUsers' )
- && $this->getUser()->getOption( 'shownumberswatching' );
- $watcherCache = [];
-
- $counter = 1;
- $list = ChangesList::newFromContext( $this->getContext(), $this->filterGroups );
- $list->initChangesListRows( $rows );
-
- $userShowHiddenCats = $this->getUser()->getBoolOption( 'showhiddencats' );
- $rclistOutput = $list->beginRecentChangesList();
- if ( $this->isStructuredFilterUiEnabled() ) {
- $rclistOutput .= $this->makeLegend();
- }
-
- foreach ( $rows as $obj ) {
- if ( $limit == 0 ) {
- break;
- }
- $rc = RecentChange::newFromRow( $obj );
-
- # Skip CatWatch entries for hidden cats based on user preference
- if (
- $rc->getAttribute( 'rc_type' ) == RC_CATEGORIZE &&
- !$userShowHiddenCats &&
- $rc->getParam( 'hidden-cat' )
- ) {
- continue;
- }
-
- $rc->counter = $counter++;
- # Check if the page has been updated since the last visit
- if ( $this->getConfig()->get( 'ShowUpdatedMarker' )
- && !empty( $obj->wl_notificationtimestamp )
- ) {
- $rc->notificationtimestamp = ( $obj->rc_timestamp >= $obj->wl_notificationtimestamp );
- } else {
- $rc->notificationtimestamp = false; // Default
- }
- # Check the number of users watching the page
- $rc->numberofWatchingusers = 0; // Default
- if ( $showWatcherCount && $obj->rc_namespace >= 0 ) {
- if ( !isset( $watcherCache[$obj->rc_namespace][$obj->rc_title] ) ) {
- $watcherCache[$obj->rc_namespace][$obj->rc_title] =
- MediaWikiServices::getInstance()->getWatchedItemStore()->countWatchers(
- new TitleValue( (int)$obj->rc_namespace, $obj->rc_title )
- );
- }
- $rc->numberofWatchingusers = $watcherCache[$obj->rc_namespace][$obj->rc_title];
- }
-
- $changeLine = $list->recentChangesLine( $rc, !empty( $obj->wl_user ), $counter );
- if ( $changeLine !== false ) {
- $rclistOutput .= $changeLine;
- --$limit;
- }
- }
- $rclistOutput .= $list->endRecentChangesList();
-
- if ( $rows->numRows() === 0 ) {
- $this->outputNoResults();
- if ( !$this->including() ) {
- $this->getOutput()->setStatusCode( 404 );
- }
- } else {
- $this->getOutput()->addHTML( $rclistOutput );
- }
- }
-
- /**
- * Set the text to be displayed above the changes
- *
- * @param FormOptions $opts
- * @param int $numRows Number of rows in the result to show after this header
- */
- public function doHeader( $opts, $numRows ) {
- $this->setTopText( $opts );
-
- $defaults = $opts->getAllValues();
- $nondefaults = $opts->getChangedValues();
-
- $panel = [];
- if ( !$this->isStructuredFilterUiEnabled() ) {
- $panel[] = $this->makeLegend();
- }
- $panel[] = $this->optionsPanel( $defaults, $nondefaults, $numRows );
- $panel[] = '<hr />';
-
- $extraOpts = $this->getExtraOptions( $opts );
- $extraOptsCount = count( $extraOpts );
- $count = 0;
- $submit = ' ' . Xml::submitButton( $this->msg( 'recentchanges-submit' )->text() );
-
- $out = Xml::openElement( 'table', [ 'class' => 'mw-recentchanges-table' ] );
- foreach ( $extraOpts as $name => $optionRow ) {
- # Add submit button to the last row only
- ++$count;
- $addSubmit = ( $count === $extraOptsCount ) ? $submit : '';
-
- $out .= Xml::openElement( 'tr', [ 'class' => $name . 'Form' ] );
- if ( is_array( $optionRow ) ) {
- $out .= Xml::tags(
- 'td',
- [ 'class' => 'mw-label mw-' . $name . '-label' ],
- $optionRow[0]
- );
- $out .= Xml::tags(
- 'td',
- [ 'class' => 'mw-input' ],
- $optionRow[1] . $addSubmit
- );
- } else {
- $out .= Xml::tags(
- 'td',
- [ 'class' => 'mw-input', 'colspan' => 2 ],
- $optionRow . $addSubmit
- );
- }
- $out .= Xml::closeElement( 'tr' );
- }
- $out .= Xml::closeElement( 'table' );
-
- $unconsumed = $opts->getUnconsumedValues();
- foreach ( $unconsumed as $key => $value ) {
- $out .= Html::hidden( $key, $value );
- }
-
- $t = $this->getPageTitle();
- $out .= Html::hidden( 'title', $t->getPrefixedText() );
- $form = Xml::tags( 'form', [ 'action' => wfScript() ], $out );
- $panel[] = $form;
- $panelString = implode( "\n", $panel );
-
- $rcoptions = Xml::fieldset(
- $this->msg( 'recentchanges-legend' )->text(),
- $panelString,
- [ 'class' => 'rcoptions cloptions' ]
- );
-
- // Insert a placeholder for RCFilters
- if ( $this->isStructuredFilterUiEnabled() ) {
- $rcfilterContainer = Html::element(
- 'div',
- [ 'class' => 'rcfilters-container' ]
- );
-
- $loadingContainer = Html::rawElement(
- 'div',
- [ 'class' => 'rcfilters-spinner' ],
- Html::element(
- 'div',
- [ 'class' => 'rcfilters-spinner-bounce' ]
- )
- );
-
- // Wrap both with rcfilters-head
- $this->getOutput()->addHTML(
- Html::rawElement(
- 'div',
- [ 'class' => 'rcfilters-head' ],
- $rcfilterContainer . $rcoptions
- )
- );
-
- // Add spinner
- $this->getOutput()->addHTML( $loadingContainer );
- } else {
- $this->getOutput()->addHTML( $rcoptions );
- }
-
- $this->setBottomText( $opts );
- }
-
- /**
- * Send the text to be displayed above the options
- *
- * @param FormOptions $opts Unused
- */
- function setTopText( FormOptions $opts ) {
- $message = $this->msg( 'recentchangestext' )->inContentLanguage();
- if ( !$message->isDisabled() ) {
- $contLang = MediaWikiServices::getInstance()->getContentLanguage();
- // Parse the message in this weird ugly way to preserve the ability to include interlanguage
- // links in it (T172461). In the future when T66969 is resolved, perhaps we can just use
- // $message->parse() instead. This code is copied from Message::parseText().
- $parserOutput = MessageCache::singleton()->parse(
- $message->plain(),
- $this->getPageTitle(),
- /*linestart*/true,
- // Message class sets the interface flag to false when parsing in a language different than
- // user language, and this is wiki content language
- /*interface*/false,
- $contLang
- );
- $content = $parserOutput->getText( [
- 'enableSectionEditLinks' => false,
- ] );
- // Add only metadata here (including the language links), text is added below
- $this->getOutput()->addParserOutputMetadata( $parserOutput );
-
- $langAttributes = [
- 'lang' => $contLang->getHtmlCode(),
- 'dir' => $contLang->getDir(),
- ];
-
- $topLinksAttributes = [ 'class' => 'mw-recentchanges-toplinks' ];
-
- if ( $this->isStructuredFilterUiEnabled() ) {
- // Check whether the widget is already collapsed or expanded
- $collapsedState = $this->getRequest()->getCookie( 'rcfilters-toplinks-collapsed-state' );
- // Note that an empty/unset cookie means collapsed, so check for !== 'expanded'
- $topLinksAttributes[ 'class' ] .= $collapsedState !== 'expanded' ?
- ' mw-recentchanges-toplinks-collapsed' : '';
-
- $this->getOutput()->enableOOUI();
- $contentTitle = new OOUI\ButtonWidget( [
- 'classes' => [ 'mw-recentchanges-toplinks-title' ],
- 'label' => new OOUI\HtmlSnippet( $this->msg( 'rcfilters-other-review-tools' )->parse() ),
- 'framed' => false,
- 'indicator' => $collapsedState !== 'expanded' ? 'down' : 'up',
- 'flags' => [ 'progressive' ],
- ] );
-
- $contentWrapper = Html::rawElement( 'div',
- array_merge(
- [ 'class' => 'mw-recentchanges-toplinks-content mw-collapsible-content' ],
- $langAttributes
- ),
- $content
- );
- $content = $contentTitle . $contentWrapper;
- } else {
- // Language direction should be on the top div only
- // if the title is not there. If it is there, it's
- // interface direction, and the language/dir attributes
- // should be on the content itself
- $topLinksAttributes = array_merge( $topLinksAttributes, $langAttributes );
- }
-
- $this->getOutput()->addHTML(
- Html::rawElement( 'div', $topLinksAttributes, $content )
- );
- }
- }
-
- /**
- * Get options to be displayed in a form
- *
- * @param FormOptions $opts
- * @return array
- */
- function getExtraOptions( $opts ) {
- $opts->consumeValues( [
- 'namespace', 'invert', 'associated', 'tagfilter'
- ] );
-
- $extraOpts = [];
- $extraOpts['namespace'] = $this->namespaceFilterForm( $opts );
-
- $tagFilter = ChangeTags::buildTagFilterSelector(
- $opts['tagfilter'], false, $this->getContext() );
- if ( count( $tagFilter ) ) {
- $extraOpts['tagfilter'] = $tagFilter;
- }
-
- // Don't fire the hook for subclasses. (Or should we?)
- if ( $this->getName() === 'Recentchanges' ) {
- Hooks::run( 'SpecialRecentChangesPanel', [ &$extraOpts, $opts ] );
- }
-
- return $extraOpts;
- }
-
- /**
- * Add page-specific modules.
- */
- protected function addModules() {
- parent::addModules();
- $out = $this->getOutput();
- $out->addModules( 'mediawiki.special.recentchanges' );
- }
-
- /**
- * Get last modified date, for client caching
- * Don't use this if we are using the patrol feature, patrol changes don't
- * update the timestamp
- *
- * @return string|bool
- */
- public function checkLastModified() {
- $dbr = $this->getDB();
- $lastmod = $dbr->selectField( 'recentchanges', 'MAX(rc_timestamp)', '', __METHOD__ );
-
- return $lastmod;
- }
-
- /**
- * Creates the choose namespace selection
- *
- * @param FormOptions $opts
- * @return string[]
- */
- protected function namespaceFilterForm( FormOptions $opts ) {
- $nsSelect = Html::namespaceSelector(
- [ 'selected' => $opts['namespace'], 'all' => '', 'in-user-lang' => true ],
- [ 'name' => 'namespace', 'id' => 'namespace' ]
- );
- $nsLabel = Xml::label( $this->msg( 'namespace' )->text(), 'namespace' );
- $attribs = [ 'class' => [ 'mw-input-with-label' ] ];
- // Hide the checkboxes when the namespace filter is set to 'all'.
- if ( $opts['namespace'] === '' ) {
- $attribs['class'][] = 'mw-input-hidden';
- }
- $invert = Html::rawElement( 'span', $attribs, Xml::checkLabel(
- $this->msg( 'invert' )->text(), 'invert', 'nsinvert',
- $opts['invert'],
- [ 'title' => $this->msg( 'tooltip-invert' )->text() ]
- ) );
- $associated = Html::rawElement( 'span', $attribs, Xml::checkLabel(
- $this->msg( 'namespace_association' )->text(), 'associated', 'nsassociated',
- $opts['associated'],
- [ 'title' => $this->msg( 'tooltip-namespace_association' )->text() ]
- ) );
-
- return [ $nsLabel, "$nsSelect $invert $associated" ];
- }
-
- /**
- * Filter $rows by categories set in $opts
- *
- * @deprecated since 1.31
- *
- * @param IResultWrapper &$rows Database rows
- * @param FormOptions $opts
- */
- function filterByCategories( &$rows, FormOptions $opts ) {
- wfDeprecated( __METHOD__, '1.31' );
-
- $categories = array_map( 'trim', explode( '|', $opts['categories'] ) );
-
- if ( $categories === [] ) {
- return;
- }
-
- # Filter categories
- $cats = [];
- foreach ( $categories as $cat ) {
- $cat = trim( $cat );
- if ( $cat == '' ) {
- continue;
- }
- $cats[] = $cat;
- }
-
- # Filter articles
- $articles = [];
- $a2r = [];
- $rowsarr = [];
- foreach ( $rows as $k => $r ) {
- $nt = Title::makeTitle( $r->rc_namespace, $r->rc_title );
- $id = $nt->getArticleID();
- if ( $id == 0 ) {
- continue; # Page might have been deleted...
- }
- if ( !in_array( $id, $articles ) ) {
- $articles[] = $id;
- }
- if ( !isset( $a2r[$id] ) ) {
- $a2r[$id] = [];
- }
- $a2r[$id][] = $k;
- $rowsarr[$k] = $r;
- }
-
- # Shortcut?
- if ( $articles === [] || $cats === [] ) {
- return;
- }
-
- # Look up
- $catFind = new CategoryFinder;
- $catFind->seed( $articles, $cats, $opts['categories_any'] ? 'OR' : 'AND' );
- $match = $catFind->run();
-
- # Filter
- $newrows = [];
- foreach ( $match as $id ) {
- foreach ( $a2r[$id] as $rev ) {
- $k = $rev;
- $newrows[$k] = $rowsarr[$k];
- }
- }
- $rows = new FakeResultWrapper( array_values( $newrows ) );
- }
-
- /**
- * Makes change an option link which carries all the other options
- *
- * @param string $title
- * @param array $override Options to override
- * @param array $options Current options
- * @param bool $active Whether to show the link in bold
- * @return string
- */
- function makeOptionsLink( $title, $override, $options, $active = false ) {
- $params = $this->convertParamsForLink( $override + $options );
-
- if ( $active ) {
- $title = new HtmlArmor( '<strong>' . htmlspecialchars( $title ) . '</strong>' );
- }
-
- return $this->getLinkRenderer()->makeKnownLink( $this->getPageTitle(), $title, [
- 'data-params' => json_encode( $override ),
- 'data-keys' => implode( ',', array_keys( $override ) ),
- ], $params );
- }
-
- /**
- * Creates the options panel.
- *
- * @param array $defaults
- * @param array $nondefaults
- * @param int $numRows Number of rows in the result to show after this header
- * @return string
- */
- function optionsPanel( $defaults, $nondefaults, $numRows ) {
- $options = $nondefaults + $defaults;
-
- $note = '';
- $msg = $this->msg( 'rclegend' );
- if ( !$msg->isDisabled() ) {
- $note .= Html::rawElement(
- 'div',
- [ 'class' => 'mw-rclegend' ],
- $msg->parse()
- );
- }
-
- $lang = $this->getLanguage();
- $user = $this->getUser();
- $config = $this->getConfig();
- if ( $options['from'] ) {
- $resetLink = $this->makeOptionsLink( $this->msg( 'rclistfromreset' ),
- [ 'from' => '' ], $nondefaults );
-
- $noteFromMsg = $this->msg( 'rcnotefrom' )
- ->numParams( $options['limit'] )
- ->params(
- $lang->userTimeAndDate( $options['from'], $user ),
- $lang->userDate( $options['from'], $user ),
- $lang->userTime( $options['from'], $user )
- )
- ->numParams( $numRows );
- $note .= Html::rawElement(
- 'span',
- [ 'class' => 'rcnotefrom' ],
- $noteFromMsg->parse()
- ) .
- ' ' .
- Html::rawElement(
- 'span',
- [ 'class' => 'rcoptions-listfromreset' ],
- $this->msg( 'parentheses' )->rawParams( $resetLink )->parse()
- ) .
- '<br />';
- }
-
- # Sort data for display and make sure it's unique after we've added user data.
- $linkLimits = $config->get( 'RCLinkLimits' );
- $linkLimits[] = $options['limit'];
- sort( $linkLimits );
- $linkLimits = array_unique( $linkLimits );
-
- $linkDays = $config->get( 'RCLinkDays' );
- $linkDays[] = $options['days'];
- sort( $linkDays );
- $linkDays = array_unique( $linkDays );
-
- // limit links
- $cl = [];
- foreach ( $linkLimits as $value ) {
- $cl[] = $this->makeOptionsLink( $lang->formatNum( $value ),
- [ 'limit' => $value ], $nondefaults, $value == $options['limit'] );
- }
- $cl = $lang->pipeList( $cl );
-
- // day links, reset 'from' to none
- $dl = [];
- foreach ( $linkDays as $value ) {
- $dl[] = $this->makeOptionsLink( $lang->formatNum( $value ),
- [ 'days' => $value, 'from' => '' ], $nondefaults, $value == $options['days'] );
- }
- $dl = $lang->pipeList( $dl );
-
- $showhide = [ 'show', 'hide' ];
-
- $links = [];
-
- foreach ( $this->getLegacyShowHideFilters() as $key => $filter ) {
- $msg = $filter->getShowHide();
- $linkMessage = $this->msg( $msg . '-' . $showhide[1 - $options[$key]] );
- // Extensions can define additional filters, but don't need to define the corresponding
- // messages. If they don't exist, just fall back to 'show' and 'hide'.
- if ( !$linkMessage->exists() ) {
- $linkMessage = $this->msg( $showhide[1 - $options[$key]] );
- }
-
- $link = $this->makeOptionsLink( $linkMessage->text(),
- [ $key => 1 - $options[$key] ], $nondefaults );
-
- $attribs = [
- 'class' => "$msg rcshowhideoption clshowhideoption",
- 'data-filter-name' => $filter->getName(),
- ];
-
- if ( $filter->isFeatureAvailableOnStructuredUi( $this ) ) {
- $attribs['data-feature-in-structured-ui'] = true;
- }
-
- $links[] = Html::rawElement(
- 'span',
- $attribs,
- $this->msg( $msg )->rawParams( $link )->parse()
- );
- }
-
- // show from this onward link
- $timestamp = wfTimestampNow();
- $now = $lang->userTimeAndDate( $timestamp, $user );
- $timenow = $lang->userTime( $timestamp, $user );
- $datenow = $lang->userDate( $timestamp, $user );
- $pipedLinks = '<span class="rcshowhide">' . $lang->pipeList( $links ) . '</span>';
-
- $rclinks = Html::rawElement(
- 'span',
- [ 'class' => 'rclinks' ],
- $this->msg( 'rclinks' )->rawParams( $cl, $dl, '' )->parse()
- );
-
- $rclistfrom = Html::rawElement(
- 'span',
- [ 'class' => 'rclistfrom' ],
- $this->makeOptionsLink(
- $this->msg( 'rclistfrom' )->plaintextParams( $now, $timenow, $datenow )->parse(),
- [ 'from' => $timestamp ],
- $nondefaults
- )
- );
-
- return "{$note}$rclinks<br />$pipedLinks<br />$rclistfrom";
- }
-
- public function isIncludable() {
- return true;
- }
-
- protected function getCacheTTL() {
- return 60 * 5;
- }
-
- public function getDefaultLimit() {
- $systemPrefValue = $this->getUser()->getIntOption( 'rclimit' );
- // Prefer the RCFilters-specific preference if RCFilters is enabled
- if ( $this->isStructuredFilterUiEnabled() ) {
- return $this->getUser()->getIntOption( static::$limitPreferenceName, $systemPrefValue );
- }
-
- // Otherwise, use the system rclimit preference value
- return $systemPrefValue;
- }
-}
+++ /dev/null
-<?php
-/**
- * Implements Special:Recentchangeslinked
- *
- * This program is free software; you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation; either version 2 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License along
- * with this program; if not, write to the Free Software Foundation, Inc.,
- * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
- * http://www.gnu.org/copyleft/gpl.html
- *
- * @file
- * @ingroup SpecialPage
- */
-
-/**
- * This is to display changes made to all articles linked in an article.
- *
- * @ingroup SpecialPage
- */
-class SpecialRecentChangesLinked extends SpecialRecentChanges {
- /** @var bool|Title */
- protected $rclTargetTitle;
-
- function __construct() {
- parent::__construct( 'Recentchangeslinked' );
- }
-
- public function getDefaultOptions() {
- $opts = parent::getDefaultOptions();
- $opts->add( 'target', '' );
- $opts->add( 'showlinkedto', false );
-
- return $opts;
- }
-
- public function parseParameters( $par, FormOptions $opts ) {
- $opts['target'] = $par;
- }
-
- /**
- * @inheritDoc
- */
- protected function doMainQuery( $tables, $select, $conds, $query_options,
- $join_conds, FormOptions $opts
- ) {
- $target = $opts['target'];
- $showlinkedto = $opts['showlinkedto'];
- $limit = $opts['limit'];
-
- if ( $target === '' ) {
- return false;
- }
- $outputPage = $this->getOutput();
- $title = Title::newFromText( $target );
- if ( !$title || $title->isExternal() ) {
- $outputPage->addHTML(
- Html::errorBox( $this->msg( 'allpagesbadtitle' )->parse() )
- );
- return false;
- }
-
- $outputPage->setPageTitle( $this->msg( 'recentchangeslinked-title', $title->getPrefixedText() ) );
-
- /*
- * Ordinary links are in the pagelinks table, while transclusions are
- * in the templatelinks table, categorizations in categorylinks and
- * image use in imagelinks. We need to somehow combine all these.
- * Special:Whatlinkshere does this by firing multiple queries and
- * merging the results, but the code we inherit from our parent class
- * expects only one result set so we use UNION instead.
- */
-
- $dbr = wfGetDB( DB_REPLICA, 'recentchangeslinked' );
- $id = $title->getArticleID();
- $ns = $title->getNamespace();
- $dbkey = $title->getDBkey();
-
- $rcQuery = RecentChange::getQueryInfo();
- $tables = array_merge( $tables, $rcQuery['tables'] );
- $select = array_merge( $rcQuery['fields'], $select );
- $join_conds = array_merge( $join_conds, $rcQuery['joins'] );
-
- // left join with watchlist table to highlight watched rows
- $uid = $this->getUser()->getId();
- if ( $uid && $this->getUser()->isAllowed( 'viewmywatchlist' ) ) {
- $tables[] = 'watchlist';
- $select[] = 'wl_user';
- $join_conds['watchlist'] = [ 'LEFT JOIN', [
- 'wl_user' => $uid,
- 'wl_title=rc_title',
- 'wl_namespace=rc_namespace'
- ] ];
- }
-
- // JOIN on page, used for 'last revision' filter highlight
- $tables[] = 'page';
- $join_conds['page'] = [ 'LEFT JOIN', 'rc_cur_id=page_id' ];
- $select[] = 'page_latest';
-
- $tagFilter = $opts['tagfilter'] ? explode( '|', $opts['tagfilter'] ) : [];
- ChangeTags::modifyDisplayQuery(
- $tables,
- $select,
- $conds,
- $join_conds,
- $query_options,
- $tagFilter
- );
-
- if ( $dbr->unionSupportsOrderAndLimit() ) {
- if ( count( $tagFilter ) > 1 ) {
- // ChangeTags::modifyDisplayQuery() will have added DISTINCT.
- // To prevent this from causing query performance problems, we need to add
- // a GROUP BY, and add rc_id to the ORDER BY.
- $order = [
- 'GROUP BY' => 'rc_timestamp, rc_id',
- 'ORDER BY' => 'rc_timestamp DESC, rc_id DESC'
- ];
- } else {
- $order = [ 'ORDER BY' => 'rc_timestamp DESC' ];
- }
- } else {
- $order = [];
- }
-
- if ( !$this->runMainQueryHook( $tables, $select, $conds, $query_options, $join_conds,
- $opts )
- ) {
- return false;
- }
-
- if ( $ns == NS_CATEGORY && !$showlinkedto ) {
- // special handling for categories
- // XXX: should try to make this less kludgy
- $link_tables = [ 'categorylinks' ];
- $showlinkedto = true;
- } else {
- // for now, always join on these tables; really should be configurable as in whatlinkshere
- $link_tables = [ 'pagelinks', 'templatelinks' ];
- // imagelinks only contains links to pages in NS_FILE
- if ( $ns == NS_FILE || !$showlinkedto ) {
- $link_tables[] = 'imagelinks';
- }
- }
-
- if ( $id == 0 && !$showlinkedto ) {
- return false; // nonexistent pages can't link to any pages
- }
-
- // field name prefixes for all the various tables we might want to join with
- $prefix = [
- 'pagelinks' => 'pl',
- 'templatelinks' => 'tl',
- 'categorylinks' => 'cl',
- 'imagelinks' => 'il'
- ];
-
- $subsql = []; // SELECT statements to combine with UNION
-
- foreach ( $link_tables as $link_table ) {
- $pfx = $prefix[$link_table];
-
- // imagelinks and categorylinks tables have no xx_namespace field,
- // and have xx_to instead of xx_title
- if ( $link_table == 'imagelinks' ) {
- $link_ns = NS_FILE;
- } elseif ( $link_table == 'categorylinks' ) {
- $link_ns = NS_CATEGORY;
- } else {
- $link_ns = 0;
- }
-
- if ( $showlinkedto ) {
- // find changes to pages linking to this page
- if ( $link_ns ) {
- if ( $ns != $link_ns ) {
- continue;
- } // should never happen, but check anyway
- $subconds = [ "{$pfx}_to" => $dbkey ];
- } else {
- $subconds = [ "{$pfx}_namespace" => $ns, "{$pfx}_title" => $dbkey ];
- }
- $subjoin = "rc_cur_id = {$pfx}_from";
- } else {
- // find changes to pages linked from this page
- $subconds = [ "{$pfx}_from" => $id ];
- if ( $link_table == 'imagelinks' || $link_table == 'categorylinks' ) {
- $subconds["rc_namespace"] = $link_ns;
- $subjoin = "rc_title = {$pfx}_to";
- } else {
- $subjoin = [ "rc_namespace = {$pfx}_namespace", "rc_title = {$pfx}_title" ];
- }
- }
-
- $query = $dbr->selectSQLText(
- array_merge( $tables, [ $link_table ] ),
- $select,
- $conds + $subconds,
- __METHOD__,
- $order + $query_options,
- $join_conds + [ $link_table => [ 'JOIN', $subjoin ] ]
- );
-
- if ( $dbr->unionSupportsOrderAndLimit() ) {
- $query = $dbr->limitResult( $query, $limit );
- }
-
- $subsql[] = $query;
- }
-
- if ( count( $subsql ) == 0 ) {
- return false; // should never happen
- }
- if ( count( $subsql ) == 1 && $dbr->unionSupportsOrderAndLimit() ) {
- $sql = $subsql[0];
- } else {
- // need to resort and relimit after union
- $sql = $dbr->unionQueries( $subsql, $dbr::UNION_DISTINCT ) .
- ' ORDER BY rc_timestamp DESC';
- $sql = $dbr->limitResult( $sql, $limit, false );
- }
-
- $res = $dbr->query( $sql, __METHOD__ );
-
- if ( $res->numRows() == 0 ) {
- $this->mResultEmpty = true;
- }
-
- return $res;
- }
-
- function setTopText( FormOptions $opts ) {
- $target = $this->getTargetTitle();
- if ( $target ) {
- $this->getOutput()->addBacklinkSubtitle( $target );
- $this->getSkin()->setRelevantTitle( $target );
- }
- }
-
- /**
- * Get options to be displayed in a form
- *
- * @param FormOptions $opts
- * @return array
- */
- function getExtraOptions( $opts ) {
- $extraOpts = parent::getExtraOptions( $opts );
-
- $opts->consumeValues( [ 'showlinkedto', 'target' ] );
-
- $extraOpts['target'] = [ $this->msg( 'recentchangeslinked-page' )->escaped(),
- Xml::input( 'target', 40, str_replace( '_', ' ', $opts['target'] ) ) .
- Xml::check( 'showlinkedto', $opts['showlinkedto'], [ 'id' => 'showlinkedto' ] ) . ' ' .
- Xml::label( $this->msg( 'recentchangeslinked-to' )->text(), 'showlinkedto' ) ];
-
- $this->addHelpLink( 'Help:Related changes' );
- return $extraOpts;
- }
-
- /**
- * @return Title
- */
- function getTargetTitle() {
- if ( $this->rclTargetTitle === null ) {
- $opts = $this->getOptions();
- if ( isset( $opts['target'] ) && $opts['target'] !== '' ) {
- $this->rclTargetTitle = Title::newFromText( $opts['target'] );
- } else {
- $this->rclTargetTitle = false;
- }
- }
-
- return $this->rclTargetTitle;
- }
-
- /**
- * Return an array of subpages beginning with $search that this special page will accept.
- *
- * @param string $search Prefix to search for
- * @param int $limit Maximum number of results to return (usually 10)
- * @param int $offset Number of results to skip (usually 0)
- * @return string[] Matching subpages
- */
- public function prefixSearchSubpages( $search, $limit, $offset ) {
- return $this->prefixSearchString( $search, $limit, $offset );
- }
-
- protected function outputNoResults() {
- $targetTitle = $this->getTargetTitle();
- if ( $targetTitle === false ) {
- $this->getOutput()->addHTML(
- Html::rawElement(
- 'div',
- [ 'class' => 'mw-changeslist-empty mw-changeslist-notargetpage' ],
- $this->msg( 'recentchanges-notargetpage' )->parse()
- )
- );
- } elseif ( !$targetTitle || $targetTitle->isExternal() ) {
- $this->getOutput()->addHTML(
- Html::rawElement(
- 'div',
- [ 'class' => 'mw-changeslist-empty mw-changeslist-invalidtargetpage' ],
- $this->msg( 'allpagesbadtitle' )->parse()
- )
- );
- } else {
- parent::outputNoResults();
- }
- }
-}
--- /dev/null
+<?php
+/**
+ * Implements Special:Revisiondelete
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+
+/**
+ * Special page allowing users with the appropriate permissions to view
+ * and hide revisions. Log items can also be hidden.
+ *
+ * @ingroup SpecialPage
+ */
+class SpecialRevisionDelete extends UnlistedSpecialPage {
+ /** @var bool Was the DB modified in this request */
+ protected $wasSaved = false;
+
+ /** @var bool True if the submit button was clicked, and the form was posted */
+ private $submitClicked;
+
+ /** @var array Target ID list */
+ private $ids;
+
+ /** @var string Archive name, for reviewing deleted files */
+ private $archiveName;
+
+ /** @var string Edit token for securing image views against XSS */
+ private $token;
+
+ /** @var Title Title object for target parameter */
+ private $targetObj;
+
+ /** @var string Deletion type, may be revision, archive, oldimage, filearchive, logging. */
+ private $typeName;
+
+ /** @var array Array of checkbox specs (message, name, deletion bits) */
+ private $checks;
+
+ /** @var array UI Labels about the current type */
+ private $typeLabels;
+
+ /** @var RevDelList RevDelList object, storing the list of items to be deleted/undeleted */
+ private $revDelList;
+
+ /** @var bool Whether user is allowed to perform the action */
+ private $mIsAllowed;
+
+ /** @var string */
+ private $otherReason;
+
+ /**
+ * UI labels for each type.
+ */
+ private static $UILabels = [
+ 'revision' => [
+ 'check-label' => 'revdelete-hide-text',
+ 'success' => 'revdelete-success',
+ 'failure' => 'revdelete-failure',
+ 'text' => 'revdelete-text-text',
+ 'selected' => 'revdelete-selected-text',
+ ],
+ 'archive' => [
+ 'check-label' => 'revdelete-hide-text',
+ 'success' => 'revdelete-success',
+ 'failure' => 'revdelete-failure',
+ 'text' => 'revdelete-text-text',
+ 'selected' => 'revdelete-selected-text',
+ ],
+ 'oldimage' => [
+ 'check-label' => 'revdelete-hide-image',
+ 'success' => 'revdelete-success',
+ 'failure' => 'revdelete-failure',
+ 'text' => 'revdelete-text-file',
+ 'selected' => 'revdelete-selected-file',
+ ],
+ 'filearchive' => [
+ 'check-label' => 'revdelete-hide-image',
+ 'success' => 'revdelete-success',
+ 'failure' => 'revdelete-failure',
+ 'text' => 'revdelete-text-file',
+ 'selected' => 'revdelete-selected-file',
+ ],
+ 'logging' => [
+ 'check-label' => 'revdelete-hide-name',
+ 'success' => 'logdelete-success',
+ 'failure' => 'logdelete-failure',
+ 'text' => 'logdelete-text',
+ 'selected' => 'logdelete-selected',
+ ],
+ ];
+
+ public function __construct() {
+ parent::__construct( 'Revisiondelete', 'deleterevision' );
+ }
+
+ public function doesWrites() {
+ return true;
+ }
+
+ public function execute( $par ) {
+ $this->useTransactionalTimeLimit();
+
+ $this->checkPermissions();
+ $this->checkReadOnly();
+
+ $output = $this->getOutput();
+ $user = $this->getUser();
+
+ // Check blocks
+ if ( $user->isBlocked() ) {
+ throw new UserBlockedError( $user->getBlock() );
+ }
+
+ $this->setHeaders();
+ $this->outputHeader();
+ $request = $this->getRequest();
+ $this->submitClicked = $request->wasPosted() && $request->getBool( 'wpSubmit' );
+ # Handle our many different possible input types.
+ $ids = $request->getVal( 'ids' );
+ if ( !is_null( $ids ) ) {
+ # Allow CSV, for backwards compatibility, or a single ID for show/hide links
+ $this->ids = explode( ',', $ids );
+ } else {
+ # Array input
+ $this->ids = array_keys( $request->getArray( 'ids', [] ) );
+ }
+ // $this->ids = array_map( 'intval', $this->ids );
+ $this->ids = array_unique( array_filter( $this->ids ) );
+
+ $this->typeName = $request->getVal( 'type' );
+ $this->targetObj = Title::newFromText( $request->getText( 'target' ) );
+
+ # For reviewing deleted files...
+ $this->archiveName = $request->getVal( 'file' );
+ $this->token = $request->getVal( 'token' );
+ if ( $this->archiveName && $this->targetObj ) {
+ $this->tryShowFile( $this->archiveName );
+
+ return;
+ }
+
+ $this->typeName = RevisionDeleter::getCanonicalTypeName( $this->typeName );
+
+ # No targets?
+ if ( !$this->typeName || count( $this->ids ) == 0 ) {
+ throw new ErrorPageError( 'revdelete-nooldid-title', 'revdelete-nooldid-text' );
+ }
+
+ # Allow the list type to adjust the passed target
+ $this->targetObj = RevisionDeleter::suggestTarget(
+ $this->typeName,
+ $this->targetObj,
+ $this->ids
+ );
+
+ # We need a target page!
+ if ( $this->targetObj === null ) {
+ $output->addWikiMsg( 'undelete-header' );
+
+ return;
+ }
+
+ $this->typeLabels = self::$UILabels[$this->typeName];
+ $list = $this->getList();
+ $list->reset();
+ $this->mIsAllowed = $user->isAllowed( RevisionDeleter::getRestriction( $this->typeName ) );
+ $canViewSuppressedOnly = $this->getUser()->isAllowed( 'viewsuppressed' ) &&
+ !$this->getUser()->isAllowed( 'suppressrevision' );
+ $pageIsSuppressed = $list->areAnySuppressed();
+ $this->mIsAllowed = $this->mIsAllowed && !( $canViewSuppressedOnly && $pageIsSuppressed );
+
+ $this->otherReason = $request->getVal( 'wpReason' );
+ # Give a link to the logs/hist for this page
+ $this->showConvenienceLinks();
+
+ # Initialise checkboxes
+ $this->checks = [
+ # Messages: revdelete-hide-text, revdelete-hide-image, revdelete-hide-name
+ [ $this->typeLabels['check-label'], 'wpHidePrimary',
+ RevisionDeleter::getRevdelConstant( $this->typeName )
+ ],
+ [ 'revdelete-hide-comment', 'wpHideComment', Revision::DELETED_COMMENT ],
+ [ 'revdelete-hide-user', 'wpHideUser', Revision::DELETED_USER ]
+ ];
+ if ( $user->isAllowed( 'suppressrevision' ) ) {
+ $this->checks[] = [ 'revdelete-hide-restricted',
+ 'wpHideRestricted', Revision::DELETED_RESTRICTED ];
+ }
+
+ # Either submit or create our form
+ if ( $this->mIsAllowed && $this->submitClicked ) {
+ $this->submit();
+ } else {
+ $this->showForm();
+ }
+
+ if ( $user->isAllowed( 'deletedhistory' ) ) {
+ $qc = $this->getLogQueryCond();
+ # Show relevant lines from the deletion log
+ $deleteLogPage = new LogPage( 'delete' );
+ $output->addHTML( "<h2>" . $deleteLogPage->getName()->escaped() . "</h2>\n" );
+ LogEventsList::showLogExtract(
+ $output,
+ 'delete',
+ $this->targetObj,
+ '', /* user */
+ [ 'lim' => 25, 'conds' => $qc, 'useMaster' => $this->wasSaved ]
+ );
+ }
+ # Show relevant lines from the suppression log
+ if ( $user->isAllowed( 'suppressionlog' ) ) {
+ $suppressLogPage = new LogPage( 'suppress' );
+ $output->addHTML( "<h2>" . $suppressLogPage->getName()->escaped() . "</h2>\n" );
+ LogEventsList::showLogExtract(
+ $output,
+ 'suppress',
+ $this->targetObj,
+ '',
+ [ 'lim' => 25, 'conds' => $qc, 'useMaster' => $this->wasSaved ]
+ );
+ }
+ }
+
+ /**
+ * Show some useful links in the subtitle
+ */
+ protected function showConvenienceLinks() {
+ $linkRenderer = $this->getLinkRenderer();
+ # Give a link to the logs/hist for this page
+ if ( $this->targetObj ) {
+ // Also set header tabs to be for the target.
+ $this->getSkin()->setRelevantTitle( $this->targetObj );
+
+ $links = [];
+ $links[] = $linkRenderer->makeKnownLink(
+ SpecialPage::getTitleFor( 'Log' ),
+ $this->msg( 'viewpagelogs' )->text(),
+ [],
+ [ 'page' => $this->targetObj->getPrefixedText() ]
+ );
+ if ( !$this->targetObj->isSpecialPage() ) {
+ # Give a link to the page history
+ $links[] = $linkRenderer->makeKnownLink(
+ $this->targetObj,
+ $this->msg( 'pagehist' )->text(),
+ [],
+ [ 'action' => 'history' ]
+ );
+ # Link to deleted edits
+ if ( $this->getUser()->isAllowed( 'undelete' ) ) {
+ $undelete = SpecialPage::getTitleFor( 'Undelete' );
+ $links[] = $linkRenderer->makeKnownLink(
+ $undelete,
+ $this->msg( 'deletedhist' )->text(),
+ [],
+ [ 'target' => $this->targetObj->getPrefixedDBkey() ]
+ );
+ }
+ }
+ # Logs themselves don't have histories or archived revisions
+ $this->getOutput()->addSubtitle( $this->getLanguage()->pipeList( $links ) );
+ }
+ }
+
+ /**
+ * Get the condition used for fetching log snippets
+ * @return array
+ */
+ protected function getLogQueryCond() {
+ $conds = [];
+ // Revision delete logs for these item
+ $conds['log_type'] = [ 'delete', 'suppress' ];
+ $conds['log_action'] = $this->getList()->getLogAction();
+ $conds['ls_field'] = RevisionDeleter::getRelationType( $this->typeName );
+ $conds['ls_value'] = $this->ids;
+
+ return $conds;
+ }
+
+ /**
+ * Show a deleted file version requested by the visitor.
+ * @todo Mostly copied from Special:Undelete. Refactor.
+ * @param string $archiveName
+ * @throws MWException
+ * @throws PermissionsError
+ */
+ protected function tryShowFile( $archiveName ) {
+ $repo = RepoGroup::singleton()->getLocalRepo();
+ $oimage = $repo->newFromArchiveName( $this->targetObj, $archiveName );
+ $oimage->load();
+ // Check if user is allowed to see this file
+ if ( !$oimage->exists() ) {
+ $this->getOutput()->addWikiMsg( 'revdelete-no-file' );
+
+ return;
+ }
+ $user = $this->getUser();
+ if ( !$oimage->userCan( File::DELETED_FILE, $user ) ) {
+ if ( $oimage->isDeleted( File::DELETED_RESTRICTED ) ) {
+ throw new PermissionsError( 'suppressrevision' );
+ } else {
+ throw new PermissionsError( 'deletedtext' );
+ }
+ }
+ if ( !$user->matchEditToken( $this->token, $archiveName ) ) {
+ $lang = $this->getLanguage();
+ $this->getOutput()->addWikiMsg( 'revdelete-show-file-confirm',
+ $this->targetObj->getText(),
+ $lang->userDate( $oimage->getTimestamp(), $user ),
+ $lang->userTime( $oimage->getTimestamp(), $user ) );
+ $this->getOutput()->addHTML(
+ Xml::openElement( 'form', [
+ 'method' => 'POST',
+ 'action' => $this->getPageTitle()->getLocalURL( [
+ 'target' => $this->targetObj->getPrefixedDBkey(),
+ 'file' => $archiveName,
+ 'token' => $user->getEditToken( $archiveName ),
+ ] )
+ ]
+ ) .
+ Xml::submitButton( $this->msg( 'revdelete-show-file-submit' )->text() ) .
+ '</form>'
+ );
+
+ return;
+ }
+ $this->getOutput()->disable();
+ # We mustn't allow the output to be CDN cached, otherwise
+ # if an admin previews a deleted image, and it's cached, then
+ # a user without appropriate permissions can toddle off and
+ # nab the image, and CDN will serve it
+ $this->getRequest()->response()->header( 'Expires: ' . gmdate( 'D, d M Y H:i:s', 0 ) . ' GMT' );
+ $this->getRequest()->response()->header(
+ 'Cache-Control: no-cache, no-store, max-age=0, must-revalidate'
+ );
+ $this->getRequest()->response()->header( 'Pragma: no-cache' );
+
+ $key = $oimage->getStorageKey();
+ $path = $repo->getZonePath( 'deleted' ) . '/' . $repo->getDeletedHashPath( $key ) . $key;
+ $repo->streamFile( $path );
+ }
+
+ /**
+ * Get the list object for this request
+ * @return RevDelList
+ */
+ protected function getList() {
+ if ( is_null( $this->revDelList ) ) {
+ $this->revDelList = RevisionDeleter::createList(
+ $this->typeName, $this->getContext(), $this->targetObj, $this->ids
+ );
+ }
+
+ return $this->revDelList;
+ }
+
+ /**
+ * Show a list of items that we will operate on, and show a form with checkboxes
+ * which will allow the user to choose new visibility settings.
+ */
+ protected function showForm() {
+ $userAllowed = true;
+
+ // Messages: revdelete-selected-text, revdelete-selected-file, logdelete-selected
+ $out = $this->getOutput();
+ $out->wrapWikiMsg( "<strong>$1</strong>", [ $this->typeLabels['selected'],
+ $this->getLanguage()->formatNum( count( $this->ids ) ), $this->targetObj->getPrefixedText() ] );
+
+ $this->addHelpLink( 'Help:RevisionDelete' );
+ $out->addHTML( "<ul>" );
+
+ $numRevisions = 0;
+ // Live revisions...
+ $list = $this->getList();
+ for ( $list->reset(); $list->current(); $list->next() ) {
+ $item = $list->current();
+
+ if ( !$item->canView() ) {
+ if ( !$this->submitClicked ) {
+ throw new PermissionsError( 'suppressrevision' );
+ }
+ $userAllowed = false;
+ }
+
+ $numRevisions++;
+ $out->addHTML( $item->getHTML() );
+ }
+
+ if ( !$numRevisions ) {
+ throw new ErrorPageError( 'revdelete-nooldid-title', 'revdelete-nooldid-text' );
+ }
+
+ $out->addHTML( "</ul>" );
+ // Explanation text
+ $this->addUsageText();
+
+ // Normal sysops can always see what they did, but can't always change it
+ if ( !$userAllowed ) {
+ return;
+ }
+
+ // Show form if the user can submit
+ if ( $this->mIsAllowed ) {
+ $out->addModules( [ 'mediawiki.special.revisionDelete' ] );
+ $out->addModuleStyles( [ 'mediawiki.special',
+ 'mediawiki.interface.helpers.styles' ] );
+
+ $form = Xml::openElement( 'form', [ 'method' => 'post',
+ 'action' => $this->getPageTitle()->getLocalURL( [ 'action' => 'submit' ] ),
+ 'id' => 'mw-revdel-form-revisions' ] ) .
+ Xml::fieldset( $this->msg( 'revdelete-legend' )->text() ) .
+ $this->buildCheckBoxes() .
+ Xml::openElement( 'table' ) .
+ "<tr>\n" .
+ '<td class="mw-label">' .
+ Xml::label( $this->msg( 'revdelete-log' )->text(), 'wpRevDeleteReasonList' ) .
+ '</td>' .
+ '<td class="mw-input">' .
+ Xml::listDropDown( 'wpRevDeleteReasonList',
+ $this->msg( 'revdelete-reason-dropdown' )->inContentLanguage()->text(),
+ $this->msg( 'revdelete-reasonotherlist' )->inContentLanguage()->text(),
+ $this->getRequest()->getText( 'wpRevDeleteReasonList', 'other' ), 'wpReasonDropDown'
+ ) .
+ '</td>' .
+ "</tr><tr>\n" .
+ '<td class="mw-label">' .
+ Xml::label( $this->msg( 'revdelete-otherreason' )->text(), 'wpReason' ) .
+ '</td>' .
+ '<td class="mw-input">' .
+ Xml::input( 'wpReason', 60, $this->otherReason, [
+ 'id' => 'wpReason',
+ // HTML maxlength uses "UTF-16 code units", which means that characters outside BMP
+ // (e.g. emojis) count for two each. This limit is overridden in JS to instead count
+ // Unicode codepoints.
+ // "- 155" is to leave room for the 'wpRevDeleteReasonList' value.
+ 'maxlength' => CommentStore::COMMENT_CHARACTER_LIMIT - 155,
+ ] ) .
+ '</td>' .
+ "</tr><tr>\n" .
+ '<td></td>' .
+ '<td class="mw-submit">' .
+ Xml::submitButton( $this->msg( 'revdelete-submit', $numRevisions )->text(),
+ [ 'name' => 'wpSubmit' ] ) .
+ '</td>' .
+ "</tr>\n" .
+ Xml::closeElement( 'table' ) .
+ Html::hidden( 'wpEditToken', $this->getUser()->getEditToken() ) .
+ Html::hidden( 'target', $this->targetObj->getPrefixedText() ) .
+ Html::hidden( 'type', $this->typeName ) .
+ Html::hidden( 'ids', implode( ',', $this->ids ) ) .
+ Xml::closeElement( 'fieldset' ) . "\n" .
+ Xml::closeElement( 'form' ) . "\n";
+ // Show link to edit the dropdown reasons
+ if ( $this->getUser()->isAllowed( 'editinterface' ) ) {
+ $link = $this->getLinkRenderer()->makeKnownLink(
+ $this->msg( 'revdelete-reason-dropdown' )->inContentLanguage()->getTitle(),
+ $this->msg( 'revdelete-edit-reasonlist' )->text(),
+ [],
+ [ 'action' => 'edit' ]
+ );
+ $form .= Xml::tags( 'p', [ 'class' => 'mw-revdel-editreasons' ], $link ) . "\n";
+ }
+ } else {
+ $form = '';
+ }
+ $out->addHTML( $form );
+ }
+
+ /**
+ * Show some introductory text
+ * @todo FIXME: Wikimedia-specific policy text
+ */
+ protected function addUsageText() {
+ // Messages: revdelete-text-text, revdelete-text-file, logdelete-text
+ $this->getOutput()->wrapWikiMsg(
+ "<strong>$1</strong>\n$2", $this->typeLabels['text'],
+ 'revdelete-text-others'
+ );
+
+ if ( $this->getUser()->isAllowed( 'suppressrevision' ) ) {
+ $this->getOutput()->addWikiMsg( 'revdelete-suppress-text' );
+ }
+
+ if ( $this->mIsAllowed ) {
+ $this->getOutput()->addWikiMsg( 'revdelete-confirm' );
+ }
+ }
+
+ /**
+ * @return string HTML
+ */
+ protected function buildCheckBoxes() {
+ $html = '<table>';
+ // If there is just one item, use checkboxes
+ $list = $this->getList();
+ if ( $list->length() == 1 ) {
+ $list->reset();
+ $bitfield = $list->current()->getBits(); // existing field
+
+ if ( $this->submitClicked ) {
+ $bitfield = RevisionDeleter::extractBitfield( $this->extractBitParams(), $bitfield );
+ }
+
+ foreach ( $this->checks as $item ) {
+ // Messages: revdelete-hide-text, revdelete-hide-image, revdelete-hide-name,
+ // revdelete-hide-comment, revdelete-hide-user, revdelete-hide-restricted
+ list( $message, $name, $field ) = $item;
+ $innerHTML = Xml::checkLabel(
+ $this->msg( $message )->text(),
+ $name,
+ $name,
+ $bitfield & $field
+ );
+
+ if ( $field == Revision::DELETED_RESTRICTED ) {
+ $innerHTML = "<b>$innerHTML</b>";
+ }
+
+ $line = Xml::tags( 'td', [ 'class' => 'mw-input' ], $innerHTML );
+ $html .= "<tr>$line</tr>\n";
+ }
+ } else {
+ // Otherwise, use tri-state radios
+ $html .= '<tr>';
+ $html .= '<th class="mw-revdel-checkbox">'
+ . $this->msg( 'revdelete-radio-same' )->escaped() . '</th>';
+ $html .= '<th class="mw-revdel-checkbox">'
+ . $this->msg( 'revdelete-radio-unset' )->escaped() . '</th>';
+ $html .= '<th class="mw-revdel-checkbox">'
+ . $this->msg( 'revdelete-radio-set' )->escaped() . '</th>';
+ $html .= "<th></th></tr>\n";
+ foreach ( $this->checks as $item ) {
+ // Messages: revdelete-hide-text, revdelete-hide-image, revdelete-hide-name,
+ // revdelete-hide-comment, revdelete-hide-user, revdelete-hide-restricted
+ list( $message, $name, $field ) = $item;
+ // If there are several items, use third state by default...
+ if ( $this->submitClicked ) {
+ $selected = $this->getRequest()->getInt( $name, 0 /* unchecked */ );
+ } else {
+ $selected = -1; // use existing field
+ }
+ $line = '<td class="mw-revdel-checkbox">' . Xml::radio( $name, -1, $selected == -1 ) . '</td>';
+ $line .= '<td class="mw-revdel-checkbox">' . Xml::radio( $name, 0, $selected == 0 ) . '</td>';
+ $line .= '<td class="mw-revdel-checkbox">' . Xml::radio( $name, 1, $selected == 1 ) . '</td>';
+ $label = $this->msg( $message )->escaped();
+ if ( $field == Revision::DELETED_RESTRICTED ) {
+ $label = "<b>$label</b>";
+ }
+ $line .= "<td>$label</td>";
+ $html .= "<tr>$line</tr>\n";
+ }
+ }
+
+ $html .= '</table>';
+
+ return $html;
+ }
+
+ /**
+ * UI entry point for form submission.
+ * @throws PermissionsError
+ * @return bool
+ */
+ protected function submit() {
+ # Check edit token on submission
+ $token = $this->getRequest()->getVal( 'wpEditToken' );
+ if ( $this->submitClicked && !$this->getUser()->matchEditToken( $token ) ) {
+ $this->getOutput()->addWikiMsg( 'sessionfailure' );
+
+ return false;
+ }
+ $bitParams = $this->extractBitParams();
+ // from dropdown
+ $listReason = $this->getRequest()->getText( 'wpRevDeleteReasonList', 'other' );
+ $comment = $listReason;
+ if ( $comment === 'other' ) {
+ $comment = $this->otherReason;
+ } elseif ( $this->otherReason !== '' ) {
+ // Entry from drop down menu + additional comment
+ $comment .= $this->msg( 'colon-separator' )->inContentLanguage()->text()
+ . $this->otherReason;
+ }
+ # Can the user set this field?
+ if ( $bitParams[Revision::DELETED_RESTRICTED] == 1
+ && !$this->getUser()->isAllowed( 'suppressrevision' )
+ ) {
+ throw new PermissionsError( 'suppressrevision' );
+ }
+ # If the save went through, go to success message...
+ $status = $this->save( $bitParams, $comment );
+ if ( $status->isGood() ) {
+ $this->success();
+
+ return true;
+ } else {
+ # ...otherwise, bounce back to form...
+ $this->failure( $status );
+ }
+
+ return false;
+ }
+
+ /**
+ * Report that the submit operation succeeded
+ */
+ protected function success() {
+ // Messages: revdelete-success, logdelete-success
+ $this->getOutput()->setPageTitle( $this->msg( 'actioncomplete' ) );
+ $this->getOutput()->wrapWikiMsg(
+ "<div class=\"successbox\">\n$1\n</div>",
+ $this->typeLabels['success']
+ );
+ $this->wasSaved = true;
+ $this->revDelList->reloadFromMaster();
+ $this->showForm();
+ }
+
+ /**
+ * Report that the submit operation failed
+ * @param Status $status
+ */
+ protected function failure( $status ) {
+ // Messages: revdelete-failure, logdelete-failure
+ $this->getOutput()->setPageTitle( $this->msg( 'actionfailed' ) );
+ $this->getOutput()->wrapWikiTextAsInterface(
+ 'errorbox',
+ $status->getWikiText( $this->typeLabels['failure'] )
+ );
+ $this->showForm();
+ }
+
+ /**
+ * Put together an array that contains -1, 0, or the *_deleted const for each bit
+ *
+ * @return array
+ */
+ protected function extractBitParams() {
+ $bitfield = [];
+ foreach ( $this->checks as $item ) {
+ list( /* message */, $name, $field ) = $item;
+ $val = $this->getRequest()->getInt( $name, 0 /* unchecked */ );
+ if ( $val < -1 || $val > 1 ) {
+ $val = -1; // -1 for existing value
+ }
+ $bitfield[$field] = $val;
+ }
+ if ( !isset( $bitfield[Revision::DELETED_RESTRICTED] ) ) {
+ $bitfield[Revision::DELETED_RESTRICTED] = 0;
+ }
+
+ return $bitfield;
+ }
+
+ /**
+ * Do the write operations. Simple wrapper for RevDel*List::setVisibility().
+ * @param array $bitPars ExtractBitParams() bitfield array
+ * @param string $reason
+ * @return Status
+ */
+ protected function save( array $bitPars, $reason ) {
+ return $this->getList()->setVisibility(
+ [ 'value' => $bitPars, 'comment' => $reason ]
+ );
+ }
+
+ protected function getGroupName() {
+ return 'pagetools';
+ }
+}
+++ /dev/null
-<?php
-/**
- * Implements Special:Revisiondelete
- *
- * This program is free software; you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation; either version 2 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License along
- * with this program; if not, write to the Free Software Foundation, Inc.,
- * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
- * http://www.gnu.org/copyleft/gpl.html
- *
- * @file
- * @ingroup SpecialPage
- */
-
-/**
- * Special page allowing users with the appropriate permissions to view
- * and hide revisions. Log items can also be hidden.
- *
- * @ingroup SpecialPage
- */
-class SpecialRevisionDelete extends UnlistedSpecialPage {
- /** @var bool Was the DB modified in this request */
- protected $wasSaved = false;
-
- /** @var bool True if the submit button was clicked, and the form was posted */
- private $submitClicked;
-
- /** @var array Target ID list */
- private $ids;
-
- /** @var string Archive name, for reviewing deleted files */
- private $archiveName;
-
- /** @var string Edit token for securing image views against XSS */
- private $token;
-
- /** @var Title Title object for target parameter */
- private $targetObj;
-
- /** @var string Deletion type, may be revision, archive, oldimage, filearchive, logging. */
- private $typeName;
-
- /** @var array Array of checkbox specs (message, name, deletion bits) */
- private $checks;
-
- /** @var array UI Labels about the current type */
- private $typeLabels;
-
- /** @var RevDelList RevDelList object, storing the list of items to be deleted/undeleted */
- private $revDelList;
-
- /** @var bool Whether user is allowed to perform the action */
- private $mIsAllowed;
-
- /** @var string */
- private $otherReason;
-
- /**
- * UI labels for each type.
- */
- private static $UILabels = [
- 'revision' => [
- 'check-label' => 'revdelete-hide-text',
- 'success' => 'revdelete-success',
- 'failure' => 'revdelete-failure',
- 'text' => 'revdelete-text-text',
- 'selected' => 'revdelete-selected-text',
- ],
- 'archive' => [
- 'check-label' => 'revdelete-hide-text',
- 'success' => 'revdelete-success',
- 'failure' => 'revdelete-failure',
- 'text' => 'revdelete-text-text',
- 'selected' => 'revdelete-selected-text',
- ],
- 'oldimage' => [
- 'check-label' => 'revdelete-hide-image',
- 'success' => 'revdelete-success',
- 'failure' => 'revdelete-failure',
- 'text' => 'revdelete-text-file',
- 'selected' => 'revdelete-selected-file',
- ],
- 'filearchive' => [
- 'check-label' => 'revdelete-hide-image',
- 'success' => 'revdelete-success',
- 'failure' => 'revdelete-failure',
- 'text' => 'revdelete-text-file',
- 'selected' => 'revdelete-selected-file',
- ],
- 'logging' => [
- 'check-label' => 'revdelete-hide-name',
- 'success' => 'logdelete-success',
- 'failure' => 'logdelete-failure',
- 'text' => 'logdelete-text',
- 'selected' => 'logdelete-selected',
- ],
- ];
-
- public function __construct() {
- parent::__construct( 'Revisiondelete', 'deleterevision' );
- }
-
- public function doesWrites() {
- return true;
- }
-
- public function execute( $par ) {
- $this->useTransactionalTimeLimit();
-
- $this->checkPermissions();
- $this->checkReadOnly();
-
- $output = $this->getOutput();
- $user = $this->getUser();
-
- // Check blocks
- if ( $user->isBlocked() ) {
- throw new UserBlockedError( $user->getBlock() );
- }
-
- $this->setHeaders();
- $this->outputHeader();
- $request = $this->getRequest();
- $this->submitClicked = $request->wasPosted() && $request->getBool( 'wpSubmit' );
- # Handle our many different possible input types.
- $ids = $request->getVal( 'ids' );
- if ( !is_null( $ids ) ) {
- # Allow CSV, for backwards compatibility, or a single ID for show/hide links
- $this->ids = explode( ',', $ids );
- } else {
- # Array input
- $this->ids = array_keys( $request->getArray( 'ids', [] ) );
- }
- // $this->ids = array_map( 'intval', $this->ids );
- $this->ids = array_unique( array_filter( $this->ids ) );
-
- $this->typeName = $request->getVal( 'type' );
- $this->targetObj = Title::newFromText( $request->getText( 'target' ) );
-
- # For reviewing deleted files...
- $this->archiveName = $request->getVal( 'file' );
- $this->token = $request->getVal( 'token' );
- if ( $this->archiveName && $this->targetObj ) {
- $this->tryShowFile( $this->archiveName );
-
- return;
- }
-
- $this->typeName = RevisionDeleter::getCanonicalTypeName( $this->typeName );
-
- # No targets?
- if ( !$this->typeName || count( $this->ids ) == 0 ) {
- throw new ErrorPageError( 'revdelete-nooldid-title', 'revdelete-nooldid-text' );
- }
-
- # Allow the list type to adjust the passed target
- $this->targetObj = RevisionDeleter::suggestTarget(
- $this->typeName,
- $this->targetObj,
- $this->ids
- );
-
- # We need a target page!
- if ( $this->targetObj === null ) {
- $output->addWikiMsg( 'undelete-header' );
-
- return;
- }
-
- $this->typeLabels = self::$UILabels[$this->typeName];
- $list = $this->getList();
- $list->reset();
- $this->mIsAllowed = $user->isAllowed( RevisionDeleter::getRestriction( $this->typeName ) );
- $canViewSuppressedOnly = $this->getUser()->isAllowed( 'viewsuppressed' ) &&
- !$this->getUser()->isAllowed( 'suppressrevision' );
- $pageIsSuppressed = $list->areAnySuppressed();
- $this->mIsAllowed = $this->mIsAllowed && !( $canViewSuppressedOnly && $pageIsSuppressed );
-
- $this->otherReason = $request->getVal( 'wpReason' );
- # Give a link to the logs/hist for this page
- $this->showConvenienceLinks();
-
- # Initialise checkboxes
- $this->checks = [
- # Messages: revdelete-hide-text, revdelete-hide-image, revdelete-hide-name
- [ $this->typeLabels['check-label'], 'wpHidePrimary',
- RevisionDeleter::getRevdelConstant( $this->typeName )
- ],
- [ 'revdelete-hide-comment', 'wpHideComment', Revision::DELETED_COMMENT ],
- [ 'revdelete-hide-user', 'wpHideUser', Revision::DELETED_USER ]
- ];
- if ( $user->isAllowed( 'suppressrevision' ) ) {
- $this->checks[] = [ 'revdelete-hide-restricted',
- 'wpHideRestricted', Revision::DELETED_RESTRICTED ];
- }
-
- # Either submit or create our form
- if ( $this->mIsAllowed && $this->submitClicked ) {
- $this->submit();
- } else {
- $this->showForm();
- }
-
- if ( $user->isAllowed( 'deletedhistory' ) ) {
- $qc = $this->getLogQueryCond();
- # Show relevant lines from the deletion log
- $deleteLogPage = new LogPage( 'delete' );
- $output->addHTML( "<h2>" . $deleteLogPage->getName()->escaped() . "</h2>\n" );
- LogEventsList::showLogExtract(
- $output,
- 'delete',
- $this->targetObj,
- '', /* user */
- [ 'lim' => 25, 'conds' => $qc, 'useMaster' => $this->wasSaved ]
- );
- }
- # Show relevant lines from the suppression log
- if ( $user->isAllowed( 'suppressionlog' ) ) {
- $suppressLogPage = new LogPage( 'suppress' );
- $output->addHTML( "<h2>" . $suppressLogPage->getName()->escaped() . "</h2>\n" );
- LogEventsList::showLogExtract(
- $output,
- 'suppress',
- $this->targetObj,
- '',
- [ 'lim' => 25, 'conds' => $qc, 'useMaster' => $this->wasSaved ]
- );
- }
- }
-
- /**
- * Show some useful links in the subtitle
- */
- protected function showConvenienceLinks() {
- $linkRenderer = $this->getLinkRenderer();
- # Give a link to the logs/hist for this page
- if ( $this->targetObj ) {
- // Also set header tabs to be for the target.
- $this->getSkin()->setRelevantTitle( $this->targetObj );
-
- $links = [];
- $links[] = $linkRenderer->makeKnownLink(
- SpecialPage::getTitleFor( 'Log' ),
- $this->msg( 'viewpagelogs' )->text(),
- [],
- [ 'page' => $this->targetObj->getPrefixedText() ]
- );
- if ( !$this->targetObj->isSpecialPage() ) {
- # Give a link to the page history
- $links[] = $linkRenderer->makeKnownLink(
- $this->targetObj,
- $this->msg( 'pagehist' )->text(),
- [],
- [ 'action' => 'history' ]
- );
- # Link to deleted edits
- if ( $this->getUser()->isAllowed( 'undelete' ) ) {
- $undelete = SpecialPage::getTitleFor( 'Undelete' );
- $links[] = $linkRenderer->makeKnownLink(
- $undelete,
- $this->msg( 'deletedhist' )->text(),
- [],
- [ 'target' => $this->targetObj->getPrefixedDBkey() ]
- );
- }
- }
- # Logs themselves don't have histories or archived revisions
- $this->getOutput()->addSubtitle( $this->getLanguage()->pipeList( $links ) );
- }
- }
-
- /**
- * Get the condition used for fetching log snippets
- * @return array
- */
- protected function getLogQueryCond() {
- $conds = [];
- // Revision delete logs for these item
- $conds['log_type'] = [ 'delete', 'suppress' ];
- $conds['log_action'] = $this->getList()->getLogAction();
- $conds['ls_field'] = RevisionDeleter::getRelationType( $this->typeName );
- $conds['ls_value'] = $this->ids;
-
- return $conds;
- }
-
- /**
- * Show a deleted file version requested by the visitor.
- * @todo Mostly copied from Special:Undelete. Refactor.
- * @param string $archiveName
- * @throws MWException
- * @throws PermissionsError
- */
- protected function tryShowFile( $archiveName ) {
- $repo = RepoGroup::singleton()->getLocalRepo();
- $oimage = $repo->newFromArchiveName( $this->targetObj, $archiveName );
- $oimage->load();
- // Check if user is allowed to see this file
- if ( !$oimage->exists() ) {
- $this->getOutput()->addWikiMsg( 'revdelete-no-file' );
-
- return;
- }
- $user = $this->getUser();
- if ( !$oimage->userCan( File::DELETED_FILE, $user ) ) {
- if ( $oimage->isDeleted( File::DELETED_RESTRICTED ) ) {
- throw new PermissionsError( 'suppressrevision' );
- } else {
- throw new PermissionsError( 'deletedtext' );
- }
- }
- if ( !$user->matchEditToken( $this->token, $archiveName ) ) {
- $lang = $this->getLanguage();
- $this->getOutput()->addWikiMsg( 'revdelete-show-file-confirm',
- $this->targetObj->getText(),
- $lang->userDate( $oimage->getTimestamp(), $user ),
- $lang->userTime( $oimage->getTimestamp(), $user ) );
- $this->getOutput()->addHTML(
- Xml::openElement( 'form', [
- 'method' => 'POST',
- 'action' => $this->getPageTitle()->getLocalURL( [
- 'target' => $this->targetObj->getPrefixedDBkey(),
- 'file' => $archiveName,
- 'token' => $user->getEditToken( $archiveName ),
- ] )
- ]
- ) .
- Xml::submitButton( $this->msg( 'revdelete-show-file-submit' )->text() ) .
- '</form>'
- );
-
- return;
- }
- $this->getOutput()->disable();
- # We mustn't allow the output to be CDN cached, otherwise
- # if an admin previews a deleted image, and it's cached, then
- # a user without appropriate permissions can toddle off and
- # nab the image, and CDN will serve it
- $this->getRequest()->response()->header( 'Expires: ' . gmdate( 'D, d M Y H:i:s', 0 ) . ' GMT' );
- $this->getRequest()->response()->header(
- 'Cache-Control: no-cache, no-store, max-age=0, must-revalidate'
- );
- $this->getRequest()->response()->header( 'Pragma: no-cache' );
-
- $key = $oimage->getStorageKey();
- $path = $repo->getZonePath( 'deleted' ) . '/' . $repo->getDeletedHashPath( $key ) . $key;
- $repo->streamFile( $path );
- }
-
- /**
- * Get the list object for this request
- * @return RevDelList
- */
- protected function getList() {
- if ( is_null( $this->revDelList ) ) {
- $this->revDelList = RevisionDeleter::createList(
- $this->typeName, $this->getContext(), $this->targetObj, $this->ids
- );
- }
-
- return $this->revDelList;
- }
-
- /**
- * Show a list of items that we will operate on, and show a form with checkboxes
- * which will allow the user to choose new visibility settings.
- */
- protected function showForm() {
- $userAllowed = true;
-
- // Messages: revdelete-selected-text, revdelete-selected-file, logdelete-selected
- $out = $this->getOutput();
- $out->wrapWikiMsg( "<strong>$1</strong>", [ $this->typeLabels['selected'],
- $this->getLanguage()->formatNum( count( $this->ids ) ), $this->targetObj->getPrefixedText() ] );
-
- $this->addHelpLink( 'Help:RevisionDelete' );
- $out->addHTML( "<ul>" );
-
- $numRevisions = 0;
- // Live revisions...
- $list = $this->getList();
- for ( $list->reset(); $list->current(); $list->next() ) {
- $item = $list->current();
-
- if ( !$item->canView() ) {
- if ( !$this->submitClicked ) {
- throw new PermissionsError( 'suppressrevision' );
- }
- $userAllowed = false;
- }
-
- $numRevisions++;
- $out->addHTML( $item->getHTML() );
- }
-
- if ( !$numRevisions ) {
- throw new ErrorPageError( 'revdelete-nooldid-title', 'revdelete-nooldid-text' );
- }
-
- $out->addHTML( "</ul>" );
- // Explanation text
- $this->addUsageText();
-
- // Normal sysops can always see what they did, but can't always change it
- if ( !$userAllowed ) {
- return;
- }
-
- // Show form if the user can submit
- if ( $this->mIsAllowed ) {
- $out->addModules( [ 'mediawiki.special.revisionDelete' ] );
- $out->addModuleStyles( [ 'mediawiki.special',
- 'mediawiki.interface.helpers.styles' ] );
-
- $form = Xml::openElement( 'form', [ 'method' => 'post',
- 'action' => $this->getPageTitle()->getLocalURL( [ 'action' => 'submit' ] ),
- 'id' => 'mw-revdel-form-revisions' ] ) .
- Xml::fieldset( $this->msg( 'revdelete-legend' )->text() ) .
- $this->buildCheckBoxes() .
- Xml::openElement( 'table' ) .
- "<tr>\n" .
- '<td class="mw-label">' .
- Xml::label( $this->msg( 'revdelete-log' )->text(), 'wpRevDeleteReasonList' ) .
- '</td>' .
- '<td class="mw-input">' .
- Xml::listDropDown( 'wpRevDeleteReasonList',
- $this->msg( 'revdelete-reason-dropdown' )->inContentLanguage()->text(),
- $this->msg( 'revdelete-reasonotherlist' )->inContentLanguage()->text(),
- $this->getRequest()->getText( 'wpRevDeleteReasonList', 'other' ), 'wpReasonDropDown'
- ) .
- '</td>' .
- "</tr><tr>\n" .
- '<td class="mw-label">' .
- Xml::label( $this->msg( 'revdelete-otherreason' )->text(), 'wpReason' ) .
- '</td>' .
- '<td class="mw-input">' .
- Xml::input( 'wpReason', 60, $this->otherReason, [
- 'id' => 'wpReason',
- // HTML maxlength uses "UTF-16 code units", which means that characters outside BMP
- // (e.g. emojis) count for two each. This limit is overridden in JS to instead count
- // Unicode codepoints.
- // "- 155" is to leave room for the 'wpRevDeleteReasonList' value.
- 'maxlength' => CommentStore::COMMENT_CHARACTER_LIMIT - 155,
- ] ) .
- '</td>' .
- "</tr><tr>\n" .
- '<td></td>' .
- '<td class="mw-submit">' .
- Xml::submitButton( $this->msg( 'revdelete-submit', $numRevisions )->text(),
- [ 'name' => 'wpSubmit' ] ) .
- '</td>' .
- "</tr>\n" .
- Xml::closeElement( 'table' ) .
- Html::hidden( 'wpEditToken', $this->getUser()->getEditToken() ) .
- Html::hidden( 'target', $this->targetObj->getPrefixedText() ) .
- Html::hidden( 'type', $this->typeName ) .
- Html::hidden( 'ids', implode( ',', $this->ids ) ) .
- Xml::closeElement( 'fieldset' ) . "\n" .
- Xml::closeElement( 'form' ) . "\n";
- // Show link to edit the dropdown reasons
- if ( $this->getUser()->isAllowed( 'editinterface' ) ) {
- $link = $this->getLinkRenderer()->makeKnownLink(
- $this->msg( 'revdelete-reason-dropdown' )->inContentLanguage()->getTitle(),
- $this->msg( 'revdelete-edit-reasonlist' )->text(),
- [],
- [ 'action' => 'edit' ]
- );
- $form .= Xml::tags( 'p', [ 'class' => 'mw-revdel-editreasons' ], $link ) . "\n";
- }
- } else {
- $form = '';
- }
- $out->addHTML( $form );
- }
-
- /**
- * Show some introductory text
- * @todo FIXME: Wikimedia-specific policy text
- */
- protected function addUsageText() {
- // Messages: revdelete-text-text, revdelete-text-file, logdelete-text
- $this->getOutput()->wrapWikiMsg(
- "<strong>$1</strong>\n$2", $this->typeLabels['text'],
- 'revdelete-text-others'
- );
-
- if ( $this->getUser()->isAllowed( 'suppressrevision' ) ) {
- $this->getOutput()->addWikiMsg( 'revdelete-suppress-text' );
- }
-
- if ( $this->mIsAllowed ) {
- $this->getOutput()->addWikiMsg( 'revdelete-confirm' );
- }
- }
-
- /**
- * @return string HTML
- */
- protected function buildCheckBoxes() {
- $html = '<table>';
- // If there is just one item, use checkboxes
- $list = $this->getList();
- if ( $list->length() == 1 ) {
- $list->reset();
- $bitfield = $list->current()->getBits(); // existing field
-
- if ( $this->submitClicked ) {
- $bitfield = RevisionDeleter::extractBitfield( $this->extractBitParams(), $bitfield );
- }
-
- foreach ( $this->checks as $item ) {
- // Messages: revdelete-hide-text, revdelete-hide-image, revdelete-hide-name,
- // revdelete-hide-comment, revdelete-hide-user, revdelete-hide-restricted
- list( $message, $name, $field ) = $item;
- $innerHTML = Xml::checkLabel(
- $this->msg( $message )->text(),
- $name,
- $name,
- $bitfield & $field
- );
-
- if ( $field == Revision::DELETED_RESTRICTED ) {
- $innerHTML = "<b>$innerHTML</b>";
- }
-
- $line = Xml::tags( 'td', [ 'class' => 'mw-input' ], $innerHTML );
- $html .= "<tr>$line</tr>\n";
- }
- } else {
- // Otherwise, use tri-state radios
- $html .= '<tr>';
- $html .= '<th class="mw-revdel-checkbox">'
- . $this->msg( 'revdelete-radio-same' )->escaped() . '</th>';
- $html .= '<th class="mw-revdel-checkbox">'
- . $this->msg( 'revdelete-radio-unset' )->escaped() . '</th>';
- $html .= '<th class="mw-revdel-checkbox">'
- . $this->msg( 'revdelete-radio-set' )->escaped() . '</th>';
- $html .= "<th></th></tr>\n";
- foreach ( $this->checks as $item ) {
- // Messages: revdelete-hide-text, revdelete-hide-image, revdelete-hide-name,
- // revdelete-hide-comment, revdelete-hide-user, revdelete-hide-restricted
- list( $message, $name, $field ) = $item;
- // If there are several items, use third state by default...
- if ( $this->submitClicked ) {
- $selected = $this->getRequest()->getInt( $name, 0 /* unchecked */ );
- } else {
- $selected = -1; // use existing field
- }
- $line = '<td class="mw-revdel-checkbox">' . Xml::radio( $name, -1, $selected == -1 ) . '</td>';
- $line .= '<td class="mw-revdel-checkbox">' . Xml::radio( $name, 0, $selected == 0 ) . '</td>';
- $line .= '<td class="mw-revdel-checkbox">' . Xml::radio( $name, 1, $selected == 1 ) . '</td>';
- $label = $this->msg( $message )->escaped();
- if ( $field == Revision::DELETED_RESTRICTED ) {
- $label = "<b>$label</b>";
- }
- $line .= "<td>$label</td>";
- $html .= "<tr>$line</tr>\n";
- }
- }
-
- $html .= '</table>';
-
- return $html;
- }
-
- /**
- * UI entry point for form submission.
- * @throws PermissionsError
- * @return bool
- */
- protected function submit() {
- # Check edit token on submission
- $token = $this->getRequest()->getVal( 'wpEditToken' );
- if ( $this->submitClicked && !$this->getUser()->matchEditToken( $token ) ) {
- $this->getOutput()->addWikiMsg( 'sessionfailure' );
-
- return false;
- }
- $bitParams = $this->extractBitParams();
- // from dropdown
- $listReason = $this->getRequest()->getText( 'wpRevDeleteReasonList', 'other' );
- $comment = $listReason;
- if ( $comment === 'other' ) {
- $comment = $this->otherReason;
- } elseif ( $this->otherReason !== '' ) {
- // Entry from drop down menu + additional comment
- $comment .= $this->msg( 'colon-separator' )->inContentLanguage()->text()
- . $this->otherReason;
- }
- # Can the user set this field?
- if ( $bitParams[Revision::DELETED_RESTRICTED] == 1
- && !$this->getUser()->isAllowed( 'suppressrevision' )
- ) {
- throw new PermissionsError( 'suppressrevision' );
- }
- # If the save went through, go to success message...
- $status = $this->save( $bitParams, $comment );
- if ( $status->isGood() ) {
- $this->success();
-
- return true;
- } else {
- # ...otherwise, bounce back to form...
- $this->failure( $status );
- }
-
- return false;
- }
-
- /**
- * Report that the submit operation succeeded
- */
- protected function success() {
- // Messages: revdelete-success, logdelete-success
- $this->getOutput()->setPageTitle( $this->msg( 'actioncomplete' ) );
- $this->getOutput()->wrapWikiMsg(
- "<div class=\"successbox\">\n$1\n</div>",
- $this->typeLabels['success']
- );
- $this->wasSaved = true;
- $this->revDelList->reloadFromMaster();
- $this->showForm();
- }
-
- /**
- * Report that the submit operation failed
- * @param Status $status
- */
- protected function failure( $status ) {
- // Messages: revdelete-failure, logdelete-failure
- $this->getOutput()->setPageTitle( $this->msg( 'actionfailed' ) );
- $this->getOutput()->wrapWikiTextAsInterface(
- 'errorbox',
- $status->getWikiText( $this->typeLabels['failure'] )
- );
- $this->showForm();
- }
-
- /**
- * Put together an array that contains -1, 0, or the *_deleted const for each bit
- *
- * @return array
- */
- protected function extractBitParams() {
- $bitfield = [];
- foreach ( $this->checks as $item ) {
- list( /* message */, $name, $field ) = $item;
- $val = $this->getRequest()->getInt( $name, 0 /* unchecked */ );
- if ( $val < -1 || $val > 1 ) {
- $val = -1; // -1 for existing value
- }
- $bitfield[$field] = $val;
- }
- if ( !isset( $bitfield[Revision::DELETED_RESTRICTED] ) ) {
- $bitfield[Revision::DELETED_RESTRICTED] = 0;
- }
-
- return $bitfield;
- }
-
- /**
- * Do the write operations. Simple wrapper for RevDel*List::setVisibility().
- * @param array $bitPars ExtractBitParams() bitfield array
- * @param string $reason
- * @return Status
- */
- protected function save( array $bitPars, $reason ) {
- return $this->getList()->setVisibility(
- [ 'value' => $bitPars, 'comment' => $reason ]
- );
- }
-
- protected function getGroupName() {
- return 'pagetools';
- }
-}
--- /dev/null
+<?php
+/**
+ * Implements Special:Whatlinkshere
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @todo Use some variant of Pager or something; the pagination here is lousy.
+ */
+
+use Wikimedia\Rdbms\IDatabase;
+
+/**
+ * Implements Special:Whatlinkshere
+ *
+ * @ingroup SpecialPage
+ */
+class SpecialWhatLinksHere extends IncludableSpecialPage {
+ /** @var FormOptions */
+ protected $opts;
+
+ protected $selfTitle;
+
+ /** @var Title */
+ protected $target;
+
+ protected $limits = [ 20, 50, 100, 250, 500 ];
+
+ public function __construct() {
+ parent::__construct( 'Whatlinkshere' );
+ }
+
+ function execute( $par ) {
+ $out = $this->getOutput();
+
+ $this->setHeaders();
+ $this->outputHeader();
+ $this->addHelpLink( 'Help:What links here' );
+
+ $opts = new FormOptions();
+
+ $opts->add( 'target', '' );
+ $opts->add( 'namespace', '', FormOptions::INTNULL );
+ $opts->add( 'limit', $this->getConfig()->get( 'QueryPageDefaultLimit' ) );
+ $opts->add( 'from', 0 );
+ $opts->add( 'back', 0 );
+ $opts->add( 'hideredirs', false );
+ $opts->add( 'hidetrans', false );
+ $opts->add( 'hidelinks', false );
+ $opts->add( 'hideimages', false );
+ $opts->add( 'invert', false );
+
+ $opts->fetchValuesFromRequest( $this->getRequest() );
+ $opts->validateIntBounds( 'limit', 0, 5000 );
+
+ // Give precedence to subpage syntax
+ if ( $par !== null ) {
+ $opts->setValue( 'target', $par );
+ }
+
+ // Bind to member variable
+ $this->opts = $opts;
+
+ $this->target = Title::newFromText( $opts->getValue( 'target' ) );
+ if ( !$this->target ) {
+ if ( !$this->including() ) {
+ $out->addHTML( $this->whatlinkshereForm() );
+ }
+
+ return;
+ }
+
+ $this->getSkin()->setRelevantTitle( $this->target );
+
+ $this->selfTitle = $this->getPageTitle( $this->target->getPrefixedDBkey() );
+
+ $out->setPageTitle( $this->msg( 'whatlinkshere-title', $this->target->getPrefixedText() ) );
+ $out->addBacklinkSubtitle( $this->target );
+ $this->showIndirectLinks(
+ 0,
+ $this->target,
+ $opts->getValue( 'limit' ),
+ $opts->getValue( 'from' ),
+ $opts->getValue( 'back' )
+ );
+ }
+
+ /**
+ * @param int $level Recursion level
+ * @param Title $target Target title
+ * @param int $limit Number of entries to display
+ * @param int $from Display from this article ID (default: 0)
+ * @param int $back Display from this article ID at backwards scrolling (default: 0)
+ */
+ function showIndirectLinks( $level, $target, $limit, $from = 0, $back = 0 ) {
+ $out = $this->getOutput();
+ $dbr = wfGetDB( DB_REPLICA );
+
+ $hidelinks = $this->opts->getValue( 'hidelinks' );
+ $hideredirs = $this->opts->getValue( 'hideredirs' );
+ $hidetrans = $this->opts->getValue( 'hidetrans' );
+ $hideimages = $target->getNamespace() != NS_FILE || $this->opts->getValue( 'hideimages' );
+
+ $fetchlinks = ( !$hidelinks || !$hideredirs );
+
+ // Build query conds in concert for all three tables...
+ $conds['pagelinks'] = [
+ 'pl_namespace' => $target->getNamespace(),
+ 'pl_title' => $target->getDBkey(),
+ ];
+ $conds['templatelinks'] = [
+ 'tl_namespace' => $target->getNamespace(),
+ 'tl_title' => $target->getDBkey(),
+ ];
+ $conds['imagelinks'] = [
+ 'il_to' => $target->getDBkey(),
+ ];
+
+ $namespace = $this->opts->getValue( 'namespace' );
+ $invert = $this->opts->getValue( 'invert' );
+ $nsComparison = ( $invert ? '!= ' : '= ' ) . $dbr->addQuotes( $namespace );
+ if ( is_int( $namespace ) ) {
+ $conds['pagelinks'][] = "pl_from_namespace $nsComparison";
+ $conds['templatelinks'][] = "tl_from_namespace $nsComparison";
+ $conds['imagelinks'][] = "il_from_namespace $nsComparison";
+ }
+
+ if ( $from ) {
+ $conds['templatelinks'][] = "tl_from >= $from";
+ $conds['pagelinks'][] = "pl_from >= $from";
+ $conds['imagelinks'][] = "il_from >= $from";
+ }
+
+ if ( $hideredirs ) {
+ $conds['pagelinks']['rd_from'] = null;
+ } elseif ( $hidelinks ) {
+ $conds['pagelinks'][] = 'rd_from is NOT NULL';
+ }
+
+ $queryFunc = function ( IDatabase $dbr, $table, $fromCol ) use (
+ $conds, $target, $limit
+ ) {
+ // Read an extra row as an at-end check
+ $queryLimit = $limit + 1;
+ $on = [
+ "rd_from = $fromCol",
+ 'rd_title' => $target->getDBkey(),
+ 'rd_interwiki = ' . $dbr->addQuotes( '' ) . ' OR rd_interwiki IS NULL'
+ ];
+ $on['rd_namespace'] = $target->getNamespace();
+ // Inner LIMIT is 2X in case of stale backlinks with wrong namespaces
+ $subQuery = $dbr->buildSelectSubquery(
+ [ $table, 'redirect', 'page' ],
+ [ $fromCol, 'rd_from' ],
+ $conds[$table],
+ __CLASS__ . '::showIndirectLinks',
+ // Force JOIN order per T106682 to avoid large filesorts
+ [ 'ORDER BY' => $fromCol, 'LIMIT' => 2 * $queryLimit, 'STRAIGHT_JOIN' ],
+ [
+ 'page' => [ 'JOIN', "$fromCol = page_id" ],
+ 'redirect' => [ 'LEFT JOIN', $on ]
+ ]
+ );
+ return $dbr->select(
+ [ 'page', 'temp_backlink_range' => $subQuery ],
+ [ 'page_id', 'page_namespace', 'page_title', 'rd_from', 'page_is_redirect' ],
+ [],
+ __CLASS__ . '::showIndirectLinks',
+ [ 'ORDER BY' => 'page_id', 'LIMIT' => $queryLimit ],
+ [ 'page' => [ 'JOIN', "$fromCol = page_id" ] ]
+ );
+ };
+
+ if ( $fetchlinks ) {
+ $plRes = $queryFunc( $dbr, 'pagelinks', 'pl_from' );
+ }
+
+ if ( !$hidetrans ) {
+ $tlRes = $queryFunc( $dbr, 'templatelinks', 'tl_from' );
+ }
+
+ if ( !$hideimages ) {
+ $ilRes = $queryFunc( $dbr, 'imagelinks', 'il_from' );
+ }
+
+ if ( ( !$fetchlinks || !$plRes->numRows() )
+ && ( $hidetrans || !$tlRes->numRows() )
+ && ( $hideimages || !$ilRes->numRows() )
+ ) {
+ if ( $level == 0 && !$this->including() ) {
+ $out->addHTML( $this->whatlinkshereForm() );
+
+ // Show filters only if there are links
+ if ( $hidelinks || $hidetrans || $hideredirs || $hideimages ) {
+ $out->addHTML( $this->getFilterPanel() );
+ }
+ $msgKey = is_int( $namespace ) ? 'nolinkshere-ns' : 'nolinkshere';
+ $link = $this->getLinkRenderer()->makeLink(
+ $this->target,
+ null,
+ [],
+ $this->target->isRedirect() ? [ 'redirect' => 'no' ] : []
+ );
+
+ $errMsg = $this->msg( $msgKey )
+ ->params( $this->target->getPrefixedText() )
+ ->rawParams( $link )
+ ->parseAsBlock();
+ $out->addHTML( $errMsg );
+ $out->setStatusCode( 404 );
+ }
+
+ return;
+ }
+
+ // Read the rows into an array and remove duplicates
+ // templatelinks comes second so that the templatelinks row overwrites the
+ // pagelinks row, so we get (inclusion) rather than nothing
+ if ( $fetchlinks ) {
+ foreach ( $plRes as $row ) {
+ $row->is_template = 0;
+ $row->is_image = 0;
+ $rows[$row->page_id] = $row;
+ }
+ }
+ if ( !$hidetrans ) {
+ foreach ( $tlRes as $row ) {
+ $row->is_template = 1;
+ $row->is_image = 0;
+ $rows[$row->page_id] = $row;
+ }
+ }
+ if ( !$hideimages ) {
+ foreach ( $ilRes as $row ) {
+ $row->is_template = 0;
+ $row->is_image = 1;
+ $rows[$row->page_id] = $row;
+ }
+ }
+
+ // Sort by key and then change the keys to 0-based indices
+ ksort( $rows );
+ $rows = array_values( $rows );
+
+ $numRows = count( $rows );
+
+ // Work out the start and end IDs, for prev/next links
+ if ( $numRows > $limit ) {
+ // More rows available after these ones
+ // Get the ID from the last row in the result set
+ $nextId = $rows[$limit]->page_id;
+ // Remove undisplayed rows
+ $rows = array_slice( $rows, 0, $limit );
+ } else {
+ // No more rows after
+ $nextId = false;
+ }
+ $prevId = $from;
+
+ // use LinkBatch to make sure, that all required data (associated with Titles)
+ // is loaded in one query
+ $lb = new LinkBatch();
+ foreach ( $rows as $row ) {
+ $lb->add( $row->page_namespace, $row->page_title );
+ }
+ $lb->execute();
+
+ if ( $level == 0 && !$this->including() ) {
+ $out->addHTML( $this->whatlinkshereForm() );
+ $out->addHTML( $this->getFilterPanel() );
+
+ $link = $this->getLinkRenderer()->makeLink(
+ $this->target,
+ null,
+ [],
+ $this->target->isRedirect() ? [ 'redirect' => 'no' ] : []
+ );
+
+ $msg = $this->msg( 'linkshere' )
+ ->params( $this->target->getPrefixedText() )
+ ->rawParams( $link )
+ ->parseAsBlock();
+ $out->addHTML( $msg );
+
+ $prevnext = $this->getPrevNext( $prevId, $nextId );
+ $out->addHTML( $prevnext );
+ }
+ $out->addHTML( $this->listStart( $level ) );
+ foreach ( $rows as $row ) {
+ $nt = Title::makeTitle( $row->page_namespace, $row->page_title );
+
+ if ( $row->rd_from && $level < 2 ) {
+ $out->addHTML( $this->listItem( $row, $nt, $target, true ) );
+ $this->showIndirectLinks(
+ $level + 1,
+ $nt,
+ $this->getConfig()->get( 'MaxRedirectLinksRetrieved' )
+ );
+ $out->addHTML( Xml::closeElement( 'li' ) );
+ } else {
+ $out->addHTML( $this->listItem( $row, $nt, $target ) );
+ }
+ }
+
+ $out->addHTML( $this->listEnd() );
+
+ if ( $level == 0 && !$this->including() ) {
+ $out->addHTML( $prevnext );
+ }
+ }
+
+ protected function listStart( $level ) {
+ return Xml::openElement( 'ul', ( $level ? [] : [ 'id' => 'mw-whatlinkshere-list' ] ) );
+ }
+
+ protected function listItem( $row, $nt, $target, $notClose = false ) {
+ $dirmark = $this->getLanguage()->getDirMark();
+
+ # local message cache
+ static $msgcache = null;
+ if ( $msgcache === null ) {
+ static $msgs = [ 'isredirect', 'istemplate', 'semicolon-separator',
+ 'whatlinkshere-links', 'isimage', 'editlink' ];
+ $msgcache = [];
+ foreach ( $msgs as $msg ) {
+ $msgcache[$msg] = $this->msg( $msg )->escaped();
+ }
+ }
+
+ if ( $row->rd_from ) {
+ $query = [ 'redirect' => 'no' ];
+ } else {
+ $query = [];
+ }
+
+ $link = $this->getLinkRenderer()->makeKnownLink(
+ $nt,
+ null,
+ $row->page_is_redirect ? [ 'class' => 'mw-redirect' ] : [],
+ $query
+ );
+
+ // Display properties (redirect or template)
+ $propsText = '';
+ $props = [];
+ if ( $row->rd_from ) {
+ $props[] = $msgcache['isredirect'];
+ }
+ if ( $row->is_template ) {
+ $props[] = $msgcache['istemplate'];
+ }
+ if ( $row->is_image ) {
+ $props[] = $msgcache['isimage'];
+ }
+
+ Hooks::run( 'WhatLinksHereProps', [ $row, $nt, $target, &$props ] );
+
+ if ( count( $props ) ) {
+ $propsText = $this->msg( 'parentheses' )
+ ->rawParams( implode( $msgcache['semicolon-separator'], $props ) )->escaped();
+ }
+
+ # Space for utilities links, with a what-links-here link provided
+ $wlhLink = $this->wlhLink( $nt, $msgcache['whatlinkshere-links'], $msgcache['editlink'] );
+ $wlh = Xml::wrapClass(
+ $this->msg( 'parentheses' )->rawParams( $wlhLink )->escaped(),
+ 'mw-whatlinkshere-tools'
+ );
+
+ return $notClose ?
+ Xml::openElement( 'li' ) . "$link $propsText $dirmark $wlh\n" :
+ Xml::tags( 'li', null, "$link $propsText $dirmark $wlh" ) . "\n";
+ }
+
+ protected function listEnd() {
+ return Xml::closeElement( 'ul' );
+ }
+
+ protected function wlhLink( Title $target, $text, $editText ) {
+ static $title = null;
+ if ( $title === null ) {
+ $title = $this->getPageTitle();
+ }
+
+ $linkRenderer = $this->getLinkRenderer();
+
+ if ( $text !== null ) {
+ $text = new HtmlArmor( $text );
+ }
+
+ // always show a "<- Links" link
+ $links = [
+ 'links' => $linkRenderer->makeKnownLink(
+ $title,
+ $text,
+ [],
+ [ 'target' => $target->getPrefixedText() ]
+ ),
+ ];
+
+ // if the page is editable, add an edit link
+ if (
+ // check user permissions
+ $this->getUser()->isAllowed( 'edit' ) &&
+ // check, if the content model is editable through action=edit
+ ContentHandler::getForTitle( $target )->supportsDirectEditing()
+ ) {
+ if ( $editText !== null ) {
+ $editText = new HtmlArmor( $editText );
+ }
+
+ $links['edit'] = $linkRenderer->makeKnownLink(
+ $target,
+ $editText,
+ [],
+ [ 'action' => 'edit' ]
+ );
+ }
+
+ // build the links html
+ return $this->getLanguage()->pipeList( $links );
+ }
+
+ function makeSelfLink( $text, $query ) {
+ if ( $text !== null ) {
+ $text = new HtmlArmor( $text );
+ }
+
+ return $this->getLinkRenderer()->makeKnownLink(
+ $this->selfTitle,
+ $text,
+ [],
+ $query
+ );
+ }
+
+ function getPrevNext( $prevId, $nextId ) {
+ $currentLimit = $this->opts->getValue( 'limit' );
+ $prev = $this->msg( 'whatlinkshere-prev' )->numParams( $currentLimit )->escaped();
+ $next = $this->msg( 'whatlinkshere-next' )->numParams( $currentLimit )->escaped();
+
+ $changed = $this->opts->getChangedValues();
+ unset( $changed['target'] ); // Already in the request title
+
+ if ( $prevId != 0 ) {
+ $overrides = [ 'from' => $this->opts->getValue( 'back' ) ];
+ $prev = $this->makeSelfLink( $prev, array_merge( $changed, $overrides ) );
+ }
+ if ( $nextId != 0 ) {
+ $overrides = [ 'from' => $nextId, 'back' => $prevId ];
+ $next = $this->makeSelfLink( $next, array_merge( $changed, $overrides ) );
+ }
+
+ $limitLinks = [];
+ $lang = $this->getLanguage();
+ foreach ( $this->limits as $limit ) {
+ $prettyLimit = htmlspecialchars( $lang->formatNum( $limit ) );
+ $overrides = [ 'limit' => $limit ];
+ $limitLinks[] = $this->makeSelfLink( $prettyLimit, array_merge( $changed, $overrides ) );
+ }
+
+ $nums = $lang->pipeList( $limitLinks );
+
+ return $this->msg( 'viewprevnext' )->rawParams( $prev, $next, $nums )->escaped();
+ }
+
+ function whatlinkshereForm() {
+ // We get nicer value from the title object
+ $this->opts->consumeValue( 'target' );
+ // Reset these for new requests
+ $this->opts->consumeValues( [ 'back', 'from' ] );
+
+ $target = $this->target ? $this->target->getPrefixedText() : '';
+ $namespace = $this->opts->consumeValue( 'namespace' );
+ $nsinvert = $this->opts->consumeValue( 'invert' );
+
+ # Build up the form
+ $f = Xml::openElement( 'form', [ 'action' => wfScript() ] );
+
+ # Values that should not be forgotten
+ $f .= Html::hidden( 'title', $this->getPageTitle()->getPrefixedText() );
+ foreach ( $this->opts->getUnconsumedValues() as $name => $value ) {
+ $f .= Html::hidden( $name, $value );
+ }
+
+ $f .= Xml::fieldset( $this->msg( 'whatlinkshere' )->text() );
+
+ # Target input (.mw-searchInput enables suggestions)
+ $f .= Xml::inputLabel( $this->msg( 'whatlinkshere-page' )->text(), 'target',
+ 'mw-whatlinkshere-target', 40, $target, [ 'class' => 'mw-searchInput' ] );
+
+ $f .= ' ';
+
+ # Namespace selector
+ $f .= Html::namespaceSelector(
+ [
+ 'selected' => $namespace,
+ 'all' => '',
+ 'label' => $this->msg( 'namespace' )->text(),
+ 'in-user-lang' => true,
+ ], [
+ 'name' => 'namespace',
+ 'id' => 'namespace',
+ 'class' => 'namespaceselector',
+ ]
+ );
+
+ $f .= "\u{00A0}" .
+ Xml::checkLabel(
+ $this->msg( 'invert' )->text(),
+ 'invert',
+ 'nsinvert',
+ $nsinvert,
+ [ 'title' => $this->msg( 'tooltip-whatlinkshere-invert' )->text() ]
+ );
+
+ $f .= ' ';
+
+ # Submit
+ $f .= Xml::submitButton( $this->msg( 'whatlinkshere-submit' )->text() );
+
+ # Close
+ $f .= Xml::closeElement( 'fieldset' ) . Xml::closeElement( 'form' ) . "\n";
+
+ return $f;
+ }
+
+ /**
+ * Create filter panel
+ *
+ * @return string HTML fieldset and filter panel with the show/hide links
+ */
+ function getFilterPanel() {
+ $show = $this->msg( 'show' )->escaped();
+ $hide = $this->msg( 'hide' )->escaped();
+
+ $changed = $this->opts->getChangedValues();
+ unset( $changed['target'] ); // Already in the request title
+
+ $links = [];
+ $types = [ 'hidetrans', 'hidelinks', 'hideredirs' ];
+ if ( $this->target->getNamespace() == NS_FILE ) {
+ $types[] = 'hideimages';
+ }
+
+ // Combined message keys: 'whatlinkshere-hideredirs', 'whatlinkshere-hidetrans',
+ // 'whatlinkshere-hidelinks', 'whatlinkshere-hideimages'
+ // To be sure they will be found by grep
+ foreach ( $types as $type ) {
+ $chosen = $this->opts->getValue( $type );
+ $msg = $chosen ? $show : $hide;
+ $overrides = [ $type => !$chosen ];
+ $links[] = $this->msg( "whatlinkshere-{$type}" )->rawParams(
+ $this->makeSelfLink( $msg, array_merge( $changed, $overrides ) ) )->escaped();
+ }
+
+ return Xml::fieldset(
+ $this->msg( 'whatlinkshere-filters' )->text(),
+ $this->getLanguage()->pipeList( $links )
+ );
+ }
+
+ /**
+ * Return an array of subpages beginning with $search that this special page will accept.
+ *
+ * @param string $search Prefix to search for
+ * @param int $limit Maximum number of results to return (usually 10)
+ * @param int $offset Number of results to skip (usually 0)
+ * @return string[] Matching subpages
+ */
+ public function prefixSearchSubpages( $search, $limit, $offset ) {
+ return $this->prefixSearchString( $search, $limit, $offset );
+ }
+
+ protected function getGroupName() {
+ return 'pagetools';
+ }
+}
+++ /dev/null
-<?php
-/**
- * Implements Special:Whatlinkshere
- *
- * This program is free software; you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation; either version 2 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License along
- * with this program; if not, write to the Free Software Foundation, Inc.,
- * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
- * http://www.gnu.org/copyleft/gpl.html
- *
- * @file
- * @todo Use some variant of Pager or something; the pagination here is lousy.
- */
-
-use Wikimedia\Rdbms\IDatabase;
-
-/**
- * Implements Special:Whatlinkshere
- *
- * @ingroup SpecialPage
- */
-class SpecialWhatLinksHere extends IncludableSpecialPage {
- /** @var FormOptions */
- protected $opts;
-
- protected $selfTitle;
-
- /** @var Title */
- protected $target;
-
- protected $limits = [ 20, 50, 100, 250, 500 ];
-
- public function __construct() {
- parent::__construct( 'Whatlinkshere' );
- }
-
- function execute( $par ) {
- $out = $this->getOutput();
-
- $this->setHeaders();
- $this->outputHeader();
- $this->addHelpLink( 'Help:What links here' );
-
- $opts = new FormOptions();
-
- $opts->add( 'target', '' );
- $opts->add( 'namespace', '', FormOptions::INTNULL );
- $opts->add( 'limit', $this->getConfig()->get( 'QueryPageDefaultLimit' ) );
- $opts->add( 'from', 0 );
- $opts->add( 'back', 0 );
- $opts->add( 'hideredirs', false );
- $opts->add( 'hidetrans', false );
- $opts->add( 'hidelinks', false );
- $opts->add( 'hideimages', false );
- $opts->add( 'invert', false );
-
- $opts->fetchValuesFromRequest( $this->getRequest() );
- $opts->validateIntBounds( 'limit', 0, 5000 );
-
- // Give precedence to subpage syntax
- if ( $par !== null ) {
- $opts->setValue( 'target', $par );
- }
-
- // Bind to member variable
- $this->opts = $opts;
-
- $this->target = Title::newFromText( $opts->getValue( 'target' ) );
- if ( !$this->target ) {
- if ( !$this->including() ) {
- $out->addHTML( $this->whatlinkshereForm() );
- }
-
- return;
- }
-
- $this->getSkin()->setRelevantTitle( $this->target );
-
- $this->selfTitle = $this->getPageTitle( $this->target->getPrefixedDBkey() );
-
- $out->setPageTitle( $this->msg( 'whatlinkshere-title', $this->target->getPrefixedText() ) );
- $out->addBacklinkSubtitle( $this->target );
- $this->showIndirectLinks(
- 0,
- $this->target,
- $opts->getValue( 'limit' ),
- $opts->getValue( 'from' ),
- $opts->getValue( 'back' )
- );
- }
-
- /**
- * @param int $level Recursion level
- * @param Title $target Target title
- * @param int $limit Number of entries to display
- * @param int $from Display from this article ID (default: 0)
- * @param int $back Display from this article ID at backwards scrolling (default: 0)
- */
- function showIndirectLinks( $level, $target, $limit, $from = 0, $back = 0 ) {
- $out = $this->getOutput();
- $dbr = wfGetDB( DB_REPLICA );
-
- $hidelinks = $this->opts->getValue( 'hidelinks' );
- $hideredirs = $this->opts->getValue( 'hideredirs' );
- $hidetrans = $this->opts->getValue( 'hidetrans' );
- $hideimages = $target->getNamespace() != NS_FILE || $this->opts->getValue( 'hideimages' );
-
- $fetchlinks = ( !$hidelinks || !$hideredirs );
-
- // Build query conds in concert for all three tables...
- $conds['pagelinks'] = [
- 'pl_namespace' => $target->getNamespace(),
- 'pl_title' => $target->getDBkey(),
- ];
- $conds['templatelinks'] = [
- 'tl_namespace' => $target->getNamespace(),
- 'tl_title' => $target->getDBkey(),
- ];
- $conds['imagelinks'] = [
- 'il_to' => $target->getDBkey(),
- ];
-
- $namespace = $this->opts->getValue( 'namespace' );
- $invert = $this->opts->getValue( 'invert' );
- $nsComparison = ( $invert ? '!= ' : '= ' ) . $dbr->addQuotes( $namespace );
- if ( is_int( $namespace ) ) {
- $conds['pagelinks'][] = "pl_from_namespace $nsComparison";
- $conds['templatelinks'][] = "tl_from_namespace $nsComparison";
- $conds['imagelinks'][] = "il_from_namespace $nsComparison";
- }
-
- if ( $from ) {
- $conds['templatelinks'][] = "tl_from >= $from";
- $conds['pagelinks'][] = "pl_from >= $from";
- $conds['imagelinks'][] = "il_from >= $from";
- }
-
- if ( $hideredirs ) {
- $conds['pagelinks']['rd_from'] = null;
- } elseif ( $hidelinks ) {
- $conds['pagelinks'][] = 'rd_from is NOT NULL';
- }
-
- $queryFunc = function ( IDatabase $dbr, $table, $fromCol ) use (
- $conds, $target, $limit
- ) {
- // Read an extra row as an at-end check
- $queryLimit = $limit + 1;
- $on = [
- "rd_from = $fromCol",
- 'rd_title' => $target->getDBkey(),
- 'rd_interwiki = ' . $dbr->addQuotes( '' ) . ' OR rd_interwiki IS NULL'
- ];
- $on['rd_namespace'] = $target->getNamespace();
- // Inner LIMIT is 2X in case of stale backlinks with wrong namespaces
- $subQuery = $dbr->buildSelectSubquery(
- [ $table, 'redirect', 'page' ],
- [ $fromCol, 'rd_from' ],
- $conds[$table],
- __CLASS__ . '::showIndirectLinks',
- // Force JOIN order per T106682 to avoid large filesorts
- [ 'ORDER BY' => $fromCol, 'LIMIT' => 2 * $queryLimit, 'STRAIGHT_JOIN' ],
- [
- 'page' => [ 'JOIN', "$fromCol = page_id" ],
- 'redirect' => [ 'LEFT JOIN', $on ]
- ]
- );
- return $dbr->select(
- [ 'page', 'temp_backlink_range' => $subQuery ],
- [ 'page_id', 'page_namespace', 'page_title', 'rd_from', 'page_is_redirect' ],
- [],
- __CLASS__ . '::showIndirectLinks',
- [ 'ORDER BY' => 'page_id', 'LIMIT' => $queryLimit ],
- [ 'page' => [ 'JOIN', "$fromCol = page_id" ] ]
- );
- };
-
- if ( $fetchlinks ) {
- $plRes = $queryFunc( $dbr, 'pagelinks', 'pl_from' );
- }
-
- if ( !$hidetrans ) {
- $tlRes = $queryFunc( $dbr, 'templatelinks', 'tl_from' );
- }
-
- if ( !$hideimages ) {
- $ilRes = $queryFunc( $dbr, 'imagelinks', 'il_from' );
- }
-
- if ( ( !$fetchlinks || !$plRes->numRows() )
- && ( $hidetrans || !$tlRes->numRows() )
- && ( $hideimages || !$ilRes->numRows() )
- ) {
- if ( $level == 0 && !$this->including() ) {
- $out->addHTML( $this->whatlinkshereForm() );
-
- // Show filters only if there are links
- if ( $hidelinks || $hidetrans || $hideredirs || $hideimages ) {
- $out->addHTML( $this->getFilterPanel() );
- }
- $msgKey = is_int( $namespace ) ? 'nolinkshere-ns' : 'nolinkshere';
- $link = $this->getLinkRenderer()->makeLink(
- $this->target,
- null,
- [],
- $this->target->isRedirect() ? [ 'redirect' => 'no' ] : []
- );
-
- $errMsg = $this->msg( $msgKey )
- ->params( $this->target->getPrefixedText() )
- ->rawParams( $link )
- ->parseAsBlock();
- $out->addHTML( $errMsg );
- $out->setStatusCode( 404 );
- }
-
- return;
- }
-
- // Read the rows into an array and remove duplicates
- // templatelinks comes second so that the templatelinks row overwrites the
- // pagelinks row, so we get (inclusion) rather than nothing
- if ( $fetchlinks ) {
- foreach ( $plRes as $row ) {
- $row->is_template = 0;
- $row->is_image = 0;
- $rows[$row->page_id] = $row;
- }
- }
- if ( !$hidetrans ) {
- foreach ( $tlRes as $row ) {
- $row->is_template = 1;
- $row->is_image = 0;
- $rows[$row->page_id] = $row;
- }
- }
- if ( !$hideimages ) {
- foreach ( $ilRes as $row ) {
- $row->is_template = 0;
- $row->is_image = 1;
- $rows[$row->page_id] = $row;
- }
- }
-
- // Sort by key and then change the keys to 0-based indices
- ksort( $rows );
- $rows = array_values( $rows );
-
- $numRows = count( $rows );
-
- // Work out the start and end IDs, for prev/next links
- if ( $numRows > $limit ) {
- // More rows available after these ones
- // Get the ID from the last row in the result set
- $nextId = $rows[$limit]->page_id;
- // Remove undisplayed rows
- $rows = array_slice( $rows, 0, $limit );
- } else {
- // No more rows after
- $nextId = false;
- }
- $prevId = $from;
-
- // use LinkBatch to make sure, that all required data (associated with Titles)
- // is loaded in one query
- $lb = new LinkBatch();
- foreach ( $rows as $row ) {
- $lb->add( $row->page_namespace, $row->page_title );
- }
- $lb->execute();
-
- if ( $level == 0 && !$this->including() ) {
- $out->addHTML( $this->whatlinkshereForm() );
- $out->addHTML( $this->getFilterPanel() );
-
- $link = $this->getLinkRenderer()->makeLink(
- $this->target,
- null,
- [],
- $this->target->isRedirect() ? [ 'redirect' => 'no' ] : []
- );
-
- $msg = $this->msg( 'linkshere' )
- ->params( $this->target->getPrefixedText() )
- ->rawParams( $link )
- ->parseAsBlock();
- $out->addHTML( $msg );
-
- $prevnext = $this->getPrevNext( $prevId, $nextId );
- $out->addHTML( $prevnext );
- }
- $out->addHTML( $this->listStart( $level ) );
- foreach ( $rows as $row ) {
- $nt = Title::makeTitle( $row->page_namespace, $row->page_title );
-
- if ( $row->rd_from && $level < 2 ) {
- $out->addHTML( $this->listItem( $row, $nt, $target, true ) );
- $this->showIndirectLinks(
- $level + 1,
- $nt,
- $this->getConfig()->get( 'MaxRedirectLinksRetrieved' )
- );
- $out->addHTML( Xml::closeElement( 'li' ) );
- } else {
- $out->addHTML( $this->listItem( $row, $nt, $target ) );
- }
- }
-
- $out->addHTML( $this->listEnd() );
-
- if ( $level == 0 && !$this->including() ) {
- $out->addHTML( $prevnext );
- }
- }
-
- protected function listStart( $level ) {
- return Xml::openElement( 'ul', ( $level ? [] : [ 'id' => 'mw-whatlinkshere-list' ] ) );
- }
-
- protected function listItem( $row, $nt, $target, $notClose = false ) {
- $dirmark = $this->getLanguage()->getDirMark();
-
- # local message cache
- static $msgcache = null;
- if ( $msgcache === null ) {
- static $msgs = [ 'isredirect', 'istemplate', 'semicolon-separator',
- 'whatlinkshere-links', 'isimage', 'editlink' ];
- $msgcache = [];
- foreach ( $msgs as $msg ) {
- $msgcache[$msg] = $this->msg( $msg )->escaped();
- }
- }
-
- if ( $row->rd_from ) {
- $query = [ 'redirect' => 'no' ];
- } else {
- $query = [];
- }
-
- $link = $this->getLinkRenderer()->makeKnownLink(
- $nt,
- null,
- $row->page_is_redirect ? [ 'class' => 'mw-redirect' ] : [],
- $query
- );
-
- // Display properties (redirect or template)
- $propsText = '';
- $props = [];
- if ( $row->rd_from ) {
- $props[] = $msgcache['isredirect'];
- }
- if ( $row->is_template ) {
- $props[] = $msgcache['istemplate'];
- }
- if ( $row->is_image ) {
- $props[] = $msgcache['isimage'];
- }
-
- Hooks::run( 'WhatLinksHereProps', [ $row, $nt, $target, &$props ] );
-
- if ( count( $props ) ) {
- $propsText = $this->msg( 'parentheses' )
- ->rawParams( implode( $msgcache['semicolon-separator'], $props ) )->escaped();
- }
-
- # Space for utilities links, with a what-links-here link provided
- $wlhLink = $this->wlhLink( $nt, $msgcache['whatlinkshere-links'], $msgcache['editlink'] );
- $wlh = Xml::wrapClass(
- $this->msg( 'parentheses' )->rawParams( $wlhLink )->escaped(),
- 'mw-whatlinkshere-tools'
- );
-
- return $notClose ?
- Xml::openElement( 'li' ) . "$link $propsText $dirmark $wlh\n" :
- Xml::tags( 'li', null, "$link $propsText $dirmark $wlh" ) . "\n";
- }
-
- protected function listEnd() {
- return Xml::closeElement( 'ul' );
- }
-
- protected function wlhLink( Title $target, $text, $editText ) {
- static $title = null;
- if ( $title === null ) {
- $title = $this->getPageTitle();
- }
-
- $linkRenderer = $this->getLinkRenderer();
-
- if ( $text !== null ) {
- $text = new HtmlArmor( $text );
- }
-
- // always show a "<- Links" link
- $links = [
- 'links' => $linkRenderer->makeKnownLink(
- $title,
- $text,
- [],
- [ 'target' => $target->getPrefixedText() ]
- ),
- ];
-
- // if the page is editable, add an edit link
- if (
- // check user permissions
- $this->getUser()->isAllowed( 'edit' ) &&
- // check, if the content model is editable through action=edit
- ContentHandler::getForTitle( $target )->supportsDirectEditing()
- ) {
- if ( $editText !== null ) {
- $editText = new HtmlArmor( $editText );
- }
-
- $links['edit'] = $linkRenderer->makeKnownLink(
- $target,
- $editText,
- [],
- [ 'action' => 'edit' ]
- );
- }
-
- // build the links html
- return $this->getLanguage()->pipeList( $links );
- }
-
- function makeSelfLink( $text, $query ) {
- if ( $text !== null ) {
- $text = new HtmlArmor( $text );
- }
-
- return $this->getLinkRenderer()->makeKnownLink(
- $this->selfTitle,
- $text,
- [],
- $query
- );
- }
-
- function getPrevNext( $prevId, $nextId ) {
- $currentLimit = $this->opts->getValue( 'limit' );
- $prev = $this->msg( 'whatlinkshere-prev' )->numParams( $currentLimit )->escaped();
- $next = $this->msg( 'whatlinkshere-next' )->numParams( $currentLimit )->escaped();
-
- $changed = $this->opts->getChangedValues();
- unset( $changed['target'] ); // Already in the request title
-
- if ( $prevId != 0 ) {
- $overrides = [ 'from' => $this->opts->getValue( 'back' ) ];
- $prev = $this->makeSelfLink( $prev, array_merge( $changed, $overrides ) );
- }
- if ( $nextId != 0 ) {
- $overrides = [ 'from' => $nextId, 'back' => $prevId ];
- $next = $this->makeSelfLink( $next, array_merge( $changed, $overrides ) );
- }
-
- $limitLinks = [];
- $lang = $this->getLanguage();
- foreach ( $this->limits as $limit ) {
- $prettyLimit = htmlspecialchars( $lang->formatNum( $limit ) );
- $overrides = [ 'limit' => $limit ];
- $limitLinks[] = $this->makeSelfLink( $prettyLimit, array_merge( $changed, $overrides ) );
- }
-
- $nums = $lang->pipeList( $limitLinks );
-
- return $this->msg( 'viewprevnext' )->rawParams( $prev, $next, $nums )->escaped();
- }
-
- function whatlinkshereForm() {
- // We get nicer value from the title object
- $this->opts->consumeValue( 'target' );
- // Reset these for new requests
- $this->opts->consumeValues( [ 'back', 'from' ] );
-
- $target = $this->target ? $this->target->getPrefixedText() : '';
- $namespace = $this->opts->consumeValue( 'namespace' );
- $nsinvert = $this->opts->consumeValue( 'invert' );
-
- # Build up the form
- $f = Xml::openElement( 'form', [ 'action' => wfScript() ] );
-
- # Values that should not be forgotten
- $f .= Html::hidden( 'title', $this->getPageTitle()->getPrefixedText() );
- foreach ( $this->opts->getUnconsumedValues() as $name => $value ) {
- $f .= Html::hidden( $name, $value );
- }
-
- $f .= Xml::fieldset( $this->msg( 'whatlinkshere' )->text() );
-
- # Target input (.mw-searchInput enables suggestions)
- $f .= Xml::inputLabel( $this->msg( 'whatlinkshere-page' )->text(), 'target',
- 'mw-whatlinkshere-target', 40, $target, [ 'class' => 'mw-searchInput' ] );
-
- $f .= ' ';
-
- # Namespace selector
- $f .= Html::namespaceSelector(
- [
- 'selected' => $namespace,
- 'all' => '',
- 'label' => $this->msg( 'namespace' )->text(),
- 'in-user-lang' => true,
- ], [
- 'name' => 'namespace',
- 'id' => 'namespace',
- 'class' => 'namespaceselector',
- ]
- );
-
- $f .= "\u{00A0}" .
- Xml::checkLabel(
- $this->msg( 'invert' )->text(),
- 'invert',
- 'nsinvert',
- $nsinvert,
- [ 'title' => $this->msg( 'tooltip-whatlinkshere-invert' )->text() ]
- );
-
- $f .= ' ';
-
- # Submit
- $f .= Xml::submitButton( $this->msg( 'whatlinkshere-submit' )->text() );
-
- # Close
- $f .= Xml::closeElement( 'fieldset' ) . Xml::closeElement( 'form' ) . "\n";
-
- return $f;
- }
-
- /**
- * Create filter panel
- *
- * @return string HTML fieldset and filter panel with the show/hide links
- */
- function getFilterPanel() {
- $show = $this->msg( 'show' )->escaped();
- $hide = $this->msg( 'hide' )->escaped();
-
- $changed = $this->opts->getChangedValues();
- unset( $changed['target'] ); // Already in the request title
-
- $links = [];
- $types = [ 'hidetrans', 'hidelinks', 'hideredirs' ];
- if ( $this->target->getNamespace() == NS_FILE ) {
- $types[] = 'hideimages';
- }
-
- // Combined message keys: 'whatlinkshere-hideredirs', 'whatlinkshere-hidetrans',
- // 'whatlinkshere-hidelinks', 'whatlinkshere-hideimages'
- // To be sure they will be found by grep
- foreach ( $types as $type ) {
- $chosen = $this->opts->getValue( $type );
- $msg = $chosen ? $show : $hide;
- $overrides = [ $type => !$chosen ];
- $links[] = $this->msg( "whatlinkshere-{$type}" )->rawParams(
- $this->makeSelfLink( $msg, array_merge( $changed, $overrides ) ) )->escaped();
- }
-
- return Xml::fieldset(
- $this->msg( 'whatlinkshere-filters' )->text(),
- $this->getLanguage()->pipeList( $links )
- );
- }
-
- /**
- * Return an array of subpages beginning with $search that this special page will accept.
- *
- * @param string $search Prefix to search for
- * @param int $limit Maximum number of results to return (usually 10)
- * @param int $offset Number of results to skip (usually 0)
- * @return string[] Matching subpages
- */
- public function prefixSearchSubpages( $search, $limit, $offset ) {
- return $this->prefixSearchString( $search, $limit, $offset );
- }
-
- protected function getGroupName() {
- return 'pagetools';
- }
-}