or wikilinks.
* (T163966) Page moves are now counted as edits for the purposes of
autopromotion, i.e., they increment the user_editcount field in the database.
+* Two new hooks, LogEventsListLineEnding and NewPagesLineEnding were added for
+ manipulating Special:Log and Special:NewPages lines.
+* The OldChangesListRecentChangesLine, EnhancedChangesListModifyLineData,
+ PageHistoryLineEnding, ContributionsLineEnding and DeletedContributionsLineEnding
+ hooks have an additional parameter, for manipulating HTML data attributes of
+ RC/history lines. EnhancedChangesListModifyBlockLineData can do that via the
+ $data['attribs'] subarray.
== Compatibility ==
MediaWiki 1.30 requires PHP 5.5.9 or later. There is experimental support for
&$ret: the HTML line
$row: the DB row for this line
&$classes: the classes to add to the surrounding <li>
+&$attribs: associative array of other HTML attributes for the <li> element.
+ Currently only data attributes reserved to MediaWiki are allowed
+ (see Sanitizer::isReservedDataAttribute).
'ContributionsToolLinks': Change tool links above Special:Contributions
$id: User identifier
&$ret: the HTML line
$row: the DB row for this line
&$classes: the classes to add to the surrounding <li>
+&$attribs: associative array of other HTML attributes for the <li> element.
+ Currently only data attributes reserved to MediaWiki are allowed
+ (see Sanitizer::isReservedDataAttribute).
'DifferenceEngineAfterLoadNewText': called in DifferenceEngine::loadNewText()
after the new revision's content has been loaded into the class member variable
$block: An array of RecentChange objects in that block
$rc: The RecentChange object for this line
&$classes: An array of classes to change
+&$attribs: associative array of other HTML attributes for the <tr> element.
+ Currently only data attributes reserved to MediaWiki are allowed
+ (see Sanitizer::isReservedDataAttribute).
'EnhancedChangesListModifyBlockLineData': to alter data used to build
a non-grouped recent change line in EnhancedChangesList.
&$attribs: the attributes to be applied
&$ret: the value to return if your hook returns false
+'LogEventsListLineEnding': Called before a Special:Log line is finished
+$page: the LogEventsList object
+&$ret: the HTML line
+$entry: the DatabaseLogEntry object for this row
+&$classes: the classes to add to the surrounding <li>
+&$attribs: associative array of other HTML attributes for the <li> element.
+ Currently only data attributes reserved to MediaWiki are allowed
+ (see Sanitizer::isReservedDataAttribute).
+
+
'HtmlPageLinkRendererBegin':
Used when generating internal and interwiki links in
LinkRenderer, before processing starts. Return false to skip default
$old: the ?old= param value from the url
$new: the ?new= param value from the url
+'NewPagesLineEnding': Called before a NewPages line is finished.
+$page: the SpecialNewPages object
+&$ret: the HTML line
+$row: the database row for this page (the recentchanges record and a few extras - see
+ NewPagesPager::getQueryInfo)
+&$classes: the classes to add to the surrounding <li>
+&$attribs: associative array of other HTML attributes for the <li> element.
+ Currently only data attributes reserved to MediaWiki are allowed
+ (see Sanitizer::isReservedDataAttribute).
+
'NewRevisionFromEditComplete': Called when a revision was inserted due to an
edit.
$wikiPage: the WikiPage edited
&$changeslist: The OldChangesList instance.
&$s: HTML of the form "<li>...</li>" containing one RC entry.
$rc: The RecentChange object.
-&$classes: array of css classes for the <li> element
+&$classes: array of css classes for the <li> element.
+&$attribs: associative array of other HTML attributes for the <li> element.
+ Currently only data attributes reserved to MediaWiki are allowed
+ (see Sanitizer::isReservedDataAttribute).
'OpenSearchUrls': Called when constructing the OpenSearch description XML. Hooks
can alter or append to the array of URLs for search & suggestion formats.
&$row: the revision row for this line
&$s: the string representing this parsed line
&$classes: array containing the <li> element classes
+&$attribs: associative array of other HTML attributes for the <li> element.
+ Currently only data attributes reserved to MediaWiki are allowed
+ (see Sanitizer::isReservedDataAttribute).
'PageHistoryPager::doBatchLookups': Called after the pager query was run, before
any output is generated, to allow batch lookups for prefetching information
}
}
+/**
+ * Like array_filter with ARRAY_FILTER_USE_BOTH, but works pre-5.6.
+ *
+ * @param array $arr
+ * @param callable $callback Will be called with the array value and key (in that order) and
+ * should return a bool which will determine whether the array element is kept.
+ * @return array
+ */
+function wfArrayFilter( array $arr, callable $callback ) {
+ if ( defined( 'ARRAY_FILTER_USE_BOTH' ) ) {
+ return array_filter( $arr, $callback, ARRAY_FILTER_USE_BOTH );
+ }
+ $filteredKeys = array_filter( array_keys( $arr ), function ( $key ) use ( $arr, $callback ) {
+ return call_user_func( $callback, $arr[$key], $key );
+ } );
+ return array_intersect_key( $arr, array_fill_keys( $filteredKeys, true ) );
+}
+
+/**
+ * Like array_filter with ARRAY_FILTER_USE_KEY, but works pre-5.6.
+ *
+ * @param array $arr
+ * @param callable $callback Will be called with the array key and should return a bool which
+ * will determine whether the array element is kept.
+ * @return array
+ */
+function wfArrayFilterByKey( array $arr, callable $callback ) {
+ return wfArrayFilter( $arr, function ( $val, $key ) use ( $callback ) {
+ return call_user_func( $callback, $key );
+ } );
+}
+
/**
* Appends to second array if $value differs from that in $default
*
# Allow any attribute beginning with "data-"
# However:
- # * data-ooui is reserved for ooui
- # * data-mw and data-parsoid are reserved for parsoid
- # * data-mw-<name here> is reserved for extensions (or core) if
- # they need to communicate some data to the client and want to be
- # sure that it isn't coming from an untrusted user.
+ # * Disallow data attributes used by MediaWiki code
# * Ensure that the attribute is not namespaced by banning
# colons.
- if ( !preg_match( '/^data-(?!ooui|mw|parsoid)[^:]*$/i', $attribute )
+ if ( !preg_match( '/^data-[^:]*$/i', $attribute )
&& !isset( $whitelist[$attribute] )
+ || self::isReservedDataAttribute( $attribute )
) {
continue;
}
return $out;
}
+ /**
+ * Given an attribute name, checks whether it is a reserved data attribute
+ * (such as data-mw-foo) which is unavailable to user-generated HTML so MediaWiki
+ * core and extension code can safely use it to communicate with frontend code.
+ * @param string $attr Attribute name.
+ * @return bool
+ */
+ public static function isReservedDataAttribute( $attr ) {
+ // data-ooui is reserved for ooui.
+ // data-mw and data-parsoid are reserved for parsoid.
+ // data-mw-<name here> is reserved for extensions (or core) if
+ // they need to communicate some data to the client and want to be
+ // sure that it isn't coming from an untrusted user.
+ // We ignore the possibility of namespaces since user-generated HTML
+ // can't use them anymore.
+ return (bool)preg_match( '/^data-(ooui|mw|parsoid)/i', $attr );
+ }
+
/**
* Merge two sets of HTML attributes. Conflicting items in the second set
* will override those in the first, except for 'class' attributes which
$s .= ' <span class="mw-changeslist-separator">. .</span> ' . $s2;
}
- Hooks::run( 'PageHistoryLineEnding', [ $this, &$row, &$s, &$classes ] );
+ $attribs = [ 'data-mw-revid' => $rev->getId() ];
+
+ Hooks::run( 'PageHistoryLineEnding', [ $this, &$row, &$s, &$classes, &$attribs ] );
+ $attribs = wfArrayFilterByKey( $attribs, [ Sanitizer::class, 'isReservedDataAttribute' ] );
- $attribs = [];
if ( $classes ) {
$attribs['class'] = implode( ' ', $classes );
}
&& intval( $rcObj->getAttribute( 'rc_this_oldid' ) ) === 0;
}
+ /**
+ * Get recommended data attributes for a change line.
+ * @param RecentChange $rc
+ * @return string[] attribute name => value
+ */
+ protected function getDataAttributes( RecentChange $rc ) {
+ $type = $rc->getAttribute( 'rc_source' );
+ switch ( $type ) {
+ case RecentChange::SRC_EDIT:
+ case RecentChange::SRC_NEW:
+ return [
+ 'data-mw-revid' => $rc->mAttribs['rc_this_oldid'],
+ ];
+ case RecentChange::SRC_LOG:
+ return [
+ 'data-mw-logid' => $rc->mAttribs['rc_logid'],
+ 'data-mw-logaction' => $rc->mAttribs['rc_log_type'] . '/' . $rc->mAttribs['rc_log_action'],
+ ];
+ default:
+ return [];
+ }
+ }
}
# Tags
$data['tags'] = $this->getTags( $rcObj, $classes );
+ $attribs = $this->getDataAttributes( $rcObj );
+
// give the hook a chance to modify the data
$success = Hooks::run( 'EnhancedChangesListModifyLineData',
- [ $this, &$data, $block, $rcObj, &$classes ] );
+ [ $this, &$data, $block, $rcObj, &$classes, &$attribs ] );
if ( !$success ) {
// skip entry if hook aborted it
return [];
}
+ $attribs = wfArrayFilterByKey( $attribs, [ Sanitizer::class, 'isReservedDataAttribute' ] );
$lineParams['recentChangesFlagsRaw'] = [];
if ( isset( $data['recentChangesFlags'] ) ) {
}
$lineParams['classes'] = array_values( $classes );
+ $lineParams['attribs'] = Html::expandAttributes( $attribs );
// everything else: makes it easier for extensions to add or remove data
$lineParams['data'] = array_values( $data );
# Show how many people are watching this if enabled
$data['watchingUsers'] = $this->numberofWatchingusers( $rcObj->numberofWatchingusers );
+ $data['attribs'] = array_merge( $this->getDataAttributes( $rcObj ), [ 'class' => $classes ] );
+
// give the hook a chance to modify the data
$success = Hooks::run( 'EnhancedChangesListModifyBlockLineData',
[ $this, &$data, $rcObj ] );
// skip entry if hook aborted it
return '';
}
+ $attribs = $data['attribs'];
+ unset( $data['attribs'] );
+ $attribs = wfArrayFilterByKey( $attribs, [ Sanitizer::class, 'isReservedDataAttribute' ] );
- $line = Html::openElement( 'table', [ 'class' => $classes ] ) .
- Html::openElement( 'tr' );
+ $line = Html::openElement( 'table', $attribs ) . Html::openElement( 'tr' );
$line .= '<td class="mw-enhanced-rc"><span class="mw-enhancedchanges-arrow-space"></span>';
if ( isset( $data['recentChangesFlags'] ) ) {
$rc->mAttribs['rc_namespace'] . '-' . $rc->mAttribs['rc_title'] );
}
+ $attribs = $this->getDataAttributes( $rc );
+
// Avoid PHP 7.1 warning from passing $this by reference
$list = $this;
- if ( !Hooks::run( 'OldChangesListRecentChangesLine', [ &$list, &$html, $rc, &$classes ] ) ) {
+ if ( !Hooks::run( 'OldChangesListRecentChangesLine',
+ [ &$list, &$html, $rc, &$classes, &$attribs ] )
+ ) {
return false;
}
+ $attribs = wfArrayFilterByKey( $attribs, [ Sanitizer::class, 'isReservedDataAttribute' ] );
$dateheader = ''; // $html now contains only <li>...</li>, for hooks' convenience.
$this->insertDateHeader( $dateheader, $rc->mAttribs['rc_timestamp'] );
- return "$dateheader<li class=\"" . implode( ' ', $classes ) . "\">" . $html . "</li>\n";
+ $attribs['class'] = implode( ' ', $classes );
+
+ return $dateheader . Html::rawElement( 'li', $attribs, $html ) . "\n";
}
/**
[ 'mw-logline-' . $entry->getType() ],
$newClasses
);
+ $attribs = [
+ 'data-mw-logid' => $entry->getId(),
+ 'data-mw-logaction' => $entry->getFullType(),
+ ];
+ $ret = "$del $time $action $comment $revert $tagDisplay";
+
+ // Let extensions add data
+ Hooks::run( 'LogEventsListLineEnding', [ $this, &$ret, $entry, &$classes, &$attribs ] );
+ $attribs = wfArrayFilterByKey( $attribs, [ Sanitizer::class, 'isReservedDataAttribute' ] );
+ $attribs['class'] = implode( ' ', $classes );
- return Html::rawElement( 'li', [ 'class' => $classes ],
- "$del $time $action $comment $revert $tagDisplay" ) . "\n";
+ return Html::rawElement( 'li', $attribs, $ret ) . "\n";
}
/**
$rev->setTitle( $title );
$classes = [];
+ $attribs = [ 'data-mw-revid' => $result->rev_id ];
$lang = $this->getLanguage();
$dm = $lang->getDirMark();
$tagDisplay = '';
}
- $css = count( $classes ) ? ' class="' . implode( ' ', $classes ) . '"' : '';
-
# Display the old title if the namespace/title has been changed
$oldTitleText = '';
$oldTitle = Title::makeTitle( $result->rc_namespace, $result->rc_title );
+ $ret = "{$time} {$dm}{$plink} {$hist} {$dm}{$length} {$dm}{$ulink} {$comment} "
+ . "{$tagDisplay} {$oldTitleText}";
+
+ // Let extensions add data
+ Hooks::run( 'NewPagesLineEnding', [ $this, &$ret, $result, &$classes, &$attribs ] );
+ $attribs = wfArrayFilterByKey( $attribs, [ Sanitizer::class, 'isReservedDataAttribute' ] );
+
+ if ( count( $classes ) ) {
+ $attribs['class'] = implode( ' ', $classes );
+ }
if ( !$title->equals( $oldTitle ) ) {
$oldTitleText = $oldTitle->getPrefixedText();
);
}
- return "<li{$css}>{$time} {$dm}{$plink} {$hist} {$dm}{$length} "
- . "{$dm}{$ulink} {$comment} {$tagDisplay} {$oldTitleText}</li>\n";
+ return Html::rawElement( 'li', $attribs, $ret ) . "\n";
}
/**
* @return string
*/
function formatRow( $row ) {
-
$ret = '';
$classes = [];
+ $attribs = [];
$linkRenderer = MediaWikiServices::getInstance()->getLinkRenderer();
MediaWiki\restoreWarnings();
if ( $validRevision ) {
- $classes = [];
+ $attribs['data-mw-revid'] = $rev->getId();
$page = Title::newFromRow( $row );
$link = $linkRenderer->makeLink(
}
// Let extensions add data
- Hooks::run( 'ContributionsLineEnding', [ $this, &$ret, $row, &$classes ] );
+ Hooks::run( 'ContributionsLineEnding', [ $this, &$ret, $row, &$classes, &$attribs ] );
+ $attribs = wfArrayFilterByKey( $attribs, [ Sanitizer::class, 'isReservedDataAttribute' ] );
// TODO: Handle exceptions in the catch block above. Do any extensions rely on
// receiving empty rows?
- if ( $classes === [] && $ret === '' ) {
+ if ( $classes === [] && $attribs === [] && $ret === '' ) {
wfDebug( "Dropping Special:Contribution row that could not be formatted\n" );
return "<!-- Could not format Special:Contribution row. -->\n";
}
+ $attribs['class'] = $classes;
// FIXME: The signature of the ContributionsLineEnding hook makes it
// very awkward to move this LI wrapper into the template.
- return Html::rawElement( 'li', [ 'class' => $classes ], $ret ) . "\n";
+ return Html::rawElement( 'li', $attribs, $ret ) . "\n";
}
/**
function formatRow( $row ) {
$ret = '';
$classes = [];
+ $attribs = [];
/*
* There may be more than just revision rows. To make sure that we'll only be processing
MediaWiki\restoreWarnings();
if ( $validRevision ) {
+ $attribs['data-mw-revid'] = $rev->getId();
$ret = $this->formatRevisionRow( $row );
}
// Let extensions add data
- Hooks::run( 'DeletedContributionsLineEnding', [ $this, &$ret, $row, &$classes ] );
+ Hooks::run( 'DeletedContributionsLineEnding', [ $this, &$ret, $row, &$classes, &$attribs ] );
+ $attribs = wfArrayFilterByKey( $attribs, [ Sanitizer::class, 'isReservedDataAttribute' ] );
- if ( $classes === [] && $ret === '' ) {
+ if ( $classes === [] && $attribs === [] && $ret === '' ) {
wfDebug( "Dropping Special:DeletedContribution row that could not be formatted\n" );
$ret = "<!-- Could not format Special:DeletedContribution row. -->\n";
} else {
- $ret = Html::rawElement( 'li', [ 'class' => $classes ], $ret ) . "\n";
+ $attribs['class'] = $classes;
+ $ret = Html::rawElement( 'li', $attribs, $ret ) . "\n";
}
return $ret;
</td>
</tr>
{{# lines }}
- <tr class="{{# classes }}{{ . }} {{/ classes }}">
+ <tr class="{{# classes }}{{ . }} {{/ classes }}"{{{ attribs }}}>
<td></td>
<td class="mw-enhanced-rc">{{{ recentChangesFlags }}} </td>
<td class="mw-enhanced-rc-nested">
--- /dev/null
+<?php
+
+class WfArrayFilterTest extends \PHPUnit_Framework_TestCase {
+ public function testWfArrayFilter() {
+ $arr = [ 'a' => 1, 'b' => 2, 'c' => 3 ];
+ $filtered = wfArrayFilter( $arr, function( $val, $key ) {
+ return $key !== 'b';
+ } );
+ $this->assertSame( [ 'a' => 1, 'c' => 3 ], $filtered );
+
+ $arr = [ 'a' => 1, 'b' => 2, 'c' => 3 ];
+ $filtered = wfArrayFilter( $arr, function( $val, $key ) {
+ return $val !== 2;
+ } );
+ $this->assertSame( [ 'a' => 1, 'c' => 3 ], $filtered );
+
+ $arr = [ 'a', 'b', 'c' ];
+ $filtered = wfArrayFilter( $arr, function( $val, $key ) {
+ return $key !== 0;
+ } );
+ $this->assertSame( [ 1 => 'b', 2 => 'c' ], $filtered );
+ }
+
+ public function testWfArrayFilterByKey() {
+ $arr = [ 'a' => 1, 'b' => 2, 'c' => 3 ];
+ $filtered = wfArrayFilterByKey( $arr, function( $key ) {
+ return $key !== 'b';
+ } );
+ $this->assertSame( [ 'a' => 1, 'c' => 3 ], $filtered );
+
+ $arr = [ 'a', 'b', 'c' ];
+ $filtered = wfArrayFilterByKey( $arr, function( $key ) {
+ return $key !== 0;
+ } );
+ $this->assertSame( [ 1 => 'b', 2 => 'c' ], $filtered );
+ }
+}
[ '+1 +2', '+1', '+2' ],
];
}
+
+ /**
+ * @dataProvider provideIsReservedDataAttribute
+ */
+ public function testIsReservedDataAttribute( $attr, $expected ) {
+ $this->assertSame( $expected, Sanitizer::isReservedDataAttribute( $attr ) );
+ }
+
+ public static function provideIsReservedDataAttribute() {
+ return [
+ [ 'foo', false ],
+ [ 'data', false ],
+ [ 'data-foo', false ],
+ [ 'data-mw', true ],
+ [ 'data-ooui', true ],
+ [ 'data-parsoid', true ],
+ [ 'data-mw-foo', true ],
+ [ 'data-ooui-foo', true ],
+ [ 'data-mwfoo', true ], // could be false but this is how it's implemented currently
+ ];
+ }
}
$recentChange = $this->getEditChange( '20131103092153' );
$enhancedChangesList->recentChangesLine( $recentChange, false );
+ $html = $enhancedChangesList->endRecentChangesList();
+ $this->assertContains( 'data-mw-revid="5"', $html );
+
$recentChange2 = $this->getEditChange( '20131103092253' );
$enhancedChangesList->recentChangesLine( $recentChange2, false );
preg_match_all( '/td class="mw-enhanced-rc-nested"/', $html, $matches );
$this->assertCount( 2, $matches[0] );
+
+ $recentChange3 = $this->getLogChange();
+ $enhancedChangesList->recentChangesLine( $recentChange3, false );
+
+ $html = $enhancedChangesList->endRecentChangesList();
+ $this->assertContains( 'data-mw-logaction="foo/bar"', $html );
+ $this->assertContains( 'data-mw-logid="25"', $html );
}
/**
return $recentChange;
}
+ private function getLogChange() {
+ $user = $this->getMutableTestUser()->getUser();
+ $recentChange = $this->testRecentChangesHelper->makeLogRecentChange( 'foo', 'bar', $user,
+ 'Title', '20131103092153', 0, 0
+ );
+
+ return $recentChange;
+ }
+
/**
* @return RecentChange
*/
);
}
- public function testRecentChangesLine_Tags() {
+ public function testRecentChangesLine_Attribs() {
$recentChange = $this->getEditChange();
$recentChange->mAttribs['ts_tags'] = 'vandalism,newbie';
$oldChangesList = $this->getOldChangesList();
$line = $oldChangesList->recentChangesLine( $recentChange, false, 1 );
- $this->assertRegExp( '/<li class="[\w\s-]*mw-tag-vandalism[\w\s-]*">/', $line );
- $this->assertRegExp( '/<li class="[\w\s-]*mw-tag-newbie[\w\s-]*">/', $line );
+ $this->assertRegExp( '/<li data-mw-revid="\d+" class="[\w\s-]*mw-tag-vandalism[\w\s-]*">/',
+ $line );
+ $this->assertRegExp( '/<li data-mw-revid="\d+" class="[\w\s-]*mw-tag-newbie[\w\s-]*">/',
+ $line );
}
public function testRecentChangesLine_numberOfWatchingUsers() {