* (bug 42026) Deprecated uctoponly in favor of ucshow=top.
* list=search no longer has a "srredirects" parameter. Redirects are now
included in all searches.
+* Added list=prefixsearch that works like action=opensearch but can be used as
+ a generator.
=== Languages updated in 1.23 ===
'StatCounter' => 'includes/StatCounter.php',
'Status' => 'includes/Status.php',
'StreamFile' => 'includes/StreamFile.php',
+ 'StringPrefixSearch' => 'includes/PrefixSearch.php',
'StubContLang' => 'includes/StubObject.php',
'StubObject' => 'includes/StubObject.php',
'StubUserLang' => 'includes/StubObject.php',
'Title' => 'includes/Title.php',
'TitleArray' => 'includes/TitleArray.php',
'TitleArrayFromResult' => 'includes/TitleArrayFromResult.php',
+ 'TitlePrefixSearch' => 'includes/PrefixSearch.php',
'UnlistedSpecialPage' => 'includes/specialpage/UnlistedSpecialPage.php',
'UploadSourceAdapter' => 'includes/Import.php',
'UppercaseCollation' => 'includes/Collation.php',
'ApiQueryPageProps' => 'includes/api/ApiQueryPageProps.php',
'ApiQueryPagesWithProp' => 'includes/api/ApiQueryPagesWithProp.php',
'ApiQueryPagePropNames' => 'includes/api/ApiQueryPagePropNames.php',
+ 'ApiQueryPrefixSearch' => 'includes/api/ApiQueryPrefixSearch.php',
'ApiQueryProtectedTitles' => 'includes/api/ApiQueryProtectedTitles.php',
'ApiQueryQueryPage' => 'includes/api/ApiQueryQueryPage.php',
'ApiQueryRandom' => 'includes/api/ApiQueryRandom.php',
*
* @ingroup Search
*/
-class PrefixSearch {
+abstract class PrefixSearch {
/**
* Do a prefix search of titles and return a list of matching page names.
+ * @deprecated: Since 1.23, use TitlePrefixSearch or StringPrefixSearch classes
*
* @param $search String
* @param $limit Integer
* @return Array of strings
*/
public static function titleSearch( $search, $limit, $namespaces = array() ) {
+ $search = new StringPrefixSearch;
+ return $search->search( $search, $limit, $namespaces );
+ }
+
+ /**
+ * Do a prefix search of titles and return a list of matching page names.
+ *
+ * @param $search String
+ * @param $limit Integer
+ * @param array $namespaces used if query is not explicitly prefixed
+ * @return Array of strings or Title objects
+ */
+ public function search( $search, $limit, $namespaces = array() ) {
$search = trim( $search );
if ( $search == '' ) {
return array(); // Return empty result
}
- $namespaces = self::validateNamespaces( $namespaces );
+ $namespaces = $this->validateNamespaces( $namespaces );
// Find a Title which is not an interwiki and is in NS_MAIN
$title = Title::newFromText( $search );
if ( $ns[0] == NS_MAIN ) {
$ns = $namespaces; // no explicit prefix, use default namespaces
}
- return self::searchBackend(
+ return $this->searchBackend(
$ns, $title->getText(), $limit );
}
$title = Title::newFromText( $search . 'Dummy' );
if ( $title && $title->getText() == 'Dummy'
&& $title->getNamespace() != NS_MAIN
- && !$title->isExternal() ) {
- return self::searchBackend(
- array( $title->getNamespace() ), '', $limit );
+ && !$title->isExternal() )
+ {
+ $namespaces = array( $title->getNamespace() );
+ $search = '';
}
- return self::searchBackend( $namespaces, $search, $limit );
+ return $this->searchBackend( $namespaces, $search, $limit );
+ }
+
+ /**
+ * Do a prefix search for all possible variants of the prefix
+ * @param $search String
+ * @param $limit Integer
+ * @param array $namespaces
+ *
+ * @return array
+ */
+ public function searchWithVariants( $search, $limit, array $namespaces ) {
+ wfProfileIn( __METHOD__ );
+ $searches = $this->search( $search, $limit, $namespaces );
+
+ // if the content language has variants, try to retrieve fallback results
+ $fallbackLimit = $limit - count( $searches );
+ if ( $fallbackLimit > 0 ) {
+ global $wgContLang;
+
+ $fallbackSearches = $wgContLang->autoConvertToAllVariants( $search );
+ $fallbackSearches = array_diff( array_unique( $fallbackSearches ), array( $search ) );
+
+ foreach ( $fallbackSearches as $fbs ) {
+ $fallbackSearchResult = $this->search( $fbs, $fallbackLimit, $namespaces );
+ $searches = array_merge( $searches, $fallbackSearchResult );
+ $fallbackLimit -= count( $fallbackSearchResult );
+
+ if ( $fallbackLimit == 0 ) {
+ break;
+ }
+ }
+ }
+ wfProfileOut( __METHOD__ );
+ return $searches;
}
+ /**
+ * When implemented in a descendant class, receives an array of Title objects and returns
+ * either an unmodified array or an array of strings corresponding to titles passed to it.
+ *
+ * @param array $titles
+ * @return array
+ */
+ protected abstract function titles( array $titles );
+
+ /**
+ * When implemented in a descendant class, receives an array of titles as strings and returns
+ * either an unmodified array or an array of Title objects corresponding to strings received.
+ *
+ * @param array $strings
+ *
+ * @return array
+ */
+ protected abstract function strings( array $strings );
+
/**
* Do a prefix search of titles and return a list of matching page names.
* @param $namespaces Array
* @param $limit Integer
* @return Array of strings
*/
- protected static function searchBackend( $namespaces, $search, $limit ) {
+ protected function searchBackend( $namespaces, $search, $limit ) {
if ( count( $namespaces ) == 1 ) {
$ns = $namespaces[0];
if ( $ns == NS_MEDIA ) {
$namespaces = array( NS_FILE );
} elseif ( $ns == NS_SPECIAL ) {
- return self::specialSearch( $search, $limit );
+ return $this->titles( $this->specialSearch( $search, $limit ) );
}
}
$srchres = array();
if ( wfRunHooks( 'PrefixSearchBackend', array( $namespaces, $search, $limit, &$srchres ) ) ) {
- return self::defaultSearchBackend( $namespaces, $search, $limit );
+ return $this->titles( $this->defaultSearchBackend( $namespaces, $search, $limit ) );
}
- return $srchres;
+ return $this->strings( $srchres );
}
/**
* @param $limit Integer: max number of items to return
* @return Array
*/
- protected static function specialSearch( $search, $limit ) {
+ protected function specialSearch( $search, $limit ) {
global $wgContLang;
# normalize searchKey, so aliases with spaces can be found - bug 25675
// localizes its input leading to searches for e.g. Special:All
// returning Spezial:MediaWiki-Systemnachrichten and returning
// Spezial:Alle_Seiten twice when $wgLanguageCode == 'de'
- $srchres[] = Title::makeTitleSafe( NS_SPECIAL, $page )->getPrefixedText();
+ $srchres[] = Title::makeTitleSafe( NS_SPECIAL, $page );
wfRestoreWarnings();
}
* @param array $namespaces namespaces to search in
* @param string $search term
* @param $limit Integer: max number of items to return
- * @return Array of title strings
+ * @return Array of Title objects
*/
- protected static function defaultSearchBackend( $namespaces, $search, $limit ) {
+ protected function defaultSearchBackend( $namespaces, $search, $limit ) {
$ns = array_shift( $namespaces ); // support only one namespace
if ( in_array( NS_MAIN, $namespaces ) ) {
$ns = NS_MAIN; // if searching on many always default to main
}
- // Prepare nested request
- $req = new FauxRequest( array(
- 'action' => 'query',
- 'list' => 'allpages',
- 'apnamespace' => $ns,
- 'aplimit' => $limit,
- 'apprefix' => $search
- ));
-
- // Execute
- $module = new ApiMain( $req );
- $module->execute();
-
- // Get resulting data
- $data = $module->getResultData();
-
- // Reformat useful data for future printing by JSON engine
+ $t = Title::newFromText( $search, $ns );
+ $prefix = $t ? $t->getDBkey() : '';
+ $dbr = wfGetDB( DB_SLAVE );
+ $res = $dbr->select( 'page',
+ array( 'page_id', 'page_namespace', 'page_title' ),
+ array(
+ 'page_namespace' => $ns,
+ 'page_title ' . $dbr->buildLike( $prefix, $dbr->anyString() )
+ ),
+ __METHOD__,
+ array( 'LIMIT' => $limit, 'ORDER BY' => 'page_title' )
+ );
$srchres = array();
- foreach ( (array)$data['query']['allpages'] as $pageinfo ) {
- // Note: this data will no be printable by the xml engine
- // because it does not support lists of unnamed items
- $srchres[] = $pageinfo['title'];
+ foreach ( $res as $row ) {
+ $srchres[] = Title::newFromRow( $row );
}
-
return $srchres;
}
* @param $namespaces Array
* @return Array (default: contains only NS_MAIN)
*/
- protected static function validateNamespaces( $namespaces ) {
+ protected function validateNamespaces( $namespaces ) {
global $wgContLang;
// We will look at each given namespace against wgContLang namespaces
return array( NS_MAIN );
}
}
+
+/**
+ * Performs prefix search, returning Title objects
+ * @ingroup Search
+ */
+class TitlePrefixSearch extends PrefixSearch {
+
+ protected function titles( array $titles ) {
+ return $titles;
+ }
+
+ protected function strings( array $strings ) {
+ $titles = array_map( 'Title::newFromText', $strings );
+ $lb = new LinkBatch( $titles );
+ $lb->setCaller( __METHOD__ );
+ $lb->execute();
+ return $titles;
+ }
+}
+
+/**
+ * Performs prefix search, returning strings
+ * @ingroup Search
+ */
+class StringPrefixSearch extends PrefixSearch {
+
+ protected function titles( array $titles ) {
+ return array_map( function( Title $t ) { return $t->getPrefixedText(); }, $titles );
+ }
+
+ protected function strings( array $strings ) {
+ return $strings;
+ }
+}
public function getFeedObject( $feedFormat, $specialClass ) {
if ( $specialClass === 'SpecialRecentchangeslinked' ) {
$title = Title::newFromText( $this->params['target'] );
+ if ( !$title ) {
+ $this->dieUsageMsg( array( 'invalidtitle', $this->params['target'] ) );
+ }
+
$feed = new ChangesFeed( $feedFormat, false );
$feedObj = $feed->getFeedObject(
$this->msg( 'recentchangeslinked-title', $title->getPrefixedText() )
$this->getMain()->setCacheMaxAge( $wgSearchSuggestCacheExpiry );
$this->getMain()->setCacheMode( 'public' );
- $searches = PrefixSearch::titleSearch( $search, $limit,
- $namespaces );
-
- // if the content language has variants, try to retrieve fallback results
- $fallbackLimit = $limit - count( $searches );
- if ( $fallbackLimit > 0 ) {
- global $wgContLang;
-
- $fallbackSearches = $wgContLang->autoConvertToAllVariants( $search );
- $fallbackSearches = array_diff( array_unique( $fallbackSearches ), array( $search ) );
-
- foreach ( $fallbackSearches as $fbs ) {
- $fallbackSearchResult = PrefixSearch::titleSearch( $fbs, $fallbackLimit,
- $namespaces );
- $searches = array_merge( $searches, $fallbackSearchResult );
- $fallbackLimit -= count( $fallbackSearchResult );
-
- if ( $fallbackLimit == 0 ) {
- break;
- }
- }
- }
+ $searcher = new StringPrefixSearch;
+ $searches = $searcher->searchWithVariants( $search, $limit, $namespaces );
}
// Set top level elements
$result = $this->getResult();
'logevents' => 'ApiQueryLogEvents',
'pageswithprop' => 'ApiQueryPagesWithProp',
'pagepropnames' => 'ApiQueryPagePropNames',
+ 'prefixsearch' => 'ApiQueryPrefixSearch',
'protectedtitles' => 'ApiQueryProtectedTitles',
'querypage' => 'ApiQueryQueryPage',
'random' => 'ApiQueryRandom',
--- /dev/null
+<?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
+ * @since 1.23
+ */
+
+/**
+ * @ingroup API
+ */
+class ApiQueryPrefixSearch extends ApiQueryGeneratorBase {
+ public function __construct( $query, $moduleName ) {
+ parent::__construct( $query, $moduleName, 'ps' );
+ }
+
+ public function execute() {
+ $this->run();
+ }
+
+ public function executeGenerator( $resultPageSet ) {
+ $this->run( $resultPageSet );
+ }
+
+ /**
+ * @param $resultPageSet ApiPageSet
+ */
+ private function run( $resultPageSet = null ) {
+ $params = $this->extractRequestParams();
+ $search = $params['search'];
+ $limit = $params['limit'];
+ $namespaces = $params['namespace'];
+
+ $searcher = new TitlePrefixSearch;
+ $titles = $searcher->searchWithVariants( $search, $limit, $namespaces );
+ if ( $resultPageSet ) {
+ $resultPageSet->populateFromTitles( $titles );
+ } else {
+ $result = $this->getResult();
+ foreach ( $titles as $title ) {
+ if ( !$limit-- ) {
+ break;
+ }
+ $vals = array(
+ 'ns' => intval( $title->getNamespace() ),
+ 'title' => $title->getPrefixedText(),
+ );
+ if ( $title->isSpecialPage() ) {
+ $vals['special'] = '';
+ } else {
+ $vals['pageid'] = intval( $title->getArticleId() );
+ }
+ $fit = $result->addValue( array( 'query', $this->getModuleName() ), null, $vals );
+ if ( !$fit ) {
+ break;
+ }
+ }
+ $result->setIndexedTagName_internal(
+ array( 'query', $this->getModuleName() ), $this->getModulePrefix()
+ );
+ }
+ }
+
+ public function getCacheMode( $params ) {
+ return 'public';
+ }
+
+ public function getAllowedParams() {
+ return array(
+ 'search' => array(
+ ApiBase::PARAM_TYPE => 'string',
+ ApiBase::PARAM_REQUIRED => true,
+ ),
+ 'namespace' => array(
+ ApiBase::PARAM_DFLT => NS_MAIN,
+ ApiBase::PARAM_TYPE => 'namespace',
+ ApiBase::PARAM_ISMULTI => true,
+ ),
+ 'limit' => array(
+ ApiBase::PARAM_DFLT => 10,
+ ApiBase::PARAM_TYPE => 'limit',
+ ApiBase::PARAM_MIN => 1,
+ ApiBase::PARAM_MAX => 100, // Non-standard value for compatibility
+ // with action=opensearch
+ ApiBase::PARAM_MAX2 => 200,
+ ),
+ );
+ }
+
+ public function getParamDescription() {
+ return array(
+ 'search' => 'Search string',
+ 'limit' => 'Maximum amount of results to return',
+ 'namespace' => 'Namespaces to search',
+ );
+ }
+
+ public function getDescription() {
+ return 'Perform a prefix search for page titles';
+ }
+
+ public function getExamples() {
+ return array(
+ 'api.php?action=query&list=prefixsearch&pssearch=meaning',
+ );
+ }
+
+ public function getHelpUrls() {
+ return 'https://www.mediawiki.org/wiki/API:Prefixsearch';
+ }
+}
* @throws MWException if there is a syntax error in the JSON file
* @return array with a 'messages' key, or empty array if the file doesn't exist
*/
- protected function readJSONFile( $fileName ) {
+ public function readJSONFile( $fileName ) {
wfProfileIn( __METHOD__ );
if ( !is_readable( $fileName ) ) {
global $wgVersion, $wgRequestTime;
$request = $context->getRequest();
+ // HHVM's reported memory usage from memory_get_peak_usage()
+ // is not useful when passing false, but we continue passing
+ // false for consistency of historical data in zend.
+ // see: https://github.com/facebook/hhvm/issues/2257#issuecomment-39362246
+ $realMemoryUsage = wfIsHHVM();
+
return array(
'mwVersion' => $wgVersion,
'phpVersion' => PHP_VERSION,
'headers' => $request->getAllHeaders(),
'params' => $request->getValues(),
),
- 'memory' => $context->getLanguage()->formatSize( memory_get_usage() ),
- 'memoryPeak' => $context->getLanguage()->formatSize( memory_get_peak_usage() ),
+ 'memory' => $context->getLanguage()->formatSize( memory_get_usage( $realMemoryUsage ) ),
+ 'memoryPeak' => $context->getLanguage()->formatSize( memory_get_peak_usage( $realMemoryUsage ) ),
'includes' => self::getFilesIncluded( $context ),
+ 'profile' => Profiler::instance()->getRawData(),
);
}
}
// Flag this job as an old duplicate based on its "root" job...
try {
if ( $job && $this->isRootJobOldDuplicate( $job ) ) {
- JobQueue::incrStats( 'job-pop-duplicate', $this->type );
+ JobQueue::incrStats( 'job-pop-duplicate', $this->type, 1, $this->wiki );
$job = DuplicateJob::newFromJob( $job ); // convert to a no-op
}
} catch ( MWException $e ) {
* @param string $key Event type
* @param string $type Job type
* @param int $delta
+ * @param string $wiki Wiki ID (added in 1.23)
* @since 1.22
*/
- public static function incrStats( $key, $type, $delta = 1 ) {
+ public static function incrStats( $key, $type, $delta = 1, $wiki = null ) {
wfIncrStats( $key, $delta );
wfIncrStats( "{$key}-{$type}", $delta );
+ if ( $wiki !== null ) {
+ wfIncrStats( "{$key}-{$type}-{$wiki}", $delta );
+ }
}
/**
foreach ( array_chunk( $rows, 50 ) as $rowBatch ) {
$dbw->insert( 'job', $rowBatch, $method );
}
- JobQueue::incrStats( 'job-insert', $this->type, count( $rows ) );
+ JobQueue::incrStats( 'job-insert', $this->type, count( $rows ), $this->wiki );
JobQueue::incrStats(
'job-insert-duplicate',
$this->type,
- count( $rowSet ) + count( $rowList ) - count( $rows )
+ count( $rowSet ) + count( $rowList ) - count( $rows ),
+ $this->wiki
);
} catch ( DBError $e ) {
if ( $flags & self::QOS_ATOMIC ) {
$this->cache->set( $this->getCacheKey( 'empty' ), 'true', self::CACHE_TTL_LONG );
break; // nothing to do
}
- JobQueue::incrStats( 'job-pop', $this->type );
+ JobQueue::incrStats( 'job-pop', $this->type, 1, $this->wiki );
// Get the job object from the row...
$title = Title::makeTitleSafe( $row->job_namespace, $row->job_title );
if ( !$title ) {
'job_id' => $ids ),
__METHOD__
);
- $count += $dbw->affectedRows();
- JobQueue::incrStats( 'job-recycle', $this->type, $dbw->affectedRows() );
+ $affected = $dbw->affectedRows();
+ $count += $affected;
+ JobQueue::incrStats( 'job-recycle', $this->type, $affected, $this->wiki );
$this->cache->set( $this->getCacheKey( 'empty' ), 'false', self::CACHE_TTL_LONG );
}
}
);
if ( count( $ids ) ) {
$dbw->delete( 'job', array( 'job_id' => $ids ), __METHOD__ );
- $count += $dbw->affectedRows();
- JobQueue::incrStats( 'job-abandon', $this->type, $dbw->affectedRows() );
+ $affected = $dbw->affectedRows();
+ $count += $affected;
+ JobQueue::incrStats( 'job-abandon', $this->type, $affected, $this->wiki );
}
$dbw->unlock( "jobqueue-recycle-{$this->type}", __METHOD__ );
return false;
}
- JobQueue::incrStats( 'job-insert', $this->type, count( $items ) );
+ JobQueue::incrStats( 'job-insert', $this->type, count( $items ), $this->wiki );
JobQueue::incrStats( 'job-insert-duplicate', $this->type,
- count( $items ) - $failed - $pushed );
+ count( $items ) - $failed - $pushed, $this->wiki );
} catch ( RedisException $e ) {
$this->throwRedisException( $conn, $e );
}
break; // no jobs; nothing to do
}
- JobQueue::incrStats( 'job-pop', $this->type );
+ JobQueue::incrStats( 'job-pop', $this->type, 1, $this->wiki );
$item = $this->unserialize( $blob );
if ( $item === false ) {
wfDebugLog( 'JobQueueRedis', "Could not unserialize {$this->type} job." );
if ( $res ) {
list( $released, $abandoned, $pruned, $undelayed ) = $res;
$count += $released + $pruned + $undelayed;
- JobQueue::incrStats( 'job-recycle', $this->type, $released );
- JobQueue::incrStats( 'job-abandon', $this->type, $abandoned );
+ JobQueue::incrStats( 'job-recycle', $this->type, $released, $this->wiki );
+ JobQueue::incrStats( 'job-abandon', $this->type, $abandoned, $this->wiki );
}
} catch ( RedisException $e ) {
$this->throwRedisException( $conn, $e );
abstract class BagOStuff {
private $debugMode = false;
+ protected $lastError = self::ERR_NONE;
+
+ /** Possible values for getLastError() */
+ const ERR_NONE = 0; // no error
+ const ERR_NO_RESPONSE = 1; // no response
+ const ERR_UNREACHABLE = 2; // can't connect
+ const ERR_UNEXPECTED = 3; // response gave some error
+
/**
* @param $bool bool
*/
* @return bool success
*/
public function lock( $key, $timeout = 6 ) {
+ $this->clearLastError();
$timestamp = microtime( true ); // starting UNIX timestamp
if ( $this->add( "{$key}:lock", 1, $timeout ) ) {
return true;
+ } elseif ( $this->getLastError() ) {
+ return false;
}
$uRTT = ceil( 1e6 * ( microtime( true ) - $timestamp ) ); // estimate RTT (us)
$sleep *= 2;
}
usleep( $sleep ); // back off
+ $this->clearLastError();
$locked = $this->add( "{$key}:lock", 1, $timeout );
+ if ( $this->getLastError() ) {
+ return false;
+ }
} while ( !$locked );
return $locked;
return $this->incr( $key, - $value );
}
+ /**
+ * Get the "last error" registered; clearLastError() should be called manually
+ * @return integer ERR_* constant for the "last error" registry
+ * @since 1.23
+ */
+ public function getLastError() {
+ return $this->lastError;
+ }
+
+ /**
+ * Clear the "last error" registry
+ * @since 1.23
+ */
+ public function clearLastError() {
+ $this->lastError = self::ERR_NONE;
+ }
+
+ /**
+ * Set the "last error" registry
+ * @param $err integer ERR_* constant
+ * @since 1.23
+ */
+ protected function setLastError( $err ) {
+ $this->lastError = $err;
+ }
+
/**
* @param $text string
*/
$msg = "Memcached error: $msg";
}
wfDebugLog( 'memcached-serious', $msg );
+ $this->setLastError( BagOStuff::ERR_UNEXPECTED );
}
return $result;
}
return $this->doWrite( 'merge', $key, $callback, $exptime );
}
+ public function getLastError() {
+ return isset( $this->caches[0] ) ? $this->caches[0]->getLastError() : self::ERR_NONE;
+ }
+
+ public function clearLastError() {
+ if ( isset( $this->caches[0] ) ) {
+ $this->caches[0]->clearLastError();
+ }
+ }
+
/**
* @param $method string
* @return bool
return array( $server, $conn );
}
}
+ $this->setLastError( BagOStuff::ERR_UNREACHABLE );
return array( false, false );
}
* object and let it be reopened during the next request.
*/
protected function handleException( RedisConnRef $conn, $e ) {
+ $this->setLastError( BagOStuff::ERR_UNEXPECTED );
$this->redisPool->handleError( $conn, $e );
}
}
wfDebugLog( 'SQLBagOStuff', "DBError: {$exception->getMessage()}" );
if ( $exception instanceof DBConnectionError ) {
+ $this->setLastError( BagOStuff::ERR_UNREACHABLE );
wfDebug( __METHOD__ . ": ignoring connection error\n" );
} else {
+ $this->setLastError( BagOStuff::ERR_UNEXPECTED );
wfDebug( __METHOD__ . ": ignoring query error\n" );
}
}
}
wfDebugLog( 'SQLBagOStuff', "DBError: {$exception->getMessage()}" );
if ( $exception instanceof DBConnectionError ) {
+ $this->setLastError( BagOStuff::ERR_UNREACHABLE );
wfDebug( __METHOD__ . ": ignoring connection error\n" );
} else {
+ $this->setLastError( BagOStuff::ERR_UNEXPECTED );
wfDebug( __METHOD__ . ": ignoring query error\n" );
}
}
*/
class Profiler {
protected $mStack = array(), $mWorkStack = array(), $mCollated = array(),
- $mCalls = array(), $mTotals = array();
+ $mCalls = array(), $mTotals = array(), $mPeriods = array();
protected $mTimeMetric = 'wall';
protected $mProfileID = false, $mCollateDone = false, $mTemplated = false;
$this->mMin[$fname] = 1 << 24;
$this->mMax[$fname] = 0;
$this->mOverhead[$fname] = 0;
+ $this->mPeriods[$fname] = array();
}
$this->mCollated[$fname] += $elapsed;
$this->mMin[$fname] = min( $this->mMin[$fname], $elapsed );
$this->mMax[$fname] = max( $this->mMax[$fname], $elapsed );
$this->mOverhead[$fname] += $subcalls;
+ $this->mPeriods[$fname][] = compact( 'start', 'end', 'memory', 'subcalls' );
}
$this->mCalls['-overhead-total'] = $profileCount;
return $prof;
}
+ /**
+ * @return array
+ */
+ public function getRawData() {
+ $this->collateData();
+
+ $profile = array();
+ $total = isset( $this->mCollated['-total'] ) ? $this->mCollated['-total'] : 0;
+ foreach ( $this->mCollated as $fname => $elapsed ) {
+ $periods = array();
+ foreach ( $this->mPeriods[$fname] as $period ) {
+ $period['start'] *= 1000;
+ $period['end'] *= 1000;
+ $periods[] = $period;
+ }
+ $profile[] = array(
+ 'name' => $fname,
+ 'calls' => $this->mCalls[$fname],
+ 'elapsed' => $elapsed * 1000,
+ 'percent' => $total ? 100. * $elapsed / $total : 0,
+ 'memory' => $this->mMemory[$fname],
+ 'min' => $this->mMin[$fname] * 1000,
+ 'max' => $this->mMax[$fname] * 1000,
+ 'overhead' => $this->mOverhead[$fname],
+ 'periods' => $periods,
+ );
+ }
+
+ return $profile;
+ }
+
/**
* Dummy calls to wfProfileIn/wfProfileOut to calculate its overhead
*/
public function getCurrentSection() { return ''; }
public function transactionWritingIn( $server, $db ) {}
public function transactionWritingOut( $server, $db ) {}
+ public function getRawData() { return array(); }
}
"mw.log",
"mw.inspect",
"mw.inspect.reports",
- "mw.Debug"
+ "mw.Debug",
+ "mw.Debug.profile"
]
}
]
$this->mNamespaceAliases[$code] = array();
$this->mMagicWords[$code] = array();
$this->mSpecialPageAliases[$code] = array();
+
+ $jsonfilename = Language::getJsonMessagesFileName( $code );
+ if ( file_exists( $jsonfilename ) ) {
+ $json = Language::getLocalisationCache()->readJSONFile( $jsonfilename );
+ $this->mRawMessages[$code] = $json['messages'];
+ }
+
$filename = Language::getMessagesFileName( $code );
if ( file_exists( $filename ) ) {
require $filename;
- if ( isset( $messages ) ) {
- $this->mRawMessages[$code] = $messages;
- }
if ( isset( $fallback ) ) {
$this->mFallback[$code] = $fallback;
}
'dependencies' => array(
'mediawiki.api',
'mediawiki.Title',
+ 'user.tokens',
),
),
'mediawiki.api.login' => array(
),
),
'mediawiki.debug' => array(
- 'scripts' => 'resources/mediawiki/mediawiki.debug.js',
- 'styles' => 'resources/mediawiki/mediawiki.debug.less',
- 'dependencies' => 'jquery.footHovzer',
+ 'scripts' => array(
+ 'resources/mediawiki/mediawiki.debug.js',
+ 'resources/mediawiki/mediawiki.debug.profile.js'
+ ),
+ 'styles' => array(
+ 'resources/mediawiki/mediawiki.debug.less',
+ 'resources/mediawiki/mediawiki.debug.profile.css'
+ ),
+ 'dependencies' => array(
+ 'jquery.footHovzer',
+ 'jquery.tipsy',
+ ),
'position' => 'bottom',
),
'mediawiki.debug.init' => array(
(function($) {
- function maybeCall(thing, ctx) {
- return (typeof thing == 'function') ? (thing.call(ctx)) : thing;
- };
-
+ function maybeCall(thing, ctx) {
+ return (typeof thing == 'function') ? (thing.call(ctx)) : thing;
+ }
+
function fixTitle($ele) {
if ($ele.attr('title') || typeof($ele.attr('original-title')) != 'string') {
$ele.attr('original-title', $ele.attr('title') || '').removeAttr('title');
}
}
-
+
function Tipsy(element, options) {
this.$element = $(element);
this.options = options;
this.enabled = true;
fixTitle(this.$element);
}
-
+
Tipsy.prototype = {
show: function() {
var title = this.getTitle();
if (title && this.enabled) {
var $tip = this.tip();
-
+
$tip.find('.tipsy-inner')[this.options.html ? 'html' : 'text'](title);
$tip[0].className = 'tipsy'; // reset classname in case of dynamic gravity
if (this.options.className) {
$tip.addClass(maybeCall(this.options.className, this.$element[0]));
}
$tip.remove().css({top: 0, left: 0, visibility: 'hidden', display: 'block'}).appendTo(document.body);
-
+
var pos = $.extend({}, this.$element.offset(), {
width: this.$element[0].offsetWidth,
height: this.$element[0].offsetHeight
});
-
- var actualWidth = $tip[0].offsetWidth, actualHeight = $tip[0].offsetHeight;
+
var gravity = (typeof this.options.gravity == 'function')
? this.options.gravity.call(this.$element[0])
: this.options.gravity;
-
+
+ // Attach css classes before checking height/width so they
+ // can be applied.
+ $tip.addClass('tipsy-' + gravity);
+ if (this.options.className) {
+ $tip.addClass(maybeCall(this.options.className, this.$element[0]));
+ }
+
+ var actualWidth = $tip[0].offsetWidth, actualHeight = $tip[0].offsetHeight;
var tp;
switch (gravity.charAt(0)) {
case 'n':
tp = {top: pos.top + pos.height / 2 - actualHeight / 2, left: pos.left + pos.width + this.options.offset};
break;
}
-
+
if (gravity.length == 2) {
if (gravity.charAt(1) == 'w') {
if (this.options.center) {
}
}
}
-
- $tip.css(tp).addClass('tipsy-' + gravity);
-
+ $tip.css(tp);
+
if (this.options.fade) {
$tip.stop().css({opacity: 0, display: 'block', visibility: 'visible'}).animate({opacity: this.options.opacity}, 100);
} else {
}
}
},
-
+
hide: function() {
if (this.options.fade) {
this.tip().stop().fadeOut(100, function() { $(this).remove(); });
this.tip().remove();
}
},
-
+
getTitle: function() {
var title, $e = this.$element, o = this.options;
fixTitle($e);
title = ('' + title).replace(/(^\s*|\s*$)/, "");
return title || o.fallback;
},
-
+
tip: function() {
if (!this.$tip) {
this.$tip = $('<div class="tipsy"></div>').html('<div class="tipsy-arrow"></div><div class="tipsy-inner"/></div>');
}
return this.$tip;
},
-
+
validate: function() {
if (!this.$element[0].parentNode) {
this.hide();
this.options = null;
}
},
-
+
enable: function() { this.enabled = true; },
disable: function() { this.enabled = false; },
toggleEnabled: function() { this.enabled = !this.enabled; }
};
-
+
$.fn.tipsy = function(options) {
-
+
if (options === true) {
return this.data('tipsy');
} else if (typeof options == 'string') {
return this.data('tipsy')[options]();
}
-
+
options = $.extend({}, $.fn.tipsy.defaults, options);
-
+
function get(ele) {
var tipsy = $.data(ele, 'tipsy');
if (!tipsy) {
}
return tipsy;
}
-
+
function enter() {
var tipsy = get(this);
tipsy.hoverState = 'in';
setTimeout(function() { if (tipsy.hoverState == 'in') tipsy.show(); }, options.delayIn);
}
};
-
+
function leave() {
var tipsy = get(this);
tipsy.hoverState = 'out';
setTimeout(function() { if (tipsy.hoverState == 'out') tipsy.hide(); }, options.delayOut);
}
};
-
+
if (!options.live) this.each(function() { get(this); });
-
+
if (options.trigger != 'manual') {
var binder = options.live ? 'live' : 'bind',
eventIn = options.trigger == 'hover' ? 'mouseenter' : 'focus',
eventOut = options.trigger == 'hover' ? 'mouseleave' : 'blur';
this[binder](eventIn, enter)[binder](eventOut, leave);
}
-
+
return this;
-
+
};
-
+
$.fn.tipsy.defaults = {
className: null,
delayIn: 0,
title: 'title',
trigger: 'hover'
};
-
+
// Overwrite this method to provide options on a per-element basis.
// For example, you could store the gravity in a 'tipsy-gravity' attribute:
// return $.extend({}, options, {gravity: $(ele).attr('tipsy-gravity') || 'n' });
$.fn.tipsy.elementOptions = function(ele, options) {
return $.metadata ? $.extend({}, options, $(ele).metadata()) : options;
};
-
+
$.fn.tipsy.autoNS = function() {
return $(this).offset().top > ($(document).scrollTop() + $(window).height() / 2) ? 's' : 'n';
};
-
+
$.fn.tipsy.autoWE = function() {
return $(this).offset().left > ($(document).scrollLeft() + $(window).width() / 2) ? 'e' : 'w';
};
-
+
})(jQuery);
dataType: 'json'
}
},
- tokenCache = {};
+ // Keyed by ajax url and symbolic name for the individual request
+ deferreds = {};
+
+ // Pre-populate with fake ajax deferreds to save http requests for tokens
+ // we already have on the page via the user.tokens module (bug 34733).
+ deferreds[ defaultOptions.ajax.url ] = {};
+ $.each( mw.user.tokens.get(), function ( key, value ) {
+ // This requires #getToken to use the same key as user.tokens.
+ // Format: token-type + "Token" (eg. editToken, patrolToken, watchToken).
+ deferreds[ defaultOptions.ajax.url ][ key ] = $.Deferred()
+ .resolve( value )
+ .promise( { abort: function () {} } );
+ } );
/**
* Constructor to create an object to interact with the API of a particular MediaWiki server.
* @since 1.22
*/
postWithToken: function ( tokenType, params ) {
- var api = this, hasOwn = tokenCache.hasOwnProperty;
- if ( hasOwn.call( tokenCache, tokenType ) && tokenCache[tokenType] !== undefined ) {
- params.token = tokenCache[tokenType];
+ var api = this;
+
+ return api.getToken( tokenType ).then( function ( token ) {
+ params.token = token;
return api.post( params ).then(
+ // If no error, return to caller as-is
null,
+ // Error handler
function ( code ) {
if ( code === 'badtoken' ) {
- // force a new token, clear any old one
- tokenCache[tokenType] = params.token = undefined;
- return api.post( params );
+ // Clear from cache
+ deferreds[ this.defaults.ajax.url ][ tokenType + 'Token' ] =
+ params.token = undefined;
+
+ // Try again, once
+ return api.getToken( tokenType ).then( function ( token ) {
+ params.token = token;
+ return api.post( params );
+ } );
}
- // Pass the promise forward, so the caller gets error codes
+
+ // Different error, pass on to let caller handle the error code
return this;
}
);
- } else {
- return api.getToken( tokenType ).then( function ( token ) {
- tokenCache[tokenType] = params.token = token;
- return api.post( params );
- } );
- }
+ } );
},
/**
- * Api helper to grab any token.
+ * Get a token for a certain action from the API.
*
- * @param {string} type Token type.
+ * @param {string} type Token type
* @return {jQuery.Promise}
* @return {Function} return.done
* @return {string} return.done.token Received token.
*/
getToken: function ( type ) {
var apiPromise,
+ deferredGroup = deferreds[ this.defaults.ajax.url ],
+ d = deferredGroup && deferredGroup[ type + 'Token' ];
+
+ if ( !d ) {
d = $.Deferred();
- apiPromise = this.get( {
- action: 'tokens',
- type: type
- } )
- .done( function ( data ) {
- // If token type is not available for this user,
- // key '...token' is missing or can contain Boolean false
- if ( data.tokens && data.tokens[type + 'token'] ) {
- d.resolve( data.tokens[type + 'token'] );
- } else {
- d.reject( 'token-missing', data );
- }
- } )
- .fail( d.reject );
+ apiPromise = this.get( { action: 'tokens', type: type } )
+ .done( function ( data ) {
+ // If token type is not available for this user,
+ // key '...token' is missing or can contain Boolean false
+ if ( data.tokens && data.tokens[type + 'token'] ) {
+ d.resolve( data.tokens[type + 'token'] );
+ } else {
+ d.reject( 'token-missing', data );
+ }
+ } )
+ .fail( d.reject );
+
+ // Attach abort handler
+ d.abort = apiPromise.abort;
+
+ // Store deferred now so that we can use this again even if it isn't ready yet
+ if ( !deferredGroup ) {
+ deferredGroup = deferreds[ this.defaults.ajax.url ] = {};
+ }
+ deferredGroup[ type + 'Token' ] = d;
+ }
- return d.promise( { abort: apiPromise.abort } );
+ return d.promise( { abort: d.abort } );
}
};
background: url(images/icon-contributors.png) no-repeat left center;
}
-/* Special font for numbers in benefits*/
-div.mw-number-text h3 {
+/*
+ * Special font for numbers in benefits, same as Vector's @content-heading-font-family.
+ * Needs an ID so that it's more specific than Vector's div#content h3.
+ */
+#bodyContent div.mw-number-text h3 {
top: 0;
margin: 0;
padding: 0;
color: #252525;
- font-family: 'Georgia', serif;
+ font-family: "Linux Libertine", Georgia, Times, serif;
font-weight: normal;
font-size: 2.2em;
line-height: 1.2;
width: @defaultFormWidth;
// Immediate divs in a vform are block and spaced-out.
+ // XXX: We shouldn't depend on the tag name here...
& > div {
display: block;
margin: 0 0 15px 0;
padding: 0;
width: 100%;
+ }
- // MW currently doesn't use the type attribute everywhere on inputs.
- input,
- .mw-ui-button {
- display: block;
- .box-sizing(border-box);
- margin: 0;
- width: 100%;
- }
-
- // We exclude these because they'll generally use mw-ui-button.
- // Otherwise, we'll unintentionally override that.
- input:not([type=button]):not([type=submit]):not([type=file]), {
- .agora-field-styling(); // mixins/forms.less
- }
-
- label {
- display: block;
- .box-sizing(border-box);
- .agora-label-styling();
- width: auto;
- margin: 0 0 0.2em;
- padding: 0;
- }
-
- // Override input styling just for checkboxes and radio inputs.
- input[type="checkbox"],
- input[type="radio"] {
- display: inline;
- .box-sizing(content-box);
- width: auto;
- }
+ // MW currently doesn't use the type attribute everywhere on inputs.
+ input,
+ .mw-ui-button {
+ display: block;
+ .box-sizing(border-box);
+ margin: 0;
+ width: 100%;
+ }
+ // We exclude these because they'll generally use mw-ui-button.
+ // Otherwise, we'll unintentionally override that.
+ input:not([type=button]):not([type=submit]):not([type=file]), {
+ .agora-field-styling(); // mixins/forms.less
}
+ label {
+ display: block;
+ .box-sizing(border-box);
+ .agora-label-styling();
+ width: auto;
+ margin: 0 0 0.2em;
+ padding: 0;
+ }
+
+ // Override input styling just for checkboxes and radio inputs.
+ input[type="checkbox"],
+ input[type="radio"] {
+ display: inline;
+ .box-sizing(content-box);
+ width: auto;
+ }
+
+
// Styles for information boxes
//
// Regular HTMLForm uses .error class, some special pages like
// You generally don't need to use this class on divs within an Agora
// form container such as mw-ui-vform
// XXX DRY: This repeats earlier styling, use an @include agora-div-styling ?
+// XXX: What is this even for?
.mw-ui-vform-div {
display: block;
margin: 0 0 15px;
@import "../../mixins/type";
.mw-ui-vform,
-.mw-ui-vform > div input,
+.mw-ui-vform input,
.mw-ui-input {
.vector-type();
}
paneTriggerBitDiv( 'includes', 'PHP includes', this.data.includes.length );
+ paneTriggerBitDiv( 'profile', 'Profile', this.data.profile.length );
+
gitInfo = '';
if ( this.data.gitRevision !== false ) {
gitInfo = '(' + this.data.gitRevision.substring( 0, 7 ) + ')';
querylist: this.buildQueryTable(),
debuglog: this.buildDebugLogTable(),
request: this.buildRequestPane(),
- includes: this.buildIncludesPane()
+ includes: this.buildIncludesPane(),
+ profile: this.buildProfilePane()
};
for ( id in panes ) {
}
return $table;
+ },
+
+ buildProfilePane: function () {
+ return mw.Debug.profile.init();
}
};
--- /dev/null
+
+.mw-debug-profile-tipsy .tipsy-inner {
+ /* undo max-width from vector on .tipsy-inner */
+ max-width: none;
+ /* needed for some browsers to provide space for the scrollbar without wrapping text */
+ min-width: 100%;
+ max-height: 150px;
+ overflow-y: auto;
+}
+
+.mw-debug-profile-underline {
+ stroke-width: 1;
+ stroke: #dfdfdf;
+}
+
+.mw-debug-profile-period {
+ fill: red;
+}
+
+/* connecting line between endpoints on long events */
+.mw-debug-profile-period line {
+ stroke: red;
+ stroke-width: 2;
+}
+
+.mw-debug-profile-tipsy,
+.mw-debug-profile-timeline text {
+ color: #444;
+ fill: #444;
+ /* using em's causes the two locations to have different sizes */
+ font-size: 12px;
+ font-family: sans-serif;
+}
+
+.mw-debug-profile-meta,
+.mw-debug-profile-timeline tspan {
+ /* using em's causes the two locations to have different sizes */
+ font-size: 10px;
+}
+
+.mw-debug-profile-no-data {
+ text-align: center;
+ padding-top: 5em;
+ font-weight: bold;
+ font-size: 1.2em;
+}
--- /dev/null
+/*!
+ * JavaScript for the debug toolbar profiler, enabled through $wgDebugToolbar
+ * and StartProfiler.php.
+ *
+ * @author Erik Bernhardson
+ * @since 1.23
+ */
+
+( function ( mw, $ ) {
+ 'use strict';
+
+ /**
+ * @singleton
+ * @class mw.Debug.profile
+ */
+ var profile = mw.Debug.profile = {
+ /**
+ * Object containing data for the debug toolbar
+ *
+ * @property ProfileData
+ */
+ data: null,
+
+ /**
+ * @property DOMElement
+ */
+ container: null,
+
+ /**
+ * Initializes the profiling pane.
+ */
+ init: function ( data, width, mergeThresholdPx, dropThresholdPx ) {
+ data = data || mw.config.get( 'debugInfo' ).profile;
+ profile.width = width || $(window).width() - 20;
+ // merge events from same pixel(some events are very granular)
+ mergeThresholdPx = mergeThresholdPx || 2;
+ // only drop events if requested
+ dropThresholdPx = dropThresholdPx || 0;
+
+ if ( !Array.prototype.map || !Array.prototype.reduce || !Array.prototype.filter ) {
+ profile.container = profile.buildRequiresES5();
+ } else if ( data.length === 0 ) {
+ profile.container = profile.buildNoData();
+ } else {
+ // generate a flyout
+ profile.data = new ProfileData( data, profile.width, mergeThresholdPx, dropThresholdPx );
+ // draw it
+ profile.container = profile.buildSvg( profile.container );
+ profile.attachFlyout();
+ }
+
+ return profile.container;
+ },
+
+ buildRequiresES5: function () {
+ return $( '<div>' )
+ .text( 'An ES5 compatible javascript engine is required for the profile visualization.' )
+ .get( 0 );
+ },
+
+ buildNoData: function () {
+ return $( '<div>' ).addClass( 'mw-debug-profile-no-data' )
+ .text( 'No events recorded, ensure profiling is enabled in StartProfiler.php.' )
+ .get( 0 );
+ },
+
+ /**
+ * Creates DOM nodes appropriately namespaced for SVG.
+ *
+ * @param string tag to create
+ * @return DOMElement
+ */
+ createSvgElement: document.createElementNS.bind( document, 'http://www.w3.org/2000/svg' ),
+
+ /**
+ * @param DOMElement|undefined
+ */
+ buildSvg: function ( node ) {
+ var container, group, i, g,
+ timespan = profile.data.timespan,
+ gapPerEvent = 38,
+ space = 10.5,
+ currentHeight = space,
+ totalHeight = 0;
+
+ profile.ratio = ( profile.width - space * 2 ) / ( timespan.end - timespan.start );
+ totalHeight += gapPerEvent * profile.data.groups.length;
+
+ if ( node ) {
+ $( node ).empty();
+ } else {
+ node = profile.createSvgElement( 'svg' );
+ node.setAttribute( 'version', '1.2' );
+ node.setAttribute( 'baseProfile', 'tiny' );
+ }
+ node.style.height = totalHeight;
+ node.style.width = profile.width;
+
+ // use a container that can be transformed
+ container = profile.createSvgElement( 'g' );
+ node.appendChild( container );
+
+ for ( i = 0; i < profile.data.groups.length; i++ ) {
+ group = profile.data.groups[i];
+ g = profile.buildTimeline( group );
+
+ g.setAttribute( 'transform', 'translate( 0 ' + currentHeight + ' )' );
+ container.appendChild( g );
+
+ currentHeight += gapPerEvent;
+ }
+
+ return node;
+ },
+
+ /**
+ * @param Object group of periods to transform into graphics
+ */
+ buildTimeline: function ( group ) {
+ var text, tspan, line, i,
+ sum = group.timespan.sum,
+ ms = ' ~ ' + ( sum < 1 ? sum.toFixed( 2 ) : sum.toFixed( 0 ) ) + ' ms',
+ timeline = profile.createSvgElement( 'g' );
+
+ timeline.setAttribute( 'class', 'mw-debug-profile-timeline' );
+
+ // draw label
+ text = profile.createSvgElement( 'text' );
+ text.setAttribute( 'x', profile.xCoord( group.timespan.start ) );
+ text.setAttribute( 'y', 0 );
+ text.textContent = group.name;
+ timeline.appendChild( text );
+
+ // draw metadata
+ tspan = profile.createSvgElement( 'tspan' );
+ tspan.textContent = ms;
+ text.appendChild( tspan );
+
+ // draw timeline periods
+ for ( i = 0; i < group.periods.length; i++ ) {
+ timeline.appendChild( profile.buildPeriod( group.periods[i] ) );
+ }
+
+ // full-width line under each timeline
+ line = profile.createSvgElement( 'line' );
+ line.setAttribute( 'class', 'mw-debug-profile-underline' );
+ line.setAttribute( 'x1', 0 );
+ line.setAttribute( 'y1', 28 );
+ line.setAttribute( 'x2', profile.width );
+ line.setAttribute( 'y2', 28 );
+ timeline.appendChild( line );
+
+ return timeline;
+ },
+
+ /**
+ * @param Object period to transform into graphics
+ */
+ buildPeriod: function ( period ) {
+ var node,
+ head = profile.xCoord( period.start ),
+ tail = profile.xCoord( period.end ),
+ g = profile.createSvgElement( 'g' );
+
+ g.setAttribute( 'class', 'mw-debug-profile-period' );
+ $( g ).data( 'period', period );
+
+ if ( head + 16 > tail ) {
+ node = profile.createSvgElement( 'rect' );
+ node.setAttribute( 'x', head );
+ node.setAttribute( 'y', 8 );
+ node.setAttribute( 'width', 2 );
+ node.setAttribute( 'height', 9 );
+ g.appendChild( node );
+
+ node = profile.createSvgElement( 'rect' );
+ node.setAttribute( 'x', head );
+ node.setAttribute( 'y', 8 );
+ node.setAttribute( 'width', ( period.end - period.start ) * profile.ratio || 2 );
+ node.setAttribute( 'height', 6 );
+ g.appendChild( node );
+ } else {
+ node = profile.createSvgElement( 'polygon' );
+ node.setAttribute( 'points', pointList( [
+ [ head, 8 ],
+ [ head, 19 ],
+ [ head + 8, 8 ],
+ [ head, 8]
+ ] ) );
+ g.appendChild( node );
+
+ node = profile.createSvgElement( 'polygon' );
+ node.setAttribute( 'points', pointList( [
+ [ tail, 8 ],
+ [ tail, 19 ],
+ [ tail - 8, 8 ],
+ [ tail, 8 ],
+ ] ) );
+ g.appendChild( node );
+
+ node = profile.createSvgElement( 'line' );
+ node.setAttribute( 'x1', head );
+ node.setAttribute( 'y1', 9 );
+ node.setAttribute( 'x2', tail );
+ node.setAttribute( 'y2', 9 );
+ g.appendChild( node );
+ }
+
+ return g;
+ },
+
+ /**
+ * @param Object
+ */
+ buildFlyout: function ( period ) {
+ var contained, sum, ms, mem, i,
+ node = $( '<div>' );
+
+ for ( i = 0; i < period.contained.length; i++ ) {
+ contained = period.contained[i];
+ sum = contained.end - contained.start;
+ ms = '' + ( sum < 1 ? sum.toFixed( 2 ) : sum.toFixed( 0 ) ) + ' ms';
+ mem = formatBytes( contained.memory );
+
+ $( '<div>' ).text( contained.source.name )
+ .append( $( '<span>' ).text( ' ~ ' + ms + ' / ' + mem ).addClass( 'mw-debug-profile-meta' ) )
+ .appendTo( node );
+ }
+
+ return node;
+ },
+
+ /**
+ * Attach a hover flyout to all .mw-debug-profile-period groups.
+ */
+ attachFlyout: function () {
+ // for some reason addClass and removeClass from jQuery
+ // arn't working on svg elements in chrome <= 33.0 (possibly more)
+ var $container = $( profile.container ),
+ addClass = function ( node, value ) {
+ var current = node.getAttribute( 'class' ),
+ list = current ? current.split( ' ' ) : false,
+ idx = list ? list.indexOf( value ) : -1;
+
+ if ( idx === -1 ) {
+ node.setAttribute( 'class', current ? ( current + ' ' + value ) : value );
+ }
+ },
+ removeClass = function ( node, value ) {
+ var current = node.getAttribute( 'class' ),
+ list = current ? current.split( ' ' ) : false,
+ idx = list ? list.indexOf( value ) : -1;
+
+ if ( idx !== -1 ) {
+ list.splice( idx, 1 );
+ node.setAttribute( 'class', list.join( ' ' ) );
+ }
+ },
+ // hide all tipsy flyouts
+ hide = function () {
+ $container.find( '.mw-debug-profile-period.tipsy-visible' )
+ .each( function () {
+ removeClass( this, 'tipsy-visible' );
+ $( this ).tipsy( 'hide' );
+ } );
+ };
+
+ $container.find( '.mw-debug-profile-period' ).tipsy( {
+ fade: true,
+ gravity: function () {
+ return $.fn.tipsy.autoNS.call( this )
+ + $.fn.tipsy.autoWE.call( this );
+ },
+ className: 'mw-debug-profile-tipsy',
+ center: false,
+ html: true,
+ trigger: 'manual',
+ title: function () {
+ return profile.buildFlyout( $( this ).data( 'period' ) ).html();
+ },
+ } ).on( 'mouseenter', function () {
+ hide();
+ addClass( this, 'tipsy-visible' );
+ $( this ).tipsy( 'show' );
+ } );
+
+ $container.on( 'mouseleave', function ( event ) {
+ var $from = $( event.relatedTarget ),
+ $to = $( event.target );
+ // only close the tipsy if we are not
+ if ( $from.closest( '.tipsy' ).length === 0 &&
+ $to.closest( '.tipsy' ).length === 0 &&
+ $to.get( 0 ).namespaceURI !== 'http://www.w4.org/2000/svg'
+ ) {
+ hide();
+ }
+ } ).on( 'click', function () {
+ // convenience method for closing
+ hide();
+ } );
+ },
+
+ /**
+ * @return number the x co-ordinate for the specified timestamp
+ */
+ xCoord: function ( msTimestamp ) {
+ return ( msTimestamp - profile.data.timespan.start ) * profile.ratio;
+ },
+ };
+
+ function ProfileData( data, width, mergeThresholdPx, dropThresholdPx ) {
+ // validate input data
+ this.data = data.map( function ( event ) {
+ event.periods = event.periods.filter( function ( period ) {
+ return period.start && period.end
+ && period.start < period.end
+ // period start must be a reasonable ms timestamp
+ && period.start > 1000000;
+ } );
+ return event;
+ } ).filter( function ( event ) {
+ return event.name && event.periods.length > 0;
+ } );
+
+ // start and end time of the data
+ this.timespan = this.data.reduce( function ( result, event ) {
+ return event.periods.reduce( periodMinMax, result );
+ }, periodMinMax.initial() );
+
+ // transform input data
+ this.groups = this.collate( width, mergeThresholdPx, dropThresholdPx );
+
+ return this;
+ }
+
+ /**
+ * There are too many unique events to display a line for each,
+ * so this does a basic grouping.
+ */
+ ProfileData.groupOf = function ( label ) {
+ var pos, prefix = 'Profile section ended by close(): ';
+ if ( label.indexOf( prefix ) === 0 ) {
+ label = label.substring( prefix.length );
+ }
+
+ pos = [ '::', ':', '-' ].reduce( function ( result, separator ) {
+ var pos = label.indexOf( separator );
+ if ( pos === -1 ) {
+ return result;
+ } else if ( result === -1 ) {
+ return pos;
+ } else {
+ return Math.min( result, pos );
+ }
+ }, -1 );
+
+ if ( pos === -1 ) {
+ return label;
+ } else {
+ return label.substring( 0, pos );
+ }
+ };
+
+ /**
+ * @return Array list of objects with `name` and `events` keys
+ */
+ ProfileData.groupEvents = function ( events ) {
+ var group, i,
+ groups = {};
+
+ // Group events together
+ for ( i = events.length - 1; i >= 0; i-- ) {
+ group = ProfileData.groupOf( events[i].name );
+ if ( groups[group] ) {
+ groups[group].push( events[i] );
+ } else {
+ groups[group] = [events[i]];
+ }
+ }
+
+ // Return an array of groups
+ return Object.keys( groups ).map( function ( group ) {
+ return {
+ name: group,
+ events: groups[group],
+ };
+ } );
+ };
+
+ ProfileData.periodSorter = function ( a, b ) {
+ if ( a.start === b.start ) {
+ return a.end - b.end;
+ }
+ return a.start - b.start;
+ };
+
+ ProfileData.genMergePeriodReducer = function ( mergeThresholdMs ) {
+ return function ( result, period ) {
+ if ( result.length === 0 ) {
+ // period is first result
+ return [{
+ start: period.start,
+ end: period.end,
+ contained: [period],
+ }];
+ }
+ var last = result[result.length - 1];
+ if ( period.end < last.end ) {
+ // end is contained within previous
+ result[result.length - 1].contained.push( period );
+ } else if ( period.start - mergeThresholdMs < last.end ) {
+ // neighbors within merging distance
+ result[result.length - 1].end = period.end;
+ result[result.length - 1].contained.push( period );
+ } else {
+ // period is next result
+ result.push({
+ start: period.start,
+ end: period.end,
+ contained: [period],
+ });
+ }
+ return result;
+ };
+ };
+
+ /**
+ * Collect all periods from the grouped events and apply merge and
+ * drop transformations
+ */
+ ProfileData.extractPeriods = function ( events, mergeThresholdMs, dropThresholdMs ) {
+ // collect the periods from all events
+ return events.reduce( function ( result, event ) {
+ if ( !event.periods.length ) {
+ return result;
+ }
+ result.push.apply( result, event.periods.map( function ( period ) {
+ // maintain link from period to event
+ period.source = event;
+ return period;
+ } ) );
+ return result;
+ }, [] )
+ // sort combined periods
+ .sort( ProfileData.periodSorter )
+ // Apply merge threshold. Original periods
+ // are maintained in the `contained` property
+ .reduce( ProfileData.genMergePeriodReducer( mergeThresholdMs ), [] )
+ // Apply drop threshold
+ .filter( function ( period ) {
+ return period.end - period.start > dropThresholdMs;
+ } );
+ };
+
+ /**
+ * runs a callback on all periods in the group. Only valid after
+ * groups.periods[0..n].contained are populated. This runs against
+ * un-transformed data and is better suited to summing or other
+ * stat collection
+ */
+ ProfileData.reducePeriods = function ( group, callback, result ) {
+ return group.periods.reduce( function ( result, period ) {
+ return period.contained.reduce( callback, result );
+ }, result );
+ };
+
+ /**
+ * Transforms this.data grouping by labels, merging neighboring
+ * events in the groups, and drops events and groups below the
+ * display threshold. Groups are returned sorted by starting time.
+ */
+ ProfileData.prototype.collate = function ( width, mergeThresholdPx, dropThresholdPx ) {
+ // ms to pixel ratio
+ var ratio = ( this.timespan.end - this.timespan.start ) / width,
+ // transform thresholds to ms
+ mergeThresholdMs = mergeThresholdPx * ratio,
+ dropThresholdMs = dropThresholdPx * ratio;
+
+ return ProfileData.groupEvents( this.data )
+ // generate data about the grouped events
+ .map( function ( group ) {
+ // Cleaned periods from all events
+ group.periods = ProfileData.extractPeriods( group.events, mergeThresholdMs, dropThresholdMs );
+ // min and max timestamp per group
+ group.timespan = ProfileData.reducePeriods( group, periodMinMax, periodMinMax.initial() );
+ // ms from first call to end of last call
+ group.timespan.length = group.timespan.end - group.timespan.start;
+ // collect the un-transformed periods
+ group.timespan.sum = ProfileData.reducePeriods( group, function ( result, period ) {
+ result.push( period );
+ return result;
+ }, [] )
+ // sort by start time
+ .sort( ProfileData.periodSorter )
+ // merge overlapping
+ .reduce( ProfileData.genMergePeriodReducer( 0 ), [] )
+ // sum
+ .reduce( function ( result, period ) {
+ return result + period.end - period.start;
+ }, 0 );
+
+ return group;
+ }, this )
+ // remove groups that have had all their periods filtered
+ .filter( function ( group ) {
+ return group.periods.length > 0;
+ } )
+ // sort events by first start
+ .sort( function ( a, b ) {
+ return ProfileData.periodSorter( a.timespan, b.timespan );
+ } );
+ };
+
+ // reducer to find edges of period array
+ function periodMinMax( result, period ) {
+ if ( period.start < result.start ) {
+ result.start = period.start;
+ }
+ if ( period.end > result.end ) {
+ result.end = period.end;
+ }
+ return result;
+ }
+
+ periodMinMax.initial = function () {
+ return { start: Number.POSITIVE_INFINITY, end: Number.NEGATIVE_INFINITY };
+ };
+
+ function formatBytes( bytes ) {
+ var i, sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
+ if ( bytes === 0 ) {
+ return '0 Bytes';
+ }
+ i = parseInt( Math.floor( Math.log( bytes ) / Math.log( 1024 ) ), 10 );
+ return Math.round( bytes / Math.pow( 1024, i ), 2 ) + ' ' + sizes[i];
+ }
+
+ // turns a 2d array into a point list for svg
+ // polygon points attribute
+ // ex: [[1,2],[3,4],[4,2]] = '1,2 3,4 4,2'
+ function pointList( pairs ) {
+ return pairs.map( function ( pair ) {
+ return pair.join( ',' );
+ } ).join( ' ' );
+ }
+}( mediaWiki, jQuery ) );
return {
/**
- * Escape a string for HTML. Converts special characters to HTML entities.
+ * Escape a string for HTML.
+ *
+ * Converts special characters to HTML entities.
*
* mw.html.escape( '< > \' & "' );
* // Returns < > ' & "
*
* @param {string} s The string to escape
+ * @return {string} HTML
*/
escape: function ( s ) {
return s.replace( /['"<>&]/g, escapeCallback );
* - this.Cdata: The value attribute is included, and an exception is
* thrown if it contains an illegal ETAGO delimiter.
* See <http://www.w3.org/TR/1999/REC-html401-19991224/appendix/notes.html#h-B.3.2>.
+ * @return {string} HTML
*/
element: function ( name, attrs, contents ) {
var v, attrName, s = '<' + name;
]
},
"ooui-dialog-action-close": "Затваряне",
- "ooui-outline-control-remove": "Ð\9fÑ\80емаÑ\85ване на обекÑ\82и",
+ "ooui-outline-control-remove": "Ð\9fÑ\80емаÑ\85ване на обекÑ\82а",
"ooui-toolbar-more": "Още"
-}
\ No newline at end of file
+}
"ooui-dialog-action-close": "Zacyniś",
"ooui-outline-control-move-down": "Element dołoj pśesunuś",
"ooui-outline-control-move-up": "Element górjej pśesunuś",
+ "ooui-outline-control-remove": "Zapisk wótpóraś",
"ooui-toolbar-more": "Wěcej"
-}
\ No newline at end of file
+}
]
},
"ooui-dialog-action-close": "Κλείσιμο",
- "ooui-outline-control-move-down": "Μετακίνηση προς τα κάτω",
- "ooui-outline-control-move-up": "Μετακίνηση προς τα πάνω",
+ "ooui-outline-control-move-down": "Μετακίνηση στοιχείου προς τα κάτω",
+ "ooui-outline-control-move-up": "Μετακίνηση στοιχείου προς τα επάνω",
+ "ooui-outline-control-remove": "Αφαίρεση στοιχείου",
"ooui-toolbar-more": "Περισσότερα"
-}
\ No newline at end of file
+}
"ooui-outline-control-move-up": "Mover arriba",
"ooui-outline-control-remove": "Eliminar elemento",
"ooui-toolbar-more": "Más"
-}
\ No newline at end of file
+}
"ooui-outline-control-move-up": "Liiguta üksust ülespoole",
"ooui-outline-control-remove": "Eemalda üksus",
"ooui-toolbar-more": "Veel"
-}
\ No newline at end of file
+}
"ooui-outline-control-move-up": "प्रविष्टि ऊपर ले जाएँ",
"ooui-outline-control-remove": "आइटम हटाएँ",
"ooui-toolbar-more": "अधिक"
-}
\ No newline at end of file
+}
"ooui-outline-control-move-up": "Элементті жоғары жылжыту",
"ooui-outline-control-remove": "Элементті алып тастау",
"ooui-toolbar-more": "толығырақ"
-}
\ No newline at end of file
+}
"ooui-dialog-action-close": "Tutup",
"ooui-outline-control-move-down": "Alihkan perkara ke bawah",
"ooui-outline-control-move-up": "Alihkan perkara ke atas",
- "ooui-toolbar-more": "Lagi"
-}
\ No newline at end of file
+ "ooui-outline-control-remove": "Buang perkara",
+ "ooui-toolbar-more": "Selebihnya"
+}
"ooui-outline-control-move-up": "Przenieś wyżej",
"ooui-outline-control-remove": "Usuń element",
"ooui-toolbar-more": "Więcej"
-}
\ No newline at end of file
+}
"ooui-outline-control-move-up": "Qallawata huqariy",
"ooui-outline-control-remove": "P'anqa sutikunata qichuy",
"ooui-toolbar-more": "Aswan"
-}
\ No newline at end of file
+}
"ooui-dialog-action-close": "关闭",
"ooui-outline-control-move-down": "下移项",
"ooui-outline-control-move-up": "上移项",
- "ooui-outline-control-remove": "移除项",
+ "ooui-outline-control-remove": "删除项",
"ooui-toolbar-more": "更多"
-}
\ No newline at end of file
+}
.oo-ui-buttonedElement-framed.oo-ui-widget-disabled .oo-ui-buttonedElement-button.oo-ui-buttonedElement-pressed {
color: #333;
background: #eee;
+ border-color: #ccc;
opacity: 0.5;
box-shadow: none;
}
background-color: #e1f3ff;
}
-.oo-ui-optionWidget-selected {
+.oo-ui-selectWidget-depressed .oo-ui-optionWidget-selected {
+ background-color: #a7dcff;
+}
+
+.oo-ui-selectWidget-pressed .oo-ui-optionWidget-pressed {
background-color: #a7dcff;
}
}
.oo-ui-buttonOptionWidget.oo-ui-optionWidget-selected,
+.oo-ui-buttonOptionWidget.oo-ui-optionWidget-pressed,
.oo-ui-buttonOptionWidget.oo-ui-optionWidget-highlighted {
background-color: transparent;
}
left: 4em;
}
-.oo-ui-outlineItemWidget.oo-ui-optionWidget-selected {
+.oo-ui-selectWidget-depressed .oo-ui-outlineItemWidget.oo-ui-optionWidget-selected {
text-shadow: 0 1px 1px rgba(255, 255, 255, 0.5);
background-color: #a7dcff;
}
/*!
- * OOjs UI v0.1.0-pre (23fb1b6144)
+ * OOjs UI v0.1.0-pre (eaa1b7f06d)
* https://www.mediawiki.org/wiki/OOjs_UI
*
* Copyright 2011–2014 OOjs Team and other contributors.
* Released under the MIT license
* http://oojs.mit-license.org
*
- * Date: Thu Mar 27 2014 14:49:30 GMT-0700 (PDT)
+ * Date: Thu Apr 03 2014 16:56:21 GMT-0700 (PDT)
*/
( function ( OO ) {
*
* @static
* @param {jQuery|HTMLElement|HTMLDocument|Window} obj Object to get the document for
- * @return {HTMLDocument} Document object
- * @throws {Error} If context is invalid
+ * @return {HTMLDocument|null} Document object
*/
OO.ui.Element.getDocument = function ( obj ) {
- var doc =
- // jQuery - selections created "offscreen" won't have a context, so .context isn't reliable
- ( obj[0] && obj[0].ownerDocument ) ||
+ // jQuery - selections created "offscreen" won't have a context, so .context isn't reliable
+ return ( obj[0] && obj[0].ownerDocument ) ||
// Empty jQuery selections might have a context
obj.context ||
// HTMLElement
// Window
obj.document ||
// HTMLDocument
- ( obj.nodeType === 9 && obj );
-
- if ( doc ) {
- return doc;
- }
-
- throw new Error( 'Invalid context' );
+ ( obj.nodeType === 9 && obj ) ||
+ null;
};
/**
return this.constructor.static.tagName;
};
+/**
+ * Check if the element is attached to the DOM
+ * @return {boolean} The element is attached to the DOM
+ */
+OO.ui.Element.prototype.isElementAttached = function () {
+ return $.contains( this.getElementDocument(), this.$element[0] );
+};
+
/**
* Get the DOM document.
*
/**
* Set the disabled state of the widget.
*
- * This should probably change the widgets's appearance and prevent it from being used.
+ * This should probably change the widgets' appearance and prevent it from being used.
*
* @param {boolean} disabled Disable widget
* @chainable
}
}
}
- }
// Include tools with matching name and exclude already used tools
- else if ( item.name && ( !used || !used[item.name] ) ) {
+ } else if ( item.name && ( !used || !used[item.name] ) ) {
names.push( item.name );
if ( used ) {
used[item.name] = true;
*/
OO.ui.ToolGroup = function OoUiToolGroup( toolbar, config ) {
// Configuration initialization
- config = config || {};
+ config = $.extend( true, {
+ 'aggregations': { 'disable': 'itemDisable' }
+ }, config );
// Parent constructor
OO.ui.ToolGroup.super.call( this, config );
this.toolbar = toolbar;
this.tools = {};
this.pressed = null;
+ this.autoDisabled = false;
this.include = config.include || [];
this.exclude = config.exclude || [];
this.promote = config.promote || [];
'mouseout': OO.ui.bind( this.onMouseOut, this )
} );
this.toolbar.getToolFactory().connect( this, { 'register': 'onToolFactoryRegister' } );
+ this.connect( this, { 'itemDisable': 'updateDisabled' } );
// Initialization
this.$group.addClass( 'oo-ui-toolGroup-tools' );
*/
OO.ui.ToolGroup.static.accelTooltips = false;
+/**
+ * Automatically disable the toolgroup when all tools are disabled
+ *
+ * @static
+ * @property {boolean}
+ * @inheritable
+ */
+OO.ui.ToolGroup.static.autoDisable = true;
+
/* Methods */
+/**
+ * @inheritdoc
+ */
+OO.ui.ToolGroup.prototype.isDisabled = function () {
+ return this.autoDisabled || OO.ui.ToolGroup.super.prototype.isDisabled.apply( this, arguments );
+};
+
+/**
+ * @inheritdoc
+ */
+OO.ui.ToolGroup.prototype.updateDisabled = function () {
+ var i, item, allDisabled = true;
+
+ if ( this.constructor.static.autoDisable ) {
+ for ( i = this.items.length - 1; i >= 0; i-- ) {
+ item = this.items[i];
+ if ( !item.isDisabled() ) {
+ allDisabled = false;
+ break;
+ }
+ }
+ this.autoDisabled = allDisabled;
+ }
+ OO.ui.ToolGroup.super.prototype.updateDisabled.apply( this, arguments );
+};
+
/**
* Handle mouse down events.
*
}
// Re-add tools (moving existing ones to new locations)
this.addItems( add );
+ // Disabled state may depend on items
+ this.updateDisabled();
};
/**
/* Methods */
+/**
+ * @inheritdoc
+ */
+OO.ui.PopupToolGroup.prototype.setDisabled = function () {
+ // Parent method
+ OO.ui.PopupToolGroup.super.prototype.setDisabled.apply( this, arguments );
+
+ if ( this.isDisabled() && this.isElementAttached() ) {
+ this.setActive( false );
+ }
+};
+
/**
* Handle focus being lost.
*
this.data = data;
this.selected = false;
this.highlighted = false;
+ this.pressed = false;
// Initialization
this.$element
OO.ui.OptionWidget.static.highlightable = true;
+OO.ui.OptionWidget.static.pressable = true;
+
OO.ui.OptionWidget.static.scrollIntoViewOnSelect = false;
/* Methods */
return this.constructor.static.highlightable && !this.disabled;
};
+/**
+ * Check if option can be pressed.
+ *
+ * @method
+ * @returns {boolean} Item is pressable
+ */
+OO.ui.OptionWidget.prototype.isPressable = function () {
+ return this.constructor.static.pressable && !this.disabled;
+};
+
/**
* Check if option is selected.
*
return this.highlighted;
};
+/**
+ * Check if option is pressed.
+ *
+ * @method
+ * @returns {boolean} Item is pressed
+ */
+OO.ui.OptionWidget.prototype.isPressed = function () {
+ return this.pressed;
+};
+
/**
* Set selected state.
*
return this;
};
+/**
+ * Set pressed state.
+ *
+ * @method
+ * @param {boolean} [state=false] Press option
+ * @chainable
+ */
+OO.ui.OptionWidget.prototype.setPressed = function ( state ) {
+ if ( !this.disabled && this.constructor.static.pressable ) {
+ this.pressed = !!state;
+ if ( this.pressed ) {
+ this.$element.addClass( 'oo-ui-optionWidget-pressed' );
+ } else {
+ this.$element.removeClass( 'oo-ui-optionWidget-pressed' );
+ }
+ }
+ return this;
+};
+
/**
* Make the option's highlight flash.
*
+ * While flashing, the visual style of the pressed state is removed if present.
+ *
* @method
* @param {Function} [done] Callback to execute when flash effect is complete.
*/
OO.ui.OptionWidget.prototype.flash = function ( done ) {
var $this = this.$element;
- if ( !this.disabled && this.constructor.static.highlightable ) {
- $this.removeClass( 'oo-ui-optionWidget-highlighted' );
+ if ( !this.disabled && this.constructor.static.pressable ) {
+ $this.removeClass( 'oo-ui-optionWidget-highlighted oo-ui-optionWidget-pressed' );
setTimeout( OO.ui.bind( function () {
$this.addClass( 'oo-ui-optionWidget-highlighted' );
if ( done ) {
+ // Restore original classes
+ $this
+ .toggleClass( 'oo-ui-optionWidget-highlighted', this.highlighted )
+ .toggleClass( 'oo-ui-optionWidget-pressed', this.pressed );
setTimeout( done, 100 );
}
}, this ), 100 );
} );
// Initialization
- this.$element.addClass( 'oo-ui-selectWidget' );
+ this.$element.addClass( 'oo-ui-selectWidget oo-ui-selectWidget-depressed' );
if ( $.isArray( config.items ) ) {
this.addItems( config.items );
}
* @param {OO.ui.OptionWidget|null} item Highlighted item
*/
+/**
+ * @event press
+ * @param {OO.ui.OptionWidget|null} item Pressed item
+ */
+
/**
* @event select
* @param {OO.ui.OptionWidget|null} item Selected item
var item;
if ( !this.disabled && e.which === 1 ) {
- this.pressed = true;
+ this.togglePressed( true );
item = this.getTargetItem( e );
if ( item && item.isSelectable() ) {
- this.intializeSelection( item );
+ this.pressItem( item );
this.selecting = item;
this.$( this.$.context ).one( 'mouseup', OO.ui.bind( this.onMouseUp, this ) );
}
*/
OO.ui.SelectWidget.prototype.onMouseUp = function ( e ) {
var item;
- this.pressed = false;
+
+ this.togglePressed( false );
if ( !this.selecting ) {
item = this.getTargetItem( e );
if ( item && item.isSelectable() ) {
}
}
if ( !this.disabled && e.which === 1 && this.selecting ) {
+ this.pressItem( null );
this.selectItem( this.selecting );
this.selecting = null;
}
+
return false;
};
if ( !this.disabled && this.pressed ) {
item = this.getTargetItem( e );
if ( item && item !== this.selecting && item.isSelectable() ) {
- this.intializeSelection( item );
+ this.pressItem( item );
this.selecting = item;
}
}
*/
OO.ui.SelectWidget.prototype.onMouseLeave = function () {
if ( !this.disabled ) {
- this.highlightItem();
+ this.highlightItem( null );
}
return false;
};
return null;
};
+/**
+ * Toggle pressed state.
+ *
+ * @param {boolean} pressed An option is being pressed
+ */
+OO.ui.SelectWidget.prototype.togglePressed = function ( pressed ) {
+ if ( pressed === undefined ) {
+ pressed = !this.pressed;
+ }
+ if ( pressed !== this.pressed ) {
+ this.$element.toggleClass( 'oo-ui-selectWidget-pressed', pressed );
+ this.$element.toggleClass( 'oo-ui-selectWidget-depressed', !pressed );
+ this.pressed = pressed;
+ }
+};
+
/**
* Highlight an item.
*
* @chainable
*/
OO.ui.SelectWidget.prototype.highlightItem = function ( item ) {
- var i, len;
+ var i, len, highlighted,
+ changed = false;
for ( i = 0, len = this.items.length; i < len; i++ ) {
- this.items[i].setHighlighted( this.items[i] === item );
+ highlighted = this.items[i] === item;
+ if ( this.items[i].isHighlighted() !== highlighted ) {
+ this.items[i].setHighlighted( highlighted );
+ changed = true;
+ }
+ }
+ if ( changed ) {
+ this.emit( 'highlight', item );
}
- this.emit( 'highlight', item );
return this;
};
* @chainable
*/
OO.ui.SelectWidget.prototype.selectItem = function ( item ) {
- var i, len;
+ var i, len, selected,
+ changed = false;
+
+ for ( i = 0, len = this.items.length; i < len; i++ ) {
+ selected = this.items[i] === item;
+ if ( this.items[i].isSelected() !== selected ) {
+ this.items[i].setSelected( selected );
+ changed = true;
+ }
+ }
+ if ( changed ) {
+ this.emit( 'select', item );
+ }
+
+ return this;
+};
+
+/**
+ * Press an item.
+ *
+ * @method
+ * @param {OO.ui.OptionWidget} [item] Item to press, omit to depress all
+ * @fires press
+ * @chainable
+ */
+OO.ui.SelectWidget.prototype.pressItem = function ( item ) {
+ var i, len, pressed,
+ changed = false;
for ( i = 0, len = this.items.length; i < len; i++ ) {
- this.items[i].setSelected( this.items[i] === item );
+ pressed = this.items[i] === item;
+ if ( this.items[i].isPressed() !== pressed ) {
+ this.items[i].setPressed( pressed );
+ changed = true;
+ }
+ }
+ if ( changed ) {
+ this.emit( 'press', item );
}
- this.emit( 'select', item );
return this;
};
* @param {OO.ui.OptionWidget} [item] Item to select
* @chainable
*/
-OO.ui.SelectWidget.prototype.intializeSelection = function( item ) {
+OO.ui.SelectWidget.prototype.intializeSelection = function ( item ) {
var i, len, selected;
for ( i = 0, len = this.items.length; i < len; i++ ) {
*
* @chainable
*/
-OO.ui.TextInputWidget.prototype.adjustSize = function() {
+OO.ui.TextInputWidget.prototype.adjustSize = function () {
var $clone, scrollHeight, innerHeight, outerHeight, maxInnerHeight, idealHeight;
if ( this.multiline && this.autosize ) {
/*!
- * OOjs UI v0.1.0-pre (23fb1b6144)
+ * OOjs UI v0.1.0-pre (eaa1b7f06d)
* https://www.mediawiki.org/wiki/OOjs_UI
*
* Copyright 2011–2014 OOjs Team and other contributors.
* Released under the MIT license
* http://oojs.mit-license.org
*
- * Date: Thu Mar 27 2014 14:49:30 GMT-0700 (PDT)
+ * Date: Thu Apr 03 2014 16:56:21 GMT-0700 (PDT)
*/
/* Textures */
/*!
- * OOjs v1.0.8
+ * OOjs v1.0.9
* https://www.mediawiki.org/wiki/OOjs
*
* Copyright 2011-2014 OOjs Team and other contributors.
* Released under the MIT license
* http://oojs.mit-license.org
*
- * Date: Tue Mar 11 2014 19:27:31 GMT+0100 (CET)
+ * Date: Wed Apr 02 2014 14:29:36 GMT-0700 (PDT)
*/
( function ( global ) {
return true;
};
+/**
+ * Utility to initialize a class for OO inheritance.
+ *
+ * Currently this just initializes an empty static object.
+ *
+ * @param {Function} fn
+ */
+oo.initClass = function ( fn ) {
+ fn.static = fn.static || {};
+};
+
/**
* Utility for common usage of Object#create for inheriting from one
* prototype to another.
} );
// Extend static properties - always initialize both sides
- originFn.static = originFn.static || {};
+ oo.initClass( originFn );
targetFn.static = Object.create( originFn.static );
};
}
// Copy static properties - always initialize both sides
- targetFn.static = targetFn.static || {};
+ oo.initClass( targetFn );
if ( originFn.static ) {
for ( key in originFn.static ) {
if ( hasOwn.call( originFn.static, key ) ) {
}
}
} else {
- originFn.static = {};
+ oo.initClass( originFn );
}
};
* Classes must have a static `name` property to be registered.
*
* function MyClass() {};
+ * OO.initClass( MyClass );
* // Adds a static property to the class defining a symbolic name
- * MyClass.static = { 'name': 'mine' };
+ * MyClass.static.name = 'mine';
* // Registers class with factory, available via symbolic name 'mine'
* factory.register( MyClass );
*
ul.mw-gallery-packed-overlay li.gallerybox div.gallerytextwrapper,
ul.mw-gallery-packed-hover li.gallerybox.mw-gallery-focused div.gallerytextwrapper {
position:absolute;
- opacity:.8;
- filter:alpha(opacity=80);
- zoom: 1;
- background-color:white;
+ background: white;
+ background: rgba(255, 255, 255, 0.8);
padding: 5px 10px;
bottom: 0;
left: 0; /* Needed for IE */
text-align: center;
}
-ul.mw-gallery-packed-hover div.gallerytext,
-ul.mw-gallery-packed-overlay div.gallerytext {
- opacity: 1;
- position: relative; /* Resets opacity in old IE */
-}
-
-
.mw-ajax-loader {
/* @embed */
background-image: url(images/ajax-loader.gif);
this.server.respond();
} );
+ QUnit.test( 'getToken( cached )', function ( assert ) {
+ QUnit.expect( 2 );
+
+ var api = new mw.Api();
+
+ // Get editToken for local wiki, this should not make
+ // a request as it should be retrieved from user.tokens.
+ api.getToken( 'edit' )
+ .done( function ( token ) {
+ assert.ok( token.length, 'Got a token' );
+ } )
+ .fail( function ( err ) {
+ assert.equal( '', err, 'API error' );
+ } );
+
+ assert.equal( this.server.requests.length, 0, 'Requests made' );
+ } );
+
+ QUnit.test( 'getToken( uncached )', function ( assert ) {
+ QUnit.expect( 2 );
+
+ var api = new mw.Api();
+
+ // Get a token of a type that isn't prepopulated by user.tokens.
+ // Could use "block" or "delete" here, but those could in theory
+ // be added to user.tokens, use a fake one instead.
+ api.getToken( 'testaction' )
+ .done( function ( token ) {
+ assert.ok( token.length, 'Got a token' );
+ } )
+ .fail( function ( err ) {
+ assert.equal( '', err, 'API error' );
+ } );
+
+ assert.equal( this.server.requests.length, 1, 'Requests made' );
+
+ this.server.respond( function ( request ) {
+ request.respond( 200, { 'Content-Type': 'application/json' },
+ '{ "tokens": { "testactiontoken": "0123abc" } }'
+ );
+ } );
+ } );
+
}( mediaWiki ) );