performance reasons, and installations with this setting will now work as if it
was configured with 'any'.
* $wgLogAutopatrol now defaults to false instead of true.
+* $wgValidateAllHtml was removed and will be ignored.
=== New features in 1.31 ===
* (T76554) User sub-pages named ….json are now protected in the same way that ….js
* The ResourceLoaderGetLessVars hook, deprecated in 1.30, has been removed.
Use ResourceLoaderModule::getLessVars() to expose local variables instead
of global ones.
+* As part of work to modernise user-generated content clean-up, a config option and some
+ methods related to HTML validity were removed without deprecation. The public methods
+ MWTidy::checkErrors() and its callee TidyDriverBase::validate() are removed, as are
+ MediaWikiTestCase::assertValidHtmlSnippet() and ::assertValidHtmlDocument(). The
+ $wgValidateAllHtml configuration option is removed and will be ignored.
=== Deprecations in 1.31 ===
* The Revision class was deprecated in favor of RevisionStore, BlobStore, and
*/
$wgSiteSupportPage = '';
-/**
- * Validate the overall output using tidy and refuse
- * to display the page if it's not valid.
- */
-$wgValidateAllHtml = false;
-
/**
* Default skin, for new users and anonymous visitors. Registered users may
* change this to any one of the other available skins in their preferences.
namespace MediaWiki;
-use MWTidy;
-use Html;
-
/**
* @since 1.31
*/
* @return string
*/
public static function handle( $s ) {
- global $wgDisableOutputCompression, $wgValidateAllHtml, $wgMangleFlashPolicy;
+ global $wgDisableOutputCompression, $wgMangleFlashPolicy;
if ( $wgMangleFlashPolicy ) {
$s = self::mangleFlashPolicy( $s );
}
- if ( $wgValidateAllHtml ) {
- $headers = headers_list();
- $isHTML = false;
- foreach ( $headers as $header ) {
- $parts = explode( ':', $header, 2 );
- if ( count( $parts ) !== 2 ) {
- continue;
- }
- $name = strtolower( trim( $parts[0] ) );
- $value = trim( $parts[1] );
- if ( $name == 'content-type' && ( strpos( $value, 'text/html' ) === 0
- || strpos( $value, 'application/xhtml+xml' ) === 0 )
- ) {
- $isHTML = true;
- break;
- }
- }
- if ( $isHTML ) {
- $s = self::validateAllHtml( $s );
- }
- }
if ( !$wgDisableOutputCompression && !ini_get( 'zlib.output_compression' ) ) {
if ( !defined( 'MW_NO_OUTPUT_COMPRESSION' ) ) {
$s = self::handleGzip( $s );
header( "Content-Length: $length" );
}
}
-
- /**
- * Replace the output with an error if the HTML is not valid.
- *
- * @param string $s
- * @return string
- */
- private static function validateAllHtml( $s ) {
- $errors = '';
- if ( MWTidy::checkErrors( $s, $errors ) ) {
- return $s;
- }
-
- header( 'Cache-Control: no-cache' );
-
- $out = Html::element( 'h1', null, 'HTML validation error' );
- $out .= Html::openElement( 'ul' );
-
- $error = strtok( $errors, "\n" );
- $badLines = [];
- while ( $error !== false ) {
- if ( preg_match( '/^line (\d+)/', $error, $m ) ) {
- $lineNum = intval( $m[1] );
- $badLines[$lineNum] = true;
- $out .= Html::rawElement( 'li', null,
- Html::element( 'a', [ 'href' => "#line-{$lineNum}" ], $error ) ) . "\n";
- }
- $error = strtok( "\n" );
- }
-
- $out .= Html::closeElement( 'ul' );
- $out .= Html::element( 'pre', null, $errors );
- $out .= Html::openElement( 'ol' ) . "\n";
- $line = strtok( $s, "\n" );
- $i = 1;
- while ( $line !== false ) {
- $attrs = [];
- if ( isset( $badLines[$i] ) ) {
- $attrs['class'] = 'highlight';
- $attrs['id'] = "line-$i";
- }
- $out .= Html::element( 'li', $attrs, $line ) . "\n";
- $line = strtok( "\n" );
- $i++;
- }
- $out .= Html::closeElement( 'ol' );
-
- $style = <<<CSS
- .highlight { background-color: #ffc }
- li { white-space: pre }
-CSS;
-
- $out = Html::htmlHeader( [ 'lang' => 'en', 'dir' => 'ltr' ] ) .
- Html::rawElement( 'head', null,
- Html::element( 'title', null, 'HTML validation error' ) .
- Html::inlineStyle( $style ) ) .
- Html::rawElement( 'body', null, $out ) .
- Html::closeElement( 'html' );
-
- return $out;
- }
}
if ( $params['onlypst'] ) {
// Build a result and bail out
$result_array = [];
- if ( $this->contentIsDeleted ) {
- $result_array['textdeleted'] = true;
- }
- if ( $this->contentIsSuppressed ) {
- $result_array['textsuppressed'] = true;
- }
$result_array['text'] = $this->pstContent->serialize( $format );
$result_array[ApiResult::META_BC_SUBELEMENTS][] = 'text';
if ( isset( $prop['wikitext'] ) ) {
}
if ( isset( $prop['displaytitle'] ) ) {
- $result_array['displaytitle'] = $p_result->getDisplayTitle() ?:
- $titleObj->getPrefixedText();
+ $result_array['displaytitle'] = $p_result->getDisplayTitle() !== false
+ ? $p_result->getDisplayTitle() : $titleObj->getPrefixedText();
}
if ( isset( $prop['headitems'] ) ) {
}
$wgParser->startExternalParse( $titleObj, $popts, Parser::OT_PREPROCESS );
- $dom = $wgParser->preprocessToDom( $this->content->getNativeData() );
- if ( is_callable( [ $dom, 'saveXML' ] ) ) {
- $xml = $dom->saveXML();
- } else {
- $xml = $dom->__toString();
- }
+ $xml = $wgParser->preprocessToDom( $this->content->getNativeData() )->__toString();
$result_array['parsetree'] = $xml;
$result_array[ApiResult::META_BC_SUBELEMENTS][] = 'parsetree';
}
} else {
$this->content = $page->getContent( Revision::FOR_THIS_USER, $this->getUser() );
if ( !$this->content ) {
- $this->dieWithError( [ 'apierror-missingcontent-pageid', $pageId ] );
+ $this->dieWithError( [ 'apierror-missingcontent-pageid', $page->getId() ] );
}
}
$this->contentIsDeleted = $isDeleted;
$pout = $page->getParserOutput( $popts, $revId, $suppressCache );
}
if ( !$pout ) {
- $this->dieWithError( [ 'apierror-nosuchrevid', $revId ?: $page->getLatest() ] );
+ $this->dieWithError( [ 'apierror-nosuchrevid', $revId ?: $page->getLatest() ] ); // @codeCoverageIgnore
}
return $pout;
}
// Wait on the GTID set (MariaDB only)
$gtidArg = $this->addQuotes( implode( ',', $gtidsWait ) );
- $res = $this->doQuery( "SELECT MASTER_GTID_WAIT($gtidArg, $timeout)" );
+ if ( strpos( $gtidArg, ':' ) !== false ) {
+ // MySQL GTIDs, e.g "source_id:transaction_id"
+ $res = $this->doQuery( "SELECT WAIT_FOR_EXECUTED_GTID_SET($gtidArg, $timeout)" );
+ } else {
+ // MariaDB GTIDs, e.g."domain:server:sequence"
+ $res = $this->doQuery( "SELECT MASTER_GTID_WAIT($gtidArg, $timeout)" );
+ }
} else {
// Wait on the binlog coordinates
$encFile = $this->addQuotes( $pos->getLogFile() );
- $encPos = intval( $pos->pos[1] );
+ $encPos = intval( $pos->getLogPosition()[$pos::CORD_EVENT] );
$res = $this->doQuery( "SELECT MASTER_POS_WAIT($encFile, $encPos, $timeout)" );
}
$row = $res ? $this->fetchRow( $res ) : false;
if ( !$row ) {
- throw new DBExpectedError( $this,
- "MASTER_POS_WAIT() or MASTER_GTID_WAIT() failed: {$this->lastError()}" );
+ throw new DBExpectedError( $this, "Replication wait failed: {$this->lastError()}" );
}
// Result can be NULL (error), -1 (timeout), or 0+ per the MySQL manual
* @return MySQLMasterPos|bool
*/
public function getReplicaPos() {
- $now = microtime( true );
-
- if ( $this->useGTIDs ) {
- $res = $this->query( "SELECT @@global.gtid_slave_pos AS Value", __METHOD__ );
- $gtidRow = $this->fetchObject( $res );
- if ( $gtidRow && strlen( $gtidRow->Value ) ) {
- return new MySQLMasterPos( $gtidRow->Value, $now );
+ $now = microtime( true ); // as-of-time *before* fetching GTID variables
+
+ if ( $this->useGTIDs() ) {
+ // Try to use GTIDs, fallbacking to binlog positions if not possible
+ $data = $this->getServerGTIDs( __METHOD__ );
+ // Use gtid_slave_pos for MariaDB and gtid_executed for MySQL
+ foreach ( [ 'gtid_slave_pos', 'gtid_executed' ] as $name ) {
+ if ( isset( $data[$name] ) && strlen( $data[$name] ) ) {
+ return new MySQLMasterPos( $data[$name], $now );
+ }
}
}
- $res = $this->query( 'SHOW SLAVE STATUS', __METHOD__ );
- $row = $this->fetchObject( $res );
- if ( $row && strlen( $row->Relay_Master_Log_File ) ) {
+ $data = $this->getServerRoleStatus( 'SLAVE', __METHOD__ );
+ if ( $data && strlen( $data['Relay_Master_Log_File'] ) ) {
return new MySQLMasterPos(
- "{$row->Relay_Master_Log_File}/{$row->Exec_Master_Log_Pos}",
+ "{$data['Relay_Master_Log_File']}/{$data['Exec_Master_Log_Pos']}",
$now
);
}
* @return MySQLMasterPos|bool
*/
public function getMasterPos() {
- $now = microtime( true );
+ $now = microtime( true ); // as-of-time *before* fetching GTID variables
+
+ $pos = false;
+ if ( $this->useGTIDs() ) {
+ // Try to use GTIDs, fallbacking to binlog positions if not possible
+ $data = $this->getServerGTIDs( __METHOD__ );
+ // Use gtid_binlog_pos for MariaDB and gtid_executed for MySQL
+ foreach ( [ 'gtid_binlog_pos', 'gtid_executed' ] as $name ) {
+ if ( isset( $data[$name] ) && strlen( $data[$name] ) ) {
+ $pos = new MySQLMasterPos( $data[$name], $now );
+ break;
+ }
+ }
+ // Filter domains that are inactive or not relevant to the session
+ if ( $pos ) {
+ $pos->setActiveOriginServerId( $this->getServerId() );
+ $pos->setActiveOriginServerUUID( $this->getServerUUID() );
+ if ( isset( $data['gtid_domain_id'] ) ) {
+ $pos->setActiveDomain( $data['gtid_domain_id'] );
+ }
+ }
+ }
- if ( $this->useGTIDs ) {
- $res = $this->query( "SELECT @@global.gtid_binlog_pos AS Value", __METHOD__ );
- $gtidRow = $this->fetchObject( $res );
- if ( $gtidRow && strlen( $gtidRow->Value ) ) {
- return new MySQLMasterPos( $gtidRow->Value, $now );
+ if ( !$pos ) {
+ $data = $this->getServerRoleStatus( 'MASTER', __METHOD__ );
+ if ( $data && strlen( $data['File'] ) ) {
+ $pos = new MySQLMasterPos( "{$data['File']}/{$data['Position']}", $now );
}
}
- $res = $this->query( 'SHOW MASTER STATUS', __METHOD__ );
- $row = $this->fetchObject( $res );
- if ( $row && strlen( $row->File ) ) {
- return new MySQLMasterPos( "{$row->File}/{$row->Position}", $now );
+ return $pos;
+ }
+
+ /**
+ * @return int
+ * @throws DBQueryError If the variable doesn't exist for some reason
+ */
+ protected function getServerId() {
+ return $this->srvCache->getWithSetCallback(
+ $this->srvCache->makeGlobalKey( 'mysql-server-id', $this->getServer() ),
+ self::SERVER_ID_CACHE_TTL,
+ function () {
+ $res = $this->query( "SELECT @@server_id AS id", __METHOD__ );
+ return intval( $this->fetchObject( $res )->id );
+ }
+ );
+ }
+
+ /**
+ * @return string|null
+ */
+ protected function getServerUUID() {
+ return $this->srvCache->getWithSetCallback(
+ $this->srvCache->makeGlobalKey( 'mysql-server-uuid', $this->getServer() ),
+ self::SERVER_ID_CACHE_TTL,
+ function () {
+ $res = $this->query( "SHOW GLOBAL VARIABLES LIKE 'server_uuid'" );
+ $row = $this->fetchObject( $res );
+
+ return $row ? $row->Value : null;
+ }
+ );
+ }
+
+ /**
+ * @param string $fname
+ * @return string[]
+ */
+ protected function getServerGTIDs( $fname = __METHOD__ ) {
+ $map = [];
+ // Get global-only variables like gtid_executed
+ $res = $this->query( "SHOW GLOBAL VARIABLES LIKE 'gtid_%'", $fname );
+ foreach ( $res as $row ) {
+ $map[$row->Variable_name] = $row->Value;
+ }
+ // Get session-specific (e.g. gtid_domain_id since that is were writes will log)
+ $res = $this->query( "SHOW SESSION VARIABLES LIKE 'gtid_%'", $fname );
+ foreach ( $res as $row ) {
+ $map[$row->Variable_name] = $row->Value;
}
- return false;
+ return $map;
+ }
+
+ /**
+ * @param string $role One of "MASTER"/"SLAVE"
+ * @param string $fname
+ * @return string[] Latest available server status row
+ */
+ protected function getServerRoleStatus( $role, $fname = __METHOD__ ) {
+ return $this->query( "SHOW $role STATUS", $fname )->fetchRow() ?: [];
}
public function serverIsReadOnly() {
return 'CAST( ' . $field . ' AS SIGNED )';
}
+ /*
+ * @return bool Whether GTID support is used (mockable for testing)
+ */
+ protected function useGTIDs() {
+ return $this->useGTIDs;
+ }
}
class_alias( DatabaseMysqlBase::class, 'DatabaseMysqlBase' );
use InvalidArgumentException;
use Wikimedia\ScopedCallback;
use RuntimeException;
-use UnexpectedValueException;
use stdClass;
/**
/**
* Run a callback as soon as the current transaction commits or rolls back.
* An error is thrown if no transaction is pending. Queries in the function will run in
- * AUTO-COMMIT mode unless there are begin() calls. Callbacks must commit any transactions
+ * AUTOCOMMIT mode unless there are begin() calls. Callbacks must commit any transactions
* that they begin.
*
* This is useful for combining cooperative locks and DB transactions.
*
+ * @note: do not assume that *other* IDatabase instances will be AUTOCOMMIT mode
+ *
* The callback takes one argument:
* - How the transaction ended (IDatabase::TRIGGER_COMMIT or IDatabase::TRIGGER_ROLLBACK)
*
* of the round, just after all peer transactions COMMIT. If the transaction round
* is rolled back, then the callback is cancelled.
*
- * Queries in the function will run in AUTO-COMMIT mode unless there are begin() calls.
+ * Queries in the function will run in AUTOCOMMIT mode unless there are begin() calls.
* Callbacks must commit any transactions that they begin.
*
* This is useful for updates to different systems or when separate transactions are needed.
*
* Updates will execute in the order they were enqueued.
*
+ * @note: do not assume that *other* IDatabase instances will be AUTOCOMMIT mode
+ *
* The callback takes one argument:
* - How the transaction ended (IDatabase::TRIGGER_COMMIT or IDatabase::TRIGGER_IDLE)
*
public function setTransactionListener( $name, callable $callback = null );
/**
- * Begin an atomic section of statements
+ * Begin an atomic section of SQL statements
+ *
+ * Start an implicit transaction if no transaction is already active, set a savepoint
+ * (if $cancelable is ATOMIC_CANCELABLE), and track the given section name to enforce
+ * that the transaction is not committed prematurely. The end of the section must be
+ * signified exactly once, either by endAtomic() or cancelAtomic(). Sections can have
+ * have layers of inner sections (sub-sections), but all sections must be ended in order
+ * of innermost to outermost. Transactions cannot be started or committed until all
+ * atomic sections are closed.
+ *
+ * ATOMIC_CANCELABLE is useful when the caller needs to handle specific failure cases
+ * by discarding the section's writes. This should not be used for failures when:
+ * - upsert() could easily be used instead
+ * - insert() with IGNORE could easily be used instead
+ * - select() with FOR UPDATE could be checked before issuing writes instead
+ * - The failure is from code that runs after the first write but doesn't need to
+ * - The failures are from contention solvable via onTransactionPreCommitOrIdle()
+ * - The failures are deadlocks; the RDBMs usually discard the whole transaction
*
- * If a transaction has been started already, (optionally) sets a savepoint
- * and tracks the given section name to make sure the transaction is not
- * committed pre-maturely. This function can be used in layers (with
- * sub-sections), so use a stack to keep track of the different atomic
- * sections. If there is no transaction, one is started implicitly.
+ * @note: callers must use additional measures for situations involving two or more
+ * (peer) transactions (e.g. updating two database servers at once). The transaction
+ * and savepoint logic of this method only applies to this specific IDatabase instance.
*
- * The goal of this function is to create an atomic section of SQL queries
- * without having to start a new transaction if it already exists.
+ * Example usage:
+ * @code
+ * // Start a transaction if there isn't one already
+ * $dbw->startAtomic( __METHOD__ );
+ * // Serialize these thread table updates
+ * $dbw->select( 'thread', '1', [ 'td_id' => $tid ], __METHOD__, 'FOR UPDATE' );
+ * // Add a new comment for the thread
+ * $dbw->insert( 'comment', $row, __METHOD__ );
+ * $cid = $db->insertId();
+ * // Update thread reference to last comment
+ * $dbw->update( 'thread', [ 'td_latest' => $cid ], [ 'td_id' => $tid ], __METHOD__ );
+ * // Demark the end of this conceptual unit of updates
+ * $dbw->endAtomic( __METHOD__ );
+ * @endcode
*
- * All atomic levels *must* be explicitly closed using IDatabase::endAtomic()
- * or IDatabase::cancelAtomic(), and any database transactions cannot be
- * began or committed until all atomic levels are closed. There is no such
- * thing as implicitly opening or closing an atomic section.
+ * Example usage (atomic changes that might have to be discarded):
+ * @code
+ * // Start a transaction if there isn't one already
+ * $dbw->startAtomic( __METHOD__, $dbw::ATOMIC_CANCELABLE );
+ * // Create new record metadata row
+ * $dbw->insert( 'records', $row, __METHOD__ );
+ * // Figure out where to store the data based on the new row's ID
+ * $path = $recordDirectory . '/' . $dbw->insertId();
+ * // Write the record data to the storage system
+ * $status = $fileBackend->create( [ 'dst' => $path, 'content' => $data ] );
+ * if ( $status->isOK() ) {
+ * // Try to cleanup files orphaned by transaction rollback
+ * $dbw->onTransactionResolution(
+ * function ( $type ) use ( $fileBackend, $path ) {
+ * if ( $type === IDatabase::TRIGGER_ROLLBACK ) {
+ * $fileBackend->delete( [ 'src' => $path ] );
+ * }
+ * },
+ * __METHOD__
+ * );
+ * // Demark the end of this conceptual unit of updates
+ * $dbw->endAtomic( __METHOD__ );
+ * } else {
+ * // Discard these writes from the transaction (preserving prior writes)
+ * $dbw->cancelAtomic( __METHOD__ );
+ * }
+ * @endcode
*
* @since 1.23
* @param string $fname
* corresponding startAtomic() implicitly started a transaction, that
* transaction is rolled back.
*
- * Note that a call to IDatabase::rollback() will also roll back any open
- * atomic sections.
+ * @note: callers must use additional measures for situations involving two or more
+ * (peer) transactions (e.g. updating two database servers at once). The transaction
+ * and savepoint logic of startAtomic() are bound to specific IDatabase instances.
+ *
+ * Note that a call to IDatabase::rollback() will also roll back any open atomic sections.
*
* @note As a micro-optimization to save a few DB calls, this method may only
* be called when startAtomic() was called with the ATOMIC_CANCELABLE flag.
public function cancelAtomic( $fname = __METHOD__, AtomicSectionIdentifier $sectionId = null );
/**
- * Run a callback to do an atomic set of updates for this database
+ * Perform an atomic section of reversable SQL statements from a callback
*
* The $callback takes the following arguments:
* - This database object
* - The value of $fname
*
- * If any exception occurs in the callback, then cancelAtomic() will be
- * called to back out any statements executed by the callback and the error
- * will be re-thrown. It may also be that the cancel itself fails with an
- * exception before then. In any case, such errors are expected to
- * terminate the request, without any outside caller attempting to catch
- * errors and commit anyway.
+ * This will execute the callback inside a pair of startAtomic()/endAtomic() calls.
+ * If any exception occurs during execution of the callback, it will be handled as follows:
+ * - If $cancelable is ATOMIC_CANCELABLE, cancelAtomic() will be called to back out any
+ * (and only) statements executed during the atomic section. If that succeeds, then the
+ * exception will be re-thrown; if it fails, then a different exception will be thrown
+ * and any further query attempts will fail until rollback() is called.
+ * - If $cancelable is ATOMIC_NOT_CANCELABLE, cancelAtomic() will be called to mark the
+ * end of the section and the error will be re-thrown. Any further query attempts will
+ * fail until rollback() is called.
+ *
+ * This method is convenient for letting calls to the caller of this method be wrapped
+ * in a try/catch blocks for exception types that imply that the caller failed but was
+ * able to properly discard the changes it made in the transaction. This method can be
+ * an alternative to explicit calls to startAtomic()/endAtomic()/cancelAtomic().
+ *
+ * Example usage, "RecordStore::save" method:
+ * @code
+ * $dbw->doAtomicSection( __METHOD__, function ( $dbw ) use ( $record ) {
+ * // Create new record metadata row
+ * $dbw->insert( 'records', $record->toArray(), __METHOD__ );
+ * // Figure out where to store the data based on the new row's ID
+ * $path = $this->recordDirectory . '/' . $dbw->insertId();
+ * // Write the record data to the storage system;
+ * // blob store throughs StoreFailureException on failure
+ * $this->blobStore->create( $path, $record->getJSON() );
+ * // Try to cleanup files orphaned by transaction rollback
+ * $dbw->onTransactionResolution(
+ * function ( $type ) use ( $path ) {
+ * if ( $type === IDatabase::TRIGGER_ROLLBACK ) {
+ * $this->blobStore->delete( $path );
+ * }
+ * },
+ * },
+ * __METHOD__
+ * );
+ * @endcode
*
- * This can be an alternative to explicit startAtomic()/endAtomic()/cancelAtomic() calls.
+ * Example usage, caller of the "RecordStore::save" method:
+ * @code
+ * $dbw->startAtomic( __METHOD__ );
+ * // ...various SQL writes happen...
+ * try {
+ * $recordStore->save( $record );
+ * } catch ( StoreFailureException $e ) {
+ * $dbw->cancelAtomic( __METHOD__ );
+ * // ...various SQL writes happen...
+ * }
+ * // ...various SQL writes happen...
+ * $dbw->endAtomic( __METHOD__ );
+ * @endcode
*
* @see Database::startAtomic
* @see Database::endAtomic
* @return mixed $res Result of the callback (since 1.28)
* @throws DBError
* @throws RuntimeException
- * @throws UnexpectedValueException
* @since 1.27; prior to 1.31 this did a rollback() instead of
* cancelAtomic(), and assumed no callers up the stack would ever try to
* catch the exception.
* This is useful when transactions might use snapshot isolation
* (e.g. REPEATABLE-READ in innodb), so the "real" lag of that data
* is this lag plus transaction duration. If they don't, it is still
- * safe to be pessimistic. In AUTO-COMMIT mode, this still gives an
+ * safe to be pessimistic. In AUTOCOMMIT mode, this still gives an
* indication of the staleness of subsequent reads.
*
* @return array ('lag': seconds or false on error, 'since': UNIX timestamp of BEGIN)
* - Binlog-based usage assumes single-source replication and non-hierarchical replication.
* - GTID-based usage allows getting/syncing with multi-source replication. It is assumed
* that GTID sets are complete (e.g. include all domains on the server).
+ *
+ * @see https://mariadb.com/kb/en/library/gtid/
+ * @see https://dev.mysql.com/doc/refman/5.6/en/replication-gtids-concepts.html
*/
class MySQLMasterPos implements DBMasterPos {
- /** @var string|null Binlog file base name */
- public $binlog;
- /** @var int[]|null Binglog file position tuple */
- public $pos;
- /** @var string[] GTID list */
- public $gtids = [];
+ /** @var int One of (BINARY_LOG, GTID_MYSQL, GTID_MARIA) */
+ private $style;
+ /** @var string|null Base name of all Binary Log files */
+ private $binLog;
+ /** @var int[]|null Binary Log position tuple (index number, event number) */
+ private $logPos;
+ /** @var string[] Map of (server_uuid/gtid_domain_id => GTID) */
+ private $gtids = [];
+ /** @var int|null Active GTID domain ID */
+ private $activeDomain;
+ /** @var int|null ID of the server were DB writes originate */
+ private $activeServerId;
+ /** @var string|null UUID of the server were DB writes originate */
+ private $activeServerUUID;
/** @var float UNIX timestamp */
- public $asOfTime = 0.0;
+ private $asOfTime = 0.0;
+
+ const BINARY_LOG = 'binary-log';
+ const GTID_MARIA = 'gtid-maria';
+ const GTID_MYSQL = 'gtid-mysql';
+
+ /** @var int Key name of the binary log index number of a position tuple */
+ const CORD_INDEX = 0;
+ /** @var int Key name of the binary log event number of a position tuple */
+ const CORD_EVENT = 1;
/**
* @param string $position One of (comma separated GTID list, <binlog file>/<integer>)
protected function init( $position, $asOfTime ) {
$m = [];
if ( preg_match( '!^(.+)\.(\d+)/(\d+)$!', $position, $m ) ) {
- $this->binlog = $m[1]; // ideally something like host name
- $this->pos = [ (int)$m[2], (int)$m[3] ];
+ $this->binLog = $m[1]; // ideally something like host name
+ $this->logPos = [ self::CORD_INDEX => (int)$m[2], self::CORD_EVENT => (int)$m[3] ];
+ $this->style = self::BINARY_LOG;
} else {
$gtids = array_filter( array_map( 'trim', explode( ',', $position ) ) );
foreach ( $gtids as $gtid ) {
- if ( !self::parseGTID( $gtid ) ) {
+ $components = self::parseGTID( $gtid );
+ if ( !$components ) {
throw new InvalidArgumentException( "Invalid GTID '$gtid'." );
}
- $this->gtids[] = $gtid;
+
+ list( $domain, $pos ) = $components;
+ if ( isset( $this->gtids[$domain] ) ) {
+ // For MySQL, handle the case where some past issue caused a gap in the
+ // executed GTID set, e.g. [last_purged+1,N-1] and [N+1,N+2+K]. Ignore the
+ // gap by using the GTID with the highest ending sequence number.
+ list( , $otherPos ) = self::parseGTID( $this->gtids[$domain] );
+ if ( $pos > $otherPos ) {
+ $this->gtids[$domain] = $gtid;
+ }
+ } else {
+ $this->gtids[$domain] = $gtid;
+ }
+
+ if ( is_int( $domain ) ) {
+ $this->style = self::GTID_MARIA; // gtid_domain_id
+ } else {
+ $this->style = self::GTID_MYSQL; // server_uuid
+ }
}
if ( !$this->gtids ) {
- throw new InvalidArgumentException( "Got empty GTID set." );
+ throw new InvalidArgumentException( "GTID set cannot be empty." );
}
}
}
// Prefer GTID comparisons, which work with multi-tier replication
- $thisPosByDomain = $this->getGtidCoordinates();
- $thatPosByDomain = $pos->getGtidCoordinates();
+ $thisPosByDomain = $this->getActiveGtidCoordinates();
+ $thatPosByDomain = $pos->getActiveGtidCoordinates();
if ( $thisPosByDomain && $thatPosByDomain ) {
$comparisons = [];
// Check that this has positions reaching those in $pos for all domains in common
}
// Prefer GTID comparisons, which work with multi-tier replication
- $thisPosDomains = array_keys( $this->getGtidCoordinates() );
- $thatPosDomains = array_keys( $pos->getGtidCoordinates() );
+ $thisPosDomains = array_keys( $this->getActiveGtidCoordinates() );
+ $thatPosDomains = array_keys( $pos->getActiveGtidCoordinates() );
if ( $thisPosDomains && $thatPosDomains ) {
// Check that $this has a GTID for at least one domain also in $pos; due to MariaDB
// quirks, prior master switch-overs may result in inactive garbage GTIDs that cannot
}
/**
- * @return string|null
+ * @return string|null Base name of binary log files
+ * @since 1.31
+ */
+ public function getLogName() {
+ return $this->gtids ? null : $this->binLog;
+ }
+
+ /**
+ * @return int[]|null Tuple of (binary log file number, event number)
+ * @since 1.31
+ */
+ public function getLogPosition() {
+ return $this->gtids ? null : $this->logPos;
+ }
+
+ /**
+ * @return string|null Name of the binary log file for this position
+ * @since 1.31
*/
public function getLogFile() {
- return $this->gtids ? null : "{$this->binlog}.{$this->pos[0]}";
+ return $this->gtids ? null : "{$this->binLog}.{$this->logPos[self::CORD_INDEX]}";
}
/**
- * @return string[]
+ * @return string[] Map of (server_uuid/gtid_domain_id => GTID)
+ * @since 1.31
*/
public function getGTIDs() {
return $this->gtids;
}
/**
- * @return string GTID set or <binlog file>/<position> (e.g db1034-bin.000976/843431247)
+ * @param int|null $id @@gtid_domain_id of the active replication stream
+ * @since 1.31
*/
- public function __toString() {
- return $this->gtids
- ? implode( ',', $this->gtids )
- : $this->getLogFile() . "/{$this->pos[1]}";
+ public function setActiveDomain( $id ) {
+ $this->activeDomain = (int)$id;
+ }
+
+ /**
+ * @param int|null $id @@server_id of the server were writes originate
+ * @since 1.31
+ */
+ public function setActiveOriginServerId( $id ) {
+ $this->activeServerId = (int)$id;
+ }
+
+ /**
+ * @param string|null $id @@server_uuid of the server were writes originate
+ * @since 1.31
+ */
+ public function setActiveOriginServerUUID( $id ) {
+ $this->activeServerUUID = $id;
}
/**
* @param MySQLMasterPos $pos
* @param MySQLMasterPos $refPos
* @return string[] List of GTIDs from $pos that have domains in $refPos
+ * @since 1.31
*/
public static function getCommonDomainGTIDs( MySQLMasterPos $pos, MySQLMasterPos $refPos ) {
- $gtidsCommon = [];
-
- $relevantDomains = $refPos->getGtidCoordinates(); // (domain => unused)
- foreach ( $pos->gtids as $gtid ) {
- list( $domain ) = self::parseGTID( $gtid );
- if ( isset( $relevantDomains[$domain] ) ) {
- $gtidsCommon[] = $gtid;
- }
- }
-
- return $gtidsCommon;
+ return array_values(
+ array_intersect_key( $pos->gtids, $refPos->getActiveGtidCoordinates() )
+ );
}
/**
* @see https://mariadb.com/kb/en/mariadb/gtid
* @see https://dev.mysql.com/doc/refman/5.6/en/replication-gtids-concepts.html
- * @return array Map of (domain => integer position); possibly empty
+ * @return array Map of (server_uuid/gtid_domain_id => integer position); possibly empty
*/
- protected function getGtidCoordinates() {
+ protected function getActiveGtidCoordinates() {
$gtidInfos = [];
- foreach ( $this->gtids as $gtid ) {
- list( $domain, $pos ) = self::parseGTID( $gtid );
- $gtidInfos[$domain] = $pos;
+
+ foreach ( $this->gtids as $domain => $gtid ) {
+ list( $domain, $pos, $server ) = self::parseGTID( $gtid );
+
+ $ignore = false;
+ // Filter out GTIDs from non-active replication domains
+ if ( $this->style === self::GTID_MARIA && $this->activeDomain !== null ) {
+ $ignore |= ( $domain !== $this->activeDomain );
+ }
+ // Likewise for GTIDs from non-active replication origin servers
+ if ( $this->style === self::GTID_MARIA && $this->activeServerId !== null ) {
+ $ignore |= ( $server !== $this->activeServerId );
+ } elseif ( $this->style === self::GTID_MYSQL && $this->activeServerUUID !== null ) {
+ $ignore |= ( $server !== $this->activeServerUUID );
+ }
+
+ if ( !$ignore ) {
+ $gtidInfos[$domain] = $pos;
+ }
}
return $gtidInfos;
}
/**
- * @param string $gtid
- * @return array|null [domain, integer position] or null
+ * @param string $id GTID
+ * @return array|null [domain ID or server UUID, sequence number, server ID/UUID] or null
*/
- protected static function parseGTID( $gtid ) {
+ protected static function parseGTID( $id ) {
$m = [];
- if ( preg_match( '!^(\d+)-\d+-(\d+)$!', $gtid, $m ) ) {
+ if ( preg_match( '!^(\d+)-(\d+)-(\d+)$!', $id, $m ) ) {
// MariaDB style: <domain>-<server id>-<sequence number>
- return [ (int)$m[1], (int)$m[2] ];
- } elseif ( preg_match( '!^(\w{8}-\w{4}-\w{4}-\w{4}-\w{12}):(\d+)$!', $gtid, $m ) ) {
- // MySQL style: <UUID domain>:<sequence number>
- return [ $m[1], (int)$m[2] ];
+ return [ (int)$m[1], (int)$m[3], (int)$m[2] ];
+ } elseif ( preg_match( '!^(\w{8}-\w{4}-\w{4}-\w{4}-\w{12}):(?:\d+-|)(\d+)$!', $id, $m ) ) {
+ // MySQL style: <server UUID>:<sequence number>-<sequence number>
+ // Normally, the first number should reflect the point (gtid_purged) where older
+ // binary logs where purged to save space. When doing comparisons, it may as well
+ // be 1 in that case. Assume that this is generally the situation.
+ return [ $m[1], (int)$m[2], $m[1] ];
}
return null;
/**
* @see https://dev.mysql.com/doc/refman/5.7/en/show-master-status.html
* @see https://dev.mysql.com/doc/refman/5.7/en/show-slave-status.html
- * @return array|bool (binlog, (integer file number, integer position)) or false
+ * @return array|bool Map of (binlog:<string>, pos:(<integer>, <integer>)) or false
*/
protected function getBinlogCoordinates() {
- return ( $this->binlog !== null && $this->pos !== null )
- ? [ 'binlog' => $this->binlog, 'pos' => $this->pos ]
+ return ( $this->binLog !== null && $this->logPos !== null )
+ ? [ 'binlog' => $this->binLog, 'pos' => $this->logPos ]
: false;
}
public function serialize() {
- return serialize( [ 'position' => $this->__toString(), 'asOfTime' => $this->asOfTime ] );
+ return serialize( [
+ 'position' => $this->__toString(),
+ 'activeDomain' => $this->activeDomain,
+ 'activeServerId' => $this->activeServerId,
+ 'activeServerUUID' => $this->activeServerUUID,
+ 'asOfTime' => $this->asOfTime
+ ] );
}
public function unserialize( $serialized ) {
}
$this->init( $data['position'], $data['asOfTime'] );
+ if ( isset( $data['activeDomain'] ) ) {
+ $this->setActiveDomain( $data['activeDomain'] );
+ }
+ if ( isset( $data['activeServerId'] ) ) {
+ $this->setActiveOriginServerId( $data['activeServerId'] );
+ }
+ if ( isset( $data['activeServerUUID'] ) ) {
+ $this->setActiveOriginServerUUID( $data['activeServerUUID'] );
+ }
+ }
+
+ /**
+ * @return string GTID set or <binary log file>/<position> (e.g db1034-bin.000976/843431247)
+ */
+ public function __toString() {
+ return $this->gtids
+ ? implode( ',', $this->gtids )
+ : $this->getLogFile() . "/{$this->logPos[self::CORD_EVENT]}";
}
}
return $driver->tidy( $text );
}
- /**
- * Check HTML for errors, used if $wgValidateAllHtml = true.
- *
- * @param string $text
- * @param string &$errorStr Return the error string
- * @return bool Whether the HTML is valid
- * @throws MWException
- */
- public static function checkErrors( $text, &$errorStr = null ) {
- $driver = self::singleton();
- if ( !$driver ) {
- throw new MWException( __METHOD__ .
- ': tidy is disabled, caller should have checked MWTidy::isEnabled()' );
- }
- if ( $driver->supportsValidate() ) {
- return $driver->validate( $text, $errorStr );
- } else {
- throw new MWException( __METHOD__ . ": tidy driver does not support validate()" );
- }
- }
-
/**
* @return bool
*/
// Same format as filterGroupDefinitions, but for a single group (reviewStatus)
// that is registered conditionally.
+ private $legacyReviewStatusFilterGroupDefinition;
+
+ // Single filter group registered conditionally
private $reviewStatusFilterGroupDefinition;
- // Single filter registered conditionally
+ // Single filter group registered conditionally
private $hideCategorizationFilterDefinition;
/**
]
],
- // reviewStatus (conditional)
+ // significance (conditional)
[
'name' => 'significance',
];
- $this->reviewStatusFilterGroupDefinition = [
+ $this->legacyReviewStatusFilterGroupDefinition = [
[
- 'name' => 'reviewStatus',
+ 'name' => 'legacyReviewStatus',
'title' => 'rcfilters-filtergroup-reviewstatus',
'class' => ChangesListBooleanFilterGroup::class,
- 'priority' => -5,
'filters' => [
[
'name' => 'hidepatrolled',
- 'label' => 'rcfilters-filter-patrolled-label',
- 'description' => 'rcfilters-filter-patrolled-description',
// rcshowhidepatr-show, rcshowhidepatr-hide
// wlshowhidepatr
'showHideSuffix' => 'showhidepatr',
) {
$conds[] = 'rc_patrolled = 0';
},
- 'cssClassSuffix' => 'patrolled',
- 'isRowApplicableCallable' => function ( $ctx, $rc ) {
- return $rc->getAttribute( 'rc_patrolled' );
- },
+ 'isReplacedInStructuredUi' => true,
],
[
'name' => 'hideunpatrolled',
- 'label' => 'rcfilters-filter-unpatrolled-label',
- 'description' => 'rcfilters-filter-unpatrolled-description',
'default' => false,
'queryCallable' => function ( $specialClassName, $ctx, $dbr, &$tables, &$fields, &$conds,
&$query_options, &$join_conds
) {
$conds[] = 'rc_patrolled != 0';
},
- 'cssClassSuffix' => 'unpatrolled',
+ 'isReplacedInStructuredUi' => true,
+ ],
+ ],
+ ]
+ ];
+
+ $this->reviewStatusFilterGroupDefinition = [
+ [
+ 'name' => 'reviewStatus',
+ 'title' => 'rcfilters-filtergroup-reviewstatus',
+ 'class' => ChangesListStringOptionsFilterGroup::class,
+ 'isFullCoverage' => true,
+ 'priority' => -5,
+ 'filters' => [
+ [
+ 'name' => 'unpatrolled',
+ 'label' => 'rcfilters-filter-reviewstatus-unpatrolled-label',
+ 'description' => 'rcfilters-filter-reviewstatus-unpatrolled-description',
+ 'cssClassSuffix' => 'reviewstatus-unpatrolled',
+ 'isRowApplicableCallable' => function ( $ctx, $rc ) {
+ return $rc->getAttribute( 'rc_patrolled' ) == RecentChange::PRC_UNPATROLLED;
+ },
+ ],
+ [
+ 'name' => 'manual',
+ 'label' => 'rcfilters-filter-reviewstatus-manual-label',
+ 'description' => 'rcfilters-filter-reviewstatus-manual-description',
+ 'cssClassSuffix' => 'reviewstatus-manual',
'isRowApplicableCallable' => function ( $ctx, $rc ) {
- return !$rc->getAttribute( 'rc_patrolled' );
+ return $rc->getAttribute( 'rc_patrolled' ) == RecentChange::PRC_PATROLLED;
+ },
+ ],
+ [
+ 'name' => 'auto',
+ 'label' => 'rcfilters-filter-reviewstatus-auto-label',
+ 'description' => 'rcfilters-filter-reviewstatus-auto-description',
+ 'cssClassSuffix' => 'reviewstatus-auto',
+ 'isRowApplicableCallable' => function ( $ctx, $rc ) {
+ return $rc->getAttribute( 'rc_patrolled' ) == RecentChange::PRC_AUTOPATROLLED;
},
],
],
+ 'default' => ChangesListStringOptionsFilterGroup::NONE,
+ 'queryCallable' => function ( $specialPageClassName, $ctx, $dbr,
+ &$tables, &$fields, &$conds, &$query_options, &$join_conds, $selected
+ ) {
+ if ( $selected === [] ) {
+ return;
+ }
+ $rcPatrolledValues = [
+ 'unpatrolled' => RecentChange::PRC_UNPATROLLED,
+ 'manual' => RecentChange::PRC_PATROLLED,
+ 'auto' => RecentChange::PRC_AUTOPATROLLED,
+ ];
+ // e.g. rc_patrolled IN (0, 2)
+ $conds['rc_patrolled'] = array_map( function ( $s ) use ( $rcPatrolledValues ) {
+ return $rcPatrolledValues[ $s ];
+ }, $selected );
+ }
]
];
// information to all users just because the user that saves the edit can
// patrol or is logged in)
if ( !$this->including() && $this->getUser()->useRCPatrol() ) {
+ $this->registerFiltersFromDefinitions( $this->legacyReviewStatusFilterGroupDefinition );
$this->registerFiltersFromDefinitions( $this->reviewStatusFilterGroupDefinition );
}
}
/**
- * Replace old options 'hideanons' or 'hideliu' with structured UI equivalent
+ * Replace old options with their structured UI equivalents
*
* @param FormOptions $opts
* @return bool True if the change was made
return false;
}
+ $changed = false;
+
// At this point 'hideanons' and 'hideliu' cannot be both true,
// because fixBackwardsCompatibilityOptions resets (at least) 'hideanons' in such case
if ( $opts[ 'hideanons' ] ) {
$opts->reset( 'hideanons' );
$opts[ 'userExpLevel' ] = 'registered';
- return true;
+ $changed = true;
}
if ( $opts[ 'hideliu' ] ) {
$opts->reset( 'hideliu' );
$opts[ 'userExpLevel' ] = 'unregistered';
- return true;
+ $changed = true;
}
- return false;
+ if ( $this->getFilterGroup( 'legacyReviewStatus' ) ) {
+ if ( $opts[ 'hidepatrolled' ] ) {
+ $opts->reset( 'hidepatrolled' );
+ $opts[ 'reviewStatus' ] = 'unpatrolled';
+ $changed = true;
+ }
+
+ if ( $opts[ 'hideunpatrolled' ] ) {
+ $opts->reset( 'hideunpatrolled' );
+ $opts[ 'reviewStatus' ] = implode(
+ ChangesListStringOptionsFilterGroup::SEPARATOR,
+ [ 'manual', 'auto' ]
+ );
+ $changed = true;
+ }
+ }
+
+ return $changed;
}
/**
$reviewStatus = $this->getFilterGroup( 'reviewStatus' );
if ( $reviewStatus !== null ) {
// Conditional on feature being available and rights
- $hidePatrolled = $reviewStatus->getFilter( 'hidepatrolled' );
- $hidePatrolled->setDefault( $user->getBoolOption( 'hidepatrolled' ) );
+ if ( $user->getBoolOption( 'hidepatrolled' ) ) {
+ $reviewStatus->setDefault( 'unpatrolled' );
+ $legacyReviewStatus = $this->getFilterGroup( 'legacyReviewStatus' );
+ $legacyHidePatrolled = $legacyReviewStatus->getFilter( 'hidepatrolled' );
+ $legacyHidePatrolled->setDefault( true );
+ }
}
$changeType = $this->getFilterGroup( 'changeType' );
$reviewStatus = $this->getFilterGroup( 'reviewStatus' );
if ( $reviewStatus !== null ) {
// Conditional on feature being available and rights
- $hidePatrolled = $reviewStatus->getFilter( 'hidepatrolled' );
- $hidePatrolled->setDefault( $user->getBoolOption( 'watchlisthidepatrolled' ) );
+ if ( $user->getBoolOption( 'watchlisthidepatrolled' ) ) {
+ $reviewStatus->setDefault( 'unpatrolled' );
+ $legacyReviewStatus = $this->getFilterGroup( 'legacyReviewStatus' );
+ $legacyHidePatrolled = $legacyReviewStatus->getFilter( 'hidepatrolled' );
+ $legacyHidePatrolled->setDefault( true );
+ }
}
$authorship = $this->getFilterGroup( 'authorship' );
return false;
}
- /**
- * Check HTML for errors, used if $wgValidateAllHtml = true.
- *
- * @param string $text
- * @param string &$errorStr Return the error string
- * @throws \MWException
- * @return bool Whether the HTML is valid
- */
- public function validate( $text, &$errorStr ) {
- throw new \MWException( static::class . ' does not support validate()' );
- }
-
/**
* Clean up HTML
*
"rcfilters-filter-humans-label": "Human (not bot)",
"rcfilters-filter-humans-description": "Edits made by human editors.",
"rcfilters-filtergroup-reviewstatus": "Review status",
- "rcfilters-filter-patrolled-label": "Patrolled",
- "rcfilters-filter-patrolled-description": "Edits marked as patrolled.",
- "rcfilters-filter-unpatrolled-label": "Unpatrolled",
- "rcfilters-filter-unpatrolled-description": "Edits not marked as patrolled.",
+ "rcfilters-filter-reviewstatus-unpatrolled-description": "Edits not manually or automatically marked as patrolled.",
+ "rcfilters-filter-reviewstatus-unpatrolled-label": "Unpatrolled",
+ "rcfilters-filter-reviewstatus-manual-description": "Edits manually marked as patrolled.",
+ "rcfilters-filter-reviewstatus-manual-label": "Manually patrolled",
+ "rcfilters-filter-reviewstatus-auto-description": "Edits by advanced users whose work is automatically marked as patrolled.",
+ "rcfilters-filter-reviewstatus-auto-label": "Autopatrolled",
"rcfilters-filtergroup-significance": "Significance",
"rcfilters-filter-minor-label": "Minor edits",
"rcfilters-filter-minor-description": "Edits the author labeled as minor.",
"rcfilters-filter-humans-label": "Label for the filter for showing edits made by human editors.",
"rcfilters-filter-humans-description": "Description for the filter for showing edits made by human editors.",
"rcfilters-filtergroup-reviewstatus": "Title for the filter group about review status (in core this is whether it's been patrolled)",
- "rcfilters-filter-patrolled-label": "Label for the filter for showing patrolled edits",
- "rcfilters-filter-patrolled-description": "Label for the filter showing patrolled edits",
- "rcfilters-filter-unpatrolled-label": "Label for the filter for showing unpatrolled edits",
- "rcfilters-filter-unpatrolled-description": "Description for the filter for showing unpatrolled edits",
+ "rcfilters-filter-reviewstatus-manual-description": "Description for the filter showing manually patrolled edits",
+ "rcfilters-filter-reviewstatus-manual-label": "Label for the filter showing manually patrolled edits",
+ "rcfilters-filter-reviewstatus-auto-description": "Description for the filter showing automatically patrolled edits",
+ "rcfilters-filter-reviewstatus-auto-label": "Label for the filter showing automatically patrolled edits",
+ "rcfilters-filter-reviewstatus-unpatrolled-description": "Description for the filter for showing unpatrolled edits",
+ "rcfilters-filter-reviewstatus-unpatrolled-label": "Label for the filter for showing unpatrolled edits",
"rcfilters-filtergroup-significance": "Title for the filter group for edit significance.\n{{Identical|Significance}}",
"rcfilters-filter-minor-label": "Label for the filter for showing edits marked as minor.",
"rcfilters-filter-minor-description": "Description for the filter for showing edits marked as minor.",
min-width: 20em;
}
+/* Hide empty live-log textarea */
+#config-live-log textarea:empty {
+ display: none;
+}
+
/* tooltip styles */
.config-help-field-hint {
display: none;
-ms-user-select: none;
user-select: none;
}
+.mw-collapsible-toggle:before {
+ content: '[';
+}
+.mw-collapsible-toggle:after {
+ content: ']';
+}
/* Align the toggle based on the direction of the content language */
/* @noflip */
.mw-content-ltr .mw-collapsible-toggle,
* @class jQuery.plugin.makeCollapsible
*/
( function ( $, mw ) {
-
/**
* Handler for a click on a collapsible toggler.
*
role: 'button',
tabindex: 0
} )
- .prepend( '<span>[</span>' )
- .append( '<span>]</span>' )
.on( 'click.mw-collapsible keypress.mw-collapsible', actionHandler );
};
@import 'mediawiki.mixins';
@import 'mediawiki.ui/variables';
+@import 'mw.rcfilters.variables';
.mw-rcfilters-ui-filterMenuHeaderWidget {
&-title {
&-invert,
&-highlight {
- width: 1em;
+ min-width: 1em;
vertical-align: middle;
// Using the same padding that the filter item
// uses, so the button is aligned with the highlight
// buttons for the filters
- padding-right: 0.5em;
+ padding-right: 12 / @font-size-system-ui / @font-size-vector;
}
&-back {
width: 1em;
vertical-align: middle;
- padding-left: 0.5em;
+
+ .mw-rcfilters-ui-filterMenuHeaderWidget-backButton:first-child {
+ // Overwrite `.oo-ui-buttonElement-frameless.oo-ui-iconElement:first-child`
+ margin-left: 0;
+ }
}
&-title {
@import 'mediawiki.mixins';
@import 'mediawiki.ui/variables';
+@import 'mw.rcfilters.variables';
.mw-rcfilters-ui-filterMenuOptionWidget {
+ .mw-rcfilters-ui-filterMenuSectionOptionWidget ~ & {
+ padding-left: 12 / @font-size-system-ui / @font-size-vector;
+ }
+
&.oo-ui-flaggedElement-muted {
&:not( .oo-ui-optionWidget-selected ) {
// Namespaces are muted 'the other way around' when they
}
}
+ // Override OOUI's pretty specific
+ // `.oo-ui-fieldLayout.oo-ui-labelElement.oo-ui-fieldLayout-align-inline > .oo-ui-fieldLayout-body > .oo-ui-fieldLayout-header`
+ // selector
+ .mw-rcfilters-ui-itemMenuOptionWidget-itemCheckbox > .oo-ui-fieldLayout > .oo-ui-fieldLayout-body > .oo-ui-fieldLayout-header {
+ padding-top: 0;
+ padding-bottom: 0;
+ padding-left: 12 / @font-size-system-ui / @font-size-vector;
+ }
}
@import 'mediawiki.mixins';
@import 'mediawiki.ui/variables';
+@import 'mw.rcfilters.variables';
.mw-rcfilters-ui-filterMenuSectionOptionWidget {
- background: @colorGray14;
- padding-bottom: 0.7em;
-
- &-header {
- padding: 0 0.75em;
- // Use a high specificity to override OOUI
- .oo-ui-optionWidget.oo-ui-labelElement &-title.oo-ui-labelElement-label {
- color: @colorGray5;
- .box-sizing( border-box );
- display: inline-block;
- }
+ background-color: @colorGray14;
+ padding-bottom: 8 / @font-size-system-ui / @font-size-vector;
+ padding-left: 12 / @font-size-system-ui / @font-size-vector;
+ padding-right: 12 / @font-size-system-ui / @font-size-vector;
+
+ &-header-title.oo-ui-labelElement-label {
+ color: @colorGray5;
+ display: inline-block;
}
&-whatsThisButton {
margin-left: 1.5em;
&.oo-ui-buttonElement > .oo-ui-buttonElement-button {
- font-weight: normal;
border: 0; // Override OOUI `border` needed for frameless keyboard focus
padding: 0;
+ font-weight: normal;
&:focus {
.box-shadow( none );
padding: 1em;
&-header {
- font-weight: bold;
margin-bottom: 1em;
+ font-weight: bold;
}
&-link {
}
}
- &-active {
- .mw-rcfilters-ui-filterMenuSectionOptionWidget-header-title {
- font-weight: bold;
- }
+ &-active .mw-rcfilters-ui-filterMenuSectionOptionWidget-header-title {
+ font-weight: bold;
}
}
}
&-view-namespaces {
- border-top: 5px solid @colorGray12;
+ border-top: 4px solid @colorGray12;
&:first-child,
&.mw-rcfilters-ui-itemMenuOptionWidget-identifier-subject + &.mw-rcfilters-ui-itemMenuOptionWidget-identifier-talk {
}
.mw-rcfilters-ui-table {
- padding-top: 0.5em;
+ padding-top: 6 / @font-size-system-ui / @font-size-vector;
+ padding-bottom: 6 / @font-size-system-ui / @font-size-vector;
}
&.oo-ui-optionWidget-selected {
}
&-itemCheckbox {
+ .oo-ui-fieldLayout-body > .oo-ui-fieldLayout-header {
+ padding-left: 12 / @font-size-system-ui / @font-size-vector;
+ }
+
.oo-ui-fieldLayout.oo-ui-fieldLayout-align-inline {
// Override margin-top and -bottom rules from FieldLayout
margin: 0 !important; /* stylelint-disable-line declaration-no-important */
@import 'mediawiki.mixins';
@import 'mediawiki.ui/variables';
+@import 'mw.rcfilters.variables';
.mw-rcfilters-ui-menuSelectWidget {
z-index: auto;
}
&-noresults {
- padding: 0.5em;
color: @colorGray5;
+ padding: 12 / @font-size-system-ui / @font-size-vector;
}
&-body {
}
&-footer {
- padding: 0.5em;
background-color: @colorGray15;
border-top: 1px solid @colorGray12;
+ padding: 12 / @font-size-system-ui / @font-size-vector;
& + & {
border-top: 0;
+// “External” variables
+@font-size-system-ui: 16; // Assumed browser default of `16px`
+@font-size-vector: 0.875em; // equals `14px` at browser default of `16px`
+
+// RCFilters variables
@background-color-base: #fff;
@background-color-primary: #eaf3ff;
@color-base--inverted: #fff;
// Parent constructor
mw.widgets.TitleOptionWidget.parent.call( this, config );
+ // Remove check icon
+ this.checkIcon.$element.remove();
+
// Initialization
this.$label.attr( 'href', config.url );
this.$element.addClass( 'mw-widget-titleOptionWidget' );
return false;
} );// hooks::register
+ // Reset the service in case any other tests already cached some prefixes.
+ MediaWikiServices::getInstance()->resetServiceForTesting( 'InterwikiLookup' );
+
return function () {
// Tear down
Hooks::clear( 'InterwikiLoadPrefix' );
+ MediaWikiServices::getInstance()->resetServiceForTesting( 'InterwikiLookup' );
};
}
return $loaded;
}
- /**
- * Asserts that the given string is a valid HTML snippet.
- * Wraps the given string in the required top level tags and
- * then calls assertValidHtmlDocument().
- * The snippet is expected to be HTML 5.
- *
- * @since 1.23
- *
- * @note Will mark the test as skipped if the "tidy" module is not installed.
- * @note This ignores $wgUseTidy, so we can check for valid HTML even (and especially)
- * when automatic tidying is disabled.
- *
- * @param string $html An HTML snippet (treated as the contents of the body tag).
- */
- protected function assertValidHtmlSnippet( $html ) {
- $html = '<!DOCTYPE html><html><head><title>test</title></head><body>' . $html . '</body></html>';
- $this->assertValidHtmlDocument( $html );
- }
-
- /**
- * Asserts that the given string is valid HTML document.
- *
- * @since 1.23
- *
- * @note Will mark the test as skipped if the "tidy" module is not installed.
- * @note This ignores $wgUseTidy, so we can check for valid HTML even (and especially)
- * when automatic tidying is disabled.
- *
- * @param string $html A complete HTML document
- */
- protected function assertValidHtmlDocument( $html ) {
- // Note: we only validate if the tidy PHP extension is available.
- // In case wgTidyInternal is false, MWTidy would fall back to the command line version
- // of tidy. In that case however, we can not reliably detect whether a failing validation
- // is due to malformed HTML, or caused by tidy not being installed as a command line tool.
- // That would cause all HTML assertions to fail on a system that has no tidy installed.
- if ( !$GLOBALS['wgTidyInternal'] || !MWTidy::isEnabled() ) {
- $this->markTestSkipped( 'Tidy extension not installed' );
- }
-
- $errorBuffer = '';
- MWTidy::checkErrors( $html, $errorBuffer );
- $allErrors = preg_split( '/[\r\n]+/', $errorBuffer );
-
- // Filter Tidy warnings which aren't useful for us.
- // Tidy eg. often cries about parameters missing which have actually
- // been deprecated since HTML4, thus we should not care about them.
- $errors = preg_grep(
- '/^(.*Warning: (trimming empty|.* lacks ".*?" attribute).*|\s*)$/m',
- $allErrors, PREG_GREP_INVERT
- );
-
- $this->assertEmpty( $errors, implode( "\n", $errors ) );
- }
-
/**
* Used as a marker to prevent wfResetOutputBuffers from breaking PHPUnit.
* @param string $buffer
protected static $revIds = [];
public function addDBDataOnce() {
- $user = static::getTestSysop()->getUser();
$title = Title::newFromText( __CLASS__ );
- $page = WikiPage::factory( $title );
- $status = $page->doEditContent(
- ContentHandler::makeContent( 'Test for revdel', $title, CONTENT_MODEL_WIKITEXT ),
- __METHOD__ . ' Test for revdel', 0, false, $user
- );
- if ( !$status->isOK() ) {
- $this->fail( "Failed to create $title: " . $status->getWikiText( false, false, 'en' ) );
- }
+ $status = $this->editPage( __CLASS__, 'Test for revdel' );
self::$pageId = $status->value['revision']->getPage();
self::$revIds['revdel'] = $status->value['revision']->getId();
- $status = $page->doEditContent(
- ContentHandler::makeContent( 'Test for oldid', $title, CONTENT_MODEL_WIKITEXT ),
- __METHOD__ . ' Test for oldid', 0, false, $user
- );
- if ( !$status->isOK() ) {
- $this->fail( "Failed to edit $title: " . $status->getWikiText( false, false, 'en' ) );
- }
+ $status = $this->editPage( __CLASS__, 'Test for suppressed' );
+ self::$revIds['suppressed'] = $status->value['revision']->getId();
+
+ $status = $this->editPage( __CLASS__, 'Test for oldid' );
self::$revIds['oldid'] = $status->value['revision']->getId();
- $status = $page->doEditContent(
- ContentHandler::makeContent( 'Test for latest', $title, CONTENT_MODEL_WIKITEXT ),
- __METHOD__ . ' Test for latest', 0, false, $user
+ $status = $this->editPage( __CLASS__, 'Test for latest' );
+ self::$revIds['latest'] = $status->value['revision']->getId();
+
+ $this->revisionDelete( self::$revIds['revdel'] );
+ $this->revisionDelete(
+ self::$revIds['suppressed'],
+ [ Revision::DELETED_TEXT => 1, Revision::DELETED_RESTRICTED => 1 ]
);
- if ( !$status->isOK() ) {
- $this->fail( "Failed to edit $title: " . $status->getWikiText( false, false, 'en' ) );
+
+ Title::clearCaches(); // Otherwise it has the wrong latest revision for some reason
+ }
+
+ /**
+ * Assert that the given result of calling $this->doApiRequest() with
+ * action=parse resulted in $html, accounting for the boilerplate that the
+ * parser adds around the parsed page. Also asserts that warnings match
+ * the provided $warning.
+ *
+ * @param string $html Expected HTML
+ * @param array $res Returned from doApiRequest()
+ * @param string|null $warnings Exact value of expected warnings, null for
+ * no warnings
+ */
+ protected function assertParsedTo( $expected, array $res, $warnings = null ) {
+ $this->doAssertParsedTo( $expected, $res, $warnings, [ $this, 'assertSame' ] );
+ }
+
+ /**
+ * Same as above, but asserts that the HTML matches a regexp instead of a
+ * literal string match.
+ *
+ * @param string $html Expected HTML
+ * @param array $res Returned from doApiRequest()
+ * @param string|null $warnings Exact value of expected warnings, null for
+ * no warnings
+ */
+ protected function assertParsedToRegExp( $expected, array $res, $warnings = null ) {
+ $this->doAssertParsedTo( $expected, $res, $warnings, [ $this, 'assertRegExp' ] );
+ }
+
+ private function doAssertParsedTo( $expected, array $res, $warnings, callable $callback ) {
+ $html = $res[0]['parse']['text'];
+
+ $expectedStart = '<div class="mw-parser-output">';
+ $this->assertSame( $expectedStart, substr( $html, 0, strlen( $expectedStart ) ) );
+
+ $html = substr( $html, strlen( $expectedStart ) );
+
+ if ( $res[1]->getBool( 'disablelimitreport' ) ) {
+ $expectedEnd = "</div>";
+ $this->assertSame( $expectedEnd, substr( $html, -strlen( $expectedEnd ) ) );
+
+ $html = substr( $html, 0, strlen( $html ) - strlen( $expectedEnd ) );
+ } else {
+ $expectedEnd = '#\n<!-- \nNewPP limit report\n(?>.+?\n-->)\n' .
+ '<!--\nTransclusion expansion time report \(%,ms,calls,template\)\n(?>.*?\n-->)\n' .
+ '</div>(\n<!-- Saved in parser cache (?>.*?\n -->)\n)?$#s';
+ $this->assertRegExp( $expectedEnd, $html );
+
+ $html = preg_replace( $expectedEnd, '', $html );
}
- self::$revIds['latest'] = $status->value['revision']->getId();
- RevisionDeleter::createList(
- 'revision', RequestContext::getMain(), $title, [ self::$revIds['revdel'] ]
- )->setVisibility( [
- 'value' => [
- Revision::DELETED_TEXT => 1,
+ call_user_func( $callback, $expected, $html );
+
+ if ( $warnings === null ) {
+ $this->assertCount( 1, $res[0] );
+ } else {
+ $this->assertCount( 2, $res[0] );
+ // This deliberately fails if there are extra warnings
+ $this->assertSame( [ 'parse' => [ 'warnings' => $warnings ] ], $res[0]['warnings'] );
+ }
+ }
+
+ /**
+ * Set up an interwiki entry for testing.
+ */
+ protected function setupInterwiki() {
+ $dbw = wfGetDB( DB_MASTER );
+ $dbw->insert(
+ 'interwiki',
+ [
+ 'iw_prefix' => 'madeuplanguage',
+ 'iw_url' => "https://example.com/wiki/$1",
+ 'iw_api' => '',
+ 'iw_wikiid' => '',
+ 'iw_local' => false,
],
- 'comment' => 'Test for revdel',
- ] );
+ __METHOD__,
+ 'IGNORE'
+ );
- Title::clearCaches(); // Otherwise it has the wrong latest revision for some reason
+ $this->setMwGlobals( 'wgExtraInterlanguageLinkPrefixes', [ 'madeuplanguage' ] );
+ $this->tablesUsed[] = 'interwiki';
+ }
+
+ /**
+ * Set up a skin for testing.
+ *
+ * @todo Should this code be in MediaWikiTestCase or something?
+ */
+ protected function setupSkin() {
+ $factory = new SkinFactory();
+ $factory->register( 'testing', 'Testing', function () {
+ $skin = $this->getMockBuilder( SkinFallback::class )
+ ->setMethods( [ 'getDefaultModules', 'setupSkinUserCss' ] )
+ ->getMock();
+ $skin->expects( $this->once() )->method( 'getDefaultModules' )
+ ->willReturn( [
+ 'core' => [ 'foo', 'bar' ],
+ 'content' => [ 'baz' ]
+ ] );
+ $skin->expects( $this->once() )->method( 'setupSkinUserCss' )
+ ->will( $this->returnCallback( function ( OutputPage $out ) {
+ $out->addModuleStyles( 'foo.styles' );
+ } ) );
+ return $skin;
+ } );
+ $this->setService( 'SkinFactory', $factory );
}
public function testParseByName() {
'action' => 'parse',
'page' => __CLASS__,
] );
- $this->assertContains( 'Test for latest', $res[0]['parse']['text'] );
+ $this->assertParsedTo( "<p>Test for latest\n</p>", $res );
$res = $this->doApiRequest( [
'action' => 'parse',
'page' => __CLASS__,
'disablelimitreport' => 1,
] );
- $this->assertContains( 'Test for latest', $res[0]['parse']['text'] );
+ $this->assertParsedTo( "<p>Test for latest\n</p>", $res );
}
public function testParseById() {
'action' => 'parse',
'pageid' => self::$pageId,
] );
- $this->assertContains( 'Test for latest', $res[0]['parse']['text'] );
+ $this->assertParsedTo( "<p>Test for latest\n</p>", $res );
}
public function testParseByOldId() {
'action' => 'parse',
'oldid' => self::$revIds['oldid'],
] );
- $this->assertContains( 'Test for oldid', $res[0]['parse']['text'] );
+ $this->assertParsedTo( "<p>Test for oldid\n</p>", $res );
$this->assertArrayNotHasKey( 'textdeleted', $res[0]['parse'] );
$this->assertArrayNotHasKey( 'textsuppressed', $res[0]['parse'] );
}
- public function testParseRevDel() {
- $user = static::getTestUser()->getUser();
- $sysop = static::getTestSysop()->getUser();
-
- try {
- $this->doApiRequest( [
- 'action' => 'parse',
- 'oldid' => self::$revIds['revdel'],
- ], null, null, $user );
- $this->fail( "API did not return an error as expected" );
- } catch ( ApiUsageException $ex ) {
- $this->assertTrue( ApiTestCase::apiExceptionHasCode( $ex, 'permissiondenied' ),
- "API failed with error 'permissiondenied'" );
- }
-
+ public function testRevDel() {
$res = $this->doApiRequest( [
'action' => 'parse',
'oldid' => self::$revIds['revdel'],
- ], null, null, $sysop );
- $this->assertContains( 'Test for revdel', $res[0]['parse']['text'] );
+ ] );
+
+ $this->assertParsedTo( "<p>Test for revdel\n</p>", $res );
$this->assertArrayHasKey( 'textdeleted', $res[0]['parse'] );
$this->assertArrayNotHasKey( 'textsuppressed', $res[0]['parse'] );
}
- public function testParseNonexistentPage() {
+ public function testRevDelNoPermission() {
+ $this->setExpectedException( ApiUsageException::class,
+ "You don't have permission to view deleted revision text." );
+
+ $this->doApiRequest( [
+ 'action' => 'parse',
+ 'oldid' => self::$revIds['revdel'],
+ ], null, null, static::getTestUser()->getUser() );
+ }
+
+ public function testSuppressed() {
+ $this->setGroupPermissions( 'sysop', 'viewsuppressed', true );
+
+ $res = $this->doApiRequest( [
+ 'action' => 'parse',
+ 'oldid' => self::$revIds['suppressed']
+ ] );
+
+ $this->assertParsedTo( "<p>Test for suppressed\n</p>", $res );
+ $this->assertArrayHasKey( 'textsuppressed', $res[0]['parse'] );
+ $this->assertArrayHasKey( 'textdeleted', $res[0]['parse'] );
+ }
+
+ public function testNonexistentPage() {
try {
$this->doApiRequest( [
'action' => 'parse',
}
}
- public function testSkinModules() {
- $factory = new SkinFactory();
- $factory->register( 'testing', 'Testing', function () {
- $skin = $this->getMockBuilder( SkinFallback::class )
- ->setMethods( [ 'getDefaultModules', 'setupSkinUserCss' ] )
- ->getMock();
- $skin->expects( $this->once() )->method( 'getDefaultModules' )
- ->willReturn( [
- 'core' => [ 'foo', 'bar' ],
- 'content' => [ 'baz' ]
- ] );
- $skin->expects( $this->once() )->method( 'setupSkinUserCss' )
- ->will( $this->returnCallback( function ( OutputPage $out ) {
- $out->addModuleStyles( 'foo.styles' );
- } ) );
- return $skin;
- } );
- $this->setService( 'SkinFactory', $factory );
+ public function testTitleProvided() {
+ $res = $this->doApiRequest( [
+ 'action' => 'parse',
+ 'title' => 'Some interesting page',
+ 'text' => '{{PAGENAME}} has attracted my attention',
+ ] );
+
+ $this->assertParsedTo( "<p>Some interesting page has attracted my attention\n</p>", $res );
+ }
+
+ public function testSection() {
+ $name = ucfirst( __FUNCTION__ );
+
+ $this->editPage( $name,
+ "Intro\n\n== Section 1 ==\n\nContent 1\n\n== Section 2 ==\n\nContent 2" );
+
+ $res = $this->doApiRequest( [
+ 'action' => 'parse',
+ 'page' => $name,
+ 'section' => 1,
+ ] );
+
+ $this->assertParsedToRegExp( '!<h2>.*Section 1.*</h2>\n<p>Content 1\n</p>!', $res );
+ }
+
+ public function testInvalidSection() {
+ $this->setExpectedException( ApiUsageException::class,
+ 'The "section" parameter must be a valid section ID or "new".' );
+
+ $this->doApiRequest( [
+ 'action' => 'parse',
+ 'section' => 'T-new',
+ ] );
+ }
+
+ public function testSectionNoContent() {
+ $name = ucfirst( __FUNCTION__ );
+
+ $status = $this->editPage( $name,
+ "Intro\n\n== Section 1 ==\n\nContent 1\n\n== Section 2 ==\n\nContent 2" );
+
+ $this->setExpectedException( ApiUsageException::class,
+ "Missing content for page ID {$status->value['revision']->getPage()}." );
+
+ $this->db->delete( 'revision', [ 'rev_id' => $status->value['revision']->getId() ] );
+
+ // Suppress warning in WikiPage::getContentModel
+ Wikimedia\suppressWarnings();
+ try {
+ $this->doApiRequest( [
+ 'action' => 'parse',
+ 'page' => $name,
+ 'section' => 1,
+ ] );
+ } finally {
+ Wikimedia\restoreWarnings();
+ }
+ }
+
+ public function testNewSectionWithPage() {
+ $this->setExpectedException( ApiUsageException::class,
+ '"section=new" cannot be combined with the "oldid", "pageid" or "page" ' .
+ 'parameters. Please use "title" and "text".' );
+
+ $this->doApiRequest( [
+ 'action' => 'parse',
+ 'page' => __CLASS__,
+ 'section' => 'new',
+ ] );
+ }
+
+ public function testNonexistentOldId() {
+ $this->setExpectedException( ApiUsageException::class,
+ 'There is no revision with ID 2147483647.' );
+
+ $this->doApiRequest( [
+ 'action' => 'parse',
+ 'oldid' => pow( 2, 31 ) - 1,
+ ] );
+ }
+
+ public function testUnfollowedRedirect() {
+ $name = ucfirst( __FUNCTION__ );
+
+ $this->editPage( $name, "#REDIRECT [[$name 2]]" );
+ $this->editPage( "$name 2", "Some ''text''" );
+
+ $res = $this->doApiRequest( [
+ 'action' => 'parse',
+ 'page' => $name,
+ ] );
+
+ // Can't use assertParsedTo because the parser output is different for
+ // redirects
+ $this->assertRegExp( "/Redirect to:.*$name 2/", $res[0]['parse']['text'] );
+ $this->assertArrayNotHasKey( 'warnings', $res[0] );
+ }
+
+ public function testFollowedRedirect() {
+ $name = ucfirst( __FUNCTION__ );
+
+ $this->editPage( $name, "#REDIRECT [[$name 2]]" );
+ $this->editPage( "$name 2", "Some ''text''" );
+
+ $res = $this->doApiRequest( [
+ 'action' => 'parse',
+ 'page' => $name,
+ 'redirects' => true,
+ ] );
+
+ $this->assertParsedTo( "<p>Some <i>text</i>\n</p>", $res );
+ }
+
+ public function testFollowedRedirectById() {
+ $name = ucfirst( __FUNCTION__ );
+
+ $id = $this->editPage( $name, "#REDIRECT [[$name 2]]" )->value['revision']->getPage();
+ $this->editPage( "$name 2", "Some ''text''" );
+
+ $res = $this->doApiRequest( [
+ 'action' => 'parse',
+ 'pageid' => $id,
+ 'redirects' => true,
+ ] );
+
+ $this->assertParsedTo( "<p>Some <i>text</i>\n</p>", $res );
+ }
+
+ public function testInvalidTitle() {
+ $this->setExpectedException( ApiUsageException::class, 'Bad title "|".' );
+
+ $this->doApiRequest( [
+ 'action' => 'parse',
+ 'title' => '|',
+ ] );
+ }
+
+ public function testTitleWithNonexistentRevId() {
+ $this->setExpectedException( ApiUsageException::class,
+ 'There is no revision with ID 2147483647.' );
+
+ $this->doApiRequest( [
+ 'action' => 'parse',
+ 'title' => __CLASS__,
+ 'revid' => pow( 2, 31 ) - 1,
+ ] );
+ }
+
+ public function testTitleWithNonMatchingRevId() {
+ $name = ucfirst( __FUNCTION__ );
+
+ $res = $this->doApiRequest( [
+ 'action' => 'parse',
+ 'title' => $name,
+ 'revid' => self::$revIds['latest'],
+ 'text' => 'Some text',
+ ] );
+
+ $this->assertParsedTo( "<p>Some text\n</p>", $res,
+ 'r' . self::$revIds['latest'] . " is not a revision of $name." );
+ }
+
+ public function testRevId() {
+ $res = $this->doApiRequest( [
+ 'action' => 'parse',
+ 'revid' => self::$revIds['latest'],
+ 'text' => 'My revid is {{REVISIONID}}!',
+ ] );
+
+ $this->assertParsedTo( "<p>My revid is " . self::$revIds['latest'] . "!\n</p>", $res );
+ }
+
+ public function testTitleNoText() {
+ $res = $this->doApiRequest( [
+ 'action' => 'parse',
+ 'title' => 'Special:AllPages',
+ ] );
+
+ $this->assertParsedTo( '', $res,
+ '"title" used without "text", and parsed page properties were requested. ' .
+ 'Did you mean to use "page" instead of "title"?' );
+ }
+
+ public function testRevidNoText() {
+ $res = $this->doApiRequest( [
+ 'action' => 'parse',
+ 'revid' => self::$revIds['latest'],
+ ] );
+
+ $this->assertParsedTo( '', $res,
+ '"revid" used without "text", and parsed page properties were requested. ' .
+ 'Did you mean to use "oldid" instead of "revid"?' );
+ }
+
+ public function testTextNoContentModel() {
+ $res = $this->doApiRequest( [
+ 'action' => 'parse',
+ 'text' => "Some ''text''",
+ ] );
+
+ $this->assertParsedTo( "<p>Some <i>text</i>\n</p>", $res,
+ 'No "title" or "contentmodel" was given, assuming wikitext.' );
+ }
+
+ public function testSerializationError() {
+ $this->setExpectedException( APIUsageException::class,
+ 'Content serialization failed: Could not unserialize content' );
+
+ $this->mergeMwGlobalArrayValue( 'wgContentHandlers',
+ [ 'testing-serialize-error' => 'DummySerializeErrorContentHandler' ] );
+
+ $this->doApiRequest( [
+ 'action' => 'parse',
+ 'text' => "Some ''text''",
+ 'contentmodel' => 'testing-serialize-error',
+ ] );
+ }
+
+ public function testNewSection() {
+ $res = $this->doApiRequest( [
+ 'action' => 'parse',
+ 'title' => __CLASS__,
+ 'section' => 'new',
+ 'sectiontitle' => 'Title',
+ 'text' => 'Content',
+ ] );
+
+ $this->assertParsedToRegExp( '!<h2>.*Title.*</h2>\n<p>Content\n</p>!', $res );
+ }
+
+ public function testExistingSection() {
+ $res = $this->doApiRequest( [
+ 'action' => 'parse',
+ 'title' => __CLASS__,
+ 'section' => 1,
+ 'text' => "Intro\n\n== Section 1 ==\n\nContent\n\n== Section 2 ==\n\nMore content",
+ ] );
+
+ $this->assertParsedToRegExp( '!<h2>.*Section 1.*</h2>\n<p>Content\n</p>!', $res );
+ }
+
+ public function testNoPst() {
+ $name = ucfirst( __FUNCTION__ );
+
+ $this->editPage( "Template:$name", "Template ''text''" );
+
+ $res = $this->doApiRequest( [
+ 'action' => 'parse',
+ 'text' => "{{subst:$name}}",
+ 'contentmodel' => 'wikitext',
+ ] );
+
+ $this->assertParsedTo( "<p>{{subst:$name}}\n</p>", $res );
+ }
+
+ public function testPst() {
+ $name = ucfirst( __FUNCTION__ );
+
+ $this->editPage( "Template:$name", "Template ''text''" );
+
+ $res = $this->doApiRequest( [
+ 'action' => 'parse',
+ 'pst' => '',
+ 'text' => "{{subst:$name}}",
+ 'contentmodel' => 'wikitext',
+ 'prop' => 'text|wikitext',
+ ] );
+
+ $this->assertParsedTo( "<p>Template <i>text</i>\n</p>", $res );
+ $this->assertSame( "{{subst:$name}}", $res[0]['parse']['wikitext'] );
+ }
+
+ public function testOnlyPst() {
+ $name = ucfirst( __FUNCTION__ );
+
+ $this->editPage( "Template:$name", "Template ''text''" );
+
+ $res = $this->doApiRequest( [
+ 'action' => 'parse',
+ 'onlypst' => '',
+ 'text' => "{{subst:$name}}",
+ 'contentmodel' => 'wikitext',
+ 'prop' => 'text|wikitext',
+ 'summary' => 'Summary',
+ ] );
+
+ $this->assertSame(
+ [ 'parse' => [
+ 'text' => "Template ''text''",
+ 'wikitext' => "{{subst:$name}}",
+ 'parsedsummary' => 'Summary',
+ ] ],
+ $res[0]
+ );
+ }
+
+ public function testHeadHtml() {
+ $res = $this->doApiRequest( [
+ 'action' => 'parse',
+ 'page' => __CLASS__,
+ 'prop' => 'headhtml',
+ ] );
+
+ // Just do a rough sanity check
+ $this->assertRegExp( '#<!DOCTYPE.*<html.*<head.*</head>.*<body#s',
+ $res[0]['parse']['headhtml'] );
+ $this->assertArrayNotHasKey( 'warnings', $res[0] );
+ }
+
+ public function testCategoriesHtml() {
+ $name = ucfirst( __FUNCTION__ );
+
+ $this->editPage( $name, "[[Category:$name]]" );
+
+ $res = $this->doApiRequest( [
+ 'action' => 'parse',
+ 'page' => $name,
+ 'prop' => 'categorieshtml',
+ ] );
+
+ $this->assertRegExp( "#Category.*Category:$name.*$name#",
+ $res[0]['parse']['categorieshtml'] );
+ $this->assertArrayNotHasKey( 'warnings', $res[0] );
+ }
+
+ public function testEffectiveLangLinks() {
+ $hookRan = false;
+ $this->setTemporaryHook( 'LanguageLinks',
+ function () use ( &$hookRan ) {
+ $hookRan = true;
+ }
+ );
+
+ $res = $this->doApiRequest( [
+ 'action' => 'parse',
+ 'title' => __CLASS__,
+ 'text' => '[[zh:' . __CLASS__ . ']]',
+ 'effectivelanglinks' => '',
+ ] );
+
+ $this->assertTrue( $hookRan );
+ $this->assertSame( 'The parameter "effectivelanglinks" has been deprecated.',
+ $res[0]['warnings']['parse']['warnings'] );
+ }
+
+ /**
+ * @param array $arr Extra params to add to API request
+ */
+ private function doTestLangLinks( array $arr = [] ) {
+ $this->setupInterwiki();
+
+ $res = $this->doApiRequest( array_merge( [
+ 'action' => 'parse',
+ 'title' => 'Omelette',
+ 'text' => '[[madeuplanguage:Omelette]]',
+ 'prop' => 'langlinks',
+ ], $arr ) );
+
+ $langLinks = $res[0]['parse']['langlinks'];
+
+ $this->assertCount( 1, $langLinks );
+ $this->assertSame( 'madeuplanguage', $langLinks[0]['lang'] );
+ $this->assertSame( 'Omelette', $langLinks[0]['title'] );
+ $this->assertSame( 'https://example.com/wiki/Omelette', $langLinks[0]['url'] );
+ $this->assertArrayNotHasKey( 'warnings', $res[0] );
+ }
+
+ public function testLangLinks() {
+ $this->doTestLangLinks();
+ }
+
+ public function testLangLinksWithSkin() {
+ $this->setupSkin();
+ $this->doTestLangLinks( [ 'useskin' => 'testing' ] );
+ }
+
+ public function testHeadItems() {
+ $res = $this->doApiRequest( [
+ 'action' => 'parse',
+ 'title' => __CLASS__,
+ 'text' => '',
+ 'prop' => 'headitems',
+ ] );
+
+ $this->assertSame( [], $res[0]['parse']['headitems'] );
+ $this->assertSame(
+ '"prop=headitems" is deprecated since MediaWiki 1.28. ' .
+ 'Use "prop=headhtml" when creating new HTML documents, ' .
+ 'or "prop=modules|jsconfigvars" when updating a document client-side.',
+ $res[0]['warnings']['parse']['warnings']
+ );
+ }
+
+ public function testHeadItemsWithSkin() {
+ $this->setupSkin();
+
+ $res = $this->doApiRequest( [
+ 'action' => 'parse',
+ 'title' => __CLASS__,
+ 'text' => '',
+ 'prop' => 'headitems',
+ 'useskin' => 'testing',
+ ] );
+
+ $this->assertSame( [], $res[0]['parse']['headitems'] );
+ $this->assertSame(
+ '"prop=headitems" is deprecated since MediaWiki 1.28. ' .
+ 'Use "prop=headhtml" when creating new HTML documents, ' .
+ 'or "prop=modules|jsconfigvars" when updating a document client-side.',
+ $res[0]['warnings']['parse']['warnings']
+ );
+ }
+
+ public function testModules() {
+ $this->setTemporaryHook( 'ParserAfterParse',
+ function ( $parser ) {
+ $output = $parser->getOutput();
+ $output->addModules( [ 'foo', 'bar' ] );
+ $output->addModuleScripts( [ 'baz', 'quuz' ] );
+ $output->addModuleStyles( [ 'aaa', 'zzz' ] );
+ $output->addJsConfigVars( [ 'x' => 'y', 'z' => -3 ] );
+ }
+ );
+ $res = $this->doApiRequest( [
+ 'action' => 'parse',
+ 'title' => __CLASS__,
+ 'text' => 'Content',
+ 'prop' => 'modules|jsconfigvars|encodedjsconfigvars',
+ ] );
+
+ $this->assertSame( [ 'foo', 'bar' ], $res[0]['parse']['modules'] );
+ $this->assertSame( [ 'baz', 'quuz' ], $res[0]['parse']['modulescripts'] );
+ $this->assertSame( [ 'aaa', 'zzz' ], $res[0]['parse']['modulestyles'] );
+ $this->assertSame( [ 'x' => 'y', 'z' => -3 ], $res[0]['parse']['jsconfigvars'] );
+ $this->assertSame( '{"x":"y","z":-3}', $res[0]['parse']['encodedjsconfigvars'] );
+ $this->assertArrayNotHasKey( 'warnings', $res[0] );
+ }
+
+ public function testModulesWithSkin() {
+ $this->setupSkin();
$res = $this->doApiRequest( [
'action' => 'parse',
$res[0]['parse']['modulestyles'],
'resp.parse.modulestyles'
);
+ $this->assertSame(
+ [ 'parse' =>
+ [ 'warnings' =>
+ 'Property "modules" was set but not "jsconfigvars" or ' .
+ '"encodedjsconfigvars". Configuration variables are necessary for ' .
+ 'proper module usage.'
+ ]
+ ],
+ $res[0]['warnings']
+ );
+ }
+
+ public function testIndicators() {
+ $res = $this->doApiRequest( [
+ 'action' => 'parse',
+ 'title' => __CLASS__,
+ 'text' =>
+ '<indicator name="b">BBB!</indicator>Some text<indicator name="a">aaa</indicator>',
+ 'prop' => 'indicators',
+ ] );
+
+ $this->assertSame(
+ // It seems we return in markup order and not display order
+ [ 'b' => 'BBB!', 'a' => 'aaa' ],
+ $res[0]['parse']['indicators']
+ );
+ $this->assertArrayNotHasKey( 'warnings', $res[0] );
+ }
+
+ public function testIndicatorsWithSkin() {
+ $this->setupSkin();
+
+ $res = $this->doApiRequest( [
+ 'action' => 'parse',
+ 'title' => __CLASS__,
+ 'text' =>
+ '<indicator name="b">BBB!</indicator>Some text<indicator name="a">aaa</indicator>',
+ 'prop' => 'indicators',
+ 'useskin' => 'testing',
+ ] );
+
+ $this->assertSame(
+ // Now we return in display order rather than markup order
+ [ 'a' => 'aaa', 'b' => 'BBB!' ],
+ $res[0]['parse']['indicators']
+ );
+ $this->assertArrayNotHasKey( 'warnings', $res[0] );
+ }
+
+ public function testIwlinks() {
+ $this->setupInterwiki();
+
+ $res = $this->doApiRequest( [
+ 'action' => 'parse',
+ 'title' => 'Omelette',
+ 'text' => '[[:madeuplanguage:Omelette]][[madeuplanguage:Spaghetti]]',
+ 'prop' => 'iwlinks',
+ ] );
+
+ $iwlinks = $res[0]['parse']['iwlinks'];
+
+ $this->assertCount( 1, $iwlinks );
+ $this->assertSame( 'madeuplanguage', $iwlinks[0]['prefix'] );
+ $this->assertSame( 'https://example.com/wiki/Omelette', $iwlinks[0]['url'] );
+ $this->assertSame( 'madeuplanguage:Omelette', $iwlinks[0]['title'] );
+ $this->assertArrayNotHasKey( 'warnings', $res[0] );
+ }
+
+ public function testLimitReports() {
+ $res = $this->doApiRequest( [
+ 'action' => 'parse',
+ 'pageid' => self::$pageId,
+ 'prop' => 'limitreportdata|limitreporthtml',
+ ] );
+
+ // We don't bother testing the actual values here
+ $this->assertInternalType( 'array', $res[0]['parse']['limitreportdata'] );
+ $this->assertInternalType( 'string', $res[0]['parse']['limitreporthtml'] );
+ $this->assertArrayNotHasKey( 'warnings', $res[0] );
+ }
+
+ public function testParseTreeNonWikitext() {
+ $this->setExpectedException( ApiUsageException::class,
+ '"prop=parsetree" is only supported for wikitext content.' );
+
+ $this->doApiRequest( [
+ 'action' => 'parse',
+ 'text' => '',
+ 'contentmodel' => 'json',
+ 'prop' => 'parsetree',
+ ] );
+ }
+
+ public function testParseTree() {
+ $res = $this->doApiRequest( [
+ 'action' => 'parse',
+ 'text' => "Some ''text'' is {{nice|to have|i=think}}",
+ 'contentmodel' => 'wikitext',
+ 'prop' => 'parsetree',
+ ] );
+
+ // Preprocessor_DOM and Preprocessor_Hash give different results here,
+ // so we'll accept either
+ $this->assertRegExp(
+ '#^<root>Some \'\'text\'\' is <template><title>nice</title>' .
+ '<part><name index="1"/><value>to have</value></part>' .
+ '<part><name>i</name>(?:<equals>)?=(?:</equals>)?<value>think</value></part>' .
+ '</template></root>$#',
+ $res[0]['parse']['parsetree']
+ );
+ $this->assertArrayNotHasKey( 'warnings', $res[0] );
+ }
+
+ public function testDisableTidy() {
+ $this->setMwGlobals( 'wgTidyConfig', [ 'driver' => 'RemexHtml' ] );
+
+ // Check that disabletidy doesn't have an effect just because tidying
+ // doesn't work for some other reason
+ $res1 = $this->doApiRequest( [
+ 'action' => 'parse',
+ 'text' => "<b>Mixed <i>up</b></i>",
+ 'contentmodel' => 'wikitext',
+ ] );
+ $this->assertParsedTo( "<p><b>Mixed <i>up</i></b>\n</p>", $res1 );
+
+ $res2 = $this->doApiRequest( [
+ 'action' => 'parse',
+ 'text' => "<b>Mixed <i>up</b></i>",
+ 'contentmodel' => 'wikitext',
+ 'disabletidy' => '',
+ ] );
+
+ $this->assertParsedTo( "<p><b>Mixed <i>up</b></i>\n</p>", $res2 );
+ }
+
+ public function testFormatCategories() {
+ $name = ucfirst( __FUNCTION__ );
+
+ $this->editPage( "Category:$name", 'Content' );
+ $this->editPage( 'Category:Hidden', '__HIDDENCAT__' );
+
+ $res = $this->doApiRequest( [
+ 'action' => 'parse',
+ 'title' => __CLASS__,
+ 'text' => "[[Category:$name]][[Category:Foo|Sort me]][[Category:Hidden]]",
+ 'prop' => 'categories',
+ ] );
+
+ $this->assertSame(
+ [ [ 'sortkey' => '', 'category' => $name ],
+ [ 'sortkey' => 'Sort me', 'category' => 'Foo', 'missing' => true ],
+ [ 'sortkey' => '', 'category' => 'Hidden', 'hidden' => true ] ],
+ $res[0]['parse']['categories']
+ );
+ $this->assertArrayNotHasKey( 'warnings', $res[0] );
}
}
return $page->doEditContent( ContentHandler::makeContent( $text, $title ), $summary );
}
+ /**
+ * Revision-deletes a revision.
+ *
+ * @param Revision|int $rev Revision to delete
+ * @param array $value Keys are Revision::DELETED_* flags. Values are 1 to set the bit, 0 to
+ * clear, -1 to leave alone. (All other values also clear the bit.)
+ * @param string $comment Deletion comment
+ */
+ protected function revisionDelete(
+ $rev, array $value = [ Revision::DELETED_TEXT => 1 ], $comment = ''
+ ) {
+ if ( is_int( $rev ) ) {
+ $rev = Revision::newFromId( $rev );
+ }
+ RevisionDeleter::createList(
+ 'revision', RequestContext::getMain(), $rev->getTitle(), [ $rev->getId() ]
+ )->setVisibility( [
+ 'value' => $value,
+ 'comment' => $comment,
+ ] );
+ }
+
/**
* Does the API request and returns the result.
*
$db->listViews( '' ) );
}
+ /**
+ * @covers Wikimedia\Rdbms\MySQLMasterPos
+ */
public function testBinLogName() {
$pos = new MySQLMasterPos( "db1052.2424/4643", 1 );
- $this->assertEquals( "db1052", $pos->binlog );
+ $this->assertEquals( "db1052", $pos->getLogName() );
$this->assertEquals( "db1052.2424", $pos->getLogFile() );
- $this->assertEquals( [ 2424, 4643 ], $pos->pos );
+ $this->assertEquals( [ 2424, 4643 ], $pos->getLogPosition() );
}
/**
],
// MySQL GTID style
[
- new MySQLMasterPos( '3E11FA47-71CA-11E1-9E33-C80AA9429562:23', $now ),
- new MySQLMasterPos( '3E11FA47-71CA-11E1-9E33-C80AA9429562:24', $now ),
+ new MySQLMasterPos( '3E11FA47-71CA-11E1-9E33-C80AA9429562:1-23', $now ),
+ new MySQLMasterPos( '3E11FA47-71CA-11E1-9E33-C80AA9429562:5-24', $now ),
true,
false
],
[
- new MySQLMasterPos( '3E11FA47-71CA-11E1-9E33-C80AA9429562:99', $now ),
- new MySQLMasterPos( '3E11FA47-71CA-11E1-9E33-C80AA9429562:100', $now ),
+ new MySQLMasterPos( '3E11FA47-71CA-11E1-9E33-C80AA9429562:5-99', $now ),
+ new MySQLMasterPos( '3E11FA47-71CA-11E1-9E33-C80AA9429562:1-100', $now ),
true,
false
],
[
- new MySQLMasterPos( '3E11FA47-71CA-11E1-9E33-C80AA9429562:99', $now ),
- new MySQLMasterPos( '1E11FA47-71CA-11E1-9E33-C80AA9429562:100', $now ),
+ new MySQLMasterPos( '3E11FA47-71CA-11E1-9E33-C80AA9429562:1-99', $now ),
+ new MySQLMasterPos( '1E11FA47-71CA-11E1-9E33-C80AA9429562:1-100', $now ),
false,
false
],
],
[
new MySQLMasterPos(
- '2E11FA47-71CA-11E1-9E33-C80AA9429562:5,' .
- '3E11FA47-71CA-11E1-9E33-C80AA9429562:99,' .
- '7E11FA47-71CA-11E1-9E33-C80AA9429562:30',
+ '2E11FA47-71CA-11E1-9E33-C80AA9429562:1-5,' .
+ '3E11FA47-71CA-11E1-9E33-C80AA9429562:20-99,' .
+ '7E11FA47-71CA-11E1-9E33-C80AA9429562:1-30',
1
),
new MySQLMasterPos(
- '1E11FA47-71CA-11E1-9E33-C80AA9429562:100,' .
- '3E11FA47-71CA-11E1-9E33-C80AA9429562:66',
+ '1E11FA47-71CA-11E1-9E33-C80AA9429562:30-100,' .
+ '3E11FA47-71CA-11E1-9E33-C80AA9429562:30-66',
1
),
- [ '3E11FA47-71CA-11E1-9E33-C80AA9429562:99' ]
+ [ '3E11FA47-71CA-11E1-9E33-C80AA9429562:20-99' ]
]
];
}
];
}
+ /**
+ * @dataProvider provideGtidData
+ * @covers Wikimedia\Rdbms\MySQLMasterPos
+ * @covers Wikimedia\Rdbms\DatabaseMysqlBase::getReplicaPos
+ * @covers Wikimedia\Rdbms\DatabaseMysqlBase::getMasterPos
+ */
+ public function testServerGtidTable( $gtable, $rBLtable, $mBLtable, $rGTIDs, $mGTIDs ) {
+ $db = $this->getMockBuilder( DatabaseMysqli::class )
+ ->disableOriginalConstructor()
+ ->setMethods( [
+ 'useGTIDs',
+ 'getServerGTIDs',
+ 'getServerRoleStatus',
+ 'getServerId',
+ 'getServerUUID'
+ ] )
+ ->getMock();
+
+ $db->method( 'useGTIDs' )->willReturn( true );
+ $db->method( 'getServerGTIDs' )->willReturn( $gtable );
+ $db->method( 'getServerRoleStatus' )->willReturnCallback(
+ function ( $role ) use ( $rBLtable, $mBLtable ) {
+ if ( $role === 'SLAVE' ) {
+ return $rBLtable;
+ } elseif ( $role === 'MASTER' ) {
+ return $mBLtable;
+ }
+
+ return null;
+ }
+ );
+ $db->method( 'getServerId' )->willReturn( 1 );
+ $db->method( 'getServerUUID' )->willReturn( '2E11FA47-71CA-11E1-9E33-C80AA9429562' );
+
+ if ( is_array( $rGTIDs ) ) {
+ $this->assertEquals( $rGTIDs, $db->getReplicaPos()->getGTIDs() );
+ } else {
+ $this->assertEquals( false, $db->getReplicaPos() );
+ }
+ if ( is_array( $mGTIDs ) ) {
+ $this->assertEquals( $mGTIDs, $db->getMasterPos()->getGTIDs() );
+ } else {
+ $this->assertEquals( false, $db->getMasterPos() );
+ }
+ }
+
+ public static function provideGtidData() {
+ return [
+ // MariaDB
+ [
+ [
+ 'gtid_domain_id' => 100,
+ 'gtid_current_pos' => '100-13-77',
+ 'gtid_binlog_pos' => '100-13-77',
+ 'gtid_slave_pos' => null // master
+ ],
+ [
+ 'Relay_Master_Log_File' => 'host.1600',
+ 'Exec_Master_Log_Pos' => '77'
+ ],
+ [
+ 'File' => 'host.1600',
+ 'Position' => '77'
+ ],
+ [],
+ [ '100' => '100-13-77' ]
+ ],
+ [
+ [
+ 'gtid_domain_id' => 100,
+ 'gtid_current_pos' => '100-13-77',
+ 'gtid_binlog_pos' => '100-13-77',
+ 'gtid_slave_pos' => '100-13-77' // replica
+ ],
+ [
+ 'Relay_Master_Log_File' => 'host.1600',
+ 'Exec_Master_Log_Pos' => '77'
+ ],
+ [],
+ [ '100' => '100-13-77' ],
+ [ '100' => '100-13-77' ]
+ ],
+ [
+ [
+ 'gtid_current_pos' => '100-13-77',
+ 'gtid_binlog_pos' => '100-13-77',
+ 'gtid_slave_pos' => '100-13-77' // replica
+ ],
+ [
+ 'Relay_Master_Log_File' => 'host.1600',
+ 'Exec_Master_Log_Pos' => '77'
+ ],
+ [],
+ [ '100' => '100-13-77' ],
+ [ '100' => '100-13-77' ]
+ ],
+ // MySQL
+ [
+ [
+ 'gtid_executed' => '2E11FA47-71CA-11E1-9E33-C80AA9429562:1-77'
+ ],
+ [
+ 'Relay_Master_Log_File' => 'host.1600',
+ 'Exec_Master_Log_Pos' => '77'
+ ],
+ [], // only a replica
+ [ '2E11FA47-71CA-11E1-9E33-C80AA9429562'
+ => '2E11FA47-71CA-11E1-9E33-C80AA9429562:1-77' ],
+ // replica/master use same var
+ [ '2E11FA47-71CA-11E1-9E33-C80AA9429562'
+ => '2E11FA47-71CA-11E1-9E33-C80AA9429562:1-77' ],
+ ],
+ [
+ [
+ 'gtid_executed' => '2E11FA47-71CA-11E1-9E33-C80AA9429562:1-49,' .
+ '2E11FA47-71CA-11E1-9E33-C80AA9429562:51-77'
+ ],
+ [
+ 'Relay_Master_Log_File' => 'host.1600',
+ 'Exec_Master_Log_Pos' => '77'
+ ],
+ [], // only a replica
+ [ '2E11FA47-71CA-11E1-9E33-C80AA9429562'
+ => '2E11FA47-71CA-11E1-9E33-C80AA9429562:51-77' ],
+ // replica/master use same var
+ [ '2E11FA47-71CA-11E1-9E33-C80AA9429562'
+ => '2E11FA47-71CA-11E1-9E33-C80AA9429562:51-77' ],
+ ],
+ [
+ [
+ 'gtid_executed' => null, // not enabled?
+ 'gtid_binlog_pos' => null
+ ],
+ [
+ 'Relay_Master_Log_File' => 'host.1600',
+ 'Exec_Master_Log_Pos' => '77'
+ ],
+ [], // only a replica
+ [], // binlog fallback
+ false
+ ],
+ [
+ [
+ 'gtid_executed' => null, // not enabled?
+ 'gtid_binlog_pos' => null
+ ],
+ [], // no replication
+ [], // no replication
+ false,
+ false
+ ]
+ ];
+ }
+
/**
* @covers Wikimedia\Rdbms\MySQLMasterPos
*/
$user = $this->getTestSysop()->getUser();
$this->assertConditions(
[ # expected
- 'rc_patrolled = 0',
+ 'rc_patrolled' => 0,
],
[
'hidepatrolled' => 1,
$user = $this->getTestSysop()->getUser();
$this->assertConditions(
[ # expected
- 'rc_patrolled != 0',
+ 'rc_patrolled' => [ 1, 2 ],
],
[
'hideunpatrolled' => 1,
);
}
+ public function testRcReviewStatusFilter() {
+ $user = $this->getTestSysop()->getUser();
+ $this->assertConditions(
+ [ #expected
+ 'rc_patrolled' => 1,
+ ],
+ [
+ 'reviewStatus' => 'manual'
+ ],
+ "rc conditions: reviewStatus=manual",
+ $user
+ );
+ $this->assertConditions(
+ [ #expected
+ 'rc_patrolled' => [ 0, 2 ],
+ ],
+ [
+ 'reviewStatus' => 'unpatrolled;auto'
+ ],
+ "rc conditions: reviewStatus=unpatrolled;auto",
+ $user
+ );
+ }
+
public function testRcHideminorFilter() {
$this->assertConditions(
[ # expected