wikimedia/mediawiki-extensions-Wikibase

View on GitHub
repo/includes/Specials/SpecialModifyEntity.php

Summary

Maintainability
C
1 day
Test Coverage
<?php

namespace Wikibase\Repo\Specials;

use MediaWiki\Html\Html;
use MediaWiki\HTMLForm\HTMLForm;
use Wikibase\DataModel\Entity\EntityDocument;
use Wikibase\DataModel\Entity\EntityId;
use Wikibase\DataModel\Services\Lookup\UnresolvedEntityRedirectException;
use Wikibase\Lib\MessageException;
use Wikibase\Lib\Store\EntityRevision;
use Wikibase\Lib\Store\EntityTitleLookup;
use Wikibase\Lib\Store\StorageException;
use Wikibase\Lib\Summary;
use Wikibase\Lib\UserInputException;
use Wikibase\Repo\AnonymousEditWarningBuilder;
use Wikibase\Repo\ChangeOp\ChangeOp;
use Wikibase\Repo\ChangeOp\ChangeOpException;
use Wikibase\Repo\ChangeOp\ChangeOpValidationException;
use Wikibase\Repo\EditEntity\EditEntityStatus;
use Wikibase\Repo\EditEntity\MediaWikiEditEntityFactory;
use Wikibase\Repo\SummaryFormatter;

/**
 * Abstract special page for modifying Wikibase entity.
 *
 * @license GPL-2.0-or-later
 * @author Bene* < benestar.wikimedia@googlemail.com >
 * @author Daniel Kinzler
 */
abstract class SpecialModifyEntity extends SpecialWikibaseRepoPage {

    private AnonymousEditWarningBuilder $anonymousEditWarningBuilder;

    /**
     * @var EntityDocument|null
     */
    private $entityForModification = null;

    /**
     * @var EntityId|null
     */
    private $entityId;

    /**
     * @param string $title The title of the special page
     * @param string[] $tags List of tags to add to edits
     * @param SpecialPageCopyrightView $copyrightView
     * @param SummaryFormatter $summaryFormatter
     * @param EntityTitleLookup $entityTitleLookup
     * @param MediaWikiEditEntityFactory $editEntityFactory
     */
    public function __construct(
        $title,
        array $tags,
        SpecialPageCopyrightView $copyrightView,
        SummaryFormatter $summaryFormatter,
        EntityTitleLookup $entityTitleLookup,
        MediaWikiEditEntityFactory $editEntityFactory,
        AnonymousEditWarningBuilder $anonymousEditWarningBuilder
    ) {
        parent::__construct(
            $title,
            'edit',
            $tags,
            $copyrightView,
            $summaryFormatter,
            $entityTitleLookup,
            $editEntityFactory
        );
        $this->anonymousEditWarningBuilder = $anonymousEditWarningBuilder;
    }

    public function doesWrites() {
        return true;
    }

    /**
     * Returns the ID of the Entity being modified.
     * Returns null if no entity ID was specified in the request.
     *
     * @note The return value is undefined before prepareArguments() has been called.
     *
     * @return null|EntityId
     */
    protected function getEntityId() {
        return $this->entityId;
    }

    /**
     * Returns the base revision. If no base revision ID was passed to prepareEditEntity(),
     * this returns the latest revision.
     *
     * @throws UserInputException
     *
     * @return EntityRevision
     */
    protected function getBaseRevision() {
        $id = $this->getEntityId();
        try {
            $baseRev = $this->getEditEntity()->getBaseRevision();

            if ( $baseRev === null ) {
                throw new UserInputException(
                    'wikibase-wikibaserepopage-invalid-id',
                    [ $id->getSerialization() ],
                    'Entity ID "' . $id->getSerialization() . '" is unknown'
                );
            }
        } catch ( UnresolvedEntityRedirectException $ex ) {
            throw new UserInputException(
                'wikibase-wikibaserepopage-unresolved-redirect',
                [ $id->getSerialization() ],
                'Entity ID "' . $id->getSerialization() . '"" refers to a redirect'
            );
        } catch ( StorageException $ex ) {
            throw new UserInputException(
                'wikibase-wikibaserepopage-storage-exception',
                [ $id->getSerialization(), $ex->getMessage() ],
                'Entity "' . $id->getSerialization() . '" could not be loaded'
            );
        }

        return $baseRev;
    }

    /**
     * Returns the current revision.
     *
     * @throws UserInputException
     *
     * @return null|EntityRevision
     */
    protected function getLatestRevision() {
        $id = $this->getEntityId();
        try {
            $baseRev = $this->getEditEntity()->getLatestRevision();

            if ( $baseRev === null ) {
                throw new UserInputException(
                    'wikibase-wikibaserepopage-invalid-id',
                    [ $id->getSerialization() ],
                    'Entity ID "' . $id->getSerialization() . '" is unknown'
                );
            }
        } catch ( UnresolvedEntityRedirectException $ex ) {
            throw new UserInputException(
                'wikibase-wikibaserepopage-unresolved-redirect',
                [ $id->getSerialization() ],
                'Entity ID "' . $id->getSerialization() . '"" refers to a redirect'
            );
        } catch ( StorageException $ex ) {
            throw new UserInputException(
                'wikibase-wikibaserepopage-storage-exception',
                [ $id->getSerialization(), $ex->getMessage() ],
                'Entity "' . $id->getSerialization() . '" could not be loaded'
            );
        }

        return $baseRev;
    }

    /**
     * Returns the EntityDocument that is to be modified by code in this class (or subclasses).
     * The first call to this method calls getBaseRevision().
     *
     * @throws MessageException
     * @throws UserInputException
     *
     * @return EntityDocument
     */
    protected function getEntityForModification() {
        if ( !$this->entityForModification ) {
            $revision = $this->getBaseRevision();
            $this->entityForModification = $revision->getEntity()->copy();
        }

        return $this->entityForModification;
    }

    /**
     * Returns the EntityDocument that is to be shown by code in this class (or subclasses).
     * The returns null if no entity ID was specified in the request.
     *
     * @throws MessageException
     * @throws UserInputException
     *
     * @return EntityDocument|null
     */
    protected function getEntityForDisplay() {
        if ( $this->entityId ) {
            $revision = $this->getBaseRevision();
            return $revision->getEntity();
        }

        return null;
    }

    /**
     * @see SpecialWikibasePage::execute
     *
     * @param string|null $subPage
     */
    public function execute( $subPage ) {
        parent::execute( $subPage );

        $this->checkPermissions();
        $this->checkBlocked();
        $this->checkReadOnly();

        $this->setHeaders();
        $this->outputHeader();

        try {
            $this->processArguments( $subPage );
            $valid = $this->validateInput();

            if ( $valid && $this->isModificationRequested() ) {
                $updatedEntity = $this->getEntityForModification();
                $summary = $this->modifyEntity( $updatedEntity );

                if ( $summary ) {
                    $token = $this->getRequest()->getRawVal( 'wpEditToken' ) ?? '';
                    $status = $this->saveEntity( $updatedEntity, $summary, $token );

                    $this->handleStatus( $status, $updatedEntity );
                    return;
                }
            }

            $entity = $this->getEntityForDisplay();
            $this->setForm( $entity );
        } catch ( UserInputException $ex ) {
            $error = $this->msg( $ex->getKey(), $ex->getParams() )->parse();
            $this->showErrorHTML( $error );
        }
    }

    private function handleStatus( EditEntityStatus $status, EntityDocument $entity ) {
        if ( $status->isOK() ) {
            $this->redirectToEntityPage( $status );
        } else {
            $errors = $status->getMessages();
            $this->showErrorHTML( $this->msg( $errors[0] )->parse() );
            $this->setForm( $entity );
        }
    }

    /**
     * Prepares the arguments.
     *
     * @param string|null $subPage
     */
    protected function processArguments( $subPage ) {
        $parts = $subPage ? explode( '/', $subPage, 2 ) : [];

        $idString = $this->getRequest()->getVal( 'id', $parts[0] ?? null );
        $baseRevId = $this->getRequest()->getInt( 'revid', 0 );

        if ( !$idString ) {
            return;
        }

        $this->entityId = $this->parseEntityId( $idString );

        $this->prepareEditEntity( $this->entityId, $baseRevId );
    }

    /**
     * Return the HTML form.
     *
     * @param EntityDocument|null $entity
     *
     * @return HTMLForm
     */
    abstract protected function getForm( EntityDocument $entity = null );

    /**
     * Building the HTML form for modifying an entity.
     *
     * @param EntityDocument|null $entity
     */
    private function setForm( EntityDocument $entity = null ) {
        $submitKey = $this->getSubmitKey( $entity );
        if ( $this->showCopyrightNotice( $entity ) ) {
            $this->getOutput()->addHTML( $this->getCopyrightHTML( $submitKey ) );
        }
        if ( !$this->getUser()->isRegistered() ) {
            $this->getOutput()->addHTML( Html::rawElement(
                'p',
                [ 'class' => 'warning' ],
                $this->anonymousEditWarningBuilder->buildAnonymousEditWarningHTML( $this->getFullTitle()->getPrefixedText() )
            ) );
        }

        $this->getForm( $entity )
            ->setId( 'wb-' . strtolower( $this->getName() ) . '-form1' )
            ->setSubmitID( 'wb-' . strtolower( $this->getName() ) . '-submit' )
            ->setSubmitName( $submitKey )
            ->setSubmitTextMsg( $submitKey )
            ->setWrapperLegendMsg( $this->getDescription() )
            ->setSubmitCallback( function () {
                // no-op
            } )->show();
    }

    /**
     * @param EntityDocument|null $entity
     *
     * @return array
     */
    protected function getFormElements( EntityDocument $entity = null ) {
        $id = 'wb-modifyentity-id';

        return [
            'id' => [
                'name' => 'id',
                'label-message' => 'wikibase-modifyentity-id',
                'type' => 'text',
                'id' => $id,
                'default' => $entity === null ? '' : $entity->getId(),
            ],
        ];
    }

    /**
     * Validates form input.
     *
     * The default implementation does nothing.
     * Subclasses should override this to detect otherwise incomplete or erroneous input.
     *
     * If this method returns false, the entity should not be updated and the user should be
     * presented with an input form. Only if it returns true, and isModificationRequested() also
     * returns true, the entity should be updated in the storage backend.
     *
     * @throws UserInputException if any of the provided input is invalid. If the input is
     *         merely incomplete, no exception should be raised.
     *
     * @return bool true if all input needed for modification has been supplied.
     *         false if the input is valid but incomplete, or if the input is invalid and
     *         showErrorHTML() has already been called to notify the user of the problem.
     *         The preferred way of indicating invalid input is however to throw a
     *         UserInputException.
     */
    protected function validateInput() {
        return $this->getEntityId() !== null;
    }

    /**
     * Whether the current request is a request for modification (as opposed to a
     * request for showing the input form).
     *
     * If this method returns false, the entity should not be updated and the user should be
     * presented with an input form. Only if it returns true, and validateInput() also
     * returns true, the entity should be updated in the storage backend.
     *
     * Undefined before processArguments() was called.
     *
     * @return bool
     */
    protected function isModificationRequested() {
        return $this->getRequest()->wasPosted();
    }

    /**
     * Modifies the entity.
     *
     * @param EntityDocument $entity
     *
     * @return Summary|bool
     */
    abstract protected function modifyEntity( EntityDocument $entity );

    /**
     * Applies the given ChangeOp to the given Entity.
     * If validation fails, a ChangeOpValidationException is thrown.
     *
     * @param ChangeOp $changeOp
     * @param EntityDocument $entity
     * @param Summary|null $summary The summary object to update with information about the change.
     *
     * @throws ChangeOpException
     */
    protected function applyChangeOp( ChangeOp $changeOp, EntityDocument $entity, Summary $summary = null ) {
        // NOTE: always validate modification against the current revision!
        // TODO: this should be re-engineered, see T126231
        $currentEntityRevision = $this->getLatestRevision();
        $result = $changeOp->validate( $currentEntityRevision->getEntity() );

        if ( !$result->isValid() ) {
            throw new ChangeOpValidationException( $result );
        }

        $changeOp->apply( $entity, $summary );
    }

    /**
     * @param EntityDocument|null $entity
     *
     * @return string submit message key
     */
    protected function getSubmitKey( EntityDocument $entity = null ) {
        return 'wikibase-' . strtolower( $this->getName() ) . '-submit';
    }

    /**
     * @param EntityDocument|null $entity
     *
     * @return bool
     */
    protected function showCopyrightNotice( EntityDocument $entity = null ) {
        return true;
    }
}