Localisation updates from https://translatewiki.net.
[mediawiki.git] / includes / specials / SpecialUndelete.php
blobb5fe1359fbca253ba4b3aa3e14790cc99b4df985
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.
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.
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
18 * @file
21 namespace MediaWiki\Specials;
23 use ArchivedFile;
24 use ChangesList;
25 use ChangeTags;
26 use ErrorPageError;
27 use File;
28 use LocalRepo;
29 use LogEventsList;
30 use LogPage;
31 use MediaWiki\Cache\LinkBatch;
32 use MediaWiki\Cache\LinkBatchFactory;
33 use MediaWiki\CommentFormatter\CommentFormatter;
34 use MediaWiki\CommentStore\CommentStore;
35 use MediaWiki\Content\IContentHandlerFactory;
36 use MediaWiki\Content\TextContent;
37 use MediaWiki\Context\DerivativeContext;
38 use MediaWiki\Html\Html;
39 use MediaWiki\Linker\Linker;
40 use MediaWiki\Linker\LinkTarget;
41 use MediaWiki\MainConfigNames;
42 use MediaWiki\Message\Message;
43 use MediaWiki\Page\UndeletePage;
44 use MediaWiki\Page\UndeletePageFactory;
45 use MediaWiki\Page\WikiPageFactory;
46 use MediaWiki\Permissions\PermissionManager;
47 use MediaWiki\Revision\ArchivedRevisionLookup;
48 use MediaWiki\Revision\RevisionAccessException;
49 use MediaWiki\Revision\RevisionArchiveRecord;
50 use MediaWiki\Revision\RevisionRecord;
51 use MediaWiki\Revision\RevisionRenderer;
52 use MediaWiki\Revision\RevisionStore;
53 use MediaWiki\Revision\SlotRecord;
54 use MediaWiki\SpecialPage\SpecialPage;
55 use MediaWiki\Storage\NameTableAccessException;
56 use MediaWiki\Storage\NameTableStore;
57 use MediaWiki\Title\Title;
58 use MediaWiki\User\Options\UserOptionsLookup;
59 use MediaWiki\User\User;
60 use MediaWiki\Watchlist\WatchlistManager;
61 use MediaWiki\Xml\Xml;
62 use OOUI\ActionFieldLayout;
63 use OOUI\ButtonInputWidget;
64 use OOUI\CheckboxInputWidget;
65 use OOUI\DropdownInputWidget;
66 use OOUI\FieldLayout;
67 use OOUI\FieldsetLayout;
68 use OOUI\FormLayout;
69 use OOUI\HorizontalLayout;
70 use OOUI\HtmlSnippet;
71 use OOUI\Layout;
72 use OOUI\PanelLayout;
73 use OOUI\TextInputWidget;
74 use OOUI\Widget;
75 use PageArchive;
76 use PermissionsError;
77 use RepoGroup;
78 use SearchEngineFactory;
79 use UserBlockedError;
80 use Wikimedia\Rdbms\IConnectionProvider;
81 use Wikimedia\Rdbms\IDBAccessObject;
82 use Wikimedia\Rdbms\IResultWrapper;
84 /**
85 * Special page allowing users with the appropriate permissions to view
86 * and restore deleted content.
88 * @ingroup SpecialPage
90 class SpecialUndelete extends SpecialPage {
92 /**
93 * Limit of revisions (Page history) to display.
94 * (If there are more items to display - "Load more" button will appear).
96 private const REVISION_HISTORY_LIMIT = 500;
98 /** @var string|null */
99 private $mAction;
100 /** @var string */
101 private $mTarget;
102 /** @var string */
103 private $mTimestamp;
104 /** @var bool */
105 private $mRestore;
106 /** @var bool */
107 private $mRevdel;
108 /** @var bool */
109 private $mInvert;
110 /** @var string */
111 private $mFilename;
112 /** @var string[] */
113 private $mTargetTimestamp = [];
114 /** @var bool */
115 private $mAllowed;
116 /** @var bool */
117 private $mCanView;
118 /** @var string */
119 private $mComment = '';
120 /** @var string */
121 private $mToken;
122 /** @var bool|null */
123 private $mPreview;
124 /** @var bool|null */
125 private $mDiff;
126 /** @var bool|null */
127 private $mDiffOnly;
128 /** @var bool|null */
129 private $mUnsuppress;
130 /** @var int[] */
131 private $mFileVersions = [];
132 /** @var bool|null */
133 private $mUndeleteTalk;
134 /** @var string|null Timestamp at which to start a "load more" request (open interval) */
135 private $mHistoryOffset;
137 /** @var Title|null */
138 private $mTargetObj;
140 * @var string Search prefix
142 private $mSearchPrefix;
144 private PermissionManager $permissionManager;
145 private RevisionStore $revisionStore;
146 private RevisionRenderer $revisionRenderer;
147 private IContentHandlerFactory $contentHandlerFactory;
148 private NameTableStore $changeTagDefStore;
149 private LinkBatchFactory $linkBatchFactory;
150 private LocalRepo $localRepo;
151 private IConnectionProvider $dbProvider;
152 private UserOptionsLookup $userOptionsLookup;
153 private WikiPageFactory $wikiPageFactory;
154 private SearchEngineFactory $searchEngineFactory;
155 private UndeletePageFactory $undeletePageFactory;
156 private ArchivedRevisionLookup $archivedRevisionLookup;
157 private CommentFormatter $commentFormatter;
158 private WatchlistManager $watchlistManager;
161 * @param PermissionManager $permissionManager
162 * @param RevisionStore $revisionStore
163 * @param RevisionRenderer $revisionRenderer
164 * @param IContentHandlerFactory $contentHandlerFactory
165 * @param NameTableStore $changeTagDefStore
166 * @param LinkBatchFactory $linkBatchFactory
167 * @param RepoGroup $repoGroup
168 * @param IConnectionProvider $dbProvider
169 * @param UserOptionsLookup $userOptionsLookup
170 * @param WikiPageFactory $wikiPageFactory
171 * @param SearchEngineFactory $searchEngineFactory
172 * @param UndeletePageFactory $undeletePageFactory
173 * @param ArchivedRevisionLookup $archivedRevisionLookup
174 * @param CommentFormatter $commentFormatter
175 * @param WatchlistManager $watchlistManager
177 public function __construct(
178 PermissionManager $permissionManager,
179 RevisionStore $revisionStore,
180 RevisionRenderer $revisionRenderer,
181 IContentHandlerFactory $contentHandlerFactory,
182 NameTableStore $changeTagDefStore,
183 LinkBatchFactory $linkBatchFactory,
184 RepoGroup $repoGroup,
185 IConnectionProvider $dbProvider,
186 UserOptionsLookup $userOptionsLookup,
187 WikiPageFactory $wikiPageFactory,
188 SearchEngineFactory $searchEngineFactory,
189 UndeletePageFactory $undeletePageFactory,
190 ArchivedRevisionLookup $archivedRevisionLookup,
191 CommentFormatter $commentFormatter,
192 WatchlistManager $watchlistManager
194 parent::__construct( 'Undelete', 'deletedhistory' );
195 $this->permissionManager = $permissionManager;
196 $this->revisionStore = $revisionStore;
197 $this->revisionRenderer = $revisionRenderer;
198 $this->contentHandlerFactory = $contentHandlerFactory;
199 $this->changeTagDefStore = $changeTagDefStore;
200 $this->linkBatchFactory = $linkBatchFactory;
201 $this->localRepo = $repoGroup->getLocalRepo();
202 $this->dbProvider = $dbProvider;
203 $this->userOptionsLookup = $userOptionsLookup;
204 $this->wikiPageFactory = $wikiPageFactory;
205 $this->searchEngineFactory = $searchEngineFactory;
206 $this->undeletePageFactory = $undeletePageFactory;
207 $this->archivedRevisionLookup = $archivedRevisionLookup;
208 $this->commentFormatter = $commentFormatter;
209 $this->watchlistManager = $watchlistManager;
212 public function doesWrites() {
213 return true;
216 private function loadRequest( $par ) {
217 $request = $this->getRequest();
218 $user = $this->getUser();
220 $this->mAction = $request->getRawVal( 'action' );
221 if ( $par !== null && $par !== '' ) {
222 $this->mTarget = $par;
223 } else {
224 $this->mTarget = $request->getVal( 'target' );
227 $this->mTargetObj = null;
229 if ( $this->mTarget !== null && $this->mTarget !== '' ) {
230 $this->mTargetObj = Title::newFromText( $this->mTarget );
233 $this->mSearchPrefix = $request->getText( 'prefix' );
234 $time = $request->getVal( 'timestamp' );
235 $this->mTimestamp = $time ? wfTimestamp( TS_MW, $time ) : '';
236 $this->mFilename = $request->getVal( 'file' );
238 $posted = $request->wasPosted() &&
239 $user->matchEditToken( $request->getVal( 'wpEditToken' ) );
240 $this->mRestore = $request->getCheck( 'restore' ) && $posted;
241 $this->mRevdel = $request->getCheck( 'revdel' ) && $posted;
242 $this->mInvert = $request->getCheck( 'invert' ) && $posted;
243 $this->mPreview = $request->getCheck( 'preview' ) && $posted;
244 $this->mDiff = $request->getCheck( 'diff' );
245 $this->mDiffOnly = $request->getBool( 'diffonly',
246 $this->userOptionsLookup->getOption( $this->getUser(), 'diffonly' ) );
247 $commentList = $request->getText( 'wpCommentList', 'other' );
248 $comment = $request->getText( 'wpComment' );
249 if ( $commentList === 'other' ) {
250 $this->mComment = $comment;
251 } elseif ( $comment !== '' ) {
252 $this->mComment = $commentList . $this->msg( 'colon-separator' )->inContentLanguage()->text() . $comment;
253 } else {
254 $this->mComment = $commentList;
256 $this->mUnsuppress = $request->getVal( 'wpUnsuppress' ) &&
257 $this->permissionManager->userHasRight( $user, 'suppressrevision' );
258 $this->mToken = $request->getVal( 'token' );
259 $this->mUndeleteTalk = $request->getCheck( 'undeletetalk' );
260 $this->mHistoryOffset = $request->getVal( 'historyoffset' );
262 if ( $this->isAllowed( 'undelete' ) ) {
263 $this->mAllowed = true; // user can restore
264 $this->mCanView = true; // user can view content
265 } elseif ( $this->isAllowed( 'deletedtext' ) ) {
266 $this->mAllowed = false; // user cannot restore
267 $this->mCanView = true; // user can view content
268 $this->mRestore = false;
269 } else { // user can only view the list of revisions
270 $this->mAllowed = false;
271 $this->mCanView = false;
272 $this->mTimestamp = '';
273 $this->mRestore = false;
276 if ( $this->mRestore || $this->mInvert ) {
277 $timestamps = [];
278 $this->mFileVersions = [];
279 foreach ( $request->getValues() as $key => $val ) {
280 $matches = [];
281 if ( preg_match( '/^ts(\d{14})$/', $key, $matches ) ) {
282 $timestamps[] = $matches[1];
285 if ( preg_match( '/^fileid(\d+)$/', $key, $matches ) ) {
286 $this->mFileVersions[] = intval( $matches[1] );
289 rsort( $timestamps );
290 $this->mTargetTimestamp = $timestamps;
295 * Checks whether a user is allowed the permission for the
296 * specific title if one is set.
298 * @param string $permission
299 * @param User|null $user
300 * @return bool
302 protected function isAllowed( $permission, ?User $user = null ) {
303 $user ??= $this->getUser();
304 $block = $user->getBlock();
306 if ( $this->mTargetObj !== null ) {
307 return $this->permissionManager->userCan( $permission, $user, $this->mTargetObj );
308 } else {
309 $hasRight = $this->permissionManager->userHasRight( $user, $permission );
310 $sitewideBlock = $block && $block->isSitewide();
311 return $permission === 'undelete' ? ( $hasRight && !$sitewideBlock ) : $hasRight;
315 public function userCanExecute( User $user ) {
316 return $this->isAllowed( $this->mRestriction, $user );
320 * @inheritDoc
322 public function checkPermissions() {
323 $user = $this->getUser();
325 // First check if user has the right to use this page. If not,
326 // show a permissions error whether they are blocked or not.
327 if ( !parent::userCanExecute( $user ) ) {
328 $this->displayRestrictionError();
331 // If a user has the right to use this page, but is blocked from
332 // the target, show a block error.
333 if (
334 $this->mTargetObj && $this->permissionManager->isBlockedFrom( $user, $this->mTargetObj ) ) {
335 // @phan-suppress-next-line PhanTypeMismatchArgumentNullable Block is checked and not null
336 throw new UserBlockedError( $user->getBlock() );
339 // Finally, do the comprehensive permission check via isAllowed.
340 if ( !$this->userCanExecute( $user ) ) {
341 $this->displayRestrictionError();
345 public function execute( $par ) {
346 $this->useTransactionalTimeLimit();
348 $user = $this->getUser();
350 $this->setHeaders();
351 $this->outputHeader();
352 $this->addHelpLink( 'Help:Deletion_and_undeletion' );
354 $this->loadRequest( $par );
355 $this->checkPermissions(); // Needs to be after mTargetObj is set
357 $out = $this->getOutput();
359 if ( $this->mTargetObj === null ) {
360 $out->addWikiMsg( 'undelete-header' );
362 # Not all users can just browse every deleted page from the list
363 if ( $this->permissionManager->userHasRight( $user, 'browsearchive' ) ) {
364 $this->showSearchForm();
367 return;
370 $this->addHelpLink( 'Help:Undelete' );
371 if ( $this->mAllowed ) {
372 $out->setPageTitleMsg( $this->msg( 'undeletepage' ) );
373 } else {
374 $out->setPageTitleMsg( $this->msg( 'viewdeletedpage' ) );
377 $this->getSkin()->setRelevantTitle( $this->mTargetObj );
379 if ( $this->mTimestamp !== '' ) {
380 $this->showRevision( $this->mTimestamp );
381 } elseif ( $this->mFilename !== null && $this->mTargetObj->inNamespace( NS_FILE ) ) {
382 $file = new ArchivedFile( $this->mTargetObj, 0, $this->mFilename );
383 // Check if user is allowed to see this file
384 if ( !$file->exists() ) {
385 $out->addWikiMsg( 'filedelete-nofile', $this->mFilename );
386 } elseif ( !$file->userCan( File::DELETED_FILE, $user ) ) {
387 if ( $file->isDeleted( File::DELETED_RESTRICTED ) ) {
388 throw new PermissionsError( 'suppressrevision' );
389 } else {
390 throw new PermissionsError( 'deletedtext' );
392 } elseif ( !$user->matchEditToken( $this->mToken, $this->mFilename ) ) {
393 $this->showFileConfirmationForm( $this->mFilename );
394 } else {
395 $this->showFile( $this->mFilename );
397 } elseif ( $this->mAction === 'submit' ) {
398 if ( $this->mRestore ) {
399 $this->undelete();
400 } elseif ( $this->mRevdel ) {
401 $this->redirectToRevDel();
403 } elseif ( $this->mAction === 'render' ) {
404 $this->showMoreHistory();
405 } else {
406 $this->showHistory();
411 * Convert submitted form data to format expected by RevisionDelete and
412 * redirect the request
414 private function redirectToRevDel() {
415 $revisions = [];
417 foreach ( $this->getRequest()->getValues() as $key => $val ) {
418 $matches = [];
419 if ( preg_match( "/^ts(\d{14})$/", $key, $matches ) ) {
420 $revisionRecord = $this->archivedRevisionLookup
421 ->getRevisionRecordByTimestamp( $this->mTargetObj, $matches[1] );
422 if ( $revisionRecord ) {
423 // Can return null
424 $revisions[ $revisionRecord->getId() ] = 1;
429 $query = [
430 'type' => 'revision',
431 'ids' => $revisions,
432 'target' => $this->mTargetObj->getPrefixedText()
434 $url = SpecialPage::getTitleFor( 'Revisiondelete' )->getFullURL( $query );
435 $this->getOutput()->redirect( $url );
438 private function showSearchForm() {
439 $out = $this->getOutput();
440 $out->setPageTitleMsg( $this->msg( 'undelete-search-title' ) );
441 $fuzzySearch = $this->getRequest()->getVal( 'fuzzy', '1' );
443 $out->enableOOUI();
445 $fields = [];
446 $fields[] = new ActionFieldLayout(
447 new TextInputWidget( [
448 'name' => 'prefix',
449 'inputId' => 'prefix',
450 'infusable' => true,
451 'value' => $this->mSearchPrefix,
452 'autofocus' => true,
453 ] ),
454 new ButtonInputWidget( [
455 'label' => $this->msg( 'undelete-search-submit' )->text(),
456 'flags' => [ 'primary', 'progressive' ],
457 'inputId' => 'searchUndelete',
458 'type' => 'submit',
459 ] ),
461 'label' => new HtmlSnippet(
462 $this->msg(
463 $fuzzySearch ? 'undelete-search-full' : 'undelete-search-prefix'
464 )->parse()
466 'align' => 'left',
470 $fieldset = new FieldsetLayout( [
471 'label' => $this->msg( 'undelete-search-box' )->text(),
472 'items' => $fields,
473 ] );
475 $form = new FormLayout( [
476 'method' => 'get',
477 'action' => wfScript(),
478 ] );
480 $form->appendContent(
481 $fieldset,
482 new HtmlSnippet(
483 Html::hidden( 'title', $this->getPageTitle()->getPrefixedDBkey() ) .
484 Html::hidden( 'fuzzy', $fuzzySearch )
488 $out->addHTML(
489 new PanelLayout( [
490 'expanded' => false,
491 'padded' => true,
492 'framed' => true,
493 'content' => $form,
497 # List undeletable articles
498 if ( $this->mSearchPrefix ) {
499 // For now, we enable search engine match only when specifically asked to
500 // by using fuzzy=1 parameter.
501 if ( $fuzzySearch ) {
502 $result = PageArchive::listPagesBySearch( $this->mSearchPrefix );
503 } else {
504 $result = PageArchive::listPagesByPrefix( $this->mSearchPrefix );
506 $this->showList( $result );
511 * Generic list of deleted pages
513 * @param IResultWrapper $result
514 * @return bool
516 private function showList( $result ) {
517 $out = $this->getOutput();
519 if ( $result->numRows() == 0 ) {
520 $out->addWikiMsg( 'undelete-no-results' );
522 return false;
525 $out->addWikiMsg( 'undeletepagetext', $this->getLanguage()->formatNum( $result->numRows() ) );
527 $linkRenderer = $this->getLinkRenderer();
528 $undelete = $this->getPageTitle();
529 $out->addHTML( "<ul id='undeleteResultsList'>\n" );
530 foreach ( $result as $row ) {
531 $title = Title::makeTitleSafe( $row->ar_namespace, $row->ar_title );
532 if ( $title !== null ) {
533 $item = $linkRenderer->makeKnownLink(
534 $undelete,
535 $title->getPrefixedText(),
537 [ 'target' => $title->getPrefixedText() ]
539 } else {
540 // The title is no longer valid, show as text
541 $item = Html::element(
542 'span',
543 [ 'class' => 'mw-invalidtitle' ],
544 Linker::getInvalidTitleDescription(
545 $this->getContext(),
546 $row->ar_namespace,
547 $row->ar_title
551 $revs = $this->msg( 'undeleterevisions' )->numParams( $row->count )->parse();
552 $out->addHTML(
553 Html::rawElement(
554 'li',
555 [ 'class' => 'undeleteResult' ],
556 $item . $this->msg( 'word-separator' )->escaped() .
557 $this->msg( 'parentheses' )->rawParams( $revs )->escaped()
561 $result->free();
562 $out->addHTML( "</ul>\n" );
564 return true;
567 private function showRevision( $timestamp ) {
568 if ( !preg_match( '/[0-9]{14}/', $timestamp ) ) {
569 return;
571 $out = $this->getOutput();
572 $out->addModuleStyles( 'mediawiki.interface.helpers.styles' );
574 // When viewing a specific revision, add a subtitle link back to the overall
575 // history, see T284114
576 $listLink = $this->getLinkRenderer()->makeKnownLink(
577 $this->getPageTitle(),
578 $this->msg( 'undelete-back-to-list' )->text(),
580 [ 'target' => $this->mTargetObj->getPrefixedText() ]
582 // same < arrow as with subpages
583 $subtitle = "&lt; $listLink";
584 $out->setSubtitle( $subtitle );
586 $archive = new PageArchive( $this->mTargetObj );
587 // FIXME: This hook must be deprecated, passing PageArchive by ref is awful.
588 if ( !$this->getHookRunner()->onUndeleteForm__showRevision(
589 $archive, $this->mTargetObj )
591 return;
593 $revRecord = $this->archivedRevisionLookup->getRevisionRecordByTimestamp( $this->mTargetObj, $timestamp );
595 $user = $this->getUser();
597 if ( !$revRecord ) {
598 $out->addWikiMsg( 'undeleterevision-missing' );
599 return;
602 if ( $revRecord->isDeleted( RevisionRecord::DELETED_TEXT ) ) {
603 // Used in wikilinks, should not contain whitespaces
604 $titleText = $this->mTargetObj->getPrefixedDBkey();
605 if ( !$revRecord->userCan( RevisionRecord::DELETED_TEXT, $this->getAuthority() ) ) {
606 $msg = $revRecord->isDeleted( RevisionRecord::DELETED_RESTRICTED )
607 ? [ 'rev-suppressed-text-permission', $titleText ]
608 : [ 'rev-deleted-text-permission', $titleText ];
609 $out->addHTML(
610 Html::warningBox(
611 $this->msg( $msg[0], $msg[1] )->parse(),
612 'plainlinks'
615 return;
618 $msg = $revRecord->isDeleted( RevisionRecord::DELETED_RESTRICTED )
619 ? [ 'rev-suppressed-text-view', $titleText ]
620 : [ 'rev-deleted-text-view', $titleText ];
621 $out->addHTML(
622 Html::warningBox(
623 $this->msg( $msg[0], $msg[1] )->parse(),
624 'plainlinks'
627 // and we are allowed to see...
630 if ( $this->mDiff ) {
631 $previousRevRecord = $this->archivedRevisionLookup
632 ->getPreviousRevisionRecord( $this->mTargetObj, $timestamp );
633 if ( $previousRevRecord ) {
634 $this->showDiff( $previousRevRecord, $revRecord );
635 if ( $this->mDiffOnly ) {
636 return;
639 $out->addHTML( '<hr />' );
640 } else {
641 $out->addWikiMsg( 'undelete-nodiff' );
645 $link = $this->getLinkRenderer()->makeKnownLink(
646 $this->getPageTitle( $this->mTargetObj->getPrefixedDBkey() ),
647 $this->mTargetObj->getPrefixedText()
650 $lang = $this->getLanguage();
652 // date and time are separate parameters to facilitate localisation.
653 // $time is kept for backward compat reasons.
654 $time = $lang->userTimeAndDate( $timestamp, $user );
655 $d = $lang->userDate( $timestamp, $user );
656 $t = $lang->userTime( $timestamp, $user );
657 $userLink = Linker::revUserTools( $revRecord );
659 try {
660 $content = $revRecord->getContent(
661 SlotRecord::MAIN,
662 RevisionRecord::FOR_THIS_USER,
663 $user
665 } catch ( RevisionAccessException $e ) {
666 $content = null;
669 // TODO: MCR: this will have to become something like $hasTextSlots and $hasNonTextSlots
670 $isText = ( $content instanceof TextContent );
672 $undeleteRevisionContent = '';
673 // Revision delete links
674 if ( !$this->mDiff ) {
675 $revdel = Linker::getRevDeleteLink(
676 $user,
677 $revRecord,
678 $this->mTargetObj
680 if ( $revdel ) {
681 $undeleteRevisionContent = $revdel . ' ';
685 $undeleteRevisionContent .= $out->msg(
686 'undelete-revision',
687 Message::rawParam( $link ),
688 $time,
689 Message::rawParam( $userLink ),
692 )->parseAsBlock();
694 if ( $this->mPreview || $isText ) {
695 $out->addHTML(
696 Html::warningBox(
697 $undeleteRevisionContent,
698 'mw-undelete-revision'
701 } else {
702 $out->addHTML(
703 Html::rawElement(
704 'div',
705 [ 'class' => 'mw-undelete-revision', ],
706 $undeleteRevisionContent
711 if ( $this->mPreview || !$isText ) {
712 // NOTE: non-text content has no source view, so always use rendered preview
714 $popts = $out->parserOptions();
716 try {
717 $rendered = $this->revisionRenderer->getRenderedRevision(
718 $revRecord,
719 $popts,
720 $user,
721 [ 'audience' => RevisionRecord::FOR_THIS_USER, 'causeAction' => 'undelete-preview' ]
724 // Fail hard if the audience check fails, since we already checked
725 // at the beginning of this method.
726 $pout = $rendered->getRevisionParserOutput();
728 $out->addParserOutput( $pout, [
729 'enableSectionEditLinks' => false,
730 ] );
731 } catch ( RevisionAccessException $e ) {
735 $out->enableOOUI();
736 $buttonFields = [];
738 if ( $isText ) {
739 '@phan-var TextContent $content';
740 // TODO: MCR: make this work for multiple slots
741 // source view for textual content
742 $sourceView = Xml::element( 'textarea', [
743 'readonly' => 'readonly',
744 'cols' => 80,
745 'rows' => 25
746 ], $content->getText() . "\n" );
748 $buttonFields[] = new ButtonInputWidget( [
749 'type' => 'submit',
750 'name' => 'preview',
751 'label' => $this->msg( 'showpreview' )->text()
752 ] );
753 } else {
754 $sourceView = '';
757 $buttonFields[] = new ButtonInputWidget( [
758 'name' => 'diff',
759 'type' => 'submit',
760 'label' => $this->msg( 'showdiff' )->text()
761 ] );
763 $out->addHTML(
764 $sourceView .
765 Xml::openElement( 'div', [
766 'style' => 'clear: both' ] ) .
767 Xml::openElement( 'form', [
768 'method' => 'post',
769 'action' => $this->getPageTitle()->getLocalURL( [ 'action' => 'submit' ] ) ] ) .
770 Xml::element( 'input', [
771 'type' => 'hidden',
772 'name' => 'target',
773 'value' => $this->mTargetObj->getPrefixedDBkey() ] ) .
774 Xml::element( 'input', [
775 'type' => 'hidden',
776 'name' => 'timestamp',
777 'value' => $timestamp ] ) .
778 Xml::element( 'input', [
779 'type' => 'hidden',
780 'name' => 'wpEditToken',
781 'value' => $user->getEditToken() ] ) .
782 new FieldLayout(
783 new Widget( [
784 'content' => new HorizontalLayout( [
785 'items' => $buttonFields
789 Xml::closeElement( 'form' ) .
790 Xml::closeElement( 'div' )
795 * Build a diff display between this and the previous either deleted
796 * or non-deleted edit.
798 * @param RevisionRecord $previousRevRecord
799 * @param RevisionRecord $currentRevRecord
801 private function showDiff(
802 RevisionRecord $previousRevRecord,
803 RevisionRecord $currentRevRecord
805 $currentTitle = Title::newFromLinkTarget( $currentRevRecord->getPageAsLinkTarget() );
807 $diffContext = new DerivativeContext( $this->getContext() );
808 $diffContext->setTitle( $currentTitle );
809 $diffContext->setWikiPage( $this->wikiPageFactory->newFromTitle( $currentTitle ) );
811 $contentModel = $currentRevRecord->getSlot(
812 SlotRecord::MAIN,
813 RevisionRecord::RAW
814 )->getModel();
816 $diffEngine = $this->contentHandlerFactory->getContentHandler( $contentModel )
817 ->createDifferenceEngine( $diffContext );
819 $diffEngine->setRevisions( $previousRevRecord, $currentRevRecord );
820 $diffEngine->showDiffStyle();
821 $formattedDiff = $diffEngine->getDiff(
822 $this->diffHeader( $previousRevRecord, 'o' ),
823 $this->diffHeader( $currentRevRecord, 'n' )
826 $this->getOutput()->addHTML( "<div>$formattedDiff</div>\n" );
830 * @param RevisionRecord $revRecord
831 * @param string $prefix
832 * @return string
834 private function diffHeader( RevisionRecord $revRecord, $prefix ) {
835 if ( $revRecord instanceof RevisionArchiveRecord ) {
836 // Revision in the archive table, only viewable via this special page
837 $targetPage = $this->getPageTitle();
838 $targetQuery = [
839 'target' => $this->mTargetObj->getPrefixedText(),
840 'timestamp' => wfTimestamp( TS_MW, $revRecord->getTimestamp() )
842 } else {
843 // Revision in the revision table, viewable by oldid
844 $targetPage = $revRecord->getPageAsLinkTarget();
845 $targetQuery = [ 'oldid' => $revRecord->getId() ];
848 // Add show/hide deletion links if available
849 $user = $this->getUser();
850 $lang = $this->getLanguage();
851 $rdel = Linker::getRevDeleteLink( $user, $revRecord, $this->mTargetObj );
853 if ( $rdel ) {
854 $rdel = " $rdel";
857 $minor = $revRecord->isMinor() ? ChangesList::flag( 'minor' ) : '';
859 $dbr = $this->dbProvider->getReplicaDatabase();
860 $tagIds = $dbr->newSelectQueryBuilder()
861 ->select( 'ct_tag_id' )
862 ->from( 'change_tag' )
863 ->where( [ 'ct_rev_id' => $revRecord->getId() ] )
864 ->caller( __METHOD__ )->fetchFieldValues();
865 $tags = [];
866 foreach ( $tagIds as $tagId ) {
867 try {
868 $tags[] = $this->changeTagDefStore->getName( (int)$tagId );
869 } catch ( NameTableAccessException $exception ) {
870 continue;
873 $tags = implode( ',', $tags );
874 $tagSummary = ChangeTags::formatSummaryRow( $tags, 'deleteddiff', $this->getContext() );
875 $asof = $this->getLinkRenderer()->makeLink(
876 $targetPage,
877 $this->msg(
878 'revisionasof',
879 $lang->userTimeAndDate( $revRecord->getTimestamp(), $user ),
880 $lang->userDate( $revRecord->getTimestamp(), $user ),
881 $lang->userTime( $revRecord->getTimestamp(), $user )
882 )->text(),
884 $targetQuery
886 if ( $revRecord->isDeleted( RevisionRecord::DELETED_TEXT ) ) {
887 $asof = Html::rawElement(
888 'span',
889 [ 'class' => Linker::getRevisionDeletedClass( $revRecord ) ],
890 $asof
894 // FIXME This is reimplementing DifferenceEngine#getRevisionHeader
895 // and partially #showDiffPage, but worse
896 return '<div id="mw-diff-' . $prefix . 'title1"><strong>' .
897 $asof .
898 '</strong></div>' .
899 '<div id="mw-diff-' . $prefix . 'title2">' .
900 Linker::revUserTools( $revRecord ) . '<br />' .
901 '</div>' .
902 '<div id="mw-diff-' . $prefix . 'title3">' .
903 $minor . $this->commentFormatter->formatRevision( $revRecord, $user ) . $rdel . '<br />' .
904 '</div>' .
905 '<div id="mw-diff-' . $prefix . 'title5">' .
906 $tagSummary[0] . '<br />' .
907 '</div>';
911 * Show a form confirming whether a tokenless user really wants to see a file
912 * @param string $key
914 private function showFileConfirmationForm( $key ) {
915 $out = $this->getOutput();
916 $lang = $this->getLanguage();
917 $user = $this->getUser();
918 $file = new ArchivedFile( $this->mTargetObj, 0, $this->mFilename );
919 $out->addWikiMsg( 'undelete-show-file-confirm',
920 $this->mTargetObj->getText(),
921 $lang->userDate( $file->getTimestamp(), $user ),
922 $lang->userTime( $file->getTimestamp(), $user ) );
923 $out->addHTML(
924 Html::rawElement( 'form', [
925 'method' => 'POST',
926 'action' => $this->getPageTitle()->getLocalURL( [
927 'target' => $this->mTarget,
928 'file' => $key,
929 'token' => $user->getEditToken( $key ),
930 ] ),
932 Xml::submitButton( $this->msg( 'undelete-show-file-submit' )->text() )
938 * Show a deleted file version requested by the visitor.
939 * @param string $key
941 private function showFile( $key ) {
942 $this->getOutput()->disable();
944 # We mustn't allow the output to be CDN cached, otherwise
945 # if an admin previews a deleted image, and it's cached, then
946 # a user without appropriate permissions can toddle off and
947 # nab the image, and CDN will serve it
948 $response = $this->getRequest()->response();
949 $response->header( 'Expires: ' . gmdate( 'D, d M Y H:i:s', 0 ) . ' GMT' );
950 $response->header( 'Cache-Control: no-cache, no-store, max-age=0, must-revalidate' );
952 $path = $this->localRepo->getZonePath( 'deleted' ) . '/' . $this->localRepo->getDeletedHashPath( $key ) . $key;
953 $this->localRepo->streamFileWithStatus( $path );
957 * @param LinkBatch $batch
958 * @param IResultWrapper $revisions
960 private function addRevisionsToBatch( LinkBatch $batch, IResultWrapper $revisions ) {
961 foreach ( $revisions as $row ) {
962 $batch->add( NS_USER, $row->ar_user_text );
963 $batch->add( NS_USER_TALK, $row->ar_user_text );
968 * @param LinkBatch $batch
969 * @param IResultWrapper $files
971 private function addFilesToBatch( LinkBatch $batch, IResultWrapper $files ) {
972 foreach ( $files as $row ) {
973 $batch->add( NS_USER, $row->fa_user_text );
974 $batch->add( NS_USER_TALK, $row->fa_user_text );
979 * Handle XHR "show more history" requests (T249977)
981 protected function showMoreHistory() {
982 $out = $this->getOutput();
983 $out->setArticleBodyOnly( true );
984 $dbr = $this->dbProvider->getReplicaDatabase();
985 if ( $this->mHistoryOffset ) {
986 $extraConds = [ $dbr->expr( 'ar_timestamp', '<', $dbr->timestamp( $this->mHistoryOffset ) ) ];
987 } else {
988 $extraConds = [];
990 $revisions = $this->archivedRevisionLookup->listRevisions(
991 $this->mTargetObj,
992 $extraConds,
993 self::REVISION_HISTORY_LIMIT + 1
995 $batch = $this->linkBatchFactory->newLinkBatch();
996 $this->addRevisionsToBatch( $batch, $revisions );
997 $batch->execute();
998 $out->addHTML( $this->formatRevisionHistory( $revisions ) );
1000 if ( $revisions->numRows() > self::REVISION_HISTORY_LIMIT ) {
1001 // Indicate to JS that the "show more" button should remain active
1002 $out->setStatusCode( 206 );
1007 * Generate the <ul> element representing a list of deleted revisions
1009 * @param IResultWrapper $revisions
1010 * @return string
1012 protected function formatRevisionHistory( IResultWrapper $revisions ) {
1013 $history = Html::openElement( 'ul', [ 'class' => 'mw-undelete-revlist' ] );
1015 // Exclude the last data row if there is more data than history limit amount
1016 $numRevisions = $revisions->numRows();
1017 $displayCount = min( $numRevisions, self::REVISION_HISTORY_LIMIT );
1018 $firstRev = $this->revisionStore->getFirstRevision( $this->mTargetObj );
1019 $earliestLiveTime = $firstRev ? $firstRev->getTimestamp() : null;
1021 $revisions->rewind();
1022 for ( $i = 0; $i < $displayCount; $i++ ) {
1023 $row = $revisions->fetchObject();
1024 // The $remaining parameter controls diff links and so must
1025 // include the undisplayed row beyond the display limit.
1026 $history .= $this->formatRevisionRow( $row, $earliestLiveTime, $numRevisions - $i );
1028 $history .= Html::closeElement( 'ul' );
1029 return $history;
1032 protected function showHistory() {
1033 $this->checkReadOnly();
1035 $out = $this->getOutput();
1036 if ( $this->mAllowed ) {
1037 $out->addModules( 'mediawiki.misc-authed-ooui' );
1038 $out->addModuleStyles( 'mediawiki.special' );
1040 $out->addModuleStyles( 'mediawiki.interface.helpers.styles' );
1041 $out->wrapWikiMsg(
1042 "<div class='mw-undelete-pagetitle'>\n$1\n</div>\n",
1043 [ 'undeletepagetitle', wfEscapeWikiText( $this->mTargetObj->getPrefixedText() ) ]
1046 $archive = new PageArchive( $this->mTargetObj );
1047 // FIXME: This hook must be deprecated, passing PageArchive by ref is awful.
1048 $this->getHookRunner()->onUndeleteForm__showHistory( $archive, $this->mTargetObj );
1050 $out->addHTML( Html::openElement( 'div', [ 'class' => 'mw-undelete-history' ] ) );
1051 if ( $this->mAllowed ) {
1052 $out->addWikiMsg( 'undeletehistory' );
1053 $out->addWikiMsg( 'undeleterevdel' );
1054 } else {
1055 $out->addWikiMsg( 'undeletehistorynoadmin' );
1057 $out->addHTML( Html::closeElement( 'div' ) );
1059 # List all stored revisions
1060 $revisions = $this->archivedRevisionLookup->listRevisions(
1061 $this->mTargetObj,
1063 self::REVISION_HISTORY_LIMIT + 1
1065 $files = $archive->listFiles();
1066 $numRevisions = $revisions->numRows();
1067 $showLoadMore = $numRevisions > self::REVISION_HISTORY_LIMIT;
1068 $haveRevisions = $numRevisions > 0;
1069 $haveFiles = $files && $files->numRows() > 0;
1071 # Batch existence check on user and talk pages
1072 if ( $haveRevisions || $haveFiles ) {
1073 $batch = $this->linkBatchFactory->newLinkBatch();
1074 $this->addRevisionsToBatch( $batch, $revisions );
1075 if ( $haveFiles ) {
1076 // @phan-suppress-next-line PhanTypeMismatchArgumentNullable -- $files is non-null
1077 $this->addFilesToBatch( $batch, $files );
1079 $batch->execute();
1082 if ( $this->mAllowed ) {
1083 $out->enableOOUI();
1085 $action = $this->getPageTitle()->getLocalURL( [ 'action' => 'submit' ] );
1086 # Start the form here
1087 $form = new FormLayout( [
1088 'method' => 'post',
1089 'action' => $action,
1090 'id' => 'undelete',
1091 ] );
1094 # Show relevant lines from the deletion log:
1095 $deleteLogPage = new LogPage( 'delete' );
1096 $out->addHTML( Xml::element( 'h2', null, $deleteLogPage->getName()->text() ) . "\n" );
1097 LogEventsList::showLogExtract( $out, 'delete', $this->mTargetObj );
1098 # Show relevant lines from the suppression log:
1099 $suppressLogPage = new LogPage( 'suppress' );
1100 if ( $this->permissionManager->userHasRight( $this->getUser(), 'suppressionlog' ) ) {
1101 $out->addHTML( Xml::element( 'h2', null, $suppressLogPage->getName()->text() ) . "\n" );
1102 LogEventsList::showLogExtract( $out, 'suppress', $this->mTargetObj );
1105 if ( $this->mAllowed && ( $haveRevisions || $haveFiles ) ) {
1106 $unsuppressAllowed = $this->permissionManager->userHasRight( $this->getUser(), 'suppressrevision' );
1107 $fields = [];
1108 $fields[] = new Layout( [
1109 'content' => new HtmlSnippet( $this->msg( 'undeleteextrahelp' )->parseAsBlock() )
1110 ] );
1112 $dropdownComment = $this->msg( 'undelete-comment-dropdown' )
1113 ->page( $this->mTargetObj )->inContentLanguage()->text();
1114 // Add additional specific reasons for unsuppress
1115 if ( $unsuppressAllowed ) {
1116 $dropdownComment .= "\n" . $this->msg( 'undelete-comment-dropdown-unsuppress' )
1117 ->page( $this->mTargetObj )->inContentLanguage()->text();
1119 $options = Xml::listDropdownOptions(
1120 $dropdownComment,
1121 [ 'other' => $this->msg( 'undeletecommentotherlist' )->text() ]
1123 $options = Xml::listDropdownOptionsOoui( $options );
1125 $fields[] = new FieldLayout(
1126 new DropdownInputWidget( [
1127 'name' => 'wpCommentList',
1128 'inputId' => 'wpCommentList',
1129 'infusable' => true,
1130 'value' => $this->getRequest()->getText( 'wpCommentList', 'other' ),
1131 'options' => $options,
1132 ] ),
1134 'label' => $this->msg( 'undeletecomment' )->text(),
1135 'align' => 'top',
1139 $fields[] = new FieldLayout(
1140 new TextInputWidget( [
1141 'name' => 'wpComment',
1142 'inputId' => 'wpComment',
1143 'infusable' => true,
1144 'value' => $this->getRequest()->getText( 'wpComment' ),
1145 'autofocus' => true,
1146 // HTML maxlength uses "UTF-16 code units", which means that characters outside BMP
1147 // (e.g. emojis) count for two each. This limit is overridden in JS to instead count
1148 // Unicode codepoints.
1149 'maxLength' => CommentStore::COMMENT_CHARACTER_LIMIT,
1150 ] ),
1152 'label' => $this->msg( 'undeleteothercomment' )->text(),
1153 'align' => 'top',
1157 if ( $this->getUser()->isRegistered() ) {
1158 $checkWatch = $this->watchlistManager->isWatched( $this->getUser(), $this->mTargetObj )
1159 || $this->getRequest()->getText( 'wpWatch' );
1160 $fields[] = new FieldLayout(
1161 new CheckboxInputWidget( [
1162 'name' => 'wpWatch',
1163 'inputId' => 'mw-undelete-watch',
1164 'value' => '1',
1165 'selected' => $checkWatch,
1166 ] ),
1168 'label' => $this->msg( 'watchthis' )->text(),
1169 'align' => 'inline',
1174 if ( $unsuppressAllowed ) {
1175 $fields[] = new FieldLayout(
1176 new CheckboxInputWidget( [
1177 'name' => 'wpUnsuppress',
1178 'inputId' => 'mw-undelete-unsuppress',
1179 'value' => '1',
1180 ] ),
1182 'label' => $this->msg( 'revdelete-unsuppress' )->text(),
1183 'align' => 'inline',
1188 $undelPage = $this->undeletePageFactory->newUndeletePage(
1189 $this->wikiPageFactory->newFromTitle( $this->mTargetObj ),
1190 $this->getContext()->getAuthority()
1192 if ( $undelPage->canProbablyUndeleteAssociatedTalk()->isGood() ) {
1193 $fields[] = new FieldLayout(
1194 new CheckboxInputWidget( [
1195 'name' => 'undeletetalk',
1196 'inputId' => 'mw-undelete-undeletetalk',
1197 'selected' => false,
1198 ] ),
1200 'label' => $this->msg( 'undelete-undeletetalk' )->text(),
1201 'align' => 'inline',
1206 $fields[] = new FieldLayout(
1207 new Widget( [
1208 'content' => new HorizontalLayout( [
1209 'items' => [
1210 new ButtonInputWidget( [
1211 'name' => 'restore',
1212 'inputId' => 'mw-undelete-submit',
1213 'value' => '1',
1214 'label' => $this->msg( 'undeletebtn' )->text(),
1215 'flags' => [ 'primary', 'progressive' ],
1216 'type' => 'submit',
1217 ] ),
1218 new ButtonInputWidget( [
1219 'name' => 'invert',
1220 'inputId' => 'mw-undelete-invert',
1221 'value' => '1',
1222 'label' => $this->msg( 'undeleteinvert' )->text()
1223 ] ),
1229 $fieldset = new FieldsetLayout( [
1230 'label' => $this->msg( 'undelete-fieldset-title' )->text(),
1231 'id' => 'mw-undelete-table',
1232 'items' => $fields,
1233 ] );
1235 $link = '';
1236 if ( $this->getAuthority()->isAllowed( 'editinterface' ) ) {
1237 if ( $unsuppressAllowed ) {
1238 $link .= $this->getLinkRenderer()->makeKnownLink(
1239 $this->msg( 'undelete-comment-dropdown-unsuppress' )->inContentLanguage()->getTitle(),
1240 $this->msg( 'undelete-edit-commentlist-unsuppress' )->text(),
1242 [ 'action' => 'edit' ]
1244 $link .= $this->msg( 'pipe-separator' )->escaped();
1246 $link .= $this->getLinkRenderer()->makeKnownLink(
1247 $this->msg( 'undelete-comment-dropdown' )->inContentLanguage()->getTitle(),
1248 $this->msg( 'undelete-edit-commentlist' )->text(),
1250 [ 'action' => 'edit' ]
1253 $link = Html::rawElement( 'p', [ 'class' => 'mw-undelete-editcomments' ], $link );
1256 // @phan-suppress-next-line PhanPossiblyUndeclaredVariable form is set, when used here
1257 $form->appendContent(
1258 new PanelLayout( [
1259 'expanded' => false,
1260 'padded' => true,
1261 'framed' => true,
1262 'content' => $fieldset,
1263 ] ),
1264 new HtmlSnippet(
1265 $link .
1266 Html::hidden( 'target', $this->mTarget ) .
1267 Html::hidden( 'wpEditToken', $this->getUser()->getEditToken() )
1272 $history = '';
1273 $history .= Xml::element( 'h2', null, $this->msg( 'history' )->text() ) . "\n";
1275 if ( $haveRevisions ) {
1276 # Show the page's stored (deleted) history
1278 if ( $this->permissionManager->userHasRight( $this->getUser(), 'deleterevision' ) ) {
1279 $history .= Html::element(
1280 'button',
1282 'name' => 'revdel',
1283 'type' => 'submit',
1284 'class' => 'deleterevision-log-submit mw-log-deleterevision-button'
1286 $this->msg( 'showhideselectedversions' )->text()
1287 ) . "\n";
1290 $history .= $this->formatRevisionHistory( $revisions );
1292 if ( $showLoadMore ) {
1293 $history .=
1294 Html::openElement( 'div' ) .
1295 Html::element(
1296 'span',
1297 [ 'id' => 'mw-load-more-revisions' ],
1298 $this->msg( 'undelete-load-more-revisions' )->text()
1300 Html::closeElement( 'div' ) .
1301 "\n";
1303 } else {
1304 $out->addWikiMsg( 'nohistory' );
1307 if ( $haveFiles ) {
1308 $history .= Xml::element( 'h2', null, $this->msg( 'filehist' )->text() ) . "\n";
1309 $history .= Html::openElement( 'ul', [ 'class' => 'mw-undelete-revlist' ] );
1310 foreach ( $files as $row ) {
1311 $history .= $this->formatFileRow( $row );
1313 $files->free();
1314 $history .= Html::closeElement( 'ul' );
1317 if ( $this->mAllowed ) {
1318 # Slip in the hidden controls here
1319 $misc = Html::hidden( 'target', $this->mTarget );
1320 $misc .= Html::hidden( 'wpEditToken', $this->getUser()->getEditToken() );
1321 $history .= $misc;
1323 // @phan-suppress-next-line PhanPossiblyUndeclaredVariable form is set, when used here
1324 $form->appendContent( new HtmlSnippet( $history ) );
1325 // @phan-suppress-next-line PhanPossiblyUndeclaredVariable form is set, when used here
1326 $out->addHTML( (string)$form );
1327 } else {
1328 $out->addHTML( $history );
1331 return true;
1334 protected function formatRevisionRow( $row, $earliestLiveTime, $remaining ) {
1335 $revRecord = $this->revisionStore->newRevisionFromArchiveRow(
1336 $row,
1337 IDBAccessObject::READ_NORMAL,
1338 $this->mTargetObj
1341 $revTextSize = '';
1342 $ts = wfTimestamp( TS_MW, $row->ar_timestamp );
1343 // Build checkboxen...
1344 if ( $this->mAllowed ) {
1345 if ( $this->mInvert ) {
1346 if ( in_array( $ts, $this->mTargetTimestamp ) ) {
1347 $checkBox = Xml::check( "ts$ts" );
1348 } else {
1349 $checkBox = Xml::check( "ts$ts", true );
1351 } else {
1352 $checkBox = Xml::check( "ts$ts" );
1354 } else {
1355 $checkBox = '';
1358 // Build page & diff links...
1359 $user = $this->getUser();
1360 if ( $this->mCanView ) {
1361 $titleObj = $this->getPageTitle();
1362 # Last link
1363 if ( !$revRecord->userCan( RevisionRecord::DELETED_TEXT, $this->getAuthority() ) ) {
1364 $pageLink = htmlspecialchars( $this->getLanguage()->userTimeAndDate( $ts, $user ) );
1365 $last = $this->msg( 'diff' )->escaped();
1366 } elseif ( $remaining > 0 || ( $earliestLiveTime && $ts > $earliestLiveTime ) ) {
1367 $pageLink = $this->getPageLink( $revRecord, $titleObj, $ts );
1368 $last = $this->getLinkRenderer()->makeKnownLink(
1369 $titleObj,
1370 $this->msg( 'diff' )->text(),
1373 'target' => $this->mTargetObj->getPrefixedText(),
1374 'timestamp' => $ts,
1375 'diff' => 'prev'
1378 } else {
1379 $pageLink = $this->getPageLink( $revRecord, $titleObj, $ts );
1380 $last = $this->msg( 'diff' )->escaped();
1382 } else {
1383 $pageLink = htmlspecialchars( $this->getLanguage()->userTimeAndDate( $ts, $user ) );
1384 $last = $this->msg( 'diff' )->escaped();
1387 // User links
1388 $userLink = Linker::revUserTools( $revRecord );
1390 // Minor edit
1391 $minor = $revRecord->isMinor() ? ChangesList::flag( 'minor' ) : '';
1393 // Revision text size
1394 $size = $row->ar_len;
1395 if ( $size !== null ) {
1396 $revTextSize = Linker::formatRevisionSize( $size );
1399 // Edit summary
1400 $comment = $this->commentFormatter->formatRevision( $revRecord, $user );
1402 // Tags
1403 $attribs = [];
1404 [ $tagSummary, $classes ] = ChangeTags::formatSummaryRow(
1405 $row->ts_tags,
1406 'deletedhistory',
1407 $this->getContext()
1409 if ( $classes ) {
1410 $attribs['class'] = implode( ' ', $classes );
1413 $revisionRow = $this->msg( 'undelete-revision-row2' )
1414 ->rawParams(
1415 $checkBox,
1416 $last,
1417 $pageLink,
1418 $userLink,
1419 $minor,
1420 $revTextSize,
1421 $comment,
1422 $tagSummary
1424 ->escaped();
1426 return Xml::tags( 'li', $attribs, $revisionRow ) . "\n";
1429 private function formatFileRow( $row ) {
1430 $file = ArchivedFile::newFromRow( $row );
1431 $ts = wfTimestamp( TS_MW, $row->fa_timestamp );
1432 $user = $this->getUser();
1434 $checkBox = '';
1435 if ( $this->mCanView && $row->fa_storage_key ) {
1436 if ( $this->mAllowed ) {
1437 $checkBox = Xml::check( 'fileid' . $row->fa_id );
1439 $key = urlencode( $row->fa_storage_key );
1440 $pageLink = $this->getFileLink( $file, $this->getPageTitle(), $ts, $key );
1441 } else {
1442 $pageLink = htmlspecialchars( $this->getLanguage()->userTimeAndDate( $ts, $user ) );
1444 $userLink = $this->getFileUser( $file );
1445 $data = $this->msg( 'widthheight' )->numParams( $row->fa_width, $row->fa_height )->text();
1446 $bytes = $this->msg( 'parentheses' )
1447 ->plaintextParams( $this->msg( 'nbytes' )->numParams( $row->fa_size )->text() )
1448 ->plain();
1449 $data = htmlspecialchars( $data . ' ' . $bytes );
1450 $comment = $this->getFileComment( $file );
1452 // Add show/hide deletion links if available
1453 $canHide = $this->isAllowed( 'deleterevision' );
1454 if ( $canHide || ( $file->getVisibility() && $this->isAllowed( 'deletedhistory' ) ) ) {
1455 if ( !$file->userCan( File::DELETED_RESTRICTED, $user ) ) {
1456 // Revision was hidden from sysops
1457 $revdlink = Linker::revDeleteLinkDisabled( $canHide );
1458 } else {
1459 $query = [
1460 'type' => 'filearchive',
1461 'target' => $this->mTargetObj->getPrefixedDBkey(),
1462 'ids' => $row->fa_id
1464 $revdlink = Linker::revDeleteLink( $query,
1465 $file->isDeleted( File::DELETED_RESTRICTED ), $canHide );
1467 } else {
1468 $revdlink = '';
1471 return "<li>$checkBox $revdlink $pageLink . . $userLink $data $comment</li>\n";
1475 * Fetch revision text link if it's available to all users
1477 * @param RevisionRecord $revRecord
1478 * @param LinkTarget $target
1479 * @param string $ts Timestamp
1480 * @return string
1482 private function getPageLink( RevisionRecord $revRecord, LinkTarget $target, $ts ) {
1483 $user = $this->getUser();
1484 $time = $this->getLanguage()->userTimeAndDate( $ts, $user );
1486 if ( !$revRecord->userCan( RevisionRecord::DELETED_TEXT, $this->getAuthority() ) ) {
1487 // TODO The condition cannot be true when the function is called
1488 return Html::element(
1489 'span',
1490 [ 'class' => 'history-deleted' ],
1491 $time
1495 $link = $this->getLinkRenderer()->makeKnownLink(
1496 $target,
1497 $time,
1500 'target' => $this->mTargetObj->getPrefixedText(),
1501 'timestamp' => $ts
1505 if ( $revRecord->isDeleted( RevisionRecord::DELETED_TEXT ) ) {
1506 $class = Linker::getRevisionDeletedClass( $revRecord );
1507 $link = '<span class="' . $class . '">' . $link . '</span>';
1510 return $link;
1514 * Fetch image view link if it's available to all users
1516 * @param File|ArchivedFile $file
1517 * @param LinkTarget $target
1518 * @param string $ts A timestamp
1519 * @param string $key A storage key
1521 * @return string HTML fragment
1523 private function getFileLink( $file, LinkTarget $target, $ts, $key ) {
1524 $user = $this->getUser();
1525 $time = $this->getLanguage()->userTimeAndDate( $ts, $user );
1527 if ( !$file->userCan( File::DELETED_FILE, $user ) ) {
1528 return Html::element(
1529 'span',
1530 [ 'class' => 'history-deleted' ],
1531 $time
1535 if ( $file->exists() ) {
1536 $link = $this->getLinkRenderer()->makeKnownLink(
1537 $target,
1538 $time,
1541 'target' => $this->mTargetObj->getPrefixedText(),
1542 'file' => $key,
1543 'token' => $user->getEditToken( $key )
1546 } else {
1547 $link = htmlspecialchars( $time );
1550 if ( $file->isDeleted( File::DELETED_FILE ) ) {
1551 $link = '<span class="history-deleted">' . $link . '</span>';
1554 return $link;
1558 * Fetch file's user id if it's available to this user
1560 * @param File|ArchivedFile $file
1561 * @return string HTML fragment
1563 private function getFileUser( $file ) {
1564 $uploader = $file->getUploader( File::FOR_THIS_USER, $this->getAuthority() );
1565 if ( !$uploader ) {
1566 return Html::rawElement(
1567 'span',
1568 [ 'class' => 'history-deleted' ],
1569 $this->msg( 'rev-deleted-user' )->escaped()
1573 $link = Linker::userLink( $uploader->getId(), $uploader->getName() ) .
1574 Linker::userToolLinks( $uploader->getId(), $uploader->getName() );
1576 if ( $file->isDeleted( File::DELETED_USER ) ) {
1577 $link = Html::rawElement(
1578 'span',
1579 [ 'class' => 'history-deleted' ],
1580 $link
1584 return $link;
1588 * Fetch file upload comment if it's available to this user
1590 * @param File|ArchivedFile $file
1591 * @return string HTML fragment
1593 private function getFileComment( $file ) {
1594 if ( !$file->userCan( File::DELETED_COMMENT, $this->getAuthority() ) ) {
1595 return Html::rawElement(
1596 'span',
1597 [ 'class' => 'history-deleted' ],
1598 Html::rawElement(
1599 'span',
1600 [ 'class' => 'comment' ],
1601 $this->msg( 'rev-deleted-comment' )->escaped()
1606 $comment = $file->getDescription( File::FOR_THIS_USER, $this->getAuthority() );
1607 $link = $this->commentFormatter->formatBlock( $comment );
1609 if ( $file->isDeleted( File::DELETED_COMMENT ) ) {
1610 $link = Html::rawElement(
1611 'span',
1612 [ 'class' => 'history-deleted' ],
1613 $link
1617 return $link;
1620 private function undelete() {
1621 if ( $this->getConfig()->get( MainConfigNames::UploadMaintenance )
1622 && $this->mTargetObj->getNamespace() === NS_FILE
1624 throw new ErrorPageError( 'undelete-error', 'filedelete-maintenance' );
1627 $this->checkReadOnly();
1629 $out = $this->getOutput();
1630 $undeletePage = $this->undeletePageFactory->newUndeletePage(
1631 $this->wikiPageFactory->newFromTitle( $this->mTargetObj ),
1632 $this->getAuthority()
1634 if ( $this->mUndeleteTalk && $undeletePage->canProbablyUndeleteAssociatedTalk()->isGood() ) {
1635 $undeletePage->setUndeleteAssociatedTalk( true );
1637 $status = $undeletePage
1638 ->setUndeleteOnlyTimestamps( $this->mTargetTimestamp )
1639 ->setUndeleteOnlyFileVersions( $this->mFileVersions )
1640 ->setUnsuppress( $this->mUnsuppress )
1641 // TODO This is currently duplicating some permission checks, but we do need it (T305680)
1642 ->undeleteIfAllowed( $this->mComment );
1644 if ( !$status->isGood() ) {
1645 $out->setPageTitleMsg( $this->msg( 'undelete-error' ) );
1646 $out->addModuleStyles( 'mediawiki.codex.messagebox.styles' );
1647 foreach ( $status->getMessages() as $msg ) {
1648 $out->addHTML( Html::errorBox(
1649 $this->msg( $msg )->parse()
1650 ) );
1652 return;
1655 $restoredRevs = $status->getValue()[UndeletePage::REVISIONS_RESTORED];
1656 $restoredFiles = $status->getValue()[UndeletePage::FILES_RESTORED];
1658 if ( $restoredRevs === 0 && $restoredFiles === 0 ) {
1659 // TODO Should use a different message here
1660 $out->setPageTitleMsg( $this->msg( 'undelete-error' ) );
1661 } else {
1662 if ( $status->getValue()[UndeletePage::FILES_RESTORED] !== 0 ) {
1663 $this->getHookRunner()->onFileUndeleteComplete(
1664 $this->mTargetObj, $this->mFileVersions, $this->getUser(), $this->mComment );
1667 $link = $this->getLinkRenderer()->makeKnownLink( $this->mTargetObj );
1668 $out->addWikiMsg( 'undeletedpage', Message::rawParam( $link ) );
1670 $this->watchlistManager->setWatch(
1671 $this->getRequest()->getCheck( 'wpWatch' ),
1672 $this->getAuthority(),
1673 $this->mTargetObj
1679 * Return an array of subpages beginning with $search that this special page will accept.
1681 * @param string $search Prefix to search for
1682 * @param int $limit Maximum number of results to return (usually 10)
1683 * @param int $offset Number of results to skip (usually 0)
1684 * @return string[] Matching subpages
1686 public function prefixSearchSubpages( $search, $limit, $offset ) {
1687 return $this->prefixSearchString( $search, $limit, $offset, $this->searchEngineFactory );
1690 protected function getGroupName() {
1691 return 'pagetools';
1696 * Retain the old class name for backwards compatibility.
1697 * @deprecated since 1.41
1699 class_alias( SpecialUndelete::class, 'SpecialUndelete' );