MediaWiki:Gadget-WishlistManager.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)
- Edge: Hold Ctrl while clicking Refresh, or press Ctrl-F5.
// <nowiki>
mw.loader.using( ["mediawiki.api","mediawiki.util"] ).then( ( require ) => {
/**
* WishlistManager: A gadget helping with management tasks for the Community Wishlist.
* 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 voteRecordedStorageName = "wishlist-intake-vote-added";
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 focusAreaPagePrefix = "Community Wishlist/Focus areas/";
var focusAreaVoteCountSuffix = "/Vote count";
var focusAreaTemplate = "Community Wishlist/Focus area";
var focusAreasTemplate = "Community Wishlist/Focus areas";
var focusAreaTemplateAll = "Community Wishlist/Focus areas/All";
var focusAreaTemplateTop = "Community Wishlist/Focus areas/Top";
var supportTemplate = "Community Wishlist/Support";
var messagesPage = "MediaWiki:Gadget-WishlistIntake/messages";
var interfaceMessageGroupId = "agg-Community_Wishlist_interface";
var wishesMessageGroupId = "agg-Community_Wishlist_wishes";
var maxRecentWishes = 5;
var gadgets = {
WishlistIntake: {
description: "A gadget for the intake and editing of Community Wishlist proposals.",
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: {
description: "A gadget helping with management tasks for the Community Wishlist.",
ResourceLoader: true,
"default": true,
hidden: true,
"package": true,
files: [
"WishlistManager.js"
],
rights: [
"editmyusercss"
],
dependencies: [
"mediawiki.api",
"mediawiki.util"
]
},
WishlistTranslation: {
description: "A gadget for machine translation of pages within the Community Wishlist.",
ResourceLoader: true,
"package": true,
files: [
"WishlistTranslation.js"
],
categories: [
"Community Wishlist/Intake"
],
dependencies: [
"vue",
"@wikimedia/codex",
"mediawiki.storage"
],
messages: [
"communityrequests-translation-translatable",
"communityrequests-translation-translated",
"communityrequests-translation-show-now",
"communityrequests-translation-progress",
"communityrequests-translation-errors"
]
}
};
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",
"communitywishlist-support-focus-area": "Support focus area",
"communitywishlist-support-focus-area-dialog-title": "Support \"$1\"",
"communitywishlist-optional-comment": "Optional comment",
"communitywishlist-supported": "Already supported",
"communitywishlist-support-focus-area-confirmed": "You have voted in support of this focus area.",
"communitywishlist-unsupport-focus-area": "Remove your support vote"
};
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,
voteRecordedStorageName: voteRecordedStorageName,
wishIndexTemplate: wishIndexTemplate,
wishIndexTemplateAll: wishIndexTemplateAll,
wishIndexTemplateRecent: wishIndexTemplateRecent,
wishIndexTemplateArchive: wishIndexTemplateArchive,
wishCategory: wishCategory,
wishPagePrefix: wishPagePrefix,
wishTemplate: wishTemplate,
focusAreaPagePrefix: focusAreaPagePrefix,
focusAreaVoteCountSuffix: focusAreaVoteCountSuffix,
focusAreaTemplate: focusAreaTemplate,
focusAreasTemplate: focusAreasTemplate,
focusAreaTemplateAll: focusAreaTemplateAll,
focusAreaTemplateTop: focusAreaTemplateTop,
supportTemplate: supportTemplate,
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 this.isWishPage() && !this.isWishEdit() && mw.config.get( 'wgAction' ) === 'view';
}
/**
* 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' )
);
}
/**
* Are we currently on a focus area page?
*
* @return {boolean}
*/
static isFocusAreaPage() {
return this.getPageName().startsWith( config.focusAreaPagePrefix );
}
/**
* 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} 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.
const slugPortion = this.getPageName().slice( config.wishPagePrefix.length );
// Strip off language subpage. Slashes are disallowed in wish slugs.
return slugPortion.split( '/' )[ 0 ];
}
return null;
}
/**
* Get the slug for the focus area derived from the page title.
*
* @return {string|null} null if not a focus area page
*/
static getFocusAreaSlug() {
if ( this.isFocusAreaPage() ) {
const slugPortion = this.getPageName().slice( config.focusAreaPagePrefix.length );
// Strip off language subpage. Slashes are disallowed in focus area slugs.
return slugPortion.split( '/' )[ 0 ];
}
return null;
}
/**
* Get the full page title of the wish from the slug.
*
* @param {string} slug
* @return {string}
*/
static getWishPageTitleFromSlug( 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;
}
/**
* Is the user viewing in mobile format?
*
* @return {boolean}
*/
static isMobile() {
return !!mw.config.get( 'wgMFMode' );
}
/**
* Fetch messages from the wiki and set them in mw.messages.
*
* @param {mw.Api} api
* @return {jQuery.Promise}
*/
static setOnWikiMessages( api ) {
const titles = [ config.messagesPage + '/en' ],
langPageLocal = config.messagesPage + '/' + mw.config.get( 'wgUserLanguage' );
if ( mw.config.get( 'wgUserLanguage' ) !== 'en' ) {
titles.push( langPageLocal );
}
return api.get( {
action: 'query',
prop: 'revisions',
titles,
rvprop: 'content',
rvslots: 'main',
format: 'json',
formatversion: 2,
// Cache for 30 minutes.
maxage: 1800,
smaxage: 1800
} ).then( ( resp ) => {
let messagesLocal = {},
messagesEn = {};
/**
* The content model of the messages page is wikitext so that it can be used with
* Extension:Translate. Consequently, it's possible to break things. This just does
* a try/catch and returns the default English messages if it fails.
*
* @param {string} title
* @param {string} content
* @return {Object}
*/
const parseJSON = ( title, content ) => {
try {
return JSON.parse( content );
} catch ( e ) {
WebUtil.logError( `Failed to parse JSON for ${ title }.`, e );
return { messages: config.messages };
}
};
resp.query.pages.forEach( ( page ) => {
if ( !page.revisions ) {
// Missing
return;
}
const pageObj = page.revisions[ 0 ].slots.main;
const parsedContent = parseJSON( config.messagesPage, pageObj.content );
if ( page.title === langPageLocal && mw.config.get( 'wgUserLanguage' ) !== 'en' ) {
messagesLocal = parsedContent.messages;
} else {
messagesEn = parsedContent.messages;
}
} );
mw.messages.set( Object.assign( messagesEn, messagesLocal ) );
} );
}
}
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();
this.updateDiscussionLink();
}
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.
* This is disabled on mobile, since we have to check wgCategories to determine
* if the wish is marked for translation, and that's not available in MobileFrontend.
*/
addPrepareForTranslationButton() {
if ( !WebUtil.isStaff() || WebUtil.isMobile() ) {
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() {
if ( WebUtil.isMobile() ) {
this.addEditLinkMobile();
return;
}
// 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';
const pageTitle = WebUtil.isNewWish() ?
config.wishIntakePage :
config.wishPagePrefix + WebUtil.getWishSlug();
editWithForm.href = mw.util.getUrl( pageTitle, { [ 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' );
}
} );
}
/**
* The normal 'Edit' tab is not on mobile. We'll change the familiar pencil icon
* to point to the intake form, which should be acceptable as you can still edit
* the full wikitext through page menu > "Edit full page".
*
* We have to use setTimeout() to get around race conditions with MobileFrontend,
* as there are no JS hook that we can rely on. The 500ms is arbitrary but seems
* to reliably work. Even if it doesn't, the "Edit wish" button is still available.
*/
addEditLinkMobile() {
setTimeout( () => {
const editTab = document.querySelector( '#ca-edit' );
editTab.href = mw.util.getUrl(
WebUtil.getPageName(),
{ [ config.wishEditParam ]: 1 }
);
$( editTab ).off( 'click.mfeditlink' );
}, 500 );
}
/**
* Update "Discussion" link to point to base talk page and not /es, /fr etc.
*/
updateDiscussionLink() {
const talkLink = WebUtil.isMobile() ?
document.querySelector( 'a[rel="discussion"]' ) :
document.querySelector( '#ca-talk a' );
talkLink.href = mw.util.getUrl(
`Talk:${ config.wishPagePrefix }${ WebUtil.getWishSlug() }`
);
}
}
/**
* 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>