PK       ! QņS  S     CategoryMembershipChangeTest.phpnu ÕIw¶“        <?php

use MediaWiki\Revision\RevisionRecord;
use MediaWiki\Title\Title;
use MediaWiki\User\UserIdentity;

/**
 * @covers \CategoryMembershipChange
 *
 * @group Database
 *
 * @author Addshore
 */
class CategoryMembershipChangeTest extends MediaWikiLangTestCase {

	/**
	 * @var array
	 */
	private static $lastNotifyArgs;

	/**
	 * @var int
	 */
	private static $notifyCallCounter = 0;

	/**
	 * @var RecentChange
	 */
	private static $mockRecentChange;

	/**
	 * @var RevisionRecord
	 */
	private static $pageRev = null;

	/**
	 * @var UserIdentity
	 */
	private static $revUser = null;

	/**
	 * @var string
	 */
	private static $pageName = 'CategoryMembershipChangeTestPage';

	public static function newForCategorizationCallback( ...$args ) {
		self::$lastNotifyArgs = $args;
		self::$notifyCallCounter += 1;
		return self::$mockRecentChange;
	}

	protected function setUp(): void {
		parent::setUp();
		self::$notifyCallCounter = 0;
		self::$mockRecentChange = $this->createMock( RecentChange::class );

		$this->setContentLang( 'qqx' );
	}

	public function addDBDataOnce() {
		$info = $this->insertPage( self::$pageName );
		$title = $info['title'];

		$page = $this->getServiceContainer()->getWikiPageFactory()->newFromTitle( $title );
		self::$pageRev = $page->getRevisionRecord();
		self::$revUser = self::$pageRev->getUser( RevisionRecord::RAW );
	}

	private function newChange( ?RevisionRecord $revision = null ) {
		$title = Title::makeTitle( NS_MAIN, self::$pageName );
		$blcFactory = $this->getServiceContainer()->getBacklinkCacheFactory();
		$change = new CategoryMembershipChange(
			$title, $blcFactory->getBacklinkCache( $title ), $revision
		);
		$change->overrideNewForCategorizationCallback(
			'CategoryMembershipChangeTest::newForCategorizationCallback'
		);

		return $change;
	}

	public function testChangeAddedNoRev() {
		$change = $this->newChange();
		$change->triggerCategoryAddedNotification( Title::makeTitle( NS_CATEGORY, 'CategoryName' ) );

		$this->assertSame( 1, self::$notifyCallCounter );

		$this->assertSame( 14, strlen( self::$lastNotifyArgs[0] ) );
		$this->assertEquals( 'Category:CategoryName', self::$lastNotifyArgs[1]->getPrefixedText() );
		$this->assertEquals( '(autochange-username)', self::$lastNotifyArgs[2]->getName() );
		$this->assertEquals( '(recentchanges-page-added-to-category: ' . self::$pageName . ')',
			self::$lastNotifyArgs[3] );
		$this->assertEquals( self::$pageName, self::$lastNotifyArgs[4]->getPrefixedText() );
		$this->assertSame( 0, self::$lastNotifyArgs[5] );
		$this->assertSame( 0, self::$lastNotifyArgs[6] );
		$this->assertNull( self::$lastNotifyArgs[7] );
		$this->assertSame( 1, self::$lastNotifyArgs[8] );
		$this->assertSame( '', self::$lastNotifyArgs[9] );
		$this->assertSame( 0, self::$lastNotifyArgs[10] );
	}

	public function testChangeRemovedNoRev() {
		$change = $this->newChange();
		$change->triggerCategoryRemovedNotification( Title::makeTitle( NS_CATEGORY, 'CategoryName' ) );

		$this->assertSame( 1, self::$notifyCallCounter );

		$this->assertSame( 14, strlen( self::$lastNotifyArgs[0] ) );
		$this->assertEquals( 'Category:CategoryName', self::$lastNotifyArgs[1]->getPrefixedText() );
		$this->assertEquals( '(autochange-username)', self::$lastNotifyArgs[2]->getName() );
		$this->assertEquals( '(recentchanges-page-removed-from-category: ' . self::$pageName . ')',
			self::$lastNotifyArgs[3] );
		$this->assertEquals( self::$pageName, self::$lastNotifyArgs[4]->getPrefixedText() );
		$this->assertSame( 0, self::$lastNotifyArgs[5] );
		$this->assertSame( 0, self::$lastNotifyArgs[6] );
		$this->assertNull( self::$lastNotifyArgs[7] );
		$this->assertSame( 1, self::$lastNotifyArgs[8] );
		$this->assertSame( '', self::$lastNotifyArgs[9] );
		$this->assertSame( 0, self::$lastNotifyArgs[10] );
	}

	public function testChangeAddedWithRev() {
		$revision = $this->getServiceContainer()
			->getRevisionLookup()
			->getRevisionByTitle( Title::makeTitle( NS_MAIN, self::$pageName ) );
		$change = $this->newChange( $revision );
		$change->triggerCategoryAddedNotification( Title::makeTitle( NS_CATEGORY, 'CategoryName' ) );

		$this->assertSame( 1, self::$notifyCallCounter );

		$this->assertSame( 14, strlen( self::$lastNotifyArgs[0] ) );
		$this->assertEquals( 'Category:CategoryName', self::$lastNotifyArgs[1]->getPrefixedText() );
		$this->assertEquals( self::$revUser->getName(), self::$lastNotifyArgs[2]->getName() );
		$this->assertEquals( '(recentchanges-page-added-to-category: ' . self::$pageName . ')',
			self::$lastNotifyArgs[3] );
		$this->assertEquals( self::$pageName, self::$lastNotifyArgs[4]->getPrefixedText() );
		$this->assertEquals( self::$pageRev->getParentId(), self::$lastNotifyArgs[5] );
		$this->assertEquals( $revision->getId(), self::$lastNotifyArgs[6] );
		$this->assertNull( self::$lastNotifyArgs[7] );
		$this->assertSame( 0, self::$lastNotifyArgs[8] );
		$this->assertEquals( '127.0.0.1', self::$lastNotifyArgs[9] );
		$this->assertSame( 0, self::$lastNotifyArgs[10] );
	}

	public function testChangeRemovedWithRev() {
		$revision = $this->getServiceContainer()
			->getRevisionLookup()
			->getRevisionByTitle( Title::makeTitle( NS_MAIN, self::$pageName ) );
		$change = $this->newChange( $revision );
		$change->triggerCategoryRemovedNotification( Title::makeTitle( NS_CATEGORY, 'CategoryName' ) );

		$this->assertSame( 1, self::$notifyCallCounter );

		$this->assertSame( 14, strlen( self::$lastNotifyArgs[0] ) );
		$this->assertEquals( 'Category:CategoryName', self::$lastNotifyArgs[1]->getPrefixedText() );
		$this->assertEquals( self::$revUser->getName(), self::$lastNotifyArgs[2]->getName() );
		$this->assertEquals( '(recentchanges-page-removed-from-category: ' . self::$pageName . ')',
			self::$lastNotifyArgs[3] );
		$this->assertEquals( self::$pageName, self::$lastNotifyArgs[4]->getPrefixedText() );
		$this->assertEquals( self::$pageRev->getParentId(), self::$lastNotifyArgs[5] );
		$this->assertEquals( $revision->getId(), self::$lastNotifyArgs[6] );
		$this->assertNull( self::$lastNotifyArgs[7] );
		$this->assertSame( 0, self::$lastNotifyArgs[8] );
		$this->assertEquals( '127.0.0.1', self::$lastNotifyArgs[9] );
		$this->assertSame( 0, self::$lastNotifyArgs[10] );
	}

}
PK       ! '$ŃĪĘ  Ę    TestRecentChangesHelper.phpnu ÕIw¶“        <?php

use MediaWiki\Context\RequestContext;
use MediaWiki\MediaWikiServices;
use MediaWiki\Title\Title;
use MediaWiki\User\User;

/**
 * Helper for generating test recent changes entries.
 *
 * @author Katie Filbert < aude.wiki@gmail.com >
 */
class TestRecentChangesHelper {

	public function makeEditRecentChange( User $user, $titleText, $curid, $thisid, $lastid,
		$timestamp, $counter, $watchingUsers
	) {
		$attribs = array_merge(
			$this->getDefaultAttributes( $titleText, $timestamp ),
			[
				'rc_user' => $user->getId(),
				'rc_user_text' => $user->getName(),
				'rc_this_oldid' => $thisid,
				'rc_last_oldid' => $lastid,
				'rc_cur_id' => $curid
			]
		);

		return $this->makeRecentChange( $attribs, $counter, $watchingUsers );
	}

	public function makeLogRecentChange(
		$logType, $logAction, User $user, $titleText, $timestamp, $counter, $watchingUsers
	) {
		$attribs = array_merge(
			$this->getDefaultAttributes( $titleText, $timestamp ),
			[
				'rc_cur_id' => 0,
				'rc_user' => $user->getId(),
				'rc_user_text' => $user->getName(),
				'rc_this_oldid' => 0,
				'rc_last_oldid' => 0,
				'rc_old_len' => null,
				'rc_new_len' => null,
				'rc_type' => 3,
				'rc_logid' => 25,
				'rc_log_type' => $logType,
				'rc_log_action' => $logAction,
				'rc_source' => 'mw.log'
			]
		);

		return $this->makeRecentChange( $attribs, $counter, $watchingUsers );
	}

	public function makeDeletedEditRecentChange( User $user, $titleText, $timestamp, $curid,
		$thisid, $lastid, $counter, $watchingUsers
	) {
		$attribs = array_merge(
			$this->getDefaultAttributes( $titleText, $timestamp ),
			[
				'rc_user' => $user->getId(),
				'rc_user_text' => $user->getName(),
				'rc_deleted' => 5,
				'rc_cur_id' => $curid,
				'rc_this_oldid' => $thisid,
				'rc_last_oldid' => $lastid
			]
		);

		return $this->makeRecentChange( $attribs, $counter, $watchingUsers );
	}

	public function makeNewBotEditRecentChange( User $user, $titleText, $curid, $thisid, $lastid,
		$timestamp, $counter, $watchingUsers
	) {
		$attribs = array_merge(
			$this->getDefaultAttributes( $titleText, $timestamp ),
			[
				'rc_user' => $user->getId(),
				'rc_user_text' => $user->getName(),
				'rc_this_oldid' => $thisid,
				'rc_last_oldid' => $lastid,
				'rc_cur_id' => $curid,
				'rc_type' => 1,
				'rc_bot' => 1,
				'rc_source' => 'mw.new'
			]
		);

		return $this->makeRecentChange( $attribs, $counter, $watchingUsers );
	}

	private function makeRecentChange( $attribs, $counter, $watchingUsers ) {
		$change = new RecentChange();
		$change->setAttribs( $attribs );
		$change->counter = $counter;
		$change->numberofWatchingusers = $watchingUsers;

		return $change;
	}

	public function getCacheEntry( $recentChange ) {
		$rcCacheFactory = new RCCacheEntryFactory(
			new RequestContext(),
			[ 'diff' => 'diff', 'cur' => 'cur', 'last' => 'last' ],
			MediaWikiServices::getInstance()->getLinkRenderer()
		);
		return $rcCacheFactory->newFromRecentChange( $recentChange, false );
	}

	public function makeCategorizationRecentChange(
		User $user, $titleText, $curid, $thisid, $lastid, $timestamp
	) {
		$attribs = array_merge(
			$this->getDefaultAttributes( $titleText, $timestamp ),
			[
				'rc_type' => RC_CATEGORIZE,
				'rc_user' => $user->getId(),
				'rc_user_text' => $user->getName(),
				'rc_this_oldid' => $thisid,
				'rc_last_oldid' => $lastid,
				'rc_cur_id' => $curid,
				'rc_comment' => '[[:Testpage]] added to category',
				'rc_comment_text' => '[[:Testpage]] added to category',
				'rc_comment_data' => null,
				'rc_old_len' => 0,
				'rc_new_len' => 0,
			]
		);

		return $this->makeRecentChange( $attribs, 0, 0 );
	}

	private function getDefaultAttributes( $titleText, $timestamp ) {
		return [
			'rc_id' => 545,
			'rc_user' => 0,
			'rc_user_text' => '127.0.0.1',
			'rc_ip' => '127.0.0.1',
			'rc_title' => $titleText,
			'rc_namespace' => 0,
			'rc_timestamp' => $timestamp,
			'rc_old_len' => 212,
			'rc_new_len' => 188,
			'rc_comment' => '',
			'rc_comment_text' => '',
			'rc_comment_data' => null,
			'rc_minor' => 0,
			'rc_bot' => 0,
			'rc_type' => 0,
			'rc_patrolled' => 1,
			'rc_deleted' => 0,
			'rc_logid' => 0,
			'rc_log_type' => null,
			'rc_log_action' => '',
			'rc_params' => '',
			'rc_source' => 'mw.edit'
		];
	}

	public function getTestContext( User $user ) {
		$context = new RequestContext();
		$context->setLanguage( 'en' );

		$context->setUser( $user );

		$title = Title::makeTitle( NS_SPECIAL, 'RecentChanges' );
		$context->setTitle( $title );

		return $context;
	}
}
PK       ! Åī©=Ö  Ö    RCCacheEntryFactoryTest.phpnu ÕIw¶“        <?php

use MediaWiki\Linker\LinkRenderer;
use MediaWiki\Title\Title;

/**
 * @covers \RCCacheEntryFactory
 * @group Database
 * @author Katie Filbert <aude.wiki@gmail.com>
 */
class RCCacheEntryFactoryTest extends MediaWikiLangTestCase {

	/**
	 * @var TestRecentChangesHelper
	 */
	private $testRecentChangesHelper;

	/**
	 * @var LinkRenderer
	 */
	private $linkRenderer;

	protected function setUp(): void {
		parent::setUp();

		$this->linkRenderer = $this->getServiceContainer()->getLinkRenderer();
		$this->testRecentChangesHelper = new TestRecentChangesHelper();
	}

	public function testNewFromRecentChange() {
		$user = $this->getMutableTestUser()->getUser();
		$recentChange = $this->testRecentChangesHelper->makeEditRecentChange(
			$user,
			'Xyz',
			5, // curid
			191, // thisid
			190, // lastid
			'20131103212153',
			0, // counter
			0 // number of watching users
		);
		$cacheEntryFactory = new RCCacheEntryFactory(
			$this->getContext(),
			$this->getMessages(),
			$this->linkRenderer
		);
		$cacheEntry = $cacheEntryFactory->newFromRecentChange( $recentChange, false );

		$this->assertInstanceOf( RCCacheEntry::class, $cacheEntry );

		$this->assertFalse( $cacheEntry->watched, 'watched' );
		$this->assertEquals( '21:21', $cacheEntry->timestamp, 'timestamp' );
		$this->assertSame( 0, $cacheEntry->numberofWatchingusers, 'watching users' );
		$this->assertFalse( $cacheEntry->unpatrolled, 'unpatrolled' );

		$this->assertUserLinks( $user->getName(), $cacheEntry );
		$this->assertTitleLink( 'Xyz', $cacheEntry );

		$diff = [ 'curid' => 5, 'diff' => 191, 'oldid' => 190 ];
		$cur = [ 'curid' => 5, 'diff' => 0, 'oldid' => 191 ];
		$this->assertQueryLink( 'cur', $cur, $cacheEntry->curlink );
		$this->assertQueryLink( 'prev', $diff, $cacheEntry->lastlink );
		$this->assertQueryLink( 'diff', $diff, $cacheEntry->difflink );
	}

	public function testNewForDeleteChange() {
		$user = $this->getMutableTestUser()->getUser();
		$recentChange = $this->testRecentChangesHelper->makeLogRecentChange(
			'delete',
			'delete',
			$user,
			'Abc',
			'20131103212153',
			0, // counter
			0 // number of watching users
		);
		$cacheEntryFactory = new RCCacheEntryFactory(
			$this->getContext(),
			$this->getMessages(),
			$this->linkRenderer
		);
		$cacheEntry = $cacheEntryFactory->newFromRecentChange( $recentChange, false );

		$this->assertInstanceOf( RCCacheEntry::class, $cacheEntry );

		$this->assertFalse( $cacheEntry->watched, 'watched' );
		$this->assertEquals( '21:21', $cacheEntry->timestamp, 'timestamp' );
		$this->assertSame( 0, $cacheEntry->numberofWatchingusers, 'watching users' );
		$this->assertFalse( $cacheEntry->unpatrolled, 'unpatrolled' );

		$this->assertDeleteLogLink( $cacheEntry );
		$this->assertUserLinks( $user->getName(), $cacheEntry );

		$this->assertEquals( 'cur', $cacheEntry->curlink, 'cur link for delete log or rev' );
		$this->assertEquals( 'diff', $cacheEntry->difflink, 'diff link for delete log or rev' );
		$this->assertEquals( 'prev', $cacheEntry->lastlink, 'pref link for delete log or rev' );
	}

	public function testNewForRevUserDeleteChange() {
		$user = $this->getMutableTestUser()->getUser();
		$recentChange = $this->testRecentChangesHelper->makeDeletedEditRecentChange(
			$user,
			'Zzz',
			'20131103212153',
			191, // thisid
			190, // lastid
			'20131103212153',
			0, // counter
			0 // number of watching users
		);
		$cacheEntryFactory = new RCCacheEntryFactory(
			$this->getContext(),
			$this->getMessages(),
			$this->linkRenderer
		);
		$cacheEntry = $cacheEntryFactory->newFromRecentChange( $recentChange, false );

		$this->assertInstanceOf( RCCacheEntry::class, $cacheEntry );

		$this->assertFalse( $cacheEntry->watched, 'watched' );
		$this->assertEquals( '21:21', $cacheEntry->timestamp, 'timestamp' );
		$this->assertSame( 0, $cacheEntry->numberofWatchingusers, 'watching users' );
		$this->assertFalse( $cacheEntry->unpatrolled, 'unpatrolled' );

		$this->assertRevDel( $cacheEntry );
		$this->assertTitleLink( 'Zzz', $cacheEntry );

		$this->assertEquals( 'cur', $cacheEntry->curlink, 'cur link for delete log or rev' );
		$this->assertEquals( 'diff', $cacheEntry->difflink, 'diff link for delete log or rev' );
		$this->assertEquals( 'prev', $cacheEntry->lastlink, 'pref link for delete log or rev' );
	}

	private function assertValidHTML( $actual ) {
		$this->assertNotSame( '', $actual );
		$document = new DOMDocument;

		$oldUseInternalErrors = libxml_use_internal_errors( true );

		try {
			$loaded = $document->loadHTML( $actual );
			$message = '';
			foreach ( libxml_get_errors() as $error ) {
				$message .= "\n" . $error->message;
			}

			$this->assertNotFalse( $loaded, $message ?: 'Invalid for unknown reason' );
		} finally {
			libxml_use_internal_errors( $oldUseInternalErrors );
		}
	}

	private function assertUserLinks( $user, $cacheEntry ) {
		$this->assertValidHTML( $cacheEntry->userlink );
		$this->assertMatchesRegularExpression(
			'#^<a .*class="new mw-userlink".*><bdi>' . $user . '</bdi></a>#',
			$cacheEntry->userlink,
			'verify user link'
		);

		$this->assertValidHTML( $cacheEntry->usertalklink );
		$this->assertMatchesRegularExpression(
			'#^ <span class="mw-usertoollinks mw-changeslist-links">.*<span><a .+>talk</a></span>.*</span>#',
			$cacheEntry->usertalklink,
			'verify user talk link'
		);

		$this->assertValidHTML( $cacheEntry->usertalklink );
		$this->assertMatchesRegularExpression(
			'#^ <span class="mw-usertoollinks mw-changeslist-links">.*<span><a .+>' .
				'contribs</a></span>.*</span>$#',
			$cacheEntry->usertalklink,
			'verify user tool links'
		);
	}

	private function assertDeleteLogLink( $cacheEntry ) {
		$this->assertEquals(
			'(<a href="/wiki/Special:Log/delete" title="Special:Log/delete">Deletion log</a>)',
			$cacheEntry->link,
			'verify deletion log link'
		);

		$this->assertValidHTML( $cacheEntry->link );
	}

	private function assertRevDel( $cacheEntry ) {
		$this->assertEquals(
			' <span class="history-deleted">(username removed)</span>',
			$cacheEntry->userlink,
			'verify user link for change with deleted revision and user'
		);
		$this->assertValidHTML( $cacheEntry->userlink );
	}

	private function assertTitleLink( $title, $cacheEntry ) {
		$this->assertEquals(
			'<a href="/wiki/' . $title . '" title="' . $title . '">' . $title . '</a>',
			$cacheEntry->link,
			'verify title link'
		);
		$this->assertValidHTML( $cacheEntry->link );
	}

	private function assertQueryLink( $content, $params, $link ) {
		$this->assertMatchesRegularExpression(
			"#^<a .+>$content</a>$#",
			$link,
			'verify query link element'
		);
		$this->assertValidHTML( $link );

		foreach ( $params as $key => $value ) {
			$this->assertMatchesRegularExpression( '/' . $key . '=' . $value . '/', $link, "verify $key link params" );
		}
	}

	private function getMessages() {
		return [
			'cur' => 'cur',
			'diff' => 'diff',
			'hist' => 'hist',
			'enhancedrc-history' => 'history',
			'last' => 'prev',
			'blocklink' => 'block',
			'history' => 'Page history',
			'semicolon-separator' => '; ',
			'pipe-separator' => ' | '
		];
	}

	private function getContext() {
		$user = $this->getMutableTestUser()->getUser();
		$context = $this->testRecentChangesHelper->getTestContext( $user );

		$title = Title::makeTitle( NS_SPECIAL, 'RecentChanges' );
		$context->setTitle( $title );

		return $context;
	}
}
PK       ! R©«¦       rcfeed/RCFeedIntegrationTest.phpnu ÕIw¶“        <?php

use MediaWiki\MainConfigNames;
use MediaWiki\RCFeed\FormattedRCFeed;
use MediaWiki\RCFeed\JSONRCFeedFormatter;
use MediaWiki\SpecialPage\SpecialPage;
use MediaWiki\Title\Title;

/**
 * @group medium
 * @group Database
 * @covers \MediaWiki\RCFeed\FormattedRCFeed
 * @covers \RecentChange
 * @covers \MediaWiki\RCFeed\JSONRCFeedFormatter
 * @covers \MediaWiki\RCFeed\MachineReadableRCFeedFormatter
 * @covers \MediaWiki\RCFeed\RCFeed
 */
class RCFeedIntegrationTest extends MediaWikiIntegrationTestCase {
	protected function setUp(): void {
		parent::setUp();
		$this->overrideConfigValues( [
			MainConfigNames::CanonicalServer => 'https://example.org',
			MainConfigNames::ServerName => 'example.org',
			MainConfigNames::ScriptPath => '/w',
			MainConfigNames::Script => '/w/index.php',
			MainConfigNames::DBname => 'example',
			MainConfigNames::DBprefix => self::dbPrefix(),
			MainConfigNames::RCFeeds => [],
			MainConfigNames::RCEngines => [],
		] );
	}

	public function testNotify() {
		$feed = $this->getMockBuilder( FormattedRCFeed::class )
			->setConstructorArgs( [ [ 'formatter' => JSONRCFeedFormatter::class ] ] )
			->onlyMethods( [ 'send' ] )
			->getMock();

		$feed->expects( $this->once() )
			->method( 'send' )
			->willReturn( true )
			->with( $this->anything(), $this->callback( function ( $line ) {
				$this->assertJsonStringEqualsJsonString(
					json_encode( [
						'id' => null,
						'type' => 'log',
						'namespace' => 0,
						'title' => 'Example',
						'title_url' => 'https://example.org/wiki/Example',
						'comment' => '',
						'timestamp' => 1301644800,
						'user' => 'UTSysop',
						'bot' => false,
						'notify_url' => null,
						'log_id' => 0,
						'log_type' => 'move',
						'log_action' => 'move',
						'log_params' => [
							'color' => 'green',
							'nr' => 42,
							'pet' => 'cat',
						],
						'log_action_comment' => '',
						'server_url' => 'https://example.org',
						'server_name' => 'example.org',
						'server_script_path' => '/w',
						'wiki' => 'example-' . self::dbPrefix(),
					] ),
					$line
				);
				return true;
			} ) );

		$this->overrideConfigValue(
			MainConfigNames::RCFeeds,
			[
				'myfeed' => [
					'class' => $feed,
					'uri' => 'test://localhost:1234',
					'formatter' => JSONRCFeedFormatter::class,
				],
			]
		);
		$logpage = SpecialPage::getTitleFor( 'Log', 'move' );
		$user = $this->getTestSysop()->getUser();
		$rc = RecentChange::newLogEntry(
			'20110401080000',
			$logpage, // &$title
			$user, // &$user
			'', // $actionComment
			'127.0.0.1', // $ip
			'move', // $type
			'move', // $action
			Title::makeTitle( 0, 'Example' ), // $target
			'', // $logComment
			LogEntryBase::makeParamBlob( [
				'4::color' => 'green',
				'5:number:nr' => 42,
				'pet' => 'cat',
			] )
		);
		$rc->notifyRCFeeds();
	}
}
PK       ! ēĖ£š<  <    RecentChangesUpdateJobTest.phpnu ÕIw¶“        <?php

use MediaWiki\MainConfigNames;
use Wikimedia\Timestamp\ConvertibleTimestamp;

/**
 * @group Database
 * @covers RecentChangesUpdateJob
 * @author Dreamy Jazz
 */
class RecentChangesUpdateJobTest extends MediaWikiIntegrationTestCase {

	private function addTestingExpiredRows() {
		// Make three testing edits, which will trigger a recentchanges insert. Two of the edits will be made
		// over wgRCMaxAge seconds ago while the other will be made a day ago
		$testPage = $this->getExistingTestPage();
		$testUser = $this->getTestUser()->getAuthority();
		// So that only our two testing edits are present, and nothing from creating the test page or test user
		$this->truncateTable( 'recentchanges' );
		// Fix wgRCMaxAge at a high value to ensure that the recentchanges entries we are creating are not purged
		// by later testing edits.
		$this->overrideConfigValue( MainConfigNames::RCMaxAge, 24 * 3600 * 1000 );
		ConvertibleTimestamp::setFakeTime( '20230405060708' );
		$this->editPage( $testPage, 'testing1234', '', NS_MAIN, $testUser );
		ConvertibleTimestamp::setFakeTime( '20230705060708' );
		$this->editPage( $testPage, 'testing12345', '', NS_MAIN, $testUser );
		ConvertibleTimestamp::setFakeTime( '20240405060708' );
		$this->editPage( $testPage, 'testing123456', '', NS_MAIN, $testUser );
		// Verify that the recentchanges table row count is as expected for the test
		$this->newSelectQueryBuilder()
			->field( 'COUNT(*)' )
			->table( 'recentchanges' )
			->assertFieldValue( 3 );
	}

	public function testNewPurgeJob() {
		$this->addTestingExpiredRows();
		// Set the time as one day beyond the last test edit
		ConvertibleTimestamp::setFakeTime( '20240406060708' );
		// Fix wgRCMaxAge for the test, in case the default value changes.
		$this->overrideConfigValue( MainConfigNames::RCMaxAge, 90 * 24 * 3600 );
		$hookRunAtLeastOnce = false;
		$this->setTemporaryHook( 'RecentChangesPurgeRows', function ( $rows ) use ( &$hookRunAtLeastOnce ) {
			// Check that the first row has the expected columns. Checking just the first row should be fine
			// as the value of $rows should come from ::fetchResultSet which returns the same columns for each
			// returned row.
			$rowAsArray = (array)$rows[0];
			// To get the expected fields, use the value of the items in the 'fields' array. The exception to this
			// is where the key is a string, when it should be used instead (as this is an alias).
			$recentChangeQueryFields = RecentChange::getQueryInfo()['fields'];
			$expectedFields = [];
			foreach ( $recentChangeQueryFields as $key => $value ) {
				if ( is_string( $key ) ) {
					$expectedFields[] = $key;
				} else {
					$expectedFields[] = $value;
				}
			}
			$this->assertArrayEquals(
				$expectedFields,
				array_keys( $rowAsArray ),
				false,
				true,
				'Columns in the provided $row are not as expected'
			);
			$hookRunAtLeastOnce = true;
		} );
		// Call the code we are testing
		$objectUnderTest = RecentChangesUpdateJob::newPurgeJob();
		$this->assertInstanceOf( RecentChangesUpdateJob::class, $objectUnderTest );
		$objectUnderTest->run();
		// Verify that only the edit made a day ago is now in the recentchanges table
		$this->newSelectQueryBuilder()
			->field( 'rc_timestamp' )
			->table( 'recentchanges' )
			->assertFieldValue( $this->getDb()->timestamp( '20240405060708' ) );
		// Verify that the lock placed to do the purge is no longer active.
		$this->assertTrue( $this->getDb()->lockIsFree(
			$this->getDb()->getDomainID() . ':recentchanges-prune', __METHOD__
		) );
		// Check that the RecentChangesPurgeRows hook was run at least once
		$this->assertTrue( $hookRunAtLeastOnce, 'RecentChangesPurgeRows hook was not run' );
	}

	/** @dataProvider provideInvalidTypes */
	public function testWhenTypeForInvalidType( $type ) {
		$this->expectException( InvalidArgumentException::class );
		$objectUnderTest = new RecentChangesUpdateJob( $this->getExistingTestPage()->getTitle(), [ 'type' => $type ] );
		$objectUnderTest->run();
	}

	public static function provideInvalidTypes() {
		return [
			'Type is null' => [ null ],
			'Type is a unrecognised string' => [ 'unknown-type' ],
		];
	}
}
PK       ! ?¬¾Ę  Ę    OldChangesListTest.phpnu ÕIw¶“        <?php

use MediaWiki\Context\RequestContext;
use MediaWiki\Title\Title;

/**
 * @todo add tests to cover article link, timestamp, character difference,
 *       log entry, user tool links, direction marks, tags, rollback,
 *       watching users, and date header.
 *
 * @covers \OldChangesList
 * @group Database
 * @author Katie Filbert <aude.wiki@gmail.com>
 */
class OldChangesListTest extends MediaWikiLangTestCase {

	/**
	 * @var TestRecentChangesHelper
	 */
	private $testRecentChangesHelper;

	protected function setUp(): void {
		parent::setUp();

		$this->setUserLang( 'qqx' );
		$this->testRecentChangesHelper = new TestRecentChangesHelper();
	}

	/**
	 * @dataProvider recentChangesLine_CssForLineNumberProvider
	 */
	public function testRecentChangesLine_CssForLineNumber( $expected, $linenumber, $message ) {
		$oldChangesList = $this->getOldChangesList();
		$recentChange = $this->getEditChange();

		$line = $oldChangesList->recentChangesLine( $recentChange, false, $linenumber );

		$this->assertMatchesRegularExpression( $expected, $line, $message );
	}

	public static function recentChangesLine_CssForLineNumberProvider() {
		return [
			[ '/mw-line-odd/', 1, 'odd line number' ],
			[ '/mw-line-even/', 2, 'even line number' ]
		];
	}

	public function testRecentChangesLine_NotWatchedCssClass() {
		$oldChangesList = $this->getOldChangesList();
		$recentChange = $this->getEditChange();

		$line = $oldChangesList->recentChangesLine( $recentChange, false, 1 );

		$this->assertMatchesRegularExpression( '/mw-changeslist-line-not-watched/', $line );
	}

	public function testRecentChangesLine_WatchedCssClass() {
		$oldChangesList = $this->getOldChangesList();
		$recentChange = $this->getEditChange();

		$line = $oldChangesList->recentChangesLine( $recentChange, true, 1 );

		$this->assertMatchesRegularExpression( '/mw-changeslist-line-watched/', $line );
	}

	public function testRecentChangesLine_LogTitle() {
		$oldChangesList = $this->getOldChangesList();
		$recentChange = $this->getLogChange( 'delete', 'delete' );

		$line = $oldChangesList->recentChangesLine( $recentChange, false, 1 );

		$this->assertMatchesRegularExpression( '/href="\/wiki\/Special:Log\/delete/', $line, 'link has href attribute' );
		$this->assertMatchesRegularExpression( '/title="Special:Log\/delete/', $line, 'link has title attribute' );
		$this->assertMatchesRegularExpression( "/dellogpage/", $line, 'link text' );
	}

	public function testRecentChangesLine_DiffHistLinks() {
		$oldChangesList = $this->getOldChangesList();
		$recentChange = $this->getEditChange();

		$line = $oldChangesList->recentChangesLine( $recentChange, false, 1 );

		$this->assertMatchesRegularExpression(
			'/title=Cat&amp;curid=20131103212153&amp;diff=5&amp;oldid=191/',
			$line,
			'assert diff link'
		);

		$this->assertMatchesRegularExpression(
			'/title=Cat&amp;curid=20131103212153&amp;action=history"/',
			$line,
			'assert history link'
		);
	}

	public function testRecentChangesLine_Flags() {
		$oldChangesList = $this->getOldChangesList();
		$recentChange = $this->getNewBotEditChange();

		$line = $oldChangesList->recentChangesLine( $recentChange, false, 1 );

		$this->assertStringContainsString(
			'<abbr class="newpage" title="(recentchanges-label-newpage)">(newpageletter)</abbr>',
			$line,
			'new page flag'
		);

		$this->assertStringContainsString(
			'<abbr class="botedit" title="(recentchanges-label-bot)">(boteditletter)</abbr>',
			$line,
			'bot flag'
		);
	}

	public function testRecentChangesLine_Attribs() {
		$recentChange = $this->getEditChange();
		$recentChange->mAttribs['ts_tags'] = 'vandalism,newbie';

		$this->setTemporaryHook( 'OldChangesListRecentChangesLine', static function (
			$oldChangesList, &$html, $rc, $classes, $attribs
		) {
			$html = $html . '/<div>Additional change line </div>/';
		} );

		$oldChangesList = $this->getOldChangesList();
		$line = $oldChangesList->recentChangesLine( $recentChange, false, 1 );

		$this->assertStringContainsString(
			'/<div>Additional change line </div>/',
			$line
		);
		$this->assertMatchesRegularExpression(
			'/<li data-mw-revid="\d+" data-mw-ts="\d+" class="[\w\s-]*mw-tag-vandalism[\w\s-]*">/',
			$line
		);
		$this->assertMatchesRegularExpression(
			'/<li data-mw-revid="\d+" data-mw-ts="\d+" class="[\w\s-]*mw-tag-newbie[\w\s-]*">/',
			$line
		);
	}

	public function testRecentChangesLine_numberOfWatchingUsers() {
		$oldChangesList = $this->getOldChangesList();

		$recentChange = $this->getEditChange();
		$recentChange->numberofWatchingusers = 100;

		$line = $oldChangesList->recentChangesLine( $recentChange, false, 1 );
		$this->assertMatchesRegularExpression( "/(number-of-watching-users-for-recent-changes: 100)/", $line );
	}

	public function testRecentChangesLine_watchlistCssClass() {
		$oldChangesList = $this->getOldChangesList();
		$oldChangesList->setWatchlistDivs( true );

		$recentChange = $this->getEditChange();
		$line = $oldChangesList->recentChangesLine( $recentChange, false, 1 );
		$this->assertMatchesRegularExpression( "/watchlist-0-Cat/", $line );
	}

	public function testRecentChangesLine_dataAttribute() {
		$oldChangesList = $this->getOldChangesList();
		$oldChangesList->setWatchlistDivs( true );

		$recentChange = $this->getEditChange();
		$line = $oldChangesList->recentChangesLine( $recentChange, false, 1 );
		$this->assertMatchesRegularExpression( '/data-target-page=\"Cat\"/', $line );

		$recentChange = $this->getLogChange( 'delete', 'delete' );
		$line = $oldChangesList->recentChangesLine( $recentChange, false, 1 );
		$this->assertMatchesRegularExpression( '/data-target-page="Abc"/', $line );
	}

	public function testRecentChangesLine_prefix() {
		$mockContext = $this->getMockBuilder( RequestContext::class )
			->onlyMethods( [ 'getTitle' ] )
			->getMock();
		$mockContext->method( 'getTitle' )
			->willReturn( Title::makeTitle( NS_MAIN, 'Expected Context Title' ) );

		$oldChangesList = $this->getOldChangesList();
		$oldChangesList->setContext( $mockContext );
		$recentChange = $this->getEditChange();

		$oldChangesList->setChangeLinePrefixer( function ( $rc, $changesList ) {
			// Make sure RecentChange and ChangesList objects are the same
			$this->assertEquals( 'Expected Context Title', $changesList->getContext()->getTitle() );
			$this->assertEquals( 'Cat', $rc->getTitle() );
			return 'I am a prefix';
		} );
		$line = $oldChangesList->recentChangesLine( $recentChange );
		$this->assertMatchesRegularExpression( "/I am a prefix/", $line );
	}

	private function getNewBotEditChange() {
		$user = $this->getMutableTestUser()->getUser();

		$recentChange = $this->testRecentChangesHelper->makeNewBotEditRecentChange(
			$user, 'Abc', '20131103212153', 5, 191, 190, 0, 0
		);

		return $recentChange;
	}

	private function getLogChange( $logType, $logAction ) {
		$user = $this->getMutableTestUser()->getUser();

		$recentChange = $this->testRecentChangesHelper->makeLogRecentChange(
			$logType, $logAction, $user, 'Abc', '20131103212153', 0, 0
		);

		return $recentChange;
	}

	private function getEditChange() {
		$user = $this->getMutableTestUser()->getUser();
		$recentChange = $this->testRecentChangesHelper->makeEditRecentChange(
			$user, 'Cat', '20131103212153', 5, 191, 190, 0, 0
		);

		return $recentChange;
	}

	private function getOldChangesList() {
		$context = $this->getContext();
		return new OldChangesList( $context );
	}

	private function getContext() {
		$user = $this->getMutableTestUser()->getUser();
		$context = $this->testRecentChangesHelper->getTestContext( $user );
		$context->setLanguage( 'qqx' );

		return $context;
	}

}
PK       ! #OO·@  ·@    RecentChangeTest.phpnu ÕIw¶“        <?php

use MediaWiki\MainConfigNames;
use MediaWiki\Page\PageIdentity;
use MediaWiki\Page\PageIdentityValue;
use MediaWiki\Page\PageProps;
use MediaWiki\Page\PageReference;
use MediaWiki\Page\PageReferenceValue;
use MediaWiki\Permissions\PermissionStatus;
use MediaWiki\Tests\Unit\Permissions\MockAuthorityTrait;
use MediaWiki\Tests\User\TempUser\TempUserTestTrait;
use MediaWiki\Title\Title;
use MediaWiki\User\UserIdentity;
use MediaWiki\User\UserIdentityValue;
use MediaWiki\Utils\MWTimestamp;

/**
 * @group Database
 */
class RecentChangeTest extends MediaWikiIntegrationTestCase {
	use MockAuthorityTrait;
	use MockTitleTrait;
	use TempUserTestTrait;

	/** @var PageIdentity */
	protected $title;
	/** @var PageIdentity */
	protected $target;
	/** @var UserIdentity */
	protected $user;
	private const USER_COMMENT = '<User comment about action>';

	protected function setUp(): void {
		parent::setUp();

		$this->title = new PageIdentityValue( 17, NS_MAIN, 'SomeTitle', PageIdentity::LOCAL );
		$this->target = new PageIdentityValue( 78, NS_MAIN, 'TestTarget', PageIdentity::LOCAL );

		$user = $this->getTestUser()->getUser();
		$this->user = new UserIdentityValue( $user->getId(), $user->getName() );

		$this->overrideConfigValues( [
			MainConfigNames::CanonicalServer => 'https://example.org',
			MainConfigNames::ServerName => 'example.org',
			MainConfigNames::ScriptPath => '/w',
			MainConfigNames::Script => '/w/index.php',
			MainConfigNames::UseRCPatrol => false,
			MainConfigNames::UseNPPatrol => false,
			MainConfigNames::RCFeeds => [],
			MainConfigNames::RCEngines => [],
		] );
	}

	public static function provideAttribs() {
		$attribs = [
			'rc_timestamp' => wfTimestamp( TS_MW ),
			'rc_namespace' => NS_USER,
			'rc_title' => 'Tony',
			'rc_type' => RC_EDIT,
			'rc_source' => RecentChange::SRC_EDIT,
			'rc_minor' => 0,
			'rc_cur_id' => 77,
			'rc_user' => 858173476,
			'rc_user_text' => 'Tony',
			'rc_comment' => '',
			'rc_comment_text' => '',
			'rc_comment_data' => null,
			'rc_this_oldid' => 70,
			'rc_last_oldid' => 71,
			'rc_bot' => 0,
			'rc_ip' => '',
			'rc_patrolled' => 0,
			'rc_new' => 0,
			'rc_old_len' => 80,
			'rc_new_len' => 88,
			'rc_deleted' => 0,
			'rc_logid' => 0,
			'rc_log_type' => null,
			'rc_log_action' => '',
			'rc_params' => '',
		];

		yield 'external user' => [
			[
				'rc_type' => RC_EXTERNAL,
				'rc_source' => 'foo',
				'rc_user' => 0,
				'rc_user_text' => 'm>External User',
			] + $attribs
		];

		yield 'anon user' => [
			[
				'rc_type' => RC_EXTERNAL,
				'rc_source' => 'foo',
				'rc_user' => 0,
				'rc_user_text' => '192.168.0.1',
			] + $attribs
		];

		yield 'special title' => [
			[
				'rc_namespace' => NS_SPECIAL,
				'rc_title' => 'Log',
				'rc_type' => RC_LOG,
				'rc_source' => RecentChange::SRC_LOG,
				'rc_log_type' => 'delete',
				'rc_log_action' => 'delete',
			] + $attribs
		];

		yield 'no title' => [
			[
				'rc_namespace' => NS_MAIN,
				'rc_title' => '',
				'rc_type' => RC_LOG,
				'rc_source' => RecentChange::SRC_LOG,
				'rc_log_type' => 'delete',
				'rc_log_action' => 'delete',
			] + $attribs
		];
	}

	/**
	 * @covers \RecentChange::save
	 * @covers \RecentChange::newFromId
	 * @covers \RecentChange::getTitle
	 * @covers \RecentChange::getPerformerIdentity
	 * @dataProvider provideAttribs
	 */
	public function testDatabaseRoundTrip( $attribs ) {
		$rc_user = $attribs['rc_user'] ?? 0;
		if ( !$rc_user ) {
			$this->disableAutoCreateTempUser();
		}
		$rc = new RecentChange;
		$rc->mAttribs = $attribs;
		$rc->mExtra = [
			'pageStatus' => 'changed'
		];
		$rc->save();
		$id = $rc->getAttribute( 'rc_id' );

		$rc = RecentChange::newFromId( $id );

		$actualAttribs = array_intersect_key( $rc->mAttribs, $attribs );
		$this->assertArrayEquals( $attribs, $actualAttribs, false, true );

		$user = new UserIdentityValue( $rc_user, $attribs['rc_user_text'] );
		$this->assertTrue( $user->equals( $rc->getPerformerIdentity() ) );

		if ( empty( $attribs['rc_title'] ) ) {
			$this->assertNull( $rc->getPage() );
		} else {
			$title = Title::makeTitle( $attribs['rc_namespace'], $attribs['rc_title'] );
			$this->assertTrue( $title->isSamePageAs( $rc->getTitle() ) );
			$this->assertTrue( $title->isSamePageAs( $rc->getPage() ) );
		}
	}

	/**
	 * @covers \RecentChange::newFromRow
	 * @covers \RecentChange::loadFromRow
	 * @covers \RecentChange::getAttributes
	 * @covers \RecentChange::getPerformerIdentity
	 */
	public function testNewFromRow() {
		$user = $this->getTestUser()->getUser();

		$row = (object)[
			'rc_foo' => 'AAA',
			'rc_timestamp' => '20150921134808',
			'rc_deleted' => 'bar',
			'rc_comment_text' => 'comment',
			'rc_comment_data' => null,
			'rc_user' => $user->getId(), // lookup by id
		];

		$rc = RecentChange::newFromRow( $row );

		$expected = [
			'rc_foo' => 'AAA',
			'rc_timestamp' => '20150921134808',
			'rc_deleted' => 'bar',
			'rc_comment' => 'comment',
			'rc_comment_text' => 'comment',
			'rc_comment_data' => null,
			'rc_user' => $user->getId(),
			'rc_user_text' => $user->getName()
		];
		$this->assertEquals( $expected, $rc->getAttributes() );
		$this->assertTrue( $user->equals( $rc->getPerformerIdentity() ) );

		$row = (object)[
			'rc_foo' => 'AAA',
			'rc_timestamp' => '20150921134808',
			'rc_deleted' => 'bar',
			'rc_comment' => 'comment',
			'rc_user_text' => $user->getName(), // lookup by name
		];
		$rc = @RecentChange::newFromRow( $row );

		$expected = [
			'rc_foo' => 'AAA',
			'rc_timestamp' => '20150921134808',
			'rc_deleted' => 'bar',
			'rc_comment' => 'comment',
			'rc_comment_text' => 'comment',
			'rc_comment_data' => null,
			'rc_user' => $user->getId(),
			'rc_user_text' => $user->getName()
		];
		$this->assertEquals( $expected, $rc->getAttributes() );
		$this->assertEquals( $expected, $rc->getAttributes() );
		$this->assertTrue( $user->equals( $rc->getPerformerIdentity() ) );
	}

	/**
	 * @covers \RecentChange::notifyNew
	 * @covers \RecentChange::newFromId
	 * @covers \RecentChange::getAttributes
	 * @covers \RecentChange::getPerformerIdentity
	 */
	public function testNotifyNew() {
		$now = MWTimestamp::now();
		$rc = RecentChange::notifyNew(
			$now,
			$this->title,
			false,
			$this->user,
			self::USER_COMMENT,
			false
		);

		$expected = [
			'rc_timestamp' => $now,
			'rc_deleted' => 0,
			'rc_comment_text' => self::USER_COMMENT,
			'rc_user' => $this->user->getId(),
			'rc_user_text' => $this->user->getName()
		];

		$actual = array_intersect_key( $rc->getAttributes(), $expected );

		$this->assertEquals( $expected, $actual );
		$this->assertTrue( $this->user->equals( $rc->getPerformerIdentity() ) );

		$rc = RecentChange::newFromId( $rc->getAttribute( 'rc_id' ) );

		$actual = array_intersect_key( $rc->getAttributes(), $expected );

		$this->assertEquals( $expected, $actual );
		$this->assertTrue( $this->user->equals( $rc->getPerformerIdentity() ) );
	}

	/**
	 * @covers \RecentChange::notifyNew
	 * @covers \RecentChange::newFromId
	 * @covers \RecentChange::getAttributes
	 * @covers \RecentChange::getPerformerIdentity
	 */
	public function testNotifyEdit() {
		$now = MWTimestamp::now();
		$rc = RecentChange::notifyEdit(
			$now,
			$this->title,
			false,
			$this->user,
			self::USER_COMMENT,
			0,
			$now,
			false
		);

		$expected = [
			'rc_timestamp' => $now,
			'rc_deleted' => 0,
			'rc_comment_text' => self::USER_COMMENT,
			'rc_user' => $this->user->getId(),
			'rc_user_text' => $this->user->getName()
		];

		$actual = array_intersect_key( $rc->getAttributes(), $expected );

		$this->assertEquals( $expected, $actual );
		$this->assertTrue( $this->user->equals( $rc->getPerformerIdentity() ) );

		$rc = RecentChange::newFromId( $rc->getAttribute( 'rc_id' ) );

		$actual = array_intersect_key( $rc->getAttributes(), $expected );

		$this->assertEquals( $expected, $actual );
		$this->assertTrue( $this->user->equals( $rc->getPerformerIdentity() ) );
	}

	/**
	 * @covers \RecentChange::notifyNew
	 * @covers \RecentChange::newFromId
	 * @covers \RecentChange::getAttributes
	 * @covers \RecentChange::getPerformerIdentity
	 */
	public function testNewLogEntry() {
		$now = MWTimestamp::now();
		$logPage = new PageReferenceValue( NS_SPECIAL, 'Log/test', PageReference::LOCAL );

		$rc = RecentChange::newLogEntry(
			$now,
			$logPage,
			$this->user,
			'action comment',
			'192.168.0.2',
			'test',
			'testing',
			$this->title,
			self::USER_COMMENT,
			'a|b|c',
			7,
			'',
			42,
			false,
			true
		);

		$expected = [
			'rc_timestamp' => $now,
			'rc_comment_text' => self::USER_COMMENT,
			'rc_user' => $this->user->getId(),
			'rc_user_text' => $this->user->getName(),
			'rc_title' => $this->title->getDBkey(),
			'rc_logid' => 7,
			'rc_log_type' => 'test',
			'rc_log_action' => 'testing',
			'rc_this_oldid' => 42,
			'rc_patrolled' => RecentChange::PRC_AUTOPATROLLED,
			'rc_bot' => 1,
		];

		$actual = array_intersect_key( $rc->getAttributes(), $expected );

		$this->assertEquals( $expected, $actual );
		$this->assertTrue( $this->user->equals( $rc->getPerformerIdentity() ) );
		$this->assertTrue( $this->title->isSamePageAs( $rc->getPage() ) );
		$this->assertTrue( $this->title->isSamePageAs( $rc->getTitle() ) );
	}

	public static function provideParseParams() {
		// $expected, $raw
		yield 'extracting an array' => [
			[
				'root' => [
					'A' => 1,
					'B' => 'two'
				]
			],
			'a:1:{s:4:"root";a:2:{s:1:"A";i:1;s:1:"B";s:3:"two";}}'
		];

		yield 'null' => [ null, null ];
		yield 'false' => [ null, serialize( false ) ];
		yield 'non-array' => [ null, 'not-an-array' ];
	}

	/**
	 * @covers \RecentChange::parseParams
	 * @dataProvider provideParseParams
	 * @param array $expectedParseParams
	 * @param string|null $rawRcParams
	 */
	public function testParseParams( $expectedParseParams, $rawRcParams ) {
		$rc = new RecentChange;
		$rc->setAttribs( [ 'rc_params' => $rawRcParams ] );

		$actualParseParams = $rc->parseParams();

		$this->assertEquals( $expectedParseParams, $actualParseParams );
	}

	/**
	 * @covers \RecentChange::getNotifyUrl
	 */
	public function testGetNotifyUrlForEdit() {
		$rc = new RecentChange;
		$rc->mAttribs = [
			'rc_id' => 60,
			'rc_timestamp' => '20110401090000',
			'rc_namespace' => NS_MAIN,
			'rc_title' => 'Example',
			'rc_type' => RC_EDIT,
			'rc_cur_id' => 42,
			'rc_this_oldid' => 50,
			'rc_last_oldid' => 30,
			'rc_patrolled' => 0,
		];
		$this->assertSame(
			'https://example.org/w/index.php?diff=50&oldid=30',
			$rc->getNotifyUrl(), 'Notify url'
		);

		$this->overrideConfigValue( MainConfigNames::UseRCPatrol, true );
		$this->assertSame(
			'https://example.org/w/index.php?diff=50&oldid=30&rcid=60',
			$rc->getNotifyUrl(), 'Notify url (RC Patrol)'
		);
	}

	/**
	 * @covers \RecentChange::getNotifyUrl
	 */
	public function testGetNotifyUrlForCreate() {
		$rc = new RecentChange;
		$rc->mAttribs = [
			'rc_id' => 60,
			'rc_timestamp' => '20110401090000',
			'rc_namespace' => NS_MAIN,
			'rc_title' => 'Example',
			'rc_type' => RC_NEW,
			'rc_cur_id' => 42,
			'rc_this_oldid' => 50,
			'rc_last_oldid' => 0,
			'rc_patrolled' => 0,
		];
		$this->assertSame(
			'https://example.org/w/index.php?oldid=50',
			$rc->getNotifyUrl(), 'Notify url'
		);

		$this->overrideConfigValue( MainConfigNames::UseNPPatrol, true );
		$this->assertSame(
			'https://example.org/w/index.php?oldid=50&rcid=60',
			$rc->getNotifyUrl(), 'Notify url (NP Patrol)'
		);
	}

	/**
	 * @covers \RecentChange::getNotifyUrl
	 */
	public function testGetNotifyUrlForLog() {
		$rc = new RecentChange;
		$rc->mAttribs = [
			'rc_id' => 60,
			'rc_timestamp' => '20110401090000',
			'rc_namespace' => NS_MAIN,
			'rc_title' => 'Example',
			'rc_type' => RC_LOG,
			'rc_cur_id' => 42,
			'rc_this_oldid' => 50,
			'rc_last_oldid' => 0,
			'rc_patrolled' => 2,
			'rc_logid' => 160,
			'rc_log_type' => 'delete',
			'rc_log_action' => 'delete',
		];
		$this->assertSame( null, $rc->getNotifyUrl(), 'Notify url' );
	}

	/**
	 * @return array
	 */
	public static function provideIsInRCLifespan() {
		return [
			[ 6000, -3000, 0, true ],
			[ 3000, -6000, 0, false ],
			[ 6000, -3000, 6000, true ],
			[ 3000, -6000, 6000, true ],
		];
	}

	/**
	 * @covers \RecentChange::isInRCLifespan
	 * @dataProvider provideIsInRCLifespan
	 */
	public function testIsInRCLifespan( $maxAge, $offset, $tolerance, $expected ) {
		$this->overrideConfigValue( MainConfigNames::RCMaxAge, $maxAge );
		// Calculate this here instead of the data provider because the provider
		// is expanded early on and the full test suite may take longer than 100 minutes
		// when coverage is enabled.
		$timestamp = time() + $offset;
		$this->assertEquals( $expected, RecentChange::isInRCLifespan( $timestamp, $tolerance ) );
	}

	public static function provideRCTypes() {
		return [
			[ RC_EDIT, 'edit' ],
			[ RC_NEW, 'new' ],
			[ RC_LOG, 'log' ],
			[ RC_EXTERNAL, 'external' ],
			[ RC_CATEGORIZE, 'categorize' ],
		];
	}

	/**
	 * @dataProvider provideRCTypes
	 * @covers \RecentChange::parseFromRCType
	 */
	public function testParseFromRCType( $rcType, $type ) {
		$this->assertEquals( $type, RecentChange::parseFromRCType( $rcType ) );
	}

	/**
	 * @dataProvider provideRCTypes
	 * @covers \RecentChange::parseToRCType
	 */
	public function testParseToRCType( $rcType, $type ) {
		$this->assertEquals( $rcType, RecentChange::parseToRCType( $type ) );
	}

	public static function provideCategoryContent() {
		return [
			[ true ],
			[ false ],
		];
	}

	/**
	 * @dataProvider provideCategoryContent
	 * @covers \RecentChange::newForCategorization
	 */
	public function testHiddenCategoryChange( $isHidden ) {
		$categoryTitle = Title::makeTitle( NS_CATEGORY, 'CategoryPage' );

		$pageProps = $this->createMock( PageProps::class );
		$pageProps->expects( $this->once() )
			->method( 'getProperties' )
			->with( $categoryTitle, 'hiddencat' )
			->willReturn( $isHidden ? [ $categoryTitle->getArticleID() => '' ] : [] );

		$this->setService( 'PageProps', $pageProps );

		$rc = RecentChange::newForCategorization(
			'0',
			$categoryTitle,
			$this->user,
			self::USER_COMMENT,
			$this->title,
			$categoryTitle->getLatestRevID(),
			$categoryTitle->getLatestRevID(),
			'0',
			false
		);

		$this->assertEquals( $isHidden, $rc->getParam( 'hidden-cat' ) );
	}

	private function getDummyEditRecentChange(): RecentChange {
		return RecentChange::notifyEdit(
			MWTimestamp::now(),
			$this->title,
			false,
			$this->user,
			self::USER_COMMENT,
			0,
			MWTimestamp::now(),
			false
		);
	}

	/**
	 * @covers \RecentChange::markPatrolled
	 */
	public function testMarkPatrolledPermissions() {
		$rc = $this->getDummyEditRecentChange();
		$performer = $this->mockRegisteredAuthority( static function (
			string $permission,
			PageIdentity $page,
			PermissionStatus $status
		) {
			if ( $permission === 'patrol' ) {
				$status->fatal( 'missing-patrol' );
				return false;
			}
			return true;
		} );
		$status = $rc->markPatrolled(
			$performer
		);
		$this->assertStatusError( 'missing-patrol', $status );
	}

	/**
	 * @covers \RecentChange::markPatrolled
	 */
	public function testMarkPatrolledPermissions_Hook() {
		$rc = $this->getDummyEditRecentChange();
		$this->setTemporaryHook( 'MarkPatrolled', static function () {
			return false;
		} );
		$status = $rc->markPatrolled( $this->mockRegisteredUltimateAuthority() );
		$this->assertStatusError( 'hookaborted', $status );
	}

	/**
	 * @covers \RecentChange::markPatrolled
	 */
	public function testMarkPatrolledPermissions_Self() {
		$rc = $this->getDummyEditRecentChange();
		$status = $rc->markPatrolled(
			$this->mockUserAuthorityWithoutPermissions( $this->user, [ 'autopatrol' ] )
		);
		$this->assertStatusError( 'markedaspatrollederror-noautopatrol', $status );
	}

	/**
	 * @covers \RecentChange::markPatrolled
	 */
	public function testMarkPatrolledPermissions_NoRcPatrol() {
		$rc = $this->getDummyEditRecentChange();
		$status = $rc->markPatrolled( $this->mockRegisteredUltimateAuthority() );
		$this->assertStatusError( 'rcpatroldisabled', $status );
	}

	/**
	 * @covers \RecentChange::markPatrolled
	 */
	public function testMarkPatrolled() {
		$this->overrideConfigValue( MainConfigNames::UseRCPatrol, true );
		$rc = $this->getDummyEditRecentChange();
		$status = $rc->markPatrolled(
			$this->mockUserAuthorityWithPermissions( $this->user, [ 'patrol', 'autopatrol' ] )
		);
		$this->assertStatusGood( $status );

		$reloadedRC = RecentChange::newFromId( $rc->getAttribute( 'rc_id' ) );
		$this->assertSame( '1', $reloadedRC->getAttribute( 'rc_patrolled' ) );
	}
}
PK       ! «éE„'  „'    EnhancedChangesListTest.phpnu ÕIw¶“        <?php

use MediaWiki\Content\WikitextContent;
use MediaWiki\Context\RequestContext;
use MediaWiki\Title\Title;
use MediaWiki\User\User;
use MediaWiki\Utils\MWTimestamp;

/**
 * @covers \EnhancedChangesList
 *
 * @group Database
 *
 * @author Katie Filbert < aude.wiki@gmail.com >
 */
class EnhancedChangesListTest extends MediaWikiLangTestCase {

	/**
	 * @var TestRecentChangesHelper
	 */
	private $testRecentChangesHelper;

	protected function setUp(): void {
		parent::setUp();
		$this->testRecentChangesHelper = new TestRecentChangesHelper();
	}

	public function testBeginRecentChangesList_styleModules() {
		$enhancedChangesList = $this->newEnhancedChangesList();
		$enhancedChangesList->beginRecentChangesList();

		$styleModules = $enhancedChangesList->getOutput()->getModuleStyles();

		$this->assertContains(
			'mediawiki.special.changeslist',
			$styleModules,
			'has mediawiki.special.changeslist'
		);

		$this->assertContains(
			'mediawiki.special.changeslist.enhanced',
			$styleModules,
			'has mediawiki.special.changeslist.enhanced'
		);
	}

	public function testBeginRecentChangesList_html() {
		$enhancedChangesList = $this->newEnhancedChangesList();
		$html = $enhancedChangesList->beginRecentChangesList();

		$this->assertEquals( '<div class="mw-changeslist" aria-live="polite">', $html );
	}

	/**
	 * @todo more tests
	 */
	public function testRecentChangesLine() {
		$enhancedChangesList = $this->newEnhancedChangesList();
		$enhancedChangesList->beginRecentChangesList();

		$recentChange = $this->getEditChange( '20131103092153' );
		$html = $enhancedChangesList->recentChangesLine( $recentChange, false );

		$this->assertIsString( $html );

		$recentChange2 = $this->getEditChange( '20131103092253' );
		$html = $enhancedChangesList->recentChangesLine( $recentChange2, false );

		$this->assertSame( '', $html );
	}

	public function testRecentChangesPrefix() {
		$mockContext = $this->getMockBuilder( RequestContext::class )
			->onlyMethods( [ 'getTitle' ] )
			->getMock();
		$mockContext->method( 'getTitle' )
			->willReturn( Title::makeTitle( NS_MAIN, 'Expected Context Title' ) );

		// One group of two lines
		$enhancedChangesList = $this->newEnhancedChangesList();
		$enhancedChangesList->setContext( $mockContext );
		$enhancedChangesList->setChangeLinePrefixer( function ( $rc, $changesList ) {
			// Make sure RecentChange and ChangesList objects are the same
			$this->assertEquals( 'Expected Context Title', $changesList->getContext()->getTitle() );
			$this->assertTrue( $rc->getTitle() == 'Cat' || $rc->getTitle() == 'Dog' );
			return 'Hello world prefix';
		} );

		$this->setTemporaryHook( 'EnhancedChangesListModifyLineData', static function (
			$enhancedChangesList, &$data, $block, $rc, &$classes, &$attribs
		) {
			$data['recentChangesFlags']['minor'] = 1;
		} );

		$this->setTemporaryHook( 'EnhancedChangesListModifyBlockLineData', static function (
			$enhancedChangesList, &$data, $rcObj
		) {
			$data['recentChangesFlags']['bot'] = 1;
		} );

		$enhancedChangesList->beginRecentChangesList();

		$recentChange = $this->getEditChange( '20131103092153' );
		$enhancedChangesList->recentChangesLine( $recentChange );
		$recentChange = $this->getEditChange( '20131103092154' );
		$enhancedChangesList->recentChangesLine( $recentChange );

		$html = $enhancedChangesList->endRecentChangesList();

		$this->assertMatchesRegularExpression( '/Hello world prefix/', $html );

		// Test EnhancedChangesListModifyLineData hook was run
		$this->assertMatchesRegularExpression( '/This is a minor edit/', $html );

		// Two separate lines
		$enhancedChangesList->beginRecentChangesList();

		$recentChange = $this->getEditChange( '20131103092153' );
		$enhancedChangesList->recentChangesLine( $recentChange );
		$recentChange = $this->getEditChange( '20131103092154', 'Dog' );
		$enhancedChangesList->recentChangesLine( $recentChange );

		$html = $enhancedChangesList->endRecentChangesList();

		// Test EnhancedChangesListModifyBlockLineData hook was run
		$this->assertMatchesRegularExpression( '/This edit was performed by a bot/', $html );

		preg_match_all( '/Hello world prefix/', $html, $matches );
		$this->assertCount( 2, $matches[0] );
	}

	public function testCategorizationLineFormatting() {
		$html = $this->createCategorizationLine(
			$this->getCategorizationChange( '20150629191735', 0, 0 )
		);
		$this->assertStringNotContainsString( 'diffhist', strip_tags( $html ) );
	}

	public function testCategorizationLineFormattingWithRevision() {
		$html = $this->createCategorizationLine(
			$this->getCategorizationChange( '20150629191735', 1025, 1024 )
		);
		$this->assertStringContainsString( 'diffhist', strip_tags( $html ) );
	}

	/**
	 * @todo more tests for actual formatting, this is more of a smoke test
	 */
	public function testEndRecentChangesList() {
		$enhancedChangesList = $this->newEnhancedChangesList();
		$enhancedChangesList->beginRecentChangesList();

		$recentChange = $this->getEditChange( '20131103092153' );
		$enhancedChangesList->recentChangesLine( $recentChange, false );

		$html = $enhancedChangesList->endRecentChangesList();
		$this->assertMatchesRegularExpression(
			'/data-mw-revid="5" data-mw-ts="20131103092153" class="[^"]*mw-enhanced-rc[^"]*"/',
			$html
		);

		$recentChange2 = $this->getEditChange( '20131103092253' );
		$enhancedChangesList->recentChangesLine( $recentChange2, false );

		$html = $enhancedChangesList->endRecentChangesList();

		preg_match_all( '/td class="mw-enhanced-rc-nested"/', $html, $matches );
		$this->assertCount( 2, $matches[0] );

		preg_match_all( '/data-target-page="Cat"/', $html, $matches );
		$this->assertCount( 2, $matches[0] );

		$recentChange3 = $this->getLogChange();
		$enhancedChangesList->recentChangesLine( $recentChange3, false );

		$html = $enhancedChangesList->endRecentChangesList();
		$this->assertStringContainsString( 'data-mw-logaction="foo/bar"', $html );
		$this->assertStringContainsString( 'data-mw-logid="25"', $html );
		$this->assertStringContainsString( 'data-target-page="Title"', $html );
	}

	/**
	 * @return EnhancedChangesList
	 */
	private function newEnhancedChangesList() {
		$user = User::newFromId( 0 );
		$context = $this->testRecentChangesHelper->getTestContext( $user );

		return new EnhancedChangesList( $context );
	}

	/**
	 * @param string $timestamp
	 * @param string $pageTitle
	 * @return RecentChange
	 */
	private function getEditChange( $timestamp, $pageTitle = 'Cat' ) {
		$user = $this->getMutableTestUser()->getUser();
		$recentChange = $this->testRecentChangesHelper->makeEditRecentChange(
			$user, $pageTitle, 0, 5, 191, $timestamp, 0, 0
		);

		return $recentChange;
	}

	private function getLogChange() {
		$user = $this->getMutableTestUser()->getUser();
		$recentChange = $this->testRecentChangesHelper->makeLogRecentChange( 'foo', 'bar', $user,
			'Title', '20131103092153', 0, 0
		);

		return $recentChange;
	}

	/**
	 * @param string $timestamp
	 * @param int $thisId
	 * @param int $lastId
	 * @return RecentChange
	 */
	private function getCategorizationChange( $timestamp, $thisId, $lastId ) {
		$wikiPage = $this->getServiceContainer()->getWikiPageFactory()->newFromTitle( Title::makeTitle( NS_MAIN, 'Testpage' ) );
		$wikiPage->doUserEditContent(
			new WikitextContent( 'Some random text' ),
			$this->getTestSysop()->getUser(),
			'page created'
		);

		$wikiPage = $this->getServiceContainer()->getWikiPageFactory()->newFromTitle( Title::makeTitle( NS_CATEGORY, 'Foo' ) );
		$wikiPage->doUserEditContent(
			new WikitextContent( 'Some random text' ),
			$this->getTestSysop()->getUser(),
			'category page created'
		);

		$user = $this->getMutableTestUser()->getUser();
		$recentChange = $this->testRecentChangesHelper->makeCategorizationRecentChange(
			$user, 'Category:Foo', $wikiPage->getId(), $thisId, $lastId, $timestamp
		);

		return $recentChange;
	}

	private function createCategorizationLine( $recentChange ) {
		$enhancedChangesList = $this->newEnhancedChangesList();
		$cacheEntry = $this->testRecentChangesHelper->getCacheEntry( $recentChange );

		$reflection = new \ReflectionClass( get_class( $enhancedChangesList ) );
		$method = $reflection->getMethod( 'recentChangesBlockLine' );
		$method->setAccessible( true );

		return $method->invokeArgs( $enhancedChangesList, [ $cacheEntry ] );
	}

	public function testExpiringWatchlistItem(): void {
		// Set current time to 2020-05-05.
		MWTimestamp::setFakeTime( '20200505000000' );
		$enhancedChangesList = $this->newEnhancedChangesList();
		$enhancedChangesList->getOutput()->enableOOUI();
		$enhancedChangesList->setWatchlistDivs( true );

		$row = (object)[
			'rc_namespace' => NS_MAIN,
			'rc_title' => '',
			'rc_timestamp' => '20150921134808',
			'rc_deleted' => '',
			'rc_comment_text' => 'comment',
			'rc_comment_data' => null,
			'rc_user' => $this->getTestUser()->getUser()->getId(),
			'we_expiry' => '20200101000000',
		];
		$rc = RecentChange::newFromRow( $row );

		// Make sure it doesn't output anything for a past expiry.
		$html1 = $enhancedChangesList->getWatchlistExpiry( $rc );
		$this->assertSame( '', $html1 );

		// Check a future expiry for the right tooltip text.
		$rc->watchlistExpiry = '20200512000000';
		$html2 = $enhancedChangesList->getWatchlistExpiry( $rc );
		$this->assertStringContainsString( "title='7 days left in your watchlist'", $html2 );

		// Check that multiple changes on the same day all get the clock icon.
		$enhancedChangesList->beginRecentChangesList();
		// 1. Expire on 2020-06-01 (27 days):
		$rc1 = $this->getEditChange( '20200501000001', __METHOD__ . '1' );
		$rc1->watchlistExpiry = '20200601000000';
		$enhancedChangesList->recentChangesLine( $rc1 );
		// 2. Expire on 2020-06-08 (34 days):
		$rc2 = $this->getEditChange( '20200501000002', __METHOD__ . '2' );
		$rc2->watchlistExpiry = '20200608000000';
		$enhancedChangesList->recentChangesLine( $rc2 );
		// Get and test the HTML.
		$html3 = $enhancedChangesList->endRecentChangesList();
		$this->assertStringContainsString( '27 days left in your watchlist', $html3 );
		$this->assertStringContainsString( '34 days left in your watchlist', $html3 );
	}
}
PK       ! šÉź      ChangesFeed.phpnu ÕIw¶“        <?php
/**
 * 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
 */

use MediaWiki\Feed\ChannelFeed;
use MediaWiki\Feed\FeedItem;
use MediaWiki\Feed\FeedUtils;
use MediaWiki\MainConfigNames;
use MediaWiki\MediaWikiServices;
use MediaWiki\Revision\RevisionRecord;
use MediaWiki\Title\Title;
use Wikimedia\Rdbms\IResultWrapper;

/**
 * XML feed for Special:RecentChanges and Special:RecentChangesLinked.
 *
 * @ingroup RecentChanges
 * @ingroup Feed
 */
class ChangesFeed {
	/** @var string */
	private $format;

	/**
	 * @param string $format Feed's format (either 'rss' or 'atom')
	 */
	public function __construct( $format ) {
		$this->format = $format;
	}

	/**
	 * Get a MediaWiki\Feed\ChannelFeed subclass object to use
	 *
	 * @param string $title Feed's title
	 * @param string $description Feed's description
	 * @param string $url Url of origin page
	 * @return ChannelFeed|bool MediaWiki\Feed\ChannelFeed subclass or false on failure
	 */
	public function getFeedObject( $title, $description, $url ) {
		$mainConfig = MediaWikiServices::getInstance()->getMainConfig();
		$sitename = $mainConfig->get( MainConfigNames::Sitename );
		$languageCode = $mainConfig->get( MainConfigNames::LanguageCode );
		$feedClasses = $mainConfig->get( MainConfigNames::FeedClasses );
		if ( !isset( $feedClasses[$this->format] ) ) {
			return false;
		}

		if ( !array_key_exists( $this->format, $feedClasses ) ) {
			// falling back to atom
			$this->format = 'atom';
		}

		$feedTitle = "{$sitename}  - {$title} [{$languageCode}]";
		return new $feedClasses[$this->format](
			$feedTitle, htmlspecialchars( $description ), $url );
	}

	/**
	 * Generate the feed items given a row from the database.
	 * @param IResultWrapper $rows IDatabase resource with recentchanges rows
	 * @return array
	 * @suppress PhanTypeInvalidDimOffset False positives in the foreach
	 */
	public static function buildItems( $rows ) {
		$items = [];

		# Merge adjacent edits by one user
		$sorted = [];
		$n = 0;
		foreach ( $rows as $obj ) {
			if ( $obj->rc_type == RC_EXTERNAL ) {
				continue;
			}

			if ( $n > 0 &&
				$obj->rc_type == RC_EDIT &&
				$obj->rc_namespace >= 0 &&
				$obj->rc_cur_id == $sorted[$n - 1]->rc_cur_id &&
				$obj->rc_user_text == $sorted[$n - 1]->rc_user_text ) {
				$sorted[$n - 1]->rc_last_oldid = $obj->rc_last_oldid;
			} else {
				$sorted[$n] = $obj;
				$n++;
			}
		}

		$services = MediaWikiServices::getInstance();
		$commentFormatter = $services->getRowCommentFormatter();
		$formattedComments = $commentFormatter->formatItems(
			$commentFormatter->rows( $rows )
				->commentKey( 'rc_comment' )
				->indexField( 'rc_id' )
		);

		$nsInfo = $services->getNamespaceInfo();
		foreach ( $sorted as $obj ) {
			$title = Title::makeTitle( $obj->rc_namespace, $obj->rc_title );
			$talkpage = $nsInfo->hasTalkNamespace( $obj->rc_namespace ) && $title->canExist()
				? $title->getTalkPage()->getFullURL()
				: '';

			// Skip items with deleted content (avoids partially complete/inconsistent output)
			if ( $obj->rc_deleted ) {
				continue;
			}

			if ( $obj->rc_this_oldid ) {
				$url = $title->getFullURL( [
					'diff' => $obj->rc_this_oldid,
					'oldid' => $obj->rc_last_oldid,
				] );
			} else {
				// log entry or something like that.
				$url = $title->getFullURL();
			}

			$items[] = new FeedItem(
				$title->getPrefixedText(),
				FeedUtils::formatDiff( $obj, $formattedComments[$obj->rc_id] ),
				$url,
				$obj->rc_timestamp,
				( $obj->rc_deleted & RevisionRecord::DELETED_USER )
					? wfMessage( 'rev-deleted-user' )->escaped() : $obj->rc_user_text,
				$talkpage
			);
		}

		return $items;
	}
}
PK       ! śØ3õ$  õ$    CategoryMembershipChange.phpnu ÕIw¶“        <?php
/**
 * 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
 */

use MediaWiki\Cache\BacklinkCache;
use MediaWiki\MediaWikiServices;
use MediaWiki\Page\PageIdentity;
use MediaWiki\Revision\RevisionRecord;
use MediaWiki\Title\Title;
use MediaWiki\User\User;
use MediaWiki\User\UserIdentity;
use Wikimedia\Rdbms\IDBAccessObject;

/**
 * Helper class for category membership changes
 *
 * @since 1.27
 * @ingroup RecentChanges
 * @author Kai Nissen
 * @author Addshore
 */
class CategoryMembershipChange {

	private const CATEGORY_ADDITION = 1;
	private 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 RevisionRecord|null Latest revision of the categorized page
	 */
	private $revision;

	/** @var bool Whether this was caused by an import */
	private $forImport;

	/**
	 * @var int
	 * Number of pages this WikiPage is embedded by
	 * Set by CategoryMembershipChange::checkTemplateLinks()
	 */
	private $numTemplateLinks = 0;

	/**
	 * @var callable|null
	 */
	private $newForCategorizationCallback = null;

	/** @var BacklinkCache */
	private $backlinkCache;

	/**
	 * @param Title $pageTitle Title instance of the categorized page
	 * @param BacklinkCache $backlinkCache
	 * @param RevisionRecord|null $revision Latest revision of the categorized page.
	 * @param bool $forImport Whether this was caused by a import
	 */
	public function __construct(
		Title $pageTitle, BacklinkCache $backlinkCache, ?RevisionRecord $revision = null, bool $forImport = false
	) {
		// TODO: Update callers of this method to pass for import
		$this->pageTitle = $pageTitle;
		$this->revision = $revision;

		// Use the current timestamp for creating the RC entry when dealing with imported revisions,
		// since their timestamp may be significantly older than the current time.
		// This ensures the resulting RC entry won't be immediately reaped by probabilistic RC purging if
		// the imported revision is older than $wgRCMaxAge (T377392).
		if ( $revision === null || $forImport ) {
			$this->timestamp = wfTimestampNow();
		} else {
			$this->timestamp = $revision->getTimestamp();
		}
		$this->newForCategorizationCallback = [ RecentChange::class, 'newForCategorization' ];
		$this->backlinkCache = $backlinkCache;
		$this->forImport = $forImport;
	}

	/**
	 * 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
	 */
	public function overrideNewForCategorizationCallback( callable $callback ) {
		if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
			throw new LogicException( 'Cannot override newForCategorization callback in operation.' );
		}
		$this->newForCategorizationCallback = $callback;
	}

	/**
	 * Determines the number of template links for recursive link updates
	 */
	public function checkTemplateLinks() {
		$this->numTemplateLinks = $this->backlinkCache->getNumLinks( 'templatelinks' );
	}

	/**
	 * Create a recentchanges entry for category additions
	 *
	 * @param PageIdentity $categoryPage
	 */
	public function triggerCategoryAddedNotification( PageIdentity $categoryPage ) {
		$this->createRecentChangesEntry( $categoryPage, self::CATEGORY_ADDITION );
	}

	/**
	 * Create a recentchanges entry for category removals
	 *
	 * @param PageIdentity $categoryPage
	 */
	public function triggerCategoryRemovedNotification( PageIdentity $categoryPage ) {
		$this->createRecentChangesEntry( $categoryPage, self::CATEGORY_REMOVAL );
	}

	/**
	 * Create a recentchanges entry using RecentChange::notifyCategorization()
	 *
	 * @param PageIdentity $categoryPage
	 * @param int $type
	 */
	private function createRecentChangesEntry( PageIdentity $categoryPage, $type ) {
		$this->notifyCategorization(
			$this->timestamp,
			$categoryPage,
			$this->getUser(),
			$this->getChangeMessageText(
				$type,
				$this->pageTitle->getPrefixedText(),
				$this->numTemplateLinks
			),
			$this->pageTitle,
			$this->getPreviousRevisionTimestamp(),
			$this->revision,
			$this->forImport,
			$type === self::CATEGORY_ADDITION
		);
	}

	/**
	 * @param string $timestamp Timestamp of the recent change to occur in TS_MW format
	 * @param PageIdentity $categoryPage Page of the category a page is being added to or removed from
	 * @param UserIdentity|null $user User object of the user that made the change
	 * @param string $comment Change summary
	 * @param PageIdentity $page Page that is being added or removed
	 * @param string $lastTimestamp Parent revision timestamp of this change in TS_MW format
	 * @param RevisionRecord|null $revision
	 * @param bool $forImport Whether the associated revision was imported
	 * @param bool $added true, if the category was added, false for removed
	 */
	private function notifyCategorization(
		$timestamp,
		PageIdentity $categoryPage,
		?UserIdentity $user,
		$comment,
		PageIdentity $page,
		$lastTimestamp,
		$revision,
		bool $forImport,
		$added
	) {
		$deleted = $revision ? $revision->getVisibility() & RevisionRecord::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 replica DB lag
		 */
		$bot = 1;
		$lastRevId = 0;
		$ip = '';

		# If no revision is given, the change was probably triggered by parser functions
		if ( $revision !== null ) {
			$revisionStore = MediaWikiServices::getInstance()->getRevisionStore();

			$correspondingRc = $revisionStore->getRecentChange( $this->revision ) ??
				$revisionStore->getRecentChange( $this->revision, IDBAccessObject::READ_LATEST );
			if ( $correspondingRc !== null ) {
				$bot = $correspondingRc->getAttribute( 'rc_bot' ) ?: 0;
				$ip = $correspondingRc->getAttribute( 'rc_ip' ) ?: '';
				$lastRevId = $correspondingRc->getAttribute( 'rc_last_oldid' ) ?: 0;
			}
		}

		/** @var RecentChange $rc */
		$rc = ( $this->newForCategorizationCallback )(
			$timestamp,
			$categoryPage,
			$user,
			$comment,
			$page,
			$lastRevId,
			$newRevId,
			$lastTimestamp,
			$bot,
			$ip,
			$deleted,
			$added,
			$forImport
		);
		$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 UserIdentity|null
	 */
	private function getUser(): ?UserIdentity {
		if ( $this->revision ) {
			$user = $this->revision->getUser( RevisionRecord::RAW );
			if ( $user ) {
				return $user;
			}
		}

		$username = wfMessage( 'autochange-username' )->inContentLanguage()->text();

		$user = User::newSystemUser( $username );
		if ( $user && !$user->isRegistered() ) {
			$user->addToDatabase();
		}

		return $user ?: null;
	}

	/**
	 * 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 string $prefixedText result of Title::->getPrefixedText()
	 * @param int $numTemplateLinks
	 *
	 * @return string
	 */
	private function getChangeMessageText( $type, $prefixedText, $numTemplateLinks ) {
		$array = [
			self::CATEGORY_ADDITION => 'recentchanges-page-added-to-category',
			self::CATEGORY_REMOVAL => 'recentchanges-page-removed-from-category',
		];

		$msgKey = $array[$type];

		if ( intval( $numTemplateLinks ) > 0 ) {
			$msgKey .= '-bundled';
		}

		return wfMessage( $msgKey, $prefixedText )->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() {
		$rl = MediaWikiServices::getInstance()->getRevisionLookup();
		$latestRev = $rl->getRevisionByTitle( $this->pageTitle );
		if ( $latestRev ) {
			$previousRev = $rl->getPreviousRevision( $latestRev );
			if ( $previousRev ) {
				return $previousRev->getTimestamp();
			}
		}
		return null;
	}

}
PK       ! īĘ«¢      ChangesList.phpnu ÕIw¶“        <?php
/**
 * 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
 */

use MediaWiki\CommentFormatter\RowCommentFormatter;
use MediaWiki\Context\ContextSource;
use MediaWiki\Context\IContextSource;
use MediaWiki\Context\RequestContext;
use MediaWiki\HookContainer\HookRunner;
use MediaWiki\HookContainer\ProtectedHookAccessorTrait;
use MediaWiki\Html\Html;
use MediaWiki\Language\Language;
use MediaWiki\Linker\Linker;
use MediaWiki\Linker\LinkRenderer;
use MediaWiki\MainConfigNames;
use MediaWiki\MediaWikiServices;
use MediaWiki\Pager\PagerTools;
use MediaWiki\Parser\Sanitizer;
use MediaWiki\Permissions\Authority;
use MediaWiki\Revision\MutableRevisionRecord;
use MediaWiki\Revision\RevisionRecord;
use MediaWiki\Title\Title;
use MediaWiki\User\User;
use MediaWiki\User\UserIdentityValue;
use MediaWiki\Watchlist\WatchedItem;
use OOUI\IconWidget;
use Wikimedia\Rdbms\IResultWrapper;

/**
 * Base class for lists of recent changes shown on special pages.
 *
 * This is used via ChangesListSpecialPage by recent changes (SpecialRecentChanges),
 * related changes (SpecialRecentChangesLinked), and watchlist (SpecialWatchlist).
 *
 * @ingroup RecentChanges
 */
class ChangesList extends ContextSource {
	use ProtectedHookAccessorTrait;

	public const CSS_CLASS_PREFIX = 'mw-changeslist-';

	/** @var bool */
	protected $watchlist = false;
	/** @var string */
	protected $lastdate;
	/** @var string[] */
	protected $message;
	/** @var array */
	protected $rc_cache;
	/** @var int */
	protected $rcCacheIndex;
	/** @var bool */
	protected $rclistOpen;
	/** @var int */
	protected $rcMoveIndex;

	/** @var callable */
	protected $changeLinePrefixer;

	/** @var MapCacheLRU */
	protected $watchMsgCache;

	/**
	 * @var LinkRenderer
	 */
	protected $linkRenderer;

	/**
	 * @var RowCommentFormatter
	 */
	protected $commentFormatter;

	/**
	 * @var string[] Comments indexed by rc_id
	 */
	protected $formattedComments;

	/**
	 * @var ChangesListFilterGroup[]
	 */
	protected $filterGroups;

	/**
	 * @var MapCacheLRU
	 */
	protected $tagsCache;

	/**
	 * @var MapCacheLRU
	 */
	protected $userLinkCache;

	private LogFormatterFactory $logFormatterFactory;

	/**
	 * @param IContextSource $context
	 * @param ChangesListFilterGroup[] $filterGroups Array of ChangesListFilterGroup objects (currently optional)
	 */
	public function __construct( $context, array $filterGroups = [] ) {
		$this->setContext( $context );
		$this->preCacheMessages();
		$this->watchMsgCache = new MapCacheLRU( 50 );
		$this->filterGroups = $filterGroups;

		$services = MediaWikiServices::getInstance();
		$this->linkRenderer = $services->getLinkRenderer();
		$this->commentFormatter = $services->getRowCommentFormatter();
		$this->logFormatterFactory = $services->getLogFormatterFactory();
		$this->tagsCache = new MapCacheLRU( 50 );
		$this->userLinkCache = new MapCacheLRU( 50 );
	}

	/**
	 * Fetch an appropriate changes list class for the specified context
	 * Some users might want to use an enhanced list format, for instance
	 *
	 * @param IContextSource $context
	 * @param array $groups Array of ChangesListFilterGroup objects (currently optional)
	 * @return ChangesList
	 */
	public static function newFromContext( IContextSource $context, array $groups = [] ) {
		$user = $context->getUser();
		$sk = $context->getSkin();
		$services = MediaWikiServices::getInstance();
		$list = null;
		if ( ( new HookRunner( $services->getHookContainer() ) )->onFetchChangesList( $user, $sk, $list, $groups ) ) {
			$userOptionsLookup = $services->getUserOptionsLookup();
			$new = $context->getRequest()->getBool(
				'enhanced',
				$userOptionsLookup->getBoolOption( $user, 'usenewrc' )
			);

			return $new ?
				new EnhancedChangesList( $context, $groups ) :
				new OldChangesList( $context, $groups );
		} else {
			return $list;
		}
	}

	/**
	 * Format a line
	 *
	 * @since 1.27
	 *
	 * @param RecentChange &$rc Passed by reference
	 * @param bool $watched (default false)
	 * @param int|null $linenumber (default null)
	 *
	 * @return string|bool
	 */
	public function recentChangesLine( &$rc, $watched = false, $linenumber = null ) {
		throw new RuntimeException( 'recentChangesLine should be implemented' );
	}

	/**
	 * Get the container for highlights that are used in the new StructuredFilters
	 * system
	 *
	 * @return string HTML structure of the highlight container div
	 */
	protected function getHighlightsContainerDiv() {
		$highlightColorDivs = '';
		foreach ( [ 'none', 'c1', 'c2', 'c3', 'c4', 'c5' ] as $color ) {
			$highlightColorDivs .= Html::rawElement(
				'div',
				[
					'class' => 'mw-rcfilters-ui-highlights-color-' . $color,
					'data-color' => $color
				]
			);
		}

		return Html::rawElement(
			'div',
			[ 'class' => 'mw-rcfilters-ui-highlights' ],
			$highlightColorDivs
		);
	}

	/**
	 * Sets the list to use a "<li class='watchlist-(namespace)-(page)'>" tag
	 * @param bool $value
	 */
	public function setWatchlistDivs( $value = true ) {
		$this->watchlist = $value;
	}

	/**
	 * @return bool True when setWatchlistDivs has been called
	 * @since 1.23
	 */
	public function isWatchlist() {
		return (bool)$this->watchlist;
	}

	/**
	 * As we use the same small set of messages in various methods and that
	 * they are called often, we call them once and save them in $this->message
	 */
	private function preCacheMessages() {
		if ( !isset( $this->message ) ) {
			$this->message = [];
			foreach ( [
				'cur', 'diff', 'hist', 'enhancedrc-history', 'last', 'blocklink', 'history',
				'semicolon-separator', 'pipe-separator', 'word-separator' ] as $msg
			) {
				$this->message[$msg] = $this->msg( $msg )->escaped();
			}
		}
	}

	/**
	 * Returns the appropriate flags for new page, minor change and patrolling
	 * @param array $flags Associative array of 'flag' => Bool
	 * @param string $nothing To use for empty space
	 * @return string
	 */
	public function recentChangesFlags( $flags, $nothing = "\u{00A0}" ) {
		$f = '';
		foreach (
			$this->getConfig()->get( MainConfigNames::RecentChangesFlags ) as $flag => $_
		) {
			$f .= isset( $flags[$flag] ) && $flags[$flag]
				? self::flag( $flag, $this->getContext() )
				: $nothing;
		}

		return $f;
	}

	/**
	 * Get an array of default HTML class attributes for the change.
	 *
	 * @param RecentChange|RCCacheEntry $rc
	 * @param string|bool $watched Optionally timestamp for adding watched class
	 *
	 * @return string[] List of CSS class names
	 */
	protected function getHTMLClasses( $rc, $watched ) {
		$classes = [ self::CSS_CLASS_PREFIX . 'line' ];
		$logType = $rc->mAttribs['rc_log_type'];

		if ( $logType ) {
			$classes[] = self::CSS_CLASS_PREFIX . 'log';
			$classes[] = Sanitizer::escapeClass( self::CSS_CLASS_PREFIX . 'log-' . $logType );
		} else {
			$classes[] = self::CSS_CLASS_PREFIX . 'edit';
			$classes[] = Sanitizer::escapeClass( self::CSS_CLASS_PREFIX . 'ns' .
				$rc->mAttribs['rc_namespace'] . '-' . $rc->mAttribs['rc_title'] );
		}

		// Indicate watched status on the line to allow for more
		// comprehensive styling.
		$classes[] = $watched && $rc->mAttribs['rc_timestamp'] >= $watched
			? self::CSS_CLASS_PREFIX . 'line-watched'
			: self::CSS_CLASS_PREFIX . 'line-not-watched';

		$classes = array_merge( $classes, $this->getHTMLClassesForFilters( $rc ) );

		return $classes;
	}

	/**
	 * Get an array of CSS classes attributed to filters for this row. Used for highlighting
	 * in the front-end.
	 *
	 * @param RecentChange $rc
	 * @return string[] Array of CSS classes
	 */
	protected function getHTMLClassesForFilters( $rc ) {
		$classes = [];

		$classes[] = Sanitizer::escapeClass( self::CSS_CLASS_PREFIX . 'ns-' .
			$rc->mAttribs['rc_namespace'] );

		$nsInfo = MediaWikiServices::getInstance()->getNamespaceInfo();
		$classes[] = Sanitizer::escapeClass(
			self::CSS_CLASS_PREFIX .
			'ns-' .
			( $nsInfo->isTalk( $rc->mAttribs['rc_namespace'] ) ? 'talk' : 'subject' )
		);

		foreach ( $this->filterGroups as $filterGroup ) {
			foreach ( $filterGroup->getFilters() as $filter ) {
				$filter->applyCssClassIfNeeded( $this, $rc, $classes );
			}
		}

		return $classes;
	}

	/**
	 * Make an "<abbr>" element for a given change flag. The flag indicating a new page, minor edit,
	 * bot edit, or unpatrolled edit. In English it typically contains "N", "m", "b", or "!".
	 *
	 * Styling for these flags is provided through mediawiki.interface.helpers.styles.
	 *
	 * @param string $flag One key of $wgRecentChangesFlags
	 * @param IContextSource|null $context
	 * @return string HTML
	 */
	public static function flag( $flag, ?IContextSource $context = null ) {
		static $map = [ 'minoredit' => 'minor', 'botedit' => 'bot' ];
		static $flagInfos = null;

		if ( $flagInfos === null ) {
			$recentChangesFlags = MediaWikiServices::getInstance()->getMainConfig()
				->get( MainConfigNames::RecentChangesFlags );
			$flagInfos = [];
			foreach ( $recentChangesFlags as $key => $value ) {
				$flagInfos[$key]['letter'] = $value['letter'];
				$flagInfos[$key]['title'] = $value['title'];
				// Allow customized class name, fall back to flag name
				$flagInfos[$key]['class'] = $value['class'] ?? $key;
			}
		}

		$context = $context ?: RequestContext::getMain();

		// Inconsistent naming, kept for b/c
		if ( isset( $map[$flag] ) ) {
			$flag = $map[$flag];
		}

		$info = $flagInfos[$flag];
		return Html::element( 'abbr', [
			'class' => $info['class'],
			'title' => wfMessage( $info['title'] )->setContext( $context )->text(),
		], wfMessage( $info['letter'] )->setContext( $context )->text() );
	}

	/**
	 * Returns text for the start of the tabular part of RC
	 * @return string
	 */
	public function beginRecentChangesList() {
		$this->rc_cache = [];
		$this->rcMoveIndex = 0;
		$this->rcCacheIndex = 0;
		$this->lastdate = '';
		$this->rclistOpen = false;
		$this->getOutput()->addModuleStyles( [
			'mediawiki.interface.helpers.styles',
			'mediawiki.special.changeslist'
		] );

		return '<div class="mw-changeslist">';
	}

	/**
	 * @param IResultWrapper|stdClass[] $rows
	 */
	public function initChangesListRows( $rows ) {
		$this->getHookRunner()->onChangesListInitRows( $this, $rows );
		$this->formattedComments = $this->commentFormatter->createBatch()
			->comments(
				$this->commentFormatter->rows( $rows )
					->commentKey( 'rc_comment' )
					->namespaceField( 'rc_namespace' )
					->titleField( 'rc_title' )
					->indexField( 'rc_id' )
			)
			->useBlock()
			->execute();
	}

	/**
	 * Show formatted char difference
	 *
	 * Needs the css module 'mediawiki.special.changeslist' to style output
	 *
	 * @param int $old Number of bytes
	 * @param int $new Number of bytes
	 * @param IContextSource|null $context
	 * @return string
	 */
	public static function showCharacterDifference( $old, $new, ?IContextSource $context = null ) {
		if ( !$context ) {
			$context = RequestContext::getMain();
		}

		$new = (int)$new;
		$old = (int)$old;
		$szdiff = $new - $old;

		$lang = $context->getLanguage();
		$config = $context->getConfig();
		$code = $lang->getCode();
		static $fastCharDiff = [];
		if ( !isset( $fastCharDiff[$code] ) ) {
			$fastCharDiff[$code] = $config->get( MainConfigNames::MiserMode )
				|| $context->msg( 'rc-change-size' )->plain() === '$1';
		}

		$formattedSize = $lang->formatNum( $szdiff );

		if ( !$fastCharDiff[$code] ) {
			$formattedSize = $context->msg( 'rc-change-size', $formattedSize )->text();
		}

		if ( abs( $szdiff ) > abs( $config->get( MainConfigNames::RCChangedSizeThreshold ) ) ) {
			$tag = 'strong';
		} else {
			$tag = 'span';
		}

		if ( $szdiff === 0 ) {
			$formattedSizeClass = 'mw-plusminus-null';
		} elseif ( $szdiff > 0 ) {
			$formattedSize = '+' . $formattedSize;
			$formattedSizeClass = 'mw-plusminus-pos';
		} else {
			$formattedSizeClass = 'mw-plusminus-neg';
		}
		$formattedSizeClass .= ' mw-diff-bytes';

		$formattedTotalSize = $context->msg( 'rc-change-size-new' )->numParams( $new )->text();

		return Html::element( $tag,
			[ 'dir' => 'ltr', 'class' => $formattedSizeClass, 'title' => $formattedTotalSize ],
			$formattedSize );
	}

	/**
	 * Format the character difference of one or several changes.
	 *
	 * @param RecentChange $old
	 * @param RecentChange|null $new Last change to use, if not provided, $old will be used
	 * @return string HTML fragment
	 */
	public function formatCharacterDifference( RecentChange $old, ?RecentChange $new = null ) {
		$oldlen = $old->mAttribs['rc_old_len'];

		if ( $new ) {
			$newlen = $new->mAttribs['rc_new_len'];
		} else {
			$newlen = $old->mAttribs['rc_new_len'];
		}

		if ( $oldlen === null || $newlen === null ) {
			return '';
		}

		return self::showCharacterDifference( $oldlen, $newlen, $this->getContext() );
	}

	/**
	 * Returns text for the end of RC
	 * @return string
	 */
	public function endRecentChangesList() {
		$out = $this->rclistOpen ? "</ul>\n" : '';
		$out .= '</div>';

		return $out;
	}

	/**
	 * Render the date and time of a revision in the current user language
	 * based on whether the user is able to view this information or not.
	 * @param RevisionRecord $rev
	 * @param Authority $performer
	 * @param Language $lang
	 * @param Title|null $title (optional) where Title does not match
	 *   the Title associated with the RevisionRecord
	 * @param string $className (optional) to append to .mw-changelist-date element for access to the
	 *   associated timestamp string.
	 * @internal For usage by Pager classes only (e.g. HistoryPager, NewPagesPager and ContribsPager).
	 * @return string HTML
	 */
	public static function revDateLink(
		RevisionRecord $rev,
		Authority $performer,
		Language $lang,
		$title = null,
		$className = ''
	) {
		$ts = $rev->getTimestamp();
		$time = $lang->userTime( $ts, $performer->getUser() );
		$date = $lang->userTimeAndDate( $ts, $performer->getUser() );
		$class = trim( 'mw-changeslist-date ' . $className );
		if ( $rev->userCan( RevisionRecord::DELETED_TEXT, $performer ) ) {
			$link = Html::rawElement( 'bdi', [ 'dir' => $lang->getDir() ],
				MediaWikiServices::getInstance()->getLinkRenderer()->makeKnownLink(
					$title ?? $rev->getPageAsLinkTarget(),
					$date,
					[ 'class' => $class ],
					[ 'oldid' => $rev->getId() ]
				)
			);
		} else {
			$link = htmlspecialchars( $date );
		}
		if ( $rev->isDeleted( RevisionRecord::DELETED_TEXT ) ) {
			$class = Linker::getRevisionDeletedClass( $rev ) . " $class";
			$link = "<span class=\"$class\">$link</span>";
		}
		return Html::element( 'span', [
			'class' => 'mw-changeslist-time'
		], $time ) . $link;
	}

	/**
	 * @param string &$s HTML to update
	 * @param mixed $rc_timestamp
	 */
	public function insertDateHeader( &$s, $rc_timestamp ) {
		# Make date header if necessary
		$date = $this->getLanguage()->userDate( $rc_timestamp, $this->getUser() );
		if ( $date != $this->lastdate ) {
			if ( $this->lastdate != '' ) {
				$s .= "</ul>\n";
			}
			$s .= Html::element( 'h4', [], $date ) . "\n<ul class=\"special\">";
			$this->lastdate = $date;
			$this->rclistOpen = true;
		}
	}

	/**
	 * @param string &$s HTML to update
	 * @param Title $title
	 * @param string $logtype
	 * @param bool $useParentheses (optional) Wrap log entry in parentheses where needed
	 */
	public function insertLog( &$s, $title, $logtype, $useParentheses = true ) {
		$page = new LogPage( $logtype );
		$logname = $page->getName()->setContext( $this->getContext() )->text();
		$link = $this->linkRenderer->makeKnownLink( $title, $logname, [
			'class' => $useParentheses ? '' : 'mw-changeslist-links'
		] );
		if ( $useParentheses ) {
			$s .= $this->msg( 'parentheses' )->rawParams(
				$link
			)->escaped();
		} else {
			$s .= $link;
		}
	}

	/**
	 * @param string &$s HTML to update
	 * @param RecentChange &$rc
	 * @param bool|null $unpatrolled Unused variable, since 1.27.
	 */
	public function insertDiffHist( &$s, &$rc, $unpatrolled = null ) {
		# Diff link
		if (
			$rc->mAttribs['rc_type'] == RC_NEW ||
			$rc->mAttribs['rc_type'] == RC_LOG ||
			$rc->mAttribs['rc_type'] == RC_CATEGORIZE
		) {
			$diffLink = $this->message['diff'];
		} elseif ( !self::userCan( $rc, RevisionRecord::DELETED_TEXT, $this->getAuthority() ) ) {
			$diffLink = $this->message['diff'];
		} else {
			$query = [
				'curid' => $rc->mAttribs['rc_cur_id'],
				'diff' => $rc->mAttribs['rc_this_oldid'],
				'oldid' => $rc->mAttribs['rc_last_oldid']
			];

			$diffLink = $this->linkRenderer->makeKnownLink(
				$rc->getTitle(),
				new HtmlArmor( $this->message['diff'] ),
				[ 'class' => 'mw-changeslist-diff' ],
				$query
			);
		}
		if ( $rc->mAttribs['rc_type'] == RC_CATEGORIZE ) {
			$histLink = $this->message['hist'];
		} else {
			$histLink = $this->linkRenderer->makeKnownLink(
				$rc->getTitle(),
				new HtmlArmor( $this->message['hist'] ),
				[ 'class' => 'mw-changeslist-history' ],
				[
					'curid' => $rc->mAttribs['rc_cur_id'],
					'action' => 'history'
				]
			);
		}

		$s .= Html::rawElement( 'span', [ 'class' => 'mw-changeslist-links' ],
				Html::rawElement( 'span', [], $diffLink ) .
				Html::rawElement( 'span', [], $histLink )
			) .
			' <span class="mw-changeslist-separator"></span> ';
	}

	/**
	 * Get the HTML link to the changed page, possibly with a prefix from hook handlers, and a
	 * suffix for temporarily watched items.
	 *
	 * @param RecentChange &$rc
	 * @param bool $unpatrolled
	 * @param bool $watched
	 * @return string HTML
	 * @since 1.26
	 */
	public function getArticleLink( &$rc, $unpatrolled, $watched ) {
		$params = [];
		if ( $rc->getTitle()->isRedirect() ) {
			$params = [ 'redirect' => 'no' ];
		}

		$articlelink = $this->linkRenderer->makeLink(
			$rc->getTitle(),
			null,
			[ 'class' => 'mw-changeslist-title' ],
			$params
		);
		if ( static::isDeleted( $rc, RevisionRecord::DELETED_TEXT ) ) {
			$class = 'history-deleted';
			if ( static::isDeleted( $rc, RevisionRecord::DELETED_RESTRICTED ) ) {
				$class .= ' mw-history-suppressed';
			}
			$articlelink = '<span class="' . $class . '">' . $articlelink . '</span>';
		}
		$dir = $this->getLanguage()->getDir();
		$articlelink = Html::rawElement( 'bdi', [ 'dir' => $dir ], $articlelink );
		# To allow for boldening pages watched by this user
		# Don't wrap result of this with another tag, see T376814
		$articlelink = "<span class=\"mw-title\">{$articlelink}</span>";

		# TODO: Deprecate the $s argument, it seems happily unused.
		$s = '';
		$this->getHookRunner()->onChangesListInsertArticleLink( $this, $articlelink,
			$s, $rc, $unpatrolled, $watched );

		// Watchlist expiry icon.
		$watchlistExpiry = '';
		if ( isset( $rc->watchlistExpiry ) && $rc->watchlistExpiry ) {
			$watchlistExpiry = $this->getWatchlistExpiry( $rc );
		}

		return "{$s} {$articlelink}{$watchlistExpiry}";
	}

	/**
	 * Get HTML to display the clock icon for watched items that have a watchlist expiry time.
	 * @since 1.35
	 * @param RecentChange $recentChange
	 * @return string The HTML to display an indication of the expiry time.
	 */
	public function getWatchlistExpiry( RecentChange $recentChange ): string {
		$item = WatchedItem::newFromRecentChange( $recentChange, $this->getUser() );
		// Guard against expired items, even though they shouldn't come here.
		if ( $item->isExpired() ) {
			return '';
		}
		$daysLeftText = $item->getExpiryInDaysText( $this->getContext() );
		// Matching widget is also created in ChangesListSpecialPage, for the legend.
		$widget = new IconWidget( [
			'icon' => 'clock',
			'title' => $daysLeftText,
			'classes' => [ 'mw-changesList-watchlistExpiry' ],
		] );
		$widget->setAttributes( [
			// Add labels for assistive technologies.
			'role' => 'img',
			'aria-label' => $this->msg( 'watchlist-expires-in-aria-label' )->text(),
			// Days-left is used in resources/src/mediawiki.special.changeslist.watchlistexpiry/watchlistexpiry.js
			'data-days-left' => $item->getExpiryInDays(),
		] );
		// Add spaces around the widget (the page title is to one side,
		// and a semicolon or opening-parenthesis to the other).
		return " $widget ";
	}

	/**
	 * Get the timestamp from $rc formatted with current user's settings
	 * and a separator
	 *
	 * @param RecentChange $rc
	 * @deprecated since 1.43; use revDateLink instead.
	 * @return string HTML fragment
	 */
	public function getTimestamp( $rc ) {
		// This uses the semi-colon separator unless there's a watchlist expiry date for the entry,
		// because in that case the timestamp is preceded by a clock icon.
		// A space is important after `.mw-changeslist-separator--semicolon` to make sure
		// that whatever comes before it is distinguishable.
		// (Otherwise your have the text of titles pushing up against the timestamp)
		// A specific element is used for this purpose rather than styling `.mw-changeslist-date`
		// as the `.mw-changeslist-date` class is used in a variety
		// of other places with a different position and the information proceeding getTimestamp can vary.
		// The `.mw-changeslist-time` class allows us to distinguish from `.mw-changeslist-date` elements that
		// contain the full date (month, year) and adds consistency with Special:Contributions
		// and other pages.
		$separatorClass = $rc->watchlistExpiry ? 'mw-changeslist-separator' : 'mw-changeslist-separator--semicolon';
		return Html::element( 'span', [ 'class' => $separatorClass ] ) . ' ' .
			'<span class="mw-changeslist-date mw-changeslist-time">' .
			htmlspecialchars( $this->getLanguage()->userTime(
				$rc->mAttribs['rc_timestamp'],
				$this->getUser()
			) ) . '</span> <span class="mw-changeslist-separator"></span> ';
	}

	/**
	 * Insert time timestamp string from $rc into $s
	 *
	 * @param string &$s HTML to update
	 * @param RecentChange $rc
	 */
	public function insertTimestamp( &$s, $rc ) {
		$s .= $this->getTimestamp( $rc );
	}

	/**
	 * Insert links to user page, user talk page and eventually a blocking link
	 *
	 * @param string &$s HTML to update
	 * @param RecentChange &$rc
	 */
	public function insertUserRelatedLinks( &$s, &$rc ) {
		if ( static::isDeleted( $rc, RevisionRecord::DELETED_USER ) ) {
			$deletedClass = 'history-deleted';
			if ( static::isDeleted( $rc, RevisionRecord::DELETED_RESTRICTED ) ) {
				$deletedClass .= ' mw-history-suppressed';
			}
			$s .= ' <span class="' . $deletedClass . '">' .
				$this->msg( 'rev-deleted-user' )->escaped() . '</span>';
		} else {
			# Don't wrap result of this with another tag, see T376814
			$s .= $this->userLinkCache->getWithSetCallback(
				$this->userLinkCache->makeKey(
					$rc->mAttribs['rc_user_text'],
					$this->getUser()->getName(),
					$this->getLanguage()->getCode()
				),
				static function () use ( $rc ) {
					return Linker::userLink(
						$rc->mAttribs['rc_user'],
						$rc->mAttribs['rc_user_text']
					) . Linker::userToolLinks(
						$rc->mAttribs['rc_user'], $rc->mAttribs['rc_user_text'],
						false, 0, null,
						// The text content of tools is not wrapped with parentheses or "piped".
						// This will be handled in CSS (T205581).
						false
					);
				}
			);
		}
	}

	/**
	 * Insert a formatted action
	 *
	 * @param RecentChange $rc
	 * @return string
	 */
	public function insertLogEntry( $rc ) {
		$formatter = $this->logFormatterFactory->newFromRow( $rc->mAttribs );
		$formatter->setContext( $this->getContext() );
		$formatter->setShowUserToolLinks( true );

		$comment = $formatter->getComment();
		if ( $comment !== '' ) {
			$dir = $this->getLanguage()->getDir();
			$comment = Html::rawElement( 'bdi', [ 'dir' => $dir ], $comment );
		}

		return Html::openElement( 'span', [ 'class' => 'mw-changeslist-log-entry' ] )
			. $formatter->getActionText()
			. ' '
			. $comment
			. $this->message['word-separator']
			. $formatter->getActionLinks()
			. Html::closeElement( 'span' );
	}

	/**
	 * Insert a formatted comment
	 * @param RecentChange $rc
	 * @return string
	 */
	public function insertComment( $rc ) {
		if ( static::isDeleted( $rc, RevisionRecord::DELETED_COMMENT ) ) {
			$deletedClass = 'history-deleted';
			if ( static::isDeleted( $rc, RevisionRecord::DELETED_RESTRICTED ) ) {
				$deletedClass .= ' mw-history-suppressed';
			}
			return ' <span class="' . $deletedClass . ' comment">' .
				$this->msg( 'rev-deleted-comment' )->escaped() . '</span>';
		} elseif ( isset( $rc->mAttribs['rc_id'] )
			&& isset( $this->formattedComments[$rc->mAttribs['rc_id']] )
		) {
			return $this->formattedComments[$rc->mAttribs['rc_id']];
		} else {
			return $this->commentFormatter->formatBlock(
				$rc->mAttribs['rc_comment'],
				$rc->getTitle(),
				// Whether section links should refer to local page (using default false)
				false,
				// wikid to generate links for (using default null) */
				null,
				// whether parentheses should be rendered as part of the message
				false
			);
		}
	}

	/**
	 * Returns the string which indicates the number of watching users
	 * @param int $count Number of user watching a page
	 * @return string
	 */
	protected function numberofWatchingusers( $count ) {
		if ( $count <= 0 ) {
			return '';
		}

		return $this->watchMsgCache->getWithSetCallback(
			$this->watchMsgCache->makeKey(
				'watching-users-msg',
				strval( $count ),
				$this->getUser()->getName(),
				$this->getLanguage()->getCode()
			),
			function () use ( $count ) {
				return $this->msg( 'number-of-watching-users-for-recent-changes' )
					->numParams( $count )->escaped();
			}
		);
	}

	/**
	 * Determine if said field of a revision is hidden
	 * @param RCCacheEntry|RecentChange $rc
	 * @param int $field One of DELETED_* bitfield constants
	 * @return bool
	 */
	public static function isDeleted( $rc, $field ) {
		return ( $rc->mAttribs['rc_deleted'] & $field ) == $field;
	}

	/**
	 * Determine if the current user is allowed to view a particular
	 * field of this revision, if it's marked as deleted.
	 * @param RCCacheEntry|RecentChange $rc
	 * @param int $field
	 * @param Authority|null $performer to check permissions against. If null, the global RequestContext's
	 * User is assumed instead.
	 * @return bool
	 */
	public static function userCan( $rc, $field, ?Authority $performer = null ) {
		$performer ??= RequestContext::getMain()->getAuthority();

		if ( $rc->mAttribs['rc_type'] == RC_LOG ) {
			return LogEventsList::userCanBitfield( $rc->mAttribs['rc_deleted'], $field, $performer );
		}

		return RevisionRecord::userCanBitfield( $rc->mAttribs['rc_deleted'], $field, $performer );
	}

	/**
	 * @param string $link
	 * @param bool $watched
	 * @return string
	 */
	protected function maybeWatchedLink( $link, $watched = false ) {
		if ( $watched ) {
			return '<strong class="mw-watched">' . $link . '</strong>';
		} else {
			return '<span class="mw-rc-unwatched">' . $link . '</span>';
		}
	}

	/**
	 * Insert a rollback link
	 *
	 * @param string &$s
	 * @param RecentChange &$rc
	 */
	public function insertRollback( &$s, &$rc ) {
		$this->insertPageTools( $s, $rc );
	}

	/**
	 * Insert an extensible set of page tools into the changelist row
	 * which includes a rollback link and undo link if applicable.
	 *
	 * @param string &$s
	 * @param RecentChange &$rc
	 *
	 */
	private function insertPageTools( &$s, &$rc ) {
		// FIXME Some page tools (e.g. thanks) might make sense for log entries.
		if ( !in_array( $rc->mAttribs['rc_type'], [ RC_EDIT, RC_NEW ] )
			// FIXME When would either of these not exist when type is RC_EDIT? Document.
			|| !$rc->mAttribs['rc_this_oldid']
			|| !$rc->mAttribs['rc_cur_id']
		) {
			return;
		}

		// Construct a fake revision for PagerTools. FIXME can't we just obtain the real one?
		$title = $rc->getTitle();
		$revRecord = new MutableRevisionRecord( $title );
		$revRecord->setId( (int)$rc->mAttribs['rc_this_oldid'] );
		$revRecord->setVisibility( (int)$rc->mAttribs['rc_deleted'] );
		$user = new UserIdentityValue(
			(int)$rc->mAttribs['rc_user'],
			$rc->mAttribs['rc_user_text']
		);
		$revRecord->setUser( $user );

		$tools = new PagerTools(
			$revRecord,
			null,
			// only show a rollback link on the top-most revision
			$rc->getAttribute( 'page_latest' ) == $rc->mAttribs['rc_this_oldid']
				&& $rc->mAttribs['rc_type'] != RC_NEW,
			$this->getHookRunner(),
			$title,
			$this->getContext(),
			// @todo: Inject
			MediaWikiServices::getInstance()->getLinkRenderer()
		);

		$s .= $tools->toHTML();
	}

	/**
	 * @param RecentChange $rc
	 * @return string
	 * @since 1.26
	 */
	public function getRollback( RecentChange $rc ) {
		$s = '';
		$this->insertRollback( $s, $rc );
		return $s;
	}

	/**
	 * @param string &$s
	 * @param RecentChange &$rc
	 * @param string[] &$classes
	 */
	public function insertTags( &$s, &$rc, &$classes ) {
		if ( empty( $rc->mAttribs['ts_tags'] ) ) {
			return;
		}

		/**
		 * Tags are repeated for a lot of the records, so during single run of RecentChanges, we
		 * should cache those that were already processed as doing that for each record takes
		 * significant amount of time.
		 */
		[ $tagSummary, $newClasses ] = $this->tagsCache->getWithSetCallback(
			$this->tagsCache->makeKey(
				$rc->mAttribs['ts_tags'],
				$this->getUser()->getName(),
				$this->getLanguage()->getCode()
			),
			fn () => ChangeTags::formatSummaryRow(
				$rc->mAttribs['ts_tags'],
				'changeslist',
				$this->getContext()
			)
		);
		$classes = array_merge( $classes, $newClasses );
		$s .= ' ' . $tagSummary;
	}

	/**
	 * @param RecentChange $rc
	 * @param string[] &$classes
	 * @return string
	 * @since 1.26
	 */
	public function getTags( RecentChange $rc, array &$classes ) {
		$s = '';
		$this->insertTags( $s, $rc, $classes );
		return $s;
	}

	public function insertExtra( &$s, &$rc, &$classes ) {
		// Empty, used for subclasses to add anything special.
	}

	protected function showAsUnpatrolled( RecentChange $rc ) {
		return self::isUnpatrolled( $rc, $this->getUser() );
	}

	/**
	 * @param stdClass|RecentChange $rc Database row from recentchanges or a RecentChange object
	 * @param User $user
	 * @return bool
	 */
	public static function isUnpatrolled( $rc, User $user ) {
		if ( $rc instanceof RecentChange ) {
			$isPatrolled = $rc->mAttribs['rc_patrolled'];
			$rcType = $rc->mAttribs['rc_type'];
			$rcLogType = $rc->mAttribs['rc_log_type'];
		} else {
			$isPatrolled = $rc->rc_patrolled;
			$rcType = $rc->rc_type;
			$rcLogType = $rc->rc_log_type;
		}

		if ( $isPatrolled ) {
			return false;
		}

		return $user->useRCPatrol() ||
			( $rcType == RC_NEW && $user->useNPPatrol() ) ||
			( $rcLogType === 'upload' && $user->useFilePatrol() );
	}

	/**
	 * Determines whether a revision is linked to this change; this may not be the case
	 * when the categorization wasn't done by an edit but a conditional parser function
	 *
	 * @since 1.27
	 *
	 * @param RecentChange|RCCacheEntry $rcObj
	 * @return bool
	 */
	protected function isCategorizationWithoutRevision( $rcObj ) {
		return intval( $rcObj->getAttribute( 'rc_type' ) ) === RC_CATEGORIZE
			&& intval( $rcObj->getAttribute( 'rc_this_oldid' ) ) === 0;
	}

	/**
	 * Get recommended data attributes for a change line.
	 * @param RecentChange $rc
	 * @return string[] attribute name => value
	 */
	protected function getDataAttributes( RecentChange $rc ) {
		$attrs = [];

		$type = $rc->getAttribute( 'rc_source' );
		switch ( $type ) {
			case RecentChange::SRC_EDIT:
			case RecentChange::SRC_CATEGORIZE:
			case RecentChange::SRC_NEW:
				$attrs['data-mw-revid'] = $rc->mAttribs['rc_this_oldid'];
				break;
			case RecentChange::SRC_LOG:
				$attrs['data-mw-logid'] = $rc->mAttribs['rc_logid'];
				$attrs['data-mw-logaction'] =
					$rc->mAttribs['rc_log_type'] . '/' . $rc->mAttribs['rc_log_action'];
				break;
		}

		$attrs[ 'data-mw-ts' ] = $rc->getAttribute( 'rc_timestamp' );

		return $attrs;
	}

	/**
	 * Sets the callable that generates a change line prefix added to the beginning of each line.
	 *
	 * @param callable $prefixer Callable to run that generates the change line prefix.
	 *     Takes three parameters: a RecentChange object, a ChangesList object,
	 *     and whether the current entry is a grouped entry.
	 */
	public function setChangeLinePrefixer( callable $prefixer ) {
		$this->changeLinePrefixer = $prefixer;
	}
}
PK       ! \qļv6  v6    ChangesListFilterGroup.phpnu ÕIw¶“        <?php
/**
 * 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
 */

use MediaWiki\Html\FormOptions;
use MediaWiki\SpecialPage\ChangesListSpecialPage;
use Wikimedia\Rdbms\IReadableDatabase;

/**
 * Represents a filter group (used on ChangesListSpecialPage and descendants)
 *
 * @todo Might want to make a super-class or trait to share behavior (especially re
 * conflicts) between ChangesListFilter and ChangesListFilterGroup.
 * What to call it.  FilterStructure?  That would also let me make
 * setUnidirectionalConflict protected.
 *
 * @since 1.29
 * @ingroup RecentChanges
 * @author Matthew Flaschen
 * @method registerFilter($filter)
 */
abstract class ChangesListFilterGroup {
	/**
	 * Name (internal identifier)
	 *
	 * @var string
	 */
	protected $name;

	/**
	 * i18n key for title
	 *
	 * @var string
	 */
	protected $title;

	/**
	 * i18n key for header of What's This?
	 *
	 * @var string|null
	 */
	protected $whatsThisHeader;

	/**
	 * i18n key for body of What's This?
	 *
	 * @var string|null
	 */
	protected $whatsThisBody;

	/**
	 * URL of What's This? link
	 *
	 * @var string|null
	 */
	protected $whatsThisUrl;

	/**
	 * i18n key for What's This? link
	 *
	 * @var string|null
	 */
	protected $whatsThisLinkText;

	/**
	 * Type, from a TYPE constant of a subclass
	 *
	 * @var string
	 */
	protected $type;

	/**
	 * Priority integer.  Higher values means higher up in the
	 * group list.
	 *
	 * @var int
	 */
	protected $priority;

	/**
	 * Associative array of filters, as ChangesListFilter objects, with filter name as key
	 *
	 * @var ChangesListFilter[]
	 */
	protected $filters;

	/**
	 * Whether this group is full coverage.  This means that checking every item in the
	 * group means no changes list (e.g. RecentChanges) entries are filtered out.
	 *
	 * @var bool
	 */
	protected $isFullCoverage;

	/**
	 * Array of associative arrays with conflict information.  See
	 * setUnidirectionalConflict
	 *
	 * @var array
	 */
	protected $conflictingGroups = [];

	/**
	 * Array of associative arrays with conflict information.  See
	 * setUnidirectionalConflict
	 *
	 * @var array
	 */
	protected $conflictingFilters = [];

	private const DEFAULT_PRIORITY = -100;

	private const RESERVED_NAME_CHAR = '_';

	/**
	 * Create a new filter group with the specified configuration
	 *
	 * @param array $groupDefinition Configuration of group
	 * * $groupDefinition['name'] string Group name; use camelCase with no punctuation
	 * * $groupDefinition['title'] string i18n key for title (optional, can be omitted
	 *     only if none of the filters in the group display in the structured UI)
	 * * $groupDefinition['type'] string A type constant from a subclass of this one
	 * * $groupDefinition['priority'] int Priority integer.  Higher value means higher
	 *     up in the group list (optional, defaults to -100).
	 * * $groupDefinition['filters'] array Numeric array of filter definitions, each of which
	 *     is an associative array to be passed to the filter constructor.  However,
	 *     'priority' is optional for the filters.  Any filter that has priority unset
	 *     will be put to the bottom, in the order given.
	 * * $groupDefinition['isFullCoverage'] bool Whether the group is full coverage;
	 *     if true, this means that checking every item in the group means no
	 *     changes list entries are filtered out.
	 * * $groupDefinition['whatsThisHeader'] string i18n key for header of "What's
	 *     This" popup (optional).
	 * * $groupDefinition['whatsThisBody'] string i18n key for body of "What's This"
	 *     popup (optional).
	 * * $groupDefinition['whatsThisUrl'] string URL for main link of "What's This"
	 *     popup (optional).
	 * * $groupDefinition['whatsThisLinkText'] string i18n key of text for main link of
	 *     "What's This" popup (optional).
	 */
	public function __construct( array $groupDefinition ) {
		if ( strpos( $groupDefinition['name'], self::RESERVED_NAME_CHAR ) !== false ) {
			throw new InvalidArgumentException( 'Group names may not contain \'' .
				self::RESERVED_NAME_CHAR .
				'\'.  Use the naming convention: \'camelCase\''
			);
		}

		$this->name = $groupDefinition['name'];

		if ( isset( $groupDefinition['title'] ) ) {
			$this->title = $groupDefinition['title'];
		}

		if ( isset( $groupDefinition['whatsThisHeader'] ) ) {
			$this->whatsThisHeader = $groupDefinition['whatsThisHeader'];
			$this->whatsThisBody = $groupDefinition['whatsThisBody'];
			$this->whatsThisUrl = $groupDefinition['whatsThisUrl'];
			$this->whatsThisLinkText = $groupDefinition['whatsThisLinkText'];
		}

		$this->type = $groupDefinition['type'];
		$this->priority = $groupDefinition['priority'] ?? self::DEFAULT_PRIORITY;

		$this->isFullCoverage = $groupDefinition['isFullCoverage'];

		$this->filters = [];
		$lowestSpecifiedPriority = -1;
		foreach ( $groupDefinition['filters'] as $filterDefinition ) {
			if ( isset( $filterDefinition['priority'] ) ) {
				$lowestSpecifiedPriority = min( $lowestSpecifiedPriority, $filterDefinition['priority'] );
			}
		}

		// Convenience feature: If you specify a group (and its filters) all in
		// one place, you don't have to specify priority.  You can just put them
		// in order.  However, if you later add one (e.g. an extension adds a filter
		// to a core-defined group), you need to specify it.
		$autoFillPriority = $lowestSpecifiedPriority - 1;
		foreach ( $groupDefinition['filters'] as $filterDefinition ) {
			if ( !isset( $filterDefinition['priority'] ) ) {
				$filterDefinition['priority'] = $autoFillPriority;
				$autoFillPriority--;
			}
			$filterDefinition['group'] = $this;

			$filter = $this->createFilter( $filterDefinition );
			$this->registerFilter( $filter );
		}
	}

	/**
	 * Creates a filter of the appropriate type for this group, from the definition
	 *
	 * @param array $filterDefinition
	 * @return ChangesListFilter Filter
	 */
	abstract protected function createFilter( array $filterDefinition );

	/**
	 * Marks that the given ChangesListFilterGroup or ChangesListFilter conflicts with this object.
	 *
	 * WARNING: This means there is a conflict when both things are *shown*
	 * (not filtered out), even for the hide-based filters.  So e.g. conflicting with
	 * 'hideanons' means there is a conflict if only anonymous users are *shown*.
	 *
	 * @param ChangesListFilterGroup|ChangesListFilter $other
	 * @param string $globalKey i18n key for top-level conflict message
	 * @param string $forwardKey i18n key for conflict message in this
	 *  direction (when in UI context of $this object)
	 * @param string $backwardKey i18n key for conflict message in reverse
	 *  direction (when in UI context of $other object)
	 */
	public function conflictsWith( $other, string $globalKey, string $forwardKey, string $backwardKey ) {
		$this->setUnidirectionalConflict(
			$other,
			$globalKey,
			$forwardKey
		);

		$other->setUnidirectionalConflict(
			$this,
			$globalKey,
			$backwardKey
		);
	}

	/**
	 * Marks that the given ChangesListFilterGroup or ChangesListFilter conflicts with
	 * this object.
	 *
	 * Internal use ONLY.
	 *
	 * @param ChangesListFilterGroup|ChangesListFilter $other
	 * @param string $globalDescription i18n key for top-level conflict message
	 * @param string $contextDescription i18n key for conflict message in this
	 *  direction (when in UI context of $this object)
	 */
	public function setUnidirectionalConflict( $other, $globalDescription, $contextDescription ) {
		if ( $other instanceof ChangesListFilterGroup ) {
			$this->conflictingGroups[] = [
				'group' => $other->getName(),
				'groupObject' => $other,
				'globalDescription' => $globalDescription,
				'contextDescription' => $contextDescription,
			];
		} elseif ( $other instanceof ChangesListFilter ) {
			$this->conflictingFilters[] = [
				'group' => $other->getGroup()->getName(),
				'filter' => $other->getName(),
				'filterObject' => $other,
				'globalDescription' => $globalDescription,
				'contextDescription' => $contextDescription,
			];
		} else {
			throw new InvalidArgumentException(
				'You can only pass in a ChangesListFilterGroup or a ChangesListFilter'
			);
		}
	}

	/**
	 * @return string Internal name
	 */
	public function getName() {
		return $this->name;
	}

	/**
	 * @return string i18n key for title
	 */
	public function getTitle() {
		return $this->title;
	}

	/**
	 * @return string Type (TYPE constant from a subclass)
	 */
	public function getType() {
		return $this->type;
	}

	/**
	 * @return int Priority.  Higher means higher in the group list
	 */
	public function getPriority() {
		return $this->priority;
	}

	/**
	 * @return ChangesListFilter[] Associative array of ChangesListFilter objects, with
	 *   filter name as key
	 */
	public function getFilters() {
		return $this->filters;
	}

	/**
	 * Get filter by name
	 *
	 * @param string $name Filter name
	 * @return ChangesListFilter|null Specified filter, or null if it is not registered
	 */
	public function getFilter( $name ) {
		return $this->filters[$name] ?? null;
	}

	/**
	 * Gets the JS data in the format required by the front-end of the structured UI
	 *
	 * @return array|null Associative array, or null if there are no filters that
	 *  display in the structured UI.  messageKeys is a special top-level value, with
	 *  the value being an array of the message keys to send to the client.
	 */
	public function getJsData() {
		$output = [
			'name' => $this->name,
			'type' => $this->type,
			'fullCoverage' => $this->isFullCoverage,
			'filters' => [],
			'priority' => $this->priority,
			'conflicts' => [],
			'messageKeys' => [ $this->title ]
		];

		if ( isset( $this->whatsThisHeader ) ) {
			$output['whatsThisHeader'] = $this->whatsThisHeader;
			$output['whatsThisBody'] = $this->whatsThisBody;
			$output['whatsThisUrl'] = $this->whatsThisUrl;
			$output['whatsThisLinkText'] = $this->whatsThisLinkText;

			array_push(
				$output['messageKeys'],
				$output['whatsThisHeader'],
				$output['whatsThisBody'],
				$output['whatsThisLinkText']
			);
		}

		usort( $this->filters, static function ( ChangesListFilter $a, ChangesListFilter $b ) {
			return $b->getPriority() <=> $a->getPriority();
		} );

		foreach ( $this->filters as $filter ) {
			if ( $filter->displaysOnStructuredUi() ) {
				$filterData = $filter->getJsData();
				$output['messageKeys'] = array_merge(
					$output['messageKeys'],
					$filterData['messageKeys']
				);
				unset( $filterData['messageKeys'] );
				$output['filters'][] = $filterData;
			}
		}

		if ( count( $output['filters'] ) === 0 ) {
			return null;
		}

		$output['title'] = $this->title;

		$conflicts = array_merge(
			$this->conflictingGroups,
			$this->conflictingFilters
		);

		foreach ( $conflicts as $conflictInfo ) {
			unset( $conflictInfo['filterObject'] );
			unset( $conflictInfo['groupObject'] );
			$output['conflicts'][] = $conflictInfo;
			array_push(
				$output['messageKeys'],
				$conflictInfo['globalDescription'],
				$conflictInfo['contextDescription']
			);
		}

		return $output;
	}

	/**
	 * Get groups conflicting with this filter group
	 *
	 * @return ChangesListFilterGroup[]
	 */
	public function getConflictingGroups() {
		return array_column( $this->conflictingGroups, 'groupObject' );
	}

	/**
	 * Get filters conflicting with this filter group
	 *
	 * @return ChangesListFilter[]
	 */
	public function getConflictingFilters() {
		return array_column( $this->conflictingFilters, 'filterObject' );
	}

	/**
	 * Check if any filter in this group is selected
	 *
	 * @param FormOptions $opts
	 * @return bool
	 */
	public function anySelected( FormOptions $opts ) {
		return (bool)count( array_filter(
			$this->getFilters(),
			static function ( ChangesListFilter $filter ) use ( $opts ) {
				return $filter->isSelected( $opts );
			}
		) );
	}

	/**
	 * Modifies the query to include the filter group.
	 *
	 * The modification is only done if the filter group is in effect.  This means that
	 * one or more valid and allowed filters were selected.
	 *
	 * @param IReadableDatabase $dbr Database, for addQuotes, makeList, and similar
	 * @param ChangesListSpecialPage $specialPage Current special page
	 * @param array &$tables Array of tables; see IDatabase::select $table
	 * @param array &$fields Array of fields; see IDatabase::select $vars
	 * @param array &$conds Array of conditions; see IDatabase::select $conds
	 * @param array &$query_options Array of query options; see IDatabase::select $options
	 * @param array &$join_conds Array of join conditions; see IDatabase::select $join_conds
	 * @param FormOptions $opts Wrapper for the current request options and their defaults
	 * @param bool $isStructuredFiltersEnabled True if the Structured UI is currently enabled
	 */
	abstract public function modifyQuery( IReadableDatabase $dbr, ChangesListSpecialPage $specialPage,
		&$tables, &$fields, &$conds, &$query_options, &$join_conds,
		FormOptions $opts, $isStructuredFiltersEnabled );

	/**
	 * All the options represented by this filter group to $opts
	 *
	 * @param FormOptions $opts
	 * @param bool $allowDefaults
	 * @param bool $isStructuredFiltersEnabled
	 */
	abstract public function addOptions( FormOptions $opts, $allowDefaults,
		$isStructuredFiltersEnabled );
}
PK       ! kĖT±r(  r(    RCCacheEntryFactory.phpnu ÕIw¶“        <?php
/**
 * 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
 */

use MediaWiki\Context\IContextSource;
use MediaWiki\Linker\Linker;
use MediaWiki\Linker\LinkRenderer;
use MediaWiki\Revision\RevisionRecord;
use MediaWiki\SpecialPage\SpecialPage;
use MediaWiki\Title\Title;
use MediaWiki\User\ExternalUserNames;

/**
 * Create a RCCacheEntry from a RecentChange to use in EnhancedChangesList
 *
 * @ingroup RecentChanges
 */
class RCCacheEntryFactory {

	/** @var IContextSource */
	private $context;

	/** @var string[] */
	private $messages;

	/**
	 * @var LinkRenderer
	 */
	private $linkRenderer;

	/**
	 * @var MapCacheLRU
	 */
	private MapCacheLRU $userLinkCache;

	/**
	 * @var MapCacheLRU
	 */
	private MapCacheLRU $toolLinkCache;

	/**
	 * @param IContextSource $context
	 * @param string[] $messages
	 * @param LinkRenderer $linkRenderer
	 */
	public function __construct(
		IContextSource $context, $messages, LinkRenderer $linkRenderer
	) {
		$this->context = $context;
		$this->messages = $messages;
		$this->linkRenderer = $linkRenderer;
		$this->userLinkCache = new MapCacheLRU( 50 );
		$this->toolLinkCache = new MapCacheLRU( 50 );
	}

	/**
	 * @param RecentChange $baseRC
	 * @param bool $watched
	 *
	 * @return RCCacheEntry
	 */
	public function newFromRecentChange( RecentChange $baseRC, $watched ) {
		$user = $this->context->getUser();

		$cacheEntry = RCCacheEntry::newFromParent( $baseRC );

		// Should patrol-related stuff be shown?
		$cacheEntry->unpatrolled = ChangesList::isUnpatrolled( $baseRC, $user );

		$cacheEntry->watched = $cacheEntry->mAttribs['rc_type'] == RC_LOG ? false : $watched;
		$cacheEntry->numberofWatchingusers = $baseRC->numberofWatchingusers;
		$cacheEntry->watchlistExpiry = $baseRC->watchlistExpiry;

		$cacheEntry->link = $this->buildCLink( $cacheEntry );
		$cacheEntry->timestamp = $this->buildTimestamp( $cacheEntry );

		// Make "cur" and "diff" links.  Do not use link(), it is too slow if
		// called too many times (50% of CPU time on RecentChanges!).
		$showDiffLinks = ChangesList::userCan( $cacheEntry, RevisionRecord::DELETED_TEXT, $user );

		$cacheEntry->difflink = $this->buildDiffLink( $cacheEntry, $showDiffLinks );
		$cacheEntry->curlink = $this->buildCurLink( $cacheEntry, $showDiffLinks );
		$cacheEntry->lastlink = $this->buildLastLink( $cacheEntry, $showDiffLinks );

		// Make user links
		$cacheEntry->userlink = $this->getUserLink( $cacheEntry );

		if ( !ChangesList::isDeleted( $cacheEntry, RevisionRecord::DELETED_USER ) ) {
			/**
			 * userToolLinks requires a lot of parser work to process multiple links that are
			 * rendered there, like contrib page, user talk etc. Often, active
			 * users will appear multiple times on same run of RecentChanges, and therefore it is
			 * unnecessary to process it for each RC record separately.
			 */
			$cacheEntry->usertalklink = $this->toolLinkCache->getWithSetCallback(
				$this->toolLinkCache->makeKey(
					$cacheEntry->mAttribs['rc_user_text'],
					$this->context->getUser()->getName(),
					$this->context->getLanguage()->getCode()
				),
				static fn () => Linker::userToolLinks(
					$cacheEntry->mAttribs['rc_user'],
					$cacheEntry->mAttribs['rc_user_text'],
					// Should the contributions link be red if the user has no edits (using default)
					false,
					// Customisation flags (using default 0)
					0,
					// User edit count (using default )
					null,
					// do not wrap the message in parentheses
					false
				)
			);
		}

		return $cacheEntry;
	}

	/**
	 * @param RCCacheEntry $cacheEntry
	 *
	 * @return string
	 */
	private function buildCLink( RCCacheEntry $cacheEntry ) {
		$type = $cacheEntry->mAttribs['rc_type'];

		// Log entries
		if ( $type == RC_LOG ) {
			$logType = $cacheEntry->mAttribs['rc_log_type'];

			if ( $logType ) {
				$clink = $this->getLogLink( $logType );
			} else {
				wfDebugLog( 'recentchanges', 'Unexpected log entry with no log type in recent changes' );
				$clink = $this->linkRenderer->makeLink( $cacheEntry->getTitle() );
			}
		// Log entries (old format) and special pages
		} elseif ( $cacheEntry->mAttribs['rc_namespace'] == NS_SPECIAL ) {
			wfDebugLog( 'recentchanges', 'Unexpected special page in recentchanges' );
			$clink = '';
		// Edits and everything else
		} else {
			$clink = $this->linkRenderer->makeKnownLink( $cacheEntry->getTitle() );
		}

		return $clink;
	}

	private function getLogLink( $logType ) {
		$logtitle = SpecialPage::getTitleFor( 'Log', $logType );
		$logpage = new LogPage( $logType );
		$logname = $logpage->getName()->text();

		$logLink = $this->context->msg( 'parentheses' )
			->rawParams(
				$this->linkRenderer->makeKnownLink( $logtitle, $logname )
			)->escaped();

		return $logLink;
	}

	/**
	 * @param RecentChange $cacheEntry
	 *
	 * @return string
	 */
	private function buildTimestamp( RecentChange $cacheEntry ) {
		return $this->context->getLanguage()->userTime(
			$cacheEntry->mAttribs['rc_timestamp'],
			$this->context->getUser()
		);
	}

	/**
	 * @param RecentChange $recentChange
	 *
	 * @return array
	 */
	private function buildCurQueryParams( RecentChange $recentChange ) {
		return [
			'curid' => $recentChange->mAttribs['rc_cur_id'],
			'diff' => 0,
			'oldid' => $recentChange->mAttribs['rc_this_oldid']
		];
	}

	/**
	 * @param RecentChange $cacheEntry
	 * @param bool $showDiffLinks
	 *
	 * @return string
	 */
	private function buildCurLink( RecentChange $cacheEntry, $showDiffLinks ) {
		$curMessage = $this->getMessage( 'cur' );
		$logTypes = [ RC_LOG ];
		if ( $cacheEntry->mAttribs['rc_this_oldid'] == $cacheEntry->getAttribute( 'page_latest' ) ) {
			$showDiffLinks = false;
		}

		if ( !$showDiffLinks || in_array( $cacheEntry->mAttribs['rc_type'], $logTypes ) ) {
			$curLink = $curMessage;
		} else {
			$queryParams = $this->buildCurQueryParams( $cacheEntry );
			$curUrl = htmlspecialchars( $cacheEntry->getTitle()->getLinkURL( $queryParams ) );
			$curLink = "<a class=\"mw-changeslist-diff-cur\" href=\"$curUrl\">$curMessage</a>";
		}

		return $curLink;
	}

	/**
	 * @param RecentChange $recentChange
	 *
	 * @return array
	 */
	private function buildDiffQueryParams( RecentChange $recentChange ) {
		return [
			'curid' => $recentChange->mAttribs['rc_cur_id'],
			'diff' => $recentChange->mAttribs['rc_this_oldid'],
			'oldid' => $recentChange->mAttribs['rc_last_oldid']
		];
	}

	/**
	 * @param RecentChange $cacheEntry
	 * @param bool $showDiffLinks
	 *
	 * @return string
	 */
	private function buildDiffLink( RecentChange $cacheEntry, $showDiffLinks ) {
		$queryParams = $this->buildDiffQueryParams( $cacheEntry );
		$diffMessage = $this->getMessage( 'diff' );
		$logTypes = [ RC_NEW, RC_LOG ];

		if ( !$showDiffLinks ) {
			$diffLink = $diffMessage;
		} elseif ( in_array( $cacheEntry->mAttribs['rc_type'], $logTypes ) ) {
			$diffLink = $diffMessage;
		} elseif ( $cacheEntry->getAttribute( 'rc_type' ) == RC_CATEGORIZE ) {
			$rcCurId = $cacheEntry->getAttribute( 'rc_cur_id' );
			$pageTitle = Title::newFromID( $rcCurId );
			if ( $pageTitle === null ) {
				wfDebugLog( 'RCCacheEntryFactory', 'Could not get Title for rc_cur_id: ' . $rcCurId );
				return $diffMessage;
			}
			$diffUrl = htmlspecialchars( $pageTitle->getLinkURL( $queryParams ) );
			$diffLink = "<a class=\"mw-changeslist-diff\" href=\"$diffUrl\">$diffMessage</a>";
		} else {
			$diffUrl = htmlspecialchars( $cacheEntry->getTitle()->getLinkURL( $queryParams ) );
			$diffLink = "<a class=\"mw-changeslist-diff\" href=\"$diffUrl\">$diffMessage</a>";
		}

		return $diffLink;
	}

	/**
	 * Builds the link to the previous version
	 *
	 * @param RecentChange $cacheEntry
	 * @param bool $showDiffLinks
	 *
	 * @return string
	 */
	private function buildLastLink( RecentChange $cacheEntry, $showDiffLinks ) {
		$lastOldid = $cacheEntry->mAttribs['rc_last_oldid'];
		$lastMessage = $this->getMessage( 'last' );
		$type = $cacheEntry->mAttribs['rc_type'];
		$logTypes = [ RC_LOG ];

		// Make "last" link
		if ( !$showDiffLinks || !$lastOldid || in_array( $type, $logTypes ) ) {
			$lastLink = $lastMessage;
		} else {
			$lastLink = $this->linkRenderer->makeKnownLink(
				$cacheEntry->getTitle(),
				new HtmlArmor( $lastMessage ),
				[ 'class' => 'mw-changeslist-diff' ],
				$this->buildDiffQueryParams( $cacheEntry )
			);
		}

		return $lastLink;
	}

	/**
	 * @param RecentChange $cacheEntry
	 *
	 * @return string
	 */
	private function getUserLink( RecentChange $cacheEntry ) {
		if ( ChangesList::isDeleted( $cacheEntry, RevisionRecord::DELETED_USER ) ) {
			$deletedClass = 'history-deleted';
			if ( ChangesList::isDeleted( $cacheEntry, RevisionRecord::DELETED_RESTRICTED ) ) {
				$deletedClass .= ' mw-history-suppressed';
			}
			$userLink = ' <span class="' . $deletedClass . '">' .
				$this->context->msg( 'rev-deleted-user' )->escaped() . '</span>';
		} else {
			/**
			 * UserLink requires parser to render which when run on thousands of records can add
			 * up to significant amount of processing time.
			 * @see RCCacheEntryFactory::newFromRecentChange
			 */
			$userLink = $this->userLinkCache->getWithSetCallback(
				$this->userLinkCache->makeKey(
					$cacheEntry->mAttribs['rc_user_text'],
					$this->context->getUser()->getName(),
					$this->context->getLanguage()->getCode()
				),
				static fn () => Linker::userLink(
					$cacheEntry->mAttribs['rc_user'],
					$cacheEntry->mAttribs['rc_user_text'],
					ExternalUserNames::getLocal( $cacheEntry->mAttribs['rc_user_text'] )
				)
			);
		}

		return $userLink;
	}

	/**
	 * @param string $key
	 *
	 * @return string
	 */
	private function getMessage( $key ) {
		return $this->messages[$key];
	}

}
PK       ! ņį|!ų  ų    RCCacheEntry.phpnu ÕIw¶“        <?php
/**
 * 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 RecentChanges
 */
class RCCacheEntry extends RecentChange {
	/** @var string|null */
	public $curlink;
	/** @var string|null */
	public $difflink;
	/** @var string|null */
	public $lastlink;
	/** @var string|null */
	public $link;
	/** @var string|null */
	public $timestamp;
	/** @var bool|null */
	public $unpatrolled;
	/** @var string|null */
	public $userlink;
	/** @var string|null */
	public $usertalklink;
	/** @var bool|null */
	public $watched;
	/** @var string|null */
	public $watchlistExpiry;

	/**
	 * @param RecentChange $rc
	 * @return RCCacheEntry
	 */
	public static function newFromParent( $rc ) {
		$rc2 = new RCCacheEntry;
		$rc2->mAttribs = $rc->mAttribs;
		$rc2->mExtra = $rc->mExtra;

		return $rc2;
	}
}
PK       ! \¹ÉÉóc  óc    EnhancedChangesList.phpnu ÕIw¶“        <?php
/**
 * 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
 */

use MediaWiki\Context\IContextSource;
use MediaWiki\Html\Html;
use MediaWiki\Html\TemplateParser;
use MediaWiki\MainConfigNames;
use MediaWiki\Parser\Sanitizer;
use MediaWiki\Revision\RevisionRecord;
use MediaWiki\SpecialPage\SpecialPage;
use MediaWiki\Title\Title;

/**
 * Generate a list of changes using an Enhanced system (uses javascript).
 *
 * @ingroup RecentChanges
 */
class EnhancedChangesList extends ChangesList {

	/**
	 * @var RCCacheEntryFactory
	 */
	protected $cacheEntryFactory;

	/**
	 * @var RCCacheEntry[][]
	 */
	protected $rc_cache;

	/**
	 * @var TemplateParser
	 */
	protected $templateParser;

	/**
	 * @param IContextSource $context
	 * @param ChangesListFilterGroup[] $filterGroups Array of ChangesListFilterGroup objects (currently optional)
	 */
	public function __construct( $context, array $filterGroups = [] ) {
		parent::__construct( $context, $filterGroups );

		// message is set by the parent ChangesList class
		$this->cacheEntryFactory = new RCCacheEntryFactory(
			$context,
			$this->message,
			$this->linkRenderer
		);
		$this->templateParser = new TemplateParser();
	}

	/**
	 * Add the JavaScript file for enhanced changeslist
	 * @return string
	 */
	public function beginRecentChangesList() {
		$this->getOutput()->addModuleStyles( [
			'mediawiki.special.changeslist.enhanced',
		] );

		parent::beginRecentChangesList();
		return '<div class="mw-changeslist" aria-live="polite">';
	}

	/**
	 * Format a line for enhanced recentchange (aka with javascript and block of lines).
	 *
	 * @param RecentChange &$rc
	 * @param bool $watched
	 * @param int|null $linenumber (default null)
	 *
	 * @return string
	 */
	public function recentChangesLine( &$rc, $watched = false, $linenumber = null ) {
		$date = $this->getLanguage()->userDate(
			$rc->mAttribs['rc_timestamp'],
			$this->getUser()
		);
		if ( $this->lastdate === '' ) {
			$this->lastdate = $date;
		}

		$ret = '';

		# If it's a new day, flush the cache and update $this->lastdate
		if ( $date !== $this->lastdate ) {
			# Process current cache (uses $this->lastdate to generate a heading)
			$ret = $this->recentChangesBlock();
			$this->rc_cache = [];
			$this->lastdate = $date;
		}

		$cacheEntry = $this->cacheEntryFactory->newFromRecentChange( $rc, $watched );
		$this->addCacheEntry( $cacheEntry );

		return $ret;
	}

	/**
	 * Put accumulated information into the cache, for later display.
	 * Page moves go on their own line.
	 *
	 * @param RCCacheEntry $cacheEntry
	 */
	protected function addCacheEntry( RCCacheEntry $cacheEntry ) {
		$cacheGroupingKey = $this->makeCacheGroupingKey( $cacheEntry );
		$this->rc_cache[$cacheGroupingKey][] = $cacheEntry;
	}

	/**
	 * @todo use rc_source to group, if set; fallback to rc_type
	 *
	 * @param RCCacheEntry $cacheEntry
	 *
	 * @return string
	 */
	protected function makeCacheGroupingKey( RCCacheEntry $cacheEntry ) {
		$title = $cacheEntry->getTitle();
		$cacheGroupingKey = $title->getPrefixedDBkey();

		$type = $cacheEntry->mAttribs['rc_type'];

		if ( $type == RC_LOG ) {
			// Group by log type
			$cacheGroupingKey = SpecialPage::getTitleFor(
				'Log',
				$cacheEntry->mAttribs['rc_log_type']
			)->getPrefixedDBkey();
		}

		return $cacheGroupingKey;
	}

	/**
	 * Enhanced RC group
	 * @param RCCacheEntry[] $block
	 * @return string
	 */
	protected function recentChangesBlockGroup( $block ) {
		$recentChangesFlags = $this->getConfig()->get( MainConfigNames::RecentChangesFlags );

		# Add the namespace and title of the block as part of the class
		$tableClasses = [ 'mw-enhanced-rc', 'mw-changeslist-line' ];
		if ( $block[0]->mAttribs['rc_log_type'] ) {
			# Log entry
			$tableClasses[] = 'mw-changeslist-log';
			$tableClasses[] = Sanitizer::escapeClass( 'mw-changeslist-log-'
				. $block[0]->mAttribs['rc_log_type'] );
		} else {
			$tableClasses[] = 'mw-changeslist-edit';
			$tableClasses[] = Sanitizer::escapeClass( 'mw-changeslist-ns'
				. $block[0]->mAttribs['rc_namespace'] . '-' . $block[0]->mAttribs['rc_title'] );
		}
		if ( $block[0]->watched ) {
			$tableClasses[] = 'mw-changeslist-line-watched';
		} else {
			$tableClasses[] = 'mw-changeslist-line-not-watched';
		}

		# Collate list of users
		$usercounts = [];
		$userlinks = [];
		# Some catalyst variables...
		$namehidden = true;
		$allLogs = true;
		$RCShowChangedSize = $this->getConfig()->get( MainConfigNames::RCShowChangedSize );

		# Default values for RC flags
		$collectedRcFlags = [];
		foreach ( $recentChangesFlags as $key => $value ) {
			$flagGrouping = $value['grouping'] ?? 'any';
			switch ( $flagGrouping ) {
				case 'all':
					$collectedRcFlags[$key] = true;
					break;
				case 'any':
					$collectedRcFlags[$key] = false;
					break;
				default:
					throw new DomainException( "Unknown grouping type \"{$flagGrouping}\"" );
			}
		}
		foreach ( $block as $rcObj ) {
			// If all log actions to this page were hidden, then don't
			// give the name of the affected page for this block!
			if ( !static::isDeleted( $rcObj, LogPage::DELETED_ACTION ) ) {
				$namehidden = false;
			}
			$username = $rcObj->getPerformerIdentity()->getName();
			$userlink = $rcObj->userlink;
			if ( !isset( $usercounts[$username] ) ) {
				$usercounts[$username] = 0;
				$userlinks[$username] = $userlink;
			}
			if ( $rcObj->mAttribs['rc_type'] != RC_LOG ) {
				$allLogs = false;
			}

			$usercounts[$username]++;
		}

		# Sort the list and convert to text
		krsort( $usercounts );
		asort( $usercounts );
		$users = [];
		foreach ( $usercounts as $username => $count ) {
			$text = (string)$userlinks[$username];
			if ( $count > 1 ) {
				$formattedCount = $this->msg( 'ntimes' )->numParams( $count )->escaped();
				$text .= ' ' . $this->msg( 'parentheses' )->rawParams( $formattedCount )->escaped();
			}
			$users[] = $text;
		}

		# Article link
		$articleLink = '';
		$revDeletedMsg = false;
		if ( $namehidden ) {
			$revDeletedMsg = $this->msg( 'rev-deleted-event' )->escaped();
		} elseif ( $allLogs ) {
			$articleLink = $this->maybeWatchedLink( $block[0]->link, $block[0]->watched );
		} else {
			$articleLink = $this->getArticleLink(
				$block[0], $block[0]->unpatrolled, $block[0]->watched );
		}

		# Sub-entries
		$lines = [];
		$filterClasses = [];
		foreach ( $block as $i => $rcObj ) {
			$line = $this->getLineData( $block, $rcObj );
			if ( !$line ) {
				// completely ignore this RC entry if we don't want to render it
				unset( $block[$i] );
				continue;
			}

			// Roll up flags
			foreach ( $line['recentChangesFlagsRaw'] as $key => $value ) {
				$flagGrouping = ( $recentChangesFlags[$key]['grouping'] ?? 'any' );
				switch ( $flagGrouping ) {
					case 'all':
						if ( !$value ) {
							$collectedRcFlags[$key] = false;
						}
						break;
					case 'any':
						if ( $value ) {
							$collectedRcFlags[$key] = true;
						}
						break;
					default:
						throw new DomainException( "Unknown grouping type \"{$flagGrouping}\"" );
				}
			}

			// Roll up filter-based CSS classes
			$filterClasses = array_merge( $filterClasses, $this->getHTMLClassesForFilters( $rcObj ) );
			// Add classes for change tags separately, getHTMLClassesForFilters() doesn't add them
			$this->getTags( $rcObj, $filterClasses );
			$filterClasses = array_unique( $filterClasses );

			$lines[] = $line;
		}

		// Further down are some assumptions that $block is a 0-indexed array
		// with (count-1) as last key. Let's make sure it is.
		$block = array_values( $block );
		$filterClasses = array_values( $filterClasses );

		if ( !$block || !$lines ) {
			// if we can't show anything, don't display this block altogether
			return '';
		}

		$logText = $this->getLogText( $block, [], $allLogs,
			$collectedRcFlags['newpage'], $namehidden
		);

		# Character difference (does not apply if only log items)
		$charDifference = false;
		if ( $RCShowChangedSize && !$allLogs ) {
			$last = 0;
			$first = count( $block ) - 1;
			# Some events (like logs and category changes) have an "empty" size, so we need to skip those...
			while ( $last < $first && $block[$last]->mAttribs['rc_new_len'] === null ) {
				$last++;
			}
			while ( $last < $first && $block[$first]->mAttribs['rc_old_len'] === null ) {
				$first--;
			}
			# Get net change
			$charDifference = $this->formatCharacterDifference( $block[$first], $block[$last] ) ?: false;
		}

		$numberofWatchingusers = $this->numberofWatchingusers( $block[0]->numberofWatchingusers );
		$usersList = $this->msg( 'brackets' )->rawParams(
			implode( $this->message['semicolon-separator'], $users )
		)->escaped();

		$prefix = '';
		if ( is_callable( $this->changeLinePrefixer ) ) {
			$prefix = call_user_func( $this->changeLinePrefixer, $block[0], $this, true );
		}

		$templateParams = [
			'checkboxId' => 'mw-checkbox-' . base64_encode( random_bytes( 3 ) ),
			'articleLink' => $articleLink,
			'charDifference' => $charDifference,
			'collectedRcFlags' => $this->recentChangesFlags( $collectedRcFlags ),
			'filterClasses' => $filterClasses,
			'lines' => $lines,
			'logText' => $logText,
			'numberofWatchingusers' => $numberofWatchingusers,
			'prefix' => $prefix,
			'rev-deleted-event' => $revDeletedMsg,
			'tableClasses' => $tableClasses,
			'timestamp' => $block[0]->timestamp,
			'fullTimestamp' => $block[0]->getAttribute( 'rc_timestamp' ),
			'users' => $usersList,
		];

		$this->rcCacheIndex++;

		return $this->templateParser->processTemplate(
			'EnhancedChangesListGroup',
			$templateParams
		);
	}

	/**
	 * @param RCCacheEntry[] $block
	 * @param RCCacheEntry $rcObj
	 * @param array $queryParams
	 * @return array
	 */
	protected function getLineData( array $block, RCCacheEntry $rcObj, array $queryParams = [] ) {
		$RCShowChangedSize = $this->getConfig()->get( MainConfigNames::RCShowChangedSize );

		$type = $rcObj->mAttribs['rc_type'];
		$data = [];
		$lineParams = [ 'targetTitle' => $rcObj->getTitle() ];

		$classes = [ 'mw-enhanced-rc' ];
		if ( $rcObj->watched ) {
			$classes[] = 'mw-enhanced-watched';
		}
		$classes = array_merge( $classes, $this->getHTMLClasses( $rcObj, $rcObj->watched ) );

		$separator = ' <span class="mw-changeslist-separator"></span> ';

		$data['recentChangesFlags'] = [
			'newpage' => $type == RC_NEW,
			'minor' => $rcObj->mAttribs['rc_minor'],
			'unpatrolled' => $rcObj->unpatrolled,
			'bot' => $rcObj->mAttribs['rc_bot'],
		];

		# Log timestamp
		if ( $type == RC_LOG ) {
			$link = htmlspecialchars( $rcObj->timestamp );
			# Revision link
		} elseif ( !ChangesList::userCan( $rcObj, RevisionRecord::DELETED_TEXT, $this->getAuthority() ) ) {
			$link = Html::element( 'span', [ 'class' => 'history-deleted' ], $rcObj->timestamp );
		} else {
			$params = [];
			$params['curid'] = $rcObj->mAttribs['rc_cur_id'];
			if ( $rcObj->mAttribs['rc_this_oldid'] != 0 ) {
				$params['oldid'] = $rcObj->mAttribs['rc_this_oldid'];
			}
			// FIXME: The link has incorrect "title=" when rc_type = RC_CATEGORIZE.
			// rc_cur_id refers to the page that was categorized
			// whereas RecentChange::getTitle refers to the category.
			$link = $this->linkRenderer->makeKnownLink(
				$rcObj->getTitle(),
				$rcObj->timestamp,
				[],
				$params + $queryParams
			);
			if ( static::isDeleted( $rcObj, RevisionRecord::DELETED_TEXT ) ) {
				$link = '<span class="history-deleted">' . $link . '</span> ';
			}
		}
		$data['timestampLink'] = $link;

		$currentAndLastLinks = '';
		if ( $type == RC_EDIT || $type == RC_NEW ) {
			$currentAndLastLinks .= ' ' . $this->msg( 'parentheses' )->rawParams(
					$rcObj->curlink .
					$this->message['pipe-separator'] .
					$rcObj->lastlink
				)->escaped();
		}
		$data['currentAndLastLinks'] = $currentAndLastLinks;
		$data['separatorAfterCurrentAndLastLinks'] = $separator;

		# Character diff
		if ( $RCShowChangedSize ) {
			$cd = $this->formatCharacterDifference( $rcObj );
			if ( $cd !== '' ) {
				$data['characterDiff'] = $cd;
				$data['separatorAfterCharacterDiff'] = $separator;
			}
		}

		if ( $type == RC_LOG ) {
			$data['logEntry'] = $this->insertLogEntry( $rcObj );
		} elseif ( $this->isCategorizationWithoutRevision( $rcObj ) ) {
			$data['comment'] = $this->insertComment( $rcObj );
		} else {
			# User links
			$data['userLink'] = $rcObj->userlink;
			$data['userTalkLink'] = $rcObj->usertalklink;
			$data['comment'] = $this->insertComment( $rcObj );
			if ( $type == RC_CATEGORIZE ) {
				$data['historyLink'] = $this->getDiffHistLinks( $rcObj, false );
			}
			# Rollback, thanks etc...
			$data['rollback'] = $this->getRollback( $rcObj );
		}

		# Tags
		$data['tags'] = $this->getTags( $rcObj, $classes );

		$attribs = $this->getDataAttributes( $rcObj );

		// give the hook a chance to modify the data
		$success = $this->getHookRunner()->onEnhancedChangesListModifyLineData(
			$this, $data, $block, $rcObj, $classes, $attribs );
		if ( !$success ) {
			// skip entry if hook aborted it
			return [];
		}
		$attribs = array_filter( $attribs,
			[ Sanitizer::class, 'isReservedDataAttribute' ],
			ARRAY_FILTER_USE_KEY
		);

		$lineParams['recentChangesFlagsRaw'] = [];
		if ( isset( $data['recentChangesFlags'] ) ) {
			$lineParams['recentChangesFlags'] = $this->recentChangesFlags( $data['recentChangesFlags'] );
			# FIXME: This is used by logic, don't return it in the template params.
			$lineParams['recentChangesFlagsRaw'] = $data['recentChangesFlags'];
			unset( $data['recentChangesFlags'] );
		}

		if ( isset( $data['timestampLink'] ) ) {
			$lineParams['timestampLink'] = $data['timestampLink'];
			unset( $data['timestampLink'] );
		}

		$lineParams['classes'] = array_values( $classes );
		$lineParams['attribs'] = Html::expandAttributes( $attribs );

		// everything else: makes it easier for extensions to add or remove data
		$lineParams['data'] = array_values( $data );

		return $lineParams;
	}

	/**
	 * Generates amount of changes (linking to diff ) & link to history.
	 *
	 * @param RCCacheEntry[] $block
	 * @param array $queryParams
	 * @param bool $allLogs
	 * @param bool $isnew
	 * @param bool $namehidden
	 * @return string
	 */
	protected function getLogText( $block, $queryParams, $allLogs, $isnew, $namehidden ) {
		if ( !$block ) {
			return '';
		}

		// Changes message
		static $nchanges = [];
		static $sinceLastVisitMsg = [];

		$n = count( $block );
		if ( !isset( $nchanges[$n] ) ) {
			$nchanges[$n] = $this->msg( 'nchanges' )->numParams( $n )->escaped();
		}

		$sinceLast = 0;
		$unvisitedOldid = null;
		$currentRevision = 0;
		$previousRevision = 0;
		$curId = 0;
		$allCategorization = true;
		/** @var RCCacheEntry $rcObj */
		foreach ( $block as $rcObj ) {
			// Fields of categorization entries refer to the changed page
			// rather than the category for which we are building the log text.
			if ( $rcObj->mAttribs['rc_type'] == RC_CATEGORIZE ) {
				continue;
			}

			$allCategorization = false;
			$previousRevision = $rcObj->mAttribs['rc_last_oldid'];
			// Same logic as below inside main foreach
			if ( $rcObj->watched ) {
				$sinceLast++;
				$unvisitedOldid = $previousRevision;
			}
			if ( !$currentRevision ) {
				$currentRevision = $rcObj->mAttribs['rc_this_oldid'];
			}
			if ( !$curId ) {
				$curId = $rcObj->mAttribs['rc_cur_id'];
			}
		}

		// Total change link
		$links = [];
		$title = $block[0]->getTitle();
		if ( !$allLogs ) {
			// TODO: Disable the link if the user cannot see it (rc_deleted).
			// Beware of possibly interspersed categorization entries.
			if ( $isnew || $allCategorization ) {
				$links['total-changes'] = Html::rawElement( 'span', [], $nchanges[$n] );
			} else {
				$links['total-changes'] = Html::rawElement( 'span', [],
					$this->linkRenderer->makeKnownLink(
						$title,
						new HtmlArmor( $nchanges[$n] ),
						[ 'class' => 'mw-changeslist-groupdiff' ],
						$queryParams + [
							'curid' => $curId,
							'diff' => $currentRevision,
							'oldid' => $previousRevision,
						]
					)
				);
			}

			if (
				!$allCategorization &&
				$sinceLast > 0 &&
				$sinceLast < $n
			) {
				if ( !isset( $sinceLastVisitMsg[$sinceLast] ) ) {
					$sinceLastVisitMsg[$sinceLast] =
						$this->msg( 'enhancedrc-since-last-visit' )->numParams( $sinceLast )->escaped();
				}
				$links['total-changes-since-last'] = Html::rawElement( 'span', [],
					$this->linkRenderer->makeKnownLink(
						$title,
						new HtmlArmor( $sinceLastVisitMsg[$sinceLast] ),
						[ 'class' => 'mw-changeslist-groupdiff' ],
						$queryParams + [
							'curid' => $curId,
							'diff' => $currentRevision,
							'oldid' => $unvisitedOldid,
						]
					)
				);
			}
		}

		// History
		if ( $allLogs || $allCategorization ) {
			// don't show history link for logs
		} elseif ( $namehidden || !$title->exists() ) {
			$links['history'] = Html::rawElement( 'span', [], $this->message['enhancedrc-history'] );
		} else {
			$links['history'] = Html::rawElement( 'span', [],
				$this->linkRenderer->makeKnownLink(
					$title,
					new HtmlArmor( $this->message['enhancedrc-history'] ),
					[ 'class' => 'mw-changeslist-history' ],
					[
						'curid' => $curId,
						'action' => 'history',
					] + $queryParams
				)
			);
		}

		// Allow others to alter, remove or add to these links
		$this->getHookRunner()->onEnhancedChangesList__getLogText( $this, $links, $block );

		if ( !$links ) {
			return '';
		}

		$logtext = Html::rawElement( 'span', [ 'class' => 'mw-changeslist-links' ],
			implode( ' ', $links ) );
		return ' ' . $logtext;
	}

	/**
	 * Enhanced RC ungrouped line.
	 *
	 * @param RecentChange|RCCacheEntry $rcObj
	 * @return string A HTML formatted line (generated using $r)
	 */
	protected function recentChangesBlockLine( $rcObj ) {
		$data = [];

		$type = $rcObj->mAttribs['rc_type'];
		$logType = $rcObj->mAttribs['rc_log_type'];
		$classes = $this->getHTMLClasses( $rcObj, $rcObj->watched );
		$classes[] = 'mw-enhanced-rc';

		if ( $logType ) {
			# Log entry
			$classes[] = 'mw-changeslist-log';
			$classes[] = Sanitizer::escapeClass( 'mw-changeslist-log-' . $logType );
		} else {
			$classes[] = 'mw-changeslist-edit';
			$classes[] = Sanitizer::escapeClass( 'mw-changeslist-ns' .
				$rcObj->mAttribs['rc_namespace'] . '-' . $rcObj->mAttribs['rc_title'] );
		}

		# Flag and Timestamp
		$data['recentChangesFlags'] = [
			'newpage' => $type == RC_NEW,
			'minor' => $rcObj->mAttribs['rc_minor'],
			'unpatrolled' => $rcObj->unpatrolled,
			'bot' => $rcObj->mAttribs['rc_bot'],
		];
		// timestamp is not really a link here, but is called timestampLink
		// for consistency with EnhancedChangesListModifyLineData
		$data['timestampLink'] = htmlspecialchars( $rcObj->timestamp );

		# Article or log link
		if ( $logType ) {
			$logPage = new LogPage( $logType );
			$logTitle = SpecialPage::getTitleFor( 'Log', $logType );
			$logName = $logPage->getName()->text();
			$data['logLink'] = Html::rawElement( 'span', [ 'class' => 'mw-changeslist-links' ],
				$this->linkRenderer->makeKnownLink( $logTitle, $logName )
			);
		} else {
			$data['articleLink'] = $this->getArticleLink( $rcObj, $rcObj->unpatrolled, $rcObj->watched );
		}

		# Diff and hist links
		if ( $type != RC_LOG && $type != RC_CATEGORIZE ) {
			$data['historyLink'] = $this->getDiffHistLinks( $rcObj, false );
		}
		$data['separatorAfterLinks'] = ' <span class="mw-changeslist-separator"></span> ';

		# Character diff
		if ( $this->getConfig()->get( MainConfigNames::RCShowChangedSize ) ) {
			$cd = $this->formatCharacterDifference( $rcObj );
			if ( $cd !== '' ) {
				$data['characterDiff'] = $cd;
				$data['separatorAftercharacterDiff'] = ' <span class="mw-changeslist-separator"></span> ';
			}
		}

		if ( $type == RC_LOG ) {
			$data['logEntry'] = $this->insertLogEntry( $rcObj );
		} elseif ( $this->isCategorizationWithoutRevision( $rcObj ) ) {
			$data['comment'] = $this->insertComment( $rcObj );
		} else {
			$data['userLink'] = $rcObj->userlink;
			$data['userTalkLink'] = $rcObj->usertalklink;
			$data['comment'] = $this->insertComment( $rcObj );
			if ( $type == RC_CATEGORIZE ) {
				$data['historyLink'] = $this->getDiffHistLinks( $rcObj, false );
			}
			$data['rollback'] = $this->getRollback( $rcObj );
		}

		# Tags
		$data['tags'] = $this->getTags( $rcObj, $classes );

		# Show how many people are watching this if enabled
		$data['watchingUsers'] = $this->numberofWatchingusers( $rcObj->numberofWatchingusers );

		$data['attribs'] = array_merge( $this->getDataAttributes( $rcObj ), [ 'class' => $classes ] );

		// give the hook a chance to modify the data
		$success = $this->getHookRunner()->onEnhancedChangesListModifyBlockLineData(
			$this, $data, $rcObj );
		if ( !$success ) {
			// skip entry if hook aborted it
			return '';
		}
		$attribs = $data['attribs'];
		unset( $data['attribs'] );
		$attribs = array_filter( $attribs, static function ( $key ) {
			return $key === 'class' || Sanitizer::isReservedDataAttribute( $key );
		}, ARRAY_FILTER_USE_KEY );

		$prefix = '';
		if ( is_callable( $this->changeLinePrefixer ) ) {
			$prefix = call_user_func( $this->changeLinePrefixer, $rcObj, $this, false );
		}

		$line = Html::openElement( 'table', $attribs ) . Html::openElement( 'tr' );
		// Highlight block
		$line .= Html::rawElement( 'td', [],
			$this->getHighlightsContainerDiv()
		);

		$line .= Html::rawElement( 'td', [], '<span class="mw-enhancedchanges-arrow-space"></span>' );
		$line .= Html::rawElement( 'td', [ 'class' => 'mw-changeslist-line-prefix' ], $prefix );
		$line .= '<td class="mw-enhanced-rc" colspan="2">';

		if ( isset( $data['recentChangesFlags'] ) ) {
			$line .= $this->recentChangesFlags( $data['recentChangesFlags'] );
			unset( $data['recentChangesFlags'] );
		}

		if ( isset( $data['timestampLink'] ) ) {
			$line .= "\u{00A0}" . $data['timestampLink'];
			unset( $data['timestampLink'] );
		}
		$line .= "\u{00A0}</td>";
		$line .= Html::openElement( 'td', [
			'class' => 'mw-changeslist-line-inner',
			// Used for reliable determination of the affiliated page
			'data-target-page' => $rcObj->getTitle(),
		] );

		// everything else: makes it easier for extensions to add or remove data
		foreach ( $data as $key => $dataItem ) {
			$line .= Html::rawElement( 'span', [
				'class' => 'mw-changeslist-line-inner-' . $key,
			], $dataItem );
		}

		$line .= "</td></tr></table>\n";

		return $line;
	}

	/**
	 * Returns value to be used in 'historyLink' element of $data param in
	 * EnhancedChangesListModifyBlockLineData hook.
	 *
	 * @since 1.27
	 *
	 * @param RCCacheEntry $rc
	 * @param bool|array|null $query deprecated
	 * @param bool|null $useParentheses (optional) Wrap comments in parentheses where needed
	 * @return string HTML
	 */
	public function getDiffHistLinks( RCCacheEntry $rc, $query = null, $useParentheses = null ) {
		if ( is_bool( $query ) ) {
			$useParentheses = $query;
		} elseif ( $query !== null ) {
			wfDeprecated( __METHOD__ . ' with $query parameter', '1.36' );
		}
		$pageTitle = $rc->getTitle();
		if ( $rc->getAttribute( 'rc_type' ) == RC_CATEGORIZE ) {
			// For categorizations we must swap the category title with the page title!
			$pageTitle = Title::newFromID( $rc->getAttribute( 'rc_cur_id' ) );
			if ( !$pageTitle ) {
				// The page has been deleted, but the RC entry
				// deletion job has not run yet. Just skip.
				return '';
			}
		}

		$histLink = $this->linkRenderer->makeKnownLink(
			$pageTitle,
			new HtmlArmor( $this->message['hist'] ),
			[ 'class' => 'mw-changeslist-history' ],
			[
				'curid' => $rc->getAttribute( 'rc_cur_id' ),
				'action' => 'history'
			]
		);
		if ( $useParentheses !== false ) {
			$retVal = $this->msg( 'parentheses' )
			->rawParams( $rc->difflink . $this->message['pipe-separator']
				. $histLink )->escaped();
		} else {
			$retVal = Html::rawElement( 'span', [ 'class' => 'mw-changeslist-links' ],
				Html::rawElement( 'span', [], $rc->difflink ) .
				Html::rawElement( 'span', [], $histLink )
			);
		}
		return ' ' . $retVal;
	}

	/**
	 * If enhanced RC is in use, this function takes the previously cached
	 * RC lines, arranges them, and outputs the HTML
	 *
	 * @return string
	 */
	protected function recentChangesBlock() {
		if ( count( $this->rc_cache ) == 0 ) {
			return '';
		}

		$blockOut = '';
		foreach ( $this->rc_cache as $block ) {
			if ( count( $block ) < 2 ) {
				$blockOut .= $this->recentChangesBlockLine( array_shift( $block ) );
			} else {
				$blockOut .= $this->recentChangesBlockGroup( $block );
			}
		}

		if ( $blockOut === '' ) {
			return '';
		}
		// $this->lastdate is kept up to date by recentChangesLine()
		return Html::element( 'h4', [], $this->lastdate ) . "\n<div>" . $blockOut . '</div>';
	}

	/**
	 * Returns text for the end of RC
	 * If enhanced RC is in use, returns pretty much all the text
	 * @return string
	 */
	public function endRecentChangesList() {
		return $this->recentChangesBlock() . '</div>';
	}
}
PK       ! 	2č  č  '  ChangesListStringOptionsFilterGroup.phpnu ÕIw¶“        <?php
/**
 * 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
 */

use MediaWiki\Html\FormOptions;
use MediaWiki\SpecialPage\ChangesListSpecialPage;
use Wikimedia\Rdbms\IReadableDatabase;

/**
 * Represents a filter group with multiple string options. They are passed to the server as
 * a single form parameter separated by a delimiter.  The parameter name is the
 * group name.  E.g. groupname=opt1;opt2 .
 *
 * If all options are selected they are replaced by the term "all".
 *
 * There is also a single DB query modification for the whole group.
 *
 * @since 1.29
 * @ingroup RecentChanges
 * @author Matthew Flaschen
 */
class ChangesListStringOptionsFilterGroup extends ChangesListFilterGroup {
	/**
	 * Type marker, used by JavaScript
	 */
	public const TYPE = 'string_options';

	/**
	 * Delimiter
	 */
	public const SEPARATOR = ';';

	/**
	 * Signifies that all options in the group are selected.
	 */
	public const ALL = 'all';

	/**
	 * Signifies that no options in the group are selected, meaning the group has no effect.
	 *
	 * For full-coverage groups, this is the same as ALL if all filters are allowed.
	 * For others, it is not.
	 */
	public const NONE = '';

	/**
	 * Default parameter value
	 *
	 * @var string
	 */
	protected $defaultValue;

	/**
	 * Callable used to do the actual query modification; see constructor
	 *
	 * @var callable
	 */
	protected $queryCallable;

	/**
	 * Create a new filter group with the specified configuration
	 *
	 * @param array $groupDefinition Configuration of group
	 * * $groupDefinition['name'] string Group name
	 * * $groupDefinition['title'] string i18n key for title (optional, can be omitted
	 *     only if none of the filters in the group display in the structured UI)
	 * * $groupDefinition['priority'] int Priority integer.  Higher means higher in the
	 *     group list.
	 * * $groupDefinition['filters'] array Numeric array of filter definitions, each of which
	 *     is an associative array to be passed to the filter constructor.  However,
	 *     'priority' is optional for the filters.  Any filter that has priority unset
	 *     will be put to the bottom, in the order given.
	 * * $groupDefinition['default'] string Default for group.
	 * * $groupDefinition['isFullCoverage'] bool Whether the group is full coverage;
	 *     if true, this means that checking every item in the group means no
	 *     changes list entries are filtered out.
	 * * $groupDefinition['queryCallable'] callable Callable accepting parameters:
	 * 	* string $specialPageClassName Class name of current special page
	 * 	* IContextSource $context Context, for e.g. user
	 * 	* IDatabase $dbr Database, for addQuotes, makeList, and similar
	 * 	* array &$tables Array of tables; see IDatabase::select $table
	 * 	* array &$fields Array of fields; see IDatabase::select $vars
	 * 	* array &$conds Array of conditions; see IDatabase::select $conds
	 * 	* array &$query_options Array of query options; see IDatabase::select $options
	 * 	* array &$join_conds Array of join conditions; see IDatabase::select $join_conds
	 * 	* array $selectedValues The allowed and requested values, lower-cased and sorted
	 * * $groupDefinition['whatsThisHeader'] string i18n key for header of "What's
	 *     This" popup (optional).
	 * * $groupDefinition['whatsThisBody'] string i18n key for body of "What's This"
	 *     popup (optional).
	 * * $groupDefinition['whatsThisUrl'] string URL for main link of "What's This"
	 *     popup (optional).
	 * * $groupDefinition['whatsThisLinkText'] string i18n key of text for main link of
	 *     "What's This" popup (optional).
	 */
	public function __construct( array $groupDefinition ) {
		if ( !isset( $groupDefinition['isFullCoverage'] ) ) {
			throw new InvalidArgumentException( 'You must specify isFullCoverage' );
		}

		$groupDefinition['type'] = self::TYPE;

		parent::__construct( $groupDefinition );

		$this->queryCallable = $groupDefinition['queryCallable'];

		if ( isset( $groupDefinition['default'] ) ) {
			$this->setDefault( $groupDefinition['default'] );
		} else {
			throw new InvalidArgumentException( 'You must specify a default' );
		}
	}

	/**
	 * Sets default of filter group.
	 *
	 * @param string $defaultValue
	 */
	public function setDefault( $defaultValue ) {
		$this->defaultValue = $defaultValue;
	}

	/**
	 * Gets default of filter group
	 *
	 * @return string
	 */
	public function getDefault() {
		return $this->defaultValue;
	}

	/**
	 * @inheritDoc
	 */
	protected function createFilter( array $filterDefinition ) {
		return new ChangesListStringOptionsFilter( $filterDefinition );
	}

	/**
	 * Registers a filter in this group
	 *
	 * @param ChangesListStringOptionsFilter $filter
	 * @suppress PhanParamSignaturePHPDocMismatchHasParamType,PhanParamSignatureMismatch
	 */
	public function registerFilter( ChangesListStringOptionsFilter $filter ) {
		$this->filters[$filter->getName()] = $filter;
	}

	/**
	 * @inheritDoc
	 */
	public function modifyQuery( IReadableDatabase $dbr, ChangesListSpecialPage $specialPage,
		&$tables, &$fields, &$conds, &$query_options, &$join_conds,
		FormOptions $opts, $isStructuredFiltersEnabled
	) {
		// STRING_OPTIONS filter groups are exclusively active on Structured UI
		if ( !$isStructuredFiltersEnabled ) {
			return;
		}

		$value = $opts[ $this->getName() ];
		$allowedFilterNames = [];
		foreach ( $this->filters as $filter ) {
			$allowedFilterNames[] = $filter->getName();
		}

		if ( $value === self::ALL ) {
			$selectedValues = $allowedFilterNames;
		} else {
			$selectedValues = explode( self::SEPARATOR, strtolower( $value ) );

			// remove values that are not recognized or not currently allowed
			$selectedValues = array_intersect(
				$selectedValues,
				$allowedFilterNames
			);
		}

		// If there are now no values, because all are disallowed or invalid (also,
		// the user may not have selected any), this is a no-op.

		// If everything is unchecked, the group always has no effect, regardless
		// of full-coverage.
		if ( count( $selectedValues ) === 0 ) {
			return;
		}

		sort( $selectedValues );

		( $this->queryCallable )(
			get_class( $specialPage ),
			$specialPage->getContext(),
			$dbr,
			$tables,
			$fields,
			$conds,
			$query_options,
			$join_conds,
			$selectedValues
		);
	}

	/**
	 * @inheritDoc
	 */
	public function getJsData() {
		$output = parent::getJsData();

		$output['separator'] = self::SEPARATOR;
		$output['default'] = $this->getDefault();

		return $output;
	}

	/**
	 * @inheritDoc
	 */
	public function addOptions( FormOptions $opts, $allowDefaults, $isStructuredFiltersEnabled ) {
		$opts->add( $this->getName(), $allowDefaults ? $this->getDefault() : '' );
	}
}
PK       ! »ĪSw7  7    OldChangesList.phpnu ÕIw¶“        <?php
/**
 * 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
 */

use MediaWiki\Html\Html;
use MediaWiki\MainConfigNames;
use MediaWiki\MediaWikiServices;
use MediaWiki\Parser\Sanitizer;
use MediaWiki\SpecialPage\SpecialPage;

/**
 * Generate a list of changes using the good old system (no javascript).
 *
 * @ingroup RecentChanges
 */
class OldChangesList extends ChangesList {

	/**
	 * Format a line using the old system (aka without any javascript).
	 *
	 * @param RecentChange &$rc Passed by reference
	 * @param bool $watched (default false)
	 * @param int|null $linenumber (default null)
	 *
	 * @return string|bool
	 * @return-taint none
	 */
	public function recentChangesLine( &$rc, $watched = false, $linenumber = null ) {
		$classes = $this->getHTMLClasses( $rc, $watched );
		// use mw-line-even/mw-line-odd class only if linenumber is given (feature from T16468)
		if ( $linenumber ) {
			if ( $linenumber & 1 ) {
				$classes[] = 'mw-line-odd';
			} else {
				$classes[] = 'mw-line-even';
			}
		}

		$html = $this->formatChangeLine( $rc, $classes, $watched );

		if ( $this->watchlist ) {
			$classes[] = Sanitizer::escapeClass( 'watchlist-' .
				$rc->mAttribs['rc_namespace'] . '-' . $rc->mAttribs['rc_title'] );
		}

		$attribs = $this->getDataAttributes( $rc );

		if ( !$this->getHookRunner()->onOldChangesListRecentChangesLine(
			$this, $html, $rc, $classes, $attribs )
		) {
			return false;
		}
		$attribs = array_filter( $attribs,
			[ Sanitizer::class, 'isReservedDataAttribute' ],
			ARRAY_FILTER_USE_KEY
		);

		$dateheader = ''; // $html now contains only <li>...</li>, for hooks' convenience.
		$this->insertDateHeader( $dateheader, $rc->mAttribs['rc_timestamp'] );

		$html = $this->getHighlightsContainerDiv() . $html;
		$attribs['class'] = $classes;

		return $dateheader . Html::rawElement( 'li', $attribs, $html ) . "\n";
	}

	/**
	 * @param RecentChange $rc
	 * @param string[] &$classes
	 * @param bool $watched
	 *
	 * @return string
	 */
	private function formatChangeLine( RecentChange $rc, array &$classes, $watched ) {
		$html = '';
		$unpatrolled = $this->showAsUnpatrolled( $rc );

		if ( $rc->mAttribs['rc_log_type'] ) {
			$logtitle = SpecialPage::getTitleFor( 'Log', $rc->mAttribs['rc_log_type'] );
			$this->insertLog( $html, $logtitle, $rc->mAttribs['rc_log_type'], false );
			$flags = $this->recentChangesFlags( [ 'unpatrolled' => $unpatrolled,
				'bot' => $rc->mAttribs['rc_bot'] ], '' );
			if ( $flags !== '' ) {
				$html .= ' ' . $flags;
			}
		// Log entries (old format) or log targets, and special pages
		} elseif ( $rc->mAttribs['rc_namespace'] == NS_SPECIAL ) {
			[ $name, $htmlubpage ] = MediaWikiServices::getInstance()->getSpecialPageFactory()->
				resolveAlias( $rc->mAttribs['rc_title'] );
			if ( $name == 'Log' ) {
				$this->insertLog( $html, $rc->getTitle(), $htmlubpage, false );
			}
		// Regular entries
		} else {
			$this->insertDiffHist( $html, $rc );
			# M, N, b and ! (minor, new, bot and unpatrolled)
			$html .= $this->recentChangesFlags(
				[
					'newpage' => $rc->mAttribs['rc_type'] == RC_NEW,
					'minor' => $rc->mAttribs['rc_minor'],
					'unpatrolled' => $unpatrolled,
					'bot' => $rc->mAttribs['rc_bot']
				],
				''
			);
			$html .= $this->getArticleLink( $rc, $unpatrolled, $watched );
		}
		# Edit/log timestamp
		$this->insertTimestamp( $html, $rc );
		# Bytes added or removed
		if ( $this->getConfig()->get( MainConfigNames::RCShowChangedSize ) ) {
			$cd = $this->formatCharacterDifference( $rc );
			if ( $cd !== '' ) {
				$html .= $cd . '  <span class="mw-changeslist-separator"></span> ';
			}
		}

		if ( $rc->mAttribs['rc_type'] == RC_LOG ) {
			$html .= $this->insertLogEntry( $rc );
		} elseif ( $this->isCategorizationWithoutRevision( $rc ) ) {
			$html .= $this->insertComment( $rc );
		} else {
			# User tool links
			$this->insertUserRelatedLinks( $html, $rc );
			$html .= $this->insertComment( $rc );
		}

		# Tags
		$this->insertTags( $html, $rc, $classes );
		# Rollback
		$this->insertRollback( $html, $rc );
		# For subclasses
		$this->insertExtra( $html, $rc, $classes );

		# How many users watch this page
		if ( $rc->numberofWatchingusers > 0 ) {
			$html .= ' ' . $this->numberofWatchingusers( $rc->numberofWatchingusers );
		}

		$html = Html::rawElement( 'span', [
			'class' => 'mw-changeslist-line-inner',
			'data-target-page' => $rc->getTitle(), // Used for reliable determination of the affiliated page
		], $html );
		if ( is_callable( $this->changeLinePrefixer ) ) {
			$prefix = call_user_func( $this->changeLinePrefixer, $rc, $this, false );
			$html = Html::rawElement( 'span', [ 'class' => 'mw-changeslist-line-prefix' ], $prefix ) . $html;
		}

		return $html;
	}
}
PK       ! 9B    !  ChangesListBooleanFilterGroup.phpnu ÕIw¶“        <?php

use MediaWiki\Html\FormOptions;
use MediaWiki\SpecialPage\ChangesListSpecialPage;
use Wikimedia\Rdbms\IReadableDatabase;

/**
 * If the group is active, any unchecked filters will
 * translate to hide parameters in the URL.  E.g. if 'Human (not bot)' is checked,
 * but 'Bot' is unchecked, hidebots=1 will be sent.
 *
 * @since 1.29
 * @ingroup RecentChanges
 * @method ChangesListBooleanFilter[] getFilters()
 */
class ChangesListBooleanFilterGroup extends ChangesListFilterGroup {
	/**
	 * Type marker, used by JavaScript
	 */
	public const TYPE = 'send_unselected_if_any';

	/**
	 * Create a new filter group with the specified configuration
	 *
	 * @param array $groupDefinition Configuration of group
	 * * $groupDefinition['name'] string Group name
	 * * $groupDefinition['title'] string i18n key for title (optional, can be omitted
	 *     only if none of the filters in the group display in the structured UI)
	 * * $groupDefinition['priority'] int Priority integer.  Higher means higher in the
	 *     group list.
	 * * $groupDefinition['filters'] array Numeric array of filter definitions, each of which
	 *     is an associative array to be passed to the filter constructor.  However,
	 *    'priority' is optional for the filters.  Any filter that has priority unset
	 *     will be put to the bottom, in the order given.
	 * * $groupDefinition['whatsThisHeader'] string i18n key for header of "What's
	 *     This" popup (optional).
	 * * $groupDefinition['whatsThisBody'] string i18n key for body of "What's This"
	 *     popup (optional).
	 * * $groupDefinition['whatsThisUrl'] string URL for main link of "What's This"
	 *     popup (optional).
	 * * $groupDefinition['whatsThisLinkText'] string i18n key of text for main link of
	 *     "What's This" popup (optional).
	 */
	public function __construct( array $groupDefinition ) {
		$groupDefinition['isFullCoverage'] = true;
		$groupDefinition['type'] = self::TYPE;

		parent::__construct( $groupDefinition );
	}

	/**
	 * @inheritDoc
	 */
	protected function createFilter( array $filterDefinition ) {
		return new ChangesListBooleanFilter( $filterDefinition );
	}

	/**
	 * Registers a filter in this group
	 *
	 * @param ChangesListBooleanFilter $filter
	 * @suppress PhanParamSignaturePHPDocMismatchHasParamType,PhanParamSignatureMismatch
	 */
	public function registerFilter( ChangesListBooleanFilter $filter ) {
		$this->filters[$filter->getName()] = $filter;
	}

	/**
	 * @inheritDoc
	 */
	public function modifyQuery( IReadableDatabase $dbr, ChangesListSpecialPage $specialPage,
		&$tables, &$fields, &$conds, &$query_options, &$join_conds,
		FormOptions $opts, $isStructuredFiltersEnabled
	) {
		/** @var ChangesListBooleanFilter $filter */
		foreach ( $this->getFilters() as $filter ) {
			if ( $filter->isActive( $opts, $isStructuredFiltersEnabled ) ) {
				$filter->modifyQuery( $dbr, $specialPage, $tables, $fields, $conds,
					$query_options, $join_conds );
			}
		}
	}

	/**
	 * @inheritDoc
	 */
	public function addOptions( FormOptions $opts, $allowDefaults, $isStructuredFiltersEnabled ) {
		/** @var ChangesListBooleanFilter $filter */
		foreach ( $this->getFilters() as $filter ) {
			$defaultValue = $allowDefaults ? $filter->getDefault( $isStructuredFiltersEnabled ) : false;
			$opts->add( $filter->getName(), $defaultValue );
		}
	}
}
PK       ! Äł»28  28    ChangesListFilter.phpnu ÕIw¶“        <?php
/**
 * 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
 */

use MediaWiki\Context\IContextSource;
use MediaWiki\Html\FormOptions;

/**
 * Represents a filter (used on ChangesListSpecialPage and descendants)
 *
 * @since 1.29
 * @ingroup RecentChanges
 * @author Matthew Flaschen
 */
abstract class ChangesListFilter {
	/**
	 * Filter name
	 *
	 * @var string
	 */
	protected $name;

	/**
	 * CSS class suffix used for attribution, e.g. 'bot'.
	 *
	 * In this example, if bot actions are included in the result set, this CSS class
	 * will then be included in all bot-flagged actions.
	 *
	 * @var string|null
	 */
	protected $cssClassSuffix;

	/**
	 * Callable that returns true if and only if a row is attributed to this filter
	 *
	 * @var callable
	 */
	protected $isRowApplicableCallable;

	/**
	 * Group.  ChangesListFilterGroup this belongs to
	 *
	 * @var ChangesListFilterGroup
	 */
	protected $group;

	/**
	 * i18n key of label for structured UI
	 *
	 * @var string
	 */
	protected $label;

	/**
	 * i18n key of description for structured UI
	 *
	 * @var string
	 */
	protected $description;

	/**
	 * Array of associative arrays with conflict information.  See
	 * setUnidirectionalConflict
	 *
	 * @var array
	 */
	protected $conflictingGroups = [];

	/**
	 * Array of associative arrays with conflict information.  See
	 * setUnidirectionalConflict
	 *
	 * @var array
	 */
	protected $conflictingFilters = [];

	/**
	 * Array of associative arrays with subset information
	 *
	 * @var array
	 */
	protected $subsetFilters = [];

	/**
	 * Priority integer.  Higher value means higher up in the group's filter list.
	 *
	 * @var int
	 */
	protected $priority;

	/**
	 * @var string
	 */
	protected $defaultHighlightColor;

	private const RESERVED_NAME_CHAR = '_';

	/**
	 * Creates a new filter with the specified configuration, and registers it to the
	 * specified group.
	 *
	 * It infers which UI (it can be either or both) to display the filter on based on
	 * which messages are provided.
	 *
	 * If 'label' is provided, it will be displayed on the structured UI.  Thus,
	 * 'label', 'description', and sub-class parameters are optional depending on which
	 * UI it's for.
	 *
	 * @param array $filterDefinition ChangesListFilter definition
	 * * $filterDefinition['name'] string Name of filter; use lowercase with no
	 *     punctuation
	 * * $filterDefinition['cssClassSuffix'] string CSS class suffix, used to mark
	 *     that a particular row belongs to this filter (when a row is included by the
	 *     filter) (optional)
	 * * $filterDefinition['isRowApplicableCallable'] callable Callable taking two parameters, the
	 *     IContextSource, and the RecentChange object for the row, and returning true if
	 *     the row is attributed to this filter.  The above CSS class will then be
	 *     automatically added (optional, required if cssClassSuffix is used).
	 * * $filterDefinition['group'] ChangesListFilterGroup Group.  Filter group this
	 *     belongs to.
	 * * $filterDefinition['label'] string i18n key of label for structured UI.
	 * * $filterDefinition['description'] string i18n key of description for structured
	 *     UI.
	 * * $filterDefinition['priority'] int Priority integer.  Higher value means higher
	 *     up in the group's filter list.
	 * @phpcs:ignore Generic.Files.LineLength
	 * @phan-param array{name:string,cssClassSuffix?:string,isRowApplicableCallable?:callable,group:ChangesListFilterGroup,label:string,description:string,priority:int} $filterDefinition
	 */
	public function __construct( array $filterDefinition ) {
		if ( isset( $filterDefinition['group'] ) ) {
			$this->group = $filterDefinition['group'];
		} else {
			throw new InvalidArgumentException( 'You must use \'group\' to specify the ' .
				'ChangesListFilterGroup this filter belongs to' );
		}

		if ( strpos( $filterDefinition['name'], self::RESERVED_NAME_CHAR ) !== false ) {
			throw new InvalidArgumentException( 'Filter names may not contain \'' .
				self::RESERVED_NAME_CHAR .
				'\'.  Use the naming convention: \'lowercase\''
			);
		}

		if ( $this->group->getFilter( $filterDefinition['name'] ) ) {
			throw new InvalidArgumentException( 'Two filters in a group cannot have the ' .
				"same name: '{$filterDefinition['name']}'" );
		}

		$this->name = $filterDefinition['name'];

		if ( isset( $filterDefinition['cssClassSuffix'] ) ) {
			$this->cssClassSuffix = $filterDefinition['cssClassSuffix'];
			// @phan-suppress-next-line PhanTypePossiblyInvalidDimOffset Documented as required
			$this->isRowApplicableCallable = $filterDefinition['isRowApplicableCallable'];
		}

		if ( isset( $filterDefinition['label'] ) ) {
			$this->label = $filterDefinition['label'];
			$this->description = $filterDefinition['description'];
		}

		$this->priority = $filterDefinition['priority'];

		$this->group->registerFilter( $this );
	}

	/**
	 * Marks that the given ChangesListFilterGroup or ChangesListFilter conflicts with this object.
	 *
	 * WARNING: This means there is a conflict when both things are *shown*
	 * (not filtered out), even for the hide-based filters.  So e.g. conflicting with
	 * 'hideanons' means there is a conflict if only anonymous users are *shown*.
	 *
	 * @param ChangesListFilterGroup|ChangesListFilter $other
	 * @param string $globalKey i18n key for top-level conflict message
	 * @param string $forwardKey i18n key for conflict message in this
	 *  direction (when in UI context of $this object)
	 * @param string $backwardKey i18n key for conflict message in reverse
	 *  direction (when in UI context of $other object)
	 */
	public function conflictsWith( $other, string $globalKey, string $forwardKey, string $backwardKey ) {
		$this->setUnidirectionalConflict(
			$other,
			$globalKey,
			$forwardKey
		);

		$other->setUnidirectionalConflict(
			$this,
			$globalKey,
			$backwardKey
		);
	}

	/**
	 * Marks that the given ChangesListFilterGroup or ChangesListFilter conflicts with
	 * this object.
	 *
	 * Internal use ONLY.
	 *
	 * @param ChangesListFilterGroup|ChangesListFilter $other
	 * @param string $globalDescription i18n key for top-level conflict message
	 * @param string $contextDescription i18n key for conflict message in this
	 *  direction (when in UI context of $this object)
	 */
	public function setUnidirectionalConflict( $other, $globalDescription, $contextDescription ) {
		if ( $other instanceof ChangesListFilterGroup ) {
			$this->conflictingGroups[] = [
				'group' => $other->getName(),
				'groupObject' => $other,
				'globalDescription' => $globalDescription,
				'contextDescription' => $contextDescription,
			];
		} elseif ( $other instanceof ChangesListFilter ) {
			$this->conflictingFilters[] = [
				'group' => $other->getGroup()->getName(),
				'filter' => $other->getName(),
				'filterObject' => $other,
				'globalDescription' => $globalDescription,
				'contextDescription' => $contextDescription,
			];
		} else {
			throw new InvalidArgumentException(
				'You can only pass in a ChangesListFilterGroup or a ChangesListFilter'
			);
		}
	}

	/**
	 * Marks that the current instance is (also) a superset of the filter passed in.
	 * This can be called more than once.
	 *
	 * This means that anything in the results for the other filter is also in the
	 * results for this one.
	 *
	 * @param ChangesListFilter $other The filter the current instance is a superset of
	 */
	public function setAsSupersetOf( ChangesListFilter $other ) {
		if ( $other->getGroup() !== $this->getGroup() ) {
			throw new InvalidArgumentException( 'Supersets can only be defined for filters in the same group' );
		}

		$this->subsetFilters[] = [
			// It's always the same group, but this makes the representation
			// more consistent with conflicts.
			'group' => $other->getGroup()->getName(),
			'filter' => $other->getName(),
		];
	}

	/**
	 * @return string Name, e.g. hideanons
	 */
	public function getName() {
		return $this->name;
	}

	/**
	 * @return ChangesListFilterGroup Group this belongs to
	 */
	public function getGroup() {
		return $this->group;
	}

	/**
	 * @return string i18n key of label for structured UI
	 */
	public function getLabel() {
		return $this->label;
	}

	/**
	 * @return string i18n key of description for structured UI
	 */
	public function getDescription() {
		return $this->description;
	}

	/**
	 * Checks whether the filter should display on the unstructured UI
	 *
	 * @return bool Whether to display
	 */
	abstract public function displaysOnUnstructuredUi();

	/**
	 * Checks whether the filter should display on the structured UI
	 * This refers to the exact filter.  See also isFeatureAvailableOnStructuredUi.
	 *
	 * @return bool Whether to display
	 */
	public function displaysOnStructuredUi() {
		return $this->label !== null;
	}

	/**
	 * Checks whether an equivalent feature for this filter is available on the
	 * structured UI.
	 *
	 * This can either be the exact filter, or a new filter that replaces it.
	 * @return bool
	 */
	public function isFeatureAvailableOnStructuredUi() {
		return $this->displaysOnStructuredUi();
	}

	/**
	 * @return int Priority.  Higher value means higher up in the group list
	 */
	public function getPriority() {
		return $this->priority;
	}

	/**
	 * Gets the CSS class
	 *
	 * @return string|null CSS class, or null if not defined
	 */
	protected function getCssClass() {
		if ( $this->cssClassSuffix !== null ) {
			return ChangesList::CSS_CLASS_PREFIX . $this->cssClassSuffix;
		} else {
			return null;
		}
	}

	/**
	 * Add CSS class if needed
	 *
	 * @param IContextSource $ctx Context source
	 * @param RecentChange $rc Recent changes object
	 * @param array &$classes Non-associative array of CSS class names; appended to if needed
	 */
	public function applyCssClassIfNeeded( IContextSource $ctx, RecentChange $rc, array &$classes ) {
		if ( $this->isRowApplicableCallable === null ) {
			return;
		}

		if ( call_user_func( $this->isRowApplicableCallable, $ctx, $rc ) ) {
			$classes[] = $this->getCssClass();
		}
	}

	/**
	 * Gets the JS data required by the front-end of the structured UI
	 *
	 * @return array Associative array Data required by the front-end.  messageKeys is
	 *  a special top-level value, with the value being an array of the message keys to
	 *  send to the client.
	 */
	public function getJsData() {
		$output = [
			'name' => $this->getName(),
			'label' => $this->getLabel(),
			'description' => $this->getDescription(),
			'cssClass' => $this->getCssClass(),
			'priority' => $this->priority,
			'subset' => $this->subsetFilters,
			'conflicts' => [],
			'defaultHighlightColor' => $this->defaultHighlightColor
		];

		$output['messageKeys'] = [
			$this->getLabel(),
			$this->getDescription(),
		];

		$conflicts = array_merge(
			$this->conflictingGroups,
			$this->conflictingFilters
		);

		foreach ( $conflicts as $conflictInfo ) {
			unset( $conflictInfo['filterObject'] );
			unset( $conflictInfo['groupObject'] );
			$output['conflicts'][] = $conflictInfo;
			array_push(
				$output['messageKeys'],
				$conflictInfo['globalDescription'],
				$conflictInfo['contextDescription']
			);
		}

		return $output;
	}

	/**
	 * Checks whether this filter is selected in the provided options
	 *
	 * @param FormOptions $opts
	 * @return bool
	 */
	abstract public function isSelected( FormOptions $opts );

	/**
	 * Get groups conflicting with this filter
	 *
	 * @return ChangesListFilterGroup[]
	 */
	public function getConflictingGroups() {
		return array_column( $this->conflictingGroups, 'groupObject' );
	}

	/**
	 * Get filters conflicting with this filter
	 *
	 * @return ChangesListFilter[]
	 */
	public function getConflictingFilters() {
		return array_column( $this->conflictingFilters, 'filterObject' );
	}

	/**
	 * Check if the conflict with a group is currently "active"
	 *
	 * @param ChangesListFilterGroup $group
	 * @param FormOptions $opts
	 * @return bool
	 */
	public function activelyInConflictWithGroup( ChangesListFilterGroup $group, FormOptions $opts ) {
		if ( $group->anySelected( $opts ) && $this->isSelected( $opts ) ) {
			/** @var ChangesListFilter $siblingFilter */
			foreach ( $this->getSiblings() as $siblingFilter ) {
				if ( $siblingFilter->isSelected( $opts ) && !$siblingFilter->hasConflictWithGroup( $group ) ) {
					return false;
				}
			}
			return true;
		}
		return false;
	}

	private function hasConflictWithGroup( ChangesListFilterGroup $group ) {
		return in_array( $group, $this->getConflictingGroups() );
	}

	/**
	 * Check if the conflict with a filter is currently "active"
	 *
	 * @param ChangesListFilter $filter
	 * @param FormOptions $opts
	 * @return bool
	 */
	public function activelyInConflictWithFilter( ChangesListFilter $filter, FormOptions $opts ) {
		if ( $this->isSelected( $opts ) && $filter->isSelected( $opts ) ) {
			/** @var ChangesListFilter $siblingFilter */
			foreach ( $this->getSiblings() as $siblingFilter ) {
				if (
					$siblingFilter->isSelected( $opts ) &&
					!$siblingFilter->hasConflictWithFilter( $filter )
				) {
					return false;
				}
			}
			return true;
		}
		return false;
	}

	private function hasConflictWithFilter( ChangesListFilter $filter ) {
		return in_array( $filter, $this->getConflictingFilters() );
	}

	/**
	 * Get filters in the same group
	 *
	 * @return ChangesListFilter[]
	 */
	protected function getSiblings() {
		return array_filter(
			$this->getGroup()->getFilters(),
			function ( $filter ) {
				return $filter !== $this;
			}
		);
	}

	/**
	 * @param string $defaultHighlightColor
	 */
	public function setDefaultHighlightColor( $defaultHighlightColor ) {
		$this->defaultHighlightColor = $defaultHighlightColor;
	}
}
PK       ! ąAĆ¹!  ¹!    RecentChangesUpdateJob.phpnu ÕIw¶“        <?php
/**
 * 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
 */

use MediaWiki\Deferred\SiteStatsUpdate;
use MediaWiki\HookContainer\HookRunner;
use MediaWiki\MainConfigNames;
use MediaWiki\MediaWikiServices;
use MediaWiki\SpecialPage\SpecialPage;
use MediaWiki\Title\Title;
use Wikimedia\Timestamp\ConvertibleTimestamp;

/**
 * Purge expired rows from the recentchanges table.
 *
 * @since 1.25
 * @ingroup RecentChanges
 * @ingroup JobQueue
 */
class RecentChangesUpdateJob extends Job {
	public function __construct( Title $title, array $params ) {
		parent::__construct( 'recentChangesUpdate', $title, $params );

		if ( !isset( $params['type'] ) ) {
			throw new InvalidArgumentException( "Missing 'type' parameter." );
		}

		$this->executionFlags |= self::JOB_NO_EXPLICIT_TRX_ROUND;
		$this->removeDuplicates = true;
	}

	/**
	 * @return RecentChangesUpdateJob
	 */
	final public static function newPurgeJob() {
		return new self(
			SpecialPage::getTitleFor( 'Recentchanges' ), [ 'type' => 'purge' ]
		);
	}

	/**
	 * @return RecentChangesUpdateJob
	 * @since 1.26
	 */
	final public static function newCacheUpdateJob() {
		return new self(
			SpecialPage::getTitleFor( 'Recentchanges' ), [ 'type' => 'cacheUpdate' ]
		);
	}

	public function run() {
		if ( $this->params['type'] === 'purge' ) {
			$this->purgeExpiredRows();
		} elseif ( $this->params['type'] === 'cacheUpdate' ) {
			$this->updateActiveUsers();
		} else {
			throw new InvalidArgumentException(
				"Invalid 'type' parameter '{$this->params['type']}'." );
		}

		return true;
	}

	protected function purgeExpiredRows() {
		$services = MediaWikiServices::getInstance();
		$rcMaxAge = $services->getMainConfig()->get(
			MainConfigNames::RCMaxAge );
		$updateRowsPerQuery = $services->getMainConfig()->get(
			MainConfigNames::UpdateRowsPerQuery );
		$dbProvider = $services->getConnectionProvider();
		$dbw = $dbProvider->getPrimaryDatabase();
		$lockKey = $dbw->getDomainID() . ':recentchanges-prune';
		if ( !$dbw->lock( $lockKey, __METHOD__, 0 ) ) {
			// already in progress
			return;
		}
		$ticket = $dbProvider->getEmptyTransactionTicket( __METHOD__ );
		$hookRunner = new HookRunner( $services->getHookContainer() );
		$cutoff = $dbw->timestamp( ConvertibleTimestamp::time() - $rcMaxAge );
		$rcQuery = RecentChange::getQueryInfo();
		do {
			$rcIds = [];
			$rows = [];
			$res = $dbw->newSelectQueryBuilder()
				->queryInfo( $rcQuery )
				->where( $dbw->expr( 'rc_timestamp', '<', $cutoff ) )
				->limit( $updateRowsPerQuery )
				->caller( __METHOD__ )
				->fetchResultSet();
			foreach ( $res as $row ) {
				$rcIds[] = $row->rc_id;
				$rows[] = $row;
			}
			if ( $rcIds ) {
				$dbw->newDeleteQueryBuilder()
					->deleteFrom( 'recentchanges' )
					->where( [ 'rc_id' => $rcIds ] )
					->caller( __METHOD__ )->execute();
				$hookRunner->onRecentChangesPurgeRows( $rows );
				// There might be more, so try waiting for replica DBs
				if ( !$dbProvider->commitAndWaitForReplication(
					__METHOD__, $ticket, [ 'timeout' => 3 ]
				) ) {
					// Another job will continue anyway
					break;
				}
			}
		} while ( $rcIds );

		$dbw->unlock( $lockKey, __METHOD__ );
	}

	protected function updateActiveUsers() {
		$activeUserDays = MediaWikiServices::getInstance()->getMainConfig()->get(
			MainConfigNames::ActiveUserDays );

		// Users that made edits at least this many days ago are "active"
		$days = $activeUserDays;
		// Pull in the full window of active users in this update
		$window = $activeUserDays * 86400;

		$dbProvider = MediaWikiServices::getInstance()->getConnectionProvider();
		$dbw = $dbProvider->getPrimaryDatabase();
		$ticket = $dbProvider->getEmptyTransactionTicket( __METHOD__ );

		$lockKey = $dbw->getDomainID() . '-activeusers';
		if ( !$dbw->lock( $lockKey, __METHOD__, 0 ) ) {
			// Exclusive update (avoids duplicate entries)ā¦ it's usually fine to just
			// drop out here, if the Job is already running.
			return;
		}

		// Long-running queries expected
		$dbw->setSessionOptions( [ 'connTimeout' => 900 ] );

		$nowUnix = time();
		// Get the last-updated timestamp for the cache
		$cTime = $dbw->newSelectQueryBuilder()
			->select( 'qci_timestamp' )
			->from( 'querycache_info' )
			->where( [ 'qci_type' => 'activeusers' ] )
			->caller( __METHOD__ )->fetchField();
		$cTimeUnix = $cTime ? (int)wfTimestamp( TS_UNIX, $cTime ) : 1;

		// Pick the date range to fetch from. This is normally from the last
		// update to till the present time, but has a limited window.
		// If the window is limited, multiple runs are need to fully populate it.
		$sTimestamp = max( $cTimeUnix, $nowUnix - $days * 86400 );
		$eTimestamp = min( $sTimestamp + $window, $nowUnix );

		// Get all the users active since the last update
		$res = $dbw->newSelectQueryBuilder()
			->select( [ 'actor_name', 'lastedittime' => 'MAX(rc_timestamp)' ] )
			->from( 'recentchanges' )
			->join( 'actor', null, 'actor_id=rc_actor' )
			->where( [
				$dbw->expr( 'actor_user', '!=', null ), // actual accounts
				$dbw->expr( 'rc_type', '!=', RC_EXTERNAL ), // no wikidata
				$dbw->expr( 'rc_log_type', '=', null )->or( 'rc_log_type', '!=', 'newusers' ),
				$dbw->expr( 'rc_timestamp', '>=', $dbw->timestamp( $sTimestamp ) ),
				$dbw->expr( 'rc_timestamp', '<=', $dbw->timestamp( $eTimestamp ) ),
			] )
			->groupBy( 'actor_name' )
			->orderBy( 'NULL' ) // avoid filesort
			->caller( __METHOD__ )->fetchResultSet();

		$names = [];
		foreach ( $res as $row ) {
			$names[$row->actor_name] = $row->lastedittime;
		}

		// Find which of the recently active users are already accounted for
		if ( count( $names ) ) {
			$res = $dbw->newSelectQueryBuilder()
				->select( [ 'user_name' => 'qcc_title' ] )
				->from( 'querycachetwo' )
				->where( [
					'qcc_type' => 'activeusers',
					'qcc_namespace' => NS_USER,
					'qcc_title' => array_map( 'strval', array_keys( $names ) ),
					$dbw->expr( 'qcc_value', '>=', $nowUnix - $days * 86400 ),
				] )
				->caller( __METHOD__ )->fetchResultSet();
			// Note: In order for this to be actually consistent, we would need
			// to update these rows with the new lastedittime.
			foreach ( $res as $row ) {
				unset( $names[$row->user_name] );
			}
		}

		// Insert the users that need to be added to the list
		if ( count( $names ) ) {
			$newRows = [];
			foreach ( $names as $name => $lastEditTime ) {
				$newRows[] = [
					'qcc_type' => 'activeusers',
					'qcc_namespace' => NS_USER,
					'qcc_title' => $name,
					'qcc_value' => (int)wfTimestamp( TS_UNIX, $lastEditTime ),
					'qcc_namespacetwo' => 0, // unused
					'qcc_titletwo' => '' // unused
				];
			}
			foreach ( array_chunk( $newRows, 500 ) as $rowBatch ) {
				$dbw->newInsertQueryBuilder()
					->insertInto( 'querycachetwo' )
					->rows( $rowBatch )
					->caller( __METHOD__ )->execute();
				$dbProvider->commitAndWaitForReplication( __METHOD__, $ticket );
			}
		}

		// If a transaction was already started, it might have an old
		// snapshot, so kludge the timestamp range back as needed.
		$asOfTimestamp = min( $eTimestamp, (int)$dbw->trxTimestamp() );

		// Touch the data freshness timestamp
		$dbw->newReplaceQueryBuilder()
			->replaceInto( 'querycache_info' )
			->row( [
				'qci_type' => 'activeusers',
				'qci_timestamp' => $dbw->timestamp( $asOfTimestamp ), // not always $now
			] )
			->uniqueIndexFields( [ 'qci_type' ] )
			->caller( __METHOD__ )->execute();

		// Rotate out users that have not edited in too long (according to old data set)
		$dbw->newDeleteQueryBuilder()
			->deleteFrom( 'querycachetwo' )
			->where( [
				'qcc_type' => 'activeusers',
				$dbw->expr( 'qcc_value', '<', $nowUnix - $days * 86400 ) // TS_UNIX
			] )
			->caller( __METHOD__ )->execute();

		if ( !MediaWikiServices::getInstance()->getMainConfig()->get( MainConfigNames::MiserMode ) ) {
			SiteStatsUpdate::cacheUpdate( $dbw );
		}

		$dbw->unlock( $lockKey, __METHOD__ );
	}
}
PK       ! Ø¦³Ø  Ø     Hook/ChangesListInitRowsHook.phpnu ÕIw¶“        <?php

namespace MediaWiki\Hook;

use ChangesList;
use Wikimedia\Rdbms\IResultWrapper;

/**
 * This is a hook handler interface, see docs/Hooks.md.
 * Use the hook name "ChangesListInitRows" to register handlers implementing this interface.
 *
 * @stable to implement
 * @ingroup Hooks
 */
interface ChangesListInitRowsHook {
	/**
	 * Use this hook to batch process change list rows prior to rendering.
	 *
	 * @since 1.35
	 *
	 * @param ChangesList $changesList
	 * @param IResultWrapper|\stdClass[] $rows Data that will be rendered
	 * @return bool|void True or no return value to continue or false to abort
	 */
	public function onChangesListInitRows( $changesList, $rows );
}
PK       ! oeą®    ,  Hook/EnhancedChangesList__getLogTextHook.phpnu ÕIw¶“        <?php

namespace MediaWiki\Hook;

// phpcs:disable Squiz.Classes.ValidClassName.NotCamelCaps
use EnhancedChangesList;
use RecentChange;

/**
 * This is a hook handler interface, see docs/Hooks.md.
 * Use the hook name "EnhancedChangesList::getLogText" to register handlers implementing this interface.
 *
 * @stable to implement
 * @ingroup Hooks
 */
interface EnhancedChangesList__getLogTextHook {
	/**
	 * Use this hook to alter, remove, or add to the links of a group of changes in
	 * EnhancedChangesList.
	 *
	 * @since 1.35
	 *
	 * @param EnhancedChangesList $changesList
	 * @param string[] &$links Links generated by EnhancedChangesList
	 * @param RecentChange[] $block RecentChange objects in that block
	 * @return bool|void True or no return value to continue, or false to omit
	 *   this line from recentchanges
	 */
	public function onEnhancedChangesList__getLogText( $changesList, &$links,
		$block
	);
}
PK       ! lfM×c  c    Hook/RecentChange_saveHook.phpnu ÕIw¶“        <?php

namespace MediaWiki\Hook;

// phpcs:disable Squiz.Classes.ValidClassName.NotCamelCaps
use RecentChange;

/**
 * This is a hook handler interface, see docs/Hooks.md.
 * Use the hook name "RecentChange_save" to register handlers implementing this interface.
 *
 * @stable to implement
 * @ingroup Hooks
 */
interface RecentChange_saveHook {
	/**
	 * This hook is called at the end of RecentChange::save().
	 *
	 * @since 1.35
	 *
	 * @param RecentChange $recentChange
	 * @return bool|void True or no return value to continue or false to abort
	 */
	public function onRecentChange_save( $recentChange );
}
PK       ! še³?  ?    Hook/MarkPatrolledHook.phpnu ÕIw¶“        <?php

namespace MediaWiki\Hook;

use MediaWiki\User\User;

/**
 * This is a hook handler interface, see docs/Hooks.md.
 * Use the hook name "MarkPatrolled" to register handlers implementing this interface.
 *
 * @stable to implement
 * @ingroup Hooks
 */
interface MarkPatrolledHook {
	/**
	 * This hook is called before an edit is marked patrolled.
	 *
	 * @since 1.35
	 *
	 * @param int $rcid ID of the revision to be marked patrolled
	 * @param User $user User marking the revision as patrolled
	 * @param bool $wcOnlySysopsCanPatrol Always false
	 * @param bool $auto Always false
	 * @param string[] &$tags Tags to be applied to the patrol log entry
	 * @return bool|void True or no return value to continue or false to abort
	 */
	public function onMarkPatrolled( $rcid, $user, $wcOnlySysopsCanPatrol, $auto,
		&$tags
	);
}
PK       ! {Cś^  ^  3  Hook/EnhancedChangesListModifyBlockLineDataHook.phpnu ÕIw¶“        <?php

namespace MediaWiki\Hook;

use EnhancedChangesList;
use RecentChange;

/**
 * This is a hook handler interface, see docs/Hooks.md.
 * Use the hook name "EnhancedChangesListModifyBlockLineData" to register handlers implementing this interface.
 *
 * @stable to implement
 * @ingroup Hooks
 */
interface EnhancedChangesListModifyBlockLineDataHook {
	/**
	 * Use this hook to alter data used to build a non-grouped recent change line in
	 * EnhancedChangesList.
	 *
	 * @since 1.35
	 *
	 * @param EnhancedChangesList $changesList
	 * @param array &$data Array of components that will be joined in order to create the line
	 * @param RecentChange $rc RecentChange object for this line
	 * @return bool|void True or no return value to continue or false to abort
	 */
	public function onEnhancedChangesListModifyBlockLineData( $changesList, &$data,
		$rc
	);
}
PK       ! įuøłŃ  Ń  )  Hook/ChangesListInsertArticleLinkHook.phpnu ÕIw¶“        <?php

namespace MediaWiki\Hook;

use ChangesList;
use RecentChange;

/**
 * This is a hook handler interface, see docs/Hooks.md.
 * Use the hook name "ChangesListInsertArticleLink" to register handlers implementing this interface.
 *
 * @stable to implement
 * @ingroup Hooks
 */
interface ChangesListInsertArticleLinkHook {
	/**
	 * Use this hook to override or augment link to article in RC list.
	 *
	 * @since 1.35
	 *
	 * @param ChangesList $changesList
	 * @param string &$articlelink HTML of link to article (already filled-in)
	 * @param string &$s HTML of row that is being constructed
	 * @param RecentChange $rc
	 * @param bool $unpatrolled Whether or not we are showing unpatrolled changes
	 * @param bool $watched Whether or not the change is watched by the user
	 * @return bool|void True or no return value to continue or false to abort
	 */
	public function onChangesListInsertArticleLink( $changesList, &$articlelink,
		&$s, $rc, $unpatrolled, $watched
	);
}
PK       ! UĪQ    "  Hook/MarkPatrolledCompleteHook.phpnu ÕIw¶“        <?php

namespace MediaWiki\Hook;

use MediaWiki\User\User;

/**
 * This is a hook handler interface, see docs/Hooks.md.
 * Use the hook name "MarkPatrolledComplete" to register handlers implementing this interface.
 *
 * @stable to implement
 * @ingroup Hooks
 */
interface MarkPatrolledCompleteHook {
	/**
	 * This hook is called after an edit is marked patrolled.
	 *
	 * @since 1.35
	 *
	 * @param int $rcid ID of the revision marked as patrolled
	 * @param User $user User who marked the edit patrolled
	 * @param bool $wcOnlySysopsCanPatrol Always false
	 * @param bool $auto Always false
	 * @return bool|void True or no return value to continue or false to abort
	 */
	public function onMarkPatrolledComplete( $rcid, $user, $wcOnlySysopsCanPatrol,
		$auto
	);
}
PK       ! P0 š  š  .  Hook/EnhancedChangesListModifyLineDataHook.phpnu ÕIw¶“        <?php

namespace MediaWiki\Hook;

use EnhancedChangesList;
use RecentChange;

/**
 * This is a hook handler interface, see docs/Hooks.md.
 * Use the hook name "EnhancedChangesListModifyLineData" to register handlers implementing this interface.
 *
 * @stable to implement
 * @ingroup Hooks
 */
interface EnhancedChangesListModifyLineDataHook {
	/**
	 * Use this hook to alter data used to build a grouped recent change inner line in
	 * EnhancedChangesList.
	 *
	 * @since 1.35
	 *
	 * @param EnhancedChangesList $changesList
	 * @param array &$data Array of components that will be joined in order to create the line
	 * @param RecentChange[] $block Array of RecentChange objects in that block
	 * @param RecentChange $rc RecentChange object for this line
	 * @param string[] &$classes Array of classes to change
	 * @param string[] &$attribs Associative array of other HTML attributes for the `<tr>` element.
	 *   Currently only data attributes reserved to MediaWiki are allowed
	 *   (see Sanitizer::isReservedDataAttribute).
	 * @return bool|void True or no return value to continue, or false to omit this line from
	 *   recentchanges
	 */
	public function onEnhancedChangesListModifyLineData( $changesList, &$data,
		$block, $rc, &$classes, &$attribs
	);
}
PK       ! Üń¢Ģ  Ģ    Hook/FetchChangesListHook.phpnu ÕIw¶“        <?php

namespace MediaWiki\Hook;

use ChangesList;
use ChangesListFilterGroup;
use MediaWiki\User\User;
use Skin;

/**
 * This is a hook handler interface, see docs/Hooks.md.
 * Use the hook name "FetchChangesList" to register handlers implementing this interface.
 *
 * @stable to implement
 * @ingroup Hooks
 */
interface FetchChangesListHook {
	/**
	 * This hook is called when fetching the ChangesList derivative for a particular user.
	 *
	 * @since 1.35
	 *
	 * @param User $user User the list is being fetched for
	 * @param Skin $skin Skin object to be used with the list
	 * @param ChangesList|null &$list Defaults to NULL. Change it to an object instance and
	 *   return false to override the list derivative used.
	 * @param ChangesListFilterGroup[] $groups Added in 1.34
	 * @return bool|void True or no return value to continue, or false to override the list
	 *   derivative used
	 */
	public function onFetchChangesList( $user, $skin, &$list, $groups );
}
PK       ! ŗMµš  š  #  Hook/AbortEmailNotificationHook.phpnu ÕIw¶“        <?php

namespace MediaWiki\Hook;

use MediaWiki\Title\Title;
use MediaWiki\User\User;
use RecentChange;

/**
 * This is a hook handler interface, see docs/Hooks.md.
 * Use the hook name "AbortEmailNotification" to register handlers implementing this interface.
 *
 * @stable to implement
 * @ingroup Hooks
 */
interface AbortEmailNotificationHook {
	/**
	 * Use this hook to cancel email notifications for an edit.
	 *
	 * @since 1.35
	 *
	 * @param User $editor User who made the change
	 * @param Title $title Title of the page that was edited
	 * @param RecentChange $rc Current RecentChange object
	 * @return bool|void True or no return value to continue or false to abort
	 */
	public function onAbortEmailNotification( $editor, $title, $rc );
}
PK       ! édKLM  M  ,  Hook/OldChangesListRecentChangesLineHook.phpnu ÕIw¶“        <?php

namespace MediaWiki\Hook;

use OldChangesList;
use RecentChange;

/**
 * This is a hook handler interface, see docs/Hooks.md.
 * Use the hook name "OldChangesListRecentChangesLine" to register handlers implementing this interface.
 *
 * @stable to implement
 * @ingroup Hooks
 */
interface OldChangesListRecentChangesLineHook {
	/**
	 * Use this hook to customize a recent changes line.
	 *
	 * @since 1.35
	 *
	 * @param OldChangesList $changeslist
	 * @param string &$s HTML of the form `<li>...</li>` containing one RC entry
	 * @param RecentChange $rc
	 * @param string[] &$classes Array of CSS classes for the `<li>` element
	 * @param string[] &$attribs Associative array of other HTML attributes for the `<li>` element.
	 *   Currently only data attributes reserved to MediaWiki are allowed
	 *   (see Sanitizer::isReservedDataAttribute).
	 * @return bool|void True or no return value to continue, or false to omit the line from
	 *   RecentChanges and Watchlist special pages
	 */
	public function onOldChangesListRecentChangesLine( $changeslist, &$s, $rc,
		&$classes, &$attribs
	);
}
PK       ! {łWR³  R³    RecentChange.phpnu ÕIw¶“        <?php
/**
 * 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
 */

use MediaWiki\ChangeTags\Taggable;
use MediaWiki\Config\Config;
use MediaWiki\Debug\DeprecationHelper;
use MediaWiki\Deferred\DeferredUpdates;
use MediaWiki\HookContainer\HookRunner;
use MediaWiki\Json\FormatJson;
use MediaWiki\MainConfigNames;
use MediaWiki\MediaWikiServices;
use MediaWiki\Page\PageIdentity;
use MediaWiki\Page\PageReference;
use MediaWiki\Page\PageReferenceValue;
use MediaWiki\Permissions\Authority;
use MediaWiki\Permissions\PermissionStatus;
use MediaWiki\RCFeed\RCFeed;
use MediaWiki\Storage\EditResult;
use MediaWiki\Title\Title;
use MediaWiki\User\UserIdentity;
use MediaWiki\User\UserIdentityValue;
use MediaWiki\Utils\MWTimestamp;
use Wikimedia\Assert\Assert;
use Wikimedia\AtEase\AtEase;
use Wikimedia\IPUtils;

/**
 * @defgroup RecentChanges Recent changes
 * Discovery and review of recent edits and log events on the wiki.
 *
 * The Recent changes feature stores a temporary copy of the long-term
 * `revision` and `logging` table rows which represent page edits and
 * log actions respectively.
 *
 * Recent changes augments revision and logging rows with additional metadata
 * that empower reviewers to efficiently find edits related to their
 * interest, or edits that warrant a closer look. This includes page
 * namespace, "minor" edit status, and user type. As well as metadata we
 * don't store elsewhere, such the bot flag (rc_bot), edit type (page creation,
 * edit, or something else), and patrolling state (rc_patrolled).
 *
 * The patrolled status facilitates edit review via the "mark as patrolled"
 * button, in combination with filtering by patrol status via SpecialRecentChanges,
 * SpecialWatchlist, and ApiQueryRecentChanges.
 */

/**
 * Utility class for creating and reading rows in the recentchanges table.
 *
 * mAttribs:
 *  rc_id           id of the row in the recentchanges table
 *  rc_timestamp    time the entry was made
 *  rc_namespace    namespace #
 *  rc_title        non-prefixed db key
 *  rc_type         is new entry, used to determine whether updating is necessary
 *  rc_source       string representation of change source
 *  rc_minor        is minor
 *  rc_cur_id       page_id of associated page entry
 *  rc_user         user id who made the entry
 *  rc_user_text    user name who made the entry
 *  rc_comment      edit summary
 *  rc_this_oldid   rev_id associated with this entry (or zero)
 *  rc_last_oldid   rev_id associated with the entry before this one (or zero)
 *  rc_bot          is bot, hidden
 *  rc_ip           IP address of the user in dotted quad notation
 *  rc_new          obsolete, use rc_source=='mw.new'
 *  rc_patrolled    boolean whether or not someone has marked this edit as patrolled
 *  rc_old_len      integer byte length of the text before the edit
 *  rc_new_len      the same after the edit
 *  rc_deleted      partial deletion
 *  rc_logid        the log_id value for this log entry (or zero)
 *  rc_log_type     the log type (or null)
 *  rc_log_action   the log action (or null)
 *  rc_params       log params
 *
 * mExtra:
 *  prefixedDBkey   prefixed db key, used by external app via msg queue
 *  lastTimestamp   timestamp of previous entry, used in WHERE clause during update
 *  oldSize         text size before the change
 *  newSize         text size after the change
 *  pageStatus      status of the page: created, deleted, moved, restored, changed
 *
 * temporary:       not stored in the database
 *      notificationtimestamp
 *      numberofWatchingusers
 *      watchlistExpiry        for temporary watchlist items
 *
 * @todo Deprecate access to mAttribs (direct or via getAttributes). Right now
 *  we're having to include both rc_comment and rc_comment_text/rc_comment_data
 *  so random crap works right.
 *
 * @ingroup RecentChanges
 */
class RecentChange implements Taggable {
	use DeprecationHelper;

	// Constants for the rc_source field.  Extensions may also have
	// their own source constants.
	public const SRC_EDIT = 'mw.edit';
	public const SRC_NEW = 'mw.new';
	public const SRC_LOG = 'mw.log';
	public const SRC_EXTERNAL = 'mw.external'; // obsolete
	public const SRC_CATEGORIZE = 'mw.categorize';

	public const PRC_UNPATROLLED = 0;
	public const PRC_PATROLLED = 1;
	public const PRC_AUTOPATROLLED = 2;

	/**
	 * @var bool For save() - save to the database only, without any events.
	 */
	public const SEND_NONE = true;

	/**
	 * @var bool For save() - do emit the change to RCFeeds (usually public).
	 */
	public const SEND_FEED = false;

	/** @var array */
	public $mAttribs = [];
	/** @var array */
	public $mExtra = [];

	/**
	 * @var PageReference|null
	 */
	private $mPage = null;

	/**
	 * @var UserIdentity|null
	 */
	private $mPerformer = null;

	/** @var int */
	public $numberofWatchingusers = 0;
	/** @var bool */
	public $notificationtimestamp;

	/**
	 * @var string|null The expiry time, if this is a temporary watchlist item.
	 */
	public $watchlistExpiry;

	/**
	 * @var int Line number of recent change. Default -1.
	 */
	public $counter = -1;

	/**
	 * @var array List of tags to apply
	 */
	private $tags = [];

	/**
	 * @var EditResult|null EditResult associated with the edit
	 */
	private $editResult = null;

	private const CHANGE_TYPES = [
		'edit' => RC_EDIT,
		'new' => RC_NEW,
		'log' => RC_LOG,
		'external' => RC_EXTERNAL,
		'categorize' => RC_CATEGORIZE,
	];

	# Factory methods

	/**
	 * @param mixed $row
	 * @return RecentChange
	 */
	public static function newFromRow( $row ) {
		$rc = new RecentChange;
		$rc->loadFromRow( $row );

		return $rc;
	}

	/**
	 * Parsing text to RC_* constants
	 * @since 1.24
	 * @param string|array $type Callers must make sure that the given types are valid RC types.
	 * @return int|array RC_TYPE
	 */
	public static function parseToRCType( $type ) {
		if ( is_array( $type ) ) {
			$retval = [];
			foreach ( $type as $t ) {
				$retval[] = self::parseToRCType( $t );
			}

			return $retval;
		}

		if ( !array_key_exists( $type, self::CHANGE_TYPES ) ) {
			throw new InvalidArgumentException( "Unknown type '$type'" );
		}
		return self::CHANGE_TYPES[$type];
	}

	/**
	 * Parsing RC_* constants to human-readable test
	 * @since 1.24
	 * @param int $rcType
	 * @return string
	 */
	public static function parseFromRCType( $rcType ) {
		return array_search( $rcType, self::CHANGE_TYPES, true ) ?: "$rcType";
	}

	/**
	 * Get an array of all change types
	 *
	 * @since 1.26
	 *
	 * @return array
	 */
	public static function getChangeTypes() {
		return array_keys( self::CHANGE_TYPES );
	}

	/**
	 * Obtain the recent change with a given rc_id value
	 *
	 * @param int $rcid The rc_id value to retrieve
	 * @return RecentChange|null
	 */
	public static function newFromId( $rcid ) {
		return self::newFromConds( [ 'rc_id' => $rcid ], __METHOD__ );
	}

	/**
	 * Find the first recent change matching some specific conditions
	 *
	 * @param array $conds Array of conditions
	 * @param mixed $fname Override the method name in profiling/logs @phan-mandatory-param
	 * @param int $dbType DB_* constant
	 *
	 * @return RecentChange|null
	 */
	public static function newFromConds(
		$conds,
		$fname = __METHOD__,
		$dbType = DB_REPLICA
	) {
		$icp = MediaWikiServices::getInstance()->getConnectionProvider();

		$db = ( $dbType === DB_REPLICA ) ? $icp->getReplicaDatabase() : $icp->getPrimaryDatabase();

		$rcQuery = self::getQueryInfo();
		$row = $db->newSelectQueryBuilder()
			->queryInfo( $rcQuery )
			->where( $conds )
			->caller( $fname )
			->fetchRow();
		if ( $row !== false ) {
			return self::newFromRow( $row );
		} else {
			return null;
		}
	}

	/**
	 * Return the tables, fields, and join conditions to be selected to create
	 * a new recentchanges object.
	 *
	 * Since 1.34, rc_user and rc_user_text have not been present in the
	 * database, but they continue to be available in query results as
	 * aliases.
	 *
	 * @since 1.31
	 * @return array[] With three keys:
	 *   - tables: (string[]) to include in the `$table` to `IDatabase->select()` or `SelectQueryBuilder::tables`
	 *   - fields: (string[]) to include in the `$vars` to `IDatabase->select()` or `SelectQueryBuilder::fields`
	 *   - joins: (array) to include in the `$join_conds` to `IDatabase->select()` or `SelectQueryBuilder::joinConds`
	 * @phan-return array{tables:string[],fields:string[],joins:array}
	 */
	public static function getQueryInfo() {
		$commentQuery = MediaWikiServices::getInstance()->getCommentStore()->getJoin( 'rc_comment' );
		// Optimizer sometimes refuses to pick up the correct join order (T311360)
		$commentQuery['joins']['comment_rc_comment'][0] = 'STRAIGHT_JOIN';
		return [
			'tables' => [
				'recentchanges',
				'recentchanges_actor' => 'actor'
			] + $commentQuery['tables'],
			'fields' => [
				'rc_id',
				'rc_timestamp',
				'rc_namespace',
				'rc_title',
				'rc_minor',
				'rc_bot',
				'rc_new',
				'rc_cur_id',
				'rc_this_oldid',
				'rc_last_oldid',
				'rc_type',
				'rc_source',
				'rc_patrolled',
				'rc_ip',
				'rc_old_len',
				'rc_new_len',
				'rc_deleted',
				'rc_logid',
				'rc_log_type',
				'rc_log_action',
				'rc_params',
				'rc_actor',
				'rc_user' => 'recentchanges_actor.actor_user',
				'rc_user_text' => 'recentchanges_actor.actor_name',
			] + $commentQuery['fields'],
			'joins' => [
				'recentchanges_actor' => [ 'STRAIGHT_JOIN', 'actor_id=rc_actor' ]
			] + $commentQuery['joins'],
		];
	}

	public function __construct() {
		$this->deprecatePublicPropertyFallback(
			'mTitle',
			'1.37',
			function () {
				return Title::castFromPageReference( $this->mPage );
			},
			function ( ?Title $title ) {
				$this->mPage = $title;
			}
		);
	}

	# Accessors

	/**
	 * @param array $attribs
	 */
	public function setAttribs( $attribs ) {
		$this->mAttribs = $attribs;
	}

	/**
	 * @param array $extra
	 */
	public function setExtra( $extra ) {
		$this->mExtra = $extra;
	}

	/**
	 * @deprecated since 1.37, use getPage() instead.
	 * @return Title
	 */
	public function getTitle() {
		$this->mPage = Title::castFromPageReference( $this->getPage() );
		return $this->mPage ?: Title::makeTitle( NS_SPECIAL, 'BadTitle' );
	}

	/**
	 * @since 1.37
	 * @return ?PageReference
	 */
	public function getPage(): ?PageReference {
		if ( !$this->mPage ) {
			// NOTE: As per the 1.36 release, we always provide rc_title,
			//       even in cases where it doesn't really make sense.
			//       In the future, rc_title may be nullable, or we may use
			//       empty strings in entries that do not refer to a page.
			if ( ( $this->mAttribs['rc_title'] ?? '' ) === '' ) {
				return null;
			}

			// XXX: We could use rc_cur_id to create a PageIdentityValue,
			//      at least if it's not a special page.
			//      However, newForCategorization() puts the ID of the categorized page into
			//      rc_cur_id, but the title of the category page into rc_title.
			$this->mPage = new PageReferenceValue(
				(int)$this->mAttribs['rc_namespace'],
				$this->mAttribs['rc_title'],
				PageReference::LOCAL
			);
		}

		return $this->mPage;
	}

	/**
	 * Get the UserIdentity of the client that performed this change.
	 *
	 * @since 1.36
	 *
	 * @return UserIdentity
	 */
	public function getPerformerIdentity(): UserIdentity {
		if ( !$this->mPerformer ) {
			$this->mPerformer = $this->getUserIdentityFromAnyId(
				$this->mAttribs['rc_user'] ?? null,
				$this->mAttribs['rc_user_text'] ?? null,
				$this->mAttribs['rc_actor'] ?? null
			);
		}

		return $this->mPerformer;
	}

	/**
	 * Writes the data in this object to the database
	 *
	 * For compatibility reasons, the SEND_ constants internally reference a value
	 * that may seem negated from their purpose (none=true, feed=false). This is
	 * because the parameter used to be called "$noudp", defaulting to false.
	 *
	 * @param bool $send self::SEND_FEED or self::SEND_NONE
	 */
	public function save( $send = self::SEND_FEED ) {
		$services = MediaWikiServices::getInstance();
		$mainConfig = $services->getMainConfig();
		$putIPinRC = $mainConfig->get( MainConfigNames::PutIPinRC );
		$dbw = $services->getConnectionProvider()->getPrimaryDatabase();
		if ( !is_array( $this->mExtra ) ) {
			$this->mExtra = [];
		}

		if ( !$putIPinRC ) {
			$this->mAttribs['rc_ip'] = '';
		}

		# Strict mode fixups (not-NULL fields)
		foreach ( [ 'minor', 'bot', 'new', 'patrolled', 'deleted' ] as $field ) {
			$this->mAttribs["rc_$field"] = (int)$this->mAttribs["rc_$field"];
		}
		# ...more fixups (NULL fields)
		foreach ( [ 'old_len', 'new_len' ] as $field ) {
			$this->mAttribs["rc_$field"] = isset( $this->mAttribs["rc_$field"] )
				? (int)$this->mAttribs["rc_$field"]
				: null;
		}

		$row = $this->mAttribs;

		# Trim spaces on user supplied text
		$row['rc_comment'] = trim( $row['rc_comment'] ?? '' );

		# Fixup database timestamps
		$row['rc_timestamp'] = $dbw->timestamp( $row['rc_timestamp'] );

		# # If we are using foreign keys, an entry of 0 for the page_id will fail, so use NULL
		if ( $row['rc_cur_id'] == 0 ) {
			unset( $row['rc_cur_id'] );
		}

		# Convert mAttribs['rc_comment'] for CommentStore
		$comment = $row['rc_comment'];
		unset( $row['rc_comment'], $row['rc_comment_text'], $row['rc_comment_data'] );
		$row += $services->getCommentStore()->insert( $dbw, 'rc_comment', $comment );

		# Normalize UserIdentity to actor ID
		$user = $this->getPerformerIdentity();
		if ( array_key_exists( 'forImport', $this->mExtra ) && $this->mExtra['forImport'] ) {
			$actorStore = $services->getActorStoreFactory()->getActorStoreForImport();
		} else {
			$actorStore = $services->getActorStoreFactory()->getActorStore();
		}
		$row['rc_actor'] = $actorStore->acquireActorId( $user, $dbw );
		unset( $row['rc_user'], $row['rc_user_text'] );

		# Don't reuse an existing rc_id for the new row, if one happens to be
		# set for some reason.
		unset( $row['rc_id'] );

		# Insert new row
		$dbw->newInsertQueryBuilder()
			->insertInto( 'recentchanges' )
			->row( $row )
			->caller( __METHOD__ )->execute();

		# Set the ID
		$this->mAttribs['rc_id'] = $dbw->insertId();

		# Notify extensions
		$hookRunner = new HookRunner( $services->getHookContainer() );
		$hookRunner->onRecentChange_save( $this );

		// Apply revert tags (if needed)
		if ( $this->editResult !== null && count( $this->editResult->getRevertTags() ) ) {
			MediaWikiServices::getInstance()->getChangeTagsStore()->addTags(
				$this->editResult->getRevertTags(),
				$this->mAttribs['rc_id'],
				$this->mAttribs['rc_this_oldid'],
				$this->mAttribs['rc_logid'],
				FormatJson::encode( $this->editResult ),
				$this
			);
		}

		if ( count( $this->tags ) ) {
			// $this->tags may contain revert tags we already applied above, they will
			// just be ignored.
			MediaWikiServices::getInstance()->getChangeTagsStore()->addTags(
				$this->tags,
				$this->mAttribs['rc_id'],
				$this->mAttribs['rc_this_oldid'],
				$this->mAttribs['rc_logid'],
				null,
				$this
			);
		}

		if ( $send === self::SEND_FEED ) {
			// Emit the change to external applications via RCFeeds.
			$this->notifyRCFeeds();
		}

		# E-mail notifications
		if ( self::isEnotifEnabled( $mainConfig ) ) {
			$userFactory = $services->getUserFactory();
			$editor = $userFactory->newFromUserIdentity( $this->getPerformerIdentity() );
			$page = $this->getPage();
			$title = Title::castFromPageReference( $page );

			// Never send an RC notification email about categorization changes
			if (
				$title &&
				$hookRunner->onAbortEmailNotification( $editor, $title, $this ) &&
				$this->mAttribs['rc_type'] != RC_CATEGORIZE
			) {
				// @FIXME: This would be better as an extension hook
				// Send emails or email jobs once this row is safely committed
				$dbw->onTransactionCommitOrIdle(
					function () use ( $editor, $title ) {
						$enotif = new EmailNotification();
						$enotif->notifyOnPageChange(
							$editor,
							$title,
							$this->mAttribs['rc_timestamp'],
							$this->mAttribs['rc_comment'],
							$this->mAttribs['rc_minor'],
							$this->mAttribs['rc_last_oldid'],
							$this->mExtra['pageStatus']
						);
					},
					__METHOD__
				);
			}
		}

		$jobs = [];
		// Flush old entries from the `recentchanges` table
		if ( mt_rand( 0, 9 ) == 0 ) {
			$jobs[] = RecentChangesUpdateJob::newPurgeJob();
		}
		// Update the cached list of active users
		if ( $this->mAttribs['rc_user'] > 0 ) {
			$jobs[] = RecentChangesUpdateJob::newCacheUpdateJob();
		}
		$services->getJobQueueGroup()->lazyPush( $jobs );
	}

	/**
	 * Notify all the feeds about the change.
	 * @param array|null $feeds Optional feeds to send to, defaults to $wgRCFeeds
	 */
	public function notifyRCFeeds( ?array $feeds = null ) {
		$feeds ??=
			MediaWikiServices::getInstance()->getMainConfig()->get( MainConfigNames::RCFeeds );

		$performer = $this->getPerformerIdentity();

		foreach ( $feeds as $params ) {
			$params += [
				'omit_bots' => false,
				'omit_anon' => false,
				'omit_user' => false,
				'omit_minor' => false,
				'omit_patrolled' => false,
			];

			if (
				( $params['omit_bots'] && $this->mAttribs['rc_bot'] ) ||
				( $params['omit_anon'] && !$performer->isRegistered() ) ||
				( $params['omit_user'] && $performer->isRegistered() ) ||
				( $params['omit_minor'] && $this->mAttribs['rc_minor'] ) ||
				( $params['omit_patrolled'] && $this->mAttribs['rc_patrolled'] ) ||
				$this->mAttribs['rc_type'] == RC_EXTERNAL
			) {
				continue;
			}

			$actionComment = $this->mExtra['actionCommentIRC'] ?? null;

			$feed = RCFeed::factory( $params );
			$feed->notify( $this, $actionComment );
		}
	}

	/**
	 * Mark this RecentChange as patrolled
	 *
	 * NOTE: Can also return 'rcpatroldisabled', 'hookaborted' and
	 * 'markedaspatrollederror-noautopatrol' as errors
	 *
	 * @deprecated since 1.43 Use markPatrolled() instead
	 *
	 * @param Authority $performer User performing the action
	 * @param bool|null $auto Unused. Passing true logs a warning.
	 * @param string|string[]|null $tags Change tags to add to the patrol log entry
	 *   ($user should be able to add the specified tags before this is called)
	 * @return array[] Array of permissions errors, see PermissionManager::getPermissionErrors()
	 */
	public function doMarkPatrolled( Authority $performer, $auto = null, $tags = null ) {
		wfDeprecated( __METHOD__, '1.43' );
		if ( $auto ) {
			wfWarn( __METHOD__ . ' with $auto = true' );
			return [];
		}
		return $this->markPatrolled( $performer, $tags )->toLegacyErrorArray();
	}

	/**
	 * Mark this RecentChange as patrolled
	 *
	 * NOTE: Can also return 'rcpatroldisabled', 'hookaborted' and
	 * 'markedaspatrollederror-noautopatrol' as errors
	 *
	 * @param Authority $performer User performing the action
	 * @param string|string[]|null $tags Change tags to add to the patrol log entry
	 *   ($user should be able to add the specified tags before this is called)
	 * @return PermissionStatus
	 */
	public function markPatrolled( Authority $performer, $tags = null ): PermissionStatus {
		$services = MediaWikiServices::getInstance();
		$mainConfig = $services->getMainConfig();
		$useRCPatrol = $mainConfig->get( MainConfigNames::UseRCPatrol );
		$useNPPatrol = $mainConfig->get( MainConfigNames::UseNPPatrol );
		$useFilePatrol = $mainConfig->get( MainConfigNames::UseFilePatrol );
		// Fix up $tags so that the MarkPatrolled hook below always gets an array
		if ( $tags === null ) {
			$tags = [];
		} elseif ( is_string( $tags ) ) {
			$tags = [ $tags ];
		}

		$status = PermissionStatus::newEmpty();
		// If recentchanges patrol is disabled, only new pages or new file versions
		// can be patrolled, provided the appropriate config variable is set
		if ( !$useRCPatrol && ( !$useNPPatrol || $this->getAttribute( 'rc_type' ) != RC_NEW ) &&
			( !$useFilePatrol || !( $this->getAttribute( 'rc_type' ) == RC_LOG &&
			$this->getAttribute( 'rc_log_type' ) == 'upload' ) ) ) {
			$status->fatal( 'rcpatroldisabled' );
		}
		$performer->authorizeWrite( 'patrol', $this->getTitle(), $status );
		$user = $services->getUserFactory()->newFromAuthority( $performer );
		$hookRunner = new HookRunner( $services->getHookContainer() );
		if ( !$hookRunner->onMarkPatrolled(
			$this->getAttribute( 'rc_id' ), $user, false, false, $tags )
		) {
			$status->fatal( 'hookaborted' );
		}
		// Users without the 'autopatrol' right can't patrol their own revisions
		if ( $performer->getUser()->getName() === $this->getAttribute( 'rc_user_text' ) &&
			!$performer->isAllowed( 'autopatrol' )
		) {
			$status->fatal( 'markedaspatrollederror-noautopatrol' );
		}
		if ( !$status->isGood() ) {
			return $status;
		}
		// If the change was patrolled already, do nothing
		if ( $this->getAttribute( 'rc_patrolled' ) ) {
			return $status;
		}
		// Attempt to set the 'patrolled' flag in RC database
		$affectedRowCount = $this->reallyMarkPatrolled();

		if ( $affectedRowCount === 0 ) {
			// Query succeeded but no rows change, e.g. another request
			// patrolled the same change just before us.
			// Avoid duplicate log entry (T196182).
			return $status;
		}

		// Log this patrol event
		PatrolLog::record( $this, false, $performer->getUser(), $tags );

		$hookRunner->onMarkPatrolledComplete(
			$this->getAttribute( 'rc_id' ), $user, false, false );

		return $status;
	}

	/**
	 * Mark this RecentChange patrolled, without error checking
	 *
	 * @return int Number of database rows changed, usually 1, but 0 if
	 * another request already patrolled it in the mean time.
	 */
	public function reallyMarkPatrolled() {
		$dbw = MediaWikiServices::getInstance()->getConnectionProvider()->getPrimaryDatabase();
		$dbw->newUpdateQueryBuilder()
			->update( 'recentchanges' )
			->set( [ 'rc_patrolled' => self::PRC_PATROLLED ] )
			->where( [
				'rc_id' => $this->getAttribute( 'rc_id' ),
				'rc_patrolled' => self::PRC_UNPATROLLED,
			] )
			->caller( __METHOD__ )->execute();
		$affectedRowCount = $dbw->affectedRows();
		// The change was patrolled already, do nothing
		if ( $affectedRowCount === 0 ) {
			return 0;
		}
		// Invalidate the page cache after the page has been patrolled
		// to make sure that the Patrol link isn't visible any longer!
		$this->getTitle()->invalidateCache();

		// Enqueue a reverted tag update (in case the edit was a revert)
		$revisionId = $this->getAttribute( 'rc_this_oldid' );
		if ( $revisionId ) {
			$revertedTagUpdateManager =
				MediaWikiServices::getInstance()->getRevertedTagUpdateManager();
			$revertedTagUpdateManager->approveRevertedTagForRevision( $revisionId );
		}

		return $affectedRowCount;
	}

	/**
	 * Makes an entry in the database corresponding to an edit
	 *
	 * @since 1.36 Added $editResult parameter
	 *
	 * @param string $timestamp
	 * @param PageIdentity $page
	 * @param bool $minor
	 * @param UserIdentity $user
	 * @param string $comment
	 * @param int $oldId
	 * @param string $lastTimestamp
	 * @param bool $bot
	 * @param string $ip
	 * @param int $oldSize
	 * @param int $newSize
	 * @param int $newId
	 * @param int $patrol
	 * @param string[] $tags
	 * @param EditResult|null $editResult EditResult associated with this edit. Can be safely
	 *  skipped if the edit is not a revert. Used only for marking revert tags.
	 *
	 * @return RecentChange
	 */
	public static function notifyEdit(
		$timestamp, $page, $minor, $user, $comment, $oldId, $lastTimestamp,
		$bot, $ip = '', $oldSize = 0, $newSize = 0, $newId = 0, $patrol = 0,
		$tags = [], ?EditResult $editResult = null
	) {
		Assert::parameter( $page->exists(), '$page', 'must represent an existing page' );

		$rc = new RecentChange;
		$rc->mPage = $page;
		$rc->mPerformer = $user;
		$rc->mAttribs = [
			'rc_timestamp' => $timestamp,
			'rc_namespace' => $page->getNamespace(),
			'rc_title' => $page->getDBkey(),
			'rc_type' => RC_EDIT,
			'rc_source' => self::SRC_EDIT,
			'rc_minor' => $minor ? 1 : 0,
			'rc_cur_id' => $page->getId(),
			'rc_user' => $user->getId(),
			'rc_user_text' => $user->getName(),
			'rc_comment' => &$comment,
			'rc_comment_text' => &$comment,
			'rc_comment_data' => null,
			'rc_this_oldid' => (int)$newId,
			'rc_last_oldid' => $oldId,
			'rc_bot' => $bot ? 1 : 0,
			'rc_ip' => self::checkIPAddress( $ip ),
			'rc_patrolled' => intval( $patrol ),
			'rc_new' => 0, # obsolete
			'rc_old_len' => $oldSize,
			'rc_new_len' => $newSize,
			'rc_deleted' => 0,
			'rc_logid' => 0,
			'rc_log_type' => null,
			'rc_log_action' => '',
			'rc_params' => ''
		];

		// TODO: deprecate the 'prefixedDBkey' entry, let callers do the formatting.
		$formatter = MediaWikiServices::getInstance()->getTitleFormatter();

		$rc->mExtra = [
			'prefixedDBkey' => $formatter->getPrefixedDBkey( $page ),
			'lastTimestamp' => $lastTimestamp,
			'oldSize' => $oldSize,
			'newSize' => $newSize,
			'pageStatus' => 'changed'
		];

		DeferredUpdates::addCallableUpdate(
			static function () use ( $rc, $tags, $editResult ) {
				$rc->addTags( $tags );
				$rc->setEditResult( $editResult );
				$rc->save();
			},
			DeferredUpdates::POSTSEND,
			MediaWikiServices::getInstance()->getConnectionProvider()->getPrimaryDatabase()
		);

		return $rc;
	}

	/**
	 * Makes an entry in the database corresponding to page creation
	 * @note $page must reflect the state of the database after the page creation. In particular,
	 *       $page->getId() must return the newly assigned page ID.
	 *
	 * @param string $timestamp
	 * @param PageIdentity $page
	 * @param bool $minor
	 * @param UserIdentity $user
	 * @param string $comment
	 * @param bool $bot
	 * @param string $ip
	 * @param int $size
	 * @param int $newId
	 * @param int $patrol
	 * @param string[] $tags
	 *
	 * @return RecentChange
	 */
	public static function notifyNew(
		$timestamp,
		$page, $minor, $user, $comment, $bot,
		$ip = '', $size = 0, $newId = 0, $patrol = 0, $tags = []
	) {
		Assert::parameter( $page->exists(), '$page', 'must represent an existing page' );

		$rc = new RecentChange;
		$rc->mPage = $page;
		$rc->mPerformer = $user;
		$rc->mAttribs = [
			'rc_timestamp' => $timestamp,
			'rc_namespace' => $page->getNamespace(),
			'rc_title' => $page->getDBkey(),
			'rc_type' => RC_NEW,
			'rc_source' => self::SRC_NEW,
			'rc_minor' => $minor ? 1 : 0,
			'rc_cur_id' => $page->getId(),
			'rc_user' => $user->getId(),
			'rc_user_text' => $user->getName(),
			'rc_comment' => &$comment,
			'rc_comment_text' => &$comment,
			'rc_comment_data' => null,
			'rc_this_oldid' => (int)$newId,
			'rc_last_oldid' => 0,
			'rc_bot' => $bot ? 1 : 0,
			'rc_ip' => self::checkIPAddress( $ip ),
			'rc_patrolled' => intval( $patrol ),
			'rc_new' => 1, # obsolete
			'rc_old_len' => 0,
			'rc_new_len' => $size,
			'rc_deleted' => 0,
			'rc_logid' => 0,
			'rc_log_type' => null,
			'rc_log_action' => '',
			'rc_params' => ''
		];

		// TODO: deprecate the 'prefixedDBkey' entry, let callers do the formatting.
		$formatter = MediaWikiServices::getInstance()->getTitleFormatter();

		$rc->mExtra = [
			'prefixedDBkey' => $formatter->getPrefixedDBkey( $page ),
			'lastTimestamp' => 0,
			'oldSize' => 0,
			'newSize' => $size,
			'pageStatus' => 'created'
		];

		DeferredUpdates::addCallableUpdate(
			static function () use ( $rc, $tags ) {
				$rc->addTags( $tags );
				$rc->save();
			},
			DeferredUpdates::POSTSEND,
			MediaWikiServices::getInstance()->getConnectionProvider()->getPrimaryDatabase()
		);

		return $rc;
	}

	/**
	 * @param string $timestamp
	 * @param PageReference $logPage
	 * @param UserIdentity $user
	 * @param string $actionComment
	 * @param string $ip
	 * @param string $type
	 * @param string $action
	 * @param PageReference $target
	 * @param string $logComment
	 * @param string $params
	 * @param int $newId
	 * @param string $actionCommentIRC
	 *
	 * @return bool
	 */
	public static function notifyLog( $timestamp,
		$logPage, $user, $actionComment, $ip, $type,
		$action, $target, $logComment, $params, $newId = 0, $actionCommentIRC = ''
	) {
		$logRestrictions = MediaWikiServices::getInstance()->getMainConfig()
			->get( MainConfigNames::LogRestrictions );

		# Don't add private logs to RC!
		if ( isset( $logRestrictions[$type] ) && $logRestrictions[$type] != '*' ) {
			return false;
		}
		$rc = self::newLogEntry( $timestamp,
			$logPage, $user, $actionComment, $ip, $type, $action,
			$target, $logComment, $params, $newId, $actionCommentIRC );
		$rc->save();

		return true;
	}

	/**
	 * @param string $timestamp
	 * @param PageReference $logPage
	 * @param UserIdentity $user
	 * @param string $actionComment
	 * @param string $ip
	 * @param string $type
	 * @param string $action
	 * @param PageReference $target
	 * @param string $logComment
	 * @param string $params
	 * @param int $newId
	 * @param string $actionCommentIRC
	 * @param int $revId Id of associated revision, if any
	 * @param bool $isPatrollable Whether this log entry is patrollable
	 * @param bool|null $forceBotFlag Override the default behavior and set bot flag to
	 * 	the value of the argument. When omitted or null, it falls back to the global state.
	 *
	 * @return RecentChange
	 */
	public static function newLogEntry( $timestamp,
		$logPage, $user, $actionComment, $ip,
		$type, $action, $target, $logComment, $params, $newId = 0, $actionCommentIRC = '',
		$revId = 0, $isPatrollable = false, $forceBotFlag = null
	) {
		global $wgRequest;

		$permissionManager = MediaWikiServices::getInstance()->getPermissionManager();

		# # Get pageStatus for email notification
		switch ( $type . '-' . $action ) {
			case 'delete-delete':
			case 'delete-delete_redir':
			case 'delete-delete_redir2':
				$pageStatus = 'deleted';
				break;
			case 'move-move':
			case 'move-move_redir':
				$pageStatus = 'moved';
				break;
			case 'delete-restore':
				$pageStatus = 'restored';
				break;
			case 'upload-upload':
				$pageStatus = 'created';
				break;
			case 'upload-overwrite':
			default:
				$pageStatus = 'changed';
				break;
		}

		// Allow unpatrolled status for patrollable log entries
		$canAutopatrol = $permissionManager->userHasRight( $user, 'autopatrol' );
		$markPatrolled = $isPatrollable ? $canAutopatrol : true;

		if ( $target instanceof PageIdentity && $target->canExist() ) {
			$pageId = $target->getId();
		} else {
			$pageId = 0;
		}

		if ( $forceBotFlag !== null ) {
			$bot = (int)$forceBotFlag;
		} else {
			$bot = $permissionManager->userHasRight( $user, 'bot' ) ?
				(int)$wgRequest->getBool( 'bot', true ) : 0;
		}

		$rc = new RecentChange;
		$rc->mPage = $target;
		$rc->mPerformer = $user;
		$rc->mAttribs = [
			'rc_timestamp' => $timestamp,
			'rc_namespace' => $target->getNamespace(),
			'rc_title' => $target->getDBkey(),
			'rc_type' => RC_LOG,
			'rc_source' => self::SRC_LOG,
			'rc_minor' => 0,
			'rc_cur_id' => $pageId,
			'rc_user' => $user->getId(),
			'rc_user_text' => $user->getName(),
			'rc_comment' => &$logComment,
			'rc_comment_text' => &$logComment,
			'rc_comment_data' => null,
			'rc_this_oldid' => (int)$revId,
			'rc_last_oldid' => 0,
			'rc_bot' => $bot,
			'rc_ip' => self::checkIPAddress( $ip ),
			'rc_patrolled' => $markPatrolled ? self::PRC_AUTOPATROLLED : self::PRC_UNPATROLLED,
			'rc_new' => 0, # obsolete
			'rc_old_len' => null,
			'rc_new_len' => null,
			'rc_deleted' => 0,
			'rc_logid' => $newId,
			'rc_log_type' => $type,
			'rc_log_action' => $action,
			'rc_params' => $params
		];

		// TODO: deprecate the 'prefixedDBkey' entry, let callers do the formatting.
		$formatter = MediaWikiServices::getInstance()->getTitleFormatter();

		$rc->mExtra = [
			// XXX: This does not correspond to rc_namespace/rc_title/rc_cur_id.
			//      Is that intentional? For all other kinds of RC entries, prefixedDBkey
			//      matches rc_namespace/rc_title. Do we even need $logPage?
			'prefixedDBkey' => $formatter->getPrefixedDBkey( $logPage ),
			'lastTimestamp' => 0,
			'actionComment' => $actionComment, // the comment appended to the action, passed from LogPage
			'pageStatus' => $pageStatus,
			'actionCommentIRC' => $actionCommentIRC
		];

		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.27
	 *
	 * @param string $timestamp Timestamp of the recent change to occur
	 * @param PageIdentity $categoryTitle the category a page is being added to or removed from
	 * @param UserIdentity|null $user User object of the user that made the change
	 * @param string $comment Change summary
	 * @param PageIdentity $pageTitle 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
	 * @param bool|null $added true, if the category was added, false for removed
	 * @param bool $forImport Whether the associated revision was imported
	 *
	 * @return RecentChange
	 */
	public static function newForCategorization(
		$timestamp,
		PageIdentity $categoryTitle,
		?UserIdentity $user,
		$comment,
		PageIdentity $pageTitle,
		$oldRevId,
		$newRevId,
		$lastTimestamp,
		$bot,
		$ip = '',
		$deleted = 0,
		$added = null,
		bool $forImport = false
	) {
		// Done in a backwards compatible way.
		$categoryWikiPage = MediaWikiServices::getInstance()->getWikiPageFactory()
			->newFromTitle( $categoryTitle );

		'@phan-var WikiCategoryPage $categoryWikiPage';
		$params = [
			'hidden-cat' => $categoryWikiPage->isHidden()
		];
		if ( $added !== null ) {
			$params['added'] = $added;
		}

		if ( !$user ) {
			// XXX: when and why do we need this?
			$user = MediaWikiServices::getInstance()->getActorStore()->getUnknownActor();
		}

		$rc = new RecentChange;
		$rc->mPage = $categoryTitle;
		$rc->mPerformer = $user;
		$rc->mAttribs = [
			'rc_timestamp' => MWTimestamp::convert( TS_MW, $timestamp ),
			'rc_namespace' => $categoryTitle->getNamespace(),
			'rc_title' => $categoryTitle->getDBkey(),
			'rc_type' => RC_CATEGORIZE,
			'rc_source' => self::SRC_CATEGORIZE,
			'rc_minor' => 0,
			// XXX: rc_cur_id does not correspond to rc_namespace/rc_title.
			// It's because when the page (rc_cur_id) is deleted, we want
			// to delete the categorization entries, too (see LinksDeletionUpdate).
			'rc_cur_id' => $pageTitle->getId(),
			'rc_user' => $user->getId(),
			'rc_user_text' => $user->getName(),
			'rc_comment' => &$comment,
			'rc_comment_text' => &$comment,
			'rc_comment_data' => null,
			'rc_this_oldid' => (int)$newRevId,
			'rc_last_oldid' => $oldRevId,
			'rc_bot' => $bot ? 1 : 0,
			'rc_ip' => self::checkIPAddress( $ip ),
			'rc_patrolled' => self::PRC_AUTOPATROLLED, // Always patrolled, just like log entries
			'rc_new' => 0, # obsolete
			'rc_old_len' => null,
			'rc_new_len' => null,
			'rc_deleted' => $deleted,
			'rc_logid' => 0,
			'rc_log_type' => null,
			'rc_log_action' => '',
			'rc_params' => serialize( $params )
		];

		// TODO: deprecate the 'prefixedDBkey' entry, let callers do the formatting.
		$formatter = MediaWikiServices::getInstance()->getTitleFormatter();

		$rc->mExtra = [
			'prefixedDBkey' => $formatter->getPrefixedDBkey( $categoryTitle ),
			'lastTimestamp' => $lastTimestamp,
			'oldSize' => 0,
			'newSize' => 0,
			'pageStatus' => 'changed',
			'forImport' => $forImport,
		];

		return $rc;
	}

	/**
	 * Get a parameter value
	 *
	 * @since 1.27
	 *
	 * @param string $name parameter name
	 * @return mixed
	 */
	public function getParam( $name ) {
		$params = $this->parseParams();
		return $params[$name] ?? null;
	}

	/**
	 * Initialises the members of this object from a mysql row object
	 *
	 * @param mixed $row
	 */
	public function loadFromRow( $row ) {
		$this->mAttribs = get_object_vars( $row );
		$this->mAttribs['rc_timestamp'] = wfTimestamp( TS_MW, $this->mAttribs['rc_timestamp'] );
		// rc_deleted MUST be set
		$this->mAttribs['rc_deleted'] = $row->rc_deleted;

		$dbr = MediaWikiServices::getInstance()->getConnectionProvider()->getReplicaDatabase();
		$comment = MediaWikiServices::getInstance()->getCommentStore()
			// Legacy because $row may have come from self::selectFields()
			->getCommentLegacy( $dbr, 'rc_comment', $row, true )
			->text;
		$this->mAttribs['rc_comment'] = &$comment;
		$this->mAttribs['rc_comment_text'] = &$comment;
		$this->mAttribs['rc_comment_data'] = null;

		$this->mPerformer = $this->getUserIdentityFromAnyId(
			$row->rc_user ?? null,
			$row->rc_user_text ?? null,
			$row->rc_actor ?? null
		);
		$this->mAttribs['rc_user'] = $this->mPerformer->getId();
		$this->mAttribs['rc_user_text'] = $this->mPerformer->getName();

		// Watchlist expiry.
		if ( isset( $row->we_expiry ) && $row->we_expiry ) {
			$this->watchlistExpiry = wfTimestamp( TS_MW, $row->we_expiry );
		}
	}

	/**
	 * Get an attribute value
	 *
	 * @param string $name Attribute name
	 * @return mixed
	 */
	public function getAttribute( $name ) {
		if ( $name === 'rc_comment' ) {
			return MediaWikiServices::getInstance()->getCommentStore()
				->getComment( 'rc_comment', $this->mAttribs, true )->text;
		}

		if ( $name === 'rc_user' || $name === 'rc_user_text' || $name === 'rc_actor' ) {
			$user = $this->getPerformerIdentity();

			if ( $name === 'rc_user' ) {
				return $user->getId();
			}
			if ( $name === 'rc_user_text' ) {
				return $user->getName();
			}
			if ( $name === 'rc_actor' ) {
				// NOTE: rc_actor exists in the database, but application logic should not use it.
				wfDeprecatedMsg( 'Accessing deprecated field rc_actor', '1.36' );
				$actorStore = MediaWikiServices::getInstance()->getActorStore();
				$db = MediaWikiServices::getInstance()->getConnectionProvider()->getReplicaDatabase();
				return $actorStore->findActorId( $user, $db );
			}
		}

		return $this->mAttribs[$name] ?? null;
	}

	/**
	 * @return array
	 */
	public function getAttributes() {
		return $this->mAttribs;
	}

	/**
	 * Gets the end part of the diff URL associated with this object
	 * Blank if no diff link should be displayed
	 * @param bool $forceCur
	 * @return string
	 */
	public function diffLinkTrail( $forceCur ) {
		if ( $this->mAttribs['rc_type'] == RC_EDIT ) {
			$trail = "curid=" . (int)( $this->mAttribs['rc_cur_id'] ) .
				"&oldid=" . (int)( $this->mAttribs['rc_last_oldid'] );
			if ( $forceCur ) {
				$trail .= '&diff=0';
			} else {
				$trail .= '&diff=' . (int)( $this->mAttribs['rc_this_oldid'] );
			}
		} else {
			$trail = '';
		}

		return $trail;
	}

	/**
	 * Returns the change size (HTML).
	 * The lengths can be given optionally.
	 * @param int $old
	 * @param int $new
	 * @return string
	 */
	public function getCharacterDifference( $old = 0, $new = 0 ) {
		if ( $old === 0 ) {
			$old = $this->mAttribs['rc_old_len'];
		}
		if ( $new === 0 ) {
			$new = $this->mAttribs['rc_new_len'];
		}
		if ( $old === null || $new === null ) {
			return '';
		}

		return ChangesList::showCharacterDifference( $old, $new );
	}

	private static function checkIPAddress( $ip ) {
		global $wgRequest;

		if ( $ip ) {
			if ( !IPUtils::isIPAddress( $ip ) ) {
				throw new RuntimeException( "Attempt to write \"" . $ip .
					"\" as an IP address into recent changes" );
			}
		} else {
			$ip = $wgRequest->getIP();
			if ( !$ip ) {
				$ip = '';
			}
		}

		return $ip;
	}

	/**
	 * Check whether the given timestamp is new enough to have a RC row with a given tolerance
	 * as the recentchanges table might not be cleared out regularly (so older entries might exist)
	 * or rows which will be deleted soon shouldn't be included.
	 *
	 * @param mixed $timestamp MWTimestamp compatible timestamp
	 * @param int $tolerance Tolerance in seconds
	 * @return bool
	 */
	public static function isInRCLifespan( $timestamp, $tolerance = 0 ) {
		$rcMaxAge =
			MediaWikiServices::getInstance()->getMainConfig()->get( MainConfigNames::RCMaxAge );

		return (int)wfTimestamp( TS_UNIX, $timestamp ) > time() - $tolerance - $rcMaxAge;
	}

	/**
	 * Whether e-mail notifications are generally enabled on this wiki.
	 *
	 * This is used for:
	 *
	 * - performance optimization in RecentChange::save().
	 *   After an edit, whether or not we need to use the EmailNotification
	 *   service to determine which EnotifNotifyJob to dispatch.
	 *
	 * - performance optmization in WatchlistManager.
	 *   After using reset ("Mark all pages as seen") on Special:Watchlist,
	 *   whether to only look for user talk data to reset, or whether to look
	 *   at all possible pages for timestamps to reset.
	 *
	 * TODO: Determine whether these optimizations still make sense.
	 *
	 * FIXME: The $wgShowUpdatedMarker variable was added to this condtion
	 * in 2008 (2cf12c973d, SVN r35001) because at the time the per-user
	 * "last seen" marker for watchlist and page history, was managed by
	 * the EmailNotification/UserMailed classes. As of August 2022, this
	 * appears to no longer be the case.
	 *
	 * @since 1.40
	 * @param Config $conf
	 * @return bool
	 */
	public static function isEnotifEnabled( Config $conf ): bool {
		return $conf->get( MainConfigNames::EnotifUserTalk ) ||
			$conf->get( MainConfigNames::EnotifWatchlist ) ||
			$conf->get( MainConfigNames::ShowUpdatedMarker );
	}

	/**
	 * Get the extra URL that is given as part of the notification to RCFeed consumers.
	 *
	 * This is mainly to facilitate patrolling or other content review.
	 *
	 * @since 1.40
	 * @return string|null URL
	 */
	public function getNotifyUrl() {
		$services = MediaWikiServices::getInstance();
		$mainConfig = $services->getMainConfig();
		$useRCPatrol = $mainConfig->get( MainConfigNames::UseRCPatrol );
		$useNPPatrol = $mainConfig->get( MainConfigNames::UseNPPatrol );
		$localInterwikis = $mainConfig->get( MainConfigNames::LocalInterwikis );
		$canonicalServer = $mainConfig->get( MainConfigNames::CanonicalServer );
		$script = $mainConfig->get( MainConfigNames::Script );

		$type = $this->getAttribute( 'rc_type' );
		if ( $type == RC_LOG ) {
			$url = null;
		} else {
			$url = $canonicalServer . $script;
			if ( $type == RC_NEW ) {
				$query = '?oldid=' . $this->getAttribute( 'rc_this_oldid' );
			} else {
				$query = '?diff=' . $this->getAttribute( 'rc_this_oldid' )
					. '&oldid=' . $this->getAttribute( 'rc_last_oldid' );
			}
			if ( $useRCPatrol || ( $this->getAttribute( 'rc_type' ) == RC_NEW && $useNPPatrol ) ) {
				$query .= '&rcid=' . $this->getAttribute( 'rc_id' );
			}

			( new HookRunner( $services->getHookContainer() ) )->onIRCLineURL( $url, $query, $this );
			$url .= $query;
		}

		return $url;
	}

	/**
	 * Parses and returns the rc_params attribute
	 *
	 * @since 1.26
	 * @return mixed|bool false on failed unserialization
	 */
	public function parseParams() {
		$rcParams = $this->getAttribute( 'rc_params' );

		AtEase::suppressWarnings();
		$unserializedParams = unserialize( $rcParams );
		AtEase::restoreWarnings();

		return $unserializedParams;
	}

	/**
	 * Tags to append to the recent change,
	 * and associated revision/log
	 *
	 * @since 1.28
	 *
	 * @param string|string[] $tags
	 */
	public function addTags( $tags ) {
		if ( is_string( $tags ) ) {
			$this->tags[] = $tags;
		} else {
			$this->tags = array_merge( $tags, $this->tags );
		}
	}

	/**
	 * Sets the EditResult associated with the edit.
	 *
	 * @since 1.36
	 *
	 * @param EditResult|null $editResult
	 */
	public function setEditResult( ?EditResult $editResult ) {
		$this->editResult = $editResult;
	}

	/**
	 * @param string|int|null $userId
	 * @param string|null $userName
	 * @param string|int|null $actorId
	 *
	 * @return UserIdentity
	 */
	private function getUserIdentityFromAnyId(
		$userId,
		$userName,
		$actorId = null
	): UserIdentity {
		// XXX: Is this logic needed elsewhere? Should it be reusable?

		$userId = isset( $userId ) ? (int)$userId : null;
		$actorId = isset( $actorId ) ? (int)$actorId : 0;

		$actorStore = MediaWikiServices::getInstance()->getActorStore();
		if ( $userName && $actorId ) {
			// Likely the fields are coming from a join on actor table,
			// so can definitely build a UserIdentityValue.
			return $actorStore->newActorFromRowFields( $userId, $userName, $actorId );
		}
		if ( $userId !== null ) {
			if ( $userName !== null ) {
				// NOTE: For IPs and external users, $userId will be 0.
				$user = new UserIdentityValue( $userId, $userName );
			} else {
				$user = $actorStore->getUserIdentityByUserId( $userId );

				if ( !$user ) {
					throw new RuntimeException( "User not found by ID: $userId" );
				}
			}
		} elseif ( $actorId > 0 ) {
			$db = MediaWikiServices::getInstance()->getConnectionProvider()->getReplicaDatabase();
			$user = $actorStore->getActorById( $actorId, $db );

			if ( !$user ) {
				throw new RuntimeException( "User not found by actor ID: $actorId" );
			}
		} elseif ( $userName !== null ) {
			$user = $actorStore->getUserIdentityByName( $userName );

			if ( !$user ) {
				throw new RuntimeException( "User not found by name: $userName" );
			}
		} else {
			throw new RuntimeException( 'At least one of user ID, actor ID or user name must be given' );
		}

		return $user;
	}
}
PK       ! ÅĢ  Ģ    ChangesListBooleanFilter.phpnu ÕIw¶“        <?php
/**
 * 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
 */

use MediaWiki\Html\FormOptions;
use MediaWiki\SpecialPage\ChangesListSpecialPage;
use Wikimedia\Rdbms\IReadableDatabase;

/**
 * Represents a hide-based boolean filter (used on ChangesListSpecialPage and descendants)
 *
 * @since 1.29
 * @ingroup RecentChanges
 * @author Matthew Flaschen
 */
class ChangesListBooleanFilter extends ChangesListFilter {
	/**
	 * Main unstructured UI i18n key
	 *
	 * @var string
	 */
	protected $showHide;

	/**
	 * Whether there is a feature designed to replace this filter available on the
	 * structured UI
	 *
	 * @var bool
	 */
	protected $isReplacedInStructuredUi;

	/**
	 * Default
	 *
	 * @var bool
	 */
	protected $defaultValue;

	/**
	 * Callable used to do the actual query modification; see constructor
	 *
	 * @var callable
	 */
	protected $queryCallable;

	/**
	 * Value that defined when this filter is considered active
	 *
	 * @var bool
	 */
	protected $activeValue;

	/**
	 * Create a new filter with the specified configuration.
	 *
	 * It infers which UI (it can be either or both) to display the filter on based on
	 * which messages are provided.
	 *
	 * If 'label' is provided, it will be displayed on the structured UI.  If
	 * 'showHide' is provided, it will be displayed on the unstructured UI.  Thus,
	 * 'label', 'description', and 'showHide' are optional depending on which UI
	 * it's for.
	 *
	 * @param array $filterDefinition ChangesListFilter definition
	 * * $filterDefinition['name'] string Name.  Used as URL parameter.
	 * * $filterDefinition['group'] ChangesListFilterGroup Group.  Filter group this
	 *     belongs to.
	 * * $filterDefinition['label'] string i18n key of label for structured UI.
	 * * $filterDefinition['description'] string i18n key of description for structured
	 *     UI.
	 * * $filterDefinition['showHide'] string Main i18n key used for unstructured UI.
	 * * $filterDefinition['isReplacedInStructuredUi'] bool Whether there is an
	 *     equivalent feature available in the structured UI; this is optional, defaulting
	 *     to true.  It does not need to be set if the exact same filter is simply visible
	 *     on both.
	 * * $filterDefinition['default'] bool Default
	 * * $filterDefinition['activeValue'] bool This filter is considered active when
	 *     its value is equal to its activeValue. Default is true.
	 * * $filterDefinition['priority'] int Priority integer.  Higher value means higher
	 *     up in the group's filter list.
	 * * $filterDefinition['queryCallable'] callable Callable accepting parameters, used
	 *     to implement filter's DB query modification.  Required, except for legacy
	 *     filters that still use the query hooks directly.  Callback parameters:
	 * 	* string $specialPageClassName Class name of current special page
	 * 	* IContextSource $context Context, for e.g. user
	 * 	* IDatabase $dbr Database, for addQuotes, makeList, and similar
	 * 	* array &$tables Array of tables; see IDatabase::select $table
	 * 	* array &$fields Array of fields; see IDatabase::select $vars
	 * 	* array &$conds Array of conditions; see IDatabase::select $conds
	 * 	* array &$query_options Array of query options; see IDatabase::select $options
	 * 	* array &$join_conds Array of join conditions; see IDatabase::select $join_conds
	 */
	public function __construct( $filterDefinition ) {
		parent::__construct( $filterDefinition );

		if ( isset( $filterDefinition['showHide'] ) ) {
			$this->showHide = $filterDefinition['showHide'];
		}

		$this->isReplacedInStructuredUi = $filterDefinition['isReplacedInStructuredUi'] ?? false;

		if ( isset( $filterDefinition['default'] ) ) {
			$this->setDefault( $filterDefinition['default'] );
		} else {
			throw new InvalidArgumentException( 'You must set a default' );
		}

		if ( isset( $filterDefinition['queryCallable'] ) ) {
			$this->queryCallable = $filterDefinition['queryCallable'];
		}

		$this->activeValue = $filterDefinition['activeValue'] ?? true;
	}

	/**
	 * Get the default value
	 *
	 * @param bool $structuredUI Are we currently showing the structured UI
	 * @return bool|null Default value
	 */
	public function getDefault( $structuredUI = false ) {
		return $this->isReplacedInStructuredUi && $structuredUI ?
			!$this->activeValue :
			$this->defaultValue;
	}

	/**
	 * Sets default.  It must be a boolean.
	 *
	 * It will be coerced to boolean.
	 *
	 * @param bool $defaultValue
	 */
	public function setDefault( $defaultValue ) {
		$this->defaultValue = (bool)$defaultValue;
	}

	/**
	 * @return string Main i18n key for unstructured UI
	 */
	public function getShowHide() {
		return $this->showHide;
	}

	/**
	 * @inheritDoc
	 */
	public function displaysOnUnstructuredUi() {
		return (bool)$this->showHide;
	}

	/**
	 * @inheritDoc
	 */
	public function isFeatureAvailableOnStructuredUi() {
		return $this->isReplacedInStructuredUi ||
			parent::isFeatureAvailableOnStructuredUi();
	}

	/**
	 * Modifies the query to include the filter.  This is only called if the filter is
	 * in effect (taking into account the default).
	 *
	 * @param IReadableDatabase $dbr Database, for addQuotes, makeList, and similar
	 * @param ChangesListSpecialPage $specialPage Current special page
	 * @param array &$tables Array of tables; see IDatabase::select $table
	 * @param array &$fields Array of fields; see IDatabase::select $vars
	 * @param array &$conds Array of conditions; see IDatabase::select $conds
	 * @param array &$query_options Array of query options; see IDatabase::select $options
	 * @param array &$join_conds Array of join conditions; see IDatabase::select $join_conds
	 */
	public function modifyQuery( IReadableDatabase $dbr, ChangesListSpecialPage $specialPage,
		&$tables, &$fields, &$conds, &$query_options, &$join_conds
	) {
		if ( $this->queryCallable === null ) {
			return;
		}

		( $this->queryCallable )(
			get_class( $specialPage ),
			$specialPage->getContext(),
			$dbr,
			$tables,
			$fields,
			$conds,
			$query_options,
			$join_conds
		);
	}

	/**
	 * @inheritDoc
	 */
	public function getJsData() {
		$output = parent::getJsData();

		$output['default'] = $this->defaultValue;

		return $output;
	}

	/**
	 * @inheritDoc
	 */
	public function isSelected( FormOptions $opts ) {
		return !$opts[ $this->getName() ] &&
			array_filter(
				$this->getSiblings(),
				static function ( ChangesListBooleanFilter $sibling ) use ( $opts ) {
					return $opts[ $sibling->getName() ];
				}
			);
	}

	/**
	 * @param FormOptions $opts Query parameters merged with defaults
	 * @param bool $isStructuredUI Whether the structured UI is currently enabled
	 * @return bool Whether this filter should be considered active
	 */
	public function isActive( FormOptions $opts, $isStructuredUI ) {
		if ( $this->isReplacedInStructuredUi && $isStructuredUI ) {
			return false;
		}

		return $opts[ $this->getName() ] === $this->activeValue;
	}
}
PK       ! 
"    )  RCFeed/MachineReadableRCFeedFormatter.phpnu ÕIw¶“        <?php

/**
 * 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
 */

namespace MediaWiki\RCFeed;

use MediaWiki\MainConfigNames;
use MediaWiki\MediaWikiServices;
use MediaWiki\WikiMap\WikiMap;
use RecentChange;

/**
 * Abstract class so there can be multiple formatters outputting the same data
 *
 * @since 1.23
 * @ingroup RecentChanges
 */
abstract class MachineReadableRCFeedFormatter implements RCFeedFormatter {

	/**
	 * Take the packet and return the formatted string
	 *
	 * @param array $packet
	 *
	 * @return string
	 */
	abstract protected function formatArray( array $packet );

	/**
	 * Generates a notification that can be easily interpreted by a machine.
	 *
	 * @see RCFeedFormatter::getLine
	 *
	 * @param array $feed
	 * @param RecentChange $rc
	 * @param string|null $actionComment
	 *
	 * @return string|null
	 */
	public function getLine( array $feed, RecentChange $rc, $actionComment ) {
		$mainConfig = MediaWikiServices::getInstance()->getMainConfig();
		$canonicalServer = $mainConfig->get( MainConfigNames::CanonicalServer );
		$serverName = $mainConfig->get( MainConfigNames::ServerName );
		$scriptPath = $mainConfig->get( MainConfigNames::ScriptPath );
		$packet = [
			// Usually, RC ID is exposed only for patrolling purposes,
			// but there is no real reason not to expose it in other cases,
			// and I can see how this may be potentially useful for clients.
			'id' => $rc->getAttribute( 'rc_id' ),
			'type' => RecentChange::parseFromRCType( $rc->getAttribute( 'rc_type' ) ),
			'namespace' => $rc->getTitle()->getNamespace(),
			'title' => $rc->getTitle()->getPrefixedText(),
			'title_url' => $rc->getTitle()->getCanonicalURL(),
			'comment' => $rc->getAttribute( 'rc_comment' ),
			'timestamp' => (int)wfTimestamp( TS_UNIX, $rc->getAttribute( 'rc_timestamp' ) ),
			'user' => $rc->getAttribute( 'rc_user_text' ),
			'bot' => (bool)$rc->getAttribute( 'rc_bot' ),
			'notify_url' => $rc->getNotifyUrl(),
		];

		if ( isset( $feed['channel'] ) ) {
			$packet['channel'] = $feed['channel'];
		}

		$type = $rc->getAttribute( 'rc_type' );
		if ( $type == RC_EDIT || $type == RC_NEW ) {
			$useRCPatrol = MediaWikiServices::getInstance()->getMainConfig()->get( MainConfigNames::UseRCPatrol );
			$useNPPatrol = MediaWikiServices::getInstance()->getMainConfig()->get( MainConfigNames::UseNPPatrol );
			$packet['minor'] = (bool)$rc->getAttribute( 'rc_minor' );
			if ( $useRCPatrol || ( $type == RC_NEW && $useNPPatrol ) ) {
				$packet['patrolled'] = (bool)$rc->getAttribute( 'rc_patrolled' );
			}
		}

		switch ( $type ) {
			case RC_EDIT:
				$packet['length'] = [
					'old' => $rc->getAttribute( 'rc_old_len' ),
					'new' => $rc->getAttribute( 'rc_new_len' )
				];
				$packet['revision'] = [
					'old' => $rc->getAttribute( 'rc_last_oldid' ),
					'new' => $rc->getAttribute( 'rc_this_oldid' )
				];
				break;

			case RC_NEW:
				$packet['length'] = [ 'old' => null, 'new' => $rc->getAttribute( 'rc_new_len' ) ];
				$packet['revision'] = [ 'old' => null, 'new' => $rc->getAttribute( 'rc_this_oldid' ) ];
				break;

			case RC_LOG:
				$packet['log_id'] = $rc->getAttribute( 'rc_logid' );
				$packet['log_type'] = $rc->getAttribute( 'rc_log_type' );
				$packet['log_action'] = $rc->getAttribute( 'rc_log_action' );
				if ( $rc->getAttribute( 'rc_params' ) ) {
					$params = $rc->parseParams();
					if (
						// If it's an actual serialised false...
						$rc->getAttribute( 'rc_params' ) == serialize( false ) ||
						// Or if we did not get false back when trying to unserialise
						$params !== false
					) {
						// From ApiQueryLogEvents::addLogParams
						$logParams = [];
						// Keys like "4::paramname" can't be used for output so we change them to "paramname"
						foreach ( $params as $key => $value ) {
							if ( strpos( $key, ':' ) === false ) {
								$logParams[$key] = $value;
								continue;
							}
							$logParam = explode( ':', $key, 3 );
							$logParams[$logParam[2]] = $value;
						}
						$packet['log_params'] = $logParams;
					} else {
						$packet['log_params'] = explode( "\n", $rc->getAttribute( 'rc_params' ) );
					}
				}
				$packet['log_action_comment'] = $actionComment;
				break;
		}

		$packet['server_url'] = $canonicalServer;
		$packet['server_name'] = $serverName;

		$packet['server_script_path'] = $scriptPath ?: '/';
		$packet['wiki'] = WikiMap::getCurrentWikiId();

		return $this->formatArray( $packet );
	}
}
/** @deprecated class alias since 1.43 */
class_alias( MachineReadableRCFeedFormatter::class, 'MachineReadableRCFeedFormatter' );
PK       ! )Av¦  ¦    RCFeed/XMLRCFeedFormatter.phpnu ÕIw¶“        <?php
/**
 * 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
 */

namespace MediaWiki\RCFeed;

use MediaWiki\Api\ApiFormatXml;

/**
 * @since 1.23
 * @ingroup RecentChanges
 */
class XMLRCFeedFormatter extends MachineReadableRCFeedFormatter {

	protected function formatArray( array $packet ) {
		return ApiFormatXml::recXmlPrint( 'recentchange', $packet, 0 );
	}
}
/** @deprecated class alias since 1.43 */
class_alias( XMLRCFeedFormatter::class, 'XMLRCFeedFormatter' );
PK       ! Źš-	  -	    RCFeed/FormattedRCFeed.phpnu ÕIw¶“        <?php
/**
 * 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
 */

namespace MediaWiki\RCFeed;

use RecentChange;

/**
 * Base class for RCFeed implementations that use RCFeedFormatter.
 *
 * Parameters:
 *  - formatter: [required] Which RCFeedFormatter class to use.
 *
 * @see $wgRCFeeds
 * @since 1.29
 * @ingroup RecentChanges
 */
abstract class FormattedRCFeed extends RCFeed {
	private array $params;

	/**
	 * @param array $params
	 */
	public function __construct( array $params ) {
		$this->params = $params;
	}

	/**
	 * Send some text to the specified feed.
	 *
	 * @param array $feed The feed, as configured in an associative array
	 * @param string $line The text to send
	 * @return bool Success
	 */
	abstract public function send( array $feed, $line );

	/**
	 * @param RecentChange $rc
	 * @param string|null $actionComment
	 * @return bool Success
	 */
	public function notify( RecentChange $rc, $actionComment = null ) {
		$params = $this->params;
		/** @var RCFeedFormatter $formatter */
		$formatter = is_object( $params['formatter'] ) ? $params['formatter'] : new $params['formatter'];

		$line = $formatter->getLine( $params, $rc, $actionComment );
		if ( !$line ) {
			// @codeCoverageIgnoreStart
			// T109544 - If a feed formatter returns null, this will otherwise cause an
			// error in at least RedisPubSubFeedEngine. Not sure best to handle this.
			// @phan-suppress-next-line PhanTypeMismatchReturnProbablyReal
			return;
			// @codeCoverageIgnoreEnd
		}
		return $this->send( $params, $line );
	}
}
/** @deprecated class alias since 1.43 */
class_alias( FormattedRCFeed::class, 'FormattedRCFeed' );
PK       ! TĢ7§ņ	  ņ	    RCFeed/RCFeed.phpnu ÕIw¶“        <?php
/**
 * 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
 */

namespace MediaWiki\RCFeed;

use InvalidArgumentException;
use RecentChange;

/**
 * @see $wgRCFeeds
 * @since 1.29
 * @ingroup RecentChanges
 */
abstract class RCFeed {
	/**
	 * @param array $params
	 */
	public function __construct( array $params = [] ) {
	}

	/**
	 * Dispatch the recent changes notification.
	 *
	 * @param RecentChange $rc
	 * @param string|null $actionComment
	 * @return bool Success
	 */
	abstract public function notify( RecentChange $rc, $actionComment = null );

	/**
	 * @param array $params
	 * @return RCFeed
	 */
	final public static function factory( array $params ): RCFeed {
		if ( !isset( $params['class'] ) ) {
			if ( !isset( $params['uri'] ) ) {
				throw new InvalidArgumentException( 'RCFeeds must have a class set' );
			}
			if ( strpos( $params['uri'], 'udp:' ) === 0 ) {
				$params['class'] = UDPRCFeedEngine::class;
			} elseif ( strpos( $params['uri'], 'redis:' ) === 0 ) {
				$params['class'] = RedisPubSubFeedEngine::class;
			} else {
				global $wgRCEngines;
				wfDeprecated( '$wgRCFeeds without class', '1.38' );
				$scheme = parse_url( $params['uri'], PHP_URL_SCHEME );
				if ( !$scheme ) {
					throw new InvalidArgumentException( "Invalid RCFeed uri: {$params['uri']}" );
				}
				if ( !isset( $wgRCEngines[$scheme] ) ) {
					throw new InvalidArgumentException( "Unknown RCFeed engine: $scheme" );
				}
				$params['class'] = $wgRCEngines[$scheme];
			}
		}

		$class = $params['class'];
		if ( defined( 'MW_PHPUNIT_TEST' ) && is_object( $class ) ) {
			return $class;
		}
		if ( !class_exists( $class ) ) {
			throw new InvalidArgumentException( "Unknown class '$class'." );
		}
		return new $class( $params );
	}
}
/** @deprecated class alias since 1.43 */
class_alias( RCFeed::class, 'RCFeed' );
PK       ! ®fŠł  ł    RCFeed/Hook/IRCLineURLHook.phpnu ÕIw¶“        <?php

namespace MediaWiki\Hook;

use RecentChange;

/**
 * This is a hook handler interface, see docs/Hooks.md.
 * Use the hook name "IRCLineURL" to register handlers implementing this interface.
 *
 * @stable to implement
 * @ingroup Hooks
 */
interface IRCLineURLHook {
	/**
	 * This hook is called when constructing the URL to use in an RCFeed notification.
	 * Callee may modify $url and $query; URL will be constructed as $url . $query
	 *
	 * @since 1.35
	 *
	 * @param string &$url URL to index.php
	 * @param string &$query Query string
	 * @param RecentChange $rc RecentChange object that triggered URL generation
	 * @return bool|void True or no return value to continue or false to abort
	 */
	public function onIRCLineURL( &$url, &$query, $rc );
}
PK       ! Y„sĻŌ
  Ō
     RCFeed/RedisPubSubFeedEngine.phpnu ÕIw¶“        <?php
/**
 * 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
 */

namespace MediaWiki\RCFeed;

use Wikimedia\ObjectCache\RedisConnectionPool;

/**
 * Send recent change to a Redis Pub/Sub channel.
 *
 * If the feed URI contains a path component, it will be used to generate a
 * channel name by stripping the leading slash and replacing any remaining
 * slashes with '.'. If no path component is present, the channel is set to
 * 'rc'. If the URI contains a query string, its parameters will be parsed
 * as RedisConnectionPool options.
 *
 * Parameters:
 * - `formatter`: (Required) Which RCFeedFormatter class to use.
 * - `uri`: (Required) Where to send the messages.
 *
 * @par Example:
 * @code
 * $wgRCFeeds['rc-to-redis'] = [
 *      'class' => 'RedisPubSubFeedEngine',
 *      'formatter' => 'JSONRCFeedFormatter',
 *      'uri' => "redis://127.0.0.1:6379/rc.$wgDBname",
 * ];
 * @endcode
 *
 * @see $wgRCFeeds
 * @since 1.22
 * @ingroup RecentChanges
 */
class RedisPubSubFeedEngine extends FormattedRCFeed {

	/**
	 * @see FormattedRCFeed::send
	 * @param array $feed
	 * @param string $line
	 * @return bool
	 */
	public function send( array $feed, $line ) {
		$parsed = wfGetUrlUtils()->parse( $feed['uri'] );
		// @phan-suppress-next-line PhanTypeArraySuspiciousNullable Valid URL
		$server = $parsed['host'];
		$options = [ 'serializer' => 'none' ];
		$channel = 'rc';

		if ( isset( $parsed['port'] ) ) {
			$server .= ":{$parsed['port']}";
		}
		if ( isset( $parsed['query'] ) ) {
			parse_str( $parsed['query'], $options );
		}
		if ( isset( $parsed['pass'] ) ) {
			$options['password'] = $parsed['pass'];
		}
		if ( isset( $parsed['path'] ) ) {
			$channel = str_replace( '/', '.', ltrim( $parsed['path'], '/' ) );
		}
		$pool = RedisConnectionPool::singleton( $options );
		$conn = $pool->getConnection( $server );
		if ( $conn !== false ) {
			$conn->publish( $channel, $line );
			return true;
		}

		return false;
	}
}
/** @deprecated class alias since 1.43 */
class_alias( RedisPubSubFeedEngine::class, 'RedisPubSubFeedEngine' );
PK       ! [ėø  ø    RCFeed/UDPRCFeedEngine.phpnu ÕIw¶“        <?php
/**
 * 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
 */

namespace MediaWiki\RCFeed;

use UDPTransport;

/**
 * Send recent change notifications to a destination address over UDP.
 *
 * Parameters:
 * - `formatter`: (Required) Which RCFeedFormatter class to use.
 * - `uri`: (Required) Where to send the messages.
 *
 * @par Example:
 * @code
 * $wgRCFeeds['rc-to-udp'] = [
 *      'class' => 'UDPRCFeedEngine',
 *      'formatter' => 'JSONRCFeedFormatter',
 *      'uri' => 'udp://localhost:1336',
 * ];
 * @endcode
 *
 * @see $wgRCFeeds
 * @since 1.22
 * @ingroup RecentChanges
 */
class UDPRCFeedEngine extends FormattedRCFeed {

	/**
	 * @see FormattedRCFeed::send
	 * @param array $feed
	 * @param string $line
	 * @return bool
	 */
	public function send( array $feed, $line ) {
		$transport = UDPTransport::newFromString( $feed['uri'] );
		$transport->emit( $line );
		return true;
	}
}
/** @deprecated class alias since 1.43 */
class_alias( UDPRCFeedEngine::class, 'UDPRCFeedEngine' );
PK       ! vś®
y  y    RCFeed/JSONRCFeedFormatter.phpnu ÕIw¶“        <?php

/**
 * 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
 */

namespace MediaWiki\RCFeed;

use MediaWiki\Json\FormatJson;

/**
 * Format a recent change notification using JSON (https://www.json.org).
 *
 * Parameters:
 * - `channel`: If set, a 'channel' property with the same value will
 *   also be added to the JSON-formatted message.
 *
 * @see $wgRCFeeds
 * @since 1.22
 * @ingroup RecentChanges
 */
class JSONRCFeedFormatter extends MachineReadableRCFeedFormatter {

	protected function formatArray( array $packet ) {
		return FormatJson::encode( $packet );
	}
}
/** @deprecated class alias since 1.43 */
class_alias( JSONRCFeedFormatter::class, 'JSONRCFeedFormatter' );
PK       ! ø0¤      RCFeed/RCFeedFormatter.phpnu ÕIw¶“        <?php

/**
 * 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
 */

namespace MediaWiki\RCFeed;

use RecentChange;

/**
 * Interface for RC feed formatters
 *
 * @stable to implement
 * @since 1.22
 * @ingroup RecentChanges
 */
interface RCFeedFormatter {
	/**
	 * Formats the line to be sent by an engine
	 *
	 * @param array $feed The feed, as configured in an associative array.
	 * @param RecentChange $rc The RecentChange object showing what sort
	 *                         of event has taken place.
	 * @param string|null $actionComment
	 * @return string|null The text to send.  If the formatter returns null,
	 *  the line will not be sent.
	 */
	public function getLine( array $feed, RecentChange $rc, $actionComment );
}
/** @deprecated class alias since 1.43 */
class_alias( RCFeedFormatter::class, 'RCFeedFormatter' );
PK       ! Ä IĪ  Ī  &  RCFeed/IRCColourfulRCFeedFormatter.phpnu ÕIw¶“        <?php

/**
 * 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
 */

namespace MediaWiki\RCFeed;

use MediaWiki\MainConfigNames;
use MediaWiki\MediaWikiServices;
use MediaWiki\Parser\Sanitizer;
use MediaWiki\Title\Title;
use RecentChange;

/**
 * Format a notification as a human-readable string using IRC colour codes.
 *
 * Parameters:
 * - `add_interwiki_prefix`: Whether the titles should be prefixed with
 *   the first entry in the $wgLocalInterwikis array (or the value of
 *   $wgLocalInterwiki, if set).
 *   Default: false.
 *
 * @see $wgRCFeeds
 * @since 1.22
 * @ingroup RecentChanges
 */
class IRCColourfulRCFeedFormatter implements RCFeedFormatter {
	/**
	 * @see RCFeedFormatter::getLine
	 * @param array $feed
	 * @param RecentChange $rc
	 * @param string|null $actionComment
	 * @return string|null
	 */
	public function getLine( array $feed, RecentChange $rc, $actionComment ) {
		$services = MediaWikiServices::getInstance();
		$mainConfig = $services->getMainConfig();
		$localInterwikis = $mainConfig->get( MainConfigNames::LocalInterwikis );
		$useRCPatrol = $mainConfig->get( MainConfigNames::UseRCPatrol );
		$useNPPatrol = $mainConfig->get( MainConfigNames::UseNPPatrol );
		$attribs = $rc->getAttributes();
		if ( $attribs['rc_type'] == RC_CATEGORIZE ) {
			// Don't send RC_CATEGORIZE events to IRC feed (T127360)
			return null;
		}

		if ( $attribs['rc_type'] == RC_LOG ) {
			// Don't use SpecialPage::getTitleFor, backwards compatibility with
			// IRC API which expects "Log".
			$titleObj = Title::newFromText( 'Log/' . $attribs['rc_log_type'], NS_SPECIAL );
		} else {
			$titleObj = $rc->getTitle();
		}
		$title = $titleObj->getPrefixedText();
		$title = self::cleanupForIRC( $title );

		$notifyUrl = $rc->getNotifyUrl() ?? '';

		if ( $attribs['rc_old_len'] !== null && $attribs['rc_new_len'] !== null ) {
			$szdiff = $attribs['rc_new_len'] - $attribs['rc_old_len'];
			if ( $szdiff < -500 ) {
				$szdiff = "\002$szdiff\002";
			} elseif ( $szdiff >= 0 ) {
				$szdiff = '+' . $szdiff;
			}
			// @todo i18n with parentheses in content language?
			$szdiff = '(' . $szdiff . ')';
		} else {
			$szdiff = '';
		}

		$user = self::cleanupForIRC( $attribs['rc_user_text'] );

		if ( $attribs['rc_type'] == RC_LOG ) {
			$targetText = $rc->getTitle()->getPrefixedText();
			$comment = self::cleanupForIRC( str_replace(
				"[[$targetText]]",
				"[[\00302$targetText\00310]]",
				$actionComment
			) );
			$flag = $attribs['rc_log_action'];
		} else {
			$store = $services->getCommentStore();
			$comment = self::cleanupForIRC( $store->getComment( 'rc_comment', $attribs )->text );
			$flag = '';
			if ( !$attribs['rc_patrolled']
				&& ( $useRCPatrol || ( $attribs['rc_type'] == RC_NEW && $useNPPatrol ) )
			) {
				$flag .= '!';
			}
			$flag .= ( $attribs['rc_type'] == RC_NEW ? "N" : "" )
				. ( $attribs['rc_minor'] ? "M" : "" ) . ( $attribs['rc_bot'] ? "B" : "" );
		}

		if ( $feed['add_interwiki_prefix'] === true && $localInterwikis ) {
			// we use the first entry in $wgLocalInterwikis in recent changes feeds
			$prefix = $localInterwikis[0];
		} elseif ( $feed['add_interwiki_prefix'] ) {
			$prefix = $feed['add_interwiki_prefix'];
		} else {
			$prefix = false;
		}
		if ( $prefix !== false ) {
			$titleString = "\00314[[\00303$prefix:\00307$title\00314]]";
		} else {
			$titleString = "\00314[[\00307$title\00314]]";
		}

		# see http://www.irssi.org/documentation/formats for some colour codes. prefix is \003,
		# no colour (\003) switches back to the term default
		$fullString = "$titleString\0034 $flag\00310 " .
			"\00302$notifyUrl\003 \0035*\003 \00303$user\003 \0035*\003 $szdiff \00310$comment\003\n";

		return $fullString;
	}

	/**
	 * Remove newlines, carriage returns and decode html entities
	 * @param string $text
	 * @return string
	 */
	public static function cleanupForIRC( $text ) {
		return str_replace(
			[ "\n", "\r" ],
			[ " ", "" ],
			Sanitizer::decodeCharReferences( $text )
		);
	}
}
/** @deprecated class alias since 1.43 */
class_alias( IRCColourfulRCFeedFormatter::class, 'IRCColourfulRCFeedFormatter' );
PK       ! o£0ć  ć  "  ChangesListStringOptionsFilter.phpnu ÕIw¶“        <?php

use MediaWiki\Html\FormOptions;

/**
 * An individual filter in a ChangesListStringOptionsFilterGroup.
 *
 * This filter type will only be displayed on the structured UI currently.
 *
 * @since 1.29
 * @ingroup RecentChanges
 */
class ChangesListStringOptionsFilter extends ChangesListFilter {
	/**
	 * @inheritDoc
	 */
	public function displaysOnUnstructuredUi() {
		return false;
	}

	/**
	 * @inheritDoc
	 */
	public function isSelected( FormOptions $opts ) {
		$option = $opts[ $this->getGroup()->getName() ];
		if ( $option === ChangesListStringOptionsFilterGroup::ALL ) {
			return true;
		}

		$values = explode( ChangesListStringOptionsFilterGroup::SEPARATOR, $option );
		return in_array( $this->getName(), $values );
	}
}
PK         ! QņS  S                   CategoryMembershipChangeTest.phpnu ÕIw¶“        PK         ! '$ŃĪĘ  Ę              £  TestRecentChangesHelper.phpnu ÕIw¶“        PK         ! Åī©=Ö  Ö              “*  RCCacheEntryFactoryTest.phpnu ÕIw¶“        PK         ! R©«¦                 ÕG  rcfeed/RCFeedIntegrationTest.phpnu ÕIw¶“        PK         ! ēĖ£š<  <              9S  RecentChangesUpdateJobTest.phpnu ÕIw¶“        PK         ! ?¬¾Ę  Ę              Ćc  OldChangesListTest.phpnu ÕIw¶“        PK         ! #OO·@  ·@              Ļ  RecentChangeTest.phpnu ÕIw¶“        PK         ! «éE„'  „'              ŹĀ  EnhancedChangesListTest.phpnu ÕIw¶“        PK         ! šÉź                ŗź  ChangesFeed.phpnu ÕIw¶“        PK         ! śØ3õ$  õ$              üū  CategoryMembershipChange.phpnu ÕIw¶“        PK         ! īĘ«¢                =! ChangesList.phpnu ÕIw¶“        PK         ! \qļv6  v6              ” ChangesListFilterGroup.phpnu ÕIw¶“        PK         ! kĖT±r(  r(              HŲ RCCacheEntryFactory.phpnu ÕIw¶“        PK         ! ņį|!ų  ų               RCCacheEntry.phpnu ÕIw¶“        PK         ! \¹ÉÉóc  óc              9 EnhancedChangesList.phpnu ÕIw¶“        PK         ! 	2č  č  '            sk ChangesListStringOptionsFilterGroup.phpnu ÕIw¶“        PK         ! »ĪSw7  7              ² OldChangesList.phpnu ÕIw¶“        PK         ! 9B    !            + ChangesListBooleanFilterGroup.phpnu ÕIw¶“        PK         ! Äł»28  28              « ChangesListFilter.phpnu ÕIw¶“        PK         ! ąAĆ¹!  ¹!              ųć RecentChangesUpdateJob.phpnu ÕIw¶“        PK         ! Ø¦³Ø  Ø               ū Hook/ChangesListInitRowsHook.phpnu ÕIw¶“        PK         ! oeą®    ,            ó Hook/EnhancedChangesList__getLogTextHook.phpnu ÕIw¶“        PK         ! lfM×c  c              ę Hook/RecentChange_saveHook.phpnu ÕIw¶“        PK         ! še³?  ?               Hook/MarkPatrolledHook.phpnu ÕIw¶“        PK         ! {Cś^  ^  3              Hook/EnhancedChangesListModifyBlockLineDataHook.phpnu ÕIw¶“        PK         ! įuøłŃ  Ń  )            į Hook/ChangesListInsertArticleLinkHook.phpnu ÕIw¶“        PK         ! UĪQ    "             Hook/MarkPatrolledCompleteHook.phpnu ÕIw¶“        PK         ! P0 š  š  .            ^ Hook/EnhancedChangesListModifyLineDataHook.phpnu ÕIw¶“        PK         ! Üń¢Ģ  Ģ              ¬# Hook/FetchChangesListHook.phpnu ÕIw¶“        PK         ! ŗMµš  š  #            Å' Hook/AbortEmailNotificationHook.phpnu ÕIw¶“        PK         ! édKLM  M  ,            + Hook/OldChangesListRecentChangesLineHook.phpnu ÕIw¶“        PK         ! {łWR³  R³              ±/ RecentChange.phpnu ÕIw¶“        PK         ! ÅĢ  Ģ              Cć ChangesListBooleanFilter.phpnu ÕIw¶“        PK         ! 
"    )            [ RCFeed/MachineReadableRCFeedFormatter.phpnu ÕIw¶“        PK         ! )Av¦  ¦              O RCFeed/XMLRCFeedFormatter.phpnu ÕIw¶“        PK         ! Źš-	  -	              B RCFeed/FormattedRCFeed.phpnu ÕIw¶“        PK         ! TĢ7§ņ	  ņ	              ¹$ RCFeed/RCFeed.phpnu ÕIw¶“        PK         ! ®fŠł  ł              ģ. RCFeed/Hook/IRCLineURLHook.phpnu ÕIw¶“        PK         ! Y„sĻŌ
  Ō
               32 RCFeed/RedisPubSubFeedEngine.phpnu ÕIw¶“        PK         ! [ėø  ø              W= RCFeed/UDPRCFeedEngine.phpnu ÕIw¶“        PK         ! vś®
y  y              YD RCFeed/JSONRCFeedFormatter.phpnu ÕIw¶“        PK         ! ø0¤                 J RCFeed/RCFeedFormatter.phpnu ÕIw¶“        PK         ! Ä IĪ  Ī  &            wP RCFeed/IRCColourfulRCFeedFormatter.phpnu ÕIw¶“        PK         ! o£0ć  ć  "            c ChangesListStringOptionsFilter.phpnu ÕIw¶“        PK    , ,   Šf   