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