return $msg->parse();
}
+ /**
+ * Get the message object for the tag's long description.
+ *
+ * Checks if message key "mediawiki:tag-$tag-description" exists. If it does not,
+ * or if message is disabled, returns false. Otherwise, returns the message object
+ * for the long description.
+ *
+ * @param string $tag Tag
+ * @param IContextSource $context
+ * @return Message|bool Message object of the tag long description or false if
+ * there is no description.
+ */
+ public static function tagLongDescriptionMessage( $tag, IContextSource $context ) {
+ $msg = $context->msg( "tag-$tag-description" );
+ if ( !$msg->exists() ) {
+ return false;
+ }
+ if ( $msg->isDisabled() ) {
+ // The message exists but is disabled, hide the description.
+ return false;
+ }
+
+ // Message exists and isn't disabled, use it.
+ return $msg;
+ }
+
/**
* Add tags to a change given its rc_id, rev_id and/or log_id
*
'wgStructuredChangeFiltersEnableExperimentalViews',
$wgStructuredChangeFiltersEnableExperimentalViews
);
+ $out->addJsConfigVars(
+ 'wgRCFiltersChangeTags',
+ $this->buildChangeTagList()
+ );
}
}
+ /**
+ * Fetch the change tags list for the front end
+ *
+ * @return Array Tag data
+ */
+ protected function buildChangeTagList() {
+ function stripAllHtml( $input ) {
+ return trim( html_entity_decode( strip_tags( $input ) ) );
+ }
+
+ $explicitlyDefinedTags = array_fill_keys( ChangeTags::listExplicitlyDefinedTags(), 0 );
+ $softwareActivatedTags = array_fill_keys( ChangeTags::listSoftwareActivatedTags(), 0 );
+ $tagStats = ChangeTags::tagUsageStatistics();
+
+ $tagHitCounts = array_merge( $explicitlyDefinedTags, $softwareActivatedTags, $tagStats );
+
+ // Sort by hits
+ asort( $tagHitCounts );
+
+ // Build the list and data
+ $result = [];
+ foreach ( $tagHitCounts as $tagName => $hits ) {
+ if (
+ // Only get active tags
+ isset( $explicitlyDefinedTags[ $tagName ] ) ||
+ isset( $softwareActivatedTags[ $tagName ] )
+ ) {
+ // Parse description
+ $desc = ChangeTags::tagLongDescriptionMessage( $tagName, $this->getContext() );
+
+ $result[] = [
+ 'name' => $tagName,
+ 'label' => stripAllHtml( ChangeTags::tagDescription( $tagName, $this->getContext() ) ),
+ 'description' => $desc ? stripAllHtml( $desc->parse() ) : '',
+ 'cssClass' => Sanitizer::escapeClass( 'mw-tag-' . $tagName ),
+ 'hits' => $hits,
+ ];
+ }
+ }
+
+ return $result;
+ }
+
/**
* @inheritdoc
*/
"rcfilters-filter-excluded": "Excluded",
"rcfilters-tag-prefix-namespace": ":$1",
"rcfilters-tag-prefix-namespace-inverted": "<strong>:not</strong> $1",
+ "rcfilters-tag-prefix-tags": "#$1",
+ "rcfilters-view-tags": "Tags",
"rcnotefrom": "Below {{PLURAL:$5|is the change|are the changes}} since <strong>$3, $4</strong> (up to <strong>$1</strong> shown).",
"rclistfromreset": "Reset date selection",
"rclistfrom": "Show new changes starting from $2, $3",
"rcfilters-filter-excluded": "Label for a menu item in [[Special:RecentChanges]] noting that the item is being excluded from the results.",
"rcfilters-tag-prefix-namespace": "Prefix for the namespace tags in [[Special:RecentChanges]]. Namespace tags use a colon (:) as prefix. Please keep this format.\n\nParameters:\n* $1 - Filter name.",
"rcfilters-tag-prefix-namespace-inverted": "Prefix for the namespace inverted tags in [[Special:RecentChanges]]. Namespace tags use a colon (:) as prefix. Please keep this format.\n\nParameters:\n* $1 - Filter name.",
+ "rcfilters-tag-prefix-tags": "Prefix for the edit tags in [[Special:RecentChanges]]. Edit tags use a hash (#) as prefix. Please keep this format.\n\nParameters:\n* $1 - Tag display name.",
+ "rcfilters-view-tags": "Title for the tags view in [[Special:RecentChanges]]",
"rcnotefrom": "This message is displayed at [[Special:RecentChanges]] when viewing recentchanges from some specific time.\n\nThe corresponding message is {{msg-mw|Rclistfrom}}.\n\nParameters:\n* $1 - the maximum number of changes that are displayed\n* $2 - (Optional) a date and time\n* $3 - a date\n* $4 - a time\n* $5 - Number of changes are displayed, for use with PLURAL",
"rclistfromreset": "Used on [[Special:RecentChanges]] to reset a selection of a certain date range.",
"rclistfrom": "Used on [[Special:RecentChanges]]. Parameters:\n* $1 - (Currently not use) date and time. The date and the time adds to the rclistfrom description.\n* $2 - time. The time adds to the rclistfrom link description (with split of date and time).\n* $3 - date. The date adds to the rclistfrom link description (with split of date and time).\n\nThe corresponding message is {{msg-mw|Rcnotefrom}}.",
'rcfilters-filter-excluded',
'rcfilters-tag-prefix-namespace',
'rcfilters-tag-prefix-namespace-inverted',
+ 'rcfilters-tag-prefix-tags',
+ 'rcfilters-view-tags',
'blanknamespace',
'namespaces',
'invert',
*
* @param {Array} filters Filter group definition
* @param {Object} [namespaces] Namespace definition
+ * @param {Object[]} [tags] Tag array definition
*/
- mw.rcfilters.dm.FiltersViewModel.prototype.initializeFilters = function ( filters, namespaces ) {
+ mw.rcfilters.dm.FiltersViewModel.prototype.initializeFilters = function ( filters, namespaces, tags ) {
var filterItem, filterConflictResult, groupConflictResult,
model = this,
items = [],
items = items.concat( model.groups.namespace.getItems() );
}
+ tags = tags || [];
+ if (
+ mw.config.get( 'wgStructuredChangeFiltersEnableExperimentalViews' ) &&
+ tags.length > 0
+ ) {
+ // Define view
+ this.views.tags = { name: 'tags', label: mw.msg( 'rcfilters-view-tags' ), trigger: '#' };
+
+ // Add the group
+ model.groups.tags = new mw.rcfilters.dm.FilterGroup(
+ 'tags',
+ {
+ type: 'string_options',
+ view: 'tags',
+ title: 'rcfilters-view-tags', // Message key
+ labelPrefixKey: 'rcfilters-tag-prefix-tags',
+ separator: '|',
+ fullCoverage: false
+ }
+ );
+
+ // Add tag items to group
+ model.groups.tags.initializeFilters( tags );
+
+ // Add item references to the model, for lookup
+ items = items.concat( model.groups.tags.getItems() );
+ }
+
// Add item references to the model, for lookup
this.addItems( items );
+
// Expand conflicts
groupConflictResult = expandConflictDefinitions( groupConflictMap );
filterConflictResult = expandConflictDefinitions( filterConflictMap );
groupTitle,
result = {},
flatResult = [],
- view = query.indexOf( this.getViewTrigger( 'namespaces' ) ) === 0 ? 'namespaces' : 'default',
+ view = this.getViewByTrigger( query.substr( 0, 1 ) ),
items = this.getFiltersByView( view );
// Normalize so we can search strings regardless of case and view
query = query.toLowerCase();
- if ( view === 'namespaces' ) {
+ if ( view !== 'default' ) {
query = query.substr( 1 );
}
for ( i = 0; i < items.length; i++ ) {
if (
searchIsEmpty ||
- items[ i ].getLabel().toLowerCase().indexOf( query ) === 0
+ items[ i ].getLabel().toLowerCase().indexOf( query ) === 0 ||
+ (
+ // For tags, we want the parameter name to be included in the search
+ view === 'tags' &&
+ items[ i ].getParamName().toLowerCase().indexOf( query ) > -1
+ )
) {
result[ items[ i ].getGroupName() ] = result[ items[ i ].getGroupName() ] || [];
result[ items[ i ].getGroupName() ].push( items[ i ] );
searchIsEmpty ||
items[ i ].getLabel().toLowerCase().indexOf( query ) > -1 ||
items[ i ].getDescription().toLowerCase().indexOf( query ) > -1 ||
- groupTitle.toLowerCase().indexOf( query ) > -1
+ groupTitle.toLowerCase().indexOf( query ) > -1 ||
+ (
+ // For tags, we want the parameter name to be included in the search
+ view === 'tags' &&
+ items[ i ].getParamName().toLowerCase().indexOf( query ) > -1
+ )
) {
result[ items[ i ].getGroupName() ] = result[ items[ i ].getGroupName() ] || [];
result[ items[ i ].getGroupName() ].push( items[ i ] );
return this.views[ this.getCurrentView() ].label;
};
+ /**
+ * Get an array of all available view names
+ *
+ * @return {string} Available view names
+ */
+ mw.rcfilters.dm.FiltersViewModel.prototype.getAvailableViews = function () {
+ return Object.keys( this.views );
+ };
+
+ /**
+ * Get the view that fits the given trigger
+ *
+ * @param {string} trigger Trigger
+ * @return {string} Name of view
+ */
+ mw.rcfilters.dm.FiltersViewModel.prototype.getViewByTrigger = function ( trigger ) {
+ var result = 'default';
+
+ $.each( this.views, function ( name, data ) {
+ if ( data.trigger === trigger ) {
+ result = name;
+ }
+ } );
+
+ return result;
+ };
+
/**
* Toggle the highlight feature on and off.
* Propagate the change to filter items.
*
* @param {Array} filterStructure Filter definition and structure for the model
* @param {Object} [namespaceStructure] Namespace definition
+ * @param {Object} [tagList] Tag definition
*/
- mw.rcfilters.Controller.prototype.initialize = function ( filterStructure, namespaceStructure ) {
+ mw.rcfilters.Controller.prototype.initialize = function ( filterStructure, namespaceStructure, tagList ) {
var parsedSavedQueries,
uri = new mw.Uri(),
$changesList = $( '.mw-changeslist' ).first().contents();
// Initialize the model
- this.filtersModel.initializeFilters( filterStructure, namespaceStructure );
+ this.filtersModel.initializeFilters( filterStructure, namespaceStructure, tagList );
+
this._buildBaseFilterState();
this.uriProcessor = new mw.rcfilters.UriProcessor(
new mw.rcfilters.ui.ChangesListWrapperWidget(
filtersModel, changesListModel, $( '.mw-changeslist, .mw-changeslist-empty' ) );
- controller.initialize( mw.config.get( 'wgStructuredChangeFilters' ), mw.config.get( 'wgFormattedNamespaces' ) );
+ controller.initialize(
+ mw.config.get( 'wgStructuredChangeFilters' ),
+ mw.config.get( 'wgFormattedNamespaces' ),
+ mw.config.get( 'wgRCFiltersChangeTags' )
+ );
// eslint-disable-next-line no-new
new mw.rcfilters.ui.FormWrapperWidget(
// Make sure this uses the interface direction, not the content direction
direction: ltr;
- &-namespaceToggle {
+ &-viewToggleButtons {
margin-top: 1em;
}
}
* @param {string} value Value of the input
*/
mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.onInputChange = function ( value ) {
- var view = 'default';
-
- if ( value.indexOf( this.model.getViewTrigger( 'namespaces' ) ) === 0 ) {
- view = 'namespaces';
- }
+ var view = this.model.getViewByTrigger( value.substr( 0, 1 ) );
this.controller.switchView( view );
};
mw.rcfilters.ui.FilterTagMultiselectWidget.prototype.updateElementsForView = function () {
var view = this.model.getCurrentView(),
inputValue = this.input.getValue(),
- newInputValue = inputValue;
+ inputView = this.model.getViewByTrigger( inputValue.substr( 0, 1 ) );
- switch ( view ) {
- case 'namespaces':
- if ( inputValue.indexOf( this.model.getViewTrigger( 'namespaces' ) ) !== 0 ) {
- // Add the prefix to the input
- newInputValue = this.model.getViewTrigger( 'namespaces' ) + inputValue;
- }
- break;
- default:
- case 'default':
- if ( inputValue.indexOf( this.model.getViewTrigger( 'namespaces' ) ) === 0 ) {
- // Remove the prefix
- newInputValue = inputValue.substr( 1 );
- }
- break;
+ if ( inputView !== 'default' ) {
+ // We have a prefix already, remove it
+ inputValue = inputValue.substr( 1 );
+ }
+
+ if ( inputView !== view ) {
+ // Add the correct prefix
+ inputValue = this.model.getViewTrigger( view ) + inputValue;
}
// Update input
- this.input.setValue( newInputValue );
+ this.input.setValue( inputValue );
};
/**
{ $overlay: this.$overlay }
);
- this.namespaceButton = new OO.ui.ButtonWidget( {
- label: mw.msg( 'namespaces' ),
- icon: 'article',
- classes: [ 'mw-rcfilters-ui-filterWrapperWidget-namespaceToggle' ]
+ this.viewToggle = new OO.ui.ButtonSelectWidget( {
+ classes: [ 'mw-rcfilters-ui-filterWrapperWidget-viewToggleButtons' ],
+ items: [
+ new OO.ui.ButtonOptionWidget( {
+ data: 'namespaces',
+ label: mw.msg( 'namespaces' ),
+ icon: 'article',
+ classes: [ 'mw-rcfilters-ui-filterWrapperWidget-viewToggleButtons-namespaces' ]
+ } ),
+ new OO.ui.ButtonOptionWidget( {
+ data: 'tags',
+ label: mw.msg( 'rcfilters-view-tags' ),
+ icon: 'tag',
+ classes: [ 'mw-rcfilters-ui-filterWrapperWidget-viewToggleButtons-tags' ]
+ } )
+ ]
} );
- this.namespaceButton.setActive( this.model.getCurrentView() === 'namespaces' );
// Events
this.model.connect( this, { update: 'onModelUpdate' } );
- this.namespaceButton.connect( this, { click: 'onNamespaceToggleClick' } );
+ this.viewToggle.connect( this, { select: 'onViewToggleSelect' } );
// Initialize
this.$element
this.$element.append(
this.filterTagWidget.$element,
- this.namespaceButton.$element
+ this.viewToggle.$element
);
- this.namespaceButton.toggle( !!mw.config.get( 'wgStructuredChangeFiltersEnableExperimentalViews' ) );
+ this.viewToggle.toggle( !!mw.config.get( 'wgStructuredChangeFiltersEnableExperimentalViews' ) );
};
/* Initialization */
* Respond to model update event
*/
mw.rcfilters.ui.FilterWrapperWidget.prototype.onModelUpdate = function () {
- // Synchronize the state of the toggle button with the current view
- this.namespaceButton.setActive( this.model.getCurrentView() === 'namespaces' );
+ // Synchronize the state of the toggle buttons with the current view
+ this.viewToggle.selectItemByData( this.model.getCurrentView() );
};
/**
* Respond to namespace toggle button click
+ *
+ * @param {OO.ui.ButtonWidget} buttonWidget The button that was clicked
*/
- mw.rcfilters.ui.FilterWrapperWidget.prototype.onNamespaceToggleClick = function () {
- this.controller.switchView( 'namespaces' );
- this.filterTagWidget.focus();
+ mw.rcfilters.ui.FilterWrapperWidget.prototype.onViewToggleSelect = function ( buttonWidget ) {
+ if ( buttonWidget ) {
+ this.controller.switchView( buttonWidget.getData() );
+ this.filterTagWidget.focus();
+ }
};
+
}( mediaWiki ) );
this.parentNode.removeChild( this );
} );
- // Hide namespaces
+ // Hide namespaces and tags
if ( mw.config.get( 'wgStructuredChangeFiltersEnableExperimentalViews' ) ) {
$namespaceSelect.closest( 'tr' ).detach();
+ this.$element.find( '.mw-tagfilter-label' ).closest( 'tr' ).detach();
}
// Collapse legend