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