SpecialUndelete.php 35 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255
  1. <?php
  2. /**
  3. * Implements Special:Undelete
  4. *
  5. * This program is free software; you can redistribute it and/or modify
  6. * it under the terms of the GNU General Public License as published by
  7. * the Free Software Foundation; either version 2 of the License, or
  8. * (at your option) any later version.
  9. *
  10. * This program is distributed in the hope that it will be useful,
  11. * but WITHOUT ANY WARRANTY; without even the implied warranty of
  12. * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  13. * GNU General Public License for more details.
  14. *
  15. * You should have received a copy of the GNU General Public License along
  16. * with this program; if not, write to the Free Software Foundation, Inc.,
  17. * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
  18. * http://www.gnu.org/copyleft/gpl.html
  19. *
  20. * @file
  21. * @ingroup SpecialPage
  22. */
  23. use MediaWiki\MediaWikiServices;
  24. use MediaWiki\Revision\RevisionRecord;
  25. use MediaWiki\Storage\NameTableAccessException;
  26. use Wikimedia\Rdbms\IResultWrapper;
  27. /**
  28. * Special page allowing users with the appropriate permissions to view
  29. * and restore deleted content.
  30. *
  31. * @ingroup SpecialPage
  32. */
  33. class SpecialUndelete extends SpecialPage {
  34. private $mAction;
  35. private $mTarget;
  36. private $mTimestamp;
  37. private $mRestore;
  38. private $mRevdel;
  39. private $mInvert;
  40. private $mFilename;
  41. private $mTargetTimestamp;
  42. private $mAllowed;
  43. private $mCanView;
  44. private $mComment;
  45. private $mToken;
  46. /** @var bool|null */
  47. private $mPreview;
  48. /** @var bool|null */
  49. private $mDiff;
  50. /** @var bool|null */
  51. private $mDiffOnly;
  52. /** @var bool|null */
  53. private $mUnsuppress;
  54. /** @var int[]|null */
  55. private $mFileVersions;
  56. /** @var Title */
  57. private $mTargetObj;
  58. /**
  59. * @var string Search prefix
  60. */
  61. private $mSearchPrefix;
  62. function __construct() {
  63. parent::__construct( 'Undelete', 'deletedhistory' );
  64. }
  65. public function doesWrites() {
  66. return true;
  67. }
  68. function loadRequest( $par ) {
  69. $request = $this->getRequest();
  70. $user = $this->getUser();
  71. $this->mAction = $request->getVal( 'action' );
  72. if ( $par !== null && $par !== '' ) {
  73. $this->mTarget = $par;
  74. } else {
  75. $this->mTarget = $request->getVal( 'target' );
  76. }
  77. $this->mTargetObj = null;
  78. if ( $this->mTarget !== null && $this->mTarget !== '' ) {
  79. $this->mTargetObj = Title::newFromText( $this->mTarget );
  80. }
  81. $this->mSearchPrefix = $request->getText( 'prefix' );
  82. $time = $request->getVal( 'timestamp' );
  83. $this->mTimestamp = $time ? wfTimestamp( TS_MW, $time ) : '';
  84. $this->mFilename = $request->getVal( 'file' );
  85. $posted = $request->wasPosted() &&
  86. $user->matchEditToken( $request->getVal( 'wpEditToken' ) );
  87. $this->mRestore = $request->getCheck( 'restore' ) && $posted;
  88. $this->mRevdel = $request->getCheck( 'revdel' ) && $posted;
  89. $this->mInvert = $request->getCheck( 'invert' ) && $posted;
  90. $this->mPreview = $request->getCheck( 'preview' ) && $posted;
  91. $this->mDiff = $request->getCheck( 'diff' );
  92. $this->mDiffOnly = $request->getBool( 'diffonly', $this->getUser()->getOption( 'diffonly' ) );
  93. $this->mComment = $request->getText( 'wpComment' );
  94. $this->mUnsuppress = $request->getVal( 'wpUnsuppress' ) && MediaWikiServices::getInstance()
  95. ->getPermissionManager()
  96. ->userHasRight( $user, 'suppressrevision' );
  97. $this->mToken = $request->getVal( 'token' );
  98. $block = $user->getBlock();
  99. if ( $this->isAllowed( 'undelete' ) && !( $block && $block->isSitewide() ) ) {
  100. $this->mAllowed = true; // user can restore
  101. $this->mCanView = true; // user can view content
  102. } elseif ( $this->isAllowed( 'deletedtext' ) ) {
  103. $this->mAllowed = false; // user cannot restore
  104. $this->mCanView = true; // user can view content
  105. $this->mRestore = false;
  106. } else { // user can only view the list of revisions
  107. $this->mAllowed = false;
  108. $this->mCanView = false;
  109. $this->mTimestamp = '';
  110. $this->mRestore = false;
  111. }
  112. if ( $this->mRestore || $this->mInvert ) {
  113. $timestamps = [];
  114. $this->mFileVersions = [];
  115. foreach ( $request->getValues() as $key => $val ) {
  116. $matches = [];
  117. if ( preg_match( '/^ts(\d{14})$/', $key, $matches ) ) {
  118. array_push( $timestamps, $matches[1] );
  119. }
  120. if ( preg_match( '/^fileid(\d+)$/', $key, $matches ) ) {
  121. $this->mFileVersions[] = intval( $matches[1] );
  122. }
  123. }
  124. rsort( $timestamps );
  125. $this->mTargetTimestamp = $timestamps;
  126. }
  127. }
  128. /**
  129. * Checks whether a user is allowed the permission for the
  130. * specific title if one is set.
  131. *
  132. * @param string $permission
  133. * @param User|null $user
  134. * @return bool
  135. */
  136. protected function isAllowed( $permission, User $user = null ) {
  137. $user = $user ?: $this->getUser();
  138. $permissionManager = MediaWikiServices::getInstance()->getPermissionManager();
  139. if ( $this->mTargetObj !== null ) {
  140. return $permissionManager->userCan( $permission, $user, $this->mTargetObj );
  141. } else {
  142. return $permissionManager->userHasRight( $user, $permission );
  143. }
  144. }
  145. function userCanExecute( User $user ) {
  146. return $this->isAllowed( $this->mRestriction, $user );
  147. }
  148. function execute( $par ) {
  149. $this->useTransactionalTimeLimit();
  150. $user = $this->getUser();
  151. $this->setHeaders();
  152. $this->outputHeader();
  153. $this->addHelpLink( 'Help:Deletion_and_undeletion' );
  154. $this->loadRequest( $par );
  155. $this->checkPermissions(); // Needs to be after mTargetObj is set
  156. $out = $this->getOutput();
  157. if ( is_null( $this->mTargetObj ) ) {
  158. $out->addWikiMsg( 'undelete-header' );
  159. # Not all users can just browse every deleted page from the list
  160. if ( MediaWikiServices::getInstance()
  161. ->getPermissionManager()
  162. ->userHasRight( $user, 'browsearchive' )
  163. ) {
  164. $this->showSearchForm();
  165. }
  166. return;
  167. }
  168. $this->addHelpLink( 'Help:Undelete' );
  169. if ( $this->mAllowed ) {
  170. $out->setPageTitle( $this->msg( 'undeletepage' ) );
  171. } else {
  172. $out->setPageTitle( $this->msg( 'viewdeletedpage' ) );
  173. }
  174. $this->getSkin()->setRelevantTitle( $this->mTargetObj );
  175. if ( $this->mTimestamp !== '' ) {
  176. $this->showRevision( $this->mTimestamp );
  177. } elseif ( $this->mFilename !== null && $this->mTargetObj->inNamespace( NS_FILE ) ) {
  178. $file = new ArchivedFile( $this->mTargetObj, 0, $this->mFilename );
  179. // Check if user is allowed to see this file
  180. if ( !$file->exists() ) {
  181. $out->addWikiMsg( 'filedelete-nofile', $this->mFilename );
  182. } elseif ( !$file->userCan( File::DELETED_FILE, $user ) ) {
  183. if ( $file->isDeleted( File::DELETED_RESTRICTED ) ) {
  184. throw new PermissionsError( 'suppressrevision' );
  185. } else {
  186. throw new PermissionsError( 'deletedtext' );
  187. }
  188. } elseif ( !$user->matchEditToken( $this->mToken, $this->mFilename ) ) {
  189. $this->showFileConfirmationForm( $this->mFilename );
  190. } else {
  191. $this->showFile( $this->mFilename );
  192. }
  193. } elseif ( $this->mAction === 'submit' ) {
  194. if ( $this->mRestore ) {
  195. $this->undelete();
  196. } elseif ( $this->mRevdel ) {
  197. $this->redirectToRevDel();
  198. }
  199. } else {
  200. $this->showHistory();
  201. }
  202. }
  203. /**
  204. * Convert submitted form data to format expected by RevisionDelete and
  205. * redirect the request
  206. */
  207. private function redirectToRevDel() {
  208. $archive = new PageArchive( $this->mTargetObj );
  209. $revisions = [];
  210. foreach ( $this->getRequest()->getValues() as $key => $val ) {
  211. $matches = [];
  212. if ( preg_match( "/^ts(\d{14})$/", $key, $matches ) ) {
  213. $revisions[$archive->getRevision( $matches[1] )->getId()] = 1;
  214. }
  215. }
  216. $query = [
  217. 'type' => 'revision',
  218. 'ids' => $revisions,
  219. 'target' => $this->mTargetObj->getPrefixedText()
  220. ];
  221. $url = SpecialPage::getTitleFor( 'Revisiondelete' )->getFullURL( $query );
  222. $this->getOutput()->redirect( $url );
  223. }
  224. function showSearchForm() {
  225. $out = $this->getOutput();
  226. $out->setPageTitle( $this->msg( 'undelete-search-title' ) );
  227. $fuzzySearch = $this->getRequest()->getVal( 'fuzzy', true );
  228. $out->enableOOUI();
  229. $fields = [];
  230. $fields[] = new OOUI\ActionFieldLayout(
  231. new OOUI\TextInputWidget( [
  232. 'name' => 'prefix',
  233. 'inputId' => 'prefix',
  234. 'infusable' => true,
  235. 'value' => $this->mSearchPrefix,
  236. 'autofocus' => true,
  237. ] ),
  238. new OOUI\ButtonInputWidget( [
  239. 'label' => $this->msg( 'undelete-search-submit' )->text(),
  240. 'flags' => [ 'primary', 'progressive' ],
  241. 'inputId' => 'searchUndelete',
  242. 'type' => 'submit',
  243. ] ),
  244. [
  245. 'label' => new OOUI\HtmlSnippet(
  246. $this->msg(
  247. $fuzzySearch ? 'undelete-search-full' : 'undelete-search-prefix'
  248. )->parse()
  249. ),
  250. 'align' => 'left',
  251. ]
  252. );
  253. $fieldset = new OOUI\FieldsetLayout( [
  254. 'label' => $this->msg( 'undelete-search-box' )->text(),
  255. 'items' => $fields,
  256. ] );
  257. $form = new OOUI\FormLayout( [
  258. 'method' => 'get',
  259. 'action' => wfScript(),
  260. ] );
  261. $form->appendContent(
  262. $fieldset,
  263. new OOUI\HtmlSnippet(
  264. Html::hidden( 'title', $this->getPageTitle()->getPrefixedDBkey() ) .
  265. Html::hidden( 'fuzzy', $fuzzySearch )
  266. )
  267. );
  268. $out->addHTML(
  269. new OOUI\PanelLayout( [
  270. 'expanded' => false,
  271. 'padded' => true,
  272. 'framed' => true,
  273. 'content' => $form,
  274. ] )
  275. );
  276. # List undeletable articles
  277. if ( $this->mSearchPrefix ) {
  278. // For now, we enable search engine match only when specifically asked to
  279. // by using fuzzy=1 parameter.
  280. if ( $fuzzySearch ) {
  281. $result = PageArchive::listPagesBySearch( $this->mSearchPrefix );
  282. } else {
  283. $result = PageArchive::listPagesByPrefix( $this->mSearchPrefix );
  284. }
  285. $this->showList( $result );
  286. }
  287. }
  288. /**
  289. * Generic list of deleted pages
  290. *
  291. * @param IResultWrapper $result
  292. * @return bool
  293. */
  294. private function showList( $result ) {
  295. $out = $this->getOutput();
  296. if ( $result->numRows() == 0 ) {
  297. $out->addWikiMsg( 'undelete-no-results' );
  298. return false;
  299. }
  300. $out->addWikiMsg( 'undeletepagetext', $this->getLanguage()->formatNum( $result->numRows() ) );
  301. $linkRenderer = $this->getLinkRenderer();
  302. $undelete = $this->getPageTitle();
  303. $out->addHTML( "<ul id='undeleteResultsList'>\n" );
  304. foreach ( $result as $row ) {
  305. $title = Title::makeTitleSafe( $row->ar_namespace, $row->ar_title );
  306. if ( $title !== null ) {
  307. $item = $linkRenderer->makeKnownLink(
  308. $undelete,
  309. $title->getPrefixedText(),
  310. [],
  311. [ 'target' => $title->getPrefixedText() ]
  312. );
  313. } else {
  314. // The title is no longer valid, show as text
  315. $item = Html::element(
  316. 'span',
  317. [ 'class' => 'mw-invalidtitle' ],
  318. Linker::getInvalidTitleDescription(
  319. $this->getContext(),
  320. $row->ar_namespace,
  321. $row->ar_title
  322. )
  323. );
  324. }
  325. $revs = $this->msg( 'undeleterevisions' )->numParams( $row->count )->parse();
  326. $out->addHTML(
  327. Html::rawElement(
  328. 'li',
  329. [ 'class' => 'undeleteResult' ],
  330. "{$item} ({$revs})"
  331. )
  332. );
  333. }
  334. $result->free();
  335. $out->addHTML( "</ul>\n" );
  336. return true;
  337. }
  338. private function showRevision( $timestamp ) {
  339. if ( !preg_match( '/[0-9]{14}/', $timestamp ) ) {
  340. return;
  341. }
  342. $archive = new PageArchive( $this->mTargetObj, $this->getConfig() );
  343. if ( !Hooks::run( 'UndeleteForm::showRevision', [ &$archive, $this->mTargetObj ] ) ) {
  344. return;
  345. }
  346. $rev = $archive->getRevision( $timestamp );
  347. $out = $this->getOutput();
  348. $user = $this->getUser();
  349. if ( !$rev ) {
  350. $out->addWikiMsg( 'undeleterevision-missing' );
  351. return;
  352. }
  353. if ( $rev->isDeleted( RevisionRecord::DELETED_TEXT ) ) {
  354. if ( !$rev->userCan( RevisionRecord::DELETED_TEXT, $user ) ) {
  355. $out->wrapWikiMsg(
  356. "<div class='mw-warning plainlinks'>\n$1\n</div>\n",
  357. $rev->isDeleted( RevisionRecord::DELETED_RESTRICTED ) ?
  358. 'rev-suppressed-text-permission' : 'rev-deleted-text-permission'
  359. );
  360. return;
  361. }
  362. $out->wrapWikiMsg(
  363. "<div class='mw-warning plainlinks'>\n$1\n</div>\n",
  364. $rev->isDeleted( RevisionRecord::DELETED_RESTRICTED ) ?
  365. 'rev-suppressed-text-view' : 'rev-deleted-text-view'
  366. );
  367. $out->addHTML( '<br />' );
  368. // and we are allowed to see...
  369. }
  370. if ( $this->mDiff ) {
  371. $previousRev = $archive->getPreviousRevision( $timestamp );
  372. if ( $previousRev ) {
  373. $this->showDiff( $previousRev, $rev );
  374. if ( $this->mDiffOnly ) {
  375. return;
  376. }
  377. $out->addHTML( '<hr />' );
  378. } else {
  379. $out->addWikiMsg( 'undelete-nodiff' );
  380. }
  381. }
  382. $link = $this->getLinkRenderer()->makeKnownLink(
  383. $this->getPageTitle( $this->mTargetObj->getPrefixedDBkey() ),
  384. $this->mTargetObj->getPrefixedText()
  385. );
  386. $lang = $this->getLanguage();
  387. // date and time are separate parameters to facilitate localisation.
  388. // $time is kept for backward compat reasons.
  389. $time = $lang->userTimeAndDate( $timestamp, $user );
  390. $d = $lang->userDate( $timestamp, $user );
  391. $t = $lang->userTime( $timestamp, $user );
  392. $userLink = Linker::revUserTools( $rev );
  393. $content = $rev->getContent( RevisionRecord::FOR_THIS_USER, $user );
  394. // TODO: MCR: this will have to become something like $hasTextSlots and $hasNonTextSlots
  395. $isText = ( $content instanceof TextContent );
  396. if ( $this->mPreview || $isText ) {
  397. $openDiv = '<div id="mw-undelete-revision" class="mw-warning">';
  398. } else {
  399. $openDiv = '<div id="mw-undelete-revision">';
  400. }
  401. $out->addHTML( $openDiv );
  402. // Revision delete links
  403. if ( !$this->mDiff ) {
  404. $revdel = Linker::getRevDeleteLink( $user, $rev, $this->mTargetObj );
  405. if ( $revdel ) {
  406. $out->addHTML( "$revdel " );
  407. }
  408. }
  409. $out->addWikiMsg(
  410. 'undelete-revision',
  411. Message::rawParam( $link ), $time,
  412. Message::rawParam( $userLink ), $d, $t
  413. );
  414. $out->addHTML( '</div>' );
  415. if ( !Hooks::run( 'UndeleteShowRevision', [ $this->mTargetObj, $rev ] ) ) {
  416. return;
  417. }
  418. if ( $this->mPreview || !$isText ) {
  419. // NOTE: non-text content has no source view, so always use rendered preview
  420. $popts = $out->parserOptions();
  421. $renderer = MediaWikiServices::getInstance()->getRevisionRenderer();
  422. $rendered = $renderer->getRenderedRevision(
  423. $rev->getRevisionRecord(),
  424. $popts,
  425. $user,
  426. [ 'audience' => RevisionRecord::FOR_THIS_USER ]
  427. );
  428. // Fail hard if the audience check fails, since we already checked
  429. // at the beginning of this method.
  430. $pout = $rendered->getRevisionParserOutput();
  431. $out->addParserOutput( $pout, [
  432. 'enableSectionEditLinks' => false,
  433. ] );
  434. }
  435. $out->enableOOUI();
  436. $buttonFields = [];
  437. if ( $isText ) {
  438. '@phan-var TextContent $content';
  439. // TODO: MCR: make this work for multiple slots
  440. // source view for textual content
  441. $sourceView = Xml::element( 'textarea', [
  442. 'readonly' => 'readonly',
  443. 'cols' => 80,
  444. 'rows' => 25
  445. ], $content->getText() . "\n" );
  446. $buttonFields[] = new OOUI\ButtonInputWidget( [
  447. 'type' => 'submit',
  448. 'name' => 'preview',
  449. 'label' => $this->msg( 'showpreview' )->text()
  450. ] );
  451. } else {
  452. $sourceView = '';
  453. }
  454. $buttonFields[] = new OOUI\ButtonInputWidget( [
  455. 'name' => 'diff',
  456. 'type' => 'submit',
  457. 'label' => $this->msg( 'showdiff' )->text()
  458. ] );
  459. $out->addHTML(
  460. $sourceView .
  461. Xml::openElement( 'div', [
  462. 'style' => 'clear: both' ] ) .
  463. Xml::openElement( 'form', [
  464. 'method' => 'post',
  465. 'action' => $this->getPageTitle()->getLocalURL( [ 'action' => 'submit' ] ) ] ) .
  466. Xml::element( 'input', [
  467. 'type' => 'hidden',
  468. 'name' => 'target',
  469. 'value' => $this->mTargetObj->getPrefixedDBkey() ] ) .
  470. Xml::element( 'input', [
  471. 'type' => 'hidden',
  472. 'name' => 'timestamp',
  473. 'value' => $timestamp ] ) .
  474. Xml::element( 'input', [
  475. 'type' => 'hidden',
  476. 'name' => 'wpEditToken',
  477. 'value' => $user->getEditToken() ] ) .
  478. new OOUI\FieldLayout(
  479. new OOUI\Widget( [
  480. 'content' => new OOUI\HorizontalLayout( [
  481. 'items' => $buttonFields
  482. ] )
  483. ] )
  484. ) .
  485. Xml::closeElement( 'form' ) .
  486. Xml::closeElement( 'div' )
  487. );
  488. }
  489. /**
  490. * Build a diff display between this and the previous either deleted
  491. * or non-deleted edit.
  492. *
  493. * @param Revision $previousRev
  494. * @param Revision $currentRev
  495. */
  496. function showDiff( $previousRev, $currentRev ) {
  497. $diffContext = clone $this->getContext();
  498. $diffContext->setTitle( $currentRev->getTitle() );
  499. $diffContext->setWikiPage( WikiPage::factory( $currentRev->getTitle() ) );
  500. $diffEngine = $currentRev->getContentHandler()->createDifferenceEngine( $diffContext );
  501. $diffEngine->setRevisions( $previousRev->getRevisionRecord(), $currentRev->getRevisionRecord() );
  502. $diffEngine->showDiffStyle();
  503. $formattedDiff = $diffEngine->getDiff(
  504. $this->diffHeader( $previousRev, 'o' ),
  505. $this->diffHeader( $currentRev, 'n' )
  506. );
  507. $this->getOutput()->addHTML( "<div>$formattedDiff</div>\n" );
  508. }
  509. /**
  510. * @param Revision $rev
  511. * @param string $prefix
  512. * @return string
  513. */
  514. private function diffHeader( $rev, $prefix ) {
  515. $isDeleted = !( $rev->getId() && $rev->getTitle() );
  516. if ( $isDeleted ) {
  517. /// @todo FIXME: $rev->getTitle() is null for deleted revs...?
  518. $targetPage = $this->getPageTitle();
  519. $targetQuery = [
  520. 'target' => $this->mTargetObj->getPrefixedText(),
  521. 'timestamp' => wfTimestamp( TS_MW, $rev->getTimestamp() )
  522. ];
  523. } else {
  524. /// @todo FIXME: getId() may return non-zero for deleted revs...
  525. $targetPage = $rev->getTitle();
  526. $targetQuery = [ 'oldid' => $rev->getId() ];
  527. }
  528. // Add show/hide deletion links if available
  529. $user = $this->getUser();
  530. $lang = $this->getLanguage();
  531. $rdel = Linker::getRevDeleteLink( $user, $rev, $this->mTargetObj );
  532. if ( $rdel ) {
  533. $rdel = " $rdel";
  534. }
  535. $minor = $rev->isMinor() ? ChangesList::flag( 'minor' ) : '';
  536. $tagIds = wfGetDB( DB_REPLICA )->selectFieldValues(
  537. 'change_tag',
  538. 'ct_tag_id',
  539. [ 'ct_rev_id' => $rev->getId() ],
  540. __METHOD__
  541. );
  542. $tags = [];
  543. $changeTagDefStore = MediaWikiServices::getInstance()->getChangeTagDefStore();
  544. foreach ( $tagIds as $tagId ) {
  545. try {
  546. $tags[] = $changeTagDefStore->getName( (int)$tagId );
  547. } catch ( NameTableAccessException $exception ) {
  548. continue;
  549. }
  550. }
  551. $tags = implode( ',', $tags );
  552. $tagSummary = ChangeTags::formatSummaryRow( $tags, 'deleteddiff', $this->getContext() );
  553. // FIXME This is reimplementing DifferenceEngine#getRevisionHeader
  554. // and partially #showDiffPage, but worse
  555. return '<div id="mw-diff-' . $prefix . 'title1"><strong>' .
  556. $this->getLinkRenderer()->makeLink(
  557. $targetPage,
  558. $this->msg(
  559. 'revisionasof',
  560. $lang->userTimeAndDate( $rev->getTimestamp(), $user ),
  561. $lang->userDate( $rev->getTimestamp(), $user ),
  562. $lang->userTime( $rev->getTimestamp(), $user )
  563. )->text(),
  564. [],
  565. $targetQuery
  566. ) .
  567. '</strong></div>' .
  568. '<div id="mw-diff-' . $prefix . 'title2">' .
  569. Linker::revUserTools( $rev ) . '<br />' .
  570. '</div>' .
  571. '<div id="mw-diff-' . $prefix . 'title3">' .
  572. $minor . Linker::revComment( $rev ) . $rdel . '<br />' .
  573. '</div>' .
  574. '<div id="mw-diff-' . $prefix . 'title5">' .
  575. $tagSummary[0] . '<br />' .
  576. '</div>';
  577. }
  578. /**
  579. * Show a form confirming whether a tokenless user really wants to see a file
  580. * @param string $key
  581. */
  582. private function showFileConfirmationForm( $key ) {
  583. $out = $this->getOutput();
  584. $lang = $this->getLanguage();
  585. $user = $this->getUser();
  586. $file = new ArchivedFile( $this->mTargetObj, 0, $this->mFilename );
  587. $out->addWikiMsg( 'undelete-show-file-confirm',
  588. $this->mTargetObj->getText(),
  589. $lang->userDate( $file->getTimestamp(), $user ),
  590. $lang->userTime( $file->getTimestamp(), $user ) );
  591. $out->addHTML(
  592. Xml::openElement( 'form', [
  593. 'method' => 'POST',
  594. 'action' => $this->getPageTitle()->getLocalURL( [
  595. 'target' => $this->mTarget,
  596. 'file' => $key,
  597. 'token' => $user->getEditToken( $key ),
  598. ] ),
  599. ]
  600. ) .
  601. Xml::submitButton( $this->msg( 'undelete-show-file-submit' )->text() ) .
  602. '</form>'
  603. );
  604. }
  605. /**
  606. * Show a deleted file version requested by the visitor.
  607. * @param string $key
  608. */
  609. private function showFile( $key ) {
  610. $this->getOutput()->disable();
  611. # We mustn't allow the output to be CDN cached, otherwise
  612. # if an admin previews a deleted image, and it's cached, then
  613. # a user without appropriate permissions can toddle off and
  614. # nab the image, and CDN will serve it
  615. $response = $this->getRequest()->response();
  616. $response->header( 'Expires: ' . gmdate( 'D, d M Y H:i:s', 0 ) . ' GMT' );
  617. $response->header( 'Cache-Control: no-cache, no-store, max-age=0, must-revalidate' );
  618. $response->header( 'Pragma: no-cache' );
  619. $repo = RepoGroup::singleton()->getLocalRepo();
  620. $path = $repo->getZonePath( 'deleted' ) . '/' . $repo->getDeletedHashPath( $key ) . $key;
  621. $repo->streamFileWithStatus( $path );
  622. }
  623. protected function showHistory() {
  624. $this->checkReadOnly();
  625. $out = $this->getOutput();
  626. if ( $this->mAllowed ) {
  627. $out->addModules( 'mediawiki.special.undelete' );
  628. }
  629. $out->wrapWikiMsg(
  630. "<div class='mw-undelete-pagetitle'>\n$1\n</div>\n",
  631. [ 'undeletepagetitle', wfEscapeWikiText( $this->mTargetObj->getPrefixedText() ) ]
  632. );
  633. $archive = new PageArchive( $this->mTargetObj, $this->getConfig() );
  634. Hooks::run( 'UndeleteForm::showHistory', [ &$archive, $this->mTargetObj ] );
  635. $out->addHTML( '<div class="mw-undelete-history">' );
  636. if ( $this->mAllowed ) {
  637. $out->addWikiMsg( 'undeletehistory' );
  638. $out->addWikiMsg( 'undeleterevdel' );
  639. } else {
  640. $out->addWikiMsg( 'undeletehistorynoadmin' );
  641. }
  642. $out->addHTML( '</div>' );
  643. # List all stored revisions
  644. $revisions = $archive->listRevisions();
  645. $files = $archive->listFiles();
  646. $haveRevisions = $revisions && $revisions->numRows() > 0;
  647. $haveFiles = $files && $files->numRows() > 0;
  648. # Batch existence check on user and talk pages
  649. if ( $haveRevisions ) {
  650. $batch = new LinkBatch();
  651. foreach ( $revisions as $row ) {
  652. $batch->addObj( Title::makeTitleSafe( NS_USER, $row->ar_user_text ) );
  653. $batch->addObj( Title::makeTitleSafe( NS_USER_TALK, $row->ar_user_text ) );
  654. }
  655. $batch->execute();
  656. $revisions->seek( 0 );
  657. }
  658. if ( $haveFiles ) {
  659. $batch = new LinkBatch();
  660. foreach ( $files as $row ) {
  661. $batch->addObj( Title::makeTitleSafe( NS_USER, $row->fa_user_text ) );
  662. $batch->addObj( Title::makeTitleSafe( NS_USER_TALK, $row->fa_user_text ) );
  663. }
  664. $batch->execute();
  665. $files->seek( 0 );
  666. }
  667. if ( $this->mAllowed ) {
  668. $out->enableOOUI();
  669. $action = $this->getPageTitle()->getLocalURL( [ 'action' => 'submit' ] );
  670. # Start the form here
  671. $form = new OOUI\FormLayout( [
  672. 'method' => 'post',
  673. 'action' => $action,
  674. 'id' => 'undelete',
  675. ] );
  676. }
  677. # Show relevant lines from the deletion log:
  678. $deleteLogPage = new LogPage( 'delete' );
  679. $out->addHTML( Xml::element( 'h2', null, $deleteLogPage->getName()->text() ) . "\n" );
  680. LogEventsList::showLogExtract( $out, 'delete', $this->mTargetObj );
  681. # Show relevant lines from the suppression log:
  682. $suppressLogPage = new LogPage( 'suppress' );
  683. $permissionManager = MediaWikiServices::getInstance()->getPermissionManager();
  684. if ( $permissionManager->userHasRight( $this->getUser(), 'suppressionlog' ) ) {
  685. $out->addHTML( Xml::element( 'h2', null, $suppressLogPage->getName()->text() ) . "\n" );
  686. LogEventsList::showLogExtract( $out, 'suppress', $this->mTargetObj );
  687. }
  688. if ( $this->mAllowed && ( $haveRevisions || $haveFiles ) ) {
  689. $fields = [];
  690. $fields[] = new OOUI\Layout( [
  691. 'content' => new OOUI\HtmlSnippet( $this->msg( 'undeleteextrahelp' )->parseAsBlock() )
  692. ] );
  693. $fields[] = new OOUI\FieldLayout(
  694. new OOUI\TextInputWidget( [
  695. 'name' => 'wpComment',
  696. 'inputId' => 'wpComment',
  697. 'infusable' => true,
  698. 'value' => $this->mComment,
  699. 'autofocus' => true,
  700. // HTML maxlength uses "UTF-16 code units", which means that characters outside BMP
  701. // (e.g. emojis) count for two each. This limit is overridden in JS to instead count
  702. // Unicode codepoints.
  703. 'maxLength' => CommentStore::COMMENT_CHARACTER_LIMIT,
  704. ] ),
  705. [
  706. 'label' => $this->msg( 'undeletecomment' )->text(),
  707. 'align' => 'top',
  708. ]
  709. );
  710. $fields[] = new OOUI\FieldLayout(
  711. new OOUI\Widget( [
  712. 'content' => new OOUI\HorizontalLayout( [
  713. 'items' => [
  714. new OOUI\ButtonInputWidget( [
  715. 'name' => 'restore',
  716. 'inputId' => 'mw-undelete-submit',
  717. 'value' => '1',
  718. 'label' => $this->msg( 'undeletebtn' )->text(),
  719. 'flags' => [ 'primary', 'progressive' ],
  720. 'type' => 'submit',
  721. ] ),
  722. new OOUI\ButtonInputWidget( [
  723. 'name' => 'invert',
  724. 'inputId' => 'mw-undelete-invert',
  725. 'value' => '1',
  726. 'label' => $this->msg( 'undeleteinvert' )->text()
  727. ] ),
  728. ]
  729. ] )
  730. ] )
  731. );
  732. if ( $permissionManager->userHasRight( $this->getUser(), 'suppressrevision' ) ) {
  733. $fields[] = new OOUI\FieldLayout(
  734. new OOUI\CheckboxInputWidget( [
  735. 'name' => 'wpUnsuppress',
  736. 'inputId' => 'mw-undelete-unsuppress',
  737. 'value' => '1',
  738. ] ),
  739. [
  740. 'label' => $this->msg( 'revdelete-unsuppress' )->text(),
  741. 'align' => 'inline',
  742. ]
  743. );
  744. }
  745. $fieldset = new OOUI\FieldsetLayout( [
  746. 'label' => $this->msg( 'undelete-fieldset-title' )->text(),
  747. 'id' => 'mw-undelete-table',
  748. 'items' => $fields,
  749. ] );
  750. $form->appendContent(
  751. new OOUI\PanelLayout( [
  752. 'expanded' => false,
  753. 'padded' => true,
  754. 'framed' => true,
  755. 'content' => $fieldset,
  756. ] ),
  757. new OOUI\HtmlSnippet(
  758. Html::hidden( 'target', $this->mTarget ) .
  759. Html::hidden( 'wpEditToken', $this->getUser()->getEditToken() )
  760. )
  761. );
  762. }
  763. $history = '';
  764. $history .= Xml::element( 'h2', null, $this->msg( 'history' )->text() ) . "\n";
  765. if ( $haveRevisions ) {
  766. # Show the page's stored (deleted) history
  767. if ( $permissionManager->userHasRight( $this->getUser(), 'deleterevision' ) ) {
  768. $history .= Html::element(
  769. 'button',
  770. [
  771. 'name' => 'revdel',
  772. 'type' => 'submit',
  773. 'class' => 'deleterevision-log-submit mw-log-deleterevision-button'
  774. ],
  775. $this->msg( 'showhideselectedversions' )->text()
  776. ) . "\n";
  777. }
  778. $history .= '<ul class="mw-undelete-revlist">';
  779. $remaining = $revisions->numRows();
  780. $earliestLiveTime = $this->mTargetObj->getEarliestRevTime();
  781. foreach ( $revisions as $row ) {
  782. $remaining--;
  783. $history .= $this->formatRevisionRow( $row, $earliestLiveTime, $remaining );
  784. }
  785. $revisions->free();
  786. $history .= '</ul>';
  787. } else {
  788. $out->addWikiMsg( 'nohistory' );
  789. }
  790. if ( $haveFiles ) {
  791. $history .= Xml::element( 'h2', null, $this->msg( 'filehist' )->text() ) . "\n";
  792. $history .= '<ul class="mw-undelete-revlist">';
  793. foreach ( $files as $row ) {
  794. $history .= $this->formatFileRow( $row );
  795. }
  796. $files->free();
  797. $history .= '</ul>';
  798. }
  799. if ( $this->mAllowed ) {
  800. # Slip in the hidden controls here
  801. $misc = Html::hidden( 'target', $this->mTarget );
  802. $misc .= Html::hidden( 'wpEditToken', $this->getUser()->getEditToken() );
  803. $history .= $misc;
  804. $form->appendContent( new OOUI\HtmlSnippet( $history ) );
  805. $out->addHTML( $form );
  806. } else {
  807. $out->addHTML( $history );
  808. }
  809. return true;
  810. }
  811. protected function formatRevisionRow( $row, $earliestLiveTime, $remaining ) {
  812. $rev = Revision::newFromArchiveRow( $row,
  813. [
  814. 'title' => $this->mTargetObj
  815. ] );
  816. $revTextSize = '';
  817. $ts = wfTimestamp( TS_MW, $row->ar_timestamp );
  818. // Build checkboxen...
  819. if ( $this->mAllowed ) {
  820. if ( $this->mInvert ) {
  821. if ( in_array( $ts, $this->mTargetTimestamp ) ) {
  822. $checkBox = Xml::check( "ts$ts" );
  823. } else {
  824. $checkBox = Xml::check( "ts$ts", true );
  825. }
  826. } else {
  827. $checkBox = Xml::check( "ts$ts" );
  828. }
  829. } else {
  830. $checkBox = '';
  831. }
  832. // Build page & diff links...
  833. $user = $this->getUser();
  834. if ( $this->mCanView ) {
  835. $titleObj = $this->getPageTitle();
  836. # Last link
  837. if ( !$rev->userCan( RevisionRecord::DELETED_TEXT, $this->getUser() ) ) {
  838. $pageLink = htmlspecialchars( $this->getLanguage()->userTimeAndDate( $ts, $user ) );
  839. $last = $this->msg( 'diff' )->escaped();
  840. } elseif ( $remaining > 0 || ( $earliestLiveTime && $ts > $earliestLiveTime ) ) {
  841. $pageLink = $this->getPageLink( $rev, $titleObj, $ts );
  842. $last = $this->getLinkRenderer()->makeKnownLink(
  843. $titleObj,
  844. $this->msg( 'diff' )->text(),
  845. [],
  846. [
  847. 'target' => $this->mTargetObj->getPrefixedText(),
  848. 'timestamp' => $ts,
  849. 'diff' => 'prev'
  850. ]
  851. );
  852. } else {
  853. $pageLink = $this->getPageLink( $rev, $titleObj, $ts );
  854. $last = $this->msg( 'diff' )->escaped();
  855. }
  856. } else {
  857. $pageLink = htmlspecialchars( $this->getLanguage()->userTimeAndDate( $ts, $user ) );
  858. $last = $this->msg( 'diff' )->escaped();
  859. }
  860. // User links
  861. $userLink = Linker::revUserTools( $rev );
  862. // Minor edit
  863. $minor = $rev->isMinor() ? ChangesList::flag( 'minor' ) : '';
  864. // Revision text size
  865. $size = $row->ar_len;
  866. if ( !is_null( $size ) ) {
  867. $revTextSize = Linker::formatRevisionSize( $size );
  868. }
  869. // Edit summary
  870. $comment = Linker::revComment( $rev );
  871. // Tags
  872. $attribs = [];
  873. list( $tagSummary, $classes ) = ChangeTags::formatSummaryRow(
  874. $row->ts_tags,
  875. 'deletedhistory',
  876. $this->getContext()
  877. );
  878. if ( $classes ) {
  879. $attribs['class'] = implode( ' ', $classes );
  880. }
  881. $revisionRow = $this->msg( 'undelete-revision-row2' )
  882. ->rawParams(
  883. $checkBox,
  884. $last,
  885. $pageLink,
  886. $userLink,
  887. $minor,
  888. $revTextSize,
  889. $comment,
  890. $tagSummary
  891. )
  892. ->escaped();
  893. return Xml::tags( 'li', $attribs, $revisionRow ) . "\n";
  894. }
  895. private function formatFileRow( $row ) {
  896. $file = ArchivedFile::newFromRow( $row );
  897. $ts = wfTimestamp( TS_MW, $row->fa_timestamp );
  898. $user = $this->getUser();
  899. $checkBox = '';
  900. if ( $this->mCanView && $row->fa_storage_key ) {
  901. if ( $this->mAllowed ) {
  902. $checkBox = Xml::check( 'fileid' . $row->fa_id );
  903. }
  904. $key = urlencode( $row->fa_storage_key );
  905. $pageLink = $this->getFileLink( $file, $this->getPageTitle(), $ts, $key );
  906. } else {
  907. $pageLink = htmlspecialchars( $this->getLanguage()->userTimeAndDate( $ts, $user ) );
  908. }
  909. $userLink = $this->getFileUser( $file );
  910. $data = $this->msg( 'widthheight' )->numParams( $row->fa_width, $row->fa_height )->text();
  911. $bytes = $this->msg( 'parentheses' )
  912. ->plaintextParams( $this->msg( 'nbytes' )->numParams( $row->fa_size )->text() )
  913. ->plain();
  914. $data = htmlspecialchars( $data . ' ' . $bytes );
  915. $comment = $this->getFileComment( $file );
  916. // Add show/hide deletion links if available
  917. $canHide = $this->isAllowed( 'deleterevision' );
  918. if ( $canHide || ( $file->getVisibility() && $this->isAllowed( 'deletedhistory' ) ) ) {
  919. if ( !$file->userCan( File::DELETED_RESTRICTED, $user ) ) {
  920. // Revision was hidden from sysops
  921. $revdlink = Linker::revDeleteLinkDisabled( $canHide );
  922. } else {
  923. $query = [
  924. 'type' => 'filearchive',
  925. 'target' => $this->mTargetObj->getPrefixedDBkey(),
  926. 'ids' => $row->fa_id
  927. ];
  928. $revdlink = Linker::revDeleteLink( $query,
  929. $file->isDeleted( File::DELETED_RESTRICTED ), $canHide );
  930. }
  931. } else {
  932. $revdlink = '';
  933. }
  934. return "<li>$checkBox $revdlink $pageLink . . $userLink $data $comment</li>\n";
  935. }
  936. /**
  937. * Fetch revision text link if it's available to all users
  938. *
  939. * @param Revision $rev
  940. * @param Title $titleObj
  941. * @param string $ts Timestamp
  942. * @return string
  943. */
  944. function getPageLink( $rev, $titleObj, $ts ) {
  945. $user = $this->getUser();
  946. $time = $this->getLanguage()->userTimeAndDate( $ts, $user );
  947. if ( !$rev->userCan( RevisionRecord::DELETED_TEXT, $user ) ) {
  948. return '<span class="history-deleted">' . $time . '</span>';
  949. }
  950. $link = $this->getLinkRenderer()->makeKnownLink(
  951. $titleObj,
  952. $time,
  953. [],
  954. [
  955. 'target' => $this->mTargetObj->getPrefixedText(),
  956. 'timestamp' => $ts
  957. ]
  958. );
  959. if ( $rev->isDeleted( RevisionRecord::DELETED_TEXT ) ) {
  960. $link = '<span class="history-deleted">' . $link . '</span>';
  961. }
  962. return $link;
  963. }
  964. /**
  965. * Fetch image view link if it's available to all users
  966. *
  967. * @param File|ArchivedFile $file
  968. * @param Title $titleObj
  969. * @param string $ts A timestamp
  970. * @param string $key A storage key
  971. *
  972. * @return string HTML fragment
  973. */
  974. function getFileLink( $file, $titleObj, $ts, $key ) {
  975. $user = $this->getUser();
  976. $time = $this->getLanguage()->userTimeAndDate( $ts, $user );
  977. if ( !$file->userCan( File::DELETED_FILE, $user ) ) {
  978. return '<span class="history-deleted">' . htmlspecialchars( $time ) . '</span>';
  979. }
  980. $link = $this->getLinkRenderer()->makeKnownLink(
  981. $titleObj,
  982. $time,
  983. [],
  984. [
  985. 'target' => $this->mTargetObj->getPrefixedText(),
  986. 'file' => $key,
  987. 'token' => $user->getEditToken( $key )
  988. ]
  989. );
  990. if ( $file->isDeleted( File::DELETED_FILE ) ) {
  991. $link = '<span class="history-deleted">' . $link . '</span>';
  992. }
  993. return $link;
  994. }
  995. /**
  996. * Fetch file's user id if it's available to this user
  997. *
  998. * @param File|ArchivedFile $file
  999. * @return string HTML fragment
  1000. */
  1001. function getFileUser( $file ) {
  1002. if ( !$file->userCan( File::DELETED_USER, $this->getUser() ) ) {
  1003. return '<span class="history-deleted">' .
  1004. $this->msg( 'rev-deleted-user' )->escaped() .
  1005. '</span>';
  1006. }
  1007. $link = Linker::userLink( $file->getRawUser(), $file->getRawUserText() ) .
  1008. Linker::userToolLinks( $file->getRawUser(), $file->getRawUserText() );
  1009. if ( $file->isDeleted( File::DELETED_USER ) ) {
  1010. $link = '<span class="history-deleted">' . $link . '</span>';
  1011. }
  1012. return $link;
  1013. }
  1014. /**
  1015. * Fetch file upload comment if it's available to this user
  1016. *
  1017. * @param File|ArchivedFile $file
  1018. * @return string HTML fragment
  1019. */
  1020. function getFileComment( $file ) {
  1021. if ( !$file->userCan( File::DELETED_COMMENT, $this->getUser() ) ) {
  1022. return '<span class="history-deleted"><span class="comment">' .
  1023. $this->msg( 'rev-deleted-comment' )->escaped() . '</span></span>';
  1024. }
  1025. $link = Linker::commentBlock( $file->getRawDescription() );
  1026. if ( $file->isDeleted( File::DELETED_COMMENT ) ) {
  1027. $link = '<span class="history-deleted">' . $link . '</span>';
  1028. }
  1029. return $link;
  1030. }
  1031. function undelete() {
  1032. if ( $this->getConfig()->get( 'UploadMaintenance' )
  1033. && $this->mTargetObj->getNamespace() == NS_FILE
  1034. ) {
  1035. throw new ErrorPageError( 'undelete-error', 'filedelete-maintenance' );
  1036. }
  1037. $this->checkReadOnly();
  1038. $out = $this->getOutput();
  1039. $archive = new PageArchive( $this->mTargetObj, $this->getConfig() );
  1040. Hooks::run( 'UndeleteForm::undelete', [ &$archive, $this->mTargetObj ] );
  1041. $ok = $archive->undelete(
  1042. $this->mTargetTimestamp,
  1043. $this->mComment,
  1044. $this->mFileVersions,
  1045. $this->mUnsuppress,
  1046. $this->getUser()
  1047. );
  1048. if ( is_array( $ok ) ) {
  1049. if ( $ok[1] ) { // Undeleted file count
  1050. Hooks::run( 'FileUndeleteComplete', [
  1051. $this->mTargetObj, $this->mFileVersions,
  1052. $this->getUser(), $this->mComment ] );
  1053. }
  1054. $link = $this->getLinkRenderer()->makeKnownLink( $this->mTargetObj );
  1055. $out->addWikiMsg( 'undeletedpage', Message::rawParam( $link ) );
  1056. } else {
  1057. $out->setPageTitle( $this->msg( 'undelete-error' ) );
  1058. }
  1059. // Show revision undeletion warnings and errors
  1060. $status = $archive->getRevisionStatus();
  1061. if ( $status && !$status->isGood() ) {
  1062. $out->wrapWikiTextAsInterface(
  1063. 'error',
  1064. '<div id="mw-error-cannotundelete">' .
  1065. $status->getWikiText(
  1066. 'cannotundelete',
  1067. 'cannotundelete',
  1068. $this->getLanguage()
  1069. ) . '</div>'
  1070. );
  1071. }
  1072. // Show file undeletion warnings and errors
  1073. $status = $archive->getFileStatus();
  1074. if ( $status && !$status->isGood() ) {
  1075. $out->wrapWikiTextAsInterface(
  1076. 'error',
  1077. $status->getWikiText(
  1078. 'undelete-error-short',
  1079. 'undelete-error-long',
  1080. $this->getLanguage()
  1081. )
  1082. );
  1083. }
  1084. }
  1085. /**
  1086. * Return an array of subpages beginning with $search that this special page will accept.
  1087. *
  1088. * @param string $search Prefix to search for
  1089. * @param int $limit Maximum number of results to return (usually 10)
  1090. * @param int $offset Number of results to skip (usually 0)
  1091. * @return string[] Matching subpages
  1092. */
  1093. public function prefixSearchSubpages( $search, $limit, $offset ) {
  1094. return $this->prefixSearchString( $search, $limit, $offset );
  1095. }
  1096. protected function getGroupName() {
  1097. return 'pagetools';
  1098. }
  1099. }