From: addshore Date: Thu, 17 Sep 2015 09:51:25 +0000 (+0100) Subject: Introduce CategoryMembershipChange X-Git-Tag: 1.31.0-rc.0~9644^2 X-Git-Url: https://git.cyclocoop.org/%27.%24link.%27?a=commitdiff_plain;h=2fb2a3f14b34d4b81de2ca1aec07efc5659f9e90;p=lhc%2Fweb%2Fwiklou.git Introduce CategoryMembershipChange This is split from: I03516bb34144d95e5f25c46ae98ab70ce699b31b Change-Id: I27539d25ef3e81cf991657dffc0a62b9719d21d8 --- diff --git a/autoload.php b/autoload.php index f1b0a6cae7..29dccd459f 100644 --- a/autoload.php +++ b/autoload.php @@ -194,6 +194,7 @@ $wgAutoloadLocalClasses = array( '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', diff --git a/includes/DefaultSettings.php b/includes/DefaultSettings.php index 5fd85743ec..f3e2639f19 100644 --- a/includes/DefaultSettings.php +++ b/includes/DefaultSettings.php @@ -4462,6 +4462,7 @@ $wgReservedUsernames = array( '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) ); /** diff --git a/includes/Defines.php b/includes/Defines.php index 1c06ad0d78..b8a0ec04c7 100644 --- a/includes/Defines.php +++ b/includes/Defines.php @@ -170,6 +170,7 @@ define( 'RC_EDIT', 0 ); define( 'RC_NEW', 1 ); define( 'RC_LOG', 3 ); define( 'RC_EXTERNAL', 5 ); +define( 'RC_CATEGORIZE', 6 ); /**@}*/ /**@{ diff --git a/includes/changes/CategoryMembershipChange.php b/includes/changes/CategoryMembershipChange.php new file mode 100644 index 0000000000..2533a5bb3a --- /dev/null +++ b/includes/changes/CategoryMembershipChange.php @@ -0,0 +1,273 @@ +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; + } + +} diff --git a/includes/changes/RecentChange.php b/includes/changes/RecentChange.php index 5c884a5e91..7cf2138031 100644 --- a/includes/changes/RecentChange.php +++ b/includes/changes/RecentChange.php @@ -67,6 +67,7 @@ class RecentChange { 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(); @@ -97,6 +98,7 @@ class RecentChange { 'new' => RC_NEW, 'log' => RC_LOG, 'external' => RC_EXTERNAL, + 'categorize' => RC_CATEGORIZE, ); # Factory methods @@ -749,6 +751,80 @@ class RecentChange { 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 * diff --git a/languages/i18n/en.json b/languages/i18n/en.json index 0b17c78c0f..0bc19206ec 100644 --- a/languages/i18n/en.json +++ b/languages/i18n/en.json @@ -1287,6 +1287,11 @@ "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 bold.", "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", diff --git a/languages/i18n/qqq.json b/languages/i18n/qqq.json index 56575497b4..ca278d7f9d 100644 --- a/languages/i18n/qqq.json +++ b/languages/i18n/qqq.json @@ -1460,6 +1460,11 @@ "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}}", diff --git a/tests/phpunit/includes/changes/CategoryMembershipChangeTest.php b/tests/phpunit/includes/changes/CategoryMembershipChangeTest.php new file mode 100644 index 0000000000..2622ad48c1 --- /dev/null +++ b/tests/phpunit/includes/changes/CategoryMembershipChangeTest.php @@ -0,0 +1,126 @@ +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] ); + } + +} diff --git a/tests/phpunit/includes/changes/RecentChangeTest.php b/tests/phpunit/includes/changes/RecentChangeTest.php index fd287b5355..85dbe796a0 100644 --- a/tests/phpunit/includes/changes/RecentChangeTest.php +++ b/tests/phpunit/includes/changes/RecentChangeTest.php @@ -114,6 +114,7 @@ class RecentChangeTest extends MediaWikiTestCase { array( RC_NEW, 'new' ), array( RC_LOG, 'log' ), array( RC_EXTERNAL, 'external' ), + array( RC_CATEGORIZE, 'categorize' ), ); }