/*! * VisualEditor UserInterface MWTemplateTitleInputWidget class. * * @copyright 2011-2020 VisualEditor Team and others; see AUTHORS.txt * @license The MIT License (MIT); see LICENSE.txt */ /** * 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. * * @class * @extends mw.widgets.TitleInputWidget * * @constructor * @param {Object} [config] Configuration options * @cfg {number} [namespace] Namespace to prepend to queries. Defaults to template namespace. * @cfg {boolean} [showDescriptions] Show template descriptions from the TemplateData API * @cfg {mw.Api} [api] */ ve.ui.MWTemplateTitleInputWidget = function VeUiMWTemplateTitleInputWidget( config ) { config = ve.extendObject( {}, { namespace: mw.config.get( 'wgNamespaceIds' ).template, showMissing: false, // We don't need results to show up twice normalized and unnormalized addQueryInput: false, icon: 'search', placeholder: ve.msg( 'visualeditor-dialog-transclusion-placeholder-input-placeholder' ) }, config ); // Parent constructor ve.ui.MWTemplateTitleInputWidget.super.call( this, config ); // All code below expects this, but ContentTranslation doesn't necessarily set it to 2 this.api.defaults.parameters.formatversion = 2; this.showTemplateDescriptions = this.showDescriptions; // Clear the showDescriptions flag for subsequent requests as we implement // description fetch ourselves this.showDescriptions = false; // Properties this.descriptions = {}; // Initialization this.$element.addClass( 've-ui-mwTemplateTitleInputWidget' ); }; /* Inheritance */ // FIXME: This should extend mw.widgets.TitleSearchWidget instead OO.inheritClass( ve.ui.MWTemplateTitleInputWidget, mw.widgets.TitleInputWidget ); /* Methods */ // @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 ) { ve.extendObject( params, { generator: 'search', gsrsearch: params.gpssearch, gsrnamespace: params.gpsnamespace, gsrlimit: params.gpslimit } ); // 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. var endsWithAlpha; try { // eslint-disable-next-line es-x/no-regexp-u-flag, es-x/no-regexp-unicode-property-escapes, prefer-regex-literals endsWithAlpha = new RegExp( '[0-9a-z\\p{L}\\p{N}]$', 'iu' ); } catch ( e ) { // Support: IE, Chrome <= 49, FF <= 45, Safari <= 9.1 // See https://caniuse.com/mdn-javascript_builtins_regexp_unicode endsWithAlpha = /[0-9a-z\xC0-\uFFFF]$/i; } if ( endsWithAlpha.test( params.gsrsearch ) ) { params.gsrsearch += '*'; } if ( this.showRedirectTargets ) { params.gsrprop = 'redirecttitle'; } delete params.gpssearch; delete params.gpsnamespace; delete params.gpslimit; } return params; }; // @inheritdoc mw.widgets.TitleInputWidget ve.ui.MWTemplateTitleInputWidget.prototype.getLookupRequest = function () { var widget = this, originalResponse, templateDataMessage = mw.message( 'templatedata-doc-subpage' ), templateDataInstalled = templateDataMessage.exists(), templateDocPageFragment = '/' + templateDataMessage.text(), promise = ve.ui.MWTemplateTitleInputWidget.super.prototype.getLookupRequest.call( this ); if ( mw.config.get( 'wgVisualEditorConfig' ).cirrusSearchLookup ) { promise = promise .then( this.addExactMatch.bind( this ) ) .promise( { abort: function () {} } ); } if ( !this.showTemplateDescriptions ) { return promise; } return promise .then( function ( response ) { var redirects = ( response.query && response.query.redirects ) || [], newPages = ( response.query && response.query.pages ) || []; newPages.forEach( function ( page ) { if ( !( 'index' in page ) ) { // Watch out for cases where the index is specified on the redirect object // rather than the page object. for ( var j in redirects ) { if ( redirects[ j ].to === page.title ) { page.index = redirects[ j ].index; break; } } } } ); // Ensure everything goes into the order defined by the page's index key newPages.sort( function ( a, b ) { return a.index - b.index; } ); // T54448: Filter out matches which end in /doc or as configured on-wiki if ( templateDataInstalled ) { newPages = newPages.filter( function ( page ) { // Can't use String.endsWith() as that's ES6. // page.title.endsWith( templateDocPageFragment ) return page.title.slice( -templateDocPageFragment.length ) !== templateDocPageFragment; } ); } var titles = newPages.map( function ( page ) { return page.title; } ); 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 ) { var xhr = widget.getApi().get( { action: 'templatedata', titles: titles, redirects: !!widget.showRedirects, includeMissingTitles: '1', lang: mw.config.get( 'wgUserLanguage' ) } ); return xhr.promise( { abort: xhr.abort } ); } } ) .then( function ( templateDataResponse ) { var pages = ( templateDataResponse && templateDataResponse.pages ) || {}; // Look for descriptions and cache them for ( var i in pages ) { var page = pages[ i ]; if ( page.missing ) { // Remember templates that don't exist in the link cache // { title: { missing: true|false } var missingTitle = {}; missingTitle[ page.title ] = { missing: true }; ve.init.platform.linkCache.setMissing( missingTitle ); } else if ( !page.notemplatedata ) { // Cache descriptions widget.descriptions[ page.title ] = page.description; } } // Return the original response return originalResponse; }, function () { // API request failed; most likely, we're on a wiki which doesn't have TemplateData. return originalResponse || ve.createDeferred().reject(); } ) .promise( { abort: function () {} } ); }; /** * @private * @method * @param {Object} response Action API response from server * @return {Object} Modified response */ ve.ui.MWTemplateTitleInputWidget.prototype.addExactMatch = function ( response ) { var widget = this, query = this.getQueryValue(), title = mw.Title.newFromText( query, this.namespace ); // No point in trying anything when the title is invalid if ( !title ) { return response; } if ( !response.query ) { response.query = { pages: [] }; } var lowerTitle = title.getPrefixedText().toLowerCase(), metadata = response.query.redirects || [], foundMatchingMetadata = metadata.some( function ( redirect ) { return redirect.from.toLowerCase() === lowerTitle; } ); if ( foundMatchingMetadata ) { // Redirects will be carefully positioned later in TitleWidget.getOptionsFromData() return response; } /** * @param {{pageid: number}[]} pages * @param {number} pageId * @return {boolean} */ function containsPageId( pages, pageId ) { return pageId && pages.some( function ( page ) { return page.pageid === pageId; } ); } /** * @param {{index: number}[]} pages * @param {Object} [newPage] */ function unshiftPages( pages, newPage ) { pages.forEach( function ( page ) { page.index++; } ); if ( newPage && newPage.title ) { newPage.index = 1; pages.unshift( newPage ); if ( pages.length > widget.limit ) { pages.sort( function ( a, b ) { return a.index - b.index; } ).splice( widget.limit ); } } } var i, matchingRedirects = response.query.pages.filter( function ( page ) { return page.redirecttitle && page.redirecttitle.toLowerCase() === lowerTitle; } ); if ( matchingRedirects.length ) { for ( i = matchingRedirects.length; i--; ) { var matchingRedirect = matchingRedirects[ i ]; // Offer redirects as separate options when the user's input is an exact match unshiftPages( response.query.pages, { pageid: matchingRedirect.pageid, ns: matchingRedirect.ns, title: matchingRedirect.redirecttitle } ); } return response; } var matchingTitles = response.query.pages.filter( function ( page ) { return page.title.toLowerCase() === lowerTitle; } ); 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( { action: 'query', // Can't use a direct lookup by title because we need this to be case-insensitive generator: 'prefixsearch', gpssearch: query, gpsnamespace: this.namespace, // Try to fill with prefix matches, otherwise just the top-1 prefix match gpslimit: this.limit } ).then( function ( prefixMatches ) { // action=query returns page objects in `{ query: { pages: [] } }`, not keyed by page id var pages = prefixMatches.query && prefixMatches.query.pages || []; pages.sort( function ( a, b ) { return a.index - b.index; } ); 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; } } return response; }, function () { // Proceed with the unmodified response in case the additional API request failed return response; } ) .promise( { abort: function () {} } ); }; // @inheritdoc mw.widgets.TitleWidget ve.ui.MWTemplateTitleInputWidget.prototype.getOptionWidgetData = function ( title, data ) { return ve.extendObject( ve.ui.MWTemplateTitleInputWidget.super.prototype.getOptionWidgetData.apply( this, arguments ), { description: this.descriptions[ title ], redirecttitle: data.originalData.redirecttitle } ); }; // @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 = $( '' ) .addClass( 'mw-widget-titleOptionWidget-description' ) .appendTo( widget.$element ); } $desc.prepend( $( '
' ) .addClass( 've-ui-mwTemplateTitleInputWidget-redirectedfrom' ) .text( mw.msg( 'redirectedfrom', text ) ) ); } return widget; };