Jump to content

MediaWiki:Gadget-WishlistManager.js

From Meta, a Wikimedia project coordination wiki

Note: After publishing, you may have to bypass your browser's cache to see the changes.

  • Firefox / Safari: Hold Shift while clicking Reload, or press either Ctrl-F5 or Ctrl-R (⌘-R on a Mac)
  • Google Chrome: Press Ctrl-Shift-R (⌘-Shift-R on a Mac)
  • Edge: Hold Ctrl while clicking Refresh, or press Ctrl-F5.
// <nowiki>
mw.loader.using( ["mediawiki.api","mediawiki.util"] ).then( ( require ) => {
/**
 * WishlistIntake: A gadget for intake and editing of Wishlist proposals.
 * Version 0.0.1
 * From [[Community Tech]]
 * Compiled from source at https://gitlab.wikimedia.org/repos/commtech/wishlist-intake
 * Please submit code changes as a merge request to the source repository.
 */

'use strict';

var wishHomePage = "Community Wishlist";
var wishIntakePage = "Community Wishlist/Intake";
var wishEditParam = "editwish";
var wishIndexTemplate = "Community Wishlist/Wishes";
var wishIndexTemplateAll = "Community Wishlist/Wishes/All";
var wishIndexTemplateRecent = "Community Wishlist/Wishes/Recent";
var wishIndexTemplateArchive = "Community Wishlist/Wishes/Archive";
var wishCategory = "Community Wishlist/Wishes";
var wishPagePrefix = "Community Wishlist/Wishes/";
var wishTemplate = "Community Wishlist/Wish";
var messagesPage = "MediaWiki:Gadget-WishlistIntake/messages";
var interfaceMessageGroupId = "agg-Community_Wishlist_interface";
var wishesMessageGroupId = "agg-Community_Wishlist_wishes";
var maxRecentWishes = 5;
var gadgets = {
	WishlistIntake: {
		ResourceLoader: true,
		"default": true,
		hidden: true,
		"package": true,
		files: [
			"WishlistIntake.js"
		],
		rights: [
			"editmyusercss"
		],
		categories: [
			"Community Wishlist/Intake"
		],
		dependencies: [
			"vue",
			"@wikimedia/codex",
			"mediawiki.util",
			"mediawiki.api",
			"user.options",
			"mediawiki.action.view.postEdit",
			"mediawiki.confirmCloseWindow",
			"mediawiki.jqueryMsg"
		],
		peers: [
			"WishlistIntake-pagestyles"
		]
	},
	"WishlistIntake-pagestyles": {
		peer: true,
		hidden: true,
		files: [
			"WishlistIntake-pagestyles.css"
		],
		filesOnWiki: true
	},
	WishlistManager: {
		ResourceLoader: true,
		"default": true,
		hidden: true,
		"package": true,
		files: [
			"WishlistManager.js"
		],
		rights: [
			"editmyusercss"
		],
		dependencies: [
			"mediawiki.api",
			"mediawiki.util"
		]
	}
};
var messages = {
	"communitywishlist-wish-loading-error": "There was an error while parsing the wish source text. It may contain invalid wikitext. Please [$1 refresh] and try again, use the [[$2|source editor]], or ask for help on the [[$3|talk page]].",
	"communitywishlist-edit-with-form": "Edit with form",
	"communitywishlist-form-subtitle": "Welcome to the new Community Wishlist. Please fill in the form below to submit your wish.",
	"communitywishlist-form-error": "Something went wrong. Please try saving again, or ask for help on the [[$1|talk page]].",
	"communitywishlist-description": "Describe your problem",
	"communitywishlist-description-description": "Explain in detail the wish or problem you are addressing.",
	"communitywishlist-title": "Wish title",
	"communitywishlist-title-error": "Please enter a value for this field (between $1 and $2 {{PLURAL:$2|character|characters}}).",
	"communitywishlist-title-description": "Make sure your title contains a brief description of the wish or problem.",
	"communitywishlist-description-error": "Please enter a value for this field ($1 or more {{PLURAL:$1|character|characters}}).",
	"communitywishlist-wishtype-label": "Which type best describes your wish?",
	"communitywishlist-wishtype-description": "For submitting a policy change request, please consult the applicable project.",
	"communitywishlist-wishtype-feature-label": "Feature request",
	"communitywishlist-wishtype-feature-description": "You want new features and functions that do not exist yet.",
	"communitywishlist-wishtype-bug-label": "Bug report",
	"communitywishlist-wishtype-bug-description": "You want a problem or error fixed with existing features.",
	"communitywishlist-wishtype-change-label": "System change",
	"communitywishlist-wishtype-change-description": "You want a currently working feature or function to be changed.",
	"communitywishlist-wishtype-unknown-label": "I'm not sure or I don't know",
	"communitywishlist-wishtype-unknown-description": "After receiving your wish, we will assign a relevant type.",
	"communitywishlist-wishtype-error": "Please select a wish type.",
	"communitywishlist-project-intro": "Which projects is your wish related to?",
	"communitywishlist-project-help": "Select all projects your wish will have an impact on.",
	"communitywishlist-project-all-projects": "All projects",
	"communitywishlist-project-show-less": "Show less",
	"communitywishlist-project-show-all": "Show all",
	"communitywishlist-project-other-label": "It's something else",
	"communitywishlist-project-other-description": "e.g. gadgets, bots and external tools",
	"communitywishlist-project-other-error": "Please enter a value for this field (greater than $1 {{PLURAL:$1|character|characters}}), or select a project checkbox.",
	"communitywishlist-project-no-selection": "Please select at least $1 {{PLURAL:$1|project checkbox|project checkboxes}}, or enter a value for the \"$2\" field.",
	"communitywishlist-audience-label": "Primary affected users",
	"communitywishlist-audience-description": "Describe which user group and situation this will affect the most",
	"communitywishlist-audience-error": "Please enter a value for this field (between $1 and $2 {{PLURAL:$2|characters}}).",
	"communitywishlist-phabricator-label": "Phabricator tasks (optional)",
	"communitywishlist-phabricator-desc": "Enter Phabricator task IDs or URLs.",
	"communitywishlist-phabricator-chip-desc": "A list of Phabricator task IDs.",
	"communitywishlist-create-success": "Your wish has been submitted.",
	"communitywishlist-edit-success": "Your wish has been saved.",
	"communitywishlist-view-all-wishes": "View all wishes.",
	"communitywishlist-close": "Close",
	"communitywishlist-publish": "Publish wish",
	"communitywishlist-save": "Save changes"
};
var importedMessages = [
	"project-localized-name-commonswiki",
	"project-localized-name-group-wikinews",
	"project-localized-name-group-wikipedia",
	"project-localized-name-group-wikiquote",
	"project-localized-name-group-wikisource",
	"project-localized-name-group-wikiversity",
	"project-localized-name-group-wikivoyage",
	"project-localized-name-group-wiktionary",
	"project-localized-name-mediawikiwiki",
	"project-localized-name-metawiki",
	"project-localized-name-specieswiki",
	"project-localized-name-wikidatawiki",
	"project-localized-name-wikifunctionswiki",
	"wikimedia-otherprojects-cloudservices",
	"cancel",
	"wikimedia-copyrightwarning"
];
var config = {
	wishHomePage: wishHomePage,
	wishIntakePage: wishIntakePage,
	wishEditParam: wishEditParam,
	wishIndexTemplate: wishIndexTemplate,
	wishIndexTemplateAll: wishIndexTemplateAll,
	wishIndexTemplateRecent: wishIndexTemplateRecent,
	wishIndexTemplateArchive: wishIndexTemplateArchive,
	wishCategory: wishCategory,
	wishPagePrefix: wishPagePrefix,
	wishTemplate: wishTemplate,
	messagesPage: messagesPage,
	interfaceMessageGroupId: interfaceMessageGroupId,
	wishesMessageGroupId: wishesMessageGroupId,
	maxRecentWishes: maxRecentWishes,
	gadgets: gadgets,
	messages: messages,
	importedMessages: importedMessages
};

/**
 * Utility functions for the gadget
 */
class WebUtil {

	/**
	 * Get the full page name with underscores replaced by spaces.
	 * We use this instead of wgTitle because it's possible to set up
	 * the wishlist gadget for use outside the mainspace.
	 *
	 * @return {string}
	 */
	static getPageName() {
		return mw.config.get( 'wgPageName' ).replaceAll( '_', ' ' );
	}

	/**
	 * Is the current page a wish page?
	 *
	 * @return {boolean}
	 */
	static isWishPage() {
		return this.getPageName().startsWith( config.wishPagePrefix );
	}

	/**
	 * Are we currently creating a new wish?
	 *
	 * @return {boolean}
	 */
	static isNewWish() {
		return this.getPageName().startsWith( config.wishIntakePage ) &&
			mw.config.get( 'wgAction' ) === 'view' &&
			!this.isWishEdit() &&
			// Don't load on diff pages
			!mw.config.get( 'wgDiffOldId' );
	}

	/**
	 * Are we currently viewing (but not editing) a wish page?
	 *
	 * @return {boolean}
	 */
	static isWishView() {
		return mw.config.get( 'wgCategories' ).includes( config.wishCategory ) &&
			!this.isWishEdit();
	}

	/**
	 * Are we currently editing a wish page?
	 *
	 * @return {boolean}
	 */
	static isWishEdit() {
		return this.isWishPage() && !!mw.util.getParamValue( config.wishEditParam );
	}

	/**
	 * Are we currently manually editing a wish page?
	 *
	 * @return {boolean}
	 */
	static isManualWishEdit() {
		return this.isWishPage() &&
			(
				mw.config.get( 'wgAction' ) === 'edit' ||
				document.documentElement.classList.contains( 've-active' )
			);
	}

	/**
	 * Get the user's preferred language.
	 *
	 * @return {string}
	 */
	static userPreferredLang() {
		if ( mw.config.get( 'wgArticleId' ) === 0 ) {
			// Use interface language for new pages.
			return mw.config.get( 'wgUserLanguage' );
		}
		// Use content language for existing pages.
		return mw.config.get( 'wgContentLanguage' );
	}

	/**
	 * Is the user's preferred language right-to-left?
	 *
	 * @return {boolean}
	 */
	static isRtl() {
		return $( 'body' ).css( 'direction' ) === 'rtl';
	}

	/**
	 * Are we on a page related to creating, editing, or viewing a wish?
	 * This can include viewing the revision history, manual editing of wishes, etc.
	 *
	 * @return {boolean}
	 */
	static isWishRelatedPage() {
		return this.isNewWish() || this.isWishEdit() || this.isWishView() || this.isWishPage();
	}

	/**
	 * Should we show the intake form?
	 *
	 * @return {boolean}
	 */
	static shouldShowForm() {
		// Prevent form from loading on i.e. action=history
		return mw.config.get( 'wgAction' ) === 'view' &&
			( this.isNewWish() || this.isWishEdit() );
	}

	/**
	 * Get the slug for the wish derived from the page title.
	 * This is the subpage title and not necessarily the wish title,
	 * which is stored in the proposal content.
	 *
	 * @return {?string} null if not a wish-related page
	 */
	static getWishSlug() {
		if ( this.isNewWish() ) {
			// New wishes have no slug yet.
			return '';
		} else if ( this.isWishPage() ) {
			// Existing wishes have the page prefix stripped.
			return this.getPageName().slice( config.wishPagePrefix.length );
		}
		return null;
	}

	/**
	 * Get the full page title of the wish from the slug.
	 *
	 * @param {string} slug
	 * @return {string}
	 */
	static getPageTitleFromSlug( slug ) {
		return config.wishPagePrefix + slug;
	}

	/**
	 * Is the user WMF staff?
	 *
	 * @todo WMF-specific
	 * @return {boolean}
	 */
	static isStaff() {
		return /\s\(WMF\)$|-WMF$/.test( mw.config.get( 'wgUserName' ) );
	}

	/**
	 * Log an error to the console.
	 *
	 * @param {string} text
	 * @param {Error} error
	 */
	static logError( text, error ) {
		mw.log.error( `[WishlistIntake] ${ text }`, error );
	}

	/**
	 * Get a CSS-only Codex Message component of the specified type.
	 * This is for use outside the Vue application.
	 *
	 * @param {mw.Message} message
	 * @param {string} type 'notice', 'warning', 'error' or 'success'
	 * @return {HTMLDivElement}
	 */
	static getMessageBox( message, type ) {
		const messageBlock = document.createElement( 'div' );
		// The following messages may be used here:
		// * cdx-message--notice
		// * cdx-message--warning
		// * cdx-message--error
		// * cdx-message--success
		messageBlock.classList.add( 'cdx-message', 'cdx-message--block', `cdx-message--${ type }` );
		if ( type === 'warning' ) {
			messageBlock.role = 'alert';
		} else {
			messageBlock.ariaLive = 'polite';
		}
		const icon = document.createElement( 'span' );
		icon.classList.add( 'cdx-message__icon' );
		const content = document.createElement( 'div' );
		content.classList.add( 'cdx-message__content' );
		content.innerHTML = message.parse();
		messageBlock.appendChild( icon );
		messageBlock.appendChild( content );
		return messageBlock;
	}
}

class TemplateParserError extends Error {
}

/**
 * Extension tag names from metawiki
 *
 * TODO: fetch this list from the wiki being acted on
 *
 * @type {string[]}
 */
const EXT_NAMES = [
	'pre',
	'nowiki',
	'gallery',
	'indicator',
	'langconvert',
	'graph',
	'languages',
	'timeline',
	'hiero',
	'charinsert',
	'ref',
	'references',
	'inputbox',
	'imagemap',
	'source',
	'syntaxhighlight',
	'poem',
	'categorytree',
	'section',
	'score',
	'dynamicpagelist',
	'rss',
	'templatestyles',
	'templatedata',
	'math',
	'ce',
	'chem',
	'maplink',
	'mapframe',
	'phonos'
];

class TemplateParser {
	/**
	 * @callback TemplateCallback
	 * @param {string} name
	 * @param {Object<string>} args
	 */

	/**
	 * Parse some wikitext and call a function for each template.
	 *
	 * Throw a TemplateParserError if the input text does not match our
	 * restricted grammar.
	 *
	 * @param {string} text
	 * @param {TemplateCallback} callback
	 */
	static parse( text, callback ) {
		const parser = new TemplateParser( text, callback );
		parser.execute();
	}

	/**
	 * Parse some wikitext and extract the parameters of the first template
	 * with a name matching the specified name.
	 *
	 * If the template was not found, or if there was a parse error, return
	 * null.
	 *
	 * @param {string} text
	 * @param {string} targetTemplateName
	 * @return {?Object<string>}
	 */
	static getParams( text, targetTemplateName ) {
		function normalize( name ) {
			name = name.charAt( 0 ).toUpperCase() +
				name.slice( 1 );
			return name.replaceAll( '_', ' ' );
		}

		targetTemplateName = normalize( targetTemplateName );
		let foundParams = null;
		try {
			TemplateParser.parse(
				text,
				function ( templateName, args ) {
					if ( foundParams === null &&
						normalize( templateName ) === targetTemplateName
					) {
						foundParams = args;
					}
				}
			);
		} catch ( e ) {
			if ( !( e instanceof TemplateParserError ) ) {
				throw e;
			}
		}
		return foundParams;
	}

	/**
	 * @param {string} text
	 * @param {TemplateCallback} callback
	 */
	constructor( text, callback ) {
		this.text = text;
		this.callback = callback;
	}

	/**
	 * Run the parser
	 */
	execute() {
		this.consumeWikitext( 0, [] );
	}

	/**
	 * Consume the top-level grammar rule
	 *
	 * @param {number} pos The offset at which to begin parsing
	 * @param {string[]} terminators Markup which, if encountered, causes
	 *   the function to return
	 * @return {number} The new offset beyond the end
	 */
	consumeWikitext( pos, terminators ) {
		while ( pos < this.text.length ) {
			for ( const terminator of terminators ) {
				if ( this.text.startsWith( terminator, pos ) ) {
					return pos;
				}
			}

			const char = this.text.charAt( pos );
			const char2 = this.text.slice( pos, pos + 2 );
			if ( char2 === '}}' ) {
				break;
			} else if ( char2 === '{{' ) {
				pos = this.consumeTemplate( pos );
			} else if ( char2 === '[[' ) {
				pos = this.consumeLink( pos );
			} else if ( char2 === '<!' &&
				this.text.slice( pos, pos + 4 ) === '<!--'
			) {
				pos = this.consumeComment( pos );
			} else if ( char === '<' &&
				EXT_NAMES.includes( this.getExtName( pos + 1 ) )
			) {
				pos = this.consumeExtension( pos );
			} else {
				pos = this.consumeLiteral( pos );
			}
		}
		return pos;
	}

	/**
	 * Consume a template call
	 *
	 * @param {number} pos The offset of the start markup "{{"
	 * @return {number} The offset beyond the end
	 */
	consumeTemplate( pos ) {
		let nextArgIndex = 1;
		pos = this.consumeMarkup( pos, '{{' );
		const nameStart = pos;
		pos = this.consumeWikitext( pos, [ '|', '}}' ] );
		const templateName = this.text.slice( nameStart, pos ).trim();
		const templateParams = {};
		while ( pos < this.text.length && this.text.charAt( pos ) === '|' ) {
			pos = this.consumeMarkup( pos, '|' );
			const partStart = pos;
			let name, value;
			pos = this.consumeWikitext( pos, [ '=', '|', '}}' ] );
			if ( this.text.charAt( pos ) === '=' ) {
				name = this.text.slice( partStart, pos ).trim();
				pos++;
				const valueStart = pos;
				pos = this.consumeWikitext( pos, [ '|', '}}' ] );
				value = this.text.slice( valueStart, pos ).trim();
			} else {
				name = nextArgIndex++;
				value = this.text.slice( partStart, pos );
			}
			templateParams[ name ] = value;
		}
		this.callback( templateName, templateParams );
		pos = this.consumeMarkup( pos, '}}' );
		return pos;
	}

	/**
	 * Consume a link
	 *
	 * @param {number} pos The offset of the start markup "[["
	 * @return {number} The offset beyond the end
	 */
	consumeLink( pos ) {
		pos = this.consumeMarkup( pos, '[[' );
		pos = this.consumeWikitext( pos, [ ']]' ] );
		pos = this.consumeMarkup( pos, ']]' );
		return pos;
	}

	/**
	 * Consume an HTML comment
	 *
	 * @param {number} pos The offset of the start markup "<!--"
	 * @return {number} The offset beyond the end
	 */
	consumeComment( pos ) {
		pos = this.consumeMarkup( pos, '<!--' );
		const endPos = this.text.indexOf( '-->', pos );
		if ( endPos === -1 ) {
			this.error( pos, 'missing comment terminator' );
		}
		pos = endPos + '-->'.length;
		return pos;
	}

	/**
	 * Consume an xmlish extension element
	 *
	 * @param {number} pos The offset of the start markup
	 * @return {number} The offset beyond the end
	 */
	consumeExtension( pos ) {
		pos = this.consumeMarkup( pos, '<' );
		const name = this.getExtName( pos );
		pos += name.length;
		const tagEndPos = this.text.indexOf( '>', pos );
		if ( tagEndPos === -1 ) {
			this.error( pos, 'missing end of extension tag' );
		}
		pos = tagEndPos + 1;
		if ( this.text.charAt( tagEndPos - 1 ) === '/' ) {
			// Self-closing
			return pos;
		} else {
			const endTag = `</${ name }>`;
			const endPos = this.text.indexOf( endTag, pos );
			if ( endPos === -1 ) {
				this.error( pos, 'missing extension end tag' );
			}
			return endPos + endTag.length;
		}
	}

	/**
	 * Consume at least one literal character
	 *
	 * @param {number} pos
	 * @return {number} The offset beyond the end of the run of literal characters
	 */
	consumeLiteral( pos ) {
		const literal = this.text.slice( pos ).match( /^[^[{|=}\]<]*/ )[ 0 ];
		if ( literal.length ) {
			// Literal composed of uninteresting characters
			return pos + literal.length;
		}
		if ( pos < this.text.length ) {
			// Literal composed of one interesting character not consumed elsewhere
			return pos + 1;
		}
		return pos;
	}

	/**
	 * Assert that the specified characters exist at the specified location
	 *
	 * @param {number} pos
	 * @param {string} markup
	 * @return {number} The position beyond the end of the markup
	 */
	consumeMarkup( pos, markup ) {
		if ( this.text.slice( pos, pos + markup.length ) !== markup ) {
			this.error( pos, `expected "${ markup }"` );
		}
		return pos + markup.length;
	}

	/**
	 * Look ahead to find the name of a prospective xmlish extension tag
	 *
	 * @param {number} pos
	 * @return {string}
	 */
	getExtName( pos ) {
		return this.text.slice( pos ).match( /^[a-zA-Z0-9_-]*/ )[ 0 ];
	}

	/**
	 * Raise a parse error
	 *
	 * @param {number} pos
	 * @param {string} message
	 */
	error( pos, message ) {
		throw new TemplateParserError( `Syntax error parsing template at offset ${ pos }: ${ message }` );
	}
}

/**
 * The wikitext template parameters (and optional page ID) of a wish
 */
class Wish {

	/**
	 * Normalize an array of values by trimming whitespace,
	 * removing empty values, and removing duplicates.
	 *
	 * @param {Array<string>} givenArray
	 * @return {Array<string>}
	 * @private
	 */
	static normalizeArray( givenArray ) {
		// Trim whitespace.
		return givenArray.map( ( value ) => value.trim() )
			// Remove empty values and duplicates.
			.filter( ( value, index, array ) => value !== '' && array.indexOf( value ) === index );
	}

	/**
	 * Get the storage value for a parameter from an array of values.
	 *
	 * @param {Array<string>} values
	 * @return {string}
	 */
	static getValueFromArray( values ) {
		return this.normalizeArray( values ).join( Wish.DELIMITER );
	}

	/**
	 * Get an array of values from a storage value for a parameter.
	 *
	 * @param {string} value
	 * @return {Array<string>}
	 */
	static getArrayFromValue( value ) {
		return this.normalizeArray( value.split( Wish.DELIMITER ) );
	}

	/**
	 * Props values here should be identical to storage, be it wikitext or MariaDB.
	 *
	 * @param {Object} props
	 */
	constructor( props ) {
		// Non-template (metadata) properties
		this.pageId = props.pageId || null;
		this.page = props.page || '';
		this.name = props.name || '';
		this.lang = props.lang || '';
		this.updated = props.updated || '';

		// Template parameters
		this.baselang = props.baselang || '';
		this.type = props.type || '';
		this.status = props.status || '';
		this.title = props.title || '';
		this.description = props.description || '';
		this.audience = props.audience || '';
		this.tasks = props.tasks || '';
		this.proposer = props.proposer || '';
		this.created = props.created || '';
		this.projects = props.projects || '';
		this.otherproject = props.otherproject || '';
		this.area = props.area || '';
	}
}

// Delimiter for array types
Wish.DELIMITER = ',';

Wish.TYPE_FEATURE = 'feature';
Wish.TYPE_BUG = 'bug';
Wish.TYPE_CHANGE = 'change';
Wish.TYPE_UNKNOWN = '';
Wish.STATUS_DRAFT = 'draft';
Wish.STATUS_SUBMITTED = 'submitted';
Wish.STATUS_OPEN = 'open';
Wish.STATUS_IN_PROGRESS = 'started';
Wish.STATUS_DELIVERED = 'delivered';
Wish.STATUS_BLOCKED = 'blocked';
Wish.STATUS_ARCHIVED = 'archived';

class WishlistTemplate {

	constructor( templateName ) {
		this.templateName = templateName.replace( /^Template:/, '' );
	}

	/**
	 * @param {Object} wish
	 * @return {string}
	 */
	getWikitext( wish ) {
		const paramNames = [
			'status',
			'type',
			'title',
			'description',
			'audience',
			'tasks',
			'proposer',
			'created',
			'projects',
			'otherproject',
			'area',
			'baselang'
		];
		let out = '{{' + this.templateName + '\n';
		for ( const key of paramNames ) {
			const value = wish[ key ];
			if ( value === '' ) {
				out += `| ${ key } =\n`;
			} else if ( value !== undefined ) {
				out += `| ${ key } = ${ value }\n`;
			}
		}
		out += '}}';
		return out;
	}

	/**
	 * Get the Wish object from the given wikitext, or null if the wish template
	 * was not found on the page. Any <translate> tags in the wikitext will be
	 * removed.
	 *
	 * @param {string} wikitext
	 * @param {string} pageTitle
	 * @param {?number} pageId
	 * @param {string} updated
	 * @return {?Wish}
	 */
	getWish( wikitext, pageTitle = '', pageId = null, updated = '' ) {
		const data = TemplateParser.getParams( wikitext, this.templateName );
		if ( data === null ) {
			return null;
		}
		data.page = pageTitle;
		if ( pageId !== null ) {
			data.pageId = pageId;
		}
		if ( pageTitle.startsWith( config.wishPagePrefix ) ) {
			const relPage = pageTitle.slice( config.wishPagePrefix.length );
			const m = relPage.match( /(.*)\/([a-z0-9-]{2,})$/ );
			if ( m ) {
				data.name = m[ 1 ];
				data.lang = m[ 2 ];
			} else {
				data.name = relPage;
				data.lang = '';
			}
		} else {
			data.name = pageTitle;
		}
		data.updated = updated;
		return new Wish( data );
	}

	/**
	 * Strip <translate> tags from a string
	 *
	 * @param {string} text
	 * @return {string}
	 */
	stripTranslate( text ) {
		text = text.replace( /<translate( nowrap)?>\n?/g, '' );
		text = text.replace( /\n?<\/translate>/g, '' );
		text = text.replace( /<tvar\s+name\s*=\s*('[^']*')|("[^"]*")|([^"'\s>]*)\s*>.*?<\/tvar\s*>/g, '' );
		text = text.replace( /(^=.*=) <!--T:[^_/\n<>]+-->$/mg, '$1' );
		text = text.replace( /<!--T:[^_/\n<>]+-->[\n ]?/g, '' );
		return text;
	}
}

/**
 * Utility functions for the gadget and bot.
 */
class Util {
	/**
	 * Get the wish template object.
	 *
	 * @return {WishlistTemplate}
	 */
	static getWishTemplate() {
		return new WishlistTemplate( config.wishTemplate );
	}
}

/**
 * WishlistManager
 *
 * This is used by staff and translation admins to manage wishes. Currently, it's
 * only used for translation preparation, but may later be expanded to include other
 * functionality
 *
 * This is a separate gadget from WishlistIntake because it has to be loaded on
 * Special:PageTranslation as well as action=edit of wish pages, while WishlistIntake
 * only loads on action=view for [[Category:Community_Wishlist/Intake]].
 */
class WishlistManager {

	/**
	 * Has the current wish page been set up for translation?
	 *
	 * @return {boolean}
	 */
	static isMarkedForTranslation() {
		return WebUtil.isWishPage() &&
			mw.config.get( 'wgCategories' ).includes( config.wishCategory + '/Translatable' );
	}

	/**
	 * Initialize the WishlistManager gadget.
	 */
	init() {
		if ( WebUtil.isWishPage() ) {
			this.addEditLink();
		}

		if ( WebUtil.isWishView() ) {
			// When viewing a wish…
			this.addPrepareForTranslationButton();
		} else if ( WebUtil.isManualWishEdit() && mw.util.getParamValue( 'translationprep' ) ) {
			// When editing a wish manually and the translationprep parameter is set…
			this.addTranslateTags();
		} else if ( mw.config.get( 'wgCanonicalSpecialPageName' ) === 'PageTranslation' ) {
			// When on Special:PageTranslation…
			this.handleSpecialPageTranslation();
		}
	}

	/**
	 * Add the "Prepare for translation" button next to the "Edit wish" button.
	 */
	addPrepareForTranslationButton() {
		if ( !WebUtil.isStaff() ) {
			return;
		}
		const button = document.createElement( 'button' );
		button.className = 'community-wishlist-translation-prep-btn cdx-button';
		if ( WishlistManager.isMarkedForTranslation() ) {
			button.className += ' cdx-button--action-default';
			button.textContent = 'Marked for translation';
			button.onclick = () => {
				window.location.replace(
					mw.util.getUrl(
						`Special:PageTranslation/${ WebUtil.getPageName() }`,
						{ do: 'mark' }
					)
				);
			};
		} else {
			button.className += ' cdx-button--action-progressive cdx-button--weight-primary';
			button.textContent = 'Prepare for translation';
			button.onclick = () => {
				window.location.replace( mw.util.getUrl( WebUtil.getPageName(), { action: 'edit', translationprep: '1' } ) );
			};
		}
		document.querySelector( '.community-wishlist-edit-wish-btn' ).after( button );
	}

	/**
	 * Add <translate> tags to the wish page for appropriate fields.
	 */
	addTranslateTags() {
		const $textarea = $( '#wpTextbox1' );
		const template = Util.getWishTemplate();
		// Get a Wish object from the wikitext.
		const wikitext = $textarea.textSelection( 'getContents' );
		if ( wikitext.includes( '<translate>' ) ) {
			mw.notify(
				'<translate> tags already found on this page. No changes made.',
				{ title: 'Community Wishlist Manager' }
			);
			return;
		}
		const wish = template.getWish( wikitext, WebUtil.getPageName() );
		// Convert back, this time inserting the <translate> tags where needed.
		wish.title = `<translate>${ wish.title }</translate>`;
		wish.description = `<translate>${ wish.description }</translate>`;
		wish.audience = `<translate>${ wish.audience }</translate>`;
		if ( wish.otherproject ) {
			wish.otherproject = `<translate>${ wish.otherproject }</translate>`;
		}
		// Change the status to "Open".
		wish.status = Wish.STATUS_OPEN;
		$textarea.textSelection( 'setContents', template.getWikitext( wish ) );
		this.notifyTranslateTags();
	}

	/**
	 * Notify the user that the tags have been added, with a link to the Staff instructions page.
	 */
	notifyTranslateTags() {
		mw.loader.using( 'mediawiki.jqueryMsg' ).then( () => {
			// Needs to be a mw.Message object to evaluate as wikitext.
			mw.messages.set( {
				'community-wishlist-pre-translation-notify': 'Translate tags added. Please review and add any ' +
					`<code><nowiki><tvar></nowiki></code> syntax as needed. [[${ config.wishHomePage + '/Staff instructions' }|Learn more]].`
			} );
			mw.notify(
				mw.message( 'community-wishlist-pre-translation-notify' ),
				{
					title: 'Community Wishlist Manager',
					autoHideSeconds: 'long'
				}
			);
		} );
	}

	/**
	 * Handle use of Special:PageTranslation.
	 *
	 * If marking a wish page for translation, the "Allow translation of page title"
	 * option is unchecked. This is because the title is already translatable via the
	 * wish page content.
	 */
	handleSpecialPageTranslation() {
		// For the more common query string URL.
		let wishPageTitle = mw.util.getParamValue( 'target' ).replaceAll( '_', ' ' );
		if ( mw.config.get( 'wgTitle' ).includes( '/' ) ) {
			// We're on the path-like URL, i.e. Special:PageTranslation/Page_title
			wishPageTitle = mw.config.get( 'wgTitle' ).replace( /^PageTranslation/, '' );
		}

		if ( !wishPageTitle.startsWith( config.wishPagePrefix ) ) {
			// Either we're at post-submission, or this is not for a wish page.
			return;
		}

		// Disable the "Allow translation of page title" option.
		$( '[name=translatetitle]' ).prop( 'checked', false );
	}

	/**
	 * Show an edit link on proposal subpages (including while editing).
	 */
	addEditLink() {
		// Fetch the "Edit with form" message.
		let msg = config.messages[ 'communitywishlist-edit-with-form' ];
		( new mw.Api() ).get( {
			action: 'query',
			prop: 'revisions',
			titles: `Translations:MediaWiki:Gadget-WishlistIntake/messages/communitywishlist-edit-with-form/${ mw.config.get( 'wgUserLanguage' ) }`,
			rvprop: 'content',
			rvslots: 'main',
			formatversion: 2,
			// Cache for 30 minutes.
			maxage: 1800,
			smaxage: 1800
		} ).then( ( res ) => {
			const pages = res.query.pages;
			if ( !pages[ 0 ].missing ) {
				msg = pages[ 0 ].revisions[ 0 ].slots.main.content;
			}
		} ).always( () => {
			mw.messages.set( { 'communitywishlist-edit-with-form': msg } );
			const editItem = document.querySelector( '#ca-edit' );
			const editWithFormItem = editItem.cloneNode( true );
			const editWithForm = editWithFormItem.querySelector( 'a' );
			editWithFormItem.id = 'ca-wishlist-intake-edit';
			editWithForm.href = mw.util.getUrl(
				WebUtil.isNewWish() ? config.wishIntakePage : mw.config.get( 'wgPageName' ),
				{ [ config.wishEditParam ]: 1 }
			);
			delete editWithForm.title;
			delete editWithForm.accesskey;
			// The <a> sometimes contains a <span> and sometime doesn't;
			// we can leave it out because it doesn't seem to change anything.
			editWithForm.textContent = mw.msg( 'communitywishlist-edit-with-form' );
			editItem.after( editWithFormItem );
			// Highlight the "Edit with form" tab when editing.
			const selectedNode = document.querySelector( '.mw-portlet-views .selected, .skin-monobook #p-cactions.portlet .selected' );
			if ( selectedNode && ( WebUtil.isWishEdit() || WebUtil.isNewWish() ) ) {
				selectedNode.classList.remove( 'selected' );
				editWithFormItem.classList.add( 'selected' );
			} else {
				editWithFormItem.classList.remove( 'selected' );
			}
		} );
	}
}

/**
 * Entry point for the WishlistManager gadget.
 */
if ( mw.config.get( 'wgIsProbablyEditable' ) ||
	mw.config.get( 'wgCanonicalSpecialPageName' ) === 'PageTranslation'
) {
	mw.loader.using( [ 'mediawiki.util', 'mediawiki.api' ], () => {
		const wishlistManager = new WishlistManager();
		wishlistManager.init();
	} );
}

} );
// </nowiki>