* The ExpandTemplates extension has been moved into MediaWiki core.
* (bug 52812) Removed "Disable search suggestions" from Preference.
* (bug 52809) Removed "Disable browser page caching" from Preference.
+* Three new modules intended for use by custom skins were added:
+ 'skins.common.elements', 'skins.common.content', and 'skins.common.interface',
+ representing three levels of standard MediaWiki styling. Previously skin
+ creators wishing to use them had to refer to the file names of appropriate
+ files directly, which is now discouraged.
+* The modules 'skins.vector' and 'skins.monobook' have been renamed to
+ 'skins.vector.styles' and 'skins.monobook.styles', respectively,
+ and their definition was changed not to include the common*.css files;
+ the two skins now load the 'skins.common.interface' module instead.
+* A page_links_updated field has been added to the page table.
== Compatibility ==
* - Jobs that you would never want to run as part of a page rendering request.
* - Jobs that you want to run on specialized machines ( like transcoding, or a particular
* machine on your cluster has 'outside' web access you could restrict uploadFromUrl )
+ * These settings should be global to all wikis.
*/
$wgJobTypesExcludedFromDefaultQueue = array( 'AssembleUploadChunks', 'PublishStashedFile' );
+/**
+ * Map of job types to how many job "work items" should be run per second
+ * on each job runner process. The meaning of "work items" varies per job,
+ * but typically would be something like "pages to update". A single job
+ * may have a variable number of work items, as is the case with batch jobs.
+ * These settings should be global to all wikis.
+ */
+$wgJobBackoffThrottling = array();
+
/**
* Map of job types to configuration arrays.
* This determines which queue class and storage system is used for each job type.
*/
protected $mTouched = '19700101000000';
+ /**
+ * @var string
+ */
+ protected $mLinksUpdated = '19700101000000';
+
/**
* @var int|null
*/
$this->mRedirectTarget = null; // Title object if set
$this->mLastRevision = null; // Latest revision
$this->mTouched = '19700101000000';
+ $this->mLinksUpdated = '19700101000000';
$this->mTimestamp = '';
$this->mIsRedirect = false;
$this->mLatest = false;
'page_is_new',
'page_random',
'page_touched',
+ 'page_links_updated',
'page_latest',
'page_len',
);
$this->mId = intval( $data->page_id );
$this->mCounter = intval( $data->page_counter );
$this->mTouched = wfTimestamp( TS_MW, $data->page_touched );
+ $this->mLinksUpdated = wfTimestampOrNull( TS_MW, $data->page_links_updated );
$this->mIsRedirect = intval( $data->page_is_redirect );
$this->mLatest = intval( $data->page_latest );
// Bug 37225: $latest may no longer match the cached latest Revision object.
return $this->mTouched;
}
+ /**
+ * Get the page_links_updated field
+ * @return string|null containing GMT timestamp
+ */
+ public function getLinksTimestamp() {
+ if ( !$this->mDataLoaded ) {
+ $this->loadPageData();
+ }
+ return $this->mLinksUpdated;
+ }
+
/**
* Get the page_latest field
* @return integer rev_id of current revision
'page' => array( 'LEFT JOIN',
array( 'log_namespace=page_namespace',
'log_title=page_title' ) ) ) );
- $index = array( 'logging' => 'times' ); // default, may change
$this->addFields( array(
'log_type',
$this->addWhereFld( 'log_action', $action );
} elseif ( !is_null( $params['type'] ) ) {
$this->addWhereFld( 'log_type', $params['type'] );
- $index['logging'] = 'type_time';
}
$this->addTimestampWhereRange(
$user = $params['user'];
if ( !is_null( $user ) ) {
$userid = User::idFromName( $user );
- if ( !$userid ) {
- $this->dieUsage( "User name $user not found", 'param_user' );
+ if ( $userid ) {
+ $this->addWhereFld( 'log_user', $userid );
+ } else {
+ $this->addWhereFld( 'log_user_text', IP::sanitizeIP( $user ) );
}
- $this->addWhereFld( 'log_user', $userid );
- $index['logging'] = 'user_time';
}
$title = $params['title'];
}
$this->addWhereFld( 'log_namespace', $titleObj->getNamespace() );
$this->addWhereFld( 'log_title', $titleObj->getDBkey() );
-
- // Use the title index in preference to the user index if there is a conflict
- $index['logging'] = is_null( $user ) ? 'page_time' : array( 'page_time', 'user_time' );
}
$prefix = $params['prefix'];
$this->addWhere( 'log_title ' . $db->buildLike( $title->getDBkey(), $db->anyString() ) );
}
- $this->addOption( 'USE INDEX', $index );
-
// Paranoia: avoid brute force searches (bug 17342)
if ( !is_null( $title ) ) {
$this->addWhere( $db->bitAnd( 'log_deleted', LogPage::DELETED_ACTION ) . ' = 0' );
$changed = $propertiesDeletes + array_diff_assoc( $this->mProperties, $existing );
$this->invalidateProperties( $changed );
+ # Update the links table freshness for this title
+ $this->updateLinksTimestamp();
+
# Refresh links of all pages including this page
# This will be in a separate transaction
if ( $this->mRecursive ) {
return $result;
}
+
+ /**
+ * Update links table freshness
+ */
+ protected function updateLinksTimestamp() {
+ if ( $this->mId ) {
+ $this->mDb->update( 'page',
+ array( 'page_links_updated' => $this->mDb->timestamp() ),
+ array( 'page_id' => $this->mId ),
+ __METHOD__
+ );
+ }
+ }
}
/**
array( 'addField', 'recentchanges', 'rc_source', 'patch-rc_source.sql' ),
array( 'addIndex', 'logging', 'log_user_text_type_time', 'patch-logging_user_text_type_time_index.sql' ),
array( 'addIndex', 'logging', 'log_user_text_time', 'patch-logging_user_text_time_index.sql' ),
+ array( 'addField', 'page', 'page_links_updated', 'patch-page_links_updated.sql' ),
);
}
// 1.23
array( 'addPgField', 'recentchanges', 'rc_source', "TEXT NOT NULL DEFAULT ''" ),
+ array( 'addPgField', 'page', 'page_links_updated', "TIMESTAMPTZ NULL" ),
);
}
array( 'addField', 'recentchanges', 'rc_source', 'patch-rc_source.sql' ),
array( 'addIndex', 'logging', 'log_user_text_type_time', 'patch-logging_user_text_type_time_index.sql' ),
array( 'addIndex', 'logging', 'log_user_text_time', 'patch-logging_user_text_time_index.sql' ),
+ array( 'addField', 'page', 'page_links_updated', 'patch-page_links_updated.sql' ),
);
}
// and loaded as one file.
$moduleNames = array(
'mediawiki.legacy.shared',
- 'skins.common',
+ 'skins.common.interface',
'skins.vector.styles',
'mediawiki.legacy.config',
);
return true;
}
+ /**
+ * @return integer Number of actually "work items" handled in this job
+ * @see $wgJobBackoffThrottling
+ * @since 1.23
+ */
+ public function workItemCount() {
+ return 1;
+ }
+
/**
* Subclasses may need to override this to make duplication detection work.
* The resulting map conveys everything that makes the job unique. This is
const TYPE_ANY = 2; // integer; any job
const USE_CACHE = 1; // integer; use process or persistent cache
- const USE_PRIORITY = 2; // integer; respect deprioritization
const PROC_CACHE_TTL = 15; // integer; seconds
* This pops a job off a queue as specified by $wgJobTypeConf and
* updates the aggregate job queue information cache as needed.
*
- * @param int|string $qtype JobQueueGroup::TYPE_DEFAULT or type string
+ * @param int|string $qtype JobQueueGroup::TYPE_* constant or job type string
* @param int $flags Bitfield of JobQueueGroup::USE_* constants
+ * @param array $blacklist List of job types to ignore
* @return Job|bool Returns false on failure
*/
- public function pop( $qtype = self::TYPE_DEFAULT, $flags = 0 ) {
+ public function pop( $qtype = self::TYPE_DEFAULT, $flags = 0, array $blacklist = array() ) {
+ $job = false;
+
if ( is_string( $qtype ) ) { // specific job type
- $job = $this->get( $qtype )->pop();
- if ( !$job ) {
- JobQueueAggregator::singleton()->notifyQueueEmpty( $this->wiki, $qtype );
+ if ( !in_array( $qtype, $blacklist ) ) {
+ $job = $this->get( $qtype )->pop();
+ if ( !$job ) {
+ JobQueueAggregator::singleton()->notifyQueueEmpty( $this->wiki, $qtype );
+ }
}
-
- return $job;
} else { // any job in the "default" jobs types
if ( $flags & self::USE_CACHE ) {
if ( !$this->cache->has( 'queues-ready', 'list', self::PROC_CACHE_TTL ) ) {
if ( $qtype == self::TYPE_DEFAULT ) {
$types = array_intersect( $types, $this->getDefaultQueueTypes() );
}
+
+ $types = array_diff( $types, $blacklist ); // avoid selected types
shuffle( $types ); // avoid starvation
foreach ( $types as $type ) { // for each queue...
$job = $this->get( $type )->pop();
if ( $job ) { // found
- return $job;
+ break;
} else { // not found
JobQueueAggregator::singleton()->notifyQueueEmpty( $this->wiki, $type );
$this->cache->clear( 'queues-ready' );
}
}
-
- return false; // no jobs found
}
+
+ return $job;
}
/**
}
}
}
+
+ public function workItemCount() {
+ return isset( $this->params['pages'] ) ? count( $this->params['pages'] ) : 1;
+ }
}
if ( isset( $this->params['rootJobTimestamp'] ) ) {
$page = WikiPage::factory( $title );
$skewedTimestamp = wfTimestamp( TS_UNIX, $this->params['rootJobTimestamp'] ) + 5;
+ if ( $page->getLinksTimestamp() > wfTimestamp( TS_MW, $skewedTimestamp ) ) {
+ // Something already updated the backlinks since this job was made
+ return true;
+ }
if ( $page->getTouched() > wfTimestamp( TS_MW, $skewedTimestamp ) ) {
$parserOptions = $page->makeParserOptions( 'canonical' );
$parserOutput = ParserCache::singleton()->getDirty( $page, $parserOptions );
return $info;
}
+
+ public function workItemCount() {
+ return isset( $this->params['pages'] ) ? count( $this->params['pages'] ) : 1;
+ }
}
/* Fetch userid at first, if known, provides awesome query plan afterwards */
$userid = User::idFromName( $name );
if ( !$userid ) {
- /* It should be nicer to abort query at all,
- but for now it won't pass anywhere behind the optimizer */
- $this->mConds[] = "NULL";
+ $this->mConds['log_user_text'] = IP::sanitizeIP( $name );
} else {
$this->mConds['log_user'] = $userid;
- // Paranoia: avoid brute force searches (bug 17342)
- $user = $this->getUser();
- if ( !$user->isAllowed( 'deletedhistory' ) ) {
- $this->mConds[] = $this->mDb->bitAnd( 'log_deleted', LogPage::DELETED_USER ) . ' = 0';
- } elseif ( !$user->isAllowed( 'suppressrevision' ) ) {
- $this->mConds[] = $this->mDb->bitAnd( 'log_deleted', LogPage::SUPPRESSED_USER ) .
- ' != ' . LogPage::SUPPRESSED_USER;
- }
- $this->performer = $usertitle->getText();
}
+ // Paranoia: avoid brute force searches (bug 17342)
+ $user = $this->getUser();
+ if ( !$user->isAllowed( 'deletedhistory' ) ) {
+ $this->mConds[] = $this->mDb->bitAnd( 'log_deleted', LogPage::DELETED_USER ) . ' = 0';
+ } elseif ( !$user->isAllowed( 'suppressrevision' ) ) {
+ $this->mConds[] = $this->mDb->bitAnd( 'log_deleted', LogPage::SUPPRESSED_USER ) .
+ ' != ' . LogPage::SUPPRESSED_USER;
+ }
+ $this->performer = $usertitle->getText();
}
/**
* also the name that will be listed in Special:Specialpages
*
* Derived classes can override this, but usually it is easier to keep the
- * default behavior. Messages can be added at run-time, see
- * MessageCache.php.
+ * default behavior.
*
- * @return String
+ * @return string
*/
function getDescription() {
return $this->msg( strtolower( $this->mName ) )->text();
--- /dev/null
+ALTER TABLE /*$wgDBprefix*/page
+ ADD page_links_updated varbinary(14) NULL default NULL;
page_is_new SMALLINT NOT NULL DEFAULT 0,
page_random NUMERIC(15,14) NOT NULL DEFAULT RANDOM(),
page_touched TIMESTAMPTZ,
+ page_links_updated TIMESTAMPTZ NULL,
page_latest INTEGER NOT NULL, -- FK?
page_len INTEGER NOT NULL,
page_content_model TEXT
}
}
}
+
+ $type = $this->getOption( 'type', false );
$maxJobs = $this->getOption( 'maxjobs', false );
$maxTime = $this->getOption( 'maxtime', false );
$startTime = time();
- $type = $this->getOption( 'type', false );
$wgTitle = Title::newFromText( 'RunJobs.php' );
- $jobsRun = 0; // counter
$group = JobQueueGroup::singleton();
// Handle any required periodic queue maintenance
$this->runJobsLog( "Executed $count periodic queue task(s)." );
}
- $flags = JobQueueGroup::USE_CACHE | JobQueueGroup::USE_PRIORITY;
+ $backoffs = $this->loadBackoffs(); // map of (type => UNIX expiry)
+ $startingBackoffs = $backoffs; // avoid unnecessary writes
+ $backoffExpireFunc = function( $t ) { return $t > time(); };
+
+ $jobsRun = 0; // counter
+ $flags = JobQueueGroup::USE_CACHE;
$lastTime = time(); // time since last slave check
do {
- $job = ( $type === false )
- ? $group->pop( JobQueueGroup::TYPE_DEFAULT, $flags )
- : $group->pop( $type ); // job from a single queue
+ if ( $type === false ) {
+ $backoffs = array_filter( $backoffs, $backoffExpireFunc );
+ $blacklist = array_keys( $backoffs );
+ $job = $group->pop( JobQueueGroup::TYPE_DEFAULT, $flags, $blacklist );
+ } else {
+ $group->pop( $type ); // job from a single queue
+ }
if ( $job ) { // found a job
++$jobsRun;
$this->runJobsLog( $job->toString() . " STARTING" );
$this->runJobsLog( $job->toString() . " t=$timeMs good" );
}
+ // Back off of certain jobs for a while
+ $ttw = $this->getBackoffTimeToWait( $job );
+ if ( $ttw > 0 ) {
+ $jType = $job->getType();
+ $backoffs[$jType] = isset( $backoffs[$jType] ) ? $backoffs[$jType] : 0;
+ $backoffs[$jType] = max( $backoffs[$jType], time() + $ttw );
+ }
+
// Break out if we hit the job count or wall time limits...
if ( $maxJobs && $jobsRun >= $maxJobs ) {
break;
$this->assertMemoryOK();
}
} while ( $job ); // stop when there are no jobs
+ // Sync the persistent backoffs for the next runJobs.php pass
+ $backoffs = array_filter( $backoffs, $backoffExpireFunc );
+ if ( $backoffs !== $startingBackoffs ) {
+ $this->syncBackoffs( $backoffs );
+ }
+ }
+
+ /**
+ * @param Job $job
+ * @return integer Seconds for this runner to avoid doing more jobs of this type
+ * @see $wgJobBackoffThrottling
+ */
+ private function getBackoffTimeToWait( Job $job ) {
+ global $wgJobBackoffThrottling;
+
+ if ( !isset( $wgJobBackoffThrottling[$job->getType()] ) ) {
+ return 0; // not throttled
+ }
+ $itemsPerSecond = $wgJobBackoffThrottling[$job->getType()];
+ if ( $itemsPerSecond <= 0 ) {
+ return 0; // not throttled
+ }
+
+ $seconds = 0;
+ if ( $job->workItemCount() > 0 ) {
+ $seconds = floor( $job->workItemCount() / $itemsPerSecond );
+ $remainder = $job->workItemCount() % $itemsPerSecond;
+ $seconds += ( mt_rand( 1, $itemsPerSecond ) <= $remainder ) ? 1 : 0;
+ }
+
+ return (int)$seconds;
+ }
+
+ /**
+ * Get the previous backoff expiries from persistent storage
+ *
+ * @return array Map of (job type => backoff expiry timestamp)
+ */
+ private function loadBackoffs() {
+ $section = new ProfileSection( __METHOD__ );
+
+ $backoffs = array();
+ $file = wfTempDir() . '/mw-runJobs-backoffs.json';
+ if ( is_file( $file ) ) {
+ $handle = fopen( $file, 'rb' );
+ flock( $handle, LOCK_SH );
+ $content = stream_get_contents( $handle );
+ flock( $handle, LOCK_UN );
+ fclose( $handle );
+ $backoffs = json_decode( $content, true ) ?: array();
+ }
+
+ return $backoffs;
+ }
+
+ /**
+ * Merge the current backoff expiries from persistent storage
+ *
+ * @param array $backoffs Map of (job type => backoff expiry timestamp)
+ */
+ private function syncBackoffs( array $backoffs ) {
+ $section = new ProfileSection( __METHOD__ );
+
+ $file = wfTempDir() . '/mw-runJobs-backoffs.json';
+ $handle = fopen( $file, 'wb+' );
+ flock( $handle, LOCK_EX );
+ $content = stream_get_contents( $handle );
+ $cBackoffs = json_decode( $content, true ) ?: array();
+ foreach ( $backoffs as $type => $timestamp ) {
+ $cBackoffs[$type] = isset( $cBackoffs[$type] ) ? $cBackoffs[$type] : 0;
+ $cBackoffs[$type] = max( $cBackoffs[$type], $backoffs[$type] );
+ }
+ ftruncate( $handle, 0 );
+ fwrite( $handle, json_encode( $backoffs ) );
+ flock( $handle, LOCK_UN );
+ fclose( $handle );
}
/**
- * Make sure that this script is not too close to the memory usage limit
+ * Make sure that this script is not too close to the memory usage limit.
+ * It is better to die in between jobs than OOM right in the middle of one.
* @throws MWException
*/
private function assertMemoryOK() {
$m = array();
if ( preg_match( '!^(\d+)(k|m|g|)$!i', ini_get( 'memory_limit' ), $m ) ) {
list( , $num, $unit ) = $m;
- $conv = array( 'g' => 1024 * 1024 * 1024, 'm' => 1024 * 1024, 'k' => 1024, '' => 1 );
+ $conv = array( 'g' => 1073741824, 'm' => 1048576, 'k' => 1024, '' => 1 );
$maxBytes = $num * $conv[strtolower( $unit )];
} else {
$maxBytes = 0;
-- of contained templates.
page_touched binary(14) NOT NULL default '',
+ -- This timestamp is updated whenever a page is re-parsed and
+ -- it has all the link tracking tables updated for it. This is
+ -- useful for de-duplicating expensive backlink update jobs.
+ page_links_updated varbinary(14) NULL default NULL,
+
-- Handy key to revision.rev_id of the current revision.
-- This may be 0 during page creation, but that shouldn't
-- happen outside of a transaction... hopefully.
// Scripts for the dynamic language specific data, like grammar forms.
'mediawiki.language.data' => array( 'class' => 'ResourceLoaderLanguageDataModule' ),
+ /**
+ * Common skin styles, grouped into three graded levels.
+ *
+ * Level 1 "elements":
+ * The base level that only contains the most basic of common skin styles.
+ * Only styles for single elements are included, no styling for complex structures like the TOC
+ * is present. This level is for skins that want to implement the entire style of even content area
+ * structures like the TOC themselves.
+ *
+ * Level 2 "content":
+ * The most commonly used level for skins implemented from scratch. This level includes all the single
+ * element styles from "elements" as well as styles for complex structures such as the TOC that are output
+ * in the content area by MediaWiki rather than the skin. Essentially this is the common level that lets
+ * skins leave the style of the content area as it is normally styled, while leaving the rest of the skin
+ * up to the skin implementation.
+ *
+ * Level 3 "interface":
+ * The highest level, this stylesheet contains extra common styles for classes like .firstHeading, #contentSub,
+ * et cetera which are not outputted by MediaWiki but are common to skins like MonoBook, Vector, etc...
+ * Essentially this level is for styles that are common to MonoBook clones. And since practically every skin
+ * that currently exists within core is a MonoBook clone, all our core skins currently use this level.
+ *
+ * These modules are typically loaded by addModuleStyles which has absolutely no concept of dependency
+ * management. As a result the skins.common.* modules contain duplicate stylesheet references instead of
+ * setting 'dependencies' to the lower level the module is based on. For this reason avoid including multiple
+ * skins.common.* modules into your skin as this will result in duplicate css.
+ */
+ 'skins.common.elements' => array(
+ 'styles' => array(
+ 'common/commonElements.css' => array( 'media' => 'screen' ),
+ ),
+ 'remoteBasePath' => $GLOBALS['wgStylePath'],
+ 'localBasePath' => $GLOBALS['wgStyleDirectory'],
+ ),
+ 'skins.common.content' => array(
+ 'styles' => array(
+ 'common/commonElements.css' => array( 'media' => 'screen' ),
+ 'common/commonContent.css' => array( 'media' => 'screen' ),
+ ),
+ 'remoteBasePath' => $GLOBALS['wgStylePath'],
+ 'localBasePath' => $GLOBALS['wgStyleDirectory'],
+ ),
+ 'skins.common.interface' => array(
+ // Used in the web installer. Test it after modifying this definition!
+ 'styles' => array(
+ 'common/commonElements.css' => array( 'media' => 'screen' ),
+ 'common/commonContent.css' => array( 'media' => 'screen' ),
+ 'common/commonInterface.css' => array( 'media' => 'screen' ),
+ ),
+ 'remoteBasePath' => $GLOBALS['wgStylePath'],
+ 'localBasePath' => $GLOBALS['wgStyleDirectory'],
+ ),
+
/**
* Skins
* Be careful not to add 'scripts' to these modules,
'remoteBasePath' => $GLOBALS['wgStylePath'],
'localBasePath' => $GLOBALS['wgStyleDirectory'],
),
- 'skins.common' => array(
- // Used in the web installer. Test it after modifying this definition!
- 'styles' => array(
- 'common/commonElements.css' => array( 'media' => 'screen' ),
- 'common/commonContent.css' => array( 'media' => 'screen' ),
- 'common/commonInterface.css' => array( 'media' => 'screen' ),
- ),
- 'remoteBasePath' => $GLOBALS['wgStylePath'],
- 'localBasePath' => $GLOBALS['wgStyleDirectory'],
- ),
// FIXME: Remove in favour of skins.monobook.styles when cache expires
'skins.monobook' => array(
'styles' => array(
*
* Example:
*
- * var uri = new mw.Uri( 'http://foo.com/mysite/mypage.php?quux=2' );
+ * var uri = new mw.Uri( 'http://example.com/mysite/mypage.php?quux=2' );
*
- * if ( uri.host == 'foo.com' ) {
- * uri.host = 'www.foo.com';
+ * if ( uri.host == 'example.com' ) {
+ * uri.host = 'foo.example.com';
* uri.extend( { bar: 1 } );
*
* $( 'a#id1' ).attr( 'href', uri );
- * // anchor with id 'id1' now links to http://foo.com/mysite/mypage.php?bar=1&quux=2
+ * // anchor with id 'id1' now links to http://foo.example.com/mysite/mypage.php?bar=1&quux=2
*
* $( 'a#id2' ).attr( 'href', uri.clone().extend( { bar: 3, pif: 'paf' } ) );
- * // anchor with id 'id2' now links to http://foo.com/mysite/mypage.php?bar=3&quux=2&pif=paf
+ * // anchor with id 'id2' now links to http://foo.example.com/mysite/mypage.php?bar=3&quux=2&pif=paf
* }
*
* Parsing here is regex based, so may not work on all URIs, but is good enough for most.
*
* Given a URI like
- * 'http://usr:pwd@www.test.com:81/dir/dir.2/index.htm?q1=0&&test1&test2=&test3=value+%28escaped%29&r=1&r=2#top':
+ * 'http://usr:pwd@www.example.com:81/dir/dir.2/index.htm?q1=0&&test1&test2=&test3=value+%28escaped%29&r=1&r=2#top':
* The returned object will have the following properties:
*
* protocol 'http'
* user 'usr'
* password 'pwd'
- * host 'www.test.com'
+ * host 'www.example.com'
* port '81'
* path '/dir/dir.2/index.htm'
* query {
'protocol', // http
'user', // usr
'password', // pwd
- 'host', // www.test.com
+ 'host', // www.example.com
'port', // 81
'path', // /dir/dir.2/index.htm
'query', // q1=0&&test1&test2=value (will become { q1: '0', test1: '', test2: 'value' } )
/* Private Members */
var hasOwn = Object.prototype.hasOwnProperty,
- slice = Array.prototype.slice;
+ slice = Array.prototype.slice,
+ trackCallbacks = $.Callbacks( 'memory' ),
+ trackQueue = [];
/**
* Log a message to window.console, if possible. Useful to force logging of some
return {
/* Public Members */
+ /**
+ * Get the current time, measured in milliseconds since January 1, 1970 (UTC).
+ *
+ * On browsers that implement the Navigation Timing API, this function will produce floating-point
+ * values with microsecond precision that are guaranteed to be monotonic. On all other browsers,
+ * it will fall back to using `Date`.
+ *
+ * @returns {number} Current time
+ */
+ now: ( function () {
+ var perf = window.performance,
+ navStart = perf && perf.timing && perf.timing.navigationStart;
+ return navStart && typeof perf.now === 'function' ?
+ function () { return navStart + perf.now(); } :
+ function () { return +new Date(); };
+ }() ),
+
+ /**
+ * Track an analytic event.
+ *
+ * This method provides a generic means for MediaWiki JavaScript code to capture state
+ * information for analysis. Each logged event specifies a string topic name that describes
+ * the kind of event that it is. Topic names consist of dot-separated path components,
+ * arranged from most general to most specific. Each path component should have a clear and
+ * well-defined purpose.
+ *
+ * Data handlers are registered via `mw.trackSubscribe`, and receive the full set of
+ * events that match their subcription, including those that fired before the handler was
+ * bound.
+ *
+ * @param {string} topic Topic name
+ * @param {Object} [data] Data describing the event, encoded as an object
+ */
+ track: function ( topic, data ) {
+ trackQueue.push( { topic: topic, timeStamp: mw.now(), data: data } );
+ trackCallbacks.fire( trackQueue );
+ },
+
+ /**
+ * Register a handler for subset of analytic events, specified by topic
+ *
+ * Handlers will be called once for each tracked event, including any events that fired before the
+ * handler was registered; 'this' is set to a plain object with a 'timeStamp' property indicating
+ * the exact time at which the event fired, a string 'topic' property naming the event, and a
+ * 'data' property which is an object of event-specific data. The event topic and event data are
+ * also passed to the callback as the first and second arguments, respectively.
+ *
+ * @param {string} topic Handle events whose name starts with this string prefix
+ * @param {Function} callback Handler to call for each matching tracked event
+ */
+ trackSubscribe: function ( topic, callback ) {
+ var seen = 0;
+
+ trackCallbacks.add( function ( trackQueue ) {
+ var event;
+ for ( ; seen < trackQueue.length; seen++ ) {
+ event = trackQueue[ seen ];
+ if ( event.topic.indexOf( topic ) === 0 ) {
+ callback.call( event, event.topic, event.data );
+ }
+ }
+ } );
+ },
+
/**
* Dummy placeholder for {@link mw.log}
* @method
function setupSkinUserCss( OutputPage $out ) {
parent::setupSkinUserCss( $out );
- $out->addModuleStyles( array( 'skins.common', 'skins.monobook.styles' ) );
+ $out->addModuleStyles( array( 'skins.common.interface', 'skins.monobook.styles' ) );
// TODO: Migrate all of these
$out->addStyle( 'monobook/IE60Fixes.css', 'screen', 'IE 6' );
function setupSkinUserCss( OutputPage $out ) {
parent::setupSkinUserCss( $out );
- $styles = array( 'skins.common', 'skins.vector.styles' );
+ $styles = array( 'skins.common.interface', 'skins.vector.styles' );
wfRunHooks( 'SkinVectorStyleModules', array( &$this, &$styles ) );
$out->addModuleStyles( $styles );
}
assert.throws(
function () {
- return new mw.Uri( 'foo.com/bar/baz', {
+ return new mw.Uri( 'example.com/bar/baz', {
strictMode: true
} );
},
'throw error on URI without protocol or // or leading / in strict mode'
);
- uri = new mw.Uri( 'foo.com/bar/baz', {
+ uri = new mw.Uri( 'example.com/bar/baz', {
strictMode: false
} );
- assert.equal( uri.toString(), 'http://foo.com/bar/baz', 'normalize URI without protocol or // in loose mode' );
+ assert.equal( uri.toString(), 'http://example.com/bar/baz', 'normalize URI without protocol or // in loose mode' );
} );
QUnit.test( 'Constructor( Object )', 3, function ( assert ) {
} );
QUnit.test( '.getQueryString()', 2, function ( assert ) {
- var uri = new mw.Uri( 'http://www.google.com/?q=uri' );
+ var uri = new mw.Uri( 'http://search.example.com/?q=uri' );
assert.deepEqual(
{
},
{
protocol: 'http',
- host: 'www.google.com',
+ host: 'search.example.com',
port: undefined,
path: '/',
query: { q: 'uri' },
'basic object properties'
);
- uri = new mw.Uri( 'https://example.org/mw/index.php?title=Sandbox/7&other=Sandbox/7&foo' );
+ uri = new mw.Uri( 'https://example.com/mw/index.php?title=Sandbox/7&other=Sandbox/7&foo' );
assert.equal(
uri.getQueryString(),
'title=Sandbox/7&other=Sandbox%2F7&foo',