Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
19.22% covered (danger)
19.22%
64 / 333
13.64% covered (danger)
13.64%
3 / 22
CRAP
0.00% covered (danger)
0.00%
0 / 1
SpecialImportFile
19.22% covered (danger)
19.22%
64 / 333
13.64% covered (danger)
13.64%
3 / 22
3040.15
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 getRestriction
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isListed
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 doesWrites
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getGroupName
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 userCanExecute
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
 executeStandardChecks
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
56
 getDescription
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 execute
0.00% covered (danger)
0.00%
0 / 64
0.00% covered (danger)
0.00%
0 / 1
306
 handleAction
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
42
 makeImportPlan
0.00% covered (danger)
0.00%
0 / 28
0.00% covered (danger)
0.00%
0 / 1
2
 logErrorStats
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 doCodexImport
0.00% covered (danger)
0.00%
0 / 40
0.00% covered (danger)
0.00%
0 / 1
20
 doImport
84.31% covered (warning)
84.31%
43 / 51
0.00% covered (danger)
0.00%
0 / 1
6.14
 logActionStats
11.11% covered (danger)
11.11%
1 / 9
0.00% covered (danger)
0.00%
0 / 1
31.28
 performPostImportActions
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 getWarningMessage
33.33% covered (danger)
33.33%
2 / 6
0.00% covered (danger)
0.00%
0 / 1
5.67
 showWarningMessage
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 showImportPage
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 getAutomatedCapabilities
0.00% covered (danger)
0.00%
0 / 34
0.00% covered (danger)
0.00%
0 / 1
90
 showCodexImportPage
0.00% covered (danger)
0.00%
0 / 31
0.00% covered (danger)
0.00%
0 / 1
6
 showLandingPage
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2
3namespace FileImporter;
4
5use Exception;
6use FileImporter\Data\ImportPlan;
7use FileImporter\Data\ImportRequest;
8use FileImporter\Exceptions\AbuseFilterWarningsException;
9use FileImporter\Exceptions\CommunityPolicyException;
10use FileImporter\Exceptions\DuplicateFilesException;
11use FileImporter\Exceptions\HttpRequestException;
12use FileImporter\Exceptions\ImportException;
13use FileImporter\Exceptions\LocalizedImportException;
14use FileImporter\Exceptions\RecoverableTitleException;
15use FileImporter\Html\ChangeFileInfoForm;
16use FileImporter\Html\ChangeFileNameForm;
17use FileImporter\Html\DuplicateFilesErrorPage;
18use FileImporter\Html\ErrorPage;
19use FileImporter\Html\FileInfoDiffPage;
20use FileImporter\Html\HelpBanner;
21use FileImporter\Html\ImportPreviewPage;
22use FileImporter\Html\ImportSuccessSnippet;
23use FileImporter\Html\InfoPage;
24use FileImporter\Html\InputFormPage;
25use FileImporter\Html\RecoverableTitleExceptionPage;
26use FileImporter\Html\SourceWikiCleanupSnippet;
27use FileImporter\Remote\MediaWiki\RemoteApiActionExecutor;
28use FileImporter\Services\Importer;
29use FileImporter\Services\ImportPlanFactory;
30use FileImporter\Services\SourceSiteLocator;
31use FileImporter\Services\WikidataTemplateLookup;
32use MediaWiki\Config\Config;
33use MediaWiki\Content\IContentHandlerFactory;
34use MediaWiki\EditPage\EditPage;
35use MediaWiki\Exception\ErrorPageError;
36use MediaWiki\Exception\PermissionsError;
37use MediaWiki\Exception\UserBlockedError;
38use MediaWiki\Html\Html;
39use MediaWiki\Logger\LoggerFactory;
40use MediaWiki\Registration\ExtensionRegistry;
41use MediaWiki\Request\WebRequest;
42use MediaWiki\SpecialPage\SpecialPage;
43use MediaWiki\Status\Status;
44use MediaWiki\User\Options\UserOptionsManager;
45use MediaWiki\User\User;
46use OOUI\HtmlSnippet;
47use OOUI\MessageWidget;
48use Psr\Log\LoggerInterface;
49use StatusValue;
50use UploadBase;
51use Wikimedia\Stats\StatsFactory;
52
53/**
54 * @license GPL-2.0-or-later
55 * @author Addshore
56 */
57class SpecialImportFile extends SpecialPage {
58
59    private const ERROR_UPLOAD_DISABLED = 'uploadDisabled';
60    private const ERROR_USER_PERMISSIONS = 'userPermissionsError';
61    private const ERROR_LOCAL_BLOCK = 'userBlocked';
62
63    private readonly StatsFactory $statsFactory;
64    private readonly LoggerInterface $logger;
65
66    public function __construct(
67        private readonly SourceSiteLocator $sourceSiteLocator,
68        private readonly Importer $importer,
69        private readonly ImportPlanFactory $importPlanFactory,
70        private readonly RemoteApiActionExecutor $remoteActionApi,
71        private readonly WikidataTemplateLookup $templateLookup,
72        private readonly IContentHandlerFactory $contentHandlerFactory,
73        StatsFactory $statsFactory,
74        private readonly UserOptionsManager $userOptionsManager,
75        private readonly Config $config,
76    ) {
77        parent::__construct( 'ImportFile' );
78
79        $this->statsFactory = $statsFactory->withComponent( 'FileImporter' );
80        $this->logger = LoggerFactory::getInstance( 'FileImporter' );
81    }
82
83    /** @inheritDoc */
84    public function getRestriction(): string {
85        return $this->config->get( 'FileImporterRequiredRight' );
86    }
87
88    /** @inheritDoc */
89    public function isListed(): bool {
90        return $this->config->get( 'FileImporterShowInputScreen' );
91    }
92
93    /** @inheritDoc */
94    public function doesWrites() {
95        return true;
96    }
97
98    /**
99     * @inheritDoc
100     */
101    public function getGroupName() {
102        return 'media';
103    }
104
105    /**
106     * @inheritDoc
107     */
108    public function userCanExecute( User $user ) {
109        return UploadBase::isEnabled() && parent::userCanExecute( $user );
110    }
111
112    /**
113     * Checks based on those in EditPage and SpecialUpload
114     *
115     * @throws ErrorPageError when one of the checks failed
116     */
117    private function executeStandardChecks(): void {
118        $unicodeCheck = $this->getRequest()->getText( 'wpUnicodeCheck' );
119        if ( $unicodeCheck && $unicodeCheck !== EditPage::UNICODE_CHECK ) {
120            throw new ErrorPageError( 'errorpagetitle', 'unicode-support-fail' );
121        }
122
123        # Check uploading enabled
124        if ( !UploadBase::isEnabled() ) {
125            $this->logErrorStats( self::ERROR_UPLOAD_DISABLED, false );
126            throw new ErrorPageError( 'uploaddisabled', 'uploaddisabledtext' );
127        }
128
129        $user = $this->getUser();
130
131        // Check if the user does have all the rights required via $wgFileImporterRequiredRight (set
132        // to "upload" by default), as well as "upload" and "edit" in case â€¦RequiredRight is more
133        // relaxed. Note special pages must call userCanExecute() manually when parent::execute()
134        // isn't called, {@see SpecialPage::__construct}.
135        $missingPermission = parent::userCanExecute( $user )
136            ? UploadBase::isAllowed( $user )
137            : $this->getRestriction();
138        if ( is_string( $missingPermission ) ) {
139            $this->logErrorStats( self::ERROR_USER_PERMISSIONS, false );
140            throw new PermissionsError( $missingPermission );
141        }
142
143        # Check blocks
144        $localBlock = $user->getBlock();
145        if ( $localBlock ) {
146            $this->logErrorStats( self::ERROR_LOCAL_BLOCK, false );
147            throw new UserBlockedError( $localBlock );
148        }
149
150        # Check whether we actually want to allow changing stuff
151        $this->checkReadOnly();
152    }
153
154    /** @inheritDoc */
155    public function getDescription() {
156        return $this->msg( 'fileimporter-specialpage' );
157    }
158
159    /**
160     * @param string|null $subPage
161     */
162    public function execute( $subPage ): void {
163        $webRequest = $this->getRequest();
164        $clientUrl = $webRequest->getVal( 'clientUrl', '' );
165        $action = $webRequest->getRawVal( ImportPreviewPage::ACTION_BUTTON );
166        if ( $action ) {
167            $this->logger->info( "Performing $action on ImportPlan for URL: $clientUrl" );
168        }
169
170        $isCodex = $webRequest->getBool( 'codex' ) &&
171            $this->getConfig()->get( 'FileImporterCodexMode' );
172        $isCodexSubmit = $isCodex && $this->getRequest()->wasPosted() && $action === 'submit';
173
174        if ( !$isCodexSubmit ) {
175            $this->setHeaders();
176            $this->getOutput()->enableOOUI();
177        }
178        $this->executeStandardChecks();
179
180        if ( !$isCodex ) {
181            $this->getOutput()->addModuleStyles( 'ext.FileImporter.SpecialCss' );
182            $this->getOutput()->addModuleStyles( 'ext.FileImporter.Images' );
183            $this->getOutput()->addModules( 'ext.FileImporter.SpecialJs' );
184        }
185
186        // Note: executions by users that don't have the rights to view the page etc will not be
187        // shown in this metric as executeStandardChecks will have already kicked them out,
188        $execTotalMetric = $this->statsFactory->getCounter( 'specialPage_executions_total' )
189            ->setLabel( 'parameter', 'none' );
190        // The importSource url parameter is added to requests from the FileExporter extension.
191        if ( $webRequest->getRawVal( 'importSource' ) === 'FileExporter' ) {
192            $execTotalMetric->setLabel( 'parameter', 'fromFileExporter' )
193                ->copyToStatsdAt( 'FileImporter.specialPage.execute.fromFileExporter' );
194        }
195
196        if ( $clientUrl === '' ) {
197            $execTotalMetric->setLabel( 'parameter', 'noClientUrl' )
198                ->copyToStatsdAt( 'FileImporter.specialPage.execute.noClientUrl' );
199            $this->showLandingPage();
200            return;
201        }
202
203        if ( $webRequest->getBool( HelpBanner::HIDE_HELP_BANNER_CHECK_BOX ) &&
204            $this->getUser()->isNamed()
205        ) {
206            $this->userOptionsManager->setOption(
207                $this->getUser(),
208                HelpBanner::HIDE_HELP_BANNER_PREFERENCE,
209                '1'
210            );
211            $this->userOptionsManager->saveOptions( $this->getUser() );
212        }
213
214        try {
215            $this->logger->info( 'Getting ImportPlan for URL: ' . $clientUrl );
216            $importPlan = $this->makeImportPlan( $webRequest );
217
218            if ( $isCodexSubmit ) {
219                // disable all default output of the special page, like headers, title, navigation
220                $this->getOutput()->disable();
221                header( 'Content-type: application/json; charset=utf-8' );
222                $this->doCodexImport( $importPlan );
223            } elseif ( $isCodex ) {
224                $this->getOutput()->addModules( 'ext.FileImporter.SpecialCodexJs' );
225                $this->showCodexImportPage( $importPlan );
226            } else {
227                $this->handleAction( $action, $importPlan );
228            }
229        } catch ( ImportException $exception ) {
230            $this->logger->info( 'ImportException: ' . $exception->getMessage() );
231            $this->logErrorStats(
232                (string)$exception->getCode(),
233                $exception instanceof RecoverableTitleException
234            );
235
236            if ( $exception instanceof DuplicateFilesException ) {
237                $html = ( new DuplicateFilesErrorPage( $this ) )->getHtml(
238                    $exception->getFiles(),
239                    $clientUrl
240                );
241            } elseif ( $exception instanceof RecoverableTitleException ) {
242                $html = ( new RecoverableTitleExceptionPage( $this ) )->getHtml( $exception );
243            } else {
244                $html = ( new ErrorPage( $this ) )->getHtml(
245                    $this->getWarningMessage( $exception ),
246                    $clientUrl,
247                    $exception instanceof CommunityPolicyException ? 'warning' : 'error'
248                );
249            }
250            $this->getOutput()->enableOOUI();
251            $this->getOutput()->addHTML( $html );
252        }
253    }
254
255    private function handleAction( ?string $action, ImportPlan $importPlan ): void {
256        switch ( $action ) {
257            case ImportPreviewPage::ACTION_SUBMIT:
258                $this->doImport( $importPlan );
259                break;
260            case ImportPreviewPage::ACTION_EDIT_TITLE:
261                $importPlan->setActionIsPerformed( ImportPreviewPage::ACTION_EDIT_TITLE );
262                $this->getOutput()->addHTML(
263                    ( new ChangeFileNameForm( $this ) )->getHtml( $importPlan )
264                );
265                break;
266            case ImportPreviewPage::ACTION_EDIT_INFO:
267                $importPlan->setActionIsPerformed( ImportPreviewPage::ACTION_EDIT_INFO );
268                $this->getOutput()->addHTML(
269                    ( new ChangeFileInfoForm( $this ) )->getHtml( $importPlan )
270                );
271                break;
272            case ImportPreviewPage::ACTION_VIEW_DIFF:
273                $contentHandler = $this->contentHandlerFactory->getContentHandler( CONTENT_MODEL_WIKITEXT );
274                $this->getOutput()->addHTML(
275                    ( new FileInfoDiffPage( $this ) )->getHtml( $importPlan, $contentHandler )
276                );
277                break;
278            default:
279                $this->showImportPage( $importPlan );
280        }
281    }
282
283    /**
284     * @throws ImportException
285     */
286    private function makeImportPlan( WebRequest $webRequest ): ImportPlan {
287        $importRequest = new ImportRequest(
288            $webRequest->getVal( 'clientUrl' ),
289            $webRequest->getVal( 'intendedFileName' ),
290            $webRequest->getVal( 'intendedWikitext' ),
291            $webRequest->getVal( 'intendedRevisionSummary' ),
292            $webRequest->getRawVal( 'importDetailsHash' ) ?? ''
293        );
294
295        $url = $importRequest->getUrl();
296        $sourceSite = $this->sourceSiteLocator->getSourceSite( $url );
297        $importDetails = $sourceSite->retrieveImportDetails( $url );
298
299        $importPlan = $this->importPlanFactory->newPlan(
300            $importRequest,
301            $importDetails,
302            $this->getUser()
303        );
304        $importPlan->setActionStats(
305            json_decode( $webRequest->getVal( 'actionStats', '[]' ), true )
306        );
307        $importPlan->setValidationWarnings(
308            json_decode( $webRequest->getVal( 'validationWarnings', '[]' ), true )
309        );
310        $importPlan->setAutomateSourceWikiCleanUp(
311            $webRequest->getBool( 'automateSourceWikiCleanup' )
312        );
313        $importPlan->setAutomateSourceWikiDelete(
314            $webRequest->getBool( 'automateSourceWikiDelete' )
315        );
316
317        return $importPlan;
318    }
319
320    private function logErrorStats( string $type, bool $isRecoverable ): void {
321        $this->statsFactory->getCounter( 'errors_total' )
322            ->setLabel( 'recoverable', wfBoolToStr( $isRecoverable ) )
323            ->setLabel( 'type', $type )
324            ->copyToStatsdAt( 'FileImporter.error.byRecoverable.' . wfBoolToStr( $isRecoverable ) . '.byType.' . $type )
325            ->increment();
326    }
327
328    private function doCodexImport( ImportPlan $importPlan ): void {
329        // TODO handle error cases and echo JSON to allow Codex to visualize the errors
330        try {
331            $this->importer->import(
332                $this->getUser(),
333                $importPlan
334            );
335            $this->statsFactory->getCounter( 'imports_total' )
336                ->setLabel( 'result', 'success' )
337                ->copyToStatsdAt( 'FileImporter.import.result.success' )
338                ->increment();
339            $this->logActionStats( $importPlan );
340
341            $postImportResult = $this->performPostImportActions( $importPlan );
342            $successRedirectUrl = ( new ImportSuccessSnippet() )->getRedirectWithNotice(
343                $importPlan->getTitle(),
344                $this->getUser(),
345                $postImportResult
346            );
347
348            echo json_encode( [
349                'success' => true,
350                'redirect' => $successRedirectUrl,
351            ] );
352        } catch ( ImportException $exception ) {
353            if ( $exception instanceof AbuseFilterWarningsException ) {
354                $warningMessages = [];
355                $warningMessages[] = [
356                    'type' => 'warning',
357                    'message' => $this->getWarningMessage( $exception )
358                ];
359
360                foreach ( $exception->getMessages() as $msg ) {
361                    $warningMessages[] = [
362                        'type' => 'warning',
363                        'message' => $this->msg( $msg )->parse()
364                    ];
365                }
366
367                echo json_encode( [
368                    'error' => true,
369                    'warningMessages' => $warningMessages,
370                    'validationWarnings' => $importPlan->getValidationWarnings()
371                ] );
372            } else {
373                // TODO: More graceful error handling
374                echo json_encode( [
375                    'error' => true,
376                    'output' => $exception->getTrace(),
377                ] );
378            }
379        }
380    }
381
382    private function doImport( ImportPlan $importPlan ): bool {
383        $out = $this->getOutput();
384        $importDetails = $importPlan->getDetails();
385
386        $importDetailsHash = $out->getRequest()->getRawVal( 'importDetailsHash' ) ?? '';
387        $token = $out->getRequest()->getRawVal( 'token' ) ?? '';
388
389        if ( !$this->getContext()->getCsrfTokenSet()->matchToken( $token ) ) {
390            $this->showWarningMessage( $this->msg( 'fileimporter-badtoken' )->parse() );
391            $this->logErrorStats( 'badToken', true );
392            return false;
393        }
394
395        if ( $importDetails->getOriginalHash() !== $importDetailsHash ) {
396            $this->showWarningMessage( $this->msg( 'fileimporter-badimporthash' )->parse() );
397            $this->logErrorStats( 'badImportHash', true );
398            return false;
399        }
400
401        try {
402            $this->importer->import(
403                $this->getUser(),
404                $importPlan
405            );
406            $this->statsFactory->getCounter( 'imports_total' )
407                ->setLabel( 'result', 'success' )
408                ->copyToStatsdAt( 'FileImporter.import.result.success' )
409                ->increment();
410            // TODO: inline at site of action
411            $this->logActionStats( $importPlan );
412
413            $postImportResult = $this->performPostImportActions( $importPlan );
414
415            $out->redirect(
416                ( new ImportSuccessSnippet() )->getRedirectWithNotice(
417                    $importPlan->getTitle(),
418                    $this->getUser(),
419                    $postImportResult
420                )
421            );
422
423            return true;
424        } catch ( ImportException $exception ) {
425            $this->logErrorStats(
426                (string)$exception->getCode(),
427                $exception instanceof RecoverableTitleException
428            );
429
430            if ( $exception instanceof AbuseFilterWarningsException ) {
431                $this->showWarningMessage( $this->getWarningMessage( $exception ), 'warning' );
432
433                foreach ( $exception->getMessages() as $msg ) {
434                    $this->showWarningMessage(
435                        $this->msg( $msg )->parse(),
436                        'warning',
437                        true
438                    );
439                }
440
441                $this->showImportPage( $importPlan );
442            } else {
443                $this->showWarningMessage(
444                    Html::rawElement( 'strong', [], $this->msg( 'fileimporter-importfailed' )->parse() ) .
445                    '<br>' .
446                    $this->getWarningMessage( $exception ),
447                    'error'
448                );
449            }
450            return false;
451        }
452    }
453
454    private function logActionStats( ImportPlan $importPlan ): void {
455        foreach ( $importPlan->getActionStats() as $key => $_ ) {
456            if (
457                $key === ImportPreviewPage::ACTION_EDIT_TITLE ||
458                $key === ImportPreviewPage::ACTION_EDIT_INFO ||
459                $key === SourceWikiCleanupSnippet::ACTION_OFFERED_SOURCE_DELETE ||
460                $key === SourceWikiCleanupSnippet::ACTION_OFFERED_SOURCE_EDIT
461            ) {
462                $this->statsFactory->getCounter( 'specialPage_actions_total' )
463                    ->setLabel( 'action', $key )
464                    ->copyToStatsdAt( 'FileImporter.specialPage.action.' . $key )
465                    ->increment();
466            }
467        }
468    }
469
470    private function performPostImportActions( ImportPlan $importPlan ): StatusValue {
471        $sourceSite = $importPlan->getRequest()->getUrl();
472        $postImportHandler = $this->sourceSiteLocator->getSourceSite( $sourceSite )
473            ->getPostImportHandler();
474
475        return $postImportHandler->execute( $importPlan, $this->getUser() );
476    }
477
478    /**
479     * @return string HTML
480     */
481    private function getWarningMessage( Exception $ex ): string {
482        if ( $ex instanceof LocalizedImportException ) {
483            return $ex->getMessageObject()->inLanguage( $this->getLanguage() )->parse();
484        }
485        if ( $ex instanceof HttpRequestException ) {
486            return Status::wrap( $ex->getStatusValue() )->getHTML( false, false,
487                $this->getLanguage() );
488        }
489
490        return htmlspecialchars( $ex->getMessage() );
491    }
492
493    /**
494     * @param string $html
495     * @param string $type Set to "notice" for a gray box, defaults to "error" (red)
496     * @param bool $inline
497     */
498    private function showWarningMessage( string $html, string $type = 'error', bool $inline = false ): void {
499        $this->getOutput()->enableOOUI();
500        $this->getOutput()->addHTML(
501            new MessageWidget( [
502                'label' => new HtmlSnippet( $html ),
503                'type' => $type,
504                'inline' => $inline,
505            ] ) .
506            '<br>'
507        );
508    }
509
510    private function showImportPage( ImportPlan $importPlan ): void {
511        $this->getOutput()->addHTML(
512            ( new ImportPreviewPage( $this ) )->getHtml( $importPlan )
513        );
514    }
515
516    /**
517     * @return array of automation features and whether they are available
518     */
519    private function getAutomatedCapabilities( ImportPlan $importPlan ) {
520        $capabilities = [];
521
522        $config = $this->getConfig();
523        $isCentralAuthEnabled = ExtensionRegistry::getInstance()->isLoaded( 'CentralAuth' );
524        $sourceUrl = $importPlan->getRequest()->getUrl();
525
526        $capabilities['canAutomateEdit'] =
527            $isCentralAuthEnabled &&
528            $config->get( 'FileImporterSourceWikiTemplating' ) &&
529            $this->templateLookup->fetchNowCommonsLocalTitle( $sourceUrl ) &&
530            $this->remoteActionApi->executeTestEditActionQuery(
531                $sourceUrl,
532                $this->getUser(),
533                $importPlan->getTitle()->getPrefixedText()
534            )->isGood();
535        $capabilities['canAutomateDelete'] =
536            $isCentralAuthEnabled &&
537            $config->get( 'FileImporterSourceWikiDeletion' ) &&
538            $this->remoteActionApi->executeUserRightsQuery( $sourceUrl, $this->getUser() )->isGood();
539
540        if ( $capabilities['canAutomateDelete'] ) {
541            $capabilities['automateDeleteSelected'] = $importPlan->getAutomateSourceWikiDelete();
542
543            $this->statsFactory->getCounter( 'specialPage_actions_total' )
544                ->setLabel( 'action', 'offeredSourceDelete' )
545                ->copyToStatsdAt( 'FileImporter.specialPage.action.offeredSourceDelete' )
546                ->increment();
547        } elseif ( $capabilities['canAutomateEdit'] ) {
548            $capabilities['automateEditSelected'] =
549                $importPlan->getAutomateSourceWikiCleanUp() ||
550                $importPlan->getRequest()->getImportDetailsHash() === '';
551            $capabilities['cleanupTitle'] =
552                $this->templateLookup->fetchNowCommonsLocalTitle( $sourceUrl );
553            $this->statsFactory->getCounter( 'specialPage_actions_total' )
554                ->setLabel( 'action', 'offeredSourceEdit' )
555                ->copyToStatsdAt( 'FileImporter.specialPage.action.offeredSourceEdit' )
556                ->increment();
557        }
558
559        return $capabilities;
560    }
561
562    private function showCodexImportPage( ImportPlan $importPlan ): void {
563        $this->getOutput()->addHTML(
564            Html::element( 'noscript', [], $this->msg( 'fileimporter-no-script-warning' )->text() )
565        );
566
567        $this->getOutput()->addHTML(
568            Html::rawElement( 'div', [ 'id' => 'ext-fileimporter-vue-root' ] )
569        );
570
571        $showHelpBanner = !$this->userOptionsManager
572            ->getBoolOption( $this->getUser(), 'userjs-fileimporter-hide-help-banner' );
573
574        $this->getOutput()->addJsConfigVars( [
575            'wgFileImporterAutomatedCapabilities' => $this->getAutomatedCapabilities( $importPlan ),
576            'wgFileImporterClientUrl' => $importPlan->getRequest()->getUrl()->getUrl(),
577            'wgFileImporterEditToken' => $this->getUser()->getEditToken(),
578            'wgFileImporterFileRevisionsCount' =>
579                count( $importPlan->getDetails()->getFileRevisions()->toArray() ),
580            'wgFileImporterHelpBannerContentHtml' => $showHelpBanner ?
581                FileImporterUtils::addTargetBlankToLinks(
582                    $this->msg( 'fileimporter-help-banner-text' )->parse()
583                ) : null,
584            'wgFileImporterTextRevisionsCount' =>
585                count( $importPlan->getDetails()->getTextRevisions()->toArray() ),
586            'wgFileImporterTitle' => $importPlan->getFileName(),
587            'wgFileImporterFileExtension' => $importPlan->getFileExtension(),
588            'wgFileImporterPrefixedTitle' => $importPlan->getTitle()->getPrefixedText(),
589            'wgFileImporterImageUrl' => $importPlan->getDetails()->getImageDisplayUrl(),
590            'wgFileImporterInitialFileInfoWikitext' => $importPlan->getInitialFileInfoText(),
591            'wgFileImporterFileInfoWikitext' =>
592                // FIXME: can assume the edit field is persistent
593                $importPlan->getRequest()->getIntendedText() ?? $importPlan->getFileInfoText(),
594            'wgFileImporterEditSummary' => $importPlan->getRequest()->getIntendedSummary(),
595            'wgFileImporterDetailsHash' => $importPlan->getDetails()->getOriginalHash(),
596            'wgFileImporterTemplateReplacementCount' => $importPlan->getNumberOfTemplateReplacements(),
597        ] );
598    }
599
600    private function showLandingPage(): void {
601        $page = $this->getConfig()->get( 'FileImporterShowInputScreen' )
602            ? new InputFormPage( $this )
603            : new InfoPage( $this );
604
605        $this->getOutput()->addHTML( $page->getHtml() );
606    }
607
608}