wikimedia/mediawiki-core

View on GitHub
includes/changes/CategoryMembershipChange.php

Summary

Maintainability
A
2 hrs
Test Coverage
<?php

use MediaWiki\Cache\BacklinkCache;
use MediaWiki\MediaWikiServices;
use MediaWiki\Revision\RevisionRecord;
use MediaWiki\Title\Title;
use MediaWiki\User\User;
use MediaWiki\User\UserIdentity;

/**
 * Helper class for category membership changes
 *
 * This program is free software; you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation; either version 2 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License along
 * with this program; if not, write to the Free Software Foundation, Inc.,
 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
 * http://www.gnu.org/copyleft/gpl.html
 *
 * @file
 * @author Kai Nissen
 * @author Addshore
 * @since 1.27
 */

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 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.
     */
    public function __construct(
        Title $pageTitle, BacklinkCache $backlinkCache, RevisionRecord $revision = null
    ) {
        $this->pageTitle = $pageTitle;
        $this->revision = $revision;
        if ( $revision === null ) {
            $this->timestamp = wfTimestampNow();
        } else {
            $this->timestamp = $revision->getTimestamp();
        }
        $this->newForCategorizationCallback = [ RecentChange::class, 'newForCategorization' ];
        $this->backlinkCache = $backlinkCache;
    }

    /**
     * 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 Title $categoryTitle
     */
    public function triggerCategoryAddedNotification( Title $categoryTitle ) {
        $this->createRecentChangesEntry( $categoryTitle, self::CATEGORY_ADDITION );
    }

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

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

    /**
     * @param string $timestamp Timestamp of the recent change to occur in TS_MW format
     * @param Title $categoryTitle Title of the category a page is being added to or removed from
     * @param UserIdentity|null $user User object of the user that made the change
     * @param string $comment Change summary
     * @param Title $pageTitle Title of the page that is being added or removed
     * @param string $lastTimestamp Parent revision timestamp of this change in TS_MW format
     * @param RevisionRecord|null $revision
     * @param bool $added true, if the category was added, false for removed
     */
    private function notifyCategorization(
        $timestamp,
        Title $categoryTitle,
        ?UserIdentity $user,
        $comment,
        Title $pageTitle,
        $lastTimestamp,
        $revision,
        $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,
            $categoryTitle,
            $user,
            $comment,
            $pageTitle,
            $lastRevId,
            $newRevId,
            $lastTimestamp,
            $bot,
            $ip,
            $deleted,
            $added
        );
        $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;
    }

}