MediaWiki:Gadget-cws-manager.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)
  • Internet Explorer / Edge: Hold Ctrl while clicking Refresh, or press Ctrl-F5
  • Opera: Press Ctrl-F5.
// <nowiki>
/**
 * @class
 * @property {jQuery} $content
 * @property {mw.Api} api
 * @property {Object} config
 * @property {OO.ui.ProcessDialog} dialog
 * @property {Object} pageCache
 * @property {boolean} viewingCategoryPage
 */
class CwsManager {
	static configPage = 'User:Community_Tech_bot/WishlistSurvey/config';

	/**
	 * @constructor
	 * @param {jQuery} $content
	 */
	constructor( $content ) {
		this.$content = $content;
		this.api = new mw.Api();
		this.config = null;
		this.pageCache = {};
		this.viewingCategoryPage = !!this.$content.find( '.community-wishlist-edit-proposal-btn' ).length;
	}

	/**
	 * Adds a "Manage" button to proposal headings.
	 */
	addButtons() {
		// A little styling for button/text; only CSS this gadget should need.
		mw.util.addCSS(
			'.cws-manager-btn { margin: 0 12px; }' +
			'.cws-manager-btn-approved .oo-ui-labelElement-label { color: #14866d }'
		);

		const $existingButtons = this.viewingCategoryPage ?
			this.$content.find( '.community-wishlist-edit-proposal-btn' ) :
			this.$content.find( '.community-wishlist-proposal-back-btn' );
		const proposalTitles = [];
		const parseProposalTitle = ( button ) => {
			return this.viewingCategoryPage ?
				decodeURIComponent(
					button.parentElement.href.match( /(Community_Wishlist_Survey_\d+\/.*?\/.*?)&/ )[ 1 ]
				) : mw.config.get( 'wgPageName' );
		};

		// First collect all the proposal titles.
		$existingButtons.each( ( _i, button ) => {
			proposalTitles.push( parseProposalTitle( button ) );
		} );

		// Build the page cache, then add the buttons where necessary.
		this.fetchPageInfo( proposalTitles ).then( () => {
			// Now loop through again and add the buttons as necessary.
			$existingButtons.each( ( _i, button ) => {
				// Page cache uses normalized page titles (no underscores).
				const proposalTitle = parseProposalTitle( button ).replaceAll( '_', ' ' );
				let newButton;

				if ( this.pageCache[ proposalTitle ] && this.pageCache[ proposalTitle ].approved ) {
					// Already approved; indicate as such with a link to the translation subpage.
					newButton = new OO.ui.ButtonWidget( {
						label: 'Approved',
						flags: [ 'success' ],
						icon: 'check',
						classes: [ 'cws-manager-btn cws-manager-btn-approved' ],
						href: mw.util.getUrl( `${proposalTitle}/Proposal` ),
						title: 'This proposal has been approved and cannot managed further.'
					} );
				} else {
					// Approve button
					newButton = new OO.ui.ButtonWidget( {
						label: 'Manage',
						flags: [ 'primary', 'progressive' ],
						classes: [ 'cws-manager-btn' ]
					} );
					// Add click listener.
					newButton.on( 'click', () => {
						this.fetchConfig().then( () => {
							this.showDialog(
								proposalTitle,
								this.pageCache[ proposalTitle ].watched
							);
						} );
					} );
				}

				$( button ).parents( '.plainlinks' )
					.append( newButton.$element );
			} );
		} );
	}

	/**
	 * Show a Dialog for the given proposal
	 *
	 * @param {string} proposalTitle
	 * @param {boolean} watched
	 */
	showDialog( proposalTitle, watched ) {
		const Dialog = require( './cws-manager-dialog.js' );
		const windowManager = OO.ui.getWindowManager();
		if ( !this.dialog ) {
			this.dialog = new Dialog( this, proposalTitle, watched );
			windowManager.addWindows( [ this.dialog ] );
		}
		windowManager.openWindow( this.dialog );
	}

	/**
	 * Fetches and stores the config.
	 *
	 * @return {jQuery.Deferred}
	 */
	fetchConfig() {
		const dfd = $.Deferred();

		if ( this.config ) {
			// Already loaded.
			return dfd.resolve();
		}

		this.api.get( {
			action: 'query',
			prop: 'revisions',
			titles: CwsManager.configPage,
			rvprop: 'content',
			rvslots: 'main',
			format: 'json',
			formatversion: 2
		} ).then( ( resp ) => {
			this.config = JSON.parse( resp.query.pages[ 0 ].revisions[ 0 ].slots.main.content );
			dfd.resolve();
		} );

		return dfd;
	}

	/**
	 * Get basic info about the proposal page and existence of its translation subpage.
	 *
	 * @param {string|Array<string>} proposalTitles
	 * @return {jQuery.Deferred}
	 */
	fetchPageInfo( proposalTitles ) {
		const dfd = $.Deferred();

		if ( !Array.isArray( proposalTitles ) ) {
			proposalTitles = [ proposalTitles ];
		}

		const promises = [];

		// First add /Proposal for each proposal title.
		proposalTitles.forEach( ( proposalTitle ) => {
			proposalTitles.push( `${proposalTitle}/Proposal` );
		} );

		// Break up proposal API requests into 50-title chunks.
		[ ...Array( Math.ceil( proposalTitles.length / 50 ) ) ]
			.map( () => proposalTitles.splice( 0, 50 ) )
			.forEach( ( chunk ) => {
				promises.push( this.#fetchPageInfo( chunk ) );
			} );

		Promise.all( promises ).then( ( resp ) => {
			if ( !resp || !resp[ 0 ] || !resp[ 0 ].query ) {
				return dfd.reject();
			}

			resp[ 0 ].query.pages.forEach( ( page ) => {
				const normalizedTitle = page.title.replace( /\/Proposal$/, '' );
				this.pageCache[ normalizedTitle ] = {};

				if ( page.title.endsWith( '/Proposal' ) && !page.missing ) {
					this.pageCache[ normalizedTitle ].approved = true;
				} else if ( !page.title.endsWith( '/Proposal' ) && page.watched ) {
					this.pageCache[ normalizedTitle ].watched = true;
				}
			} );

			dfd.resolve();
		} );

		return dfd;
	}

	/**
	 * @param {Array<string>} proposalTitles
	 * @return {jQuery.Promise}
	 * @private
	 */
	#fetchPageInfo( proposalTitles ) {
		return this.api.get( {
			action: 'query',
			prop: 'info',
			titles: proposalTitles.join( '|' ),
			inprop: 'watched',
			formatversion: 2
		} );
	}

	/**
	 * Called when the Dialog form is submitted.
	 *
	 * @param {string} action
	 * @param {Object} data
	 * @return {jQuery.Deferred}
	 */
	submit( action, data ) {
		const dfd = $.Deferred();

		this[ `${action}Proposal` ]( data )
			.fail( ( code, resp ) => {
				// Catch the 'articleexists' error, which means we need sysop intervention.
				if ( action === 'move' && code === 'articleexists' && resp.error ) {
					return dfd.reject( new OO.ui.Error(
						`The target page "${data.newTitle}" already exists! If this is the correct page you intended to move to, ` +
						'it probably means you need to request an admin to delete it first to make way for the move.',
						{ recoverable: false }
					) );
				}

				// Transform any other API errors into an OOUI Error.
				if ( resp && resp.error && resp.error.info ) {
					return dfd.reject( new OO.ui.Error( resp.error.info ) );
				}

				return dfd.reject( code, resp );
			} )
			.then( () => {
				return dfd.resolve();
			} );

		return dfd;
	}

	/**
	 * Move a proposal page
	 *
	 * @param {Object} data
	 * @return {jQuery.Promise}
	 */
	moveProposal( data ) {
		data.newTitle = `${this.config.survey_root}/${data.newCategory}/${data.newName}`;

		if ( data.proposalTitle !== data.newTitle ) {
			// The proposal name changed, so we need to first update the proposal header template.
			return this.api.edit( data.proposalTitle, ( revision ) => {
				const newContent = revision.content.replace(
					/^{{\s*Community Wishlist Survey\/Proposal header\s*\|\s*1=\s*(.*?)\s*}}/,
					`{{Community Wishlist Survey/Proposal header|1=${data.newName}}}`
				);

				// In this case we want to also purge the category page, even though this may
				// not finish before the page reloads (a purge may likely be needed, anyway).
				if ( !data.proposalTitle.includes( data.newCategory ) ) {
					this.api.post( {
						action: 'purge',
						titles: `${this.config.survey_root}/${data.newCategory}`
					} );
				}

				return {
					text: newContent,
					summary: `Correcting proposal header template for [[${data.newTitle}|${data.newName}]]`
				};
			} ).then( this.#moveProposal.bind( this, data ) );
		}

		return this.#moveProposal( data );
	}

	/**
	 * @param {Object} data
	 * @return {jQuery.Promise}
	 * @private
	 */
	#moveProposal( data ) {
		return this.api.postWithToken( 'csrf', {
			action: 'move',
			from: data.proposalTitle,
			to: data.newTitle,
			reason: data.reason || '',
			watchlist: data.watchProposal ? 'watch' : 'preferences'
		} );
	}

	/**
	 * Move a proposal to the Archive.
	 *
	 * @param {Object} data
	 * @return {jQuery.Promise}
	 */
	archiveProposal( data ) {
		if ( data.reason.includes( '$1' ) && data.param ) {
			data.reason = data.reason.replace( '$1', data.param );
		}
		const declineComment = `{{cross}} '''${data.reason}'''`;

		// First add the decline comment and supplementary comment to the page.
		return this.api.edit( data.proposalTitle, ( revision ) => {
			// Add the decline rationale to the top.
			revision.content = revision.content.replace(
				/DO NOT EDIT ABOVE THIS LINE -->\n+/,
				`DO NOT EDIT ABOVE THIS LINE -->\n${declineComment}\n\n`
			);
			// Remove extraneous new lines from the end.
			revision.content = revision.content.replace( /\n+$/, '\n' );
			// Add the comment, if provided, at the bottom.
			if ( data.comment ) {
				data.comment = data.comment.trim().replace( '~~~~', '' );
				revision.content += `\n* ${data.comment} ~~~~\n`;
			}
			// Return mutated content to edit API plugin.
			return {
				text: revision.content,
				summary: data.reason
			};
		} ).then( () => {
			// Then move the proposal.
			return this.moveProposal( Object.assign( data, {
				newName: data.newName,
				newCategory: 'Archive'
			} ) );
		} );
	}

	/**
	 * Move a proposal out of the archives, removing the standardized archive
	 * rationale that may have been added with this.archiveProposal().
	 *
	 * @param {Object} data
	 * @return {jQuery.Promise}
	 */
	unarchiveProposal( data ) {
		return this.api.edit( data.proposalTitle, ( revision ) => {
			const text = revision.content.replace(
				/DO NOT EDIT ABOVE THIS LINE -->\n{{cross}}.*?\n/,
				'DO NOT EDIT ABOVE THIS LINE -->\n'
			);
			return {
				text,
				summary: 'Unarchiving proposal'
			};
		} ).then( () => {
			return this.moveProposal( data );
		} );
	}

	/**
	 * Parse the proposal to use the Proposal template, add translate tags,
	 * and move it to the /Proposal subpage.
	 *
	 * @param {Object} data
	 * @return {jQuery.Deferred}
	 */
	approveProposal( data ) {
		const dfd = $.Deferred();

		this.api.edit( data.proposalTitle, ( revision ) => {
			const [ templateParams, errorFields ] = this.#parseProposal( revision.content );

			if ( errorFields.length ) {
				const fieldsList = errorFields.map( ( error ) => {
					return `'${error}'`;
				} ).join( ', ' );
				return dfd.reject( new OO.ui.Error(
					`The following fields could not be parsed and require manual review: ${fieldsList}. ` +
					'Please make sure the wikitext is in the expected format, that no fields are missing, ' +
					'and that they are in the correct order.'
				) );
			}

			const subpageContent = ( '<noinclude><languages/></noinclude>' +
				'{{:{{TNTN|Community Wishlist Survey/Proposal|uselang={{int:lang}}}}' +
				`\n| title = <translate>${templateParams.title}</translate>` +
				`\n| problem = <translate>${templateParams.problem}</translate>` +
				`\n| solution = <translate>${templateParams.solution}</translate>` +
				`\n| beneficiaries = <translate>${templateParams.beneficiaries}</translate>` +
				`\n| comments = <translate>${templateParams.comments}</translate>` +
				`\n| phab = ${templateParams.phab}` +
				`\n| proposer = ${templateParams.proposer}` +
				'\n| titleonly = {{{titleonly|}}}' +
				'\n}}' )
				// Remove any empty translate tags.
				.replaceAll( '<translate></translate>', '' );
			const subpageTitle = mw.Title.newFromText( data.proposalTitle + '/Proposal' );

			// Create the /Proposal subpage.
			return this.api.create( subpageTitle,
				{ summary: 'Creating translation subpage' },
				subpageContent
			).then( () => {
				// Now that the /Proposal subpage is created, open it in a new tab.
				window.open( subpageTitle.getUrl( { action: 'edit' } ), '_blank' );

				// Remove content from original page.
				const text = revision.content.replace(
					/<!-- DO NOT EDIT ABOVE THIS LINE.*?-->.*<!-- DO NOT EDIT BELOW THIS LINE.*?-->/s,
					'<!-- DO NOT EDIT ABOVE THIS LINE! PROPOSAL CONTENT IS NOW ON THE /Proposal ' +
						'SUBPAGE (FOR TRANSLATION) AND SHOULD NOT BE MODIFIED FURTHER -->'
				);

				// Return the new content for the original page to the edit API plugin.
				return {
					text,
					summary: `Moving proposal content to [[${data.proposalTitle}/Proposal]] for translation`
				};
			} );
		} ).then( () => {
			return dfd.resolve();
		} );

		return dfd;
	}

	/**
	 * Parse each field and the title from the proposal content.
	 *
	 * @param {string} content
	 * @return {Array} [ template parameters, erroneous fields ]
	 * @private
	 */
	#parseProposal( content ) {
		const fields = {
			problem: 'Problem',
			solution: 'Proposed solution',
			beneficiaries: 'Who would benefit',
			comments: 'More comments',
			phab: 'Phabricator tickets',
			proposer: 'Proposer'
		};
		const templateParams = {};
		const errorFields = [];
		const fieldKeys = Object.keys( fields );

		fieldKeys.forEach( ( key, i ) => {
			let regex = new RegExp(
				`'''\\s*${fields[ key ]}\\s*'''\\s*:\\s*(.*?)\\s*\\n\\*\\s*'''\\s*${fields[ fieldKeys[ i + 1 ] ]}`,
				's'
			);
			if ( key === 'proposer' ) {
				regex = /'''\s*Proposer\s*'''\s*:\s*(.*)\s*\n/;
			}

			const matches = content.match( regex );
			if ( !matches ) {
				errorFields.push( fields[ key ] );
				return;
			}

			let value = matches[ 1 ];
			if ( key === 'phab' ) {
				// Remove comments.
				value = value.replace( /<!--.*?-->/, '' );
			}

			templateParams[ key ] = value;
		} );

		// Parse out the title
		templateParams.title = content.match( /Proposal header\|1=(.*?)}}/ )[ 1 ];

		return [ templateParams, errorFields ];
	}

	/**
	 * Hacks into Special:PageTranslation by doing the following:
	 * - If marking a proposal page for translation, the "Allow translation of page title"
	 *   option is unchecked.
	 * - After submission, the proposal page is added to the aggregate group
	 *
	 * This method expects the aggregate group to have a name of the form:
	 *    agg-Community_Wishlist_Survey_2023_Proposals
	 * replacing '2023' with the appropriate year.
	 */
	addTranslationHack() {
		const sessionKey = 'cws-translation-marked',
			matches = mw.config.get( 'wgPageName' )
				.match( /Community_Wishlist_Survey_(\d+)\/.*\/Proposal$/ ) || [],
			encodedMatches = location.href.match( /Special:PageTranslation.*?target=(.*?)&/ );

		if ( encodedMatches ) {
			// We're at the index.php variant of the page.
			matches[ 0 ] = new URL( location.href ).searchParams.get( 'target' );
			matches[ 1 ] = matches[ 0 ].match( /^Community Wishlist Survey (\d+)/ )[ 1 ];
		}

		// If there's a match, we're currently at Special:PageTranslation for a proposal.
		if ( matches.length ) {
			// Don't need to translate the page display title.
			// eslint-disable-next-line no-jquery/no-global-selector
			$( '[name=translatetitle]' ).prop( 'checked', false );

			// Now set a flag in sessionStorage so that we can do more things post-submission.
			mw.storage.session.setObject( sessionKey, {
				page: matches[ 0 ].replaceAll( '_', ' ' ),
				surveyYear: matches[ 1 ]
			} );

			// Return early so the below code doesn't run :)
			return;
		}

		// From here we are at Special:PageTranslation, most likely post-submission. If the group
		// name is in session storage, we know we need to add it to the aggregate group.
		const groupInfo = mw.storage.session.getObject( sessionKey );
		if ( groupInfo ) {
			mw.storage.session.remove( sessionKey );
			const aggGroupName = `agg-Community_Wishlist_Survey_${groupInfo.surveyYear}_Proposals`,
				aggGroupNameDisplay = aggGroupName.replaceAll( '_', ' ' );
			this.api.postWithToken( 'csrf', {
				action: 'aggregategroups',
				aggregategroup: aggGroupName,
				do: 'associate',
				group: `page-${groupInfo.page}`
			} ).done( () => {
				mw.notify(
					`"${groupInfo.page}" successfully added to the aggregate group "${aggGroupNameDisplay}"`,
					{ title: 'CWS Manager', type: 'success', autoHideSeconds: 'long' }
				);
			} ).fail( ( _, errObj ) => {
				mw.notify(
					`Failed to add "${groupInfo.page}" to the aggregate group "${aggGroupNameDisplay}". ` +
					'Check the console log for more information.',
					{ title: 'CWS Manager', autoHide: false, type: 'error' }
				);
				mw.log.warn( `[CWS Manager] ${errObj.error.info}` );
			} );
		}
	}
}

/**
 * Entry point, called after the 'wikipage.content' hook is fired.
 *
 * @param {jQuery} $content
 */
function init( $content ) {
	Promise.all( [
		// Resource loader modules
		mw.loader.using( [ 'oojs-ui', 'mediawiki.util', 'mediawiki.api', 'mediawiki.Title' ] ),
		// Page ready
		$.ready
	] ).then( () => {
		// Only run on CWS pages and for WMF staff.
		if ( /^Community_Wishlist_Survey_(\d+)\//.test( mw.config.get( 'wgPageName' ) ) &&
			/\s\(WMF\)$|-WMF$/.test( mw.config.get( 'wgUserName' ) ) &&
			!!$content.find( '.community-wishlist-proposal-header' )
		) {
			const cwsArchiver = new CwsManager( $content );
			cwsArchiver.addButtons();
		}

		// For translation admins (okay if this is used by non-staff).
		if ( mw.config.get( 'wgCanonicalSpecialPageName' ) === 'PageTranslation' ) {
			const cwsArchiver = new CwsManager( $content );
			cwsArchiver.addTranslationHack();
		}

		// Remove listener so that CwsManager is only instantiated once.
		mw.hook( 'wikipage.content' ).remove( init );
	} );
}

mw.hook( 'wikipage.content' ).add( init );
// </nowiki>