Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
79.67% covered (warning)
79.67%
239 / 300
20.00% covered (danger)
20.00%
3 / 15
CRAP
0.00% covered (danger)
0.00%
0 / 1
UndeletePage
79.67% covered (warning)
79.67%
239 / 300
20.00% covered (danger)
20.00%
3 / 15
106.87
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
1
 setUnsuppress
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 setTags
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 setUndeleteOnlyTimestamps
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 setUndeleteOnlyFileVersions
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 canProbablyUndeleteAssociatedTalk
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 setUndeleteAssociatedTalk
75.00% covered (warning)
75.00%
6 / 8
0.00% covered (danger)
0.00%
0 / 1
2.06
 undeleteIfAllowed
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 authorizeUndeletion
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 undeleteUnsafe
82.28% covered (warning)
82.28%
65 / 79
0.00% covered (danger)
0.00%
0 / 1
24.69
 runPreUndeleteHook
94.12% covered (success)
94.12%
16 / 17
0.00% covered (danger)
0.00%
0 / 1
5.01
 addLogEntry
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
1
 undeleteRevisions
83.21% covered (warning)
83.21%
114 / 137
0.00% covered (danger)
0.00%
0 / 1
25.50
 getFileStatus
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getRevisionStatus
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2/**
3 * @license GPL-2.0-or-later
4 * @file
5 */
6
7namespace MediaWiki\Page;
8
9use MediaWiki\ChangeTags\ChangeTags;
10use MediaWiki\Content\IContentHandlerFactory;
11use MediaWiki\Content\ValidationParams;
12use MediaWiki\Exception\ReadOnlyError;
13use MediaWiki\FileRepo\File\LocalFile;
14use MediaWiki\FileRepo\RepoGroup;
15use MediaWiki\HookContainer\HookContainer;
16use MediaWiki\HookContainer\HookRunner;
17use MediaWiki\JobQueue\JobQueueGroup;
18use MediaWiki\JobQueue\Jobs\HTMLCacheUpdateJob;
19use MediaWiki\Logging\ManualLogEntry;
20use MediaWiki\Page\Event\PageLatestRevisionChangedEvent;
21use MediaWiki\Permissions\Authority;
22use MediaWiki\Permissions\PermissionStatus;
23use MediaWiki\Revision\ArchivedRevisionLookup;
24use MediaWiki\Revision\RevisionRecord;
25use MediaWiki\Revision\RevisionStore;
26use MediaWiki\Status\Status;
27use MediaWiki\Storage\PageUpdater;
28use MediaWiki\Storage\PageUpdaterFactory;
29use MediaWiki\Title\NamespaceInfo;
30use Psr\Log\LoggerInterface;
31use StatusValue;
32use Wikimedia\Assert\Assert;
33use Wikimedia\Message\ITextFormatter;
34use Wikimedia\Message\MessageValue;
35use Wikimedia\Rdbms\IConnectionProvider;
36use Wikimedia\Rdbms\IDatabase;
37use Wikimedia\Rdbms\IDBAccessObject;
38use Wikimedia\Rdbms\ReadOnlyMode;
39use Wikimedia\Timestamp\TimestampFormat as TS;
40
41/**
42 * Backend logic for performing a page undelete action.
43 *
44 * @since 1.38
45 */
46class UndeletePage {
47
48    // Constants used as keys in the StatusValue returned by undelete()
49    public const FILES_RESTORED = 'files';
50    public const REVISIONS_RESTORED = 'revs';
51
52    /** @var Status|null */
53    private $fileStatus;
54    /** @var StatusValue<array{int, bool, ?RevisionRecord, array<int,true>}>|null */
55    private $revisionStatus;
56    /** @var string[] */
57    private $timestamps = [];
58    /** @var int[] */
59    private $fileVersions = [];
60    /** @var bool */
61    private $unsuppress = false;
62    /** @var string[] */
63    private $tags = [];
64    /** @var WikiPage|null If not null, it means that we have to undelete it. */
65    private $associatedTalk;
66
67    private HookRunner $hookRunner;
68    private JobQueueGroup $jobQueueGroup;
69    private IConnectionProvider $dbProvider;
70    private ReadOnlyMode $readOnlyMode;
71    private RepoGroup $repoGroup;
72    private LoggerInterface $logger;
73    private RevisionStore $revisionStore;
74    private WikiPageFactory $wikiPageFactory;
75    private PageUpdaterFactory $pageUpdaterFactory;
76    private IContentHandlerFactory $contentHandlerFactory;
77    private ArchivedRevisionLookup $archivedRevisionLookup;
78    private NamespaceInfo $namespaceInfo;
79    private ITextFormatter $contLangMsgTextFormatter;
80    private ProperPageIdentity $page;
81    private Authority $performer;
82
83    /**
84     * @internal Create via the UndeletePageFactory service.
85     */
86    public function __construct(
87        HookContainer $hookContainer,
88        JobQueueGroup $jobQueueGroup,
89        IConnectionProvider $dbProvider,
90        ReadOnlyMode $readOnlyMode,
91        RepoGroup $repoGroup,
92        LoggerInterface $logger,
93        RevisionStore $revisionStore,
94        WikiPageFactory $wikiPageFactory,
95        PageUpdaterFactory $pageUpdaterFactory,
96        IContentHandlerFactory $contentHandlerFactory,
97        ArchivedRevisionLookup $archivedRevisionLookup,
98        NamespaceInfo $namespaceInfo,
99        ITextFormatter $contLangMsgTextFormatter,
100        ProperPageIdentity $page,
101        Authority $performer
102    ) {
103        $this->hookRunner = new HookRunner( $hookContainer );
104        $this->jobQueueGroup = $jobQueueGroup;
105        $this->dbProvider = $dbProvider;
106        $this->readOnlyMode = $readOnlyMode;
107        $this->repoGroup = $repoGroup;
108        $this->logger = $logger;
109        $this->revisionStore = $revisionStore;
110        $this->wikiPageFactory = $wikiPageFactory;
111        $this->pageUpdaterFactory = $pageUpdaterFactory;
112        $this->contentHandlerFactory = $contentHandlerFactory;
113        $this->archivedRevisionLookup = $archivedRevisionLookup;
114        $this->namespaceInfo = $namespaceInfo;
115        $this->contLangMsgTextFormatter = $contLangMsgTextFormatter;
116
117        $this->page = $page;
118        $this->performer = $performer;
119    }
120
121    /**
122     * Whether to remove all ar_deleted/fa_deleted restrictions of selected revs.
123     *
124     * @param bool $unsuppress
125     * @return self For chaining
126     */
127    public function setUnsuppress( bool $unsuppress ): self {
128        $this->unsuppress = $unsuppress;
129        return $this;
130    }
131
132    /**
133     * Change tags to add to log entry (the user should be able to add the specified tags before this is called)
134     *
135     * @param string[] $tags
136     * @return self For chaining
137     */
138    public function setTags( array $tags ): self {
139        $this->tags = $tags;
140        return $this;
141    }
142
143    /**
144     * If you don't want to undelete all revisions, pass an array of timestamps to undelete.
145     *
146     * @param string[] $timestamps
147     * @return self For chaining
148     */
149    public function setUndeleteOnlyTimestamps( array $timestamps ): self {
150        $this->timestamps = $timestamps;
151        return $this;
152    }
153
154    /**
155     * If you don't want to undelete all file versions, pass an array of versions to undelete.
156     *
157     * @param int[] $fileVersions
158     * @return self For chaining
159     */
160    public function setUndeleteOnlyFileVersions( array $fileVersions ): self {
161        $this->fileVersions = $fileVersions;
162        return $this;
163    }
164
165    /**
166     * Tests whether it's probably possible to undelete the associated talk page. This checks the replica,
167     * so it may not see the latest master change, and is useful e.g. for building the UI.
168     * @return StatusValue<never>
169     */
170    public function canProbablyUndeleteAssociatedTalk(): StatusValue {
171        if ( $this->namespaceInfo->isTalk( $this->page->getNamespace() ) ) {
172            return StatusValue::newFatal( 'undelete-error-associated-alreadytalk' );
173        }
174        // @todo FIXME: NamespaceInfo should work with PageIdentity
175        $thisWikiPage = $this->wikiPageFactory->newFromTitle( $this->page );
176        $talkPage = $this->wikiPageFactory->newFromLinkTarget(
177            $this->namespaceInfo->getTalkPage( $thisWikiPage->getTitle() )
178        );
179        // NOTE: The talk may exist, but have some deleted revision. That's fine.
180        if ( !$this->archivedRevisionLookup->hasArchivedRevisions( $talkPage ) ) {
181            return StatusValue::newFatal( 'undelete-error-associated-notdeleted' );
182        }
183        return StatusValue::newGood();
184    }
185
186    /**
187     * Whether to delete the associated talk page with the subject page
188     *
189     * @param bool $undelete
190     * @return self For chaining
191     */
192    public function setUndeleteAssociatedTalk( bool $undelete ): self {
193        if ( !$undelete ) {
194            $this->associatedTalk = null;
195            return $this;
196        }
197
198        // @todo FIXME: NamespaceInfo should accept PageIdentity
199        $thisWikiPage = $this->wikiPageFactory->newFromTitle( $this->page );
200        $this->associatedTalk = $this->wikiPageFactory->newFromLinkTarget(
201            $this->namespaceInfo->getTalkPage( $thisWikiPage->getTitle() )
202        );
203        return $this;
204    }
205
206    /**
207     * Same as undeleteUnsafe, but checks permissions.
208     *
209     * @param string $comment
210     * @return StatusValue<array{files:int, revs:int}>
211     */
212    public function undeleteIfAllowed( string $comment ): StatusValue {
213        $status = $this->authorizeUndeletion();
214        if ( !$status->isGood() ) {
215            return $status;
216        }
217
218        return $this->undeleteUnsafe( $comment );
219    }
220
221    private function authorizeUndeletion(): PermissionStatus {
222        $status = PermissionStatus::newEmpty();
223        $this->performer->authorizeWrite( 'undelete', $this->page, $status );
224        if ( $this->associatedTalk ) {
225            $this->performer->authorizeWrite( 'undelete', $this->associatedTalk, $status );
226        }
227        if ( $this->tags ) {
228            $status->merge( ChangeTags::canAddTagsAccompanyingChange( $this->tags, $this->performer ) );
229        }
230        return $status;
231    }
232
233    /**
234     * Restore the given (or all) text and file revisions for the page.
235     * Once restored, the items will be removed from the archive tables.
236     * The deletion log will be updated with an undeletion notice.
237     *
238     * This also sets Status objects, $this->fileStatus and $this->revisionStatus
239     * (depending what operations are attempted).
240     *
241     * @note This method doesn't check user permissions. Use undeleteIfAllowed for that.
242     *
243     * @param string $comment
244     * @return StatusValue<array{revs:int, files:int}> Good Status with the following value on success:
245     *   [
246     *     self::REVISIONS_RESTORED => number of text revisions restored,
247     *     self::FILES_RESTORED => number of file revisions restored
248     *   ]
249     *   Fatal Status on failure.
250     */
251    public function undeleteUnsafe( string $comment ): StatusValue {
252        $hookStatus = $this->runPreUndeleteHook( $comment );
253        if ( !$hookStatus->isGood() ) {
254            return $hookStatus;
255        }
256        // If both the set of text revisions and file revisions are empty,
257        // restore everything. Otherwise, just restore the requested items.
258        $restoreAll = $this->timestamps === [] && $this->fileVersions === [];
259
260        $restoreText = $restoreAll || $this->timestamps !== [];
261        $restoreFiles = $restoreAll || $this->fileVersions !== [];
262
263        $resStatus = StatusValue::newGood();
264        $filesRestored = 0;
265        if ( $restoreFiles && $this->page->getNamespace() === NS_FILE ) {
266            /** @var LocalFile $img */
267            $img = $this->repoGroup->getLocalRepo()->newFile( $this->page );
268            $img->load( IDBAccessObject::READ_LATEST );
269            $this->fileStatus = $img->restore( $this->fileVersions, $this->unsuppress );
270            if ( !$this->fileStatus->isOK() ) {
271                return $this->fileStatus;
272            }
273            $filesRestored = $this->fileStatus->successCount;
274            $resStatus->merge( $this->fileStatus );
275        }
276
277        $textRestored = 0;
278        $pageCreated = false;
279        $restoredRevision = null;
280        $restoredPageIds = [];
281        if ( $restoreText ) {
282            // If we already restored files, then don't bail if there isn't any text to restore
283            $acceptNoRevisions = $filesRestored > 0;
284            $this->revisionStatus = $this->undeleteRevisions(
285                $this->page, $this->timestamps,
286                $comment, $acceptNoRevisions
287            );
288            if ( !$this->revisionStatus->isOK() ) {
289                // Type mismatch for status's value doesn't matter for non-OK status
290                // @phan-suppress-next-line PhanTypeMismatchReturn
291                return $this->revisionStatus;
292            }
293
294            [ $textRestored, $pageCreated, $restoredRevision, $restoredPageIds ] = $this->revisionStatus->getValue();
295            $resStatus->merge( $this->revisionStatus );
296        }
297
298        $talkRestored = 0;
299        $talkCreated = false;
300        $restoredTalkRevision = null;
301        $restoredTalkPageIds = [];
302        if ( $this->associatedTalk ) {
303            $talkStatus = $this->canProbablyUndeleteAssociatedTalk();
304            // if undeletion of the page fails we don't want to undelete the talk page
305            if ( $talkStatus->isGood() && $resStatus->isGood() ) {
306                $talkStatus = $this->undeleteRevisions( $this->associatedTalk, [], $comment, false );
307                if ( !$talkStatus->isOK() ) {
308                    // Type mismatch for status's value doesn't matter for non-OK status
309                    // @phan-suppress-next-line PhanTypeMismatchReturn
310                    return $talkStatus;
311                }
312                [ $talkRestored, $talkCreated, $restoredTalkRevision, $restoredTalkPageIds ] = $talkStatus->getValue();
313
314            } else {
315                // Add errors as warnings since the talk page is secondary to the main action
316                foreach ( $talkStatus->getMessages() as $msg ) {
317                    $resStatus->warning( $msg );
318                }
319            }
320        }
321
322        $resStatus->value = [
323            self::REVISIONS_RESTORED => $textRestored + $talkRestored,
324            self::FILES_RESTORED => $filesRestored
325        ];
326
327        if ( !$textRestored && !$filesRestored && !$talkRestored ) {
328            $this->logger->debug( "Undelete: nothing undeleted..." );
329            return $resStatus;
330        }
331
332        if ( $textRestored || $filesRestored ) {
333            $logEntry = $this->addLogEntry( $this->page, $comment, $textRestored, $filesRestored );
334
335            if ( $textRestored ) {
336                $this->hookRunner->onPageUndeleteComplete(
337                    $this->page,
338                    $this->performer,
339                    $comment,
340                    // @phan-suppress-next-line PhanTypeMismatchArgumentNullable
341                    $restoredRevision,
342                    $logEntry,
343                    $textRestored,
344                    $pageCreated,
345                    $restoredPageIds
346                );
347            }
348        }
349
350        if ( $talkRestored ) {
351            $talkRestoredComment = $this->contLangMsgTextFormatter->format(
352                MessageValue::new( 'undelete-talk-summary-prefix' )->plaintextParams( $comment )
353            );
354            $logEntry = $this->addLogEntry( $this->associatedTalk, $talkRestoredComment, $talkRestored, 0 );
355
356            $this->hookRunner->onPageUndeleteComplete(
357                $this->associatedTalk,
358                $this->performer,
359                $talkRestoredComment,
360                // @phan-suppress-next-line PhanTypeMismatchArgumentNullable
361                $restoredTalkRevision,
362                $logEntry,
363                $talkRestored,
364                $talkCreated,
365                $restoredTalkPageIds
366            );
367        }
368
369        return $resStatus;
370    }
371
372    /**
373     * @return StatusValue<never>
374     */
375    private function runPreUndeleteHook( string $comment ): StatusValue {
376        $checkPages = [ $this->page ];
377        if ( $this->associatedTalk ) {
378            $checkPages[] = $this->associatedTalk;
379        }
380        foreach ( $checkPages as $page ) {
381            $hookStatus = StatusValue::newGood();
382            $hookRes = $this->hookRunner->onPageUndelete(
383                $page,
384                $this->performer,
385                $comment,
386                $this->unsuppress,
387                $this->timestamps,
388                $this->fileVersions,
389                $hookStatus
390            );
391            if ( !$hookRes && !$hookStatus->isGood() ) {
392                // Note: as per the PageUndeleteHook documentation, `return false` is ignored if $status is good.
393                return $hookStatus;
394            }
395        }
396        return Status::newGood();
397    }
398
399    /**
400     * @param ProperPageIdentity $page
401     * @param string $comment
402     * @param int $textRestored
403     * @param int $filesRestored
404     *
405     * @return ManualLogEntry
406     */
407    private function addLogEntry(
408        ProperPageIdentity $page,
409        string $comment,
410        int $textRestored,
411        int $filesRestored
412    ): ManualLogEntry {
413        $logEntry = new ManualLogEntry( 'delete', 'restore' );
414        $logEntry->setPerformer( $this->performer->getUser() );
415        $logEntry->setTarget( $page );
416        $logEntry->setComment( $comment );
417        $logEntry->addTags( $this->tags );
418        $logEntry->setParameters( [
419            ':assoc:count' => [
420                'revisions' => $textRestored,
421                'files' => $filesRestored,
422            ],
423        ] );
424
425        $logid = $logEntry->insert();
426        $logEntry->publish( $logid );
427
428        return $logEntry;
429    }
430
431    /**
432     * This is the meaty bit -- It restores archived revisions of the given page
433     * to the revision table.
434     *
435     * @param ProperPageIdentity $page
436     * @param string[] $timestamps
437     * @param string $comment
438     * @param bool $acceptNoRevisions Whether to return a good status rather than an error
439     *     if no revisions are undeleted.
440     * @throws ReadOnlyError
441     * @return StatusValue<array{int, bool, ?RevisionRecord, array<int,true>}>
442     */
443    private function undeleteRevisions(
444        ProperPageIdentity $page, array $timestamps,
445        string $comment, bool $acceptNoRevisions
446    ): StatusValue {
447        if ( $this->readOnlyMode->isReadOnly() ) {
448            throw new ReadOnlyError();
449        }
450
451        $dbw = $this->dbProvider->getPrimaryDatabase();
452        $dbw->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE );
453
454        $oldWhere = [
455            'ar_namespace' => $page->getNamespace(),
456            'ar_title' => $page->getDBkey(),
457        ];
458        if ( $timestamps ) {
459            $oldWhere['ar_timestamp'] = array_map( $dbw->timestamp( ... ), $timestamps );
460        }
461
462        $revisionStore = $this->revisionStore;
463        $result = $revisionStore->newArchiveSelectQueryBuilder( $dbw )
464            ->joinComment()
465            ->leftJoin( 'revision', null, 'ar_rev_id=rev_id' )
466            ->field( 'rev_id' )
467            ->where( $oldWhere )
468            ->orderBy( 'ar_timestamp' )
469            ->caller( __METHOD__ )->fetchResultSet();
470
471        $rev_count = $result->numRows();
472        if ( !$rev_count ) {
473            $this->logger->debug( __METHOD__ . ": no revisions to restore" );
474
475            // Status value is count of revisions, whether the page has been created,
476            // last revision undeleted and all undeleted pages
477            $status = Status::newGood( [ 0, false, null, [] ] );
478            if ( !$acceptNoRevisions ) {
479                $status->error( "undelete-no-results" );
480            }
481            $dbw->endAtomic( __METHOD__ );
482
483            return $status;
484        }
485
486        $result->seek( $rev_count - 1 );
487        $latestRestorableRow = $result->current();
488
489        // move back
490        $result->seek( 0 );
491
492        $wikiPage = $this->wikiPageFactory->newFromTitle( $page );
493
494        $created = true;
495        $oldcountable = false;
496        $updatedCurrentRevision = false;
497        $restoredRevCount = 0;
498        $restoredPages = [];
499
500        // pass this to ArticleUndelete hook
501        $oldPageId = (int)$latestRestorableRow->ar_page_id;
502
503        // Grab the content to check consistency with global state before restoring the page.
504        // XXX: The only current use case is Wikibase, which tries to enforce uniqueness of
505        // certain things across all pages. There may be a better way to do that.
506        $revision = $revisionStore->newRevisionFromArchiveRow(
507            $latestRestorableRow,
508            0,
509            $page
510        );
511
512        foreach ( $revision->getSlotRoles() as $role ) {
513            $content = $revision->getContent( $role, RevisionRecord::RAW );
514            // NOTE: article ID may not be known yet. validateSave() should not modify the database.
515            $contentHandler = $this->contentHandlerFactory->getContentHandler( $content->getModel() );
516            $validationParams = new ValidationParams( $wikiPage, 0 );
517            // @phan-suppress-next-line PhanTypeMismatchArgumentNullable RAW never returns null
518            $status = $contentHandler->validateSave( $content, $validationParams );
519            if ( !$status->isOK() ) {
520                $dbw->endAtomic( __METHOD__ );
521
522                return $status;
523            }
524        }
525
526        // Grab page state before changing it
527        $updater = $this->pageUpdaterFactory->newDerivedPageDataUpdater( $wikiPage );
528        $updater->grabCurrentRevision();
529
530        $pageId = $wikiPage->insertOn( $dbw, $latestRestorableRow->ar_page_id );
531        if ( $pageId === false ) {
532            // The page ID is reserved; let's pick another
533            $pageId = $wikiPage->insertOn( $dbw );
534            if ( $pageId === false ) {
535                // The page title must be already taken (race condition)
536                $created = false;
537            }
538        }
539
540        # Does this page already exist? We'll have to update it...
541        if ( !$created ) {
542            # Load latest data for the current page (T33179)
543            $wikiPage->loadPageData( IDBAccessObject::READ_EXCLUSIVE );
544            $pageId = $wikiPage->getId();
545            $oldcountable = $wikiPage->isCountable();
546
547            $previousTimestamp = false;
548            $latestRevId = $wikiPage->getLatest();
549            if ( $latestRevId ) {
550                $previousTimestamp = $revisionStore->getTimestampFromId(
551                    $latestRevId,
552                    IDBAccessObject::READ_LATEST
553                );
554            }
555            if ( $previousTimestamp === false ) {
556                $this->logger->debug( __METHOD__ . ": existing page refers to a page_latest that does not exist" );
557
558                // Status value is count of revisions, whether the page has been created,
559                // last revision undeleted and all undeleted pages
560                $status = Status::newGood( [ 0, false, null, [] ] );
561                $status->error( 'undeleterevision-missing' );
562                $dbw->cancelAtomic( __METHOD__ );
563
564                return $status;
565            }
566        } else {
567            $previousTimestamp = 0;
568        }
569
570        // Re-create the PageIdentity using $pageId
571        $page = PageIdentityValue::localIdentity(
572            $pageId,
573            $page->getNamespace(),
574            $page->getDBkey()
575        );
576
577        Assert::postcondition( $page->exists(), 'The page should exist now' );
578
579        // Check if a deleted revision will become the current revision...
580        $latestRestorableRowTimestamp = wfTimestamp( TS::MW, $latestRestorableRow->ar_timestamp );
581        if ( $latestRestorableRowTimestamp > $previousTimestamp ) {
582            // Check the state of the newest to-be version...
583            if ( !$this->unsuppress
584                && ( $latestRestorableRow->ar_deleted & RevisionRecord::DELETED_TEXT )
585            ) {
586                $dbw->cancelAtomic( __METHOD__ );
587
588                return Status::newFatal( "undeleterevdel" );
589            }
590            $updatedCurrentRevision = true;
591        }
592
593        foreach ( $result as $row ) {
594            // Insert one revision at a time...maintaining deletion status
595            // unless we are specifically removing all restrictions...
596            $revision = $revisionStore->newRevisionFromArchiveRow(
597                $row,
598                0,
599                $page,
600                [
601                    'page_id' => $pageId,
602                    'deleted' => $this->unsuppress ? 0 : $row->ar_deleted
603                ]
604            );
605
606            // This will also copy the revision to ip_changes if it was an IP edit.
607            $revision = $revisionStore->insertRevisionOn( $revision, $dbw );
608
609            $restoredRevCount++;
610
611            $this->hookRunner->onRevisionUndeleted( $revision, $row->ar_page_id );
612
613            $restoredPages[$row->ar_page_id] = true;
614        }
615
616        // Now that it's safely stored, take it out of the archive
617        $dbw->newDeleteQueryBuilder()
618            ->deleteFrom( 'archive' )
619            ->where( $oldWhere )
620            ->caller( __METHOD__ )->execute();
621
622        // Status value is count of revisions, whether the page has been created,
623        // last revision undeleted and all undeleted pages
624        $status = Status::newGood( [ $restoredRevCount, $created, $revision, $restoredPages ] );
625
626        // Was anything restored at all?
627        if ( $restoredRevCount ) {
628
629            if ( $updatedCurrentRevision ) {
630                // Attach the latest revision to the page...
631                // XXX: updateRevisionOn should probably move into a PageStore service.
632                $wasnew = $wikiPage->updateRevisionOn(
633                    $dbw,
634                    $revision,
635                    $created ? 0 : $wikiPage->getLatest()
636                );
637            } else {
638                $wasnew = false;
639            }
640
641            if ( $created || $wasnew ) {
642                // Update site stats, link tables, etc
643                $options = [
644                    PageLatestRevisionChangedEvent::FLAG_SILENT => true,
645                    PageLatestRevisionChangedEvent::FLAG_IMPLICIT => true,
646                    'created' => $created,
647                    'oldcountable' => $oldcountable,
648                    'reason' => $comment
649                ];
650
651                $updater->setCause( PageUpdater::CAUSE_UNDELETE );
652                $updater->setPerformer( $this->performer->getUser() );
653                $updater->prepareUpdate( $revision, $options );
654                $updater->doUpdates();
655            }
656
657            $this->hookRunner->onArticleUndelete(
658                $wikiPage->getTitle(), $created, $comment, $oldPageId, $restoredPages );
659
660            if ( $page->getNamespace() === NS_FILE ) {
661                $job = HTMLCacheUpdateJob::newForBacklinks(
662                    $page,
663                    'imagelinks',
664                    [ 'causeAction' => 'undelete-file' ]
665                );
666                $this->jobQueueGroup->lazyPush( $job );
667            }
668        }
669
670        $dbw->endAtomic( __METHOD__ );
671
672        return $status;
673    }
674
675    /**
676     * @internal BC method to be used by PageArchive only
677     * @return Status|null
678     */
679    public function getFileStatus(): ?Status {
680        return $this->fileStatus;
681    }
682
683    /**
684     * @internal BC methods to be used by PageArchive only
685     * @return StatusValue<array{int, bool, ?RevisionRecord, array<int,true>}>|null
686     */
687    public function getRevisionStatus(): ?StatusValue {
688        return $this->revisionStatus;
689    }
690}