User:Dragoniez/Selective Rollback.js
Appearance
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.
/***************************************************************************************************\
Selective Rollback
@author [[User:Dragoniez]]
@version 5.1.3
@see https://meta.wikimedia.org/wiki/User:Dragoniez/Selective_Rollback
Some functionality in this script is adapted from:
@link https://meta.wikimedia.org/wiki/User:Hoo_man/smart_rollback.js
@link https://en.wikipedia.org/wiki/User:DannyS712/AjaxRollback.js
See also the type definitions at:
@link https://github.com/Dr4goniez/wiki-gadgets/blob/main/src/window/Selective%20Rollback.d.ts
\***************************************************************************************************/
// @ts-check
/* global mw, OO */
//<nowiki>
(() => {
//**************************************************************************************************
const version = '5.1.3';
// Run this script only when on /wiki/$1 or /w/index.php
if (
!location.pathname.startsWith(mw.config.get('wgArticlePath').replace('$1', '')) &&
location.pathname !== mw.config.get('wgScript')
) {
return;
}
// Ensure the user is logged in. String-casting is used for type safety
// in nested function scopes (to avoid inference as `string | null`).
const wgUserName = /** @type {string} */ (mw.config.get('wgUserName'));
if (wgUserName === null || mw.config.get('wgUserIsTemp')) {
return;
}
const wgWikiID = mw.config.get('wgWikiID');
/**
* @type {mw.Api}
*/
let api;
/**
* @type {Languages}
*/
let langSwitch;
/**
* @type {Messages}
*/
let msg;
/**
* @type {InterfaceDirection}
*/
let dir;
/**
* Whether the user is on Recentchanges or Watchlist.
*/
const isOnRCW = ['Recentchanges', 'Recentchangeslinked', 'Watchlist'].includes(mw.config.get('wgCanonicalSpecialPageName') || '');
/**
* Whether the user is on Special:SelectiveRollbackConfig.
*/
const isOnConfig = mw.config.get('wgNamespaceNumber') === -1 && /^(SelectiveRollbackConfig|SRC)$/i.test(mw.config.get('wgTitle'));
class SelectiveRollback {
static async init() {
const modules = [
'mediawiki.api',
'mediawiki.user',
'mediawiki.util',
'oojs-ui'
];
if (isOnConfig) {
modules.push('mediawiki.ForeignApi');
}
await $.when(
mw.loader.using(modules),
$.ready
);
// Set up variables
api = new mw.Api(this.apiOptions());
const cfg = SelectiveRollbackConfig.getMerged();
this.appendStyleTag(cfg);
// Get localized message object
langSwitch = /** @type {Languages} */ (
// Fall back to the user's language in preferences
(cfg.lang || mw.config.get('wgUserLanguage')).replace(/-.*$/, '')
);
if (!(langSwitch in this.i18n)) {
if (cfg.lang) {
console.error(`[SR] Sorry, Selective Rollback does not support ${cfg.lang} as its interface language.`);
}
langSwitch = 'en';
}
msg = this.i18n[langSwitch];
dir = langSwitch === 'ar' ? 'rtl' : 'ltr';
if (isOnConfig) {
SelectiveRollbackConfig.init();
return;
}
// Fetch metadata for script initialization
const meta = await this.getMetaInfo();
if (cfg.purgerLink) {
this.createCachePurger();
}
if (cfg.configLink) {
SelectiveRollbackConfig.createPortletLink();
}
// Stop running the script if the user doesn't have rollback rights or there're no visible rollback links
// However, keep it running on RCW even when the no-link condition is met, since rollback links may not
// exist at page load but can be added dynamically later through AJAX updates
if (!meta.rights.has('rollback') || (!this.collectLinks().length && !isOnRCW)) {
return;
}
// Create a SelectiveRollbackDialog instance
const parentNode = this.getParentNode();
const SelectiveRollbackDialog = SelectiveRollbackDialogFactory(cfg, meta, parentNode);
const autocompleteSources = await this.getAutocompleteSourcesForJawiki();
const dialog = new SelectiveRollbackDialog({
$element: $('<div>').attr({ dir }),
classes: ['sr-dialog'],
size: 'large'
}, autocompleteSources);
SelectiveRollbackDialog.windowManager.addWindows([dialog]);
const sr = new this(dialog, cfg, parentNode);
dialog.bindSR(sr);
// Set up a hook for page content updates
const hook = mw.hook('wikipage.content');
let /** @type {NodeJS.Timeout} */ hookTimeout;
const hookCallback = () => {
clearTimeout(hookTimeout);
hookTimeout = setTimeout(() => {
if (dialog.isDestroyed()) {
hook.remove(hookCallback);
} else {
sr.initializeLinks();
}
}, 100);
};
hook.add(hookCallback);
}
/**
* Collects visible rollback links as a jQuery object.
* @returns {JQuery<HTMLSpanElement>}
* @private
*/
static collectLinks() {
return $('.mw-rollback-link:visible');
}
/**
* @returns {ParentNode}
* @throws {Error} If the parent node cannot be defined
* @private
*/
static getParentNode() {
const spName = mw.config.get('wgCanonicalSpecialPageName');
let /** @type {ParentNode} */ parentNode;
if (isOnRCW) {
parentNode = null;
} else if (
mw.config.get('wgAction') === 'history' ||
(spName && ['Contributions', 'IPContributions', 'GlobalContributions'].includes(spName))
) {
parentNode = 'li';
} else if (typeof mw.config.get('wgDiffNewId') === 'number') {
parentNode = '#mw-diff-ntitle2';
} else if (document.querySelector('.mw-changeslist-line')) {
// SR checkboxes shouldn't be generated when Special:Recentchanges is transcluded
parentNode = false;
} else {
const date = new Date().toJSON().replace(/T[^T]+$/, '').replace(/-/g, '');
const err = '[SR] Parent node could not be defined.';
mw.notify(
$('<div>').append(
err + ' This is likely a bug of Selective Rollback. (',
$('<a>')
.prop({
href: '//meta.wikimedia.org' + mw.util.getUrl('User_talk:Dragoniez/Selective_Rollback', {
action: 'edit',
section: 'new',
preloadtitle: `Error report (${date})`,
preload: 'User:Dragoniez/preload'
}) +
'&preloadparams%5B%5D=a%20parentNode%20error' +
'&preloadparams%5B%5D=' + encodeURIComponent(location.href),
target: '_blank'
})
.text('Report the error'),
')'
),
{ type: 'error', autoHideSeconds: 'long' }
);
throw new Error(err);
}
return parentNode;
}
/**
* Generates options for the `mw.Api` constructor.
* @param {boolean} [readOnlyPost] Whether to add the `Promise-Non-Write-API-Action` header.
* (Default: `false`)
* @returns {mw.Api.Options}
*/
static apiOptions(readOnlyPost = false) {
/** @type {mw.Api.Options} */
const options = {
ajax: {
headers: {
'Api-User-Agent': `Selective_Rollback/${version} (https://meta.wikimedia.org/wiki/User:Dragoniez/Selective_Rollback.js)`
}
},
parameters: {
action: 'query',
format: 'json',
formatversion: '2'
}
};
if (readOnlyPost) {
// @ts-expect-error
options.ajax.headers['Promise-Non-Write-API-Action'] = true;
}
return options;
}
/**
* Appends to the document head a \<style> tag for SR.
* @param {Required<SelectiveRollbackConfigObject>} cfg
* @returns {void}
* @private
*/
static appendStyleTag(cfg) {
const style = document.createElement('style');
style.textContent =
'.sr-rollback {' +
'display: inline-block;' +
'margin: 0 0.5em;' +
'}' +
'.sr-checkbox-wrapper {' +
'display: inline-block;' +
'}' +
'.sr-checkbox {' +
'margin: 0 4px 2px !important;' +
'vertical-align: middle;' +
'}' +
'.sr-rollback-label {' +
'font-weight: bold;' +
`color: ${cfg.checkboxLabelColor};` +
'}' +
'.sr-dialog .oo-ui-inline-help code {' +
'color: inherit;' +
'}' +
'.sr-selected-count {' +
'padding-top: 6px !important;' +
'}' +
'#sr-summarypreview {' +
'background-color: var(--background-color-neutral-subtle, #f8f9fa);' +
'color: var(--color-emphasized, #000);' +
'margin: 0;' +
'border: 1px solid var(--border-color-base, #a2a9b1);' +
'border-radius: 2px;' +
'padding: 5px 8px;' +
'font-size: inherit;' +
'font-family: inherit;' +
'line-height: 1.42857143em;' +
'width: 100%;' +
'box-sizing: border-box;' +
'vertical-align: middle;' +
'max-width: 50em;' +
'min-height: 2.2857143em;' +
'}' +
'.sr-rollback-link-success {' +
'background-color: lightgreen;' +
'}' +
'@media screen {' +
'html.skin-theme-clientpref-night .sr-rollback-link-success {' +
'background-color: #099979;' +
'}' +
'}' +
'@media screen and (prefers-color-scheme: dark) {' +
'html.skin-theme-clientpref-os .sr-rollback-link-success {' +
'background-color: #099979;' +
'}' +
'}' +
'.sr-rollback-link-fail {' +
'background-color: lightpink;' +
'}' +
'@media screen {' +
'html.skin-theme-clientpref-night .sr-rollback-link-fail {' +
'background-color: #f54739;' +
'}' +
'}' +
'@media screen and (prefers-color-scheme: dark) {' +
'html.skin-theme-clientpref-os .sr-rollback-link-fail {' +
'background-color: #f54739;' +
'}' +
'}' +
'.sr-config-overlay {' +
'width: 100%;' +
'height: 100%;' +
'position: absolute;' +
'top: 0;' +
'left: 0;' +
'z-index: 10000;' +
'}' +
'.sr-config-notice {' +
'margin-bottom: 1em;' +
'max-width: 50em;' +
'}' +
'.sr-config-headinglabel {' +
'margin-top: 12px;' +
'}' +
'.oo-ui-fieldLayout.sr-config-propertyfield-buttoncontainer {' +
'margin-top: 8px;' +
'}' +
'.sr-config-icon-container {' +
'display: inline-block;' +
'}' +
'.sr-config-icon {' +
'width: 1em;' +
'vertical-align: middle;' +
'border: 0;' +
'}' +
'.sr-config-icon-subtext {' +
'margin-left: 0.2em;' +
'}' +
'.sr-config-icon-subtext-green {' +
'color: var(--color-icon-success, #099979);' +
'}' +
'.sr-config-icon-subtext-red {' +
'color: var(--color-icon-error, #f54739);' +
'}' +
'';
document.head.appendChild(style);
}
/**
* Retrieves the default rollback summary and the current user's user rights on the local wiki.
* @returns {Promise<MetaInfo>}
* @private
*/
static async getMetaInfo() {
const params = Object.create(null);
params.meta = [];
let summary = mw.storage.get(this.storageKeys.summary);
if (typeof summary !== 'string') {
params.meta.push('allmessages');
Object.assign(params, {
ammessages: 'revertpage',
amlang: mw.config.get('wgContentLanguage') // the language of the wiki
});
}
/** @type {string[] | null | false} */
let rights = mw.storage.getObject(this.storageKeys.rights);
if (!Array.isArray(rights) || !rights.every(v => typeof v === 'string')) {
params.meta.push('userinfo');
params.uiprop = 'rights';
}
/**
* Extracts the fallback ("other") form from a `'{{PLURAL:$7|...}}'` expression.
* Only the last non-numeric form is used.
* @param {string} str
* @returns {string}
*/
const parsePluralOther = (str) => {
return str.replace(/\{\{\s*PLURAL:\s*\$7\s*\|([^}]+?)\}\}/gi, (match, forms) => {
const formList = /** @type {string} */ (forms).split('|').map((f) => f.trim());
for (let i = formList.length - 1; i >= 0; i--) {
const form = formList[i];
if (!/^\d+\s*=/.test(form)) {
return form;
}
}
return match;
});
};
if (typeof summary === 'string' && rights) {
return {
summary,
parsedsummary: parsePluralOther(summary),
fetched: true,
rights: new Set(rights)
};
}
/** @type {ApiResponse} */
const { query } = await api.get(params).catch((_, err) => {
console.warn(err);
return /** @type {ApiResponse} */ ({ query: void 0 });
});
const { allmessages = [], userinfo } = query || {};
if (allmessages[0] && typeof allmessages[0].content === 'string') {
summary = allmessages[0].content;
mw.storage.set(this.storageKeys.summary, summary, 3 * 24 * 60 * 60); // 3 days
}
if (userinfo && userinfo.rights) {
rights = userinfo.rights;
mw.storage.setObject(this.storageKeys.rights, rights, 24 * 60 * 60); // 1 day
}
let fetched = false;
if (typeof summary === 'string') {
fetched = true;
} else {
summary = 'Reverted edits by [[Special:Contributions/$2|$2]] ([[User talk:$2|talk]]) to last revision by [[User:$1|$1]]';
}
return {
summary,
parsedsummary: parsePluralOther(summary),
fetched,
rights: rights ? new Set(rights) : new Set()
};
}
/**
* Generates a portlet link that triggers a process of purging `mw.storage` cache.
* @returns {void}
* @private
*/
static createCachePurger() {
if (!Object.values(this.storageKeys).some(key => mw.storage.get(key))) {
// Don't generate the portlet link if no cache exists
return;
}
const portlet = mw.util.addPortletLink(
mw.config.get('skin') === 'minerva' ? 'p-personal' : 'p-cactions',
'#',
msg['portlet-label-uncacher'],
'ca-sr-uncacher',
msg['portlet-label-uncacher'],
void 0,
'#ca-move'
);
if (!portlet) {
return;
}
portlet.addEventListener('click', (e) => {
e.preventDefault();
this.purgeCache();
location.reload();
});
}
static purgeCache() {
for (const key of Object.values(this.storageKeys)) {
mw.storage.remove(key);
}
}
/**
* @returns {JQuery.Promise<string[]>}
* @private
*/
static getAutocompleteSourcesForJawiki() {
const moduleName = 'ext.gadget.WpLibExtra';
if (wgWikiID !== 'jawiki' || !new Set(mw.loader.getModuleNames()).has(moduleName)) {
return $.Deferred().resolve([]).promise();
}
/** @type {string[] | false | null} */
const cache = mw.storage.getObject(this.storageKeys.autocomplete);
if (Array.isArray(cache) && cache.every(el => typeof el === 'string')) {
return $.Deferred().resolve(cache).promise();
}
return mw.loader.using(moduleName).then((require) => {
const /** @type {WpLibExtra} */ lib = require(moduleName);
return $.when(lib.getVipList('wikilink'), lib.getLtaList('wikilink')).then((vipList, ltaList) => {
const list = vipList.concat(ltaList);
mw.storage.setObject(this.storageKeys.autocomplete, list, 24 * 60 * 60); // 1 day
return list;
});
});
}
/**
* @param {InstanceType<ReturnType<typeof SelectiveRollbackDialogFactory>>} dialog
* @param {Required<SelectiveRollbackConfigObject>} cfg
* @param {ParentNode} parentNode
* @private
*/
constructor(dialog, cfg, parentNode) {
/**
* @type {InstanceType<ReturnType<typeof SelectiveRollbackDialogFactory>>}
* @readonly
* @private
*/
this.dialog = dialog;
/**
* @type {ParentNode}
* @readonly
* @private
*/
this.parentNode = parentNode;
/**
* @type {SRConfirm}
* @readonly
* @private
*/
this.confirmation = SelectiveRollback.regex.mobile.test(navigator && navigator.userAgent || '')
? cfg.mobileConfirm
: cfg.desktopConfirm;
/**
* Mapping from indexes as strings to rollback links and their associated SR checkboxes.
*
* Selective Rollback assigns each rbspan a `data-sr-index` attribute, which corresponds to
* a key of this object. This index attribute is referred to when marking a specific SR-ized
* rollback link as resolved and unbind it from this instance.
*
* The actual (un)binding happens via {@link initializeLinks}, which should be called each time
* when the `wikipage_content` hook is fired.
*
* @type {RollbackLinkMap}
* @readonly
* @private
*/
this.links = Object.create(null);
}
/**
* Binds/unbinds rollback links to this intance on page content updates.
* @private
*/
initializeLinks() {
// Remove detached rollback links
for (const [index, { rbspan }] of Object.entries(this.links)) {
if (!rbspan.isConnected) {
delete this.links[index];
}
}
// Collect all rollback links again and SR-ize those that have not yet been SR-ized
const clss = 'sr-rollback-link';
SelectiveRollback.collectLinks().each((_, rbspan) => {
// Set up data for the wrapper span
const $rbspan = $(rbspan);
if ($rbspan.hasClass(clss)) {
return;
}
$rbspan.addClass(clss).attr('data-sr-index', (++SelectiveRollback.index));
// Add an SR checkbox
let /** @type {?SRBox} */ box = null;
if (this.parentNode && (box = SelectiveRollback.createCheckbox())) {
$rbspan.closest(this.parentNode).append(box.$wrapper);
}
this.links[SelectiveRollback.index] = { rbspan, box };
// Bind AJAX rollback as a click event
$rbspan.off('click').on('click', (e) => this.clickEvent(e, $rbspan, box));
});
}
/**
* Creates an SR checkbox.
* @returns {SRBox}
* @private
*/
static createCheckbox() {
const /** @type {JQuery<HTMLSpanElement>} */ $wrapper = $('<span>');
const /** @type {JQuery<HTMLLabelElement>} */ $label = $('<label>');
const /** @type {JQuery<HTMLInputElement>} */ $checkbox = $('<input>');
$wrapper
.addClass('sr-rollback')
.append(
$('<b>').text('['),
$label
.addClass('sr-checkbox-wrapper')
.append(
$checkbox
.prop({ type: 'checkbox' })
.addClass('sr-checkbox'),
$('<span>')
.text('SR')
.addClass('sr-rollback-label')
),
$('<b>').html(' ]')
);
return { $wrapper, $label, $checkbox };
}
/**
* Selects all the SR checkboxes.
* @returns {number}
*/
selectAll() {
let count = 0;
for (const { box } of Object.values(this.links)) {
if (box) {
box.$checkbox.prop('checked', true);
count++;
}
}
if (!count) {
mw.notify(msg['rollback-notify-linksresolved'], { type: 'warn' });
}
return count;
}
/**
* The click event callback for rollback links that internally calls {@link ajaxRollback}.
* @param {JQuery.ClickEvent<HTMLSpanElement, undefined, HTMLSpanElement, HTMLSpanElement>} e
* @param {JQuery<HTMLSpanElement>} $rbspan
* @param {?SRBox} box
* @returns {Promise<void>}
* @private
*/
async clickEvent(e, $rbspan, box) {
e.preventDefault();
if (e.ctrlKey) {
// If CTRL key is pressed down, just open the dialog, not executing rollback
this.dialog.open();
return;
} else if (
// Confirm rollback per config
!e.shiftKey && (
this.confirmation === 'always' ||
isOnRCW && this.confirmation === 'RCW' ||
!isOnRCW && this.confirmation === 'nonRCW'
)
) {
// Visualize which rollback link has been clicked
$rbspan.css({ border: '1px dotted var(--color-emphasized, #000)' });
const confirmed = await OO.ui.confirm(msg['rollback-confirm'], { size: 'medium' });
$rbspan.css({ border: '' });
if (!confirmed) return;
}
this.ajaxRollback($rbspan[0], box);
}
/**
* Performs AJAX rollback on a rollback link.
* @param {HTMLSpanElement} rbspan The wrapper span of the rollback link.
* @param {?SRBox} box The SR checkbox object. (**Note: this method removes the box unconditionally.**)
* @param {RollbackParams} [params] Parameters for the rollback API. Retrieved from the dialog if omitted.
* @returns {Promise<boolean>} Whether the rollback succeeded.
* @private
*/
async ajaxRollback(rbspan, box, params) {
if (box) box.$wrapper.remove();
params = params || this.dialog.getParams();
// Collect required parameters to action=rollback from the rollback link internal to the rbspan
const rblink = rbspan.querySelector('a');
const href = rblink && rblink.href;
let /** @type {?string} */ title = null;
let /** @type {?string} */ user = null;
if (href) {
title = mw.util.getParamValue('title', href);
if (!title) {
const articleMatch = SelectiveRollback.regex.article.exec(href);
if (articleMatch && articleMatch[1]) {
try {
title = decodeURIComponent(articleMatch[1]);
} catch (_) { /**/ }
}
}
user = mw.util.getParamValue('from', href);
}
let /** @type {?[string, string]} */ error = null;
if (!rblink) {
error = [
'[SR] Error: Anchor tag is missing in the rollback link for some reason.',
'linkmissing'
];
} else if (!href) {
error = [
'[SR] Error: The rollback link lacks an href attribute.',
'hrefmissing'
];
} else if (!title) {
error = [
'[SR] Error: The rollback link does not have a "title" query parameter.',
'titlemissing'
];
} else if (!user) {
error = [
'[SR] Error: The rollback link does not have a "from" query parameter.',
'usermissing'
];
}
if (error) {
console.error(error[0], rbspan);
this.processRollbackLink(rbspan, error[1]);
return false;
}
// Perform AJAX rollback
this.processRollbackLink(rbspan);
const safeTitle = /** @type {string} */ (title);
const safeUser = /** @type {string} */ (user);
const code = await SelectiveRollback.doRollback(safeTitle, safeUser, params);
this.processRollbackLink(rbspan, code);
return code === true;
}
/**
* Pre- or post-processes the given rollback link for an {@link ajaxRollback} call.
*
* This method:
* * Replaces the innerHTML of the rollback link with a spinner icon for a pre-process,
* or with the result of a rollback for a post-process.
* * Removes click event handlers on the rollback link once called.
* * Unbinds the rollback link from the instance for a post-process.
*
* @param {HTMLSpanElement} rbspan
* @param {string | boolean} [result]
* * `string` - The error code on failure.
* * `true` - On success.
* * `false` (default) - For a spinner icon.
* @returns {void}
* @private
*/
processRollbackLink(rbspan, result = false) {
const $rbspan = $(rbspan);
$rbspan.off('click');
if (result === false) {
// Replace the innerHTML of the rbspan with a spinner icon
$rbspan
.empty()
.append(
$('<img>')
.prop({ src: 'https://upload.wikimedia.org/wikipedia/commons/4/42/Loading.gif' })
.css({
'vertical-align': 'middle',
height: '1em',
border: 0
})
);
} else {
// Replace the innerHTML of the rbspan with the rollback result
const isFailure = typeof result === 'string';
$rbspan
.empty()
.append(
document.createTextNode('['),
$('<span>')
.text(isFailure ? `${msg['rollback-label-failure']} (${result})` : msg['rollback-label-success'])
.addClass(isFailure ? 'sr-rollback-link-fail' : 'sr-rollback-link-success'),
document.createTextNode(']')
)
.removeClass('mw-rollback-link')
.addClass('sr-rollback-link-resolved');
// Unbind the rbspan from the class
const index = /** @type {string} */ ($rbspan.attr('data-sr-index'));
delete this.links[index];
// If no rbspan is bound to the instance any longer, remove the dialog and the portlet link
// unless the user is on RCW, where new rollback links may be generated on page content updates
if (!isOnRCW && $.isEmptyObject(this.links)) {
this.dialog.destroy();
}
}
}
/**
* Issues an `action=rollback` HTTP request.
* @param {string} title
* @param {string} user
* @param {RollbackParams} params
* @returns {JQuery.Promise<string | true>} `true` on success, or an error code as a string.
* @private
*/
static doRollback(title, user, params) {
return api.rollback(title, user, /** @type {any} */ (params)).then(() => true).catch((code, err) => {
console.warn(err);
return code;
});
}
/**
* Performs selective rollback on the given links.
* @param {RollbackLink[]} selectedLinks
* @returns {Promise<void>}
*/
async selectiveRollback(selectedLinks) {
const params = this.dialog.getParams();
this.dialog.close();
const batches = selectedLinks.map(({ box, rbspan }) => {
return this.ajaxRollback(rbspan, box, params);
});
const results = await Promise.all(batches);
let success = 0, fail = 0;
for (const bool of results) {
bool ? success++ : fail++;
}
mw.notify(
$('<div>').append(
`${msg.scriptname} (${success + fail})`,
$('<ul>').append(
$('<li>').text(`${msg['rollback-notify-success']}: ${success}`),
$('<li>').text(`${msg['rollback-notify-failure']}: ${fail}`)
)
),
{ type: 'success' }
);
}
/**
* Retrieves selected RollbackLink objects as an array.
* @returns {RollbackLink[]}
*/
getSelected() {
return Object.values(this.links).reduce((acc, obj) => {
if (obj.box && obj.box.$checkbox.is(':checked')) {
acc.push(obj);
}
return acc;
}, /** @type {RollbackLink[]} */ ([]));
}
}
/** @type {Record<Languages, Messages>} */
SelectiveRollback.i18n = {
ja: {
'scriptname': 'Selective Rollback', // Added in v5.0.1
'portlet-tooltip-dialog': 'Selective Rollbackのダイアログを開く',
'portlet-label-uncacher': 'Selective Rollbackのキャッシュを破棄', // v4.4.3
'dialog-label-summary': '編集要約',
'dialog-label-summary-default': '既定の編集要約',
'dialog-label-summary-custom': 'カスタム',
'dialog-label-summaryinput': 'カスタム編集要約',
'dialog-help-summaryinput-$0': '<code>$0</code>は既定の編集要約に置換されます。',
'dialog-help-summaryinput-$0-error': '<code>$0</code>は<b>英語の</b>既定編集要約に置換されます。',
'dialog-label-summarypreview': '要約プレビュー', // v4.0.0
'dialog-help-summarypreview': '<code>{{PLURAL:$7}}</code>は置換されます。', // Updated in v5.0.0
'dialog-label-markbot': 'ボット編集として巻き戻し',
'dialog-label-watchlist': '巻き戻し対象をウォッチリストに追加',
'dialog-label-watchlistexpiry': '期間', // Deprecated since v5.0.0
'dialog-label-watchlistexpiry-indefinite': '無期限',
'dialog-label-watchlistexpiry-1week': '1週間',
'dialog-label-watchlistexpiry-1month': '1か月',
'dialog-label-watchlistexpiry-3months': '3か月',
'dialog-label-watchlistexpiry-6months': '6か月',
'dialog-label-watchlistexpiry-1year': '1年',
'dialog-button-rollback': '巻き戻し', // Updated in v5.0.0
'dialog-button-documentation': '解説', // Added in v5.0.0
'dialog-button-config': '設定', // v5.1.0
'dialog-button-selectall': '全選択', // Updated in v5.0.0
'dialog-label-selectcount': '選択済み:', // Added in v5.0.7
'dialog-button-close': '閉じる', // Deprecated since v5.0.0
'rollback-notify-noneselected': 'チェックボックスがチェックされていません。',
'rollback-notify-linksresolved': 'このページの巻き戻しリンクは全て解消済みです。',
'rollback-confirm': '巻き戻しを実行しますか?',
'rollback-label-success': '巻き戻し済',
'rollback-label-failure': '巻き戻し失敗',
'rollback-notify-success': '成功', // v4.0.0
'rollback-notify-failure': '失敗', // v4.0.0
// v5.1.0
'config-title': 'Selective Rollbackの設定',
'config-tab-local': 'ローカル',
'config-tab-global': 'グローバル',
'config-notice-local': 'ローカル設定はこのプロジェクトのみに適用され、グローバル設定が存在する場合はそれを(部分的に)上書きします。',
'config-notice-global': 'グローバル設定はすべてのプロジェクトに適用され、ローカル設定が存在する場合は(部分的に)上書きされます。',
'config-default': '規定値',
'config-default-disabled': '無効',
'config-default-enabled': '有効',
'config-label-lang': '言語',
'config-help-lang': '個人設定で指定された言語、または翻訳が利用できない場合は英語',
'config-label-summary': '定型要約',
'config-label-propertyinput-key': 'キー',
'config-label-propertyinput-value': '値',
'config-error-propertyinput-key-empty': 'キーを空にすることはできません。',
'config-error-propertyinput-value-empty': '値を空にすることはできません。',
'config-error-propertyinput-key-reserved': 'キー「$1」はシステムで予約されているため使用できません。',
'config-error-propertyinput-key-duplicate': 'キーが重複しています。',
'config-button-add': '追加',
'config-button-remove': '除去',
'config-button-deselectall': '全選択解除',
'config-help-summary-$0': '<code>$0</code> — ローカルウィキの既定の巻き戻し要約',
'config-help-summary-$1': '<code>$1</code> — 復元される編集の投稿者の利用者名',
'config-help-summary-$2': '<code>$2</code> — 巻き戻される編集の投稿者の利用者名',
'config-help-summary-$3': '<code>$3</code> — 巻き戻し先の版のID',
'config-help-summary-$4': '<code>$4</code> — 巻き戻し先の版のタイムスタンプ',
'config-help-summary-$5': '<code>$5</code> — 巻き戻し元の版のID',
'config-help-summary-$6': '<code>$6</code> — 巻き戻し元の版のタイムスタンプ',
'config-help-summary-$7': '<code>$7</code> — 巻き戻された版数',
'config-label-showkeys': '値の代わりにキーをドロップダウンの項目として使用する',
'config-label-mergesummaries': 'グローバル設定の要約を上書きせずに統合する',
'config-label-replacer': '置換表現',
'config-help-replacer': '置換表現は、巻き戻し要約で特定の文字列に置き換えられるキーワードです。意図しない置換を防ぐため、<b>常に</b><code>$</code>などの記号で始めることを推奨します。',
'config-label-mergereplacers': 'グローバル設定の置換表現を上書きせずに統合する',
'config-label-watchlist': 'ウォッチリスト',
'config-label-watchlistexpiry': 'ウォッチリストの有効期限',
'config-label-confirmation': '巻き戻し確認',
'config-label-confirmation-desktop': 'デスクトップ',
'config-label-confirmation-mobile': 'モバイル',
'config-label-confirmation-always': '常に確認する',
'config-label-confirmation-never': '確認しない',
'config-label-confirmation-RCW': '最近の更新またはウォッチリスト上では確認する',
'config-label-confirmation-nonRCW': '最近の更新またはウォッチリスト以外では確認する',
'config-label-checkboxlabelcolor': 'チェックボックスのラベル色',
'config-help-checkboxlabelcolor': 'プレビュー:',
'config-label-miscellaneous': 'その他',
'config-help-markbot': 'この設定は、必要な権限を持つ場合にのみ適用されます。',
'config-label-configlink': '設定ページへのポートレットリンクを生成',
'config-label-purger': 'キャッシュ破棄用のポートレットリンクを生成',
'config-button-save': '保存',
'config-notify-save-success': '設定を保存しました。',
'config-notify-save-failure': '設定の保存に失敗しました: $1',
'config-button-reset': 'リセット',
'config-confirm-reset': '設定を既定値にリセットしますか?変更内容は手動で保存する必要があります。',
'config-notify-reset': 'フィールドの値を既定値にリセットしました。',
'config-label-deleteglobal': 'グローバル設定を削除',
'config-help-deleteglobal-absent': 'グローバル設定は保存されていません。',
'config-label-deletelocal': 'ローカル設定を削除',
'config-help-deletelocal-absent': 'ローカル設定は保存されていません。',
'config-label-deletelocalall': '他のすべてのプロジェクトのローカル設定を削除',
'config-help-deletelocalall-present': 'この操作を行うには、$1でログインしている必要があります。',
'config-help-deletelocalall-absent': 'ローカル設定が保存されている他プロジェクトはありません。',
'config-label-deletedata': 'データを削除',
'config-button-deletedata': '削除',
'config-confirm-deletedata': '設定データを本当に削除しますか?この操作は元に戻せません。',
'config-notify-deletedata-success': '指定された設定データを削除しました。',
'config-notify-deletedata-failure': '指定された設定データの一部を削除できませんでした。',
},
en: {
'scriptname': 'Selective Rollback', // Added in v5.0.1
'portlet-tooltip-dialog': 'Open the Selective Rollback dialog',
'portlet-label-uncacher': 'Purge cache for Selective Rollback', // v4.4.3
'dialog-label-summary': 'Edit summary',
'dialog-label-summary-default': 'Default edit summary',
'dialog-label-summary-custom': 'Custom',
'dialog-label-summaryinput': 'Custom edit summary',
'dialog-help-summaryinput-$0': '<code>$0</code> will be replaced with the default rollback summary.',
'dialog-help-summaryinput-$0-error': '<code>$0</code> will be replaced with the default rollback summary <b>in English</b>.',
'dialog-label-summarypreview': 'Summary preview', // v4.0.0
'dialog-help-summarypreview': '<code>{{PLURAL:$7}}</code> will be replaced.', // Updated in v5.0.0
'dialog-label-markbot': 'Mark rollbacks as bot edits',
'dialog-label-watchlist': 'Add rollback targets to watchlist',
'dialog-label-watchlistexpiry': 'Expiry', // Deprecated since v5.0.0
'dialog-label-watchlistexpiry-indefinite': 'Indefinite',
'dialog-label-watchlistexpiry-1week': '1 week',
'dialog-label-watchlistexpiry-1month': '1 month',
'dialog-label-watchlistexpiry-3months': '3 months',
'dialog-label-watchlistexpiry-6months': '6 months',
'dialog-label-watchlistexpiry-1year': '1 year',
'dialog-button-rollback': 'Rollback', // Updated in v5.0.0
'dialog-button-documentation': 'Docs', // Added in v5.0.0
'dialog-button-config': 'Config', // v5.1.0
'dialog-button-selectall': 'Select all', // Updated in v5.0.0
'dialog-label-selectcount': 'Selected:', // Added in v5.0.7
'dialog-button-close': 'Close', // Deprecated since v5.0.0
'rollback-notify-noneselected': 'No checkbox is checked.',
'rollback-notify-linksresolved': 'Rollback links on this page have all been resolved.',
'rollback-confirm': 'Are you sure you want to rollback this edit?',
'rollback-label-success': 'reverted',
'rollback-label-failure': 'rollback failed',
'rollback-notify-success': 'Success', // v4.0.0
'rollback-notify-failure': 'Failure', // v4.0.0
// v5.1.0
'config-title': 'Configure Selective Rollback',
'config-tab-local': 'Local',
'config-tab-global': 'Global',
'config-notice-local': 'Local config applies only to this project and may (partially) override the global config if present.',
'config-notice-global': 'Global config applies to all projects and may be (partially) overridden by the local config if present.',
'config-default': 'Default',
'config-default-disabled': 'Disabled',
'config-default-enabled': 'Enabled',
'config-label-lang': 'Language',
'config-help-lang': 'The user\'s interface language as set in preferences, or English if translations are unavailable.',
'config-label-summary': 'Preset summaries',
'config-label-propertyinput-key': 'Key',
'config-label-propertyinput-value': 'Value',
'config-error-propertyinput-key-empty': 'The key must not be empty.',
'config-error-propertyinput-value-empty': 'The value must not be empty.',
'config-error-propertyinput-key-reserved': 'The key "$1" is reserved by the system and hence disallowed.',
'config-error-propertyinput-key-duplicate': 'The key must be unique.',
'config-button-add': 'Add',
'config-button-remove': 'Remove',
'config-button-deselectall': 'Deselect all',
'config-help-summary-$0': '<code>$0</code> — default rollback summary on the local wiki',
'config-help-summary-$1': '<code>$1</code> — username of the author of the edit that is being restored',
'config-help-summary-$2': '<code>$2</code> — username of the author of the edits that are being reverted',
'config-help-summary-$3': '<code>$3</code> — revision ID of the revision reverted to',
'config-help-summary-$4': '<code>$4</code> — timestamp of the revision reverted to',
'config-help-summary-$5': '<code>$5</code> — revision ID of the revision reverted from',
'config-help-summary-$6': '<code>$6</code> — timestamp of the revision reverted from',
'config-help-summary-$7': '<code>$7</code> — the number of edits that have been reverted',
'config-label-showkeys': 'Use keys instead of values as dropdown options',
'config-label-mergesummaries': 'Merge summaries from the global config instead of overriding them',
'config-label-replacer': 'Replacement expressions',
'config-help-replacer': 'Replacement expressions are keywords that will be replaced with certain texts in a rollback summary. It is recommended to <b>always prefix your expressions</b> with <code>$</code> or a similar symbol to avoid unintentional text replacements.',
'config-label-mergereplacers': 'Merge replacement expressions from the global config instead of overriding them',
'config-label-watchlist': 'Watchlist',
'config-label-watchlistexpiry': 'Watchlist expiry',
'config-label-confirmation': 'Rollback confirmation',
'config-label-confirmation-desktop': 'Desktop',
'config-label-confirmation-mobile': 'Mobile',
'config-label-confirmation-always': 'Always',
'config-label-confirmation-never': 'Never',
'config-label-confirmation-RCW': 'If on Recentchanges or Watchlist',
'config-label-confirmation-nonRCW': 'If not on Recentchanges or Watchlist',
'config-label-checkboxlabelcolor': 'Checkbox label color',
'config-help-checkboxlabelcolor': 'Preview:',
'config-label-miscellaneous': 'Miscellaneous',
'config-help-markbot': 'This option applies only when you have the required rights on the wiki.',
'config-label-configlink': 'Generate a portlet link to the config page',
'config-label-purger': 'Generate a portlet link to purge the cache',
'config-button-save': 'Save',
'config-notify-save-success': 'Saved the configurations.',
'config-notify-save-failure': 'Failed to save the configurations: $1',
'config-button-reset': 'Reset',
'config-confirm-reset': 'Do you want to reset the configurations to their default values? Changes will need to be saved manually.',
'config-notify-reset': 'Field values have been reset to their default values.',
'config-label-deleteglobal': 'Delete global config',
'config-help-deleteglobal-absent': 'You do not have any global settings configured.',
'config-label-deletelocal': 'Delete local config',
'config-help-deletelocal-absent': 'You do not have any local settings configured.',
'config-label-deletelocalall': 'Delete local config on all other projects',
'config-help-deletelocalall-present': 'To perform this action, you need to be logged in on $1.',
'config-help-deletelocalall-absent': 'You do not have any local settings configured on other projects.',
'config-label-deletedata': 'Delete data',
'config-button-deletedata': 'Delete',
'config-confirm-deletedata': 'Are you sure you want to delete configuration data? This cannot be undone.',
'config-notify-deletedata-success': 'Deleted the specified configuration data.',
'config-notify-deletedata-failure': 'Failed to delete some of the specified configuration data.',
},
/**
* @author [[User:User xyBW847toYwJSYpc]] (formerly known as PAVLOV)
* @since 1.2.3
*/
zh: {
'scriptname': 'Selective Rollback', // Added in v5.0.1
'portlet-tooltip-dialog': '打开Selective Rollback日志',
'portlet-label-uncacher': '清除Selective Rollback缓存', // v4.4.3
'dialog-label-summary': '编辑摘要',
'dialog-label-summary-default': '默认编辑摘要',
'dialog-label-summary-custom': '自定义',
'dialog-label-summaryinput': '自定义编辑摘要',
'dialog-help-summaryinput-$0': '<code>$0</code>将会被默认编辑摘要替代。',
'dialog-help-summaryinput-$0-error': '<code>$0</code>将会被默认编辑摘要为<b>英文</b>替代。',
'dialog-label-summarypreview': '编辑摘要的预览', // v4.0.0
'dialog-help-summarypreview': '<code>{{PLURAL:$7}}</code>将被替换。', // Updated in v5.0.0
'dialog-label-markbot': '标记为机器人编辑',
'dialog-label-watchlist': '将回退目标加入监视列表', // Updated in v5.1.0
'dialog-label-watchlistexpiry': '时间', // Deprecated since v5.0.0
'dialog-label-watchlistexpiry-indefinite': '不限期',
'dialog-label-watchlistexpiry-1week': '1周',
'dialog-label-watchlistexpiry-1month': '1个月',
'dialog-label-watchlistexpiry-3months': '3个月',
'dialog-label-watchlistexpiry-6months': '6个月',
'dialog-label-watchlistexpiry-1year': '1年',
'dialog-button-rollback': '回退', // Updated in v5.0.0
'dialog-button-documentation': '文档', // Added in v5.0.0
'dialog-button-config': '配置', // v5.1.0
'dialog-button-selectall': '全选', // Updated in v5.0.0
'dialog-label-selectcount': '已选择:', // Added in v5.0.7
'dialog-button-close': '关闭', // Deprecated since v5.0.0
'rollback-notify-noneselected': '未选择任何勾选框。',
'rollback-notify-linksresolved': '与该页面相关的回退全部完成。',
'rollback-confirm': '您确定要回退该编辑吗?',
'rollback-label-success': '已回退',
'rollback-label-failure': '回退失败',
'rollback-notify-success': '成功', // v4.0.0
'rollback-notify-failure': '失败', // v4.0.0
// v5.1.0 (review required)
'config-title': '配置Selective Rollback',
'config-tab-local': '本地',
'config-tab-global': '全局',
'config-notice-local': '本地设置仅适用于本项目,如存在全局设置,则可能(部分)覆盖全局设置。',
'config-notice-global': '全局设置适用于所有项目,如存在本地设置,则可能被(部分)覆盖。',
'config-default': '默认',
'config-default-disabled': '已禁用',
'config-default-enabled': '已启用',
'config-label-lang': '语言',
'config-help-lang': '用户在偏好中设置的界面语言;若无可用翻译,则使用英文。',
'config-label-summary': '预设摘要',
'config-label-propertyinput-key': '键',
'config-label-propertyinput-value': '值',
'config-error-propertyinput-key-empty': '键不能为空。',
'config-error-propertyinput-value-empty': '值不能为空。',
'config-error-propertyinput-key-reserved': '键"$1"是系统保留字,不能使用。',
'config-error-propertyinput-key-duplicate': '键不能重复。',
'config-button-add': '添加',
'config-button-remove': '移除',
'config-button-deselectall': '取消全选',
'config-help-summary-$0': '<code>$0</code> — 本地维基的默认回退摘要',
'config-help-summary-$1': '<code>$1</code> — 被恢复版本的作者用户名',
'config-help-summary-$2': '<code>$2</code> — 被回退编辑的作者用户名',
'config-help-summary-$3': '<code>$3</code> — 回退到的版本ID',
'config-help-summary-$4': '<code>$4</code> — 回退到的版本时间戳',
'config-help-summary-$5': '<code>$5</code> — 被回退版本的版本ID',
'config-help-summary-$6': '<code>$6</code> — 被回退版本的时间戳',
'config-help-summary-$7': '<code>$7</code> — 被回退的编辑数量',
'config-label-showkeys': '在下拉菜单中显示键而非值',
'config-label-mergesummaries': '合并全局配置中的摘要,而不是覆盖它们',
'config-label-replacer': '替换表达',
'config-help-replacer': '替换表达会在回退摘要中被替换为特定文本。建议<b>始终</b>以<code>$</code>或类似符号开头,以避免意外替换。',
'config-label-mergereplacers': '合并全局配置中的置换表达,而不是覆盖它们',
'config-label-watchlist': '监视列表',
'config-label-watchlistexpiry': '监视列表过期时间',
'config-label-confirmation': '回退确认',
'config-label-confirmation-desktop': '桌面',
'config-label-confirmation-mobile': '移动端',
'config-label-confirmation-always': '总是确认',
'config-label-confirmation-never': '从不确认',
'config-label-confirmation-RCW': '在"最近更改"或"监视列表"页面时确认',
'config-label-confirmation-nonRCW': '不在"最近更改"或"监视列表"页面时确认',
'config-label-checkboxlabelcolor': '复选框标签颜色',
'config-help-checkboxlabelcolor': '预览:',
'config-label-miscellaneous': '其它',
'config-help-markbot': '仅在您拥有所需权限时适用。',
'config-label-configlink': '生成指向配置页面的端口链接',
'config-label-purger': '生成清除缓存的端口栏链接',
'config-button-save': '保存',
'config-notify-save-success': '已保存设置。',
'config-notify-save-failure': '保存设置失败:$1',
'config-button-reset': '重置',
'config-confirm-reset': '是否要将配置重置为默认值?更改需要手动保存。',
'config-notify-reset': '字段值已重置为默认值。',
'config-label-deleteglobal': '删除全局配置',
'config-help-deleteglobal-absent': '您尚未设置任何全局配置。',
'config-label-deletelocal': '删除本地配置',
'config-help-deletelocal-absent': '您尚未设置任何本地配置。',
'config-label-deletelocalall': '删除所有其他项目的本地配置',
'config-help-deletelocalall-present': '要执行此操作,您需要登录到 $1。',
'config-help-deletelocalall-absent': '您在其他项目上没有设置本地配置。',
'config-label-deletedata': '删除数据',
'config-button-deletedata': '删除',
'config-confirm-deletedata': '确定要删除配置数据吗?此操作无法撤销。',
'config-notify-deletedata-success': '已删除指定的配置数据。',
'config-notify-deletedata-failure': '未能删除部分指定的配置数据。',
},
/**
* @author [[User:Codename Noreste]]
* @since 3.2.0
*/
es: {
'scriptname': 'Selective Rollback', // Added in v5.0.1
'portlet-tooltip-dialog': 'Abrir el cuadro de diálogo para Selective Rollback',
'portlet-label-uncacher': 'Vaciar caché de Selective Rollback', // v4.4.3
'dialog-label-summary': 'Resumen de edición',
'dialog-label-summary-default': 'Resumen de edición predeterminado',
'dialog-label-summary-custom': 'Personalizado',
'dialog-label-summaryinput': 'Resumen de edición personalizada',
'dialog-help-summaryinput-$0': '<code>$0</code> será reemplazado con el resumen de edición predeterminado.',
'dialog-help-summaryinput-$0-error': '<code>$0</code> será reemplazado con él resumen de edición predeterminado <b>en inglés</b>.',
'dialog-label-summarypreview': 'Vista previa del resumen', // v4.0.0
'dialog-help-summarypreview': '<code>{{PLURAL:$7}}</code> será reemplazado.', // Updated in v5.0.0
'dialog-label-markbot': 'Marcar las reversiones como ediciones del bot',
'dialog-label-watchlist': 'Agregar objetivos de reversión a la lista de seguimiento', // Updated in v5.1.0
'dialog-label-watchlistexpiry': 'Expiración', // Deprecated since v5.0.0
'dialog-label-watchlistexpiry-indefinite': 'Siempre',
'dialog-label-watchlistexpiry-1week': '1 semana',
'dialog-label-watchlistexpiry-1month': '1 mes',
'dialog-label-watchlistexpiry-3months': '3 meses',
'dialog-label-watchlistexpiry-6months': '6 meses',
'dialog-label-watchlistexpiry-1year': '1 años',
'dialog-button-rollback': 'Revertir', // Updated in v5.0.0
'dialog-button-documentation': 'Documentación', // Added in v5.0.0
'dialog-button-config': 'Configurar', // v5.1.0
'dialog-button-selectall': 'Seleccionar todo', // Updated in v5.0.0
'dialog-label-selectcount': 'Seleccionado:', // Added in v5.0.7
'dialog-button-close': 'Cerrar', // Deprecated since v5.0.0
'rollback-notify-noneselected': 'No hay ninguna casilla de verificación marcada.',
'rollback-notify-linksresolved': 'Los enlaces de reversión en esta página se han resuelto todos.',
'rollback-confirm': '¿Estás seguro de que quieres revertir esta edición?',
'rollback-label-success': 'revertido',
'rollback-label-failure': 'la reversión falló',
'rollback-notify-success': 'Éxito', // v4.0.0
'rollback-notify-failure': 'Falla', // v4.0.0
// v5.1.0 (review required)
'config-title': 'Configurar Selective Rollback',
'config-tab-local': 'Local',
'config-tab-global': 'Global',
'config-notice-local': 'La configuración local solo se aplica a este proyecto y puede (parcialmente) anular la configuración global si existe.',
'config-notice-global': 'La configuración global se aplica a todos los proyectos y puede ser (parcialmente) anulada por la configuración local si existe.',
'config-default': 'Predeterminado',
'config-default-disabled': 'Desactivado',
'config-default-enabled': 'Activado',
'config-label-lang': 'Idioma',
'config-help-lang': 'El idioma de la interfaz del usuario definido en las preferencias, o inglés si no hay traducciones disponibles.',
'config-label-summary': 'Resúmenes predefinidos',
'config-label-propertyinput-key': 'Clave',
'config-label-propertyinput-value': 'Valor',
'config-error-propertyinput-key-empty': 'La clave no puede estar vacía.',
'config-error-propertyinput-value-empty': 'El valor no puede estar vacío.',
'config-error-propertyinput-key-reserved': 'La clave "$1" está reservada por el sistema y no se permite usarla.',
'config-error-propertyinput-key-duplicate': 'La clave no puede estar duplicada.',
'config-button-add': 'Añadir',
'config-button-remove': 'Eliminar',
'config-button-deselectall': 'Deseleccionar todo',
'config-help-summary-$0': '<code>$0</code> — resumen de reversión predeterminado del wiki local',
'config-help-summary-$1': '<code>$1</code> — nombre de usuario del autor de la edición restaurada',
'config-help-summary-$2': '<code>$2</code> — nombre de usuario del autor de las ediciones revertidas',
'config-help-summary-$3': '<code>$3</code> — ID de la revisión a la que se vuelve',
'config-help-summary-$4': '<code>$4</code> — marca de tiempo de la revisión a la que se vuelve',
'config-help-summary-$5': '<code>$5</code> — ID de la revisión desde la cual se revierte',
'config-help-summary-$6': '<code>$6</code> — marca de tiempo de la revisión desde la cual se revierte',
'config-help-summary-$7': '<code>$7</code> — cantidad de ediciones revertidas',
'config-label-showkeys': 'Usar claves en lugar de valores en las opciones del menú desplegable',
'config-label-mergesummaries': 'Unir los resúmenes del ajuste global en lugar de reemplazarlos',
'config-label-replacer': 'Expresiones de reemplazo',
'config-help-replacer': 'Las expresiones de de reemplazo se sustituyen por textos específicos en un resumen de reversión. Se recomienda <b>siempre</b> comenzar con <code>$</code> u otro símbolo para evitar reemplazos accidentales.',
'config-label-mergereplacers': 'Unir las expresiones de reemplazo del ajuste global en lugar de reemplazarlas',
'config-label-watchlist': 'Lista de seguimiento',
'config-label-watchlistexpiry': 'Caducidad de la lista de seguimiento',
'config-label-confirmation': 'Confirmación de reversión',
'config-label-confirmation-desktop': 'Escritorio',
'config-label-confirmation-mobile': 'Móvil',
'config-label-confirmation-always': 'Siempre confirmar',
'config-label-confirmation-never': 'Nunca confirmar',
'config-label-confirmation-RCW': 'Confirmar en CambiosRecientes o Seguimiento',
'config-label-confirmation-nonRCW': 'Confirmar fuera de CambiosRecientes o Seguimiento',
'config-label-checkboxlabelcolor': 'Color de la etiqueta del checkbox',
'config-help-checkboxlabelcolor': 'Vista previa:',
'config-label-miscellaneous': 'Varios',
'config-help-markbot': 'Esta opción solo se aplica si tiene los permisos necesarios en el wiki.',
'config-label-configlink': 'Generar un enlace de portlet a la página de configuración',
'config-label-purger': 'Generar un enlace de portlet para limpiar la caché',
'config-button-save': 'Guardar',
'config-notify-save-success': 'Se guardó la configuración.',
'config-notify-save-failure': 'No se pudo guardar la configuración: $1',
'config-button-reset': 'Restablecer',
'config-confirm-reset': '¿Deseas restablecer las configuraciones a sus valores predeterminados? Los cambios deberán guardarse manualmente.',
'config-notify-reset': 'Los valores de los campos se han restablecido a sus valores predeterminados.',
'config-label-deleteglobal': 'Eliminar configuración global',
'config-help-deleteglobal-absent': 'No tienes ninguna configuración global establecida.',
'config-label-deletelocal': 'Eliminar configuración local',
'config-help-deletelocal-absent': 'No tienes ninguna configuración local establecida.',
'config-label-deletelocalall': 'Eliminar configuración local en todos los demás proyectos',
'config-help-deletelocalall-present': 'Para realizar esta acción, debes haber iniciado sesión en $1.',
'config-help-deletelocalall-absent': 'No tienes configuraciones locales en otros proyectos.',
'config-label-deletedata': 'Borrar datos',
'config-button-deletedata': 'Borrar',
'config-confirm-deletedata': '¿Seguro que deseas borrar los datos de configuración? Esta acción no se puede deshacer.',
'config-notify-deletedata-success': 'Se eliminaron los datos de configuración especificados.',
'config-notify-deletedata-failure': 'No se pudieron eliminar algunos de los datos de configuración especificados.',
},
/**
* @author [[User:NGC 54]]
* @since 3.3.0
*/
ro: {
'scriptname': 'Selective Rollback', // Added in v5.0.1
'portlet-tooltip-dialog': 'Deschide dialogul Selective Rollback',
'portlet-label-uncacher': 'Șterge memoria cache pentru Selective Rollback', // v4.4.3
'dialog-label-summary': 'Descrierea modificării',
'dialog-label-summary-default': 'Descrierea implicită a modificării',
'dialog-label-summary-custom': 'Personalizat',
'dialog-label-summaryinput': 'Descriere personalizată a modificării',
'dialog-help-summaryinput-$0': '<code>$0</code> va fi înlocuit cu descrierea implicită a revenirii.',
'dialog-help-summaryinput-$0-error': '<code>$0</code> va fi înlocuit cu descrierea implicită a revenirii <b>în engleză</b>.',
'dialog-label-summarypreview': 'Previzualizare descriere', // v4.0.0
'dialog-help-summarypreview': '<code>{{PLURAL:$7}}</code> va fi înlocuit.', // Updated in v5.0.0
'dialog-label-markbot': 'Marchează revenirile drept modificări făcute de robot',
'dialog-label-watchlist': 'Adaugă țintele revenirii în lista de urmărire', // Updated in v5.1.0
'dialog-label-watchlistexpiry': 'Expiră', // Deprecated since v5.0.0
'dialog-label-watchlistexpiry-indefinite': 'Nelimitat',
'dialog-label-watchlistexpiry-1week': '1 săptămână',
'dialog-label-watchlistexpiry-1month': '1 lună',
'dialog-label-watchlistexpiry-3months': '3 luni',
'dialog-label-watchlistexpiry-6months': '6 luni',
'dialog-label-watchlistexpiry-1year': '1 an',
'dialog-button-rollback': 'Revino', // Updated in v5.0.0
'dialog-button-documentation': 'Documentație', // Added in v5.0.0
'dialog-button-config': 'Configurare', // v5.1.0
'dialog-button-selectall': 'Selectează tot', // Updated in v5.0.0
'dialog-label-selectcount': 'Selectat:', // Added in v5.0.7
'dialog-button-close': 'Închide', // Deprecated since v5.0.0
'rollback-notify-noneselected': 'Nu este bifată nicio căsuță bifabilă.',
'rollback-notify-linksresolved': 'Toate legăturile de revenire de pe această pagină au fost utilizate.',
'rollback-confirm': 'Ești sigur(ă) că vrei să revii asupra acestei modificări?',
'rollback-label-success': 'revenit',
'rollback-label-failure': 'revenire eșuată',
'rollback-notify-success': 'Succes', // v4.0.0
'rollback-notify-failure': 'Eșec', // v4.0.0
// v5.1.0 (review required)
'config-title': 'Configurare Selective Rollback',
'config-tab-local': 'Local',
'config-tab-global': 'Global',
'config-notice-local': 'Configurația locală se aplică doar acestui proiect și poate (parțial) suprascrie configurația globală dacă există.',
'config-notice-global': 'Configurația globală se aplică tuturor proiectelor și poate fi (parțial) suprascrisă de configurația locală dacă există.',
'config-default': 'Implicit',
'config-default-disabled': 'Dezactivat',
'config-default-enabled': 'Activat',
'config-label-lang': 'Limbă',
'config-help-lang': 'Limba interfeței utilizatorului setată în preferințe sau engleza dacă traducerile nu sunt disponibile.',
'config-label-summary': 'Rezumat predefinit',
'config-label-propertyinput-key': 'Cheie',
'config-label-propertyinput-value': 'Valoare',
'config-error-propertyinput-key-empty': 'Cheia nu poate fi goală.',
'config-error-propertyinput-value-empty': 'Valoarea nu poate fi goală.',
'config-error-propertyinput-key-reserved': 'Cheia „$1” este rezervată de sistem și nu este permisă.',
'config-error-propertyinput-key-duplicate': 'Cheia nu poate fi duplicată.',
'config-button-add': 'Adaugă',
'config-button-remove': 'Înlătură',
'config-button-deselectall': 'Deselectează tot',
'config-help-summary-$0': '<code>$0</code> — rezumatul implicit al revenirii pe wiki-ul local',
'config-help-summary-$1': '<code>$1</code> — numele autorului reviziei restaurate',
'config-help-summary-$2': '<code>$2</code> — numele autorului editărilor revinite',
'config-help-summary-$3': '<code>$3</code> — ID-ul reviziei la care se revine',
'config-help-summary-$4': '<code>$4</code> — timestampul reviziei la care se revine',
'config-help-summary-$5': '<code>$5</code> — ID-ul reviziei de la care se revine',
'config-help-summary-$6': '<code>$6</code> — timestampul reviziei de la care se revine',
'config-help-summary-$7': '<code>$7</code> — numărul editărilor revinite',
'config-label-showkeys': 'Folosește cheile în locul valorilor în opțiunile din meniu',
'config-label-mergesummaries': 'Combină rezumatele din configurarea globală în loc să le înlocuiești',
'config-label-replacer': 'Expresii de înlocuire',
'config-help-replacer': 'Expresii de înlocuire sunt înlocuite cu texte specifice în rezumatul revenirii. Se recomandă <b>să înceapă întotdeauna</b> cu <code>$</code> sau un simbol similar pentru a evita înlocuirile nedorite.',
'config-label-mergereplacers': 'Combină expresiile de înlocuire din configurarea globală în loc să le înlocuiești',
'config-label-watchlist': 'Listă de urmărire',
'config-label-watchlistexpiry': 'Expirarea listei de urmărire',
'config-label-confirmation': 'Confirmare revenire',
'config-label-confirmation-desktop': 'Desktop',
'config-label-confirmation-mobile': 'Mobil',
'config-label-confirmation-always': 'Confirmă întotdeauna',
'config-label-confirmation-never': 'Nu confirma niciodată',
'config-label-confirmation-RCW': 'Confirmă pe Schimbări recente sau Pagini urmărite',
'config-label-confirmation-nonRCW': 'Confirmă în afara Schimbări recente sau Pagini urmărite',
'config-label-checkboxlabelcolor': 'Culoarea etichetei checkbox-ului',
'config-help-checkboxlabelcolor': 'Previzualizare:',
'config-label-miscellaneous': 'Diverse',
'config-help-markbot': 'Această opțiune se aplică doar dacă aveți drepturile necesare pe wiki.',
'config-label-configlink': 'Generează un link de portlet către pagina de configurare',
'config-label-purger': 'Genera un link de portlet pentru curățarea cache-ului',
'config-button-save': 'Salvează',
'config-notify-save-success': 'Configurările au fost salvate.',
'config-notify-save-failure': 'Nu s-au putut salva configurările: $1',
'config-button-reset': 'Resetați',
'config-confirm-reset': 'Doriți să resetați configurațiile la valorile implicite? Modificările trebuie salvate manual.',
'config-notify-reset': 'Valorile câmpurilor au fost resetate la valorile implicite.',
'config-label-deleteglobal': 'Șterge configurația globală',
'config-help-deleteglobal-absent': 'Nu ai nicio configurație globală setată.',
'config-label-deletelocal': 'Șterge configurația locală',
'config-help-deletelocal-absent': 'Nu ai nicio configurație locală setată.',
'config-label-deletelocalall': 'Șterge configurația locală de pe toate celelalte proiecte',
'config-help-deletelocalall-present': 'Pentru a efectua această acțiune, trebuie să fii autentificat pe $1.',
'config-help-deletelocalall-absent': 'Nu ai nicio configurație locală pe alte proiecte.',
'config-label-deletedata': 'Șterge datele',
'config-button-deletedata': 'Șterge',
'config-confirm-deletedata': 'Sigur vrei să ștergi datele de configurare? Această acțiune nu poate fi anulată.',
'config-notify-deletedata-success': 'Datele de configurare specificate au fost șterse.',
'config-notify-deletedata-failure': 'Nu s-au putut șterge unele dintre datele de configurare specificate.',
},
/**
* @author [[User:Hide on Rosé]]
* @author [[User:Nvdtn19]]
* @since 4.1.0
*/
vi: {
'scriptname': 'Selective Rollback', // Added in v5.0.1
'portlet-tooltip-dialog': 'Mở hộp thoại Selective Rollback',
'portlet-label-uncacher': 'Xóa bộ nhớ đệm Selective Rollback', // v4.4.3
'dialog-label-summary': 'Tóm lược sửa đổi',
'dialog-label-summary-default': 'Tóm lược sửa đổi mặc định',
'dialog-label-summary-custom': 'Tuỳ chỉnh',
'dialog-label-summaryinput': 'Tóm lược tuỳ chỉnh',
'dialog-help-summaryinput-$0': '<code>$0</code> sẽ được thay bằng tóm lược lùi sửa mặc định.',
'dialog-help-summaryinput-$0-error': '<code>$0</code> sẽ được thay bằng tóm lược lùi sửa mặc định <b>trong tiếng Anh</b>.',
'dialog-label-summarypreview': 'Xem trước tóm lược', // v4.0.0
'dialog-help-summarypreview': '<code>{{PLURAL:$7}}</code> sẽ được thay thế.', // Updated in v5.0.0
'dialog-label-markbot': 'Đánh dấu là sửa đổi bot',
'dialog-label-watchlist': 'Thêm trang tôi lùi sửa vào danh sách theo dõi', // Updated in v5.1.0
'dialog-label-watchlistexpiry': 'Thời hạn', // Deprecated since v5.0.0
'dialog-label-watchlistexpiry-indefinite': 'Vô hạn',
'dialog-label-watchlistexpiry-1week': '1 tuần',
'dialog-label-watchlistexpiry-1month': '1 tháng',
'dialog-label-watchlistexpiry-3months': '3 tháng',
'dialog-label-watchlistexpiry-6months': '6 tháng',
'dialog-label-watchlistexpiry-1year': '1 năm',
'dialog-button-rollback': 'Lùi sửa', // Updated in v5.0.0
'dialog-button-documentation': 'Tài liệu', // Added in v5.0.0
'dialog-button-config': 'Cấu hình', // v5.1.0
'dialog-button-selectall': 'Chọn tất cả', // Updated in v5.0.0
'dialog-label-selectcount': 'Đã chọn:', // Added in v5.0.7
'dialog-button-close': 'Đóng', // Deprecated since v5.0.0
'rollback-notify-noneselected': 'Chưa chọn sửa đổi.',
'rollback-notify-linksresolved': 'Đã xử lý tất cả liên kết lùi sửa.',
'rollback-confirm': 'Bạn có muốn lùi sửa đổi này không?',
'rollback-label-success': 'đã lùi sửa',
'rollback-label-failure': 'lùi lại không thành công',
'rollback-notify-success': 'Thành công', // v4.0.0
'rollback-notify-failure': 'Không thành công', // v4.0.0
// v5.1.0
'config-title': 'Cấu hình Selective Rollback',
'config-tab-local': 'Cục bộ',
'config-tab-global': 'Toàn cục',
'config-notice-local': 'Cài đặt cục bộ chỉ áp dụng cho dự án này và có thể (một phần) ghi đè cài đặt toàn cục nếu có.',
'config-notice-global': 'Cài đặt toàn cục áp dụng cho tất cả dự án và có thể bị (một phần) ghi đè bởi cài đặt cục bộ nếu có.',
'config-default': 'Mặc định',
'config-default-disabled': 'Tắt',
'config-default-enabled': 'Bật',
'config-label-lang': 'Ngôn ngữ',
'config-help-lang': 'Dùng ngôn ngữ giao diện mà người dùng đã thiết lập trong phần Tùy chọn, nếu không có bản dịch thì mặc định là tiếng Anh.',
'config-label-summary': 'Tóm lược tùy chỉnh',
'config-label-propertyinput-key': 'Nhãn',
'config-label-propertyinput-value': 'Giá trị',
'config-error-propertyinput-key-empty': 'Không thể để trống nhãn.',
'config-error-propertyinput-value-empty': 'Không thể để trống giá trị.',
'config-error-propertyinput-key-reserved': 'Nhãn "$1" là dành riêng cho hệ thống nên không thể sử dụng.',
'config-error-propertyinput-key-duplicate': 'Nhãn không được trùng lặp.',
'config-button-add': 'Thêm',
'config-button-remove': 'Xóa',
'config-button-deselectall': 'Bỏ chọn tất cả',
'config-help-summary-$0': '<code>$0</code> — tóm lược lùi sửa mặc định của wiki cục bộ',
'config-help-summary-$1': '<code>$1</code> — tên người dùng của phiên bản được lùi về',
'config-help-summary-$2': '<code>$2</code> — tên người dùng của phiên bản bị lùi lại',
'config-help-summary-$3': '<code>$3</code> — ID phiên bản được lùi về',
'config-help-summary-$4': '<code>$4</code> — thời gian của phiên bản được lùi về',
'config-help-summary-$5': '<code>$5</code> — ID phiên bản bị lùi lại',
'config-help-summary-$6': '<code>$6</code> — thời gian của phiên bản bị lùi lại',
'config-help-summary-$7': '<code>$7</code> — số sửa đổi bị lùi lại',
'config-label-showkeys': 'Dùng nhãn thay vì giá trị trong menu thả xuống',
'config-label-mergesummaries': 'Bổ sung các tóm lược từ cấu hình toàn cục thay vì ghi đè',
'config-label-replacer': 'Thay thế biểu thức',
'config-help-replacer': 'Các biểu thức thay thế sẽ được chuyển thành văn bản tương ứng trong phần tóm lược lùi sửa. Nên <b>luôn</b> bắt đầu bằng <code>$</code> hoặc ký hiệu tương tự để tránh việc thay thế ngoài ý muốn.',
'config-label-mergereplacers': 'Bổ sung các biểu thức thay thế từ cấu hình toàn cục thay vì ghi đè',
'config-label-watchlist': 'Danh sách theo dõi',
'config-label-watchlistexpiry': 'Thời hạn trong danh sách theo dõi',
'config-label-confirmation': 'Xác nhận lùi sửa',
'config-label-confirmation-desktop': 'Máy tính',
'config-label-confirmation-mobile': 'Di động',
'config-label-confirmation-always': 'Luôn yêu cầu xác nhận',
'config-label-confirmation-never': 'Không yêu cầu xác nhận',
'config-label-confirmation-RCW': 'Yêu cầu xác nhận khi ở Thay đổi gần đây hoặc Danh sách theo dõi',
'config-label-confirmation-nonRCW': 'Yêu cầu xác nhận khi không ở Thay đổi gần đây hoặc Danh sách theo dõi',
'config-label-checkboxlabelcolor': 'Màu nhãn hộp kiểm',
'config-help-checkboxlabelcolor': 'Xem trước:',
'config-label-miscellaneous': 'Khác',
'config-help-markbot': 'Tùy chọn này chỉ được áp dụng khi bạn có quyền cần thiết trên wiki.',
'config-label-configlink': 'Tạo liên kết portlet đến trang cấu hình',
'config-label-purger': 'Tạo liên kết portlet để xóa bộ nhớ đệm',
'config-button-save': 'Lưu',
'config-notify-save-success': 'Đã lưu cấu hình.',
'config-notify-save-failure': 'Không thể lưu cấu hình: $1',
'config-button-reset': 'Đặt lại',
'config-confirm-reset': 'Bạn có muốn đặt lại các cấu hình về giá trị mặc định không? Các thay đổi cần được lưu thủ công.',
'config-notify-reset': 'Các giá trị của trường đã được đặt lại về giá trị mặc định.',
'config-label-deleteglobal': 'Xóa cấu hình toàn cục',
'config-help-deleteglobal-absent': 'Bạn chưa thiết lập bất kỳ cấu hình toàn cục nào.',
'config-label-deletelocal': 'Xóa cấu hình cục bộ',
'config-help-deletelocal-absent': 'Bạn chưa thiết lập bất kỳ cấu hình cục bộ nào.',
'config-label-deletelocalall': 'Xóa cấu hình cục bộ trên tất cả các dự án khác',
'config-help-deletelocalall-present': 'Để thực hiện thao tác này, bạn cần đăng nhập vào $1.',
'config-help-deletelocalall-absent': 'Bạn không có cấu hình cục bộ nào trên các dự án khác.',
'config-label-deletedata': 'Xóa dữ liệu',
'config-button-deletedata': 'Xóa',
'config-confirm-deletedata': 'Bạn có chắc chắn muốn xóa dữ liệu cấu hình không? Bạn không thể hoàn tác hành động này.',
'config-notify-deletedata-success': 'Đã xóa dữ liệu cấu hình được chọn.',
'config-notify-deletedata-failure': 'Không thể xóa một số dữ liệu cấu hình được chọn.',
},
/**
* @author [[User:Gerges]]
* @since 5.0.1
*/
ar: {
'scriptname': 'للتراجع الانتقائي', // Added in v5.0.1
'portlet-tooltip-dialog': 'فتح نافذة التراجع الانتقائي',
'portlet-label-uncacher': 'تطهير ذاكرة التخزين المؤقت للتراجع الانتقائي', // v4.4.3
'dialog-label-summary': 'ملخص التعديل',
'dialog-label-summary-default': 'ملخص التعديل الافتراضي',
'dialog-label-summary-custom': 'مخصص',
'dialog-label-summaryinput': 'ملخص تعديل مخصص',
'dialog-help-summaryinput-$0': '<code>$0</code> سيتم استبداله بملخص التراجع الافتراضي.',
'dialog-help-summaryinput-$0-error': '<code>$0</code> سيتم استبداله بملخص التراجع الافتراضي <b>باللغة الإنجليزية</b>.',
'dialog-label-summarypreview': 'معاينة الملخص', // v4.0.0
'dialog-help-summarypreview': 'سيتم استبدال الكلمات السحرية (مثل <code>{{PLURAL:$7}}</code>).', // Updated in v5.0.0
'dialog-label-markbot': 'تمييز التراجعات كتحريرات بوت',
'dialog-label-watchlist': 'أضف صفحات التراجع إلى قائمة المراقبة', // Updated in v5.1.0
'dialog-label-watchlistexpiry': 'مدة الصلاحية', // Deprecated since v5.0.0
'dialog-label-watchlistexpiry-indefinite': 'غير محددة',
'dialog-label-watchlistexpiry-1week': 'أسبوع واحد',
'dialog-label-watchlistexpiry-1month': 'شهر واحد',
'dialog-label-watchlistexpiry-3months': '3 أشهر',
'dialog-label-watchlistexpiry-6months': '6 أشهر',
'dialog-label-watchlistexpiry-1year': 'سنة واحدة',
'dialog-button-rollback': 'تراجع عن العناصر المحددة', // Updated in v5.0.0
'dialog-button-documentation': 'التوثيق', // Added in v5.0.0
'dialog-button-config': 'الإعداد', // v5.1.0
'dialog-button-selectall': 'تحديد الكل', // Updated in v5.0.0
'dialog-label-selectcount': 'المحدد:', // Added in v5.0.7
'dialog-button-close': 'إغلاق', // Deprecated since v5.0.0
'rollback-notify-noneselected': 'لم يتم تحديد أي مربع اختيار.',
'rollback-notify-linksresolved': 'تم حل جميع روابط التراجع في هذه الصفحة.',
'rollback-confirm': 'هل أنت متأكد أنك تريد التراجع عن هذا التعديل؟',
'rollback-label-success': 'تم التراجع',
'rollback-label-failure': 'فشل التراجع',
'rollback-notify-success': 'تم بنجاح', // v4.0.0
'rollback-notify-failure': 'فشل', // v4.0.0
// v5.1.0 (review required)
'config-title': 'تهيئة التراجع الانتقائي',
'config-tab-local': 'محلي',
'config-tab-global': 'عام',
'config-notice-local': 'الإعدادات المحلية تنطبق فقط على هذا المشروع وقد (جزئياً) تتجاوز الإعدادات العامة إن وُجدت.',
'config-notice-global': 'الإعدادات العامة تنطبق على جميع المشاريع وقد يتم (جزئياً) تجاوزها بواسطة الإعدادات المحلية إن وُجدت.',
'config-default': 'افتراضي',
'config-default-disabled': 'معطل',
'config-default-enabled': 'مفعل',
'config-label-lang': 'اللغة',
'config-help-lang': 'لغة واجهة المستخدم كما هي في التفضيلات، أو الإنجليزية إذا لم تتوفر ترجمات.',
'config-label-summary': 'ملخصات جاهزة',
'config-label-propertyinput-key': 'المفتاح',
'config-label-propertyinput-value': 'القيمة',
'config-error-propertyinput-key-empty': 'يجب ألا يكون المفتاح فارغًا.',
'config-error-propertyinput-value-empty': 'يجب ألا تكون القيمة فارغة.',
'config-error-propertyinput-key-reserved': 'المفتاح "$1" محجوز من قبل النظام ولذلك لا يُسمح باستخدامه.',
'config-error-propertyinput-key-duplicate': 'يجب ألا يكون المفتاح مكررًا.',
'config-button-add': 'إضافة',
'config-button-remove': 'إزالة',
'config-button-deselectall': 'إلغاء تحديد الكل',
'config-help-summary-$0': '<code>$0</code> — ملخص التراجع الافتراضي في هذا الويكي',
'config-help-summary-$1': '<code>$1</code> — اسم مستخدم صاحب المراجعة المُستعادة',
'config-help-summary-$2': '<code>$2</code> — اسم مستخدم صاحب المراجعات المُتراجَع عنها',
'config-help-summary-$3': '<code>$3</code> — رقم المراجعة التي تم التراجع إليها',
'config-help-summary-$4': '<code>$4</code> — الطابع الزمني للمراجعة التي تم التراجع إليها',
'config-help-summary-$5': '<code>$5</code> — رقم المراجعة التي تم التراجع منها',
'config-help-summary-$6': '<code>$6</code> — الطابع الزمني للمراجعة التي تم التراجع منها',
'config-help-summary-$7': '<code>$7</code> — عدد التعديلات التي تم التراجع عنها',
'config-label-showkeys': 'استخدم المفاتيح بدلاً من القيم في خيارات القائمة المنسدلة',
'config-label-mergesummaries': 'دمج الملخصات من الإعدادات العالمية بدلاً من استبدالها',
'config-label-replacer': 'عبارات الاستبدال',
'config-help-replacer': 'تُستبدل الكلمات المفتاحية بنصوص معينة في ملخص التراجع. يُفضل <b>دائماً</b> أن تبدأ بـ <code>$</code> أو رمز مشابه لتجنب الاستبدالات غير المقصودة.',
'config-label-mergereplacers': 'دمج تعبيرات الاستبدال من الإعدادات العالمية بدلاً من استبدالها',
'config-label-watchlist': 'قائمة المراقبة',
'config-label-watchlistexpiry': 'مدة قائمة المراقبة',
'config-label-confirmation': 'تأكيد التراجع',
'config-label-confirmation-desktop': 'سطح المكتب',
'config-label-confirmation-mobile': 'الجوال',
'config-label-confirmation-always': 'التأكيد دائماً',
'config-label-confirmation-never': 'عدم التأكيد',
'config-label-confirmation-RCW': 'التأكيد عند التواجد في أحدث التغييرات أو قائمة المراقبة',
'config-label-confirmation-nonRCW': 'التأكيد عند عدم التواجد في أحدث التغييرات أو قائمة المراقبة',
'config-label-checkboxlabelcolor': 'لون تسمية مربع الاختيار',
'config-help-checkboxlabelcolor': 'معاينة:',
'config-label-miscellaneous': 'متفرقات',
'config-help-markbot': 'ينطبق هذا الخيار فقط إذا كان لديك الصلاحيات اللازمة في الويكي.',
'config-label-configlink': 'إنشاء رابط منفذ إلى صفحة الإعدادات',
'config-label-purger': 'إنشاء رابط بورتلت لمسح ذاكرة التخزين المؤقت',
'config-button-save': 'حفظ',
'config-notify-save-success': 'تم حفظ الإعدادات.',
'config-notify-save-failure': 'فشل حفظ الإعدادات: $1',
'config-button-reset': 'إعادة التعيين',
'config-confirm-reset': 'هل تريد إعادة تعيين الإعدادات إلى القيم الافتراضية؟ يجب حفظ التغييرات يدويًا.',
'config-notify-reset': 'تمت إعادة تعيين قيم الحقول إلى القيم الافتراضية.',
'config-label-deleteglobal': 'حذف الإعدادات العامة',
'config-help-deleteglobal-absent': 'ليس لديك أي إعدادات عامة مُكوّنة.',
'config-label-deletelocal': 'حذف الإعدادات المحلية',
'config-help-deletelocal-absent': 'ليس لديك أي إعدادات محلية مُكوّنة.',
'config-label-deletelocalall': 'حذف الإعدادات المحلية في جميع المشاريع الأخرى',
'config-help-deletelocalall-present': 'لإتمام هذه العملية، يجب أن تسجل الدخول في $1.',
'config-help-deletelocalall-absent': 'ليس لديك أي إعدادات محلية في المشاريع الأخرى.',
'config-label-deletedata': 'حذف البيانات',
'config-button-deletedata': 'حذف',
'config-confirm-deletedata': 'هل أنت متأكد أنك تريد حذف بيانات الإعداد؟ لا يمكن التراجع عن هذا الإجراء.',
'config-notify-deletedata-success': 'تم حذف بيانات الإعداد المحددة.',
'config-notify-deletedata-failure': 'فشل حذف بعض بيانات الإعداد المحددة.',
}
};
SelectiveRollback.regex = {
/** Adapted from {@link https://github.com/wikimedia/mediawiki-extensions-MobileDetect/blob/master/src/Hooks.php}. */
mobile: new RegExp(
// iPod/iPhone
'ipod|iphone|' +
// Android
'android|' +
// Opera Mini/Mobile
'opera mini|' +
// Blackberry
'blackberry|' +
// Palm OS
'pre/|palm os|palm|hiptop|avantgo|plucker|xiino|blazer|elaine|' +
// Windows Mobile
'iris|3g_t|windows ce|opera mobi|windows ce; smartphone;|windows ce; iemobile|' +
// Other generic terms
'mini 9.5|vx1000|lge|m800|e860|u940|ux840|compal|wireless|mobi|ahong|lg380|lgku|lgu900|lg210|lg47|lg920|lg840|lg370|sam-r|mg50|s55|g83|t66|vx400|mk99|d615|d763|el370|sl900|mp500|samu3|samu4|vx10|xda_|samu5|samu6|samu7|samu9|a615|b832|m881|s920|n210|s700|c-810|_h797|mob-x|sk16d|848b|mowser|s580|r800|471x|v120|rim8|c500foma:|160x|x160|480x|x640|t503|w839|i250|sprint|w398samr810|m5252|c7100|mt126|x225|s5330|s820|htil-g1|fly v71|s302|-x113|novarra|k610i|-three|8325rc|8352rc|sanyo|vx54|c888|nx250|n120|mtk|c5588|s710|t880|c5005|i;458x|p404i|s210|c5100|teleca|s940|c500|s590|foma|samsu|vx8|vx9|a1000|_mms|myx|a700|gu1100|bc831|e300|ems100|me701|me702m-three|sd588|s800|8325rc|ac831|mw200|brew|d88|htc/|htc_touch|355x|m50|km100|d736|p-9521|telco|sl74|ktouch|m4u/|me702|8325rc|kddi|phone|lg|sonyericsson|samsung|240x|x320|vx10|nokia|sony cmd|motorola|up.browser|up.link|mmp|symbian|smartphone|midp|wap|vodafone|o2|pocket|kindle|mobile|psp|treo|' +
// First 4 letters
'^(1207|3gso|4thp|501i|502i|503i|504i|505i|506i|6310|6590|770s|802s|a wa|acer|acs-|airn|alav|asus|attw|au-m|aur |aus |abac|acoo|aiko|alco|alca|amoi|anex|anny|anyw|aptu|arch|argo|bell|bird|bw-n|bw-u|beck|benq|bilb|blac|c55/|cdm-|chtm|capi|cond|craw|dall|dbte|dc-s|dica|ds-d|ds12|dait|devi|dmob|doco|dopo|el49|erk0|esl8|ez40|ez60|ez70|ezos|ezze|elai|emul|eric|ezwa|fake|fly-|fly_|g-mo|g1 u|g560|gf-5|grun|gene|go.w|good|grad|hcit|hd-m|hd-p|hd-t|hei-|hp i|hpip|hs-c|htc |htc-|htca|htcg|htcp|htcs|htct|htc_|haie|hita|huaw|hutc|i-20|i-go|i-ma|i230|iac|iac-|iac/|ig01|im1k|inno|iris|jata|java|kddi|kgt|kgt/|kpt |kwc-|klon|lexi|lg g|lg-a|lg-b|lg-c|lg-d|lg-f|lg-g|lg-k|lg-l|lg-m|lg-o|lg-p|lg-s|lg-t|lg-u|lg-w|lg/k|lg/l|lg/u|lg50|lg54|lge-|lge/|lynx|leno|m1-w|m3ga|m50/|maui|mc01|mc21|mcca|medi|meri|mio8|mioa|mo01|mo02|mode|modo|mot |mot-|mt50|mtp1|mtv |mate|maxo|merc|mits|mobi|motv|mozz|n100|n101|n102|n202|n203|n300|n302|n500|n502|n505|n700|n701|n710|nec-|nem-|newg|neon|netf|noki|nzph|o2 x|o2-x|opwv|owg1|opti|oran|p800|pand|pg-1|pg-2|pg-3|pg-6|pg-8|pg-c|pg13|phil|pn-2|pt-g|palm|pana|pire|pock|pose|psio|qa-a|qc-2|qc-3|qc-5|qc-7|qc07|qc12|qc21|qc32|qc60|qci-|qwap|qtek|r380|r600|raks|rim9|rove|s55/|sage|sams|sc01|sch-|scp-|sdk/|se47|sec-|sec0|sec1|semc|sgh-|shar|sie-|sk-0|sl45|slid|smb3|smt5|sp01|sph-|spv |spv-|sy01|samm|sany|sava|scoo|send|siem|smar|smit|soft|sony|t-mo|t218|t250|t600|t610|t618|tcl-|tdg-|telm|tim-|ts70|tsm-|tsm3|tsm5|tx-9|tagt|talk|teli|topl|hiba|up.b|upg1|utst|v400|v750|veri|vk-v|vk40|vk50|vk52|vk53|vm40|vx98|virg|vite|voda|vulc|w3c |w3c-|wapj|wapp|wapu|wapm|wig |wapi|wapr|wapv|wapy|wapa|waps|wapt|winc|winw|wonu|x700|xda2|xdag|yas-|your|zte-|zeto|acs-|alav|alca|amoi|aste|audi|avan|benq|bird|blac|blaz|brew|brvw|bumb|ccwa|cell|cldc|cmd-|dang|doco|eml2|eric|fetc|hipt|http|ibro|idea|ikom|inno|ipaq|jbro|jemu|java|jigs|kddi|keji|kyoc|kyok|leno|lg-c|lg-d|lg-g|lge-|libw|m-cr|maui|maxo|midp|mits|mmef|mobi|mot-|moto|mwbp|mywa|nec-|newt|nok6|noki|o2im|opwv|palm|pana|pant|pdxg|phil|play|pluc|port|prox|qtek|qwap|rozo|sage|sama|sams|sany|sch-|sec-|send|seri|sgh-|shar|sie-|siem|smal|smar|sony|sph-|symb|t-mo|teli|tim-|tosh|treo|tsm-|upg1|upsi|vk-v|voda|vx52|vx53|vx60|vx61|vx70|vx80|vx81|vx83|vx85|wap-|wapa|wapi|wapp|wapr|webc|whit|winw|wmlb|xda-)',
'i'
),
/**
* * `$0` - `'/wiki/<title>'`
* * `$1` - `'<title>'`
*/
article: new RegExp(mw.config.get('wgArticlePath').replace('$1', '([^#?]+)'))
};
/**
* Index assigned to each rollback link.
*/
SelectiveRollback.index = -1;
/**
* Keys for `mw.storage`.
*/
SelectiveRollback.storageKeys = {
autocomplete: 'mw-SelectiveRollback-autocomplete',
summary: 'mw-SelectiveRollback-summary',
rights: 'mw-SelectiveRollback-rights'
};
/**
* Removes unicode bidirectional characters from the given string and trims it.
* @param {string} str
* @returns {string}
*/
function clean(str) {
return str.replace(/[\u200E\u200F\u202A-\u202E]+/g, '').trim();
}
class SelectiveRollbackConfig {
/**
* Creates a portlet link to the config page.
*
* This should be called after {@link SelectiveRollback.createCachePurger}.
*/
static createPortletLink() {
mw.util.addPortletLink(
mw.config.get('skin') === 'minerva' ? 'p-personal' : 'p-cactions',
mw.util.getUrl('Special:SelectiveRollbackConfig'),
msg['config-title'],
'ca-sr-config',
void 0,
void 0,
document.getElementById('ca-sr-uncacher') || '#ca-move'
);
}
/**
* Returns the default configurations.
* @returns {Required<SelectiveRollbackConfigObject>}
* @private
*/
static get defaults() {
return {
lang: '',
editSummaries: Object.create(null),
showKeys: false,
mergeSummaries: false,
replacementExpressions: Object.create(null),
mergeReplacers: false,
watchlist: false,
watchlistExpiry: 'indefinite',
desktopConfirm: 'never',
mobileConfirm: 'always',
checkboxLabelColor: 'orange',
markBot: true,
configLink: false,
purgerLink: false
};
}
/**
* Returns a configuration object retrieved when all the field values are the default ones.
* @returns {Record<keyof SelectiveRollbackConfigObject, ?boolean>}
* @private
*/
get fieldDefaults() {
return {
lang: null,
editSummaries: null,
showKeys: false,
mergeSummaries: false,
replacementExpressions: null,
mergeReplacers: false,
watchlist: false,
watchlistExpiry: null,
desktopConfirm: null,
mobileConfirm: null,
checkboxLabelColor: null,
markBot: true,
configLink: false,
purgerLink: false
};
}
/**
* Retrieves `SelectiveRollbackConfigObject`, generated by merging all configuration objects.
* @returns {Required<SelectiveRollbackConfigObject>}
*/
static getMerged() {
const local = this.get('local');
const global = this.get('global');
const legacy = global ? null : this.getLegacy(); // Disregard legacy config if already migrated
const cfg = this.defaults;
for (const obj of [legacy, global, local]) {
if (!obj) {
continue;
}
/**
* @param {string} key
* @returns {boolean}
*/
const shouldMergeObject = (key) => {
return key !== 'editSummaries' ||
('mergeSummaries' in obj && !!obj.mergeSummaries) ||
('mergeReplacers' in obj && !!obj.mergeReplacers);
};
for (const [key, value] of Object.entries(obj)) {
if (!(key in cfg)) {
console.error('Unknown config key: ' + key);
continue;
}
if (value === undefined || value === null) {
continue;
}
if (isObject(value) && shouldMergeObject(key)) {
// @ts-expect-error
Object.assign(cfg[key], value);
} else {
// @ts-expect-error
cfg[key] = value;
}
}
}
return cfg;
}
/**
* Retrieves `SelectiveRollbackConfigObject` for the given domain.
* @type {ConfigRetriever}
*/
static get(domain) {
/** @type {?string} */
let rawCfg = mw.user.options.get(this.keys[domain]);
if (!rawCfg) {
// @ts-expect-error
return null;
}
try {
return JSON.parse(rawCfg);
} catch (_) {
// @ts-expect-error
return null;
}
}
/**
* Sanitizes and retrieves the legacy config if defined by the user.
* @returns {?SelectiveRollbackConfigObjectLegacy}
* @private
*/
static getLegacy() {
const userCfg = window.selectiveRollbackConfig;
if (!isObject(userCfg)) {
return null;
}
if (!this.deprecatedConfigWarned) {
console.warn('Use of window.selectiveRollbackConfig has been deprecated. Please use Special:SelectiveRollbackConfig instead.');
this.deprecatedConfigWarned = true;
}
/**
* Checks whether a config value is of the expected type.
* @type {IsOfType}
*/
const isOfType = (expectedType, value, key) => {
const valType = value === null ? 'null' : typeof value;
if (valType !== expectedType) {
console.error(`[SR] TypeError: ${expectedType} expected for "${key}", ${valType} given`);
return false;
} else {
return true;
}
};
/**
* @param {string} key
* @param {unknown} value
*/
const errKeyVal = (key, value) => {
console.error(`[SR] Invalid config value for "${key}"`, value);
};
const keyConvertMap = {
specialExpressions: 'replacementExpressions',
watchPage: 'watchlist',
watchExpiry: 'watchlistExpiry',
confirm: 'desktopConfirm'
};
/** @type {SelectiveRollbackConfigObjectLegacy} */
const cfg = Object.create(null);
const confirmVals = new Set(['never', 'always', 'RCW', 'nonRCW']);
for (let [key, val] of Object.entries(userCfg)) {
key = clean(key);
if (typeof val === 'string') {
val = clean(val);
}
// Strict type check
if (val === null || val === undefined) {
errKeyVal(key, val);
continue;
}
switch (key) {
case 'lang':
case 'watchExpiry':
case 'checkboxLabelColor':
if (!isOfType('string', val, key)) continue;
break;
case 'confirm':
case 'mobileConfirm':
if (!isOfType('string', val, key)) continue;
if (!confirmVals.has(/** @type {any} */ (val))) {
errKeyVal(key, val);
continue;
}
break;
case 'editSummaries':
case 'specialExpressions':
if (!isOfType('object', val, key)) continue;
break;
case 'showKeys':
case 'markBot':
case 'watchPage':
if (!isOfType('boolean', val, key)) continue;
break;
default:
console.error(`[SR] Invalid config key: ${key}`);
continue;
}
if (key === 'watchExpiry') { // Some typo fix
let v = String(val);
let m;
if (/^(in|never)/.test(v)) {
v = 'indefinite';
} else if ((m = /^1\s*(week|month|year)/.exec(v))) {
v = '1 ' + m[1];
} else if ((m = /^([36])\s*month/.exec(v))) {
v = m[1] + ' months';
} else {
errKeyVal(key, val);
continue;
}
val = v;
}
// @ts-expect-error
key = keyConvertMap[key] || key;
// @ts-expect-error
cfg[key] = val;
}
return cfg;
}
/**
* Initializes `Special:SelectiveRollbackConfig`.
* @returns {void}
*/
static init() {
const pageName = msg['config-title'];
document.title = pageName + ' - ' + mw.config.get('wgSiteName');
const $heading = $('.mw-first-heading');
const $content = $('.mw-body-content');
if (!$heading.length || !$content.length) {
return;
}
$heading.text(pageName).attr({ dir });
$('.vector-page-toolbar').attr({ dir });
$content.attr({ dir });
const globalTabPanel = new OO.ui.TabPanelLayout('Global', {
expanded: false,
label: msg['config-tab-global'],
scrollable: false
});
const localTabPanel = new OO.ui.TabPanelLayout('Local', {
expanded: false,
label: msg['config-tab-local'],
scrollable: false
});
const miscTabPanel = new OO.ui.TabPanelLayout('Misc', {
expanded: false,
label: msg['config-label-miscellaneous'],
scrollable: false
});
const index = new OO.ui.IndexLayout({
expanded: false,
framed: false
}).addTabPanels([globalTabPanel, localTabPanel, miscTabPanel], 0);
const $overlay = $('<div>').addClass('sr-config-overlay').hide();
$content
.empty()
.append($overlay, index.$element)
.css({ position: 'relative' });
const miscTab = new SelectiveRollbackConfigMisc($overlay);
const globalTab = new this('global', $overlay, miscTab);
const localTab = new this('local', $overlay, miscTab);
globalTabPanel.$element.append(
new OO.ui.MessageWidget({
classes: ['sr-config-notice'],
type: 'notice',
label: msg['config-notice-global']
}).$element,
globalTab.$element
);
localTabPanel.$element.append(
new OO.ui.MessageWidget({
classes: ['sr-config-notice'],
type: 'notice',
label: msg['config-notice-local']
}).$element,
localTab.$element
);
miscTabPanel.$element.append(
miscTab.$element
);
const dirMismatch = document.dir !== dir;
if (dirMismatch) {
this.handleDirMismatch();
}
const beforeunloadMap = {
local: localTab,
global: globalTab
};
window.onbeforeunload = (e) => {
const unsaved = Object.entries(beforeunloadMap).some(([k, field]) => {
const key = /** @type {'local' | 'global'} */ (k);
return !objectsEqual(this.get(key), field.retrieve());
});
if (unsaved) {
e.preventDefault();
e.returnValue = 'You have unsaved changes. Do you want to leave the page?';
}
};
}
/**
* @private
*/
static handleDirMismatch() {
document.documentElement.classList.add('sr-config');
const uiStart = dir === 'rtl' ? 'right' : 'left';
const uiEnd = dir === 'rtl' ? 'left' : 'right';
const style = document.createElement('style');
if (langSwitch === 'ar') {
style.textContent =
'.sr-config .mw-page-container {' +
'font-family: system-ui;' +
'}';
}
style.textContent +=
'.sr-config .vector-dropdown .vector-dropdown-checkbox {' +
`${uiStart}: 0;` +
`${uiEnd}: unset;` +
'}' +
'.sr-config #right-navigation .vector-dropdown-content {' +
`${uiStart}: auto;` +
`${uiEnd}: 0;` +
'}' +
'.sr-config .oo-ui-tabSelectWidget {' +
'text-align: unset;' +
'}' +
'.sr-config .oo-ui-messageWidget > .oo-ui-labelElement-label {' +
`margin-${uiStart}: 1.99999997em;` +
`margin-${uiEnd}: unset;` +
'}' +
'.sr-config .oo-ui-fieldLayout .oo-ui-fieldLayout-help {' +
`float: ${uiEnd};` +
'}' +
'.sr-config .oo-ui-fieldLayout.oo-ui-fieldLayout-align-top .oo-ui-fieldLayout-help,' +
'.sr-config .oo-ui-fieldLayout.oo-ui-fieldLayout-align-inline .oo-ui-fieldLayout-help {' +
`margin-${uiStart}: 0;` +
`margin-${uiEnd}: -8px;` +
'}' +
'.sr-config .oo-ui-textInputWidget > .oo-ui-indicatorElement-indicator,' +
'.sr-config .oo-ui-textInputWidget-labelPosition-after > .oo-ui-labelElement-label {' +
`${uiStart}: unset;` +
`${uiEnd}: 0;` +
'}' +
'.sr-config .sr-config-buttoncontainer > .oo-ui-buttonWidget {' +
`margin-${uiStart}: unset;` +
`margin-${uiEnd}: 8px;` +
'}' +
'.sr-config .oo-ui-fieldLayout.oo-ui-labelElement.oo-ui-fieldLayout-align-inline > .oo-ui-fieldLayout-body > .oo-ui-fieldLayout-header {' +
`padding-${uiStart}: 6px;` +
`padding-${uiEnd}: 0;` +
'}' +
'';
document.head.appendChild(style);
}
/**
* @param {Exclude<ConfigDomain, 'localexists'>} domain
* @param {JQuery<HTMLElement>} $overlay
* @param {SelectiveRollbackConfigMisc} miscTab
* @private
*/
constructor(domain, $overlay, miscTab) {
/** @type {SelectiveRollbackConfigObject} */
const cfg = SelectiveRollbackConfig.get(domain) || Object.create(null);
/** @type {OO.ui.Element[]} */
const items = [];
/** @param {keyof Messages} key */
const helpTextForDefaultValueByKey = (key) => {
return msg['config-default'] + ': ' + msg[key];
};
/** @param {string} value */
const helpTextForDefaultValueByValue = (value) => {
return msg['config-default'] + ': ' + value;
};
const defaultDropdownOption = () => {
return new OO.ui.MenuOptionWidget({
data: null,
label: `(${msg['config-default']})`
});
};
/**
* @type {JQuery<HTMLElement>}
* @readonly
* @private
*/
this.$overlay = $overlay;
/**
* @type {Exclude<ConfigDomain, 'localexists'>}
* @readonly
* @private
*/
this.domain = domain;
const isLocal = domain === 'local';
/**
* @type {SelectiveRollbackConfigMisc}
* @readonly
* @private
*/
this.miscTab = miscTab;
this.miscTab.onConfigDeleted((types) => {
if (types.includes(this.domain)) {
this.resetFields();
}
});
/**
* @type {OO.ui.DropdownWidget}
* @readonly
* @private
*/
this.lang = new OO.ui.DropdownWidget({
menu: {
items: [
defaultDropdownOption(),
...Object.keys(SelectiveRollback.i18n).map((key) => {
return new OO.ui.MenuOptionWidget({ data: key, label: key });
})
]
},
});
this.lang.getMenu().selectItemByData(cfg.lang || null);
items.push(
new OO.ui.FieldLayout(this.lang, {
align: 'top',
label: $headingLabel().text(msg['config-label-lang']),
help: helpTextForDefaultValueByKey('config-help-lang'),
helpInline: true
})
);
/**
* @type {KeyValueCollection}
* @readonly
* @private
*/
this.editSummaries = new KeyValueCollection(new Set(['other']));
if (cfg.editSummaries) {
for (const [key, value] of Object.entries(cfg.editSummaries)) {
this.editSummaries.add(key, value);
}
}
items.push(
new OO.ui.FieldLayout(this.editSummaries.widget, {
align: 'top',
help: new OO.ui.HtmlSnippet(
'<ul>' +
// @ts-expect-error
[...Array(8)].map((_, i) => '<li>' + msg[`config-help-summary-$${i}`] + '</li>').join('') +
'</ul>'
),
label: $headingLabel().text(msg['config-label-summary'])
}),
new OO.ui.FieldLayout(this.editSummaries.buttons, {
classes: ['sr-config-propertyfield-buttoncontainer']
})
);
/**
* @type {OO.ui.CheckboxInputWidget}
* @readonly
* @private
*/
this.showKeys = new OO.ui.CheckboxInputWidget({
selected: cfg.showKeys
});
items.push(
new OO.ui.FieldLayout(this.showKeys, {
align: 'inline',
label: msg['config-label-showkeys'],
help: helpTextForDefaultValueByKey('config-default-disabled'),
helpInline: true
})
);
/**
* @type {OO.ui.CheckboxInputWidget}
* @readonly
* @private
*/
this.mergeSummaries = new OO.ui.CheckboxInputWidget({
selected: cfg.mergeSummaries
});
if (isLocal) {
items.push(
new OO.ui.FieldLayout(this.mergeSummaries, {
align: 'inline',
label: msg['config-label-mergesummaries'],
help: helpTextForDefaultValueByKey('config-default-disabled'),
helpInline: true
})
);
}
/**
* @type {KeyValueCollection}
* @readonly
* @private
*/
this.replacementExpressions = new KeyValueCollection();
if (cfg.replacementExpressions) {
for (const [key, value] of Object.entries(cfg.replacementExpressions)) {
this.replacementExpressions.add(key, value);
}
}
items.push(
new OO.ui.FieldLayout(this.replacementExpressions.widget, {
align: 'top',
label: $headingLabel().text(msg['config-label-replacer']),
help: new OO.ui.HtmlSnippet(msg['config-help-replacer'])
}),
new OO.ui.FieldLayout(this.replacementExpressions.buttons, {
classes: ['sr-config-propertyfield-buttoncontainer']
})
);
/**
* @type {OO.ui.CheckboxInputWidget}
* @readonly
* @private
*/
this.mergeReplacers = new OO.ui.CheckboxInputWidget({
selected: cfg.mergeReplacers
});
if (isLocal) {
items.push(
new OO.ui.FieldLayout(this.mergeReplacers, {
align: 'inline',
label: msg['config-label-mergereplacers'],
help: helpTextForDefaultValueByKey('config-default-disabled'),
helpInline: true
})
);
}
/**
* @type {OO.ui.CheckboxInputWidget}
* @readonly
* @private
*/
this.watchlist = new OO.ui.CheckboxInputWidget({
selected: cfg.watchlist
});
items.push(
new OO.ui.FieldLayout(
new OO.ui.LabelWidget({
label: $headingLabel().text(msg['config-label-watchlist'])
})
),
new OO.ui.FieldLayout(this.watchlist, {
align: 'inline',
label: msg['dialog-label-watchlist'],
help: helpTextForDefaultValueByKey('config-default-disabled'),
helpInline: true
})
);
/**
* @type {OO.ui.DropdownWidget}
* @readonly
* @private
*/
this.watchlistExpiry = new OO.ui.DropdownWidget({
menu: {
items: [
defaultDropdownOption(),
...getWatchlistExpiryOptions()
]
},
});
this.watchlistExpiry.getMenu().selectItemByData(cfg.watchlistExpiry || null);
items.push(
new OO.ui.FieldLayout(this.watchlistExpiry, {
align: 'top',
label: msg['config-label-watchlistexpiry'],
help: helpTextForDefaultValueByKey('dialog-label-watchlistexpiry-indefinite'),
helpInline: true
})
);
const generateConfirmationOptions = () => {
/** @type {SRConfirm[]} */
const values = ['always', 'never', 'RCW', 'nonRCW'];
const options = values.map((val) => {
return new OO.ui.MenuOptionWidget({
data: val,
label: msg[`config-label-confirmation-${val}`]
});
});
return [defaultDropdownOption()].concat(options);
};
/**
* @type {OO.ui.DropdownWidget}
* @readonly
* @private
*/
this.desktopConfirm = new OO.ui.DropdownWidget({
menu: {
items: generateConfirmationOptions()
}
});
this.desktopConfirm.getMenu().selectItemByData(cfg.desktopConfirm || null);
/**
* @type {OO.ui.DropdownWidget}
* @readonly
* @private
*/
this.mobileConfirm = new OO.ui.DropdownWidget({
menu: {
items: generateConfirmationOptions()
}
});
this.mobileConfirm.getMenu().selectItemByData(cfg.mobileConfirm || null);
items.push(
new OO.ui.FieldLayout(
new OO.ui.LabelWidget({
label: $headingLabel().text(msg['config-label-confirmation'])
})
),
new OO.ui.FieldLayout(this.desktopConfirm, {
align: 'top',
label: msg['config-label-confirmation-desktop'],
help: helpTextForDefaultValueByKey('config-label-confirmation-never'),
helpInline: true
}),
new OO.ui.FieldLayout(this.mobileConfirm, {
align: 'top',
label: msg['config-label-confirmation-mobile'],
help: helpTextForDefaultValueByKey('config-label-confirmation-always'),
helpInline: true
})
);
/**
* @type {OO.ui.TextInputWidget}
* @readonly
* @private
*/
this.checkboxLabelColor = new OO.ui.TextInputWidget({
value: cfg.checkboxLabelColor
});
const labelColorPreviewId = 'sr-config-labelcolor-preview-' + domain;
let /** @type {NodeJS.Timeout} */ labelColorTimeout;
this.checkboxLabelColor.on('change', (value) => {
clearTimeout(labelColorTimeout);
labelColorTimeout = setTimeout(() => {
$('#' + labelColorPreviewId).css({ color: clean(value) || 'orange' });
}, 500);
});
this.checkboxLabelColor.emit('change', this.checkboxLabelColor.getValue());
items.push(
new OO.ui.FieldLayout(this.checkboxLabelColor, {
align: 'top',
label: $headingLabel().text(msg['config-label-checkboxlabelcolor']),
help: new OO.ui.HtmlSnippet(
'(' + helpTextForDefaultValueByValue('orange') + ') ' +
msg['config-help-checkboxlabelcolor'] + ` <b id="${labelColorPreviewId}">SR</b>`
),
helpInline: true
})
);
/**
* @type {OO.ui.CheckboxInputWidget}
* @readonly
* @private
*/
this.markBot = new OO.ui.CheckboxInputWidget({
selected: typeof cfg.markBot === 'boolean' ? cfg.markBot : true
});
items.push(
new OO.ui.FieldLayout(
new OO.ui.LabelWidget({
label: $headingLabel().text(msg['config-label-miscellaneous'])
})
),
new OO.ui.FieldLayout(this.markBot, {
align: 'inline',
label: msg['dialog-label-markbot'],
help: '(' + helpTextForDefaultValueByKey('config-default-enabled') + ') ' +
msg['config-help-markbot'],
helpInline: true
})
);
/**
* @type {OO.ui.CheckboxInputWidget}
* @readonly
* @private
*/
this.configLink = new OO.ui.CheckboxInputWidget({
selected: cfg.configLink
});
items.push(
new OO.ui.FieldLayout(this.configLink, {
align: 'inline',
label: msg['config-label-configlink'],
help: helpTextForDefaultValueByKey('config-default-disabled'),
helpInline: true
})
);
/**
* @type {OO.ui.CheckboxInputWidget}
* @readonly
* @private
*/
this.purgerLink = new OO.ui.CheckboxInputWidget({
selected: cfg.purgerLink
});
items.push(
new OO.ui.FieldLayout(this.purgerLink, {
align: 'inline',
label: msg['config-label-purger'],
help: helpTextForDefaultValueByKey('config-default-disabled'),
helpInline: true
})
);
/**
* @type {InstanceType<ReturnType<PendingButtonWidgetFactory>>}
* @readonly
* @private
*/
this.saveButton = new (PendingButtonWidgetFactory())({
flags: ['primary', 'progressive'],
label: msg['config-button-save']
});
this.saveButton.on('click', () => this.save());
/**
* @type {OO.ui.ButtonWidget}
* @readonly
* @private
*/
this.resetButton = new OO.ui.ButtonWidget({
label: msg['config-button-reset']
});
this.resetButton.on('click', async () => {
const confirmed = await OO.ui.confirm(
$('<div>').text(msg['config-confirm-reset']),
{ size: 'medium' }
);
if (confirmed) {
this.resetFields();
mw.notify(msg['config-notify-reset']);
}
});
const buttonContainer = new OO.ui.Widget({
$element: $('<div>').addClass('sr-config-buttoncontainer'),
content: [
this.saveButton,
this.resetButton
]
});
items.push(
new OO.ui.FieldLayout(buttonContainer)
);
/**
* @type {OO.ui.FieldsetLayout}
* @readonly
* @private
*/
this.fieldset = new OO.ui.FieldsetLayout();
this.fieldset.addItems(items);
/**
* @type {JQuery<HTMLElement>}
* @readonly
*/
this.$element = this.fieldset.$element;
}
/**
* Retrieves a SelectiveRollbackConfigObject from the fields.
* @returns {?SelectiveRollbackConfigObject} `null` if there is a blocker.
* @private
*/
retrieve() {
const editSummaries = this.editSummaries.collect();
if (!editSummaries) {
return null;
}
const replacementExpressions = this.replacementExpressions.collect();
if (!replacementExpressions) {
return null;
}
/**
* @param {OO.ui.DropdownWidget} dropdown
* @returns {?string}
*/
const getDropdownValue = (dropdown) => {
const selected = dropdown.getMenu().findFirstSelectedItem();
if (!selected) {
console.error('No dropdown option is selected.', dropdown);
}
return selected && /** @type {?string} */ (selected.getData());
};
/** @type {import('./window/Selective Rollback.d.ts').NullableNonBoolean<Required<SelectiveRollbackConfigObject>>} */
const ret = {
lang: getDropdownValue(this.lang),
editSummaries: !$.isEmptyObject(editSummaries) ? editSummaries : null,
showKeys: this.showKeys.isSelected(),
mergeSummaries: this.mergeSummaries.isSelected(),
replacementExpressions: !$.isEmptyObject(replacementExpressions) ? replacementExpressions : null,
mergeReplacers: this.mergeReplacers.isSelected(),
watchlist: this.watchlist.isSelected(),
watchlistExpiry: /** @type {?WatchlistExpiry} */ (getDropdownValue(this.watchlistExpiry)),
desktopConfirm: /** @type {?SRConfirm} */ (getDropdownValue(this.desktopConfirm)),
mobileConfirm: /** @type {?SRConfirm} */ (getDropdownValue(this.mobileConfirm)),
checkboxLabelColor: clean(this.checkboxLabelColor.getValue()) || null,
markBot: this.markBot.isSelected(),
configLink: this.configLink.isSelected(),
purgerLink: this.purgerLink.isSelected()
};
// Return an empty object when all the field values are defaults, so that save()
// will convert it to null to reset the option on the server
const defaults = this.fieldDefaults;
if (objectsEqual(ret, defaults)) {
return Object.create(null);
}
// Strip boolean fields that match defaults
for (const [key, defaultValue] of Object.entries(defaults)) {
const k = /** @type {keyof SelectiveRollbackConfigObject} */ (key);
if (typeof ret[k] === 'boolean' && ret[k] === defaultValue) {
delete ret[k];
}
}
// Return a SelectiveRollbackConfigObject without undefined or null values
return Object.entries(ret).reduce((acc, [key, value]) => {
if (value !== undefined && value !== null) {
acc[key] = value;
}
return acc;
}, Object.create(null));
}
/**
* Saves configurations as specified in the fields.
*
* This serves as a click handler for {@link saveButton}.
*
* @returns {Promise<void>}
* @private
*/
async save() {
let options = this.retrieve();
console.log(options); // TODO: Remove this
if (!options) {
return; // There is a blocker
}
this.saveButton.setPending();
this.$overlay.show();
if ($.isEmptyObject(options)) {
options = null;
}
const /** @type {Record<string, ?string>} */ localChange = Object.create(null);
const /** @type {Record<string, ?string>} */ globalChange = Object.create(null);
const isLocal = this.domain === 'local';
const key = SelectiveRollbackConfig.keys[this.domain];
const value = options && JSON.stringify(options);
const change = isLocal ? localChange : globalChange;
change[key] = value;
if (isLocal) {
Object.assign(globalChange, SelectiveRollbackConfig.getWikiIdOptions(value ? 'add' : 'delete'));
}
const promises = [];
if (!$.isEmptyObject(localChange)) {
promises.push(SelectiveRollbackConfig.saveOptions(localChange, 'options'));
}
if (!$.isEmptyObject(globalChange)) {
promises.push(SelectiveRollbackConfig.saveOptions(globalChange, 'globalpreferences'));
}
// Remove null (= success) from the results and deduplicate error codes
const codes = (await Promise.all(promises)).filter((r, i, arr) => r !== null && arr.indexOf(r) === i);
if (codes.length) {
mw.notify(mw.format(msg['config-notify-save-failure'], codes.join(', ')), {
type: 'error',
autoHideSeconds: 'long'
});
} else {
mw.notify(msg['config-notify-save-success'], { type: 'success' });
}
this.miscTab.updateCheckboxes();
this.saveButton.unsetPending();
this.$overlay.hide();
}
/**
* Returns an object keyed by `userjs-selectiverollback-localexists` for the GlobalPreferences API.
*
* This option tracks wikis where local options exist for Selective Rollback.
*
* @param {'add' | 'delete'} method How to handle the local wiki ID(s).
* @param {string[]} [wikiIDs] Optional wiki IDs to process in accordance with `method`. Defaults to
* the local wiki ID.
* @returns {Record<string, ?string>} An object in the form of:
* ```json
* {
* "userjs-selectiverollback-localexists": "Stringified `localexists` options or null"
* }
* ```
* where a `null` value means that the user option should be reset.
*
* If no change is needed, this method returns an empty object.
*/
static getWikiIdOptions(method, wikiIDs) {
/** @type {Record<string, string>} */
let cfg = this.get('localexists') || Object.create(null);
if (wikiIDs && method !== 'delete') {
throw new Error('Constructing API endpoints for foreign wikis is not supported.');
}
wikiIDs = wikiIDs || [wgWikiID];
let changed = false;
for (const wikiID of wikiIDs) {
if ((method === 'add' && wikiID in cfg) || (method === 'delete' && !(wikiID in cfg))) {
// No change needed
} else if (method === 'add') {
cfg[wikiID] = mw.config.get('wgServer') + mw.util.wikiScript('api');
changed = true;
} else if (method === 'delete') {
delete cfg[wikiID];
changed = true;
}
}
if (!changed) {
return Object.create(null); // No change needed
}
const value = $.isEmptyObject(cfg) ? null : JSON.stringify(cfg);
return { [this.keys.localexists]: value };
}
/**
* Saves user options via the API.
*
* @param {Record<string, ?string>} change Object mapping from option keys to their values.
* Keys valued with `null` will be reset.
* @param {'options' | 'globalpreferences'} action
* @param {mw.ForeignApi} [foreignApi] Optional `mw.ForeignApi` instance to use, if the options
* should be saved to a foreign wiki instead of the local one.
* @returns {JQuery.Promise<?string>} `null` on success, or an error code on failure.
*/
static saveOptions(change, action, foreignApi) {
if (foreignApi && action === 'globalpreferences') {
console.error('There is no need to access the foreign API to save global preferences.');
}
return (foreignApi || api).postWithEditToken({
action,
change: Object.entries(change).reduce((acc, [key, value]) => {
acc += '\u001F' + key;
if (value !== null) {
acc += '=' + value;
}
return acc;
}, ''),
assertuser: wgUserName
}).then(() => {
mw.user.options.set(change);
return null;
}).catch((code, err) => {
console.warn(err);
return code === 'assertuserfailed' ? 'notloggedin' : code;
});
}
/**
* Resets the config fields with default values.
* @returns {void}
* @private
*/
resetFields() {
const defaults = this.fieldDefaults;
for (const key of /** @type {(keyof typeof defaults)[]} */ (Object.keys(defaults))) {
if (!(key in this)) {
continue;
}
const value = defaults[key];
const widget = this[key];
if (widget instanceof OO.ui.DropdownWidget) {
widget.getMenu().selectItemByData(value);
} else if (widget instanceof KeyValueCollection) {
widget.removeAll();
} else if (widget instanceof OO.ui.CheckboxInputWidget) {
widget.setSelected(!!value);
} else if (widget instanceof OO.ui.TextInputWidget) {
widget.setValue('');
} else {
console.error('Encountered an unknown widget', widget);
}
}
}
}
SelectiveRollbackConfig.keys = {
local: 'userjs-selectiverollback-local',
global: 'userjs-selectiverollback-global',
localexists: 'userjs-selectiverollback-localexists'
};
SelectiveRollbackConfig.deprecatedConfigWarned = false;
/**
* Performs a shallow-to-moderate structural equality check between two plain objects.
*
* **What this function supports**
* - Plain objects (`Record<string, any>`).
* - Primitive values.
* - Arrays (shallow comparison only; elements must be strictly equal).
* - Nested plain objects (recursively, with the same rules).
*
* **Limitations**
* - Class instances, Dates, Maps, Sets, RegExps, and other non-plain objects are **not** supported.
* - Arrays are compared **only shallowly** (no deep comparison of nested arrays).
* - Prototype chains are ignored — only own, enumerable string keys are compared.
* - Circular references are **not** supported and will cause infinite recursion.
* - Objects must have exactly the same set of keys to be considered equal.
*
* **Null handling**
* - Two `null` values are considered equal.
* - A `null` value compared with a non-null object is considered unequal.
*
* @param {?Record<string, any>} obj1 The first object to compare.
* @param {?Record<string, any>} obj2 The second object to compare.
* @returns {boolean} `true` if both values are considered equal under the rules above.
*/
function objectsEqual(obj1, obj2) {
if (obj1 === null && obj2 === null) {
return true;
}
if (!isObject(obj1) || !isObject(obj2)) {
return false;
}
const keys1 = Object.keys(obj1);
const keys2 = Object.keys(obj2);
if (keys1.length !== keys2.length) {
return false;
}
return keys1.every(key => {
if (!(key in obj2)) {
return false;
}
const v1 = obj1[key];
const v2 = obj2[key];
// Array comparison
if (Array.isArray(v1) || Array.isArray(v2)) {
return (
Array.isArray(v1) &&
Array.isArray(v2) &&
v1.length === v2.length &&
v1.every((el, i) => el === v2[i])
);
}
// Nested plain object
if (isObject(v1) && isObject(v2)) {
return objectsEqual(v1, v2);
}
// Primitive or mismatched types
return v1 === v2;
});
}
/**
* @param {unknown} obj
* @returns {obj is Record<string, any>}
*/
function isObject(obj) {
return typeof obj === 'object' && !Array.isArray(obj) && obj !== null;
}
/**
* Returns a jQuery object for the `label` parameter of `OO.ui.FieldLayout`.
* @returns {JQuery<HTMLElement>}
*/
function $headingLabel() {
return $('<b>').addClass('sr-config-headinglabel');
}
class SelectiveRollbackConfigMisc {
/**
* @param {JQuery<HTMLElement>} $overlay
*/
constructor($overlay) {
/**
* @type {JQuery<HTMLElement>}
* @readonly
* @private
*/
this.$overlay = $overlay;
/**
* @type {DeleteConfigCallback[]}
* @readonly
* @private
*/
this.deleteConfigCallbacks = [];
/** @type {OO.ui.Element[]} */
const items = [];
items.push(
new OO.ui.FieldLayout(new OO.ui.LabelWidget({
label: $headingLabel().text(msg['config-label-deletedata'])
}))
);
/**
* @type {OO.ui.CheckboxInputWidget}
* @readonly
* @private
*/
this.purgeCache = new OO.ui.CheckboxInputWidget();
items.push(
new OO.ui.FieldLayout(this.purgeCache, {
align: 'inline',
label: msg['portlet-label-uncacher']
})
);
/**
* @type {OO.ui.CheckboxInputWidget}
* @readonly
* @private
*/
this.deleteGlobal = new OO.ui.CheckboxInputWidget();
items.push(
new OO.ui.FieldLayout(this.deleteGlobal, {
align: 'inline',
label: msg['config-label-deleteglobal'],
help: new OO.ui.HtmlSnippet('<span id="sr-config-help-deleteglobal"></span>'),
helpInline: true
})
);
/**
* @type {OO.ui.CheckboxInputWidget}
* @readonly
* @private
*/
this.deleteLocal = new OO.ui.CheckboxInputWidget();
items.push(
new OO.ui.FieldLayout(this.deleteLocal, {
align: 'inline',
label: msg['config-label-deletelocal'],
help: new OO.ui.HtmlSnippet('<span id="sr-config-help-deletelocal"></span>'),
helpInline: true
})
);
/**
* @type {OO.ui.CheckboxInputWidget}
* @readonly
* @private
*/
this.deleteLocalAll = new OO.ui.CheckboxInputWidget();
items.push(
new OO.ui.FieldLayout(this.deleteLocalAll, {
align: 'inline',
label: msg['config-label-deletelocalall'],
help: new OO.ui.HtmlSnippet('<span id="sr-config-help-deletelocalall"></span>'),
helpInline: true
})
);
/**
* @type {InstanceType<ReturnType<typeof PendingButtonWidgetFactory>>}
* @readonly
* @private
*/
this.deleteButton = new (PendingButtonWidgetFactory())({
flags: ['primary', 'destructive'],
label: msg['config-button-deletedata']
});
this.deleteButton.on('click', async () => {
const confirmed = await OO.ui.confirm(
msg['config-confirm-deletedata'],
{ size: 'medium' }
);
if (confirmed) {
this.doDelete();
}
});
items.push(
new OO.ui.FieldLayout(this.deleteButton)
);
[
this.purgeCache,
this.deleteGlobal,
this.deleteLocal,
this.deleteLocalAll
]
.forEach((checkbox) => {
checkbox.on('change', () => this.updateDeleteButtonAccessibility());
});
const fieldset = new OO.ui.FieldsetLayout();
fieldset.addItems(items);
/**
* @type {JQuery<HTMLElement>}
* @readonly
*/
this.$element = fieldset.$element;
// Must wait for the browser's repaint for the widgets just added,
// to ensure updateCheckboxes() works properly
window.requestAnimationFrame(() => this.updateCheckboxes());
}
/**
* @param {DeleteConfigCallback} callback
*/
onConfigDeleted(callback) {
this.deleteConfigCallbacks.push(callback);
}
/**
* @private
*/
updateDeleteButtonAccessibility() {
const enable = Object.values(this.collect()).some(Boolean);
this.deleteButton.setDisabled(!enable);
}
/**
* Retrives an object mapping from checkbox property names in `this` to
* the checked states of the checkboxes.
* @private
*/
collect() {
/** @param {OO.ui.CheckboxInputWidget} widget */
const falseFallback = (widget) => {
return !widget.isDisabled() ? widget.isSelected() : false;
};
return {
purgeCache: falseFallback(this.purgeCache),
deleteGlobal: falseFallback(this.deleteGlobal),
deleteLocal: falseFallback(this.deleteLocal),
deleteLocalAll: falseFallback(this.deleteLocalAll)
};
}
/**
* Retrieves the given help element injected to `OO.ui.FieldLayout`.
*
* This serves as a workaround for the technical limitation that `OO.ui.FieldLayout` does not
* accept a jQuery object for its `help` configuration parameter, meaning no such jQuery objects
* can be registered as instance properties for this class.
*
* @param {'deleteglobal' | 'deletelocal' | 'deletelocalall' | 'deletelocalall-list'} target
* @returns {JQuery<HTMLElement>}
* @private
*/
getHelpElement(target) {
const id = 'sr-config-help-' + target;
const el = document.getElementById(id);
if (!el) {
console.error(`Could not find #${id}`);
}
return $(el || []);
}
/**
* Updates checkboxes used to specify what kind of data to delete:
* * Sets the `disabled` state depending on whether the corresponding config exists
* in user options.
* * Rewrites the help text for each checkbox in accordance with the `disabled` state.
*/
updateCheckboxes() {
const $deleteGlobalHelp = this.getHelpElement('deleteglobal');
if (SelectiveRollbackConfig.get('global')) {
this.deleteGlobal.setDisabled(false);
$deleteGlobalHelp.text('');
} else {
this.deleteGlobal.setSelected(false).setDisabled(true);
$deleteGlobalHelp.text(msg['config-help-deleteglobal-absent']);
}
const $deleteLocalHelp = this.getHelpElement('deletelocal');
if (SelectiveRollbackConfig.get('local')) {
this.deleteLocal.setDisabled(false);
$deleteLocalHelp.text('');
} else {
this.deleteLocal.setSelected(false).setDisabled(true);
$deleteLocalHelp.text(msg['config-help-deletelocal-absent']);
}
/** @type {Record<string, string>} */
const wikiMap = SelectiveRollbackConfig.get('localexists') || Object.create(null);
delete wikiMap[wgWikiID]; // The local wiki ID is irrelevant here
const $deleteLocalAllHelp = this.getHelpElement('deletelocalall');
if (!$.isEmptyObject(wikiMap)) {
this.deleteLocalAll.setDisabled(false);
const message = mw.format(
msg['config-help-deletelocalall-present'],
'<span id="sr-config-help-deletelocalall-list"></span>'
);
$deleteLocalAllHelp.html(message);
const $deleteLocalAllHelpWikiList = this.getHelpElement('deletelocalall-list');
let i = 0;
for (const [wikiId, apiUrl] of Object.entries(wikiMap)) {
/** @type {(string | JQuery<HTMLElement>)[]} */
const elements = [];
if (i !== 0) {
elements.push(', ');
}
elements.push(SelectiveRollbackConfigMisc.getLinkFromWikiID(wikiId, apiUrl));
$deleteLocalAllHelpWikiList.append(...elements);
}
} else {
this.deleteLocalAll.setSelected(false).setDisabled(true);
$deleteLocalAllHelp.html(msg['config-help-deletelocalall-absent']);
}
window.requestAnimationFrame(() => this.updateDeleteButtonAccessibility());
}
/**
* Generates a link to the given wiki, e.g. `enwiki` linking to `//en.wikipedia.org`.
* @param {string} wikiID
* @param {string} apiUrl
* @returns {JQuery<HTMLAnchorElement>}
* @private
*/
static getLinkFromWikiID(wikiID, apiUrl) {
const regex = /^\/\/[^/]+/;
const baseUrl = (apiUrl.match(regex) || [])[0] || apiUrl;
return /** @type {JQuery<HTMLAnchorElement>} */ ($('<a>'))
.prop({
target: '_blank',
href: baseUrl
})
.text(wikiID);
}
/**
* Deletes configuration data as specified in the misc field.
* @returns {Promise<void>}
* @private
*/
async doDelete() {
this.$overlay.show();
this.deleteButton.setPending();
const deleteFor = this.collect();
const keys = SelectiveRollbackConfig.keys;
const /** @type {JQuery.Promise<?string>[]} */ promises = [];
const /** @type {(keyof Messages)[]} */ executionKeys = [];
const /** @type {Omit<ConfigDomain, 'localexists'>[]} */ deletionTypes = [];
if (deleteFor.purgeCache) {
SelectiveRollback.purgeCache();
promises.push($.Deferred().resolve(null).promise());
executionKeys.push('portlet-label-uncacher');
}
if (deleteFor.deleteGlobal) {
const change = { [keys.global]: null };
promises.push(SelectiveRollbackConfig.saveOptions(change, 'globalpreferences'));
executionKeys.push('config-label-deleteglobal');
deletionTypes.push('global');
}
if (deleteFor.deleteLocal) {
const change = { [keys.local]: null };
promises.push(SelectiveRollbackConfig.saveOptions(change, 'options'));
executionKeys.push('config-label-deletelocal');
deletionTypes.push('local');
}
const /** @type {[string, string][]} */ wikiIdMap = [];
while (deleteFor.deleteLocalAll) {
// Alternative to `if` so that we can use `break` when `localexists` is null
const localexists = SelectiveRollbackConfig.get('localexists');
if (!localexists) {
break;
}
delete localexists[wgWikiID];
for (const [wikiID, apiUrl] of Object.entries(localexists)) {
const foreignApi = new mw.ForeignApi(apiUrl, SelectiveRollback.apiOptions());
const change = { [keys.local]: null };
promises.push(SelectiveRollbackConfig.saveOptions(change, 'options', foreignApi));
wikiIdMap.push([wikiID, apiUrl]);
}
if (wikiIdMap.length) {
executionKeys.push('config-label-deletelocalall');
}
break;
}
let errCount = 0;
/**
* @param {string | JQuery<HTMLElement>} label
* @param {?string} [code]
* @returns {JQuery<HTMLElement>}
*/
const listItem = (label, code) => {
const $li = $('<li>').append(label, ': ');
if (code === undefined) {
// Do nothing
} else if (code) {
$li.append(SelectiveRollbackConfigMisc.getIcon('cross', code));
errCount++;
} else {
$li.append(SelectiveRollbackConfigMisc.getIcon('tick'));
}
return $li;
};
const results = await Promise.all(promises);
const /** @type {string[]} */ wikiIDsConfigDeleted = [];
let offset = Infinity;
const $err = $('<ul>');
for (let i = 0; i < results.length; i++) {
const msgKey = executionKeys[i];
/** @type {string | null | undefined} */
let code = results[i];
if (msgKey === 'config-label-deletelocal' && !code) {
wikiIDsConfigDeleted.push(wgWikiID);
} else if (msgKey === 'config-label-deletelocalall') {
code = void 0;
offset = i;
}
$err.append(listItem(msg[msgKey], code));
if (offset !== Infinity) {
break;
}
}
const $errInner = $('<ul>');
for (let i = offset; i < results.length; i++) {
const [wikiID, apiUrl] = wikiIdMap[i - offset];
const $link = SelectiveRollbackConfigMisc.getLinkFromWikiID(wikiID, apiUrl);
const code = results[i];
if (!code) {
wikiIDsConfigDeleted.push(wikiID);
}
$errInner.append(listItem($link, code));
if (i === results.length - 1) {
$err.append($errInner);
}
}
if (wikiIDsConfigDeleted.length) {
const change = SelectiveRollbackConfig.getWikiIdOptions('delete', wikiIDsConfigDeleted);
for (let i = 0; i <= 3; i++) {
// This should not fail: Retry up to 3 times
const code = await SelectiveRollbackConfig.saveOptions(change, 'globalpreferences');
if (!code) {
break;
}
if (i !== 3) {
await sleep(5000);
}
}
}
if (deletionTypes.length) {
this.deleteConfigCallbacks.forEach((callback) => callback(deletionTypes));
}
this.updateCheckboxes();
this.$overlay.hide();
this.deleteButton.unsetPending();
if (errCount) {
OO.ui.alert(
$('<div>').append(msg['config-notify-deletedata-failure'], $err),
{ size: 'medium' }
);
} else {
mw.notify(msg['config-notify-deletedata-success'], { type: 'success' });
}
}
/**
* Creates and retrieves an icon.
*
* @param {keyof typeof SelectiveRollbackConfigMisc.iconMap} iconName The name of the icon to get.
* @param {string} [subtext] Optional text shown next to the icon.
*
* The text is coloured in:
* * Green when `iconName` is `'tick'`.
* * Red when `iconName` is `'cross'`.
* @returns {HTMLSpanElement} The icon container.
* @private
*/
static getIcon(iconName, subtext) {
const href = this.iconMap[iconName];
const icon = new Image();
icon.classList.add('sr-config-icon');
icon.src = href;
const container = document.createElement('span');
container.classList.add('sr-config-icon-container');
container.appendChild(icon);
if (subtext) {
const textElement = document.createElement('span');
textElement.classList.add('sr-config-icon-subtext');
textElement.textContent = subtext;
if (iconName === 'tick') {
textElement.classList.add('sr-config-icon-subtext-green');
} else if (iconName === 'cross') {
textElement.classList.add('sr-config-icon-subtext-red');
}
container.appendChild(textElement);
}
return container;
}
}
SelectiveRollbackConfigMisc.iconMap = {
tick: 'https://upload.wikimedia.org/wikipedia/commons/thumb/b/b1/Antu_mail-mark-notjunk.svg/30px-Antu_mail-mark-notjunk.svg.png',
cross: 'https://upload.wikimedia.org/wikipedia/commons/thumb/5/57/Cross_reject.svg/30px-Cross_reject.svg.png'
};
/**
* @param {number} milliSeconds
* @returns {Promise<void>}
*/
function sleep(milliSeconds) {
return new Promise(resolve => setTimeout(resolve, milliSeconds));
}
class KeyValueCollection {
/**
* @param {Set<string>} [badKeys] Additional keys to disallow.
*/
constructor(badKeys) {
/**
* The container widget.
* @type {OO.ui.Widget}
* @readonly
*/
this.widget = new OO.ui.Widget();
/**
* @type {KeyValueCollectionRow[]}
* @private
*/
this.rows = [];
/**
* @type {Set<string>}
* @readonly
* @private
*/
this.badKeys = badKeys || new Set();
/**
* @type {OO.ui.ButtonWidget}
* @readonly
* @private
*/
this.addButton = new OO.ui.ButtonWidget({
flags: ['primary', 'progressive'],
label: msg['config-button-add']
});
this.addButton.on('click', () => this.add());
/**
* @type {OO.ui.ButtonWidget}
* @readonly
* @private
*/
this.removeButton = new OO.ui.ButtonWidget({
disabled: true,
flags: ['primary', 'destructive'],
label: msg['config-button-remove']
});
this.removeButton.on('click', () => {
for (let i = this.rows.length - 1; i >= 0; i--) {
const { checkbox } = this.rows[i];
if (checkbox.isSelected()) {
this.remove(i);
}
}
});
/**
* @type {OO.ui.ButtonWidget}
* @readonly
* @private
*/
this.selectAllButton = new OO.ui.ButtonWidget({
disabled: true,
flags: ['progressive'],
label: msg['dialog-button-selectall']
});
this.selectAllButton.on('click', () => {
this.rows.forEach(({ checkbox }) => checkbox.setSelected(true));
});
/**
* @type {OO.ui.ButtonWidget}
* @readonly
* @private
*/
this.deselectAllButton = new OO.ui.ButtonWidget({
disabled: true,
flags: ['destructive'],
label: msg['config-button-deselectall']
});
this.deselectAllButton.on('click', () => {
this.rows.forEach(({ checkbox }) => checkbox.setSelected(false));
});
/**
* The button container widget.
* @type {OO.ui.Widget}
* @readonly
*/
this.buttons = new OO.ui.Widget({
$element: $('<div>').addClass('sr-config-buttoncontainer'),
content: [
this.addButton,
this.removeButton,
this.selectAllButton,
this.deselectAllButton
]
});
}
/**
* Adds a new property field item.
* @param {string} [initialKey]
* @param {string} [initialValue]
* @returns {KeyValueCollectionRow}
*/
add(initialKey, initialValue) {
const checkbox = new OO.ui.CheckboxInputWidget();
const keyInput = new OO.ui.TextInputWidget({
label: msg['config-label-propertyinput-key'],
validate: (val) => {
val = clean(val);
return val !== '' && !this.badKeys.has(val);
},
value: initialKey
});
const keyLayout = new OO.ui.FieldLayout(keyInput, {
$element: $('<div>').css({ 'margin-top': '4px' }),
});
const valueInput = new OO.ui.TextInputWidget({
label: msg['config-label-propertyinput-value'],
validate: (val) => !!clean(val),
value: initialValue
});
const valueLayout = new OO.ui.FieldLayout(valueInput, {
$element: $('<div>').css({ 'margin-top': '4px' }),
});
const layout = new OO.ui.HorizontalLayout({
$element: $('<div>').css({ 'max-width': '50em' }),
items: [
checkbox,
new OO.ui.Widget({
$element: $('<div>').css({ 'flex-grow': '1' }),
content: [keyLayout, valueLayout]
})
]
});
this.widget.$element.append(layout.$element);
[this.removeButton, this.selectAllButton, this.deselectAllButton].forEach((w) => {
w.setDisabled(false);
});
const obj = { checkbox, keyInput, keyLayout, valueInput, valueLayout, layout };
this.rows.push(obj);
return obj;
}
/**
* @param {number} index
* @returns {this}
*/
remove(index) {
const { layout } = this.rows[index];
if (layout) {
layout.$element.remove();
this.rows.splice(index, 1);
}
if (!this.rows.length) {
[this.removeButton, this.selectAllButton, this.deselectAllButton].forEach((w) => {
w.setDisabled(true);
});
}
return this;
}
/**
* @returns {this}
*/
removeAll() {
for (let i = this.rows.length - 1; i >= 0; i--) {
this.remove(i);
}
return this;
}
/**
* Collects field values as an object.
* @returns {?Record<string, string>} `null` if any input contains an invalid value.
*/
collect() {
/**
* @type {Record<string, string>}
*/
const ret = Object.create(null);
if (!this.rows.length) {
return ret;
}
/**
* @type {Map<string, KeyValueCollectionDuplicateKey[]>}
*/
const seenKeys = new Map();
/**
* @param {string} key
* @param {OO.ui.TextInputWidget} input
* @param {OO.ui.FieldLayout} layout
*/
const setSeen = (key, input, layout) => {
if (!seenKeys.has(key)) {
seenKeys.set(key, []);
}
/** @type {KeyValueCollectionDuplicateKey[]} */ (seenKeys.get(key)).push({ input, layout });
};
/**
* @type {number[]}
*/
const emptyFieldIndexes = [];
/**
* @type {?OO.ui.TextInputWidget}
*/
let focusTarget = null;
/**
* @type {KeyValueCollectionErrorDesc[]}
*/
const errors = [];
// First pass: collect trimmed field values and record validation errors.
// Only keys that are non-empty and not reserved ("other") are added to seenKeys.
// Empty-value rows do not contribute to duplicates unless the key is valid.
for (const [i, { keyInput, keyLayout, valueInput, valueLayout }] of Object.entries(this.rows)) {
const key = clean(keyInput.getValue());
const value = clean(valueInput.getValue());
// Don't inherit errors from the previous run
// @ts-expect-error Arguments for clearErrorsHandler() omitted
[keyInput, valueInput].forEach((input) => input.off('change', KeyValueCollection.clearErrorsHandler));
[keyLayout, valueLayout].forEach((layout) => layout.setErrors([]));
// Put trimmed values back into widgets
keyInput.setValue(key);
valueInput.setValue(value);
if (!key && !value) {
// Both empty: Remove the field later
emptyFieldIndexes.push(+i);
continue;
} else if (!key) {
// Key empty
errors.push({
layout: keyLayout,
input: keyInput,
msgKey: 'config-error-propertyinput-key-empty',
invalidValue: '',
validator: function() {
return clean(this.input.getValue()) === this.invalidValue
? null
: [{ layout: this.layout, input: this.input }];
}
});
focusTarget = focusTarget || keyInput;
continue;
} else if (!value) {
// Value empty
errors.push({
layout: valueLayout,
input: valueInput,
msgKey: 'config-error-propertyinput-value-empty',
invalidValue: '',
validator: function() {
return clean(this.input.getValue()) === this.invalidValue
? null
: [{ layout: this.layout, input: this.input }];
}
});
focusTarget = focusTarget || valueInput;
if (!this.badKeys.has(key)) {
// We know that the key is non-empty, thus is valid unless it's contained in `badKeys`.
// Mark the valid key as seen, for the duplicate error logic to work as expected.
setSeen(key, keyInput, keyLayout);
}
continue;
}
// Reserved key
if (this.badKeys.has(key)) {
errors.push({
layout: keyLayout,
input: keyInput,
msgKey: 'config-error-propertyinput-key-reserved',
invalidValue: key,
validator: function() {
return clean(this.input.getValue()) === this.invalidValue
? null
: [{ layout: this.layout, input: this.input }];
}
});
focusTarget = focusTarget || keyInput;
continue;
}
setSeen(key, keyInput, keyLayout);
ret[key] = value;
}
// Second pass: detect duplicate keys. For each key with more than one valid row,
// produce a duplicate-key error unless that row already has a more specific error.
for (const [key, arr] of seenKeys.entries()) {
if (arr.length < 2) {
continue;
}
for (const keyField of arr) {
// For each entry with duplicated key, only set duplicate error if that field
// doesn't already have an error set (we avoid overwriting a more specific error).
// @ts-expect-error Accessing private property
if ((keyField.layout.errors || []).length) {
continue;
}
// Add duplicate error descriptor with validator that checks live duplication state
errors.push({
layout: keyField.layout,
input: keyField.input,
msgKey: 'config-error-propertyinput-key-duplicate',
invalidValue: key,
validator: function(descs) {
// Recompute whether this key is still duplicated in the current DOM
const currentKey = clean(this.input.getValue());
if (!currentKey) {
return null; // No key: Handled by empty-key validator instead
}
/** @type {KeyValueCollectionDuplicateKey[]} */
let clearTargets = [];
if (descs.length < 2) {
console.error('Expected 2 or more error descriptors, but got ' + descs.length);
clearTargets = descs;
} else if (descs.length === 2) {
// If there are only two descriptors, clear both errors if
// the input values don't match (i.e., deduplicated)
const key1 = clean(descs[0].input.getValue());
const key2 = clean(descs[1].input.getValue());
if (key1 !== key2) {
clearTargets = descs;
}
} else {
// If there are more than two descriptors, find deduplicated fields
for (const desc of descs) {
if (clean(desc.input.getValue()) !== this.invalidValue) {
clearTargets.push(desc);
}
}
// If clearTargets contains `descs.length - 1` elements, the remaining
// field has also been deduplicated
if (clearTargets.length === descs.length - 1) {
clearTargets = descs;
}
}
return clearTargets.length ? clearTargets : null;
}
});
focusTarget = focusTarget || keyField.input;
}
}
// Apply errors to the UI and attach handlers
const hadErrors = KeyValueCollection.applyErrors(errors);
if (hadErrors) {
// Focus the first failing input and return null to indicate failure
if (focusTarget) {
/** @type {OO.ui.TextInputWidget} */ (focusTarget).focus();
}
return null;
}
// Remove empty fields in reverse order to keep indices valid
for (let i = emptyFieldIndexes.length - 1; i >= 0; i--) {
this.remove(emptyFieldIndexes[i]);
}
return ret;
}
/**
* Applies the collected errors to the UI:
* - Displays error messages on each FieldLayout.
* - Marks the input as invalid.
* - Installs a change handler that re-validates the error using the descriptor's `validator()`.
*
* The change handler automatically clears the error and removes itself once the validator
* reports the error as resolved.
*
* @param {KeyValueCollectionErrorDesc[]} errorDescs
* @returns {boolean} `true` if any errors were applied, `false` otherwise.
* @private
*/
static applyErrors(errorDescs) {
// If nothing to do, return false (no errors applied)
if (!errorDescs.length) {
return false;
}
for (const desc of errorDescs) {
const err = mw.format(msg[desc.msgKey], desc.invalidValue);
desc.layout.setErrors([err]);
desc.input.setValidityFlag(false);
// Attach change handler that removes error when validator returns an array.
// Use a named handler so we can remove it later.
desc.input.on('change', KeyValueCollection.clearErrorsHandler, [desc, errorDescs]);
}
return true;
}
/**
* @param {KeyValueCollectionErrorDesc} desc
* @param {KeyValueCollectionErrorDesc[]} allDescs
* @returns {void}
* @private
*/
static clearErrorsHandler(desc, allDescs) {
const associatedDescs = desc.msgKey === 'config-error-propertyinput-key-duplicate'
? allDescs.filter(d => d.msgKey === desc.msgKey && d.invalidValue === desc.invalidValue)
: [desc];
// @ts-expect-error Accessing private property
const activeDescs = associatedDescs.filter(e => e.layout.errors.length);
if (!activeDescs.length) {
// @ts-expect-error Arguments omitted
allDescs.forEach(d => d.input.off('change', KeyValueCollection.clearErrorsHandler));
return;
}
const clearTargets = desc.validator.call(desc, activeDescs);
if (clearTargets) {
for (const target of clearTargets) {
target.layout.setErrors([]);
target.input.setValidityFlag(true);
// @ts-expect-error Arguments omitted
target.input.off('change', KeyValueCollection.clearErrorsHandler);
}
}
}
}
/**
* @returns {OO.ui.MenuOptionWidget[]}
*/
function getWatchlistExpiryOptions() {
return [
{ data: 'indefinite', label: msg['dialog-label-watchlistexpiry-indefinite'] },
{ data: '1 week', label: msg['dialog-label-watchlistexpiry-1week'] },
{ data: '1 month', label: msg['dialog-label-watchlistexpiry-1month'] },
{ data: '3 months', label: msg['dialog-label-watchlistexpiry-3months'] },
{ data: '6 months', label: msg['dialog-label-watchlistexpiry-6months'] },
{ data: '1 year', label: msg['dialog-label-watchlistexpiry-1year'] }
]
.map((obj) => new OO.ui.MenuOptionWidget(obj));
}
function PendingButtonWidgetFactory() {
const classPending = 'oo-ui-pendingElement-pending';
return class PendingButtonWidget extends OO.ui.ButtonWidget {
setPending() {
this.setDisabled(true)
.$element.children('.oo-ui-buttonElement-button').eq(0)
.addClass(classPending);
return this;
}
unsetPending() {
this.setDisabled(false)
.$element.children('.oo-ui-buttonElement-button').eq(0)
.removeClass(classPending);
return this;
}
};
}
/**
* Returns the SelectiveRollbackDialog class.
* @param {Required<SelectiveRollbackConfigObject>} cfg
* @param {MetaInfo} meta
* @param {ParentNode} parentNode
* @returns
*/
function SelectiveRollbackDialogFactory(cfg, meta, parentNode) {
const previewApi = new mw.Api(SelectiveRollback.apiOptions(true));
let /** @type {NodeJS.Timeout} */ previewTimeout;
const dirMismatch = document.dir !== dir;
const uiStart = dir === 'rtl' ? 'right' : 'left';
const uiEnd = dir === 'rtl' ? 'left' : 'right';
/**
* @param {OO.ui.DropdownWidget} dropdown
* @returns {string}
*/
const getDropdownValue = (dropdown) => {
return /** @type {string} */ (
/** @type {OO.ui.OptionWidget} */ (dropdown.getMenu().findSelectedItem()).getData()
);
};
class SelectiveRollbackDialog extends OO.ui.ProcessDialog {
/**
* @param {OO.ui.ProcessDialog.ConfigOptions} [config]
* @param {string[]} [autocompleteSources]
*/
constructor(config, autocompleteSources = []) {
super(config);
/**
* A {@link SelectiveRollback} instance for the dialog instance.
*
* Must be lazy-bound via {@link bindSR} because the SR class's constructor
* also requires a dialog instance for initialization.
* @type {SelectiveRollback}
* @private
*/
this.sr = Object.create(null);
/**
* @type {boolean}
* @private
*/
this.destroyed = false;
/**
* @type {?HTMLLIElement}
* @readonly
* @private
*/
this.portlet = mw.util.addPortletLink(
mw.config.get('skin') === 'minerva' ? 'p-personal' : 'p-cactions',
'#',
msg.scriptname,
'ca-sr',
msg['portlet-tooltip-dialog'],
void 0,
document.getElementById('ca-sr-config') || document.getElementById('ca-sr-uncacher') || '#ca-move'
);
if (this.portlet) {
this.portlet.addEventListener('click', (e) => {
e.preventDefault();
this.open();
});
} else {
console.error('[SR] Failed to create a portlet link.');
}
/** @type {OO.ui.Element[]} */
const items = [];
/**
* @type {JQuery<HTMLSpanElement>}
* @readonly
* @private
*/
this.$selectedCount = $('<span>');
if (parentNode) {
const selectAll = new OO.ui.ButtonWidget({
flags: ['progressive'],
label: msg['dialog-button-selectall']
});
selectAll.on('click', () => {
const count = this.sr.selectAll();
this.$selectedCount.text(count);
});
const saLayout = new OO.ui.FieldLayout(selectAll, {
$label: $('<span>').addClass('sr-selected-count'), // Increase padding-top via class CSS
align: dir === 'ltr' ? 'right' : 'left',
label: $('<span>')
.html(msg['dialog-label-selectcount'] + ' ')
.append(this.$selectedCount),
});
saLayout.$element.css({ 'margin-bottom': '-1em' });
saLayout.$header.css({ textAlign: uiEnd }); // Align the label in the same way as the button
saLayout.$field.css({ width: 'unset' }); // Remove space leading the button
saLayout.$label.off('click'); // Prevent label from interacting with the button
items.push(saLayout);
}
/**
* @type {OO.ui.DropdownWidget}
* @readonly
* @private
*/
this.summaryList = new OO.ui.DropdownWidget({
$overlay: this.$overlay,
menu: {
items: [
new OO.ui.MenuOptionWidget({ data: '', label: msg['dialog-label-summary-default'] }),
...Object.entries(cfg.editSummaries).map(([key, value]) => {
return new OO.ui.MenuOptionWidget({ data: value, label: cfg.showKeys ? key : value });
}),
new OO.ui.MenuOptionWidget({ data: 'other', label: msg['dialog-label-summary-custom'] }),
]
}
});
this.summaryList.on('labelChange', () => this.previewSummary());
this.summaryList.getMenu().selectItemByData(''); // Select default summary
items.push(
new OO.ui.FieldLayout(this.summaryList, {
align: 'top',
label: $('<b>').text(msg['dialog-label-summary'])
})
);
/**
* @type {OO.ui.ComboBoxInputWidget}
* @readonly
* @private
*/
this.summary = new OO.ui.ComboBoxInputWidget({
$overlay: this.$overlay,
menu: {
filterFromInput: true,
items: Object.keys(cfg.replacementExpressions).concat(autocompleteSources).map((source) => {
return new OO.ui.MenuOptionWidget({ data: source, label: source });
})
},
placeholder: msg['dialog-label-summaryinput']
});
let /** @type {NodeJS.Timeout} */ summaryTimeout;
this.summary.on('change', (value) => {
this.previewSummary();
clearTimeout(summaryTimeout);
summaryTimeout = setTimeout(() => {
value = clean(value);
this.summaryList.getMenu().selectItemByData(value ? 'other' : '');
}, 100);
});
items.push(
new OO.ui.FieldLayout(this.summary, {
$element: $('<div>').css({ 'margin-top': '8px' }),
align: 'top',
help: new OO.ui.HtmlSnippet(msg[meta.fetched ? 'dialog-help-summaryinput-$0' : 'dialog-help-summaryinput-$0-error']),
helpInline: true,
invisibleLabel: true
})
);
/**
* @type {OO.ui.Element}
* @readonly
* @private
*/
this.summaryPreview = new OO.ui.Element({
$element: $('<div>'),
id: 'sr-summarypreview'
});
items.push(
new OO.ui.LabelWidget({
$element: $('<div>').css({ 'margin-top': '12px', 'margin-bottom': '4px' }),
label: $('<b>').text(msg['dialog-label-summarypreview'])
}),
this.summaryPreview,
new OO.ui.LabelWidget({
$element: $('<div>').css({ 'margin-top': '4px' }),
classes: ['oo-ui-inline-help'],
label: new OO.ui.HtmlSnippet(msg['dialog-help-summarypreview'])
})
);
/**
* @type {OO.ui.CheckboxInputWidget}
* @readonly
*/
this.markBot = new OO.ui.CheckboxInputWidget();
if (meta.rights.has('markbotedits')) {
items.push(
new OO.ui.FieldLayout(this.markBot, {
label: msg['dialog-label-markbot'],
align: 'inline'
})
);
this.markBot.setSelected(cfg.markBot);
}
/**
* @type {OO.ui.CheckboxInputWidget}
* @readonly
*/
this.watchlist = new OO.ui.CheckboxInputWidget({
selected: cfg.watchlist
});
items.push(
new OO.ui.FieldLayout(this.watchlist, {
label: msg['dialog-label-watchlist'],
align: 'inline'
}),
);
/**
* @type {OO.ui.DropdownWidget}
* @readonly
*/
this.watchlistExpiry = new OO.ui.DropdownWidget({
$overlay: this.$overlay,
menu: {
items: getWatchlistExpiryOptions()
}
});
this.watchlistExpiry.getMenu().selectItemByData(cfg.watchlistExpiry);
const weLayout = new OO.ui.FieldLayout(this.watchlistExpiry);
weLayout.$element.css({ 'margin-left': '1.8em', 'margin-top': '8px' });
items.push(weLayout);
this.watchlist.on('change', (selected) => {
weLayout.toggle(!!selected);
this.updateSize();
});
weLayout.toggle(this.watchlist.isSelected());
/**
* @type {OO.ui.FieldsetLayout}
* @readonly
* @private
*/
this.fieldset = new OO.ui.FieldsetLayout();
this.fieldset.addItems(items);
}
/**
* @inheritdoc
* @override
*/
initialize() {
// @ts-expect-error
super.initialize.apply(this, arguments);
if (langSwitch === 'ar') {
this.$element.css({ 'font-family': 'system-ui' });
}
this.content = new OO.ui.PanelLayout({
padded: true,
expanded: false
});
this.content.$element.append(this.fieldset.$element);
// @ts-expect-error
this.$body.append(this.content.$element);
if (!dirMismatch) {
return this;
}
// If the interface language direction differs from the document direction
// (e.g., SR uses an RTL interface on an LTR wiki or vice versa), apply
// manual style overrides for layout elements that MediaWiki's OOUI doesn't
// automatically mirror. This remaps left/right-related properties to logical
// "start" and "end" positions based on the interface direction.
const style = document.createElement('style');
style.textContent =
'.sr-dialog .oo-ui-processDialog-actions-safe {' +
`${uiStart}: 0;` +
`${uiEnd}: unset;` +
'}' +
'.sr-dialog .oo-ui-processDialog-actions-primary {' +
`${uiStart}: unset;` +
`${uiEnd}: 0;` +
'}' +
'.sr-dialog .oo-ui-fieldLayout.oo-ui-labelElement.oo-ui-fieldLayout-align-left > .oo-ui-fieldLayout-body > .oo-ui-fieldLayout-header > .sr-selected-count.oo-ui-labelElement-label,' +
'.sr-dialog .oo-ui-fieldLayout.oo-ui-labelElement.oo-ui-fieldLayout-align-right > .oo-ui-fieldLayout-body > .oo-ui-fieldLayout-header > .sr-selected-count.oo-ui-labelElement-label {' +
`margin-${uiStart}: unset;` +
`margin-${uiEnd}: 6px;` +
'}' +
'.sr-dialog .oo-ui-comboBoxInputWidget .oo-ui-inputWidget-input {' +
`border-top-${uiStart}-radius: unset;` +
`border-bottom-${uiStart}-radius: unset;` +
`border-${uiStart}-width: 1px;` +
`border-top-${uiEnd}-radius: 0;` +
`border-bottom-${uiEnd}-radius: 0;` +
`border-${uiEnd}-width: 0;` +
'}' +
'.sr-dialog .oo-ui-fieldLayout.oo-ui-labelElement.oo-ui-fieldLayout-align-inline > .oo-ui-fieldLayout-body > .oo-ui-fieldLayout-header {' +
`padding-${uiStart}: 6px;` +
`padding-${uiEnd}: unset;` +
'}' +
'';
document.head.appendChild(style);
return this;
}
/**
* @inheritdoc
* @override
*/
getSetupProcess() {
return super.getSetupProcess().next(() => {
this.$selectedCount.text(this.sr.getSelected().length);
this.getActions().setMode(parentNode ? 'nonRCW' : 'RCW');
});
}
/**
* @inheritdoc
* @override
*/
getReadyProcess() {
return super.getReadyProcess().next(() => {
if (dirMismatch) {
this.$element.find('.oo-ui-processDialog-actions-other .oo-ui-actionWidget > .oo-ui-buttonElement-button').css({
[`border-${uiStart}-color`]: 'transparent',
[`border-${uiEnd}-color`]: 'var(--border-color-subtle,#c8ccd1)'
});
}
});
}
/**
* @inheritdoc
* @param {string} [action]
* @override
*/
getActionProcess(action) {
return new OO.ui.Process(() => {
switch (action) {
case 'execute': {
const selectedLinks = this.sr.getSelected();
if (!selectedLinks.length) {
mw.notify(msg['rollback-notify-noneselected'], { type: 'warn' });
return;
}
this.close();
this.sr.selectiveRollback(selectedLinks);
break;
}
case 'documentation':
window.open('https://meta.wikimedia.org/wiki/Special:MyLanguage/User:Dragoniez/Selective_Rollback', '_blank');
break;
case 'config':
window.open(mw.util.getUrl('Special:SelectiveRollbackConfig'), '_blank');
break;
case 'selectall': {
const count = this.sr.selectAll();
this.$selectedCount.text(count);
break;
}
default: this.close();
}
});
}
/**
* Lazy-binds a SelectiveRollback instance.
* @param {SelectiveRollback} sr
*/
bindSR(sr) {
this.sr = sr;
}
/**
* Destroys the dialog.
*/
destroy() {
SelectiveRollbackDialog.windowManager.destroy();
if (this.portlet) {
this.portlet.remove();
}
this.destroyed = true;
}
/**
* Checks whether the dialog has been destroyed.
* @returns {boolean}
*/
isDestroyed() {
return this.destroyed;
}
/**
* Gets the summary.
* @returns {string} Can return an empty string if:
* * the default option is selected, or
* * the custom option is selected but the input for a custom summary is empty.
*
* Note that the rollback API uses the default summary if:
* * the `summary` parameter is unspecified, or
* * it is specified as an empty string.
*/
getSummary() {
const dropdownValue = getDropdownValue(this.summaryList);
let summary = dropdownValue === 'other' ? clean(this.summary.getValue()) : dropdownValue;
// Process $0
if (summary === '$0') {
// If the summary is customized but is only of "$0", alter it with an empty string
// so that the API uses the default summary
summary = '';
} else {
// Replace $0 with the default summary
summary = summary.replace('$0', meta.parsedsummary);
}
// Process special expressions defined by the user
if (!$.isEmptyObject(cfg.replacementExpressions)) {
for (const [key, value] of Object.entries(cfg.replacementExpressions)) {
summary = summary.split(key).join(value);
}
}
return summary;
}
/**
* Gets the `markbot` option value.
* @returns {boolean}
*/
getMarkBot() {
return this.markBot.isSelected();
}
/**
* Gets the `watchlist` option value.
* @returns {'watch' | 'nochange'}
*/
getWatchlist() {
return this.watchlist.isSelected() ? 'watch' : 'nochange';
}
/**
* Gets the `watchlistexpiry` option value.
* @returns {string | undefined} `undefined` if the watch-page box isn't checked.
*/
getWatchlistExpiry() {
return this.watchlist.isSelected() && getDropdownValue(this.watchlistExpiry) || void 0;
}
/**
* Retrieves parameters for the rollback API from the dialog.
* @returns {RollbackParams}
*/
getParams() {
return {
summary: this.getSummary(),
markbot: this.getMarkBot(),
watchlist: this.getWatchlist(),
watchlistexpiry: this.getWatchlistExpiry()
};
}
/**
* Previews the summary.
* @private
*/
previewSummary() {
clearTimeout(previewTimeout);
const summary = this.getSummary() || meta.summary;
previewTimeout = setTimeout(() => {
previewApi.abort();
previewApi.post({
action: 'parse',
summary,
prop: ''
}).then(/** @param {ApiResponse} res */ ({ parse }) => {
return parse ? parse.parsedsummary : null;
}).catch(/** @param {Record<string, any>} err */ (_, err) => {
if (err && err.exception !== 'abort') {
console.warn(err);
}
return null;
}).then(/** @param {?string} parsedsummary */ (parsedsummary) => {
parsedsummary = parsedsummary !== null ? parsedsummary : '???';
this.summaryPreview.$element.html(parsedsummary);
this.updateSize();
});
}, 500);
}
}
SelectiveRollbackDialog.static.name = 'Selective Rollback';
SelectiveRollbackDialog.static.title = $('<label>').append(
`${msg.scriptname} (`,
$('<a>')
.prop({
target: '_blank',
href: 'https://meta.wikimedia.org/w/index.php?title=User:Dragoniez/Selective_Rollback.js&action=history'
})
.text(`v${version}`),
')'
);
SelectiveRollbackDialog.static.actions = [
{
action: 'execute',
label: msg['dialog-button-rollback'],
flags: ['primary', 'progressive'],
modes: ['nonRCW']
},
{
action: 'documentation',
label: msg['dialog-button-documentation'],
modes: ['RCW', 'nonRCW']
},
{
action: 'config',
label: msg['dialog-button-config'],
modes: ['RCW', 'nonRCW']
},
{
action: 'selectall',
label: msg['dialog-button-selectall'],
flags: ['progressive'],
modes: ['nonRCW']
},
{
flags: ['safe', 'close'],
modes: ['RCW', 'nonRCW']
}
];
SelectiveRollbackDialog.windowManager = (() => {
const windowManager = new OO.ui.WindowManager();
$(document.body).append(windowManager.$element);
return windowManager;
})();
return SelectiveRollbackDialog;
}
//**************************************************************************************************
/**
* @typedef {import('./window/Selective Rollback.d.ts').ParentNode} ParentNode
* @typedef {import('./window/Selective Rollback.d.ts').WatchlistExpiry} WatchlistExpiry
* @typedef {import('./window/Selective Rollback.d.ts').SelectiveRollbackConfigObjectLegacy} SelectiveRollbackConfigObjectLegacy
* @typedef {import('./window/Selective Rollback.d.ts').SelectiveRollbackConfigObject} SelectiveRollbackConfigObject
* @typedef {import('./window/Selective Rollback.d.ts').IsOfType} IsOfType
* @typedef {import('./window/Selective Rollback.d.ts').SRConfirm} SRConfirm
* @typedef {import('./window/Selective Rollback.d.ts').Languages} Languages
* @typedef {import('./window/Selective Rollback.d.ts').Messages} Messages
* @typedef {import('./window/Selective Rollback.d.ts').MetaInfo} MetaInfo
* @typedef {import('./window/Selective Rollback.d.ts').ApiResponse} ApiResponse
* @typedef {import('./window/Selective Rollback.d.ts').SRBox} SRBox
* @typedef {import('./window/Selective Rollback.d.ts').RollbackLink} RollbackLink
* @typedef {import('./window/Selective Rollback.d.ts').RollbackLinkMap} RollbackLinkMap
* @typedef {import('./window/Selective Rollback.d.ts').RollbackParams} RollbackParams
* @typedef {import('./window/Selective Rollback.d.ts').ConfigDomain} ConfigDomain
* @typedef {import('./window/Selective Rollback.d.ts').DeleteConfigCallback} DeleteConfigCallback
* @typedef {import('./window/Selective Rollback.d.ts').ConfigRetriever} ConfigRetriever
* @typedef {import('./window/Selective Rollback.d.ts').KeyValueCollectionRow} KeyValueCollectionRow
* @typedef {import('./window/Selective Rollback.d.ts').KeyValueCollectionDuplicateKey} KeyValueCollectionDuplicateKey
* @typedef {import('./window/Selective Rollback.d.ts').KeyValueCollectionErrorDesc} KeyValueCollectionErrorDesc
* @typedef {import('./window/Selective Rollback.d.ts').InterfaceDirection} InterfaceDirection
*/
SelectiveRollback.init();
//**************************************************************************************************
})();
//</nowiki>