diff options
Diffstat (limited to 'includes/resourceloader')
-rw-r--r-- | includes/resourceloader/ResourceLoader.php | 740 | ||||
-rw-r--r-- | includes/resourceloader/ResourceLoaderContext.php | 176 | ||||
-rw-r--r-- | includes/resourceloader/ResourceLoaderFileModule.php | 509 | ||||
-rw-r--r-- | includes/resourceloader/ResourceLoaderModule.php | 239 | ||||
-rw-r--r-- | includes/resourceloader/ResourceLoaderSiteModule.php | 63 | ||||
-rw-r--r-- | includes/resourceloader/ResourceLoaderStartUpModule.php | 225 | ||||
-rw-r--r-- | includes/resourceloader/ResourceLoaderUserModule.php | 50 | ||||
-rw-r--r-- | includes/resourceloader/ResourceLoaderUserOptionsModule.php | 121 | ||||
-rw-r--r-- | includes/resourceloader/ResourceLoaderWikiModule.php | 171 |
9 files changed, 2294 insertions, 0 deletions
diff --git a/includes/resourceloader/ResourceLoader.php b/includes/resourceloader/ResourceLoader.php new file mode 100644 index 00000000..c18022a4 --- /dev/null +++ b/includes/resourceloader/ResourceLoader.php @@ -0,0 +1,740 @@ +<?php +/** + * 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 + * @author Roan Kattouw + * @author Trevor Parscal + */ + +/** + * Dynamic JavaScript and CSS resource loading system. + * + * Most of the documention is on the MediaWiki documentation wiki starting at: + * http://www.mediawiki.org/wiki/ResourceLoader + */ +class ResourceLoader { + + /* Protected Static Members */ + protected static $filterCacheVersion = 2; + + /** Array: List of module name/ResourceLoaderModule object pairs */ + protected $modules = array(); + /** Associative array mapping module name to info associative array */ + protected $moduleInfos = array(); + + /* Protected Methods */ + + /** + * Loads information stored in the database about modules. + * + * This method grabs modules dependencies from the database and updates modules + * objects. + * + * This is not inside the module code because it is much faster to + * request all of the information at once than it is to have each module + * requests its own information. This sacrifice of modularity yields a substantial + * performance improvement. + * + * @param $modules Array: List of module names to preload information for + * @param $context ResourceLoaderContext: Context to load the information within + */ + public function preloadModuleInfo( array $modules, ResourceLoaderContext $context ) { + if ( !count( $modules ) ) { + return; // or else Database*::select() will explode, plus it's cheaper! + } + $dbr = wfGetDB( DB_SLAVE ); + $skin = $context->getSkin(); + $lang = $context->getLanguage(); + + // Get file dependency information + $res = $dbr->select( 'module_deps', array( 'md_module', 'md_deps' ), array( + 'md_module' => $modules, + 'md_skin' => $context->getSkin() + ), __METHOD__ + ); + + // Set modules' dependencies + $modulesWithDeps = array(); + foreach ( $res as $row ) { + $this->getModule( $row->md_module )->setFileDependencies( $skin, + FormatJson::decode( $row->md_deps, true ) + ); + $modulesWithDeps[] = $row->md_module; + } + + // Register the absence of a dependency row too + foreach ( array_diff( $modules, $modulesWithDeps ) as $name ) { + $this->getModule( $name )->setFileDependencies( $skin, array() ); + } + + // Get message blob mtimes. Only do this for modules with messages + $modulesWithMessages = array(); + foreach ( $modules as $name ) { + if ( count( $this->getModule( $name )->getMessages() ) ) { + $modulesWithMessages[] = $name; + } + } + $modulesWithoutMessages = array_flip( $modules ); // Will be trimmed down by the loop below + if ( count( $modulesWithMessages ) ) { + $res = $dbr->select( 'msg_resource', array( 'mr_resource', 'mr_timestamp' ), array( + 'mr_resource' => $modulesWithMessages, + 'mr_lang' => $lang + ), __METHOD__ + ); + foreach ( $res as $row ) { + $this->getModule( $row->mr_resource )->setMsgBlobMtime( $lang, $row->mr_timestamp ); + unset( $modulesWithoutMessages[$row->mr_resource] ); + } + } + foreach ( array_keys( $modulesWithoutMessages ) as $name ) { + $this->getModule( $name )->setMsgBlobMtime( $lang, 0 ); + } + } + + /** + * Runs JavaScript or CSS data through a filter, caching the filtered result for future calls. + * + * Available filters are: + * - minify-js \see JavaScriptMinifier::minify + * - minify-css \see CSSMin::minify + * + * If $data is empty, only contains whitespace or the filter was unknown, + * $data is returned unmodified. + * + * @param $filter String: Name of filter to run + * @param $data String: Text to filter, such as JavaScript or CSS text + * @return String: Filtered data, or a comment containing an error message + */ + protected function filter( $filter, $data ) { + global $wgResourceLoaderMinifierStatementsOnOwnLine, $wgResourceLoaderMinifierMaxLineLength; + wfProfileIn( __METHOD__ ); + + // For empty/whitespace-only data or for unknown filters, don't perform + // any caching or processing + if ( trim( $data ) === '' + || !in_array( $filter, array( 'minify-js', 'minify-css' ) ) ) + { + wfProfileOut( __METHOD__ ); + return $data; + } + + // Try for cache hit + // Use CACHE_ANYTHING since filtering is very slow compared to DB queries + $key = wfMemcKey( 'resourceloader', 'filter', $filter, md5( $data ) ); + $cache = wfGetCache( CACHE_ANYTHING ); + $cacheEntry = $cache->get( $key ); + if ( is_string( $cacheEntry ) ) { + wfProfileOut( __METHOD__ ); + return $cacheEntry; + } + + // Run the filter - we've already verified one of these will work + try { + switch ( $filter ) { + case 'minify-js': + $result = JavaScriptMinifier::minify( $data, + $wgResourceLoaderMinifierStatementsOnOwnLine, + $wgResourceLoaderMinifierMaxLineLength + ); + break; + case 'minify-css': + $result = CSSMin::minify( $data ); + break; + } + + // Save filtered text to Memcached + $cache->set( $key, $result ); + } catch ( Exception $exception ) { + // Return exception as a comment + $result = "/*\n{$exception->__toString()}\n*/\n"; + } + + wfProfileOut( __METHOD__ ); + + return $result; + } + + /* Methods */ + + /** + * Registers core modules and runs registration hooks. + */ + public function __construct() { + global $IP, $wgResourceModules; + + wfProfileIn( __METHOD__ ); + + // Register core modules + $this->register( include( "$IP/resources/Resources.php" ) ); + // Register extension modules + wfRunHooks( 'ResourceLoaderRegisterModules', array( &$this ) ); + $this->register( $wgResourceModules ); + + wfProfileOut( __METHOD__ ); + } + + /** + * Registers a module with the ResourceLoader system. + * + * @param $name Mixed: Name of module as a string or List of name/object pairs as an array + * @param $info Module info array. For backwards compatibility with 1.17alpha, + * this may also be a ResourceLoaderModule object. Optional when using + * multiple-registration calling style. + * @throws MWException: If a duplicate module registration is attempted + * @throws MWException: If a module name contains illegal characters (pipes or commas) + * @throws MWException: If something other than a ResourceLoaderModule is being registered + * @return Boolean: False if there were any errors, in which case one or more modules were not + * registered + */ + public function register( $name, $info = null ) { + wfProfileIn( __METHOD__ ); + + // Allow multiple modules to be registered in one call + if ( is_array( $name ) ) { + foreach ( $name as $key => $value ) { + $this->register( $key, $value ); + } + wfProfileOut( __METHOD__ ); + return; + } + + // Disallow duplicate registrations + if ( isset( $this->moduleInfos[$name] ) ) { + // A module has already been registered by this name + throw new MWException( + 'ResourceLoader duplicate registration error. ' . + 'Another module has already been registered as ' . $name + ); + } + + // Check $name for illegal characters + if ( preg_match( '/[|,]/', $name ) ) { + throw new MWException( "ResourceLoader module name '$name' is invalid. Names may not contain pipes (|) or commas (,)" ); + } + + // Attach module + if ( is_object( $info ) ) { + // Old calling convention + // Validate the input + if ( !( $info instanceof ResourceLoaderModule ) ) { + throw new MWException( 'ResourceLoader invalid module error. ' . + 'Instances of ResourceLoaderModule expected.' ); + } + + $this->moduleInfos[$name] = array( 'object' => $info ); + $info->setName( $name ); + $this->modules[$name] = $info; + } else { + // New calling convention + $this->moduleInfos[$name] = $info; + } + + wfProfileOut( __METHOD__ ); + } + + /** + * Get a list of module names + * + * @return Array: List of module names + */ + public function getModuleNames() { + return array_keys( $this->moduleInfos ); + } + + /** + * Get the ResourceLoaderModule object for a given module name. + * + * @param $name String: Module name + * @return Mixed: ResourceLoaderModule if module has been registered, null otherwise + */ + public function getModule( $name ) { + if ( !isset( $this->modules[$name] ) ) { + if ( !isset( $this->moduleInfos[$name] ) ) { + // No such module + return null; + } + // Construct the requested object + $info = $this->moduleInfos[$name]; + if ( isset( $info['object'] ) ) { + // Object given in info array + $object = $info['object']; + } else { + if ( !isset( $info['class'] ) ) { + $class = 'ResourceLoaderFileModule'; + } else { + $class = $info['class']; + } + $object = new $class( $info ); + } + $object->setName( $name ); + $this->modules[$name] = $object; + } + + return $this->modules[$name]; + } + + /** + * Outputs a response to a resource load-request, including a content-type header. + * + * @param $context ResourceLoaderContext: Context in which a response should be formed + */ + public function respond( ResourceLoaderContext $context ) { + global $wgResourceLoaderMaxage, $wgCacheEpoch; + + // Buffer output to catch warnings. Normally we'd use ob_clean() on the + // top-level output buffer to clear warnings, but that breaks when ob_gzhandler + // is used: ob_clean() will clear the GZIP header in that case and it won't come + // back for subsequent output, resulting in invalid GZIP. So we have to wrap + // the whole thing in our own output buffer to be sure the active buffer + // doesn't use ob_gzhandler. + // See http://bugs.php.net/bug.php?id=36514 + ob_start(); + + wfProfileIn( __METHOD__ ); + $exceptions = ''; + + // Split requested modules into two groups, modules and missing + $modules = array(); + $missing = array(); + foreach ( $context->getModules() as $name ) { + if ( isset( $this->moduleInfos[$name] ) ) { + $modules[$name] = $this->getModule( $name ); + } else { + $missing[] = $name; + } + } + + // If a version wasn't specified we need a shorter expiry time for updates + // to propagate to clients quickly + if ( is_null( $context->getVersion() ) ) { + $maxage = $wgResourceLoaderMaxage['unversioned']['client']; + $smaxage = $wgResourceLoaderMaxage['unversioned']['server']; + } + // If a version was specified we can use a longer expiry time since changing + // version numbers causes cache misses + else { + $maxage = $wgResourceLoaderMaxage['versioned']['client']; + $smaxage = $wgResourceLoaderMaxage['versioned']['server']; + } + + // Preload information needed to the mtime calculation below + try { + $this->preloadModuleInfo( array_keys( $modules ), $context ); + } catch( Exception $e ) { + // Add exception to the output as a comment + $exceptions .= "/*\n{$e->__toString()}\n*/\n"; + } + + wfProfileIn( __METHOD__.'-getModifiedTime' ); + + $private = false; + // To send Last-Modified and support If-Modified-Since, we need to detect + // the last modified time + $mtime = wfTimestamp( TS_UNIX, $wgCacheEpoch ); + foreach ( $modules as $module ) { + try { + // Bypass Squid and other shared caches if the request includes any private modules + if ( $module->getGroup() === 'private' ) { + $private = true; + } + // Calculate maximum modified time + $mtime = max( $mtime, $module->getModifiedTime( $context ) ); + } catch ( Exception $e ) { + // Add exception to the output as a comment + $exceptions .= "/*\n{$e->__toString()}\n*/\n"; + } + } + + wfProfileOut( __METHOD__.'-getModifiedTime' ); + + if ( $context->getOnly() === 'styles' ) { + header( 'Content-Type: text/css; charset=utf-8' ); + } else { + header( 'Content-Type: text/javascript; charset=utf-8' ); + } + header( 'Last-Modified: ' . wfTimestamp( TS_RFC2822, $mtime ) ); + if ( $context->getDebug() ) { + // Do not cache debug responses + header( 'Cache-Control: private, no-cache, must-revalidate' ); + header( 'Pragma: no-cache' ); + } else { + if ( $private ) { + header( "Cache-Control: private, max-age=$maxage" ); + $exp = $maxage; + } else { + header( "Cache-Control: public, max-age=$maxage, s-maxage=$smaxage" ); + $exp = min( $maxage, $smaxage ); + } + header( 'Expires: ' . wfTimestamp( TS_RFC2822, $exp + time() ) ); + } + + // If there's an If-Modified-Since header, respond with a 304 appropriately + // Some clients send "timestamp;length=123". Strip the part after the first ';' + // so we get a valid timestamp. + $ims = $context->getRequest()->getHeader( 'If-Modified-Since' ); + if ( $ims !== false ) { + $imsTS = strtok( $ims, ';' ); + if ( $mtime <= wfTimestamp( TS_UNIX, $imsTS ) ) { + // There's another bug in ob_gzhandler (see also the comment at + // the top of this function) that causes it to gzip even empty + // responses, meaning it's impossible to produce a truly empty + // response (because the gzip header is always there). This is + // a problem because 304 responses have to be completely empty + // per the HTTP spec, and Firefox behaves buggily when they're not. + // See also http://bugs.php.net/bug.php?id=51579 + // To work around this, we tear down all output buffering before + // sending the 304. + // On some setups, ob_get_level() doesn't seem to go down to zero + // no matter how often we call ob_get_clean(), so instead of doing + // the more intuitive while ( ob_get_level() > 0 ) ob_get_clean(); + // we have to be safe here and avoid an infinite loop. + for ( $i = 0; $i < ob_get_level(); $i++ ) { + ob_end_clean(); + } + + header( 'HTTP/1.0 304 Not Modified' ); + header( 'Status: 304 Not Modified' ); + wfProfileOut( __METHOD__ ); + return; + } + } + + // Generate a response + $response = $this->makeModuleResponse( $context, $modules, $missing ); + + // Prepend comments indicating exceptions + $response = $exceptions . $response; + + // Capture any PHP warnings from the output buffer and append them to the + // response in a comment if we're in debug mode. + if ( $context->getDebug() && strlen( $warnings = ob_get_contents() ) ) { + $response = "/*\n$warnings\n*/\n" . $response; + } + + // Remove the output buffer and output the response + ob_end_clean(); + echo $response; + + wfProfileOut( __METHOD__ ); + } + + /** + * Generates code for a response + * + * @param $context ResourceLoaderContext: Context in which to generate a response + * @param $modules Array: List of module objects keyed by module name + * @param $missing Array: List of unavailable modules (optional) + * @return String: Response data + */ + public function makeModuleResponse( ResourceLoaderContext $context, + array $modules, $missing = array() ) + { + $out = ''; + $exceptions = ''; + if ( $modules === array() && $missing === array() ) { + return '/* No modules requested. Max made me put this here */'; + } + + wfProfileIn( __METHOD__ ); + // Pre-fetch blobs + if ( $context->shouldIncludeMessages() ) { + try { + $blobs = MessageBlobStore::get( $this, $modules, $context->getLanguage() ); + } catch ( Exception $e ) { + // Add exception to the output as a comment + $exceptions .= "/*\n{$e->__toString()}\n*/\n"; + } + } else { + $blobs = array(); + } + + // Generate output + foreach ( $modules as $name => $module ) { + wfProfileIn( __METHOD__ . '-' . $name ); + try { + // Scripts + $scripts = ''; + if ( $context->shouldIncludeScripts() ) { + // bug 27054: Append semicolon to prevent weird bugs + // caused by files not terminating their statements right + $scripts .= $module->getScript( $context ) . ";\n"; + } + + // Styles + $styles = array(); + if ( $context->shouldIncludeStyles() ) { + $styles = $module->getStyles( $context ); + } + + // Messages + $messagesBlob = isset( $blobs[$name] ) ? $blobs[$name] : '{}'; + + // Append output + switch ( $context->getOnly() ) { + case 'scripts': + $out .= $scripts; + break; + case 'styles': + $out .= self::makeCombinedStyles( $styles ); + break; + case 'messages': + $out .= self::makeMessageSetScript( new XmlJsCode( $messagesBlob ) ); + break; + default: + // Minify CSS before embedding in mediaWiki.loader.implement call + // (unless in debug mode) + if ( !$context->getDebug() ) { + foreach ( $styles as $media => $style ) { + $styles[$media] = $this->filter( 'minify-css', $style ); + } + } + $out .= self::makeLoaderImplementScript( $name, $scripts, $styles, + new XmlJsCode( $messagesBlob ) ); + break; + } + } catch ( Exception $e ) { + // Add exception to the output as a comment + $exceptions .= "/*\n{$e->__toString()}\n*/\n"; + + // Register module as missing + $missing[] = $name; + unset( $modules[$name] ); + } + wfProfileOut( __METHOD__ . '-' . $name ); + } + + // Update module states + if ( $context->shouldIncludeScripts() ) { + // Set the state of modules loaded as only scripts to ready + if ( count( $modules ) && $context->getOnly() === 'scripts' + && !isset( $modules['startup'] ) ) + { + $out .= self::makeLoaderStateScript( + array_fill_keys( array_keys( $modules ), 'ready' ) ); + } + // Set the state of modules which were requested but unavailable as missing + if ( is_array( $missing ) && count( $missing ) ) { + $out .= self::makeLoaderStateScript( array_fill_keys( $missing, 'missing' ) ); + } + } + + if ( !$context->getDebug() ) { + if ( $context->getOnly() === 'styles' ) { + $out = $this->filter( 'minify-css', $out ); + } else { + $out = $this->filter( 'minify-js', $out ); + } + } + + wfProfileOut( __METHOD__ ); + return $exceptions . $out; + } + + /* Static Methods */ + + /** + * Returns JS code to call to mediaWiki.loader.implement for a module with + * given properties. + * + * @param $name Module name + * @param $scripts Array: List of JavaScript code snippets to be executed after the + * module is loaded + * @param $styles Array: List of CSS strings keyed by media type + * @param $messages Mixed: List of messages associated with this module. May either be an + * associative array mapping message key to value, or a JSON-encoded message blob containing + * the same data, wrapped in an XmlJsCode object. + */ + public static function makeLoaderImplementScript( $name, $scripts, $styles, $messages ) { + if ( is_array( $scripts ) ) { + $scripts = implode( $scripts, "\n" ); + } + return Xml::encodeJsCall( + 'mediaWiki.loader.implement', + array( + $name, + new XmlJsCode( "function( $, mw ) {{$scripts}}" ), + (object)$styles, + (object)$messages + ) ); + } + + /** + * Returns JS code which, when called, will register a given list of messages. + * + * @param $messages Mixed: Either an associative array mapping message key to value, or a + * JSON-encoded message blob containing the same data, wrapped in an XmlJsCode object. + */ + public static function makeMessageSetScript( $messages ) { + return Xml::encodeJsCall( 'mediaWiki.messages.set', array( (object)$messages ) ); + } + + /** + * Combines an associative array mapping media type to CSS into a + * single stylesheet with @media blocks. + * + * @param $styles Array: List of CSS strings keyed by media type + */ + public static function makeCombinedStyles( array $styles ) { + $out = ''; + foreach ( $styles as $media => $style ) { + // Transform the media type based on request params and config + // The way that this relies on $wgRequest to propagate request params is slightly evil + $media = OutputPage::transformCssMedia( $media ); + + if ( $media === null ) { + // Skip + } else if ( $media === '' || $media == 'all' ) { + // Don't output invalid or frivolous @media statements + $out .= "$style\n"; + } else { + $out .= "@media $media {\n" . str_replace( "\n", "\n\t", "\t" . $style ) . "\n}\n"; + } + } + return $out; + } + + /** + * Returns a JS call to mediaWiki.loader.state, which sets the state of a + * module or modules to a given value. Has two calling conventions: + * + * - ResourceLoader::makeLoaderStateScript( $name, $state ): + * Set the state of a single module called $name to $state + * + * - ResourceLoader::makeLoaderStateScript( array( $name => $state, ... ) ): + * Set the state of modules with the given names to the given states + */ + public static function makeLoaderStateScript( $name, $state = null ) { + if ( is_array( $name ) ) { + return Xml::encodeJsCall( 'mediaWiki.loader.state', array( $name ) ); + } else { + return Xml::encodeJsCall( 'mediaWiki.loader.state', array( $name, $state ) ); + } + } + + /** + * Returns JS code which calls the script given by $script. The script will + * be called with local variables name, version, dependencies and group, + * which will have values corresponding to $name, $version, $dependencies + * and $group as supplied. + * + * @param $name String: Module name + * @param $version Integer: Module version number as a timestamp + * @param $dependencies Array: List of module names on which this module depends + * @param $group String: Group which the module is in. + * @param $script String: JavaScript code + */ + public static function makeCustomLoaderScript( $name, $version, $dependencies, $group, $script ) { + $script = str_replace( "\n", "\n\t", trim( $script ) ); + return Xml::encodeJsCall( + "( function( name, version, dependencies, group ) {\n\t$script\n} )", + array( $name, $version, $dependencies, $group ) ); + } + + /** + * Returns JS code which calls mediaWiki.loader.register with the given + * parameters. Has three calling conventions: + * + * - ResourceLoader::makeLoaderRegisterScript( $name, $version, $dependencies, $group ): + * Register a single module. + * + * - ResourceLoader::makeLoaderRegisterScript( array( $name1, $name2 ) ): + * Register modules with the given names. + * + * - ResourceLoader::makeLoaderRegisterScript( array( + * array( $name1, $version1, $dependencies1, $group1 ), + * array( $name2, $version2, $dependencies1, $group2 ), + * ... + * ) ): + * Registers modules with the given names and parameters. + * + * @param $name String: Module name + * @param $version Integer: Module version number as a timestamp + * @param $dependencies Array: List of module names on which this module depends + * @param $group String: group which the module is in. + */ + public static function makeLoaderRegisterScript( $name, $version = null, + $dependencies = null, $group = null ) + { + if ( is_array( $name ) ) { + return Xml::encodeJsCall( 'mediaWiki.loader.register', array( $name ) ); + } else { + $version = (int) $version > 1 ? (int) $version : 1; + return Xml::encodeJsCall( 'mediaWiki.loader.register', + array( $name, $version, $dependencies, $group ) ); + } + } + + /** + * Returns JS code which runs given JS code if the client-side framework is + * present. + * + * @param $script String: JavaScript code + */ + public static function makeLoaderConditionalScript( $script ) { + $script = str_replace( "\n", "\n\t", trim( $script ) ); + return "if ( window.mediaWiki ) {\n\t$script\n}\n"; + } + + /** + * Returns JS code which will set the MediaWiki configuration array to + * the given value. + * + * @param $configuration Array: List of configuration values keyed by variable name + */ + public static function makeConfigSetScript( array $configuration ) { + return Xml::encodeJsCall( 'mediaWiki.config.set', array( $configuration ) ); + } + + /** + * Convert an array of module names to a packed query string. + * + * For example, array( 'foo.bar', 'foo.baz', 'bar.baz', 'bar.quux' ) + * becomes 'foo.bar,baz|bar.baz,quux' + * @param $modules array of module names (strings) + * @return string Packed query string + */ + public static function makePackedModulesString( $modules ) { + $groups = array(); // array( prefix => array( suffixes ) ) + foreach ( $modules as $module ) { + $pos = strrpos( $module, '.' ); + $prefix = $pos === false ? '' : substr( $module, 0, $pos ); + $suffix = $pos === false ? $module : substr( $module, $pos + 1 ); + $groups[$prefix][] = $suffix; + } + + $arr = array(); + foreach ( $groups as $prefix => $suffixes ) { + $p = $prefix === '' ? '' : $prefix . '.'; + $arr[] = $p . implode( ',', $suffixes ); + } + return implode( '|', $arr ); + } + + /** + * Determine whether debug mode was requested + * Order of priority is 1) request param, 2) cookie, 3) $wg setting + * @return bool + */ + public static function inDebugMode() { + global $wgRequest, $wgResourceLoaderDebug; + static $retval = null; + if ( !is_null( $retval ) ) + return $retval; + return $retval = $wgRequest->getFuzzyBool( 'debug', + $wgRequest->getCookie( 'resourceLoaderDebug', '', $wgResourceLoaderDebug ) ); + } +} diff --git a/includes/resourceloader/ResourceLoaderContext.php b/includes/resourceloader/ResourceLoaderContext.php new file mode 100644 index 00000000..bf059b46 --- /dev/null +++ b/includes/resourceloader/ResourceLoaderContext.php @@ -0,0 +1,176 @@ +<?php +/** + * 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 + * @author Trevor Parscal + * @author Roan Kattouw + */ + +/** + * Object passed around to modules which contains information about the state + * of a specific loader request + */ +class ResourceLoaderContext { + + /* Protected Members */ + + protected $resourceLoader; + protected $request; + protected $modules; + protected $language; + protected $direction; + protected $skin; + protected $user; + protected $debug; + protected $only; + protected $version; + protected $hash; + + /* Methods */ + + public function __construct( ResourceLoader $resourceLoader, WebRequest $request ) { + global $wgDefaultSkin, $wgResourceLoaderDebug; + + $this->resourceLoader = $resourceLoader; + $this->request = $request; + + // Interpret request + // List of modules + $modules = $request->getVal( 'modules' ); + $this->modules = $modules ? self::expandModuleNames( $modules ) : array(); + // Various parameters + $this->skin = $request->getVal( 'skin' ); + $this->user = $request->getVal( 'user' ); + $this->debug = $request->getFuzzyBool( 'debug', $wgResourceLoaderDebug ); + $this->only = $request->getVal( 'only' ); + $this->version = $request->getVal( 'version' ); + + if ( !$this->skin ) { + $this->skin = $wgDefaultSkin; + } + } + + /** + * Expand a string of the form jquery.foo,bar|jquery.ui.baz,quux to + * an array of module names like array( 'jquery.foo', 'jquery.bar', + * 'jquery.ui.baz', 'jquery.ui.quux' ) + * @param $modules String Packed module name list + * @return array of module names + */ + public static function expandModuleNames( $modules ) { + $retval = array(); + $exploded = explode( '|', $modules ); + foreach ( $exploded as $group ) { + if ( strpos( $group, ',' ) === false ) { + // This is not a set of modules in foo.bar,baz notation + // but a single module + $retval[] = $group; + } else { + // This is a set of modules in foo.bar,baz notation + $pos = strrpos( $group, '.' ); + if ( $pos === false ) { + // Prefixless modules, i.e. without dots + $retval = explode( ',', $group ); + } else { + // We have a prefix and a bunch of suffixes + $prefix = substr( $group, 0, $pos ); // 'foo' + $suffixes = explode( ',', substr( $group, $pos + 1 ) ); // array( 'bar', 'baz' ) + foreach ( $suffixes as $suffix ) { + $retval[] = "$prefix.$suffix"; + } + } + } + } + return $retval; + } + + public function getResourceLoader() { + return $this->resourceLoader; + } + + public function getRequest() { + return $this->request; + } + + public function getModules() { + return $this->modules; + } + + public function getLanguage() { + if ( $this->language === null ) { + global $wgLang; + $this->language = $this->request->getVal( 'lang' ); + if ( !$this->language ) { + $this->language = $wgLang->getCode(); + } + } + return $this->language; + } + + public function getDirection() { + if ( $this->direction === null ) { + $this->direction = $this->request->getVal( 'dir' ); + if ( !$this->direction ) { + global $wgContLang; + $this->direction = $wgContLang->getDir(); + } + } + return $this->direction; + } + + public function getSkin() { + return $this->skin; + } + + public function getUser() { + return $this->user; + } + + public function getDebug() { + return $this->debug; + } + + public function getOnly() { + return $this->only; + } + + public function getVersion() { + return $this->version; + } + + public function shouldIncludeScripts() { + return is_null( $this->only ) || $this->only === 'scripts'; + } + + public function shouldIncludeStyles() { + return is_null( $this->only ) || $this->only === 'styles'; + } + + public function shouldIncludeMessages() { + return is_null( $this->only ) || $this->only === 'messages'; + } + + public function getHash() { + if ( !isset( $this->hash ) ) { + $this->hash = implode( '|', array( + $this->getLanguage(), $this->getDirection(), $this->skin, $this->user, + $this->debug, $this->only, $this->version + ) ); + } + return $this->hash; + } +} diff --git a/includes/resourceloader/ResourceLoaderFileModule.php b/includes/resourceloader/ResourceLoaderFileModule.php new file mode 100644 index 00000000..44967a2e --- /dev/null +++ b/includes/resourceloader/ResourceLoaderFileModule.php @@ -0,0 +1,509 @@ +<?php +/** + * 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 + * @author Trevor Parscal + * @author Roan Kattouw + */ + +/** + * ResourceLoader module based on local JavaScript/CSS files. + */ +class ResourceLoaderFileModule extends ResourceLoaderModule { + + /* Protected Members */ + + /** String: Local base path, see __construct() */ + protected $localBasePath = ''; + /** String: Remote base path, see __construct() */ + protected $remoteBasePath = ''; + /** + * Array: List of paths to JavaScript files to always include + * @example array( [file-path], [file-path], ... ) + */ + protected $scripts = array(); + /** + * Array: List of JavaScript files to include when using a specific language + * @example array( [language-code] => array( [file-path], [file-path], ... ), ... ) + */ + protected $languageScripts = array(); + /** + * Array: List of JavaScript files to include when using a specific skin + * @example array( [skin-name] => array( [file-path], [file-path], ... ), ... ) + */ + protected $skinScripts = array(); + /** + * Array: List of paths to JavaScript files to include in debug mode + * @example array( [skin-name] => array( [file-path], [file-path], ... ), ... ) + */ + protected $debugScripts = array(); + /** + * Array: List of paths to JavaScript files to include in the startup module + * @example array( [file-path], [file-path], ... ) + */ + protected $loaderScripts = array(); + /** + * Array: List of paths to CSS files to always include + * @example array( [file-path], [file-path], ... ) + */ + protected $styles = array(); + /** + * Array: List of paths to CSS files to include when using specific skins + * @example array( [file-path], [file-path], ... ) + */ + protected $skinStyles = array(); + /** + * Array: List of modules this module depends on + * @example array( [file-path], [file-path], ... ) + */ + protected $dependencies = array(); + /** + * Array: List of message keys used by this module + * @example array( [message-key], [message-key], ... ) + */ + protected $messages = array(); + /** String: Name of group to load this module in */ + protected $group; + /** Boolean: Link to raw files in debug mode */ + protected $debugRaw = true; + /** + * Array: Cache for mtime + * @example array( [hash] => [mtime], [hash] => [mtime], ... ) + */ + protected $modifiedTime = array(); + /** + * Array: Place where readStyleFile() tracks file dependencies + * @example array( [file-path], [file-path], ... ) + */ + protected $localFileRefs = array(); + + /* Methods */ + + /** + * Constructs a new module from an options array. + * + * @param $options Array: List of options; if not given or empty, an empty module will be + * constructed + * @param $localBasePath String: Base path to prepend to all local paths in $options. Defaults + * to $IP + * @param $remoteBasePath String: Base path to prepend to all remote paths in $options. Defaults + * to $wgScriptPath + * + * @example $options + * array( + * // Base path to prepend to all local paths in $options. Defaults to $IP + * 'localBasePath' => [base path], + * // Base path to prepend to all remote paths in $options. Defaults to $wgScriptPath + * 'remoteBasePath' => [base path], + * // Equivalent of remoteBasePath, but relative to $wgExtensionAssetsPath + * 'remoteExtPath' => [base path], + * // Scripts to always include + * 'scripts' => [file path string or array of file path strings], + * // Scripts to include in specific language contexts + * 'languageScripts' => array( + * [language code] => [file path string or array of file path strings], + * ), + * // Scripts to include in specific skin contexts + * 'skinScripts' => array( + * [skin name] => [file path string or array of file path strings], + * ), + * // Scripts to include in debug contexts + * 'debugScripts' => [file path string or array of file path strings], + * // Scripts to include in the startup module + * 'loaderScripts' => [file path string or array of file path strings], + * // Modules which must be loaded before this module + * 'dependencies' => [modile name string or array of module name strings], + * // Styles to always load + * 'styles' => [file path string or array of file path strings], + * // Styles to include in specific skin contexts + * 'skinStyles' => array( + * [skin name] => [file path string or array of file path strings], + * ), + * // Messages to always load + * 'messages' => [array of message key strings], + * // Group which this module should be loaded together with + * 'group' => [group name string], + * ) + */ + public function __construct( $options = array(), $localBasePath = null, + $remoteBasePath = null ) + { + global $IP, $wgScriptPath; + $this->localBasePath = $localBasePath === null ? $IP : $localBasePath; + $this->remoteBasePath = $remoteBasePath === null ? $wgScriptPath : $remoteBasePath; + + if ( isset( $options['remoteExtPath'] ) ) { + global $wgExtensionAssetsPath; + $this->remoteBasePath = $wgExtensionAssetsPath . '/' . $options['remoteExtPath']; + } + + foreach ( $options as $member => $option ) { + switch ( $member ) { + // Lists of file paths + case 'scripts': + case 'debugScripts': + case 'loaderScripts': + case 'styles': + $this->{$member} = (array) $option; + break; + // Collated lists of file paths + case 'languageScripts': + case 'skinScripts': + case 'skinStyles': + if ( !is_array( $option ) ) { + throw new MWException( + "Invalid collated file path list error. " . + "'$option' given, array expected." + ); + } + foreach ( $option as $key => $value ) { + if ( !is_string( $key ) ) { + throw new MWException( + "Invalid collated file path list key error. " . + "'$key' given, string expected." + ); + } + $this->{$member}[$key] = (array) $value; + } + break; + // Lists of strings + case 'dependencies': + case 'messages': + $this->{$member} = (array) $option; + break; + // Single strings + case 'group': + case 'localBasePath': + case 'remoteBasePath': + $this->{$member} = (string) $option; + break; + // Single booleans + case 'debugRaw': + $this->{$member} = (bool) $option; + break; + } + } + // Make sure the remote base path is a complete valid url + $this->remoteBasePath = wfExpandUrl( $this->remoteBasePath ); + } + + /** + * Gets all scripts for a given context concatenated together. + * + * @param $context ResourceLoaderContext: Context in which to generate script + * @return String: JavaScript code for $context + */ + public function getScript( ResourceLoaderContext $context ) { + $files = array_merge( + $this->scripts, + self::tryForKey( $this->languageScripts, $context->getLanguage() ), + self::tryForKey( $this->skinScripts, $context->getSkin(), 'default' ) + ); + if ( $context->getDebug() ) { + $files = array_merge( $files, $this->debugScripts ); + if ( $this->debugRaw ) { + $script = ''; + foreach ( $files as $file ) { + $path = $this->getRemotePath( $file ); + $script .= "\n\t" . Xml::encodeJsCall( 'mediaWiki.loader.load', array( $path ) ); + } + return $script; + } + } + return $this->readScriptFiles( $files ); + } + + /** + * Gets loader script. + * + * @return String: JavaScript code to be added to startup module + */ + public function getLoaderScript() { + if ( count( $this->loaderScripts ) == 0 ) { + return false; + } + return $this->readScriptFiles( $this->loaderScripts ); + } + + /** + * Gets all styles for a given context concatenated together. + * + * @param $context ResourceLoaderContext: Context in which to generate styles + * @return String: CSS code for $context + */ + public function getStyles( ResourceLoaderContext $context ) { + // Merge general styles and skin specific styles, retaining media type collation + $styles = $this->readStyleFiles( $this->styles, $this->getFlip( $context ) ); + $skinStyles = $this->readStyleFiles( + self::tryForKey( $this->skinStyles, $context->getSkin(), 'default' ), + $this->getFlip( $context ) + ); + + foreach ( $skinStyles as $media => $style ) { + if ( isset( $styles[$media] ) ) { + $styles[$media] .= $style; + } else { + $styles[$media] = $style; + } + } + // Collect referenced files + $this->localFileRefs = array_unique( $this->localFileRefs ); + // If the list has been modified since last time we cached it, update the cache + if ( $this->localFileRefs !== $this->getFileDependencies( $context->getSkin() ) ) { + $dbw = wfGetDB( DB_MASTER ); + $dbw->replace( 'module_deps', + array( array( 'md_module', 'md_skin' ) ), array( + 'md_module' => $this->getName(), + 'md_skin' => $context->getSkin(), + 'md_deps' => FormatJson::encode( $this->localFileRefs ), + ) + ); + } + return $styles; + } + + /** + * Gets list of message keys used by this module. + * + * @return Array: List of message keys + */ + public function getMessages() { + return $this->messages; + } + + /** + * Gets the name of the group this module should be loaded in. + * + * @return String: Group name + */ + public function getGroup() { + return $this->group; + } + + /** + * Gets list of names of modules this module depends on. + * + * @return Array: List of module names + */ + public function getDependencies() { + return $this->dependencies; + } + + /** + * Get the last modified timestamp of this module. + * + * Last modified timestamps are calculated from the highest last modified + * timestamp of this module's constituent files as well as the files it + * depends on. This function is context-sensitive, only performing + * calculations on files relevant to the given language, skin and debug + * mode. + * + * @param $context ResourceLoaderContext: Context in which to calculate + * the modified time + * @return Integer: UNIX timestamp + * @see ResourceLoaderModule::getFileDependencies + */ + public function getModifiedTime( ResourceLoaderContext $context ) { + if ( isset( $this->modifiedTime[$context->getHash()] ) ) { + return $this->modifiedTime[$context->getHash()]; + } + wfProfileIn( __METHOD__ ); + + $files = array(); + + // Flatten style files into $files + $styles = self::collateFilePathListByOption( $this->styles, 'media', 'all' ); + foreach ( $styles as $styleFiles ) { + $files = array_merge( $files, $styleFiles ); + } + $skinFiles = self::tryForKey( + self::collateFilePathListByOption( $this->skinStyles, 'media', 'all' ), + $context->getSkin(), + 'default' + ); + foreach ( $skinFiles as $styleFiles ) { + $files = array_merge( $files, $styleFiles ); + } + + // Final merge, this should result in a master list of dependent files + $files = array_merge( + $files, + $this->scripts, + $context->getDebug() ? $this->debugScripts : array(), + self::tryForKey( $this->languageScripts, $context->getLanguage() ), + self::tryForKey( $this->skinScripts, $context->getSkin(), 'default' ), + $this->loaderScripts + ); + $files = array_map( array( $this, 'getLocalPath' ), $files ); + // File deps need to be treated separately because they're already prefixed + $files = array_merge( $files, $this->getFileDependencies( $context->getSkin() ) ); + + // If a module is nothing but a list of dependencies, we need to avoid + // giving max() an empty array + if ( count( $files ) === 0 ) { + wfProfileOut( __METHOD__ ); + return $this->modifiedTime[$context->getHash()] = 1; + } + + wfProfileIn( __METHOD__.'-filemtime' ); + $filesMtime = max( array_map( 'filemtime', $files ) ); + wfProfileOut( __METHOD__.'-filemtime' ); + $this->modifiedTime[$context->getHash()] = max( + $filesMtime, + $this->getMsgBlobMtime( $context->getLanguage() ) ); + + wfProfileOut( __METHOD__ ); + return $this->modifiedTime[$context->getHash()]; + } + + /* Protected Members */ + + protected function getLocalPath( $path ) { + return "{$this->localBasePath}/$path"; + } + + protected function getRemotePath( $path ) { + return "{$this->remoteBasePath}/$path"; + } + + /** + * Collates file paths by option (where provided). + * + * @param $list Array: List of file paths in any combination of index/path + * or path/options pairs + * @param $option String: option name + * @param $default Mixed: default value if the option isn't set + * @return Array: List of file paths, collated by $option + */ + protected static function collateFilePathListByOption( array $list, $option, $default ) { + $collatedFiles = array(); + foreach ( (array) $list as $key => $value ) { + if ( is_int( $key ) ) { + // File name as the value + if ( !isset( $collatedFiles[$default] ) ) { + $collatedFiles[$default] = array(); + } + $collatedFiles[$default][] = $value; + } else if ( is_array( $value ) ) { + // File name as the key, options array as the value + $optionValue = isset( $value[$option] ) ? $value[$option] : $default; + if ( !isset( $collatedFiles[$optionValue] ) ) { + $collatedFiles[$optionValue] = array(); + } + $collatedFiles[$optionValue][] = $key; + } + } + return $collatedFiles; + } + + /** + * Gets a list of element that match a key, optionally using a fallback key. + * + * @param $list Array: List of lists to select from + * @param $key String: Key to look for in $map + * @param $fallback String: Key to look for in $list if $key doesn't exist + * @return Array: List of elements from $map which matched $key or $fallback, + * or an empty list in case of no match + */ + protected static function tryForKey( array $list, $key, $fallback = null ) { + if ( isset( $list[$key] ) && is_array( $list[$key] ) ) { + return $list[$key]; + } else if ( is_string( $fallback ) + && isset( $list[$fallback] ) + && is_array( $list[$fallback] ) ) + { + return $list[$fallback]; + } + return array(); + } + + /** + * Gets the contents of a list of JavaScript files. + * + * @param $scripts Array: List of file paths to scripts to read, remap and concetenate + * @return String: Concatenated and remapped JavaScript data from $scripts + */ + protected function readScriptFiles( array $scripts ) { + if ( empty( $scripts ) ) { + return ''; + } + $js = ''; + foreach ( array_unique( $scripts ) as $fileName ) { + $localPath = $this->getLocalPath( $fileName ); + $contents = file_get_contents( $localPath ); + if ( $contents === false ) { + throw new MWException( __METHOD__.": script file not found: \"$localPath\"" ); + } + $js .= $contents . "\n"; + } + return $js; + } + + /** + * Gets the contents of a list of CSS files. + * + * @param $styles Array: List of file paths to styles to read, remap and concetenate + * @return Array: List of concatenated and remapped CSS data from $styles, + * keyed by media type + */ + protected function readStyleFiles( array $styles, $flip ) { + if ( empty( $styles ) ) { + return array(); + } + $styles = self::collateFilePathListByOption( $styles, 'media', 'all' ); + foreach ( $styles as $media => $files ) { + $uniqueFiles = array_unique( $files ); + $styles[$media] = implode( + "\n", + array_map( + array( $this, 'readStyleFile' ), + $uniqueFiles, + array_fill( 0, count( $uniqueFiles ), $flip ) + ) + ); + } + return $styles; + } + + /** + * Reads a style file. + * + * This method can be used as a callback for array_map() + * + * @param $path String: File path of script file to read + * @return String: CSS data in script file + */ + protected function readStyleFile( $path, $flip ) { + $localPath = $this->getLocalPath( $path ); + $style = file_get_contents( $localPath ); + if ( $style === false ) { + throw new MWException( __METHOD__.": style file not found: \"$localPath\"" ); + } + if ( $flip ) { + $style = CSSJanus::transform( $style, true, false ); + } + $dir = $this->getLocalPath( dirname( $path ) ); + $remoteDir = $this->getRemotePath( dirname( $path ) ); + // Get and register local file references + $this->localFileRefs = array_merge( + $this->localFileRefs, + CSSMin::getLocalFileReferences( $style, $dir ) ); + return CSSMin::remap( + $style, $dir, $remoteDir, true + ); + } +} diff --git a/includes/resourceloader/ResourceLoaderModule.php b/includes/resourceloader/ResourceLoaderModule.php new file mode 100644 index 00000000..77d230c9 --- /dev/null +++ b/includes/resourceloader/ResourceLoaderModule.php @@ -0,0 +1,239 @@ +<?php +/** + * 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 + * @author Trevor Parscal + * @author Roan Kattouw + */ + +/** + * Abstraction for resource loader modules, with name registration and maxage functionality. + */ +abstract class ResourceLoaderModule { + + /* Protected Members */ + + protected $name = null; + + // In-object cache for file dependencies + protected $fileDeps = array(); + // In-object cache for message blob mtime + protected $msgBlobMtime = array(); + + /* Methods */ + + /** + * Get this module's name. This is set when the module is registered + * with ResourceLoader::register() + * + * @return Mixed: Name (string) or null if no name was set + */ + public function getName() { + return $this->name; + } + + /** + * Set this module's name. This is called by ResourceLodaer::register() + * when registering the module. Other code should not call this. + * + * @param $name String: Name + */ + public function setName( $name ) { + $this->name = $name; + } + + /** + * Get whether CSS for this module should be flipped + */ + public function getFlip( $context ) { + return $context->getDirection() === 'rtl'; + } + + /** + * Get all JS for this module for a given language and skin. + * Includes all relevant JS except loader scripts. + * + * @param $context ResourceLoaderContext: Context object + * @return String: JavaScript code + */ + public function getScript( ResourceLoaderContext $context ) { + // Stub, override expected + return ''; + } + + /** + * Get all CSS for this module for a given skin. + * + * @param $context ResourceLoaderContext: Context object + * @return Array: List of CSS strings keyed by media type + */ + public function getStyles( ResourceLoaderContext $context ) { + // Stub, override expected + return ''; + } + + /** + * Get the messages needed for this module. + * + * To get a JSON blob with messages, use MessageBlobStore::get() + * + * @return Array: List of message keys. Keys may occur more than once + */ + public function getMessages() { + // Stub, override expected + return array(); + } + + /** + * Get the group this module is in. + * + * @return String: Group name + */ + public function getGroup() { + // Stub, override expected + return null; + } + + /** + * Get the loader JS for this module, if set. + * + * @return Mixed: JavaScript loader code as a string or boolean false if no custom loader set + */ + public function getLoaderScript() { + // Stub, override expected + return false; + } + + /** + * Get a list of modules this module depends on. + * + * Dependency information is taken into account when loading a module + * on the client side. When adding a module on the server side, + * dependency information is NOT taken into account and YOU are + * responsible for adding dependent modules as well. If you don't do + * this, the client side loader will send a second request back to the + * server to fetch the missing modules, which kind of defeats the + * purpose of the resource loader. + * + * To add dependencies dynamically on the client side, use a custom + * loader script, see getLoaderScript() + * @return Array: List of module names as strings + */ + public function getDependencies() { + // Stub, override expected + return array(); + } + + /** + * Get the files this module depends on indirectly for a given skin. + * Currently these are only image files referenced by the module's CSS. + * + * @param $skin String: Skin name + * @return Array: List of files + */ + public function getFileDependencies( $skin ) { + // Try in-object cache first + if ( isset( $this->fileDeps[$skin] ) ) { + return $this->fileDeps[$skin]; + } + + $dbr = wfGetDB( DB_SLAVE ); + $deps = $dbr->selectField( 'module_deps', 'md_deps', array( + 'md_module' => $this->getName(), + 'md_skin' => $skin, + ), __METHOD__ + ); + if ( !is_null( $deps ) ) { + $this->fileDeps[$skin] = (array) FormatJson::decode( $deps, true ); + } else { + $this->fileDeps[$skin] = array(); + } + return $this->fileDeps[$skin]; + } + + /** + * Set preloaded file dependency information. Used so we can load this + * information for all modules at once. + * @param $skin String: Skin name + * @param $deps Array: Array of file names + */ + public function setFileDependencies( $skin, $deps ) { + $this->fileDeps[$skin] = $deps; + } + + /** + * Get the last modification timestamp of the message blob for this + * module in a given language. + * @param $lang String: Language code + * @return Integer: UNIX timestamp, or 0 if no blob found + */ + public function getMsgBlobMtime( $lang ) { + if ( !isset( $this->msgBlobMtime[$lang] ) ) { + if ( !count( $this->getMessages() ) ) + return 0; + + $dbr = wfGetDB( DB_SLAVE ); + $msgBlobMtime = $dbr->selectField( 'msg_resource', 'mr_timestamp', array( + 'mr_resource' => $this->getName(), + 'mr_lang' => $lang + ), __METHOD__ + ); + $this->msgBlobMtime[$lang] = $msgBlobMtime ? wfTimestamp( TS_UNIX, $msgBlobMtime ) : 0; + } + return $this->msgBlobMtime[$lang]; + } + + /** + * Set a preloaded message blob last modification timestamp. Used so we + * can load this information for all modules at once. + * @param $lang String: Language code + * @param $mtime Integer: UNIX timestamp or 0 if there is no such blob + */ + public function setMsgBlobMtime( $lang, $mtime ) { + $this->msgBlobMtime[$lang] = $mtime; + } + + /* Abstract Methods */ + + /** + * Get this module's last modification timestamp for a given + * combination of language, skin and debug mode flag. This is typically + * the highest of each of the relevant components' modification + * timestamps. Whenever anything happens that changes the module's + * contents for these parameters, the mtime should increase. + * + * @param $context ResourceLoaderContext: Context object + * @return Integer: UNIX timestamp + */ + public function getModifiedTime( ResourceLoaderContext $context ) { + // 0 would mean now + return 1; + } + + /** + * Check whether this module is known to be empty. If a child class + * has an easy and cheap way to determine that this module is + * definitely going to be empty, it should override this method to + * return true in that case. Callers may optimize the request for this + * module away if this function returns true. + * @param $context ResourceLoaderContext: Context object + * @return Boolean + */ + public function isKnownEmpty( ResourceLoaderContext $context ) { + return false; + } +} diff --git a/includes/resourceloader/ResourceLoaderSiteModule.php b/includes/resourceloader/ResourceLoaderSiteModule.php new file mode 100644 index 00000000..977d16bb --- /dev/null +++ b/includes/resourceloader/ResourceLoaderSiteModule.php @@ -0,0 +1,63 @@ +<?php +/** + * 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 + * @author Trevor Parscal + * @author Roan Kattouw + */ + +/** + * Module for site customizations + */ +class ResourceLoaderSiteModule extends ResourceLoaderWikiModule { + + /* Protected Methods */ + + /** + * Gets list of pages used by this module + * + * @return Array: List of pages + */ + protected function getPages( ResourceLoaderContext $context ) { + global $wgHandheldStyle; + + $pages = array( + 'MediaWiki:Common.js' => array( 'type' => 'script' ), + 'MediaWiki:Common.css' => array( 'type' => 'style' ), + 'MediaWiki:' . ucfirst( $context->getSkin() ) . '.js' => array( 'type' => 'script' ), + 'MediaWiki:' . ucfirst( $context->getSkin() ) . '.css' => array( 'type' => 'style' ), + 'MediaWiki:Print.css' => array( 'type' => 'style', 'media' => 'print' ), + ); + if ( $wgHandheldStyle ) { + $pages['MediaWiki:Handheld.css'] = array( + 'type' => 'style', + 'media' => 'handheld' ); + } + return $pages; + } + + /* Methods */ + + /** + * Gets group name + * + * @return String: Name of group + */ + public function getGroup() { + return 'site'; + } +} diff --git a/includes/resourceloader/ResourceLoaderStartUpModule.php b/includes/resourceloader/ResourceLoaderStartUpModule.php new file mode 100644 index 00000000..2a3ba343 --- /dev/null +++ b/includes/resourceloader/ResourceLoaderStartUpModule.php @@ -0,0 +1,225 @@ +<?php +/** + * 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 + * @author Trevor Parscal + * @author Roan Kattouw + */ + +class ResourceLoaderStartUpModule extends ResourceLoaderModule { + + /* Protected Members */ + + protected $modifiedTime = array(); + + /* Protected Methods */ + + protected function getConfig( $context ) { + global $wgLoadScript, $wgScript, $wgStylePath, $wgScriptExtension, + $wgArticlePath, $wgScriptPath, $wgServer, $wgContLang, + $wgVariantArticlePath, $wgActionPaths, $wgUseAjax, $wgVersion, + $wgEnableAPI, $wgEnableWriteAPI, $wgDBname, $wgEnableMWSuggest, + $wgSitename, $wgFileExtensions, $wgExtensionAssetsPath, + $wgResourceLoaderMaxQueryLength; + + // Pre-process information + $separatorTransTable = $wgContLang->separatorTransformTable(); + $separatorTransTable = $separatorTransTable ? $separatorTransTable : array(); + $compactSeparatorTransTable = array( + implode( "\t", array_keys( $separatorTransTable ) ), + implode( "\t", $separatorTransTable ), + ); + $digitTransTable = $wgContLang->digitTransformTable(); + $digitTransTable = $digitTransTable ? $digitTransTable : array(); + $compactDigitTransTable = array( + implode( "\t", array_keys( $digitTransTable ) ), + implode( "\t", $digitTransTable ), + ); + $mainPage = Title::newMainPage(); + + // Build list of variables + $vars = array( + 'wgLoadScript' => $wgLoadScript, + 'debug' => $context->getDebug(), + 'skin' => $context->getSkin(), + 'stylepath' => $wgStylePath, + 'wgUrlProtocols' => wfUrlProtocols(), + 'wgArticlePath' => $wgArticlePath, + 'wgScriptPath' => $wgScriptPath, + 'wgScriptExtension' => $wgScriptExtension, + 'wgScript' => $wgScript, + 'wgVariantArticlePath' => $wgVariantArticlePath, + 'wgActionPaths' => $wgActionPaths, + 'wgServer' => $wgServer, + 'wgUserLanguage' => $context->getLanguage(), + 'wgContentLanguage' => $wgContLang->getCode(), + 'wgVersion' => $wgVersion, + 'wgEnableAPI' => $wgEnableAPI, + 'wgEnableWriteAPI' => $wgEnableWriteAPI, + 'wgSeparatorTransformTable' => $compactSeparatorTransTable, + 'wgDigitTransformTable' => $compactDigitTransTable, + 'wgMainPageTitle' => $mainPage ? $mainPage->getPrefixedText() : null, + 'wgFormattedNamespaces' => $wgContLang->getFormattedNamespaces(), + 'wgNamespaceIds' => $wgContLang->getNamespaceIds(), + 'wgSiteName' => $wgSitename, + 'wgFileExtensions' => array_values( $wgFileExtensions ), + 'wgDBname' => $wgDBname, + 'wgExtensionAssetsPath' => $wgExtensionAssetsPath, + 'wgResourceLoaderMaxQueryLength' => $wgResourceLoaderMaxQueryLength, + ); + if ( $wgContLang->hasVariants() ) { + $vars['wgUserVariant'] = $wgContLang->getPreferredVariant(); + } + if ( $wgUseAjax && $wgEnableMWSuggest ) { + $vars['wgMWSuggestTemplate'] = SearchEngine::getMWSuggestTemplate(); + } + + wfRunHooks( 'ResourceLoaderGetConfigVars', array( &$vars ) ); + + return $vars; + } + + /** + * Gets registration code for all modules + * + * @param $context ResourceLoaderContext object + * @return String: JavaScript code for registering all modules with the client loader + */ + public static function getModuleRegistrations( ResourceLoaderContext $context ) { + global $wgCacheEpoch; + wfProfileIn( __METHOD__ ); + + $out = ''; + $registrations = array(); + $resourceLoader = $context->getResourceLoader(); + foreach ( $resourceLoader->getModuleNames() as $name ) { + $module = $resourceLoader->getModule( $name ); + // Support module loader scripts + $loader = $module->getLoaderScript(); + if ( $loader !== false ) { + $deps = $module->getDependencies(); + $group = $module->getGroup(); + $version = wfTimestamp( TS_ISO_8601_BASIC, + round( $module->getModifiedTime( $context ), -2 ) ); + $out .= ResourceLoader::makeCustomLoaderScript( $name, $version, $deps, $group, $loader ); + } + // Automatically register module + else { + $mtime = max( $module->getModifiedTime( $context ), wfTimestamp( TS_UNIX, $wgCacheEpoch ) ); + // Modules without dependencies or a group pass two arguments (name, timestamp) to + // mediaWiki.loader.register() + if ( !count( $module->getDependencies() && $module->getGroup() === null ) ) { + $registrations[] = array( $name, $mtime ); + } + // Modules with dependencies but no group pass three arguments + // (name, timestamp, dependencies) to mediaWiki.loader.register() + else if ( $module->getGroup() === null ) { + $registrations[] = array( + $name, $mtime, $module->getDependencies() ); + } + // Modules with dependencies pass four arguments (name, timestamp, dependencies, group) + // to mediaWiki.loader.register() + else { + $registrations[] = array( + $name, $mtime, $module->getDependencies(), $module->getGroup() ); + } + } + } + $out .= ResourceLoader::makeLoaderRegisterScript( $registrations ); + + wfProfileOut( __METHOD__ ); + return $out; + } + + /* Methods */ + + public function getScript( ResourceLoaderContext $context ) { + global $IP, $wgLoadScript; + + $out = file_get_contents( "$IP/resources/startup.js" ); + if ( $context->getOnly() === 'scripts' ) { + // Build load query for jquery and mediawiki modules + $query = array( + 'modules' => implode( '|', array( 'jquery', 'mediawiki' ) ), + 'only' => 'scripts', + 'lang' => $context->getLanguage(), + 'skin' => $context->getSkin(), + 'debug' => $context->getDebug() ? 'true' : 'false', + 'version' => wfTimestamp( TS_ISO_8601_BASIC, round( max( + $context->getResourceLoader()->getModule( 'jquery' )->getModifiedTime( $context ), + $context->getResourceLoader()->getModule( 'mediawiki' )->getModifiedTime( $context ) + ), -2 ) ) + ); + // Ensure uniform query order + ksort( $query ); + + // Startup function + $configuration = $this->getConfig( $context ); + $registrations = self::getModuleRegistrations( $context ); + $out .= "var startUp = function() {\n" . + "\t$registrations\n" . + "\t" . Xml::encodeJsCall( 'mediaWiki.config.set', array( $configuration ) ) . + "};\n"; + + // Conditional script injection + $scriptTag = Html::linkedScript( $wgLoadScript . '?' . wfArrayToCGI( $query ) ); + $out .= "if ( isCompatible() ) {\n" . + "\t" . Xml::encodeJsCall( 'document.write', array( $scriptTag ) ) . + "}\n" . + "delete isCompatible;"; + } + + return $out; + } + + public function getModifiedTime( ResourceLoaderContext $context ) { + global $IP, $wgCacheEpoch; + + $hash = $context->getHash(); + if ( isset( $this->modifiedTime[$hash] ) ) { + return $this->modifiedTime[$hash]; + } + + // Call preloadModuleInfo() on ALL modules as we're about + // to call getModifiedTime() on all of them + $loader = $context->getResourceLoader(); + $loader->preloadModuleInfo( $loader->getModuleNames(), $context ); + + $this->modifiedTime[$hash] = filemtime( "$IP/resources/startup.js" ); + // ATTENTION!: Because of the line above, this is not going to cause + // infinite recursion - think carefully before making changes to this + // code! + $time = wfTimestamp( TS_UNIX, $wgCacheEpoch ); + foreach ( $loader->getModuleNames() as $name ) { + $module = $loader->getModule( $name ); + $time = max( $time, $module->getModifiedTime( $context ) ); + } + return $this->modifiedTime[$hash] = $time; + } + + public function getFlip( $context ) { + global $wgContLang; + + return $wgContLang->getDir() !== $context->getDirection(); + } + + /* Methods */ + + public function getGroup() { + return 'startup'; + } +} diff --git a/includes/resourceloader/ResourceLoaderUserModule.php b/includes/resourceloader/ResourceLoaderUserModule.php new file mode 100644 index 00000000..c7186653 --- /dev/null +++ b/includes/resourceloader/ResourceLoaderUserModule.php @@ -0,0 +1,50 @@ +<?php +/** + * 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 + * @author Trevor Parscal + * @author Roan Kattouw + */ + +/** + * Module for user customizations + */ +class ResourceLoaderUserModule extends ResourceLoaderWikiModule { + + /* Protected Methods */ + + protected function getPages( ResourceLoaderContext $context ) { + if ( $context->getUser() ) { + $username = $context->getUser(); + return array( + "User:$username/common.js" => array( 'type' => 'script' ), + "User:$username/" . $context->getSkin() . '.js' => + array( 'type' => 'script' ), + "User:$username/common.css" => array( 'type' => 'style' ), + "User:$username/" . $context->getSkin() . '.css' => + array( 'type' => 'style' ), + ); + } + return array(); + } + + /* Methods */ + + public function getGroup() { + return 'user'; + } +} diff --git a/includes/resourceloader/ResourceLoaderUserOptionsModule.php b/includes/resourceloader/ResourceLoaderUserOptionsModule.php new file mode 100644 index 00000000..ae654b8f --- /dev/null +++ b/includes/resourceloader/ResourceLoaderUserOptionsModule.php @@ -0,0 +1,121 @@ +<?php +/** + * 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 + * @author Trevor Parscal + * @author Roan Kattouw + */ + +/** + * Module for user preference customizations + */ +class ResourceLoaderUserOptionsModule extends ResourceLoaderModule { + + /* Protected Members */ + + protected $modifiedTime = array(); + + /* Methods */ + + public function getModifiedTime( ResourceLoaderContext $context ) { + $hash = $context->getHash(); + if ( isset( $this->modifiedTime[$hash] ) ) { + return $this->modifiedTime[$hash]; + } + + global $wgUser; + + if ( $context->getUser() === $wgUser->getName() ) { + return $this->modifiedTime[$hash] = $wgUser->getTouched(); + } else { + return 1; + } + } + + /** + * Fetch the context's user options, or if it doesn't match current user, + * the default options. + * + * @param $context ResourceLoaderContext: Context object + * @return Array: List of user options keyed by option name + */ + protected function contextUserOptions( ResourceLoaderContext $context ) { + global $wgUser; + + // Verify identity -- this is a private module + if ( $context->getUser() === $wgUser->getName() ) { + return $wgUser->getOptions(); + } else { + return User::getDefaultOptions(); + } + } + + public function getScript( ResourceLoaderContext $context ) { + return Xml::encodeJsCall( 'mediaWiki.user.options.set', + array( $this->contextUserOptions( $context ) ) ); + } + + public function getStyles( ResourceLoaderContext $context ) { + global $wgAllowUserCssPrefs; + + if ( $wgAllowUserCssPrefs ) { + $options = $this->contextUserOptions( $context ); + + // Build CSS rules + $rules = array(); + if ( $options['underline'] < 2 ) { + $rules[] = "a { text-decoration: " . + ( $options['underline'] ? 'underline' : 'none' ) . "; }"; + } + if ( $options['highlightbroken'] ) { + $rules[] = "a.new, #quickbar a.new { color: #ba0000; }\n"; + } else { + $rules[] = "a.new, #quickbar a.new, a.stub, #quickbar a.stub { color: inherit; }"; + $rules[] = "a.new:after, #quickbar a.new:after { content: '?'; color: #ba0000; }"; + $rules[] = "a.stub:after, #quickbar a.stub:after { content: '!'; color: #772233; }"; + } + if ( $options['justify'] ) { + $rules[] = "#article, #bodyContent, #mw_content { text-align: justify; }\n"; + } + if ( !$options['showtoc'] ) { + $rules[] = "#toc { display: none; }\n"; + } + if ( !$options['editsection'] ) { + $rules[] = ".editsection { display: none; }\n"; + } + if ( $options['editfont'] !== 'default' ) { + $rules[] = "textarea { font-family: {$options['editfont']}; }\n"; + } + $style = implode( "\n", $rules ); + if ( $this->getFlip( $context ) ) { + $style = CSSJanus::transform( $style, true, false ); + } + return array( 'all' => $style ); + } + return array(); + } + + public function getFlip( $context ) { + global $wgContLang; + + return $wgContLang->getDir() !== $context->getDirection(); + } + + public function getGroup() { + return 'private'; + } +} diff --git a/includes/resourceloader/ResourceLoaderWikiModule.php b/includes/resourceloader/ResourceLoaderWikiModule.php new file mode 100644 index 00000000..93e66eb0 --- /dev/null +++ b/includes/resourceloader/ResourceLoaderWikiModule.php @@ -0,0 +1,171 @@ +<?php +/** + * 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 + * @author Trevor Parscal + * @author Roan Kattouw + */ + +defined( 'MEDIAWIKI' ) || die( 1 ); + +/** + * Abstraction for resource loader modules which pull from wiki pages + * + * This can only be used for wiki pages in the MediaWiki and User namespaces, + * because of its dependence on the functionality of + * Title::isValidCssJsSubpage. + */ +abstract class ResourceLoaderWikiModule extends ResourceLoaderModule { + + /* Protected Members */ + + // In-object cache for title mtimes + protected $titleMtimes = array(); + + /* Abstract Protected Methods */ + + abstract protected function getPages( ResourceLoaderContext $context ); + + /* Protected Methods */ + + protected function getContent( $title ) { + if ( $title->getNamespace() === NS_MEDIAWIKI ) { + $dbkey = $title->getDBkey(); + return wfEmptyMsg( $dbkey ) ? '' : wfMsgExt( $dbkey, 'content' ); + } + if ( !$title->isCssJsSubpage() ) { + return null; + } + $revision = Revision::newFromTitle( $title ); + if ( !$revision ) { + return null; + } + return $revision->getRawText(); + } + + /* Methods */ + + public function getScript( ResourceLoaderContext $context ) { + $scripts = ''; + foreach ( $this->getPages( $context ) as $titleText => $options ) { + if ( $options['type'] !== 'script' ) { + continue; + } + $title = Title::newFromText( $titleText ); + if ( !$title ) { + continue; + } + $script = $this->getContent( $title ); + if ( strval( $script ) !== '' ) { + if ( strpos( $titleText, '*/' ) === false ) { + $scripts .= "/* $titleText */\n"; + } + $scripts .= $script . "\n"; + } + } + return $scripts; + } + + public function getStyles( ResourceLoaderContext $context ) { + global $wgScriptPath; + + $styles = array(); + foreach ( $this->getPages( $context ) as $titleText => $options ) { + if ( $options['type'] !== 'style' ) { + continue; + } + $title = Title::newFromText( $titleText ); + if ( !$title ) { + continue; + } + $media = isset( $options['media'] ) ? $options['media'] : 'all'; + $style = $this->getContent( $title ); + if ( strval( $style ) === '' ) { + continue; + } + if ( $this->getFlip( $context ) ) { + $style = CSSJanus::transform( $style, true, false ); + } + $style = CSSMin::remap( $style, false, $wgScriptPath, true ); + if ( !isset( $styles[$media] ) ) { + $styles[$media] = ''; + } + if ( strpos( $titleText, '*/' ) === false ) { + $styles[$media] .= "/* $titleText */\n"; + } + $styles[$media] .= $style . "\n"; + } + return $styles; + } + + public function getModifiedTime( ResourceLoaderContext $context ) { + $modifiedTime = 1; // wfTimestamp() interprets 0 as "now" + $mtimes = $this->getTitleMtimes( $context ); + if ( count( $mtimes ) ) { + $modifiedTime = max( $modifiedTime, max( $mtimes ) ); + } + return $modifiedTime; + } + + public function isKnownEmpty( ResourceLoaderContext $context ) { + return count( $this->getTitleMtimes( $context ) ) == 0; + } + + /** + * @param $context ResourceLoaderContext + * @return bool + */ + public function getFlip( $context ) { + global $wgContLang; + + return $wgContLang->getDir() !== $context->getDirection(); + } + + /** + * Get the modification times of all titles that would be loaded for + * a given context. + * @param $context ResourceLoaderContext: Context object + * @return array( prefixed DB key => UNIX timestamp ), nonexistent titles are dropped + */ + protected function getTitleMtimes( ResourceLoaderContext $context ) { + $hash = $context->getHash(); + if ( isset( $this->titleMtimes[$hash] ) ) { + return $this->titleMtimes[$hash]; + } + + $this->titleMtimes[$hash] = array(); + $batch = new LinkBatch; + foreach ( $this->getPages( $context ) as $titleText => $options ) { + $batch->addObj( Title::newFromText( $titleText ) ); + } + + if ( !$batch->isEmpty() ) { + $dbr = wfGetDB( DB_SLAVE ); + $res = $dbr->select( 'page', + array( 'page_namespace', 'page_title', 'page_touched' ), + $batch->constructSet( 'page', $dbr ), + __METHOD__ + ); + foreach ( $res as $row ) { + $title = Title::makeTitle( $row->page_namespace, $row->page_title ); + $this->titleMtimes[$hash][$title->getPrefixedDBkey()] = + wfTimestamp( TS_UNIX, $row->page_touched ); + } + } + return $this->titleMtimes[$hash]; + } +} |