MediaWiki:Gadget-cws-manager.js
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>