diff --git a/includes/OutputPage.php b/includes/OutputPage.php index e527001b5af..459fb4d64e1 100644 --- a/includes/OutputPage.php +++ b/includes/OutputPage.php @@ -1,4093 +1,4096 @@ " */ protected $mMetatags = array(); /** @var array */ protected $mLinktags = array(); /** @var bool */ protected $mCanonicalUrl = false; /** * @var array Additional stylesheets. Looks like this is for extensions. * Might be replaced by ResourceLoader. */ protected $mExtStyles = array(); /** * @var string Should be private - has getter and setter. Contains * the HTML title */ public $mPagetitle = ''; /** * @var string Contains all of the "" content. Should be private we * got set/get accessors and the append() method. */ public $mBodytext = ''; /** * Holds the debug lines that will be output as comments in page source if * $wgDebugComments is enabled. See also $wgShowDebug. * @deprecated since 1.20; use MWDebug class instead. */ public $mDebugtext = ''; /** @var string Stores contents of "" tag */ private $mHTMLtitle = ''; /** * @var bool Is the displayed content related to the source of the * corresponding wiki article. */ private $mIsarticle = false; /** @var bool Stores "article flag" toggle. */ private $mIsArticleRelated = true; /** * @var bool We have to set isPrintable(). Some pages should * never be printed (ex: redirections). */ private $mPrintable = false; /** * @var array Contains the page subtitle. Special pages usually have some * links here. Don't confuse with site subtitle added by skins. */ private $mSubtitle = array(); /** @var string */ public $mRedirect = ''; /** @var int */ protected $mStatusCode; /** * @var string Variable mLastModified and mEtag are used for sending cache control. * The whole caching system should probably be moved into its own class. */ protected $mLastModified = ''; /** * Contains an HTTP Entity Tags (see RFC 2616 section 3.13) which is used * as a unique identifier for the content. It is later used by the client * to compare its cached version with the server version. Client sends * headers If-Match and If-None-Match containing its locally cached ETAG value. * * To get more information, you will have to look at HTTP/1.1 protocol which * is properly described in RFC 2616 : http://tools.ietf.org/html/rfc2616 */ private $mETag = false; /** @var array */ protected $mCategoryLinks = array(); /** @var array */ protected $mCategories = array(); /** @var array */ protected $mIndicators = array(); /** @var array Array of Interwiki Prefixed (non DB key) Titles (e.g. 'fr:Test page') */ private $mLanguageLinks = array(); /** * Used for JavaScript (predates ResourceLoader) * @todo We should split JS / CSS. * mScripts content is inserted as is in "<head>" by Skin. This might * contain either a link to a stylesheet or inline CSS. */ private $mScripts = ''; /** @var string Inline CSS styles. Use addInlineStyle() sparingly */ protected $mInlineStyles = ''; /** * @var string Used by skin template. * Example: $tpl->set( 'displaytitle', $out->mPageLinkTitle ); */ public $mPageLinkTitle = ''; /** @var array Array of elements in "<head>". Parser might add its own headers! */ protected $mHeadItems = array(); /** @var array */ protected $mModules = array(); /** @var array */ protected $mModuleScripts = array(); /** @var array */ protected $mModuleStyles = array(); /** @var ResourceLoader */ protected $mResourceLoader; /** @var array */ protected $mJsConfigVars = array(); /** @var array */ protected $mTemplateIds = array(); /** @var array */ protected $mImageTimeKeys = array(); /** @var string */ public $mRedirectCode = ''; protected $mFeedLinksAppendQuery = null; /** @var array * What level of 'untrustworthiness' is allowed in CSS/JS modules loaded on this page? * @see ResourceLoaderModule::$origin * ResourceLoaderModule::ORIGIN_ALL is assumed unless overridden; */ protected $mAllowedModules = array( ResourceLoaderModule::TYPE_COMBINED => ResourceLoaderModule::ORIGIN_ALL, ); /** @var bool Whether output is disabled. If this is true, the 'output' method will do nothing. */ protected $mDoNothing = false; // Parser related. /** @var int */ protected $mContainsNewMagic = 0; /** * lazy initialised, use parserOptions() * @var ParserOptions */ protected $mParserOptions = null; /** * Handles the Atom / RSS links. * We probably only support Atom in 2011. * @see $wgAdvertisedFeedTypes */ private $mFeedLinks = array(); // Gwicke work on squid caching? Roughly from 2003. protected $mEnableClientCache = true; /** @var bool Flag if output should only contain the body of the article. */ private $mArticleBodyOnly = false; /** @var bool */ protected $mNewSectionLink = false; /** @var bool */ protected $mHideNewSectionLink = false; /** * @var bool Comes from the parser. This was probably made to load CSS/JS * only if we had "<gallery>". Used directly in CategoryPage.php. * Looks like ResourceLoader can replace this. */ public $mNoGallery = false; /** @var string */ private $mPageTitleActionText = ''; /** @var int Cache stuff. Looks like mEnableClientCache */ protected $mCdnMaxage = 0; /** @var int Upper limit on mCdnMaxage */ protected $mCdnMaxageLimit = INF; /** * @var bool Controls if anti-clickjacking / frame-breaking headers will * be sent. This should be done for pages where edit actions are possible. * Setters: $this->preventClickjacking() and $this->allowClickjacking(). */ protected $mPreventClickjacking = true; /** @var int To include the variable {{REVISIONID}} */ private $mRevisionId = null; /** @var string */ private $mRevisionTimestamp = null; /** @var array */ protected $mFileVersion = null; /** * @var array An array of stylesheet filenames (relative from skins path), * with options for CSS media, IE conditions, and RTL/LTR direction. * For internal use; add settings in the skin via $this->addStyle() * * Style again! This seems like a code duplication since we already have * mStyles. This is what makes Open Source amazing. */ protected $styles = array(); /** * Whether jQuery is already handled. */ protected $mJQueryDone = false; private $mIndexPolicy = 'index'; private $mFollowPolicy = 'follow'; private $mVaryHeader = array( 'Accept-Encoding' => array( 'match=gzip' ), ); /** * If the current page was reached through a redirect, $mRedirectedFrom contains the Title * of the redirect. * * @var Title */ private $mRedirectedFrom = null; /** * Additional key => value data */ private $mProperties = array(); /** * @var string|null ResourceLoader target for load.php links. If null, will be omitted */ private $mTarget = null; /** * @var bool Whether parser output should contain table of contents */ private $mEnableTOC = true; /** * @var bool Whether parser output should contain section edit links */ private $mEnableSectionEditLinks = true; /** * @var string|null The URL to send in a <link> element with rel=copyright */ private $copyrightUrl; /** * Constructor for OutputPage. This should not be called directly. * Instead a new RequestContext should be created and it will implicitly create * a OutputPage tied to that context. * @param IContextSource|null $context */ function __construct( IContextSource $context = null ) { if ( $context === null ) { # Extensions should use `new RequestContext` instead of `new OutputPage` now. wfDeprecated( __METHOD__, '1.18' ); } else { $this->setContext( $context ); } } /** * Redirect to $url rather than displaying the normal page * * @param string $url URL * @param string $responsecode HTTP status code */ public function redirect( $url, $responsecode = '302' ) { # Strip newlines as a paranoia check for header injection in PHP<5.1.2 $this->mRedirect = str_replace( "\n", '', $url ); $this->mRedirectCode = $responsecode; } /** * Get the URL to redirect to, or an empty string if not redirect URL set * * @return string */ public function getRedirect() { return $this->mRedirect; } /** * Set the copyright URL to send with the output. * Empty string to omit, null to reset. * * @since 1.26 * * @param string|null $url */ public function setCopyrightUrl( $url ) { $this->copyrightUrl = $url; } /** * Set the HTTP status code to send with the output. * * @param int $statusCode */ public function setStatusCode( $statusCode ) { $this->mStatusCode = $statusCode; } /** * Add a new "<meta>" tag * To add an http-equiv meta tag, precede the name with "http:" * * @param string $name Tag name * @param string $val Tag value */ function addMeta( $name, $val ) { array_push( $this->mMetatags, array( $name, $val ) ); } /** * Returns the current <meta> tags * * @since 1.25 * @return array */ public function getMetaTags() { return $this->mMetatags; } /** * Add a new \<link\> tag to the page header. * * Note: use setCanonicalUrl() for rel=canonical. * * @param array $linkarr Associative array of attributes. */ function addLink( array $linkarr ) { array_push( $this->mLinktags, $linkarr ); } /** * Returns the current <link> tags * * @since 1.25 * @return array */ public function getLinkTags() { return $this->mLinktags; } /** * Add a new \<link\> with "rel" attribute set to "meta" * * @param array $linkarr Associative array mapping attribute names to their * values, both keys and values will be escaped, and the * "rel" attribute will be automatically added */ function addMetadataLink( array $linkarr ) { $linkarr['rel'] = $this->getMetadataAttribute(); $this->addLink( $linkarr ); } /** * Set the URL to be used for the <link rel=canonical>. This should be used * in preference to addLink(), to avoid duplicate link tags. * @param string $url */ function setCanonicalUrl( $url ) { $this->mCanonicalUrl = $url; } /** * Returns the URL to be used for the <link rel=canonical> if * one is set. * * @since 1.25 * @return bool|string */ public function getCanonicalUrl() { return $this->mCanonicalUrl; } /** * Get the value of the "rel" attribute for metadata links * * @return string */ public function getMetadataAttribute() { # note: buggy CC software only reads first "meta" link static $haveMeta = false; if ( $haveMeta ) { return 'alternate meta'; } else { $haveMeta = true; return 'meta'; } } /** * Add raw HTML to the list of scripts (including \<script\> tag, etc.) * Internal use only. Use OutputPage::addModules() or OutputPage::addJsConfigVars() * if possible. * * @param string $script Raw HTML */ function addScript( $script ) { $this->mScripts .= $script . "\n"; } /** * Register and add a stylesheet from an extension directory. * * @deprecated since 1.27 use addModuleStyles() or addStyle() instead * @param string $url Path to sheet. Provide either a full url (beginning * with 'http', etc) or a relative path from the document root * (beginning with '/'). Otherwise it behaves identically to * addStyle() and draws from the /skins folder. */ public function addExtensionStyle( $url ) { wfDeprecated( __METHOD__, '1.27' ); array_push( $this->mExtStyles, $url ); } /** * Get all styles added by extensions * * @deprecated since 1.27 * @return array */ function getExtStyle() { wfDeprecated( __METHOD__, '1.27' ); return $this->mExtStyles; } /** * Add a JavaScript file out of skins/common, or a given relative path. * Internal use only. Use OutputPage::addModules() if possible. * * @param string $file Filename in skins/common or complete on-server path * (/foo/bar.js) * @param string $version Style version of the file. Defaults to $wgStyleVersion */ public function addScriptFile( $file, $version = null ) { // See if $file parameter is an absolute URL or begins with a slash if ( substr( $file, 0, 1 ) == '/' || preg_match( '#^[a-z]*://#i', $file ) ) { $path = $file; } else { $path = $this->getConfig()->get( 'StylePath' ) . "/common/{$file}"; } if ( is_null( $version ) ) { $version = $this->getConfig()->get( 'StyleVersion' ); } $this->addScript( Html::linkedScript( wfAppendQuery( $path, $version ) ) ); } /** * Add a self-contained script tag with the given contents * Internal use only. Use OutputPage::addModules() if possible. * * @param string $script JavaScript text, no "<script>" tags */ public function addInlineScript( $script ) { $this->mScripts .= Html::inlineScript( "\n$script\n" ) . "\n"; } /** * Get all registered JS and CSS tags for the header. * * @return string * @deprecated since 1.24 Use OutputPage::headElement to build the full header. */ function getScript() { wfDeprecated( __METHOD__, '1.24' ); return $this->mScripts . $this->getHeadItems(); } /** * Filter an array of modules to remove insufficiently trustworthy members, and modules * which are no longer registered (eg a page is cached before an extension is disabled) * @param array $modules * @param string|null $position If not null, only return modules with this position * @param string $type * @return array */ protected function filterModules( array $modules, $position = null, $type = ResourceLoaderModule::TYPE_COMBINED ) { $resourceLoader = $this->getResourceLoader(); $filteredModules = array(); foreach ( $modules as $val ) { $module = $resourceLoader->getModule( $val ); if ( $module instanceof ResourceLoaderModule && $module->getOrigin() <= $this->getAllowedModules( $type ) && ( is_null( $position ) || $module->getPosition() == $position ) && ( !$this->mTarget || in_array( $this->mTarget, $module->getTargets() ) ) ) { $filteredModules[] = $val; } } return $filteredModules; } /** * Get the list of modules to include on this page * * @param bool $filter Whether to filter out insufficiently trustworthy modules * @param string|null $position If not null, only return modules with this position * @param string $param * @return array Array of module names */ public function getModules( $filter = false, $position = null, $param = 'mModules' ) { $modules = array_values( array_unique( $this->$param ) ); return $filter ? $this->filterModules( $modules, $position ) : $modules; } /** * Add one or more modules recognized by ResourceLoader. Modules added * through this function will be loaded by ResourceLoader when the * page loads. * * @param string|array $modules Module name (string) or array of module names */ public function addModules( $modules ) { $this->mModules = array_merge( $this->mModules, (array)$modules ); } /** * Get the list of module JS to include on this page * * @param bool $filter * @param string|null $position * * @return array Array of module names */ public function getModuleScripts( $filter = false, $position = null ) { return $this->getModules( $filter, $position, 'mModuleScripts' ); } /** * Add only JS of one or more modules recognized by ResourceLoader. Module * scripts added through this function will be loaded by ResourceLoader when * the page loads. * * @param string|array $modules Module name (string) or array of module names */ public function addModuleScripts( $modules ) { $this->mModuleScripts = array_merge( $this->mModuleScripts, (array)$modules ); } /** * Get the list of module CSS to include on this page * * @param bool $filter * @param string|null $position * * @return array Array of module names */ public function getModuleStyles( $filter = false, $position = null ) { return $this->getModules( $filter, $position, 'mModuleStyles' ); } /** * Add only CSS of one or more modules recognized by ResourceLoader. * * Module styles added through this function will be added using standard link CSS * tags, rather than as a combined Javascript and CSS package. Thus, they will * load when JavaScript is disabled (unless CSS also happens to be disabled). * * @param string|array $modules Module name (string) or array of module names */ public function addModuleStyles( $modules ) { $this->mModuleStyles = array_merge( $this->mModuleStyles, (array)$modules ); } /** * Get the list of module messages to include on this page * * @deprecated since 1.26 Obsolete * @param bool $filter * @param string|null $position * @return array Array of module names */ public function getModuleMessages( $filter = false, $position = null ) { wfDeprecated( __METHOD__, '1.26' ); return array(); } /** * Load messages of one or more ResourceLoader modules. * * @deprecated since 1.26 Use addModules() instead * @param string|array $modules Module name (string) or array of module names */ public function addModuleMessages( $modules ) { wfDeprecated( __METHOD__, '1.26' ); } /** * @return null|string ResourceLoader target */ public function getTarget() { return $this->mTarget; } /** * Sets ResourceLoader target for load.php links. If null, will be omitted * * @param string|null $target */ public function setTarget( $target ) { $this->mTarget = $target; } /** * Get an array of head items * * @return array */ function getHeadItemsArray() { return $this->mHeadItems; } /** * Get all header items in a string * * @return string * @deprecated since 1.24 Use OutputPage::headElement or * if absolutely necessary use OutputPage::getHeadItemsArray */ function getHeadItems() { wfDeprecated( __METHOD__, '1.24' ); $s = ''; foreach ( $this->mHeadItems as $item ) { $s .= $item; } return $s; } /** * Add or replace an header item to the output * * Whenever possible, use more specific options like ResourceLoader modules, * OutputPage::addLink(), OutputPage::addMetaLink() and OutputPage::addFeedLink() * Fallback options for those are: OutputPage::addStyle, OutputPage::addScript(), * OutputPage::addInlineScript() and OutputPage::addInlineStyle() * This would be your very LAST fallback. * * @param string $name Item name * @param string $value Raw HTML */ public function addHeadItem( $name, $value ) { $this->mHeadItems[$name] = $value; } /** * Check if the header item $name is already set * * @param string $name Item name * @return bool */ public function hasHeadItem( $name ) { return isset( $this->mHeadItems[$name] ); } /** * Set the value of the ETag HTTP header, only used if $wgUseETag is true * * @param string $tag Value of "ETag" header */ function setETag( $tag ) { $this->mETag = $tag; } /** * Set whether the output should only contain the body of the article, * without any skin, sidebar, etc. * Used e.g. when calling with "action=render". * * @param bool $only Whether to output only the body of the article */ public function setArticleBodyOnly( $only ) { $this->mArticleBodyOnly = $only; } /** * Return whether the output will contain only the body of the article * * @return bool */ public function getArticleBodyOnly() { return $this->mArticleBodyOnly; } /** * Set an additional output property * @since 1.21 * * @param string $name * @param mixed $value */ public function setProperty( $name, $value ) { $this->mProperties[$name] = $value; } /** * Get an additional output property * @since 1.21 * * @param string $name * @return mixed Property value or null if not found */ public function getProperty( $name ) { if ( isset( $this->mProperties[$name] ) ) { return $this->mProperties[$name]; } else { return null; } } /** * checkLastModified tells the client to use the client-cached page if * possible. If successful, the OutputPage is disabled so that * any future call to OutputPage->output() have no effect. * * Side effect: sets mLastModified for Last-Modified header * * @param string $timestamp * * @return bool True if cache-ok headers was sent. */ public function checkLastModified( $timestamp ) { if ( !$timestamp || $timestamp == '19700101000000' ) { wfDebug( __METHOD__ . ": CACHE DISABLED, NO TIMESTAMP\n" ); return false; } $config = $this->getConfig(); if ( !$config->get( 'CachePages' ) ) { wfDebug( __METHOD__ . ": CACHE DISABLED\n" ); return false; } $timestamp = wfTimestamp( TS_MW, $timestamp ); $modifiedTimes = array( 'page' => $timestamp, 'user' => $this->getUser()->getTouched(), 'epoch' => $config->get( 'CacheEpoch' ) ); if ( $config->get( 'UseSquid' ) ) { // bug 44570: the core page itself may not change, but resources might $modifiedTimes['sepoch'] = wfTimestamp( TS_MW, time() - $config->get( 'SquidMaxage' ) ); } Hooks::run( 'OutputPageCheckLastModified', array( &$modifiedTimes ) ); $maxModified = max( $modifiedTimes ); $this->mLastModified = wfTimestamp( TS_RFC2822, $maxModified ); $clientHeader = $this->getRequest()->getHeader( 'If-Modified-Since' ); if ( $clientHeader === false ) { wfDebug( __METHOD__ . ": client did not send If-Modified-Since header", 'private' ); return false; } # IE sends sizes after the date like this: # Wed, 20 Aug 2003 06:51:19 GMT; length=5202 # this breaks strtotime(). $clientHeader = preg_replace( '/;.*$/', '', $clientHeader ); MediaWiki\suppressWarnings(); // E_STRICT system time bitching $clientHeaderTime = strtotime( $clientHeader ); MediaWiki\restoreWarnings(); if ( !$clientHeaderTime ) { wfDebug( __METHOD__ . ": unable to parse the client's If-Modified-Since header: $clientHeader\n" ); return false; } $clientHeaderTime = wfTimestamp( TS_MW, $clientHeaderTime ); # Make debug info $info = ''; foreach ( $modifiedTimes as $name => $value ) { if ( $info !== '' ) { $info .= ', '; } $info .= "$name=" . wfTimestamp( TS_ISO_8601, $value ); } wfDebug( __METHOD__ . ": client sent If-Modified-Since: " . wfTimestamp( TS_ISO_8601, $clientHeaderTime ), 'private' ); wfDebug( __METHOD__ . ": effective Last-Modified: " . wfTimestamp( TS_ISO_8601, $maxModified ), 'private' ); if ( $clientHeaderTime < $maxModified ) { wfDebug( __METHOD__ . ": STALE, $info", 'private' ); return false; } # Not modified # Give a 304 Not Modified response code and disable body output wfDebug( __METHOD__ . ": NOT MODIFIED, $info", 'private' ); ini_set( 'zlib.output_compression', 0 ); $this->getRequest()->response()->statusHeader( 304 ); $this->sendCacheControl(); $this->disable(); // Don't output a compressed blob when using ob_gzhandler; // it's technically against HTTP spec and seems to confuse // Firefox when the response gets split over two packets. wfClearOutputBuffers(); return true; } /** * Override the last modified timestamp * * @param string $timestamp New timestamp, in a format readable by * wfTimestamp() */ public function setLastModified( $timestamp ) { $this->mLastModified = wfTimestamp( TS_RFC2822, $timestamp ); } /** * Set the robot policy for the page: <http://www.robotstxt.org/meta.html> * * @param string $policy The literal string to output as the contents of * the meta tag. Will be parsed according to the spec and output in * standardized form. * @return null */ public function setRobotPolicy( $policy ) { $policy = Article::formatRobotPolicy( $policy ); if ( isset( $policy['index'] ) ) { $this->setIndexPolicy( $policy['index'] ); } if ( isset( $policy['follow'] ) ) { $this->setFollowPolicy( $policy['follow'] ); } } /** * Set the index policy for the page, but leave the follow policy un- * touched. * * @param string $policy Either 'index' or 'noindex'. * @return null */ public function setIndexPolicy( $policy ) { $policy = trim( $policy ); if ( in_array( $policy, array( 'index', 'noindex' ) ) ) { $this->mIndexPolicy = $policy; } } /** * Set the follow policy for the page, but leave the index policy un- * touched. * * @param string $policy Either 'follow' or 'nofollow'. * @return null */ public function setFollowPolicy( $policy ) { $policy = trim( $policy ); if ( in_array( $policy, array( 'follow', 'nofollow' ) ) ) { $this->mFollowPolicy = $policy; } } /** * Set the new value of the "action text", this will be added to the * "HTML title", separated from it with " - ". * * @param string $text New value of the "action text" */ public function setPageTitleActionText( $text ) { $this->mPageTitleActionText = $text; } /** * Get the value of the "action text" * * @return string */ public function getPageTitleActionText() { return $this->mPageTitleActionText; } /** * "HTML title" means the contents of "<title>". * It is stored as plain, unescaped text and will be run through htmlspecialchars in the skin file. * * @param string|Message $name */ public function setHTMLTitle( $name ) { if ( $name instanceof Message ) { $this->mHTMLtitle = $name->setContext( $this->getContext() )->text(); } else { $this->mHTMLtitle = $name; } } /** * Return the "HTML title", i.e. the content of the "<title>" tag. * * @return string */ public function getHTMLTitle() { return $this->mHTMLtitle; } /** * Set $mRedirectedFrom, the Title of the page which redirected us to the current page. * * @param Title $t */ public function setRedirectedFrom( $t ) { $this->mRedirectedFrom = $t; } /** * "Page title" means the contents of \<h1\>. It is stored as a valid HTML * fragment. This function allows good tags like \<sup\> in the \<h1\> tag, * but not bad tags like \<script\>. This function automatically sets * \<title\> to the same content as \<h1\> but with all tags removed. Bad * tags that were escaped in \<h1\> will still be escaped in \<title\>, and * good tags like \<i\> will be dropped entirely. * * @param string|Message $name */ public function setPageTitle( $name ) { if ( $name instanceof Message ) { $name = $name->setContext( $this->getContext() )->text(); } # change "<script>foo&bar</script>" to "<script>foo&bar</script>" # but leave "<i>foobar</i>" alone $nameWithTags = Sanitizer::normalizeCharReferences( Sanitizer::removeHTMLtags( $name ) ); $this->mPagetitle = $nameWithTags; # change "<i>foo&bar</i>" to "foo&bar" $this->setHTMLTitle( $this->msg( 'pagetitle' )->rawParams( Sanitizer::stripAllTags( $nameWithTags ) ) ->inContentLanguage() ); } /** * Return the "page title", i.e. the content of the \<h1\> tag. * * @return string */ public function getPageTitle() { return $this->mPagetitle; } /** * Set the Title object to use * * @param Title $t */ public function setTitle( Title $t ) { $this->getContext()->setTitle( $t ); } /** * Replace the subtitle with $str * * @param string|Message $str New value of the subtitle. String should be safe HTML. */ public function setSubtitle( $str ) { $this->clearSubtitle(); $this->addSubtitle( $str ); } /** * Add $str to the subtitle * * @param string|Message $str String or Message to add to the subtitle. String should be safe HTML. */ public function addSubtitle( $str ) { if ( $str instanceof Message ) { $this->mSubtitle[] = $str->setContext( $this->getContext() )->parse(); } else { $this->mSubtitle[] = $str; } } /** * Build message object for a subtitle containing a backlink to a page * * @param Title $title Title to link to * @param array $query Array of additional parameters to include in the link * @return Message * @since 1.25 */ public static function buildBacklinkSubtitle( Title $title, $query = array() ) { if ( $title->isRedirect() ) { $query['redirect'] = 'no'; } return wfMessage( 'backlinksubtitle' ) ->rawParams( Linker::link( $title, null, array(), $query ) ); } /** * Add a subtitle containing a backlink to a page * * @param Title $title Title to link to * @param array $query Array of additional parameters to include in the link */ public function addBacklinkSubtitle( Title $title, $query = array() ) { $this->addSubtitle( self::buildBacklinkSubtitle( $title, $query ) ); } /** * Clear the subtitles */ public function clearSubtitle() { $this->mSubtitle = array(); } /** * Get the subtitle * * @return string */ public function getSubtitle() { return implode( "<br />\n\t\t\t\t", $this->mSubtitle ); } /** * Set the page as printable, i.e. it'll be displayed with all * print styles included */ public function setPrintable() { $this->mPrintable = true; } /** * Return whether the page is "printable" * * @return bool */ public function isPrintable() { return $this->mPrintable; } /** * Disable output completely, i.e. calling output() will have no effect */ public function disable() { $this->mDoNothing = true; } /** * Return whether the output will be completely disabled * * @return bool */ public function isDisabled() { return $this->mDoNothing; } /** * Show an "add new section" link? * * @return bool */ public function showNewSectionLink() { return $this->mNewSectionLink; } /** * Forcibly hide the new section link? * * @return bool */ public function forceHideNewSectionLink() { return $this->mHideNewSectionLink; } /** * Add or remove feed links in the page header * This is mainly kept for backward compatibility, see OutputPage::addFeedLink() * for the new version * @see addFeedLink() * * @param bool $show True: add default feeds, false: remove all feeds */ public function setSyndicated( $show = true ) { if ( $show ) { $this->setFeedAppendQuery( false ); } else { $this->mFeedLinks = array(); } } /** * Add default feeds to the page header * This is mainly kept for backward compatibility, see OutputPage::addFeedLink() * for the new version * @see addFeedLink() * * @param string $val Query to append to feed links or false to output * default links */ public function setFeedAppendQuery( $val ) { $this->mFeedLinks = array(); foreach ( $this->getConfig()->get( 'AdvertisedFeedTypes' ) as $type ) { $query = "feed=$type"; if ( is_string( $val ) ) { $query .= '&' . $val; } $this->mFeedLinks[$type] = $this->getTitle()->getLocalURL( $query ); } } /** * Add a feed link to the page header * * @param string $format Feed type, should be a key of $wgFeedClasses * @param string $href URL */ public function addFeedLink( $format, $href ) { if ( in_array( $format, $this->getConfig()->get( 'AdvertisedFeedTypes' ) ) ) { $this->mFeedLinks[$format] = $href; } } /** * Should we output feed links for this page? * @return bool */ public function isSyndicated() { return count( $this->mFeedLinks ) > 0; } /** * Return URLs for each supported syndication format for this page. * @return array Associating format keys with URLs */ public function getSyndicationLinks() { return $this->mFeedLinks; } /** * Will currently always return null * * @return null */ public function getFeedAppendQuery() { return $this->mFeedLinksAppendQuery; } /** * Set whether the displayed content is related to the source of the * corresponding article on the wiki * Setting true will cause the change "article related" toggle to true * * @param bool $v */ public function setArticleFlag( $v ) { $this->mIsarticle = $v; if ( $v ) { $this->mIsArticleRelated = $v; } } /** * Return whether the content displayed page is related to the source of * the corresponding article on the wiki * * @return bool */ public function isArticle() { return $this->mIsarticle; } /** * Set whether this page is related an article on the wiki * Setting false will cause the change of "article flag" toggle to false * * @param bool $v */ public function setArticleRelated( $v ) { $this->mIsArticleRelated = $v; if ( !$v ) { $this->mIsarticle = false; } } /** * Return whether this page is related an article on the wiki * * @return bool */ public function isArticleRelated() { return $this->mIsArticleRelated; } /** * Add new language links * * @param array $newLinkArray Associative array mapping language code to the page * name */ public function addLanguageLinks( array $newLinkArray ) { $this->mLanguageLinks += $newLinkArray; } /** * Reset the language links and add new language links * * @param array $newLinkArray Associative array mapping language code to the page * name */ public function setLanguageLinks( array $newLinkArray ) { $this->mLanguageLinks = $newLinkArray; } /** * Get the list of language links * * @return array Array of Interwiki Prefixed (non DB key) Titles (e.g. 'fr:Test page') */ public function getLanguageLinks() { return $this->mLanguageLinks; } /** * Add an array of categories, with names in the keys * * @param array $categories Mapping category name => sort key */ public function addCategoryLinks( array $categories ) { global $wgContLang; if ( !is_array( $categories ) || count( $categories ) == 0 ) { return; } # Add the links to a LinkBatch $arr = array( NS_CATEGORY => $categories ); $lb = new LinkBatch; $lb->setArray( $arr ); # Fetch existence plus the hiddencat property $dbr = wfGetDB( DB_SLAVE ); $fields = array( 'page_id', 'page_namespace', 'page_title', 'page_len', 'page_is_redirect', 'page_latest', 'pp_value' ); if ( $this->getConfig()->get( 'ContentHandlerUseDB' ) ) { $fields[] = 'page_content_model'; } + if ( $this->getConfig()->get( 'PageLanguageUseDB' ) ) { + $fields[] = 'page_lang'; + } $res = $dbr->select( array( 'page', 'page_props' ), $fields, $lb->constructSet( 'page', $dbr ), __METHOD__, array(), array( 'page_props' => array( 'LEFT JOIN', array( 'pp_propname' => 'hiddencat', 'pp_page = page_id' ) ) ) ); # Add the results to the link cache $lb->addResultToCache( LinkCache::singleton(), $res ); # Set all the values to 'normal'. $categories = array_fill_keys( array_keys( $categories ), 'normal' ); # Mark hidden categories foreach ( $res as $row ) { if ( isset( $row->pp_value ) ) { $categories[$row->page_title] = 'hidden'; } } # Add the remaining categories to the skin if ( Hooks::run( 'OutputPageMakeCategoryLinks', array( &$this, $categories, &$this->mCategoryLinks ) ) ) { foreach ( $categories as $category => $type ) { // array keys will cast numeric category names to ints, so cast back to string $category = (string)$category; $origcategory = $category; $title = Title::makeTitleSafe( NS_CATEGORY, $category ); if ( !$title ) { continue; } $wgContLang->findVariantLink( $category, $title, true ); if ( $category != $origcategory && array_key_exists( $category, $categories ) ) { continue; } $text = $wgContLang->convertHtml( $title->getText() ); $this->mCategories[] = $title->getText(); $this->mCategoryLinks[$type][] = Linker::link( $title, $text ); } } } /** * Reset the category links (but not the category list) and add $categories * * @param array $categories Mapping category name => sort key */ public function setCategoryLinks( array $categories ) { $this->mCategoryLinks = array(); $this->addCategoryLinks( $categories ); } /** * Get the list of category links, in a 2-D array with the following format: * $arr[$type][] = $link, where $type is either "normal" or "hidden" (for * hidden categories) and $link a HTML fragment with a link to the category * page * * @return array */ public function getCategoryLinks() { return $this->mCategoryLinks; } /** * Get the list of category names this page belongs to * * @return array Array of strings */ public function getCategories() { return $this->mCategories; } /** * Add an array of indicators, with their identifiers as array * keys and HTML contents as values. * * In case of duplicate keys, existing values are overwritten. * * @param array $indicators * @since 1.25 */ public function setIndicators( array $indicators ) { $this->mIndicators = $indicators + $this->mIndicators; // Keep ordered by key ksort( $this->mIndicators ); } /** * Get the indicators associated with this page. * * The array will be internally ordered by item keys. * * @return array Keys: identifiers, values: HTML contents * @since 1.25 */ public function getIndicators() { return $this->mIndicators; } /** * Adds help link with an icon via page indicators. * Link target can be overridden by a local message containing a wikilink: * the message key is: lowercase action or special page name + '-helppage'. * @param string $to Target MediaWiki.org page title or encoded URL. * @param bool $overrideBaseUrl Whether $url is a full URL, to avoid MW.o. * @since 1.25 */ public function addHelpLink( $to, $overrideBaseUrl = false ) { $this->addModuleStyles( 'mediawiki.helplink' ); $text = $this->msg( 'helppage-top-gethelp' )->escaped(); if ( $overrideBaseUrl ) { $helpUrl = $to; } else { $toUrlencoded = wfUrlencode( str_replace( ' ', '_', $to ) ); $helpUrl = "//www.mediawiki.org/wiki/Special:MyLanguage/$toUrlencoded"; } $link = Html::rawElement( 'a', array( 'href' => $helpUrl, 'target' => '_blank', 'class' => 'mw-helplink', ), $text ); $this->setIndicators( array( 'mw-helplink' => $link ) ); } /** * Do not allow scripts which can be modified by wiki users to load on this page; * only allow scripts bundled with, or generated by, the software. * Site-wide styles are controlled by a config setting, since they can be * used to create a custom skin/theme, but not user-specific ones. * * @todo this should be given a more accurate name */ public function disallowUserJs() { $this->reduceAllowedModules( ResourceLoaderModule::TYPE_SCRIPTS, ResourceLoaderModule::ORIGIN_CORE_INDIVIDUAL ); // Site-wide styles are controlled by a config setting, see bug 71621 // for background on why. User styles are never allowed. if ( $this->getConfig()->get( 'AllowSiteCSSOnRestrictedPages' ) ) { $styleOrigin = ResourceLoaderModule::ORIGIN_USER_SITEWIDE; } else { $styleOrigin = ResourceLoaderModule::ORIGIN_CORE_INDIVIDUAL; } $this->reduceAllowedModules( ResourceLoaderModule::TYPE_STYLES, $styleOrigin ); } /** * Show what level of JavaScript / CSS untrustworthiness is allowed on this page * @see ResourceLoaderModule::$origin * @param string $type ResourceLoaderModule TYPE_ constant * @return int ResourceLoaderModule ORIGIN_ class constant */ public function getAllowedModules( $type ) { if ( $type == ResourceLoaderModule::TYPE_COMBINED ) { return min( array_values( $this->mAllowedModules ) ); } else { return isset( $this->mAllowedModules[$type] ) ? $this->mAllowedModules[$type] : ResourceLoaderModule::ORIGIN_ALL; } } /** * Set the highest level of CSS/JS untrustworthiness allowed * * @deprecated since 1.24 Raising level of allowed untrusted content is no longer supported. * Use reduceAllowedModules() instead * @param string $type ResourceLoaderModule TYPE_ constant * @param int $level ResourceLoaderModule class constant */ public function setAllowedModules( $type, $level ) { wfDeprecated( __METHOD__, '1.24' ); $this->reduceAllowedModules( $type, $level ); } /** * Limit the highest level of CSS/JS untrustworthiness allowed. * * If passed the same or a higher level than the current level of untrustworthiness set, the * level will remain unchanged. * * @param string $type * @param int $level ResourceLoaderModule class constant */ public function reduceAllowedModules( $type, $level ) { $this->mAllowedModules[$type] = min( $this->getAllowedModules( $type ), $level ); } /** * Prepend $text to the body HTML * * @param string $text HTML */ public function prependHTML( $text ) { $this->mBodytext = $text . $this->mBodytext; } /** * Append $text to the body HTML * * @param string $text HTML */ public function addHTML( $text ) { $this->mBodytext .= $text; } /** * Shortcut for adding an Html::element via addHTML. * * @since 1.19 * * @param string $element * @param array $attribs * @param string $contents */ public function addElement( $element, array $attribs = array(), $contents = '' ) { $this->addHTML( Html::element( $element, $attribs, $contents ) ); } /** * Clear the body HTML */ public function clearHTML() { $this->mBodytext = ''; } /** * Get the body HTML * * @return string HTML */ public function getHTML() { return $this->mBodytext; } /** * Get/set the ParserOptions object to use for wikitext parsing * * @param ParserOptions|null $options Either the ParserOption to use or null to only get the * current ParserOption object * @return ParserOptions */ public function parserOptions( $options = null ) { if ( $options !== null && !empty( $options->isBogus ) ) { // Someone is trying to set a bogus pre-$wgUser PO. Check if it has // been changed somehow, and keep it if so. $anonPO = ParserOptions::newFromAnon(); $anonPO->setEditSection( false ); if ( !$options->matches( $anonPO ) ) { wfLogWarning( __METHOD__ . ': Setting a changed bogus ParserOptions: ' . wfGetAllCallers( 5 ) ); $options->isBogus = false; } } if ( !$this->mParserOptions ) { if ( !$this->getContext()->getUser()->isSafeToLoad() ) { // $wgUser isn't unstubbable yet, so don't try to get a // ParserOptions for it. And don't cache this ParserOptions // either. $po = ParserOptions::newFromAnon(); $po->setEditSection( false ); $po->isBogus = true; if ( $options !== null ) { $this->mParserOptions = empty( $options->isBogus ) ? $options : null; } return $po; } $this->mParserOptions = ParserOptions::newFromContext( $this->getContext() ); $this->mParserOptions->setEditSection( false ); } if ( $options !== null && !empty( $options->isBogus ) ) { // They're trying to restore the bogus pre-$wgUser PO. Do the right // thing. return wfSetVar( $this->mParserOptions, null, true ); } else { return wfSetVar( $this->mParserOptions, $options ); } } /** * Set the revision ID which will be seen by the wiki text parser * for things such as embedded {{REVISIONID}} variable use. * * @param int|null $revid An positive integer, or null * @return mixed Previous value */ public function setRevisionId( $revid ) { $val = is_null( $revid ) ? null : intval( $revid ); return wfSetVar( $this->mRevisionId, $val ); } /** * Get the displayed revision ID * * @return int */ public function getRevisionId() { return $this->mRevisionId; } /** * Set the timestamp of the revision which will be displayed. This is used * to avoid a extra DB call in Skin::lastModified(). * * @param string|null $timestamp * @return mixed Previous value */ public function setRevisionTimestamp( $timestamp ) { return wfSetVar( $this->mRevisionTimestamp, $timestamp ); } /** * Get the timestamp of displayed revision. * This will be null if not filled by setRevisionTimestamp(). * * @return string|null */ public function getRevisionTimestamp() { return $this->mRevisionTimestamp; } /** * Set the displayed file version * * @param File|bool $file * @return mixed Previous value */ public function setFileVersion( $file ) { $val = null; if ( $file instanceof File && $file->exists() ) { $val = array( 'time' => $file->getTimestamp(), 'sha1' => $file->getSha1() ); } return wfSetVar( $this->mFileVersion, $val, true ); } /** * Get the displayed file version * * @return array|null ('time' => MW timestamp, 'sha1' => sha1) */ public function getFileVersion() { return $this->mFileVersion; } /** * Get the templates used on this page * * @return array (namespace => dbKey => revId) * @since 1.18 */ public function getTemplateIds() { return $this->mTemplateIds; } /** * Get the files used on this page * * @return array (dbKey => array('time' => MW timestamp or null, 'sha1' => sha1 or '')) * @since 1.18 */ public function getFileSearchOptions() { return $this->mImageTimeKeys; } /** * Convert wikitext to HTML and add it to the buffer * Default assumes that the current page title will be used. * * @param string $text * @param bool $linestart Is this the start of a line? * @param bool $interface Is this text in the user interface language? * @throws MWException */ public function addWikiText( $text, $linestart = true, $interface = true ) { $title = $this->getTitle(); // Work around E_STRICT if ( !$title ) { throw new MWException( 'Title is null' ); } $this->addWikiTextTitle( $text, $title, $linestart, /*tidy*/false, $interface ); } /** * Add wikitext with a custom Title object * * @param string $text Wikitext * @param Title $title * @param bool $linestart Is this the start of a line? */ public function addWikiTextWithTitle( $text, &$title, $linestart = true ) { $this->addWikiTextTitle( $text, $title, $linestart ); } /** * Add wikitext with a custom Title object and tidy enabled. * * @param string $text Wikitext * @param Title $title * @param bool $linestart Is this the start of a line? */ function addWikiTextTitleTidy( $text, &$title, $linestart = true ) { $this->addWikiTextTitle( $text, $title, $linestart, true ); } /** * Add wikitext with tidy enabled * * @param string $text Wikitext * @param bool $linestart Is this the start of a line? */ public function addWikiTextTidy( $text, $linestart = true ) { $title = $this->getTitle(); $this->addWikiTextTitleTidy( $text, $title, $linestart ); } /** * Add wikitext with a custom Title object * * @param string $text Wikitext * @param Title $title * @param bool $linestart Is this the start of a line? * @param bool $tidy Whether to use tidy * @param bool $interface Whether it is an interface message * (for example disables conversion) */ public function addWikiTextTitle( $text, Title $title, $linestart, $tidy = false, $interface = false ) { global $wgParser; $popts = $this->parserOptions(); $oldTidy = $popts->setTidy( $tidy ); $popts->setInterfaceMessage( (bool)$interface ); $parserOutput = $wgParser->getFreshParser()->parse( $text, $title, $popts, $linestart, true, $this->mRevisionId ); $popts->setTidy( $oldTidy ); $this->addParserOutput( $parserOutput ); } /** * Add a ParserOutput object, but without Html. * * @deprecated since 1.24, use addParserOutputMetadata() instead. * @param ParserOutput $parserOutput */ public function addParserOutputNoText( $parserOutput ) { wfDeprecated( __METHOD__, '1.24' ); $this->addParserOutputMetadata( $parserOutput ); } /** * Add all metadata associated with a ParserOutput object, but without the actual HTML. This * includes categories, language links, ResourceLoader modules, effects of certain magic words, * and so on. * * @since 1.24 * @param ParserOutput $parserOutput */ public function addParserOutputMetadata( $parserOutput ) { $this->mLanguageLinks += $parserOutput->getLanguageLinks(); $this->addCategoryLinks( $parserOutput->getCategories() ); $this->setIndicators( $parserOutput->getIndicators() ); $this->mNewSectionLink = $parserOutput->getNewSection(); $this->mHideNewSectionLink = $parserOutput->getHideNewSection(); if ( !$parserOutput->isCacheable() ) { $this->enableClientCache( false ); } $this->mNoGallery = $parserOutput->getNoGallery(); $this->mHeadItems = array_merge( $this->mHeadItems, $parserOutput->getHeadItems() ); $this->addModules( $parserOutput->getModules() ); $this->addModuleScripts( $parserOutput->getModuleScripts() ); $this->addModuleStyles( $parserOutput->getModuleStyles() ); $this->addJsConfigVars( $parserOutput->getJsConfigVars() ); $this->mPreventClickjacking = $this->mPreventClickjacking || $parserOutput->preventClickjacking(); // Template versioning... foreach ( (array)$parserOutput->getTemplateIds() as $ns => $dbks ) { if ( isset( $this->mTemplateIds[$ns] ) ) { $this->mTemplateIds[$ns] = $dbks + $this->mTemplateIds[$ns]; } else { $this->mTemplateIds[$ns] = $dbks; } } // File versioning... foreach ( (array)$parserOutput->getFileSearchOptions() as $dbk => $data ) { $this->mImageTimeKeys[$dbk] = $data; } // Hooks registered in the object $parserOutputHooks = $this->getConfig()->get( 'ParserOutputHooks' ); foreach ( $parserOutput->getOutputHooks() as $hookInfo ) { list( $hookName, $data ) = $hookInfo; if ( isset( $parserOutputHooks[$hookName] ) ) { call_user_func( $parserOutputHooks[$hookName], $this, $parserOutput, $data ); } } // enable OOUI if requested via ParserOutput if ( $parserOutput->getEnableOOUI() ) { $this->enableOOUI(); } // Link flags are ignored for now, but may in the future be // used to mark individual language links. $linkFlags = array(); Hooks::run( 'LanguageLinks', array( $this->getTitle(), &$this->mLanguageLinks, &$linkFlags ) ); Hooks::run( 'OutputPageParserOutput', array( &$this, $parserOutput ) ); } /** * Add the HTML and enhancements for it (like ResourceLoader modules) associated with a * ParserOutput object, without any other metadata. * * @since 1.24 * @param ParserOutput $parserOutput */ public function addParserOutputContent( $parserOutput ) { $this->addParserOutputText( $parserOutput ); $this->addModules( $parserOutput->getModules() ); $this->addModuleScripts( $parserOutput->getModuleScripts() ); $this->addModuleStyles( $parserOutput->getModuleStyles() ); $this->addJsConfigVars( $parserOutput->getJsConfigVars() ); } /** * Add the HTML associated with a ParserOutput object, without any metadata. * * @since 1.24 * @param ParserOutput $parserOutput */ public function addParserOutputText( $parserOutput ) { $text = $parserOutput->getText(); Hooks::run( 'OutputPageBeforeHTML', array( &$this, &$text ) ); $this->addHTML( $text ); } /** * Add everything from a ParserOutput object. * * @param ParserOutput $parserOutput */ function addParserOutput( $parserOutput ) { $this->addParserOutputMetadata( $parserOutput ); $parserOutput->setTOCEnabled( $this->mEnableTOC ); // Touch section edit links only if not previously disabled if ( $parserOutput->getEditSectionTokens() ) { $parserOutput->setEditSectionTokens( $this->mEnableSectionEditLinks ); } $this->addParserOutputText( $parserOutput ); } /** * Add the output of a QuickTemplate to the output buffer * * @param QuickTemplate $template */ public function addTemplate( &$template ) { $this->addHTML( $template->getHTML() ); } /** * Parse wikitext and return the HTML. * * @param string $text * @param bool $linestart Is this the start of a line? * @param bool $interface Use interface language ($wgLang instead of * $wgContLang) while parsing language sensitive magic words like GRAMMAR and PLURAL. * This also disables LanguageConverter. * @param Language $language Target language object, will override $interface * @throws MWException * @return string HTML */ public function parse( $text, $linestart = true, $interface = false, $language = null ) { global $wgParser; if ( is_null( $this->getTitle() ) ) { throw new MWException( 'Empty $mTitle in ' . __METHOD__ ); } $popts = $this->parserOptions(); if ( $interface ) { $popts->setInterfaceMessage( true ); } if ( $language !== null ) { $oldLang = $popts->setTargetLanguage( $language ); } $parserOutput = $wgParser->getFreshParser()->parse( $text, $this->getTitle(), $popts, $linestart, true, $this->mRevisionId ); if ( $interface ) { $popts->setInterfaceMessage( false ); } if ( $language !== null ) { $popts->setTargetLanguage( $oldLang ); } return $parserOutput->getText(); } /** * Parse wikitext, strip paragraphs, and return the HTML. * * @param string $text * @param bool $linestart Is this the start of a line? * @param bool $interface Use interface language ($wgLang instead of * $wgContLang) while parsing language sensitive magic * words like GRAMMAR and PLURAL * @return string HTML */ public function parseInline( $text, $linestart = true, $interface = false ) { $parsed = $this->parse( $text, $linestart, $interface ); return Parser::stripOuterParagraph( $parsed ); } /** * @param $maxage * @deprecated since 1.27 Use setCdnMaxage() instead */ public function setSquidMaxage( $maxage ) { $this->setCdnMaxage( $maxage ); } /** * Set the value of the "s-maxage" part of the "Cache-control" HTTP header * * @param int $maxage Maximum cache time on the CDN, in seconds. */ public function setCdnMaxage( $maxage ) { $this->mCdnMaxage = min( $maxage, $this->mCdnMaxageLimit ); } /** * Lower the value of the "s-maxage" part of the "Cache-control" HTTP header * * @param int $maxage Maximum cache time on the CDN, in seconds * @since 1.27 */ public function lowerCdnMaxage( $maxage ) { $this->mCdnMaxageLimit = min( $maxage, $this->mCdnMaxageLimit ); $this->setCdnMaxage( $this->mCdnMaxage ); } /** * Use enableClientCache(false) to force it to send nocache headers * * @param bool $state * * @return bool */ public function enableClientCache( $state ) { return wfSetVar( $this->mEnableClientCache, $state ); } /** * Get the list of cookies that will influence on the cache * * @return array */ function getCacheVaryCookies() { static $cookies; if ( $cookies === null ) { $config = $this->getConfig(); $cookies = array_merge( SessionManager::singleton()->getVaryCookies(), array( 'forceHTTPS', ), $config->get( 'CacheVaryCookies' ) ); Hooks::run( 'GetCacheVaryCookies', array( $this, &$cookies ) ); } return $cookies; } /** * Check if the request has a cache-varying cookie header * If it does, it's very important that we don't allow public caching * * @return bool */ function haveCacheVaryCookies() { $request = $this->getRequest(); foreach ( $this->getCacheVaryCookies() as $cookieName ) { if ( $request->getCookie( $cookieName, '', '' ) !== '' ) { wfDebug( __METHOD__ . ": found $cookieName\n" ); return true; } } wfDebug( __METHOD__ . ": no cache-varying cookies found\n" ); return false; } /** * Add an HTTP header that will influence on the cache * * @param string $header Header name * @param string[]|null $option Options for the Key header. See * https://datatracker.ietf.org/doc/draft-fielding-http-key/ * for the list of valid options. */ public function addVaryHeader( $header, array $option = null ) { if ( !array_key_exists( $header, $this->mVaryHeader ) ) { $this->mVaryHeader[$header] = array(); } if ( !is_array( $option ) ) { $option = array(); } $this->mVaryHeader[$header] = array_unique( array_merge( $this->mVaryHeader[$header], $option ) ); } /** * Return a Vary: header on which to vary caches. Based on the keys of $mVaryHeader, * such as Accept-Encoding or Cookie * * @return string */ public function getVaryHeader() { foreach ( SessionManager::singleton()->getVaryHeaders() as $header => $options ) { $this->addVaryHeader( $header, $options ); } return 'Vary: ' . join( ', ', array_keys( $this->mVaryHeader ) ); } /** * Get a complete Key header * * @return string */ public function getKeyHeader() { $cvCookies = $this->getCacheVaryCookies(); $cookiesOption = array(); foreach ( $cvCookies as $cookieName ) { $cookiesOption[] = 'param=' . $cookieName; } $this->addVaryHeader( 'Cookie', $cookiesOption ); foreach ( SessionManager::singleton()->getVaryHeaders() as $header => $options ) { $this->addVaryHeader( $header, $options ); } $headers = array(); foreach ( $this->mVaryHeader as $header => $option ) { $newheader = $header; if ( is_array( $option ) && count( $option ) > 0 ) { $newheader .= ';' . implode( ';', $option ); } $headers[] = $newheader; } $key = 'Key: ' . implode( ',', $headers ); return $key; } /** * T23672: Add Accept-Language to Vary and Key headers * if there's no 'variant' parameter existed in GET. * * For example: * /w/index.php?title=Main_page should always be served; but * /w/index.php?title=Main_page&variant=zh-cn should never be served. */ function addAcceptLanguage() { $title = $this->getTitle(); if ( !$title instanceof Title ) { return; } $lang = $title->getPageLanguage(); if ( !$this->getRequest()->getCheck( 'variant' ) && $lang->hasVariants() ) { $variants = $lang->getVariants(); $aloption = array(); foreach ( $variants as $variant ) { if ( $variant === $lang->getCode() ) { continue; } else { $aloption[] = 'substr=' . $variant; // IE and some other browsers use BCP 47 standards in // their Accept-Language header, like "zh-CN" or "zh-Hant". // We should handle these too. $variantBCP47 = wfBCP47( $variant ); if ( $variantBCP47 !== $variant ) { $aloption[] = 'substr=' . $variantBCP47; } } } $this->addVaryHeader( 'Accept-Language', $aloption ); } } /** * Set a flag which will cause an X-Frame-Options header appropriate for * edit pages to be sent. The header value is controlled by * $wgEditPageFrameOptions. * * This is the default for special pages. If you display a CSRF-protected * form on an ordinary view page, then you need to call this function. * * @param bool $enable */ public function preventClickjacking( $enable = true ) { $this->mPreventClickjacking = $enable; } /** * Turn off frame-breaking. Alias for $this->preventClickjacking(false). * This can be called from pages which do not contain any CSRF-protected * HTML form. */ public function allowClickjacking() { $this->mPreventClickjacking = false; } /** * Get the prevent-clickjacking flag * * @since 1.24 * @return bool */ public function getPreventClickjacking() { return $this->mPreventClickjacking; } /** * Get the X-Frame-Options header value (without the name part), or false * if there isn't one. This is used by Skin to determine whether to enable * JavaScript frame-breaking, for clients that don't support X-Frame-Options. * * @return string */ public function getFrameOptions() { $config = $this->getConfig(); if ( $config->get( 'BreakFrames' ) ) { return 'DENY'; } elseif ( $this->mPreventClickjacking && $config->get( 'EditPageFrameOptions' ) ) { return $config->get( 'EditPageFrameOptions' ); } return false; } /** * Send cache control HTTP headers */ public function sendCacheControl() { $response = $this->getRequest()->response(); $config = $this->getConfig(); if ( $config->get( 'UseETag' ) && $this->mETag ) { $response->header( "ETag: $this->mETag" ); } $this->addVaryHeader( 'Cookie' ); $this->addAcceptLanguage(); # don't serve compressed data to clients who can't handle it # maintain different caches for logged-in users and non-logged in ones $response->header( $this->getVaryHeader() ); if ( $config->get( 'UseKeyHeader' ) ) { $response->header( $this->getKeyHeader() ); } if ( $this->mEnableClientCache ) { if ( $config->get( 'UseSquid' ) && !SessionManager::getGlobalSession()->isPersistent() && !$this->isPrintable() && $this->mCdnMaxage != 0 && !$this->haveCacheVaryCookies() ) { if ( $config->get( 'UseESI' ) ) { # We'll purge the proxy cache explicitly, but require end user agents # to revalidate against the proxy on each visit. # Surrogate-Control controls our CDN, Cache-Control downstream caches wfDebug( __METHOD__ . ": proxy caching with ESI; {$this->mLastModified} **", 'private' ); # start with a shorter timeout for initial testing # header( 'Surrogate-Control: max-age=2678400+2678400, content="ESI/1.0"'); $response->header( 'Surrogate-Control: max-age=' . $config->get( 'SquidMaxage' ) . '+' . $this->mCdnMaxage . ', content="ESI/1.0"' ); $response->header( 'Cache-Control: s-maxage=0, must-revalidate, max-age=0' ); } else { # We'll purge the proxy cache for anons explicitly, but require end user agents # to revalidate against the proxy on each visit. # IMPORTANT! The CDN needs to replace the Cache-Control header with # Cache-Control: s-maxage=0, must-revalidate, max-age=0 wfDebug( __METHOD__ . ": local proxy caching; {$this->mLastModified} **", 'private' ); # start with a shorter timeout for initial testing # header( "Cache-Control: s-maxage=2678400, must-revalidate, max-age=0" ); $response->header( 'Cache-Control: s-maxage=' . $this->mCdnMaxage . ', must-revalidate, max-age=0' ); } } else { # We do want clients to cache if they can, but they *must* check for updates # on revisiting the page. wfDebug( __METHOD__ . ": private caching; {$this->mLastModified} **", 'private' ); $response->header( 'Expires: ' . gmdate( 'D, d M Y H:i:s', 0 ) . ' GMT' ); $response->header( "Cache-Control: private, must-revalidate, max-age=0" ); } if ( $this->mLastModified ) { $response->header( "Last-Modified: {$this->mLastModified}" ); } } else { wfDebug( __METHOD__ . ": no caching **", 'private' ); # In general, the absence of a last modified header should be enough to prevent # the client from using its cache. We send a few other things just to make sure. $response->header( 'Expires: ' . gmdate( 'D, d M Y H:i:s', 0 ) . ' GMT' ); $response->header( 'Cache-Control: no-cache, no-store, max-age=0, must-revalidate' ); $response->header( 'Pragma: no-cache' ); } } /** * Finally, all the text has been munged and accumulated into * the object, let's actually output it: */ public function output() { if ( $this->mDoNothing ) { return; } $response = $this->getRequest()->response(); $config = $this->getConfig(); if ( $this->mRedirect != '' ) { # Standards require redirect URLs to be absolute $this->mRedirect = wfExpandUrl( $this->mRedirect, PROTO_CURRENT ); $redirect = $this->mRedirect; $code = $this->mRedirectCode; if ( Hooks::run( "BeforePageRedirect", array( $this, &$redirect, &$code ) ) ) { if ( $code == '301' || $code == '303' ) { if ( !$config->get( 'DebugRedirects' ) ) { $response->statusHeader( $code ); } $this->mLastModified = wfTimestamp( TS_RFC2822 ); } if ( $config->get( 'VaryOnXFP' ) ) { $this->addVaryHeader( 'X-Forwarded-Proto' ); } $this->sendCacheControl(); $response->header( "Content-Type: text/html; charset=utf-8" ); if ( $config->get( 'DebugRedirects' ) ) { $url = htmlspecialchars( $redirect ); print "<html>\n<head>\n<title>Redirect\n\n\n"; print "

Location: $url

\n"; print "\n\n"; } else { $response->header( 'Location: ' . $redirect ); } } return; } elseif ( $this->mStatusCode ) { $response->statusHeader( $this->mStatusCode ); } # Buffer output; final headers may depend on later processing ob_start(); $response->header( 'Content-type: ' . $config->get( 'MimeType' ) . '; charset=UTF-8' ); $response->header( 'Content-language: ' . $config->get( 'LanguageCode' ) ); // Avoid Internet Explorer "compatibility view" in IE 8-10, so that // jQuery etc. can work correctly. $response->header( 'X-UA-Compatible: IE=Edge' ); // Prevent framing, if requested $frameOptions = $this->getFrameOptions(); if ( $frameOptions ) { $response->header( "X-Frame-Options: $frameOptions" ); } if ( $this->mArticleBodyOnly ) { echo $this->mBodytext; } else { $sk = $this->getSkin(); // add skin specific modules $modules = $sk->getDefaultModules(); // Enforce various default modules for all skins $coreModules = array( // Keep this list as small as possible 'site', 'mediawiki.page.startup', 'mediawiki.user', ); // Support for high-density display images if enabled if ( $config->get( 'ResponsiveImages' ) ) { $coreModules[] = 'mediawiki.hidpi'; } $this->addModules( $coreModules ); foreach ( $modules as $group ) { $this->addModules( $group ); } MWDebug::addModules( $this ); // Hook that allows last minute changes to the output page, e.g. // adding of CSS or Javascript by extensions. Hooks::run( 'BeforePageDisplay', array( &$this, &$sk ) ); $sk->outputPage(); } // This hook allows last minute changes to final overall output by modifying output buffer Hooks::run( 'AfterFinalPageOutput', array( $this ) ); $this->sendCacheControl(); ob_end_flush(); } /** * Actually output something with print. * * @param string $ins The string to output * @deprecated since 1.22 Use echo yourself. */ public function out( $ins ) { wfDeprecated( __METHOD__, '1.22' ); print $ins; } /** * Prepare this object to display an error page; disable caching and * indexing, clear the current text and redirect, set the page's title * and optionally an custom HTML title (content of the "" tag). * * @param string|Message $pageTitle Will be passed directly to setPageTitle() * @param string|Message $htmlTitle Will be passed directly to setHTMLTitle(); * optional, if not passed the "<title>" attribute will be * based on $pageTitle */ public function prepareErrorPage( $pageTitle, $htmlTitle = false ) { $this->setPageTitle( $pageTitle ); if ( $htmlTitle !== false ) { $this->setHTMLTitle( $htmlTitle ); } $this->setRobotPolicy( 'noindex,nofollow' ); $this->setArticleRelated( false ); $this->enableClientCache( false ); $this->mRedirect = ''; $this->clearSubtitle(); $this->clearHTML(); } /** * Output a standard error page * * showErrorPage( 'titlemsg', 'pagetextmsg' ); * showErrorPage( 'titlemsg', 'pagetextmsg', array( 'param1', 'param2' ) ); * showErrorPage( 'titlemsg', $messageObject ); * showErrorPage( $titleMessageObject, $messageObject ); * * @param string|Message $title Message key (string) for page title, or a Message object * @param string|Message $msg Message key (string) for page text, or a Message object * @param array $params Message parameters; ignored if $msg is a Message object */ public function showErrorPage( $title, $msg, $params = array() ) { if ( !$title instanceof Message ) { $title = $this->msg( $title ); } $this->prepareErrorPage( $title ); if ( $msg instanceof Message ) { if ( $params !== array() ) { trigger_error( 'Argument ignored: $params. The message parameters argument ' . 'is discarded when the $msg argument is a Message object instead of ' . 'a string.', E_USER_NOTICE ); } $this->addHTML( $msg->parseAsBlock() ); } else { $this->addWikiMsgArray( $msg, $params ); } $this->returnToMain(); } /** * Output a standard permission error page * * @param array $errors Error message keys * @param string $action Action that was denied or null if unknown */ public function showPermissionsErrorPage( array $errors, $action = null ) { // For some action (read, edit, create and upload), display a "login to do this action" // error if all of the following conditions are met: // 1. the user is not logged in // 2. the only error is insufficient permissions (i.e. no block or something else) // 3. the error can be avoided simply by logging in if ( in_array( $action, array( 'read', 'edit', 'createpage', 'createtalk', 'upload' ) ) && $this->getUser()->isAnon() && count( $errors ) == 1 && isset( $errors[0][0] ) && ( $errors[0][0] == 'badaccess-groups' || $errors[0][0] == 'badaccess-group0' ) && ( User::groupHasPermission( 'user', $action ) || User::groupHasPermission( 'autoconfirmed', $action ) ) ) { $displayReturnto = null; # Due to bug 32276, if a user does not have read permissions, # $this->getTitle() will just give Special:Badtitle, which is # not especially useful as a returnto parameter. Use the title # from the request instead, if there was one. $request = $this->getRequest(); $returnto = Title::newFromText( $request->getVal( 'title', '' ) ); if ( $action == 'edit' ) { $msg = 'whitelistedittext'; $displayReturnto = $returnto; } elseif ( $action == 'createpage' || $action == 'createtalk' ) { $msg = 'nocreatetext'; } elseif ( $action == 'upload' ) { $msg = 'uploadnologintext'; } else { # Read $msg = 'loginreqpagetext'; $displayReturnto = Title::newMainPage(); } $query = array(); if ( $returnto ) { $query['returnto'] = $returnto->getPrefixedText(); if ( !$request->wasPosted() ) { $returntoquery = $request->getValues(); unset( $returntoquery['title'] ); unset( $returntoquery['returnto'] ); unset( $returntoquery['returntoquery'] ); $query['returntoquery'] = wfArrayToCgi( $returntoquery ); } } $loginLink = Linker::linkKnown( SpecialPage::getTitleFor( 'Userlogin' ), $this->msg( 'loginreqlink' )->escaped(), array(), $query ); $this->prepareErrorPage( $this->msg( 'loginreqtitle' ) ); $this->addHTML( $this->msg( $msg )->rawParams( $loginLink )->parse() ); # Don't return to a page the user can't read otherwise # we'll end up in a pointless loop if ( $displayReturnto && $displayReturnto->userCan( 'read', $this->getUser() ) ) { $this->returnToMain( null, $displayReturnto ); } } else { $this->prepareErrorPage( $this->msg( 'permissionserrors' ) ); $this->addWikiText( $this->formatPermissionsErrorMessage( $errors, $action ) ); } } /** * Display an error page indicating that a given version of MediaWiki is * required to use it * * @param mixed $version The version of MediaWiki needed to use the page */ public function versionRequired( $version ) { $this->prepareErrorPage( $this->msg( 'versionrequired', $version ) ); $this->addWikiMsg( 'versionrequiredtext', $version ); $this->returnToMain(); } /** * Format a list of error messages * * @param array $errors Array of arrays returned by Title::getUserPermissionsErrors * @param string $action Action that was denied or null if unknown * @return string The wikitext error-messages, formatted into a list. */ public function formatPermissionsErrorMessage( array $errors, $action = null ) { if ( $action == null ) { $text = $this->msg( 'permissionserrorstext', count( $errors ) )->plain() . "\n\n"; } else { $action_desc = $this->msg( "action-$action" )->plain(); $text = $this->msg( 'permissionserrorstext-withaction', count( $errors ), $action_desc )->plain() . "\n\n"; } if ( count( $errors ) > 1 ) { $text .= '<ul class="permissions-errors">' . "\n"; foreach ( $errors as $error ) { $text .= '<li>'; $text .= call_user_func_array( array( $this, 'msg' ), $error )->plain(); $text .= "</li>\n"; } $text .= '</ul>'; } else { $text .= "<div class=\"permissions-errors\">\n" . call_user_func_array( array( $this, 'msg' ), reset( $errors ) )->plain() . "\n</div>"; } return $text; } /** * Display a page stating that the Wiki is in read-only mode. * Should only be called after wfReadOnly() has returned true. * * Historically, this function was used to show the source of the page that the user * was trying to edit and _also_ permissions error messages. The relevant code was * moved into EditPage in 1.19 (r102024 / d83c2a431c2a) and removed here in 1.25. * * @deprecated since 1.25; throw the exception directly * @throws ReadOnlyError */ public function readOnlyPage() { if ( func_num_args() > 0 ) { throw new MWException( __METHOD__ . ' no longer accepts arguments since 1.25.' ); } throw new ReadOnlyError; } /** * Turn off regular page output and return an error response * for when rate limiting has triggered. * * @deprecated since 1.25; throw the exception directly */ public function rateLimited() { wfDeprecated( __METHOD__, '1.25' ); throw new ThrottledError; } /** * Show a warning about slave lag * * If the lag is higher than $wgSlaveLagCritical seconds, * then the warning is a bit more obvious. If the lag is * lower than $wgSlaveLagWarning, then no warning is shown. * * @param int $lag Slave lag */ public function showLagWarning( $lag ) { $config = $this->getConfig(); if ( $lag >= $config->get( 'SlaveLagWarning' ) ) { $message = $lag < $config->get( 'SlaveLagCritical' ) ? 'lag-warn-normal' : 'lag-warn-high'; $wrap = Html::rawElement( 'div', array( 'class' => "mw-{$message}" ), "\n$1\n" ); $this->wrapWikiMsg( "$wrap\n", array( $message, $this->getLanguage()->formatNum( $lag ) ) ); } } public function showFatalError( $message ) { $this->prepareErrorPage( $this->msg( 'internalerror' ) ); $this->addHTML( $message ); } public function showUnexpectedValueError( $name, $val ) { $this->showFatalError( $this->msg( 'unexpected', $name, $val )->text() ); } public function showFileCopyError( $old, $new ) { $this->showFatalError( $this->msg( 'filecopyerror', $old, $new )->text() ); } public function showFileRenameError( $old, $new ) { $this->showFatalError( $this->msg( 'filerenameerror', $old, $new )->text() ); } public function showFileDeleteError( $name ) { $this->showFatalError( $this->msg( 'filedeleteerror', $name )->text() ); } public function showFileNotFoundError( $name ) { $this->showFatalError( $this->msg( 'filenotfound', $name )->text() ); } /** * Add a "return to" link pointing to a specified title * * @param Title $title Title to link * @param array $query Query string parameters * @param string $text Text of the link (input is not escaped) * @param array $options Options array to pass to Linker */ public function addReturnTo( $title, array $query = array(), $text = null, $options = array() ) { $link = $this->msg( 'returnto' )->rawParams( Linker::link( $title, $text, array(), $query, $options ) )->escaped(); $this->addHTML( "<p id=\"mw-returnto\">{$link}</p>\n" ); } /** * Add a "return to" link pointing to a specified title, * or the title indicated in the request, or else the main page * * @param mixed $unused * @param Title|string $returnto Title or String to return to * @param string $returntoquery Query string for the return to link */ public function returnToMain( $unused = null, $returnto = null, $returntoquery = null ) { if ( $returnto == null ) { $returnto = $this->getRequest()->getText( 'returnto' ); } if ( $returntoquery == null ) { $returntoquery = $this->getRequest()->getText( 'returntoquery' ); } if ( $returnto === '' ) { $returnto = Title::newMainPage(); } if ( is_object( $returnto ) ) { $titleObj = $returnto; } else { $titleObj = Title::newFromText( $returnto ); } if ( !is_object( $titleObj ) ) { $titleObj = Title::newMainPage(); } $this->addReturnTo( $titleObj, wfCgiToArray( $returntoquery ) ); } /** * @param Skin $sk The given Skin * @param bool $includeStyle Unused * @return string The doctype, opening "<html>", and head element. */ public function headElement( Skin $sk, $includeStyle = true ) { global $wgContLang; $userdir = $this->getLanguage()->getDir(); $sitedir = $wgContLang->getDir(); $ret = Html::htmlHeader( $sk->getHtmlElementAttributes() ); if ( $this->getHTMLTitle() == '' ) { $this->setHTMLTitle( $this->msg( 'pagetitle', $this->getPageTitle() )->inContentLanguage() ); } $openHead = Html::openElement( 'head' ); if ( $openHead ) { # Don't bother with the newline if $head == '' $ret .= "$openHead\n"; } if ( !Html::isXmlMimeType( $this->getConfig()->get( 'MimeType' ) ) ) { // Add <meta charset="UTF-8"> // This should be before <title> since it defines the charset used by // text including the text inside <title>. // The spec recommends defining XHTML5's charset using the XML declaration // instead of meta. // Our XML declaration is output by Html::htmlHeader. // http://www.whatwg.org/html/semantics.html#attr-meta-http-equiv-content-type // http://www.whatwg.org/html/semantics.html#charset $ret .= Html::element( 'meta', array( 'charset' => 'UTF-8' ) ) . "\n"; } $ret .= Html::element( 'title', null, $this->getHTMLTitle() ) . "\n"; $ret .= $this->getInlineHeadScripts() . "\n"; $ret .= $this->buildCssLinks() . "\n"; $ret .= $this->getExternalHeadScripts() . "\n"; foreach ( $this->getHeadLinksArray() as $item ) { $ret .= $item . "\n"; } foreach ( $this->mHeadItems as $item ) { $ret .= $item . "\n"; } $closeHead = Html::closeElement( 'head' ); if ( $closeHead ) { $ret .= "$closeHead\n"; } $bodyClasses = array(); $bodyClasses[] = 'mediawiki'; # Classes for LTR/RTL directionality support $bodyClasses[] = $userdir; $bodyClasses[] = "sitedir-$sitedir"; if ( $this->getLanguage()->capitalizeAllNouns() ) { # A <body> class is probably not the best way to do this . . . $bodyClasses[] = 'capitalize-all-nouns'; } $bodyClasses[] = $sk->getPageClasses( $this->getTitle() ); $bodyClasses[] = 'skin-' . Sanitizer::escapeClass( $sk->getSkinName() ); $bodyClasses[] = 'action-' . Sanitizer::escapeClass( Action::getActionName( $this->getContext() ) ); $bodyAttrs = array(); // While the implode() is not strictly needed, it's used for backwards compatibility // (this used to be built as a string and hooks likely still expect that). $bodyAttrs['class'] = implode( ' ', $bodyClasses ); // Allow skins and extensions to add body attributes they need $sk->addToBodyAttributes( $this, $bodyAttrs ); Hooks::run( 'OutputPageBodyAttributes', array( $this, $sk, &$bodyAttrs ) ); $ret .= Html::openElement( 'body', $bodyAttrs ) . "\n"; return $ret; } /** * Get a ResourceLoader object associated with this OutputPage * * @return ResourceLoader */ public function getResourceLoader() { if ( is_null( $this->mResourceLoader ) ) { $this->mResourceLoader = new ResourceLoader( $this->getConfig(), LoggerFactory::getInstance( 'resourceloader' ) ); } return $this->mResourceLoader; } /** * Construct neccecary html and loader preset states to load modules on a page. * * Use getHtmlFromLoaderLinks() to convert this array to HTML. * * @param array|string $modules One or more module names * @param string $only ResourceLoaderModule TYPE_ class constant * @param array $extraQuery [optional] Array with extra query parameters for the request * @return array A list of HTML strings and array of client loader preset states */ public function makeResourceLoaderLink( $modules, $only, array $extraQuery = array() ) { $modules = (array)$modules; $links = array( // List of html strings 'html' => array(), // Associative array of module names and their states 'states' => array(), ); if ( !count( $modules ) ) { return $links; } if ( count( $modules ) > 1 ) { // Remove duplicate module requests $modules = array_unique( $modules ); // Sort module names so requests are more uniform sort( $modules ); if ( ResourceLoader::inDebugMode() ) { // Recursively call us for every item foreach ( $modules as $name ) { $link = $this->makeResourceLoaderLink( $name, $only, $extraQuery ); $links['html'] = array_merge( $links['html'], $link['html'] ); $links['states'] += $link['states']; } return $links; } } if ( !is_null( $this->mTarget ) ) { $extraQuery['target'] = $this->mTarget; } // Create keyed-by-source and then keyed-by-group list of module objects from modules list $sortedModules = array(); $resourceLoader = $this->getResourceLoader(); foreach ( $modules as $name ) { $module = $resourceLoader->getModule( $name ); # Check that we're allowed to include this module on this page if ( !$module || ( $module->getOrigin() > $this->getAllowedModules( ResourceLoaderModule::TYPE_SCRIPTS ) && $only == ResourceLoaderModule::TYPE_SCRIPTS ) || ( $module->getOrigin() > $this->getAllowedModules( ResourceLoaderModule::TYPE_STYLES ) && $only == ResourceLoaderModule::TYPE_STYLES ) || ( $module->getOrigin() > $this->getAllowedModules( ResourceLoaderModule::TYPE_COMBINED ) && $only == ResourceLoaderModule::TYPE_COMBINED ) || ( $this->mTarget && !in_array( $this->mTarget, $module->getTargets() ) ) ) { continue; } $sortedModules[$module->getSource()][$module->getGroup()][$name] = $module; } foreach ( $sortedModules as $source => $groups ) { foreach ( $groups as $group => $grpModules ) { // Special handling for user-specific groups $user = null; if ( ( $group === 'user' || $group === 'private' ) && $this->getUser()->isLoggedIn() ) { $user = $this->getUser()->getName(); } // Create a fake request based on the one we are about to make so modules return // correct timestamp and emptiness data $query = ResourceLoader::makeLoaderQuery( array(), // modules; not determined yet $this->getLanguage()->getCode(), $this->getSkin()->getSkinName(), $user, null, // version; not determined yet ResourceLoader::inDebugMode(), $only === ResourceLoaderModule::TYPE_COMBINED ? null : $only, $this->isPrintable(), $this->getRequest()->getBool( 'handheld' ), $extraQuery ); $context = new ResourceLoaderContext( $resourceLoader, new FauxRequest( $query ) ); // Extract modules that know they're empty and see if we have one or more // raw modules $isRaw = false; foreach ( $grpModules as $key => $module ) { // Inline empty modules: since they're empty, just mark them as 'ready' (bug 46857) // If we're only getting the styles, we don't need to do anything for empty modules. if ( $module->isKnownEmpty( $context ) ) { unset( $grpModules[$key] ); if ( $only !== ResourceLoaderModule::TYPE_STYLES ) { $links['states'][$key] = 'ready'; } } $isRaw |= $module->isRaw(); } // If there are no non-empty modules, skip this group if ( count( $grpModules ) === 0 ) { continue; } // Inline private modules. These can't be loaded through load.php for security // reasons, see bug 34907. Note that these modules should be loaded from // getExternalHeadScripts() before the first loader call. Otherwise other modules can't // properly use them as dependencies (bug 30914) if ( $group === 'private' ) { if ( $only == ResourceLoaderModule::TYPE_STYLES ) { $links['html'][] = Html::inlineStyle( $resourceLoader->makeModuleResponse( $context, $grpModules ) ); } else { $links['html'][] = ResourceLoader::makeInlineScript( $resourceLoader->makeModuleResponse( $context, $grpModules ) ); } continue; } // Special handling for the user group; because users might change their stuff // on-wiki like user pages, or user preferences; we need to find the highest // timestamp of these user-changeable modules so we can ensure cache misses on change // This should NOT be done for the site group (bug 27564) because anons get that too // and we shouldn't be putting timestamps in CDN-cached HTML $version = null; if ( $group === 'user' ) { $query['version'] = $resourceLoader->getCombinedVersion( $context, array_keys( $grpModules ) ); } $query['modules'] = ResourceLoader::makePackedModulesString( array_keys( $grpModules ) ); $moduleContext = new ResourceLoaderContext( $resourceLoader, new FauxRequest( $query ) ); $url = $resourceLoader->createLoaderURL( $source, $moduleContext, $extraQuery ); // Automatically select style/script elements if ( $only === ResourceLoaderModule::TYPE_STYLES ) { $link = Html::linkedStyle( $url ); } else { if ( $context->getRaw() || $isRaw ) { // Startup module can't load itself, needs to use <script> instead of mw.loader.load $link = Html::element( 'script', array( // In SpecialJavaScriptTest, QUnit must load synchronous 'async' => !isset( $extraQuery['sync'] ), 'src' => $url ) ); } else { $link = ResourceLoader::makeInlineScript( Xml::encodeJsCall( 'mw.loader.load', array( $url ) ) ); } // For modules requested directly in the html via <script> or mw.loader.load // tell mw.loader they are being loading to prevent duplicate requests. foreach ( $grpModules as $key => $module ) { // Don't output state=loading for the startup module. if ( $key !== 'startup' ) { $links['states'][$key] = 'loading'; } } } if ( $group == 'noscript' ) { $links['html'][] = Html::rawElement( 'noscript', array(), $link ); } else { $links['html'][] = $link; } } } return $links; } /** * Build html output from an array of links from makeResourceLoaderLink. * @param array $links * @return string HTML */ protected static function getHtmlFromLoaderLinks( array $links ) { $html = array(); $states = array(); foreach ( $links as $link ) { if ( !is_array( $link ) ) { $html[] = $link; } else { $html = array_merge( $html, $link['html'] ); $states += $link['states']; } } // Filter out empty values $html = array_filter( $html, 'strlen' ); if ( count( $states ) ) { array_unshift( $html, ResourceLoader::makeInlineScript( ResourceLoader::makeLoaderStateScript( $states ) ) ); } return WrappedString::join( "\n", $html ); } /** * JS stuff to put in the "<head>". This is the startup module, config * vars and modules marked with position 'top' * * @return string HTML fragment */ function getHeadScripts() { return $this->getInlineHeadScripts() . "\n" . $this->getExternalHeadScripts(); } /** * <script src="..."> tags for "<head>". This is the startup module * and other modules marked with position 'top'. * * @return string HTML fragment */ function getExternalHeadScripts() { $links = array(); // Startup - this provides the client with the module // manifest and loads jquery and mediawiki base modules $links[] = $this->makeResourceLoaderLink( 'startup', ResourceLoaderModule::TYPE_SCRIPTS ); return self::getHtmlFromLoaderLinks( $links ); } /** * <script>...</script> tags to put in "<head>". * * @return string HTML fragment */ function getInlineHeadScripts() { $links = array(); // Client profile classes for <html>. Allows for easy hiding/showing of UI components. // Must be done synchronously on every page to avoid flashes of wrong content. // Note: This class distinguishes MediaWiki-supported JavaScript from the rest. // The "rest" includes browsers that support JavaScript but not supported by our runtime. // For the performance benefit of the majority, this is added unconditionally here and is // then fixed up by the startup module for unsupported browsers. $links[] = Html::inlineScript( 'document.documentElement.className = document.documentElement.className' . '.replace( /(^|\s)client-nojs(\s|$)/, "$1client-js$2" );' ); // Load config before anything else $links[] = ResourceLoader::makeInlineScript( ResourceLoader::makeConfigSetScript( $this->getJSVars() ) ); // Load embeddable private modules before any loader links // This needs to be TYPE_COMBINED so these modules are properly wrapped // in mw.loader.implement() calls and deferred until mw.user is available $embedScripts = array( 'user.options' ); $links[] = $this->makeResourceLoaderLink( $embedScripts, ResourceLoaderModule::TYPE_COMBINED ); // Separate user.tokens as otherwise caching will be allowed (T84960) $links[] = $this->makeResourceLoaderLink( 'user.tokens', ResourceLoaderModule::TYPE_COMBINED ); // Modules requests - let the client calculate dependencies and batch requests as it likes // Only load modules that have marked themselves for loading at the top $modules = $this->getModules( true, 'top' ); if ( $modules ) { $links[] = ResourceLoader::makeInlineScript( Xml::encodeJsCall( 'mw.loader.load', array( $modules ) ) ); } // "Scripts only" modules marked for top inclusion $links[] = $this->makeResourceLoaderLink( $this->getModuleScripts( true, 'top' ), ResourceLoaderModule::TYPE_SCRIPTS ); return self::getHtmlFromLoaderLinks( $links ); } /** * JS stuff to put at the 'bottom', which goes at the bottom of the `<body>`. * These are modules marked with position 'bottom', legacy scripts ($this->mScripts), * site JS, and user JS. * * @param bool $unused Previously used to let this method change its output based * on whether it was called by getExternalHeadScripts() or getBottomScripts(). * @return string */ function getScriptsForBottomQueue( $unused = null ) { // Scripts "only" requests marked for bottom inclusion // If we're in the <head>, use load() calls rather than <script src="..."> tags $links = array(); $links[] = $this->makeResourceLoaderLink( $this->getModuleScripts( true, 'bottom' ), ResourceLoaderModule::TYPE_SCRIPTS ); // Modules requests - let the client calculate dependencies and batch requests as it likes // Only load modules that have marked themselves for loading at the bottom $modules = $this->getModules( true, 'bottom' ); if ( $modules ) { $links[] = ResourceLoader::makeInlineScript( Xml::encodeJsCall( 'mw.loader.load', array( $modules ) ) ); } // Legacy Scripts $links[] = $this->mScripts; // Add user JS if enabled // This must use TYPE_COMBINED instead of only=scripts so that its request is handled by // mw.loader.implement() which ensures that execution is scheduled after the "site" module. if ( $this->getConfig()->get( 'AllowUserJs' ) && $this->getUser()->isLoggedIn() && $this->getTitle() && $this->getTitle()->isJsSubpage() && $this->userCanPreview() ) { // We're on a preview of a JS subpage. Exclude this page from the user module (T28283) // and include the draft contents as a raw script instead. $links[] = $this->makeResourceLoaderLink( 'user', ResourceLoaderModule::TYPE_COMBINED, array( 'excludepage' => $this->getTitle()->getPrefixedDBkey() ) ); // Load the previewed JS $links[] = ResourceLoader::makeInlineScript( Xml::encodeJsCall( 'mw.loader.using', array( array( 'user', 'site' ), new XmlJsCode( 'function () {' . Xml::encodeJsCall( '$.globalEval', array( $this->getRequest()->getText( 'wpTextbox1' ) ) ) . '}' ) ) ) ); // FIXME: If the user is previewing, say, ./vector.js, his ./common.js will be loaded // asynchronously and may arrive *after* the inline script here. So the previewed code // may execute before ./common.js runs. Normally, ./common.js runs before ./vector.js. // Similarly, when previewing ./common.js and the user module does arrive first, // it will arrive without common.js and the inline script runs after. // Thus running common after the excluded subpage. } else { // Include the user module normally, i.e., raw to avoid it being wrapped in a closure. $links[] = $this->makeResourceLoaderLink( 'user', ResourceLoaderModule::TYPE_COMBINED ); } // Group JS is only enabled if site JS is enabled. $links[] = $this->makeResourceLoaderLink( 'user.groups', ResourceLoaderModule::TYPE_COMBINED ); return self::getHtmlFromLoaderLinks( $links ); } /** * JS stuff to put at the bottom of the "<body>" * @return string */ function getBottomScripts() { return $this->getScriptsForBottomQueue(); } /** * Get the javascript config vars to include on this page * * @return array Array of javascript config vars * @since 1.23 */ public function getJsConfigVars() { return $this->mJsConfigVars; } /** * Add one or more variables to be set in mw.config in JavaScript * * @param string|array $keys Key or array of key/value pairs * @param mixed $value [optional] Value of the configuration variable */ public function addJsConfigVars( $keys, $value = null ) { if ( is_array( $keys ) ) { foreach ( $keys as $key => $value ) { $this->mJsConfigVars[$key] = $value; } return; } $this->mJsConfigVars[$keys] = $value; } /** * Get an array containing the variables to be set in mw.config in JavaScript. * * Do not add things here which can be evaluated in ResourceLoaderStartUpModule * - in other words, page-independent/site-wide variables (without state). * You will only be adding bloat to the html page and causing page caches to * have to be purged on configuration changes. * @return array */ public function getJSVars() { global $wgContLang; $curRevisionId = 0; $articleId = 0; $canonicalSpecialPageName = false; # bug 21115 $title = $this->getTitle(); $ns = $title->getNamespace(); $canonicalNamespace = MWNamespace::exists( $ns ) ? MWNamespace::getCanonicalName( $ns ) : $title->getNsText(); $sk = $this->getSkin(); // Get the relevant title so that AJAX features can use the correct page name // when making API requests from certain special pages (bug 34972). $relevantTitle = $sk->getRelevantTitle(); $relevantUser = $sk->getRelevantUser(); if ( $ns == NS_SPECIAL ) { list( $canonicalSpecialPageName, /*...*/ ) = SpecialPageFactory::resolveAlias( $title->getDBkey() ); } elseif ( $this->canUseWikiPage() ) { $wikiPage = $this->getWikiPage(); $curRevisionId = $wikiPage->getLatest(); $articleId = $wikiPage->getId(); } $lang = $title->getPageLanguage(); // Pre-process information $separatorTransTable = $lang->separatorTransformTable(); $separatorTransTable = $separatorTransTable ? $separatorTransTable : array(); $compactSeparatorTransTable = array( implode( "\t", array_keys( $separatorTransTable ) ), implode( "\t", $separatorTransTable ), ); $digitTransTable = $lang->digitTransformTable(); $digitTransTable = $digitTransTable ? $digitTransTable : array(); $compactDigitTransTable = array( implode( "\t", array_keys( $digitTransTable ) ), implode( "\t", $digitTransTable ), ); $user = $this->getUser(); $vars = array( 'wgCanonicalNamespace' => $canonicalNamespace, 'wgCanonicalSpecialPageName' => $canonicalSpecialPageName, 'wgNamespaceNumber' => $title->getNamespace(), 'wgPageName' => $title->getPrefixedDBkey(), 'wgTitle' => $title->getText(), 'wgCurRevisionId' => $curRevisionId, 'wgRevisionId' => (int)$this->getRevisionId(), 'wgArticleId' => $articleId, 'wgIsArticle' => $this->isArticle(), 'wgIsRedirect' => $title->isRedirect(), 'wgAction' => Action::getActionName( $this->getContext() ), 'wgUserName' => $user->isAnon() ? null : $user->getName(), 'wgUserGroups' => $user->getEffectiveGroups(), 'wgCategories' => $this->getCategories(), 'wgBreakFrames' => $this->getFrameOptions() == 'DENY', 'wgPageContentLanguage' => $lang->getCode(), 'wgPageContentModel' => $title->getContentModel(), 'wgSeparatorTransformTable' => $compactSeparatorTransTable, 'wgDigitTransformTable' => $compactDigitTransTable, 'wgDefaultDateFormat' => $lang->getDefaultDateFormat(), 'wgMonthNames' => $lang->getMonthNamesArray(), 'wgMonthNamesShort' => $lang->getMonthAbbreviationsArray(), 'wgRelevantPageName' => $relevantTitle->getPrefixedDBkey(), 'wgRelevantArticleId' => $relevantTitle->getArticleId(), ); if ( $user->isLoggedIn() ) { $vars['wgUserId'] = $user->getId(); $vars['wgUserEditCount'] = $user->getEditCount(); $userReg = wfTimestampOrNull( TS_UNIX, $user->getRegistration() ); $vars['wgUserRegistration'] = $userReg !== null ? ( $userReg * 1000 ) : null; // Get the revision ID of the oldest new message on the user's talk // page. This can be used for constructing new message alerts on // the client side. $vars['wgUserNewMsgRevisionId'] = $user->getNewMessageRevisionId(); } if ( $wgContLang->hasVariants() ) { $vars['wgUserVariant'] = $wgContLang->getPreferredVariant(); } // Same test as SkinTemplate $vars['wgIsProbablyEditable'] = $title->quickUserCan( 'edit', $user ) && ( $title->exists() || $title->quickUserCan( 'create', $user ) ); foreach ( $title->getRestrictionTypes() as $type ) { $vars['wgRestriction' . ucfirst( $type )] = $title->getRestrictions( $type ); } if ( $title->isMainPage() ) { $vars['wgIsMainPage'] = true; } if ( $this->mRedirectedFrom ) { $vars['wgRedirectedFrom'] = $this->mRedirectedFrom->getPrefixedDBkey(); } if ( $relevantUser ) { $vars['wgRelevantUserName'] = $relevantUser->getName(); } // Allow extensions to add their custom variables to the mw.config map. // Use the 'ResourceLoaderGetConfigVars' hook if the variable is not // page-dependant but site-wide (without state). // Alternatively, you may want to use OutputPage->addJsConfigVars() instead. Hooks::run( 'MakeGlobalVariablesScript', array( &$vars, $this ) ); // Merge in variables from addJsConfigVars last return array_merge( $vars, $this->getJsConfigVars() ); } /** * To make it harder for someone to slip a user a fake * user-JavaScript or user-CSS preview, a random token * is associated with the login session. If it's not * passed back with the preview request, we won't render * the code. * * @return bool */ public function userCanPreview() { $request = $this->getRequest(); if ( $request->getVal( 'action' ) !== 'submit' || !$request->getCheck( 'wpPreview' ) || !$request->wasPosted() ) { return false; } $user = $this->getUser(); if ( !$user->matchEditToken( $request->getVal( 'wpEditToken' ) ) ) { return false; } $title = $this->getTitle(); if ( !$title->isJsSubpage() && !$title->isCssSubpage() ) { return false; } if ( !$title->isSubpageOf( $user->getUserPage() ) ) { // Don't execute another user's CSS or JS on preview (T85855) return false; } $errors = $title->getUserPermissionsErrors( 'edit', $user ); if ( count( $errors ) !== 0 ) { return false; } return true; } /** * @return array Array in format "link name or number => 'link html'". */ public function getHeadLinksArray() { global $wgVersion; $tags = array(); $config = $this->getConfig(); $canonicalUrl = $this->mCanonicalUrl; $tags['meta-generator'] = Html::element( 'meta', array( 'name' => 'generator', 'content' => "MediaWiki $wgVersion", ) ); if ( $config->get( 'ReferrerPolicy' ) !== false ) { $tags['meta-referrer'] = Html::element( 'meta', array( 'name' => 'referrer', 'content' => $config->get( 'ReferrerPolicy' ) ) ); } $p = "{$this->mIndexPolicy},{$this->mFollowPolicy}"; if ( $p !== 'index,follow' ) { // http://www.robotstxt.org/wc/meta-user.html // Only show if it's different from the default robots policy $tags['meta-robots'] = Html::element( 'meta', array( 'name' => 'robots', 'content' => $p, ) ); } foreach ( $this->mMetatags as $tag ) { if ( 0 == strcasecmp( 'http:', substr( $tag[0], 0, 5 ) ) ) { $a = 'http-equiv'; $tag[0] = substr( $tag[0], 5 ); } else { $a = 'name'; } $tagName = "meta-{$tag[0]}"; if ( isset( $tags[$tagName] ) ) { $tagName .= $tag[1]; } $tags[$tagName] = Html::element( 'meta', array( $a => $tag[0], 'content' => $tag[1] ) ); } foreach ( $this->mLinktags as $tag ) { $tags[] = Html::element( 'link', $tag ); } # Universal edit button if ( $config->get( 'UniversalEditButton' ) && $this->isArticleRelated() ) { $user = $this->getUser(); if ( $this->getTitle()->quickUserCan( 'edit', $user ) && ( $this->getTitle()->exists() || $this->getTitle()->quickUserCan( 'create', $user ) ) ) { // Original UniversalEditButton $msg = $this->msg( 'edit' )->text(); $tags['universal-edit-button'] = Html::element( 'link', array( 'rel' => 'alternate', 'type' => 'application/x-wiki', 'title' => $msg, 'href' => $this->getTitle()->getEditURL(), ) ); // Alternate edit link $tags['alternative-edit'] = Html::element( 'link', array( 'rel' => 'edit', 'title' => $msg, 'href' => $this->getTitle()->getEditURL(), ) ); } } # Generally the order of the favicon and apple-touch-icon links # should not matter, but Konqueror (3.5.9 at least) incorrectly # uses whichever one appears later in the HTML source. Make sure # apple-touch-icon is specified first to avoid this. if ( $config->get( 'AppleTouchIcon' ) !== false ) { $tags['apple-touch-icon'] = Html::element( 'link', array( 'rel' => 'apple-touch-icon', 'href' => $config->get( 'AppleTouchIcon' ) ) ); } if ( $config->get( 'Favicon' ) !== false ) { $tags['favicon'] = Html::element( 'link', array( 'rel' => 'shortcut icon', 'href' => $config->get( 'Favicon' ) ) ); } # OpenSearch description link $tags['opensearch'] = Html::element( 'link', array( 'rel' => 'search', 'type' => 'application/opensearchdescription+xml', 'href' => wfScript( 'opensearch_desc' ), 'title' => $this->msg( 'opensearch-desc' )->inContentLanguage()->text(), ) ); if ( $config->get( 'EnableAPI' ) ) { # Real Simple Discovery link, provides auto-discovery information # for the MediaWiki API (and potentially additional custom API # support such as WordPress or Twitter-compatible APIs for a # blogging extension, etc) $tags['rsd'] = Html::element( 'link', array( 'rel' => 'EditURI', 'type' => 'application/rsd+xml', // Output a protocol-relative URL here if $wgServer is protocol-relative. // Whether RSD accepts relative or protocol-relative URLs is completely // undocumented, though. 'href' => wfExpandUrl( wfAppendQuery( wfScript( 'api' ), array( 'action' => 'rsd' ) ), PROTO_RELATIVE ), ) ); } # Language variants if ( !$config->get( 'DisableLangConversion' ) ) { $lang = $this->getTitle()->getPageLanguage(); if ( $lang->hasVariants() ) { $variants = $lang->getVariants(); foreach ( $variants as $variant ) { $tags["variant-$variant"] = Html::element( 'link', array( 'rel' => 'alternate', 'hreflang' => wfBCP47( $variant ), 'href' => $this->getTitle()->getLocalURL( array( 'variant' => $variant ) ) ) ); } # x-default link per https://support.google.com/webmasters/answer/189077?hl=en $tags["variant-x-default"] = Html::element( 'link', array( 'rel' => 'alternate', 'hreflang' => 'x-default', 'href' => $this->getTitle()->getLocalURL() ) ); } } # Copyright if ( $this->copyrightUrl !== null ) { $copyright = $this->copyrightUrl; } else { $copyright = ''; if ( $config->get( 'RightsPage' ) ) { $copy = Title::newFromText( $config->get( 'RightsPage' ) ); if ( $copy ) { $copyright = $copy->getLocalURL(); } } if ( !$copyright && $config->get( 'RightsUrl' ) ) { $copyright = $config->get( 'RightsUrl' ); } } if ( $copyright ) { $tags['copyright'] = Html::element( 'link', array( 'rel' => 'copyright', 'href' => $copyright ) ); } # Feeds if ( $config->get( 'Feed' ) ) { $feedLinks = array(); foreach ( $this->getSyndicationLinks() as $format => $link ) { # Use the page name for the title. In principle, this could # lead to issues with having the same name for different feeds # corresponding to the same page, but we can't avoid that at # this low a level. $feedLinks[] = $this->feedLink( $format, $link, # Used messages: 'page-rss-feed' and 'page-atom-feed' (for an easier grep) $this->msg( "page-{$format}-feed", $this->getTitle()->getPrefixedText() )->text() ); } # Recent changes feed should appear on every page (except recentchanges, # that would be redundant). Put it after the per-page feed to avoid # changing existing behavior. It's still available, probably via a # menu in your browser. Some sites might have a different feed they'd # like to promote instead of the RC feed (maybe like a "Recent New Articles" # or "Breaking news" one). For this, we see if $wgOverrideSiteFeed is defined. # If so, use it instead. $sitename = $config->get( 'Sitename' ); if ( $config->get( 'OverrideSiteFeed' ) ) { foreach ( $config->get( 'OverrideSiteFeed' ) as $type => $feedUrl ) { // Note, this->feedLink escapes the url. $feedLinks[] = $this->feedLink( $type, $feedUrl, $this->msg( "site-{$type}-feed", $sitename )->text() ); } } elseif ( !$this->getTitle()->isSpecial( 'Recentchanges' ) ) { $rctitle = SpecialPage::getTitleFor( 'Recentchanges' ); foreach ( $config->get( 'AdvertisedFeedTypes' ) as $format ) { $feedLinks[] = $this->feedLink( $format, $rctitle->getLocalURL( array( 'feed' => $format ) ), # For grep: 'site-rss-feed', 'site-atom-feed' $this->msg( "site-{$format}-feed", $sitename )->text() ); } } # Allow extensions to change the list pf feeds. This hook is primarily for changing, # manipulating or removing existing feed tags. If you want to add new feeds, you should # use OutputPage::addFeedLink() instead. Hooks::run( 'AfterBuildFeedLinks', array( &$feedLinks ) ); $tags += $feedLinks; } # Canonical URL if ( $config->get( 'EnableCanonicalServerLink' ) ) { if ( $canonicalUrl !== false ) { $canonicalUrl = wfExpandUrl( $canonicalUrl, PROTO_CANONICAL ); } else { if ( $this->isArticleRelated() ) { // This affects all requests where "setArticleRelated" is true. This is // typically all requests that show content (query title, curid, oldid, diff), // and all wikipage actions (edit, delete, purge, info, history etc.). // It does not apply to File pages and Special pages. // 'history' and 'info' actions address page metadata rather than the page // content itself, so they may not be canonicalized to the view page url. // TODO: this ought to be better encapsulated in the Action class. $action = Action::getActionName( $this->getContext() ); if ( in_array( $action, array( 'history', 'info' ) ) ) { $query = "action={$action}"; } else { $query = ''; } $canonicalUrl = $this->getTitle()->getCanonicalURL( $query ); } else { $reqUrl = $this->getRequest()->getRequestURL(); $canonicalUrl = wfExpandUrl( $reqUrl, PROTO_CANONICAL ); } } } if ( $canonicalUrl !== false ) { $tags[] = Html::element( 'link', array( 'rel' => 'canonical', 'href' => $canonicalUrl ) ); } return $tags; } /** * @return string HTML tag links to be put in the header. * @deprecated since 1.24 Use OutputPage::headElement or if you have to, * OutputPage::getHeadLinksArray directly. */ public function getHeadLinks() { wfDeprecated( __METHOD__, '1.24' ); return implode( "\n", $this->getHeadLinksArray() ); } /** * Generate a "<link rel/>" for a feed. * * @param string $type Feed type * @param string $url URL to the feed * @param string $text Value of the "title" attribute * @return string HTML fragment */ private function feedLink( $type, $url, $text ) { return Html::element( 'link', array( 'rel' => 'alternate', 'type' => "application/$type+xml", 'title' => $text, 'href' => $url ) ); } /** * Add a local or specified stylesheet, with the given media options. * Internal use only. Use OutputPage::addModuleStyles() if possible. * * @param string $style URL to the file * @param string $media To specify a media type, 'screen', 'printable', 'handheld' or any. * @param string $condition For IE conditional comments, specifying an IE version * @param string $dir Set to 'rtl' or 'ltr' for direction-specific sheets */ public function addStyle( $style, $media = '', $condition = '', $dir = '' ) { $options = array(); if ( $media ) { $options['media'] = $media; } if ( $condition ) { $options['condition'] = $condition; } if ( $dir ) { $options['dir'] = $dir; } $this->styles[$style] = $options; } /** * Adds inline CSS styles * Internal use only. Use OutputPage::addModuleStyles() if possible. * * @param mixed $style_css Inline CSS * @param string $flip Set to 'flip' to flip the CSS if needed */ public function addInlineStyle( $style_css, $flip = 'noflip' ) { if ( $flip === 'flip' && $this->getLanguage()->isRTL() ) { # If wanted, and the interface is right-to-left, flip the CSS $style_css = CSSJanus::transform( $style_css, true, false ); } $this->mInlineStyles .= Html::inlineStyle( $style_css ) . "\n"; } /** * Build a set of "<link>" elements for the stylesheets specified in the $this->styles array. * These will be applied to various media & IE conditionals. * * @return string */ public function buildCssLinks() { global $wgContLang; $this->getSkin()->setupSkinUserCss( $this ); // Add ResourceLoader styles // Split the styles into these groups $styles = array( 'other' => array(), 'user' => array(), 'site' => array(), 'private' => array(), 'noscript' => array() ); $links = array(); $otherTags = array(); // Tags to append after the normal <link> tags $resourceLoader = $this->getResourceLoader(); $moduleStyles = $this->getModuleStyles(); // Per-site custom styles $moduleStyles[] = 'site'; $moduleStyles[] = 'noscript'; $moduleStyles[] = 'user.groups'; // Per-user custom styles if ( $this->getConfig()->get( 'AllowUserCss' ) && $this->getTitle()->isCssSubpage() && $this->userCanPreview() ) { // We're on a preview of a CSS subpage // Exclude this page from the user module in case it's in there (bug 26283) $link = $this->makeResourceLoaderLink( 'user', ResourceLoaderModule::TYPE_STYLES, array( 'excludepage' => $this->getTitle()->getPrefixedDBkey() ) ); $otherTags = array_merge( $otherTags, $link['html'] ); // Load the previewed CSS // If needed, Janus it first. This is user-supplied CSS, so it's // assumed to be right for the content language directionality. $previewedCSS = $this->getRequest()->getText( 'wpTextbox1' ); if ( $this->getLanguage()->getDir() !== $wgContLang->getDir() ) { $previewedCSS = CSSJanus::transform( $previewedCSS, true, false ); } $otherTags[] = Html::inlineStyle( $previewedCSS ) . "\n"; } else { // Load the user styles normally $moduleStyles[] = 'user'; } // Per-user preference styles $moduleStyles[] = 'user.cssprefs'; foreach ( $moduleStyles as $name ) { $module = $resourceLoader->getModule( $name ); if ( !$module ) { continue; } if ( $name === 'site' ) { // HACK: The site module shouldn't be fragmented with a cache group and // http request. But in order to ensure its styles are separated and after the // ResourceLoaderDynamicStyles marker, pretend it is in a group called 'site'. // The scripts remain ungrouped and rides the bottom queue. $styles['site'][] = $name; continue; } $group = $module->getGroup(); // Modules in groups other than the ones needing special treatment // (see $styles assignment) // will be placed in the "other" style category. $styles[isset( $styles[$group] ) ? $group : 'other'][] = $name; } // We want site, private and user styles to override dynamically added // styles from modules, but we want dynamically added styles to override // statically added styles from other modules. So the order has to be // other, dynamic, site, private, user. Add statically added styles for // other modules $links[] = $this->makeResourceLoaderLink( $styles['other'], ResourceLoaderModule::TYPE_STYLES ); // Add normal styles added through addStyle()/addInlineStyle() here $links[] = implode( "\n", $this->buildCssLinksArray() ) . $this->mInlineStyles; // Add marker tag to mark the place where the client-side // loader should inject dynamic styles // We use a <meta> tag with a made-up name for this because that's valid HTML $links[] = Html::element( 'meta', array( 'name' => 'ResourceLoaderDynamicStyles', 'content' => '' ) ); // Add site-specific and user-specific styles // 'private' at present only contains user.options, so put that before 'user' // Any future private modules will likely have a similar user-specific character foreach ( array( 'site', 'noscript', 'private', 'user' ) as $group ) { $links[] = $this->makeResourceLoaderLink( $styles[$group], ResourceLoaderModule::TYPE_STYLES ); } // Add stuff in $otherTags (previewed user CSS if applicable) return self::getHtmlFromLoaderLinks( $links ) . implode( '', $otherTags ); } /** * @return array */ public function buildCssLinksArray() { $links = array(); // Add any extension CSS foreach ( $this->mExtStyles as $url ) { $this->addStyle( $url ); } $this->mExtStyles = array(); foreach ( $this->styles as $file => $options ) { $link = $this->styleLink( $file, $options ); if ( $link ) { $links[$file] = $link; } } return $links; } /** * Generate \<link\> tags for stylesheets * * @param string $style URL to the file * @param array $options Option, can contain 'condition', 'dir', 'media' keys * @return string HTML fragment */ protected function styleLink( $style, array $options ) { if ( isset( $options['dir'] ) ) { if ( $this->getLanguage()->getDir() != $options['dir'] ) { return ''; } } if ( isset( $options['media'] ) ) { $media = self::transformCssMedia( $options['media'] ); if ( is_null( $media ) ) { return ''; } } else { $media = 'all'; } if ( substr( $style, 0, 1 ) == '/' || substr( $style, 0, 5 ) == 'http:' || substr( $style, 0, 6 ) == 'https:' ) { $url = $style; } else { $config = $this->getConfig(); $url = $config->get( 'StylePath' ) . '/' . $style . '?' . $config->get( 'StyleVersion' ); } $link = Html::linkedStyle( $url, $media ); if ( isset( $options['condition'] ) ) { $condition = htmlspecialchars( $options['condition'] ); $link = "<!--[if $condition]>$link<![endif]-->"; } return $link; } /** * Transform path to web-accessible static resource. * * This is used to add a validation hash as query string. * This aids various behaviors: * * - Put long Cache-Control max-age headers on responses for improved * cache performance. * - Get the correct version of a file as expected by the current page. * - Instantly get the updated version of a file after deployment. * * Avoid using this for urls included in HTML as otherwise clients may get different * versions of a resource when navigating the site depending on when the page was cached. * If changes to the url propagate, this is not a problem (e.g. if the url is in * an external stylesheet). * * @since 1.27 * @param Config $config * @param string $path Path-absolute URL to file (from document root, must start with "/") * @return string URL */ public static function transformResourcePath( Config $config, $path ) { global $IP; $remotePath = $config->get( 'ResourceBasePath' ); if ( strpos( $path, $remotePath ) !== 0 ) { // Path is outside wgResourceBasePath, ignore. return $path; } $path = RelPath\getRelativePath( $path, $remotePath ); return self::transformFilePath( $remotePath, $IP, $path ); } /** * Utility method for transformResourceFilePath(). * * Caller is responsible for ensuring the file exists. Emits a PHP warning otherwise. * * @since 1.27 * @param string $remotePath URL path that points to $localPath * @param string $localPath File directory exposed at $remotePath * @param string $file Path to target file relative to $localPath * @return string URL */ public static function transformFilePath( $remotePath, $localPath, $file ) { $hash = md5_file( "$localPath/$file" ); if ( $hash === false ) { wfLogWarning( __METHOD__ . ": Failed to hash $localPath/$file" ); $hash = ''; } return "$remotePath/$file?" . substr( $hash, 0, 5 ); } /** * Transform "media" attribute based on request parameters * * @param string $media Current value of the "media" attribute * @return string Modified value of the "media" attribute, or null to skip * this stylesheet */ public static function transformCssMedia( $media ) { global $wgRequest; // http://www.w3.org/TR/css3-mediaqueries/#syntax $screenMediaQueryRegex = '/^(?:only\s+)?screen\b/i'; // Switch in on-screen display for media testing $switches = array( 'printable' => 'print', 'handheld' => 'handheld', ); foreach ( $switches as $switch => $targetMedia ) { if ( $wgRequest->getBool( $switch ) ) { if ( $media == $targetMedia ) { $media = ''; } elseif ( preg_match( $screenMediaQueryRegex, $media ) === 1 ) { /* This regex will not attempt to understand a comma-separated media_query_list * * Example supported values for $media: * 'screen', 'only screen', 'screen and (min-width: 982px)' ), * Example NOT supported value for $media: * '3d-glasses, screen, print and resolution > 90dpi' * * If it's a print request, we never want any kind of screen stylesheets * If it's a handheld request (currently the only other choice with a switch), * we don't want simple 'screen' but we might want screen queries that * have a max-width or something, so we'll pass all others on and let the * client do the query. */ if ( $targetMedia == 'print' || $media == 'screen' ) { return null; } } } } return $media; } /** * Add a wikitext-formatted message to the output. * This is equivalent to: * * $wgOut->addWikiText( wfMessage( ... )->plain() ) */ public function addWikiMsg( /*...*/ ) { $args = func_get_args(); $name = array_shift( $args ); $this->addWikiMsgArray( $name, $args ); } /** * Add a wikitext-formatted message to the output. * Like addWikiMsg() except the parameters are taken as an array * instead of a variable argument list. * * @param string $name * @param array $args */ public function addWikiMsgArray( $name, $args ) { $this->addHTML( $this->msg( $name, $args )->parseAsBlock() ); } /** * This function takes a number of message/argument specifications, wraps them in * some overall structure, and then parses the result and adds it to the output. * * In the $wrap, $1 is replaced with the first message, $2 with the second, * and so on. The subsequent arguments may be either * 1) strings, in which case they are message names, or * 2) arrays, in which case, within each array, the first element is the message * name, and subsequent elements are the parameters to that message. * * Don't use this for messages that are not in the user's interface language. * * For example: * * $wgOut->wrapWikiMsg( "<div class='error'>\n$1\n</div>", 'some-error' ); * * Is equivalent to: * * $wgOut->addWikiText( "<div class='error'>\n" * . wfMessage( 'some-error' )->plain() . "\n</div>" ); * * The newline after the opening div is needed in some wikitext. See bug 19226. * * @param string $wrap */ public function wrapWikiMsg( $wrap /*, ...*/ ) { $msgSpecs = func_get_args(); array_shift( $msgSpecs ); $msgSpecs = array_values( $msgSpecs ); $s = $wrap; foreach ( $msgSpecs as $n => $spec ) { if ( is_array( $spec ) ) { $args = $spec; $name = array_shift( $args ); if ( isset( $args['options'] ) ) { unset( $args['options'] ); wfDeprecated( 'Adding "options" to ' . __METHOD__ . ' is no longer supported', '1.20' ); } } else { $args = array(); $name = $spec; } $s = str_replace( '$' . ( $n + 1 ), $this->msg( $name, $args )->plain(), $s ); } $this->addWikiText( $s ); } /** * Enables/disables TOC, doesn't override __NOTOC__ * @param bool $flag * @since 1.22 */ public function enableTOC( $flag = true ) { $this->mEnableTOC = $flag; } /** * @return bool * @since 1.22 */ public function isTOCEnabled() { return $this->mEnableTOC; } /** * Enables/disables section edit links, doesn't override __NOEDITSECTION__ * @param bool $flag * @since 1.23 */ public function enableSectionEditLinks( $flag = true ) { $this->mEnableSectionEditLinks = $flag; } /** * @return bool * @since 1.23 */ public function sectionEditLinksEnabled() { return $this->mEnableSectionEditLinks; } /** * Helper function to setup the PHP implementation of OOUI to use in this request. * * @since 1.26 * @param String $skinName The Skin name to determine the correct OOUI theme * @param String $dir Language direction */ public static function setupOOUI( $skinName = '', $dir = 'ltr' ) { $themes = ExtensionRegistry::getInstance()->getAttribute( 'SkinOOUIThemes' ); // Make keys (skin names) lowercase for case-insensitive matching. $themes = array_change_key_case( $themes, CASE_LOWER ); $theme = isset( $themes[$skinName] ) ? $themes[$skinName] : 'MediaWiki'; // For example, 'OOUI\MediaWikiTheme'. $themeClass = "OOUI\\{$theme}Theme"; OOUI\Theme::setSingleton( new $themeClass() ); OOUI\Element::setDefaultDir( $dir ); } /** * Add ResourceLoader module styles for OOUI and set up the PHP implementation of it for use with * MediaWiki and this OutputPage instance. * * @since 1.25 */ public function enableOOUI() { self::setupOOUI( strtolower( $this->getSkin()->getSkinName() ), $this->getLanguage()->getDir() ); $this->addModuleStyles( array( 'oojs-ui-core.styles', 'oojs-ui.styles.icons', 'oojs-ui.styles.indicators', 'oojs-ui.styles.textures', 'mediawiki.widgets.styles', ) ); // Used by 'skipFunction' of the four 'oojs-ui.styles.*' modules. Please don't treat this as a // public API or you'll be severely disappointed when T87871 is fixed and it disappears. $this->addMeta( 'X-OOUI-PHP', '1' ); } } diff --git a/includes/Title.php b/includes/Title.php index e3ceaee96fa..806def22525 100644 --- a/includes/Title.php +++ b/includes/Title.php @@ -1,4786 +1,4813 @@ <?php /** * Representation of a title within %MediaWiki. * * See title.txt * * This program is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation; either version 2 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License along * with this program; if not, write to the Free Software Foundation, Inc., * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. * http://www.gnu.org/copyleft/gpl.html * * @file */ /** * Represents a title within MediaWiki. * Optionally may contain an interwiki designation or namespace. * @note This class can fetch various kinds of data from the database; * however, it does so inefficiently. * @note Consider using a TitleValue object instead. TitleValue is more lightweight * and does not rely on global state or the database. */ class Title implements LinkTarget { /** @var HashBagOStuff */ static private $titleCache = null; /** * Title::newFromText maintains a cache to avoid expensive re-normalization of * commonly used titles. On a batch operation this can become a memory leak * if not bounded. After hitting this many titles reset the cache. */ const CACHE_MAX = 1000; /** * Used to be GAID_FOR_UPDATE define. Used with getArticleID() and friends * to use the master DB */ const GAID_FOR_UPDATE = 1; /** * @name Private member variables * Please use the accessor functions instead. * @private */ // @{ /** @var string Text form (spaces not underscores) of the main part */ public $mTextform = ''; /** @var string URL-encoded form of the main part */ public $mUrlform = ''; /** @var string Main part with underscores */ public $mDbkeyform = ''; /** @var string Database key with the initial letter in the case specified by the user */ protected $mUserCaseDBKey; /** @var int Namespace index, i.e. one of the NS_xxxx constants */ public $mNamespace = NS_MAIN; /** @var string Interwiki prefix */ public $mInterwiki = ''; /** @var bool Was this Title created from a string with a local interwiki prefix? */ private $mLocalInterwiki = false; /** @var string Title fragment (i.e. the bit after the #) */ public $mFragment = ''; /** @var int Article ID, fetched from the link cache on demand */ public $mArticleID = -1; /** @var bool|int ID of most recent revision */ protected $mLatestID = false; /** * @var bool|string ID of the page's content model, i.e. one of the * CONTENT_MODEL_XXX constants */ public $mContentModel = false; /** @var int Estimated number of revisions; null of not loaded */ private $mEstimateRevisions; /** @var array Array of groups allowed to edit this article */ public $mRestrictions = array(); /** @var string|bool */ protected $mOldRestrictions = false; /** @var bool Cascade restrictions on this page to included templates and images? */ public $mCascadeRestriction; /** Caching the results of getCascadeProtectionSources */ public $mCascadingRestrictions; /** @var array When do the restrictions on this page expire? */ protected $mRestrictionsExpiry = array(); /** @var bool Are cascading restrictions in effect on this page? */ protected $mHasCascadingRestrictions; /** @var array Where are the cascading restrictions coming from on this page? */ public $mCascadeSources; /** @var bool Boolean for initialisation on demand */ public $mRestrictionsLoaded = false; /** @var string Text form including namespace/interwiki, initialised on demand */ protected $mPrefixedText = null; /** @var mixed Cached value for getTitleProtection (create protection) */ public $mTitleProtection; /** * @var int Namespace index when there is no namespace. Don't change the * following default, NS_MAIN is hardcoded in several places. See bug 696. * Zero except in {{transclusion}} tags. */ public $mDefaultNamespace = NS_MAIN; /** @var int The page length, 0 for special pages */ protected $mLength = -1; /** @var null Is the article at this title a redirect? */ public $mRedirect = null; /** @var array Associative array of user ID -> timestamp/false */ private $mNotificationTimestamp = array(); /** @var bool Whether a page has any subpages */ private $mHasSubpages; /** @var bool The (string) language code of the page's language and content code. */ private $mPageLanguage = false; - /** @var string The page language code from the database */ - private $mDbPageLanguage = null; + /** @var string|boolean|null The page language code from the database, null if not saved in + * the database or false if not loaded, yet. */ + private $mDbPageLanguage = false; /** @var TitleValue A corresponding TitleValue object */ private $mTitleValue = null; /** @var bool Would deleting this page be a big deletion? */ private $mIsBigDeletion = null; // @} /** * B/C kludge: provide a TitleParser for use by Title. * Ideally, Title would have no methods that need this. * Avoid usage of this singleton by using TitleValue * and the associated services when possible. * * @return MediaWikiTitleCodec */ private static function getMediaWikiTitleCodec() { global $wgContLang, $wgLocalInterwikis; static $titleCodec = null; static $titleCodecFingerprint = null; // $wgContLang and $wgLocalInterwikis may change (especially while testing), // make sure we are using the right one. To detect changes over the course // of a request, we remember a fingerprint of the config used to create the // codec singleton, and re-create it if the fingerprint doesn't match. $fingerprint = spl_object_hash( $wgContLang ) . '|' . join( '+', $wgLocalInterwikis ); if ( $fingerprint !== $titleCodecFingerprint ) { $titleCodec = null; } if ( !$titleCodec ) { $titleCodec = new MediaWikiTitleCodec( $wgContLang, GenderCache::singleton(), $wgLocalInterwikis ); $titleCodecFingerprint = $fingerprint; } return $titleCodec; } /** * B/C kludge: provide a TitleParser for use by Title. * Ideally, Title would have no methods that need this. * Avoid usage of this singleton by using TitleValue * and the associated services when possible. * * @return TitleFormatter */ private static function getTitleFormatter() { // NOTE: we know that getMediaWikiTitleCodec() returns a MediaWikiTitleCodec, // which implements TitleFormatter. return self::getMediaWikiTitleCodec(); } function __construct() { } /** * Create a new Title from a prefixed DB key * * @param string $key The database key, which has underscores * instead of spaces, possibly including namespace and * interwiki prefixes * @return Title|null Title, or null on an error */ public static function newFromDBkey( $key ) { $t = new Title(); $t->mDbkeyform = $key; try { $t->secureAndSplit(); return $t; } catch ( MalformedTitleException $ex ) { return null; } } /** * Create a new Title from a TitleValue * * @param TitleValue $titleValue Assumed to be safe. * * @return Title */ public static function newFromTitleValue( TitleValue $titleValue ) { return self::newFromLinkTarget( $titleValue ); } /** * Create a new Title from a LinkTarget * * @param LinkTarget $linkTarget Assumed to be safe. * * @return Title */ public static function newFromLinkTarget( LinkTarget $linkTarget ) { return self::makeTitle( $linkTarget->getNamespace(), $linkTarget->getText(), $linkTarget->getFragment() ); } /** * Create a new Title from text, such as what one would find in a link. De- * codes any HTML entities in the text. * * @param string|int|null $text The link text; spaces, prefixes, and an * initial ':' indicating the main namespace are accepted. * @param int $defaultNamespace The namespace to use if none is specified * by a prefix. If you want to force a specific namespace even if * $text might begin with a namespace prefix, use makeTitle() or * makeTitleSafe(). * @throws InvalidArgumentException * @return Title|null Title or null on an error. */ public static function newFromText( $text, $defaultNamespace = NS_MAIN ) { if ( is_object( $text ) ) { throw new InvalidArgumentException( '$text must be a string.' ); } // DWIM: Integers can be passed in here when page titles are used as array keys. if ( $text !== null && !is_string( $text ) && !is_int( $text ) ) { wfDebugLog( 'T76305', wfGetAllCallers( 5 ) ); return null; } if ( $text === null ) { return null; } try { return Title::newFromTextThrow( strval( $text ), $defaultNamespace ); } catch ( MalformedTitleException $ex ) { return null; } } /** * Like Title::newFromText(), but throws MalformedTitleException when the title is invalid, * rather than returning null. * * The exception subclasses encode detailed information about why the title is invalid. * * @see Title::newFromText * * @since 1.25 * @param string $text Title text to check * @param int $defaultNamespace * @throws MalformedTitleException If the title is invalid * @return Title */ public static function newFromTextThrow( $text, $defaultNamespace = NS_MAIN ) { if ( is_object( $text ) ) { throw new MWException( '$text must be a string, given an object' ); } $titleCache = self::getTitleCache(); // Wiki pages often contain multiple links to the same page. // Title normalization and parsing can become expensive on pages with many // links, so we can save a little time by caching them. // In theory these are value objects and won't get changed... if ( $defaultNamespace == NS_MAIN ) { $t = $titleCache->get( $text ); if ( $t ) { return $t; } } // Convert things like é ā or 〗 into normalized (bug 14952) text $filteredText = Sanitizer::decodeCharReferencesAndNormalize( $text ); $t = new Title(); $t->mDbkeyform = strtr( $filteredText, ' ', '_' ); $t->mDefaultNamespace = intval( $defaultNamespace ); $t->secureAndSplit(); if ( $defaultNamespace == NS_MAIN ) { $titleCache->set( $text, $t ); } return $t; } /** * THIS IS NOT THE FUNCTION YOU WANT. Use Title::newFromText(). * * Example of wrong and broken code: * $title = Title::newFromURL( $wgRequest->getVal( 'title' ) ); * * Example of right code: * $title = Title::newFromText( $wgRequest->getVal( 'title' ) ); * * Create a new Title from URL-encoded text. Ensures that * the given title's length does not exceed the maximum. * * @param string $url The title, as might be taken from a URL * @return Title|null The new object, or null on an error */ public static function newFromURL( $url ) { $t = new Title(); # For compatibility with old buggy URLs. "+" is usually not valid in titles, # but some URLs used it as a space replacement and they still come # from some external search tools. if ( strpos( self::legalChars(), '+' ) === false ) { $url = strtr( $url, '+', ' ' ); } $t->mDbkeyform = strtr( $url, ' ', '_' ); try { $t->secureAndSplit(); return $t; } catch ( MalformedTitleException $ex ) { return null; } } /** * @return HashBagOStuff */ private static function getTitleCache() { if ( self::$titleCache == null ) { self::$titleCache = new HashBagOStuff( array( 'maxKeys' => self::CACHE_MAX ) ); } return self::$titleCache; } /** * Returns a list of fields that are to be selected for initializing Title * objects or LinkCache entries. Uses $wgContentHandlerUseDB to determine * whether to include page_content_model. * * @return array */ protected static function getSelectFields() { - global $wgContentHandlerUseDB; + global $wgContentHandlerUseDB, $wgPageLanguageUseDB; $fields = array( 'page_namespace', 'page_title', 'page_id', 'page_len', 'page_is_redirect', 'page_latest', ); if ( $wgContentHandlerUseDB ) { $fields[] = 'page_content_model'; } + if ( $wgPageLanguageUseDB ) { + $fields[] = 'page_lang'; + } + return $fields; } /** * Create a new Title from an article ID * * @param int $id The page_id corresponding to the Title to create * @param int $flags Use Title::GAID_FOR_UPDATE to use master * @return Title|null The new object, or null on an error */ public static function newFromID( $id, $flags = 0 ) { $db = ( $flags & self::GAID_FOR_UPDATE ) ? wfGetDB( DB_MASTER ) : wfGetDB( DB_SLAVE ); $row = $db->selectRow( 'page', self::getSelectFields(), array( 'page_id' => $id ), __METHOD__ ); if ( $row !== false ) { $title = Title::newFromRow( $row ); } else { $title = null; } return $title; } /** * Make an array of titles from an array of IDs * * @param int[] $ids Array of IDs * @return Title[] Array of Titles */ public static function newFromIDs( $ids ) { if ( !count( $ids ) ) { return array(); } $dbr = wfGetDB( DB_SLAVE ); $res = $dbr->select( 'page', self::getSelectFields(), array( 'page_id' => $ids ), __METHOD__ ); $titles = array(); foreach ( $res as $row ) { $titles[] = Title::newFromRow( $row ); } return $titles; } /** * Make a Title object from a DB row * * @param stdClass $row Object database row (needs at least page_title,page_namespace) * @return Title Corresponding Title */ public static function newFromRow( $row ) { $t = self::makeTitle( $row->page_namespace, $row->page_title ); $t->loadFromRow( $row ); return $t; } /** * Load Title object fields from a DB row. * If false is given, the title will be treated as non-existing. * * @param stdClass|bool $row Database row */ public function loadFromRow( $row ) { if ( $row ) { // page found if ( isset( $row->page_id ) ) { $this->mArticleID = (int)$row->page_id; } if ( isset( $row->page_len ) ) { $this->mLength = (int)$row->page_len; } if ( isset( $row->page_is_redirect ) ) { $this->mRedirect = (bool)$row->page_is_redirect; } if ( isset( $row->page_latest ) ) { $this->mLatestID = (int)$row->page_latest; } if ( isset( $row->page_content_model ) ) { $this->mContentModel = strval( $row->page_content_model ); } else { $this->mContentModel = false; # initialized lazily in getContentModel() } if ( isset( $row->page_lang ) ) { $this->mDbPageLanguage = (string)$row->page_lang; } if ( isset( $row->page_restrictions ) ) { $this->mOldRestrictions = $row->page_restrictions; } } else { // page not found $this->mArticleID = 0; $this->mLength = 0; $this->mRedirect = false; $this->mLatestID = 0; $this->mContentModel = false; # initialized lazily in getContentModel() } } /** * Create a new Title from a namespace index and a DB key. * It's assumed that $ns and $title are *valid*, for instance when * they came directly from the database or a special page name. * For convenience, spaces are converted to underscores so that * eg user_text fields can be used directly. * * @param int $ns The namespace of the article * @param string $title The unprefixed database key form * @param string $fragment The link fragment (after the "#") * @param string $interwiki The interwiki prefix * @return Title The new object */ public static function &makeTitle( $ns, $title, $fragment = '', $interwiki = '' ) { $t = new Title(); $t->mInterwiki = $interwiki; $t->mFragment = $fragment; $t->mNamespace = $ns = intval( $ns ); $t->mDbkeyform = strtr( $title, ' ', '_' ); $t->mArticleID = ( $ns >= 0 ) ? -1 : 0; $t->mUrlform = wfUrlencode( $t->mDbkeyform ); $t->mTextform = strtr( $title, '_', ' ' ); $t->mContentModel = false; # initialized lazily in getContentModel() return $t; } /** * Create a new Title from a namespace index and a DB key. * The parameters will be checked for validity, which is a bit slower * than makeTitle() but safer for user-provided data. * * @param int $ns The namespace of the article * @param string $title Database key form * @param string $fragment The link fragment (after the "#") * @param string $interwiki Interwiki prefix * @return Title|null The new object, or null on an error */ public static function makeTitleSafe( $ns, $title, $fragment = '', $interwiki = '' ) { if ( !MWNamespace::exists( $ns ) ) { return null; } $t = new Title(); $t->mDbkeyform = Title::makeName( $ns, $title, $fragment, $interwiki, true ); try { $t->secureAndSplit(); return $t; } catch ( MalformedTitleException $ex ) { return null; } } /** * Create a new Title for the Main Page * * @return Title The new object */ public static function newMainPage() { $title = Title::newFromText( wfMessage( 'mainpage' )->inContentLanguage()->text() ); // Don't give fatal errors if the message is broken if ( !$title ) { $title = Title::newFromText( 'Main Page' ); } return $title; } /** * Extract a redirect destination from a string and return the * Title, or null if the text doesn't contain a valid redirect * This will only return the very next target, useful for * the redirect table and other checks that don't need full recursion * * @param string $text Text with possible redirect * @return Title The corresponding Title * @deprecated since 1.21, use Content::getRedirectTarget instead. */ public static function newFromRedirect( $text ) { ContentHandler::deprecated( __METHOD__, '1.21' ); $content = ContentHandler::makeContent( $text, null, CONTENT_MODEL_WIKITEXT ); return $content->getRedirectTarget(); } /** * Extract a redirect destination from a string and return the * Title, or null if the text doesn't contain a valid redirect * This will recurse down $wgMaxRedirects times or until a non-redirect target is hit * in order to provide (hopefully) the Title of the final destination instead of another redirect * * @param string $text Text with possible redirect * @return Title * @deprecated since 1.21, use Content::getUltimateRedirectTarget instead. */ public static function newFromRedirectRecurse( $text ) { ContentHandler::deprecated( __METHOD__, '1.21' ); $content = ContentHandler::makeContent( $text, null, CONTENT_MODEL_WIKITEXT ); return $content->getUltimateRedirectTarget(); } /** * Extract a redirect destination from a string and return an * array of Titles, or null if the text doesn't contain a valid redirect * The last element in the array is the final destination after all redirects * have been resolved (up to $wgMaxRedirects times) * * @param string $text Text with possible redirect * @return Title[] Array of Titles, with the destination last * @deprecated since 1.21, use Content::getRedirectChain instead. */ public static function newFromRedirectArray( $text ) { ContentHandler::deprecated( __METHOD__, '1.21' ); $content = ContentHandler::makeContent( $text, null, CONTENT_MODEL_WIKITEXT ); return $content->getRedirectChain(); } /** * Get the prefixed DB key associated with an ID * * @param int $id The page_id of the article * @return Title|null An object representing the article, or null if no such article was found */ public static function nameOf( $id ) { $dbr = wfGetDB( DB_SLAVE ); $s = $dbr->selectRow( 'page', array( 'page_namespace', 'page_title' ), array( 'page_id' => $id ), __METHOD__ ); if ( $s === false ) { return null; } $n = self::makeName( $s->page_namespace, $s->page_title ); return $n; } /** * Get a regex character class describing the legal characters in a link * * @return string The list of characters, not delimited */ public static function legalChars() { global $wgLegalTitleChars; return $wgLegalTitleChars; } /** * Returns a simple regex that will match on characters and sequences invalid in titles. * Note that this doesn't pick up many things that could be wrong with titles, but that * replacing this regex with something valid will make many titles valid. * * @deprecated since 1.25, use MediaWikiTitleCodec::getTitleInvalidRegex() instead * * @return string Regex string */ static function getTitleInvalidRegex() { wfDeprecated( __METHOD__, '1.25' ); return MediaWikiTitleCodec::getTitleInvalidRegex(); } /** * Utility method for converting a character sequence from bytes to Unicode. * * Primary usecase being converting $wgLegalTitleChars to a sequence usable in * javascript, as PHP uses UTF-8 bytes where javascript uses Unicode code units. * * @param string $byteClass * @return string */ public static function convertByteClassToUnicodeClass( $byteClass ) { $length = strlen( $byteClass ); // Input token queue $x0 = $x1 = $x2 = ''; // Decoded queue $d0 = $d1 = $d2 = ''; // Decoded integer codepoints $ord0 = $ord1 = $ord2 = 0; // Re-encoded queue $r0 = $r1 = $r2 = ''; // Output $out = ''; // Flags $allowUnicode = false; for ( $pos = 0; $pos < $length; $pos++ ) { // Shift the queues down $x2 = $x1; $x1 = $x0; $d2 = $d1; $d1 = $d0; $ord2 = $ord1; $ord1 = $ord0; $r2 = $r1; $r1 = $r0; // Load the current input token and decoded values $inChar = $byteClass[$pos]; if ( $inChar == '\\' ) { if ( preg_match( '/x([0-9a-fA-F]{2})/A', $byteClass, $m, 0, $pos + 1 ) ) { $x0 = $inChar . $m[0]; $d0 = chr( hexdec( $m[1] ) ); $pos += strlen( $m[0] ); } elseif ( preg_match( '/[0-7]{3}/A', $byteClass, $m, 0, $pos + 1 ) ) { $x0 = $inChar . $m[0]; $d0 = chr( octdec( $m[0] ) ); $pos += strlen( $m[0] ); } elseif ( $pos + 1 >= $length ) { $x0 = $d0 = '\\'; } else { $d0 = $byteClass[$pos + 1]; $x0 = $inChar . $d0; $pos += 1; } } else { $x0 = $d0 = $inChar; } $ord0 = ord( $d0 ); // Load the current re-encoded value if ( $ord0 < 32 || $ord0 == 0x7f ) { $r0 = sprintf( '\x%02x', $ord0 ); } elseif ( $ord0 >= 0x80 ) { // Allow unicode if a single high-bit character appears $r0 = sprintf( '\x%02x', $ord0 ); $allowUnicode = true; } elseif ( strpos( '-\\[]^', $d0 ) !== false ) { $r0 = '\\' . $d0; } else { $r0 = $d0; } // Do the output if ( $x0 !== '' && $x1 === '-' && $x2 !== '' ) { // Range if ( $ord2 > $ord0 ) { // Empty range } elseif ( $ord0 >= 0x80 ) { // Unicode range $allowUnicode = true; if ( $ord2 < 0x80 ) { // Keep the non-unicode section of the range $out .= "$r2-\\x7F"; } } else { // Normal range $out .= "$r2-$r0"; } // Reset state to the initial value $x0 = $x1 = $d0 = $d1 = $r0 = $r1 = ''; } elseif ( $ord2 < 0x80 ) { // ASCII character $out .= $r2; } } if ( $ord1 < 0x80 ) { $out .= $r1; } if ( $ord0 < 0x80 ) { $out .= $r0; } if ( $allowUnicode ) { $out .= '\u0080-\uFFFF'; } return $out; } /** * Make a prefixed DB key from a DB key and a namespace index * * @param int $ns Numerical representation of the namespace * @param string $title The DB key form the title * @param string $fragment The link fragment (after the "#") * @param string $interwiki The interwiki prefix * @param bool $canonicalNamespace If true, use the canonical name for * $ns instead of the localized version. * @return string The prefixed form of the title */ public static function makeName( $ns, $title, $fragment = '', $interwiki = '', $canonicalNamespace = false ) { global $wgContLang; if ( $canonicalNamespace ) { $namespace = MWNamespace::getCanonicalName( $ns ); } else { $namespace = $wgContLang->getNsText( $ns ); } $name = $namespace == '' ? $title : "$namespace:$title"; if ( strval( $interwiki ) != '' ) { $name = "$interwiki:$name"; } if ( strval( $fragment ) != '' ) { $name .= '#' . $fragment; } return $name; } /** * Escape a text fragment, say from a link, for a URL * * @param string $fragment Containing a URL or link fragment (after the "#") * @return string Escaped string */ static function escapeFragmentForURL( $fragment ) { # Note that we don't urlencode the fragment. urlencoded Unicode # fragments appear not to work in IE (at least up to 7) or in at least # one version of Opera 9.x. The W3C validator, for one, doesn't seem # to care if they aren't encoded. return Sanitizer::escapeId( $fragment, 'noninitial' ); } /** * Callback for usort() to do title sorts by (namespace, title) * * @param Title $a * @param Title $b * * @return int Result of string comparison, or namespace comparison */ public static function compare( $a, $b ) { if ( $a->getNamespace() == $b->getNamespace() ) { return strcmp( $a->getText(), $b->getText() ); } else { return $a->getNamespace() - $b->getNamespace(); } } /** * Determine whether the object refers to a page within * this project (either this wiki or a wiki with a local * interwiki, see https://www.mediawiki.org/wiki/Manual:Interwiki_table#iw_local ) * * @return bool True if this is an in-project interwiki link or a wikilink, false otherwise */ public function isLocal() { if ( $this->isExternal() ) { $iw = Interwiki::fetch( $this->mInterwiki ); if ( $iw ) { return $iw->isLocal(); } } return true; } /** * Is this Title interwiki? * * @return bool */ public function isExternal() { return $this->mInterwiki !== ''; } /** * Get the interwiki prefix * * Use Title::isExternal to check if a interwiki is set * * @return string Interwiki prefix */ public function getInterwiki() { return $this->mInterwiki; } /** * Was this a local interwiki link? * * @return bool */ public function wasLocalInterwiki() { return $this->mLocalInterwiki; } /** * Determine whether the object refers to a page within * this project and is transcludable. * * @return bool True if this is transcludable */ public function isTrans() { if ( !$this->isExternal() ) { return false; } return Interwiki::fetch( $this->mInterwiki )->isTranscludable(); } /** * Returns the DB name of the distant wiki which owns the object. * * @return string The DB name */ public function getTransWikiID() { if ( !$this->isExternal() ) { return false; } return Interwiki::fetch( $this->mInterwiki )->getWikiID(); } /** * Get a TitleValue object representing this Title. * * @note Not all valid Titles have a corresponding valid TitleValue * (e.g. TitleValues cannot represent page-local links that have a * fragment but no title text). * * @return TitleValue|null */ public function getTitleValue() { if ( $this->mTitleValue === null ) { try { $this->mTitleValue = new TitleValue( $this->getNamespace(), $this->getDBkey(), $this->getFragment() ); } catch ( InvalidArgumentException $ex ) { wfDebug( __METHOD__ . ': Can\'t create a TitleValue for [[' . $this->getPrefixedText() . ']]: ' . $ex->getMessage() . "\n" ); } } return $this->mTitleValue; } /** * Get the text form (spaces not underscores) of the main part * * @return string Main part of the title */ public function getText() { return $this->mTextform; } /** * Get the URL-encoded form of the main part * * @return string Main part of the title, URL-encoded */ public function getPartialURL() { return $this->mUrlform; } /** * Get the main part with underscores * * @return string Main part of the title, with underscores */ public function getDBkey() { return $this->mDbkeyform; } /** * Get the DB key with the initial letter case as specified by the user * * @return string DB key */ function getUserCaseDBKey() { if ( !is_null( $this->mUserCaseDBKey ) ) { return $this->mUserCaseDBKey; } else { // If created via makeTitle(), $this->mUserCaseDBKey is not set. return $this->mDbkeyform; } } /** * Get the namespace index, i.e. one of the NS_xxxx constants. * * @return int Namespace index */ public function getNamespace() { return $this->mNamespace; } /** * Get the page's content model id, see the CONTENT_MODEL_XXX constants. * * @param int $flags A bit field; may be Title::GAID_FOR_UPDATE to select for update * @return string Content model id */ public function getContentModel( $flags = 0 ) { if ( !$this->mContentModel && $this->getArticleID( $flags ) ) { $linkCache = LinkCache::singleton(); $linkCache->addLinkObj( $this ); # in case we already had an article ID $this->mContentModel = $linkCache->getGoodLinkFieldObj( $this, 'model' ); } if ( !$this->mContentModel ) { $this->mContentModel = ContentHandler::getDefaultModelFor( $this ); } return $this->mContentModel; } /** * Convenience method for checking a title's content model name * * @param string $id The content model ID (use the CONTENT_MODEL_XXX constants). * @return bool True if $this->getContentModel() == $id */ public function hasContentModel( $id ) { return $this->getContentModel() == $id; } /** * Get the namespace text * * @return string Namespace text */ public function getNsText() { if ( $this->isExternal() ) { // This probably shouldn't even happen, // but for interwiki transclusion it sometimes does. // Use the canonical namespaces if possible to try to // resolve a foreign namespace. if ( MWNamespace::exists( $this->mNamespace ) ) { return MWNamespace::getCanonicalName( $this->mNamespace ); } } try { $formatter = self::getTitleFormatter(); return $formatter->getNamespaceName( $this->mNamespace, $this->mDbkeyform ); } catch ( InvalidArgumentException $ex ) { wfDebug( __METHOD__ . ': ' . $ex->getMessage() . "\n" ); return false; } } /** * Get the namespace text of the subject (rather than talk) page * * @return string Namespace text */ public function getSubjectNsText() { global $wgContLang; return $wgContLang->getNsText( MWNamespace::getSubject( $this->mNamespace ) ); } /** * Get the namespace text of the talk page * * @return string Namespace text */ public function getTalkNsText() { global $wgContLang; return $wgContLang->getNsText( MWNamespace::getTalk( $this->mNamespace ) ); } /** * Could this title have a corresponding talk page? * * @return bool */ public function canTalk() { return MWNamespace::canTalk( $this->mNamespace ); } /** * Is this in a namespace that allows actual pages? * * @return bool */ public function canExist() { return $this->mNamespace >= NS_MAIN; } /** * Can this title be added to a user's watchlist? * * @return bool */ public function isWatchable() { return !$this->isExternal() && MWNamespace::isWatchable( $this->getNamespace() ); } /** * Returns true if this is a special page. * * @return bool */ public function isSpecialPage() { return $this->getNamespace() == NS_SPECIAL; } /** * Returns true if this title resolves to the named special page * * @param string $name The special page name * @return bool */ public function isSpecial( $name ) { if ( $this->isSpecialPage() ) { list( $thisName, /* $subpage */ ) = SpecialPageFactory::resolveAlias( $this->getDBkey() ); if ( $name == $thisName ) { return true; } } return false; } /** * If the Title refers to a special page alias which is not the local default, resolve * the alias, and localise the name as necessary. Otherwise, return $this * * @return Title */ public function fixSpecialName() { if ( $this->isSpecialPage() ) { list( $canonicalName, $par ) = SpecialPageFactory::resolveAlias( $this->mDbkeyform ); if ( $canonicalName ) { $localName = SpecialPageFactory::getLocalNameFor( $canonicalName, $par ); if ( $localName != $this->mDbkeyform ) { return Title::makeTitle( NS_SPECIAL, $localName ); } } } return $this; } /** * Returns true if the title is inside the specified namespace. * * Please make use of this instead of comparing to getNamespace() * This function is much more resistant to changes we may make * to namespaces than code that makes direct comparisons. * @param int $ns The namespace * @return bool * @since 1.19 */ public function inNamespace( $ns ) { return MWNamespace::equals( $this->getNamespace(), $ns ); } /** * Returns true if the title is inside one of the specified namespaces. * * @param int $namespaces,... The namespaces to check for * @return bool * @since 1.19 */ public function inNamespaces( /* ... */ ) { $namespaces = func_get_args(); if ( count( $namespaces ) > 0 && is_array( $namespaces[0] ) ) { $namespaces = $namespaces[0]; } foreach ( $namespaces as $ns ) { if ( $this->inNamespace( $ns ) ) { return true; } } return false; } /** * Returns true if the title has the same subject namespace as the * namespace specified. * For example this method will take NS_USER and return true if namespace * is either NS_USER or NS_USER_TALK since both of them have NS_USER * as their subject namespace. * * This is MUCH simpler than individually testing for equivalence * against both NS_USER and NS_USER_TALK, and is also forward compatible. * @since 1.19 * @param int $ns * @return bool */ public function hasSubjectNamespace( $ns ) { return MWNamespace::subjectEquals( $this->getNamespace(), $ns ); } /** * Is this Title in a namespace which contains content? * In other words, is this a content page, for the purposes of calculating * statistics, etc? * * @return bool */ public function isContentPage() { return MWNamespace::isContent( $this->getNamespace() ); } /** * Would anybody with sufficient privileges be able to move this page? * Some pages just aren't movable. * * @return bool */ public function isMovable() { if ( !MWNamespace::isMovable( $this->getNamespace() ) || $this->isExternal() ) { // Interwiki title or immovable namespace. Hooks don't get to override here return false; } $result = true; Hooks::run( 'TitleIsMovable', array( $this, &$result ) ); return $result; } /** * Is this the mainpage? * @note Title::newFromText seems to be sufficiently optimized by the title * cache that we don't need to over-optimize by doing direct comparisons and * accidentally creating new bugs where $title->equals( Title::newFromText() ) * ends up reporting something differently than $title->isMainPage(); * * @since 1.18 * @return bool */ public function isMainPage() { return $this->equals( Title::newMainPage() ); } /** * Is this a subpage? * * @return bool */ public function isSubpage() { return MWNamespace::hasSubpages( $this->mNamespace ) ? strpos( $this->getText(), '/' ) !== false : false; } /** * Is this a conversion table for the LanguageConverter? * * @return bool */ public function isConversionTable() { // @todo ConversionTable should become a separate content model. return $this->getNamespace() == NS_MEDIAWIKI && strpos( $this->getText(), 'Conversiontable/' ) === 0; } /** * Does that page contain wikitext, or it is JS, CSS or whatever? * * @return bool */ public function isWikitextPage() { return $this->hasContentModel( CONTENT_MODEL_WIKITEXT ); } /** * Could this page contain custom CSS or JavaScript for the global UI. * This is generally true for pages in the MediaWiki namespace having CONTENT_MODEL_CSS * or CONTENT_MODEL_JAVASCRIPT. * * This method does *not* return true for per-user JS/CSS. Use isCssJsSubpage() * for that! * * Note that this method should not return true for pages that contain and * show "inactive" CSS or JS. * * @return bool * @todo FIXME: Rename to isSiteConfigPage() and remove deprecated hook */ public function isCssOrJsPage() { $isCssOrJsPage = NS_MEDIAWIKI == $this->mNamespace && ( $this->hasContentModel( CONTENT_MODEL_CSS ) || $this->hasContentModel( CONTENT_MODEL_JAVASCRIPT ) ); # @note This hook is also called in ContentHandler::getDefaultModel. # It's called here again to make sure hook functions can force this # method to return true even outside the MediaWiki namespace. Hooks::run( 'TitleIsCssOrJsPage', array( $this, &$isCssOrJsPage ), '1.25' ); return $isCssOrJsPage; } /** * Is this a .css or .js subpage of a user page? * @return bool * @todo FIXME: Rename to isUserConfigPage() */ public function isCssJsSubpage() { return ( NS_USER == $this->mNamespace && $this->isSubpage() && ( $this->hasContentModel( CONTENT_MODEL_CSS ) || $this->hasContentModel( CONTENT_MODEL_JAVASCRIPT ) ) ); } /** * Trim down a .css or .js subpage title to get the corresponding skin name * * @return string Containing skin name from .css or .js subpage title */ public function getSkinFromCssJsSubpage() { $subpage = explode( '/', $this->mTextform ); $subpage = $subpage[count( $subpage ) - 1]; $lastdot = strrpos( $subpage, '.' ); if ( $lastdot === false ) { return $subpage; # Never happens: only called for names ending in '.css' or '.js' } return substr( $subpage, 0, $lastdot ); } /** * Is this a .css subpage of a user page? * * @return bool */ public function isCssSubpage() { return ( NS_USER == $this->mNamespace && $this->isSubpage() && $this->hasContentModel( CONTENT_MODEL_CSS ) ); } /** * Is this a .js subpage of a user page? * * @return bool */ public function isJsSubpage() { return ( NS_USER == $this->mNamespace && $this->isSubpage() && $this->hasContentModel( CONTENT_MODEL_JAVASCRIPT ) ); } /** * Is this a talk page of some sort? * * @return bool */ public function isTalkPage() { return MWNamespace::isTalk( $this->getNamespace() ); } /** * Get a Title object associated with the talk page of this article * * @return Title The object for the talk page */ public function getTalkPage() { return Title::makeTitle( MWNamespace::getTalk( $this->getNamespace() ), $this->getDBkey() ); } /** * Get a title object associated with the subject page of this * talk page * * @return Title The object for the subject page */ public function getSubjectPage() { // Is this the same title? $subjectNS = MWNamespace::getSubject( $this->getNamespace() ); if ( $this->getNamespace() == $subjectNS ) { return $this; } return Title::makeTitle( $subjectNS, $this->getDBkey() ); } /** * Get the other title for this page, if this is a subject page * get the talk page, if it is a subject page get the talk page * * @since 1.25 * @throws MWException * @return Title */ public function getOtherPage() { if ( $this->isSpecialPage() ) { throw new MWException( 'Special pages cannot have other pages' ); } if ( $this->isTalkPage() ) { return $this->getSubjectPage(); } else { return $this->getTalkPage(); } } /** * Get the default namespace index, for when there is no namespace * * @return int Default namespace index */ public function getDefaultNamespace() { return $this->mDefaultNamespace; } /** * Get the Title fragment (i.e.\ the bit after the #) in text form * * Use Title::hasFragment to check for a fragment * * @return string Title fragment */ public function getFragment() { return $this->mFragment; } /** * Check if a Title fragment is set * * @return bool * @since 1.23 */ public function hasFragment() { return $this->mFragment !== ''; } /** * Get the fragment in URL form, including the "#" character if there is one * @return string Fragment in URL form */ public function getFragmentForURL() { if ( !$this->hasFragment() ) { return ''; } else { return '#' . Title::escapeFragmentForURL( $this->getFragment() ); } } /** * Set the fragment for this title. Removes the first character from the * specified fragment before setting, so it assumes you're passing it with * an initial "#". * * Deprecated for public use, use Title::makeTitle() with fragment parameter. * Still in active use privately. * * @private * @param string $fragment Text */ public function setFragment( $fragment ) { $this->mFragment = strtr( substr( $fragment, 1 ), '_', ' ' ); } /** * Prefix some arbitrary text with the namespace or interwiki prefix * of this object * * @param string $name The text * @return string The prefixed text */ private function prefix( $name ) { $p = ''; if ( $this->isExternal() ) { $p = $this->mInterwiki . ':'; } if ( 0 != $this->mNamespace ) { $p .= $this->getNsText() . ':'; } return $p . $name; } /** * Get the prefixed database key form * * @return string The prefixed title, with underscores and * any interwiki and namespace prefixes */ public function getPrefixedDBkey() { $s = $this->prefix( $this->mDbkeyform ); $s = strtr( $s, ' ', '_' ); return $s; } /** * Get the prefixed title with spaces. * This is the form usually used for display * * @return string The prefixed title, with spaces */ public function getPrefixedText() { if ( $this->mPrefixedText === null ) { $s = $this->prefix( $this->mTextform ); $s = strtr( $s, '_', ' ' ); $this->mPrefixedText = $s; } return $this->mPrefixedText; } /** * Return a string representation of this title * * @return string Representation of this title */ public function __toString() { return $this->getPrefixedText(); } /** * Get the prefixed title with spaces, plus any fragment * (part beginning with '#') * * @return string The prefixed title, with spaces and the fragment, including '#' */ public function getFullText() { $text = $this->getPrefixedText(); if ( $this->hasFragment() ) { $text .= '#' . $this->getFragment(); } return $text; } /** * Get the root page name text without a namespace, i.e. the leftmost part before any slashes * * @par Example: * @code * Title::newFromText('User:Foo/Bar/Baz')->getRootText(); * # returns: 'Foo' * @endcode * * @return string Root name * @since 1.20 */ public function getRootText() { if ( !MWNamespace::hasSubpages( $this->mNamespace ) ) { return $this->getText(); } return strtok( $this->getText(), '/' ); } /** * Get the root page name title, i.e. the leftmost part before any slashes * * @par Example: * @code * Title::newFromText('User:Foo/Bar/Baz')->getRootTitle(); * # returns: Title{User:Foo} * @endcode * * @return Title Root title * @since 1.20 */ public function getRootTitle() { return Title::makeTitle( $this->getNamespace(), $this->getRootText() ); } /** * Get the base page name without a namespace, i.e. the part before the subpage name * * @par Example: * @code * Title::newFromText('User:Foo/Bar/Baz')->getBaseText(); * # returns: 'Foo/Bar' * @endcode * * @return string Base name */ public function getBaseText() { if ( !MWNamespace::hasSubpages( $this->mNamespace ) ) { return $this->getText(); } $parts = explode( '/', $this->getText() ); # Don't discard the real title if there's no subpage involved if ( count( $parts ) > 1 ) { unset( $parts[count( $parts ) - 1] ); } return implode( '/', $parts ); } /** * Get the base page name title, i.e. the part before the subpage name * * @par Example: * @code * Title::newFromText('User:Foo/Bar/Baz')->getBaseTitle(); * # returns: Title{User:Foo/Bar} * @endcode * * @return Title Base title * @since 1.20 */ public function getBaseTitle() { return Title::makeTitle( $this->getNamespace(), $this->getBaseText() ); } /** * Get the lowest-level subpage name, i.e. the rightmost part after any slashes * * @par Example: * @code * Title::newFromText('User:Foo/Bar/Baz')->getSubpageText(); * # returns: "Baz" * @endcode * * @return string Subpage name */ public function getSubpageText() { if ( !MWNamespace::hasSubpages( $this->mNamespace ) ) { return $this->mTextform; } $parts = explode( '/', $this->mTextform ); return $parts[count( $parts ) - 1]; } /** * Get the title for a subpage of the current page * * @par Example: * @code * Title::newFromText('User:Foo/Bar/Baz')->getSubpage("Asdf"); * # returns: Title{User:Foo/Bar/Baz/Asdf} * @endcode * * @param string $text The subpage name to add to the title * @return Title Subpage title * @since 1.20 */ public function getSubpage( $text ) { return Title::makeTitleSafe( $this->getNamespace(), $this->getText() . '/' . $text ); } /** * Get a URL-encoded form of the subpage text * * @return string URL-encoded subpage name */ public function getSubpageUrlForm() { $text = $this->getSubpageText(); $text = wfUrlencode( strtr( $text, ' ', '_' ) ); return $text; } /** * Get a URL-encoded title (not an actual URL) including interwiki * * @return string The URL-encoded form */ public function getPrefixedURL() { $s = $this->prefix( $this->mDbkeyform ); $s = wfUrlencode( strtr( $s, ' ', '_' ) ); return $s; } /** * Helper to fix up the get{Canonical,Full,Link,Local,Internal}URL args * get{Canonical,Full,Link,Local,Internal}URL methods accepted an optional * second argument named variant. This was deprecated in favor * of passing an array of option with a "variant" key * Once $query2 is removed for good, this helper can be dropped * and the wfArrayToCgi moved to getLocalURL(); * * @since 1.19 (r105919) * @param array|string $query * @param bool $query2 * @return string */ private static function fixUrlQueryArgs( $query, $query2 = false ) { if ( $query2 !== false ) { wfDeprecated( "Title::get{Canonical,Full,Link,Local,Internal}URL " . "method called with a second parameter is deprecated. Add your " . "parameter to an array passed as the first parameter.", "1.19" ); } if ( is_array( $query ) ) { $query = wfArrayToCgi( $query ); } if ( $query2 ) { if ( is_string( $query2 ) ) { // $query2 is a string, we will consider this to be // a deprecated $variant argument and add it to the query $query2 = wfArrayToCgi( array( 'variant' => $query2 ) ); } else { $query2 = wfArrayToCgi( $query2 ); } // If we have $query content add a & to it first if ( $query ) { $query .= '&'; } // Now append the queries together $query .= $query2; } return $query; } /** * Get a real URL referring to this title, with interwiki link and * fragment * * @see self::getLocalURL for the arguments. * @see wfExpandUrl * @param array|string $query * @param bool $query2 * @param string $proto Protocol type to use in URL * @return string The URL */ public function getFullURL( $query = '', $query2 = false, $proto = PROTO_RELATIVE ) { $query = self::fixUrlQueryArgs( $query, $query2 ); # Hand off all the decisions on urls to getLocalURL $url = $this->getLocalURL( $query ); # Expand the url to make it a full url. Note that getLocalURL has the # potential to output full urls for a variety of reasons, so we use # wfExpandUrl instead of simply prepending $wgServer $url = wfExpandUrl( $url, $proto ); # Finally, add the fragment. $url .= $this->getFragmentForURL(); Hooks::run( 'GetFullURL', array( &$this, &$url, $query ) ); return $url; } /** * Get a URL with no fragment or server name (relative URL) from a Title object. * If this page is generated with action=render, however, * $wgServer is prepended to make an absolute URL. * * @see self::getFullURL to always get an absolute URL. * @see self::getLinkURL to always get a URL that's the simplest URL that will be * valid to link, locally, to the current Title. * @see self::newFromText to produce a Title object. * * @param string|array $query An optional query string, * not used for interwiki links. Can be specified as an associative array as well, * e.g., array( 'action' => 'edit' ) (keys and values will be URL-escaped). * Some query patterns will trigger various shorturl path replacements. * @param array $query2 An optional secondary query array. This one MUST * be an array. If a string is passed it will be interpreted as a deprecated * variant argument and urlencoded into a variant= argument. * This second query argument will be added to the $query * The second parameter is deprecated since 1.19. Pass it as a key,value * pair in the first parameter array instead. * * @return string String of the URL. */ public function getLocalURL( $query = '', $query2 = false ) { global $wgArticlePath, $wgScript, $wgServer, $wgRequest; $query = self::fixUrlQueryArgs( $query, $query2 ); $interwiki = Interwiki::fetch( $this->mInterwiki ); if ( $interwiki ) { $namespace = $this->getNsText(); if ( $namespace != '' ) { # Can this actually happen? Interwikis shouldn't be parsed. # Yes! It can in interwiki transclusion. But... it probably shouldn't. $namespace .= ':'; } $url = $interwiki->getURL( $namespace . $this->getDBkey() ); $url = wfAppendQuery( $url, $query ); } else { $dbkey = wfUrlencode( $this->getPrefixedDBkey() ); if ( $query == '' ) { $url = str_replace( '$1', $dbkey, $wgArticlePath ); Hooks::run( 'GetLocalURL::Article', array( &$this, &$url ) ); } else { global $wgVariantArticlePath, $wgActionPaths, $wgContLang; $url = false; $matches = array(); if ( !empty( $wgActionPaths ) && preg_match( '/^(.*&|)action=([^&]*)(&(.*)|)$/', $query, $matches ) ) { $action = urldecode( $matches[2] ); if ( isset( $wgActionPaths[$action] ) ) { $query = $matches[1]; if ( isset( $matches[4] ) ) { $query .= $matches[4]; } $url = str_replace( '$1', $dbkey, $wgActionPaths[$action] ); if ( $query != '' ) { $url = wfAppendQuery( $url, $query ); } } } if ( $url === false && $wgVariantArticlePath && $wgContLang->getCode() === $this->getPageLanguage()->getCode() && $this->getPageLanguage()->hasVariants() && preg_match( '/^variant=([^&]*)$/', $query, $matches ) ) { $variant = urldecode( $matches[1] ); if ( $this->getPageLanguage()->hasVariant( $variant ) ) { // Only do the variant replacement if the given variant is a valid // variant for the page's language. $url = str_replace( '$2', urlencode( $variant ), $wgVariantArticlePath ); $url = str_replace( '$1', $dbkey, $url ); } } if ( $url === false ) { if ( $query == '-' ) { $query = ''; } $url = "{$wgScript}?title={$dbkey}&{$query}"; } } Hooks::run( 'GetLocalURL::Internal', array( &$this, &$url, $query ) ); // @todo FIXME: This causes breakage in various places when we // actually expected a local URL and end up with dupe prefixes. if ( $wgRequest->getVal( 'action' ) == 'render' ) { $url = $wgServer . $url; } } Hooks::run( 'GetLocalURL', array( &$this, &$url, $query ) ); return $url; } /** * Get a URL that's the simplest URL that will be valid to link, locally, * to the current Title. It includes the fragment, but does not include * the server unless action=render is used (or the link is external). If * there's a fragment but the prefixed text is empty, we just return a link * to the fragment. * * The result obviously should not be URL-escaped, but does need to be * HTML-escaped if it's being output in HTML. * * @param array $query * @param bool $query2 * @param string $proto Protocol to use; setting this will cause a full URL to be used * @see self::getLocalURL for the arguments. * @return string The URL */ public function getLinkURL( $query = '', $query2 = false, $proto = PROTO_RELATIVE ) { if ( $this->isExternal() || $proto !== PROTO_RELATIVE ) { $ret = $this->getFullURL( $query, $query2, $proto ); } elseif ( $this->getPrefixedText() === '' && $this->hasFragment() ) { $ret = $this->getFragmentForURL(); } else { $ret = $this->getLocalURL( $query, $query2 ) . $this->getFragmentForURL(); } return $ret; } /** * Get the URL form for an internal link. * - Used in various CDN-related code, in case we have a different * internal hostname for the server from the exposed one. * * This uses $wgInternalServer to qualify the path, or $wgServer * if $wgInternalServer is not set. If the server variable used is * protocol-relative, the URL will be expanded to http:// * * @see self::getLocalURL for the arguments. * @return string The URL */ public function getInternalURL( $query = '', $query2 = false ) { global $wgInternalServer, $wgServer; $query = self::fixUrlQueryArgs( $query, $query2 ); $server = $wgInternalServer !== false ? $wgInternalServer : $wgServer; $url = wfExpandUrl( $server . $this->getLocalURL( $query ), PROTO_HTTP ); Hooks::run( 'GetInternalURL', array( &$this, &$url, $query ) ); return $url; } /** * Get the URL for a canonical link, for use in things like IRC and * e-mail notifications. Uses $wgCanonicalServer and the * GetCanonicalURL hook. * * NOTE: Unlike getInternalURL(), the canonical URL includes the fragment * * @see self::getLocalURL for the arguments. * @return string The URL * @since 1.18 */ public function getCanonicalURL( $query = '', $query2 = false ) { $query = self::fixUrlQueryArgs( $query, $query2 ); $url = wfExpandUrl( $this->getLocalURL( $query ) . $this->getFragmentForURL(), PROTO_CANONICAL ); Hooks::run( 'GetCanonicalURL', array( &$this, &$url, $query ) ); return $url; } /** * Get the edit URL for this Title * * @return string The URL, or a null string if this is an interwiki link */ public function getEditURL() { if ( $this->isExternal() ) { return ''; } $s = $this->getLocalURL( 'action=edit' ); return $s; } /** * Can $user perform $action on this page? * This skips potentially expensive cascading permission checks * as well as avoids expensive error formatting * * Suitable for use for nonessential UI controls in common cases, but * _not_ for functional access control. * * May provide false positives, but should never provide a false negative. * * @param string $action Action that permission needs to be checked for * @param User $user User to check (since 1.19); $wgUser will be used if not provided. * @return bool */ public function quickUserCan( $action, $user = null ) { return $this->userCan( $action, $user, false ); } /** * Can $user perform $action on this page? * * @param string $action Action that permission needs to be checked for * @param User $user User to check (since 1.19); $wgUser will be used if not * provided. * @param string $rigor Same format as Title::getUserPermissionsErrors() * @return bool */ public function userCan( $action, $user = null, $rigor = 'secure' ) { if ( !$user instanceof User ) { global $wgUser; $user = $wgUser; } return !count( $this->getUserPermissionsErrorsInternal( $action, $user, $rigor, true ) ); } /** * Can $user perform $action on this page? * * @todo FIXME: This *does not* check throttles (User::pingLimiter()). * * @param string $action Action that permission needs to be checked for * @param User $user User to check * @param string $rigor One of (quick,full,secure) * - quick : does cheap permission checks from slaves (usable for GUI creation) * - full : does cheap and expensive checks possibly from a slave * - secure : does cheap and expensive checks, using the master as needed * @param array $ignoreErrors Array of Strings Set this to a list of message keys * whose corresponding errors may be ignored. * @return array Array of arrays of the arguments to wfMessage to explain permissions problems. */ public function getUserPermissionsErrors( $action, $user, $rigor = 'secure', $ignoreErrors = array() ) { $errors = $this->getUserPermissionsErrorsInternal( $action, $user, $rigor ); // Remove the errors being ignored. foreach ( $errors as $index => $error ) { $errKey = is_array( $error ) ? $error[0] : $error; if ( in_array( $errKey, $ignoreErrors ) ) { unset( $errors[$index] ); } if ( $errKey instanceof MessageSpecifier && in_array( $errKey->getKey(), $ignoreErrors ) ) { unset( $errors[$index] ); } } return $errors; } /** * Permissions checks that fail most often, and which are easiest to test. * * @param string $action The action to check * @param User $user User to check * @param array $errors List of current errors * @param string $rigor Same format as Title::getUserPermissionsErrors() * @param bool $short Short circuit on first error * * @return array List of errors */ private function checkQuickPermissions( $action, $user, $errors, $rigor, $short ) { if ( !Hooks::run( 'TitleQuickPermissions', array( $this, $user, $action, &$errors, ( $rigor !== 'quick' ), $short ) ) ) { return $errors; } if ( $action == 'create' ) { if ( ( $this->isTalkPage() && !$user->isAllowed( 'createtalk' ) ) || ( !$this->isTalkPage() && !$user->isAllowed( 'createpage' ) ) ) { $errors[] = $user->isAnon() ? array( 'nocreatetext' ) : array( 'nocreate-loggedin' ); } } elseif ( $action == 'move' ) { if ( !$user->isAllowed( 'move-rootuserpages' ) && $this->mNamespace == NS_USER && !$this->isSubpage() ) { // Show user page-specific message only if the user can move other pages $errors[] = array( 'cant-move-user-page' ); } // Check if user is allowed to move files if it's a file if ( $this->mNamespace == NS_FILE && !$user->isAllowed( 'movefile' ) ) { $errors[] = array( 'movenotallowedfile' ); } // Check if user is allowed to move category pages if it's a category page if ( $this->mNamespace == NS_CATEGORY && !$user->isAllowed( 'move-categorypages' ) ) { $errors[] = array( 'cant-move-category-page' ); } if ( !$user->isAllowed( 'move' ) ) { // User can't move anything $userCanMove = User::groupHasPermission( 'user', 'move' ); $autoconfirmedCanMove = User::groupHasPermission( 'autoconfirmed', 'move' ); if ( $user->isAnon() && ( $userCanMove || $autoconfirmedCanMove ) ) { // custom message if logged-in users without any special rights can move $errors[] = array( 'movenologintext' ); } else { $errors[] = array( 'movenotallowed' ); } } } elseif ( $action == 'move-target' ) { if ( !$user->isAllowed( 'move' ) ) { // User can't move anything $errors[] = array( 'movenotallowed' ); } elseif ( !$user->isAllowed( 'move-rootuserpages' ) && $this->mNamespace == NS_USER && !$this->isSubpage() ) { // Show user page-specific message only if the user can move other pages $errors[] = array( 'cant-move-to-user-page' ); } elseif ( !$user->isAllowed( 'move-categorypages' ) && $this->mNamespace == NS_CATEGORY ) { // Show category page-specific message only if the user can move other pages $errors[] = array( 'cant-move-to-category-page' ); } } elseif ( !$user->isAllowed( $action ) ) { $errors[] = $this->missingPermissionError( $action, $short ); } return $errors; } /** * Add the resulting error code to the errors array * * @param array $errors List of current errors * @param array $result Result of errors * * @return array List of errors */ private function resultToError( $errors, $result ) { if ( is_array( $result ) && count( $result ) && !is_array( $result[0] ) ) { // A single array representing an error $errors[] = $result; } elseif ( is_array( $result ) && is_array( $result[0] ) ) { // A nested array representing multiple errors $errors = array_merge( $errors, $result ); } elseif ( $result !== '' && is_string( $result ) ) { // A string representing a message-id $errors[] = array( $result ); } elseif ( $result instanceof MessageSpecifier ) { // A message specifier representing an error $errors[] = array( $result ); } elseif ( $result === false ) { // a generic "We don't want them to do that" $errors[] = array( 'badaccess-group0' ); } return $errors; } /** * Check various permission hooks * * @param string $action The action to check * @param User $user User to check * @param array $errors List of current errors * @param string $rigor Same format as Title::getUserPermissionsErrors() * @param bool $short Short circuit on first error * * @return array List of errors */ private function checkPermissionHooks( $action, $user, $errors, $rigor, $short ) { // Use getUserPermissionsErrors instead $result = ''; if ( !Hooks::run( 'userCan', array( &$this, &$user, $action, &$result ) ) ) { return $result ? array() : array( array( 'badaccess-group0' ) ); } // Check getUserPermissionsErrors hook if ( !Hooks::run( 'getUserPermissionsErrors', array( &$this, &$user, $action, &$result ) ) ) { $errors = $this->resultToError( $errors, $result ); } // Check getUserPermissionsErrorsExpensive hook if ( $rigor !== 'quick' && !( $short && count( $errors ) > 0 ) && !Hooks::run( 'getUserPermissionsErrorsExpensive', array( &$this, &$user, $action, &$result ) ) ) { $errors = $this->resultToError( $errors, $result ); } return $errors; } /** * Check permissions on special pages & namespaces * * @param string $action The action to check * @param User $user User to check * @param array $errors List of current errors * @param string $rigor Same format as Title::getUserPermissionsErrors() * @param bool $short Short circuit on first error * * @return array List of errors */ private function checkSpecialsAndNSPermissions( $action, $user, $errors, $rigor, $short ) { # Only 'createaccount' can be performed on special pages, # which don't actually exist in the DB. if ( NS_SPECIAL == $this->mNamespace && $action !== 'createaccount' ) { $errors[] = array( 'ns-specialprotected' ); } # Check $wgNamespaceProtection for restricted namespaces if ( $this->isNamespaceProtected( $user ) ) { $ns = $this->mNamespace == NS_MAIN ? wfMessage( 'nstab-main' )->text() : $this->getNsText(); $errors[] = $this->mNamespace == NS_MEDIAWIKI ? array( 'protectedinterface', $action ) : array( 'namespaceprotected', $ns, $action ); } return $errors; } /** * Check CSS/JS sub-page permissions * * @param string $action The action to check * @param User $user User to check * @param array $errors List of current errors * @param string $rigor Same format as Title::getUserPermissionsErrors() * @param bool $short Short circuit on first error * * @return array List of errors */ private function checkCSSandJSPermissions( $action, $user, $errors, $rigor, $short ) { # Protect css/js subpages of user pages # XXX: this might be better using restrictions # XXX: right 'editusercssjs' is deprecated, for backward compatibility only if ( $action != 'patrol' && !$user->isAllowed( 'editusercssjs' ) ) { if ( preg_match( '/^' . preg_quote( $user->getName(), '/' ) . '\//', $this->mTextform ) ) { if ( $this->isCssSubpage() && !$user->isAllowedAny( 'editmyusercss', 'editusercss' ) ) { $errors[] = array( 'mycustomcssprotected', $action ); } elseif ( $this->isJsSubpage() && !$user->isAllowedAny( 'editmyuserjs', 'edituserjs' ) ) { $errors[] = array( 'mycustomjsprotected', $action ); } } else { if ( $this->isCssSubpage() && !$user->isAllowed( 'editusercss' ) ) { $errors[] = array( 'customcssprotected', $action ); } elseif ( $this->isJsSubpage() && !$user->isAllowed( 'edituserjs' ) ) { $errors[] = array( 'customjsprotected', $action ); } } } return $errors; } /** * Check against page_restrictions table requirements on this * page. The user must possess all required rights for this * action. * * @param string $action The action to check * @param User $user User to check * @param array $errors List of current errors * @param string $rigor Same format as Title::getUserPermissionsErrors() * @param bool $short Short circuit on first error * * @return array List of errors */ private function checkPageRestrictions( $action, $user, $errors, $rigor, $short ) { foreach ( $this->getRestrictions( $action ) as $right ) { // Backwards compatibility, rewrite sysop -> editprotected if ( $right == 'sysop' ) { $right = 'editprotected'; } // Backwards compatibility, rewrite autoconfirmed -> editsemiprotected if ( $right == 'autoconfirmed' ) { $right = 'editsemiprotected'; } if ( $right == '' ) { continue; } if ( !$user->isAllowed( $right ) ) { $errors[] = array( 'protectedpagetext', $right, $action ); } elseif ( $this->mCascadeRestriction && !$user->isAllowed( 'protect' ) ) { $errors[] = array( 'protectedpagetext', 'protect', $action ); } } return $errors; } /** * Check restrictions on cascading pages. * * @param string $action The action to check * @param User $user User to check * @param array $errors List of current errors * @param string $rigor Same format as Title::getUserPermissionsErrors() * @param bool $short Short circuit on first error * * @return array List of errors */ private function checkCascadingSourcesRestrictions( $action, $user, $errors, $rigor, $short ) { if ( $rigor !== 'quick' && !$this->isCssJsSubpage() ) { # We /could/ use the protection level on the source page, but it's # fairly ugly as we have to establish a precedence hierarchy for pages # included by multiple cascade-protected pages. So just restrict # it to people with 'protect' permission, as they could remove the # protection anyway. list( $cascadingSources, $restrictions ) = $this->getCascadeProtectionSources(); # Cascading protection depends on more than this page... # Several cascading protected pages may include this page... # Check each cascading level # This is only for protection restrictions, not for all actions if ( isset( $restrictions[$action] ) ) { foreach ( $restrictions[$action] as $right ) { // Backwards compatibility, rewrite sysop -> editprotected if ( $right == 'sysop' ) { $right = 'editprotected'; } // Backwards compatibility, rewrite autoconfirmed -> editsemiprotected if ( $right == 'autoconfirmed' ) { $right = 'editsemiprotected'; } if ( $right != '' && !$user->isAllowedAll( 'protect', $right ) ) { $pages = ''; foreach ( $cascadingSources as $page ) { $pages .= '* [[:' . $page->getPrefixedText() . "]]\n"; } $errors[] = array( 'cascadeprotected', count( $cascadingSources ), $pages, $action ); } } } } return $errors; } /** * Check action permissions not already checked in checkQuickPermissions * * @param string $action The action to check * @param User $user User to check * @param array $errors List of current errors * @param string $rigor Same format as Title::getUserPermissionsErrors() * @param bool $short Short circuit on first error * * @return array List of errors */ private function checkActionPermissions( $action, $user, $errors, $rigor, $short ) { global $wgDeleteRevisionsLimit, $wgLang; if ( $action == 'protect' ) { if ( count( $this->getUserPermissionsErrorsInternal( 'edit', $user, $rigor, true ) ) ) { // If they can't edit, they shouldn't protect. $errors[] = array( 'protect-cantedit' ); } } elseif ( $action == 'create' ) { $title_protection = $this->getTitleProtection(); if ( $title_protection ) { if ( $title_protection['permission'] == '' || !$user->isAllowed( $title_protection['permission'] ) ) { $errors[] = array( 'titleprotected', User::whoIs( $title_protection['user'] ), $title_protection['reason'] ); } } } elseif ( $action == 'move' ) { // Check for immobile pages if ( !MWNamespace::isMovable( $this->mNamespace ) ) { // Specific message for this case $errors[] = array( 'immobile-source-namespace', $this->getNsText() ); } elseif ( !$this->isMovable() ) { // Less specific message for rarer cases $errors[] = array( 'immobile-source-page' ); } } elseif ( $action == 'move-target' ) { if ( !MWNamespace::isMovable( $this->mNamespace ) ) { $errors[] = array( 'immobile-target-namespace', $this->getNsText() ); } elseif ( !$this->isMovable() ) { $errors[] = array( 'immobile-target-page' ); } } elseif ( $action == 'delete' ) { $tempErrors = $this->checkPageRestrictions( 'edit', $user, array(), $rigor, true ); if ( !$tempErrors ) { $tempErrors = $this->checkCascadingSourcesRestrictions( 'edit', $user, $tempErrors, $rigor, true ); } if ( $tempErrors ) { // If protection keeps them from editing, they shouldn't be able to delete. $errors[] = array( 'deleteprotected' ); } if ( $rigor !== 'quick' && $wgDeleteRevisionsLimit && !$this->userCan( 'bigdelete', $user ) && $this->isBigDeletion() ) { $errors[] = array( 'delete-toobig', $wgLang->formatNum( $wgDeleteRevisionsLimit ) ); } } return $errors; } /** * Check that the user isn't blocked from editing. * * @param string $action The action to check * @param User $user User to check * @param array $errors List of current errors * @param string $rigor Same format as Title::getUserPermissionsErrors() * @param bool $short Short circuit on first error * * @return array List of errors */ private function checkUserBlock( $action, $user, $errors, $rigor, $short ) { // Account creation blocks handled at userlogin. // Unblocking handled in SpecialUnblock if ( $rigor === 'quick' || in_array( $action, array( 'createaccount', 'unblock' ) ) ) { return $errors; } global $wgEmailConfirmToEdit; if ( $wgEmailConfirmToEdit && !$user->isEmailConfirmed() ) { $errors[] = array( 'confirmedittext' ); } $useSlave = ( $rigor !== 'secure' ); if ( ( $action == 'edit' || $action == 'create' ) && !$user->isBlockedFrom( $this, $useSlave ) ) { // Don't block the user from editing their own talk page unless they've been // explicitly blocked from that too. } elseif ( $user->isBlocked() && $user->getBlock()->prevents( $action ) !== false ) { // @todo FIXME: Pass the relevant context into this function. $errors[] = $user->getBlock()->getPermissionsError( RequestContext::getMain() ); } return $errors; } /** * Check that the user is allowed to read this page. * * @param string $action The action to check * @param User $user User to check * @param array $errors List of current errors * @param string $rigor Same format as Title::getUserPermissionsErrors() * @param bool $short Short circuit on first error * * @return array List of errors */ private function checkReadPermissions( $action, $user, $errors, $rigor, $short ) { global $wgWhitelistRead, $wgWhitelistReadRegexp; $whitelisted = false; if ( User::isEveryoneAllowed( 'read' ) ) { # Shortcut for public wikis, allows skipping quite a bit of code $whitelisted = true; } elseif ( $user->isAllowed( 'read' ) ) { # If the user is allowed to read pages, he is allowed to read all pages $whitelisted = true; } elseif ( $this->isSpecial( 'Userlogin' ) || $this->isSpecial( 'ChangePassword' ) || $this->isSpecial( 'PasswordReset' ) ) { # Always grant access to the login page. # Even anons need to be able to log in. $whitelisted = true; } elseif ( is_array( $wgWhitelistRead ) && count( $wgWhitelistRead ) ) { # Time to check the whitelist # Only do these checks is there's something to check against $name = $this->getPrefixedText(); $dbName = $this->getPrefixedDBkey(); // Check for explicit whitelisting with and without underscores if ( in_array( $name, $wgWhitelistRead, true ) || in_array( $dbName, $wgWhitelistRead, true ) ) { $whitelisted = true; } elseif ( $this->getNamespace() == NS_MAIN ) { # Old settings might have the title prefixed with # a colon for main-namespace pages if ( in_array( ':' . $name, $wgWhitelistRead ) ) { $whitelisted = true; } } elseif ( $this->isSpecialPage() ) { # If it's a special page, ditch the subpage bit and check again $name = $this->getDBkey(); list( $name, /* $subpage */ ) = SpecialPageFactory::resolveAlias( $name ); if ( $name ) { $pure = SpecialPage::getTitleFor( $name )->getPrefixedText(); if ( in_array( $pure, $wgWhitelistRead, true ) ) { $whitelisted = true; } } } } if ( !$whitelisted && is_array( $wgWhitelistReadRegexp ) && !empty( $wgWhitelistReadRegexp ) ) { $name = $this->getPrefixedText(); // Check for regex whitelisting foreach ( $wgWhitelistReadRegexp as $listItem ) { if ( preg_match( $listItem, $name ) ) { $whitelisted = true; break; } } } if ( !$whitelisted ) { # If the title is not whitelisted, give extensions a chance to do so... Hooks::run( 'TitleReadWhitelist', array( $this, $user, &$whitelisted ) ); if ( !$whitelisted ) { $errors[] = $this->missingPermissionError( $action, $short ); } } return $errors; } /** * Get a description array when the user doesn't have the right to perform * $action (i.e. when User::isAllowed() returns false) * * @param string $action The action to check * @param bool $short Short circuit on first error * @return array List of errors */ private function missingPermissionError( $action, $short ) { // We avoid expensive display logic for quickUserCan's and such if ( $short ) { return array( 'badaccess-group0' ); } $groups = array_map( array( 'User', 'makeGroupLinkWiki' ), User::getGroupsWithPermission( $action ) ); if ( count( $groups ) ) { global $wgLang; return array( 'badaccess-groups', $wgLang->commaList( $groups ), count( $groups ) ); } else { return array( 'badaccess-group0' ); } } /** * Can $user perform $action on this page? This is an internal function, * which checks ONLY that previously checked by userCan (i.e. it leaves out * checks on wfReadOnly() and blocks) * * @param string $action Action that permission needs to be checked for * @param User $user User to check * @param string $rigor One of (quick,full,secure) * - quick : does cheap permission checks from slaves (usable for GUI creation) * - full : does cheap and expensive checks possibly from a slave * - secure : does cheap and expensive checks, using the master as needed * @param bool $short Set this to true to stop after the first permission error. * @return array Array of arrays of the arguments to wfMessage to explain permissions problems. */ protected function getUserPermissionsErrorsInternal( $action, $user, $rigor = 'secure', $short = false ) { if ( $rigor === true ) { $rigor = 'secure'; // b/c } elseif ( $rigor === false ) { $rigor = 'quick'; // b/c } elseif ( !in_array( $rigor, array( 'quick', 'full', 'secure' ) ) ) { throw new Exception( "Invalid rigor parameter '$rigor'." ); } # Read has special handling if ( $action == 'read' ) { $checks = array( 'checkPermissionHooks', 'checkReadPermissions', ); # Don't call checkSpecialsAndNSPermissions or checkCSSandJSPermissions # here as it will lead to duplicate error messages. This is okay to do # since anywhere that checks for create will also check for edit, and # those checks are called for edit. } elseif ( $action == 'create' ) { $checks = array( 'checkQuickPermissions', 'checkPermissionHooks', 'checkPageRestrictions', 'checkCascadingSourcesRestrictions', 'checkActionPermissions', 'checkUserBlock' ); } else { $checks = array( 'checkQuickPermissions', 'checkPermissionHooks', 'checkSpecialsAndNSPermissions', 'checkCSSandJSPermissions', 'checkPageRestrictions', 'checkCascadingSourcesRestrictions', 'checkActionPermissions', 'checkUserBlock' ); } $errors = array(); while ( count( $checks ) > 0 && !( $short && count( $errors ) > 0 ) ) { $method = array_shift( $checks ); $errors = $this->$method( $action, $user, $errors, $rigor, $short ); } return $errors; } /** * Get a filtered list of all restriction types supported by this wiki. * @param bool $exists True to get all restriction types that apply to * titles that do exist, False for all restriction types that apply to * titles that do not exist * @return array */ public static function getFilteredRestrictionTypes( $exists = true ) { global $wgRestrictionTypes; $types = $wgRestrictionTypes; if ( $exists ) { # Remove the create restriction for existing titles $types = array_diff( $types, array( 'create' ) ); } else { # Only the create and upload restrictions apply to non-existing titles $types = array_intersect( $types, array( 'create', 'upload' ) ); } return $types; } /** * Returns restriction types for the current Title * * @return array Applicable restriction types */ public function getRestrictionTypes() { if ( $this->isSpecialPage() ) { return array(); } $types = self::getFilteredRestrictionTypes( $this->exists() ); if ( $this->getNamespace() != NS_FILE ) { # Remove the upload restriction for non-file titles $types = array_diff( $types, array( 'upload' ) ); } Hooks::run( 'TitleGetRestrictionTypes', array( $this, &$types ) ); wfDebug( __METHOD__ . ': applicable restrictions to [[' . $this->getPrefixedText() . ']] are {' . implode( ',', $types ) . "}\n" ); return $types; } /** * Is this title subject to title protection? * Title protection is the one applied against creation of such title. * * @return array|bool An associative array representing any existent title * protection, or false if there's none. */ public function getTitleProtection() { // Can't protect pages in special namespaces if ( $this->getNamespace() < 0 ) { return false; } // Can't protect pages that exist. if ( $this->exists() ) { return false; } if ( $this->mTitleProtection === null ) { $dbr = wfGetDB( DB_SLAVE ); $res = $dbr->select( 'protected_titles', array( 'user' => 'pt_user', 'reason' => 'pt_reason', 'expiry' => 'pt_expiry', 'permission' => 'pt_create_perm' ), array( 'pt_namespace' => $this->getNamespace(), 'pt_title' => $this->getDBkey() ), __METHOD__ ); // fetchRow returns false if there are no rows. $row = $dbr->fetchRow( $res ); if ( $row ) { if ( $row['permission'] == 'sysop' ) { $row['permission'] = 'editprotected'; // B/C } if ( $row['permission'] == 'autoconfirmed' ) { $row['permission'] = 'editsemiprotected'; // B/C } $row['expiry'] = $dbr->decodeExpiry( $row['expiry'] ); } $this->mTitleProtection = $row; } return $this->mTitleProtection; } /** * Remove any title protection due to page existing */ public function deleteTitleProtection() { $dbw = wfGetDB( DB_MASTER ); $dbw->delete( 'protected_titles', array( 'pt_namespace' => $this->getNamespace(), 'pt_title' => $this->getDBkey() ), __METHOD__ ); $this->mTitleProtection = false; } /** * Is this page "semi-protected" - the *only* protection levels are listed * in $wgSemiprotectedRestrictionLevels? * * @param string $action Action to check (default: edit) * @return bool */ public function isSemiProtected( $action = 'edit' ) { global $wgSemiprotectedRestrictionLevels; $restrictions = $this->getRestrictions( $action ); $semi = $wgSemiprotectedRestrictionLevels; if ( !$restrictions || !$semi ) { // Not protected, or all protection is full protection return false; } // Remap autoconfirmed to editsemiprotected for BC foreach ( array_keys( $semi, 'autoconfirmed' ) as $key ) { $semi[$key] = 'editsemiprotected'; } foreach ( array_keys( $restrictions, 'autoconfirmed' ) as $key ) { $restrictions[$key] = 'editsemiprotected'; } return !array_diff( $restrictions, $semi ); } /** * Does the title correspond to a protected article? * * @param string $action The action the page is protected from, * by default checks all actions. * @return bool */ public function isProtected( $action = '' ) { global $wgRestrictionLevels; $restrictionTypes = $this->getRestrictionTypes(); # Special pages have inherent protection if ( $this->isSpecialPage() ) { return true; } # Check regular protection levels foreach ( $restrictionTypes as $type ) { if ( $action == $type || $action == '' ) { $r = $this->getRestrictions( $type ); foreach ( $wgRestrictionLevels as $level ) { if ( in_array( $level, $r ) && $level != '' ) { return true; } } } } return false; } /** * Determines if $user is unable to edit this page because it has been protected * by $wgNamespaceProtection. * * @param User $user User object to check permissions * @return bool */ public function isNamespaceProtected( User $user ) { global $wgNamespaceProtection; if ( isset( $wgNamespaceProtection[$this->mNamespace] ) ) { foreach ( (array)$wgNamespaceProtection[$this->mNamespace] as $right ) { if ( $right != '' && !$user->isAllowed( $right ) ) { return true; } } } return false; } /** * Cascading protection: Return true if cascading restrictions apply to this page, false if not. * * @return bool If the page is subject to cascading restrictions. */ public function isCascadeProtected() { list( $sources, /* $restrictions */ ) = $this->getCascadeProtectionSources( false ); return ( $sources > 0 ); } /** * Determines whether cascading protection sources have already been loaded from * the database. * * @param bool $getPages True to check if the pages are loaded, or false to check * if the status is loaded. * @return bool Whether or not the specified information has been loaded * @since 1.23 */ public function areCascadeProtectionSourcesLoaded( $getPages = true ) { return $getPages ? $this->mCascadeSources !== null : $this->mHasCascadingRestrictions !== null; } /** * Cascading protection: Get the source of any cascading restrictions on this page. * * @param bool $getPages Whether or not to retrieve the actual pages * that the restrictions have come from and the actual restrictions * themselves. * @return array Two elements: First is an array of Title objects of the * pages from which cascading restrictions have come, false for * none, or true if such restrictions exist but $getPages was not * set. Second is an array like that returned by * Title::getAllRestrictions(), or an empty array if $getPages is * false. */ public function getCascadeProtectionSources( $getPages = true ) { $pagerestrictions = array(); if ( $this->mCascadeSources !== null && $getPages ) { return array( $this->mCascadeSources, $this->mCascadingRestrictions ); } elseif ( $this->mHasCascadingRestrictions !== null && !$getPages ) { return array( $this->mHasCascadingRestrictions, $pagerestrictions ); } $dbr = wfGetDB( DB_SLAVE ); if ( $this->getNamespace() == NS_FILE ) { $tables = array( 'imagelinks', 'page_restrictions' ); $where_clauses = array( 'il_to' => $this->getDBkey(), 'il_from=pr_page', 'pr_cascade' => 1 ); } else { $tables = array( 'templatelinks', 'page_restrictions' ); $where_clauses = array( 'tl_namespace' => $this->getNamespace(), 'tl_title' => $this->getDBkey(), 'tl_from=pr_page', 'pr_cascade' => 1 ); } if ( $getPages ) { $cols = array( 'pr_page', 'page_namespace', 'page_title', 'pr_expiry', 'pr_type', 'pr_level' ); $where_clauses[] = 'page_id=pr_page'; $tables[] = 'page'; } else { $cols = array( 'pr_expiry' ); } $res = $dbr->select( $tables, $cols, $where_clauses, __METHOD__ ); $sources = $getPages ? array() : false; $now = wfTimestampNow(); foreach ( $res as $row ) { $expiry = $dbr->decodeExpiry( $row->pr_expiry ); if ( $expiry > $now ) { if ( $getPages ) { $page_id = $row->pr_page; $page_ns = $row->page_namespace; $page_title = $row->page_title; $sources[$page_id] = Title::makeTitle( $page_ns, $page_title ); # Add groups needed for each restriction type if its not already there # Make sure this restriction type still exists if ( !isset( $pagerestrictions[$row->pr_type] ) ) { $pagerestrictions[$row->pr_type] = array(); } if ( isset( $pagerestrictions[$row->pr_type] ) && !in_array( $row->pr_level, $pagerestrictions[$row->pr_type] ) ) { $pagerestrictions[$row->pr_type][] = $row->pr_level; } } else { $sources = true; } } } if ( $getPages ) { $this->mCascadeSources = $sources; $this->mCascadingRestrictions = $pagerestrictions; } else { $this->mHasCascadingRestrictions = $sources; } return array( $sources, $pagerestrictions ); } /** * Accessor for mRestrictionsLoaded * * @return bool Whether or not the page's restrictions have already been * loaded from the database * @since 1.23 */ public function areRestrictionsLoaded() { return $this->mRestrictionsLoaded; } /** * Accessor/initialisation for mRestrictions * * @param string $action Action that permission needs to be checked for * @return array Restriction levels needed to take the action. All levels are * required. Note that restriction levels are normally user rights, but 'sysop' * and 'autoconfirmed' are also allowed for backwards compatibility. These should * be mapped to 'editprotected' and 'editsemiprotected' respectively. */ public function getRestrictions( $action ) { if ( !$this->mRestrictionsLoaded ) { $this->loadRestrictions(); } return isset( $this->mRestrictions[$action] ) ? $this->mRestrictions[$action] : array(); } /** * Accessor/initialisation for mRestrictions * * @return array Keys are actions, values are arrays as returned by * Title::getRestrictions() * @since 1.23 */ public function getAllRestrictions() { if ( !$this->mRestrictionsLoaded ) { $this->loadRestrictions(); } return $this->mRestrictions; } /** * Get the expiry time for the restriction against a given action * * @param string $action * @return string|bool 14-char timestamp, or 'infinity' if the page is protected forever * or not protected at all, or false if the action is not recognised. */ public function getRestrictionExpiry( $action ) { if ( !$this->mRestrictionsLoaded ) { $this->loadRestrictions(); } return isset( $this->mRestrictionsExpiry[$action] ) ? $this->mRestrictionsExpiry[$action] : false; } /** * Returns cascading restrictions for the current article * * @return bool */ function areRestrictionsCascading() { if ( !$this->mRestrictionsLoaded ) { $this->loadRestrictions(); } return $this->mCascadeRestriction; } /** * Loads a string into mRestrictions array * * @param ResultWrapper $res Resource restrictions as an SQL result. * @param string $oldFashionedRestrictions Comma-separated list of page * restrictions from page table (pre 1.10) */ private function loadRestrictionsFromResultWrapper( $res, $oldFashionedRestrictions = null ) { $rows = array(); foreach ( $res as $row ) { $rows[] = $row; } $this->loadRestrictionsFromRows( $rows, $oldFashionedRestrictions ); } /** * Compiles list of active page restrictions from both page table (pre 1.10) * and page_restrictions table for this existing page. * Public for usage by LiquidThreads. * * @param array $rows Array of db result objects * @param string $oldFashionedRestrictions Comma-separated list of page * restrictions from page table (pre 1.10) */ public function loadRestrictionsFromRows( $rows, $oldFashionedRestrictions = null ) { $dbr = wfGetDB( DB_SLAVE ); $restrictionTypes = $this->getRestrictionTypes(); foreach ( $restrictionTypes as $type ) { $this->mRestrictions[$type] = array(); $this->mRestrictionsExpiry[$type] = 'infinity'; } $this->mCascadeRestriction = false; # Backwards-compatibility: also load the restrictions from the page record (old format). if ( $oldFashionedRestrictions !== null ) { $this->mOldRestrictions = $oldFashionedRestrictions; } if ( $this->mOldRestrictions === false ) { $this->mOldRestrictions = $dbr->selectField( 'page', 'page_restrictions', array( 'page_id' => $this->getArticleID() ), __METHOD__ ); } if ( $this->mOldRestrictions != '' ) { foreach ( explode( ':', trim( $this->mOldRestrictions ) ) as $restrict ) { $temp = explode( '=', trim( $restrict ) ); if ( count( $temp ) == 1 ) { // old old format should be treated as edit/move restriction $this->mRestrictions['edit'] = explode( ',', trim( $temp[0] ) ); $this->mRestrictions['move'] = explode( ',', trim( $temp[0] ) ); } else { $restriction = trim( $temp[1] ); if ( $restriction != '' ) { // some old entries are empty $this->mRestrictions[$temp[0]] = explode( ',', $restriction ); } } } } if ( count( $rows ) ) { # Current system - load second to make them override. $now = wfTimestampNow(); # Cycle through all the restrictions. foreach ( $rows as $row ) { // Don't take care of restrictions types that aren't allowed if ( !in_array( $row->pr_type, $restrictionTypes ) ) { continue; } // This code should be refactored, now that it's being used more generally, // But I don't really see any harm in leaving it in Block for now -werdna $expiry = $dbr->decodeExpiry( $row->pr_expiry ); // Only apply the restrictions if they haven't expired! if ( !$expiry || $expiry > $now ) { $this->mRestrictionsExpiry[$row->pr_type] = $expiry; $this->mRestrictions[$row->pr_type] = explode( ',', trim( $row->pr_level ) ); $this->mCascadeRestriction |= $row->pr_cascade; } } } $this->mRestrictionsLoaded = true; } /** * Load restrictions from the page_restrictions table * * @param string $oldFashionedRestrictions Comma-separated list of page * restrictions from page table (pre 1.10) */ public function loadRestrictions( $oldFashionedRestrictions = null ) { if ( !$this->mRestrictionsLoaded ) { $dbr = wfGetDB( DB_SLAVE ); if ( $this->exists() ) { $res = $dbr->select( 'page_restrictions', array( 'pr_type', 'pr_expiry', 'pr_level', 'pr_cascade' ), array( 'pr_page' => $this->getArticleID() ), __METHOD__ ); $this->loadRestrictionsFromResultWrapper( $res, $oldFashionedRestrictions ); } else { $title_protection = $this->getTitleProtection(); if ( $title_protection ) { $now = wfTimestampNow(); $expiry = $dbr->decodeExpiry( $title_protection['expiry'] ); if ( !$expiry || $expiry > $now ) { // Apply the restrictions $this->mRestrictionsExpiry['create'] = $expiry; $this->mRestrictions['create'] = explode( ',', trim( $title_protection['permission'] ) ); } else { // Get rid of the old restrictions $this->mTitleProtection = false; } } else { $this->mRestrictionsExpiry['create'] = 'infinity'; } $this->mRestrictionsLoaded = true; } } } /** * Flush the protection cache in this object and force reload from the database. * This is used when updating protection from WikiPage::doUpdateRestrictions(). */ public function flushRestrictions() { $this->mRestrictionsLoaded = false; $this->mTitleProtection = null; } /** * Purge expired restrictions from the page_restrictions table */ static function purgeExpiredRestrictions() { if ( wfReadOnly() ) { return; } DeferredUpdates::addUpdate( new AtomicSectionUpdate( wfGetDB( DB_MASTER ), __METHOD__, function ( IDatabase $dbw, $fname ) { $dbw->delete( 'page_restrictions', array( 'pr_expiry < ' . $dbw->addQuotes( $dbw->timestamp() ) ), $fname ); $dbw->delete( 'protected_titles', array( 'pt_expiry < ' . $dbw->addQuotes( $dbw->timestamp() ) ), $fname ); } ) ); } /** * Does this have subpages? (Warning, usually requires an extra DB query.) * * @return bool */ public function hasSubpages() { if ( !MWNamespace::hasSubpages( $this->mNamespace ) ) { # Duh return false; } # We dynamically add a member variable for the purpose of this method # alone to cache the result. There's no point in having it hanging # around uninitialized in every Title object; therefore we only add it # if needed and don't declare it statically. if ( $this->mHasSubpages === null ) { $this->mHasSubpages = false; $subpages = $this->getSubpages( 1 ); if ( $subpages instanceof TitleArray ) { $this->mHasSubpages = (bool)$subpages->count(); } } return $this->mHasSubpages; } /** * Get all subpages of this page. * * @param int $limit Maximum number of subpages to fetch; -1 for no limit * @return TitleArray|array TitleArray, or empty array if this page's namespace * doesn't allow subpages */ public function getSubpages( $limit = -1 ) { if ( !MWNamespace::hasSubpages( $this->getNamespace() ) ) { return array(); } $dbr = wfGetDB( DB_SLAVE ); $conds['page_namespace'] = $this->getNamespace(); $conds[] = 'page_title ' . $dbr->buildLike( $this->getDBkey() . '/', $dbr->anyString() ); $options = array(); if ( $limit > -1 ) { $options['LIMIT'] = $limit; } $this->mSubpages = TitleArray::newFromResult( $dbr->select( 'page', array( 'page_id', 'page_namespace', 'page_title', 'page_is_redirect' ), $conds, __METHOD__, $options ) ); return $this->mSubpages; } /** * Is there a version of this page in the deletion archive? * * @return int The number of archived revisions */ public function isDeleted() { if ( $this->getNamespace() < 0 ) { $n = 0; } else { $dbr = wfGetDB( DB_SLAVE ); $n = $dbr->selectField( 'archive', 'COUNT(*)', array( 'ar_namespace' => $this->getNamespace(), 'ar_title' => $this->getDBkey() ), __METHOD__ ); if ( $this->getNamespace() == NS_FILE ) { $n += $dbr->selectField( 'filearchive', 'COUNT(*)', array( 'fa_name' => $this->getDBkey() ), __METHOD__ ); } } return (int)$n; } /** * Is there a version of this page in the deletion archive? * * @return bool */ public function isDeletedQuick() { if ( $this->getNamespace() < 0 ) { return false; } $dbr = wfGetDB( DB_SLAVE ); $deleted = (bool)$dbr->selectField( 'archive', '1', array( 'ar_namespace' => $this->getNamespace(), 'ar_title' => $this->getDBkey() ), __METHOD__ ); if ( !$deleted && $this->getNamespace() == NS_FILE ) { $deleted = (bool)$dbr->selectField( 'filearchive', '1', array( 'fa_name' => $this->getDBkey() ), __METHOD__ ); } return $deleted; } /** * Get the article ID for this Title from the link cache, * adding it if necessary * * @param int $flags A bit field; may be Title::GAID_FOR_UPDATE to select * for update * @return int The ID */ public function getArticleID( $flags = 0 ) { if ( $this->getNamespace() < 0 ) { $this->mArticleID = 0; return $this->mArticleID; } $linkCache = LinkCache::singleton(); if ( $flags & self::GAID_FOR_UPDATE ) { $oldUpdate = $linkCache->forUpdate( true ); $linkCache->clearLink( $this ); $this->mArticleID = $linkCache->addLinkObj( $this ); $linkCache->forUpdate( $oldUpdate ); } else { if ( -1 == $this->mArticleID ) { $this->mArticleID = $linkCache->addLinkObj( $this ); } } return $this->mArticleID; } /** * Is this an article that is a redirect page? * Uses link cache, adding it if necessary * * @param int $flags A bit field; may be Title::GAID_FOR_UPDATE to select for update * @return bool */ public function isRedirect( $flags = 0 ) { if ( !is_null( $this->mRedirect ) ) { return $this->mRedirect; } if ( !$this->getArticleID( $flags ) ) { $this->mRedirect = false; return $this->mRedirect; } $linkCache = LinkCache::singleton(); $linkCache->addLinkObj( $this ); # in case we already had an article ID $cached = $linkCache->getGoodLinkFieldObj( $this, 'redirect' ); if ( $cached === null ) { # Trust LinkCache's state over our own # LinkCache is telling us that the page doesn't exist, despite there being cached # data relating to an existing page in $this->mArticleID. Updaters should clear # LinkCache as appropriate, or use $flags = Title::GAID_FOR_UPDATE. If that flag is # set, then LinkCache will definitely be up to date here, since getArticleID() forces # LinkCache to refresh its data from the master. $this->mRedirect = false; return $this->mRedirect; } $this->mRedirect = (bool)$cached; return $this->mRedirect; } /** * What is the length of this page? * Uses link cache, adding it if necessary * * @param int $flags A bit field; may be Title::GAID_FOR_UPDATE to select for update * @return int */ public function getLength( $flags = 0 ) { if ( $this->mLength != -1 ) { return $this->mLength; } if ( !$this->getArticleID( $flags ) ) { $this->mLength = 0; return $this->mLength; } $linkCache = LinkCache::singleton(); $linkCache->addLinkObj( $this ); # in case we already had an article ID $cached = $linkCache->getGoodLinkFieldObj( $this, 'length' ); if ( $cached === null ) { # Trust LinkCache's state over our own, as for isRedirect() $this->mLength = 0; return $this->mLength; } $this->mLength = intval( $cached ); return $this->mLength; } /** * What is the page_latest field for this page? * * @param int $flags A bit field; may be Title::GAID_FOR_UPDATE to select for update * @return int Int or 0 if the page doesn't exist */ public function getLatestRevID( $flags = 0 ) { if ( !( $flags & Title::GAID_FOR_UPDATE ) && $this->mLatestID !== false ) { return intval( $this->mLatestID ); } if ( !$this->getArticleID( $flags ) ) { $this->mLatestID = 0; return $this->mLatestID; } $linkCache = LinkCache::singleton(); $linkCache->addLinkObj( $this ); # in case we already had an article ID $cached = $linkCache->getGoodLinkFieldObj( $this, 'revision' ); if ( $cached === null ) { # Trust LinkCache's state over our own, as for isRedirect() $this->mLatestID = 0; return $this->mLatestID; } $this->mLatestID = intval( $cached ); return $this->mLatestID; } /** * This clears some fields in this object, and clears any associated * keys in the "bad links" section of the link cache. * * - This is called from WikiPage::doEdit() and WikiPage::insertOn() to allow * loading of the new page_id. It's also called from * WikiPage::doDeleteArticleReal() * * @param int $newid The new Article ID */ public function resetArticleID( $newid ) { $linkCache = LinkCache::singleton(); $linkCache->clearLink( $this ); if ( $newid === false ) { $this->mArticleID = -1; } else { $this->mArticleID = intval( $newid ); } $this->mRestrictionsLoaded = false; $this->mRestrictions = array(); $this->mOldRestrictions = false; $this->mRedirect = null; $this->mLength = -1; $this->mLatestID = false; $this->mContentModel = false; $this->mEstimateRevisions = null; $this->mPageLanguage = false; - $this->mDbPageLanguage = null; + $this->mDbPageLanguage = false; $this->mIsBigDeletion = null; } public static function clearCaches() { $linkCache = LinkCache::singleton(); $linkCache->clear(); $titleCache = self::getTitleCache(); $titleCache->clear(); } /** * Capitalize a text string for a title if it belongs to a namespace that capitalizes * * @param string $text Containing title to capitalize * @param int $ns Namespace index, defaults to NS_MAIN * @return string Containing capitalized title */ public static function capitalize( $text, $ns = NS_MAIN ) { global $wgContLang; if ( MWNamespace::isCapitalized( $ns ) ) { return $wgContLang->ucfirst( $text ); } else { return $text; } } /** * Secure and split - main initialisation function for this object * * Assumes that mDbkeyform has been set, and is urldecoded * and uses underscores, but not otherwise munged. This function * removes illegal characters, splits off the interwiki and * namespace prefixes, sets the other forms, and canonicalizes * everything. * * @throws MalformedTitleException On invalid titles * @return bool True on success */ private function secureAndSplit() { # Initialisation $this->mInterwiki = ''; $this->mFragment = ''; $this->mNamespace = $this->mDefaultNamespace; # Usually NS_MAIN $dbkey = $this->mDbkeyform; // @note: splitTitleString() is a temporary hack to allow MediaWikiTitleCodec to share // the parsing code with Title, while avoiding massive refactoring. // @todo: get rid of secureAndSplit, refactor parsing code. $titleParser = self::getMediaWikiTitleCodec(); // MalformedTitleException can be thrown here $parts = $titleParser->splitTitleString( $dbkey, $this->getDefaultNamespace() ); # Fill fields $this->setFragment( '#' . $parts['fragment'] ); $this->mInterwiki = $parts['interwiki']; $this->mLocalInterwiki = $parts['local_interwiki']; $this->mNamespace = $parts['namespace']; $this->mUserCaseDBKey = $parts['user_case_dbkey']; $this->mDbkeyform = $parts['dbkey']; $this->mUrlform = wfUrlencode( $this->mDbkeyform ); $this->mTextform = strtr( $this->mDbkeyform, '_', ' ' ); # We already know that some pages won't be in the database! if ( $this->isExternal() || $this->mNamespace == NS_SPECIAL ) { $this->mArticleID = 0; } return true; } /** * Get an array of Title objects linking to this Title * Also stores the IDs in the link cache. * * WARNING: do not use this function on arbitrary user-supplied titles! * On heavily-used templates it will max out the memory. * * @param array $options May be FOR UPDATE * @param string $table Table name * @param string $prefix Fields prefix * @return Title[] Array of Title objects linking here */ public function getLinksTo( $options = array(), $table = 'pagelinks', $prefix = 'pl' ) { if ( count( $options ) > 0 ) { $db = wfGetDB( DB_MASTER ); } else { $db = wfGetDB( DB_SLAVE ); } $res = $db->select( array( 'page', $table ), self::getSelectFields(), array( "{$prefix}_from=page_id", "{$prefix}_namespace" => $this->getNamespace(), "{$prefix}_title" => $this->getDBkey() ), __METHOD__, $options ); $retVal = array(); if ( $res->numRows() ) { $linkCache = LinkCache::singleton(); foreach ( $res as $row ) { $titleObj = Title::makeTitle( $row->page_namespace, $row->page_title ); if ( $titleObj ) { $linkCache->addGoodLinkObjFromRow( $titleObj, $row ); $retVal[] = $titleObj; } } } return $retVal; } /** * Get an array of Title objects using this Title as a template * Also stores the IDs in the link cache. * * WARNING: do not use this function on arbitrary user-supplied titles! * On heavily-used templates it will max out the memory. * * @param array $options May be FOR UPDATE * @return Title[] Array of Title the Title objects linking here */ public function getTemplateLinksTo( $options = array() ) { return $this->getLinksTo( $options, 'templatelinks', 'tl' ); } /** * Get an array of Title objects linked from this Title * Also stores the IDs in the link cache. * * WARNING: do not use this function on arbitrary user-supplied titles! * On heavily-used templates it will max out the memory. * * @param array $options May be FOR UPDATE * @param string $table Table name * @param string $prefix Fields prefix * @return array Array of Title objects linking here */ public function getLinksFrom( $options = array(), $table = 'pagelinks', $prefix = 'pl' ) { $id = $this->getArticleID(); # If the page doesn't exist; there can't be any link from this page if ( !$id ) { return array(); } if ( count( $options ) > 0 ) { $db = wfGetDB( DB_MASTER ); } else { $db = wfGetDB( DB_SLAVE ); } $blNamespace = "{$prefix}_namespace"; $blTitle = "{$prefix}_title"; $res = $db->select( array( $table, 'page' ), array_merge( array( $blNamespace, $blTitle ), WikiPage::selectFields() ), array( "{$prefix}_from" => $id ), __METHOD__, $options, array( 'page' => array( 'LEFT JOIN', array( "page_namespace=$blNamespace", "page_title=$blTitle" ) ) ) ); $retVal = array(); $linkCache = LinkCache::singleton(); foreach ( $res as $row ) { if ( $row->page_id ) { $titleObj = Title::newFromRow( $row ); } else { $titleObj = Title::makeTitle( $row->$blNamespace, $row->$blTitle ); $linkCache->addBadLinkObj( $titleObj ); } $retVal[] = $titleObj; } return $retVal; } /** * Get an array of Title objects used on this Title as a template * Also stores the IDs in the link cache. * * WARNING: do not use this function on arbitrary user-supplied titles! * On heavily-used templates it will max out the memory. * * @param array $options May be FOR UPDATE * @return Title[] Array of Title the Title objects used here */ public function getTemplateLinksFrom( $options = array() ) { return $this->getLinksFrom( $options, 'templatelinks', 'tl' ); } /** * Get an array of Title objects referring to non-existent articles linked * from this page. * * @todo check if needed (used only in SpecialBrokenRedirects.php, and * should use redirect table in this case). * @return Title[] Array of Title the Title objects */ public function getBrokenLinksFrom() { if ( $this->getArticleID() == 0 ) { # All links from article ID 0 are false positives return array(); } $dbr = wfGetDB( DB_SLAVE ); $res = $dbr->select( array( 'page', 'pagelinks' ), array( 'pl_namespace', 'pl_title' ), array( 'pl_from' => $this->getArticleID(), 'page_namespace IS NULL' ), __METHOD__, array(), array( 'page' => array( 'LEFT JOIN', array( 'pl_namespace=page_namespace', 'pl_title=page_title' ) ) ) ); $retVal = array(); foreach ( $res as $row ) { $retVal[] = Title::makeTitle( $row->pl_namespace, $row->pl_title ); } return $retVal; } /** * Get a list of URLs to purge from the CDN cache when this * page changes * * @return string[] Array of String the URLs */ public function getCdnUrls() { $urls = array( $this->getInternalURL(), $this->getInternalURL( 'action=history' ) ); $pageLang = $this->getPageLanguage(); if ( $pageLang->hasVariants() ) { $variants = $pageLang->getVariants(); foreach ( $variants as $vCode ) { $urls[] = $this->getInternalURL( $vCode ); } } // If we are looking at a css/js user subpage, purge the action=raw. if ( $this->isJsSubpage() ) { $urls[] = $this->getInternalUrl( 'action=raw&ctype=text/javascript' ); } elseif ( $this->isCssSubpage() ) { $urls[] = $this->getInternalUrl( 'action=raw&ctype=text/css' ); } Hooks::run( 'TitleSquidURLs', array( $this, &$urls ) ); return $urls; } /** * @deprecated since 1.27 use getCdnUrls() */ public function getSquidURLs() { return $this->getCdnUrls(); } /** * Purge all applicable CDN URLs */ public function purgeSquid() { DeferredUpdates::addUpdate( new CdnCacheUpdate( $this->getCdnUrls() ), DeferredUpdates::PRESEND ); } /** * Move this page without authentication * * @deprecated since 1.25 use MovePage class instead * @param Title $nt The new page Title * @return array|bool True on success, getUserPermissionsErrors()-like array on failure */ public function moveNoAuth( &$nt ) { wfDeprecated( __METHOD__, '1.25' ); return $this->moveTo( $nt, false ); } /** * Check whether a given move operation would be valid. * Returns true if ok, or a getUserPermissionsErrors()-like array otherwise * * @deprecated since 1.25, use MovePage's methods instead * @param Title $nt The new title * @param bool $auth Whether to check user permissions (uses $wgUser) * @param string $reason Is the log summary of the move, used for spam checking * @return array|bool True on success, getUserPermissionsErrors()-like array on failure */ public function isValidMoveOperation( &$nt, $auth = true, $reason = '' ) { global $wgUser; if ( !( $nt instanceof Title ) ) { // Normally we'd add this to $errors, but we'll get // lots of syntax errors if $nt is not an object return array( array( 'badtitletext' ) ); } $mp = new MovePage( $this, $nt ); $errors = $mp->isValidMove()->getErrorsArray(); if ( $auth ) { $errors = wfMergeErrorArrays( $errors, $mp->checkPermissions( $wgUser, $reason )->getErrorsArray() ); } return $errors ?: true; } /** * Check if the requested move target is a valid file move target * @todo move this to MovePage * @param Title $nt Target title * @return array List of errors */ protected function validateFileMoveOperation( $nt ) { global $wgUser; $errors = array(); $destFile = wfLocalFile( $nt ); $destFile->load( File::READ_LATEST ); if ( !$wgUser->isAllowed( 'reupload-shared' ) && !$destFile->exists() && wfFindFile( $nt ) ) { $errors[] = array( 'file-exists-sharedrepo' ); } return $errors; } /** * Move a title to a new location * * @deprecated since 1.25, use the MovePage class instead * @param Title $nt The new title * @param bool $auth Indicates whether $wgUser's permissions * should be checked * @param string $reason The reason for the move * @param bool $createRedirect Whether to create a redirect from the old title to the new title. * Ignored if the user doesn't have the suppressredirect right. * @return array|bool True on success, getUserPermissionsErrors()-like array on failure */ public function moveTo( &$nt, $auth = true, $reason = '', $createRedirect = true ) { global $wgUser; $err = $this->isValidMoveOperation( $nt, $auth, $reason ); if ( is_array( $err ) ) { // Auto-block user's IP if the account was "hard" blocked $wgUser->spreadAnyEditBlock(); return $err; } // Check suppressredirect permission if ( $auth && !$wgUser->isAllowed( 'suppressredirect' ) ) { $createRedirect = true; } $mp = new MovePage( $this, $nt ); $status = $mp->move( $wgUser, $reason, $createRedirect ); if ( $status->isOK() ) { return true; } else { return $status->getErrorsArray(); } } /** * Move this page's subpages to be subpages of $nt * * @param Title $nt Move target * @param bool $auth Whether $wgUser's permissions should be checked * @param string $reason The reason for the move * @param bool $createRedirect Whether to create redirects from the old subpages to * the new ones Ignored if the user doesn't have the 'suppressredirect' right * @return array Array with old page titles as keys, and strings (new page titles) or * arrays (errors) as values, or an error array with numeric indices if no pages * were moved */ public function moveSubpages( $nt, $auth = true, $reason = '', $createRedirect = true ) { global $wgMaximumMovedPages; // Check permissions if ( !$this->userCan( 'move-subpages' ) ) { return array( 'cant-move-subpages' ); } // Do the source and target namespaces support subpages? if ( !MWNamespace::hasSubpages( $this->getNamespace() ) ) { return array( 'namespace-nosubpages', MWNamespace::getCanonicalName( $this->getNamespace() ) ); } if ( !MWNamespace::hasSubpages( $nt->getNamespace() ) ) { return array( 'namespace-nosubpages', MWNamespace::getCanonicalName( $nt->getNamespace() ) ); } $subpages = $this->getSubpages( $wgMaximumMovedPages + 1 ); $retval = array(); $count = 0; foreach ( $subpages as $oldSubpage ) { $count++; if ( $count > $wgMaximumMovedPages ) { $retval[$oldSubpage->getPrefixedText()] = array( 'movepage-max-pages', $wgMaximumMovedPages ); break; } // We don't know whether this function was called before // or after moving the root page, so check both // $this and $nt if ( $oldSubpage->getArticleID() == $this->getArticleID() || $oldSubpage->getArticleID() == $nt->getArticleID() ) { // When moving a page to a subpage of itself, // don't move it twice continue; } $newPageName = preg_replace( '#^' . preg_quote( $this->getDBkey(), '#' ) . '#', StringUtils::escapeRegexReplacement( $nt->getDBkey() ), # bug 21234 $oldSubpage->getDBkey() ); if ( $oldSubpage->isTalkPage() ) { $newNs = $nt->getTalkPage()->getNamespace(); } else { $newNs = $nt->getSubjectPage()->getNamespace(); } # Bug 14385: we need makeTitleSafe because the new page names may # be longer than 255 characters. $newSubpage = Title::makeTitleSafe( $newNs, $newPageName ); $success = $oldSubpage->moveTo( $newSubpage, $auth, $reason, $createRedirect ); if ( $success === true ) { $retval[$oldSubpage->getPrefixedText()] = $newSubpage->getPrefixedText(); } else { $retval[$oldSubpage->getPrefixedText()] = $success; } } return $retval; } /** * Checks if this page is just a one-rev redirect. * Adds lock, so don't use just for light purposes. * * @return bool */ public function isSingleRevRedirect() { global $wgContentHandlerUseDB; $dbw = wfGetDB( DB_MASTER ); # Is it a redirect? $fields = array( 'page_is_redirect', 'page_latest', 'page_id' ); if ( $wgContentHandlerUseDB ) { $fields[] = 'page_content_model'; } $row = $dbw->selectRow( 'page', $fields, $this->pageCond(), __METHOD__, array( 'FOR UPDATE' ) ); # Cache some fields we may want $this->mArticleID = $row ? intval( $row->page_id ) : 0; $this->mRedirect = $row ? (bool)$row->page_is_redirect : false; $this->mLatestID = $row ? intval( $row->page_latest ) : false; $this->mContentModel = $row && isset( $row->page_content_model ) ? strval( $row->page_content_model ) : false; if ( !$this->mRedirect ) { return false; } # Does the article have a history? $row = $dbw->selectField( array( 'page', 'revision' ), 'rev_id', array( 'page_namespace' => $this->getNamespace(), 'page_title' => $this->getDBkey(), 'page_id=rev_page', 'page_latest != rev_id' ), __METHOD__, array( 'FOR UPDATE' ) ); # Return true if there was no history return ( $row === false ); } /** * Checks if $this can be moved to a given Title * - Selects for update, so don't call it unless you mean business * * @deprecated since 1.25, use MovePage's methods instead * @param Title $nt The new title to check * @return bool */ public function isValidMoveTarget( $nt ) { # Is it an existing file? if ( $nt->getNamespace() == NS_FILE ) { $file = wfLocalFile( $nt ); $file->load( File::READ_LATEST ); if ( $file->exists() ) { wfDebug( __METHOD__ . ": file exists\n" ); return false; } } # Is it a redirect with no history? if ( !$nt->isSingleRevRedirect() ) { wfDebug( __METHOD__ . ": not a one-rev redirect\n" ); return false; } # Get the article text $rev = Revision::newFromTitle( $nt, false, Revision::READ_LATEST ); if ( !is_object( $rev ) ) { return false; } $content = $rev->getContent(); # Does the redirect point to the source? # Or is it a broken self-redirect, usually caused by namespace collisions? $redirTitle = $content ? $content->getRedirectTarget() : null; if ( $redirTitle ) { if ( $redirTitle->getPrefixedDBkey() != $this->getPrefixedDBkey() && $redirTitle->getPrefixedDBkey() != $nt->getPrefixedDBkey() ) { wfDebug( __METHOD__ . ": redirect points to other page\n" ); return false; } else { return true; } } else { # Fail safe (not a redirect after all. strange.) wfDebug( __METHOD__ . ": failsafe: database sais " . $nt->getPrefixedDBkey() . " is a redirect, but it doesn't contain a valid redirect.\n" ); return false; } } /** * Get categories to which this Title belongs and return an array of * categories' names. * * @return array Array of parents in the form: * $parent => $currentarticle */ public function getParentCategories() { global $wgContLang; $data = array(); $titleKey = $this->getArticleID(); if ( $titleKey === 0 ) { return $data; } $dbr = wfGetDB( DB_SLAVE ); $res = $dbr->select( 'categorylinks', 'cl_to', array( 'cl_from' => $titleKey ), __METHOD__ ); if ( $res->numRows() > 0 ) { foreach ( $res as $row ) { // $data[] = Title::newFromText($wgContLang->getNsText ( NS_CATEGORY ).':'.$row->cl_to); $data[$wgContLang->getNsText( NS_CATEGORY ) . ':' . $row->cl_to] = $this->getFullText(); } } return $data; } /** * Get a tree of parent categories * * @param array $children Array with the children in the keys, to check for circular refs * @return array Tree of parent categories */ public function getParentCategoryTree( $children = array() ) { $stack = array(); $parents = $this->getParentCategories(); if ( $parents ) { foreach ( $parents as $parent => $current ) { if ( array_key_exists( $parent, $children ) ) { # Circular reference $stack[$parent] = array(); } else { $nt = Title::newFromText( $parent ); if ( $nt ) { $stack[$parent] = $nt->getParentCategoryTree( $children + array( $parent => 1 ) ); } } } } return $stack; } /** * Get an associative array for selecting this title from * the "page" table * * @return array Array suitable for the $where parameter of DB::select() */ public function pageCond() { if ( $this->mArticleID > 0 ) { // PK avoids secondary lookups in InnoDB, shouldn't hurt other DBs return array( 'page_id' => $this->mArticleID ); } else { return array( 'page_namespace' => $this->mNamespace, 'page_title' => $this->mDbkeyform ); } } /** * Get the revision ID of the previous revision * * @param int $revId Revision ID. Get the revision that was before this one. * @param int $flags Title::GAID_FOR_UPDATE * @return int|bool Old revision ID, or false if none exists */ public function getPreviousRevisionID( $revId, $flags = 0 ) { $db = ( $flags & self::GAID_FOR_UPDATE ) ? wfGetDB( DB_MASTER ) : wfGetDB( DB_SLAVE ); $revId = $db->selectField( 'revision', 'rev_id', array( 'rev_page' => $this->getArticleID( $flags ), 'rev_id < ' . intval( $revId ) ), __METHOD__, array( 'ORDER BY' => 'rev_id DESC' ) ); if ( $revId === false ) { return false; } else { return intval( $revId ); } } /** * Get the revision ID of the next revision * * @param int $revId Revision ID. Get the revision that was after this one. * @param int $flags Title::GAID_FOR_UPDATE * @return int|bool Next revision ID, or false if none exists */ public function getNextRevisionID( $revId, $flags = 0 ) { $db = ( $flags & self::GAID_FOR_UPDATE ) ? wfGetDB( DB_MASTER ) : wfGetDB( DB_SLAVE ); $revId = $db->selectField( 'revision', 'rev_id', array( 'rev_page' => $this->getArticleID( $flags ), 'rev_id > ' . intval( $revId ) ), __METHOD__, array( 'ORDER BY' => 'rev_id' ) ); if ( $revId === false ) { return false; } else { return intval( $revId ); } } /** * Get the first revision of the page * * @param int $flags Title::GAID_FOR_UPDATE * @return Revision|null If page doesn't exist */ public function getFirstRevision( $flags = 0 ) { $pageId = $this->getArticleID( $flags ); if ( $pageId ) { $db = ( $flags & self::GAID_FOR_UPDATE ) ? wfGetDB( DB_MASTER ) : wfGetDB( DB_SLAVE ); $row = $db->selectRow( 'revision', Revision::selectFields(), array( 'rev_page' => $pageId ), __METHOD__, array( 'ORDER BY' => 'rev_timestamp ASC', 'LIMIT' => 1 ) ); if ( $row ) { return new Revision( $row ); } } return null; } /** * Get the oldest revision timestamp of this page * * @param int $flags Title::GAID_FOR_UPDATE * @return string MW timestamp */ public function getEarliestRevTime( $flags = 0 ) { $rev = $this->getFirstRevision( $flags ); return $rev ? $rev->getTimestamp() : null; } /** * Check if this is a new page * * @return bool */ public function isNewPage() { $dbr = wfGetDB( DB_SLAVE ); return (bool)$dbr->selectField( 'page', 'page_is_new', $this->pageCond(), __METHOD__ ); } /** * Check whether the number of revisions of this page surpasses $wgDeleteRevisionsLimit * * @return bool */ public function isBigDeletion() { global $wgDeleteRevisionsLimit; if ( !$wgDeleteRevisionsLimit ) { return false; } if ( $this->mIsBigDeletion === null ) { $dbr = wfGetDB( DB_SLAVE ); $revCount = $dbr->selectRowCount( 'revision', '1', array( 'rev_page' => $this->getArticleID() ), __METHOD__, array( 'LIMIT' => $wgDeleteRevisionsLimit + 1 ) ); $this->mIsBigDeletion = $revCount > $wgDeleteRevisionsLimit; } return $this->mIsBigDeletion; } /** * Get the approximate revision count of this page. * * @return int */ public function estimateRevisionCount() { if ( !$this->exists() ) { return 0; } if ( $this->mEstimateRevisions === null ) { $dbr = wfGetDB( DB_SLAVE ); $this->mEstimateRevisions = $dbr->estimateRowCount( 'revision', '*', array( 'rev_page' => $this->getArticleID() ), __METHOD__ ); } return $this->mEstimateRevisions; } /** * Get the number of revisions between the given revision. * Used for diffs and other things that really need it. * * @param int|Revision $old Old revision or rev ID (first before range) * @param int|Revision $new New revision or rev ID (first after range) * @param int|null $max Limit of Revisions to count, will be incremented to detect truncations * @return int Number of revisions between these revisions. */ public function countRevisionsBetween( $old, $new, $max = null ) { if ( !( $old instanceof Revision ) ) { $old = Revision::newFromTitle( $this, (int)$old ); } if ( !( $new instanceof Revision ) ) { $new = Revision::newFromTitle( $this, (int)$new ); } if ( !$old || !$new ) { return 0; // nothing to compare } $dbr = wfGetDB( DB_SLAVE ); $conds = array( 'rev_page' => $this->getArticleID(), 'rev_timestamp > ' . $dbr->addQuotes( $dbr->timestamp( $old->getTimestamp() ) ), 'rev_timestamp < ' . $dbr->addQuotes( $dbr->timestamp( $new->getTimestamp() ) ) ); if ( $max !== null ) { return $dbr->selectRowCount( 'revision', '1', $conds, __METHOD__, array( 'LIMIT' => $max + 1 ) // extra to detect truncation ); } else { return (int)$dbr->selectField( 'revision', 'count(*)', $conds, __METHOD__ ); } } /** * Get the authors between the given revisions or revision IDs. * Used for diffs and other things that really need it. * * @since 1.23 * * @param int|Revision $old Old revision or rev ID (first before range by default) * @param int|Revision $new New revision or rev ID (first after range by default) * @param int $limit Maximum number of authors * @param string|array $options (Optional): Single option, or an array of options: * 'include_old' Include $old in the range; $new is excluded. * 'include_new' Include $new in the range; $old is excluded. * 'include_both' Include both $old and $new in the range. * Unknown option values are ignored. * @return array|null Names of revision authors in the range; null if not both revisions exist */ public function getAuthorsBetween( $old, $new, $limit, $options = array() ) { if ( !( $old instanceof Revision ) ) { $old = Revision::newFromTitle( $this, (int)$old ); } if ( !( $new instanceof Revision ) ) { $new = Revision::newFromTitle( $this, (int)$new ); } // XXX: what if Revision objects are passed in, but they don't refer to this title? // Add $old->getPage() != $new->getPage() || $old->getPage() != $this->getArticleID() // in the sanity check below? if ( !$old || !$new ) { return null; // nothing to compare } $authors = array(); $old_cmp = '>'; $new_cmp = '<'; $options = (array)$options; if ( in_array( 'include_old', $options ) ) { $old_cmp = '>='; } if ( in_array( 'include_new', $options ) ) { $new_cmp = '<='; } if ( in_array( 'include_both', $options ) ) { $old_cmp = '>='; $new_cmp = '<='; } // No DB query needed if $old and $new are the same or successive revisions: if ( $old->getId() === $new->getId() ) { return ( $old_cmp === '>' && $new_cmp === '<' ) ? array() : array( $old->getUserText( Revision::RAW ) ); } elseif ( $old->getId() === $new->getParentId() ) { if ( $old_cmp === '>=' && $new_cmp === '<=' ) { $authors[] = $old->getUserText( Revision::RAW ); if ( $old->getUserText( Revision::RAW ) != $new->getUserText( Revision::RAW ) ) { $authors[] = $new->getUserText( Revision::RAW ); } } elseif ( $old_cmp === '>=' ) { $authors[] = $old->getUserText( Revision::RAW ); } elseif ( $new_cmp === '<=' ) { $authors[] = $new->getUserText( Revision::RAW ); } return $authors; } $dbr = wfGetDB( DB_SLAVE ); $res = $dbr->select( 'revision', 'DISTINCT rev_user_text', array( 'rev_page' => $this->getArticleID(), "rev_timestamp $old_cmp " . $dbr->addQuotes( $dbr->timestamp( $old->getTimestamp() ) ), "rev_timestamp $new_cmp " . $dbr->addQuotes( $dbr->timestamp( $new->getTimestamp() ) ) ), __METHOD__, array( 'LIMIT' => $limit + 1 ) // add one so caller knows it was truncated ); foreach ( $res as $row ) { $authors[] = $row->rev_user_text; } return $authors; } /** * Get the number of authors between the given revisions or revision IDs. * Used for diffs and other things that really need it. * * @param int|Revision $old Old revision or rev ID (first before range by default) * @param int|Revision $new New revision or rev ID (first after range by default) * @param int $limit Maximum number of authors * @param string|array $options (Optional): Single option, or an array of options: * 'include_old' Include $old in the range; $new is excluded. * 'include_new' Include $new in the range; $old is excluded. * 'include_both' Include both $old and $new in the range. * Unknown option values are ignored. * @return int Number of revision authors in the range; zero if not both revisions exist */ public function countAuthorsBetween( $old, $new, $limit, $options = array() ) { $authors = $this->getAuthorsBetween( $old, $new, $limit, $options ); return $authors ? count( $authors ) : 0; } /** * Compare with another title. * * @param Title $title * @return bool */ public function equals( Title $title ) { // Note: === is necessary for proper matching of number-like titles. return $this->getInterwiki() === $title->getInterwiki() && $this->getNamespace() == $title->getNamespace() && $this->getDBkey() === $title->getDBkey(); } /** * Check if this title is a subpage of another title * * @param Title $title * @return bool */ public function isSubpageOf( Title $title ) { return $this->getInterwiki() === $title->getInterwiki() && $this->getNamespace() == $title->getNamespace() && strpos( $this->getDBkey(), $title->getDBkey() . '/' ) === 0; } /** * Check if page exists. For historical reasons, this function simply * checks for the existence of the title in the page table, and will * thus return false for interwiki links, special pages and the like. * If you want to know if a title can be meaningfully viewed, you should * probably call the isKnown() method instead. * * @param int $flags An optional bit field; may be Title::GAID_FOR_UPDATE to check * from master/for update * @return bool */ public function exists( $flags = 0 ) { $exists = $this->getArticleID( $flags ) != 0; Hooks::run( 'TitleExists', array( $this, &$exists ) ); return $exists; } /** * Should links to this title be shown as potentially viewable (i.e. as * "bluelinks"), even if there's no record by this title in the page * table? * * This function is semi-deprecated for public use, as well as somewhat * misleadingly named. You probably just want to call isKnown(), which * calls this function internally. * * (ISSUE: Most of these checks are cheap, but the file existence check * can potentially be quite expensive. Including it here fixes a lot of * existing code, but we might want to add an optional parameter to skip * it and any other expensive checks.) * * @return bool */ public function isAlwaysKnown() { $isKnown = null; /** * Allows overriding default behavior for determining if a page exists. * If $isKnown is kept as null, regular checks happen. If it's * a boolean, this value is returned by the isKnown method. * * @since 1.20 * * @param Title $title * @param bool|null $isKnown */ Hooks::run( 'TitleIsAlwaysKnown', array( $this, &$isKnown ) ); if ( !is_null( $isKnown ) ) { return $isKnown; } if ( $this->isExternal() ) { return true; // any interwiki link might be viewable, for all we know } switch ( $this->mNamespace ) { case NS_MEDIA: case NS_FILE: // file exists, possibly in a foreign repo return (bool)wfFindFile( $this ); case NS_SPECIAL: // valid special page return SpecialPageFactory::exists( $this->getDBkey() ); case NS_MAIN: // selflink, possibly with fragment return $this->mDbkeyform == ''; case NS_MEDIAWIKI: // known system message return $this->hasSourceText() !== false; default: return false; } } /** * Does this title refer to a page that can (or might) be meaningfully * viewed? In particular, this function may be used to determine if * links to the title should be rendered as "bluelinks" (as opposed to * "redlinks" to non-existent pages). * Adding something else to this function will cause inconsistency * since LinkHolderArray calls isAlwaysKnown() and does its own * page existence check. * * @return bool */ public function isKnown() { return $this->isAlwaysKnown() || $this->exists(); } /** * Does this page have source text? * * @return bool */ public function hasSourceText() { if ( $this->exists() ) { return true; } if ( $this->mNamespace == NS_MEDIAWIKI ) { // If the page doesn't exist but is a known system message, default // message content will be displayed, same for language subpages- // Use always content language to avoid loading hundreds of languages // to get the link color. global $wgContLang; list( $name, ) = MessageCache::singleton()->figureMessage( $wgContLang->lcfirst( $this->getText() ) ); $message = wfMessage( $name )->inLanguage( $wgContLang )->useDatabase( false ); return $message->exists(); } return false; } /** * Get the default message text or false if the message doesn't exist * * @return string|bool */ public function getDefaultMessageText() { global $wgContLang; if ( $this->getNamespace() != NS_MEDIAWIKI ) { // Just in case return false; } list( $name, $lang ) = MessageCache::singleton()->figureMessage( $wgContLang->lcfirst( $this->getText() ) ); $message = wfMessage( $name )->inLanguage( $lang )->useDatabase( false ); if ( $message->exists() ) { return $message->plain(); } else { return false; } } /** * Updates page_touched for this page; called from LinksUpdate.php * * @param string $purgeTime [optional] TS_MW timestamp * @return bool True if the update succeeded */ public function invalidateCache( $purgeTime = null ) { if ( wfReadOnly() ) { return false; } if ( $this->mArticleID === 0 ) { return true; // avoid gap locking if we know it's not there } $method = __METHOD__; $dbw = wfGetDB( DB_MASTER ); $conds = $this->pageCond(); $dbw->onTransactionIdle( function () use ( $dbw, $conds, $method, $purgeTime ) { $dbTimestamp = $dbw->timestamp( $purgeTime ?: time() ); $dbw->update( 'page', array( 'page_touched' => $dbTimestamp ), $conds + array( 'page_touched < ' . $dbw->addQuotes( $dbTimestamp ) ), $method ); } ); return true; } /** * Update page_touched timestamps and send CDN purge messages for * pages linking to this title. May be sent to the job queue depending * on the number of links. Typically called on create and delete. */ public function touchLinks() { DeferredUpdates::addUpdate( new HTMLCacheUpdate( $this, 'pagelinks' ) ); if ( $this->getNamespace() == NS_CATEGORY ) { DeferredUpdates::addUpdate( new HTMLCacheUpdate( $this, 'categorylinks' ) ); } } /** * Get the last touched timestamp * * @param IDatabase $db Optional db * @return string Last-touched timestamp */ public function getTouched( $db = null ) { if ( $db === null ) { $db = wfGetDB( DB_SLAVE ); } $touched = $db->selectField( 'page', 'page_touched', $this->pageCond(), __METHOD__ ); return $touched; } /** * Get the timestamp when this page was updated since the user last saw it. * * @param User $user * @return string|null */ public function getNotificationTimestamp( $user = null ) { global $wgUser; // Assume current user if none given if ( !$user ) { $user = $wgUser; } // Check cache first $uid = $user->getId(); if ( !$uid ) { return false; } // avoid isset here, as it'll return false for null entries if ( array_key_exists( $uid, $this->mNotificationTimestamp ) ) { return $this->mNotificationTimestamp[$uid]; } // Don't cache too much! if ( count( $this->mNotificationTimestamp ) >= self::CACHE_MAX ) { $this->mNotificationTimestamp = array(); } $watchedItem = WatchedItem::fromUserTitle( $user, $this ); $this->mNotificationTimestamp[$uid] = $watchedItem->getNotificationTimestamp(); return $this->mNotificationTimestamp[$uid]; } /** * Generate strings used for xml 'id' names in monobook tabs * * @param string $prepend Defaults to 'nstab-' * @return string XML 'id' name */ public function getNamespaceKey( $prepend = 'nstab-' ) { global $wgContLang; // Gets the subject namespace if this title $namespace = MWNamespace::getSubject( $this->getNamespace() ); // Checks if canonical namespace name exists for namespace if ( MWNamespace::exists( $this->getNamespace() ) ) { // Uses canonical namespace name $namespaceKey = MWNamespace::getCanonicalName( $namespace ); } else { // Uses text of namespace $namespaceKey = $this->getSubjectNsText(); } // Makes namespace key lowercase $namespaceKey = $wgContLang->lc( $namespaceKey ); // Uses main if ( $namespaceKey == '' ) { $namespaceKey = 'main'; } // Changes file to image for backwards compatibility if ( $namespaceKey == 'file' ) { $namespaceKey = 'image'; } return $prepend . $namespaceKey; } /** * Get all extant redirects to this Title * * @param int|null $ns Single namespace to consider; null to consider all namespaces * @return Title[] Array of Title redirects to this title */ public function getRedirectsHere( $ns = null ) { $redirs = array(); $dbr = wfGetDB( DB_SLAVE ); $where = array( 'rd_namespace' => $this->getNamespace(), 'rd_title' => $this->getDBkey(), 'rd_from = page_id' ); if ( $this->isExternal() ) { $where['rd_interwiki'] = $this->getInterwiki(); } else { $where[] = 'rd_interwiki = ' . $dbr->addQuotes( '' ) . ' OR rd_interwiki IS NULL'; } if ( !is_null( $ns ) ) { $where['page_namespace'] = $ns; } $res = $dbr->select( array( 'redirect', 'page' ), array( 'page_namespace', 'page_title' ), $where, __METHOD__ ); foreach ( $res as $row ) { $redirs[] = self::newFromRow( $row ); } return $redirs; } /** * Check if this Title is a valid redirect target * * @return bool */ public function isValidRedirectTarget() { global $wgInvalidRedirectTargets; if ( $this->isSpecialPage() ) { // invalid redirect targets are stored in a global array, but explicitly disallow Userlogout here if ( $this->isSpecial( 'Userlogout' ) ) { return false; } foreach ( $wgInvalidRedirectTargets as $target ) { if ( $this->isSpecial( $target ) ) { return false; } } } return true; } /** * Get a backlink cache object * * @return BacklinkCache */ public function getBacklinkCache() { return BacklinkCache::get( $this ); } /** * Whether the magic words __INDEX__ and __NOINDEX__ function for this page. * * @return bool */ public function canUseNoindex() { global $wgContentNamespaces, $wgExemptFromUserRobotsControl; $bannedNamespaces = is_null( $wgExemptFromUserRobotsControl ) ? $wgContentNamespaces : $wgExemptFromUserRobotsControl; return !in_array( $this->mNamespace, $bannedNamespaces ); } /** * Returns the raw sort key to be used for categories, with the specified * prefix. This will be fed to Collation::getSortKey() to get a * binary sortkey that can be used for actual sorting. * * @param string $prefix The prefix to be used, specified using * {{defaultsort:}} or like [[Category:Foo|prefix]]. Empty for no * prefix. * @return string */ public function getCategorySortkey( $prefix = '' ) { $unprefixed = $this->getText(); // Anything that uses this hook should only depend // on the Title object passed in, and should probably // tell the users to run updateCollations.php --force // in order to re-sort existing category relations. Hooks::run( 'GetDefaultSortkey', array( $this, &$unprefixed ) ); if ( $prefix !== '' ) { # Separate with a line feed, so the unprefixed part is only used as # a tiebreaker when two pages have the exact same prefix. # In UCA, tab is the only character that can sort above LF # so we strip both of them from the original prefix. $prefix = strtr( $prefix, "\n\t", ' ' ); return "$prefix\n$unprefixed"; } return $unprefixed; } + /** + * Returns the page language code saved in the database, if $wgPageLanguageUseDB is set + * to true in LocalSettings.php, otherwise returns false. If there is no language saved in + * the db, it will return NULL. + * + * @return string|null|boolean + */ + private function getDbPageLanguageCode() { + global $wgPageLanguageUseDB; + + // check, if the page language could be saved in the database, and if so and + // the value is not requested already, lookup the page language using LinkCache + if ( $wgPageLanguageUseDB && $this->mDbPageLanguage === false ) { + $linkCache = LinkCache::singleton(); + $linkCache->addLinkObj( $this ); + $this->mDbPageLanguage = $linkCache->getGoodLinkFieldObj( $this, 'lang' ); + } + + return $this->mDbPageLanguage; + } + /** * Get the language in which the content of this page is written in * wikitext. Defaults to $wgContLang, but in certain cases it can be * e.g. $wgLang (such as special pages, which are in the user language). * * @since 1.18 * @return Language */ public function getPageLanguage() { global $wgLang, $wgLanguageCode; if ( $this->isSpecialPage() ) { // special pages are in the user language return $wgLang; } // Checking if DB language is set - if ( $this->mDbPageLanguage ) { - return wfGetLangObj( $this->mDbPageLanguage ); + $dbPageLanguage = $this->getDbPageLanguageCode(); + if ( $dbPageLanguage ) { + return wfGetLangObj( $dbPageLanguage ); } if ( !$this->mPageLanguage || $this->mPageLanguage[1] !== $wgLanguageCode ) { // Note that this may depend on user settings, so the cache should // be only per-request. // NOTE: ContentHandler::getPageLanguage() may need to load the // content to determine the page language! // Checking $wgLanguageCode hasn't changed for the benefit of unit // tests. $contentHandler = ContentHandler::getForTitle( $this ); $langObj = $contentHandler->getPageLanguage( $this ); $this->mPageLanguage = array( $langObj->getCode(), $wgLanguageCode ); } else { $langObj = wfGetLangObj( $this->mPageLanguage[0] ); } return $langObj; } /** * Get the language in which the content of this page is written when * viewed by user. Defaults to $wgContLang, but in certain cases it can be * e.g. $wgLang (such as special pages, which are in the user language). * * @since 1.20 * @return Language */ public function getPageViewLanguage() { global $wgLang; if ( $this->isSpecialPage() ) { // If the user chooses a variant, the content is actually // in a language whose code is the variant code. $variant = $wgLang->getPreferredVariant(); if ( $wgLang->getCode() !== $variant ) { return Language::factory( $variant ); } return $wgLang; } // @note Can't be cached persistently, depends on user settings. // @note ContentHandler::getPageViewLanguage() may need to load the // content to determine the page language! $contentHandler = ContentHandler::getForTitle( $this ); $pageLang = $contentHandler->getPageViewLanguage( $this ); return $pageLang; } /** * Get a list of rendered edit notices for this page. * * Array is keyed by the original message key, and values are rendered using parseAsBlock, so * they will already be wrapped in paragraphs. * * @since 1.21 * @param int $oldid Revision ID that's being edited * @return array */ public function getEditNotices( $oldid = 0 ) { $notices = array(); // Optional notice for the entire namespace $editnotice_ns = 'editnotice-' . $this->getNamespace(); $msg = wfMessage( $editnotice_ns ); if ( $msg->exists() ) { $html = $msg->parseAsBlock(); // Edit notices may have complex logic, but output nothing (T91715) if ( trim( $html ) !== '' ) { $notices[$editnotice_ns] = Html::rawElement( 'div', array( 'class' => array( 'mw-editnotice', 'mw-editnotice-namespace', Sanitizer::escapeClass( "mw-$editnotice_ns" ) ) ), $html ); } } if ( MWNamespace::hasSubpages( $this->getNamespace() ) ) { // Optional notice for page itself and any parent page $parts = explode( '/', $this->getDBkey() ); $editnotice_base = $editnotice_ns; while ( count( $parts ) > 0 ) { $editnotice_base .= '-' . array_shift( $parts ); $msg = wfMessage( $editnotice_base ); if ( $msg->exists() ) { $html = $msg->parseAsBlock(); if ( trim( $html ) !== '' ) { $notices[$editnotice_base] = Html::rawElement( 'div', array( 'class' => array( 'mw-editnotice', 'mw-editnotice-base', Sanitizer::escapeClass( "mw-$editnotice_base" ) ) ), $html ); } } } } else { // Even if there are no subpages in namespace, we still don't want "/" in MediaWiki message keys $editnoticeText = $editnotice_ns . '-' . strtr( $this->getDBkey(), '/', '-' ); $msg = wfMessage( $editnoticeText ); if ( $msg->exists() ) { $html = $msg->parseAsBlock(); if ( trim( $html ) !== '' ) { $notices[$editnoticeText] = Html::rawElement( 'div', array( 'class' => array( 'mw-editnotice', 'mw-editnotice-page', Sanitizer::escapeClass( "mw-$editnoticeText" ) ) ), $html ); } } } Hooks::run( 'TitleGetEditNotices', array( $this, $oldid, &$notices ) ); return $notices; } /** * @return array */ public function __sleep() { return array( 'mNamespace', 'mDbkeyform', 'mFragment', 'mInterwiki', 'mLocalInterwiki', 'mUserCaseDBKey', 'mDefaultNamespace', ); } public function __wakeup() { $this->mArticleID = ( $this->mNamespace >= 0 ) ? -1 : 0; $this->mUrlform = wfUrlencode( $this->mDbkeyform ); $this->mTextform = strtr( $this->mDbkeyform, '_', ' ' ); } } diff --git a/includes/api/ApiPageSet.php b/includes/api/ApiPageSet.php index c6abf40e000..cb08ec10edb 100644 --- a/includes/api/ApiPageSet.php +++ b/includes/api/ApiPageSet.php @@ -1,1438 +1,1442 @@ <?php /** * * * Created on Sep 24, 2006 * * Copyright © 2006, 2013 Yuri Astrakhan "<Firstname><Lastname>@gmail.com" * * This program is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation; either version 2 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License along * with this program; if not, write to the Free Software Foundation, Inc., * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. * http://www.gnu.org/copyleft/gpl.html * * @file */ /** * This class contains a list of pages that the client has requested. * Initially, when the client passes in titles=, pageids=, or revisions= * parameter, an instance of the ApiPageSet class will normalize titles, * determine if the pages/revisions exist, and prefetch any additional page * data requested. * * When a generator is used, the result of the generator will become the input * for the second instance of this class, and all subsequent actions will use * the second instance for all their work. * * @ingroup API * @since 1.21 derives from ApiBase instead of ApiQueryBase */ class ApiPageSet extends ApiBase { /** * Constructor flag: The new instance of ApiPageSet will ignore the 'generator=' parameter * @since 1.21 */ const DISABLE_GENERATORS = 1; private $mDbSource; private $mParams; private $mResolveRedirects; private $mConvertTitles; private $mAllowGenerator; private $mAllPages = array(); // [ns][dbkey] => page_id or negative when missing private $mTitles = array(); private $mGoodAndMissingPages = array(); // [ns][dbkey] => page_id or negative when missing private $mGoodPages = array(); // [ns][dbkey] => page_id private $mGoodTitles = array(); private $mMissingPages = array(); // [ns][dbkey] => fake page_id private $mMissingTitles = array(); /** @var array [fake_page_id] => array( 'title' => $title, 'invalidreason' => $reason ) */ private $mInvalidTitles = array(); private $mMissingPageIDs = array(); private $mRedirectTitles = array(); private $mSpecialTitles = array(); private $mNormalizedTitles = array(); private $mInterwikiTitles = array(); /** @var Title[] */ private $mPendingRedirectIDs = array(); private $mResolvedRedirectTitles = array(); private $mConvertedTitles = array(); private $mGoodRevIDs = array(); private $mLiveRevIDs = array(); private $mDeletedRevIDs = array(); private $mMissingRevIDs = array(); private $mGeneratorData = array(); // [ns][dbkey] => data array private $mFakePageId = -1; private $mCacheMode = 'public'; private $mRequestedPageFields = array(); /** @var int */ private $mDefaultNamespace = NS_MAIN; /** @var callable|null */ private $mRedirectMergePolicy; /** * Add all items from $values into the result * @param array $result Output * @param array $values Values to add * @param string $flag The name of the boolean flag to mark this element * @param string $name If given, name of the value */ private static function addValues( array &$result, $values, $flag = null, $name = null ) { foreach ( $values as $val ) { if ( $val instanceof Title ) { $v = array(); ApiQueryBase::addTitleInfo( $v, $val ); } elseif ( $name !== null ) { $v = array( $name => $val ); } else { $v = $val; } if ( $flag !== null ) { $v[$flag] = true; } $result[] = $v; } } /** * @param ApiBase $dbSource Module implementing getDB(). * Allows PageSet to reuse existing db connection from the shared state like ApiQuery. * @param int $flags Zero or more flags like DISABLE_GENERATORS * @param int $defaultNamespace The namespace to use if none is specified by a prefix. * @since 1.21 accepts $flags instead of two boolean values */ public function __construct( ApiBase $dbSource, $flags = 0, $defaultNamespace = NS_MAIN ) { parent::__construct( $dbSource->getMain(), $dbSource->getModuleName() ); $this->mDbSource = $dbSource; $this->mAllowGenerator = ( $flags & ApiPageSet::DISABLE_GENERATORS ) == 0; $this->mDefaultNamespace = $defaultNamespace; $this->mParams = $this->extractRequestParams(); $this->mResolveRedirects = $this->mParams['redirects']; $this->mConvertTitles = $this->mParams['converttitles']; } /** * In case execute() is not called, call this method to mark all relevant parameters as used * This prevents unused parameters from being reported as warnings */ public function executeDryRun() { $this->executeInternal( true ); } /** * Populate the PageSet from the request parameters. */ public function execute() { $this->executeInternal( false ); } /** * Populate the PageSet from the request parameters. * @param bool $isDryRun If true, instantiates generator, but only to mark * relevant parameters as used */ private function executeInternal( $isDryRun ) { $generatorName = $this->mAllowGenerator ? $this->mParams['generator'] : null; if ( isset( $generatorName ) ) { $dbSource = $this->mDbSource; if ( !$dbSource instanceof ApiQuery ) { // If the parent container of this pageset is not ApiQuery, we must create it to run generator $dbSource = $this->getMain()->getModuleManager()->getModule( 'query' ); } $generator = $dbSource->getModuleManager()->getModule( $generatorName, null, true ); if ( $generator === null ) { $this->dieUsage( 'Unknown generator=' . $generatorName, 'badgenerator' ); } if ( !$generator instanceof ApiQueryGeneratorBase ) { $this->dieUsage( "Module $generatorName cannot be used as a generator", 'badgenerator' ); } // Create a temporary pageset to store generator's output, // add any additional fields generator may need, and execute pageset to populate titles/pageids $tmpPageSet = new ApiPageSet( $dbSource, ApiPageSet::DISABLE_GENERATORS ); $generator->setGeneratorMode( $tmpPageSet ); $this->mCacheMode = $generator->getCacheMode( $generator->extractRequestParams() ); if ( !$isDryRun ) { $generator->requestExtraData( $tmpPageSet ); } $tmpPageSet->executeInternal( $isDryRun ); // populate this pageset with the generator output if ( !$isDryRun ) { $generator->executeGenerator( $this ); Hooks::run( 'APIQueryGeneratorAfterExecute', array( &$generator, &$this ) ); } else { // Prevent warnings from being reported on these parameters $main = $this->getMain(); foreach ( $generator->extractRequestParams() as $paramName => $param ) { $main->getVal( $generator->encodeParamName( $paramName ) ); } } if ( !$isDryRun ) { $this->resolvePendingRedirects(); } } else { // Only one of the titles/pageids/revids is allowed at the same time $dataSource = null; if ( isset( $this->mParams['titles'] ) ) { $dataSource = 'titles'; } if ( isset( $this->mParams['pageids'] ) ) { if ( isset( $dataSource ) ) { $this->dieUsage( "Cannot use 'pageids' at the same time as '$dataSource'", 'multisource' ); } $dataSource = 'pageids'; } if ( isset( $this->mParams['revids'] ) ) { if ( isset( $dataSource ) ) { $this->dieUsage( "Cannot use 'revids' at the same time as '$dataSource'", 'multisource' ); } $dataSource = 'revids'; } if ( !$isDryRun ) { // Populate page information with the original user input switch ( $dataSource ) { case 'titles': $this->initFromTitles( $this->mParams['titles'] ); break; case 'pageids': $this->initFromPageIds( $this->mParams['pageids'] ); break; case 'revids': if ( $this->mResolveRedirects ) { $this->setWarning( 'Redirect resolution cannot be used ' . 'together with the revids= parameter. Any redirects ' . 'the revids= point to have not been resolved.' ); } $this->mResolveRedirects = false; $this->initFromRevIDs( $this->mParams['revids'] ); break; default: // Do nothing - some queries do not need any of the data sources. break; } } } } /** * Check whether this PageSet is resolving redirects * @return bool */ public function isResolvingRedirects() { return $this->mResolveRedirects; } /** * Return the parameter name that is the source of data for this PageSet * * If multiple source parameters are specified (e.g. titles and pageids), * one will be named arbitrarily. * * @return string|null */ public function getDataSource() { if ( $this->mAllowGenerator && isset( $this->mParams['generator'] ) ) { return 'generator'; } if ( isset( $this->mParams['titles'] ) ) { return 'titles'; } if ( isset( $this->mParams['pageids'] ) ) { return 'pageids'; } if ( isset( $this->mParams['revids'] ) ) { return 'revids'; } return null; } /** * Request an additional field from the page table. * Must be called before execute() * @param string $fieldName Field name */ public function requestField( $fieldName ) { $this->mRequestedPageFields[$fieldName] = null; } /** * Get the value of a custom field previously requested through * requestField() * @param string $fieldName Field name * @return mixed Field value */ public function getCustomField( $fieldName ) { return $this->mRequestedPageFields[$fieldName]; } /** * Get the fields that have to be queried from the page table: * the ones requested through requestField() and a few basic ones * we always need * @return array Array of field names */ public function getPageTableFields() { // Ensure we get minimum required fields // DON'T change this order $pageFlds = array( 'page_namespace' => null, 'page_title' => null, 'page_id' => null, ); if ( $this->mResolveRedirects ) { $pageFlds['page_is_redirect'] = null; } if ( $this->getConfig()->get( 'ContentHandlerUseDB' ) ) { $pageFlds['page_content_model'] = null; } + if ( $this->getConfig()->get( 'PageLanguageUseDB' ) ) { + $pageFlds['page_lang'] = null; + } + // only store non-default fields $this->mRequestedPageFields = array_diff_key( $this->mRequestedPageFields, $pageFlds ); $pageFlds = array_merge( $pageFlds, $this->mRequestedPageFields ); return array_keys( $pageFlds ); } /** * Returns an array [ns][dbkey] => page_id for all requested titles. * page_id is a unique negative number in case title was not found. * Invalid titles will also have negative page IDs and will be in namespace 0 * @return array */ public function getAllTitlesByNamespace() { return $this->mAllPages; } /** * All Title objects provided. * @return Title[] */ public function getTitles() { return $this->mTitles; } /** * Returns the number of unique pages (not revisions) in the set. * @return int */ public function getTitleCount() { return count( $this->mTitles ); } /** * Returns an array [ns][dbkey] => page_id for all good titles. * @return array */ public function getGoodTitlesByNamespace() { return $this->mGoodPages; } /** * Title objects that were found in the database. * @return Title[] Array page_id (int) => Title (obj) */ public function getGoodTitles() { return $this->mGoodTitles; } /** * Returns the number of found unique pages (not revisions) in the set. * @return int */ public function getGoodTitleCount() { return count( $this->mGoodTitles ); } /** * Returns an array [ns][dbkey] => fake_page_id for all missing titles. * fake_page_id is a unique negative number. * @return array */ public function getMissingTitlesByNamespace() { return $this->mMissingPages; } /** * Title objects that were NOT found in the database. * The array's index will be negative for each item * @return Title[] */ public function getMissingTitles() { return $this->mMissingTitles; } /** * Returns an array [ns][dbkey] => page_id for all good and missing titles. * @return array */ public function getGoodAndMissingTitlesByNamespace() { return $this->mGoodAndMissingPages; } /** * Title objects for good and missing titles. * @return array */ public function getGoodAndMissingTitles() { return $this->mGoodTitles + $this->mMissingTitles; } /** * Titles that were deemed invalid by Title::newFromText() * The array's index will be unique and negative for each item * @deprecated since 1.26, use self::getInvalidTitlesAndReasons() * @return string[] Array of strings (not Title objects) */ public function getInvalidTitles() { wfDeprecated( __METHOD__, '1.26' ); return array_map( function ( $t ) { return $t['title']; }, $this->mInvalidTitles ); } /** * Titles that were deemed invalid by Title::newFromText() * The array's index will be unique and negative for each item * @return array[] Array of arrays with 'title' and 'invalidreason' properties */ public function getInvalidTitlesAndReasons() { return $this->mInvalidTitles; } /** * Page IDs that were not found in the database * @return array Array of page IDs */ public function getMissingPageIDs() { return $this->mMissingPageIDs; } /** * Get a list of redirect resolutions - maps a title to its redirect * target, as an array of output-ready arrays * @return Title[] */ public function getRedirectTitles() { return $this->mRedirectTitles; } /** * Get a list of redirect resolutions - maps a title to its redirect * target. Includes generator data for redirect source when available. * @param ApiResult $result * @return array Array of prefixed_title (string) => Title object * @since 1.21 */ public function getRedirectTitlesAsResult( $result = null ) { $values = array(); foreach ( $this->getRedirectTitles() as $titleStrFrom => $titleTo ) { $r = array( 'from' => strval( $titleStrFrom ), 'to' => $titleTo->getPrefixedText(), ); if ( $titleTo->hasFragment() ) { $r['tofragment'] = $titleTo->getFragment(); } if ( $titleTo->isExternal() ) { $r['tointerwiki'] = $titleTo->getInterwiki(); } if ( isset( $this->mResolvedRedirectTitles[$titleStrFrom] ) ) { $titleFrom = $this->mResolvedRedirectTitles[$titleStrFrom]; $ns = $titleFrom->getNamespace(); $dbkey = $titleFrom->getDBkey(); if ( isset( $this->mGeneratorData[$ns][$dbkey] ) ) { $r = array_merge( $this->mGeneratorData[$ns][$dbkey], $r ); } } $values[] = $r; } if ( !empty( $values ) && $result ) { ApiResult::setIndexedTagName( $values, 'r' ); } return $values; } /** * Get a list of title normalizations - maps a title to its normalized * version. * @return array Array of raw_prefixed_title (string) => prefixed_title (string) */ public function getNormalizedTitles() { return $this->mNormalizedTitles; } /** * Get a list of title normalizations - maps a title to its normalized * version in the form of result array. * @param ApiResult $result * @return array Array of raw_prefixed_title (string) => prefixed_title (string) * @since 1.21 */ public function getNormalizedTitlesAsResult( $result = null ) { $values = array(); foreach ( $this->getNormalizedTitles() as $rawTitleStr => $titleStr ) { $values[] = array( 'from' => $rawTitleStr, 'to' => $titleStr ); } if ( !empty( $values ) && $result ) { ApiResult::setIndexedTagName( $values, 'n' ); } return $values; } /** * Get a list of title conversions - maps a title to its converted * version. * @return array Array of raw_prefixed_title (string) => prefixed_title (string) */ public function getConvertedTitles() { return $this->mConvertedTitles; } /** * Get a list of title conversions - maps a title to its converted * version as a result array. * @param ApiResult $result * @return array Array of (from, to) strings * @since 1.21 */ public function getConvertedTitlesAsResult( $result = null ) { $values = array(); foreach ( $this->getConvertedTitles() as $rawTitleStr => $titleStr ) { $values[] = array( 'from' => $rawTitleStr, 'to' => $titleStr ); } if ( !empty( $values ) && $result ) { ApiResult::setIndexedTagName( $values, 'c' ); } return $values; } /** * Get a list of interwiki titles - maps a title to its interwiki * prefix. * @return array Array of raw_prefixed_title (string) => interwiki_prefix (string) */ public function getInterwikiTitles() { return $this->mInterwikiTitles; } /** * Get a list of interwiki titles - maps a title to its interwiki * prefix as result. * @param ApiResult $result * @param bool $iwUrl * @return array Array of raw_prefixed_title (string) => interwiki_prefix (string) * @since 1.21 */ public function getInterwikiTitlesAsResult( $result = null, $iwUrl = false ) { $values = array(); foreach ( $this->getInterwikiTitles() as $rawTitleStr => $interwikiStr ) { $item = array( 'title' => $rawTitleStr, 'iw' => $interwikiStr, ); if ( $iwUrl ) { $title = Title::newFromText( $rawTitleStr ); $item['url'] = $title->getFullURL( '', false, PROTO_CURRENT ); } $values[] = $item; } if ( !empty( $values ) && $result ) { ApiResult::setIndexedTagName( $values, 'i' ); } return $values; } /** * Get an array of invalid/special/missing titles. * * @param array $invalidChecks List of types of invalid titles to include. * Recognized values are: * - invalidTitles: Titles and reasons from $this->getInvalidTitlesAndReasons() * - special: Titles from $this->getSpecialTitles() * - missingIds: ids from $this->getMissingPageIDs() * - missingRevIds: ids from $this->getMissingRevisionIDs() * - missingTitles: Titles from $this->getMissingTitles() * - interwikiTitles: Titles from $this->getInterwikiTitlesAsResult() * @return array Array suitable for inclusion in the response * @since 1.23 */ public function getInvalidTitlesAndRevisions( $invalidChecks = array( 'invalidTitles', 'special', 'missingIds', 'missingRevIds', 'missingTitles', 'interwikiTitles' ) ) { $result = array(); if ( in_array( "invalidTitles", $invalidChecks ) ) { self::addValues( $result, $this->getInvalidTitlesAndReasons(), 'invalid' ); } if ( in_array( "special", $invalidChecks ) ) { self::addValues( $result, $this->getSpecialTitles(), 'special', 'title' ); } if ( in_array( "missingIds", $invalidChecks ) ) { self::addValues( $result, $this->getMissingPageIDs(), 'missing', 'pageid' ); } if ( in_array( "missingRevIds", $invalidChecks ) ) { self::addValues( $result, $this->getMissingRevisionIDs(), 'missing', 'revid' ); } if ( in_array( "missingTitles", $invalidChecks ) ) { self::addValues( $result, $this->getMissingTitles(), 'missing' ); } if ( in_array( "interwikiTitles", $invalidChecks ) ) { self::addValues( $result, $this->getInterwikiTitlesAsResult() ); } return $result; } /** * Get the list of valid revision IDs (requested with the revids= parameter) * @return array Array of revID (int) => pageID (int) */ public function getRevisionIDs() { return $this->mGoodRevIDs; } /** * Get the list of non-deleted revision IDs (requested with the revids= parameter) * @return array Array of revID (int) => pageID (int) */ public function getLiveRevisionIDs() { return $this->mLiveRevIDs; } /** * Get the list of revision IDs that were associated with deleted titles. * @return array Array of revID (int) => pageID (int) */ public function getDeletedRevisionIDs() { return $this->mDeletedRevIDs; } /** * Revision IDs that were not found in the database * @return array Array of revision IDs */ public function getMissingRevisionIDs() { return $this->mMissingRevIDs; } /** * Revision IDs that were not found in the database as result array. * @param ApiResult $result * @return array Array of revision IDs * @since 1.21 */ public function getMissingRevisionIDsAsResult( $result = null ) { $values = array(); foreach ( $this->getMissingRevisionIDs() as $revid ) { $values[$revid] = array( 'revid' => $revid ); } if ( !empty( $values ) && $result ) { ApiResult::setIndexedTagName( $values, 'rev' ); } return $values; } /** * Get the list of titles with negative namespace * @return Title[] */ public function getSpecialTitles() { return $this->mSpecialTitles; } /** * Returns the number of revisions (requested with revids= parameter). * @return int Number of revisions. */ public function getRevisionCount() { return count( $this->getRevisionIDs() ); } /** * Populate this PageSet from a list of Titles * @param array $titles Array of Title objects */ public function populateFromTitles( $titles ) { $this->initFromTitles( $titles ); } /** * Populate this PageSet from a list of page IDs * @param array $pageIDs Array of page IDs */ public function populateFromPageIDs( $pageIDs ) { $this->initFromPageIds( $pageIDs ); } /** * Populate this PageSet from a rowset returned from the database * * Note that the query result must include the columns returned by * $this->getPageTableFields(). * * @param IDatabase $db * @param ResultWrapper $queryResult Query result object */ public function populateFromQueryResult( $db, $queryResult ) { $this->initFromQueryResult( $queryResult ); } /** * Populate this PageSet from a list of revision IDs * @param array $revIDs Array of revision IDs */ public function populateFromRevisionIDs( $revIDs ) { $this->initFromRevIDs( $revIDs ); } /** * Extract all requested fields from the row received from the database * @param stdClass $row Result row */ public function processDbRow( $row ) { // Store Title object in various data structures $title = Title::newFromRow( $row ); $pageId = intval( $row->page_id ); $this->mAllPages[$row->page_namespace][$row->page_title] = $pageId; $this->mTitles[] = $title; if ( $this->mResolveRedirects && $row->page_is_redirect == '1' ) { $this->mPendingRedirectIDs[$pageId] = $title; } else { $this->mGoodPages[$row->page_namespace][$row->page_title] = $pageId; $this->mGoodAndMissingPages[$row->page_namespace][$row->page_title] = $pageId; $this->mGoodTitles[$pageId] = $title; } foreach ( $this->mRequestedPageFields as $fieldName => &$fieldValues ) { $fieldValues[$pageId] = $row->$fieldName; } } /** * Do not use, does nothing, will be removed * @deprecated since 1.21 */ public function finishPageSetGeneration() { wfDeprecated( __METHOD__, '1.21' ); } /** * This method populates internal variables with page information * based on the given array of title strings. * * Steps: * #1 For each title, get data from `page` table * #2 If page was not found in the DB, store it as missing * * Additionally, when resolving redirects: * #3 If no more redirects left, stop. * #4 For each redirect, get its target from the `redirect` table. * #5 Substitute the original LinkBatch object with the new list * #6 Repeat from step #1 * * @param array $titles Array of Title objects or strings */ private function initFromTitles( $titles ) { // Get validated and normalized title objects $linkBatch = $this->processTitlesArray( $titles ); if ( $linkBatch->isEmpty() ) { return; } $db = $this->getDB(); $set = $linkBatch->constructSet( 'page', $db ); // Get pageIDs data from the `page` table $res = $db->select( 'page', $this->getPageTableFields(), $set, __METHOD__ ); // Hack: get the ns:titles stored in array(ns => array(titles)) format $this->initFromQueryResult( $res, $linkBatch->data, true ); // process Titles // Resolve any found redirects $this->resolvePendingRedirects(); } /** * Does the same as initFromTitles(), but is based on page IDs instead * @param array $pageids Array of page IDs */ private function initFromPageIds( $pageids ) { if ( !$pageids ) { return; } $pageids = array_map( 'intval', $pageids ); // paranoia $remaining = array_flip( $pageids ); $pageids = self::getPositiveIntegers( $pageids ); $res = null; if ( !empty( $pageids ) ) { $set = array( 'page_id' => $pageids ); $db = $this->getDB(); // Get pageIDs data from the `page` table $res = $db->select( 'page', $this->getPageTableFields(), $set, __METHOD__ ); } $this->initFromQueryResult( $res, $remaining, false ); // process PageIDs // Resolve any found redirects $this->resolvePendingRedirects(); } /** * Iterate through the result of the query on 'page' table, * and for each row create and store title object and save any extra fields requested. * @param ResultWrapper $res DB Query result * @param array $remaining Array of either pageID or ns/title elements (optional). * If given, any missing items will go to $mMissingPageIDs and $mMissingTitles * @param bool $processTitles Must be provided together with $remaining. * If true, treat $remaining as an array of [ns][title] * If false, treat it as an array of [pageIDs] */ private function initFromQueryResult( $res, &$remaining = null, $processTitles = null ) { if ( !is_null( $remaining ) && is_null( $processTitles ) ) { ApiBase::dieDebug( __METHOD__, 'Missing $processTitles parameter when $remaining is provided' ); } $usernames = array(); if ( $res ) { foreach ( $res as $row ) { $pageId = intval( $row->page_id ); // Remove found page from the list of remaining items if ( isset( $remaining ) ) { if ( $processTitles ) { unset( $remaining[$row->page_namespace][$row->page_title] ); } else { unset( $remaining[$pageId] ); } } // Store any extra fields requested by modules $this->processDbRow( $row ); // Need gender information if ( MWNamespace::hasGenderDistinction( $row->page_namespace ) ) { $usernames[] = $row->page_title; } } } if ( isset( $remaining ) ) { // Any items left in the $remaining list are added as missing if ( $processTitles ) { // The remaining titles in $remaining are non-existent pages foreach ( $remaining as $ns => $dbkeys ) { foreach ( array_keys( $dbkeys ) as $dbkey ) { $title = Title::makeTitle( $ns, $dbkey ); $this->mAllPages[$ns][$dbkey] = $this->mFakePageId; $this->mMissingPages[$ns][$dbkey] = $this->mFakePageId; $this->mGoodAndMissingPages[$ns][$dbkey] = $this->mFakePageId; $this->mMissingTitles[$this->mFakePageId] = $title; $this->mFakePageId--; $this->mTitles[] = $title; // need gender information if ( MWNamespace::hasGenderDistinction( $ns ) ) { $usernames[] = $dbkey; } } } } else { // The remaining pageids do not exist if ( !$this->mMissingPageIDs ) { $this->mMissingPageIDs = array_keys( $remaining ); } else { $this->mMissingPageIDs = array_merge( $this->mMissingPageIDs, array_keys( $remaining ) ); } } } // Get gender information $genderCache = GenderCache::singleton(); $genderCache->doQuery( $usernames, __METHOD__ ); } /** * Does the same as initFromTitles(), but is based on revision IDs * instead * @param array $revids Array of revision IDs */ private function initFromRevIDs( $revids ) { if ( !$revids ) { return; } $revids = array_map( 'intval', $revids ); // paranoia $db = $this->getDB(); $pageids = array(); $remaining = array_flip( $revids ); $revids = self::getPositiveIntegers( $revids ); if ( !empty( $revids ) ) { $tables = array( 'revision', 'page' ); $fields = array( 'rev_id', 'rev_page' ); $where = array( 'rev_id' => $revids, 'rev_page = page_id' ); // Get pageIDs data from the `page` table $res = $db->select( $tables, $fields, $where, __METHOD__ ); foreach ( $res as $row ) { $revid = intval( $row->rev_id ); $pageid = intval( $row->rev_page ); $this->mGoodRevIDs[$revid] = $pageid; $this->mLiveRevIDs[$revid] = $pageid; $pageids[$pageid] = ''; unset( $remaining[$revid] ); } } $this->mMissingRevIDs = array_keys( $remaining ); // Populate all the page information $this->initFromPageIds( array_keys( $pageids ) ); // If the user can see deleted revisions, pull out the corresponding // titles from the archive table and include them too. We ignore // ar_page_id because deleted revisions are tied by title, not page_id. if ( !empty( $this->mMissingRevIDs ) && $this->getUser()->isAllowed( 'deletedhistory' ) ) { $remaining = array_flip( $this->mMissingRevIDs ); $tables = array( 'archive' ); $fields = array( 'ar_rev_id', 'ar_namespace', 'ar_title' ); $where = array( 'ar_rev_id' => $this->mMissingRevIDs ); $res = $db->select( $tables, $fields, $where, __METHOD__ ); $titles = array(); foreach ( $res as $row ) { $revid = intval( $row->ar_rev_id ); $titles[$revid] = Title::makeTitle( $row->ar_namespace, $row->ar_title ); unset( $remaining[$revid] ); } $this->initFromTitles( $titles ); foreach ( $titles as $revid => $title ) { $ns = $title->getNamespace(); $dbkey = $title->getDBkey(); // Handle converted titles if ( !isset( $this->mAllPages[$ns][$dbkey] ) && isset( $this->mConvertedTitles[$title->getPrefixedText()] ) ) { $title = Title::newFromText( $this->mConvertedTitles[$title->getPrefixedText()] ); $ns = $title->getNamespace(); $dbkey = $title->getDBkey(); } if ( isset( $this->mAllPages[$ns][$dbkey] ) ) { $this->mGoodRevIDs[$revid] = $this->mAllPages[$ns][$dbkey]; $this->mDeletedRevIDs[$revid] = $this->mAllPages[$ns][$dbkey]; } else { $remaining[$revid] = true; } } $this->mMissingRevIDs = array_keys( $remaining ); } } /** * Resolve any redirects in the result if redirect resolution was * requested. This function is called repeatedly until all redirects * have been resolved. */ private function resolvePendingRedirects() { if ( $this->mResolveRedirects ) { $db = $this->getDB(); $pageFlds = $this->getPageTableFields(); // Repeat until all redirects have been resolved // The infinite loop is prevented by keeping all known pages in $this->mAllPages while ( $this->mPendingRedirectIDs ) { // Resolve redirects by querying the pagelinks table, and repeat the process // Create a new linkBatch object for the next pass $linkBatch = $this->getRedirectTargets(); if ( $linkBatch->isEmpty() ) { break; } $set = $linkBatch->constructSet( 'page', $db ); if ( $set === false ) { break; } // Get pageIDs data from the `page` table $res = $db->select( 'page', $pageFlds, $set, __METHOD__ ); // Hack: get the ns:titles stored in array(ns => array(titles)) format $this->initFromQueryResult( $res, $linkBatch->data, true ); } } } /** * Get the targets of the pending redirects from the database * * Also creates entries in the redirect table for redirects that don't * have one. * @return LinkBatch */ private function getRedirectTargets() { $lb = new LinkBatch(); $db = $this->getDB(); $res = $db->select( 'redirect', array( 'rd_from', 'rd_namespace', 'rd_fragment', 'rd_interwiki', 'rd_title' ), array( 'rd_from' => array_keys( $this->mPendingRedirectIDs ) ), __METHOD__ ); foreach ( $res as $row ) { $rdfrom = intval( $row->rd_from ); $from = $this->mPendingRedirectIDs[$rdfrom]->getPrefixedText(); $to = Title::makeTitle( $row->rd_namespace, $row->rd_title, $row->rd_fragment, $row->rd_interwiki ); $this->mResolvedRedirectTitles[$from] = $this->mPendingRedirectIDs[$rdfrom]; unset( $this->mPendingRedirectIDs[$rdfrom] ); if ( $to->isExternal() ) { $this->mInterwikiTitles[$to->getPrefixedText()] = $to->getInterwiki(); } elseif ( !isset( $this->mAllPages[$row->rd_namespace][$row->rd_title] ) ) { $lb->add( $row->rd_namespace, $row->rd_title ); } $this->mRedirectTitles[$from] = $to; } if ( $this->mPendingRedirectIDs ) { // We found pages that aren't in the redirect table // Add them foreach ( $this->mPendingRedirectIDs as $id => $title ) { $page = WikiPage::factory( $title ); $rt = $page->insertRedirect(); if ( !$rt ) { // What the hell. Let's just ignore this continue; } $lb->addObj( $rt ); $from = $title->getPrefixedText(); $this->mResolvedRedirectTitles[$from] = $title; $this->mRedirectTitles[$from] = $rt; unset( $this->mPendingRedirectIDs[$id] ); } } return $lb; } /** * Get the cache mode for the data generated by this module. * All PageSet users should take into account whether this returns a more-restrictive * cache mode than the using module itself. For possible return values and other * details about cache modes, see ApiMain::setCacheMode() * * Public caching will only be allowed if *all* the modules that supply * data for a given request return a cache mode of public. * * @param array|null $params * @return string * @since 1.21 */ public function getCacheMode( $params = null ) { return $this->mCacheMode; } /** * Given an array of title strings, convert them into Title objects. * Alternatively, an array of Title objects may be given. * This method validates access rights for the title, * and appends normalization values to the output. * * @param array $titles Array of Title objects or strings * @return LinkBatch */ private function processTitlesArray( $titles ) { $usernames = array(); $linkBatch = new LinkBatch(); foreach ( $titles as $title ) { if ( is_string( $title ) ) { try { $titleObj = Title::newFromTextThrow( $title, $this->mDefaultNamespace ); } catch ( MalformedTitleException $ex ) { // Handle invalid titles gracefully $this->mAllPages[0][$title] = $this->mFakePageId; $this->mInvalidTitles[$this->mFakePageId] = array( 'title' => $title, 'invalidreason' => $ex->getMessage(), ); $this->mFakePageId--; continue; // There's nothing else we can do } } else { $titleObj = $title; } $unconvertedTitle = $titleObj->getPrefixedText(); $titleWasConverted = false; if ( $titleObj->isExternal() ) { // This title is an interwiki link. $this->mInterwikiTitles[$unconvertedTitle] = $titleObj->getInterwiki(); } else { // Variants checking global $wgContLang; if ( $this->mConvertTitles && count( $wgContLang->getVariants() ) > 1 && !$titleObj->exists() ) { // Language::findVariantLink will modify titleText and titleObj into // the canonical variant if possible $titleText = is_string( $title ) ? $title : $titleObj->getPrefixedText(); $wgContLang->findVariantLink( $titleText, $titleObj ); $titleWasConverted = $unconvertedTitle !== $titleObj->getPrefixedText(); } if ( $titleObj->getNamespace() < 0 ) { // Handle Special and Media pages $titleObj = $titleObj->fixSpecialName(); $this->mSpecialTitles[$this->mFakePageId] = $titleObj; $this->mFakePageId--; } else { // Regular page $linkBatch->addObj( $titleObj ); } } // Make sure we remember the original title that was // given to us. This way the caller can correlate new // titles with the originally requested when e.g. the // namespace is localized or the capitalization is // different if ( $titleWasConverted ) { $this->mConvertedTitles[$unconvertedTitle] = $titleObj->getPrefixedText(); // In this case the page can't be Special. if ( is_string( $title ) && $title !== $unconvertedTitle ) { $this->mNormalizedTitles[$title] = $unconvertedTitle; } } elseif ( is_string( $title ) && $title !== $titleObj->getPrefixedText() ) { $this->mNormalizedTitles[$title] = $titleObj->getPrefixedText(); } // Need gender information if ( MWNamespace::hasGenderDistinction( $titleObj->getNamespace() ) ) { $usernames[] = $titleObj->getText(); } } // Get gender information $genderCache = GenderCache::singleton(); $genderCache->doQuery( $usernames, __METHOD__ ); return $linkBatch; } /** * Set data for a title. * * This data may be extracted into an ApiResult using * self::populateGeneratorData. This should generally be limited to * data that is likely to be particularly useful to end users rather than * just being a dump of everything returned in non-generator mode. * * Redirects here will *not* be followed, even if 'redirects' was * specified, since in the case of multiple redirects we can't know which * source's data to use on the target. * * @param Title $title * @param array $data */ public function setGeneratorData( Title $title, array $data ) { $ns = $title->getNamespace(); $dbkey = $title->getDBkey(); $this->mGeneratorData[$ns][$dbkey] = $data; } /** * Controls how generator data about a redirect source is merged into * the generator data for the redirect target. When not set no data * is merged. Note that if multiple titles redirect to the same target * the order of operations is undefined. * * Example to include generated data from redirect in target, prefering * the data generated for the destination when there is a collision: * @code * $pageSet->setRedirectMergePolicy( function( array $current, array $new ) { * return $current + $new; * } ); * @endcode * * @param callable|null $callable Recieves two array arguments, first the * generator data for the redirect target and second the generator data * for the redirect source. Returns the resulting generator data to use * for the redirect target. */ public function setRedirectMergePolicy( $callable ) { $this->mRedirectMergePolicy = $callable; } /** * Populate the generator data for all titles in the result * * The page data may be inserted into an ApiResult object or into an * associative array. The $path parameter specifies the path within the * ApiResult or array to find the "pages" node. * * The "pages" node itself must be an associative array mapping the page ID * or fake page ID values returned by this pageset (see * self::getAllTitlesByNamespace() and self::getSpecialTitles()) to * associative arrays of page data. Each of those subarrays will have the * data from self::setGeneratorData() merged in. * * Data that was set by self::setGeneratorData() for pages not in the * "pages" node will be ignored. * * @param ApiResult|array &$result * @param array $path * @return bool Whether the data fit */ public function populateGeneratorData( &$result, array $path = array() ) { if ( $result instanceof ApiResult ) { $data = $result->getResultData( $path ); if ( $data === null ) { return true; } } else { $data = &$result; foreach ( $path as $key ) { if ( !isset( $data[$key] ) ) { // Path isn't in $result, so nothing to add, so everything // "fits" return true; } $data = &$data[$key]; } } foreach ( $this->mGeneratorData as $ns => $dbkeys ) { if ( $ns === -1 ) { $pages = array(); foreach ( $this->mSpecialTitles as $id => $title ) { $pages[$title->getDBkey()] = $id; } } else { if ( !isset( $this->mAllPages[$ns] ) ) { // No known titles in the whole namespace. Skip it. continue; } $pages = $this->mAllPages[$ns]; } foreach ( $dbkeys as $dbkey => $genData ) { if ( !isset( $pages[$dbkey] ) ) { // Unknown title. Forget it. continue; } $pageId = $pages[$dbkey]; if ( !isset( $data[$pageId] ) ) { // $pageId didn't make it into the result. Ignore it. continue; } if ( $result instanceof ApiResult ) { $path2 = array_merge( $path, array( $pageId ) ); foreach ( $genData as $key => $value ) { if ( !$result->addValue( $path2, $key, $value ) ) { return false; } } } else { $data[$pageId] = array_merge( $data[$pageId], $genData ); } } } // Merge data generated about redirect titles into the redirect destination if ( $this->mRedirectMergePolicy ) { foreach ( $this->mResolvedRedirectTitles as $titleFrom ) { $dest = $titleFrom; while ( isset( $this->mRedirectTitles[$dest->getPrefixedText()] ) ) { $dest = $this->mRedirectTitles[$dest->getPrefixedText()]; } $fromNs = $titleFrom->getNamespace(); $fromDBkey = $titleFrom->getDBkey(); $toPageId = $dest->getArticleID(); if ( isset( $data[$toPageId] ) && isset( $this->mGeneratorData[$fromNs][$fromDBkey] ) ) { // It is necesary to set both $data and add to $result, if an ApiResult, // to ensure multiple redirects to the same destination are all merged. $data[$toPageId] = call_user_func( $this->mRedirectMergePolicy, $data[$toPageId], $this->mGeneratorData[$fromNs][$fromDBkey] ); if ( $result instanceof ApiResult ) { if ( !$result->addValue( $path, $toPageId, $data[$toPageId], ApiResult::OVERRIDE ) ) { return false; } } } } } return true; } /** * Get the database connection (read-only) * @return DatabaseBase */ protected function getDB() { return $this->mDbSource->getDB(); } /** * Returns the input array of integers with all values < 0 removed * * @param array $array * @return array */ private static function getPositiveIntegers( $array ) { // bug 25734 API: possible issue with revids validation // It seems with a load of revision rows, MySQL gets upset // Remove any < 0 integers, as they can't be valid foreach ( $array as $i => $int ) { if ( $int < 0 ) { unset( $array[$i] ); } } return $array; } public function getAllowedParams( $flags = 0 ) { $result = array( 'titles' => array( ApiBase::PARAM_ISMULTI => true, ApiBase::PARAM_HELP_MSG => 'api-pageset-param-titles', ), 'pageids' => array( ApiBase::PARAM_TYPE => 'integer', ApiBase::PARAM_ISMULTI => true, ApiBase::PARAM_HELP_MSG => 'api-pageset-param-pageids', ), 'revids' => array( ApiBase::PARAM_TYPE => 'integer', ApiBase::PARAM_ISMULTI => true, ApiBase::PARAM_HELP_MSG => 'api-pageset-param-revids', ), 'generator' => array( ApiBase::PARAM_TYPE => null, ApiBase::PARAM_HELP_MSG => 'api-pageset-param-generator', ApiBase::PARAM_SUBMODULE_PARAM_PREFIX => 'g', ), 'redirects' => array( ApiBase::PARAM_DFLT => false, ApiBase::PARAM_HELP_MSG => $this->mAllowGenerator ? 'api-pageset-param-redirects-generator' : 'api-pageset-param-redirects-nogenerator', ), 'converttitles' => array( ApiBase::PARAM_DFLT => false, ApiBase::PARAM_HELP_MSG => array( 'api-pageset-param-converttitles', new DeferredStringifier( function ( IContextSource $context ) { return $context->getLanguage() ->commaList( LanguageConverter::$languagesWithVariants ); }, $this ) ), ), ); if ( !$this->mAllowGenerator ) { unset( $result['generator'] ); } elseif ( $flags & ApiBase::GET_VALUES_FOR_HELP ) { $result['generator'][ApiBase::PARAM_TYPE] = 'submodule'; $result['generator'][ApiBase::PARAM_SUBMODULE_MAP] = $this->getGenerators(); } return $result; } private static $generators = null; /** * Get an array of all available generators * @return array */ private function getGenerators() { if ( self::$generators === null ) { $query = $this->mDbSource; if ( !( $query instanceof ApiQuery ) ) { // If the parent container of this pageset is not ApiQuery, // we must create it to get module manager $query = $this->getMain()->getModuleManager()->getModule( 'query' ); } $gens = array(); $prefix = $query->getModulePath() . '+'; $mgr = $query->getModuleManager(); foreach ( $mgr->getNamesWithClasses() as $name => $class ) { if ( is_subclass_of( $class, 'ApiQueryGeneratorBase' ) ) { $gens[$name] = $prefix . $name; } } ksort( $gens ); self::$generators = $gens; } return self::$generators; } } diff --git a/includes/cache/LinkBatch.php b/includes/cache/LinkBatch.php index 8f334cc435e..88d8b5a1e56 100644 --- a/includes/cache/LinkBatch.php +++ b/includes/cache/LinkBatch.php @@ -1,240 +1,243 @@ <?php /** * Batch query to determine page existence. * * This program is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation; either version 2 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License along * with this program; if not, write to the Free Software Foundation, Inc., * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. * http://www.gnu.org/copyleft/gpl.html * * @file * @ingroup Cache */ /** * Class representing a list of titles * The execute() method checks them all for existence and adds them to a LinkCache object * * @ingroup Cache */ class LinkBatch { /** * 2-d array, first index namespace, second index dbkey, value arbitrary */ public $data = array(); /** * For debugging which method is using this class. */ protected $caller; function __construct( $arr = array() ) { foreach ( $arr as $item ) { $this->addObj( $item ); } } /** * Use ->setCaller( __METHOD__ ) to indicate which code is using this * class. Only used in debugging output. * @since 1.17 * * @param string $caller */ public function setCaller( $caller ) { $this->caller = $caller; } /** * @param LinkTarget $linkTarget */ public function addObj( $linkTarget ) { if ( is_object( $linkTarget ) ) { $this->add( $linkTarget->getNamespace(), $linkTarget->getDBkey() ); } else { wfDebug( "Warning: LinkBatch::addObj got invalid LinkTarget object\n" ); } } /** * @param int $ns * @param string $dbkey */ public function add( $ns, $dbkey ) { if ( $ns < 0 ) { return; } if ( !array_key_exists( $ns, $this->data ) ) { $this->data[$ns] = array(); } $this->data[$ns][strtr( $dbkey, ' ', '_' )] = 1; } /** * Set the link list to a given 2-d array * First key is the namespace, second is the DB key, value arbitrary * * @param array $array */ public function setArray( $array ) { $this->data = $array; } /** * Returns true if no pages have been added, false otherwise. * * @return bool */ public function isEmpty() { return $this->getSize() == 0; } /** * Returns the size of the batch. * * @return int */ public function getSize() { return count( $this->data ); } /** * Do the query and add the results to the LinkCache object * * @return array Mapping PDBK to ID */ public function execute() { $linkCache = LinkCache::singleton(); return $this->executeInto( $linkCache ); } /** * Do the query and add the results to a given LinkCache object * Return an array mapping PDBK to ID * * @param LinkCache $cache * @return array Remaining IDs */ protected function executeInto( &$cache ) { $res = $this->doQuery(); $this->doGenderQuery(); $ids = $this->addResultToCache( $cache, $res ); return $ids; } /** * Add a ResultWrapper containing IDs and titles to a LinkCache object. * As normal, titles will go into the static Title cache field. * This function *also* stores extra fields of the title used for link * parsing to avoid extra DB queries. * * @param LinkCache $cache * @param ResultWrapper $res * @return array Array of remaining titles */ public function addResultToCache( $cache, $res ) { if ( !$res ) { return array(); } // For each returned entry, add it to the list of good links, and remove it from $remaining $ids = array(); $remaining = $this->data; foreach ( $res as $row ) { $title = Title::makeTitle( $row->page_namespace, $row->page_title ); $cache->addGoodLinkObjFromRow( $title, $row ); $ids[$title->getPrefixedDBkey()] = $row->page_id; unset( $remaining[$row->page_namespace][$row->page_title] ); } // The remaining links in $data are bad links, register them as such foreach ( $remaining as $ns => $dbkeys ) { foreach ( $dbkeys as $dbkey => $unused ) { $title = Title::makeTitle( $ns, $dbkey ); $cache->addBadLinkObj( $title ); $ids[$title->getPrefixedDBkey()] = 0; } } return $ids; } /** * Perform the existence test query, return a ResultWrapper with page_id fields * @return bool|ResultWrapper */ public function doQuery() { - global $wgContentHandlerUseDB; + global $wgContentHandlerUseDB, $wgPageLanguageUseDB; if ( $this->isEmpty() ) { return false; } // This is similar to LinkHolderArray::replaceInternal $dbr = wfGetDB( DB_SLAVE ); $table = 'page'; $fields = array( 'page_id', 'page_namespace', 'page_title', 'page_len', 'page_is_redirect', 'page_latest' ); if ( $wgContentHandlerUseDB ) { $fields[] = 'page_content_model'; } + if ( $wgPageLanguageUseDB ) { + $fields[] = 'page_lang'; + } $conds = $this->constructSet( 'page', $dbr ); // Do query $caller = __METHOD__; if ( strval( $this->caller ) !== '' ) { $caller .= " (for {$this->caller})"; } $res = $dbr->select( $table, $fields, $conds, $caller ); return $res; } /** * Do (and cache) {{GENDER:...}} information for userpages in this LinkBatch * * @return bool Whether the query was successful */ public function doGenderQuery() { if ( $this->isEmpty() ) { return false; } global $wgContLang; if ( !$wgContLang->needsGenderDistinction() ) { return false; } $genderCache = GenderCache::singleton(); $genderCache->doLinkBatch( $this->data, $this->caller ); return true; } /** * Construct a WHERE clause which will match all the given titles. * * @param string $prefix The appropriate table's field name prefix ('page', 'pl', etc) * @param IDatabase $db DatabaseBase object to use * @return string|bool String with SQL where clause fragment, or false if no items. */ public function constructSet( $prefix, $db ) { return $db->makeWhereFrom2d( $this->data, "{$prefix}_namespace", "{$prefix}_title" ); } } diff --git a/includes/cache/LinkCache.php b/includes/cache/LinkCache.php index 5ea926bfb56..f1998919cc2 100644 --- a/includes/cache/LinkCache.php +++ b/includes/cache/LinkCache.php @@ -1,275 +1,281 @@ <?php /** * Page existence cache. * * This program is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation; either version 2 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License along * with this program; if not, write to the Free Software Foundation, Inc., * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. * http://www.gnu.org/copyleft/gpl.html * * @file * @ingroup Cache */ /** * Cache for article titles (prefixed DB keys) and ids linked from one source * * @ingroup Cache */ class LinkCache { /** * @var HashBagOStuff */ private $mGoodLinks; /** * @var HashBagOStuff */ private $mBadLinks; private $mForUpdate = false; /** * How many Titles to store. There are two caches, so the amount actually * stored in memory can be up to twice this. */ const MAX_SIZE = 10000; /** * @var LinkCache */ protected static $instance; public function __construct() { $this->mGoodLinks = new HashBagOStuff( array( 'maxKeys' => self::MAX_SIZE ) ); $this->mBadLinks = new HashBagOStuff( array( 'maxKeys' => self::MAX_SIZE ) ); } /** * Get an instance of this class. * * @return LinkCache */ public static function &singleton() { if ( !self::$instance ) { self::$instance = new LinkCache; } return self::$instance; } /** * Destroy the singleton instance * * A new one will be created next time singleton() is called. * * @since 1.22 */ public static function destroySingleton() { self::$instance = null; } /** * Set the singleton instance to a given object. * * Since we do not have an interface for LinkCache, you have to be sure the * given object implements all the LinkCache public methods. * * @param LinkCache $instance * @since 1.22 */ public static function setSingleton( LinkCache $instance ) { self::$instance = $instance; } /** * General accessor to get/set whether the master DB should be used * * This used to also set the FOR UPDATE option (locking the rows read * in order to avoid link table inconsistency), which was later removed * for performance on wikis with a high edit rate. * * @param bool $update * @return bool */ public function forUpdate( $update = null ) { return wfSetVar( $this->mForUpdate, $update ); } /** * @param string $title * @return int Page ID or zero */ public function getGoodLinkID( $title ) { $info = $this->mGoodLinks->get( $title ); if ( !$info ) { return 0; } return $info['id']; } /** * Get a field of a title object from cache. * If this link is not a cached good title, it will return NULL. * @param Title $title * @param string $field ('length','redirect','revision','model') * @return string|int|null */ public function getGoodLinkFieldObj( $title, $field ) { $dbkey = $title->getPrefixedDBkey(); $info = $this->mGoodLinks->get( $dbkey ); if ( !$info ) { return null; } return $info[$field]; } /** * @param string $title * @return bool */ public function isBadLink( $title ) { // Use get() to ensure it records as used for LRU. return $this->mBadLinks->get( $title ) !== false; } /** * Add a link for the title to the link cache * * @param int $id Page's ID * @param Title $title * @param int $len Text's length * @param int $redir Whether the page is a redirect * @param int $revision Latest revision's ID * @param string|null $model Latest revision's content model ID + * @param string|null $lang Language code of the page, if not the content language */ public function addGoodLinkObj( $id, Title $title, $len = -1, $redir = null, - $revision = 0, $model = null + $revision = 0, $model = null, $lang = null ) { $dbkey = $title->getPrefixedDBkey(); $this->mGoodLinks->set( $dbkey, array( 'id' => (int)$id, 'length' => (int)$len, 'redirect' => (int)$redir, 'revision' => (int)$revision, 'model' => $model ? (string)$model : null, + 'lang' => $lang ? (string)$lang : null, ) ); } /** * Same as above with better interface. * @since 1.19 * @param Title $title * @param stdClass $row Object which has the fields page_id, page_is_redirect, * page_latest and page_content_model */ public function addGoodLinkObjFromRow( Title $title, $row ) { $dbkey = $title->getPrefixedDBkey(); $this->mGoodLinks->set( $dbkey, array( 'id' => intval( $row->page_id ), 'length' => intval( $row->page_len ), 'redirect' => intval( $row->page_is_redirect ), 'revision' => intval( $row->page_latest ), 'model' => !empty( $row->page_content_model ) ? strval( $row->page_content_model ) : null, + 'lang' => !empty( $row->page_lang ) ? strval( $row->page_lang ) : null, ) ); } /** * @param Title $title */ public function addBadLinkObj( Title $title ) { $dbkey = $title->getPrefixedDBkey(); if ( !$this->isBadLink( $dbkey ) ) { $this->mBadLinks->set( $dbkey, 1 ); } } public function clearBadLink( $title ) { $this->mBadLinks->clear( array( $title ) ); } /** * @param Title $title */ public function clearLink( $title ) { $dbkey = $title->getPrefixedDBkey(); $this->mBadLinks->delete( $dbkey ); $this->mGoodLinks->delete( $dbkey ); } /** * Add a title to the link cache, return the page_id or zero if non-existent * * @param string $title Title to add * @return int Page ID or zero */ public function addLink( $title ) { $nt = Title::newFromDBkey( $title ); if ( !$nt ) { return 0; } return $this->addLinkObj( $nt ); } /** * Add a title to the link cache, return the page_id or zero if non-existent * * @param Title $nt Title object to add * @return int Page ID or zero */ public function addLinkObj( Title $nt ) { - global $wgContentHandlerUseDB; + global $wgContentHandlerUseDB, $wgPageLanguageUseDB; $key = $nt->getPrefixedDBkey(); if ( $this->isBadLink( $key ) || $nt->isExternal() ) { return 0; } $id = $this->getGoodLinkID( $key ); if ( $id != 0 ) { return $id; } if ( $key === '' ) { return 0; } // Some fields heavily used for linking... $db = $this->mForUpdate ? wfGetDB( DB_MASTER ) : wfGetDB( DB_SLAVE ); $fields = array( 'page_id', 'page_len', 'page_is_redirect', 'page_latest' ); if ( $wgContentHandlerUseDB ) { $fields[] = 'page_content_model'; } + if ( $wgPageLanguageUseDB ) { + $fields[] = 'page_lang'; + } $row = $db->selectRow( 'page', $fields, array( 'page_namespace' => $nt->getNamespace(), 'page_title' => $nt->getDBkey() ), __METHOD__ ); if ( $row !== false ) { $this->addGoodLinkObjFromRow( $nt, $row ); $id = intval( $row->page_id ); } else { $this->addBadLinkObj( $nt ); $id = 0; } return $id; } /** * Clears cache */ public function clear() { $this->mGoodLinks->clear(); $this->mBadLinks->clear(); } } diff --git a/includes/parser/LinkHolderArray.php b/includes/parser/LinkHolderArray.php index 7fc9a16d94f..dfc4b535fb9 100644 --- a/includes/parser/LinkHolderArray.php +++ b/includes/parser/LinkHolderArray.php @@ -1,666 +1,672 @@ <?php /** * Holder of replacement pairs for wiki links * * This program is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation; either version 2 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License along * with this program; if not, write to the Free Software Foundation, Inc., * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. * http://www.gnu.org/copyleft/gpl.html * * @file * @ingroup Parser */ /** * @ingroup Parser */ class LinkHolderArray { public $internals = array(); public $interwikis = array(); public $size = 0; /** * @var Parser */ public $parent; protected $tempIdOffset; /** * @param Parser $parent */ public function __construct( $parent ) { $this->parent = $parent; } /** * Reduce memory usage to reduce the impact of circular references */ public function __destruct() { foreach ( $this as $name => $value ) { unset( $this->$name ); } } /** * Don't serialize the parent object, it is big, and not needed when it is * a parameter to mergeForeign(), which is the only application of * serializing at present. * * Compact the titles, only serialize the text form. * @return array */ public function __sleep() { foreach ( $this->internals as &$nsLinks ) { foreach ( $nsLinks as &$entry ) { unset( $entry['title'] ); } } unset( $nsLinks ); unset( $entry ); foreach ( $this->interwikis as &$entry ) { unset( $entry['title'] ); } unset( $entry ); return array( 'internals', 'interwikis', 'size' ); } /** * Recreate the Title objects */ public function __wakeup() { foreach ( $this->internals as &$nsLinks ) { foreach ( $nsLinks as &$entry ) { $entry['title'] = Title::newFromText( $entry['pdbk'] ); } } unset( $nsLinks ); unset( $entry ); foreach ( $this->interwikis as &$entry ) { $entry['title'] = Title::newFromText( $entry['pdbk'] ); } unset( $entry ); } /** * Merge another LinkHolderArray into this one * @param LinkHolderArray $other */ public function merge( $other ) { foreach ( $other->internals as $ns => $entries ) { $this->size += count( $entries ); if ( !isset( $this->internals[$ns] ) ) { $this->internals[$ns] = $entries; } else { $this->internals[$ns] += $entries; } } $this->interwikis += $other->interwikis; } /** * Merge a LinkHolderArray from another parser instance into this one. The * keys will not be preserved. Any text which went with the old * LinkHolderArray and needs to work with the new one should be passed in * the $texts array. The strings in this array will have their link holders * converted for use in the destination link holder. The resulting array of * strings will be returned. * * @param LinkHolderArray $other * @param array $texts Array of strings * @return array */ public function mergeForeign( $other, $texts ) { $this->tempIdOffset = $idOffset = $this->parent->nextLinkID(); $maxId = 0; # Renumber internal links foreach ( $other->internals as $ns => $nsLinks ) { foreach ( $nsLinks as $key => $entry ) { $newKey = $idOffset + $key; $this->internals[$ns][$newKey] = $entry; $maxId = $newKey > $maxId ? $newKey : $maxId; } } $texts = preg_replace_callback( '/(<!--LINK \d+:)(\d+)(-->)/', array( $this, 'mergeForeignCallback' ), $texts ); # Renumber interwiki links foreach ( $other->interwikis as $key => $entry ) { $newKey = $idOffset + $key; $this->interwikis[$newKey] = $entry; $maxId = $newKey > $maxId ? $newKey : $maxId; } $texts = preg_replace_callback( '/(<!--IWLINK )(\d+)(-->)/', array( $this, 'mergeForeignCallback' ), $texts ); # Set the parent link ID to be beyond the highest used ID $this->parent->setLinkID( $maxId + 1 ); $this->tempIdOffset = null; return $texts; } /** * @param array $m * @return string */ protected function mergeForeignCallback( $m ) { return $m[1] . ( $m[2] + $this->tempIdOffset ) . $m[3]; } /** * Get a subset of the current LinkHolderArray which is sufficient to * interpret the given text. * @param string $text * @return LinkHolderArray */ public function getSubArray( $text ) { $sub = new LinkHolderArray( $this->parent ); # Internal links $pos = 0; while ( $pos < strlen( $text ) ) { if ( !preg_match( '/<!--LINK (\d+):(\d+)-->/', $text, $m, PREG_OFFSET_CAPTURE, $pos ) ) { break; } $ns = $m[1][0]; $key = $m[2][0]; $sub->internals[$ns][$key] = $this->internals[$ns][$key]; $pos = $m[0][1] + strlen( $m[0][0] ); } # Interwiki links $pos = 0; while ( $pos < strlen( $text ) ) { if ( !preg_match( '/<!--IWLINK (\d+)-->/', $text, $m, PREG_OFFSET_CAPTURE, $pos ) ) { break; } $key = $m[1][0]; $sub->interwikis[$key] = $this->interwikis[$key]; $pos = $m[0][1] + strlen( $m[0][0] ); } return $sub; } /** * Returns true if the memory requirements of this object are getting large * @return bool */ public function isBig() { global $wgLinkHolderBatchSize; return $this->size > $wgLinkHolderBatchSize; } /** * Clear all stored link holders. * Make sure you don't have any text left using these link holders, before you call this */ public function clear() { $this->internals = array(); $this->interwikis = array(); $this->size = 0; } /** * Make a link placeholder. The text returned can be later resolved to a real link with * replaceLinkHolders(). This is done for two reasons: firstly to avoid further * parsing of interwiki links, and secondly to allow all existence checks and * article length checks (for stub links) to be bundled into a single query. * * @param Title $nt * @param string $text * @param array $query [optional] * @param string $trail [optional] * @param string $prefix [optional] * @return string */ public function makeHolder( $nt, $text = '', $query = array(), $trail = '', $prefix = '' ) { if ( !is_object( $nt ) ) { # Fail gracefully $retVal = "<!-- ERROR -->{$prefix}{$text}{$trail}"; } else { # Separate the link trail from the rest of the link list( $inside, $trail ) = Linker::splitTrail( $trail ); $entry = array( 'title' => $nt, 'text' => $prefix . $text . $inside, 'pdbk' => $nt->getPrefixedDBkey(), ); if ( $query !== array() ) { $entry['query'] = $query; } if ( $nt->isExternal() ) { // Use a globally unique ID to keep the objects mergable $key = $this->parent->nextLinkID(); $this->interwikis[$key] = $entry; $retVal = "<!--IWLINK $key-->{$trail}"; } else { $key = $this->parent->nextLinkID(); $ns = $nt->getNamespace(); $this->internals[$ns][$key] = $entry; $retVal = "<!--LINK $ns:$key-->{$trail}"; } $this->size++; } return $retVal; } /** * Replace <!--LINK--> link placeholders with actual links, in the buffer * * @param string $text */ public function replace( &$text ) { $this->replaceInternal( $text ); $this->replaceInterwiki( $text ); } /** * Replace internal links * @param string $text */ protected function replaceInternal( &$text ) { if ( !$this->internals ) { return; } - global $wgContLang, $wgContentHandlerUseDB; + global $wgContLang, $wgContentHandlerUseDB, $wgPageLanguageUseDB; $colours = array(); $linkCache = LinkCache::singleton(); $output = $this->parent->getOutput(); $dbr = wfGetDB( DB_SLAVE ); $threshold = $this->parent->getOptions()->getStubThreshold(); # Sort by namespace ksort( $this->internals ); $linkcolour_ids = array(); # Generate query $queries = array(); foreach ( $this->internals as $ns => $entries ) { foreach ( $entries as $entry ) { /** @var Title $title */ $title = $entry['title']; $pdbk = $entry['pdbk']; # Skip invalid entries. # Result will be ugly, but prevents crash. if ( is_null( $title ) ) { continue; } # Check if it's a static known link, e.g. interwiki if ( $title->isAlwaysKnown() ) { $colours[$pdbk] = ''; } elseif ( $ns == NS_SPECIAL ) { $colours[$pdbk] = 'new'; } else { $id = $linkCache->getGoodLinkID( $pdbk ); if ( $id != 0 ) { $colours[$pdbk] = Linker::getLinkColour( $title, $threshold ); $output->addLink( $title, $id ); $linkcolour_ids[$id] = $pdbk; } elseif ( $linkCache->isBadLink( $pdbk ) ) { $colours[$pdbk] = 'new'; } else { # Not in the link cache, add it to the query $queries[$ns][] = $title->getDBkey(); } } } } if ( $queries ) { $where = array(); foreach ( $queries as $ns => $pages ) { $where[] = $dbr->makeList( array( 'page_namespace' => $ns, 'page_title' => array_unique( $pages ), ), LIST_AND ); } $fields = array( 'page_id', 'page_namespace', 'page_title', 'page_is_redirect', 'page_len', 'page_latest' ); if ( $wgContentHandlerUseDB ) { $fields[] = 'page_content_model'; } + if ( $wgPageLanguageUseDB ) { + $fields[] = 'page_lang'; + } $res = $dbr->select( 'page', $fields, $dbr->makeList( $where, LIST_OR ), __METHOD__ ); # Fetch data and form into an associative array # non-existent = broken foreach ( $res as $s ) { $title = Title::makeTitle( $s->page_namespace, $s->page_title ); $pdbk = $title->getPrefixedDBkey(); $linkCache->addGoodLinkObjFromRow( $title, $s ); $output->addLink( $title, $s->page_id ); # @todo FIXME: Convoluted data flow # The redirect status and length is passed to getLinkColour via the LinkCache # Use formal parameters instead $colours[$pdbk] = Linker::getLinkColour( $title, $threshold ); // add id to the extension todolist $linkcolour_ids[$s->page_id] = $pdbk; } unset( $res ); } if ( count( $linkcolour_ids ) ) { // pass an array of page_ids to an extension Hooks::run( 'GetLinkColours', array( $linkcolour_ids, &$colours ) ); } # Do a second query for different language variants of links and categories if ( $wgContLang->hasVariants() ) { $this->doVariants( $colours ); } # Construct search and replace arrays $replacePairs = array(); foreach ( $this->internals as $ns => $entries ) { foreach ( $entries as $index => $entry ) { $pdbk = $entry['pdbk']; $title = $entry['title']; $query = isset( $entry['query'] ) ? $entry['query'] : array(); $key = "$ns:$index"; $searchkey = "<!--LINK $key-->"; $displayText = $entry['text']; if ( isset( $entry['selflink'] ) ) { $replacePairs[$searchkey] = Linker::makeSelfLinkObj( $title, $displayText, $query ); continue; } if ( $displayText === '' ) { $displayText = null; } if ( !isset( $colours[$pdbk] ) ) { $colours[$pdbk] = 'new'; } $attribs = array(); if ( $colours[$pdbk] == 'new' ) { $linkCache->addBadLinkObj( $title ); $output->addLink( $title, 0 ); $type = array( 'broken' ); } else { if ( $colours[$pdbk] != '' ) { $attribs['class'] = $colours[$pdbk]; } $type = array( 'known', 'noclasses' ); } $replacePairs[$searchkey] = Linker::link( $title, $displayText, $attribs, $query, $type ); } } $replacer = new HashtableReplacer( $replacePairs, 1 ); # Do the thing $text = preg_replace_callback( '/(<!--LINK .*?-->)/', $replacer->cb(), $text ); } /** * Replace interwiki links * @param string $text */ protected function replaceInterwiki( &$text ) { if ( empty( $this->interwikis ) ) { return; } # Make interwiki link HTML $output = $this->parent->getOutput(); $replacePairs = array(); $options = array( 'stubThreshold' => $this->parent->getOptions()->getStubThreshold(), ); foreach ( $this->interwikis as $key => $link ) { $replacePairs[$key] = Linker::link( $link['title'], $link['text'], array(), array(), $options ); $output->addInterwikiLink( $link['title'] ); } $replacer = new HashtableReplacer( $replacePairs, 1 ); $text = preg_replace_callback( '/<!--IWLINK (.*?)-->/', $replacer->cb(), $text ); } /** * Modify $this->internals and $colours according to language variant linking rules * @param array $colours */ protected function doVariants( &$colours ) { - global $wgContLang, $wgContentHandlerUseDB; + global $wgContLang, $wgContentHandlerUseDB, $wgPageLanguageUseDB; $linkBatch = new LinkBatch(); $variantMap = array(); // maps $pdbkey_Variant => $keys (of link holders) $output = $this->parent->getOutput(); $linkCache = LinkCache::singleton(); $threshold = $this->parent->getOptions()->getStubThreshold(); $titlesToBeConverted = ''; $titlesAttrs = array(); // Concatenate titles to a single string, thus we only need auto convert the // single string to all variants. This would improve parser's performance // significantly. foreach ( $this->internals as $ns => $entries ) { if ( $ns == NS_SPECIAL ) { continue; } foreach ( $entries as $index => $entry ) { $pdbk = $entry['pdbk']; // we only deal with new links (in its first query) if ( !isset( $colours[$pdbk] ) || $colours[$pdbk] === 'new' ) { $titlesAttrs[] = array( $index, $entry['title'] ); // separate titles with \0 because it would never appears // in a valid title $titlesToBeConverted .= $entry['title']->getText() . "\0"; } } } // Now do the conversion and explode string to text of titles $titlesAllVariants = $wgContLang->autoConvertToAllVariants( rtrim( $titlesToBeConverted, "\0" ) ); $allVariantsName = array_keys( $titlesAllVariants ); foreach ( $titlesAllVariants as &$titlesVariant ) { $titlesVariant = explode( "\0", $titlesVariant ); } // Then add variants of links to link batch $parentTitle = $this->parent->getTitle(); foreach ( $titlesAttrs as $i => $attrs ) { /** @var Title $title */ list( $index, $title ) = $attrs; $ns = $title->getNamespace(); $text = $title->getText(); foreach ( $allVariantsName as $variantName ) { $textVariant = $titlesAllVariants[$variantName][$i]; if ( $textVariant === $text ) { continue; } $variantTitle = Title::makeTitle( $ns, $textVariant ); if ( is_null( $variantTitle ) ) { continue; } // Self-link checking for mixed/different variant titles. At this point, we // already know the exact title does not exist, so the link cannot be to a // variant of the current title that exists as a separate page. if ( $variantTitle->equals( $parentTitle ) && !$title->hasFragment() ) { $this->internals[$ns][$index]['selflink'] = true; continue 2; } $linkBatch->addObj( $variantTitle ); $variantMap[$variantTitle->getPrefixedDBkey()][] = "$ns:$index"; } } // process categories, check if a category exists in some variant $categoryMap = array(); // maps $category_variant => $category (dbkeys) $varCategories = array(); // category replacements oldDBkey => newDBkey foreach ( $output->getCategoryLinks() as $category ) { $categoryTitle = Title::makeTitleSafe( NS_CATEGORY, $category ); $linkBatch->addObj( $categoryTitle ); $variants = $wgContLang->autoConvertToAllVariants( $category ); foreach ( $variants as $variant ) { if ( $variant !== $category ) { $variantTitle = Title::makeTitleSafe( NS_CATEGORY, $variant ); if ( is_null( $variantTitle ) ) { continue; } $linkBatch->addObj( $variantTitle ); $categoryMap[$variant] = array( $category, $categoryTitle ); } } } if ( !$linkBatch->isEmpty() ) { // construct query $dbr = wfGetDB( DB_SLAVE ); $fields = array( 'page_id', 'page_namespace', 'page_title', 'page_is_redirect', 'page_len', 'page_latest' ); if ( $wgContentHandlerUseDB ) { $fields[] = 'page_content_model'; } + if ( $wgPageLanguageUseDB ) { + $fields[] = 'page_lang'; + } $varRes = $dbr->select( 'page', $fields, $linkBatch->constructSet( 'page', $dbr ), __METHOD__ ); $linkcolour_ids = array(); // for each found variants, figure out link holders and replace foreach ( $varRes as $s ) { $variantTitle = Title::makeTitle( $s->page_namespace, $s->page_title ); $varPdbk = $variantTitle->getPrefixedDBkey(); $vardbk = $variantTitle->getDBkey(); $holderKeys = array(); if ( isset( $variantMap[$varPdbk] ) ) { $holderKeys = $variantMap[$varPdbk]; $linkCache->addGoodLinkObjFromRow( $variantTitle, $s ); $output->addLink( $variantTitle, $s->page_id ); } // loop over link holders foreach ( $holderKeys as $key ) { list( $ns, $index ) = explode( ':', $key, 2 ); $entry =& $this->internals[$ns][$index]; $pdbk = $entry['pdbk']; if ( !isset( $colours[$pdbk] ) || $colours[$pdbk] === 'new' ) { // found link in some of the variants, replace the link holder data $entry['title'] = $variantTitle; $entry['pdbk'] = $varPdbk; // set pdbk and colour # @todo FIXME: Convoluted data flow # The redirect status and length is passed to getLinkColour via the LinkCache # Use formal parameters instead $colours[$varPdbk] = Linker::getLinkColour( $variantTitle, $threshold ); $linkcolour_ids[$s->page_id] = $pdbk; } } // check if the object is a variant of a category if ( isset( $categoryMap[$vardbk] ) ) { list( $oldkey, $oldtitle ) = $categoryMap[$vardbk]; if ( !isset( $varCategories[$oldkey] ) && !$oldtitle->exists() ) { $varCategories[$oldkey] = $vardbk; } } } Hooks::run( 'GetLinkColours', array( $linkcolour_ids, &$colours ) ); // rebuild the categories in original order (if there are replacements) if ( count( $varCategories ) > 0 ) { $newCats = array(); $originalCats = $output->getCategories(); foreach ( $originalCats as $cat => $sortkey ) { // make the replacement if ( array_key_exists( $cat, $varCategories ) ) { $newCats[$varCategories[$cat]] = $sortkey; } else { $newCats[$cat] = $sortkey; } } $output->setCategoryLinks( $newCats ); } } } /** * Replace <!--LINK--> link placeholders with plain text of links * (not HTML-formatted). * * @param string $text * @return string */ public function replaceText( $text ) { $text = preg_replace_callback( '/<!--(LINK|IWLINK) (.*?)-->/', array( &$this, 'replaceTextCallback' ), $text ); return $text; } /** * Callback for replaceText() * * @param array $matches * @return string * @private */ public function replaceTextCallback( $matches ) { $type = $matches[1]; $key = $matches[2]; if ( $type == 'LINK' ) { list( $ns, $index ) = explode( ':', $key, 2 ); if ( isset( $this->internals[$ns][$index]['text'] ) ) { return $this->internals[$ns][$index]['text']; } } elseif ( $type == 'IWLINK' ) { if ( isset( $this->interwikis[$key]['text'] ) ) { return $this->interwikis[$key]['text']; } } return $matches[0]; } } diff --git a/includes/specials/SpecialPageLanguage.php b/includes/specials/SpecialPageLanguage.php index 38093be4175..20af655d496 100644 --- a/includes/specials/SpecialPageLanguage.php +++ b/includes/specials/SpecialPageLanguage.php @@ -1,223 +1,222 @@ <?php /** * Implements Special:PageLanguage * * This program is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation; either version 2 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License along * with this program; if not, write to the Free Software Foundation, Inc., * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. * http://www.gnu.org/copyleft/gpl.html * * @file * @ingroup SpecialPage * @author Kunal Grover * @since 1.24 */ /** * Special page for changing the content language of a page * * @ingroup SpecialPage */ class SpecialPageLanguage extends FormSpecialPage { /** * @var string URL to go to if language change successful */ private $goToUrl; public function __construct() { parent::__construct( 'PageLanguage', 'pagelang' ); } public function doesWrites() { return true; } protected function preText() { $this->getOutput()->addModules( 'mediawiki.special.pageLanguage' ); } protected function getFormFields() { // Get default from the subpage of Special page $defaultName = $this->par; $page = array(); $page['pagename'] = array( 'type' => 'title', 'label-message' => 'pagelang-name', 'default' => $defaultName, 'autofocus' => $defaultName === null, 'exists' => true, ); // Options for whether to use the default language or select language $selectoptions = array( (string)$this->msg( 'pagelang-use-default' )->escaped() => 1, (string)$this->msg( 'pagelang-select-lang' )->escaped() => 2, ); $page['selectoptions'] = array( 'id' => 'mw-pl-options', 'type' => 'radio', 'options' => $selectoptions, 'default' => 1 ); // Building a language selector $userLang = $this->getLanguage()->getCode(); $languages = Language::fetchLanguageNames( $userLang, 'mwfile' ); ksort( $languages ); $options = array(); foreach ( $languages as $code => $name ) { $options["$code - $name"] = $code; } $page['language'] = array( 'id' => 'mw-pl-languageselector', 'cssclass' => 'mw-languageselector', 'type' => 'select', 'options' => $options, 'label-message' => 'pagelang-language', 'default' => $this->getConfig()->get( 'LanguageCode' ), ); return $page; } protected function postText() { if ( $this->par ) { return $this->showLogFragment( $this->par ); } return ''; } protected function getDisplayFormat() { return 'ooui'; } public function alterForm( HTMLForm $form ) { Hooks::run( 'LanguageSelector', array( $this->getOutput(), 'mw-languageselector' ) ); $form->setSubmitTextMsg( 'pagelang-submit' ); } /** * * @param array $data * @return bool */ public function onSubmit( array $data ) { $title = Title::newFromText( $data['pagename'] ); // Check if title is valid if ( !$title ) { return false; } // Get the default language for the wiki - // Returns the default since the page is not loaded from DB - $defLang = $title->getPageLanguage()->getCode(); + $defLang = $this->getConfig()->get( 'LanguageCode' ); $pageId = $title->getArticleID(); // Check if article exists if ( !$pageId ) { return false; } // Load the page language from DB $dbw = wfGetDB( DB_MASTER ); $langOld = $dbw->selectField( 'page', 'page_lang', array( 'page_id' => $pageId ), __METHOD__ ); // Url to redirect to after the operation $this->goToUrl = $title->getFullURL(); // Check if user wants to use default language if ( $data['selectoptions'] == 1 ) { $langNew = null; } else { $langNew = $data['language']; } // No change in language if ( $langNew === $langOld ) { return false; } // Hardcoded [def] if the language is set to null $logOld = $langOld ? $langOld : $defLang . '[def]'; $logNew = $langNew ? $langNew : $defLang . '[def]'; // Writing new page language to database $dbw = wfGetDB( DB_MASTER ); $dbw->update( 'page', array( 'page_lang' => $langNew ), array( 'page_id' => $pageId, 'page_lang' => $langOld ), __METHOD__ ); if ( !$dbw->affectedRows() ) { return false; } // Logging change of language $logParams = array( '4::oldlanguage' => $logOld, '5::newlanguage' => $logNew ); $entry = new ManualLogEntry( 'pagelang', 'pagelang' ); $entry->setPerformer( $this->getUser() ); $entry->setTarget( $title ); $entry->setParameters( $logParams ); $logid = $entry->insert(); $entry->publish( $logid ); return true; } public function onSuccess() { // Success causes a redirect $this->getOutput()->redirect( $this->goToUrl ); } function showLogFragment( $title ) { $moveLogPage = new LogPage( 'pagelang' ); $out1 = Xml::element( 'h2', null, $moveLogPage->getName()->text() ); $out2 = ''; LogEventsList::showLogExtract( $out2, 'pagelang', $title ); return $out1 . $out2; } /** * Return an array of subpages beginning with $search that this special page will accept. * * @param string $search Prefix to search for * @param int $limit Maximum number of results to return (usually 10) * @param int $offset Number of results to skip (usually 0) * @return string[] Matching subpages */ public function prefixSearchSubpages( $search, $limit, $offset ) { return $this->prefixSearchString( $search, $limit, $offset ); } protected function getGroupName() { return 'pagetools'; } }