'CapsCleanup' => __DIR__ . '/maintenance/cleanupCaps.php',
'Category' => __DIR__ . '/includes/Category.php',
'CategoryFinder' => __DIR__ . '/includes/CategoryFinder.php',
+ 'CategoryMembershipChange' => __DIR__ . '/includes/changes/CategoryMembershipChange.php',
'CategoryPage' => __DIR__ . '/includes/page/CategoryPage.php',
'CategoryPager' => __DIR__ . '/includes/specials/SpecialCategories.php',
'CategoryViewer' => __DIR__ . '/includes/CategoryViewer.php',
'msg:usermessage-editor', // Default user for leaving user messages
'msg:proxyblocker', // For $wgProxyList and Special:Blockme (removed in 1.22)
'msg:spambot_username', // Used by cleanupSpam.php
+ 'msg:autochange-username', // Used by anon category RC entries (parser functions, Lua & purges)
);
/**
define( 'RC_NEW', 1 );
define( 'RC_LOG', 3 );
define( 'RC_EXTERNAL', 5 );
+define( 'RC_CATEGORIZE', 6 );
/**@}*/
/**@{
--- /dev/null
+<?php
+/**
+ * Helper class for category membership changes
+ *
+ * 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
+ * @author Kai Nissen
+ * @author Adam Shorland
+ * @since 1.26
+ */
+
+use Wikimedia\Assert\Assert;
+
+class CategoryMembershipChange {
+
+ const CATEGORY_ADDITION = 1;
+ const CATEGORY_REMOVAL = -1;
+
+ /**
+ * @var string Current timestamp, set during CategoryMembershipChange::__construct()
+ */
+ private $timestamp;
+
+ /**
+ * @var Title Title instance of the categorized page
+ */
+ private $pageTitle;
+
+ /**
+ * @var Revision|null Latest Revision instance of the categorized page
+ */
+ private $revision;
+
+ /**
+ * @var int
+ * Number of pages this WikiPage is embedded by; set by CategoryMembershipChange::setRecursive()
+ */
+ private $numTemplateLinks = 0;
+
+ /**
+ * @var callable|null
+ */
+ private $newForCategorizationCallback = null;
+
+ /**
+ * @param Title $pageTitle Title instance of the categorized page
+ * @param Revision $revision Latest Revision instance of the categorized page
+ *
+ * @throws MWException
+ */
+ public function __construct( Title $pageTitle, Revision $revision = null ) {
+ $this->pageTitle = $pageTitle;
+ $this->timestamp = wfTimestampNow();
+ $this->revision = $revision;
+ $this->newForCategorizationCallback = array( 'RecentChange', 'newForCategorization' );
+ }
+
+ /**
+ * Overrides the default new for categorization callback
+ * This is intended for use while testing and will fail if MW_PHPUNIT_TEST is not defined.
+ *
+ * @param callable $callback
+ * @see RecentChange::newForCategorization for callback signiture
+ *
+ * @throws MWException
+ */
+ public function overrideNewForCategorizationCallback( $callback ) {
+ if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
+ throw new MWException( 'Cannot override newForCategorization callback in operation.' );
+ }
+ Assert::parameterType( 'callable', $callback, '$callback' );
+ $this->newForCategorizationCallback = $callback;
+ }
+
+ /**
+ * Determines the number of template links for recursive link updates
+ */
+ public function checkTemplateLinks() {
+ $this->numTemplateLinks = $this->pageTitle->getBacklinkCache()->getNumLinks( 'templatelinks' );
+ }
+
+ /**
+ * Create a recentchanges entry for category additions
+ *
+ * @param Title $categoryTitle
+ */
+ public function triggerCategoryAddedNotification( Title $categoryTitle ) {
+ $this->createRecentChangesEntry( $categoryTitle, self::CATEGORY_ADDITION );
+ }
+
+ /**
+ * Create a recentchanges entry for category removals
+ *
+ * @param Title $categoryTitle
+ */
+ public function triggerCategoryRemovedNotification( Title $categoryTitle ) {
+ $this->createRecentChangesEntry( $categoryTitle, self::CATEGORY_REMOVAL );
+ }
+
+ /**
+ * Create a recentchanges entry using RecentChange::notifyCategorization()
+ *
+ * @param Title $categoryTitle
+ * @param int $type
+ */
+ private function createRecentChangesEntry( Title $categoryTitle, $type ) {
+ $this->notifyCategorization(
+ $this->timestamp,
+ $categoryTitle,
+ $this->getUser(),
+ $this->getChangeMessageText( $type, array(
+ 'prefixedText' => $this->pageTitle->getPrefixedText(),
+ 'numTemplateLinks' => $this->numTemplateLinks
+ ) ),
+ $this->pageTitle,
+ $this->getPreviousRevisionTimestamp(),
+ $this->revision
+ );
+ }
+
+ /**
+ * @param string $timestamp Timestamp of the recent change to occur in TS_MW format
+ * @param Title $categoryTitle Title of the category a page is being added to or removed from
+ * @param User $user User object of the user that made the change
+ * @param string $comment Change summary
+ * @param Title $pageTitle Title of the page that is being added or removed
+ * @param string $lastTimestamp Parent revision timestamp of this change in TS_MW format
+ * @param Revision|null $revision
+ *
+ * @throws MWException
+ */
+ private function notifyCategorization(
+ $timestamp,
+ Title $categoryTitle,
+ User $user = null,
+ $comment,
+ Title $pageTitle,
+ $lastTimestamp,
+ $revision
+ ) {
+ $deleted = $revision ? $revision->getVisibility() & Revision::SUPPRESSED_USER : 0;
+ $newRevId = $revision ? $revision->getId() : 0;
+
+ /**
+ * T109700 - Default bot flag to true when there is no corresponding RC entry
+ * This means all changes caused by parser functions & Lua on reparse are marked as bot
+ * Also in the case no RC entry could be found due to slave lag
+ */
+ $bot = 1;
+ $lastRevId = 0;
+ $ip = '';
+
+ # If no revision is given, the change was probably triggered by parser functions
+ if ( $revision !== null ) {
+ // TODO if no RC try again from the master DB?
+ $correspondingRc = $this->revision->getRecentChange();
+ if ( $correspondingRc !== null ) {
+ $bot = $correspondingRc->getAttribute( 'rc_bot' ) ?: 0;
+ $ip = $correspondingRc->getAttribute( 'rc_ip' ) ?: '';
+ $lastRevId = $correspondingRc->getAttribute( 'rc_last_oldid' ) ?: 0;
+ }
+ }
+
+ $rc = call_user_func_array(
+ $this->newForCategorizationCallback,
+ array(
+ $timestamp,
+ $categoryTitle,
+ $user,
+ $comment,
+ $pageTitle,
+ $lastRevId,
+ $newRevId,
+ $lastTimestamp,
+ $bot,
+ $ip,
+ $deleted
+ )
+ );
+ $rc->save();
+ }
+
+ /**
+ * Get the user associated with this change.
+ *
+ * If there is no revision associated with the change and thus no editing user
+ * fallback to a default.
+ *
+ * False will be returned if the user name specified in the
+ * 'autochange-username' message is invalid.
+ *
+ * @return User|bool
+ */
+ private function getUser() {
+ if ( $this->revision ) {
+ $userId = $this->revision->getUser( Revision::RAW );
+ if ( $userId === 0 ) {
+ return User::newFromName( $this->revision->getUserText( Revision::RAW ), false );
+ } else {
+ return User::newFromId( $userId );
+ }
+ }
+
+ $username = wfMessage( 'autochange-username' )->inContentLanguage()->text();
+ $user = User::newFromName( $username );
+ # User::newFromName() can return false on a badly configured wiki.
+ if ( $user && !$user->isLoggedIn() ) {
+ $user->addToDatabase();
+ }
+
+ return $user;
+ }
+
+ /**
+ * Returns the change message according to the type of category membership change
+ *
+ * The message keys created in this method may be one of:
+ * - recentchanges-page-added-to-category
+ * - recentchanges-page-added-to-category-bundled
+ * - recentchanges-page-removed-from-category
+ * - recentchanges-page-removed-from-category-bundled
+ *
+ * @param int $type may be CategoryMembershipChange::CATEGORY_ADDITION
+ * or CategoryMembershipChange::CATEGORY_REMOVAL
+ * @param array $params
+ * - prefixedUrl: result of Title::->getPrefixedURL()
+ *
+ * @return string
+ */
+ private function getChangeMessageText( $type, array $params ) {
+ $array = array(
+ self::CATEGORY_ADDITION => 'recentchanges-page-added-to-category',
+ self::CATEGORY_REMOVAL => 'recentchanges-page-removed-from-category',
+ );
+
+ $msgKey = $array[$type];
+
+ if ( intval( $params['numTemplateLinks'] ) > 0 ) {
+ $msgKey .= '-bundled';
+ }
+
+ return wfMessage( $msgKey, $params )->inContentLanguage()->text();
+ }
+
+ /**
+ * Returns the timestamp of the page's previous revision or null if the latest revision
+ * does not refer to a parent revision
+ *
+ * @return null|string
+ */
+ private function getPreviousRevisionTimestamp() {
+ $previousRev = Revision::newFromId(
+ $this->pageTitle->getPreviousRevisionID( $this->pageTitle->getLatestRevID() )
+ );
+
+ return $previousRev ? $previousRev->getTimestamp() : null;
+ }
+
+}
const SRC_NEW = 'mw.new';
const SRC_LOG = 'mw.log';
const SRC_EXTERNAL = 'mw.external'; // obsolete
+ const SRC_CATEGORIZE = 'mw.categorize';
public $mAttribs = array();
public $mExtra = array();
'new' => RC_NEW,
'log' => RC_LOG,
'external' => RC_EXTERNAL,
+ 'categorize' => RC_CATEGORIZE,
);
# Factory methods
return $rc;
}
+ /**
+ * Constructs a RecentChange object for the given categorization
+ * This does not call save() on the object and thus does not write to the db
+ *
+ * @since 1.26
+ *
+ * @param string $timestamp Timestamp of the recent change to occur
+ * @param Title $categoryTitle Title of the category a page is being added to or removed from
+ * @param User $user User object of the user that made the change
+ * @param string $comment Change summary
+ * @param Title $pageTitle Title of the page that is being added or removed
+ * @param int $oldRevId Parent revision ID of this change
+ * @param int $newRevId Revision ID of this change
+ * @param string $lastTimestamp Parent revision timestamp of this change
+ * @param bool $bot true, if the change was made by a bot
+ * @param string $ip IP address of the user, if the change was made anonymously
+ * @param int $deleted Indicates whether the change has been deleted
+ *
+ * @return RecentChange
+ */
+ public static function newForCategorization(
+ $timestamp,
+ Title $categoryTitle,
+ User $user = null,
+ $comment,
+ Title $pageTitle,
+ $oldRevId,
+ $newRevId,
+ $lastTimestamp,
+ $bot,
+ $ip = '',
+ $deleted = 0
+ ) {
+
+ $rc = new RecentChange;
+ $rc->mTitle = $categoryTitle;
+ $rc->mPerformer = $user;
+ $rc->mAttribs = array(
+ 'rc_timestamp' => $timestamp,
+ 'rc_namespace' => $categoryTitle->getNamespace(),
+ 'rc_title' => $categoryTitle->getDBkey(),
+ 'rc_type' => RC_CATEGORIZE,
+ 'rc_source' => self::SRC_CATEGORIZE,
+ 'rc_minor' => 0,
+ 'rc_cur_id' => $pageTitle->getArticleID(),
+ 'rc_user' => $user ? $user->getId() : 0,
+ 'rc_user_text' => $user ? $user->getName() : '',
+ 'rc_comment' => $comment,
+ 'rc_this_oldid' => $newRevId,
+ 'rc_last_oldid' => $oldRevId,
+ 'rc_bot' => $bot ? 1 : 0,
+ 'rc_ip' => self::checkIPAddress( $ip ),
+ 'rc_patrolled' => 1, // Always patrolled, just like log entries
+ 'rc_new' => 0, # obsolete
+ 'rc_old_len' => 0,
+ 'rc_new_len' => 0,
+ 'rc_deleted' => $deleted,
+ 'rc_logid' => 0,
+ 'rc_log_type' => null,
+ 'rc_log_action' => '',
+ 'rc_params' => ''
+ );
+
+ $rc->mExtra = array(
+ 'prefixedDBkey' => $categoryTitle->getPrefixedDBkey(),
+ 'lastTimestamp' => $lastTimestamp,
+ 'oldSize' => 0,
+ 'newSize' => 0,
+ 'pageStatus' => 'changed'
+ );
+
+ return $rc;
+ }
+
/**
* Initialises the members of this object from a mysql row object
*
"recentchangeslinked-summary": "This is a list of changes made recently to pages linked from a specified page (or to members of a specified category).\nPages on [[Special:Watchlist|your watchlist]] are <strong>bold</strong>.",
"recentchangeslinked-page": "Page name:",
"recentchangeslinked-to": "Show changes to pages linked to the given page instead",
+ "recentchanges-page-added-to-category": "[[:$1]] added to category",
+ "recentchanges-page-added-to-category-bundled": "[[:$1]] and {{PLURAL:$2|one page|$2 pages}} added to category",
+ "recentchanges-page-removed-from-category": "[[:$1]] removed from category",
+ "recentchanges-page-removed-from-category-bundled": "[[:$1]] and {{PLURAL:$2|one page|$2 pages}} removed from category",
+ "autochange-username": "MediaWiki automatic change",
"upload": "Upload file",
"uploadbtn": "Upload file",
"reuploaddesc": "Cancel upload and return to the upload form",
"recentchangeslinked-summary": "Summary of [[Special:RecentChangesLinked]].",
"recentchangeslinked-page": "{{Identical|Page name}}",
"recentchangeslinked-to": "Checkbox in [[Special:RecentChangesLinked]].",
+ "recentchanges-page-added-to-category": "Comment message for pages added to a category\n\nParameters:\n* $1 - name of the page being added",
+ "recentchanges-page-added-to-category-bundled": "Comment message for template embedded by other pages added to a category\n\nParameters:\n* $1 - name of the page being added\n* $2 - number of additional pages being affected",
+ "recentchanges-page-removed-from-category": "Comment message for pages removed from a category\n\nParameters:\n* $1 - name of the page being removed",
+ "recentchanges-page-removed-from-category-bundled": "Comment message for templates embeddedby other pages removed from a category\n\nParameters:\n* $1 - name of the page being added\n* $2 - number of additional pages being affected",
+ "autochange-username": "Used as bot / unknown username.",
"upload": "Display name for link to [[Special:Upload]] for uploading files to the wiki.\n\nSee also:\n* {{msg-mw|Upload}}\n* {{msg-mw|Accesskey-t-upload}}\n* {{msg-mw|Tooltip-t-upload}}\n{{Identical|Upload file}}",
"uploadbtn": "Button name in [[Special:Upload]].\n\nSee also:\n* {{msg-mw|Uploadbtn}}\n* {{msg-mw|Accesskey-upload}}\n* {{msg-mw|Tooltip-upload}}\n{{Identical|Upload file}}",
"reuploaddesc": "Used as button text in the Upload form on [[Special:Upload]].\n\nSee also:\n* {{msg-mw|upload-tryagain|Submit button text}}\n* {{msg-mw|ignorewarning|button text}}",
--- /dev/null
+<?php
+
+/**
+ * @covers CategoryMembershipChange
+ *
+ * @group Database
+ *
+ * @author Adam Shorland
+ */
+class CategoryMembershipChangeTest extends MediaWikiLangTestCase {
+
+ /**
+ * @var array|Title[]|User[]
+ */
+ private static $lastNotifyArgs;
+
+ /**
+ * @var int
+ */
+ private static $notifyCallCounter = 0;
+
+ /**
+ * @var RecentChange
+ */
+ private static $mockRecentChange;
+
+ public static function newForCategorizationCallback() {
+ self::$lastNotifyArgs = func_get_args();
+ self::$notifyCallCounter += 1;
+ return self::$mockRecentChange;
+ }
+
+ public function setUp() {
+ parent::setUp();
+ self::$notifyCallCounter = 0;
+ self::$mockRecentChange = self::getMock( 'RecentChange' );
+ }
+
+ private function newChange( Revision $revision = null ) {
+ $change = new CategoryMembershipChange( Title::newFromText( 'UTPage' ), $revision );
+ $change->overrideNewForCategorizationCallback(
+ 'CategoryMembershipChangeTest::newForCategorizationCallback'
+ );
+
+ return $change;
+ }
+
+ public function testChangeAddedNoRev() {
+ $change = $this->newChange();
+ $change->triggerCategoryAddedNotification( Title::newFromText( 'CategoryName', NS_CATEGORY ) );
+
+ $this->assertEquals( 1, self::$notifyCallCounter );
+
+ $this->assertTrue( strlen( self::$lastNotifyArgs[0] ) === 14 );
+ $this->assertEquals( 'Category:CategoryName', self::$lastNotifyArgs[1]->getPrefixedText() );
+ $this->assertEquals( 'MediaWiki automatic change', self::$lastNotifyArgs[2]->getName() );
+ $this->assertEquals( '[[:UTPage]] added to category', self::$lastNotifyArgs[3] );
+ $this->assertEquals( 'UTPage', self::$lastNotifyArgs[4]->getPrefixedText() );
+ $this->assertEquals( 0, self::$lastNotifyArgs[5] );
+ $this->assertEquals( 0, self::$lastNotifyArgs[6] );
+ $this->assertEquals( null, self::$lastNotifyArgs[7] );
+ $this->assertEquals( 1, self::$lastNotifyArgs[8] );
+ $this->assertEquals( null, self::$lastNotifyArgs[9] );
+ $this->assertEquals( 0, self::$lastNotifyArgs[10] );
+ }
+
+ public function testChangeRemovedNoRev() {
+ $change = $this->newChange();
+ $change->triggerCategoryRemovedNotification( Title::newFromText( 'CategoryName', NS_CATEGORY ) );
+
+ $this->assertEquals( 1, self::$notifyCallCounter );
+
+ $this->assertTrue( strlen( self::$lastNotifyArgs[0] ) === 14 );
+ $this->assertEquals( 'Category:CategoryName', self::$lastNotifyArgs[1]->getPrefixedText() );
+ $this->assertEquals( 'MediaWiki automatic change', self::$lastNotifyArgs[2]->getName() );
+ $this->assertEquals( '[[:UTPage]] removed from category', self::$lastNotifyArgs[3] );
+ $this->assertEquals( 'UTPage', self::$lastNotifyArgs[4]->getPrefixedText() );
+ $this->assertEquals( 0, self::$lastNotifyArgs[5] );
+ $this->assertEquals( 0, self::$lastNotifyArgs[6] );
+ $this->assertEquals( null, self::$lastNotifyArgs[7] );
+ $this->assertEquals( 1, self::$lastNotifyArgs[8] );
+ $this->assertEquals( null, self::$lastNotifyArgs[9] );
+ $this->assertEquals( 0, self::$lastNotifyArgs[10] );
+ }
+
+ public function testChangeAddedWithRev() {
+ $revision = Revision::newFromId( Title::newFromText( 'UTPage' )->getLatestRevID() );
+ $change = $this->newChange( $revision );
+ $change->triggerCategoryAddedNotification( Title::newFromText( 'CategoryName', NS_CATEGORY ) );
+
+ $this->assertEquals( 1, self::$notifyCallCounter );
+
+ $this->assertTrue( strlen( self::$lastNotifyArgs[0] ) === 14 );
+ $this->assertEquals( 'Category:CategoryName', self::$lastNotifyArgs[1]->getPrefixedText() );
+ $this->assertEquals( 'UTSysop', self::$lastNotifyArgs[2]->getName() );
+ $this->assertEquals( '[[:UTPage]] added to category', self::$lastNotifyArgs[3] );
+ $this->assertEquals( 'UTPage', self::$lastNotifyArgs[4]->getPrefixedText() );
+ $this->assertEquals( 0, self::$lastNotifyArgs[5] );
+ $this->assertEquals( $revision->getId(), self::$lastNotifyArgs[6] );
+ $this->assertEquals( null, self::$lastNotifyArgs[7] );
+ $this->assertEquals( 0, self::$lastNotifyArgs[8] );
+ $this->assertEquals( '127.0.0.1', self::$lastNotifyArgs[9] );
+ $this->assertEquals( 0, self::$lastNotifyArgs[10] );
+ }
+
+ public function testChangeRemovedWithRev() {
+ $revision = Revision::newFromId( Title::newFromText( 'UTPage' )->getLatestRevID() );
+ $change = $this->newChange( $revision );
+ $change->triggerCategoryRemovedNotification( Title::newFromText( 'CategoryName', NS_CATEGORY ) );
+
+ $this->assertEquals( 1, self::$notifyCallCounter );
+
+ $this->assertTrue( strlen( self::$lastNotifyArgs[0] ) === 14 );
+ $this->assertEquals( 'Category:CategoryName', self::$lastNotifyArgs[1]->getPrefixedText() );
+ $this->assertEquals( 'UTSysop', self::$lastNotifyArgs[2]->getName() );
+ $this->assertEquals( '[[:UTPage]] removed from category', self::$lastNotifyArgs[3] );
+ $this->assertEquals( 'UTPage', self::$lastNotifyArgs[4]->getPrefixedText() );
+ $this->assertEquals( 0, self::$lastNotifyArgs[5] );
+ $this->assertEquals( $revision->getId(), self::$lastNotifyArgs[6] );
+ $this->assertEquals( null, self::$lastNotifyArgs[7] );
+ $this->assertEquals( 0, self::$lastNotifyArgs[8] );
+ $this->assertEquals( '127.0.0.1', self::$lastNotifyArgs[9] );
+ $this->assertEquals( 0, self::$lastNotifyArgs[10] );
+ }
+
+}
array( RC_NEW, 'new' ),
array( RC_LOG, 'log' ),
array( RC_EXTERNAL, 'external' ),
+ array( RC_CATEGORIZE, 'categorize' ),
);
}