wikimedia/mediawiki-extensions-Translate

View on GitHub
src/Synchronization/ImportTranslationsSpecialPage.php

Summary

Maintainability
B
4 hrs
Test Coverage
<?php
declare( strict_types = 1 );

namespace MediaWiki\Extension\Translate\Synchronization;

use BagOStuff;
use FileBasedMessageGroup;
use MediaWiki\Extension\Translate\FileFormatSupport\GettextFormat;
use MediaWiki\Extension\Translate\FileFormatSupport\GettextParseException;
use MediaWiki\Extension\Translate\MessageGroupProcessing\MessageGroups;
use MediaWiki\Html\Html;
use MediaWiki\SpecialPage\SpecialPage;
use MessageGroupBase;
use Xml;

/**
 * Special page to import Gettext (.po) files exported using Translate extension.
 * Does not support generic Gettext files.
 *
 * @author Niklas Laxström
 * @author Siebrand Mazeland
 * @license GPL-2.0-or-later
 * @ingroup SpecialPage TranslateSpecialPage
 */
class ImportTranslationsSpecialPage extends SpecialPage {
    private BagOStuff $cache;

    public function __construct( BagOStuff $cache ) {
        parent::__construct( 'ImportTranslations', 'translate-import' );
        $this->cache = $cache;
    }

    public function doesWrites() {
        return true;
    }

    protected function getGroupName() {
        return 'translation';
    }

    /**
     * Special page entry point.
     * @param null|string $parameters
     * @throws \PermissionsError
     */
    public function execute( $parameters ) {
        $this->setHeaders();

        // Security and validity checks
        if ( !$this->userCanExecute( $this->getUser() ) ) {
            $this->displayRestrictionError();
        }

        if ( !$this->getRequest()->wasPosted() ) {
            $this->outputForm();

            return;
        }

        $csrfTokenSet = $this->getContext()->getCsrfTokenSet();
        if ( !$csrfTokenSet->matchTokenField( 'token' ) ) {
            $this->getOutput()->addWikiMsg( 'session_fail_preview' );
            $this->outputForm();

            return;
        }

        if ( $this->getRequest()->getCheck( 'process' ) ) {
            $data = $this->getCachedData();
            if ( !$data ) {
                $this->getOutput()->addWikiMsg( 'session_fail_preview' );
                $this->outputForm();

                return;
            }
        } else {
            /**
             * Proceed to loading and parsing if possible
             * @todo: use a Status object instead?
             */
            $file = null;
            $msg = $this->loadFile( $file );
            if ( $this->checkError( $msg ) ) {
                return;
            }

            $msg = $this->parseFile( $file );
            if ( $this->checkError( $msg ) ) {
                return;
            }

            $data = $msg[1];
            $this->setCachedData( $data );
        }

        $messages = $data['MESSAGES'];
        $groupId = $data['EXTRA']['METADATA']['group'];
        $code = $data['EXTRA']['METADATA']['code'];

        if ( !MessageGroups::exists( $groupId ) ) {
            $errorWrap = "<div class='error'>\n$1\n</div>";
            $this->getOutput()->wrapWikiMsg( $errorWrap, 'translate-import-err-stale-group' );

            return;
        }

        $importer = new MessageWebImporter( $this->getPageTitle(), $this->getUser(), $groupId, $code );
        $allDone = $importer->execute( $messages );

        $out = $this->getOutput();
        $pageTitle = $this->getPageTitle();

        if ( $allDone ) {
            $this->deleteCachedData();
            $this->outputForm();
        }

        $out->addBacklinkSubtitle( $pageTitle );
    }

    /**
     * Checks for error state from the return value of loadFile and parseFile
     * functions. Prints the error and the form and returns true if there is an
     * error. Returns false and does nothing if there is no error.
     */
    private function checkError( array $msg ): bool {
        // Give grep a chance to find the usages:
        // translate-import-err-dl-failed, translate-import-err-ul-failed,
        // translate-import-err-invalid-title, translate-import-err-no-such-file,
        // translate-import-err-stale-group, translate-import-err-no-headers,
        if ( $msg[0] !== 'ok' ) {
            $errorWrap = "<div class='error'>\n$1\n</div>";
            $msg[0] = 'translate-import-err-' . $msg[0];
            $this->getOutput()->wrapWikiMsg( $errorWrap, $msg );
            $this->outputForm();

            return true;
        }

        return false;
    }

    /** Constructs and outputs file input form with supported methods. */
    private function outputForm(): void {
        $this->getOutput()->addModules( 'ext.translate.special.importtranslations' );
        $this->getOutput()->addHelpLink( 'Help:Extension:Translate/Off-line_translation' );
        /** Ugly but necessary form building ahead, ohoy */
        $this->getOutput()->addHTML(
            Xml::openElement( 'form', [
                'action' => $this->getPageTitle()->getLocalURL(),
                'method' => 'post',
                'enctype' => 'multipart/form-data',
                'id' => 'mw-translate-import',
            ] ) .
                Html::hidden( 'token', $this->getContext()->getCsrfTokenSet()->getToken() ) .
                Html::hidden( 'title', $this->getPageTitle()->getPrefixedText() ) .
                Xml::inputLabel(
                    $this->msg( 'translate-import-from-local' )->text(),
                    'upload-local', // name
                    'mw-translate-up-local-input', // id
                    50, // size
                    $this->getRequest()->getText( 'upload-local' ),
                    [ 'type' => 'file' ]
                ) .
                Html::submitButton( $this->msg( 'translate-import-load' )->text() ) .
                Xml::closeElement( 'form' )
        );
    }

    /** Try to get the file data from any of the supported methods. */
    private function loadFile( ?string &$filedata ): array {
        $filename = $this->getRequest()->getFileTempname( 'upload-local' );

        if ( !is_uploaded_file( $filename ) ) {
            return [ 'ul-failed' ];
        }

        $filedata = file_get_contents( $filename );

        return [ 'ok' ];
    }

    /** Try parsing file. */
    private function parseFile( string $data ): array {
        /** Construct a dummy group for us...
         * @todo Time to rethink the interface again?
         * @var FileBasedMessageGroup $group
         */
        $group = MessageGroupBase::factory( [
            'FILES' => [
                'format' => 'Gettext',
                'CtxtAsKey' => true,
            ],
            'BASIC' => [
                'class' => FileBasedMessageGroup::class,
                'namespace' => -1,
            ]
        ] );
        '@phan-var FileBasedMessageGroup $group';

        $ffs = new GettextFormat( $group );

        try {
            $parseOutput = $ffs->readFromVariable( $data );
        } catch ( GettextParseException $e ) {
            return [ 'no-headers' ];
        }

        // Special data added by GettextFormat
        $metadata = $parseOutput['EXTRA']['METADATA'];

        // This should catch everything that is not a Gettext file exported from us
        if ( !isset( $metadata['code'] ) || !isset( $metadata['group'] ) ) {
            return [ 'no-headers' ];
        }

        return [ 'ok', $parseOutput ];
    }

    private function setCachedData( $data ): void {
        $key = $this->cache->makeKey( 'translate', 'webimport', $this->getUser()->getId() );
        $this->cache->set( $key, $data, 60 * 30 );
    }

    private function getCachedData() {
        $key = $this->cache->makeKey( 'translate', 'webimport', $this->getUser()->getId() );

        return $this->cache->get( $key );
    }

    private function deleteCachedData(): bool {
        $key = $this->cache->makeKey( 'translate', 'webimport', $this->getUser()->getId() );

        return $this->cache->delete( $key );
    }
}