User:Dragoniez/ToollinkTweaks.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.
/******************************************************************************************************************\
ToollinkTweaks
Extend toollinks attached to user links to the script user's liking.
@version 1.3.0
@author [[User:Dragoniez]]
\******************************************************************************************************************/
// @ts-check
/* global mw, OO */
/* eslint-disable @typescript-eslint/no-this-alias */
//<nowiki>
(function() {
// *****************************************************************************************************************
// Across-the-board variables
/**
* Array to store toollink option fields on the config page.
* @type {ToollinkField[]}
*/
var ttFields = [];
/** Index used to give an ID to \<li> elements on the config page. */
var listitemIdx = 0;
/**
* Canonical special names that are to be removed from the autocomplete candidates
* @readonly
*/
var disabledSps = [
'AllMyUploads', // ---> Special:Listfiles/USER || title=Special:Listfiles&user=USER
'ApiHelp', // ---> api.php?action=help&modules=main
'BannerLoader', // ---> error
'BannerRandom', // ---> error
'BetaFeatures', // ---> Special:Preferences#mw-prefsection-betafeatures
'ChangePassword', // ---> Special:ChangeCredentials/MediaWiki\\Auth\\PasswordAuthenticationRequest
'Listadmins', // ---> Special:Listusers/sysop || title=Special:Listusers&group=sysop
'Listbots', // ---> Special:Listusers/bot || title=Special:Listusers&group=bot
'MobileLanguages',
'MyLanguage',
'Mycontributions', // ---> Special:Contributions/USER || title=Special:Contributions&target=USER
'Mylog', // ---> Special:Log/USER || title=Special:Log&user=USER
'Mypage', // ---> User:XXX(/subpage)
'Mytalk', // ---> User talk:XXX(/subpage)
'Myuploads', // ---> Special:Listfiles/USER || title=Special:Listfiles&user=USER
'NewSection', // ---> action=edit§ion=new
'Uploads' // ---> Special:Listfiles
];
/**
* The special page name on which this script is loaded. (See #getCanonicalSpecialPageName)
* @type {string|false}
* @readonly
*/
var spName;
/**
* Canonical special page names, used on the config page.
* @type {string[]?}
* @readonly
*/
var spList = null;
/**
* The database name of the project on which this script is loaded.
* @readonly
*/
var dbName = mw.config.get('wgDBname');
/**
* Keys for `action=options` and `action=globalpreferences`.
* @readonly
*/
var userjs = {
local: 'userjs-toollinktweaks',
global: 'userjs-toollinktweaks-global'
};
// *****************************************************************************************************************
/**
* Initialize ToollinkTweaks.
* @returns {void}
*/
function init() {
/**
* Whether we're on the config page.
* @readonly
*/
var onConfig = mw.config.get('wgNamespaceNumber') === -1 && /^(ToollinkTweaksConfig|TTC)$/i.test(mw.config.get('wgTitle'));
/**
* Required modules.
* @readonly
*/
var modules = [
'mediawiki.util',
'mediawiki.user',
// Config
'mediawiki.api',
'oojs-ui',
'oojs-ui.styles.icons-movement',
'oojs-ui.styles.icons-interactions',
'oojs-ui.styles.icons-moderation',
'oojs-ui.styles.icons-editing-list',
'jquery.ui' // For effects
];
if (!onConfig) {
modules.splice(2);
}
// Load dependencies
var deferreds = [
mw.loader.using(modules),
$.ready
];
if (onConfig) {
deferreds.unshift(getCanonicalSpecialPageList());
$(loadConfigInterface); // Show a 'Loading the interface' message as soon as the DOM gets ready
}
$.when.apply($, deferreds).then(function() { // When all the modules are loaded and the DOM is ready
// Get canonical special page name, retrieve the user config, and set up CSS styles
spName = getCanonicalSpecialPageName();
var cfg = mergeConfig();
createStyleTag();
if (onConfig) {
// Create interface when we're on the config page
spList = arguments[0];
if (!spList) {
mw.notify('Failed to fetch canonical special page names.', {type: 'error'});
}
createConfigInterface(cfg);
} else {
// Add portlet link to the config page
mw.util.addPortletLink(
'p-cactions',
mw.util.getUrl('Special:ToollinkTweaksConfig'),
'ToollinkTweaksConfig',
'ca-ttc',
'Configure ToollinkTweaks'
);
// Filter out toollink builder config
var builderCfg = cfg.reduce(/** @param {TTBuilderConfig[]} acc */ function(acc, obj) {
if (!(isSpOptedOut(spName, obj.spInclude, obj.spExclude) ||
obj.optedOut.indexOf(dbName) !== -1 ||
!obj.enabled)
) {
acc.push({
label: obj.label,
url: obj.url,
target: obj.target.slice(),
tab: obj.tab
});
}
return acc;
}, []);
if (!builderCfg.length) {
console.log('ToollinkTweaks: The config is empty.');
return;
}
// Add toollinks when hook is triggered
var hookTimeout;
mw.hook('wikipage.content').add(function() {
clearTimeout(hookTimeout); // Prevent `addLinks` from being called multiple times (hook can be fired several times in an instant)
hookTimeout = setTimeout(function() {
addLinks(builderCfg);
}, 100);
});
}
});
}
/**
* Get all canonical special page names on the local project.
* @returns {JQueryPromise<string[]?>}
*/
function getCanonicalSpecialPageList() {
return new mw.Api().get({
action: 'query',
meta: 'siteinfo',
siprop: 'specialpagealiases',
formatversion: '2'
}).then(function(res) {
var resSpa;
if (!res || !res.query || !Array.isArray((resSpa = res.query.specialpagealiases))) {
return null;
}
return resSpa.reduce(/** @param {string[]} acc */ function(acc, obj) {
var canonical = obj.realname;
if (canonical && disabledSps.indexOf(canonical) === -1) {
acc.push(canonical);
}
return acc;
}, [
'Preview'
])
.sort();
}).catch(function(_code, err) {
console.warn(err);
return null;
});
}
/**
* Show 'now loading' message ahead of when the config page gets ready.
* @returns {{header?: HTMLHeadingElement; body?: HTMLDivElement;}}
*/
function loadConfigInterface() {
document.title = 'ToollinkTweaksConfig - ' + mw.config.get('wgSiteName');
var /** @type {HTMLHeadingElement?} */ header =
document.querySelector('.mw-first-heading') ||
document.querySelector('.firstHeading') ||
document.querySelector('#firstHeading');
var /** @type {HTMLDivElement?} */ body =
document.querySelector('.mw-body-content') ||
document.querySelector('#mw-content-text');
if (!header || !body) {
return {};
}
header.textContent = 'Configure ToollinkTweaks';
header.style.fontFamily = 'Linux Libertine,Georgia,Times,serif';
if (mw.util) { // This script per se has yet to load this module: Can be undefined
mw.util.$content.css('font-size', '90%');
}
body.innerHTML = 'Loading the interface ';
body.appendChild(createSpinner());
return {
header: header,
body: body
};
}
/**
* Create a spinner icon.
* @returns {HTMLImageElement}
*/
function createSpinner() {
var img = document.createElement('img');
img.src = '//upload.wikimedia.org/wikipedia/commons/4/42/Loading.gif';
img.style.cssText = 'vertical-align: middle; height: 1em; border: 0;';
return img;
}
/**
* Quite the same as `mw.config.get('wgCanonicalSpecialPageName')`, except that some index.php actions are converted to special page names.
* @returns {string|false} `False` if we are not on a special page
*/
function getCanonicalSpecialPageName() {
var canonicalSpecialPageName = mw.config.get('wgCanonicalSpecialPageName');
if (canonicalSpecialPageName) {
return canonicalSpecialPageName;
} else {
switch (mw.config.get('wgAction')) {
case 'view':
var diff = mw.util.getParamValue('diff');
var oldid = mw.util.getParamValue('oldid');
var wgRevisionId = mw.config.get('wgRevisionId').toString();
if (diff === wgRevisionId || (diff !== null && oldid === wgRevisionId)) {
return 'Diff';
} else if (oldid !== null) {
return 'PermanentLink';
}
return false;
// case 'watch':
// case 'unwatch':
// case 'revert':
case 'delete':
return 'DeletePage';
// case 'rollback':
case 'protect':
case 'unprotect':
return 'ProtectPage';
// case 'markpatrolled':
// case 'render':
case 'purge':
return 'Purge';
case 'submit':
return 'Preview';
case 'edit':
case 'editredlink':
return 'EditPage';
case 'history':
return 'PageHistory';
// case 'historysubmit':
// case 'raw':
// case 'ajax':
// case 'credits':
case 'info':
return 'PageInfo';
case 'revisiondelete':
return 'Revisiondelete';
default:
return false;
}
}
}
/**
* @typedef {"User"|"IP"|"CIDR"} UserType
*/
/**
* Toollink builder config object.
* @typedef TTBuilderConfig
* @type {object}
* @property {string} label
* @property {string} url
* @property {UserType[]} target This is actually used for filtering purposes but must be run against each user link
* @property {"_self"|"_blank"} tab
*/
/**
* Object that is keyed by DB names and valued by integers. The integers represent where in the `cfg` array the globalized toollink should be inserted.
* @typedef {Object.<string, number>} IndexObject
*/
/**
* Toollink filter config object.
* @typedef TTFilterConfig
* @type {object}
* @property {string[]} spInclude
* @property {string[]} spExclude
* @property {IndexObject|false} global `false` if the toollink isn't made global; otherwise an object keyed by DB names and valued by integers.
* @property {string[]} optedOut An array of DB names that opt out for the toollink
* @property {boolean} enabled
*/
/**
* The ToollinkTweaks config object.
* @typedef {TTBuilderConfig & TTFilterConfig} TTConfig
*/
/**
* Merge and retrieve the ToollinkTweaks config.
* @returns {TTConfig[]}
*/
function mergeConfig() {
// Parse config data
var /** @type {string} */ strCfgGlobal = mw.user.options.get(userjs.global) || '[]';
var /** @type {string} */ strCfg = mw.user.options.get(userjs.local) || '[]';
var /** @type {TTConfig[]} */ cfgGlobal;
var /** @type {TTConfig[]} */ cfg;
try {
cfgGlobal = JSON.parse(strCfgGlobal);
}
catch (err) {
console.error(err);
cfgGlobal = [];
}
try {
cfg = JSON.parse(strCfg);
}
catch (err) {
console.error(err);
cfg = [];
}
// Merge the global config data into the local one
for (var i = cfgGlobal.length - 1; i >= 0; i--) {
var obj = cfgGlobal[i];
var gl = obj.global || {}; // Never reaches the right operand
if (typeof gl[dbName] === 'number') { // TTConfig was saved on this project at some time
cfg.splice(gl[dbName], 0, obj);
} else { // TTConfig has never been saved on this project
cfg.unshift(obj);
}
}
return cfg;
}
/**
* Create a style tag for ToollinkTweaks.
* @returns {void}
*/
function createStyleTag() {
var style = document.createElement('style');
style.textContent =
// Config
'#tt-config {' +
'position: relative;' +
'}' +
'#tt-config-overlay {' + // Overlay of the config body, used to make elements in it unclickable
'width: 100%;' +
'height: 100%;' +
'position: absolute;' +
'top: 0;' +
'left: 0;' +
'z-index: 10;' +
'}' +
'.tt-config-overlay-hidden {' +
'display: none;' +
'}' +
'#tt-config-scrollbuttons {' +
'position: fixed;' +
'top: 50%;' +
'left: 0.1em;' +
'transform: translateY(-50%)' +
'}' +
'.tt-config-scrollbutton {' +
'display: block;' +
'cursor: pointer;' +
'width: 1em;' +
'padding: 0.2em 0.4em;' +
'background-color: #f8f9fa;' +
'border: 1px solid #a2a9b1;' +
'border-radius: 2px;' +
'}' +
'#tt-config-list {' + // Toollink list default
'margin: 0;' +
'padding: 0;' +
'}' +
'.tt-config-listitem {' + // No bullets
'list-style: none;' +
'}' +
'.tt-config-listitem > fieldset {' + // Border around fieldset
'padding: 1em;' +
'margin-bottom: 1em;' +
'border: 1px solid silver;' +
'}' +
'.tt-config-listitem-dragger {' + // Wrapper <div> of toollink reorderer
'margin-bottom: 0.3em;' +
'}' +
'.tt-config-listitem-dragger-icon {' + // Whitespace between icon and label
'margin-right: 0.5em;' +
'}' +
'.tt-config-listitem-dragger-label {' +
'cursor: inherit;' +
'}' +
'.tt-config-buttongroup:not(:last-child) {' + // Bottom margin for each button div
'margin-bottom: 0.8em;' +
'}' +
'#tt-config-list.tt-config-list-reordering {' + // Set border on list when getting reordered (inner fieldset is hidden)
'padding: 1em 1em 0.5em 1em;' +
'margin-bottom: 1em;' +
'border: 1px solid silver;' +
'}' +
'#tt-config-listitem-dummy {' +
'height: 0.3em;' +
'}' +
'#tt-config-list:not(.tt-config-list-reordering) > #tt-config-listitem-dummy {' + // Hide the dummy listitem when not on the reordering mode
'display: none;' +
'}' +
'#tt-config-list.tt-config-list-reordering > .tt-config-listitem:not(#tt-config-listitem-dummy) {' +
'cursor: pointer;' +
'}' +
'#tt-config-list.tt-config-list-reordering > .tt-config-listitem:not(#tt-config-listitem-dummy):hover {' +
'background-color: #80ccff;' +
'}' +
'.tt-config-listitem-dragger:not(.tt-config-list-reordering),' + // Hide reorder-irrelevant elements when on the reordering mode
'#tt-config-buttongroup1.tt-config-list-reordering,' +
'#tt-config-buttongroup2:not(.tt-config-list-reordering),' +
'#tt-config-buttongroup3.tt-config-list-reordering {' +
'display: none;' +
'}' +
'#tt-config .oo-ui-selectWidget:not(.oo-ui-element-hidden) {' + // Prevent SelectWidget options from propagating all over
'max-height: 22em;' + // the viewport by restricting the field's height
'}' +
'.tt-config-localexception {' + // "Lower the level" of local exception checkboxes
'margin-left: 2em;' +
'}' +
// Toollinks
'.tt-toollink-bare::before {' + // For toollink wrapper spans that are not enclosed by a parent span
'content: " | ";' +
'}';
document.head.appendChild(style);
}
/**
* Create the ToollinkTweaks config interface on `Special:ToollinkTweaksConfig`.
* @param {TTConfig[]} cfg An array of ToollinkTweaks config objects.
* @returns {void}
*/
function createConfigInterface(cfg) {
var elements = loadConfigInterface();
var header = elements.header;
var body = elements.body;
if (!header || !body) {
mw.notify('Failed to load the config interface.', {type: 'error', autoHide: false});
return;
}
mw.util.$content.css('font-size', '90%');
// Create container and make it the only child of the body content
var $container = $('<div>').prop('id', 'tt-config');
body.innerHTML = '';
body.appendChild($container[0]);
// Transparent overlay of the container used to make elements in it unclickable
var overlay = document.createElement('div');
overlay.id = 'tt-config-overlay';
overlay.classList.add('tt-config-overlay-hidden');
body.appendChild(overlay);
// Create inner containers
var /** @type {JQuery<HTMLUListElement>} */ $ul = $('<ul>');
$ul.prop('id', 'tt-config-list');
var /** @type {JQuery<HTMLLIElement>} */ $dummyLi = $('<li>'); // This makes it possible to drag items to the bottom of the list
$dummyLi.addClass('tt-config-listitem').prop('id', 'tt-config-listitem-dummy');
makeDraggable($dummyLi);
$ul.append($dummyLi);
var $buttons = $('<div>').prop('id', 'tt-config-buttons');
// Create buttons
var $scrollButtons = $('<div>').prop('id', 'tt-config-scrollbuttons');
var $upButton = $('<img>').prop('id', 'tt-config-scrollup').addClass('tt-config-scrollbutton')
.attr('src', 'https://upload.wikimedia.org/wikipedia/commons/1/10/OOjs_UI_icon_collapse.svg')
.off('click').on('click', function() {
setOverlay(true);
window.scrollTo({top: 0});
setOverlay(false);
});
var $downButton = $('<img>').prop('id', 'tt-config-scrolldown').addClass('tt-config-scrollbutton')
.attr('src', 'https://upload.wikimedia.org/wikipedia/commons/9/90/OOjs_UI_icon_expand.svg')
.off('click').on('click', function() {
setOverlay(true);
window.scrollTo({top: document.body.scrollHeight});
setOverlay(false);
});
$scrollButtons.append($upButton, $downButton);
$('body').append($scrollButtons);
var $buttonGroup1 = $('<div>').addClass('tt-config-buttongroup').prop('id', 'tt-config-buttongroup1');
var addButton = new OO.ui.ButtonWidget({
label: 'Add toollink',
id: 'tt-config-add',
icon: 'add'
});
var reorderButton = new OO.ui.ButtonWidget({
label: 'Reorder toollinks',
id: 'tt-config-reorder',
icon: 'listNumbered'
});
addButton.$element.off('click').on('click', function(_e) {
var options = ttFields.length ? {scroll: true}: {animate: true};
new ToollinkField($dummyLi, reorderButton, options);
});
reorderButton.$element.off('click').on('click', function(_e) {
reorderToollinkFields(true);
});
$buttonGroup1.append(addButton.$element, reorderButton.$element);
var $buttonGroup2 = $('<div>').addClass('tt-config-buttongroup').prop('id', 'tt-config-buttongroup2');
var reorderEndButton = new OO.ui.ButtonWidget({
label: 'End reordering',
id: 'tt-config-endreorder',
icon: 'logOut'
});
reorderEndButton.$element.off('click').on('click', function(_e) {
reorderToollinkFields(false);
});
$buttonGroup2.append(reorderEndButton.$element);
var $buttonGroup3 = $('<div>').addClass('tt-config-buttongroup').prop('id', 'tt-config-buttongroup3');
var saveButton = new OO.ui.ButtonWidget({
label: 'Save toollinks',
id: 'tt-config-save',
icon: 'bookmarkOutline',
flags: ['primary', 'progressive']
});
saveButton.$element.off('click').on('click', function(_e) {
saveConfig(saveButton);
});
$buttonGroup3.append(saveButton.$element);
$buttons.append($buttonGroup1, $buttonGroup2, $buttonGroup3);
$container.append($ul, $buttons);
// Initialize toollink options, reflecting the user config
cfg.forEach(function(obj) {
new ToollinkField($dummyLi, reorderButton, {cfg: obj});
});
}
/**
* Make a listitem draggable for order shuffling.
* @param {JQuery<HTMLLIElement>} $li
*/
function makeDraggable($li) {
$li.on({
dragstart: function(e) { // When the listitem has started to be dragged
// Start dragging only on the reordering mode
if (this.querySelector('.tt-config-listitem-dragger.tt-config-list-reordering') && e.originalEvent && e.originalEvent.dataTransfer) {
e.originalEvent.dataTransfer.setData('text/plain', this.id); // Save the element's ID
} else {
e.preventDefault();
}
},
dragover: function(e) { // When the listitem is dragged over a valid drop target
e.preventDefault();
this.style.borderTop = '2px solid blue'; // Highlight the drop point by border
},
dragleave: function(_e) { // When the listitem is dragged off a valid drop target
this.style.borderTop = ''; // Reset border
},
drop: function(e) { // When the dragged listitem is dropped
e.preventDefault();
if (e.originalEvent && e.originalEvent.dataTransfer) {
var id = e.originalEvent.dataTransfer.getData('text/plain');
var dragged = document.getElementById(id);
if (dragged && this.parentElement) {
this.parentElement.insertBefore(dragged, this); // Insert the element before the drop target
/** @type {HTMLLIElement[]} */
var listitems = Array.prototype.slice.call(document.querySelectorAll('.tt-config-listitem:not(#tt-config-listitem-dummy)'));
var ids = listitems.map(function(el) { return el.id; });
ttFields.sort(function(field1, field2) {
return ids.indexOf(field1.getId()) - ids.indexOf(field2.getId());
});
}
}
this.style.borderTop = '';
}
});
}
/**
* Create a new toollink field.
* @class ToollinkField
* @constructor
* @param {JQuery<HTMLLIElement>} $dummyLi \<li> before which the field will be inserted.
* @param {OO.ui.ButtonWidget} reorderButton
* @param {object} [options]
* @param {TTConfig} [options.cfg] Optional config object, referring to which initial values are set.
* @param {boolean} [options.scroll] If true, scroll to the new toollink field.
* @param {boolean} [options.animate] If true, add a toollink field with animation.
*/
function ToollinkField($dummyLi, reorderButton, options) {
// Variable initialization
options = options || {};
var _this = this;
var /** @type {TTConfig} */ cfg = options.cfg || {
label: '',
url: '',
target: [],
tab: '_self',
spInclude: ['*'],
spExclude: [],
global: false,
optedOut: [],
enabled: true
};
this.index /** @type {number} */ = ttFields.length;
this.reorderButton /** @type {OO.ui.ButtonWidget} */ = reorderButton;
// Create a listitem that stores this field
/**
* The ID of the wrapper \<li> element.
* @type {string}
*/
this.id = 'tt-config-listitem' + (++listitemIdx);
/** @type {JQuery<HTMLLIElement>} */
this.$li = $('<li>');
this.$li.addClass('tt-config-listitem').prop('id', this.id).attr('draggable', 'true');
makeDraggable(this.$li);
// Create div for listitem reordering
/**
* @type {{
* icon: OO.ui.IconWidget;
* label: OO.ui.LabelWidget;
* }}
*/
this.dragger = {
icon: new OO.ui.IconWidget({
icon: 'draggable',
classes: ['tt-config-listitem-dragger-icon']
}),
label: new OO.ui.LabelWidget({
classes: ['tt-config-listitem-dragger-label']
})
};
var $wrapper = $('<div>').addClass('tt-config-listitem-dragger');
$wrapper.append(this.dragger.icon.$element, this.dragger.label.$element);
this.$li.append($wrapper);
// Create toollink fieldset
this.fieldset /** @type {OO.ui.FieldsetLayout} */ = new OO.ui.FieldsetLayout({
label: 'Toollink',
classes: ['tt-config-listitem-field']
});
this.$li.append(this.fieldset.$element);
// Create field items
this.label /** @type {OO.ui.TextInputWidget} */ = new OO.ui.TextInputWidget({
placeholder: 'Label',
value: cfg.label,
required: true
});
this.url /** @type {OO.ui.TextInputWidget} */ = new OO.ui.TextInputWidget({
placeholder: 'URL',
value: cfg.url,
required: true
});
this.target /** @type {OO.ui.MenuTagMultiselectWidget} */ = new OO.ui.MenuTagMultiselectWidget({
inputPosition: 'inline',
options: [
{data: 'User'},
{data: 'IP'},
{data: 'CIDR'}
],
allowedValues: ['User', 'IP', 'CIDR'],
selected: cfg.target,
placeholder: 'Add taget user links',
});
this.tab /** @type {OO.ui.DropdownWidget} */ = new OO.ui.DropdownWidget({
label: 'Tab on which to open the toollink',
menu: {
items: [
new OO.ui.MenuOptionWidget({
data: '_self',
label: 'The current tab'
}),
new OO.ui.MenuOptionWidget({
data: '_blank',
label: 'A new tab'
})
]
}
});
this.tab.getMenu().selectItemByData(cfg.tab);
var spNameData = ['*'].concat(spList || []).map(function(name) {
return {data: name};
});
this.spInclude /** @type {OO.ui.MenuTagMultiselectWidget} */ = new OO.ui.MenuTagMultiselectWidget({
inputPosition: 'inline',
options: spNameData.slice(),
selected: cfg.spInclude,
placeholder: 'Add canonical special page names'
});
this.spExclude /** @type {OO.ui.MenuTagMultiselectWidget} */ = new OO.ui.MenuTagMultiselectWidget({
inputPosition: 'inline',
options: spNameData.slice(),
selected: cfg.spExclude,
placeholder: 'Add canonical special page names'
});
this.global /** @type {OO.ui.CheckboxInputWidget} */ = new OO.ui.CheckboxInputWidget({
selected: !!cfg.global
});
this.globalIndexes /** @type {IndexObject} */ = $.extend({}, cfg.global || {});
this.optedOutDBs /** @type {string[]} */ = cfg.optedOut;
this.localException /** @type {OO.ui.CheckboxInputWidget} */ = new OO.ui.CheckboxInputWidget({
classes: ['tt-config-localexception'],
selected: cfg.optedOut.indexOf(dbName) !== -1
});
this.enabled /** @type {OO.ui.CheckboxInputWidget} */ = new OO.ui.CheckboxInputWidget({
selected: cfg.enabled
});
this.removeButton /** @type {OO.ui.ButtonWidget} */ = new OO.ui.ButtonWidget({
label: 'Remove toollink',
icon: 'trash',
flags: 'destructive'
});
this.removeButton.$element.off('click').on('click', function(_e) {
_this.remove();
});
// Set field items
var spIncludeHelp = mw.config.get('wgServer') + mw.util.wikiScript('api') + '?action=query&meta=siteinfo&siprop=specialpagealiases&formatversion=2';
var otherDbs = cfg.optedOut.filter(function(project) { return project !== dbName; });
this.localExceptionWrapper /** @type {OO.ui.FieldLayout} */ = new OO.ui.FieldLayout(this.localException, {
label: 'Set a local exception and disable this toollink on this project',
align: 'inline',
help: 'Other opted-out projects: ' + (otherDbs.length ? otherDbs.join(', ') : 'none')
});
this.fieldset.addItems([
new OO.ui.FieldLayout(this.label, {
label: 'Label (required)',
align: 'top'
}),
new OO.ui.FieldLayout(this.url, {
label: 'URL (required)',
align: 'top',
help: new OO.ui.HtmlSnippet(
'Variables:' +
'<ul>' +
'<li>$1: The username associated with the toollink</li>' +
'<li>$2: Article path (usually <code>/wiki/</code>)</li>' +
'<li>$3: Script path (usually <code>/w/index.php</code>)</li>' +
'<li>$4: API path (usually <code>/w/api.php</code>)</li>' +
'</ul>'
)
}),
new OO.ui.FieldLayout(this.target, {
label: 'Target',
align: 'top'
}),
new OO.ui.FieldLayout(this.tab, {
label: 'Open link on',
align: 'top'
}),
new OO.ui.FieldLayout(this.spInclude, {
label: 'Run on special pages including',
align: 'top',
help: new OO.ui.HtmlSnippet('<code>*</code> signifies <code>all</code> (<a href="' + spIncludeHelp + '"target="_blank">find aliases</a>)'),
helpInline: true
}),
new OO.ui.FieldLayout(this.spExclude, {
label: 'Run on special pages excluding',
align: 'top',
help: 'Overrides the inclusion settings',
helpInline: true
}),
new OO.ui.FieldLayout(this.global, {
label: 'Make this toollink global',
align: 'inline'
}),
this.localExceptionWrapper,
new OO.ui.FieldLayout(this.enabled, {
label: 'Enable',
align: 'inline'
}),
new OO.ui.FieldLayout(this.removeButton, {
align: 'top'
})
]);
// Variable-dependent event listeners
this.global.$element.off('change').on('change', function(_e) {
_this.localExceptionWrapper.toggle(_this.global.isSelected());
});
this.global.$element.trigger('change');
this.localException.$element.off('change').on('change', function() {
// When the "local exception" checkbox is checked, disable all other elements in the field
var disable = _this.localException.isSelected();
if (disable) { // Ensure that the required fields are filled before disabling them
var /** @type {OO.ui.TextInputWidget} */ blankInput;
[_this.label, _this.url].forEach(function(widget) {
if (!widget.getValue() && widget.isRequired()) {
// @ts-ignore "Property 'onBlur' does not exist on type 'TextInputWidget'."
widget.onBlur(); // Highlight the blank input
blankInput = blankInput || widget;
}
});
// @ts-ignore "Variable 'blankInput' is used before being assigned."
if (blankInput) {
_this.localException.setSelected(false);
blankInput.focus();
mw.notify('Fill out the required field(s).', {type: 'error'});
return;
}
}
[
_this.label,
_this.url,
_this.target,
_this.tab,
_this.spInclude,
_this.spExclude,
_this.global,
_this.enabled,
_this.removeButton
]
.forEach(function(widget) {
widget.setDisabled(disable);
});
});
this.localException.$element.trigger('change');
// Append <li> to <ul>
if (options.scroll) {
$dummyLi.before(this.$li);
this.$li[0].scrollIntoView({behavior: 'smooth'}); // Scroll to the new field
toggleScrollButtons();
} else if (options.animate) {
this.$li.css('display', 'none'); // Temporarily hide the element to append
$dummyLi.before(this.$li);
this.$li.show({ // Gradually show the appended element
duration: 400,
complete: function() {
toggleScrollButtons();
}
});
} else {
$dummyLi.before(this.$li);
toggleScrollButtons();
}
ttFields.push(this);
reorderButton.toggle(ttFields.length > 1);
}
/**
* Get the ID of the wrapper \<li> element.
* @returns {string}
*/
ToollinkField.prototype.getId = function() {
return this.id;
};
/**
* Get `globalIndexes`.
* @returns {IndexObject}
*/
ToollinkField.prototype.getGlobalIndexes = function() {
return this.globalIndexes;
};
/**
* Set a value to `globalIndexes`.
* @param {IndexObject} globalIndexes
*/
ToollinkField.prototype.setGlobalIndexes = function(globalIndexes) {
this.globalIndexes = globalIndexes;
};
/**
* Get the names of projects that have opted out for this toolink.
* @returns {string[]} A deep copy of `optedOutDBs`.
*/
ToollinkField.prototype.getOptedOutDBs = function() {
return this.optedOutDBs.slice();
};
/**
* Set a value to `optedOutDBs`.
* @param {string[]} optedOutDBs
*/
ToollinkField.prototype.setOptedOutDBs = function(optedOutDBs) {
this.optedOutDBs = optedOutDBs;
};
/**
* Get the field index (in the `ttFields` array).
* @returns {number}
*/
ToollinkField.prototype.getIndex = function() {
return this.index;
};
/**
* Set the field index (in the `ttFields` array).
* @param {number} index
*/
ToollinkField.prototype.setIndex = function(index) {
this.index = index;
};
/**
* Remove this toollink field from the DOM and `ttFields`.
*/
ToollinkField.prototype.remove = function() {
setOverlay(true); // The save button shouldn't function when we're removing the option
var _this = this;
this.$li.hide(400, function() { // Hide the option with animation, then remove it
this.remove();
toggleScrollButtons();
var idx = _this.getIndex();
var cnt = 0;
ttFields = ttFields.reduce(/** @param {ToollinkField[]} acc */ function(acc, field, i) {
if (i !== idx) {
field.setIndex(cnt++);
acc.push(field);
}
return acc;
}, []);
_this.reorderButton.toggle(ttFields.length > 1);
setOverlay(false);
});
};
/**
* Toggle between the normal and reordering interfaces.
* @param {boolean} start Whether to start reordering
*/
function reorderToollinkFields(start) {
var selectors = [
'#tt-config-list',
'.tt-config-listitem-dragger',
'#tt-config-buttongroup1',
'#tt-config-buttongroup2',
'#tt-config-buttongroup3'
];
var $elements = $(selectors.join(', '));
var $fields = $('.tt-config-listitem-field');
var clss = 'tt-config-list-reordering';
setOverlay(true);
if (start) {
var emptyFieldCnt = 0;
ttFields.forEach(function(field) {
var /** @type {string|OO.ui.HtmlSnippet} */ inputVal = field.label.getValue() || field.url.getValue();
if (!inputVal) {
inputVal = new OO.ui.HtmlSnippet('<span style="color: red;">(Empty ' + (emptyFieldCnt++) + ')</span>');
}
field.dragger.label.setLabel(inputVal);
});
$fields.hide({
effect: 'highlight',
complete: function() {
$elements.addClass(clss);
toggleScrollButtons();
setOverlay(false);
}
});
if (emptyFieldCnt) {
mw.notify('Fill out required fields for the best results.', {type: 'warn'});
}
} else {
$elements.removeClass(clss);
$fields.show({
effect: 'highlight',
complete: function() {
toggleScrollButtons();
setOverlay(false);
}
});
}
}
/**
* Save the ToollinkTweaks config.
* @param {OO.ui.ButtonWidget} saveButton
* @returns {void}
*/
function saveConfig(saveButton) {
setOverlay(true);
/** @type {{local: TTConfig[]; global: TTConfig[];}} */
var cfg = {
local: [],
global: []
};
var /** @type {OO.ui.TextInputWidget} */ blankInput;
cfg = ttFields.reduce(function(acc, field) {
var widget;
if (blankInput) {
return acc;
} else if (!(widget = field.label).getValue() || !(widget = field.url).getValue()) {
// @ts-ignore "Property 'onBlur' does not exist on type 'TextInputWidget'."
widget.onBlur(); // Highlight the blank input
blankInput = blankInput || widget;
return acc;
}
var madeGlobal = field.global.isSelected();
var globalIndexes = field.getGlobalIndexes();
var optedOutDBs = field.getOptedOutDBs();
if (madeGlobal) {
globalIndexes[dbName] = acc.local.length;
var dbIdx = optedOutDBs.indexOf(dbName);
if (dbIdx !== -1) {
optedOutDBs.splice(dbIdx, 1);
}
if (field.localException.isSelected()) {
optedOutDBs.push(dbName);
}
} else {
globalIndexes = {};
optedOutDBs = [];
}
field.setGlobalIndexes(globalIndexes);
field.setOptedOutDBs(optedOutDBs);
acc[madeGlobal ? 'global' : 'local'].push({
label: field.label.getValue(),
url: field.url.getValue(),
// @ts-ignore
target: field.target.getValue(),
// @ts-ignore "Type 'unknown' is not assignable to type '"_self" | "_blank"'."
tab: (function() {
/**
* `OO.ui.OptionWidget` when one item is selected, `OO.ui.OptionWidget[]` when multiple items are selected, and `null` when nothing is selected.
* @type {OO.ui.OptionWidget|OO.ui.OptionWidget[]|null}
*/
var selected = field.tab.getMenu() && field.tab.getMenu().findSelectedItem(); // This is never an array because we don't use multiselect
return selected instanceof OO.ui.OptionWidget ? selected.getData() : '_self';
})(),
// @ts-ignore
spInclude: field.spInclude.getValue(),
// @ts-ignore
spExclude: field.spExclude.getValue(),
global: !madeGlobal ? false : globalIndexes,
optedOut: optedOutDBs,
enabled: field.enabled.isSelected()
});
return acc;
}, cfg);
// Stop if there's some blank field that's required to be filled out
// @ts-ignore "Variable 'blankInput' is used before being assigned."
if (blankInput) {
mw.notify('Some required fields are not filled.', {type: 'error'});
blankInput.focus();
setOverlay(false);
return;
}
// Change the save button's label
var $label = $('<span>');
var spinner = createSpinner();
spinner.style.marginRight = '1em';
$label.append(spinner);
var textNode = document.createTextNode('Saving the toollinks...');
$label.append(textNode);
saveButton.setIcon(null).setLabel($label);
// Save config (separate API requests for local and global settings)
$.when.apply($, [saveLocalOptions(cfg.local), saveGlobalOptions(cfg.global)]).then(function(lErr, gErr) {
if (!lErr && !gErr) { // Success on both
saveButton.setIcon('bookmarkOutline').setLabel('Save toollinks');
setOverlay(false);
mw.notify('Successfully saved the toollinks.', {type: 'success'});
} else if (lErr && gErr) { // Failure on both
saveButton.setIcon('bookmarkOutline').setLabel('Save toollinks');
setOverlay(false);
mw.notify('Failed to save the toollinks (' + [lErr, gErr].join(', ') + ').', {type: 'error'});
} else { // Failure on one, config is messed up
var success = gErr ? 'local' : 'global';
var fail = gErr ? 'global' : 'local';
var errMsg = 'Successfully saved the ' + success + ' toollinks, but failed to save the ' + fail + ' ones. Retrying in 10 seconds...';
mw.notify(errMsg, {type: 'warn'});
textNode.textContent = 'Please wait for a while...';
setTimeout(function() { // Make another try
var retrying = 'Retrying...';
mw.notify(retrying);
textNode.textContent = retrying;
$.when.apply($, [lErr ? saveLocalOptions(cfg.local) : saveGlobalOptions(cfg.global)]).then(function(err) {
saveButton.setIcon('bookmarkOutline').setLabel('Save toollinks');
setOverlay(false);
if (!err) {
mw.notify('Successfully saved both types of the toollinks.', {type: 'success'});
} else {
mw.notify(
'Failed to save the ' + fail + ' toollinks. It is recommended that you wait for a few minutes and try again before ' +
'making other changes in the settings or leaving this page.',
{type: 'error', autoHideSeconds: 'long'}
);
}
});
}, 10000);
}
});
}
/**
* Set the visibility of the overlay div and toggle accesibility to DOM elements in the config body.
* @param {boolean} show
*/
function setOverlay(show) {
var $overlay = $('#tt-config-overlay');
var clss = 'tt-config-overlay-hidden';
if (show) {
$overlay.removeClass(clss);
} else {
$overlay.addClass(clss);
}
}
/**
* Toggle the visibility of scroll buttons.
*/
function toggleScrollButtons() {
// Show/hide the scroll buttons if the document has a sroll bar
$('#tt-config-scrollbuttons').toggle(window.innerWidth > document.body.clientWidth);
}
/**
* Save local user preferences.
* @param {TTConfig[]} cfg
* @returns {JQueryPromise<string?>} Returns an error code on failure
*/
function saveLocalOptions(cfg) {
var /** @type {string} */ oldCfgStr = mw.user.options.get(userjs.local) || '[]';
var cfgStr = JSON.stringify(cfg);
if (oldCfgStr === '[]' && !cfg.length || oldCfgStr === cfgStr) {
return $.Deferred().resolve(null);
} else {
return new mw.Api().saveOption(userjs.local, cfgStr)
.then(function() {
mw.user.options.set(userjs.local, cfgStr);
return null;
})
.catch(function(code, err) {
console.warn(err);
return code;
});
}
}
/**
* Save global user preferences.
* @param {TTConfig[]} cfg
* @returns {JQueryPromise<string?>} Returns an error code on failure
*/
function saveGlobalOptions(cfg) {
var /** @type {string} */ oldCfgStr = mw.user.options.get(userjs.global) || '[]';
var cfgStr = JSON.stringify(cfg);
if (oldCfgStr === '[]' && !cfg.length || oldCfgStr === cfgStr) {
return $.Deferred().resolve(null);
} else {
return new mw.Api().postWithToken('csrf', {
action: 'globalpreferences',
optionname: userjs.global,
optionvalue: cfgStr,
formatversion:'2'
}).then(function() {
mw.user.options.set(userjs.global, cfgStr);
return null;
}).catch(function(code, err) {
console.warn(err);
return code;
});
}
}
/**
* Check whether the current page is opted out for toollink building.
* @param {string|false} specialPageName
* @param {string[]} spInclude
* @param {string[]} spExclude
* @returns {boolean}
*/
function isSpOptedOut(specialPageName, spInclude, spExclude) {
if (typeof specialPageName !== 'string') {
return false;
} else if (spExclude.indexOf('*') !== -1 || spExclude.indexOf(specialPageName) !== -1) {
return true;
} else if (spInclude.indexOf('*') !== -1 || spInclude.indexOf(specialPageName) !== -1) {
return false;
} else {
return true;
}
}
/**
* Add toollinks.
* @param {TTBuilderConfig[]} cfg
* @returns {void}
*/
function addLinks(cfg) {
// Make sure that this isn't a redundant run
if (document.querySelector('.tt-toollink')) {
return;
}
// Toollink right below the first heading on Special:Contributions
var headingToollink;
if (spName === 'Contributions' && (headingToollink = document.querySelector('.mw-changeslist-links'))) {
var /** @type {string?} */ user = mw.config.get('wgRelevantUserName');
if (!user) {
var /** @type {HTMLHeadingElement?} */ heading = document.querySelector('.mw-first-heading');
if (heading) {
user = extractCidr(heading.innerText);
}
}
if (user) {
createLinks(cfg, user, headingToollink, '');
}
}
/** @type {HTMLAnchorElement[]} */
var anchors = Array.prototype.slice.call(document.querySelectorAll('.mw-userlink, .mw-anonuserlink'));
if (!anchors.length) return;
anchors.forEach(function(a) {
if (a.type === 'button') {
return;
}
var /** @type {string?} */ user = null;
for (var i = 0; i < a.childNodes.length; i++) {
// Get the text content of the first <bdi> node; a.textContent might have been messed up
// if other scripts created new nodes in the anchor
var node = a.childNodes[i];
if (node.nodeName === 'BDI') {
user = node.textContent;
break;
}
}
if (!user) {
return;
}
/**
* Normal structure
* ```html
* <a class="mw-userlink">User</a>
* <span class="mw-usertoollinks">
* <span></span>
* </span>
* ```
* Special:AbuseLog
* ```html
* <a class="mw-userlink">User</a>
* <span class="mw-usertoollinks">
* (
* <a></a>
* |
* <a></a>
* )
* </span>
* ```
* Contribs of a CIDR IP
* ```html
* <a class="mw-anonuserlink">IP</a>
* <a class="new mw-usertoollinks-talk"></a>
* ```
* Special:RecentChanges, Special:Watchlist (Group changes by page)
* ```html
* <span class="mw-changeslist-line-inner-userLink">
* <a class="mw-userlink">User</a>
* </span>
* <span class="mw-changeslist-line-inner-userTalkLink">
* <span class="mw-usertoollinks">
* <span></span>
* </span>
* </span>
* ```
*/
var targetElement = a.nextElementSibling;
var pr = a.parentElement;
if (targetElement&& targetElement.classList.contains('mw-usertoollinks-talk')) { // Contribs of a CIDR IP
createLinks(cfg, user, targetElement, 'after');
} else if ( /* Normal */ targetElement && (
targetElement.classList.contains('mw-usertoollinks') ||
// There might be an intervening node created by the ipinfo extension
targetElement.classList.contains('ext-ipinfo-button') && (targetElement = targetElement.nextElementSibling) && targetElement.classList.contains('mw-usertoollinks')
)) {
/** @type {HTMLElement[]} */
var ch = Array.prototype.slice.call(targetElement.children);
if (ch.some(function(el) { return el && el.nodeName === 'A'; })) {
createLinks(cfg, user, targetElement, 'nospan'); // AbuseLog
} else {
createLinks(cfg, user, targetElement, ''); // Normal
}
} else if ( // Non-collaspsed links with the "Group changes by page" setting
pr && pr.nodeName === 'SPAN' && pr.classList.contains('mw-changeslist-line-inner-userLink') &&
(targetElement = pr.nextElementSibling) && targetElement.classList.contains('mw-changeslist-line-inner-userTalkLink') &&
(targetElement = targetElement.querySelector('.mw-usertoollinks'))
) {
createLinks(cfg, user, targetElement, '');
} else {
return;
}
});
}
/**
* Extract a CIDR address from text.
*
* Regular expressions used in this function are adapted from `mediawiki.util`.
* @param {string} text
* @returns {string?}
* @link https://doc.wikimedia.org/mediawiki-core/master/js/source/util.html#mw-util-method-isIPv4Address
* @link https://doc.wikimedia.org/mediawiki-core/master/js/source/util.html#mw-util-method-isIPv6Address
*/
function extractCidr(text) {
var v4_byte = '(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|0?[0-9]?[0-9])';
var v4_regex = new RegExp('(?:' + v4_byte + '\\.){3}' + v4_byte + '\\/(?:3[0-2]|[12]?\\d)');
var v6_block = '\\/(?:12[0-8]|1[01][0-9]|[1-9]?\\d)';
var v6_regex = new RegExp(
'(?::(?::|(?::[0-9A-Fa-f]{1,4}){1,7})|[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4}){0,6}::|[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4}){7})' +
v6_block
);
var v6_regex2 = new RegExp('[0-9A-Fa-f]{1,4}(?:::?[0-9A-Fa-f]{1,4}){1,6}' + v6_block);
var m;
if ((m = text.match(v4_regex)) ||
(m = text.match(v6_regex)) ||
(m = text.match(v6_regex2)) && /::/.test(m[0]) && !/::.*::/.test(m[0])
) {
return m[0];
} else {
return null;
}
}
/**
* Create toollinks.
* @param {TTBuilderConfig[]} cfg
* @param {string} username
* @param {Element} targetElement
* @param {""|"after"|"nospan"} appendType
* @returns {void}
*/
function createLinks(cfg, username, targetElement, appendType) {
var userType;
if (mw.util.isIPAddress(username)) {
userType = 'IP';
} else if (mw.util.isIPAddress(username, true)) {
userType = 'CIDR';
} else {
userType = 'User';
}
username = encodeURIComponent(username.trim().replace(/ /g, '_'));
var rep = {
$2: mw.config.get('wgArticlePath').replace('$1', ''),
$3: mw.config.get('wgScript'),
$4: mw.util.wikiScript('api')
};
cfg.forEach(function(obj) {
if (obj.target.indexOf(userType) === -1) {
return;
}
var a = document.createElement('a');
a.href = obj.url.replace(/\$[1234]/g, function(m) {
switch (m) {
case '$1':
return username;
case '$2':
return rep.$2;
case '$3':
return rep.$3;
case '$4':
return rep.$4;
default:
return m;
}
});
a.textContent = obj.label;
a.target = obj.tab;
var span = document.createElement('span');
span.classList.add('tt-toollink');
if (appendType === 'after') {
span.classList.add('tt-toollink-bare');
}
span.appendChild(a);
switch (appendType) {
case 'after':
$(targetElement).after(span);
targetElement = span;
break;
case 'nospan':
var ch = targetElement.childNodes;
ch[ch.length - 1].remove(); // Remove text node
targetElement.appendChild(document.createTextNode(' | '));
targetElement.appendChild(span);
targetElement.appendChild(document.createTextNode(')'));
break;
default:
targetElement.appendChild(span);
}
});
}
// *****************************************************************************************************************
// Entry point
init();
// *****************************************************************************************************************
})();
//</nowiki>