npm-debug.log
node_modules/
/tests/phpunit/phpunit.phar
+/tests/selenium/log
# Composer
/vendor
'MediaWiki\\Storage\\RevisionLookup' => __DIR__ . '/includes/Storage/RevisionLookup.php',
'MediaWiki\\Storage\\RevisionRecord' => __DIR__ . '/includes/Storage/RevisionRecord.php',
'MediaWiki\\Storage\\RevisionSlots' => __DIR__ . '/includes/Storage/RevisionSlots.php',
+ 'MediaWiki\\Storage\\RevisionSlotsUpdate' => __DIR__ . '/includes/Storage/RevisionSlotsUpdate.php',
'MediaWiki\\Storage\\RevisionStore' => __DIR__ . '/includes/Storage/RevisionStore.php',
'MediaWiki\\Storage\\RevisionStoreRecord' => __DIR__ . '/includes/Storage/RevisionStoreRecord.php',
'MediaWiki\\Storage\\SlotRecord' => __DIR__ . '/includes/Storage/SlotRecord.php',
unset( $this->slots[$role] );
}
- /**
- * Return all slots that are not inherited.
- *
- * @note This may cause the slot meta-data for the revision to be lazy-loaded.
- *
- * @return SlotRecord[]
- */
- public function getTouchedSlots() {
- return array_filter(
- $this->getSlots(),
- function ( SlotRecord $slot ) {
- return !$slot->isInherited();
- }
- );
- }
-
- /**
- * Return all slots that are inherited.
- *
- * @note This may cause the slot meta-data for the revision to be lazy-loaded.
- *
- * @return SlotRecord[]
- */
- public function getInheritedSlots() {
- return array_filter(
- $this->getSlots(),
- function ( SlotRecord $slot ) {
- return $slot->isInherited();
- }
- );
- }
-
}
* @param SlotRecord[] $slots
*/
private function setSlotsInternal( array $slots ) {
+ Assert::parameterElementType( SlotRecord::class, $slots, '$slots' );
+
$this->slots = [];
// re-key the slot array
}, null );
}
+ /**
+ * Return all slots that are not inherited.
+ *
+ * @note This may cause the slot meta-data for the revision to be lazy-loaded.
+ *
+ * @return SlotRecord[]
+ */
+ public function getTouchedSlots() {
+ return array_filter(
+ $this->getSlots(),
+ function ( SlotRecord $slot ) {
+ return !$slot->isInherited();
+ }
+ );
+ }
+
+ /**
+ * Return all slots that are inherited.
+ *
+ * @note This may cause the slot meta-data for the revision to be lazy-loaded.
+ *
+ * @return SlotRecord[]
+ */
+ public function getInheritedSlots() {
+ return array_filter(
+ $this->getSlots(),
+ function ( SlotRecord $slot ) {
+ return $slot->isInherited();
+ }
+ );
+ }
+
+ /**
+ * Checks whether the other RevisionSlots instance has the same content
+ * as this instance. Note that this does not mean that the slots have to be the same:
+ * they could for instance belong to different revisions.
+ *
+ * @param RevisionSlots $other
+ *
+ * @return bool
+ */
+ public function hasSameContent( RevisionSlots $other ) {
+ if ( $other === $this ) {
+ return true;
+ }
+
+ $aSlots = $this->getSlots();
+ $bSlots = $other->getSlots();
+
+ ksort( $aSlots );
+ ksort( $bSlots );
+
+ if ( array_keys( $aSlots ) !== array_keys( $bSlots ) ) {
+ return false;
+ }
+
+ foreach ( $aSlots as $role => $s ) {
+ $t = $bSlots[$role];
+
+ if ( !$s->hasSameContent( $t ) ) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
}
--- /dev/null
+<?php
+/**
+ * Value object representing a modification of revision slots.
+ *
+ * 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
+ */
+
+namespace MediaWiki\Storage;
+
+use Content;
+
+/**
+ * Value object representing a modification of revision slots.
+ *
+ * @since 1.32
+ */
+class RevisionSlotsUpdate {
+
+ /**
+ * @var SlotRecord[] modified slots, using the slot role as the key.
+ */
+ private $modifiedSlots = [];
+
+ /**
+ * @var bool[] removed roles, stored in the keys of the array.
+ */
+ private $removedRoles = [];
+
+ /**
+ * Constructs a RevisionSlotsUpdate representing the update that turned $parentSlots
+ * into $newSlots. If $parentSlots is not given, $newSlots is assumed to come from a
+ * page's first revision.
+ *
+ * @param RevisionSlots $newSlots
+ * @param RevisionSlots|null $parentSlots
+ *
+ * @return RevisionSlotsUpdate
+ */
+ public static function newFromRevisionSlots(
+ RevisionSlots $newSlots,
+ RevisionSlots $parentSlots = null
+ ) {
+ $modified = $newSlots->getSlots();
+ $removed = [];
+
+ if ( $parentSlots ) {
+ foreach ( $parentSlots->getSlots() as $role => $slot ) {
+ if ( !isset( $modified[$role] ) ) {
+ $removed[] = $role;
+ } elseif ( $slot->hasSameContent( $modified[$role] ) ) {
+ // Unset slots that had the same content in the parent revision from $modified.
+ unset( $modified[$role] );
+ }
+ }
+ }
+
+ return new RevisionSlotsUpdate( $modified, $removed );
+ }
+
+ /**
+ * @param SlotRecord[] $modifiedSlots
+ * @param string[] $removedRoles
+ */
+ public function __construct( array $modifiedSlots = [], array $removedRoles = [] ) {
+ foreach ( $modifiedSlots as $slot ) {
+ $this->modifySlot( $slot );
+ }
+
+ foreach ( $removedRoles as $role ) {
+ $this->removeSlot( $role );
+ }
+ }
+
+ /**
+ * Returns a list of modified slot roles, that is, roles modified by calling modifySlot(),
+ * and not later removed by calling removeSlot().
+ *
+ * @return string[]
+ */
+ public function getModifiedRoles() {
+ return array_keys( $this->modifiedSlots );
+ }
+
+ /**
+ * Returns a list of removed slot roles, that is, roles removed by calling removeSlot(),
+ * and not later re-introduced by calling modifySlot().
+ *
+ * @return string[]
+ */
+ public function getRemovedRoles() {
+ return array_keys( $this->removedRoles );
+ }
+
+ /**
+ * Returns a list of all slot roles that modified or removed.
+ *
+ * @return string[]
+ */
+ public function getTouchedRoles() {
+ return array_merge( $this->getModifiedRoles(), $this->getRemovedRoles() );
+ }
+
+ /**
+ * Sets the given slot to be modified.
+ * If a slot with the same role is already present, it is replaced.
+ *
+ * The roles used with modifySlot() will be returned from getModifiedRoles(),
+ * unless overwritten with removeSlot().
+ *
+ * @param SlotRecord $slot
+ */
+ public function modifySlot( SlotRecord $slot ) {
+ $role = $slot->getRole();
+
+ // XXX: We should perhaps require this to be an unsaved slot!
+ unset( $this->removedRoles[$role] );
+ $this->modifiedSlots[$role] = $slot;
+ }
+
+ /**
+ * Sets the content for the slot with the given role to be modified.
+ * If a slot with the same role is already present, it is replaced.
+ *
+ * @param string $role
+ * @param Content $content
+ */
+ public function modifyContent( $role, Content $content ) {
+ $slot = SlotRecord::newUnsaved( $role, $content );
+ $this->modifySlot( $slot );
+ }
+
+ /**
+ * Remove the slot for the given role, discontinue the corresponding stream.
+ *
+ * The roles used with removeSlot() will be returned from getRemovedSlots(),
+ * unless overwritten with modifySlot().
+ *
+ * @param string $role
+ */
+ public function removeSlot( $role ) {
+ unset( $this->modifiedSlots[$role] );
+ $this->removedRoles[$role] = true;
+ }
+
+ /**
+ * Returns the SlotRecord associated with the given role, if the slot with that role
+ * was modified (and not again removed).
+ *
+ * @note If the SlotRecord returned by this method returns a non-inherited slot,
+ * the content of that slot may or may not already have PST applied. Methods
+ * that take a RevisionSlotsUpdate as a parameter should specify whether they
+ * expect PST to already have been applied to all slots. Inherited slots
+ * should never have PST applied again.
+ *
+ * @param string $role The role name of the desired slot
+ *
+ * @throws RevisionAccessException if the slot does not exist or was removed.
+ * @return SlotRecord
+ */
+ public function getModifiedSlot( $role ) {
+ if ( isset( $this->modifiedSlots[$role] ) ) {
+ return $this->modifiedSlots[$role];
+ } else {
+ throw new RevisionAccessException( 'No such slot: ' . $role );
+ }
+ }
+
+ /**
+ * Returns whether getModifiedSlot() will return a SlotRecord for the given role.
+ *
+ * Will return true for the role names returned by getModifiedRoles(), false otherwise.
+ *
+ * @param string $role The role name of the desired slot
+ *
+ * @return bool
+ */
+ public function isModifiedSlot( $role ) {
+ return isset( $this->modifiedSlots[$role] );
+ }
+
+ /**
+ * Returns whether the given role is to be removed from the page.
+ *
+ * Will return true for the role names returned by getRemovedRoles(), false otherwise.
+ *
+ * @param string $role The role name of the desired slot
+ *
+ * @return bool
+ */
+ public function isRemovedSlot( $role ) {
+ return isset( $this->removedRoles[$role] );
+ }
+
+ /**
+ * Returns true if $other represents the same update - that is,
+ * if all methods defined by RevisionSlotsUpdate when called on $this or $other
+ * will yield the same result when called with the same parameters.
+ *
+ * SlotRecords for the same role are compared based on their model and content.
+ *
+ * @param RevisionSlotsUpdate $other
+ * @return bool
+ */
+ public function hasSameUpdates( RevisionSlotsUpdate $other ) {
+ // NOTE: use != not !==, since the order of entries is not significant!
+
+ if ( $this->getModifiedRoles() != $other->getModifiedRoles() ) {
+ return false;
+ }
+
+ if ( $this->getRemovedRoles() != $other->getRemovedRoles() ) {
+ return false;
+ }
+
+ foreach ( $this->getModifiedRoles() as $role ) {
+ $s = $this->getModifiedSlot( $role );
+ $t = $other->getModifiedSlot( $role );
+
+ if ( !$s->hasSameContent( $t ) ) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+}
return \Wikimedia\base_convert( sha1( $blob ), 16, 36, 31 );
}
+ /**
+ * Returns true if $other has the same content as this slot.
+ * The check is performed based on the model, address size, and hash.
+ * Two slots can have the same content if they use different content addresses,
+ * but if they have the same address and the same model, they have the same content.
+ * Two slots can have the same content if they belong to different
+ * revisions or pages.
+ *
+ * Note that hasSameContent() may return false even if Content::equals returns true for
+ * the content of two slots. This may happen if the two slots have different serializations
+ * representing equivalent Content. Such false negatives are considered acceptable. Code
+ * that has to be absolutely sure the Content is really not the same if hasSameContent()
+ * returns false should call getContent() and compare the Content objects directly.
+ *
+ * @since 1.32
+ *
+ * @param SlotRecord $other
+ * @return bool
+ */
+ public function hasSameContent( SlotRecord $other ) {
+ if ( $other === $this ) {
+ return true;
+ }
+
+ if ( $this->getModel() !== $other->getModel() ) {
+ return false;
+ }
+
+ if ( $this->hasAddress()
+ && $other->hasAddress()
+ && $this->getAddress() == $other->getAddress()
+ ) {
+ return true;
+ }
+
+ if ( $this->getSize() !== $other->getSize() ) {
+ return false;
+ }
+
+ if ( $this->getSha1() !== $other->getSha1() ) {
+ return false;
+ }
+
+ return true;
+ }
+
}
private static $mMinSearchLength;
/**
- * Parse the user's query and transform it into an SQL fragment which will
- * become part of a WHERE clause
+ * Parse the user's query and transform it into two SQL fragments:
+ * a WHERE condition and an ORDER BY expression
*
* @param string $filteredText
* @param string $fulltext
*
- * @return string
+ * @return array
*/
function parseQuery( $filteredText, $fulltext ) {
global $wgContLang;
$searchon = $this->db->addQuotes( $searchon );
$field = $this->getIndexField( $fulltext );
- return " MATCH($field) AGAINST($searchon IN BOOLEAN MODE) ";
+ return [
+ " MATCH($field) AGAINST($searchon IN BOOLEAN MODE) ",
+ " MATCH($field) AGAINST($searchon IN NATURAL LANGUAGE MODE) DESC "
+ ];
}
function regexTerm( $string, $wildcard ) {
$query['fields'][] = 'page_namespace';
$query['fields'][] = 'page_title';
$query['conds'][] = 'page_id=si_page';
- $query['conds'][] = $match;
+ $query['conds'][] = $match[0];
+ $query['options']['ORDER BY'] = $match[1];
}
/**
$query = [
'tables' => [ 'page', 'searchindex' ],
'fields' => [ 'COUNT(*) as c' ],
- 'conds' => [ 'page_id=si_page', $match ],
+ 'conds' => [ 'page_id=si_page', $match[0] ],
'options' => [],
'joins' => [],
];
/**
* Checks if two user objects point to the same user.
*
- * @since 1.25
- * @param User $user
+ * @since 1.25 ; takes a UserIdentity instead of a User since 1.32
+ * @param UserIdentity $user
* @return bool
*/
- public function equals( User $user ) {
+ public function equals( UserIdentity $user ) {
+ // XXX it's not clear whether central ID providers are supposed to obey this
return $this->getName() === $user->getName();
}
}
// TODO: we may want to (optionally?) provide a global ID, see CentralIdLookup.
+ /**
+ * @since 1.32
+ *
+ * @param UserIdentity $user
+ * @return bool
+ */
+ public function equals( UserIdentity $user );
+
}
return $this->actor;
}
+ /**
+ * @since 1.32
+ *
+ * @param UserIdentity $user
+ * @return bool
+ */
+ public function equals( UserIdentity $user ) {
+ // XXX it's not clear whether central ID providers are supposed to obey this
+ return $this->getName() === $user->getName();
+ }
+
}
'en-gb' => 'British English', # British English
'eo' => 'Esperanto', # Esperanto
'es' => 'español', # Spanish
- 'es-formal' => 'español (formal)', # Spanish formal address
+ 'es-formal' => "español (formal)\xE2\x80\x8E", # Spanish formal address
'et' => 'eesti', # Estonian
'eu' => 'euskara', # Basque
'ext' => 'estremeñu', # Extremaduran
'hsb' => 'hornjoserbsce', # Upper Sorbian
'ht' => 'Kreyòl ayisyen', # Haitian Creole French
'hu' => 'magyar', # Hungarian
- 'hu-formal' => 'magyar (formal)', # Hungarian formal address
+ 'hu-formal' => "magyar (formal)\xE2\x80\x8E", # Hungarian formal address
'hy' => 'Հայերեն', # Armenian
'hz' => 'Otsiherero', # Herero
'ia' => 'interlingua', # Interlingua (IALA)
'km' => 'ភាសាខ្មែរ', # Khmer, Central
'kn' => 'ಕನ್ನಡ', # Kannada
'ko' => '한국어', # Korean
- 'ko-kp' => '한국어 (조선)', # Korean (DPRK)
+ 'ko-kp' => '조선말', # Korean (DPRK), T190324
'koi' => 'Перем Коми', # Komi-Permyak
'kr' => 'Kanuri', # Kanuri, Central
'krc' => 'къарачай-малкъар', # Karachay-Balkar
<?php
-/** Korean (한국어(조선))
+/** Korean (Democratic People's Republic of Korea) (조선말)
*
* To improve a translation please visit https://translatewiki.net
*
"karma-firefox-launcher": "1.0.1",
"karma-mocha-reporter": "2.2.5",
"karma-qunit": "2.0.1",
- "mwbot": "1.0.10",
"postcss-less": "1.1.5",
"qunit": "2.5.0",
"stylelint": "9.2.0",
"stylelint-config-wikimedia": "0.4.3",
"wdio-junit-reporter": "0.2.0",
+ "wdio-mediawiki": "file:tests/selenium/wdio-mediawiki",
"wdio-mocha-framework": "0.5.8",
"wdio-sauce-service": "0.3.1",
"wdio-spec-reporter": "0.0.5",
'resources/src/mediawiki.skinning/interface.css' => [ 'media' => 'screen' ],
],
],
-
- 'jquery.tablesorter.styles' => [
- 'targets' => [ 'desktop', 'mobile' ],
- 'styles' => [
- 'resources/src/jquery/jquery.tablesorter.styles.less',
- ],
- ],
'jquery.makeCollapsible.styles' => [
'targets' => [ 'desktop', 'mobile' ],
'class' => ResourceLoaderLessVarFileModule::class,
'scripts' => 'resources/src/jquery/jquery.tabIndex.js',
],
'jquery.tablesorter' => [
- 'scripts' => 'resources/src/jquery/jquery.tablesorter.js',
- 'styles' => 'resources/src/jquery/jquery.tablesorter.less',
+ 'scripts' => 'resources/src/jquery.tablesorter/jquery.tablesorter.js',
+ 'styles' => 'resources/src/jquery.tablesorter/jquery.tablesorter.less',
'messages' => [ 'sort-descending', 'sort-ascending' ],
'dependencies' => [
'jquery.tablesorter.styles',
'mediawiki.language.months',
],
],
+ 'jquery.tablesorter.styles' => [
+ 'targets' => [ 'desktop', 'mobile' ],
+ 'styles' => [
+ 'resources/src/jquery/jquery.tablesorter.styles.less',
+ ],
+ ],
'jquery.textSelection' => [
'scripts' => 'resources/src/jquery/jquery.textSelection.js',
'dependencies' => 'jquery.client',
],
'mediawiki.messagePoster' => [
'scripts' => [
- 'resources/src/mediawiki.messagePoster/mediawiki.messagePoster.factory.js',
- 'resources/src/mediawiki.messagePoster/mediawiki.messagePoster.MessagePoster.js',
+ 'resources/src/mediawiki.messagePoster/factory.js',
+ 'resources/src/mediawiki.messagePoster/MessagePoster.js',
],
'dependencies' => [
'oojs',
],
'mediawiki.messagePoster.wikitext' => [
'scripts' => [
- 'resources/src/mediawiki.messagePoster/mediawiki.messagePoster.WikitextMessagePoster.js',
+ 'resources/src/mediawiki.messagePoster.wikitext/WikitextMessagePoster.js',
],
'dependencies' => [
'mediawiki.api.edit',
--- /dev/null
+<?xml version="1.0" encoding="UTF-8"?>
+<svg xmlns="http://www.w3.org/2000/svg" width="21" height="9" viewBox="0 0 21 9">
+ <path d="M14.5 5l-4 4-4-4zM14.5 4l-4-4-4 4z"/>
+</svg>
--- /dev/null
+<?xml version="1.0" encoding="UTF-8"?>
+<svg xmlns="http://www.w3.org/2000/svg" width="21" height="4" viewBox="0 0 21 4">
+ <path d="M14.5 0l-4 4-4-4z"/>
+</svg>
--- /dev/null
+<?xml version="1.0" encoding="UTF-8"?>
+<svg xmlns="http://www.w3.org/2000/svg" width="21" height="4" viewBox="0 0 21 4">
+ <path d="M6.5 4l4-4 4 4z"/>
+</svg>
--- /dev/null
+/*!
+ * TableSorter for MediaWiki
+ *
+ * Written 2011 Leo Koppelkamm
+ * Based on tablesorter.com plugin, written (c) 2007 Christian Bach.
+ *
+ * Dual licensed under the MIT and GPL licenses:
+ * http://www.opensource.org/licenses/mit-license.php
+ * http://www.gnu.org/licenses/gpl.html
+ *
+ * Depends on mw.config (wgDigitTransformTable, wgDefaultDateFormat, wgPageContentLanguage)
+ * and mw.language.months.
+ *
+ * Uses 'tableSorterCollation' in mw.config (if available)
+ *
+ * Create a sortable table with multi-column sorting capabilities
+ *
+ * // Create a simple tablesorter interface
+ * $( 'table' ).tablesorter();
+ *
+ * // Create a tablesorter interface, initially sorting on the first and second column
+ * $( 'table' ).tablesorter( { sortList: [ { 0: 'desc' }, { 1: 'asc' } ] } );
+ *
+ * @param {string} [cssHeader="header"] A string of the class name to be appended to sortable
+ * tr elements in the thead of the table.
+ *
+ * @param {string} [cssAsc="headerSortUp"] A string of the class name to be appended to
+ * sortable tr elements in the thead on a ascending sort.
+ *
+ * @param {string} [cssDesc="headerSortDown"] A string of the class name to be appended to
+ * sortable tr elements in the thead on a descending sort.
+ *
+ * @param {string} [sortMultisortKey="shiftKey"] A string of the multi-column sort key.
+ *
+ * @param {boolean} [cancelSelection=true] Boolean flag indicating iftablesorter should cancel
+ * selection of the table headers text.
+ *
+ * @param {Array} [sortList] An array containing objects specifying sorting. By passing more
+ * than one object, multi-sorting will be applied. Object structure:
+ * { <Integer column index>: <String 'asc' or 'desc'> }
+ *
+ * @event sortEnd.tablesorter: Triggered as soon as any sorting has been applied.
+ *
+ * @author Christian Bach/christian.bach@polyester.se
+ */
+( function ( $, mw ) {
+ var ts,
+ parsers = [];
+
+ /* Parser utility functions */
+
+ function getParserById( name ) {
+ var i;
+ for ( i = 0; i < parsers.length; i++ ) {
+ if ( parsers[ i ].id.toLowerCase() === name.toLowerCase() ) {
+ return parsers[ i ];
+ }
+ }
+ return false;
+ }
+
+ function getElementSortKey( node ) {
+ var $node = $( node ),
+ // Use data-sort-value attribute.
+ // Use data() instead of attr() so that live value changes
+ // are processed as well (T40152).
+ data = $node.data( 'sortValue' );
+
+ if ( data !== null && data !== undefined ) {
+ // Cast any numbers or other stuff to a string, methods
+ // like charAt, toLowerCase and split are expected.
+ return String( data );
+ }
+ if ( !node ) {
+ return $node.text();
+ }
+ if ( node.tagName.toLowerCase() === 'img' ) {
+ return $node.attr( 'alt' ) || ''; // handle undefined alt
+ }
+ return $.makeArray( node.childNodes ).map( function ( elem ) {
+ if ( elem.nodeType === Node.ELEMENT_NODE ) {
+ if ( $( elem ).hasClass( 'reference' ) ) {
+ return null;
+ } else {
+ return getElementSortKey( elem );
+ }
+ }
+ return $.text( elem );
+ } ).join( '' );
+ }
+
+ function detectParserForColumn( table, rows, column ) {
+ var l = parsers.length,
+ config = $( table ).data( 'tablesorter' ).config,
+ cellIndex,
+ nodeValue,
+ nextRow = false,
+ // Start with 1 because 0 is the fallback parser
+ i = 1,
+ lastRowIndex = -1,
+ rowIndex = 0,
+ concurrent = 0,
+ empty = 0,
+ needed = ( rows.length > 4 ) ? 5 : rows.length;
+
+ while ( i < l ) {
+ // if this is a child row, continue to the next row (as buildCache())
+ if ( rows[ rowIndex ] && !$( rows[ rowIndex ] ).hasClass( config.cssChildRow ) ) {
+ if ( rowIndex !== lastRowIndex ) {
+ lastRowIndex = rowIndex;
+ cellIndex = $( rows[ rowIndex ] ).data( 'columnToCell' )[ column ];
+ nodeValue = getElementSortKey( rows[ rowIndex ].cells[ cellIndex ] ).trim();
+ }
+ } else {
+ nodeValue = '';
+ }
+
+ if ( nodeValue !== '' ) {
+ if ( parsers[ i ].is( nodeValue, table ) ) {
+ concurrent++;
+ nextRow = true;
+ if ( concurrent >= needed ) {
+ // Confirmed the parser for multiple cells, let's return it
+ return parsers[ i ];
+ }
+ } else if ( parsers[ i ].id.match( /isoDate/ ) && /^\D*(\d{1,4}) ?(\[.+\])?$/.test( nodeValue ) ) {
+ // For 1-4 digits and maybe reference(s) parser "isoDate" or "number" is possible, check next row
+ empty++;
+ nextRow = true;
+ } else {
+ // Check next parser, reset rows
+ i++;
+ rowIndex = 0;
+ concurrent = 0;
+ empty = 0;
+ nextRow = false;
+ }
+ } else {
+ // Empty cell
+ empty++;
+ nextRow = true;
+ }
+
+ if ( nextRow ) {
+ nextRow = false;
+ rowIndex++;
+ if ( rowIndex >= rows.length ) {
+ if ( concurrent > 0 && concurrent >= rows.length - empty ) {
+ // Confirmed the parser for all filled cells
+ return parsers[ i ];
+ }
+ // Check next parser, reset rows
+ i++;
+ rowIndex = 0;
+ concurrent = 0;
+ empty = 0;
+ }
+ }
+ }
+
+ // 0 is always the generic parser (text)
+ return parsers[ 0 ];
+ }
+
+ function buildParserCache( table, $headers ) {
+ var sortType, len, j, parser,
+ rows = table.tBodies[ 0 ].rows,
+ config = $( table ).data( 'tablesorter' ).config,
+ parsers = [];
+
+ if ( rows[ 0 ] ) {
+ len = config.columns;
+ for ( j = 0; j < len; j++ ) {
+ parser = false;
+ sortType = $headers.eq( config.columnToHeader[ j ] ).data( 'sortType' );
+ if ( sortType !== undefined ) {
+ parser = getParserById( sortType );
+ }
+
+ if ( parser === false ) {
+ parser = detectParserForColumn( table, rows, j );
+ }
+
+ parsers.push( parser );
+ }
+ }
+ return parsers;
+ }
+
+ /* Other utility functions */
+
+ function buildCache( table ) {
+ var i, j, $row, cols,
+ totalRows = ( table.tBodies[ 0 ] && table.tBodies[ 0 ].rows.length ) || 0,
+ config = $( table ).data( 'tablesorter' ).config,
+ parsers = config.parsers,
+ len = parsers.length,
+ cellIndex,
+ cache = {
+ row: [],
+ normalized: []
+ };
+
+ for ( i = 0; i < totalRows; i++ ) {
+
+ // Add the table data to main data array
+ $row = $( table.tBodies[ 0 ].rows[ i ] );
+ cols = [];
+
+ // if this is a child row, add it to the last row's children and
+ // continue to the next row
+ if ( $row.hasClass( config.cssChildRow ) ) {
+ cache.row[ cache.row.length - 1 ] = cache.row[ cache.row.length - 1 ].add( $row );
+ // go to the next for loop
+ continue;
+ }
+
+ cache.row.push( $row );
+
+ for ( j = 0; j < len; j++ ) {
+ cellIndex = $row.data( 'columnToCell' )[ j ];
+ cols.push( parsers[ j ].format( getElementSortKey( $row[ 0 ].cells[ cellIndex ] ) ) );
+ }
+
+ cols.push( cache.normalized.length ); // add position for rowCache
+ cache.normalized.push( cols );
+ cols = null;
+ }
+
+ return cache;
+ }
+
+ function appendToTable( table, cache ) {
+ var i, pos, l, j,
+ row = cache.row,
+ normalized = cache.normalized,
+ totalRows = normalized.length,
+ checkCell = ( normalized[ 0 ].length - 1 ),
+ fragment = document.createDocumentFragment();
+
+ for ( i = 0; i < totalRows; i++ ) {
+ pos = normalized[ i ][ checkCell ];
+
+ l = row[ pos ].length;
+ for ( j = 0; j < l; j++ ) {
+ fragment.appendChild( row[ pos ][ j ] );
+ }
+
+ }
+ table.tBodies[ 0 ].appendChild( fragment );
+
+ $( table ).trigger( 'sortEnd.tablesorter' );
+ }
+
+ /**
+ * Find all header rows in a thead-less table and put them in a <thead> tag.
+ * This only treats a row as a header row if it contains only <th>s (no <td>s)
+ * and if it is preceded entirely by header rows. The algorithm stops when
+ * it encounters the first non-header row.
+ *
+ * After this, it will look at all rows at the bottom for footer rows
+ * And place these in a tfoot using similar rules.
+ *
+ * @param {jQuery} $table object for a <table>
+ */
+ function emulateTHeadAndFoot( $table ) {
+ var $thead, $tfoot, i, len,
+ $rows = $table.find( '> tbody > tr' );
+ if ( !$table.get( 0 ).tHead ) {
+ $thead = $( '<thead>' );
+ $rows.each( function () {
+ if ( $( this ).children( 'td' ).length ) {
+ // This row contains a <td>, so it's not a header row
+ // Stop here
+ return false;
+ }
+ $thead.append( this );
+ } );
+ $table.find( ' > tbody:first' ).before( $thead );
+ }
+ if ( !$table.get( 0 ).tFoot ) {
+ $tfoot = $( '<tfoot>' );
+ len = $rows.length;
+ for ( i = len - 1; i >= 0; i-- ) {
+ if ( $( $rows[ i ] ).children( 'td' ).length ) {
+ break;
+ }
+ $tfoot.prepend( $( $rows[ i ] ) );
+ }
+ $table.append( $tfoot );
+ }
+ }
+
+ function uniqueElements( array ) {
+ var uniques = [];
+ array.forEach( function ( elem ) {
+ if ( elem !== undefined && uniques.indexOf( elem ) === -1 ) {
+ uniques.push( elem );
+ }
+ } );
+ return uniques;
+ }
+
+ function buildHeaders( table, msg ) {
+ var config = $( table ).data( 'tablesorter' ).config,
+ maxSeen = 0,
+ colspanOffset = 0,
+ columns,
+ k,
+ $cell,
+ rowspan,
+ colspan,
+ headerCount,
+ longestTR,
+ headerIndex,
+ exploded,
+ $tableHeaders = $( [] ),
+ $tableRows = $( 'thead:eq(0) > tr', table );
+
+ if ( $tableRows.length <= 1 ) {
+ $tableHeaders = $tableRows.children( 'th' );
+ } else {
+ exploded = [];
+
+ // Loop through all the dom cells of the thead
+ $tableRows.each( function ( rowIndex, row ) {
+ $.each( row.cells, function ( columnIndex, cell ) {
+ var matrixRowIndex,
+ matrixColumnIndex;
+
+ rowspan = Number( cell.rowSpan );
+ colspan = Number( cell.colSpan );
+
+ // Skip the spots in the exploded matrix that are already filled
+ while ( exploded[ rowIndex ] && exploded[ rowIndex ][ columnIndex ] !== undefined ) {
+ ++columnIndex;
+ }
+
+ // Find the actual dimensions of the thead, by placing each cell
+ // in the exploded matrix rowspan times colspan times, with the proper offsets
+ for ( matrixColumnIndex = columnIndex; matrixColumnIndex < columnIndex + colspan; ++matrixColumnIndex ) {
+ for ( matrixRowIndex = rowIndex; matrixRowIndex < rowIndex + rowspan; ++matrixRowIndex ) {
+ if ( !exploded[ matrixRowIndex ] ) {
+ exploded[ matrixRowIndex ] = [];
+ }
+ exploded[ matrixRowIndex ][ matrixColumnIndex ] = cell;
+ }
+ }
+ } );
+ } );
+ // We want to find the row that has the most columns (ignoring colspan)
+ exploded.forEach( function ( cellArray, index ) {
+ headerCount = $( uniqueElements( cellArray ) ).filter( 'th' ).length;
+ if ( headerCount >= maxSeen ) {
+ maxSeen = headerCount;
+ longestTR = index;
+ }
+ } );
+ // We cannot use $.unique() here because it sorts into dom order, which is undesirable
+ $tableHeaders = $( uniqueElements( exploded[ longestTR ] ) ).filter( 'th' );
+ }
+
+ // as each header can span over multiple columns (using colspan=N),
+ // we have to bidirectionally map headers to their columns and columns to their headers
+ config.columnToHeader = [];
+ config.headerToColumns = [];
+ config.headerList = [];
+ headerIndex = 0;
+ $tableHeaders.each( function () {
+ $cell = $( this );
+ columns = [];
+
+ if ( !$cell.hasClass( config.unsortableClass ) ) {
+ $cell
+ .addClass( config.cssHeader )
+ .prop( 'tabIndex', 0 )
+ .attr( {
+ role: 'columnheader button',
+ title: msg[ 1 ]
+ } );
+
+ for ( k = 0; k < this.colSpan; k++ ) {
+ config.columnToHeader[ colspanOffset + k ] = headerIndex;
+ columns.push( colspanOffset + k );
+ }
+
+ config.headerToColumns[ headerIndex ] = columns;
+
+ $cell.data( {
+ headerIndex: headerIndex,
+ order: 0,
+ count: 0
+ } );
+
+ // add only sortable cells to headerList
+ config.headerList[ headerIndex ] = this;
+ headerIndex++;
+ }
+
+ colspanOffset += this.colSpan;
+ } );
+
+ // number of columns with extended colspan, inclusive unsortable
+ // parsers[j], cache[][j], columnToHeader[j], columnToCell[j] have so many elements
+ config.columns = colspanOffset;
+
+ return $tableHeaders.not( '.' + config.unsortableClass );
+ }
+
+ function isValueInArray( v, a ) {
+ var i;
+ for ( i = 0; i < a.length; i++ ) {
+ if ( a[ i ][ 0 ] === v ) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Sets the sort count of the columns that are not affected by the sorting to have them sorted
+ * in default (ascending) order when their header cell is clicked the next time.
+ *
+ * @param {jQuery} $headers
+ * @param {Array} sortList 2D number array
+ * @param {Array} headerToColumns 2D number array
+ */
+ function setHeadersOrder( $headers, sortList, headerToColumns ) {
+ // Loop through all headers to retrieve the indices of the columns the header spans across:
+ headerToColumns.forEach( function ( columns, headerIndex ) {
+
+ columns.forEach( function ( columnIndex, i ) {
+ var header = $headers[ headerIndex ],
+ $header = $( header );
+
+ if ( !isValueInArray( columnIndex, sortList ) ) {
+ // Column shall not be sorted: Reset header count and order.
+ $header.data( {
+ order: 0,
+ count: 0
+ } );
+ } else {
+ // Column shall be sorted: Apply designated count and order.
+ sortList.forEach( function ( sortColumn ) {
+ if ( sortColumn[ 0 ] === i ) {
+ $header.data( {
+ order: sortColumn[ 1 ],
+ count: sortColumn[ 1 ] + 1
+ } );
+ return false;
+ }
+ } );
+ }
+ } );
+
+ } );
+ }
+
+ function setHeadersCss( table, $headers, list, css, msg, columnToHeader ) {
+ var i, len;
+ // Remove all header information and reset titles to default message
+ $headers.removeClass( css[ 0 ] ).removeClass( css[ 1 ] ).attr( 'title', msg[ 1 ] );
+
+ for ( i = 0, len = list.length; i < len; i++ ) {
+ $headers
+ .eq( columnToHeader[ list[ i ][ 0 ] ] )
+ .addClass( css[ list[ i ][ 1 ] ] )
+ .attr( 'title', msg[ list[ i ][ 1 ] ] );
+ }
+ }
+
+ function sortText( a, b ) {
+ return ( ( a < b ) ? -1 : ( ( a > b ) ? 1 : 0 ) );
+ }
+
+ function sortTextDesc( a, b ) {
+ return ( ( b < a ) ? -1 : ( ( b > a ) ? 1 : 0 ) );
+ }
+
+ function multisort( table, sortList, cache ) {
+ var i,
+ sortFn = [];
+
+ for ( i = 0; i < sortList.length; i++ ) {
+ sortFn[ i ] = ( sortList[ i ][ 1 ] ) ? sortTextDesc : sortText;
+ }
+ cache.normalized.sort( function ( array1, array2 ) {
+ var i, col, ret;
+ for ( i = 0; i < sortList.length; i++ ) {
+ col = sortList[ i ][ 0 ];
+ ret = sortFn[ i ].call( this, array1[ col ], array2[ col ] );
+ if ( ret !== 0 ) {
+ return ret;
+ }
+ }
+ // Fall back to index number column to ensure stable sort
+ return sortText.call( this, array1[ array1.length - 1 ], array2[ array2.length - 1 ] );
+ } );
+ return cache;
+ }
+
+ function buildTransformTable() {
+ var ascii, localised, i, digitClass,
+ digits = '0123456789,.'.split( '' ),
+ separatorTransformTable = mw.config.get( 'wgSeparatorTransformTable' ),
+ digitTransformTable = mw.config.get( 'wgDigitTransformTable' );
+
+ if ( separatorTransformTable === null || ( separatorTransformTable[ 0 ] === '' && digitTransformTable[ 2 ] === '' ) ) {
+ ts.transformTable = false;
+ } else {
+ ts.transformTable = {};
+
+ // Unpack the transform table
+ ascii = separatorTransformTable[ 0 ].split( '\t' ).concat( digitTransformTable[ 0 ].split( '\t' ) );
+ localised = separatorTransformTable[ 1 ].split( '\t' ).concat( digitTransformTable[ 1 ].split( '\t' ) );
+
+ // Construct regexes for number identification
+ for ( i = 0; i < ascii.length; i++ ) {
+ ts.transformTable[ localised[ i ] ] = ascii[ i ];
+ digits.push( mw.RegExp.escape( localised[ i ] ) );
+ }
+ }
+ digitClass = '[' + digits.join( '', digits ) + ']';
+
+ // We allow a trailing percent sign, which we just strip. This works fine
+ // if percents and regular numbers aren't being mixed.
+ ts.numberRegex = new RegExp(
+ '^(' +
+ '[-+\u2212]?[0-9][0-9,]*(\\.[0-9,]*)?(E[-+\u2212]?[0-9][0-9,]*)?' + // Fortran-style scientific
+ '|' +
+ '[-+\u2212]?' + digitClass + '+[\\s\\xa0]*%?' + // Generic localised
+ ')$',
+ 'i'
+ );
+ }
+
+ function buildDateTable() {
+ var i, name,
+ regex = [];
+
+ ts.monthNames = {};
+
+ for ( i = 0; i < 12; i++ ) {
+ name = mw.language.months.names[ i ].toLowerCase();
+ ts.monthNames[ name ] = i + 1;
+ regex.push( mw.RegExp.escape( name ) );
+ name = mw.language.months.genitive[ i ].toLowerCase();
+ ts.monthNames[ name ] = i + 1;
+ regex.push( mw.RegExp.escape( name ) );
+ name = mw.language.months.abbrev[ i ].toLowerCase().replace( '.', '' );
+ ts.monthNames[ name ] = i + 1;
+ regex.push( mw.RegExp.escape( name ) );
+ }
+
+ // Build piped string
+ regex = regex.join( '|' );
+
+ // Build RegEx
+ // Any date formated with . , ' - or /
+ ts.dateRegex[ 0 ] = new RegExp( /^\s*(\d{1,2})[,.\-/'\s]{1,2}(\d{1,2})[,.\-/'\s]{1,2}(\d{2,4})\s*?/i );
+
+ // Written Month name, dmy
+ ts.dateRegex[ 1 ] = new RegExp(
+ '^\\s*(\\d{1,2})[\\,\\.\\-\\/\'\\s]+(' +
+ regex +
+ ')' +
+ '[\\,\\.\\-\\/\'\\s]+(\\d{2,4})\\s*$',
+ 'i'
+ );
+
+ // Written Month name, mdy
+ ts.dateRegex[ 2 ] = new RegExp(
+ '^\\s*(' + regex + ')' +
+ '[\\,\\.\\-\\/\'\\s]+(\\d{1,2})[\\,\\.\\-\\/\'\\s]+(\\d{2,4})\\s*$',
+ 'i'
+ );
+
+ }
+
+ /**
+ * Replace all rowspanned cells in the body with clones in each row, so sorting
+ * need not worry about them.
+ *
+ * @param {jQuery} $table jQuery object for a <table>
+ */
+ function explodeRowspans( $table ) {
+ var spanningRealCellIndex, rowSpan, colSpan,
+ cell, cellData, i, $tds, $clone, $nextRows,
+ rowspanCells = $table.find( '> tbody > tr > [rowspan]' ).get();
+
+ // Short circuit
+ if ( !rowspanCells.length ) {
+ return;
+ }
+
+ // First, we need to make a property like cellIndex but taking into
+ // account colspans. We also cache the rowIndex to avoid having to take
+ // cell.parentNode.rowIndex in the sorting function below.
+ $table.find( '> tbody > tr' ).each( function () {
+ var i,
+ col = 0,
+ len = this.cells.length;
+ for ( i = 0; i < len; i++ ) {
+ $( this.cells[ i ] ).data( 'tablesorter', {
+ realCellIndex: col,
+ realRowIndex: this.rowIndex
+ } );
+ col += this.cells[ i ].colSpan;
+ }
+ } );
+
+ // Split multi row cells into multiple cells with the same content.
+ // Sort by column then row index to avoid problems with odd table structures.
+ // Re-sort whenever a rowspanned cell's realCellIndex is changed, because it
+ // might change the sort order.
+ function resortCells() {
+ var cellAData,
+ cellBData,
+ ret;
+ rowspanCells = rowspanCells.sort( function ( a, b ) {
+ cellAData = $.data( a, 'tablesorter' );
+ cellBData = $.data( b, 'tablesorter' );
+ ret = cellAData.realCellIndex - cellBData.realCellIndex;
+ if ( !ret ) {
+ ret = cellAData.realRowIndex - cellBData.realRowIndex;
+ }
+ return ret;
+ } );
+ rowspanCells.forEach( function ( cell ) {
+ $.data( cell, 'tablesorter' ).needResort = false;
+ } );
+ }
+ resortCells();
+
+ function filterfunc() {
+ return $.data( this, 'tablesorter' ).realCellIndex >= spanningRealCellIndex;
+ }
+
+ function fixTdCellIndex() {
+ $.data( this, 'tablesorter' ).realCellIndex += colSpan;
+ if ( this.rowSpan > 1 ) {
+ $.data( this, 'tablesorter' ).needResort = true;
+ }
+ }
+
+ while ( rowspanCells.length ) {
+ if ( $.data( rowspanCells[ 0 ], 'tablesorter' ).needResort ) {
+ resortCells();
+ }
+
+ cell = rowspanCells.shift();
+ cellData = $.data( cell, 'tablesorter' );
+ rowSpan = cell.rowSpan;
+ colSpan = cell.colSpan;
+ spanningRealCellIndex = cellData.realCellIndex;
+ cell.rowSpan = 1;
+ $nextRows = $( cell ).parent().nextAll();
+ for ( i = 0; i < rowSpan - 1; i++ ) {
+ $tds = $( $nextRows[ i ].cells ).filter( filterfunc );
+ $clone = $( cell ).clone();
+ $clone.data( 'tablesorter', {
+ realCellIndex: spanningRealCellIndex,
+ realRowIndex: cellData.realRowIndex + i,
+ needResort: true
+ } );
+ if ( $tds.length ) {
+ $tds.each( fixTdCellIndex );
+ $tds.first().before( $clone );
+ } else {
+ $nextRows.eq( i ).append( $clone );
+ }
+ }
+ }
+ }
+
+ /**
+ * Build index to handle colspanned cells in the body.
+ * Set the cell index for each column in an array,
+ * so that colspaned cells set multiple in this array.
+ * columnToCell[collumnIndex] point at the real cell in this row.
+ *
+ * @param {jQuery} $table object for a <table>
+ */
+ function manageColspans( $table ) {
+ var i, j, k, $row,
+ $rows = $table.find( '> tbody > tr' ),
+ totalRows = $rows.length || 0,
+ config = $table.data( 'tablesorter' ).config,
+ columns = config.columns,
+ columnToCell, cellsInRow, index;
+
+ for ( i = 0; i < totalRows; i++ ) {
+
+ $row = $rows.eq( i );
+ // if this is a child row, continue to the next row (as buildCache())
+ if ( $row.hasClass( config.cssChildRow ) ) {
+ // go to the next for loop
+ continue;
+ }
+
+ columnToCell = [];
+ cellsInRow = ( $row[ 0 ].cells.length ) || 0; // all cells in this row
+ index = 0; // real cell index in this row
+ for ( j = 0; j < columns; index++ ) {
+ if ( index === cellsInRow ) {
+ // Row with cells less than columns: add empty cell
+ $row.append( '<td>' );
+ cellsInRow++;
+ }
+ for ( k = 0; k < $row[ 0 ].cells[ index ].colSpan; k++ ) {
+ columnToCell[ j++ ] = index;
+ }
+ }
+ // Store it in $row
+ $row.data( 'columnToCell', columnToCell );
+ }
+ }
+
+ function buildCollationTable() {
+ var key, keys = [];
+ ts.collationTable = mw.config.get( 'tableSorterCollation' );
+ ts.collationRegex = null;
+ if ( ts.collationTable ) {
+ // Build array of key names
+ for ( key in ts.collationTable ) {
+ // Check hasOwn to be safe
+ if ( ts.collationTable.hasOwnProperty( key ) ) {
+ keys.push( mw.RegExp.escape( key ) );
+ }
+ }
+ if ( keys.length ) {
+ ts.collationRegex = new RegExp( keys.join( '|' ), 'ig' );
+ }
+ }
+ }
+
+ function cacheRegexs() {
+ if ( ts.rgx ) {
+ return;
+ }
+ ts.rgx = {
+ IPAddress: [
+ new RegExp( /^\d{1,3}[.]\d{1,3}[.]\d{1,3}[.]\d{1,3}$/ )
+ ],
+ currency: [
+ new RegExp( /(^[£$€¥]|[£$€¥]$)/ ),
+ new RegExp( /[£$€¥]/g )
+ ],
+ url: [
+ new RegExp( /^(https?|ftp|file):\/\/$/ ),
+ new RegExp( /(https?|ftp|file):\/\// )
+ ],
+ isoDate: [
+ new RegExp( /^[^-\d]*(-?\d{1,4})-(0\d|1[0-2])(-([0-3]\d))?([T\s]([01]\d|2[0-4]):?(([0-5]\d):?(([0-5]\d|60)([.,]\d{1,3})?)?)?([zZ]|([-+])([01]\d|2[0-3]):?([0-5]\d)?)?)?/ ),
+ new RegExp( /^[^-\d]*(-?\d{1,4})-?(\d\d)?(-?(\d\d))?([T\s](\d\d):?((\d\d)?:?((\d\d)?([.,]\d{1,3})?)?)?([zZ]|([-+])(\d\d):?(\d\d)?)?)?/ )
+ ],
+ usLongDate: [
+ new RegExp( /^[A-Za-z]{3,10}\.? [0-9]{1,2}, ([0-9]{4}|'?[0-9]{2}) (([0-2]?[0-9]:[0-5][0-9])|([0-1]?[0-9]:[0-5][0-9]\s(AM|PM)))$/ )
+ ],
+ time: [
+ new RegExp( /^(([0-2]?[0-9]:[0-5][0-9])|([0-1]?[0-9]:[0-5][0-9]\s(am|pm)))$/ )
+ ]
+ };
+ }
+
+ /**
+ * Converts sort objects [ { Integer: String }, ... ] to the internally used nested array
+ * structure [ [ Integer, Integer ], ... ]
+ *
+ * @param {Array} sortObjects List of sort objects.
+ * @return {Array} List of internal sort definitions.
+ */
+ function convertSortList( sortObjects ) {
+ var sortList = [];
+ sortObjects.forEach( function ( sortObject ) {
+ $.each( sortObject, function ( columnIndex, order ) {
+ var orderIndex = ( order === 'desc' ) ? 1 : 0;
+ sortList.push( [ parseInt( columnIndex, 10 ), orderIndex ] );
+ } );
+ } );
+ return sortList;
+ }
+
+ /* Public scope */
+
+ $.tablesorter = {
+ defaultOptions: {
+ cssHeader: 'headerSort',
+ cssAsc: 'headerSortUp',
+ cssDesc: 'headerSortDown',
+ cssChildRow: 'expand-child',
+ sortMultiSortKey: 'shiftKey',
+ unsortableClass: 'unsortable',
+ parsers: [],
+ cancelSelection: true,
+ sortList: [],
+ headerList: [],
+ headerToColumns: [],
+ columnToHeader: [],
+ columns: 0
+ },
+
+ dateRegex: [],
+ monthNames: {},
+
+ /**
+ * @param {jQuery} $tables
+ * @param {Object} [settings]
+ * @return {jQuery}
+ */
+ construct: function ( $tables, settings ) {
+ return $tables.each( function ( i, table ) {
+ // Declare and cache.
+ var $headers, cache, config, sortCSS, sortMsg,
+ $table = $( table ),
+ firstTime = true;
+
+ // Quit if no tbody
+ if ( !table.tBodies ) {
+ return;
+ }
+ if ( !table.tHead ) {
+ // No thead found. Look for rows with <th>s and
+ // move them into a <thead> tag or a <tfoot> tag
+ emulateTHeadAndFoot( $table );
+
+ // Still no thead? Then quit
+ if ( !table.tHead ) {
+ return;
+ }
+ }
+ // The `sortable` class is used to identify tables which will become sortable
+ // If not used it will create a FOUC but it should be added since the sortable class
+ // is responsible for certain crucial style elements. If the class is already present
+ // this action will be harmless.
+ $table.addClass( 'jquery-tablesorter sortable' );
+
+ // Merge and extend
+ config = $.extend( {}, $.tablesorter.defaultOptions, settings );
+
+ // Save the settings where they read
+ $.data( table, 'tablesorter', { config: config } );
+
+ // Get the CSS class names, could be done elsewhere
+ sortCSS = [ config.cssAsc, config.cssDesc ];
+ // Messages tell the the user what the *next* state will be
+ // so are in reverse order to the CSS classes.
+ sortMsg = [ mw.msg( 'sort-descending' ), mw.msg( 'sort-ascending' ) ];
+
+ // Build headers
+ $headers = buildHeaders( table, sortMsg );
+
+ // Grab and process locale settings.
+ buildTransformTable();
+ buildDateTable();
+
+ // Precaching regexps can bring 10 fold
+ // performance improvements in some browsers.
+ cacheRegexs();
+
+ function setupForFirstSort() {
+ var $tfoot, $sortbottoms;
+
+ firstTime = false;
+
+ // Defer buildCollationTable to first sort. As user and site scripts
+ // may customize tableSorterCollation but load after $.ready(), other
+ // scripts may call .tablesorter() before they have done the
+ // tableSorterCollation customizations.
+ buildCollationTable();
+
+ // Legacy fix of .sortbottoms
+ // Wrap them inside a tfoot (because that's what they actually want to be)
+ // and put the <tfoot> at the end of the <table>
+ $sortbottoms = $table.find( '> tbody > tr.sortbottom' );
+ if ( $sortbottoms.length ) {
+ $tfoot = $table.children( 'tfoot' );
+ if ( $tfoot.length ) {
+ $tfoot.eq( 0 ).prepend( $sortbottoms );
+ } else {
+ $table.append( $( '<tfoot>' ).append( $sortbottoms ) );
+ }
+ }
+
+ explodeRowspans( $table );
+ manageColspans( $table );
+
+ // Try to auto detect column type, and store in tables config
+ config.parsers = buildParserCache( table, $headers );
+ }
+
+ // Apply event handling to headers
+ // this is too big, perhaps break it out?
+ $headers.on( 'keypress click', function ( e ) {
+ var cell, $cell, columns, newSortList, i,
+ totalRows,
+ j, s, o;
+
+ if ( e.type === 'click' && e.target.nodeName.toLowerCase() === 'a' ) {
+ // The user clicked on a link inside a table header.
+ // Do nothing and let the default link click action continue.
+ return true;
+ }
+
+ if ( e.type === 'keypress' && e.which !== 13 ) {
+ // Only handle keypresses on the "Enter" key.
+ return true;
+ }
+
+ if ( firstTime ) {
+ setupForFirstSort();
+ }
+
+ // Build the cache for the tbody cells
+ // to share between calculations for this sort action.
+ // Re-calculated each time a sort action is performed due to possiblity
+ // that sort values change. Shouldn't be too expensive, but if it becomes
+ // too slow an event based system should be implemented somehow where
+ // cells get event .change() and bubbles up to the <table> here
+ cache = buildCache( table );
+
+ totalRows = ( $table[ 0 ].tBodies[ 0 ] && $table[ 0 ].tBodies[ 0 ].rows.length ) || 0;
+ if ( totalRows > 0 ) {
+ cell = this;
+ $cell = $( cell );
+
+ // Get current column sort order
+ $cell.data( {
+ order: $cell.data( 'count' ) % 2,
+ count: $cell.data( 'count' ) + 1
+ } );
+
+ cell = this;
+ // Get current column index
+ columns = config.headerToColumns[ $cell.data( 'headerIndex' ) ];
+ newSortList = columns.map( function ( c ) {
+ return [ c, $cell.data( 'order' ) ];
+ } );
+ // Index of first column belonging to this header
+ i = columns[ 0 ];
+
+ if ( !e[ config.sortMultiSortKey ] ) {
+ // User only wants to sort on one column set
+ // Flush the sort list and add new columns
+ config.sortList = newSortList;
+ } else {
+ // Multi column sorting
+ // It is not possible for one column to belong to multiple headers,
+ // so this is okay - we don't need to check for every value in the columns array
+ if ( isValueInArray( i, config.sortList ) ) {
+ // The user has clicked on an already sorted column.
+ // Reverse the sorting direction for all tables.
+ for ( j = 0; j < config.sortList.length; j++ ) {
+ s = config.sortList[ j ];
+ o = config.headerList[ config.columnToHeader[ s[ 0 ] ] ];
+ if ( isValueInArray( s[ 0 ], newSortList ) ) {
+ $( o ).data( 'count', s[ 1 ] + 1 );
+ s[ 1 ] = $( o ).data( 'count' ) % 2;
+ }
+ }
+ } else {
+ // Add columns to sort list array
+ config.sortList = config.sortList.concat( newSortList );
+ }
+ }
+
+ // Reset order/counts of cells not affected by sorting
+ setHeadersOrder( $headers, config.sortList, config.headerToColumns );
+
+ // Set CSS for headers
+ setHeadersCss( $table[ 0 ], $headers, config.sortList, sortCSS, sortMsg, config.columnToHeader );
+ appendToTable(
+ $table[ 0 ], multisort( $table[ 0 ], config.sortList, cache )
+ );
+
+ // Stop normal event by returning false
+ return false;
+ }
+
+ // Cancel selection
+ } ).mousedown( function () {
+ if ( config.cancelSelection ) {
+ this.onselectstart = function () {
+ return false;
+ };
+ return false;
+ }
+ } );
+
+ /**
+ * Sorts the table. If no sorting is specified by passing a list of sort
+ * objects, the table is sorted according to the initial sorting order.
+ * Passing an empty array will reset sorting (basically just reset the headers
+ * making the table appear unsorted).
+ *
+ * @param {Array} [sortList] List of sort objects.
+ */
+ $table.data( 'tablesorter' ).sort = function ( sortList ) {
+
+ if ( firstTime ) {
+ setupForFirstSort();
+ }
+
+ if ( sortList === undefined ) {
+ sortList = config.sortList;
+ } else if ( sortList.length > 0 ) {
+ sortList = convertSortList( sortList );
+ }
+
+ // Set each column's sort count to be able to determine the correct sort
+ // order when clicking on a header cell the next time
+ setHeadersOrder( $headers, sortList, config.headerToColumns );
+
+ // re-build the cache for the tbody cells
+ cache = buildCache( table );
+
+ // set css for headers
+ setHeadersCss( table, $headers, sortList, sortCSS, sortMsg, config.columnToHeader );
+
+ // sort the table and append it to the dom
+ appendToTable( table, multisort( table, sortList, cache ) );
+ };
+
+ // sort initially
+ if ( config.sortList.length > 0 ) {
+ config.sortList = convertSortList( config.sortList );
+ $table.data( 'tablesorter' ).sort();
+ }
+
+ } );
+ },
+
+ addParser: function ( parser ) {
+ if ( !getParserById( parser.id ) ) {
+ parsers.push( parser );
+ }
+ },
+
+ formatDigit: function ( s ) {
+ var out, c, p, i;
+ if ( ts.transformTable !== false ) {
+ out = '';
+ for ( p = 0; p < s.length; p++ ) {
+ c = s.charAt( p );
+ if ( c in ts.transformTable ) {
+ out += ts.transformTable[ c ];
+ } else {
+ out += c;
+ }
+ }
+ s = out;
+ }
+ i = parseFloat( s.replace( /[, ]/g, '' ).replace( '\u2212', '-' ) );
+ return isNaN( i ) ? -Infinity : i;
+ },
+
+ formatFloat: function ( s ) {
+ var i = parseFloat( s );
+ return isNaN( i ) ? -Infinity : i;
+ },
+
+ formatInt: function ( s ) {
+ var i = parseInt( s, 10 );
+ return isNaN( i ) ? -Infinity : i;
+ },
+
+ clearTableBody: function ( table ) {
+ $( table.tBodies[ 0 ] ).empty();
+ },
+
+ getParser: function ( id ) {
+ buildTransformTable();
+ buildDateTable();
+ cacheRegexs();
+ buildCollationTable();
+
+ return getParserById( id );
+ },
+
+ getParsers: function () { // for table diagnosis
+ return parsers;
+ }
+ };
+
+ // Shortcut
+ ts = $.tablesorter;
+
+ // Register as jQuery prototype method
+ $.fn.tablesorter = function ( settings ) {
+ return ts.construct( this, settings );
+ };
+
+ // Add default parsers
+ ts.addParser( {
+ id: 'text',
+ is: function () {
+ return true;
+ },
+ format: function ( s ) {
+ var tsc;
+ s = s.toLowerCase().trim();
+ if ( ts.collationRegex ) {
+ tsc = ts.collationTable;
+ s = s.replace( ts.collationRegex, function ( match ) {
+ var r = tsc[ match ] ? tsc[ match ] : tsc[ match.toUpperCase() ];
+ return r.toLowerCase();
+ } );
+ }
+ return s;
+ },
+ type: 'text'
+ } );
+
+ ts.addParser( {
+ id: 'IPAddress',
+ is: function ( s ) {
+ return ts.rgx.IPAddress[ 0 ].test( s );
+ },
+ format: function ( s ) {
+ var i, item,
+ a = s.split( '.' ),
+ r = '';
+ for ( i = 0; i < a.length; i++ ) {
+ item = a[ i ];
+ if ( item.length === 1 ) {
+ r += '00' + item;
+ } else if ( item.length === 2 ) {
+ r += '0' + item;
+ } else {
+ r += item;
+ }
+ }
+ return $.tablesorter.formatFloat( r );
+ },
+ type: 'numeric'
+ } );
+
+ ts.addParser( {
+ id: 'currency',
+ is: function ( s ) {
+ return ts.rgx.currency[ 0 ].test( s );
+ },
+ format: function ( s ) {
+ return $.tablesorter.formatDigit( s.replace( ts.rgx.currency[ 1 ], '' ) );
+ },
+ type: 'numeric'
+ } );
+
+ ts.addParser( {
+ id: 'url',
+ is: function ( s ) {
+ return ts.rgx.url[ 0 ].test( s );
+ },
+ format: function ( s ) {
+ return s.replace( ts.rgx.url[ 1 ], '' ).trim();
+ },
+ type: 'text'
+ } );
+
+ ts.addParser( {
+ id: 'isoDate',
+ is: function ( s ) {
+ return ts.rgx.isoDate[ 0 ].test( s );
+ },
+ format: function ( s ) {
+ var match, i, isodate, ms, hOffset, mOffset;
+ match = s.match( ts.rgx.isoDate[ 0 ] );
+ if ( match === null ) {
+ // Otherwise a signed number with 1-4 digit is parsed as isoDate
+ match = s.match( ts.rgx.isoDate[ 1 ] );
+ }
+ if ( !match ) {
+ return -Infinity;
+ }
+ // Month and day
+ for ( i = 2; i <= 4; i += 2 ) {
+ if ( !match[ i ] || match[ i ].length === 0 ) {
+ match[ i ] = 1;
+ }
+ }
+ // Time
+ for ( i = 6; i <= 15; i++ ) {
+ if ( !match[ i ] || match[ i ].length === 0 ) {
+ match[ i ] = '0';
+ }
+ }
+ ms = parseFloat( match[ 11 ].replace( /,/, '.' ) ) * 1000;
+ hOffset = $.tablesorter.formatInt( match[ 13 ] + match[ 14 ] );
+ mOffset = $.tablesorter.formatInt( match[ 13 ] + match[ 15 ] );
+
+ isodate = new Date( 0 );
+ // Because Date constructor changes year 0-99 to 1900-1999, use setUTCFullYear()
+ isodate.setUTCFullYear( match[ 1 ], match[ 2 ] - 1, match[ 4 ] );
+ isodate.setUTCHours( match[ 6 ] - hOffset, match[ 8 ] - mOffset, match[ 10 ], ms );
+ return isodate.getTime();
+ },
+ type: 'numeric'
+ } );
+
+ ts.addParser( {
+ id: 'usLongDate',
+ is: function ( s ) {
+ return ts.rgx.usLongDate[ 0 ].test( s );
+ },
+ format: function ( s ) {
+ return $.tablesorter.formatFloat( new Date( s ).getTime() );
+ },
+ type: 'numeric'
+ } );
+
+ ts.addParser( {
+ id: 'date',
+ is: function ( s ) {
+ return ( ts.dateRegex[ 0 ].test( s ) || ts.dateRegex[ 1 ].test( s ) || ts.dateRegex[ 2 ].test( s ) );
+ },
+ format: function ( s ) {
+ var match, y;
+ s = s.toLowerCase().trim();
+
+ if ( ( match = s.match( ts.dateRegex[ 0 ] ) ) !== null ) {
+ if ( mw.config.get( 'wgDefaultDateFormat' ) === 'mdy' || mw.config.get( 'wgPageContentLanguage' ) === 'en' ) {
+ s = [ match[ 3 ], match[ 1 ], match[ 2 ] ];
+ } else if ( mw.config.get( 'wgDefaultDateFormat' ) === 'dmy' ) {
+ s = [ match[ 3 ], match[ 2 ], match[ 1 ] ];
+ } else {
+ // If we get here, we don't know which order the dd-dd-dddd
+ // date is in. So return something not entirely invalid.
+ return '99999999';
+ }
+ } else if ( ( match = s.match( ts.dateRegex[ 1 ] ) ) !== null ) {
+ s = [ match[ 3 ], String( ts.monthNames[ match[ 2 ] ] ), match[ 1 ] ];
+ } else if ( ( match = s.match( ts.dateRegex[ 2 ] ) ) !== null ) {
+ s = [ match[ 3 ], String( ts.monthNames[ match[ 1 ] ] ), match[ 2 ] ];
+ } else {
+ // Should never get here
+ return '99999999';
+ }
+
+ // Pad Month and Day
+ if ( s[ 1 ].length === 1 ) {
+ s[ 1 ] = '0' + s[ 1 ];
+ }
+ if ( s[ 2 ].length === 1 ) {
+ s[ 2 ] = '0' + s[ 2 ];
+ }
+
+ if ( ( y = parseInt( s[ 0 ], 10 ) ) < 100 ) {
+ // Guestimate years without centuries
+ if ( y < 30 ) {
+ s[ 0 ] = 2000 + y;
+ } else {
+ s[ 0 ] = 1900 + y;
+ }
+ }
+ while ( s[ 0 ].length < 4 ) {
+ s[ 0 ] = '0' + s[ 0 ];
+ }
+ return parseInt( s.join( '' ), 10 );
+ },
+ type: 'numeric'
+ } );
+
+ ts.addParser( {
+ id: 'time',
+ is: function ( s ) {
+ return ts.rgx.time[ 0 ].test( s );
+ },
+ format: function ( s ) {
+ return $.tablesorter.formatFloat( new Date( '2000/01/01 ' + s ).getTime() );
+ },
+ type: 'numeric'
+ } );
+
+ ts.addParser( {
+ id: 'number',
+ is: function ( s ) {
+ return $.tablesorter.numberRegex.test( s.trim() );
+ },
+ format: function ( s ) {
+ return $.tablesorter.formatDigit( s );
+ },
+ type: 'numeric'
+ } );
+
+}( jQuery, mediaWiki ) );
--- /dev/null
+@import 'mediawiki.mixins';
+
+/* Table Sorting */
+
+table.jquery-tablesorter {
+ th.headerSort {
+ .background-image-svg( 'images/sort_both.svg', 'images/sort_both.png' );
+ cursor: pointer;
+ background-repeat: no-repeat;
+ background-position: center right;
+ // Note: To avoid reflows, a padding is set in
+ // the jquery.tableSorter.styles module as a render blocking style.
+ // Please do not add any CSS rules here that impact the positioning of the element
+ // e.g. padding, margin, position or float.
+ }
+
+ th.headerSortUp {
+ .background-image-svg( 'images/sort_up.svg', 'images/sort_up.png' );
+ }
+
+ th.headerSortDown {
+ .background-image-svg( 'images/sort_down.svg', 'images/sort_down.png' );
+ }
+}
+++ /dev/null
-<?xml version="1.0" encoding="UTF-8"?>
-<svg xmlns="http://www.w3.org/2000/svg" width="21" height="9" viewBox="0 0 21 9">
- <path d="M14.5 5l-4 4-4-4zM14.5 4l-4-4-4 4z"/>
-</svg>
+++ /dev/null
-<?xml version="1.0" encoding="UTF-8"?>
-<svg xmlns="http://www.w3.org/2000/svg" width="21" height="4" viewBox="0 0 21 4">
- <path d="M14.5 0l-4 4-4-4z"/>
-</svg>
+++ /dev/null
-<?xml version="1.0" encoding="UTF-8"?>
-<svg xmlns="http://www.w3.org/2000/svg" width="21" height="4" viewBox="0 0 21 4">
- <path d="M6.5 4l4-4 4 4z"/>
-</svg>
+++ /dev/null
-/*!
- * TableSorter for MediaWiki
- *
- * Written 2011 Leo Koppelkamm
- * Based on tablesorter.com plugin, written (c) 2007 Christian Bach.
- *
- * Dual licensed under the MIT and GPL licenses:
- * http://www.opensource.org/licenses/mit-license.php
- * http://www.gnu.org/licenses/gpl.html
- *
- * Depends on mw.config (wgDigitTransformTable, wgDefaultDateFormat, wgPageContentLanguage)
- * and mw.language.months.
- *
- * Uses 'tableSorterCollation' in mw.config (if available)
- *
- * Create a sortable table with multi-column sorting capabilities
- *
- * // Create a simple tablesorter interface
- * $( 'table' ).tablesorter();
- *
- * // Create a tablesorter interface, initially sorting on the first and second column
- * $( 'table' ).tablesorter( { sortList: [ { 0: 'desc' }, { 1: 'asc' } ] } );
- *
- * @param {string} [cssHeader="header"] A string of the class name to be appended to sortable
- * tr elements in the thead of the table.
- *
- * @param {string} [cssAsc="headerSortUp"] A string of the class name to be appended to
- * sortable tr elements in the thead on a ascending sort.
- *
- * @param {string} [cssDesc="headerSortDown"] A string of the class name to be appended to
- * sortable tr elements in the thead on a descending sort.
- *
- * @param {string} [sortMultisortKey="shiftKey"] A string of the multi-column sort key.
- *
- * @param {boolean} [cancelSelection=true] Boolean flag indicating iftablesorter should cancel
- * selection of the table headers text.
- *
- * @param {Array} [sortList] An array containing objects specifying sorting. By passing more
- * than one object, multi-sorting will be applied. Object structure:
- * { <Integer column index>: <String 'asc' or 'desc'> }
- *
- * @event sortEnd.tablesorter: Triggered as soon as any sorting has been applied.
- *
- * @author Christian Bach/christian.bach@polyester.se
- */
-( function ( $, mw ) {
- var ts,
- parsers = [];
-
- /* Parser utility functions */
-
- function getParserById( name ) {
- var i;
- for ( i = 0; i < parsers.length; i++ ) {
- if ( parsers[ i ].id.toLowerCase() === name.toLowerCase() ) {
- return parsers[ i ];
- }
- }
- return false;
- }
-
- function getElementSortKey( node ) {
- var $node = $( node ),
- // Use data-sort-value attribute.
- // Use data() instead of attr() so that live value changes
- // are processed as well (T40152).
- data = $node.data( 'sortValue' );
-
- if ( data !== null && data !== undefined ) {
- // Cast any numbers or other stuff to a string, methods
- // like charAt, toLowerCase and split are expected.
- return String( data );
- }
- if ( !node ) {
- return $node.text();
- }
- if ( node.tagName.toLowerCase() === 'img' ) {
- return $node.attr( 'alt' ) || ''; // handle undefined alt
- }
- return $.makeArray( node.childNodes ).map( function ( elem ) {
- if ( elem.nodeType === Node.ELEMENT_NODE ) {
- if ( $( elem ).hasClass( 'reference' ) ) {
- return null;
- } else {
- return getElementSortKey( elem );
- }
- }
- return $.text( elem );
- } ).join( '' );
- }
-
- function detectParserForColumn( table, rows, column ) {
- var l = parsers.length,
- config = $( table ).data( 'tablesorter' ).config,
- cellIndex,
- nodeValue,
- nextRow = false,
- // Start with 1 because 0 is the fallback parser
- i = 1,
- lastRowIndex = -1,
- rowIndex = 0,
- concurrent = 0,
- empty = 0,
- needed = ( rows.length > 4 ) ? 5 : rows.length;
-
- while ( i < l ) {
- // if this is a child row, continue to the next row (as buildCache())
- if ( rows[ rowIndex ] && !$( rows[ rowIndex ] ).hasClass( config.cssChildRow ) ) {
- if ( rowIndex !== lastRowIndex ) {
- lastRowIndex = rowIndex;
- cellIndex = $( rows[ rowIndex ] ).data( 'columnToCell' )[ column ];
- nodeValue = getElementSortKey( rows[ rowIndex ].cells[ cellIndex ] ).trim();
- }
- } else {
- nodeValue = '';
- }
-
- if ( nodeValue !== '' ) {
- if ( parsers[ i ].is( nodeValue, table ) ) {
- concurrent++;
- nextRow = true;
- if ( concurrent >= needed ) {
- // Confirmed the parser for multiple cells, let's return it
- return parsers[ i ];
- }
- } else if ( parsers[ i ].id.match( /isoDate/ ) && /^\D*(\d{1,4}) ?(\[.+\])?$/.test( nodeValue ) ) {
- // For 1-4 digits and maybe reference(s) parser "isoDate" or "number" is possible, check next row
- empty++;
- nextRow = true;
- } else {
- // Check next parser, reset rows
- i++;
- rowIndex = 0;
- concurrent = 0;
- empty = 0;
- nextRow = false;
- }
- } else {
- // Empty cell
- empty++;
- nextRow = true;
- }
-
- if ( nextRow ) {
- nextRow = false;
- rowIndex++;
- if ( rowIndex >= rows.length ) {
- if ( concurrent > 0 && concurrent >= rows.length - empty ) {
- // Confirmed the parser for all filled cells
- return parsers[ i ];
- }
- // Check next parser, reset rows
- i++;
- rowIndex = 0;
- concurrent = 0;
- empty = 0;
- }
- }
- }
-
- // 0 is always the generic parser (text)
- return parsers[ 0 ];
- }
-
- function buildParserCache( table, $headers ) {
- var sortType, len, j, parser,
- rows = table.tBodies[ 0 ].rows,
- config = $( table ).data( 'tablesorter' ).config,
- parsers = [];
-
- if ( rows[ 0 ] ) {
- len = config.columns;
- for ( j = 0; j < len; j++ ) {
- parser = false;
- sortType = $headers.eq( config.columnToHeader[ j ] ).data( 'sortType' );
- if ( sortType !== undefined ) {
- parser = getParserById( sortType );
- }
-
- if ( parser === false ) {
- parser = detectParserForColumn( table, rows, j );
- }
-
- parsers.push( parser );
- }
- }
- return parsers;
- }
-
- /* Other utility functions */
-
- function buildCache( table ) {
- var i, j, $row, cols,
- totalRows = ( table.tBodies[ 0 ] && table.tBodies[ 0 ].rows.length ) || 0,
- config = $( table ).data( 'tablesorter' ).config,
- parsers = config.parsers,
- len = parsers.length,
- cellIndex,
- cache = {
- row: [],
- normalized: []
- };
-
- for ( i = 0; i < totalRows; i++ ) {
-
- // Add the table data to main data array
- $row = $( table.tBodies[ 0 ].rows[ i ] );
- cols = [];
-
- // if this is a child row, add it to the last row's children and
- // continue to the next row
- if ( $row.hasClass( config.cssChildRow ) ) {
- cache.row[ cache.row.length - 1 ] = cache.row[ cache.row.length - 1 ].add( $row );
- // go to the next for loop
- continue;
- }
-
- cache.row.push( $row );
-
- for ( j = 0; j < len; j++ ) {
- cellIndex = $row.data( 'columnToCell' )[ j ];
- cols.push( parsers[ j ].format( getElementSortKey( $row[ 0 ].cells[ cellIndex ] ) ) );
- }
-
- cols.push( cache.normalized.length ); // add position for rowCache
- cache.normalized.push( cols );
- cols = null;
- }
-
- return cache;
- }
-
- function appendToTable( table, cache ) {
- var i, pos, l, j,
- row = cache.row,
- normalized = cache.normalized,
- totalRows = normalized.length,
- checkCell = ( normalized[ 0 ].length - 1 ),
- fragment = document.createDocumentFragment();
-
- for ( i = 0; i < totalRows; i++ ) {
- pos = normalized[ i ][ checkCell ];
-
- l = row[ pos ].length;
- for ( j = 0; j < l; j++ ) {
- fragment.appendChild( row[ pos ][ j ] );
- }
-
- }
- table.tBodies[ 0 ].appendChild( fragment );
-
- $( table ).trigger( 'sortEnd.tablesorter' );
- }
-
- /**
- * Find all header rows in a thead-less table and put them in a <thead> tag.
- * This only treats a row as a header row if it contains only <th>s (no <td>s)
- * and if it is preceded entirely by header rows. The algorithm stops when
- * it encounters the first non-header row.
- *
- * After this, it will look at all rows at the bottom for footer rows
- * And place these in a tfoot using similar rules.
- *
- * @param {jQuery} $table object for a <table>
- */
- function emulateTHeadAndFoot( $table ) {
- var $thead, $tfoot, i, len,
- $rows = $table.find( '> tbody > tr' );
- if ( !$table.get( 0 ).tHead ) {
- $thead = $( '<thead>' );
- $rows.each( function () {
- if ( $( this ).children( 'td' ).length ) {
- // This row contains a <td>, so it's not a header row
- // Stop here
- return false;
- }
- $thead.append( this );
- } );
- $table.find( ' > tbody:first' ).before( $thead );
- }
- if ( !$table.get( 0 ).tFoot ) {
- $tfoot = $( '<tfoot>' );
- len = $rows.length;
- for ( i = len - 1; i >= 0; i-- ) {
- if ( $( $rows[ i ] ).children( 'td' ).length ) {
- break;
- }
- $tfoot.prepend( $( $rows[ i ] ) );
- }
- $table.append( $tfoot );
- }
- }
-
- function uniqueElements( array ) {
- var uniques = [];
- array.forEach( function ( elem ) {
- if ( elem !== undefined && uniques.indexOf( elem ) === -1 ) {
- uniques.push( elem );
- }
- } );
- return uniques;
- }
-
- function buildHeaders( table, msg ) {
- var config = $( table ).data( 'tablesorter' ).config,
- maxSeen = 0,
- colspanOffset = 0,
- columns,
- k,
- $cell,
- rowspan,
- colspan,
- headerCount,
- longestTR,
- headerIndex,
- exploded,
- $tableHeaders = $( [] ),
- $tableRows = $( 'thead:eq(0) > tr', table );
-
- if ( $tableRows.length <= 1 ) {
- $tableHeaders = $tableRows.children( 'th' );
- } else {
- exploded = [];
-
- // Loop through all the dom cells of the thead
- $tableRows.each( function ( rowIndex, row ) {
- $.each( row.cells, function ( columnIndex, cell ) {
- var matrixRowIndex,
- matrixColumnIndex;
-
- rowspan = Number( cell.rowSpan );
- colspan = Number( cell.colSpan );
-
- // Skip the spots in the exploded matrix that are already filled
- while ( exploded[ rowIndex ] && exploded[ rowIndex ][ columnIndex ] !== undefined ) {
- ++columnIndex;
- }
-
- // Find the actual dimensions of the thead, by placing each cell
- // in the exploded matrix rowspan times colspan times, with the proper offsets
- for ( matrixColumnIndex = columnIndex; matrixColumnIndex < columnIndex + colspan; ++matrixColumnIndex ) {
- for ( matrixRowIndex = rowIndex; matrixRowIndex < rowIndex + rowspan; ++matrixRowIndex ) {
- if ( !exploded[ matrixRowIndex ] ) {
- exploded[ matrixRowIndex ] = [];
- }
- exploded[ matrixRowIndex ][ matrixColumnIndex ] = cell;
- }
- }
- } );
- } );
- // We want to find the row that has the most columns (ignoring colspan)
- exploded.forEach( function ( cellArray, index ) {
- headerCount = $( uniqueElements( cellArray ) ).filter( 'th' ).length;
- if ( headerCount >= maxSeen ) {
- maxSeen = headerCount;
- longestTR = index;
- }
- } );
- // We cannot use $.unique() here because it sorts into dom order, which is undesirable
- $tableHeaders = $( uniqueElements( exploded[ longestTR ] ) ).filter( 'th' );
- }
-
- // as each header can span over multiple columns (using colspan=N),
- // we have to bidirectionally map headers to their columns and columns to their headers
- config.columnToHeader = [];
- config.headerToColumns = [];
- config.headerList = [];
- headerIndex = 0;
- $tableHeaders.each( function () {
- $cell = $( this );
- columns = [];
-
- if ( !$cell.hasClass( config.unsortableClass ) ) {
- $cell
- .addClass( config.cssHeader )
- .prop( 'tabIndex', 0 )
- .attr( {
- role: 'columnheader button',
- title: msg[ 1 ]
- } );
-
- for ( k = 0; k < this.colSpan; k++ ) {
- config.columnToHeader[ colspanOffset + k ] = headerIndex;
- columns.push( colspanOffset + k );
- }
-
- config.headerToColumns[ headerIndex ] = columns;
-
- $cell.data( {
- headerIndex: headerIndex,
- order: 0,
- count: 0
- } );
-
- // add only sortable cells to headerList
- config.headerList[ headerIndex ] = this;
- headerIndex++;
- }
-
- colspanOffset += this.colSpan;
- } );
-
- // number of columns with extended colspan, inclusive unsortable
- // parsers[j], cache[][j], columnToHeader[j], columnToCell[j] have so many elements
- config.columns = colspanOffset;
-
- return $tableHeaders.not( '.' + config.unsortableClass );
- }
-
- function isValueInArray( v, a ) {
- var i;
- for ( i = 0; i < a.length; i++ ) {
- if ( a[ i ][ 0 ] === v ) {
- return true;
- }
- }
- return false;
- }
-
- /**
- * Sets the sort count of the columns that are not affected by the sorting to have them sorted
- * in default (ascending) order when their header cell is clicked the next time.
- *
- * @param {jQuery} $headers
- * @param {Array} sortList 2D number array
- * @param {Array} headerToColumns 2D number array
- */
- function setHeadersOrder( $headers, sortList, headerToColumns ) {
- // Loop through all headers to retrieve the indices of the columns the header spans across:
- headerToColumns.forEach( function ( columns, headerIndex ) {
-
- columns.forEach( function ( columnIndex, i ) {
- var header = $headers[ headerIndex ],
- $header = $( header );
-
- if ( !isValueInArray( columnIndex, sortList ) ) {
- // Column shall not be sorted: Reset header count and order.
- $header.data( {
- order: 0,
- count: 0
- } );
- } else {
- // Column shall be sorted: Apply designated count and order.
- sortList.forEach( function ( sortColumn ) {
- if ( sortColumn[ 0 ] === i ) {
- $header.data( {
- order: sortColumn[ 1 ],
- count: sortColumn[ 1 ] + 1
- } );
- return false;
- }
- } );
- }
- } );
-
- } );
- }
-
- function setHeadersCss( table, $headers, list, css, msg, columnToHeader ) {
- var i, len;
- // Remove all header information and reset titles to default message
- $headers.removeClass( css[ 0 ] ).removeClass( css[ 1 ] ).attr( 'title', msg[ 1 ] );
-
- for ( i = 0, len = list.length; i < len; i++ ) {
- $headers
- .eq( columnToHeader[ list[ i ][ 0 ] ] )
- .addClass( css[ list[ i ][ 1 ] ] )
- .attr( 'title', msg[ list[ i ][ 1 ] ] );
- }
- }
-
- function sortText( a, b ) {
- return ( ( a < b ) ? -1 : ( ( a > b ) ? 1 : 0 ) );
- }
-
- function sortTextDesc( a, b ) {
- return ( ( b < a ) ? -1 : ( ( b > a ) ? 1 : 0 ) );
- }
-
- function multisort( table, sortList, cache ) {
- var i,
- sortFn = [];
-
- for ( i = 0; i < sortList.length; i++ ) {
- sortFn[ i ] = ( sortList[ i ][ 1 ] ) ? sortTextDesc : sortText;
- }
- cache.normalized.sort( function ( array1, array2 ) {
- var i, col, ret;
- for ( i = 0; i < sortList.length; i++ ) {
- col = sortList[ i ][ 0 ];
- ret = sortFn[ i ].call( this, array1[ col ], array2[ col ] );
- if ( ret !== 0 ) {
- return ret;
- }
- }
- // Fall back to index number column to ensure stable sort
- return sortText.call( this, array1[ array1.length - 1 ], array2[ array2.length - 1 ] );
- } );
- return cache;
- }
-
- function buildTransformTable() {
- var ascii, localised, i, digitClass,
- digits = '0123456789,.'.split( '' ),
- separatorTransformTable = mw.config.get( 'wgSeparatorTransformTable' ),
- digitTransformTable = mw.config.get( 'wgDigitTransformTable' );
-
- if ( separatorTransformTable === null || ( separatorTransformTable[ 0 ] === '' && digitTransformTable[ 2 ] === '' ) ) {
- ts.transformTable = false;
- } else {
- ts.transformTable = {};
-
- // Unpack the transform table
- ascii = separatorTransformTable[ 0 ].split( '\t' ).concat( digitTransformTable[ 0 ].split( '\t' ) );
- localised = separatorTransformTable[ 1 ].split( '\t' ).concat( digitTransformTable[ 1 ].split( '\t' ) );
-
- // Construct regexes for number identification
- for ( i = 0; i < ascii.length; i++ ) {
- ts.transformTable[ localised[ i ] ] = ascii[ i ];
- digits.push( mw.RegExp.escape( localised[ i ] ) );
- }
- }
- digitClass = '[' + digits.join( '', digits ) + ']';
-
- // We allow a trailing percent sign, which we just strip. This works fine
- // if percents and regular numbers aren't being mixed.
- ts.numberRegex = new RegExp(
- '^(' +
- '[-+\u2212]?[0-9][0-9,]*(\\.[0-9,]*)?(E[-+\u2212]?[0-9][0-9,]*)?' + // Fortran-style scientific
- '|' +
- '[-+\u2212]?' + digitClass + '+[\\s\\xa0]*%?' + // Generic localised
- ')$',
- 'i'
- );
- }
-
- function buildDateTable() {
- var i, name,
- regex = [];
-
- ts.monthNames = {};
-
- for ( i = 0; i < 12; i++ ) {
- name = mw.language.months.names[ i ].toLowerCase();
- ts.monthNames[ name ] = i + 1;
- regex.push( mw.RegExp.escape( name ) );
- name = mw.language.months.genitive[ i ].toLowerCase();
- ts.monthNames[ name ] = i + 1;
- regex.push( mw.RegExp.escape( name ) );
- name = mw.language.months.abbrev[ i ].toLowerCase().replace( '.', '' );
- ts.monthNames[ name ] = i + 1;
- regex.push( mw.RegExp.escape( name ) );
- }
-
- // Build piped string
- regex = regex.join( '|' );
-
- // Build RegEx
- // Any date formated with . , ' - or /
- ts.dateRegex[ 0 ] = new RegExp( /^\s*(\d{1,2})[,.\-/'\s]{1,2}(\d{1,2})[,.\-/'\s]{1,2}(\d{2,4})\s*?/i );
-
- // Written Month name, dmy
- ts.dateRegex[ 1 ] = new RegExp(
- '^\\s*(\\d{1,2})[\\,\\.\\-\\/\'\\s]+(' +
- regex +
- ')' +
- '[\\,\\.\\-\\/\'\\s]+(\\d{2,4})\\s*$',
- 'i'
- );
-
- // Written Month name, mdy
- ts.dateRegex[ 2 ] = new RegExp(
- '^\\s*(' + regex + ')' +
- '[\\,\\.\\-\\/\'\\s]+(\\d{1,2})[\\,\\.\\-\\/\'\\s]+(\\d{2,4})\\s*$',
- 'i'
- );
-
- }
-
- /**
- * Replace all rowspanned cells in the body with clones in each row, so sorting
- * need not worry about them.
- *
- * @param {jQuery} $table jQuery object for a <table>
- */
- function explodeRowspans( $table ) {
- var spanningRealCellIndex, rowSpan, colSpan,
- cell, cellData, i, $tds, $clone, $nextRows,
- rowspanCells = $table.find( '> tbody > tr > [rowspan]' ).get();
-
- // Short circuit
- if ( !rowspanCells.length ) {
- return;
- }
-
- // First, we need to make a property like cellIndex but taking into
- // account colspans. We also cache the rowIndex to avoid having to take
- // cell.parentNode.rowIndex in the sorting function below.
- $table.find( '> tbody > tr' ).each( function () {
- var i,
- col = 0,
- len = this.cells.length;
- for ( i = 0; i < len; i++ ) {
- $( this.cells[ i ] ).data( 'tablesorter', {
- realCellIndex: col,
- realRowIndex: this.rowIndex
- } );
- col += this.cells[ i ].colSpan;
- }
- } );
-
- // Split multi row cells into multiple cells with the same content.
- // Sort by column then row index to avoid problems with odd table structures.
- // Re-sort whenever a rowspanned cell's realCellIndex is changed, because it
- // might change the sort order.
- function resortCells() {
- var cellAData,
- cellBData,
- ret;
- rowspanCells = rowspanCells.sort( function ( a, b ) {
- cellAData = $.data( a, 'tablesorter' );
- cellBData = $.data( b, 'tablesorter' );
- ret = cellAData.realCellIndex - cellBData.realCellIndex;
- if ( !ret ) {
- ret = cellAData.realRowIndex - cellBData.realRowIndex;
- }
- return ret;
- } );
- rowspanCells.forEach( function ( cell ) {
- $.data( cell, 'tablesorter' ).needResort = false;
- } );
- }
- resortCells();
-
- function filterfunc() {
- return $.data( this, 'tablesorter' ).realCellIndex >= spanningRealCellIndex;
- }
-
- function fixTdCellIndex() {
- $.data( this, 'tablesorter' ).realCellIndex += colSpan;
- if ( this.rowSpan > 1 ) {
- $.data( this, 'tablesorter' ).needResort = true;
- }
- }
-
- while ( rowspanCells.length ) {
- if ( $.data( rowspanCells[ 0 ], 'tablesorter' ).needResort ) {
- resortCells();
- }
-
- cell = rowspanCells.shift();
- cellData = $.data( cell, 'tablesorter' );
- rowSpan = cell.rowSpan;
- colSpan = cell.colSpan;
- spanningRealCellIndex = cellData.realCellIndex;
- cell.rowSpan = 1;
- $nextRows = $( cell ).parent().nextAll();
- for ( i = 0; i < rowSpan - 1; i++ ) {
- $tds = $( $nextRows[ i ].cells ).filter( filterfunc );
- $clone = $( cell ).clone();
- $clone.data( 'tablesorter', {
- realCellIndex: spanningRealCellIndex,
- realRowIndex: cellData.realRowIndex + i,
- needResort: true
- } );
- if ( $tds.length ) {
- $tds.each( fixTdCellIndex );
- $tds.first().before( $clone );
- } else {
- $nextRows.eq( i ).append( $clone );
- }
- }
- }
- }
-
- /**
- * Build index to handle colspanned cells in the body.
- * Set the cell index for each column in an array,
- * so that colspaned cells set multiple in this array.
- * columnToCell[collumnIndex] point at the real cell in this row.
- *
- * @param {jQuery} $table object for a <table>
- */
- function manageColspans( $table ) {
- var i, j, k, $row,
- $rows = $table.find( '> tbody > tr' ),
- totalRows = $rows.length || 0,
- config = $table.data( 'tablesorter' ).config,
- columns = config.columns,
- columnToCell, cellsInRow, index;
-
- for ( i = 0; i < totalRows; i++ ) {
-
- $row = $rows.eq( i );
- // if this is a child row, continue to the next row (as buildCache())
- if ( $row.hasClass( config.cssChildRow ) ) {
- // go to the next for loop
- continue;
- }
-
- columnToCell = [];
- cellsInRow = ( $row[ 0 ].cells.length ) || 0; // all cells in this row
- index = 0; // real cell index in this row
- for ( j = 0; j < columns; index++ ) {
- if ( index === cellsInRow ) {
- // Row with cells less than columns: add empty cell
- $row.append( '<td>' );
- cellsInRow++;
- }
- for ( k = 0; k < $row[ 0 ].cells[ index ].colSpan; k++ ) {
- columnToCell[ j++ ] = index;
- }
- }
- // Store it in $row
- $row.data( 'columnToCell', columnToCell );
- }
- }
-
- function buildCollationTable() {
- var key, keys = [];
- ts.collationTable = mw.config.get( 'tableSorterCollation' );
- ts.collationRegex = null;
- if ( ts.collationTable ) {
- // Build array of key names
- for ( key in ts.collationTable ) {
- // Check hasOwn to be safe
- if ( ts.collationTable.hasOwnProperty( key ) ) {
- keys.push( mw.RegExp.escape( key ) );
- }
- }
- if ( keys.length ) {
- ts.collationRegex = new RegExp( keys.join( '|' ), 'ig' );
- }
- }
- }
-
- function cacheRegexs() {
- if ( ts.rgx ) {
- return;
- }
- ts.rgx = {
- IPAddress: [
- new RegExp( /^\d{1,3}[.]\d{1,3}[.]\d{1,3}[.]\d{1,3}$/ )
- ],
- currency: [
- new RegExp( /(^[£$€¥]|[£$€¥]$)/ ),
- new RegExp( /[£$€¥]/g )
- ],
- url: [
- new RegExp( /^(https?|ftp|file):\/\/$/ ),
- new RegExp( /(https?|ftp|file):\/\// )
- ],
- isoDate: [
- new RegExp( /^[^-\d]*(-?\d{1,4})-(0\d|1[0-2])(-([0-3]\d))?([T\s]([01]\d|2[0-4]):?(([0-5]\d):?(([0-5]\d|60)([.,]\d{1,3})?)?)?([zZ]|([-+])([01]\d|2[0-3]):?([0-5]\d)?)?)?/ ),
- new RegExp( /^[^-\d]*(-?\d{1,4})-?(\d\d)?(-?(\d\d))?([T\s](\d\d):?((\d\d)?:?((\d\d)?([.,]\d{1,3})?)?)?([zZ]|([-+])(\d\d):?(\d\d)?)?)?/ )
- ],
- usLongDate: [
- new RegExp( /^[A-Za-z]{3,10}\.? [0-9]{1,2}, ([0-9]{4}|'?[0-9]{2}) (([0-2]?[0-9]:[0-5][0-9])|([0-1]?[0-9]:[0-5][0-9]\s(AM|PM)))$/ )
- ],
- time: [
- new RegExp( /^(([0-2]?[0-9]:[0-5][0-9])|([0-1]?[0-9]:[0-5][0-9]\s(am|pm)))$/ )
- ]
- };
- }
-
- /**
- * Converts sort objects [ { Integer: String }, ... ] to the internally used nested array
- * structure [ [ Integer, Integer ], ... ]
- *
- * @param {Array} sortObjects List of sort objects.
- * @return {Array} List of internal sort definitions.
- */
- function convertSortList( sortObjects ) {
- var sortList = [];
- sortObjects.forEach( function ( sortObject ) {
- $.each( sortObject, function ( columnIndex, order ) {
- var orderIndex = ( order === 'desc' ) ? 1 : 0;
- sortList.push( [ parseInt( columnIndex, 10 ), orderIndex ] );
- } );
- } );
- return sortList;
- }
-
- /* Public scope */
-
- $.tablesorter = {
- defaultOptions: {
- cssHeader: 'headerSort',
- cssAsc: 'headerSortUp',
- cssDesc: 'headerSortDown',
- cssChildRow: 'expand-child',
- sortMultiSortKey: 'shiftKey',
- unsortableClass: 'unsortable',
- parsers: [],
- cancelSelection: true,
- sortList: [],
- headerList: [],
- headerToColumns: [],
- columnToHeader: [],
- columns: 0
- },
-
- dateRegex: [],
- monthNames: {},
-
- /**
- * @param {jQuery} $tables
- * @param {Object} [settings]
- * @return {jQuery}
- */
- construct: function ( $tables, settings ) {
- return $tables.each( function ( i, table ) {
- // Declare and cache.
- var $headers, cache, config, sortCSS, sortMsg,
- $table = $( table ),
- firstTime = true;
-
- // Quit if no tbody
- if ( !table.tBodies ) {
- return;
- }
- if ( !table.tHead ) {
- // No thead found. Look for rows with <th>s and
- // move them into a <thead> tag or a <tfoot> tag
- emulateTHeadAndFoot( $table );
-
- // Still no thead? Then quit
- if ( !table.tHead ) {
- return;
- }
- }
- // The `sortable` class is used to identify tables which will become sortable
- // If not used it will create a FOUC but it should be added since the sortable class
- // is responsible for certain crucial style elements. If the class is already present
- // this action will be harmless.
- $table.addClass( 'jquery-tablesorter sortable' );
-
- // Merge and extend
- config = $.extend( {}, $.tablesorter.defaultOptions, settings );
-
- // Save the settings where they read
- $.data( table, 'tablesorter', { config: config } );
-
- // Get the CSS class names, could be done elsewhere
- sortCSS = [ config.cssAsc, config.cssDesc ];
- // Messages tell the the user what the *next* state will be
- // so are in reverse order to the CSS classes.
- sortMsg = [ mw.msg( 'sort-descending' ), mw.msg( 'sort-ascending' ) ];
-
- // Build headers
- $headers = buildHeaders( table, sortMsg );
-
- // Grab and process locale settings.
- buildTransformTable();
- buildDateTable();
-
- // Precaching regexps can bring 10 fold
- // performance improvements in some browsers.
- cacheRegexs();
-
- function setupForFirstSort() {
- var $tfoot, $sortbottoms;
-
- firstTime = false;
-
- // Defer buildCollationTable to first sort. As user and site scripts
- // may customize tableSorterCollation but load after $.ready(), other
- // scripts may call .tablesorter() before they have done the
- // tableSorterCollation customizations.
- buildCollationTable();
-
- // Legacy fix of .sortbottoms
- // Wrap them inside a tfoot (because that's what they actually want to be)
- // and put the <tfoot> at the end of the <table>
- $sortbottoms = $table.find( '> tbody > tr.sortbottom' );
- if ( $sortbottoms.length ) {
- $tfoot = $table.children( 'tfoot' );
- if ( $tfoot.length ) {
- $tfoot.eq( 0 ).prepend( $sortbottoms );
- } else {
- $table.append( $( '<tfoot>' ).append( $sortbottoms ) );
- }
- }
-
- explodeRowspans( $table );
- manageColspans( $table );
-
- // Try to auto detect column type, and store in tables config
- config.parsers = buildParserCache( table, $headers );
- }
-
- // Apply event handling to headers
- // this is too big, perhaps break it out?
- $headers.on( 'keypress click', function ( e ) {
- var cell, $cell, columns, newSortList, i,
- totalRows,
- j, s, o;
-
- if ( e.type === 'click' && e.target.nodeName.toLowerCase() === 'a' ) {
- // The user clicked on a link inside a table header.
- // Do nothing and let the default link click action continue.
- return true;
- }
-
- if ( e.type === 'keypress' && e.which !== 13 ) {
- // Only handle keypresses on the "Enter" key.
- return true;
- }
-
- if ( firstTime ) {
- setupForFirstSort();
- }
-
- // Build the cache for the tbody cells
- // to share between calculations for this sort action.
- // Re-calculated each time a sort action is performed due to possiblity
- // that sort values change. Shouldn't be too expensive, but if it becomes
- // too slow an event based system should be implemented somehow where
- // cells get event .change() and bubbles up to the <table> here
- cache = buildCache( table );
-
- totalRows = ( $table[ 0 ].tBodies[ 0 ] && $table[ 0 ].tBodies[ 0 ].rows.length ) || 0;
- if ( totalRows > 0 ) {
- cell = this;
- $cell = $( cell );
-
- // Get current column sort order
- $cell.data( {
- order: $cell.data( 'count' ) % 2,
- count: $cell.data( 'count' ) + 1
- } );
-
- cell = this;
- // Get current column index
- columns = config.headerToColumns[ $cell.data( 'headerIndex' ) ];
- newSortList = columns.map( function ( c ) {
- return [ c, $cell.data( 'order' ) ];
- } );
- // Index of first column belonging to this header
- i = columns[ 0 ];
-
- if ( !e[ config.sortMultiSortKey ] ) {
- // User only wants to sort on one column set
- // Flush the sort list and add new columns
- config.sortList = newSortList;
- } else {
- // Multi column sorting
- // It is not possible for one column to belong to multiple headers,
- // so this is okay - we don't need to check for every value in the columns array
- if ( isValueInArray( i, config.sortList ) ) {
- // The user has clicked on an already sorted column.
- // Reverse the sorting direction for all tables.
- for ( j = 0; j < config.sortList.length; j++ ) {
- s = config.sortList[ j ];
- o = config.headerList[ config.columnToHeader[ s[ 0 ] ] ];
- if ( isValueInArray( s[ 0 ], newSortList ) ) {
- $( o ).data( 'count', s[ 1 ] + 1 );
- s[ 1 ] = $( o ).data( 'count' ) % 2;
- }
- }
- } else {
- // Add columns to sort list array
- config.sortList = config.sortList.concat( newSortList );
- }
- }
-
- // Reset order/counts of cells not affected by sorting
- setHeadersOrder( $headers, config.sortList, config.headerToColumns );
-
- // Set CSS for headers
- setHeadersCss( $table[ 0 ], $headers, config.sortList, sortCSS, sortMsg, config.columnToHeader );
- appendToTable(
- $table[ 0 ], multisort( $table[ 0 ], config.sortList, cache )
- );
-
- // Stop normal event by returning false
- return false;
- }
-
- // Cancel selection
- } ).mousedown( function () {
- if ( config.cancelSelection ) {
- this.onselectstart = function () {
- return false;
- };
- return false;
- }
- } );
-
- /**
- * Sorts the table. If no sorting is specified by passing a list of sort
- * objects, the table is sorted according to the initial sorting order.
- * Passing an empty array will reset sorting (basically just reset the headers
- * making the table appear unsorted).
- *
- * @param {Array} [sortList] List of sort objects.
- */
- $table.data( 'tablesorter' ).sort = function ( sortList ) {
-
- if ( firstTime ) {
- setupForFirstSort();
- }
-
- if ( sortList === undefined ) {
- sortList = config.sortList;
- } else if ( sortList.length > 0 ) {
- sortList = convertSortList( sortList );
- }
-
- // Set each column's sort count to be able to determine the correct sort
- // order when clicking on a header cell the next time
- setHeadersOrder( $headers, sortList, config.headerToColumns );
-
- // re-build the cache for the tbody cells
- cache = buildCache( table );
-
- // set css for headers
- setHeadersCss( table, $headers, sortList, sortCSS, sortMsg, config.columnToHeader );
-
- // sort the table and append it to the dom
- appendToTable( table, multisort( table, sortList, cache ) );
- };
-
- // sort initially
- if ( config.sortList.length > 0 ) {
- config.sortList = convertSortList( config.sortList );
- $table.data( 'tablesorter' ).sort();
- }
-
- } );
- },
-
- addParser: function ( parser ) {
- if ( !getParserById( parser.id ) ) {
- parsers.push( parser );
- }
- },
-
- formatDigit: function ( s ) {
- var out, c, p, i;
- if ( ts.transformTable !== false ) {
- out = '';
- for ( p = 0; p < s.length; p++ ) {
- c = s.charAt( p );
- if ( c in ts.transformTable ) {
- out += ts.transformTable[ c ];
- } else {
- out += c;
- }
- }
- s = out;
- }
- i = parseFloat( s.replace( /[, ]/g, '' ).replace( '\u2212', '-' ) );
- return isNaN( i ) ? -Infinity : i;
- },
-
- formatFloat: function ( s ) {
- var i = parseFloat( s );
- return isNaN( i ) ? -Infinity : i;
- },
-
- formatInt: function ( s ) {
- var i = parseInt( s, 10 );
- return isNaN( i ) ? -Infinity : i;
- },
-
- clearTableBody: function ( table ) {
- $( table.tBodies[ 0 ] ).empty();
- },
-
- getParser: function ( id ) {
- buildTransformTable();
- buildDateTable();
- cacheRegexs();
- buildCollationTable();
-
- return getParserById( id );
- },
-
- getParsers: function () { // for table diagnosis
- return parsers;
- }
- };
-
- // Shortcut
- ts = $.tablesorter;
-
- // Register as jQuery prototype method
- $.fn.tablesorter = function ( settings ) {
- return ts.construct( this, settings );
- };
-
- // Add default parsers
- ts.addParser( {
- id: 'text',
- is: function () {
- return true;
- },
- format: function ( s ) {
- var tsc;
- s = s.toLowerCase().trim();
- if ( ts.collationRegex ) {
- tsc = ts.collationTable;
- s = s.replace( ts.collationRegex, function ( match ) {
- var r = tsc[ match ] ? tsc[ match ] : tsc[ match.toUpperCase() ];
- return r.toLowerCase();
- } );
- }
- return s;
- },
- type: 'text'
- } );
-
- ts.addParser( {
- id: 'IPAddress',
- is: function ( s ) {
- return ts.rgx.IPAddress[ 0 ].test( s );
- },
- format: function ( s ) {
- var i, item,
- a = s.split( '.' ),
- r = '';
- for ( i = 0; i < a.length; i++ ) {
- item = a[ i ];
- if ( item.length === 1 ) {
- r += '00' + item;
- } else if ( item.length === 2 ) {
- r += '0' + item;
- } else {
- r += item;
- }
- }
- return $.tablesorter.formatFloat( r );
- },
- type: 'numeric'
- } );
-
- ts.addParser( {
- id: 'currency',
- is: function ( s ) {
- return ts.rgx.currency[ 0 ].test( s );
- },
- format: function ( s ) {
- return $.tablesorter.formatDigit( s.replace( ts.rgx.currency[ 1 ], '' ) );
- },
- type: 'numeric'
- } );
-
- ts.addParser( {
- id: 'url',
- is: function ( s ) {
- return ts.rgx.url[ 0 ].test( s );
- },
- format: function ( s ) {
- return s.replace( ts.rgx.url[ 1 ], '' ).trim();
- },
- type: 'text'
- } );
-
- ts.addParser( {
- id: 'isoDate',
- is: function ( s ) {
- return ts.rgx.isoDate[ 0 ].test( s );
- },
- format: function ( s ) {
- var match, i, isodate, ms, hOffset, mOffset;
- match = s.match( ts.rgx.isoDate[ 0 ] );
- if ( match === null ) {
- // Otherwise a signed number with 1-4 digit is parsed as isoDate
- match = s.match( ts.rgx.isoDate[ 1 ] );
- }
- if ( !match ) {
- return -Infinity;
- }
- // Month and day
- for ( i = 2; i <= 4; i += 2 ) {
- if ( !match[ i ] || match[ i ].length === 0 ) {
- match[ i ] = 1;
- }
- }
- // Time
- for ( i = 6; i <= 15; i++ ) {
- if ( !match[ i ] || match[ i ].length === 0 ) {
- match[ i ] = '0';
- }
- }
- ms = parseFloat( match[ 11 ].replace( /,/, '.' ) ) * 1000;
- hOffset = $.tablesorter.formatInt( match[ 13 ] + match[ 14 ] );
- mOffset = $.tablesorter.formatInt( match[ 13 ] + match[ 15 ] );
-
- isodate = new Date( 0 );
- // Because Date constructor changes year 0-99 to 1900-1999, use setUTCFullYear()
- isodate.setUTCFullYear( match[ 1 ], match[ 2 ] - 1, match[ 4 ] );
- isodate.setUTCHours( match[ 6 ] - hOffset, match[ 8 ] - mOffset, match[ 10 ], ms );
- return isodate.getTime();
- },
- type: 'numeric'
- } );
-
- ts.addParser( {
- id: 'usLongDate',
- is: function ( s ) {
- return ts.rgx.usLongDate[ 0 ].test( s );
- },
- format: function ( s ) {
- return $.tablesorter.formatFloat( new Date( s ).getTime() );
- },
- type: 'numeric'
- } );
-
- ts.addParser( {
- id: 'date',
- is: function ( s ) {
- return ( ts.dateRegex[ 0 ].test( s ) || ts.dateRegex[ 1 ].test( s ) || ts.dateRegex[ 2 ].test( s ) );
- },
- format: function ( s ) {
- var match, y;
- s = s.toLowerCase().trim();
-
- if ( ( match = s.match( ts.dateRegex[ 0 ] ) ) !== null ) {
- if ( mw.config.get( 'wgDefaultDateFormat' ) === 'mdy' || mw.config.get( 'wgPageContentLanguage' ) === 'en' ) {
- s = [ match[ 3 ], match[ 1 ], match[ 2 ] ];
- } else if ( mw.config.get( 'wgDefaultDateFormat' ) === 'dmy' ) {
- s = [ match[ 3 ], match[ 2 ], match[ 1 ] ];
- } else {
- // If we get here, we don't know which order the dd-dd-dddd
- // date is in. So return something not entirely invalid.
- return '99999999';
- }
- } else if ( ( match = s.match( ts.dateRegex[ 1 ] ) ) !== null ) {
- s = [ match[ 3 ], String( ts.monthNames[ match[ 2 ] ] ), match[ 1 ] ];
- } else if ( ( match = s.match( ts.dateRegex[ 2 ] ) ) !== null ) {
- s = [ match[ 3 ], String( ts.monthNames[ match[ 1 ] ] ), match[ 2 ] ];
- } else {
- // Should never get here
- return '99999999';
- }
-
- // Pad Month and Day
- if ( s[ 1 ].length === 1 ) {
- s[ 1 ] = '0' + s[ 1 ];
- }
- if ( s[ 2 ].length === 1 ) {
- s[ 2 ] = '0' + s[ 2 ];
- }
-
- if ( ( y = parseInt( s[ 0 ], 10 ) ) < 100 ) {
- // Guestimate years without centuries
- if ( y < 30 ) {
- s[ 0 ] = 2000 + y;
- } else {
- s[ 0 ] = 1900 + y;
- }
- }
- while ( s[ 0 ].length < 4 ) {
- s[ 0 ] = '0' + s[ 0 ];
- }
- return parseInt( s.join( '' ), 10 );
- },
- type: 'numeric'
- } );
-
- ts.addParser( {
- id: 'time',
- is: function ( s ) {
- return ts.rgx.time[ 0 ].test( s );
- },
- format: function ( s ) {
- return $.tablesorter.formatFloat( new Date( '2000/01/01 ' + s ).getTime() );
- },
- type: 'numeric'
- } );
-
- ts.addParser( {
- id: 'number',
- is: function ( s ) {
- return $.tablesorter.numberRegex.test( s.trim() );
- },
- format: function ( s ) {
- return $.tablesorter.formatDigit( s );
- },
- type: 'numeric'
- } );
-
-}( jQuery, mediaWiki ) );
+++ /dev/null
-@import 'mediawiki.mixins';
-
-/* Table Sorting */
-
-table.jquery-tablesorter {
- th.headerSort {
- .background-image-svg( 'images/sort_both.svg', 'images/sort_both.png' );
- cursor: pointer;
- background-repeat: no-repeat;
- background-position: center right;
- // Note: To avoid reflows, a padding is set in
- // the jquery.tableSorter.styles module as a render blocking style.
- // Please do not add any CSS rules here that impact the positioning of the element
- // e.g. padding, margin, position or float.
- }
-
- th.headerSortUp {
- .background-image-svg( 'images/sort_up.svg', 'images/sort_up.png' );
- }
-
- th.headerSortDown {
- .background-image-svg( 'images/sort_down.svg', 'images/sort_down.png' );
- }
-}
--- /dev/null
+( function ( mw, $ ) {
+ /**
+ * This is an implementation of MessagePoster for wikitext talk pages.
+ *
+ * @class mw.messagePoster.WikitextMessagePoster
+ * @extends mw.messagePoster.MessagePoster
+ *
+ * @constructor
+ * @param {mw.Title} title Wikitext page in a talk namespace, to post to
+ * @param {mw.Api} api mw.Api object to use
+ */
+ function WikitextMessagePoster( title, api ) {
+ this.api = api;
+ this.title = title;
+ }
+
+ OO.inheritClass(
+ WikitextMessagePoster,
+ mw.messagePoster.MessagePoster
+ );
+
+ /**
+ * @inheritdoc
+ */
+ WikitextMessagePoster.prototype.post = function ( subject, body ) {
+ mw.messagePoster.WikitextMessagePoster.parent.prototype.post.call( this, subject, body );
+
+ // Add signature if needed
+ if ( body.indexOf( '~~~' ) === -1 ) {
+ body += '\n\n~~~~';
+ }
+
+ return this.api.newSection(
+ this.title,
+ subject,
+ body,
+ { redirect: true }
+ ).then( function ( resp, jqXHR ) {
+ if ( resp.edit.result === 'Success' ) {
+ return $.Deferred().resolve( resp, jqXHR );
+ } else {
+ // mw.Api checks for response error. Are there actually cases where the
+ // request fails, but it's not caught there?
+ return $.Deferred().reject( 'api-unexpected' );
+ }
+ }, function ( code, details ) {
+ return $.Deferred().reject( 'api-fail', code, details );
+ } ).promise();
+ };
+
+ mw.messagePoster.factory.register( 'wikitext', WikitextMessagePoster );
+ mw.messagePoster.WikitextMessagePoster = WikitextMessagePoster;
+}( mediaWiki, jQuery ) );
--- /dev/null
+( function ( mw ) {
+ /**
+ * This is the abstract base class for MessagePoster implementations.
+ *
+ * @abstract
+ * @class
+ *
+ * @constructor
+ * @param {mw.Title} title Title to post to
+ */
+ mw.messagePoster.MessagePoster = function MwMessagePoster() {};
+
+ OO.initClass( mw.messagePoster.MessagePoster );
+
+ /**
+ * Post a message (with subject and body) to a talk page.
+ *
+ * @abstract
+ * @param {string} subject Subject/topic title. The amount of wikitext supported is
+ * implementation-specific. It is recommended to only use basic wikilink syntax for
+ * maximum compatibility.
+ * @param {string} body Body, as wikitext. Signature code will automatically be added
+ * by MessagePosters that require one, unless the message already contains the string
+ * ~~~.
+ * @return {jQuery.Promise} Promise completing when the post succeeds or fails.
+ * For failure, will be rejected with three arguments:
+ *
+ * - primaryError - Primary error code. For a mw.Api failure,
+ * this should be 'api-fail'.
+ * - secondaryError - Secondary error code. For a mw.Api failure,
+ * this, should be mw.Api's code, e.g. 'http', 'ok-but-empty', or the error passed through
+ * from the server.
+ * - details - Further details about the error
+ *
+ * @localdoc
+ * The base class currently does nothing, but could be used for shared analytics or
+ * something.
+ */
+ mw.messagePoster.MessagePoster.prototype.post = function () {};
+}( mediaWiki ) );
--- /dev/null
+( function ( mw, $ ) {
+ /**
+ * Factory for MessagePoster objects. This provides a pluggable to way to script the action
+ * of adding a message to someone's talk page.
+ *
+ * @class mw.messagePoster.factory
+ * @singleton
+ */
+ function MessagePosterFactory() {
+ this.contentModelToClass = {};
+ }
+
+ OO.initClass( MessagePosterFactory );
+
+ // Note: This registration scheme is currently not compatible with LQT, since that doesn't
+ // have its own content model, just islqttalkpage. LQT pages will be passed to the wikitext
+ // MessagePoster.
+ /**
+ * Register a MessagePoster subclass for a given content model.
+ *
+ * @param {string} contentModel Content model of pages this MessagePoster can post to
+ * @param {Function} constructor Constructor of a MessagePoster subclass
+ */
+ MessagePosterFactory.prototype.register = function ( contentModel, constructor ) {
+ if ( this.contentModelToClass[ contentModel ] !== undefined ) {
+ throw new Error( 'Content model "' + contentModel + '" is already registered' );
+ }
+
+ this.contentModelToClass[ contentModel ] = constructor;
+ };
+
+ /**
+ * Unregister a given content model.
+ * This is exposed for testing and should not normally be used.
+ *
+ * @param {string} contentModel Content model to unregister
+ */
+ MessagePosterFactory.prototype.unregister = function ( contentModel ) {
+ delete this.contentModelToClass[ contentModel ];
+ };
+
+ /**
+ * Create a MessagePoster for given a title.
+ *
+ * A promise for this is returned. It works by determining the content model, then loading
+ * the corresponding module (which registers the MessagePoster class), and finally constructing
+ * an object for the given title.
+ *
+ * This does not require the message and should be called as soon as possible, so that the
+ * API and ResourceLoader requests run in the background.
+ *
+ * @param {mw.Title} title Title that will be posted to
+ * @param {string} [apiUrl] api.php URL if the title is on another wiki
+ * @return {jQuery.Promise} Promise resolving to a mw.messagePoster.MessagePoster.
+ * For failure, rejected with up to three arguments:
+ *
+ * - errorCode Error code string
+ * - error Error explanation
+ * - details Further error details
+ */
+ MessagePosterFactory.prototype.create = function ( title, apiUrl ) {
+ var factory = this,
+ api = apiUrl ? new mw.ForeignApi( apiUrl ) : new mw.Api();
+
+ return api.get( {
+ formatversion: 2,
+ action: 'query',
+ prop: 'info',
+ titles: title.getPrefixedDb()
+ } ).then( function ( data ) {
+ var contentModel, moduleName, page = data.query.pages[ 0 ];
+ if ( !page ) {
+ return $.Deferred().reject( 'unexpected-response', 'Unexpected API response' );
+ }
+ contentModel = page.contentmodel;
+ moduleName = 'mediawiki.messagePoster.' + contentModel;
+ return mw.loader.using( moduleName ).then( function () {
+ return factory.createForContentModel(
+ contentModel,
+ title,
+ api
+ );
+ }, function () {
+ return $.Deferred().reject( 'failed-to-load-module', 'Failed to load "' + moduleName + '"' );
+ } );
+ }, function ( error, details ) {
+ return $.Deferred().reject( 'content-model-query-failed', error, details );
+ } );
+ };
+
+ /**
+ * Creates a MessagePoster instance, given a title and content model
+ *
+ * @private
+ * @param {string} contentModel Content model of title
+ * @param {mw.Title} title Title being posted to
+ * @param {mw.Api} api mw.Api instance that the instance should use
+ * @return {mw.messagePoster.MessagePoster}
+ */
+ MessagePosterFactory.prototype.createForContentModel = function ( contentModel, title, api ) {
+ return new this.contentModelToClass[ contentModel ]( title, api );
+ };
+
+ mw.messagePoster = {
+ factory: new MessagePosterFactory()
+ };
+}( mediaWiki, jQuery ) );
+++ /dev/null
-( function ( mw ) {
- /**
- * This is the abstract base class for MessagePoster implementations.
- *
- * @abstract
- * @class
- *
- * @constructor
- * @param {mw.Title} title Title to post to
- */
- mw.messagePoster.MessagePoster = function MwMessagePoster() {};
-
- OO.initClass( mw.messagePoster.MessagePoster );
-
- /**
- * Post a message (with subject and body) to a talk page.
- *
- * @abstract
- * @param {string} subject Subject/topic title. The amount of wikitext supported is
- * implementation-specific. It is recommended to only use basic wikilink syntax for
- * maximum compatibility.
- * @param {string} body Body, as wikitext. Signature code will automatically be added
- * by MessagePosters that require one, unless the message already contains the string
- * ~~~.
- * @return {jQuery.Promise} Promise completing when the post succeeds or fails.
- * For failure, will be rejected with three arguments:
- *
- * - primaryError - Primary error code. For a mw.Api failure,
- * this should be 'api-fail'.
- * - secondaryError - Secondary error code. For a mw.Api failure,
- * this, should be mw.Api's code, e.g. 'http', 'ok-but-empty', or the error passed through
- * from the server.
- * - details - Further details about the error
- *
- * @localdoc
- * The base class currently does nothing, but could be used for shared analytics or
- * something.
- */
- mw.messagePoster.MessagePoster.prototype.post = function () {};
-}( mediaWiki ) );
+++ /dev/null
-( function ( mw, $ ) {
- /**
- * This is an implementation of MessagePoster for wikitext talk pages.
- *
- * @class mw.messagePoster.WikitextMessagePoster
- * @extends mw.messagePoster.MessagePoster
- *
- * @constructor
- * @param {mw.Title} title Wikitext page in a talk namespace, to post to
- * @param {mw.Api} api mw.Api object to use
- */
- function WikitextMessagePoster( title, api ) {
- this.api = api;
- this.title = title;
- }
-
- OO.inheritClass(
- WikitextMessagePoster,
- mw.messagePoster.MessagePoster
- );
-
- /**
- * @inheritdoc
- */
- WikitextMessagePoster.prototype.post = function ( subject, body ) {
- mw.messagePoster.WikitextMessagePoster.parent.prototype.post.call( this, subject, body );
-
- // Add signature if needed
- if ( body.indexOf( '~~~' ) === -1 ) {
- body += '\n\n~~~~';
- }
-
- return this.api.newSection(
- this.title,
- subject,
- body,
- { redirect: true }
- ).then( function ( resp, jqXHR ) {
- if ( resp.edit.result === 'Success' ) {
- return $.Deferred().resolve( resp, jqXHR );
- } else {
- // mw.Api checks for response error. Are there actually cases where the
- // request fails, but it's not caught there?
- return $.Deferred().reject( 'api-unexpected' );
- }
- }, function ( code, details ) {
- return $.Deferred().reject( 'api-fail', code, details );
- } ).promise();
- };
-
- mw.messagePoster.factory.register( 'wikitext', WikitextMessagePoster );
- mw.messagePoster.WikitextMessagePoster = WikitextMessagePoster;
-}( mediaWiki, jQuery ) );
+++ /dev/null
-( function ( mw, $ ) {
- /**
- * Factory for MessagePoster objects. This provides a pluggable to way to script the action
- * of adding a message to someone's talk page.
- *
- * @class mw.messagePoster.factory
- * @singleton
- */
- function MessagePosterFactory() {
- this.contentModelToClass = {};
- }
-
- OO.initClass( MessagePosterFactory );
-
- // Note: This registration scheme is currently not compatible with LQT, since that doesn't
- // have its own content model, just islqttalkpage. LQT pages will be passed to the wikitext
- // MessagePoster.
- /**
- * Register a MessagePoster subclass for a given content model.
- *
- * @param {string} contentModel Content model of pages this MessagePoster can post to
- * @param {Function} constructor Constructor of a MessagePoster subclass
- */
- MessagePosterFactory.prototype.register = function ( contentModel, constructor ) {
- if ( this.contentModelToClass[ contentModel ] !== undefined ) {
- throw new Error( 'Content model "' + contentModel + '" is already registered' );
- }
-
- this.contentModelToClass[ contentModel ] = constructor;
- };
-
- /**
- * Unregister a given content model.
- * This is exposed for testing and should not normally be used.
- *
- * @param {string} contentModel Content model to unregister
- */
- MessagePosterFactory.prototype.unregister = function ( contentModel ) {
- delete this.contentModelToClass[ contentModel ];
- };
-
- /**
- * Create a MessagePoster for given a title.
- *
- * A promise for this is returned. It works by determining the content model, then loading
- * the corresponding module (which registers the MessagePoster class), and finally constructing
- * an object for the given title.
- *
- * This does not require the message and should be called as soon as possible, so that the
- * API and ResourceLoader requests run in the background.
- *
- * @param {mw.Title} title Title that will be posted to
- * @param {string} [apiUrl] api.php URL if the title is on another wiki
- * @return {jQuery.Promise} Promise resolving to a mw.messagePoster.MessagePoster.
- * For failure, rejected with up to three arguments:
- *
- * - errorCode Error code string
- * - error Error explanation
- * - details Further error details
- */
- MessagePosterFactory.prototype.create = function ( title, apiUrl ) {
- var factory = this,
- api = apiUrl ? new mw.ForeignApi( apiUrl ) : new mw.Api();
-
- return api.get( {
- formatversion: 2,
- action: 'query',
- prop: 'info',
- titles: title.getPrefixedDb()
- } ).then( function ( data ) {
- var contentModel, moduleName, page = data.query.pages[ 0 ];
- if ( !page ) {
- return $.Deferred().reject( 'unexpected-response', 'Unexpected API response' );
- }
- contentModel = page.contentmodel;
- moduleName = 'mediawiki.messagePoster.' + contentModel;
- return mw.loader.using( moduleName ).then( function () {
- return factory.createForContentModel(
- contentModel,
- title,
- api
- );
- }, function () {
- return $.Deferred().reject( 'failed-to-load-module', 'Failed to load "' + moduleName + '"' );
- } );
- }, function ( error, details ) {
- return $.Deferred().reject( 'content-model-query-failed', error, details );
- } );
- };
-
- /**
- * Creates a MessagePoster instance, given a title and content model
- *
- * @private
- * @param {string} contentModel Content model of title
- * @param {mw.Title} title Title being posted to
- * @param {mw.Api} api mw.Api instance that the instance should use
- * @return {mw.messagePoster.MessagePoster}
- */
- MessagePosterFactory.prototype.createForContentModel = function ( contentModel, title, api ) {
- return new this.contentModelToClass[ contentModel ]( title, api );
- };
-
- mw.messagePoster = {
- factory: new MessagePosterFactory()
- };
-}( mediaWiki, jQuery ) );
* @param {Function} [callback] Callback to run after request resolution
*/
function addScript( src, callback ) {
- var promise = $.ajax( {
- url: src,
- dataType: 'script',
- // Force jQuery behaviour to be for crossDomain. Otherwise jQuery would use
- // XHR for a same domain request instead of <script>, which changes the request
- // headers (potentially missing a cache hit), and reduces caching in general
- // since browsers cache XHR much less (if at all). And XHR means we retrieve
- // text, so we'd need to $.globalEval, which then messes up line numbers.
- crossDomain: true,
- cache: true
- } );
-
- if ( callback ) {
- promise.always( callback );
- }
+ var script = document.createElement( 'script' );
+ script.src = src;
+ script.onload = script.onerror = function () {
+ if ( script.parentNode ) {
+ script.parentNode.removeChild( script );
+ }
+ script = null;
+ if ( callback ) {
+ callback();
+ callback = null;
+ }
+ };
+ document.head.appendChild( script );
}
/**
namespace MediaWiki\Tests\Storage;
+use InvalidArgumentException;
use MediaWiki\Storage\MutableRevisionSlots;
use MediaWiki\Storage\RevisionAccessException;
+use MediaWiki\Storage\RevisionSlots;
use MediaWiki\Storage\SlotRecord;
use WikitextContent;
*/
class MutableRevisionSlotsTest extends RevisionSlotsTest {
+ /**
+ * @param SlotRecord[] $slots
+ * @return RevisionSlots
+ */
+ protected function newRevisionSlots( $slots = [] ) {
+ return new MutableRevisionSlots( $slots );
+ }
+
+ public function provideConstructorFailue() {
+ yield 'array or the wrong thing' => [
+ [ 1, 2, 3 ]
+ ];
+ }
+
+ /**
+ * @dataProvider provideConstructorFailue
+ * @param $slots
+ *
+ * @covers \MediaWiki\Storage\RevisionSlots::__construct
+ * @covers \MediaWiki\Storage\RevisionSlots::setSlotsInternal
+ */
+ public function testConstructorFailue( $slots ) {
+ $this->setExpectedException( InvalidArgumentException::class );
+
+ new MutableRevisionSlots( $slots );
+ }
+
public function testSetMultipleSlots() {
$slots = new MutableRevisionSlots();
namespace MediaWiki\Tests\Storage;
+use InvalidArgumentException;
use MediaWiki\Storage\RevisionAccessException;
use MediaWiki\Storage\RevisionSlots;
use MediaWiki\Storage\SlotRecord;
use MediaWikiTestCase;
+use TextContent;
use WikitextContent;
class RevisionSlotsTest extends MediaWikiTestCase {
return new RevisionSlots( $slots );
}
+ public function provideConstructorFailue() {
+ yield 'not an array or callable' => [
+ 'foo'
+ ];
+ yield 'array of the wrong thing' => [
+ [ 1, 2, 3 ]
+ ];
+ }
+
+ /**
+ * @dataProvider provideConstructorFailue
+ * @param $slots
+ *
+ * @covers \MediaWiki\Storage\RevisionSlots::__construct
+ * @covers \MediaWiki\Storage\RevisionSlots::setSlotsInternal
+ */
+ public function testConstructorFailue( $slots ) {
+ $this->setExpectedException( InvalidArgumentException::class );
+
+ new RevisionSlots( $slots );
+ }
+
/**
* @covers \MediaWiki\Storage\RevisionSlots::getSlot
*/
$this->assertEquals( [ 'main' => $mainSlot, 'aux' => $auxSlot ], $slots->getSlots() );
}
+ /**
+ * @covers \MediaWiki\Storage\RevisionSlots::getInheritedSlots
+ */
+ public function testGetInheritedSlots() {
+ $mainSlot = SlotRecord::newUnsaved( 'main', new WikitextContent( 'A' ) );
+ $auxSlot = SlotRecord::newInherited(
+ SlotRecord::newSaved(
+ 7, 7, 'foo',
+ SlotRecord::newUnsaved( 'aux', new WikitextContent( 'B' ) )
+ )
+ );
+ $slotsArray = [ $mainSlot, $auxSlot ];
+ $slots = $this->newRevisionSlots( $slotsArray );
+
+ $this->assertEquals( [ 'aux' => $auxSlot ], $slots->getInheritedSlots() );
+ }
+
+ /**
+ * @covers \MediaWiki\Storage\RevisionSlots::getTouchedSlots
+ */
+ public function testGetTouchedSlots() {
+ $mainSlot = SlotRecord::newUnsaved( 'main', new WikitextContent( 'A' ) );
+ $auxSlot = SlotRecord::newInherited(
+ SlotRecord::newSaved(
+ 7, 7, 'foo',
+ SlotRecord::newUnsaved( 'aux', new WikitextContent( 'B' ) )
+ )
+ );
+ $slotsArray = [ $mainSlot, $auxSlot ];
+ $slots = $this->newRevisionSlots( $slotsArray );
+
+ $this->assertEquals( [ 'main' => $mainSlot ], $slots->getTouchedSlots() );
+ }
+
public function provideComputeSize() {
yield [ 1, [ 'A' ] ];
yield [ 2, [ 'AA' ] ];
$this->assertSame( $expected, $slots->computeSha1() );
}
+ public function provideHasSameContent() {
+ $fooX = SlotRecord::newUnsaved( 'x', new TextContent( 'Foo' ) );
+ $barZ = SlotRecord::newUnsaved( 'z', new TextContent( 'Bar' ) );
+ $fooY = SlotRecord::newUnsaved( 'y', new TextContent( 'Foo' ) );
+ $barZS = SlotRecord::newSaved( 7, 7, 'xyz', $barZ );
+ $barZ2 = SlotRecord::newUnsaved( 'z', new TextContent( 'Baz' ) );
+
+ $a = $this->newRevisionSlots( [ 'x' => $fooX, 'z' => $barZ ] );
+ $a2 = $this->newRevisionSlots( [ 'x' => $fooX, 'z' => $barZ ] );
+ $a3 = $this->newRevisionSlots( [ 'x' => $fooX, 'z' => $barZS ] );
+ $b = $this->newRevisionSlots( [ 'y' => $fooY, 'z' => $barZ ] );
+ $c = $this->newRevisionSlots( [ 'x' => $fooX, 'z' => $barZ2 ] );
+
+ yield 'same instance' => [ $a, $a, true ];
+ yield 'same slots' => [ $a, $a2, true ];
+ yield 'same content' => [ $a, $a3, true ];
+
+ yield 'different roles' => [ $a, $b, false ];
+ yield 'different content' => [ $a, $c, false ];
+ }
+
+ /**
+ * @dataProvider provideHasSameContent
+ * @covers \MediaWiki\Storage\RevisionSlots::hasSameContent
+ */
+ public function testHasSameContent( RevisionSlots $a, RevisionSlots $b, $same ) {
+ $this->assertSame( $same, $a->hasSameContent( $b ) );
+ $this->assertSame( $same, $b->hasSameContent( $a ) );
+ }
+
}
--- /dev/null
+<?php
+
+namespace MediaWiki\Tests\Storage;
+
+use MediaWiki\Storage\RevisionSlots;
+use MediaWiki\Storage\RevisionSlotsUpdate;
+use MediaWiki\Storage\RevisionAccessException;
+use MediaWiki\Storage\SlotRecord;
+use MediaWikiTestCase;
+use WikitextContent;
+
+/**
+ * @covers \MediaWiki\Storage\RevisionSlotsUpdate
+ */
+class RevisionSlotsUpdateTest extends MediaWikiTestCase {
+
+ public function provideNewFromRevisionSlots() {
+ $slotA = SlotRecord::newUnsaved( 'A', new WikitextContent( 'A' ) );
+ $slotB = SlotRecord::newUnsaved( 'B', new WikitextContent( 'B' ) );
+ $slotC = SlotRecord::newUnsaved( 'C', new WikitextContent( 'C' ) );
+
+ $slotB2 = SlotRecord::newUnsaved( 'B', new WikitextContent( 'B2' ) );
+
+ $parentSlots = new RevisionSlots( [
+ 'A' => $slotA,
+ 'B' => $slotB,
+ 'C' => $slotC,
+ ] );
+
+ $newSlots = new RevisionSlots( [
+ 'A' => $slotA,
+ 'B' => $slotB2,
+ ] );
+
+ yield [ $newSlots, null, [ 'A', 'B' ], [] ];
+ yield [ $newSlots, $parentSlots, [ 'B' ], [ 'C' ] ];
+ }
+
+ /**
+ * @dataProvider provideNewFromRevisionSlots
+ *
+ * @param RevisionSlots $newSlots
+ * @param RevisionSlots $parentSlots
+ * @param $modified
+ * @param $removed
+ */
+ public function testNewFromRevisionSlots(
+ RevisionSlots $newSlots,
+ RevisionSlots $parentSlots = null,
+ array $modified = [],
+ array $removed = []
+ ) {
+ $update = RevisionSlotsUpdate::newFromRevisionSlots( $newSlots, $parentSlots );
+
+ $this->assertEquals( $modified, $update->getModifiedRoles() );
+ $this->assertEquals( $removed, $update->getRemovedRoles() );
+
+ foreach ( $modified as $role ) {
+ $this->assertSame( $newSlots->getSlot( $role ), $update->getModifiedSlot( $role ) );
+ }
+ }
+
+ public function testConstructor() {
+ $update = new RevisionSlotsUpdate();
+
+ $this->assertEmpty( $update->getModifiedRoles() );
+ $this->assertEmpty( $update->getRemovedRoles() );
+
+ $slotA = SlotRecord::newUnsaved( 'A', new WikitextContent( 'A' ) );
+ $update = new RevisionSlotsUpdate( [ 'A' => $slotA ] );
+
+ $this->assertEquals( [ 'A' ], $update->getModifiedRoles() );
+ $this->assertEmpty( $update->getRemovedRoles() );
+
+ $update = new RevisionSlotsUpdate( [ 'A' => $slotA ], [ 'X' ] );
+
+ $this->assertEquals( [ 'A' ], $update->getModifiedRoles() );
+ $this->assertEquals( [ 'X' ], $update->getRemovedRoles() );
+ }
+
+ public function testModifySlot() {
+ $slots = new RevisionSlotsUpdate();
+
+ $this->assertSame( [], $slots->getModifiedRoles() );
+ $this->assertSame( [], $slots->getRemovedRoles() );
+
+ $slotA = SlotRecord::newUnsaved( 'some', new WikitextContent( 'A' ) );
+ $slots->modifySlot( $slotA );
+ $this->assertTrue( $slots->isModifiedSlot( 'some' ) );
+ $this->assertFalse( $slots->isRemovedSlot( 'some' ) );
+ $this->assertSame( $slotA, $slots->getModifiedSlot( 'some' ) );
+ $this->assertSame( [ 'some' ], $slots->getModifiedRoles() );
+ $this->assertSame( [], $slots->getRemovedRoles() );
+
+ $slotB = SlotRecord::newUnsaved( 'other', new WikitextContent( 'B' ) );
+ $slots->modifySlot( $slotB );
+ $this->assertTrue( $slots->isModifiedSlot( 'other' ) );
+ $this->assertFalse( $slots->isRemovedSlot( 'other' ) );
+ $this->assertSame( $slotB, $slots->getModifiedSlot( 'other' ) );
+ $this->assertSame( [ 'some', 'other' ], $slots->getModifiedRoles() );
+ $this->assertSame( [], $slots->getRemovedRoles() );
+
+ // modify slot A again
+ $slots->modifySlot( $slotA );
+ $this->assertArrayEquals( [ 'some', 'other' ], $slots->getModifiedRoles() );
+
+ // remove modified slot
+ $slots->removeSlot( 'some' );
+ $this->assertSame( [ 'other' ], $slots->getModifiedRoles() );
+ $this->assertSame( [ 'some' ], $slots->getRemovedRoles() );
+
+ // modify removed slot
+ $slots->modifySlot( $slotA );
+ $this->assertArrayEquals( [ 'some', 'other' ], $slots->getModifiedRoles() );
+ $this->assertSame( [], $slots->getRemovedRoles() );
+ }
+
+ public function testRemoveSlot() {
+ $slots = new RevisionSlotsUpdate();
+
+ $slotA = SlotRecord::newUnsaved( 'main', new WikitextContent( 'A' ) );
+ $slots->modifySlot( $slotA );
+
+ $this->assertSame( [ 'main' ], $slots->getModifiedRoles() );
+
+ $slots->removeSlot( 'main' );
+ $slots->removeSlot( 'other' );
+ $this->assertSame( [], $slots->getModifiedRoles() );
+ $this->assertSame( [ 'main', 'other' ], $slots->getRemovedRoles() );
+ $this->assertTrue( $slots->isRemovedSlot( 'main' ) );
+ $this->assertTrue( $slots->isRemovedSlot( 'other' ) );
+ $this->assertFalse( $slots->isModifiedSlot( 'main' ) );
+
+ // removing the same slot again should not trigger an error
+ $slots->removeSlot( 'main' );
+
+ // getting a slot after removing it should fail
+ $this->setExpectedException( RevisionAccessException::class );
+ $slots->getModifiedSlot( 'main' );
+ }
+
+ public function testGetModifiedRoles() {
+ $slots = new RevisionSlotsUpdate( [], [ 'xyz' ] );
+
+ $this->assertSame( [], $slots->getModifiedRoles() );
+
+ $slots->modifyContent( 'main', new WikitextContent( 'A' ) );
+ $slots->modifyContent( 'foo', new WikitextContent( 'Foo' ) );
+ $this->assertSame( [ 'main', 'foo' ], $slots->getModifiedRoles() );
+
+ $slots->removeSlot( 'main' );
+ $this->assertSame( [ 'foo' ], $slots->getModifiedRoles() );
+ }
+
+ public function testGetRemovedRoles() {
+ $slotA = SlotRecord::newUnsaved( 'main', new WikitextContent( 'A' ) );
+ $slots = new RevisionSlotsUpdate( [ $slotA ] );
+
+ $this->assertSame( [], $slots->getRemovedRoles() );
+
+ $slots->removeSlot( 'main', new WikitextContent( 'A' ) );
+ $slots->removeSlot( 'foo', new WikitextContent( 'Foo' ) );
+
+ $this->assertSame( [ 'main', 'foo' ], $slots->getRemovedRoles() );
+
+ $slots->modifyContent( 'main', new WikitextContent( 'A' ) );
+ $this->assertSame( [ 'foo' ], $slots->getRemovedRoles() );
+ }
+
+ public function provideHasSameUpdates() {
+ $fooX = SlotRecord::newUnsaved( 'x', new WikitextContent( 'Foo' ) );
+ $barZ = SlotRecord::newUnsaved( 'z', new WikitextContent( 'Bar' ) );
+
+ $a = new RevisionSlotsUpdate();
+ $a->modifySlot( $fooX );
+ $a->modifySlot( $barZ );
+ $a->removeSlot( 'Q' );
+
+ $a2 = new RevisionSlotsUpdate();
+ $a2->modifySlot( $fooX );
+ $a2->modifySlot( $barZ );
+ $a2->removeSlot( 'Q' );
+
+ $b = new RevisionSlotsUpdate();
+ $b->modifySlot( $barZ );
+ $b->removeSlot( 'Q' );
+
+ $c = new RevisionSlotsUpdate();
+ $c->modifySlot( $fooX );
+ $c->modifySlot( $barZ );
+
+ yield 'same instance' => [ $a, $a, true ];
+ yield 'same udpates' => [ $a, $a2, true ];
+
+ yield 'different modified' => [ $a, $b, false ];
+ yield 'different removed' => [ $a, $c, false ];
+ }
+
+ /**
+ * @dataProvider provideHasSameUpdates
+ */
+ public function testHasSameUpdates( RevisionSlotsUpdate $a, RevisionSlotsUpdate $b, $same ) {
+ $this->assertSame( $same, $a->hasSameUpdates( $b ) );
+ $this->assertSame( $same, $b->hasSameUpdates( $a ) );
+ }
+
+}
SlotRecord::newSaved( $revisionId, $contentId, $contentAddress, $protoSlot );
}
+ public function provideHasSameContent() {
+ $fail = function () {
+ self::fail( 'There should be no need to actually load the content.' );
+ };
+
+ $a100a1 = new SlotRecord(
+ $this->makeRow(
+ [
+ 'model_name' => 'A',
+ 'content_size' => 100,
+ 'content_sha1' => 'hash-a',
+ 'content_address' => 'xxx:a1',
+ ]
+ ),
+ $fail
+ );
+ $a100a1b = new SlotRecord(
+ $this->makeRow(
+ [
+ 'model_name' => 'A',
+ 'content_size' => 100,
+ 'content_sha1' => 'hash-a',
+ 'content_address' => 'xxx:a1',
+ ]
+ ),
+ $fail
+ );
+ $a100null = new SlotRecord(
+ $this->makeRow(
+ [
+ 'model_name' => 'A',
+ 'content_size' => 100,
+ 'content_sha1' => 'hash-a',
+ 'content_address' => null,
+ ]
+ ),
+ $fail
+ );
+ $a100a2 = new SlotRecord(
+ $this->makeRow(
+ [
+ 'model_name' => 'A',
+ 'content_size' => 100,
+ 'content_sha1' => 'hash-a',
+ 'content_address' => 'xxx:a2',
+ ]
+ ),
+ $fail
+ );
+ $b100a1 = new SlotRecord(
+ $this->makeRow(
+ [
+ 'model_name' => 'B',
+ 'content_size' => 100,
+ 'content_sha1' => 'hash-a',
+ 'content_address' => 'xxx:a1',
+ ]
+ ),
+ $fail
+ );
+ $a200a1 = new SlotRecord(
+ $this->makeRow(
+ [
+ 'model_name' => 'A',
+ 'content_size' => 200,
+ 'content_sha1' => 'hash-a',
+ 'content_address' => 'xxx:a2',
+ ]
+ ),
+ $fail
+ );
+ $a100x1 = new SlotRecord(
+ $this->makeRow(
+ [
+ 'model_name' => 'A',
+ 'content_size' => 100,
+ 'content_sha1' => 'hash-x',
+ 'content_address' => 'xxx:x1',
+ ]
+ ),
+ $fail
+ );
+
+ yield 'same instance' => [ $a100a1, $a100a1, true ];
+ yield 'no address' => [ $a100a1, $a100null, true ];
+ yield 'same address' => [ $a100a1, $a100a1b, true ];
+ yield 'different address' => [ $a100a1, $a100a2, true ];
+ yield 'different model' => [ $a100a1, $b100a1, false ];
+ yield 'different size' => [ $a100a1, $a200a1, false ];
+ yield 'different hash' => [ $a100a1, $a100x1, false ];
+ }
+
+ /**
+ * @dataProvider provideHasSameContent
+ */
+ public function testHasSameContent( SlotRecord $a, SlotRecord $b, $sameContent ) {
+ $this->assertSame( $sameContent, $a->hasSameContent( $b ) );
+ $this->assertSame( $sameContent, $b->hasSameContent( $a ) );
+ }
+
}
DISPLAY=1 npm run selenium
-To run only one file (for example page.js), you first need to spawn the chromedriver:
+To run only one test (for example specs/page.js), you first need to start Chromedriver:
chromedriver --url-base=wd/hub --port=4444
-Then in another terminal:
+Then, in another terminal:
- cd tests/selenium
- ../../node_modules/.bin/wdio --spec specs/page.js
+ npm run selenium-test -- --spec tests/selenium/specs/page.js
-To run only one test (name contains string 'preferences'):
+You can also filter specific cases, for ones that contain the string 'preferences':
- ../../node_modules/.bin/wdio --spec specs/user.js --mochaOpts.grep preferences
+ npm run selenium-test -- tests/selenium/specs/user.js --mochaOpts.grep preferences
-The runner reads the config file `wdio.conf.js` and runs the spec listed in
-`page.js`.
-
-The defaults in the configuration files aim are targeting a MediaWiki-Vagrant
-installation on http://127.0.0.1:8080 with a user Admin and
-password 'vagrant'. Those settings can be overridden using environment
+The runner reads the configuration from `wdio.conf.js`. The defaults target
+a MediaWiki-Vagrant installation on `http://127.0.0.1:8080` with a user "Admin"
+and password "vagrant". Those settings can be overridden using environment
variables:
-`MW_SERVER`: to be set to the value of your $wgServer
-`MW_SCRIPT_PATH`: ditto with $wgScriptPath
-`MEDIAWIKI_USER`: username of an account that can create users on the wiki
-`MEDIAWIKI_PASSWORD`: password for above user
+- `MW_SERVER`: to be set to the value of your $wgServer
+- `MW_SCRIPT_PATH`: ditto with $wgScriptPath
+- `MEDIAWIKI_USER`: username of an account that can create users on the wiki
+- `MEDIAWIKI_PASSWORD`: password for above user
Example:
MW_SERVER=http://example.org MW_SCRIPT_PATH=/dev/w npm run selenium
-## Links
+## Further reading
- [Selenium/Node.js](https://www.mediawiki.org/wiki/Selenium/Node.js)
-const Page = require( './page' ),
- // https://github.com/Fannon/mwbot
- MWBot = require( 'mwbot' );
+const Page = require( 'wdio-mediawiki/Page' ),
+ Api = require( 'wdio-mediawiki/Api' );
class CreateAccountPage extends Page {
-
get username() { return browser.element( '#wpName2' ); }
get password() { return browser.element( '#wpPassword2' ); }
get confirmPassword() { return browser.element( '#wpRetype' ); }
get heading() { return browser.element( '#firstHeading' ); }
open() {
- super.open( 'Special:CreateAccount' );
+ super.openTitle( 'Special:CreateAccount' );
}
createAccount( username, password ) {
this.create.click();
}
+ // @deprecated Use wdio-mediawiki/Api#createAccount() instead.
apiCreateAccount( username, password ) {
- let bot = new MWBot();
-
- return bot.loginGetCreateaccountToken( {
- apiUrl: `${browser.options.baseUrl}/api.php`,
- username: browser.options.username,
- password: browser.options.password
- } ).then( function () {
- return bot.request( {
- action: 'createaccount',
- createreturnurl: browser.options.baseUrl,
- createtoken: bot.createaccountToken,
- username: username,
- password: password,
- retype: password
- } );
- } );
+ return Api.createAccount( username, password );
}
}
-const Page = require( './page' ),
- // https://github.com/Fannon/mwbot
- MWBot = require( 'mwbot' );
+const Page = require( 'wdio-mediawiki/Page' ),
+ Api = require( 'wdio-mediawiki/Api' );
class DeletePage extends Page {
get reason() { return browser.element( '#wpReason' ); }
get submit() { return browser.element( '#wpConfirmB' ); }
get displayedContent() { return browser.element( '#mw-content-text' ); }
- open( name ) {
- super.open( name + '&action=delete' );
+ open( title ) {
+ super.openTitle( title, { action: 'delete' } );
}
- delete( name, reason ) {
- this.open( name );
+ delete( title, reason ) {
+ this.open( title );
this.reason.setValue( reason );
this.submit.click();
}
+ // @deprecated Use wdio-mediawiki/Api#delete() instead.
apiDelete( name, reason ) {
- let bot = new MWBot();
-
- return bot.loginGetEditToken( {
- apiUrl: `${browser.options.baseUrl}/api.php`,
- username: browser.options.username,
- password: browser.options.password
- } ).then( function () {
- return bot.delete( name, reason );
- } );
+ return Api.delete( name, reason );
}
}
-const Page = require( './page' ),
- // https://github.com/Fannon/mwbot
- MWBot = require( 'mwbot' );
+const Page = require( 'wdio-mediawiki/Page' ),
+ Api = require( 'wdio-mediawiki/Api' );
class EditPage extends Page {
get content() { return browser.element( '#wpTextbox1' ); }
get heading() { return browser.element( '#firstHeading' ); }
get save() { return browser.element( '#wpSave' ); }
- openForEditing( name ) {
- super.open( name + '&action=edit' );
+ openForEditing( title ) {
+ super.openTitle( title, { action: 'edit' } );
}
edit( name, content ) {
this.save.click();
}
+ // @deprecated Use wdio-mediawiki/Api#edit() instead.
apiEdit( name, content ) {
- let bot = new MWBot();
-
- return bot.loginGetEditToken( {
- apiUrl: `${browser.options.baseUrl}/api.php`,
- username: browser.options.username,
- password: browser.options.password
- } ).then( function () {
- return bot.edit( name, content, `Created page with "${content}"` );
- } );
+ return Api.edit( name, content );
}
}
-const Page = require( './page' );
+const Page = require( 'wdio-mediawiki/Page' );
class HistoryPage extends Page {
get comment() { return browser.element( '#pagehistory .comment' ); }
- open( name ) {
- super.open( name + '&action=history' );
+ open( title ) {
+ super.openTitle( title, { action: 'history' } );
}
}
+const Page = require( 'wdio-mediawiki/Page' );
+
/**
- * Based on http://webdriver.io/guide/testrunner/pageobjects.html
+ * @deprecated Use wdio-mediawiki/Page and openTitle() instead.
*/
-
-class Page {
+class LegacyPage extends Page {
open( path ) {
browser.url( browser.options.baseUrl + '/index.php?title=' + path );
}
}
-module.exports = Page;
+module.exports = LegacyPage;
-const Page = require( './page' );
+const Page = require( 'wdio-mediawiki/Page' );
class PreferencesPage extends Page {
get realName() { return browser.element( '#mw-input-wprealname' ); }
get save() { return browser.element( '#prefcontrol' ); }
open() {
- super.open( 'Special:Preferences' );
+ super.openTitle( 'Special:Preferences' );
}
changeRealName( realName ) {
-const Page = require( './page' );
+const Page = require( 'wdio-mediawiki/Page' );
class RestorePage extends Page {
-
get reason() { return browser.element( '#wpComment' ); }
get submit() { return browser.element( '#mw-undelete-submit' ); }
get displayedContent() { return browser.element( '#mw-content-text' ); }
- open( name ) {
- super.open( 'Special:Undelete/' + name );
+ open( subject ) {
+ super.openTitle( 'Special:Undelete/' + subject );
}
- restore( name, reason ) {
- this.open( name );
+ restore( subject, reason ) {
+ this.open( subject );
this.reason.setValue( reason );
this.submit.click();
}
-const Page = require( './page' );
+const LoginPage = require( 'wdio-mediawiki/LoginPage' );
-class UserLoginPage extends Page {
- get username() { return browser.element( '#wpName1' ); }
- get password() { return browser.element( '#wpPassword1' ); }
- get loginButton() { return browser.element( '#wpLoginAttempt' ); }
- get userPage() { return browser.element( '#pt-userpage' ); }
-
- open() {
- super.open( 'Special:UserLogin' );
- }
-
- login( username, password ) {
- this.open();
- this.username.setValue( username );
- this.password.setValue( password );
- this.loginButton.click();
- }
-
- loginAdmin() {
- this.login( browser.options.username, browser.options.password );
- }
-}
-
-module.exports = new UserLoginPage();
+/**
+ * @deprecated Use wdio-mediawiki/LoginPage instead.
+ */
+module.exports = LoginPage;
const assert = require( 'assert' ),
+ Api = require( 'wdio-mediawiki/Api' ),
DeletePage = require( '../pageobjects/delete.page' ),
RestorePage = require( '../pageobjects/restore.page' ),
EditPage = require( '../pageobjects/edit.page' ),
// create
browser.call( function () {
- return EditPage.apiEdit( name, initialContent );
+ return Api.edit( name, initialContent );
} );
// delete
browser.call( function () {
- return DeletePage.apiDelete( name, 'delete prior to recreate' );
+ return Api.delete( name, 'delete prior to recreate' );
} );
// create
// check
assert.equal( EditPage.heading.getText(), name );
assert.equal( EditPage.displayedContent.getText(), content );
-
} );
it( 'should be editable', function () {
// create
browser.call( function () {
- return EditPage.apiEdit( name, content );
+ return Api.edit( name, content );
} );
// edit
it( 'should have history', function () {
// create
browser.call( function () {
- return EditPage.apiEdit( name, content );
+ return Api.edit( name, content );
} );
// check
// create
browser.call( function () {
- return EditPage.apiEdit( name, content );
+ return Api.edit( name, content );
} );
// delete
// create
browser.call( function () {
- return EditPage.apiEdit( name, content );
+ return Api.edit( name, content );
} );
// delete
browser.call( function () {
- return DeletePage.apiDelete( name, content + '-deletereason' );
+ return Api.delete( name, content + '-deletereason' );
} );
// restore
const assert = require( 'assert' ),
CreateAccountPage = require( '../pageobjects/createaccount.page' ),
PreferencesPage = require( '../pageobjects/preferences.page' ),
- UserLoginPage = require( '../pageobjects/userlogin.page' );
+ UserLoginPage = require( 'wdio-mediawiki/LoginPage' ),
+ Api = require( 'wdio-mediawiki/Api' );
describe( 'User', function () {
var password,
it( 'should be able to log in', function () {
// create
browser.call( function () {
- return CreateAccountPage.apiCreateAccount( username, password );
+ return Api.createAccount( username, password );
} );
// log in
// create
browser.call( function () {
- return CreateAccountPage.apiCreateAccount( username, password );
+ return Api.createAccount( username, password );
} );
// log in
--- /dev/null
+{
+ "extends": "wikimedia",
+ "env": {
+ "es6": true,
+ "node": true
+ },
+ "globals": {
+ "browser": false
+ }
+}
--- /dev/null
+const MWBot = require( 'mwbot' );
+
+// TODO: Once we require Node 7 or later, we can use async-await.
+
+module.exports = {
+ /**
+ * Shortcut for `MWBot#edit( .. )`.
+ *
+ * @since 1.0.0
+ * @see <https://www.mediawiki.org/wiki/API:Edit>
+ * @param {string} title
+ * @param {string} content
+ * @return {Object} Promise for API action=edit response data.
+ */
+ edit( title, content ) {
+ let bot = new MWBot();
+
+ return bot.loginGetEditToken( {
+ apiUrl: `${browser.options.baseUrl}/api.php`,
+ username: browser.options.username,
+ password: browser.options.password
+ } ).then( function () {
+ return bot.edit( title, content, `Created page with "${content}"` );
+ } );
+ },
+
+ /**
+ * Shortcut for `MWBot#delete( .. )`.
+ *
+ * @since 1.0.0
+ * @see <https://www.mediawiki.org/wiki/API:Delete>
+ * @param {string} title
+ * @param {string} reason
+ * @return {Object} Promise for API action=delete response data.
+ */
+ delete( title, reason ) {
+ let bot = new MWBot();
+
+ return bot.loginGetEditToken( {
+ apiUrl: `${browser.options.baseUrl}/api.php`,
+ username: browser.options.username,
+ password: browser.options.password
+ } ).then( function () {
+ return bot.delete( title, reason );
+ } );
+ },
+
+ /**
+ * Shortcut for `MWBot#request( { acount: 'createaccount', .. } )`.
+ *
+ * @since 1.0.0
+ * @see <https://www.mediawiki.org/wiki/API:Account_creation>
+ * @param {string} username
+ * @param {string} password
+ * @return {Object} Promise for API action=createaccount response data.
+ */
+ createAccount( username, password ) {
+ let bot = new MWBot();
+
+ // Log in as admin
+ return bot.loginGetCreateaccountToken( {
+ apiUrl: `${browser.options.baseUrl}/api.php`,
+ username: browser.options.username,
+ password: browser.options.password
+ } ).then( function () {
+ // Create the new account
+ return bot.request( {
+ action: 'createaccount',
+ createreturnurl: browser.options.baseUrl,
+ createtoken: bot.createaccountToken,
+ username: username,
+ password: password,
+ retype: password
+ } );
+ } );
+ }
+};
--- /dev/null
+const Page = require( 'wdio-mediawiki/Page' );
+
+class BlankPage extends Page {
+ get heading() { return browser.element( '#firstHeading' ); }
+
+ open() {
+ super.openTitle( 'Special:BlankPage', { uselang: 'en' } );
+ }
+}
+
+module.exports = new BlankPage();
--- /dev/null
+# Notable changes
+
+## [Unreleased]
+
+* Api: Added initial version.
+* Page: Added initial version.
+* BlankPage: Added initial version.
+* LoginPage: Added initial version.
--- /dev/null
+Copyright 2018 Željko Filipin
+Copyright 2018 Timo Tijhof
+
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of this software and associated documentation files (the
+"Software"), to deal in the Software without restriction, including
+without limitation the rights to use, copy, modify, merge, publish,
+distribute, sublicense, and/or sell copies of the Software, and to
+permit persons to whom the Software is furnished to do so, subject to
+the following conditions:
+
+The above copyright notice and this permission notice shall be
+included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
+LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
--- /dev/null
+const Page = require( 'wdio-mediawiki/Page' );
+
+class LoginPage extends Page {
+ get username() { return browser.element( '#wpName1' ); }
+ get password() { return browser.element( '#wpPassword1' ); }
+ get loginButton() { return browser.element( '#wpLoginAttempt' ); }
+ get userPage() { return browser.element( '#pt-userpage' ); }
+
+ open() {
+ super.openTitle( 'Special:UserLogin' );
+ }
+
+ login( username, password ) {
+ this.open();
+ this.username.setValue( username );
+ this.password.setValue( password );
+ this.loginButton.click();
+ }
+
+ loginAdmin() {
+ this.login( browser.options.username, browser.options.password );
+ }
+}
+
+module.exports = new LoginPage();
--- /dev/null
+const querystring = require( 'querystring' );
+
+/**
+ * Based on http://webdriver.io/guide/testrunner/pageobjects.html
+ */
+class Page {
+
+ /**
+ * Navigate the browser to a given page.
+ *
+ * @since 1.0.0
+ * @see <http://webdriver.io/api/protocol/url.html>
+ * @param {string} title Page title
+ * @param {Object} [query] Query parameter
+ * @return {void} This method runs a browser command.
+ */
+ openTitle( title, query = {} ) {
+ query.title = title;
+ browser.url( browser.options.baseUrl + '/index.php?' + querystring.stringify( query ) );
+ }
+}
+
+module.exports = Page;
--- /dev/null
+# wdio-mediawiki
+
+A plugin for [WebdriverIO](http://webdriver.io/) providing utilities to simplify testing of MediaWiki features.
+
+## Getting Started
+
+### Page
+
+The `Page` class is a base class for following the [Page Objects Pattern](http://webdriver.io/guide/testrunner/pageobjects.html).
+
+* `openTitle( title [, Object query ] )`
+
+The convention is for implementations to extend this class and provide an `open()` method
+that calls `super.openTitle()`, as well as add various getters for elements on the page.
+
+See [BlankPage](./BlankPage.js) and [specs/BlankPage](./specs/BlankPage.js) for an example.
+
+### Api
+
+Utilities to interact with the MediaWiki API. Uses the [mwbot](https://github.com/Fannon/mwbot) library.
+
+Actions are performed logged-in using `browser.options.username` and `browser.options.password`,
+which typically come from `MEDIAWIKI_USER` and `MEDIAWIKI_PASSWORD` environment variables.
+
+* `edit(title, content)`
+* `delete(title, reason)`
+* `createAccount(username, password)`
+
+## Versioning
+
+This package follows [Semantic Versioning guidelines](https://semver.org/) for its releases. In
+particular, its major version must be bumped when compatibility is removed for a previous of
+MediaWiki.
+
+It is the expectation that this module will only support a single version of MediaWiki at any
+given time, and that tests in older branches of MediaWiki-related projects naturally use the older
+release line of this package.
+
+In order to allow for smooth and decentralised upgrades, it is recommended that the only type of
+breaking change made to this package is a change that removes something. Thus, in order to change
+something, it must either be backwards-compatible, or must be introduced as a new method that
+co-exists with its deprecated equivalent for at least one release.
+
+## Issue tracker
+
+Please report issues to [Phabricator](https://phabricator.wikimedia.org/tag/mediawiki-core-tests/).
+
+## Contributing
+
+This module is maintained in the MediaWiki core repository and published from there as a
+package to npmjs.org. To simplify development and to ensure changes are verified
+automatically, MediaWiki core itself uses this module directly from the working copy
+using [npm Local Paths](https://docs.npmjs.com/files/package.json#local-paths).
--- /dev/null
+const fs = require( 'fs' );
+
+module.exports = {
+ /**
+ * Based on <https://github.com/webdriverio/webdriverio/issues/269#issuecomment-306342170>
+ *
+ * @since 1.0.0
+ * @param {string} title Description (will be sanitised and used as file name)
+ * @return {string} File path
+ */
+ saveScreenshot( title ) {
+ var filename, filePath;
+ // Create sane file name for current test title
+ filename = encodeURIComponent( title.replace( /\s+/g, '-' ) );
+ filePath = `${browser.options.screenshotPath}/${filename}.png`;
+ // Ensure directory exists, based on WebDriverIO#saveScreenshotSync()
+ try {
+ fs.statSync( browser.options.screenshotPath );
+ } catch ( err ) {
+ fs.mkdirSync( browser.options.screenshotPath );
+ }
+ // Create and save screenshot
+ browser.saveScreenshot( filePath );
+ return filePath;
+ }
+};
--- /dev/null
+{
+ "name": "wdio-mediawiki",
+ "version": "0.1.0",
+ "description": "WebdriverIO plugin for testing a MediaWiki site.",
+ "homepage": "https://gerrit.wikimedia.org/g/mediawiki/core/+/master/tests/selenium/wdio-mediawiki/",
+ "license": "MIT",
+ "keywords": [
+ "mediawiki",
+ "wdio-plugin"
+ ],
+ "files": [
+ "*.js",
+ "specs/"
+ ],
+ "engines": {
+ "node" : ">=6.0"
+ },
+ "dependencies": {
+ "mwbot": "1.0.10"
+ }
+}
--- /dev/null
+const assert = require( 'assert' ),
+ BlankPage = require( 'wdio-mediawiki/BlankPage' );
+
+describe( 'BlankPage', function () {
+ it( 'should have its title', function () {
+ BlankPage.open();
+
+ // check
+ assert.equal( BlankPage.heading.getText(), 'Blank page' );
+ } );
+} );
const fs = require( 'fs' ),
path = require( 'path' ),
- logPath = process.env.LOG_DIR || './log/';
+ saveScreenshot = require( 'wdio-mediawiki' ).saveScreenshot,
+ logPath = process.env.LOG_DIR || __dirname + '/log';
function relPath( foo ) {
return path.resolve( __dirname, '../..', foo );
// Custom WDIO config specific to MediaWiki
// ======
// Use in a test as `browser.options.<key>`.
-
- // Configure wiki admin user/pass via env
// Defaults are for convenience with MediaWiki-Vagrant
+
+ // Wiki admin
username: process.env.MEDIAWIKI_USER || 'Admin',
password: process.env.MEDIAWIKI_PASSWORD || 'vagrant',
+ // Base for browser.url() and Page#openTitle()
+ baseUrl: ( process.env.MW_SERVER || 'http://127.0.0.1:8080' ) + (
+ process.env.MW_SCRIPT_PATH || '/w'
+ ),
+
// ======
// Sauce Labs
// ======
+ // See http://webdriver.io/guide/services/sauce.html
+ // and https://docs.saucelabs.com/reference/platforms-configurator
services: [ 'sauce' ],
user: process.env.SAUCE_USERNAME,
key: process.env.SAUCE_ACCESS_KEY,
+ // Default timeout in milliseconds for Selenium Grid requests
+ connectionRetryTimeout: 90 * 1000,
+
+ // Default request retries count
+ connectionRetryCount: 3,
+
// ==================
- // Specify Test Files
+ // Test Files
// ==================
- // Define which test specs should run. The pattern is relative to the directory
- // from which `wdio` was called. Notice that, if you are calling `wdio` from an
- // NPM script (see https://docs.npmjs.com/cli/run-script) then the current working
- // directory is where your package.json resides, so `wdio` will be called from there.
specs: [
+ relPath( './tests/selenium/wdio-mediawiki/specs/*.js' ),
relPath( './tests/selenium/specs/**/*.js' ),
relPath( './extensions/*/tests/selenium/specs/**/*.js' ),
relPath( './extensions/VisualEditor/modules/ve-mw/tests/selenium/specs/**/*.js' ),
relPath( './skins/*/tests/selenium/specs/**/*.js' )
],
- // Patterns to exclude.
+ // Patterns to exclude
exclude: [
relPath( './extensions/CirrusSearch/tests/selenium/specs/**/*.js' )
],
// ============
// Capabilities
// ============
- // Define your capabilities here. WebdriverIO can run multiple capabilities at the same
- // time. Depending on the number of capabilities, WebdriverIO launches several test
- // sessions. Within your capabilities you can overwrite the spec and exclude options in
- // order to group specific specs to a specific capability.
- // First, you can define how many instances should be started at the same time. Let's
- // say you have 3 different capabilities (Chrome, Firefox, and Safari) and you have
- // set maxInstances to 1; wdio will spawn 3 processes. Therefore, if you have 10 spec
- // files and you set maxInstances to 10, all spec files will get tested at the same time
- // and 30 processes will get spawned. The property handles how many capabilities
- // from the same test should run tests.
+ // How many instances of the same capability (browser) may be started at the same time.
maxInstances: 1,
- // If you have trouble getting all important capabilities together, check out the
- // Sauce Labs platform configurator - a great tool to configure your capabilities:
- // https://docs.saucelabs.com/reference/platforms-configurator
- //
- // For Chrome/Chromium https://sites.google.com/a/chromium.org/chromedriver/capabilities
capabilities: [ {
- // maxInstances can get overwritten per capability. So if you have an in-house Selenium
- // grid with only 5 firefox instances available you can make sure that not more than
- // 5 instances get started at a time.
- maxInstances: 1,
+ // For Chrome/Chromium https://sites.google.com/a/chromium.org/chromedriver/capabilities
browserName: 'chrome',
+ maxInstances: 1,
chromeOptions: {
- // If DISPLAY is set, assume running from developer machine and/or with Xvfb.
+ // If DISPLAY is set, assume developer asked non-headless or CI with Xvfb.
// Otherwise, use --headless (added in Chrome 59)
// https://chromium.googlesource.com/chromium/src/+/59.0.3030.0/headless/README.md
- args: (
- process.env.DISPLAY ? [] : [ '--headless' ]
- ).concat(
+ args: [
+ ...( process.env.DISPLAY ? [] : [ '--headless' ] ),
// Chrome sandbox does not work in Docker
- fs.existsSync( '/.dockerenv' ) ? [ '--no-sandbox' ] : []
- )
+ ...( fs.existsSync( '/.dockerenv' ) ? [ '--no-sandbox' ] : [] )
+ ]
}
} ],
// ===================
// Test Configurations
// ===================
- // Define all options that are relevant for the WebdriverIO instance here
+
+ // Enabling synchronous mode (via the wdio-sync package), means specs don't have to
+ // use Promise#then() or await for browser commands, such as like `brower.element()`.
+ // Instead, it will automatically pause JavaScript execution until th command finishes.
//
- // By default WebdriverIO commands are executed in a synchronous way using
- // the wdio-sync package. If you still want to run your tests in an async way
- // e.g. using promises you can set the sync option to false.
+ // For non-browser commands (such as MWBot and other promises), this means you
+ // have to use `browser.call()` to make sure WDIO waits for it before the next
+ // browser command.
sync: true,
// Level of logging verbosity: silent | verbose | command | data | result | error
// Warns when a deprecated command is used
deprecationWarnings: true,
- // If you only want to run your tests until a specific amount of tests have failed use
- // bail (default is 0 - don't bail, run all tests).
+ // Stop the tests once a certain number of failed tests have been recorded.
+ // Default is 0 - don't bail, run all tests.
bail: 0,
- // Saves a screenshot to a given path if a command fails.
+ // Setting this enables automatic screenshots for when a browser command fails
+ // It is also used by afterTest for capturig failed assertions.
screenshotPath: logPath,
- // Set a base URL in order to shorten url command calls. If your `url` parameter starts
- // with `/`, the base url gets prepended, not including the path portion of your baseUrl.
- // If your `url` parameter starts without a scheme or `/` (like `some/path`), the base url
- // gets prepended directly.
- baseUrl: (
- process.env.MW_SERVER || 'http://127.0.0.1:8080'
- ) + (
- process.env.MW_SCRIPT_PATH || '/w'
- ),
-
- // Default timeout for all waitFor* commands.
- waitforTimeout: 10000,
-
- // Default timeout in milliseconds for request
- // if Selenium Grid doesn't send response
- connectionRetryTimeout: 90000,
-
- // Default request retries count
- connectionRetryCount: 3,
+ // Default timeout for each waitFor* command.
+ waitforTimeout: 10 * 1000,
- // Initialize the browser instance with a WebdriverIO plugin. The object should have the
- // plugin name as key and the desired plugin options as properties. Make sure you have
- // the plugin installed before running any tests. The following plugins are currently
- // available:
- // WebdriverCSS: https://github.com/webdriverio/webdrivercss
- // WebdriverRTC: https://github.com/webdriverio/webdriverrtc
- // Browserevent: https://github.com/webdriverio/browserevent
- // plugins: {
- // webdrivercss: {
- // screenshotRoot: 'my-shots',
- // failedComparisonsRoot: 'diffs',
- // misMatchTolerance: 0.05,
- // screenWidth: [320,480,640,1024]
- // },
- // webdriverrtc: {},
- // browserevent: {}
- // },
- //
- // Test runner services
- // Services take over a specific job you don't want to take care of. They enhance
- // your test setup with almost no effort. Unlike plugins, they don't add new
- // commands. Instead, they hook themselves up into the test process.
- // services: [],//
// Framework you want to run your specs with.
- // The following are supported: Mocha, Jasmine, and Cucumber
- // see also: http://webdriver.io/guide/testrunner/frameworks.html
- //
- // Make sure you have the wdio adapter package for the specific framework installed
- // before running any tests.
+ // See also: http://webdriver.io/guide/testrunner/frameworks.html
framework: 'mocha',
// Test reporter for stdout.
- // The only one supported by default is 'dot'
- // see also: http://webdriver.io/guide/testrunner/reporters.html
+ // See also: http://webdriver.io/guide/testrunner/reporters.html
reporters: [ 'spec', 'junit' ],
reporterOptions: {
junit: {
// See the full list at http://mochajs.org/
mochaOpts: {
ui: 'bdd',
- timeout: 60000
+ timeout: 60 * 1000
},
// =====
// Hooks
// =====
- // WebdriverIO provides several hooks you can use to interfere with the test process in order to enhance
- // it and to build services around it. You can either apply a single function or an array of
- // methods to it. If one of them returns with a promise, WebdriverIO will wait until that promise got
- // resolved to continue.
-
- /**
- * Gets executed once before all workers get launched.
- * @param {Object} config wdio configuration object
- * @param {Array.<Object>} capabilities list of capabilities details
- */
- // onPrepare: function (config, capabilities) {
- // },
-
- /**
- * Gets executed just before initialising the webdriver session and test framework. It allows you
- * to manipulate configurations depending on the capability or spec.
- * @param {Object} config wdio configuration object
- * @param {Array.<Object>} capabilities list of capabilities details
- * @param {Array.<String>} specs List of spec file paths that are to be run
- */
- // beforeSession: function (config, capabilities, specs) {
- // },
-
- /**
- * Gets executed before test execution begins. At this point you can access to all global
- * variables like `browser`. It is the perfect place to define custom commands.
- * @param {Array.<Object>} capabilities list of capabilities details
- * @param {Array.<String>} specs List of spec file paths that are to be run
- */
- // before: function (capabilities, specs) {
- // },
-
- /**
- * Runs before a WebdriverIO command gets executed.
- * @param {String} commandName hook command name
- * @param {Array} args arguments that command would receive
- */
- // beforeCommand: function (commandName, args) {
- // },
+ // See also: http://webdriver.io/guide/testrunner/configurationfile.html
/**
- * Hook that gets executed before the suite starts
- * @param {Object} suite suite details
- */
- // beforeSuite: function (suite) {
- // },
-
- /**
- * Function to be executed before a test (in Mocha/Jasmine) or a step (in Cucumber) starts.
- * @param {Object} test test details
- */
- // beforeTest: function (test) {
- // },
-
- /**
- * Hook that gets executed _before_ a hook within the suite starts (e.g. runs before calling
- * beforeEach in Mocha)
- */
- // beforeHook: function () {
- // },
-
- /**
- * Hook that gets executed _after_ a hook within the suite ends (e.g. runs after calling
- * afterEach in Mocha)
- */
- // afterHook: function () {
- // },
- /**
- * Function to be executed after a test (in Mocha/Jasmine) or a step (in Cucumber) ends.
- * @param {Object} test test details
+ * Save a screenshot when test fails.
+ *
+ * @param {Object} test Mocha Test object
*/
- // from https://github.com/webdriverio/webdriverio/issues/269#issuecomment-306342170
afterTest: function ( test ) {
- var filename, filePath;
- // if test passed, ignore, else take and save screenshot
- if ( test.passed ) {
- return;
+ var filePath;
+ if ( !test.passed ) {
+ filePath = saveScreenshot( test.title );
+ console.log( '\n\tScreenshot: ' + filePath + '\n' );
}
- // get current test title and clean it, to use it as file name
- filename = encodeURIComponent( test.title.replace( /\s+/g, '-' ) );
- // build file path
- filePath = this.screenshotPath + filename + '.png';
- // save screenshot
- browser.saveScreenshot( filePath );
- console.log( '\n\tScreenshot location:', filePath, '\n' );
}
-
- /**
- * Hook that gets executed after the suite has ended
- * @param {Object} suite suite details
- */
- // afterSuite: function (suite) {
- // },
-
- /**
- * Runs after a WebdriverIO command gets executed
- * @param {String} commandName hook command name
- * @param {Array} args arguments that command would receive
- * @param {Number} result 0 - command success, 1 - command error
- * @param {Object} error error object if any
- */
- // afterCommand: function (commandName, args, result, error) {
- // },
-
- /**
- * Gets executed after all tests are done. You still have access to all global variables from
- * the test.
- * @param {Number} result 0 - test pass, 1 - test fail
- * @param {Array.<Object>} capabilities list of capabilities details
- * @param {Array.<String>} specs List of spec file paths that ran
- */
- // after: function (result, capabilities, specs) {
- // },
-
- /**
- * Gets executed right after terminating the webdriver session.
- * @param {Object} config wdio configuration object
- * @param {Array.<Object>} capabilities list of capabilities details
- * @param {Array.<String>} specs List of spec file paths that ran
- */
- // afterSession: function (config, capabilities, specs) {
- // },
-
- /**
- * Gets executed after all workers got shut down and the process is about to exit.
- * @param {Object} exitCode 0 - success, 1 - fail
- * @param {Object} config wdio configuration object
- * @param {Array.<Object>} capabilities list of capabilities details
- */
- // onComplete: function(exitCode, config, capabilities) {
- // }
};