User:Chaotic Enby/Unblock wizard.js
Appearance
Code that you insert on this page could contain malicious content capable of compromising your account. If you import a script from another page with "importScript", "mw.loader.load", "iusc", or "lusc", take note that this causes you to dynamically load a remote script, which could be changed by others. Editors are responsible for all edits and actions they perform, including by scripts. User scripts are not centrally supported and may malfunction or become inoperable due to software changes. A guide to help you find broken scripts is available. If you are unsure whether code you are adding to this page is safe, you can ask at the appropriate village pump. This code will be executed when previewing this page. |
![]() | This user script seems to have a documentation page at User:Chaotic Enby/Unblock wizard. |
/**
* User:Chaotic Enby/Unblock wizard.js
*
* JavaScript used for submitting unblock requests.
* Used on [[User:Chaotic Enby/Unblock wizard]].
* Loaded via [[mw:Snippets/Load JS and CSS by URL]].
*
* Edits can be proposed via pinging me somewhere.
*
* Author: [[User:Chaotic Enby]] (derived from a script by [[User:SD0001]])
* Licence: MIT
*/
/* jshint maxerr: 999 */
/* globals mw, $, OO */
/* <nowiki> */
(function () {
$.when(
$.ready,
mw.loader.using([
'mediawiki.util', 'mediawiki.api', 'mediawiki.Title',
'mediawiki.widgets', 'oojs-ui-core', 'oojs-ui-widgets'
])
).then(function () {
if (mw.config.get('wgPageName') !== 'User:Chaotic_Enby/Unblock_wizard/Promo' ||
mw.config.get('wgAction') !== 'view') {
return;
}
init();
});
var afc = {}, ui = {};
window.afc = afc;
afc.ui = ui;
var config = {
allowedNamespaces: [2, 118, 5], // User, Draft, WT
debounceDelay: 500,
redirectionDelay: 1000,
defaultAfcTopic: 'other'
};
// TODO: move to a separate JSON subpage, would be feasible once [[phab:T198758]] is resolved
var messages = {
"document-title": "Wikipedia Unblock Wizard",
"page-title": "Wikipedia Unblock Wizard",
"fieldset-label": "I was blocked for promotional activity",
"explain-promo-label": "Can you explain, in your own words, why your edits were or were not promotional?",
"explain-promo-placeholder": "",
"explain-promo-helptip": "",
"coi-label": "What is your relationship with the subjects you have been editing about?",
"future-promo-label": "If unblocked, do you plan to make edits outside of that topic?",
"other-label": "Is there anything else that may be helpful to your unblock request?",
"submit-label": "Submit",
"footer-text": "<small>If you are not sure about what to enter in a field, you can skip it. If you need help, you can ask at the <b>[[WP:AFCHD|AfC help desk]]</b> or get live help via <b>[[WP:IRCHELP|IRC]]</b> or <b>[[WP:DISCORD|Discord]]</b>.<br>Facing some issues in using this form? <b>[/w/index.php?title=Wikipedia_talk:WikiProject_Articles_for_creation/Submission_wizard&action=edit§ion=new&preloadtitle=Issue%20with%20submission%20form&editintro=Wikipedia_talk:WikiProject_Articles_for_creation/Submission_wizard/editintro Report it]</b>.</small>",
"submitting-as": "Submitting as User:$1",
"validation-notitle": "Please enter the draft page name",
"validation-invalidtitle": "Please check draft title. This title is invalid.",
"validation-missingtitle": "Please check draft title. No such draft exists.",
"validation-wrongns": "Please check draft title – it should begin with \"Draft:\" or \"User:\"",
"warning-norefs": "This draft doesn't appear to contain any references. Please add references, without which it is likely to be declined. See [[Help:Introduction to referencing with Wiki Markup/2|help on adding references]].",
"status-processing": "Processing ...",
"status-saving": "Saving draft page ...",
"editsummary-main": "Submitting using [[WP:AFCSW|AfC-submit-wizard]]",
"status-redirecting": "Submission succeeded. Redirecting you to the draft page ...",
"captcha-label": "Please enter the letters appearing in the box below",
"captcha-placeholder": "Enter the letters here",
"captcha-helptip": "CAPTCHA security check. Click \"Submit\" again when done.",
"error-saving-main": "An error occurred ($1). Please try again or refer to the help desk.",
"status-saving-talk": "Saving draft talk page ...",
"editsummary-talk": "Adding WikiProject tags using [[WP:AFCSW|AfC-submit-wizard]]",
"status-talk-success": "Successfully added WikiProject tags to talk page",
"error-saving-talk": "An error occurred in editing the talk page ($1).",
"error-main": "An error occurred ($1). Please try again or refer to the help desk."
};
function init() {
for (var key in messages) {
mw.messages.set('afcsw-' + key, messages[key]);
}
document.title = msg('document-title');
$('#firstHeading').text(msg('page-title'));
mw.util.addCSS(
// CSS adjustments for vector-2022: hide prominent page controls which are
// irrelevant and confusing while using the wizard
'.vector-page-toolbar { display: none } ' +
'.vector-page-titlebar #p-lang-btn { display: none } ' +
// Hide categories as well, prevents accidental HotCat usage
'#catlinks { display: none } '
);
var apiOptions = {
parameters: {
format: 'json',
formatversion: '2'
},
ajax: {
headers: {
'Api-User-Agent': 'w:en:User:Chaotic Enby/Unblock wizard.js'
}
}
};
// Two different API objects so that aborts on the lookupApi don't stop the final
// evaluate process
afc.api = new mw.Api(apiOptions);
afc.lookupApi = new mw.Api(apiOptions);
constructUI();
}
function constructUI() {
const questionLabels = ["explain-promo", "coi", "future-promo", "other"];
var itemsLayout = [];
var itemsInput = [];
for(var label of questionLabels){
itemsInput.push(new OO.ui.MultilineTextInputWidget({
placeholder: msg('explain-promo-placeholder'),
multiline: true,
autosize: true,
}));
itemsLayout.push(new OO.ui.FieldLayout(itemsInput[itemsInput.length - 1], {
label: msg('explain-promo-label'),
align: 'top',
help: msg('explain-promo-helptip'),
helpInline: true
}));
}
itemsLayout.push(ui.submitLayout = new OO.ui.FieldLayout(ui.submitButton = new OO.ui.ButtonWidget({
label: msg('submit-label'),
flags: [ 'progressive', 'primary' ],
})));
ui.fieldset = new OO.ui.FieldsetLayout({
classes: [ 'container' ],
items: itemsLayout
});
ui.footerLayout = new OO.ui.FieldLayout(new OO.ui.LabelWidget({
label: $('<div>')
.append(linkify(msg('footer-text')))
}), {
align: 'top'
});
var asUser = mw.util.getParamValue('username');
if (asUser && asUser !== mw.config.get('wgUserName')) {
ui.fieldset.addItems([
new OO.ui.FieldLayout(new OO.ui.MessageWidget({
type: 'notice',
inline: true,
label: msg('submitting-as', asUser)
}))
], /* position */ 5); // just before submit button
}
// Attach
$('#afc-submit-wizard-container').empty().append(ui.fieldset.$element, ui.footerLayout.$element);
mw.track('counter.gadget_afcsw.opened');
ui.submitButton.on('click', handleSubmit);
ui.question1Input.on('change', mw.util.debounce(config.debounceDelay, onDraftInputChange));
if (mw.util.getParamValue('page')) {
onDraftInputChange();
}
// The default font size in monobook and modern are too small at 10px
mw.util.addCSS('.skin-modern .projectTagOverlay, .skin-monobook .projectTagOverlay { font-size: 130%; }');
afc.beforeUnload = function (e) {
e.preventDefault();
e.returnValue = '';
return '';
};
$(window).on('beforeunload', afc.beforeUnload);
}
function onDraftInputChange() {
afc.lookupApi.abort(); // abort older API requests
var drafttitle = ui.titleInput.getValue().trim();
if (!drafttitle) { // empty
return;
}
debug('draft input changed: "' + ui.titleInput.getValue() + '"');
// re-initialize
ui.titleLayout.setErrors([]);
ui.titleLayout.setWarnings([]);
afc.oresTopics = null;
afc.talktext = null;
afc.pagetext = null;
ui.clearTalkTags();
afc.lookupApi.get({
"action": "query",
"prop": "revisions|description|info",
"titles": drafttitle,
"rvprop": "content",
"rvslots": "main"
}).then(setPrefillsFromPageData);
var titleObj = mw.Title.newFromText(drafttitle);
if (!titleObj || titleObj.isTalkPage()) {
return;
}
var talkpagename = titleObj.getTalkPage().toText();
afc.lookupApi.get({
"action": "query",
"prop": "revisions",
"titles": talkpagename,
"rvprop": "content",
"rvslots": "main",
}).then(setPrefillsFromTalkPageData);
}
function setPrefillsFromPageData(json) {
debug('page fetch query', json);
var page = json.query.pages[0];
var preNormalizedTitle = json.query.normalized && json.query.normalized[0] &&
json.query.normalized[0].from;
debug('page.title: "' + page.title + '"');
if (ui.titleInput.getValue() !== (preNormalizedTitle || page.title)) {
return; // user must have changed the title already
}
var errors = errorsFromPageData(page);
if (errors.length) {
ui.titleLayout.setErrors(errors);
return;
}
ui.titleLayout.setWarnings(warningsFromPageData(page));
afc.pagetext = page.revisions[0].slots.main.content;
}
function setPrefillsFromTalkPageData (json) {
var talkpage = json.query.pages[0];
if (!talkpage || talkpage.missing) {
return;
}
afc.talktext = talkpage.revisions[0].slots.main.content;
debug(afc.talktext);
var existingWikiProjects = extractWikiProjectTagsFromText(afc.talktext);
var existingTags = existingWikiProjects.map(function (e) {
return e.name;
});
debug(existingTags);
ui.addTalkTags(existingTags);
}
/**
* @param {Object} page - from query API response
* @returns {string[]}
*/
function errorsFromPageData(page) {
if (!page || page.invalid) {
return [msg('validation-invalidtitle')];
}
if (page.missing) {
return [msg('validation-missingtitle')];
}
if (config.allowedNamespaces.indexOf(page.ns) === -1) {
return [msg('validation-wrongns')];
}
return [];
}
/**
* @param {Object} page - from query API response
* @returns {string[]}
*/
function warningsFromPageData(page) {
var pagetext = page.revisions[0].slots.main.content;
var warnings = [];
// Show no refs warning
if (!/<ref/i.test(pagetext) && !/\{\{([Ss]fn|[Hh]arv)/.test(pagetext)) {
warnings.push('warning-norefs');
}
// TODO: Show warning for use of deprecated/unreliable sources
// TODO: Show tip for avoiding peacock words or promotional language?
return warnings.map(function (warning) {
return new OO.ui.HtmlSnippet(linkify(msg(warning)));
});
}
/**
* @param {number} revid
* @returns {jQuery.Promise<string[]>}
*/
function getOresTopics(revid) {
return $.get('https://ores.wikimedia.org/v3/scores/enwiki/?models=drafttopic&revids=' + revid).then(function (json) {
// null is returned if at any point something in the API output is unexpected
// ES2020 has optional chaining, but of course on MediaWiki we're still stuck with ES5
return json &&
json.enwiki &&
json.enwiki.scores &&
json.enwiki.scores[revid] &&
json.enwiki.scores[revid].drafttopic &&
json.enwiki.scores[revid].drafttopic.score &&
(json.enwiki.scores[revid].drafttopic.score.prediction instanceof Array) &&
json.enwiki.scores[revid].drafttopic.score.prediction.map(function (topic, idx, topics) {
// Remove Asia.Asia* if Asia.South-Asia is present (example)
if (topic.slice(-1) === '*') {
var metatopic = topic.split('.').slice(0, -1).join('.');
for (var i = 0; i < topics.length; i++) {
if (topics[i] !== topic && topics[i].startsWith(metatopic)) {
return;
}
}
return metatopic.split('.').pop();
}
return topic.split('.').pop();
})
.filter(function (e) {
return e; // filter out undefined from above
})
.map(function (topic) {
// convert topic string to normalised form
return topic
.replace(/[A-Z]/g, function (match) {
return match[0].toLowerCase();
})
.replace(/ /g, '-')
.replace(/&/g, 'and');
});
});
}
/***
* @param {string} text
* @returns {{wikitext: string, name: string}[]}
*/
function extractWikiProjectTagsFromText(text) {
if (!text) {
return [];
}
// this is best-effort, no guaranteed accuracy
var existingTags = [];
var rgx = /\{\{(WikiProject [^|}]*).*?\}\}/g;
var match;
while (match = rgx.exec(text)) { // jshint ignore:line
var tag = match[1].trim();
if (tag === 'WikiProject banner shell') {
continue;
}
existingTags.push({
wikitext: match[0],
name: tag
});
}
return existingTags;
}
/**
* @param {string} type
* @param {string} message
*/
function setMainStatus(type, message) {
if (!ui.mainStatusLayout || !ui.mainStatusLayout.isElementAttached()) {
ui.fieldset.addItems([
ui.mainStatusLayout = new OO.ui.FieldLayout(ui.mainStatusArea = new OO.ui.MessageWidget())
]);
}
ui.mainStatusArea.setType(type);
ui.mainStatusArea.setLabel(message);
}
/**
* @param {string} type
* @param {string} message
*/
function setTalkStatus(type, message) {
if (!ui.talkStatusLayout) {
ui.fieldset.addItems([
ui.talkStatusLayout = new OO.ui.FieldLayout(ui.talkStatusArea = new OO.ui.MessageWidget())
]);
}
ui.talkStatusArea.setType(type);
ui.talkStatusArea.setLabel(message);
}
function handleSubmit() {
setMainStatus('notice', msg('status-processing'));
mw.track('counter.gadget_afcsw.submit_attempted');
ui.submitButton.setDisabled(true);
ui.mainStatusLayout.scrollElementIntoView();
var draft = ui.titleInput.getValue();
if (!draft) {
ui.titleLayout.setErrors([msg('validation-notitle')]);
ui.fieldset.removeItems([ui.mainStatusLayout]);
ui.submitButton.setDisabled(false);
ui.titleLayout.scrollElementIntoView();
return;
}
debug(draft);
afc.api.get({
"action": "query",
"prop": "revisions|description",
"titles": draft,
"rvprop": "content",
"rvslots": "main",
}).then(function (json) {
var apiPage = json.query.pages[0];
var errors = errorsFromPageData(apiPage);
if (errors.length) {
ui.titleLayout.setErrors(errors);
ui.fieldset.removeItems([ui.mainStatusLayout]);
ui.submitButton.setDisabled(false);
ui.titleLayout.scrollElementIntoView();
return;
}
var text = prepareDraftText(apiPage);
setMainStatus('notice', msg('status-saving'));
saveDraftPage(draft, text).then(function () {
setMainStatus('success', msg('status-redirecting'));
mw.track('counter.gadget_afcsw.submit_succeeded');
$(window).off('beforeunload', afc.beforeUnload);
setTimeout(function () {
location.href = mw.util.getUrl(draft);
}, config.redirectionDelay);
}, function (code, err) {
if (code === 'captcha') {
ui.fieldset.removeItems([ui.mainStatusLayout, ui.talkStatusLayout]);
ui.captchaLayout.scrollElementIntoView();
mw.track('counter.gadget_afcsw.submit_captcha');
} else {
setMainStatus('error', msg('error-saving-main', makeErrorMessage(code, err)));
mw.track('counter.gadget_afcsw.submit_failed');
mw.track('counter.gadget_afcsw.submit_failed_' + code);
}
ui.submitButton.setDisabled(false);
});
var talktext = prepareTalkText(afc.talktext);
if (!afc.talktext && !talktext) {
// No content earlier, no content now. Stop here to avoid
// creating the talk page as empty.
return;
}
setTalkStatus('notice', msg('status-saving-talk'));
afc.api.postWithEditToken({
"action": "edit",
"title": new mw.Title(draft).getTalkPage().toText(),
"text": talktext,
"summary": msg('editsummary-talk')
}).then(function (data) {
if (data.edit && data.edit.result === 'Success') {
setTalkStatus('success', msg('status-talk-success'));
} else {
return $.Deferred().reject('unexpected result');
}
}).catch(function (code, err) {
setTalkStatus('error', msg('error-saving-talk', makeErrorMessage(code, err)));
});
}).catch(function (code, err) {
setMainStatus('error', msg('error-main', makeErrorMessage(code, err)));
ui.submitButton.setDisabled(false);
mw.track('counter.gadget_afcsw.submit_failed');
mw.track('counter.gadget_afcsw.submit_failed_' + code);
});
}
function saveDraftPage(title, text) {
// TODO: handle edit conflict
var editParams = {
"action": "edit",
"title": title,
"text": text,
"summary": msg('editsummary-main')
};
if (ui.captchaLayout && ui.captchaLayout.isElementAttached()) {
editParams.captchaid = afc.captchaid;
editParams.captchaword = ui.captchaInput.getValue();
ui.fieldset.removeItems([ui.captchaLayout]);
}
return afc.api.postWithEditToken(editParams).then(function (data) {
if (!data.edit || data.edit.result !== 'Success') {
if (data.edit && data.edit.captcha) {
// Handle captcha for non-confirmed users
var url = data.edit.captcha.url;
afc.captchaid = data.edit.captcha.id; // abuse of global?
ui.fieldset.addItems([
ui.captchaLayout = new OO.ui.FieldLayout(ui.captchaInput = new OO.ui.TextInputWidget({
placeholder: msg('captcha-placeholder'),
required: true
}), {
warnings: [ new OO.ui.HtmlSnippet('<img src=' + url + '>') ],
label: msg('captcha-label'),
align: 'top',
help: msg('captcha-helptip'),
helpInline: true,
}),
], /* position */ 6); // just after submit button
// TODO: submit when enter key is pressed in captcha field
return $.Deferred().reject('captcha');
} else {
return $.Deferred().reject('unexpected-result');
}
}
});
}
/**
* @param {Object} page - page information from the API
* @returns {string} final draft page text to save
*/
function prepareDraftText(page) {
var text = page.revisions[0].slots.main.content;
var header = '';
// Handle short description
var shortDescTemplateExists = /\{\{[Ss]hort ?desc(ription)?\s*\|/.test(text);
var shortDescExists = !!page.description;
var existingShortDesc = page.description;
if (ui.shortdescInput.getValue()) {
// 1. No shortdesc - insert the one provided by user
if (!shortDescExists) {
header += '{{Short description|' + ui.shortdescInput.getValue() + '}}\n';
// 2. Shortdesc exists from {{short description}} template - replace it
} else if (shortDescExists && shortDescTemplateExists) {
text = text.replace(/\{\{[Ss]hort ?desc(ription)?\s*\|.*?\}\}\n*/g, '');
header += '{{Short description|' + ui.shortdescInput.getValue() + '}}\n';
// 3. Shortdesc exists, but not generated by {{short description}}. If the user
// has changed the value, save the new value
} else if (shortDescExists && existingShortDesc !== ui.shortdescInput.getValue()) {
header += '{{Short description|' + ui.shortdescInput.getValue() + '}}\n';
// 4. Shortdesc exists, but not generated by {{short description}}, and user hasn't changed the value
} else {
// Do nothing
}
} else {
// User emptied the shortdesc field (or didn't exist from before): remove any existing shortdesc.
// This doesn't remove any shortdesc that is generated by other templates
// Race condition (FIXME): if someone else added a shortdesc to the draft after this user opened the wizard,
// that shortdesc gets removed
text = text.replace(/\{\{[Ss]hort ?desc(ription)?\s*\|.*?\}\}\n*/g, '');
}
// Draft topics
debug(ui.oresTopicInput);
if (ui.oresTopicLayout.isVisible()) {
afc.oresTopics = ui.oresTopicInput.getValue();
}
if (afc.oresTopics && afc.oresTopics.length) {
text = text.replace(/\{\{[Dd]raft topics\|.*?\}\}\n*/g, '');
header += '{{Draft topics|' + afc.oresTopics.join('|') + '}}\n';
}
// Add AfC topic
text = text.replace(/\{\{AfC topic\|(.*?)\}\}/g, '');
header += '{{AfC topic|' + ui.afcTopicInput.getValue() + '}}\n';
// put AfC submission template
header += '{{subst:submit|1=' + (mw.util.getParamValue('username') || '{{subst:REVISIONUSER}}') + '}}\n';
// insert everything to the top
text = header + text;
debug(text);
return text;
}
/**
* @param {string} initialText - initial talk page text
* @returns {string} - final talk page text to save
*/
function prepareTalkText(initialText) {
var text = initialText;
// TODO: this can be improved to put tags within {{WikiProject banner shell}} (if already present or otherwise)
var alreadyExistingWikiProjects = extractWikiProjectTagsFromText(text);
var alreadyExistingTags = alreadyExistingWikiProjects.map(function (e) {
return e.name;
});
var tagsToAdd = ui.talkTagsInput.getValue().filter(function (tag) {
return alreadyExistingTags.indexOf(tag) === -1;
});
var tagsToRemove = alreadyExistingTags.filter(function (tag) {
return ui.talkTagsInput.getValue().indexOf(tag) === -1;
});
tagsToRemove.forEach(function (tag) {
text = text.replace(new RegExp('\\{\\{\\s*' + tag + '\\s*(\\|.*?)?\\}\\}\\n?'), '');
});
var tagsToAddText = tagsToAdd.map(function (tag) {
return '{{' + tag + '}}';
}).join('\n') + (tagsToAdd.length ? '\n' : '');
text = tagsToAddText + (text || '');
// remove |class=draft parameter in any WikiProject templates
text = text.replace(/(\{\{wikiproject.*?)\|\s*class\s*=\s*draft\s*/gi, '$1');
return text;
}
/**
* Load a JSON page from the wiki.
* Use API (instead of $.getJSON with action=raw) to take advantage of caching
* @param {string} page
* @returns {jQuery.Promise<Record<string, any>>}
**/
function getJSONPage (page) {
return afc.api.get({
action: 'query',
titles: page,
prop: 'revisions',
rvprop: 'content',
rvlimit: 1,
rvslots: 'main',
uselang: 'content',
maxage: '3600', // 1 hour
smaxage: '3600',
formatversion: 2
}).then(function (json) {
var content = json.query.pages[0].revisions[0].slots.main.content;
return JSON.parse(content);
}).catch(function (code, err) {
console.error(makeErrorMessage(code, err));
});
}
/**
* Expands wikilinks and external links into HTML.
* Used instead of mw.msg(...).parse() because we want links to open in a new tab,
* and we don't want tags to be mangled.
* @param {string} input
* @returns {string}
*/
function linkify(input) {
return input
.replace(
/\[\[:?(?:([^|\]]+?)\|)?([^\]|]+?)\]\]/g,
function(_, target, text) {
if (!target) {
target = text;
}
return '<a target="_blank" href="' + mw.util.getUrl(target) +
'" title="' + target.replace(/"/g, '"') + '">' + text + '</a>';
}
)
// for ext links, display text should be given
.replace(
/\[(\S*?) (.*?)\]/g,
function (_, target, text) {
return '<a target="_blank" href="' + target + '">' + text + '</a>';
}
);
}
function msg(key) {
var messageArgs = Array.prototype.slice.call(arguments, 1);
return mw.msg.apply(mw, ['afcsw-' + key].concat(messageArgs));
}
function makeErrorMessage(code, err) {
if (code === 'http') {
return 'http: there is no internet connectivity';
}
return code + (err && err.error && err.error.info ? ': ' + err.error.info : '');
}
function debug() {
Array.prototype.slice.call(arguments).forEach(function (arg) {
console.log(arg);
});
}
})(); // File-level closure to protect functions from being exposed to the global scope or overwritten
/* </nowiki> */