2015-04-10 20:28:38 +00:00
|
|
|
/*!
|
|
|
|
* VisualEditor UserInterface MWTemplateTitleInputWidget class.
|
|
|
|
*
|
2023-12-01 16:06:11 +00:00
|
|
|
* @copyright See AUTHORS.txt
|
2015-04-10 20:28:38 +00:00
|
|
|
* @license The MIT License (MIT); see LICENSE.txt
|
|
|
|
*/
|
|
|
|
|
|
|
|
/**
|
2022-07-12 09:55:54 +00:00
|
|
|
* Input field for entering a template title, for example when adding a template
|
|
|
|
* in the template dialog. Autocomplete fetches TemplateData and performs
|
|
|
|
* searching in the background, to display information about matching templates
|
|
|
|
* on the wiki.
|
2015-04-10 20:28:38 +00:00
|
|
|
*
|
|
|
|
* @class
|
2015-06-15 10:25:44 +00:00
|
|
|
* @extends mw.widgets.TitleInputWidget
|
2015-04-10 20:28:38 +00:00
|
|
|
*
|
|
|
|
* @constructor
|
|
|
|
* @param {Object} [config] Configuration options
|
|
|
|
* @cfg {number} [namespace] Namespace to prepend to queries. Defaults to template namespace.
|
2021-09-11 08:05:31 +00:00
|
|
|
* @cfg {boolean} [showDescriptions] Show template descriptions from the TemplateData API
|
|
|
|
* @cfg {mw.Api} [api]
|
2015-04-10 20:28:38 +00:00
|
|
|
*/
|
|
|
|
ve.ui.MWTemplateTitleInputWidget = function VeUiMWTemplateTitleInputWidget( config ) {
|
2016-10-28 00:22:30 +00:00
|
|
|
config = ve.extendObject( {}, {
|
2021-05-21 09:08:13 +00:00
|
|
|
namespace: mw.config.get( 'wgNamespaceIds' ).template,
|
2022-04-19 13:30:36 +00:00
|
|
|
showMissing: false,
|
2021-05-21 09:08:13 +00:00
|
|
|
// We don't need results to show up twice normalized and unnormalized
|
2021-11-26 10:31:58 +00:00
|
|
|
addQueryInput: false,
|
2021-11-26 15:30:19 +00:00
|
|
|
icon: 'search',
|
|
|
|
placeholder: ve.msg( 'visualeditor-dialog-transclusion-placeholder-input-placeholder' )
|
2015-06-15 10:25:44 +00:00
|
|
|
}, config );
|
|
|
|
|
2015-04-10 20:28:38 +00:00
|
|
|
// Parent constructor
|
2015-06-15 10:25:44 +00:00
|
|
|
ve.ui.MWTemplateTitleInputWidget.super.call( this, config );
|
2022-01-05 11:47:11 +00:00
|
|
|
// All code below expects this, but ContentTranslation doesn't necessarily set it to 2
|
|
|
|
this.api.defaults.parameters.formatversion = 2;
|
2015-06-15 10:25:44 +00:00
|
|
|
|
|
|
|
this.showTemplateDescriptions = this.showDescriptions;
|
|
|
|
// Clear the showDescriptions flag for subsequent requests as we implement
|
|
|
|
// description fetch ourselves
|
|
|
|
this.showDescriptions = false;
|
2015-04-10 20:28:38 +00:00
|
|
|
|
|
|
|
// Properties
|
|
|
|
this.descriptions = {};
|
|
|
|
|
|
|
|
// Initialization
|
|
|
|
this.$element.addClass( 've-ui-mwTemplateTitleInputWidget' );
|
|
|
|
};
|
2015-05-25 13:51:13 +00:00
|
|
|
|
2015-04-10 20:28:38 +00:00
|
|
|
/* Inheritance */
|
|
|
|
|
2017-04-21 01:51:11 +00:00
|
|
|
// FIXME: This should extend mw.widgets.TitleSearchWidget instead
|
2015-06-15 10:25:44 +00:00
|
|
|
OO.inheritClass( ve.ui.MWTemplateTitleInputWidget, mw.widgets.TitleInputWidget );
|
2015-04-10 20:28:38 +00:00
|
|
|
|
|
|
|
/* Methods */
|
|
|
|
|
2021-03-01 17:02:38 +00:00
|
|
|
// @inheritdoc mw.widgets.TitleWidget
|
|
|
|
ve.ui.MWTemplateTitleInputWidget.prototype.getApiParams = function ( query ) {
|
|
|
|
var params = ve.ui.MWTemplateTitleInputWidget.super.prototype.getApiParams.call( this, query );
|
|
|
|
|
|
|
|
// TODO: This should stay as a feature flag for 3rd-parties to fallback to prefixsearch
|
|
|
|
if ( mw.config.get( 'wgVisualEditorConfig' ).cirrusSearchLookup ) {
|
2021-05-19 16:51:48 +00:00
|
|
|
ve.extendObject( params, {
|
|
|
|
generator: 'search',
|
|
|
|
gsrsearch: params.gpssearch,
|
|
|
|
gsrnamespace: params.gpsnamespace,
|
|
|
|
gsrlimit: params.gpslimit
|
|
|
|
} );
|
2021-06-21 09:17:37 +00:00
|
|
|
// Adding the asterisk to emulate a prefix search behavior. It does not make sense in all
|
|
|
|
// cases though. We're limiting it to be add only of the term ends with a letter or numeric
|
|
|
|
// character.
|
2024-04-30 16:44:25 +00:00
|
|
|
// eslint-disable-next-line es-x/no-regexp-unicode-property-escapes, prefer-regex-literals
|
2023-04-12 22:02:09 +00:00
|
|
|
var endsWithAlpha = new RegExp( '[0-9a-z\\p{L}\\p{N}]$', 'iu' );
|
2021-06-21 09:17:37 +00:00
|
|
|
if ( endsWithAlpha.test( params.gsrsearch ) ) {
|
2021-05-11 13:40:10 +00:00
|
|
|
params.gsrsearch += '*';
|
|
|
|
}
|
2021-05-19 16:51:48 +00:00
|
|
|
if ( this.showRedirectTargets ) {
|
|
|
|
params.gsrprop = 'redirecttitle';
|
|
|
|
}
|
2021-03-01 17:02:38 +00:00
|
|
|
delete params.gpssearch;
|
|
|
|
delete params.gpsnamespace;
|
|
|
|
delete params.gpslimit;
|
|
|
|
}
|
|
|
|
return params;
|
|
|
|
};
|
|
|
|
|
2019-04-16 15:17:29 +00:00
|
|
|
// @inheritdoc mw.widgets.TitleInputWidget
|
2015-04-10 20:28:38 +00:00
|
|
|
ve.ui.MWTemplateTitleInputWidget.prototype.getLookupRequest = function () {
|
2015-06-15 10:25:44 +00:00
|
|
|
var widget = this,
|
|
|
|
originalResponse,
|
2019-01-09 19:30:42 +00:00
|
|
|
templateDataMessage = mw.message( 'templatedata-doc-subpage' ),
|
|
|
|
templateDataInstalled = templateDataMessage.exists(),
|
|
|
|
templateDocPageFragment = '/' + templateDataMessage.text(),
|
2015-06-15 10:25:44 +00:00
|
|
|
promise = ve.ui.MWTemplateTitleInputWidget.super.prototype.getLookupRequest.call( this );
|
|
|
|
|
2021-06-23 12:48:17 +00:00
|
|
|
if ( mw.config.get( 'wgVisualEditorConfig' ).cirrusSearchLookup ) {
|
|
|
|
promise = promise
|
|
|
|
.then( this.addExactMatch.bind( this ) )
|
2024-04-30 16:44:25 +00:00
|
|
|
.promise( { abort: () => {} } );
|
2021-05-21 09:41:22 +00:00
|
|
|
}
|
|
|
|
|
2021-03-01 17:03:54 +00:00
|
|
|
if ( !this.showTemplateDescriptions ) {
|
|
|
|
return promise;
|
|
|
|
}
|
2015-04-10 20:28:38 +00:00
|
|
|
|
2021-03-01 17:03:54 +00:00
|
|
|
return promise
|
2024-04-30 16:44:25 +00:00
|
|
|
.then( ( response ) => {
|
2021-06-21 08:10:27 +00:00
|
|
|
var redirects = ( response.query && response.query.redirects ) || [],
|
2022-04-19 12:33:49 +00:00
|
|
|
newPages = ( response.query && response.query.pages ) || [];
|
2021-03-01 17:03:54 +00:00
|
|
|
|
2024-04-30 16:44:25 +00:00
|
|
|
newPages.forEach( ( page ) => {
|
2022-04-19 12:14:48 +00:00
|
|
|
if ( !( 'index' in page ) ) {
|
2021-03-01 17:03:54 +00:00
|
|
|
// Watch out for cases where the index is specified on the redirect object
|
|
|
|
// rather than the page object.
|
2022-04-19 12:10:48 +00:00
|
|
|
for ( var j in redirects ) {
|
2022-04-19 12:14:48 +00:00
|
|
|
if ( redirects[ j ].to === page.title ) {
|
|
|
|
page.index = redirects[ j ].index;
|
2021-03-01 17:03:54 +00:00
|
|
|
break;
|
|
|
|
}
|
|
|
|
}
|
2015-09-10 16:13:36 +00:00
|
|
|
}
|
2022-04-19 12:14:48 +00:00
|
|
|
} );
|
|
|
|
|
2022-04-19 12:33:49 +00:00
|
|
|
// Ensure everything goes into the order defined by the page's index key
|
2024-04-30 16:44:25 +00:00
|
|
|
newPages.sort( ( a, b ) => a.index - b.index );
|
2021-03-01 17:03:54 +00:00
|
|
|
|
|
|
|
// T54448: Filter out matches which end in /doc or as configured on-wiki
|
|
|
|
if ( templateDataInstalled ) {
|
2024-04-30 16:44:25 +00:00
|
|
|
newPages = newPages.filter(
|
2021-03-01 17:03:54 +00:00
|
|
|
// Can't use String.endsWith() as that's ES6.
|
|
|
|
// page.title.endsWith( templateDocPageFragment )
|
2024-04-30 16:44:25 +00:00
|
|
|
( page ) => page.title.slice( -templateDocPageFragment.length ) !== templateDocPageFragment
|
|
|
|
);
|
2021-03-01 17:03:54 +00:00
|
|
|
}
|
|
|
|
|
2024-04-30 16:44:25 +00:00
|
|
|
var titles = newPages.map( ( page ) => page.title );
|
2021-03-01 17:03:54 +00:00
|
|
|
|
|
|
|
ve.setProp( response, 'query', 'pages', newPages );
|
|
|
|
originalResponse = response; // lie!
|
|
|
|
|
|
|
|
// Also get descriptions
|
|
|
|
// FIXME: This should go through MWTransclusionModel rather than duplicate.
|
|
|
|
if ( titles.length > 0 ) {
|
2021-06-21 08:10:27 +00:00
|
|
|
var xhr = widget.getApi().get( {
|
2021-03-01 17:03:54 +00:00
|
|
|
action: 'templatedata',
|
|
|
|
titles: titles,
|
|
|
|
redirects: !!widget.showRedirects,
|
|
|
|
includeMissingTitles: '1',
|
|
|
|
lang: mw.config.get( 'wgUserLanguage' )
|
|
|
|
} );
|
|
|
|
return xhr.promise( { abort: xhr.abort } );
|
|
|
|
}
|
|
|
|
} )
|
2024-04-30 16:44:25 +00:00
|
|
|
.then( ( templateDataResponse ) => {
|
2021-06-21 08:10:27 +00:00
|
|
|
var pages = ( templateDataResponse && templateDataResponse.pages ) || {};
|
2021-03-01 17:03:54 +00:00
|
|
|
// Look for descriptions and cache them
|
2022-04-19 11:35:27 +00:00
|
|
|
for ( var i in pages ) {
|
|
|
|
var page = pages[ i ];
|
2021-03-01 17:03:54 +00:00
|
|
|
|
|
|
|
if ( page.missing ) {
|
2022-08-05 08:00:54 +00:00
|
|
|
// Remember templates that don't exist in the link cache
|
2021-03-01 17:03:54 +00:00
|
|
|
// { title: { missing: true|false }
|
2021-06-21 08:10:27 +00:00
|
|
|
var missingTitle = {};
|
2021-03-01 17:03:54 +00:00
|
|
|
missingTitle[ page.title ] = { missing: true };
|
|
|
|
ve.init.platform.linkCache.setMissing( missingTitle );
|
|
|
|
} else if ( !page.notemplatedata ) {
|
|
|
|
// Cache descriptions
|
|
|
|
widget.descriptions[ page.title ] = page.description;
|
2015-06-15 10:25:44 +00:00
|
|
|
}
|
2021-03-01 17:03:54 +00:00
|
|
|
}
|
|
|
|
// Return the original response
|
|
|
|
return originalResponse;
|
2024-04-30 16:44:25 +00:00
|
|
|
// API request failed; most likely, we're on a wiki which doesn't have TemplateData.
|
|
|
|
}, () => originalResponse || ve.createDeferred().reject() )
|
|
|
|
.promise( { abort: () => {} } );
|
2015-04-10 20:28:38 +00:00
|
|
|
};
|
|
|
|
|
2021-06-23 12:48:17 +00:00
|
|
|
/**
|
|
|
|
* @private
|
|
|
|
* @method
|
|
|
|
* @param {Object} response Action API response from server
|
|
|
|
* @return {Object} Modified response
|
|
|
|
*/
|
|
|
|
ve.ui.MWTemplateTitleInputWidget.prototype.addExactMatch = function ( response ) {
|
2021-06-25 12:22:08 +00:00
|
|
|
var widget = this,
|
|
|
|
query = this.getQueryValue(),
|
2021-07-08 09:09:17 +00:00
|
|
|
title = mw.Title.newFromText( query, this.namespace );
|
2021-06-23 12:48:17 +00:00
|
|
|
// No point in trying anything when the title is invalid
|
2022-04-19 13:30:36 +00:00
|
|
|
if ( !title ) {
|
2021-06-23 12:48:17 +00:00
|
|
|
return response;
|
|
|
|
}
|
|
|
|
|
2022-04-19 13:30:36 +00:00
|
|
|
if ( !response.query ) {
|
|
|
|
response.query = { pages: [] };
|
|
|
|
}
|
|
|
|
|
2021-06-23 12:48:17 +00:00
|
|
|
var lowerTitle = title.getPrefixedText().toLowerCase(),
|
|
|
|
metadata = response.query.redirects || [],
|
2024-04-30 16:44:25 +00:00
|
|
|
foundMatchingMetadata = metadata.some( ( redirect ) => redirect.from.toLowerCase() === lowerTitle );
|
2021-06-23 12:48:17 +00:00
|
|
|
if ( foundMatchingMetadata ) {
|
|
|
|
// Redirects will be carefully positioned later in TitleWidget.getOptionsFromData()
|
|
|
|
return response;
|
|
|
|
}
|
|
|
|
|
2021-07-08 09:09:17 +00:00
|
|
|
/**
|
2024-04-29 11:52:40 +00:00
|
|
|
* @typedef {Object} PageResponse
|
|
|
|
* @memberof ve.ui.MWTemplateTitleInputWidget
|
|
|
|
* @param {number} pageId Page ID
|
|
|
|
* @param {number} index Page ID
|
|
|
|
*/
|
|
|
|
|
|
|
|
/**
|
|
|
|
* @param {ve.ui.MWTemplateTitleInputWidget.PageResponse[]} pages
|
2021-07-08 09:09:17 +00:00
|
|
|
* @param {number} pageId
|
|
|
|
* @return {boolean}
|
|
|
|
*/
|
|
|
|
function containsPageId( pages, pageId ) {
|
2024-04-30 16:44:25 +00:00
|
|
|
return pageId && pages.some( ( page ) => page.pageid === pageId );
|
2021-07-08 09:09:17 +00:00
|
|
|
}
|
|
|
|
|
2021-06-23 12:48:17 +00:00
|
|
|
/**
|
2024-04-29 11:52:40 +00:00
|
|
|
* @param {ve.ui.MWTemplateTitleInputWidget.PageResponse[]} pages
|
2021-06-23 12:48:17 +00:00
|
|
|
* @param {Object} [newPage]
|
|
|
|
*/
|
|
|
|
function unshiftPages( pages, newPage ) {
|
2024-04-30 16:44:25 +00:00
|
|
|
pages.forEach( ( page ) => {
|
2021-06-23 12:48:17 +00:00
|
|
|
page.index++;
|
|
|
|
} );
|
|
|
|
if ( newPage && newPage.title ) {
|
|
|
|
newPage.index = 1;
|
|
|
|
pages.unshift( newPage );
|
2021-06-25 12:22:08 +00:00
|
|
|
if ( pages.length > widget.limit ) {
|
2024-04-30 16:44:25 +00:00
|
|
|
pages.sort( ( a, b ) => a.index - b.index ).splice( widget.limit );
|
2021-06-25 12:22:08 +00:00
|
|
|
}
|
2021-06-23 12:48:17 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
var i,
|
2024-04-30 16:44:25 +00:00
|
|
|
matchingRedirects = response.query.pages.filter( ( page ) => page.redirecttitle && page.redirecttitle.toLowerCase() === lowerTitle );
|
2021-06-23 12:48:17 +00:00
|
|
|
if ( matchingRedirects.length ) {
|
|
|
|
for ( i = matchingRedirects.length; i--; ) {
|
2021-11-09 00:37:49 +00:00
|
|
|
var matchingRedirect = matchingRedirects[ i ];
|
2021-06-23 12:48:17 +00:00
|
|
|
// Offer redirects as separate options when the user's input is an exact match
|
|
|
|
unshiftPages( response.query.pages, {
|
2021-11-09 00:37:49 +00:00
|
|
|
pageid: matchingRedirect.pageid,
|
|
|
|
ns: matchingRedirect.ns,
|
|
|
|
title: matchingRedirect.redirecttitle
|
2021-06-23 12:48:17 +00:00
|
|
|
} );
|
|
|
|
}
|
|
|
|
return response;
|
|
|
|
}
|
|
|
|
|
2024-04-30 16:44:25 +00:00
|
|
|
var matchingTitles = response.query.pages.filter( ( page ) => page.title.toLowerCase() === lowerTitle );
|
2021-06-23 12:48:17 +00:00
|
|
|
if ( matchingTitles.length ) {
|
|
|
|
for ( i = matchingTitles.length; i--; ) {
|
|
|
|
// Make sure exact matches are at the very top
|
|
|
|
unshiftPages( response.query.pages );
|
|
|
|
matchingTitles[ i ].index = 1;
|
|
|
|
}
|
|
|
|
return response;
|
|
|
|
}
|
|
|
|
|
|
|
|
return this.getApi().get( {
|
2021-07-08 09:09:17 +00:00
|
|
|
action: 'query',
|
2021-06-23 12:48:17 +00:00
|
|
|
// Can't use a direct lookup by title because we need this to be case-insensitive
|
2021-07-08 09:09:17 +00:00
|
|
|
generator: 'prefixsearch',
|
|
|
|
gpssearch: query,
|
|
|
|
gpsnamespace: this.namespace,
|
2022-03-21 14:37:45 +00:00
|
|
|
// Try to fill with prefix matches, otherwise just the top-1 prefix match
|
2022-04-19 12:56:51 +00:00
|
|
|
gpslimit: this.limit
|
2024-04-30 16:44:25 +00:00
|
|
|
} ).then( ( prefixMatches ) => {
|
2021-07-08 09:09:17 +00:00
|
|
|
// action=query returns page objects in `{ query: { pages: [] } }`, not keyed by page id
|
2022-04-19 12:56:51 +00:00
|
|
|
var pages = prefixMatches.query && prefixMatches.query.pages || [];
|
2024-04-30 16:44:25 +00:00
|
|
|
pages.sort( ( a, b ) => a.index - b.index );
|
2022-04-19 12:56:51 +00:00
|
|
|
for ( i in pages ) {
|
|
|
|
var prefixMatch = pages[ i ];
|
|
|
|
if ( !containsPageId( response.query.pages, prefixMatch.pageid ) ) {
|
|
|
|
// Move prefix matches to the top, indexed from -9 to 0, relevant for e.g. {{!!}}
|
|
|
|
// Note: Sorting happens later in mw.widgets.TitleWidget.getOptionsFromData()
|
|
|
|
prefixMatch.index -= widget.limit;
|
|
|
|
response.query.pages.push( prefixMatch );
|
|
|
|
}
|
|
|
|
// Check only after the top-1 prefix match is guaranteed to be present
|
|
|
|
if ( response.query.pages.length >= widget.limit ) {
|
|
|
|
break;
|
2021-07-08 09:09:17 +00:00
|
|
|
}
|
2021-06-23 12:48:17 +00:00
|
|
|
}
|
|
|
|
return response;
|
2024-04-30 16:44:25 +00:00
|
|
|
// Proceed with the unmodified response in case the additional API request failed
|
|
|
|
}, () => response )
|
|
|
|
.promise( { abort: () => {} } );
|
2021-06-23 12:48:17 +00:00
|
|
|
};
|
|
|
|
|
2021-05-19 16:51:48 +00:00
|
|
|
// @inheritdoc mw.widgets.TitleWidget
|
|
|
|
ve.ui.MWTemplateTitleInputWidget.prototype.getOptionWidgetData = function ( title, data ) {
|
2015-06-15 10:25:44 +00:00
|
|
|
return ve.extendObject(
|
|
|
|
ve.ui.MWTemplateTitleInputWidget.super.prototype.getOptionWidgetData.apply( this, arguments ),
|
2021-05-19 16:51:48 +00:00
|
|
|
{
|
|
|
|
description: this.descriptions[ title ],
|
|
|
|
redirecttitle: data.originalData.redirecttitle
|
|
|
|
}
|
2015-06-15 10:25:44 +00:00
|
|
|
);
|
2015-04-10 20:28:38 +00:00
|
|
|
};
|
2021-05-19 16:51:48 +00:00
|
|
|
|
|
|
|
// @inheritdoc mw.widgets.TitleWidget
|
|
|
|
ve.ui.MWTemplateTitleInputWidget.prototype.createOptionWidget = function ( data ) {
|
|
|
|
var widget = ve.ui.MWTemplateTitleInputWidget.super.prototype.createOptionWidget.call( this, data );
|
|
|
|
|
|
|
|
if ( data.redirecttitle ) {
|
|
|
|
// Same conditions as in mw.widgets.TitleWidget.getOptionWidgetData()
|
|
|
|
var title = new mw.Title( data.redirecttitle ),
|
|
|
|
text = this.namespace !== null && this.relative ?
|
|
|
|
title.getRelativeText( this.namespace ) :
|
|
|
|
data.redirecttitle;
|
|
|
|
|
|
|
|
var $desc = widget.$element.find( '.mw-widget-titleOptionWidget-description' );
|
|
|
|
if ( !$desc.length ) {
|
|
|
|
$desc = $( '<span>' )
|
|
|
|
.addClass( 'mw-widget-titleOptionWidget-description' )
|
|
|
|
.appendTo( widget.$element );
|
|
|
|
}
|
|
|
|
$desc.prepend( $( '<div>' )
|
|
|
|
.addClass( 've-ui-mwTemplateTitleInputWidget-redirectedfrom' )
|
|
|
|
.text( mw.msg( 'redirectedfrom', text ) ) );
|
|
|
|
}
|
|
|
|
|
|
|
|
return widget;
|
|
|
|
};
|