1<?php 2/** 3 * Content object for wiki text pages. 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 * @since 1.21 21 * 22 * @file 23 * @ingroup Content 24 * 25 * @author Daniel Kinzler 26 */ 27 28use MediaWiki\Logger\LoggerFactory; 29use MediaWiki\MediaWikiServices; 30 31/** 32 * Content object for wiki text pages. 33 * 34 * @newable 35 * @ingroup Content 36 */ 37class WikitextContent extends TextContent { 38 private $redirectTargetAndText = null; 39 40 /** 41 * @var bool Tracks if the parser set the user-signature flag when creating this content, which 42 * would make it expire faster in ApiStashEdit. 43 */ 44 private $hadSignature = false; 45 46 /** 47 * @var string|null Stack trace of the previous parse 48 */ 49 private $previousParseStackTrace = null; 50 51 /** 52 * @stable to call 53 * 54 * @param string $text 55 */ 56 public function __construct( $text ) { 57 parent::__construct( $text, CONTENT_MODEL_WIKITEXT ); 58 } 59 60 /** 61 * @param string|int $sectionId 62 * 63 * @return Content|bool|null 64 * 65 * @see Content::getSection() 66 */ 67 public function getSection( $sectionId ) { 68 $text = $this->getText(); 69 $sect = MediaWikiServices::getInstance()->getParser() 70 ->getSection( $text, $sectionId, false ); 71 72 if ( $sect === false ) { 73 return false; 74 } else { 75 return new static( $sect ); 76 } 77 } 78 79 /** 80 * @param string|int|null|bool $sectionId 81 * @param Content $with 82 * @param string $sectionTitle 83 * 84 * @throws MWException 85 * @return Content 86 * 87 * @see Content::replaceSection() 88 */ 89 public function replaceSection( $sectionId, Content $with, $sectionTitle = '' ) { 90 $myModelId = $this->getModel(); 91 $sectionModelId = $with->getModel(); 92 93 if ( $sectionModelId != $myModelId ) { 94 throw new MWException( "Incompatible content model for section: " . 95 "document uses $myModelId but " . 96 "section uses $sectionModelId." ); 97 } 98 /** @var self $with $oldtext */ 99 '@phan-var self $with'; 100 101 $oldtext = $this->getText(); 102 $text = $with->getText(); 103 104 if ( strval( $sectionId ) === '' ) { 105 return $with; # XXX: copy first? 106 } 107 108 if ( $sectionId === 'new' ) { 109 # Inserting a new section 110 $subject = strval( $sectionTitle ) !== '' ? wfMessage( 'newsectionheaderdefaultlevel' ) 111 ->plaintextParams( $sectionTitle )->inContentLanguage()->text() . "\n\n" : ''; 112 if ( Hooks::runner()->onPlaceNewSection( $this, $oldtext, $subject, $text ) ) { 113 $text = strlen( trim( $oldtext ) ) > 0 114 ? "{$oldtext}\n\n{$subject}{$text}" 115 : "{$subject}{$text}"; 116 } 117 } else { 118 # Replacing an existing section; roll out the big guns 119 $text = MediaWikiServices::getInstance()->getParser() 120 ->replaceSection( $oldtext, $sectionId, $text ); 121 } 122 123 $newContent = new static( $text ); 124 125 return $newContent; 126 } 127 128 /** 129 * Returns a new WikitextContent object with the given section heading 130 * prepended. 131 * 132 * @param string $header 133 * 134 * @return Content 135 */ 136 public function addSectionHeader( $header ) { 137 $text = wfMessage( 'newsectionheaderdefaultlevel' ) 138 ->rawParams( $header )->inContentLanguage()->text(); 139 $text .= "\n\n"; 140 $text .= $this->getText(); 141 142 return new static( $text ); 143 } 144 145 /** 146 * Returns a Content object with pre-save transformations applied using 147 * Parser::preSaveTransform(). 148 * 149 * @param Title $title 150 * @param User $user 151 * @param ParserOptions $popts 152 * 153 * @return Content 154 */ 155 public function preSaveTransform( Title $title, User $user, ParserOptions $popts ) { 156 $text = $this->getText(); 157 158 $parser = MediaWikiServices::getInstance()->getParser(); 159 $pst = $parser->preSaveTransform( $text, $title, $user, $popts ); 160 161 if ( $text === $pst ) { 162 return $this; 163 } 164 165 $ret = new static( $pst ); 166 167 if ( $parser->getOutput()->getFlag( 'user-signature' ) ) { 168 $ret->hadSignature = true; 169 } 170 171 return $ret; 172 } 173 174 /** 175 * Returns a Content object with preload transformations applied (or this 176 * object if no transformations apply). 177 * 178 * @param Title $title 179 * @param ParserOptions $popts 180 * @param array $params 181 * 182 * @return Content 183 */ 184 public function preloadTransform( Title $title, ParserOptions $popts, $params = [] ) { 185 $text = $this->getText(); 186 $plt = MediaWikiServices::getInstance()->getParser() 187 ->getPreloadText( $text, $title, $popts, $params ); 188 189 return new static( $plt ); 190 } 191 192 /** 193 * Extract the redirect target and the remaining text on the page. 194 * 195 * @note migrated here from Title::newFromRedirectInternal() 196 * 197 * @since 1.23 198 * 199 * @return array List of two elements: Title|null and string. 200 */ 201 protected function getRedirectTargetAndText() { 202 global $wgMaxRedirects; 203 204 if ( $this->redirectTargetAndText !== null ) { 205 return $this->redirectTargetAndText; 206 } 207 208 if ( $wgMaxRedirects < 1 ) { 209 // redirects are disabled, so quit early 210 $this->redirectTargetAndText = [ null, $this->getText() ]; 211 return $this->redirectTargetAndText; 212 } 213 214 $redir = MediaWikiServices::getInstance()->getMagicWordFactory()->get( 'redirect' ); 215 $text = ltrim( $this->getText() ); 216 if ( $redir->matchStartAndRemove( $text ) ) { 217 // Extract the first link and see if it's usable 218 // Ensure that it really does come directly after #REDIRECT 219 // Some older redirects included a colon, so don't freak about that! 220 $m = []; 221 if ( preg_match( '!^\s*:?\s*\[{2}(.*?)(?:\|.*?)?\]{2}\s*!', $text, $m ) ) { 222 // Strip preceding colon used to "escape" categories, etc. 223 // and URL-decode links 224 if ( strpos( $m[1], '%' ) !== false ) { 225 // Match behavior of inline link parsing here; 226 $m[1] = rawurldecode( ltrim( $m[1], ':' ) ); 227 } 228 $title = Title::newFromText( $m[1] ); 229 // If the title is a redirect to bad special pages or is invalid, return null 230 if ( !$title instanceof Title || !$title->isValidRedirectTarget() ) { 231 $this->redirectTargetAndText = [ null, $this->getText() ]; 232 return $this->redirectTargetAndText; 233 } 234 235 $this->redirectTargetAndText = [ $title, substr( $text, strlen( $m[0] ) ) ]; 236 return $this->redirectTargetAndText; 237 } 238 } 239 240 $this->redirectTargetAndText = [ null, $this->getText() ]; 241 return $this->redirectTargetAndText; 242 } 243 244 /** 245 * Implement redirect extraction for wikitext. 246 * 247 * @return Title|null 248 * 249 * @see Content::getRedirectTarget 250 */ 251 public function getRedirectTarget() { 252 list( $title, ) = $this->getRedirectTargetAndText(); 253 254 return $title; 255 } 256 257 /** 258 * This implementation replaces the first link on the page with the given new target 259 * if this Content object is a redirect. Otherwise, this method returns $this. 260 * 261 * @since 1.21 262 * 263 * @param Title $target 264 * 265 * @return Content 266 * 267 * @see Content::updateRedirect() 268 */ 269 public function updateRedirect( Title $target ) { 270 if ( !$this->isRedirect() ) { 271 return $this; 272 } 273 274 # Fix the text 275 # Remember that redirect pages can have categories, templates, etc., 276 # so the regex has to be fairly general 277 $newText = preg_replace( '/ \[ \[ [^\]]* \] \] /x', 278 '[[' . $target->getFullText() . ']]', 279 $this->getText(), 1 ); 280 281 return new static( $newText ); 282 } 283 284 /** 285 * Returns true if this content is not a redirect, and this content's text 286 * is countable according to the criteria defined by $wgArticleCountMethod. 287 * 288 * @param bool|null $hasLinks If it is known whether this content contains 289 * links, provide this information here, to avoid redundant parsing to 290 * find out (default: null). 291 * @param Title|null $title Optional title, defaults to the title from the current main request. 292 * 293 * @return bool 294 */ 295 public function isCountable( $hasLinks = null, Title $title = null ) { 296 global $wgArticleCountMethod; 297 298 if ( $this->isRedirect() ) { 299 return false; 300 } 301 302 if ( $wgArticleCountMethod === 'link' ) { 303 if ( $hasLinks === null ) { # not known, find out 304 if ( !$title ) { 305 $context = RequestContext::getMain(); 306 $title = $context->getTitle(); 307 } 308 309 $po = $this->getParserOutput( $title, null, null, false ); 310 $links = $po->getLinks(); 311 $hasLinks = !empty( $links ); 312 } 313 314 return $hasLinks; 315 } 316 317 return true; 318 } 319 320 /** 321 * @param int $maxlength 322 * @return string 323 */ 324 public function getTextForSummary( $maxlength = 250 ) { 325 $truncatedtext = parent::getTextForSummary( $maxlength ); 326 327 # clean up unfinished links 328 # XXX: make this optional? wasn't there in autosummary, but required for 329 # deletion summary. 330 $truncatedtext = preg_replace( '/\[\[([^\]]*)\]?$/', '$1', $truncatedtext ); 331 332 return $truncatedtext; 333 } 334 335 /** 336 * Returns a ParserOutput object resulting from parsing the content's text 337 * using the global Parser service. 338 * 339 * @param Title $title 340 * @param int|null $revId ID of the revision being rendered. 341 * See Parser::parse() for the ramifications. (default: null) 342 * @param ParserOptions $options (default: null) 343 * @param bool $generateHtml (default: true) 344 * @param ParserOutput &$output ParserOutput representing the HTML form of the text, 345 * may be manipulated or replaced. 346 */ 347 protected function fillParserOutput( Title $title, $revId, 348 ParserOptions $options, $generateHtml, ParserOutput &$output 349 ) { 350 $stackTrace = ( new RuntimeException() )->getTraceAsString(); 351 if ( $this->previousParseStackTrace ) { 352 // NOTE: there may be legitimate changes to re-parse the same WikiText content, 353 // e.g. if predicted revision ID for the REVISIONID magic word mismatched. 354 // But that should be rare. 355 $logger = LoggerFactory::getInstance( 'DuplicateParse' ); 356 $logger->debug( 357 __METHOD__ . ': Possibly redundant parse!', 358 [ 359 'title' => $title->getPrefixedDBkey(), 360 'rev' => $revId, 361 'options-hash' => $options->optionsHash( 362 ParserOptions::allCacheVaryingOptions(), 363 $title 364 ), 365 'trace' => $stackTrace, 366 'previous-trace' => $this->previousParseStackTrace, 367 ] 368 ); 369 } 370 $this->previousParseStackTrace = $stackTrace; 371 372 list( $redir, $text ) = $this->getRedirectTargetAndText(); 373 $output = MediaWikiServices::getInstance()->getParser() 374 ->parse( $text, $title, $options, true, true, $revId ); 375 376 // Add redirect indicator at the top 377 if ( $redir ) { 378 // Make sure to include the redirect link in pagelinks 379 $output->addLink( $redir ); 380 if ( $generateHtml ) { 381 $chain = $this->getRedirectChain(); 382 $output->setText( 383 Article::getRedirectHeaderHtml( $title->getPageLanguage(), $chain, false ) . 384 $output->getRawText() 385 ); 386 $output->addModuleStyles( 'mediawiki.action.view.redirectPage' ); 387 } 388 } 389 390 // Pass along user-signature flag 391 if ( $this->hadSignature ) { 392 $output->setFlag( 'user-signature' ); 393 } 394 } 395 396 /** 397 * @throws MWException 398 */ 399 protected function getHtml() { 400 throw new MWException( 401 "getHtml() not implemented for wikitext. " 402 . "Use getParserOutput()->getText()." 403 ); 404 } 405 406 /** 407 * This implementation calls $word->match() on the this TextContent object's text. 408 * 409 * @param MagicWord $word 410 * 411 * @return bool 412 * 413 * @see Content::matchMagicWord() 414 */ 415 public function matchMagicWord( MagicWord $word ) { 416 return $word->match( $this->getText() ); 417 } 418 419} 420